@zooid/context-mcp 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,16 +1,23 @@
1
1
  import {
2
+ SUN_PATH_MAX,
3
+ agentSocketPath,
2
4
  buildContextMcpServer,
3
5
  callDaemon,
6
+ startAgentSocketServers,
4
7
  startDaemonSocketServer
5
- } from "./chunk-L5USYHTS.js";
8
+ } from "./chunk-IZ2JCKE4.js";
6
9
 
7
10
  // src/spawn-registry.ts
8
11
  import { randomUUID } from "crypto";
9
12
  var SpawnRegistry = class {
10
13
  bindings = /* @__PURE__ */ new Map();
14
+ spawnByAgentSession = /* @__PURE__ */ new Map();
15
+ spawnByAcpSession = /* @__PURE__ */ new Map();
16
+ tasks;
11
17
  register(input) {
12
18
  const spawnId = randomUUID();
13
19
  this.bindings.set(spawnId, { spawnId, ...input });
20
+ this.spawnByAgentSession.set(this.key(input.agentName, input.sessionKey ?? input.threadRef.threadId), spawnId);
14
21
  return spawnId;
15
22
  }
16
23
  get(spawnId) {
@@ -18,6 +25,29 @@ var SpawnRegistry = class {
18
25
  }
19
26
  release(spawnId) {
20
27
  this.bindings.delete(spawnId);
28
+ for (const [key, value] of this.spawnByAgentSession) {
29
+ if (value === spawnId) this.spawnByAgentSession.delete(key);
30
+ }
31
+ for (const [key, value] of this.spawnByAcpSession) {
32
+ if (value === spawnId) this.spawnByAcpSession.delete(key);
33
+ }
34
+ }
35
+ linkSession(agentName, sessionKey, acpSessionId) {
36
+ const spawnId = this.spawnByAgentSession.get(this.key(agentName, sessionKey));
37
+ if (spawnId) this.spawnByAcpSession.set(acpSessionId, spawnId);
38
+ }
39
+ getByAcpSession(acpSessionId) {
40
+ const spawnId = this.spawnByAcpSession.get(acpSessionId);
41
+ return spawnId ? this.bindings.get(spawnId) : void 0;
42
+ }
43
+ setTaskActions(actions) {
44
+ this.tasks = actions;
45
+ }
46
+ get taskActions() {
47
+ return this.tasks;
48
+ }
49
+ key(agentName, sessionKey) {
50
+ return `${agentName}::${sessionKey}`;
21
51
  }
22
52
  };
23
53
 
@@ -63,11 +93,14 @@ export {
63
93
  CONTEXT_CONTAINER_BIN,
64
94
  CONTEXT_CONTAINER_BIN_DIR,
65
95
  CONTEXT_CONTAINER_SOCK,
96
+ SUN_PATH_MAX,
66
97
  SpawnRegistry,
98
+ agentSocketPath,
67
99
  buildContextMcpServer,
68
100
  buildContextServerSpec,
69
101
  callDaemon,
70
102
  contextContainerMounts,
103
+ startAgentSocketServers,
71
104
  startDaemonSocketServer
72
105
  };
73
106
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/spawn-registry.ts","../src/factory.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto'\nimport type { SpawnBinding } from './types.js'\nimport type { TransportContextProvider, ThreadRef } from '@zooid/core'\n\nexport class SpawnRegistry {\n private readonly bindings = new Map<string, SpawnBinding>()\n\n register(input: {\n agentName: string\n threadRef: ThreadRef\n provider: TransportContextProvider\n }): string {\n const spawnId = randomUUID()\n this.bindings.set(spawnId, { spawnId, ...input })\n return spawnId\n }\n\n get(spawnId: string): SpawnBinding | undefined {\n return this.bindings.get(spawnId)\n }\n\n release(spawnId: string): void {\n this.bindings.delete(spawnId)\n }\n}\n","import { createRequire } from 'node:module'\nimport { dirname } from 'node:path'\nimport type { AcpMount } from '@zooid/core'\nimport type { ZooidContextServerSpec } from './types.js'\n\n/**\n * Resolve `dist/bin.js` via Node's runtime module resolver rather than\n * `import.meta.url`. tsup bundles this file into the CLI's own chunk, so\n * `import.meta.url` at runtime points to the CLI bundle — not this\n * package's dist. `createRequire` walks the runtime `node_modules` tree\n * instead and finds the package wherever it actually lives.\n */\nfunction resolveDefaultBin(): string {\n const req = createRequire(import.meta.url)\n // Resolve via the dedicated `./bin` export → `./dist/bin.js`. Avoids the\n // exports-restriction surprise of importing `./package.json`.\n return req.resolve('@zooid/context-mcp/bin')\n}\n\nlet cachedDefaultBin: string | null = null\nfunction getDefaultBin(): string {\n if (!cachedDefaultBin) cachedDefaultBin = resolveDefaultBin()\n return cachedDefaultBin\n}\n\n/**\n * Fixed container-side paths for the containerized MCP spec. The daemon\n * bind-mounts the host `dist/` here (ro) and the daemon socket here (rw) so\n * opencode can spawn the zooid-context MCP subprocess inside its own container.\n */\nexport const CONTEXT_CONTAINER_BIN_DIR = '/zooid/context-mcp'\nexport const CONTEXT_CONTAINER_BIN = `${CONTEXT_CONTAINER_BIN_DIR}/bin.js`\nexport const CONTEXT_CONTAINER_SOCK = '/zooid/context.sock'\n\n/**\n * The two bind-mounts a containerized, context-enabled agent needs. The bin dir\n * is read-only (self-contained bundle); the socket is read-write (the MCP\n * subprocess connect()s to it). Host sources default to the resolved package\n * dist and the passed daemon socket path.\n */\nexport function contextContainerMounts(opts: {\n sockPath: string\n binPath?: string\n}): AcpMount[] {\n const binFile = opts.binPath ?? getDefaultBin()\n return [\n { path: dirname(binFile), target: CONTEXT_CONTAINER_BIN_DIR, mode: 'ro' },\n { path: opts.sockPath, target: CONTEXT_CONTAINER_SOCK, mode: 'rw' },\n ]\n}\n\nexport function buildContextServerSpec(opts: {\n spawnId: string\n sockPath: string\n binPath?: string\n /**\n * When set, emit a spec that resolves INSIDE the agent container: bare `node`\n * (image PATH), the bind-mounted bin path, and the bind-mounted socket path.\n * The host `sockPath`/`binPath` still feed `contextContainerMounts`, but do\n * not appear in the emitted command. Absent → host-runtime spec (unchanged).\n */\n containerize?: boolean\n}): ZooidContextServerSpec {\n if (opts.containerize) {\n return {\n name: 'zooid-context',\n command: 'node',\n args: [CONTEXT_CONTAINER_BIN, '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: CONTEXT_CONTAINER_SOCK }],\n }\n }\n return {\n name: 'zooid-context',\n command: process.execPath,\n args: [opts.binPath ?? getDefaultBin(), '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: opts.sockPath }],\n }\n}\n"],"mappings":";;;;;;;AAAA,SAAS,kBAAkB;AAIpB,IAAM,gBAAN,MAAoB;AAAA,EACR,WAAW,oBAAI,IAA0B;AAAA,EAE1D,SAAS,OAIE;AACT,UAAM,UAAU,WAAW;AAC3B,SAAK,SAAS,IAAI,SAAS,EAAE,SAAS,GAAG,MAAM,CAAC;AAChD,WAAO;AAAA,EACT;AAAA,EAEA,IAAI,SAA2C;AAC7C,WAAO,KAAK,SAAS,IAAI,OAAO;AAAA,EAClC;AAAA,EAEA,QAAQ,SAAuB;AAC7B,SAAK,SAAS,OAAO,OAAO;AAAA,EAC9B;AACF;;;ACxBA,SAAS,qBAAqB;AAC9B,SAAS,eAAe;AAWxB,SAAS,oBAA4B;AACnC,QAAM,MAAM,cAAc,YAAY,GAAG;AAGzC,SAAO,IAAI,QAAQ,wBAAwB;AAC7C;AAEA,IAAI,mBAAkC;AACtC,SAAS,gBAAwB;AAC/B,MAAI,CAAC,iBAAkB,oBAAmB,kBAAkB;AAC5D,SAAO;AACT;AAOO,IAAM,4BAA4B;AAClC,IAAM,wBAAwB,GAAG,yBAAyB;AAC1D,IAAM,yBAAyB;AAQ/B,SAAS,uBAAuB,MAGxB;AACb,QAAM,UAAU,KAAK,WAAW,cAAc;AAC9C,SAAO;AAAA,IACL,EAAE,MAAM,QAAQ,OAAO,GAAG,QAAQ,2BAA2B,MAAM,KAAK;AAAA,IACxE,EAAE,MAAM,KAAK,UAAU,QAAQ,wBAAwB,MAAM,KAAK;AAAA,EACpE;AACF;AAEO,SAAS,uBAAuB,MAWZ;AACzB,MAAI,KAAK,cAAc;AACrB,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,MACT,MAAM,CAAC,uBAAuB,cAAc,KAAK,OAAO;AAAA,MACxD,KAAK,CAAC,EAAE,MAAM,qBAAqB,OAAO,uBAAuB,CAAC;AAAA,IACpE;AAAA,EACF;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS,QAAQ;AAAA,IACjB,MAAM,CAAC,KAAK,WAAW,cAAc,GAAG,cAAc,KAAK,OAAO;AAAA,IAClE,KAAK,CAAC,EAAE,MAAM,qBAAqB,OAAO,KAAK,SAAS,CAAC;AAAA,EAC3D;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/spawn-registry.ts","../src/factory.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto'\nimport type { SpawnBinding } from './types.js'\nimport type { TaskActions, TransportContextProvider, ThreadRef } from '@zooid/core'\n\nexport class SpawnRegistry {\n private readonly bindings = new Map<string, SpawnBinding>()\n private readonly spawnByAgentSession = new Map<string, string>()\n private readonly spawnByAcpSession = new Map<string, string>()\n private tasks: TaskActions | undefined\n\n register(input: {\n agentName: string\n threadRef: ThreadRef\n provider: TransportContextProvider\n sessionKey?: string\n }): string {\n const spawnId = randomUUID()\n this.bindings.set(spawnId, { spawnId, ...input })\n this.spawnByAgentSession.set(this.key(input.agentName, input.sessionKey ?? input.threadRef.threadId), spawnId)\n return spawnId\n }\n\n get(spawnId: string): SpawnBinding | undefined {\n return this.bindings.get(spawnId)\n }\n\n release(spawnId: string): void {\n this.bindings.delete(spawnId)\n for (const [key, value] of this.spawnByAgentSession) {\n if (value === spawnId) this.spawnByAgentSession.delete(key)\n }\n for (const [key, value] of this.spawnByAcpSession) {\n if (value === spawnId) this.spawnByAcpSession.delete(key)\n }\n }\n linkSession(agentName: string, sessionKey: string, acpSessionId: string): void {\n const spawnId = this.spawnByAgentSession.get(this.key(agentName, sessionKey))\n if (spawnId) this.spawnByAcpSession.set(acpSessionId, spawnId)\n }\n getByAcpSession(acpSessionId: string): SpawnBinding | undefined {\n const spawnId = this.spawnByAcpSession.get(acpSessionId)\n return spawnId ? this.bindings.get(spawnId) : undefined\n }\n setTaskActions(actions: TaskActions | undefined): void {\n this.tasks = actions\n }\n get taskActions(): TaskActions | undefined {\n return this.tasks\n }\n private key(agentName: string, sessionKey: string): string {\n return `${agentName}::${sessionKey}`\n }\n}\n","import { createRequire } from 'node:module'\nimport { dirname } from 'node:path'\nimport type { AcpMount } from '@zooid/core'\nimport type { ZooidContextServerSpec } from './types.js'\n\n/**\n * Resolve `dist/bin.js` via Node's runtime module resolver rather than\n * `import.meta.url`. tsup bundles this file into the CLI's own chunk, so\n * `import.meta.url` at runtime points to the CLI bundle — not this\n * package's dist. `createRequire` walks the runtime `node_modules` tree\n * instead and finds the package wherever it actually lives.\n */\nfunction resolveDefaultBin(): string {\n const req = createRequire(import.meta.url)\n // Resolve via the dedicated `./bin` export → `./dist/bin.js`. Avoids the\n // exports-restriction surprise of importing `./package.json`.\n return req.resolve('@zooid/context-mcp/bin')\n}\n\nlet cachedDefaultBin: string | null = null\nfunction getDefaultBin(): string {\n if (!cachedDefaultBin) cachedDefaultBin = resolveDefaultBin()\n return cachedDefaultBin\n}\n\n/**\n * Fixed container-side paths for the containerized MCP spec. The daemon\n * bind-mounts the host `dist/` here (ro) and the daemon socket here (rw) so\n * opencode can spawn the zooid-context MCP subprocess inside its own container.\n */\nexport const CONTEXT_CONTAINER_BIN_DIR = '/zooid/context-mcp'\nexport const CONTEXT_CONTAINER_BIN = `${CONTEXT_CONTAINER_BIN_DIR}/bin.js`\nexport const CONTEXT_CONTAINER_SOCK = '/zooid/context.sock'\n\n/**\n * The two bind-mounts a containerized, context-enabled agent needs. The bin dir\n * is read-only (self-contained bundle); the socket is read-write (the MCP\n * subprocess connect()s to it). Host sources default to the resolved package\n * dist and the passed daemon socket path.\n */\nexport function contextContainerMounts(opts: {\n sockPath: string\n binPath?: string\n}): AcpMount[] {\n const binFile = opts.binPath ?? getDefaultBin()\n return [\n { path: dirname(binFile), target: CONTEXT_CONTAINER_BIN_DIR, mode: 'ro' },\n { path: opts.sockPath, target: CONTEXT_CONTAINER_SOCK, mode: 'rw' },\n ]\n}\n\nexport function buildContextServerSpec(opts: {\n spawnId: string\n sockPath: string\n binPath?: string\n /**\n * When set, emit a spec that resolves INSIDE the agent container: bare `node`\n * (image PATH), the bind-mounted bin path, and the bind-mounted socket path.\n * The host `sockPath`/`binPath` still feed `contextContainerMounts`, but do\n * not appear in the emitted command. Absent → host-runtime spec (unchanged).\n */\n containerize?: boolean\n}): ZooidContextServerSpec {\n if (opts.containerize) {\n return {\n name: 'zooid-context',\n command: 'node',\n args: [CONTEXT_CONTAINER_BIN, '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: CONTEXT_CONTAINER_SOCK }],\n }\n }\n return {\n name: 'zooid-context',\n command: process.execPath,\n args: [opts.binPath ?? getDefaultBin(), '--spawn-id', opts.spawnId],\n env: [{ name: 'ZOOID_DAEMON_SOCK', value: opts.sockPath }],\n }\n}\n"],"mappings":";;;;;;;;;;AAAA,SAAS,kBAAkB;AAIpB,IAAM,gBAAN,MAAoB;AAAA,EACR,WAAW,oBAAI,IAA0B;AAAA,EACzC,sBAAsB,oBAAI,IAAoB;AAAA,EAC9C,oBAAoB,oBAAI,IAAoB;AAAA,EACrD;AAAA,EAER,SAAS,OAKE;AACT,UAAM,UAAU,WAAW;AAC3B,SAAK,SAAS,IAAI,SAAS,EAAE,SAAS,GAAG,MAAM,CAAC;AAChD,SAAK,oBAAoB,IAAI,KAAK,IAAI,MAAM,WAAW,MAAM,cAAc,MAAM,UAAU,QAAQ,GAAG,OAAO;AAC7G,WAAO;AAAA,EACT;AAAA,EAEA,IAAI,SAA2C;AAC7C,WAAO,KAAK,SAAS,IAAI,OAAO;AAAA,EAClC;AAAA,EAEA,QAAQ,SAAuB;AAC7B,SAAK,SAAS,OAAO,OAAO;AAC5B,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,qBAAqB;AACnD,UAAI,UAAU,QAAS,MAAK,oBAAoB,OAAO,GAAG;AAAA,IAC5D;AACA,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,mBAAmB;AACjD,UAAI,UAAU,QAAS,MAAK,kBAAkB,OAAO,GAAG;AAAA,IAC1D;AAAA,EACF;AAAA,EACA,YAAY,WAAmB,YAAoB,cAA4B;AAC7E,UAAM,UAAU,KAAK,oBAAoB,IAAI,KAAK,IAAI,WAAW,UAAU,CAAC;AAC5E,QAAI,QAAS,MAAK,kBAAkB,IAAI,cAAc,OAAO;AAAA,EAC/D;AAAA,EACA,gBAAgB,cAAgD;AAC9D,UAAM,UAAU,KAAK,kBAAkB,IAAI,YAAY;AACvD,WAAO,UAAU,KAAK,SAAS,IAAI,OAAO,IAAI;AAAA,EAChD;AAAA,EACA,eAAe,SAAwC;AACrD,SAAK,QAAQ;AAAA,EACf;AAAA,EACA,IAAI,cAAuC;AACzC,WAAO,KAAK;AAAA,EACd;AAAA,EACQ,IAAI,WAAmB,YAA4B;AACzD,WAAO,GAAG,SAAS,KAAK,UAAU;AAAA,EACpC;AACF;;;ACpDA,SAAS,qBAAqB;AAC9B,SAAS,eAAe;AAWxB,SAAS,oBAA4B;AACnC,QAAM,MAAM,cAAc,YAAY,GAAG;AAGzC,SAAO,IAAI,QAAQ,wBAAwB;AAC7C;AAEA,IAAI,mBAAkC;AACtC,SAAS,gBAAwB;AAC/B,MAAI,CAAC,iBAAkB,oBAAmB,kBAAkB;AAC5D,SAAO;AACT;AAOO,IAAM,4BAA4B;AAClC,IAAM,wBAAwB,GAAG,yBAAyB;AAC1D,IAAM,yBAAyB;AAQ/B,SAAS,uBAAuB,MAGxB;AACb,QAAM,UAAU,KAAK,WAAW,cAAc;AAC9C,SAAO;AAAA,IACL,EAAE,MAAM,QAAQ,OAAO,GAAG,QAAQ,2BAA2B,MAAM,KAAK;AAAA,IACxE,EAAE,MAAM,KAAK,UAAU,QAAQ,wBAAwB,MAAM,KAAK;AAAA,EACpE;AACF;AAEO,SAAS,uBAAuB,MAWZ;AACzB,MAAI,KAAK,cAAc;AACrB,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,MACT,MAAM,CAAC,uBAAuB,cAAc,KAAK,OAAO;AAAA,MACxD,KAAK,CAAC,EAAE,MAAM,qBAAqB,OAAO,uBAAuB,CAAC;AAAA,IACpE;AAAA,EACF;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS,QAAQ;AAAA,IACjB,MAAM,CAAC,KAAK,WAAW,cAAc,GAAG,cAAc,KAAK,OAAO;AAAA,IAClE,KAAK,CAAC,EAAE,MAAM,qBAAqB,OAAO,KAAK,SAAS,CAAC;AAAA,EAC3D;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/context-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Daemon-side stdio MCP server + per-spawn registry that exposes zooid_get_history / zooid_get_members / zooid_get_channel_info to ACP shims, backed by each transport's TransportContextProvider.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.0.4",
33
33
  "zod": "^3.23.0",
34
- "@zooid/core": "^0.13.0"
34
+ "@zooid/core": "^0.14.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",
package/src/bin.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { buildContextMcpServer } from './mcp-server.js'
4
4
  import { callDaemon } from './daemon-socket.js'
5
- import type { TransportContextProvider } from '@zooid/core'
5
+ import type {
6
+ StartTasksOutput,
7
+ CompleteTaskOutput,
8
+ TaskActions,
9
+ TaskRole,
10
+ TransportContextProvider,
11
+ } from '@zooid/core'
6
12
 
7
13
  const spawnIdIdx = process.argv.indexOf('--spawn-id')
8
14
  const spawnId = spawnIdIdx >= 0 ? process.argv[spawnIdIdx + 1] : undefined
@@ -35,15 +41,63 @@ const remoteProvider: TransportContextProvider = {
35
41
  params: { ...(opts ?? {}), threadId } as Record<string, unknown>,
36
42
  }) as Promise<Awaited<ReturnType<TransportContextProvider['getThreadHistory']>>>,
37
43
  getChannelMembers: () =>
38
- callDaemon(sockPath, { spawnId, method: 'getChannelMembers', params: {} }) as Promise<
39
- Awaited<ReturnType<TransportContextProvider['getChannelMembers']>>
40
- >,
41
- getChannelInfo: () =>
42
- callDaemon(sockPath, { spawnId, method: 'getChannelInfo', params: {} }) as Promise<
43
- Awaited<ReturnType<TransportContextProvider['getChannelInfo']>>
44
- >,
44
+ callDaemon(sockPath, {
45
+ spawnId,
46
+ method: 'getChannelMembers',
47
+ params: {},
48
+ }) as Promise<Awaited<ReturnType<TransportContextProvider['getChannelMembers']>>>,
49
+ getRoomInfo: () =>
50
+ callDaemon(sockPath, {
51
+ spawnId,
52
+ method: 'getRoomInfo',
53
+ params: {},
54
+ }) as Promise<Awaited<ReturnType<TransportContextProvider['getRoomInfo']>>>,
55
+ getRooms: () =>
56
+ callDaemon(sockPath, {
57
+ spawnId,
58
+ method: 'getRooms',
59
+ params: {},
60
+ }) as Promise<Awaited<ReturnType<TransportContextProvider['getRooms']>>>,
61
+ sendMessage: (input) =>
62
+ callDaemon(sockPath, {
63
+ spawnId,
64
+ method: 'sendMessage',
65
+ params: input as unknown as Record<string, unknown>,
66
+ }) as Promise<Awaited<ReturnType<TransportContextProvider['sendMessage']>>>,
45
67
  }
