zooid 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.
Files changed (48) hide show
  1. package/README.md +8 -1
  2. package/dist/bin.js +923 -184
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-3Q4BPAZD.js → chunk-R5S26T7B.js} +20576 -514
  5. package/dist/chunk-R5S26T7B.js.map +1 -0
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +1 -1
  8. package/package.json +13 -9
  9. package/src/bin.test.ts +63 -0
  10. package/src/bin.ts +52 -3
  11. package/src/bootstrap/configs.test.ts +7 -0
  12. package/src/bootstrap/configs.ts +14 -0
  13. package/src/build-registry.context.test.ts +3 -3
  14. package/src/build-registry.ts +67 -24
  15. package/src/commands/dev.ts +37 -7
  16. package/src/commands/status.test.ts +15 -0
  17. package/src/commands/status.ts +27 -2
  18. package/src/daemon/delivery-cache.test.ts +34 -0
  19. package/src/daemon/delivery-cache.ts +36 -0
  20. package/src/daemon/load-custom-verifiers.ts +43 -0
  21. package/src/daemon/start-daemon.ts +177 -39
  22. package/src/daemon/task-journal.test.ts +19 -0
  23. package/src/daemon/task-journal.ts +32 -0
  24. package/src/daemon/trigger-rooms.test.ts +60 -0
  25. package/src/daemon/trigger-rooms.ts +0 -0
  26. package/src/daemon/trigger-runner.test.ts +71 -0
  27. package/src/daemon/trigger-runner.ts +41 -0
  28. package/src/daemon/trigger-scheduler.ts +55 -0
  29. package/src/daemon/webhook-routes.test.ts +74 -0
  30. package/src/daemon/webhook-routes.ts +202 -0
  31. package/src/daemon/webhook-verify.test.ts +155 -0
  32. package/src/daemon/webhook-verify.ts +155 -0
  33. package/src/pi-extension-install.test.ts +65 -0
  34. package/src/pi-extension-install.ts +56 -0
  35. package/src/push-gateway/gateway.test.ts +150 -0
  36. package/src/push-gateway/gateway.ts +78 -0
  37. package/src/push-gateway/index.ts +17 -0
  38. package/src/push-gateway/payload.test.ts +88 -0
  39. package/src/push-gateway/payload.ts +39 -0
  40. package/src/push-gateway/types.ts +37 -0
  41. package/src/push-gateway/vapid.test.ts +45 -0
  42. package/src/push-gateway/vapid.ts +34 -0
  43. package/src/services/tuwunel.ts +21 -2
  44. package/src/version.test.ts +67 -0
  45. package/src/version.ts +30 -0
  46. package/src/web/static.test.ts +22 -0
  47. package/src/web/static.ts +9 -1
  48. package/dist/chunk-3Q4BPAZD.js.map +0 -1
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-3Q4BPAZD.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.12.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.10.0"
28
+ "webVersion": "0.12.0"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=22"
@@ -35,24 +35,28 @@
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",
41
42
  "sanitize-html": "^2.17.4",
42
43
  "tar": "^7.5.16",
44
+ "web-push": "^3.6.7",
43
45
  "yaml": "^2.5.0",
44
- "@zooid/context-mcp": "^0.12.0",
45
- "@zooid/acp-client": "^0.12.0",
46
- "@zooid/core": "^0.12.0",
47
- "@zooid/runtime-docker": "^0.12.0",
48
- "@zooid/transport-http": "^0.12.0",
49
- "@zooid/runtime-local": "^0.12.0",
50
- "@zooid/transport-matrix": "^0.12.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"
51
54
  },
