Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions clients/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,6 @@ The mechanics block defines:
- **Tools.** `post`, `edit_message`, `react`/`unreact`, `subscribe`/`unsubscribe`,
`read_channel`/`read_thread`, `list_channels`, `resolve`,
`current_identity`, `download_file`, `upload_file` — see the tool surface table below.
- **`session_id`.** Pass it on `post`, `edit_message`, `react`, `unreact`,
and `current_identity`. **Must be a UUID** (e.g. `crypto.randomUUID()`);
malformed values are rejected as if the field were missing.
CC's PreToolUse hook injects the harness session UUID automatically.

## Inbound event format

Expand Down
137 changes: 115 additions & 22 deletions clients/claude-code/hooks-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,35 @@ import hooksManifest from './hooks/hooks.json'
* So the derivation is traced from there instead, which is also what
* `comms-tww6` specifies:
*
* A TOOL WHOSE ADAPTER PATH REACHES `boundHttp` MUST DECLARE `session_id`
* A TOOL WHOSE ADAPTER PATH REACHES `boundHttp` MUST RECEIVE `session_id`
* AND BE IN THE `hooks.json` MATCHER.
*
* That is a stronger rule than the old one. The old test could only catch a
* tool that called the wrapper and was missing from the matcher; it could not
* see a tool that reached `boundHttp` while appearing in neither set — which is
* exactly the live P1 that `comms-tww6` is open about.
*
* RECEIVES, NOT DECLARES (comms-tg70). The rule used to say DECLARE, and traced
* a `session_id` property on the tool's advertised `inputSchema`. No tool
* declares one now: `session_id` is host plumbing the model has no way to fill,
* so it is supplied into `arguments` and accepted by the guard without being
* advertised (`ToolDef.hostSuppliedArgs`). What a bound-path tool must have is
* unchanged in substance — the id has to REACH it — so the trace moved to the
* accept-side marker.
*
* THAT MOVE IS WHY {@link SESSION_ID_RECEIVING_TOOLS} EXISTS. A test that
* derives a property from a source scan goes green when the scan stops matching
* — every rule below reads "no tool violates it" and a scan finding nothing
* satisfies all of them by looking at nothing. Pinning the receiving set makes
* the scan itself the thing under test: rename the marker and the pin fails
* loudly instead of the suite passing quietly.
*
* KNOWN VIOLATIONS ARE NAMED, NOT PAPERED OVER. Three tools violate the rule at
* HEAD (see `TWW6_EXCEPTIONS`). Fixing them means adding `session_id` to their
* schemas, which is `comms-tg70`'s ground and out of scope here. Encoding the
* real rule with a visible exception list beats asserting a weaker rule that
* passes: the day `comms-tww6` lands, its author deletes entries from that list
* and this test proves the fix.
* HEAD (see `TWW6_EXCEPTIONS`). Fixing them means giving them the host-supplied
* `session_id`, which is out of scope here. Encoding the real rule with a
* visible exception list beats asserting a weaker rule that passes: the day
* `comms-tww6` lands, its author deletes entries from that list and this test
* proves the fix.
*/

/**
Expand Down Expand Up @@ -100,7 +115,7 @@ const BOUND_INBOX_VERBS = ['subscribe', 'subscriptions', 'unsubscribe'] as const
const G5ZH3_MATCHER_PENDING = [] as const

/**
* Tools that reach `boundHttp` while declaring no `session_id` and sitting
* Tools that reach `boundHttp` while receiving no `session_id` and sitting
* outside the matcher — the open P1 `comms-tww6`. They run under whatever seat
* an EARLIER call happened to bind, so their attribution is inherited by
* accident of ordering rather than established by the call itself.
Expand All @@ -110,6 +125,26 @@ const G5ZH3_MATCHER_PENDING = [] as const
*/
const TWW6_EXCEPTIONS = ['resolve_thread', 'set_channel_description', 'unresolve_thread'] as const

/**
* Every tool that accepts a host-supplied `session_id`. SEVEN, which is wider
* than the five in the PreToolUse matcher: `subscribe` and `unsubscribe` are
* here for a non-CC ephemeral host that supplies the UUID itself, and a
* listen-first seat reaches an identity through no other tool. Do not read a
* statement about one of these sets as a statement about the other.
*
* Pinned, not derived, and that is the point — see the file header. This set is
* what proves the scan below still sees anything at all.
*/
const SESSION_ID_RECEIVING_TOOLS = [
'current_identity',
'edit_message',
'post',
'react',
'subscribe',
'unreact',
'unsubscribe',
] as const

