@zooid/context-mcp 0.12.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-ZY2GPHFX.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.12.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.12.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`)
@@ -2,9 +2,10 @@ import { describe, it, expect, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { randomUUID } from 'node:crypto'
5
+ import { createConnection } from 'node:net'
5
6
  import { SpawnRegistry } from './spawn-registry.js'
6
7
  import { startDaemonSocketServer, callDaemon } from './daemon-socket.js'
7
- import type { TransportContextProvider } from '@zooid/core'
8
+ import type { TaskActions, TransportContextProvider } from '@zooid/core'
8
9
 
9
10
  function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportContextProvider {
10
11
  return {
@@ -12,7 +13,18 @@ function fakeProvider(over: Partial<TransportContextProvider> = {}): TransportCo
12
13
  getRecentThreads: async () => ({ threads: [], has_more: false }),
13
14
  getThreadHistory: async () => ({ messages: [], has_more: false }),
14
15
  getChannelMembers: async () => [],
15
- 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 }),
16
28
  ...over,
17
29
  }
18
30
  }
@@ -25,7 +37,7 @@ const defaultProvider = fakeProvider({
25
37
  has_more: false,
26
38
  }),
27
39
  getChannelMembers: async () => [{ id: '@alice:hs', name: 'alice', is_agent: false }],
28
- getChannelInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
40
+ getRoomInfo: async () => ({ id: '!r:hs', name: 'general', transport: 'matrix' }),
29
41
  })
30
42
 
31
43
  const cleanup: Array<() => Promise<void>> = []
@@ -43,7 +55,7 @@ describe('daemon-socket', () => {
43
55
  provider: defaultProvider,
44
56
  })
45
57
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
46
- const server = await startDaemonSocketServer({ sockPath, registry })
58
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
47
59
  cleanup.push(() => server.close())
48
60
 
49
61
  const res = await callDaemon(sockPath, {
@@ -90,7 +102,7 @@ describe('daemon-socket', () => {
90
102
  provider,
91
103
  })
92
104
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
93
- const server = await startDaemonSocketServer({ sockPath, registry })
105
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
94
106
  cleanup.push(() => server.close())
95
107
 
96
108
  const overview = (await callDaemon(sockPath, {
@@ -111,15 +123,15 @@ describe('daemon-socket', () => {
111
123
  it('returns an error envelope for unknown spawn-ids', async () => {
112
124
  const registry = new SpawnRegistry()
113
125
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
114
- const server = await startDaemonSocketServer({ sockPath, registry })
126
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
115
127
  cleanup.push(() => server.close())
116
128
 
117
129
  await expect(
118
130
  callDaemon(sockPath, { spawnId: 'unknown', method: 'getRoomHistory', params: {} }),
119
- ).rejects.toThrow(/unknown spawn/i)
131
+ ).rejects.toThrow(/binding not owned by caller/)
120
132
  })
121
133
 
122
- it('routes getChannelMembers and getChannelInfo', async () => {
134
+ it('routes getChannelMembers and getRoomInfo', async () => {
123
135
  const registry = new SpawnRegistry()
124
136
  const spawnId = registry.register({
125
137
  agentName: 'a',
@@ -127,13 +139,13 @@ describe('daemon-socket', () => {
127
139
  provider: defaultProvider,
128
140
  })
129
141
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
130
- const server = await startDaemonSocketServer({ sockPath, registry })
142
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
131
143
  cleanup.push(() => server.close())
132
144
 
133
145
  const members = await callDaemon(sockPath, { spawnId, method: 'getChannelMembers', params: {} })
134
146
  expect(members).toEqual([{ id: '@alice:hs', name: 'alice', is_agent: false }])
135
147
 
136
- const info = await callDaemon(sockPath, { spawnId, method: 'getChannelInfo', params: {} })
148
+ const info = await callDaemon(sockPath, { spawnId, method: 'getRoomInfo', params: {} })
137
149
  expect(info).toEqual({ id: '!r:hs', name: 'general', transport: 'matrix' })
138
150
  })
139
151
 
@@ -143,28 +155,28 @@ describe('daemon-socket', () => {
143
155
  messages: [{ id: 'A1', sender: 'alice', text: 'from A', timestamp: 'T', is_agent: false }],
144
156
  has_more: false,
145
157
  }),
146
- getChannelInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
158
+ getRoomInfo: async () => ({ id: '!a:hs', name: 'room-A', transport: 'matrix' }),
147
159
  })
148
160
  const providerB = fakeProvider({
149
161
  getRoomHistory: async () => ({
150
162
  messages: [{ id: 'B1', sender: 'bob', text: 'from B', timestamp: 'T', is_agent: false }],
151
163
  has_more: false,
152
164
  }),
153
- getChannelInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
165
+ getRoomInfo: async () => ({ id: '!b:hs', name: 'room-B', transport: 'matrix' }),
154
166
  })
155
167
  const registry = new SpawnRegistry()
156
168
  const spawnA = registry.register({
157
- agentName: 'architect',
169
+ agentName: 'a',
158
170
  threadRef: { channelId: '!a:hs', threadId: '!a:hs' },
159
171
  provider: providerA,
160
172
  })
161
173
  const spawnB = registry.register({
162
- agentName: 'product-owner',
174
+ agentName: 'a',
163
175
  threadRef: { channelId: '!b:hs', threadId: '!b:hs' },
164
176
  provider: providerB,
165
177
  })
166
178
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
167
- const server = await startDaemonSocketServer({ sockPath, registry })
179
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
168
180
  cleanup.push(() => server.close())
169
181
 
170
182
  const [resA, resB] = await Promise.all([
@@ -175,7 +187,7 @@ describe('daemon-socket', () => {
175
187
  expect((resA as { messages: Array<{ id: string }> }).messages[0].id).toBe('A1')
176
188
  expect((resB as { messages: Array<{ id: string }> }).messages[0].id).toBe('B1')
177
189
 
178
- const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getChannelInfo', params: {} })
190
+ const infoB = await callDaemon(sockPath, { spawnId: spawnB, method: 'getRoomInfo', params: {} })
179
191
  expect((infoB as { id: string }).id).toBe('!b:hs')
180
192
  })
181
193
 
@@ -199,12 +211,12 @@ describe('daemon-socket', () => {
199
211
  provider: providerA,
200
212
  })
201
213
  const spawnB = registry.register({
202
- agentName: 'b',
214
+ agentName: 'a',
203
215
  threadRef: { channelId: 'b', threadId: 'b' },
204
216
  provider: providerB,
205
217
  })
206
218
  const sockPath = join(tmpdir(), `zooid-test-${randomUUID()}.sock`)
207
- const server = await startDaemonSocketServer({ sockPath, registry })
219
+ const server = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
208
220
  cleanup.push(() => server.close())
209
221
 
210
222
  for (let i = 0; i < 20; i++) {
@@ -218,4 +230,152 @@ describe('daemon-socket', () => {
218
230
  expect(res.messages[0].id).toBe(expected)
219
231
  }
220
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
+ })
221
317
  })
318
+
319
+ describe('close()', () => {
320
+ it('does not hang on a connected client that never disconnects', async () => {
321
+ // The context-mcp servers spawned beside each agent outlive
322
+ // AcpClient.stop() (it SIGTERMs the agent and doesn't wait; these are its
323
+ // grandchildren). net.Server.close() waits for every open connection and,
324
+ // unlike http.Server, never drops idle ones — so before this was fixed,
325
+ // `zooid dev` hung on shutdown for as long as those processes lived.
326
+ const sockPath = join(tmpdir(), `zooid-close-${randomUUID()}.sock`)
327
+ const registry = new SpawnRegistry()
328
+ const handle = await startDaemonSocketServer({ sockPath, registry, agentName: 'a' })
329
+
330
+ const client = createConnection(sockPath)
331
+ await new Promise<void>((resolve, reject) => {
332
+ client.once('connect', () => resolve())
333
+ client.once('error', reject)
334
+ })
335
+
336
+ const closed = await Promise.race([
337
+ handle.close().then(() => 'closed' as const),
338
+ new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2000)),
339
+ ])
340
+ client.destroy()
341
+ expect(closed).toBe('closed')
342
+ })
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'