52
55
  "devDependencies": {
53
56
  "@agentclientprotocol/sdk": "^0.21.0",
54
57
  "@playwright/test": "^1.49.0",
55
58
  "@types/node": "^22.0.0",
59
+ "@types/web-push": "^3.6.4",
56
60
  "release-it": "^19.2.4",
57
61
  "tsup": "^8.5.1",
58
62
  "tsx": "^4.19.0",
@@ -0,0 +1,63 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const HERE = dirname(fileURLToPath(import.meta.url))
7
+ const TSX = join(HERE, '..', 'node_modules', '.bin', 'tsx')
8
+ const BIN = join(HERE, 'bin.ts')
9
+
10
+ function runCli(args: string[]) {
11
+ const result = spawnSync(TSX, [BIN, ...args], { encoding: 'utf8' })
12
+ return { stdout: result.stdout, stderr: result.stderr, status: result.status }
13
+ }
14
+
15
+ describe('zooid help', () => {
16
+ it('lists every registered command with no args', () => {
17
+ const { stdout, status } = runCli([])
18
+ expect(status).toBe(1)
19
+ for (const name of ['start', 'dev', 'logs', 'status', 'init', 'help']) {
20
+ expect(stdout).toContain(name)
21
+ }
22
+ })
23
+
24
+ it('--help prints the same command list and exits 0', () => {
25
+ const { stdout, status } = runCli(['--help'])
26
+ expect(status).toBe(0)
27
+ expect(stdout).toContain('Commands:')
28
+ expect(stdout).toContain('init [dir]')
29
+ })
30
+
31
+ it('`zooid help` mirrors `zooid --help`', () => {
32
+ const { stdout, status } = runCli(['help'])
33
+ expect(status).toBe(0)
34
+ expect(stdout).toContain('Commands:')
35
+ })
36
+
37
+ it('`zooid help <command>` shows that command\'s own help', () => {
38
+ const { stdout, status } = runCli(['help', 'init'])
39
+ expect(status).toBe(0)
40
+ expect(stdout).toContain('$ zooid init [dir]')
41
+ expect(stdout).toContain('--preset <name>')
42
+ })
43
+
44
+ it('`zooid <command> --help` still works directly', () => {
45
+ const { stdout, status } = runCli(['logs', '--help'])
46
+ expect(status).toBe(0)
47
+ expect(stdout).toContain('$ zooid logs [source]')
48
+ })
49
+
50
+ it('rejects an unknown subcommand of help', () => {
51
+ const { stdout, stderr, status } = runCli(['help', 'bogus'])
52
+ expect(status).toBe(1)
53
+ expect(stderr).toContain('Unknown command: bogus')
54
+ expect(stdout).toContain('Commands:')
55
+ })
56
+
57
+ it('rejects an unknown top-level command instead of exiting silently', () => {
58
+ const { stdout, stderr, status } = runCli(['frobnicate'])
59
+ expect(status).toBe(1)
60
+ expect(stderr).toContain('Unknown command: frobnicate')
61
+ expect(stdout).toContain('Commands:')
62
+ })
63
+ })
package/src/bin.ts CHANGED
@@ -6,6 +6,7 @@ import { resolveOptions } from './commands/init/prompts.js'
6
6
  import { runLogs } from './commands/logs.js'
7
7
  import { runStart } from './commands/start.js'
8
8
  import { runStatus } from './commands/status.js'
9
+ import { CLI_VERSION } from './version.js'
9
10
 
10
11
  const cli = cac('zooid')
11
12
 
@@ -15,6 +16,8 @@ cli
15
16
  .option('--runtime <local|docker|podman>', 'Agent runtime')
16
17
  .option('--image <ref>', 'Agent container image')
17
18
  .option('--print-token', 'Print a 32-byte hex token and exit')
19
+ .example('$ zooid start --data ./data')
20
+ .example('$ zooid start --runtime docker --image ghcr.io/zooid-ai/agent:latest')
18
21
  .action(async (flags) => {
19
22
  await runStart({
20
23
  dataDir: flags.data,
@@ -35,6 +38,8 @@ cli
35
38
  '--watch-web [path]',
36
39
  'Run vite build --watch on @zooid/web. Path defaults to sibling ../zooid-clients/packages/web.',
37
40
  )
41
+ .example('$ zooid dev')
42
+ .example('$ zooid dev --engine podman --ui-port 5174')
38
43
  .action(async (flags) => {
39
44
  await runDev({
40
45
  dataDir: flags.data,
@@ -56,6 +61,9 @@ cli
56
61
  .option('--turn <id>', 'Filter ACP taps to a single turn id')
57
62
  .option('-f, --follow', 'Tail the file (not yet implemented)')
58
63
  .option('--keep <n>', 'For `logs prune`: days to retain', { default: 14 })
64
+ .example('$ zooid logs daemon')
65
+ .example('$ zooid logs agent-support.acp --turn 3f9c1a --day 2026-09-06')
66
+ .example('$ zooid logs prune --keep 7')
59
67
  .action(async (source, flags) => {
60
68
  if (source === 'prune') {
61
69
  await runLogs({
@@ -78,6 +86,7 @@ cli
78
86
  .command('status', 'Print Tuwunel + daemon health')
79
87
  .option('--data <dir>', 'Persistent data root dir', { default: './data' })
80
88
  .option('--port <n>', 'Tuwunel host port (defaults to zooid.yaml)')
89
+ .example('$ zooid status')
81
90
  .action(async (flags) => {
82
91
  await runStatus({
83
92
  dataDir: flags.data,
@@ -95,6 +104,9 @@ cli
95
104
  .option('--force', 'Allow scaffolding into a non-empty directory')
96
105
  .option('--overwrite', 'With --force, overwrite existing files')
97
106
  .option('--no-interactive', 'Disable prompts; require all flags up front')
107
+ .example('$ zooid init')
108
+ .example('$ zooid init my-workforce --preset opencode --provider anthropic')
109
+ .example('$ zooid init --preset claude --auth api-key --api-key sk-... --no-interactive')
98
110
  .action(async (dir: string | undefined, flags) => {
99
111
  const resolved = await resolveOptions({
100
112
  dir: dir ?? process.cwd(),
@@ -110,6 +122,43 @@ cli
110
122
  await runInit(resolved)
111
123
  })
112
124
 
113
- cli.help()
114
- cli.version('0.0.1')
115
- cli.parse()
125
+ cli
126
+ .command('help [command]', 'Display help for zooid, or for a specific command')
127
+ .example('$ zooid help')
128
+ .example('$ zooid help init')
129
+ .action((commandName?: string) => {
130
+ if (!commandName) {
131
+ cli.globalCommand.outputHelp()
132
+ return
133
+ }
134
+ const target = cli.commands.find((c) => c.isMatched(commandName))
135
+ if (!target) {
136
+ console.error(`Unknown command: ${commandName}\n`)
137
+ cli.globalCommand.outputHelp()
138
+ process.exitCode = 1
139
+ return
140
+ }
141
+ target.outputHelp()
142
+ })
143
+
144
+ cli.help((sections) => {
145
+ sections.push({
146
+ title: 'Docs',
147
+ body: ' https://zooid.dev/docs',
148
+ })
149
+ return sections
150
+ })
151
+ cli.version(CLI_VERSION)
152
+
153
+ cli.on('command:*', () => {
154
+ console.error(`Unknown command: ${cli.args.join(' ')}\n`)
155
+ cli.outputHelp()
156
+ process.exitCode = 1
157
+ })
158
+
159
+ if (process.argv.slice(2).length === 0) {
160
+ cli.outputHelp()
161
+ process.exitCode = 1
162
+ } else {
163
+ cli.parse()
164
+ }
@@ -15,6 +15,13 @@ describe('renderTuwunelToml', () => {
15
15
  )
16
16
  expect(toml).toContain('allow_local_presence = true')
17
17
  expect(toml).toContain('port = [8448]')
18
+ // Presence-based push suppression stays OFF — sw.js suppresses per-room
19
+ // on visibility instead, and presence lingers long past a closed tab.
20
+ expect(toml).not.toContain('suppress_push_when_active')
21
+ // DEV ONLY: Tuwunel is in Podman, the push gateway runs on the host, and
22
+ // the default ip_range_denylist (127/8, 10/8, 172.16/12, 192.168/16, ::1)
23
+ // would silently drop every pusher delivery to it.
24
+ expect(toml).toContain('ip_range_denylist = []')
18
25
  })
19
26
 
20
27
  it('honors a non-default server_name', () => {
@@ -26,6 +26,20 @@ export function renderTuwunelToml(opts: TuwunelTomlOpts): string {
26
26
  'allow_local_presence = true',
27
27
  'address = ["0.0.0.0"]',
28
28
  `port = [${TUWUNEL_INTERNAL_PORT}]`,
29
+ // NOT `suppress_push_when_active` ([[ZNC025]]). It reads Matrix presence,
30
+ // which is both too coarse and too slow for this: coarse because it is
31
+ // per-user, so reading room A kills the push for room B; slow because
32
+ // `currently_active` lingers for minutes after the last sync, so closing
33
+ // the tab and waiting for an agent to finish still delivers nothing —
34
+ // exactly the case this feature exists for. `public/sw.js` already does
35
+ // the suppression we actually want, precisely: it drops a push only when
36
+ // a *visible* window is on *that* room.
37
+ // DEV ONLY — disables an SSRF guard. Tuwunel is in Podman and the push
38
+ // gateway runs on the host, so the default ip_range_denylist (127/8,
39
+ // 10/8, 172.16/12, 192.168/16, ::1) silently drops every pusher delivery.
40
+ // Never set on a box: there the gateway is reached at the public
41
+ // hostname through Caddy.
42
+ 'ip_range_denylist = []',
29
43
  '',
30
44
  ].join('\n')
31
45
  }
@@ -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
  }
@@ -248,7 +248,22 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
248
248
  t.output = msg
249
249
  },
250
250
  }))
251
- const app = webStatic({ webRoot, homeserverUrl: homeserver })
251
+ const app = webStatic({
252
+ webRoot,
253
+ homeserverUrl: homeserver,
254
+ ...(ctx.daemon?.vapidPublicKey
255
+ ? {
256
+ // Tuwunel runs in a container; `localhost` here would
257
+ // resolve to the container, not the daemon. Same shorthand
258
+ // as the AS registration url
259
+ // (bootstrap/registration-url.ts). This URL is stored in
260
+ // the pusher and fetched by the homeserver — the browser
261
+ // never requests it.
262
+ pushGatewayUrl: `http://host.docker.internal:${ctx.daemon.port}/_matrix/push/v1/notify`,
263
+ vapidPublicKey: ctx.daemon.vapidPublicKey,
264
+ }
265
+ : {}),
266
+ })
252
267
  ctx.uiServer = serve({ fetch: app.fetch, port: flags.uiPort })
253
268
  },
254
269
  },
@@ -287,13 +302,28 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
287
302
  })
288
303
 
289
304
  if (flags.installSignalHandlers !== false) {
290
- const handler = async (): Promise<void> => {
291
- process.stdout.write(chalk.dim('\nStopping…\n'))
292
- await shutdown()
293
- process.exit(0)
305
+ // buildShutdown is idempotent, so a repeat Ctrl-C used to print again and
306
+ // await the same promise, with no way to give up on a step taking too
307
+ // long. Escalate on repeats instead.
308
+ let interrupts = 0
309
+ const onSignal = (): void => {
310
+ interrupts += 1
311
+ if (interrupts === 1) {
312
+ process.stdout.write(chalk.dim('\nStopping…\n'))
313
+ void shutdown().then(() => process.exit(0))
314
+ return
315
+ }
316
+ if (interrupts === 2) {
317
+ process.stdout.write(
318
+ chalk.dim('Still stopping — press Ctrl-C again to force quit.\n'),
319
+ )
320
+ return
321
+ }
322
+ process.stdout.write(chalk.dim('Forced.\n'))
323
+ process.exit(130)
294
324
  }
295
- process.on('SIGINT', () => void handler())
296
- process.on('SIGTERM', () => void handler())
325
+ process.on('SIGINT', onSignal)
326
+ process.on('SIGTERM', onSignal)
297
327
  }
298
328
 
299
329
  process.stdout.write(
@@ -54,6 +54,21 @@ describe('collectStatus', () => {
54
54
  ])
55
55
  })
56
56
 
57
+ it('reports the VAPID public key when vapid.json exists in the data dir', async () => {
58
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
59
+ writeFileSync(join(dir, 'vapid.json'), JSON.stringify({ publicKey: 'BPk', privateKey: 'priv' }))
60
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
61
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
62
+ expect(s.vapidPublicKey).toBe('BPk')
63
+ })
64
+
65
+ it('omits the VAPID key when no daemon has run yet', async () => {
66
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
67
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
68
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
69
+ expect(s.vapidPublicKey).toBeUndefined()
70
+ })
71
+
57
72
  it('reports daemon down when the AS callback port refuses the connection', async () => {
58
73
  writeFileSync(join(dir, 'zooid.yaml'), yaml)
59
74
  vi.stubGlobal(
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { dirname } from 'node:path'
2
+ import { dirname, join, resolve } from 'node:path'
3
3
  import chalk from 'chalk'
4
4
  import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
5
5
  import { deriveHomeserverShape } from '../bootstrap/derive.js'
6
+ import { VAPID_FILENAME } from '../push-gateway/vapid.js'
6
7
 
7
8
  export interface StatusFlags {
8
9
  cwd?: string
@@ -15,6 +16,24 @@ export interface StatusReport {
15
16
  tuwunel: { status: 'up' | 'down'; url: string }
16
17
  daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
17
18
  agents: { name: string; userId: string; trigger: string }[]
19
+ /** VAPID public key read from `<dataDir>/vapid.json`, when it exists. */
20
+ vapidPublicKey?: string
21
+ }
22
+
23
+ /**
24
+ * Read the daemon's VAPID public key straight off disk. Operators
25
+ * hand-editing a box's config.json need it and can't start a daemon just to
26
+ * ask, so this reads the persisted file rather than starting one.
27
+ */
28
+ export function readVapidPublicKey(dataDir: string): string | undefined {
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(join(dataDir, VAPID_FILENAME), 'utf8')) as {
31
+ publicKey?: string
32
+ }
33
+ return typeof parsed.publicKey === 'string' ? parsed.publicKey : undefined
34
+ } catch {
35
+ return undefined
36
+ }
18
37
  }
19
38
 
20
39
  async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
@@ -29,12 +48,14 @@ async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
29
48
  export async function collectStatus(opts: {
30
49
  cwd: string
31
50
  tuwunelUrl: string
51
+ dataDir?: string
32
52
  }): Promise<StatusReport> {
33
53
  const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
34
54
  const tuwunel: StatusReport['tuwunel'] = {
35
55
  status: tuwunelUp ? 'up' : 'down',
36
56
  url: opts.tuwunelUrl,
37
57
  }
58
+ const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : undefined
38
59
 
39
60
  const found = findConfigFile(opts.cwd)
40
61
  if (!found) {
@@ -42,6 +63,7 @@ export async function collectStatus(opts: {
42
63
  tuwunel,
43
64
  daemon: { status: 'unknown', reason: 'no zooid.yaml' },
44
65
  agents: [],
66
+ ...(vapidPublicKey ? { vapidPublicKey } : {}),
45
67
  }
46
68
  }
47
69
  const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
@@ -67,6 +89,7 @@ export async function collectStatus(opts: {
67
89
  tuwunel,
68
90
  daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
69
91
  agents,
92
+ ...(vapidPublicKey ? { vapidPublicKey } : {}),
70
93
  }
71
94
  }
72
95
 
@@ -89,7 +112,8 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
89
112
  }
90
113
  }
91
114
  const tuwunelUrl = `http://localhost:${port ?? 8448}`
92
- const s = await collectStatus({ cwd, tuwunelUrl })
115
+ const dataDir = resolve(cwd, flags.dataDir ?? './data')
116
+ const s = await collectStatus({ cwd, tuwunelUrl, dataDir })
93
117
  const fmt = (st: 'up' | 'down' | 'unknown'): string =>
94
118
  st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
95
119
  process.stdout.write(
@@ -99,6 +123,7 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
99
123
  ...s.agents.map(
100
124
  (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
101
125
  ),
126
+ ...(s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : []),
102
127
  '',
103
128
  ].join('\n'),
104
129
  )