zooid 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.d.ts CHANGED
@@ -16,13 +16,13 @@ interface BuildAcpRegistryOptions {
16
16
  agentsDir?: string;
17
17
  /**
18
18
  * Per-spawn binding store for the zooid-context MCP server. When set
19
- * together with `daemonSockPath`, agents bound to a transport that owns
19
+ * together with `daemonSockPaths`, agents bound to a transport that owns
20
20
  * conversation context get a `contextSpawn` factory threaded into their
21
21
  * AcpClient so `session/new mcpServers` includes the zooid-context entry.
22
22
  */
23
23
  contextSpawnRegistry?: SpawnRegistry;
24
- /** Path to the daemon's context Unix socket. Passed to the MCP server bin. */
25
- daemonSockPath?: string;
24
+ /** Agent → host path to that agent's context Unix socket. */
25
+ daemonSockPaths?: Record<string, string | undefined>;
26
26
  /**
27
27
  * Directory containing zooid.yaml. Required when any agent uses the
28
28
  * workspace auto-mount (the default under `runtime: docker | podman`):
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildAcpRegistry
4
- } from "./chunk-YZ4IO5MR.js";
4
+ } from "./chunk-R5S26T7B.js";
5
5
  export {
6
6
  buildAcpRegistry
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "zooid": {
28
- "webVersion": "0.11.0"
28
+ "webVersion": "0.12.0"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=22"
@@ -35,6 +35,7 @@
35
35
  "@inquirer/prompts": "^8.4.3",
36
36
  "cac": "^6.7.14",
37
37
  "chalk": "^5.4.1",
38
+ "croner": "^10.0.1",
38
39
  "hono": "^4.6.0",
39
40
  "listr2": "^8.2.5",
40
41
  "marked": "^18.0.4",
@@ -42,13 +43,14 @@
42
43
  "tar": "^7.5.16",
43
44
  "web-push": "^3.6.7",
44
45
  "yaml": "^2.5.0",
45
- "@zooid/acp-client": "^0.13.0",
46
- "@zooid/transport-matrix": "^0.13.0",
47
- "@zooid/context-mcp": "^0.13.0",
48
- "@zooid/core": "^0.13.0",
49
- "@zooid/runtime-local": "^0.13.0",
50
- "@zooid/runtime-docker": "^0.13.0",
51
- "@zooid/transport-http": "^0.13.0"
46
+ "@zooid/acp-client": "^0.14.0",
47
+ "@zooid/core": "^0.14.0",
48
+ "@zooid/pi-extension": "^0.13.1",
49
+ "@zooid/runtime-docker": "^0.14.0",
50
+ "@zooid/context-mcp": "^0.14.0",
51
+ "@zooid/runtime-local": "^0.14.0",
52
+ "@zooid/transport-matrix": "^0.14.0",
53
+ "@zooid/transport-http": "^0.14.0"
52
54
  },
53
55
  "devDependencies": {
54
56
  "@agentclientprotocol/sdk": "^0.21.0",
@@ -44,7 +44,7 @@ describe('buildAcpRegistry — context provider wiring', () => {
44
44
  const registry = buildAcpRegistry(matrixCfg, {
45
45
  approvals: { register: vi.fn(), on: vi.fn() } as never,
46
46
  contextSpawnRegistry: {} as never,
47
- daemonSockPath: '/tmp/zooid-test.sock',
47
+ daemonSockPaths: { architect: '/tmp/zooid-test.sock' },
48
48
  })
49
49
  expect(registry.hasContextSpawn('architect')).toBe(true)
50
50
  })
@@ -59,7 +59,7 @@ describe('buildAcpRegistry — context provider wiring', () => {
59
59
  const registry = buildAcpRegistry(httpCfg, {
60
60
  approvals: { register: vi.fn(), on: vi.fn() } as never,
61
61
  contextSpawnRegistry: {} as never,
62
- daemonSockPath: '/tmp/zooid-test.sock',
62
+ daemonSockPaths: { architect: '/tmp/zooid-test.sock' },
63
63
  })
64
64
  expect(registry.hasContextSpawn('architect')).toBe(false)
65
65
  })
@@ -77,7 +77,7 @@ describe('buildAcpRegistry — context provider in a container runtime', () => {
77
77
  const opts = {
78
78
  approvals: { register: vi.fn(), on: vi.fn() } as never,
79
79
  contextSpawnRegistry: { register: vi.fn(() => 'spawn-1') } as never,
80
- daemonSockPath: '/home/ubuntu/hq/data/run/context.sock',
80
+ daemonSockPaths: { architect: '/home/ubuntu/hq/data/run/context.sock' },
81
81
  daemonHome: '/home/ubuntu',
82
82
  }
83
83
 
@@ -14,7 +14,12 @@ import {
14
14
  type TransportContextProvider,
15
15
  } from '@zooid/core'
16
16
  import { MatrixClient, MatrixContextProvider } from '@zooid/transport-matrix'
17
- import { SpawnRegistry, buildContextServerSpec, contextContainerMounts } from '@zooid/context-mcp'
17
+ import {
18
+ CONTEXT_CONTAINER_SOCK,
19
+ SpawnRegistry,
20
+ buildContextServerSpec,
21
+ contextContainerMounts,
22
+ } from '@zooid/context-mcp'
18
23
  import { PRESETS, type PresetMount, type PresetName } from '@zooid/acp-client'
19
24
 
20
25
  export interface BuildAcpRegistryOptions {
@@ -32,13 +37,13 @@ export interface BuildAcpRegistryOptions {
32
37
  agentsDir?: string
33
38
  /**
34
39
  * Per-spawn binding store for the zooid-context MCP server. When set
35
- * together with `daemonSockPath`, agents bound to a transport that owns
40
+ * together with `daemonSockPaths`, agents bound to a transport that owns
36
41
  * conversation context get a `contextSpawn` factory threaded into their
37
42
  * AcpClient so `session/new mcpServers` includes the zooid-context entry.
38
43
  */
39
44
  contextSpawnRegistry?: SpawnRegistry
40
- /** Path to the daemon's context Unix socket. Passed to the MCP server bin. */
41
- daemonSockPath?: string
45
+ /** Agent → host path to that agent's context Unix socket. */
46
+ daemonSockPaths?: Record<string, string | undefined>
42
47
  /**
43
48
  * Directory containing zooid.yaml. Required when any agent uses the
44
49
  * workspace auto-mount (the default under `runtime: docker | podman`):
@@ -78,10 +83,7 @@ function presetOf(agent: AgentConfig): PresetName | undefined {
78
83
  return undefined
79
84
  }
80
85
 
81
- function resolveAgentImage(
82
- agent: AgentConfig,
83
- cfg: ZooidConfig,
84
- ): ResolvedImage {
86
+ function resolveAgentImage(agent: AgentConfig, cfg: ZooidConfig): ResolvedImage {
85
87
  if (agent.container?.image) return { image: agent.container.image, source: 'agent' }
86
88
  if (cfg.container?.image) return { image: cfg.container.image, source: 'workforce' }
87
89
  const preset = presetOf(agent)
@@ -183,9 +185,7 @@ function composeAgentMounts(
183
185
  composed.push(out)
184
186
  }
185
187
 
186
- const mkdirs: string[] = composed
187
- .filter((m) => m.create)
188
- .map((m) => m.host)
188
+ const mkdirs: string[] = composed.filter((m) => m.create).map((m) => m.host)
189
189
 
190
190
  return {
191
191
  mounts: composed,
@@ -227,8 +227,9 @@ export function buildAcpRegistry(
227
227
  }
228
228
  }
229
229
  if (missing.length > 0) {
230
- const lines = missing.map(({ name, preset }) =>
231
- ` - ${name}${preset ? ` (preset: ${preset})` : ''} — no preset-default image`,
230
+ const lines = missing.map(
231
+ ({ name, preset }) =>
232
+ ` - ${name}${preset ? ` (preset: ${preset})` : ''} — no preset-default image`,
232
233
  )
233
234
  throw new Error(
234
235
  `runtime: ${cfg.runtime} requires a container image for each agent. Unresolved:\n` +
@@ -260,9 +261,10 @@ export function buildAcpRegistry(
260
261
  mkdirByAgent[name] = composed.mkdirs
261
262
  cwdByAgent[name] = composed.cwd
262
263
  if (resolvedImage && cfg.runtime !== 'local') {
263
- const mountSummary = composed.mounts.length === 0
264
- ? 'mounts=[]'
265
- : `mounts=[${composed.mounts.map((m) => m.id ?? m.target).join(',')}]`
264
+ const mountSummary =
265
+ composed.mounts.length === 0
266
+ ? 'mounts=[]'
267
+ : `mounts=[${composed.mounts.map((m) => m.id ?? m.target).join(',')}]`
266
268
  log(
267
269
  `[zooid] agent ${name.padEnd(12)} image=${resolvedImage} source=${source} ${mountSummary}`,
268
270
  )
@@ -271,16 +273,33 @@ export function buildAcpRegistry(
271
273
 
272
274
  const contextSpawns = buildContextSpawns(cfg, opts)
273
275
 
276
+ // Extensions run in the agent process (rather than an MCP child), so all
277
+ // context-enabled agents inherit the daemon socket address directly.
278
+ if (contextSpawns) {
279
+ for (const name of Object.keys(cfg.agents)) {
280
+ if (!contextSpawns[name]) continue
281
+ const agentSock = opts.daemonSockPaths?.[name]
282
+ if (!agentSock) continue
283
+ env[name] = {
284
+ ZOOID_DAEMON_SOCK:
285
+ cfg.runtime === 'local' ? agentSock : CONTEXT_CONTAINER_SOCK,
286
+ ...env[name],
287
+ }
288
+ }
289
+ }
290
+
274
291
  // Container runtimes: opencode spawns the zooid-context MCP subprocess INSIDE
275
292
  // its container, so the daemon socket + the (self-contained) bin must be
276
293
  // bind-mounted in. Local runtime needs neither — the host spec resolves
277
294
  // directly. Only agents that actually got a context factory get the mounts.
278
- if (cfg.runtime !== 'local' && opts.daemonSockPath && contextSpawns) {
295
+ if (cfg.runtime !== 'local' && contextSpawns) {
279
296
  for (const name of Object.keys(cfg.agents)) {
280
297
  if (!contextSpawns[name]) continue
298
+ const agentSock = opts.daemonSockPaths?.[name]
299
+ if (!agentSock) continue
281
300
  mountsByAgent[name] = [
282
301
  ...(mountsByAgent[name] ?? []),
283
- ...contextContainerMounts({ sockPath: opts.daemonSockPath }),
302
+ ...contextContainerMounts({ sockPath: agentSock }),
284
303
  ]
285
304
  }
286
305
  }
@@ -297,6 +316,8 @@ export function buildAcpRegistry(
297
316
  onTap: opts.onTap,
298
317
  agentsDir: opts.agentsDir,
299
318
  contextSpawns,
319
+ onSessionEstablished: (agentName, sessionKey, sessionId) =>
320
+ opts.contextSpawnRegistry?.linkSession(agentName, sessionKey, sessionId),
300
321
  })
301
322
  }
302
323
 
@@ -304,9 +325,8 @@ function buildContextSpawns(
304
325
  cfg: ZooidConfig,
305
326
  opts: BuildAcpRegistryOptions,
306
327
  ): Record<string, ContextSpawnFactory | undefined> | undefined {
307
- if (!opts.contextSpawnRegistry || !opts.daemonSockPath) return undefined
328
+ if (!opts.contextSpawnRegistry || !opts.daemonSockPaths) return undefined
308
329
  const registry = opts.contextSpawnRegistry
309
- const sockPath = opts.daemonSockPath
310
330
 
311
331
  const matrixClients = new Map<string, MatrixClient>()
312
332
  const agentBots = new Map<string, string>()
@@ -323,18 +343,23 @@ function buildContextSpawns(
323
343
 
324
344
  const result: Record<string, ContextSpawnFactory | undefined> = {}
325
345
  for (const [name, agent] of Object.entries(cfg.agents)) {
326
- if (agent.matrix && matrixClients.has(agent.matrix.transport)) {
346
+ const sockPath = opts.daemonSockPaths[name]
347
+ if (agent.matrix && matrixClients.has(agent.matrix.transport) && sockPath) {
327
348
  const client = matrixClients.get(agent.matrix.transport)!
328
349
  const provider: TransportContextProvider = new MatrixContextProvider({
329
350
  client,
330
351
  asUserId: agent.matrix.user_id,
331
352
  agentBots,
353
+ // Same array BotPool.bootstrap rewrites `.alias` on in place — reads
354
+ // through this after bootstrap see canonical room IDs.
355
+ rooms: agent.matrix.rooms,
332
356
  })
333
- result[name] = async (threadId: string, channelId?: string) => {
357
+ result[name] = async (threadId: string, channelId?: string, sessionKey?: string) => {
334
358
  const spawnId = registry.register({
335
359
  agentName: name,
336
360
  threadRef: { channelId: channelId ?? threadId, threadId },
337
361
  provider,
362
+ sessionKey: sessionKey ?? threadId,
338
363
  })
339
364
  return buildContextServerSpec({
340
365
  spawnId,
@@ -349,13 +374,31 @@ function buildContextSpawns(
349
374
  return result
350
375
  }
351
376
 
377
+ /** Agents on configured Matrix transports get a context listener and binding. */
378
+ export function contextEligibleAgents(cfg: ZooidConfig): string[] {
379
+ const matrixTransports = new Set(
380
+ Object.entries(cfg.transports)
381
+ .filter(([, transport]) => transport.type === 'matrix')
382
+ .map(([name]) => name),
383
+ )
384
+ return Object.entries(cfg.agents)
385
+ .filter(([, agent]) => agent.matrix && matrixTransports.has(agent.matrix.transport))
386
+ .map(([name]) => name)
387
+ }
388
+
352
389
  function defaultRuntimeFor(cfg: ZooidConfig): AcpRuntime {
353
390
  if (cfg.runtime === 'local') return new LocalAcpRuntime()
354
391
  if (cfg.runtime === 'docker') {
355
- return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'docker' })
392
+ return new DockerAcpRuntime({
393
+ defaultImage: cfg.container?.image,
394
+ engine: 'docker',
395
+ })
356
396
  }