46
68
 
47
- const server = buildContextMcpServer({ resolve: async () => remoteProvider })
69
+ const remoteTasks: TaskActions = {
70
+ startTasks: (_caller, input) =>
71
+ callDaemon(sockPath, {
72
+ spawnId,
73
+ method: 'startTasks',
74
+ params: input as unknown as Record<string, unknown>,
75
+ }) as Promise<StartTasksOutput>,
76
+ completeTask: (_caller, input) =>
77
+ callDaemon(sockPath, {
78
+ spawnId,
79
+ method: 'completeTask',
80
+ params: input as unknown as Record<string, unknown>,
81
+ }) as Promise<CompleteTaskOutput>,
82
+ describeRole: () =>
83
+ callDaemon(sockPath, {
84
+ spawnId,
85
+ method: 'describeRole',
86
+ params: {},
87
+ }) as Promise<TaskRole>,
88
+ }
89
+
90
+ // A failed role query yields undefined, which registers neither task tool —
91
+ // the safe direction for MCP: the tools are additive, and a spawn that
92
+ // cannot reach the daemon cannot usefully call them anyway ([[ZOD084]]).
93
+ const role = await callDaemon(sockPath, { spawnId, method: 'describeRole', params: {} })
94
+ .then((r) => r as TaskRole)
95
+ .catch(() => undefined)
96
+
97
+ const server = buildContextMcpServer({
98
+ resolve: async () => remoteProvider,
99
+ resolveTasks: async () => remoteTasks,
100
+ role,
101
+ })
48
102
  await server.connect(new StdioServerTransport())
