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.
- package/README.md +8 -1
- package/dist/bin.js +923 -184
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-3Q4BPAZD.js → chunk-R5S26T7B.js} +20576 -514
- package/dist/chunk-R5S26T7B.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/package.json +13 -9
- package/src/bin.test.ts +63 -0
- package/src/bin.ts +52 -3
- package/src/bootstrap/configs.test.ts +7 -0
- package/src/bootstrap/configs.ts +14 -0
- package/src/build-registry.context.test.ts +3 -3
- package/src/build-registry.ts +67 -24
- package/src/commands/dev.ts +37 -7
- package/src/commands/status.test.ts +15 -0
- package/src/commands/status.ts +27 -2
- package/src/daemon/delivery-cache.test.ts +34 -0
- package/src/daemon/delivery-cache.ts +36 -0
- package/src/daemon/load-custom-verifiers.ts +43 -0
- package/src/daemon/start-daemon.ts +177 -39
- package/src/daemon/task-journal.test.ts +19 -0
- package/src/daemon/task-journal.ts +32 -0
- package/src/daemon/trigger-rooms.test.ts +60 -0
- package/src/daemon/trigger-rooms.ts +0 -0
- package/src/daemon/trigger-runner.test.ts +71 -0
- package/src/daemon/trigger-runner.ts +41 -0
- package/src/daemon/trigger-scheduler.ts +55 -0
- package/src/daemon/webhook-routes.test.ts +74 -0
- package/src/daemon/webhook-routes.ts +202 -0
- package/src/daemon/webhook-verify.test.ts +155 -0
- package/src/daemon/webhook-verify.ts +155 -0
- package/src/pi-extension-install.test.ts +65 -0
- package/src/pi-extension-install.ts +56 -0
- package/src/push-gateway/gateway.test.ts +150 -0
- package/src/push-gateway/gateway.ts +78 -0
- package/src/push-gateway/index.ts +17 -0
- package/src/push-gateway/payload.test.ts +88 -0
- package/src/push-gateway/payload.ts +39 -0
- package/src/push-gateway/types.ts +37 -0
- package/src/push-gateway/vapid.test.ts +45 -0
- package/src/push-gateway/vapid.ts +34 -0
- package/src/services/tuwunel.ts +21 -2
- package/src/version.test.ts +67 -0
- package/src/version.ts +30 -0
- package/src/web/static.test.ts +22 -0
- package/src/web/static.ts +9 -1
- 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 `
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "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.
|
|
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/
|
|
45
|
-
"@zooid/
|
|
46
|
-
"@zooid/
|
|
47
|
-
"@zooid/runtime-docker": "^0.
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/runtime-local": "^0.
|
|
50
|
-
"@zooid/transport-matrix": "^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",
|
package/src/bin.test.ts
ADDED
|
@@ -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
|
|
114
|
-
|
|
115
|
-
|
|
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', () => {
|
package/src/bootstrap/configs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
daemonSockPaths: { architect: '/home/ubuntu/hq/data/run/context.sock' },
|
|
81
81
|
daemonHome: '/home/ubuntu',
|
|
82
82
|
}
|
|
83
83
|
|
package/src/build-registry.ts
CHANGED
|
@@ -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 {
|
|
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 `
|
|
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
|
-
/**
|
|
41
|
-
|
|
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(
|
|
231
|
-
|
|
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 =
|
|
264
|
-
|
|
265
|
-
|
|
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' &&
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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
|
}
|
package/src/commands/dev.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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',
|
|
296
|
-
process.on('SIGTERM',
|
|
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(
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
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
|
)
|