/** Enclosing declaration names in the adapter source that call `boundHttp()`. */
function adapterVerbsReachingBoundHttp(source: string): ReadonlySet<string> {
const reaching = new Set<string>()
Expand All @@ -135,24 +170,37 @@ function adapterVerbsReachingBoundHttp(source: string): ReadonlySet<string> {
interface ToolFacts {
readonly verbs: ReadonlySet<string>
readonly inboxVerbs: ReadonlySet<string>
readonly declaresSessionId: boolean
readonly receivesSessionId: boolean
readonly advertisesSessionId: boolean
}

/**
* Per-tool: which publisher verbs and which inbox verbs its handler calls, and
* whether it declares `session_id`.
* Per-tool: which publisher verbs and which inbox verbs its handler calls,
* whether it accepts a host-supplied `session_id`, and whether it advertises
* one on its `inputSchema`. The last is traced only so the assertions can show
* it is nowhere — the two facts are separate and stay separately measured.
*/
function toolFactsFromToolsSource(source: string): ReadonlyMap<string, ToolFacts> {
const facts = new Map<
string,
{ verbs: Set<string>; inboxVerbs: Set<string>; declaresSessionId: boolean }
{
verbs: Set<string>
inboxVerbs: Set<string>
receivesSessionId: boolean
advertisesSessionId: boolean
}
>()
let current: string | undefined
for (const line of source.split('\n')) {
const named = line.match(/^ {6}name: '([a-z_]+)',$/)?.[1]
if (named !== undefined) {
current = named
facts.set(named, { verbs: new Set(), inboxVerbs: new Set(), declaresSessionId: false })
facts.set(named, {
verbs: new Set(),
inboxVerbs: new Set(),
receivesSessionId: false,
advertisesSessionId: false,
})
}
const entry = current === undefined ? undefined : facts.get(current)
if (entry === undefined) continue
Expand All @@ -167,7 +215,11 @@ function toolFactsFromToolsSource(source: string): ReadonlyMap<string, ToolFacts
const captured = verb[1]
if (captured !== undefined) entry.inboxVerbs.add(captured)
}
if (line.includes('session_id: sessionIdField')) entry.declaresSessionId = true
// The accept-side marker: an argument the host stamps in, admitted by the
// guard in `registerTools` and absent from `inputSchema`.
if (line.includes('hostSuppliedArgs: hostSuppliedSessionId')) entry.receivesSessionId = true
// The advertise-side property, which no tool should have (comms-tg70).
if (/^ +session_id: /.test(line)) entry.advertisesSessionId = true
}
return new Map(
[...facts].map(([name, e]) => [name, { ...e, verbs: e.verbs, inboxVerbs: e.inboxVerbs }]),
Expand Down Expand Up @@ -210,13 +262,39 @@ test('the set of adapter declarations reaching boundHttp is the pinned one', asy
])
})