49
103
  process.stderr.write(`zooid-context-mcp: ready (spawnId=${spawnId})\n`)
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'
5
5
  import { createConnection } from 'node:net'
6
6
  import { SpawnRegistry } from './spawn-registry.js'
7
7
  import { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
8
- import type { TransportContextProvider } from '@zooid/core'
8
+ import type { TaskActions, TransportContextProvider } from '@zooid/core'
9
9
 
10
10
  function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
11
11
  return {
@@ -13,7 +13,18 @@ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportCo
13
13
  getRecentThreads: async () => ({ threads: [], has_more: false }),
14
14
  getThreadHistory: async () => ({ messages: [], has_more: false }),
15
15
  getChannelMembers: async () => [],
16
- getChannelInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
16
+ getRoomInfo: async () => ({ id: 'r', name: 'r', transport: 'matrix' }),
17
+ getRooms: async () => [],
18
+ sendMessage: async () => ({ event_id: '$sent' }),
19
+ ...over,
20
+ }
21
+ }
22
+
23
+ function fakeTasks(over: Partial<TaskActions> = {}): TaskActions {
24
+ return {
25
+ startTasks: async () => ({ results: [], notify: 'caller', delivery: 'd' }),
26
+ completeTask: async () => ({ status: 'recorded' }),
27
+ describeRole: async () => ({ is_task_assignee: false, can_start_task_threads: true }),
17
28
  ...over,
18
29
  }
19
30
  }