357
397
  if (cfg.runtime === 'podman') {
358
- return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'podman' })
398
+ return new DockerAcpRuntime({
399
+ defaultImage: cfg.container?.image,
400
+ engine: 'podman',
401
+ })
359
402
  }
360
403
  throw new Error(`unsupported runtime: ${cfg.runtime}`)
361
404
  }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { DeliveryCache } from './delivery-cache.js'
3
+
4
+ describe('DeliveryCache', () => {
5
+ beforeEach(() => vi.useFakeTimers())
6
+ afterEach(() => vi.useRealTimers())
7
+
8
+ it('accepts an id once and rejects the replay', () => {
9
+ const c = new DeliveryCache(60_000)
10
+ expect(c.seen('abc')).toBe(false)
11
+ expect(c.seen('abc')).toBe(true)
12
+ })
13
+
14
+ it('treats ids from different triggers as distinct', () => {
15
+ const c = new DeliveryCache(60_000)
16
+ expect(c.seen('t1:abc')).toBe(false)
17
+ expect(c.seen('t2:abc')).toBe(false)
18
+ })
19
+
20
+ it('forgets an id after the TTL, so the cache cannot grow without bound', () => {
21
+ const c = new DeliveryCache(60_000)
22
+ c.seen('abc')
23
+ vi.advanceTimersByTime(61_000)
24
+ expect(c.seen('abc')).toBe(false)
25
+ })
26
+
27
+ it('evicts expired entries rather than retaining them forever', () => {
28
+ const c = new DeliveryCache(1_000)
29
+ for (let i = 0; i < 1000; i++) c.seen(`id-${i}`)
30
+ vi.advanceTimersByTime(2_000)
31
+ c.seen('trigger-eviction')
32
+ expect(c.size).toBeLessThan(10)
33
+ })
34
+ })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * TTL-bounded replay defence for webhook delivery ids. GitHub sends no
3
+ * timestamp, so freshness-window rejection (used for Stripe/Slack in
4
+ * `webhook-verify.ts`) is not available — dedupe on delivery id instead.
5
+ * Keys are namespaced by trigger name (e.g. `t1:abc`) so two triggers never
6
+ * collide on the same upstream id.
7
+ */
8
+ export class DeliveryCache {
9
+ private readonly ttlMs: number
10
+ private readonly expiryById = new Map<string, number>()
11
+
12
+ constructor(ttlMs: number) {
13
+ this.ttlMs = ttlMs
14
+ }
15
+
16
+ get size(): number {
17
+ return this.expiryById.size
18
+ }
19
+
20
+ /** Returns true if `id` was already seen (and still within its TTL). */
21
+ seen(id: string): boolean {
22
+ this.evictExpired()
23
+ const now = Date.now()
24
+ const expiry = this.expiryById.get(id)
25
+ if (expiry !== undefined && expiry > now) return true
26
+ this.expiryById.set(id, now + this.ttlMs)
27
+ return false
28
+ }
29
+
30
+ private evictExpired(): void {
31
+ const now = Date.now()
32
+ for (const [id, expiry] of this.expiryById) {
33
+ if (expiry <= now) this.expiryById.delete(id)
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,43 @@
1
+ import { pathToFileURL } from 'node:url'
2
+ import type { TriggerConfig } from '@zooid/core'
3
+ import type { CustomVerifier } from './webhook-verify.js'
4
+
5
+ /**
6
+ * Import the `verify:` module of every `provider: custom` trigger, keyed by
7
+ * trigger name.
8
+ *
9
+ * Loaded once at daemon start rather than per delivery: a bad path or a
10
+ * module that exports the wrong thing is a configuration error, and it
11
+ * should surface when the operator starts the daemon, not silently as a 401
12
+ * the first time the service fires. Config has already resolved the path to
13
+ * an absolute one against the zooid.yaml directory.
14
+ */
15
+ export async function loadCustomVerifiers(
16
+ triggers: Record<string, TriggerConfig>,
17
+ ): Promise<Record<string, CustomVerifier>> {
18
+ const out: Record<string, CustomVerifier> = {}
19
+ for (const [name, trigger] of Object.entries(triggers)) {
20
+ const path = trigger.webhook?.provider === 'custom' ? trigger.webhook.verify : undefined
21
+ if (!path) continue
22
+
23
+ let mod: Record<string, unknown>
24
+ try {
25
+ mod = (await import(pathToFileURL(path).href)) as Record<string, unknown>
26
+ } catch (err) {
27
+ throw new Error(
28
+ `triggers.${name}.webhook.verify: cannot load ${path} — ${(err as Error).message}`,
29
+ )
30
+ }
31
+ // `export default` is the documented shape; a named `verify` export is
32
+ // accepted so a module can hold more than one provider's verifier.
33
+ const fn = mod.default ?? mod.verify
34
+ if (typeof fn !== 'function') {
35
+ throw new Error(
36
+ `triggers.${name}.webhook.verify: ${path} must export a function as \`default\` ` +
37
+ `(or as \`verify\`), got ${typeof fn}`,
38
+ )
39
+ }
40
+ out[name] = fn as CustomVerifier
41
+ }
42
+ return out
43
+ }