test('every tool whose adapter path reaches boundHttp declares session_id', async () => {
// The pin that keeps the four rules below from holding by not looking. Each of
// them reads "no tool violates this", which a scan that matches nothing
// satisfies trivially — so assert first that the scan finds the set it is
// supposed to find.
test('the tools accepting a host-supplied session_id are exactly the pinned seven', async () => {
const facts = toolFactsFromToolsSource(await toolsSource())
const receiving = [...facts]
.filter(([, f]) => f.receivesSessionId)
.map(([name]) => name)
.sort()
expect(receiving).toEqual([...SESSION_ID_RECEIVING_TOOLS])
})

// comms-tg70: `session_id` is supplied, never advertised. A human does not type
// their session id into the compose box (docs/agent-experience.md principle 1),
// and the model has no way to fill the field, so putting it on the schema only
// ever put plumbing on the agent's surface.
test('no tool advertises session_id on its inputSchema', async () => {
const facts = toolFactsFromToolsSource(await toolsSource())
const advertising = [...facts]
.filter(([, f]) => f.advertisesSessionId)
.map(([name]) => name)
.sort()
expect(advertising).toEqual([])
})

test('every tool whose adapter path reaches boundHttp receives session_id', async () => {
const facts = toolFactsFromToolsSource(await toolsSource())
const offenders = [...facts]
.filter(
([, f]) =>
[...f.verbs].some((v) => (BOUND_VERBS as ReadonlyArray<string>).includes(v)) &&
!f.declaresSessionId,
!f.receivesSessionId,
)
.map(([name]) => name)
.sort()
Expand Down Expand Up @@ -259,7 +337,7 @@ test('comms-tww6: the known unstamped bound-path tools are exactly the recorded
const facts = toolFactsFromToolsSource(await toolsSource())
const boundHttpVerbs = new Set(['resolveThread', 'unresolveThread', 'setChannelDescription'])
const unstamped = [...facts]
.filter(([, f]) => [...f.verbs].some((v) => boundHttpVerbs.has(v)) && !f.declaresSessionId)
.filter(([, f]) => [...f.verbs].some((v) => boundHttpVerbs.has(v)) && !f.receivesSessionId)
.map(([name]) => name)
.sort()
expect(unstamped).toEqual([...TWW6_EXCEPTIONS])
Expand Down Expand Up @@ -302,9 +380,7 @@ test('adapterVerbsReachingBoundHttp ignores commented-out mentions of boundHttp'
test('toolFactsFromToolsSource attributes verbs and session_id to the enclosing tool', () => {
const synthetic = `
name: 'alpha',
inputSchema: {
properties: { session_id: sessionIdField },
},
hostSuppliedArgs: hostSuppliedSessionId,
handler: async (args) => {
await run(adapter.publisher.post(channel, body))
},
Expand All @@ -316,24 +392,41 @@ test('toolFactsFromToolsSource attributes verbs and session_id to the enclosing
handler: async () => {
await run(adapter.inbox.subscribe(target))
},
name: 'delta',
inputSchema: {
properties: {
session_id: sessionIdField,
},
},
`
const facts = toolFactsFromToolsSource(synthetic)
expect(facts.get('alpha')).toEqual({
verbs: new Set(['post']),
inboxVerbs: new Set(),
declaresSessionId: true,
receivesSessionId: true,
advertisesSessionId: false,
})
expect(facts.get('beta')).toEqual({
verbs: new Set(),
inboxVerbs: new Set(),
declaresSessionId: false,
receivesSessionId: false,
advertisesSessionId: false,
})
// A read through the inbox is still traced as an inbox verb here; whether it
// BINDS is decided by `BOUND_INBOX_VERBS`, not by the receiver.
expect(facts.get('gamma')).toEqual({
verbs: new Set(),
inboxVerbs: new Set(['subscribe']),
declaresSessionId: false,
receivesSessionId: false,
advertisesSessionId: false,
})
// The advertise-side trace catches a schema property coming back, and does
// not confuse it with the accept-side marker.
expect(facts.get('delta')).toEqual({
verbs: new Set(),
inboxVerbs: new Set(),
receivesSessionId: false,
advertisesSessionId: true,
})
})

Expand Down
3 changes: 0 additions & 3 deletions docs/agent-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,6 @@ identity are the same moment.

Places the current implementation fails this reference.

- **`session_id` is a parameter on seven tool schemas** (`packages/mcp/tools.ts`).
A human does not type their session id into the compose box. Plumbing has
surfaced in the agent-visible surface. Principle 1.
- **Full message content is pushed into the agent's turn**
(`packages/mcp/events.ts`, and the inbound format in the plugin README).
The agent has paid for the content before deciding it was relevant.
Expand Down
8 changes: 7 additions & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,13 @@ Two supported ways to avoid it, both already documented above:
- Set `COMMY_BOT_NAME` for a persistent identity — the session id is irrelevant
in that mode, and the bot subscribes under its own stable principal.
- Pass a UUID `session_id` in the tool-call arguments, which is the binding an
ephemeral non-CC host has (`docs/claude-channel-inbound-contract.md`).
ephemeral non-CC host has (`docs/claude-channel-inbound-contract.md`). The
tools accepting it are `post`, `edit_message`, `react`, `unreact`,
`current_identity`, `subscribe` and `unsubscribe`. It is **not** advertised on
their `inputSchema` — a session id is the host's to supply, not something a
model could fill, so it is documented here rather than shown to the agent.
Anything that fails UUID validation is treated as missing: the server returns
the unbound-stub error rather than minting a malformed `cc-*` identity.

Claude Code seats are unaffected: the plugin injects `CLAUDE_CODE_SESSION_ID`
into the MCP child's environment at spawn, so the id is known before the seat's
Expand Down
20 changes: 18 additions & 2 deletions packages/mcp/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,34 @@ test('initialize response declares a tools capability that supports list changes
}
})

test('initialize response carries instructions explaining the session_id contract', async () => {
test('initialize response carries instructions covering the tool surface', async () => {
const { client, close } = await pairAndConnect()
try {
const instructions = client.getInstructions()
expect(instructions).toBeDefined()
expect(instructions).toMatch(/session_id/)
expect(instructions).toMatch(/post|react|unreact|current_identity/)
} finally {
await close()
}
})

// The instructions block is read by the model, so it is the agent-visible
// surface in prose (comms-tg70). It used to carry a `session_id` paragraph
// telling the agent to pass its conversation's session id — plumbing a host
// supplies, not something an agent has or could produce. Removing the schema
// property while leaving that paragraph would have left the model instructed to
// send an argument no tool advertises.
test('initialize instructions never ask the agent for a session id', async () => {
const { client, close } = await pairAndConnect()
try {
const instructions = client.getInstructions()
expect(instructions).not.toMatch(/session_id/)
expect(instructions).not.toMatch(/session id/i)
} finally {
await close()
}
})

test('initialize instructions give substrate-coexistence guidance without naming a specific peer substrate', async () => {
const { client, close } = await pairAndConnect()
try {
Expand Down
13 changes: 8 additions & 5 deletions packages/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ export const PLUGIN_VERSION = '0.22.0'
* Echoed to every connected MCP client via the server `instructions:`
* field. Substrate-general mechanics only — channel naming + discovery,
* topic discipline, subscription discipline,
* clickable-permalink rendering, tool cheat sheet, and
* the `session_id` contract. Deliberately carries
* clickable-permalink rendering, and a tool cheat sheet.
* Deliberately carries
* no operator-specific assumptions (named peer substrates, issue
* trackers, internal ids) — those belong in an operator's own context,
* not in guidance shipped to every adopter. Etiquette — how
* to communicate *well* on the substrate — ships separately as the
* `using-commy` skill so it stays opt-in rather than always-on.
*
* Nothing here asks the agent for a `session_id`. This block is read by the
* model, so it is the agent-visible surface in prose, and a session id is the
* host's to supply (see `ToolDef.hostSuppliedArgs`). The contract a non-CC
* host implements is in `docs/self-hosting.md`, which its operator reads.
*/
const COMMY_INSTRUCTIONS = `**Substrate.** commy is the inter-agent channel: agents and humans coordinate here. If you run it alongside other agent-messaging tools, keep one substrate canonical and don't fan the same message across all of them.

Expand All @@ -30,9 +35,7 @@ const COMMY_INSTRUCTIONS = `**Substrate.** commy is the inter-agent channel: age

**Links.** Every ref the substrate hands you carries a ready-to-click \`permalink\` — on \`post\` results, \`read_channel\`/\`read_thread\` messages (message \`permalink\` plus \`channel.permalink\` and \`thread.permalink\`), \`list_channels\` (channel \`permalink\`), and inbound \`<channel source="commy">\` frames (\`permalink\` / \`channel_permalink\` / \`thread_permalink\` meta, \`target_permalink\` on reaction frames). **Whenever you show a human a message, channel, or topic reference, render it as that clickable permalink — never a bare name or numeric id.** A human can click a permalink straight to the message; a bare \`#channel > topic\` or message number makes them hunt. When you hold only a message id (e.g. one cited elsewhere, with no permalink to hand), \`message_link(message_id, channel_name?, thread?)\` returns its \`{permalink}\`.

**Tools.** \`post\` (channel; optionally thread or reply), \`react\`/\`unreact\` (emoji on a message), \`subscribe\`/\`unsubscribe\` (live target), \`read_channel\`/\`read_thread\` (history within a range), \`list_channels\` (enumerate channels in the realm), \`message_link\` (canonical permalink for a message id), \`resolve\` (identity by name), \`current_identity\` (passive — never acquires), \`download_file\` (fetch a \`/user_uploads/...\` attachment to a temp file — rooted under the operator-set \`COMMY_DOWNLOAD_DIR\` when configured so it lands somewhere you can Read, else \`$TMPDIR\`; use Read on the returned path to view images), \`upload_file\` (upload a local file by absolute path; returns a \`reference\` string to embed in a \`post\` body).

**session_id.** Pass your conversation's session id as the optional argument on \`post\`, \`edit_message\`, \`react\`, \`unreact\`, and \`current_identity\`. **Must be a UUID** (e.g. \`crypto.randomUUID()\`); anything else is rejected as malformed and the call routes to the "missing session_id" error rather than silently minting a \`cc-<garbage>\` identity. In Claude Code the plugin's PreToolUse hook injects the harness session id automatically. The server uses it to derive the ephemeral \`cc-<8>\` bot identity for this conversation and to detect transitions between conversations.`
**Tools.** \`post\` (channel; optionally thread or reply), \`react\`/\`unreact\` (emoji on a message), \`subscribe\`/\`unsubscribe\` (live target), \`read_channel\`/\`read_thread\` (history within a range), \`list_channels\` (enumerate channels in the realm), \`message_link\` (canonical permalink for a message id), \`resolve\` (identity by name), \`current_identity\` (passive — never acquires), \`download_file\` (fetch a \`/user_uploads/...\` attachment to a temp file — rooted under the operator-set \`COMMY_DOWNLOAD_DIR\` when configured so it lands somewhere you can Read, else \`$TMPDIR\`; use Read on the returned path to view images), \`upload_file\` (upload a local file by absolute path; returns a \`reference\` string to embed in a \`post\` body).`

/**
* Construct the commy MCP server with the capabilities the plugin
Expand Down
Loading