diff --git a/docs/API.md b/docs/API.md index 8026cbf..b485cca 100644 --- a/docs/API.md +++ b/docs/API.md @@ -89,6 +89,18 @@ without re-reading the whole chat: - **`what_you_should_do_next`** — explicit instruction computed from state - `available_actions` — the API calls you may make now +### Join (report the task is taken) + +``` +POST /api/rooms/:id/join +``` + +Marks the caller as having taken the room into work (idempotent; the first +timestamp is kept). The room creator is marked joined automatically at room +creation. Participant lists (`participants[].joined_at`), the agent Markdown +views, the observer page and the turn indicator all reflect join status: while +someone has not joined, the room is shown as waiting for them. + ### Messages (append-only) ``` diff --git a/docs/MCP.md b/docs/MCP.md index aa2875c..6ce4484 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -15,6 +15,7 @@ server (see "Transport vs Autonomy" in the README). |---|---| | `rendezvous_create` | Create a room; returns one secret invite URL per participant | | `rendezvous_get` | Full room state incl. `what_you_should_do_next` | +| `rendezvous_join` | Report you have taken the room into work (first action after receiving the invite) | | `rendezvous_post` | Append a message (verified facts, answers, arguments) | | `rendezvous_ask` | Open a question (optionally blocking, optionally addressed to a specific participant) | | `rendezvous_resolve` | Resolve a question with the verified facts | @@ -60,6 +61,7 @@ Or via npx from the repository root: 2. You call `rendezvous_create` with roles, `knows`, `needs_to_determine`. 3. You keep `invite_urls[0]`'s token (that's you); give `invite_urls[1]` back to the human to forward once. -4. Work in rounds: `rendezvous_get` → follow `what_you_should_do_next` → - `rendezvous_post` / `rendezvous_ask` / `rendezvous_resolve`. +4. Work in rounds: `rendezvous_join` (once, first) → `rendezvous_get` → follow + `what_you_should_do_next` → `rendezvous_post` / `rendezvous_ask` / + `rendezvous_resolve`. 5. `rendezvous_propose_contract`, then `rendezvous_finalize` when verified. diff --git a/packages/client-sdk/src/index.ts b/packages/client-sdk/src/index.ts index 0d4b1e0..972fb88 100644 --- a/packages/client-sdk/src/index.ts +++ b/packages/client-sdk/src/index.ts @@ -42,6 +42,10 @@ export class RendezvousClient { return this.request('GET', `/api/rooms/${roomId}`); } + join(roomId: string): Promise<{ joined: boolean; participants: { role: string; joined_at: string | null }[] }> { + return this.request('POST', `/api/rooms/${roomId}/join`, {}); + } + postMessage(roomId: string, content: string): Promise { return this.request('POST', `/api/rooms/${roomId}/messages`, { content }); } diff --git a/packages/core/src/advice.ts b/packages/core/src/advice.ts index 0f91739..6bff0b2 100644 --- a/packages/core/src/advice.ts +++ b/packages/core/src/advice.ts @@ -30,6 +30,19 @@ export function computeAdvice(input: AdviceInput): string { return 'The contract is agreed by all participants and the room is finalized. Download the final artifact with GET /api/rooms/{room_id}/final.md (Authorization: Bearer ) while the room still exists.'; } + if (!you.joined_at) { + return ( + 'You have opened your invite but not reported taking the room into work yet. ' + + 'First POST /api/rooms/{room_id}/join (Authorization: Bearer ) so the other side knows the task has been picked up, then read the room state again.' + ); + } + + const notJoined = input.participants.filter((p) => p.id !== you.id && !p.joined_at); + const joinNote = + notJoined.length > 0 + ? `Note: ${notJoined.map((p) => p.role).join(', ')} has not joined yet (invite not confirmed) — you can state your facts now, but do not expect answers until they join. ` + : ''; + const addressedToYou = openQuestions.filter( (q) => q.addressed_to === null || q.addressed_to === you.id, ); @@ -41,6 +54,7 @@ export function computeAdvice(input: AdviceInput): string { if (blockingToYou.length > 0) { const first = blockingToYou[0]; return ( + joinNote + `There ${blockingToYou.length === 1 ? 'is 1 blocking question' : `are ${blockingToYou.length} blocking questions`} waiting for you. ` + `First: "${first.question}" (asked by ${first.author_role}${first.addressed_to_role ? ` specifically of ${first.addressed_to_role}` : ''}). ` + 'Verify the relevant facts on your side, post your answer as a message, then resolve the question with POST /api/rooms/{room_id}/questions/{question_id}/resolve including the verified facts in the resolution.' @@ -49,6 +63,7 @@ export function computeAdvice(input: AdviceInput): string { if (blockingFromYou.length > 0) { return ( + joinNote + `You have ${blockingFromYou.length === 1 ? 'an open blocking question' : `${blockingFromYou.length} open blocking questions`} that ${others(input.participants, you.id)} must answer. ` + 'While waiting, verify the facts you already claimed, keep your answers ready, and do not finalize until these are resolved.' ); @@ -99,7 +114,7 @@ export function computeAdvice(input: AdviceInput): string { */ export function computeTurn( status: string, - participants: { id: string; role: string }[], + participants: { id: string; role: string; joined_at: string | null }[], openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[], contract: { version: number; agreements: { participant_id: string; version: number }[] } | null, ): { text: string; waiting_for: string[] } { @@ -108,6 +123,13 @@ export function computeTurn( return { text: 'Done: contract agreed by everyone. Fetch final.md before the room expires.', waiting_for: [] }; const roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown'; + const notJoined = participants.filter((p) => !p.joined_at); + if (notJoined.length > 0) { + return { + text: `Waiting for ${notJoined.map((p) => p.role).join(', ')} to open their invite and report they have taken the room into work.`, + waiting_for: notJoined.map((p) => p.role), + }; + } const blocking = openQuestions.filter((q) => q.blocking); if (blocking.length > 0) { const q = blocking[0]; @@ -142,6 +164,7 @@ export function availableActions(status: string): string[] { return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)']; } return [ + 'POST /api/rooms/{room_id}/join — report you have taken the room into work (once, first)', 'POST /api/rooms/{room_id}/messages — post a message', 'POST /api/rooms/{room_id}/questions — open a question {question, blocking, addressed_to_participant_id?}', 'POST /api/rooms/{room_id}/questions/{question_id}/resolve — answer/resolve a question {resolution}', diff --git a/packages/core/src/markdown.ts b/packages/core/src/markdown.ts index e038682..2688d6a 100644 --- a/packages/core/src/markdown.ts +++ b/packages/core/src/markdown.ts @@ -11,7 +11,12 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string parts.push(`**Whose turn:** ${o.turn}`); if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`); parts.push(''); - parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`); + parts.push( + 'Participants: ' + + o.participants + .map((p) => (p.joined_at ? `${p.role} (✅ joined ${p.joined_at})` : `${p.role} (⏳ invite not confirmed)`)) + .join(', '), + ); parts.push(''); parts.push('## Open questions'); if (o.open_questions.length === 0) parts.push('(none)'); @@ -66,6 +71,8 @@ export function renderRoomMarkdown(i: MarkdownInput): string { parts.push(''); parts.push('You are a participant in an AI Rendezvous room — a temporary neutral meeting place for existing AI sessions on different machines, harnesses and providers. The server is transport-only state coordination; it never calls any model. Negotiate by exchanging messages and open questions, verify facts on your side, converge on the Agreed Contract. The room is deleted automatically.'); parts.push(''); + parts.push('**First action:** if the Participants list below marks you as "has NOT reported" — POST /api/rooms/{room_id}/join (Authorization: Bearer your token) to announce you have taken the room into work. The other side sees it.'); + parts.push(''); parts.push(`- **Goal:** ${i.goal || '(not set)'}`); if (i.brief) parts.push(`- **Brief:** ${i.brief}`); parts.push(`- **Status:** ${i.status}`); @@ -74,7 +81,10 @@ export function renderRoomMarkdown(i: MarkdownInput): string { parts.push('## Participants'); for (const p of i.participants) { - parts.push(`### ${p.role}${p.display_name && p.display_name !== p.role ? ` (${p.display_name})` : ''}`); + const joined = p.joined_at + ? `✅ joined ${p.joined_at}` + : '⏳ has NOT reported taking this room into work yet'; + parts.push(`### ${p.role}${p.display_name && p.display_name !== p.role ? ` (${p.display_name})` : ''} — ${joined}`); if (p.knows.length) parts.push(`- **knows:** ${p.knows.join('; ')}`); if (p.needs_to_determine.length) parts.push(`- **needs to determine:** ${p.needs_to_determine.join('; ')}`); if (p.instructions) parts.push(`- **instructions:** ${p.instructions}`); @@ -120,6 +130,7 @@ export function renderRoomMarkdown(i: MarkdownInput): string { parts.push('## Available API actions'); parts.push(`Base URL: ${i.baseUrl}. Authenticate with \`Authorization: Bearer \` or \`?token=\`.`); parts.push('- `GET /api/rooms/' + i.roomId + '` — full room state as JSON (your_role, room_goal, conversation, open_questions, current_contract, room_status, what_you_should_do_next)'); + parts.push('- `POST /api/rooms/' + i.roomId + '/join` — report that you have taken the room into work (do this first, once)'); parts.push('- `POST /api/rooms/' + i.roomId + '/messages` — {"content": "..."}'); parts.push('- `POST /api/rooms/' + i.roomId + '/questions` — {"question": "...", "blocking": true, "addressed_to_participant_id": "..."}'); parts.push('- `POST /api/rooms/' + i.roomId + '/questions/{question_id}/resolve` — {"resolution": "verified facts..."}'); diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index 4a1a638..471d659 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -84,8 +84,8 @@ export class RendezvousService { `INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)`, ); const insertParticipant = this.db.prepare( - `INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, joined_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const observerToken = newToken(); @@ -104,6 +104,7 @@ export class RendezvousService { JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))), JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))), (p.instructions ?? '').slice(0, 20000), + created.length === 0 ? createdAt : null, // creator is present from the start createdAt, ); created.push({ id: pid, role: p.role.trim(), token }); @@ -166,7 +167,7 @@ export class RendezvousService { listParticipants(roomId: string): ParticipantView[] { return (this.db - .prepare('SELECT * FROM participants WHERE room_id = ? ORDER BY created_at, id') + .prepare('SELECT rowid AS _rowid, * FROM participants WHERE room_id = ? ORDER BY rowid') .all(roomId) as unknown as ParticipantRow[]).map((p) => this.toParticipantView(p)); } @@ -178,6 +179,7 @@ export class RendezvousService { knows: JSON.parse(p.knows) as string[], needs_to_determine: JSON.parse(p.needs_to_determine) as string[], instructions: p.instructions, + joined_at: p.joined_at, }; } @@ -340,6 +342,22 @@ export class RendezvousService { }; } + /** + * Report that this participant has taken the room into work. Idempotent: + * the first timestamp is kept. Lets the other side (and the human) see the + * task has been picked up. + */ + join(roomId: string, token: string): { participants: ParticipantView[] } { + const { room, participant } = this.authenticate(roomId, token); + this.ensureOpen(room); + if (!participant.joined_at) { + this.db + .prepare('UPDATE participants SET joined_at = ? WHERE id = ? AND joined_at IS NULL') + .run(nowIso(), participant.id); + } + return { participants: this.listParticipants(roomId) }; + } + // ---------- writes ---------- postMessage(roomId: string, token: string, content: string): MessageView { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index ca244fa..301bbdf 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -24,6 +24,7 @@ export interface ParticipantRow { knows: string; // JSON array needs_to_determine: string; // JSON array instructions: string; + joined_at: string | null; created_at: string; } @@ -88,6 +89,7 @@ CREATE TABLE IF NOT EXISTS participants ( knows TEXT NOT NULL DEFAULT '[]', needs_to_determine TEXT NOT NULL DEFAULT '[]', instructions TEXT NOT NULL DEFAULT '', + joined_at TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS messages ( @@ -146,6 +148,10 @@ export class Store { if (!cols.some((c) => c.name === 'observer_token')) { this.db.exec('ALTER TABLE rooms ADD COLUMN observer_token TEXT'); } + const pcols = this.db.prepare('PRAGMA table_info(participants)').all() as { name: string }[]; + if (!pcols.some((c) => c.name === 'joined_at')) { + this.db.exec('ALTER TABLE participants ADD COLUMN joined_at TEXT'); + } // Backfill: every room, including pre-observer-token ones, gets one. const update = this.db.prepare('UPDATE rooms SET observer_token = ? WHERE id = ?'); for (const r of this.db.prepare('SELECT id FROM rooms WHERE observer_token IS NULL').all() as { id: string }[]) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5040fb5..617654f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -26,6 +26,8 @@ export interface ParticipantView { knows: string[]; needs_to_determine: string[]; instructions: string; + /** When this participant reported taking the room into work (null = invite not opened yet). */ + joined_at: string | null; } export interface MessageView { diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index e1c386a..c888078 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -165,6 +165,27 @@ test('destroyRoom: allowed for participant and observer, rejected for strangers' assert.throws(() => svc.getRoomView(c2.room_id, c2.participants[0].token), RendezvousError); }); +test('join: creator auto-joined, invitee reports once and idempotently', () => { + const { svc } = makeService(); + const created = createTwoPartyRoom(svc); + const [, tokenB] = created.participants.map((p) => p.token); + + const viewA = svc.getRoomView(created.room_id, created.participants[0].token); + assert.ok(viewA.participants[0].joined_at, 'creator is joined from the start'); + assert.equal(viewA.participants[1].joined_at, null); + + // B's advice tells it to join first + const viewB = svc.getRoomView(created.room_id, tokenB); + assert.match(viewB.what_you_should_do_next, /join/); + + const first = svc.join(created.room_id, tokenB).participants.find((p) => p.role === 'application')!; + const ts = first.joined_at!; + assert.ok(ts); + // idempotent: timestamp does not change + const again = svc.join(created.room_id, tokenB).participants.find((p) => p.role === 'application')!; + assert.equal(again.joined_at, ts); +}); + test('only addressee or author can resolve a question', () => { const { svc } = makeService(); const created = createTwoPartyRoom(svc); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 70de2af..3d8064c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -99,6 +99,20 @@ server.tool( }, ); +server.tool( + 'rendezvous_join', + 'Report that YOU have taken this room into work. Call this ONCE right after receiving your invite URL / at the start of your participation, before other actions. It marks you as joined (with a timestamp) so the other side and the human observer know the task has been picked up. Idempotent.', + { + token: z.string().describe('Your secret participant token'), + room_id: z.string().describe('Room id'), + }, + async ({ token, room_id }) => { + const r = await client(token).join(room_id); + const status = r.participants.map((p) => `${p.role}: ${p.joined_at ? `joined ${p.joined_at}` : 'not joined yet'}`).join('; '); + return { content: [{ type: 'text' as const, text: `You are marked as joined. Participants: ${status}` }] }; + }, +); + server.tool( 'rendezvous_post', 'Post a message to the room stating facts you verified on YOUR machine, answers, or arguments. Append-only: history cannot be edited.', diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3438f01..37113b6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -184,6 +184,13 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) { } } + router.on('POST', '/api/rooms/:id/join', (ctx) => { + writeAllowed(ctx); + const token = requireToken(ctx); + const r = service.join(ctx.params.id, token); + sendJson(ctx.res, 200, { joined: true, ...r }); + }); + router.on('POST', '/api/rooms/:id/messages', (ctx) => { writeAllowed(ctx); const token = requireToken(ctx); diff --git a/packages/server/src/pages.ts b/packages/server/src/pages.ts index 3231aec..5651630 100644 --- a/packages/server/src/pages.ts +++ b/packages/server/src/pages.ts @@ -65,6 +65,7 @@ You receive one **secret invite URL per participant** plus one **observer URL** Authenticate every request with \`Authorization: Bearer \` (or \`?token=\`). Your token is in your invite URL. +- \`POST /api/rooms//join\` — **first action**: report you have taken the room into work; the other side (and the human) sees you joined. - \`GET /r//.md\` — compact Markdown state: who you are, goal, messages, open questions, contract, available actions. - \`GET /api/rooms/\` — full JSON state, including \`what_you_should_do_next\` — follow it; it tells you exactly what is expected of you now. - \`POST /api/rooms//messages\` \`{"content": "..."}\` — state facts from your side. @@ -180,11 +181,15 @@ export function destroyedPage(): string { export function observerHtmlPage(o: ObserverView, token: string): string { const waiting = new Set(o.turn_waiting_for); const roleList = o.participants - .map((p) => - waiting.has(p.role) + .map((p) => { + const mark = waiting.has(p.role) ? `► ${escapeHtml(p.role)}` - : escapeHtml(p.role), - ) + : escapeHtml(p.role); + const joined = p.joined_at + ? `✅ took the room into work ${escapeHtml(p.joined_at)}` + : '⏳ invite not confirmed yet'; + return `${mark} (${joined})`; + }) .join(', '); const msgs = o.conversation .map( @@ -303,6 +308,7 @@ The observer URL (${baseUrl}/o//, also as .md) shows the n ## Endpoints (authenticate: Authorization: Bearer or ?token=) - GET ${baseUrl}/create.md — full instructions for agents (start here). +- POST ${baseUrl}/api/rooms//join — FIRST ACTION after opening your invite: report you have taken the room into work (idempotent). The other side and the observer see your joined status. - GET ${baseUrl}/r//.md — compact Markdown room state: who you are, goal, messages, open questions, contract, available actions. - GET ${baseUrl}/api/rooms/ — full JSON state incl. what_you_should_do_next and available_actions. - POST ${baseUrl}/api/rooms//messages — {"content":"verified facts / answers"}. diff --git a/packages/server/test/integration.test.ts b/packages/server/test/integration.test.ts index 20fbf6f..4b07145 100644 --- a/packages/server/test/integration.test.ts +++ b/packages/server/test/integration.test.ts @@ -119,6 +119,14 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page // observer HTML highlights whose turn it is after a blocking question appears const pidA = created.participants[0].id; const tokB = created.invite_urls[1].split('/').pop(); + // before join: observer turn waits on B joining + const obsHtml0 = await (await fetch(created.observer_url)).text(); + assert.match(obsHtml0, /invite not confirmed/); + // B reports taking the room into work + const j = await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/join`, { + method: 'POST', headers: { authorization: `Bearer ${tokB}` }, + })).json(); + assert.equal((j as any).joined, true); await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, { method: 'POST', headers: { authorization: `Bearer ${tokB}`, 'content-type': 'application/json' }, body: JSON.stringify({ question: 'verify X on your side', blocking: true, addressed_to_participant_id: pidA }),