@@ -26,7 +37,7 @@ const defaultProvider = fakeProvider({
26
37
  has_more: false,
27
38
  }),
28
39
  getChannelMembers: async () => [{ id: '@alice:hs', name: 'alice', is_agent: false }],
29
- getChannelInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
40
+ getRoomInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
30
41
  })
31
42
 
32
43
  const cleanup: Array<() => Promise<void>> = []
@@ -44,7 +55,7 @@ describe('daemon-socket', () => {
44
55
  provider: defaultProvider,
45
56
  })
46
57
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
47
- const server = await startDaemonSocketServer({ sockPath, registry })
58
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
48
59
  cleanup.push(() => server.close())
49
60
 
50
61
  const res = await callDaemon(sockPath, {
@@ -91,7 +102,7 @@ describe('daemon-socket', () => {
91
102
  provider,
92
103
  })
93
104
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
94
- const server = await startDaemonSocketServer({ sockPath, registry })
105
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
95
106
  cleanup.push(() => server.close())
96
107
 
97
108
  const overview = (await callDaemon(sockPath, {
@@ -112,15 +123,15 @@ describe('daemon-socket', () => {
112
123
  it('returns an error envelope for unknown spawn-ids', async () => {
113
124
  const registry = new SpawnRegistry()
114
125
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
115
- const server = await startDaemonSocketServer({ sockPath, registry })
126
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
116
127
  cleanup.push(() => server.close())
117
128
 
118
129
  await expect(
119
130
  callDaemon(sockPath, { spawnId: 'unknown', method: 'getRoomHistory', params: {} }),
120
- ).rejects.toThrow(/unknown spawn/i)
131
+ ).rejects.toThrow(/binding not owned by caller/)
121
132
  })
122
133
 
123
- it('routes getChannelMembers and getChannelInfo', async () => {
134
+ it('routes getChannelMembers and getRoomInfo', async () => {
124
135
  const registry = new SpawnRegistry()
125
136
  const spawnId = registry.register({
126
137
  agentName: 'a',
@@ -128,13 +139,13 @@ describe('daemon-socket', () => {
128
139
  provider: defaultProvider,
129
140
  })
130
141
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
131
- const server = await startDaemonSocketServer({ sockPath, registry })
142
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
132
143
  cleanup.push(() => server.close())
133
144
 
134
145
  const members = await callDaemon(sockPath, { spawnId, method: 'getChannelMembers', params: {} })
135
146
  expect(members).toEqual([{ id: '@alice:hs', name: 'alice', is_agent: false }])
136
147
 
137
- const info = await callDaemon(sockPath, { spawnId, method: 'getChannelInfo', params: {} })
148
+ const info = await callDaemon(sockPath, { spawnId, method: 'getRoomInfo', params: {} })
138
149
  expect(info).toEqual({ id: '!r:hs', name: 'general', transport: 'matrix' })
139
150
  })
140
151
 
@@ -144,28 +155,28 @@ describe('daemon-socket', () => {
144
155
  messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
145
156
  has_more: false,
146
157
  }),
147
- getChannelInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
158
+ getRoomInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
148
159
  })
149
160
  const providerB = fakeProvider({
150
161
  getRoomHistory: async () => ({
151
162
  messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
152
163
  has_more: false,
153
164
  }),
154
- getChannelInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
165
+ getRoomInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
155
166
  })
156
167
  const registry = new SpawnRegistry()
157
168
  const spawnA = registry.register({
158
- agentName: 'architect',
169
+ agentName: 'a',
159
170
  threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
160
171
  provider: providerA,
161
172
  })
162
173
  const spawnB = registry.register({
163
- agentName: 'product-owner',
174
+ agentName: 'a',
164
175
  threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
165
176
  provider: providerB,
166
177
  })
167
178
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
168
- const server = await startDaemonSocketServer({ sockPath, registry })
179
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
169
180
  cleanup.push(() => server.close())
170
181
 
171
182
  const [resA, resB] = await Promise.all([
@@ -176,7 +187,7 @@ describe('daemon-socket', () => {
176
187
  expect((resA as { messages: Array<{ id: string }> }).messages[0].id).toBe('A1')
177
188
  expect((resB as { messages: Array<{ id: string }> }).messages[0].id).toBe('B1')
178
189
 
179
- const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getChannelInfo', params: {} })
190
+ const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getRoomInfo', params: {} })
180
191
  expect((infoB as { id: string }).id).toBe('!b:hs')
181
192
  })
182
193
 
@@ -200,12 +211,12 @@ describe('daemon-socket', () => {
200
211
  provider: providerA,
201
212
  })
202
213
  const spawnB = registry.register({
203
- agentName: 'b',
214
+ agentName: 'a',
204
215
  threadRef: { channelId: 'b', threadId: 'b' },
205
216
  provider: providerB,
206
217
  })
207
218
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
208
- const server = await startDaemonSocketServer({ sockPath, registry })
219
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
209
220
  cleanup.push(() => server.close())
210
221
 
211
222
  for (let i = 0; i < 20; i++) {
@@ -219,6 +230,90 @@ describe('daemon-socket', () => {
219
230
  expect(res.messages[0].id).toBe(expected)
220
231
  }
221
232
  })
233
+
234
+ it('routes describeRole to task actions with the bound caller', async () => {
235
+ const seen: unknown[] = []
236
+ const registry = new SpawnRegistry()
237
+ registry.setTaskActions(
238
+ fakeTasks({
239
+ describeRole: async (caller) => {
240
+ seen.push(caller)
241
+ return { is_task_assignee: false, can_start_task_threads: true }
242
+ },
243
+ }),
244
+ )
245
+ const spawnId = registry.register({
246
+ agentName: 'agent-a',
247
+ threadRef: { channelId: 'c', threadId: '$root' },
248
+ provider: defaultProvider,
249
+ })
250
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
251
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'agent-a' })
252
+ cleanup.push(() => server.close())
253
+
254
+ const res = await callDaemon(sockPath, { spawnId, method: 'describeRole', params: {} })
255
+ expect(res).toEqual({ is_task_assignee: false, can_start_task_threads: true })
256
+ expect(seen[0]).toMatchObject({ agentName: 'agent-a', threadRoot: '$root' })
257
+ })
258
+
259
+ it('routes sendMessage and getRooms to the provider', async () => {
260
+ const providerCalls: unknown[] = []
261
+ const provider = fakeProvider({
262
+ sendMessage: async (input) => {
263
+ providerCalls.push({ method: 'sendMessage', input })
264
+ return { event_id: '$e' }
265
+ },
266
+ getRooms: async () => {
267
+ providerCalls.push({ method: 'getRooms' })
268
+ return [{ id: '!a:localhost', name: 'general', transport: 'matrix' as const }]
269
+ },
270
+ })
271
+ const registry = new SpawnRegistry()
272
+ const spawnId = registry.register({
273
+ agentName: 'a',
274
+ threadRef: { channelId: 'c', threadId: 't' },
275
+ provider,
276
+ })
277
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
278
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
279
+ cleanup.push(() => server.close())
280
+
281
+ await callDaemon(sockPath, {
282
+ spawnId,
283
+ method: 'sendMessage',
284
+ params: { room: '!a:localhost', text: 'noted' },
285
+ })
286
+ await callDaemon(sockPath, { spawnId, method: 'getRooms', params: {} })
287
+ expect(providerCalls).toEqual([
288
+ { method: 'sendMessage', input: { room: '!a:localhost', text: 'noted' } },
289
+ { method: 'getRooms' },
290
+ ])
291
+ })
292
+
293
+ it('refuses sendMessage into a room the caller is not bound to', async () => {
294
+ const provider = fakeProvider({
295
+ sendMessage: async () => {
296
+ throw new Error('not_in_room: this agent is not a member of !elsewhere:localhost')
297
+ },
298
+ })
299
+ const registry = new SpawnRegistry()
300
+ const spawnId = registry.register({
301
+ agentName: 'a',
302
+ threadRef: { channelId: 'c', threadId: 't' },
303
+ provider,
304
+ })
305
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
306
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
307
+ cleanup.push(() => server.close())
308
+
309
+ await expect(
310
+ callDaemon(sockPath, {
311
+ spawnId,
312
+ method: 'sendMessage',
313
+ params: { room: '!elsewhere:localhost', text: 'hi' },
314
+ }),
315
+ ).rejects.toThrow(/not_in_room/)
316
+ })
222
317
  })
223
318
 
224
319
  describe('close()', () => {
@@ -230,7 +325,7 @@ describe('close()', () => {
230
325
  // `zooid dev` hung on shutdown for as long as those processes lived.
231
326
  const sockPath = join(tmpdir(), `zooid-close-${randomUUID()}.sock`)
232
327
  const registry = new SpawnRegistry()
233
- const handle = await startDaemonSocketServer({ sockPath, registry })
328
+ const handle = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
234
329
 
235
330
  const client = createConnection(sockPath)
236
331
  await new Promise<void>((resolve, reject) => {
@@ -246,3 +341,41 @@ describe('close()', () => {
246
341
  expect(closed).toBe('closed')
247
342
  })
248
343
  })
344
+
345
+ describe('daemon-socket caller identity', () => {
346
+ it('refuses bindings belonging to another agent through either addressing key', async () => {
347
+ const registry = new SpawnRegistry()
348
+ const spawnId = registry.register({
349
+ agentName: 'alice', threadRef: { channelId: 'c', threadId: 't' }, provider: defaultProvider, sessionKey: 't',
350
+ })
351
+ registry.linkSession('alice', 't', 'acp-session-1')
352
+ const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
353
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'bob' })
354
+ cleanup.push(() => server.close())
355
+
356
+ await expect(callDaemon(sockPath, { spawnId, method: 'getRoomHistory', params: {} })).rejects.toThrow(NOT_OWNED)
357
+ await expect(callDaemon(sockPath, { acpSessionId: 'acp-session-1', method: 'getChannelMembers', params: {} })).rejects.toThrow(NOT_OWNED)
358
+ })
359
+
360
+ it('serves its owner and makes unknown and unowned ids indistinguishable', async () => {
361
+ const registry = new SpawnRegistry()
362
+ const spawnId = registry.register({
363
+ agentName: 'alice', threadRef: { channelId: 'c', threadId: 't' }, provider: defaultProvider,
364
+ })
365
+ const alicePath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
366
+ const bobPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
367
+ const alice = await startDaemonSocketServer({ sockPath: alicePath, registry, agentName: 'alice' })
368
+ const bob = await startDaemonSocketServer({ sockPath: bobPath, registry, agentName: 'bob' })
369
+ cleanup.push(() => alice.close())
370
+ cleanup.push(() => bob.close())
371
+
372
+ await expect(callDaemon(alicePath, { spawnId, method: 'getChannelMembers', params: {} })).resolves.toEqual([
373
+ { id: '@alice:hs', name: 'alice', is_agent: false },
374
+ ])
375
+ const unowned = await callDaemon(bobPath, { spawnId, method: 'getRoomInfo', params: {} }).catch((e: Error) => e.message)
376
+ const unknown = await callDaemon(bobPath, { acpSessionId: 'no-such-session', method: 'getRoomInfo', params: {} }).catch((e: Error) => e.message)
377
+ expect(unowned).toBe(unknown)
378
+ })
379
+ })
380
+
381
+ const NOT_OWNED = 'binding not owned by caller'