zooid 0.6.1 → 0.7.1

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 (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1891 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-SUPTPSN3.js +31434 -0
  6. package/dist/chunk-SUPTPSN3.js.map +1 -0
  7. package/dist/index.d.ts +64 -0
  8. package/dist/index.js +4 -3790
  9. package/dist/index.js.map +1 -0
  10. package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  11. package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  12. package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  13. package/dist/web/assets/index-C2MAIew3.css +1 -0
  14. package/dist/web/assets/index-CqfWXqvO.js +118066 -0
  15. package/dist/web/assets/index-Cz5fEZBc.js +295 -0
  16. package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +43 -22
  20. package/src/bin.ts +115 -0
  21. package/src/bootstrap/admin.test.ts +69 -0
  22. package/src/bootstrap/admin.ts +37 -0
  23. package/src/bootstrap/configs.test.ts +24 -0
  24. package/src/bootstrap/configs.ts +64 -0
  25. package/src/bootstrap/data-layout.test.ts +25 -0
  26. package/src/bootstrap/data-layout.ts +20 -0
  27. package/src/bootstrap/derive.test.ts +74 -0
  28. package/src/bootstrap/derive.ts +40 -0
  29. package/src/bootstrap/paths.test.ts +22 -0
  30. package/src/bootstrap/paths.ts +27 -0
  31. package/src/bootstrap/tokens.test.ts +44 -0
  32. package/src/bootstrap/tokens.ts +43 -0
  33. package/src/build-registry.context.test.ts +61 -0
  34. package/src/build-registry.test.ts +59 -0
  35. package/src/build-registry.ts +343 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/build-registry.zod044.test.ts +261 -0
  38. package/src/commands/dev-cascade.test.ts +38 -0
  39. package/src/commands/dev-cascade.ts +35 -0
  40. package/src/commands/dev-data-layout.test.ts +55 -0
  41. package/src/commands/dev.ts +316 -0
  42. package/src/commands/init/generators.test.ts +97 -0
  43. package/src/commands/init/generators.ts +124 -0
  44. package/src/commands/init/prompts.ts +123 -0
  45. package/src/commands/init/registry.test.ts +33 -0
  46. package/src/commands/init/registry.ts +75 -0
  47. package/src/commands/init/sniff.test.ts +41 -0
  48. package/src/commands/init/sniff.ts +30 -0
  49. package/src/commands/init.test.ts +131 -0
  50. package/src/commands/init.ts +158 -0
  51. package/src/commands/logs.test.ts +105 -0
  52. package/src/commands/logs.ts +108 -0
  53. package/src/commands/start.ts +28 -0
  54. package/src/commands/status.test.ts +73 -0
  55. package/src/commands/status.ts +105 -0
  56. package/src/daemon/start-daemon.test.ts +67 -0
  57. package/src/daemon/start-daemon.ts +270 -0
  58. package/src/index.ts +2 -0
  59. package/src/observability/capture-agent.ts +58 -0
  60. package/src/observability/capture-tuwunel.test.ts +57 -0
  61. package/src/observability/capture-tuwunel.ts +24 -0
  62. package/src/observability/file-sink.test.ts +68 -0
  63. package/src/observability/file-sink.ts +78 -0
  64. package/src/observability/observability.integration.test.ts +162 -0
  65. package/src/observability/paths.test.ts +104 -0
  66. package/src/observability/paths.ts +94 -0
  67. package/src/prepull-images.test.ts +149 -0
  68. package/src/prepull-images.ts +100 -0
  69. package/src/services/tuwunel.test.ts +64 -0
  70. package/src/services/tuwunel.ts +91 -0
  71. package/src/web/resolve.test.ts +50 -0
  72. package/src/web/resolve.ts +31 -0
  73. package/src/web/static.test.ts +51 -0
  74. package/src/web/static.ts +61 -0
  75. package/src/web/watch.ts +103 -0
  76. package/dist/chunk-67ZRMVHO.js +0 -174
  77. package/dist/chunk-AR456MHY.js +0 -29
  78. package/dist/chunk-VBGU2NST.js +0 -139
  79. package/dist/client-4VMFEFDX.js +0 -22
  80. package/dist/config-2KK5GX42.js +0 -27
  81. package/dist/template-T5IB4YWC.js +0 -92
@@ -0,0 +1,73 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { collectStatus } from './status.js'
6
+
7
+ let dir: string
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), 'zooid-status-'))
10
+ })
11
+ afterEach(() => {
12
+ rmSync(dir, { recursive: true, force: true })
13
+ vi.restoreAllMocks()
14
+ })
15
+
16
+ const yaml = [
17
+ 'transports:',
18
+ ' m:',
19
+ ' type: matrix',
20
+ ' homeserver: http://localhost:8448',
21
+ ' as_token: as-x',
22
+ ' hs_token: hs-x',
23
+ ' sender_localpart: zooid',
24
+ ' user_namespace: "@.*:localhost"',
25
+ ' port: 9099',
26
+ 'agents:',
27
+ ' assistant:',
28
+ ' workdir: .',
29
+ ' acp: { preset: claude }',
30
+ ' matrix:',
31
+ ' transport: m',
32
+ ' user_id: "@assistant:localhost"',
33
+ ' rooms: ["!general:localhost"]',
34
+ ' trigger: mention',
35
+ ].join('\n')
36
+
37
+ describe('collectStatus', () => {
38
+ it('reports tuwunel up + daemon up + agents listed', async () => {
39
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
40
+ vi.stubGlobal(
41
+ 'fetch',
42
+ vi.fn(async (url: string) => {
43
+ if (url.includes('/_matrix/client/versions')) {
44
+ return new Response(JSON.stringify({ versions: ['v1.13'] }), { status: 200 })
45
+ }
46
+ return new Response('not found', { status: 404 })
47
+ }),
48
+ )
49
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448' })
50
+ expect(s.tuwunel).toEqual({ status: 'up', url: 'http://localhost:8448' })
51
+ expect(s.daemon).toEqual({ status: 'up', url: 'http://localhost:9099' })
52
+ expect(s.agents).toEqual([
53
+ { name: 'assistant', userId: '@assistant:localhost', trigger: 'mention' },
54
+ ])
55
+ })
56
+
57
+ it('reports daemon down when the AS callback port refuses the connection', async () => {
58
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
59
+ vi.stubGlobal(
60
+ 'fetch',
61
+ vi.fn(async (url: string) => {
62
+ if (url.includes('/_matrix/client/versions')) {
63
+ return new Response(JSON.stringify({ versions: ['v1.13'] }), { status: 200 })
64
+ }
65
+ throw new Error('ECONNREFUSED')
66
+ }),
67
+ )
68
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448' })
69
+ expect(s.tuwunel.status).toBe('up')
70
+ if ('status' in s.daemon) expect(s.daemon.status).toBe('down')
71
+ else throw new Error('expected daemon to have status field')
72
+ })
73
+ })
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import chalk from 'chalk'
4
+ import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
5
+ import { deriveHomeserverShape } from '../bootstrap/derive.js'
6
+
7
+ export interface StatusFlags {
8
+ cwd?: string
9
+ dataDir: string
10
+ /** Tuwunel host port. If omitted, derived from zooid.yaml's matrix transport. */
11
+ port?: number
12
+ }
13
+
14
+ export interface StatusReport {
15
+ tuwunel: { status: 'up' | 'down'; url: string }
16
+ daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
17
+ agents: { name: string; userId: string; trigger: string }[]
18
+ }
19
+
20
+ async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
21
+ try {
22
+ const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
23
+ return r.status > 0
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ export async function collectStatus(opts: {
30
+ cwd: string
31
+ tuwunelUrl: string
32
+ }): Promise<StatusReport> {
33
+ const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
34
+ const tuwunel: StatusReport['tuwunel'] = {
35
+ status: tuwunelUp ? 'up' : 'down',
36
+ url: opts.tuwunelUrl,
37
+ }
38
+
39
+ const found = findConfigFile(opts.cwd)
40
+ if (!found) {
41
+ return {
42
+ tuwunel,
43
+ daemon: { status: 'unknown', reason: 'no zooid.yaml' },
44
+ agents: [],
45
+ }
46
+ }
47
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
48
+ configDir: dirname(found.path),
49
+ })
50
+ const matrixEntry = Object.entries(cfg.transports).find(
51
+ ([, t]) => t.type === 'matrix',
52
+ ) as [string, { port?: number }] | undefined
53
+ const daemonPort = matrixEntry?.[1]?.port ?? 8080
54
+ const daemonUrl = `http://localhost:${daemonPort}`
55
+ const daemonUp = await probe(daemonUrl)
56
+ const agents: StatusReport['agents'] = []
57
+ for (const a of Object.values(cfg.agents)) {
58
+ if (a.matrix) {
59
+ agents.push({
60
+ name: a.name,
61
+ userId: a.matrix.user_id,
62
+ trigger: a.matrix.trigger,
63
+ })
64
+ }
65
+ }
66
+ return {
67
+ tuwunel,
68
+ daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
69
+ agents,
70
+ }
71
+ }
72
+
73
+ export async function runStatus(flags: StatusFlags): Promise<void> {
74
+ const cwd = flags.cwd ?? process.cwd()
75
+ let port = flags.port
76
+ if (port === undefined) {
77
+ const found = findConfigFile(cwd)
78
+ if (found) {
79
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
80
+ configDir: dirname(found.path),
81
+ })
82
+ const matrix = findMatrixTransport(cfg)
83
+ if (matrix) {
84
+ const userIds = Object.values(cfg.agents)
85
+ .filter((a) => a.matrix?.transport === matrix.name)
86
+ .map((a) => a.matrix!.user_id)
87
+ port = deriveHomeserverShape(matrix.transport, userIds).port
88
+ }
89
+ }
90
+ }
91
+ const tuwunelUrl = `http://localhost:${port ?? 8448}`
92
+ const s = await collectStatus({ cwd, tuwunelUrl })
93
+ const fmt = (st: 'up' | 'down' | 'unknown'): string =>
94
+ st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
95
+ process.stdout.write(
96
+ [
97
+ `tuwunel ${fmt(s.tuwunel.status)} ${s.tuwunel.url}`,
98
+ `daemon ${fmt(s.daemon.status)} ${'url' in s.daemon ? s.daemon.url : s.daemon.reason}`,
99
+ ...s.agents.map(
100
+ (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
101
+ ),
102
+ '',
103
+ ].join('\n'),
104
+ )
105
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { startDaemon } from './start-daemon.js'
6
+
7
+ let dir: string
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), 'zooid-startd-'))
10
+ })
11
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
12
+
13
+ describe('startDaemon — http transport (smoke without Matrix)', () => {
14
+ it('starts an http transport when zooid.yaml only declares one', async () => {
15
+ const yamlPath = join(dir, 'zooid.yaml')
16
+ writeFileSync(
17
+ yamlPath,
18
+ [
19
+ 'runtime: local',
20
+ 'transports:',
21
+ ' http-local:',
22
+ ' type: http',
23
+ ' port: 0',
24
+ 'agents:',
25
+ ' noop:',
26
+ ' workdir: .',
27
+ ' acp: { preset: claude }',
28
+ ' http: { transport: http-local }',
29
+ ].join('\n'),
30
+ )
31
+ process.env.ZOOID_TOKEN = 'test-token'
32
+
33
+ const handle = await startDaemon({
34
+ configPath: yamlPath,
35
+ installSignalHandlers: false,
36
+ })
37
+
38
+ expect(handle.port).toBeGreaterThan(0)
39
+ expect(handle.agentNames).toEqual(['noop'])
40
+
41
+ const r = await fetch(`http://localhost:${handle.port}/`, {
42
+ headers: { authorization: 'Bearer test-token' },
43
+ })
44
+ expect(r.status).toBeGreaterThan(0)
45
+
46
+ await handle.stop()
47
+ await handle.stop()
48
+ })
49
+
50
+ it('rejects when no transport is declared', async () => {
51
+ const yamlPath = join(dir, 'zooid.yaml')
52
+ writeFileSync(
53
+ yamlPath,
54
+ [
55
+ 'runtime: local',
56
+ 'agents:',
57
+ ' a:',
58
+ ' workdir: .',
59
+ ' acp: { preset: claude }',
60
+ ' http: { transport: x }',
61
+ ].join('\n'),
62
+ )
63
+ await expect(
64
+ startDaemon({ configPath: yamlPath, installSignalHandlers: false }),
65
+ ).rejects.toThrow()
66
+ })
67
+ })
@@ -0,0 +1,270 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { mkdir, unlink } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import type { AddressInfo } from 'node:net'
6
+ import { serve, type ServerType } from '@hono/node-server'
7
+ import {
8
+ ApprovalCorrelator,
9
+ findConfigFile,
10
+ findHttpTransport,
11
+ findMatrixTransport,
12
+ loadZooidConfig,
13
+ mergeCliFlags,
14
+ type CliFlags,
15
+ type TapEvent,
16
+ } from '@zooid/core'
17
+ import { createApp } from '@zooid/transport-http'
18
+ import {
19
+ MatrixClient,
20
+ createMatrixTransport,
21
+ ensureWorkforceSpace,
22
+ startWorkforcePublisher,
23
+ type AgentBinding,
24
+ type PublisherHandle,
25
+ } from '@zooid/transport-matrix'
26
+ import {
27
+ SpawnRegistry,
28
+ startDaemonSocketServer,
29
+ type DaemonSocketHandle,
30
+ } from '@zooid/context-mcp'
31
+ import { buildAcpRegistry } from '../build-registry.js'
32
+ import { prepullImages } from '../prepull-images.js'
33
+
34
+ export interface StartDaemonOpts {
35
+ configPath?: string
36
+ cwd?: string
37
+ cliFlags?: CliFlags
38
+ installSignalHandlers?: boolean
39
+ /** Admin Matrix user ID. Forwarded to the matrix transport for room bootstrap. */
40
+ adminUserId?: string
41
+ /** Observability tap forwarded to each AcpClient. */
42
+ onTap?: (agentName: string, event: TapEvent) => void
43
+ /**
44
+ * Per-agent state root (`<dataRoot>/agents/`). Threaded into
45
+ * `buildAcpRegistry` so each AcpClient persists `sessionId`s across
46
+ * daemon restarts.
47
+ */
48
+ agentsDir?: string
49
+ /** Skip the startup image-prepull pass entirely. */
50
+ noPrepull?: boolean
51
+ /** Force a re-pull of every resolved image at startup (no inspect check). */
52
+ refreshImages?: boolean
53
+ /**
54
+ * Per-line progress hook for the image-prepull pass. Called once per
55
+ * top-level summary plus once per image (start + completion). Used by
56
+ * `zooid dev` to surface pull progress under the "Start daemon" listr
57
+ * task instead of leaving the user staring at an unmoving spinner.
58
+ */
59
+ prepullLog?: (line: string) => void
60
+ }
61
+
62
+ export interface DaemonHandle {
63
+ port: number
64
+ agentNames: string[]
65
+ stop(): Promise<void>
66
+ whenStopped: Promise<void>
67
+ }
68
+
69
+ function listenAsync(server: ServerType): Promise<number> {
70
+ return new Promise((resolve) => {
71
+ const check = () => {
72
+ const addr = server.address() as AddressInfo | string | null
73
+ if (addr && typeof addr === 'object') resolve(addr.port)
74
+ else setImmediate(check)
75
+ }
76
+ check()
77
+ })
78
+ }
79
+
80
+ function closeAsync(server: ServerType): Promise<void> {
81
+ return new Promise((resolve) => {
82
+ server.close(() => resolve())
83
+ })
84
+ }
85
+
86
+ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHandle> {
87
+ const cwd = opts.cwd ?? process.cwd()
88
+ const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
89
+ if (!found) throw new Error('zooid.yaml is required')
90
+ const configDir = dirname(found.path)
91
+ const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir })
92
+ const config = mergeCliFlags(base, opts.cliFlags ?? {})
93
+
94
+ const approvals = new ApprovalCorrelator()
95
+
96
+ const daemonSockPath = opts.agentsDir
97
+ ? join(opts.agentsDir, '..', 'run', 'context.sock')
98
+ : join(tmpdir(), `zooid-context-${process.pid}.sock`)
99
+ await mkdir(dirname(daemonSockPath), { recursive: true }).catch(() => {})
100
+ const contextSpawnRegistry = new SpawnRegistry()
101
+ let contextSocket: DaemonSocketHandle | null = null
102
+ try {
103
+ contextSocket = await startDaemonSocketServer({
104
+ sockPath: daemonSockPath,
105
+ registry: contextSpawnRegistry,
106
+ })
107
+ } catch (err) {
108
+ console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
109
+ }
110
+
111
+ const dataDir = opts.agentsDir ? dirname(opts.agentsDir) : undefined
112
+ const registry = buildAcpRegistry(config, {
113
+ approvals,
114
+ onTap: opts.onTap,
115
+ agentsDir: opts.agentsDir,
116
+ contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
117
+ daemonSockPath: contextSocket ? daemonSockPath : undefined,
118
+ configDir,
119
+ dataDir,
120
+ daemonHome: process.env.HOME,
121
+ })
122
+ const agentNames = Object.keys(config.agents)
123
+
124
+ if (config.runtime !== 'local') {
125
+ await prepullImages(registry, {
126
+ engine: config.runtime === 'podman' ? 'podman' : 'docker',
127
+ runtime: config.runtime,
128
+ skip: opts.noPrepull ?? process.env.ZOOID_NO_PREPULL === '1',
129
+ refresh: opts.refreshImages ?? process.env.ZOOID_REFRESH_IMAGES === '1',
130
+ log: opts.prepullLog,
131
+ })
132
+ }
133
+
134
+ console.log(
135
+ `[context] socket=${daemonSockPath} ` +
136
+ `status=${contextSocket ? 'listening' : 'disabled'} ` +
137
+ `agents={${agentNames
138
+ .map((n) => `${n}:${registry.hasContextSpawn(n) ? 'yes' : 'no'}`)
139
+ .join(', ')}}`,
140
+ )
141
+
142
+ let server: ServerType | null = null
143
+ let stopped = false
144
+ let resolveStopped!: () => void
145
+ const whenStopped = new Promise<void>((r) => {
146
+ resolveStopped = r
147
+ })
148
+
149
+ const matrix = findMatrixTransport(config)
150
+ let port: number
151
+
152
+ if (matrix) {
153
+ const client = new MatrixClient({
154
+ homeserver: matrix.transport.homeserver,
155
+ asToken: matrix.transport.as_token,
156
+ })
157
+ const bindings: AgentBinding[] = []
158
+ for (const a of Object.values(config.agents)) {
159
+ if (a.matrix?.transport !== matrix.name) continue
160
+ const binding: AgentBinding = {
161
+ name: a.name,
162
+ userId: a.matrix.user_id,
163
+ rooms: a.matrix.rooms,
164
+ trigger: a.matrix.trigger,
165
+ }
166
+ if (a.matrix.display_name !== undefined) binding.displayName = a.matrix.display_name
167
+ bindings.push(binding)
168
+ }
169
+ const transport = createMatrixTransport({
170
+ agents: registry,
171
+ approvals,
172
+ client,
173
+ bindings,
174
+ hsToken: matrix.transport.hs_token,
175
+ adminUserId: opts.adminUserId,
176
+ })
177
+ const requestedPort = matrix.transport.port ?? 8080
178
+ // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
179
+ // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
180
+ // events back to host.docker.internal:<port>.
181
+ server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
182
+ port = await listenAsync(server)
183
+
184
+ // user_namespace is a regex like `@.*:localhost`; the part after the last
185
+ // `:` is the homeserver's server_name. Fall back to the homeserver URL's
186
+ // host if the namespace shape is unexpected.
187
+ const serverName =
188
+ matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
189
+ new URL(matrix.transport.homeserver).hostname
190
+ const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
191
+ const spaceLocalpart = matrix.transport.space ?? 'dev'
192
+ let spaceRoomId: string | undefined
193
+ try {
194
+ spaceRoomId = await ensureWorkforceSpace({
195
+ client,
196
+ asUserId,
197
+ serverName,
198
+ spaceLocalpart,
199
+ preset: 'public_chat',
200
+ })
201
+ console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
202
+ } catch (err) {
203
+ console.warn('[matrix] workforce space provisioning failed:', err)
204
+ }
205
+
206
+ // Bootstrap *after* the listener is up: registerBot/createRoom/joinRoom
207
+ // each trigger Tuwunel to push events back to the AS, and Tuwunel only
208
+ // retries those a few times before dropping. Listening first ensures
209
+ // those pushes don't hit a connection-refused. Bootstrap also runs
210
+ // *after* the space exists so BotPool can attach each agent room as
211
+ // m.space.child while joining it.
212
+ await transport.bootstrap({ spaceRoomId, asUserId })
213
+
214
+ if (spaceRoomId) {
215
+ try {
216
+ const publisher: PublisherHandle = await startWorkforcePublisher({
217
+ client,
218
+ spaceRoomId,
219
+ asUserId,
220
+ getAgents: () => bindings,
221
+ })
222
+ console.log(`[matrix] published eco.zoon.workforce (${bindings.length} agents)`)
223
+ void publisher
224
+ } catch (err) {
225
+ console.warn('[matrix] workforce roster publication failed:', err)
226
+ }
227
+ }
228
+ } else {
229
+ const http = findHttpTransport(config)
230
+ if (!http) throw new Error('no transport declared in zooid.yaml')
231
+ const token = process.env.ZOOID_TOKEN
232
+ if (!token) throw new Error('ZOOID_TOKEN is required for http transport')
233
+ const app = createApp({ agents: registry, approvals, token })
234
+ server = serve({ fetch: app.fetch, port: http.transport.port, hostname: '0.0.0.0' })
235
+ port = await listenAsync(server)
236
+ }
237
+
238
+ const stop = async (): Promise<void> => {
239
+ if (stopped) return whenStopped
240
+ stopped = true
241
+ try {
242
+ if (server) await closeAsync(server)
243
+ } catch {
244
+ // swallow
245
+ }
246
+ try {
247
+ await registry.stopAll()
248
+ } catch (err) {
249
+ console.error('stopAll:', err)
250
+ }
251
+ try {
252
+ if (contextSocket) await contextSocket.close()
253
+ await unlink(daemonSockPath).catch(() => {})
254
+ } catch {
255
+ // swallow
256
+ }
257
+ resolveStopped()
258
+ }
259
+
260
+ if (opts.installSignalHandlers !== false) {
261
+ const handler = (sig: NodeJS.Signals): void => {
262
+ console.log(`received ${sig}, stopping agents...`)
263
+ void stop().then(() => process.exit(0))
264
+ }
265
+ process.on('SIGINT', () => handler('SIGINT'))
266
+ process.on('SIGTERM', () => handler('SIGTERM'))
267
+ }
268
+
269
+ return { port, agentNames, stop, whenStopped }
270
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { buildAcpRegistry } from './build-registry.js'
2
+ export type { BuildAcpRegistryOptions } from './build-registry.js'
@@ -0,0 +1,58 @@
1
+ import type { TapEvent } from '@zooid/core'
2
+ import type { LogPaths } from './paths.js'
3
+ import { JsonlSink, shouldCaptureUpdate, type Verbosity } from './file-sink.js'
4
+
5
+ export interface MatrixContext {
6
+ room_id: string
7
+ event_id: string
8
+ }
9
+
10
+ export interface WireAgentCaptureOpts {
11
+ agent: string
12
+ paths: LogPaths
13
+ verbosity: Verbosity
14
+ /** Returns the most-recently-known matrix context for the agent, or null. */
15
+ matrixContext: () => MatrixContext | null
16
+ /** Override for tests. */
17
+ now?: () => Date
18
+ }
19
+
20
+ export interface AgentCapture {
21
+ onTap: (event: TapEvent) => void
22
+ close: () => Promise<void>
23
+ }
24
+
25
+ export function wireAgentCapture(opts: WireAgentCaptureOpts): AgentCapture {
26
+ const sink = new JsonlSink(opts.paths.agentTap(opts.agent))
27
+ const now = opts.now ?? (() => new Date())
28
+ const pending: Promise<unknown>[] = []
29
+
30
+ const onTap = (event: TapEvent): void => {
31
+ if (event.kind === 'session_update') {
32
+ const variant =
33
+ (event.update as { sessionUpdate?: string }).sessionUpdate ?? 'unknown'
34
+ if (!shouldCaptureUpdate(opts.verbosity, variant)) return
35
+ }
36
+ const matrix = opts.matrixContext()
37
+ const envelope: Record<string, unknown> = {
38
+ ts: now().toISOString(),
39
+ agent: opts.agent,
40
+ session_id: event.sessionId,
41
+ turn_id: event.turnId,
42
+ kind: event.kind,
43
+ }
44
+ if (matrix) envelope.matrix = matrix
45
+ if (event.kind === 'session_update') envelope.notification = event.update
46
+ else if (event.kind === 'turn_started') envelope.prompt_text = event.promptText
47
+ else if (event.kind === 'turn_completed') envelope.stop_reason = event.stopReason
48
+ pending.push(sink.write(envelope))
49
+ }
50
+
51
+ return {
52
+ onTap,
53
+ close: async () => {
54
+ await Promise.all(pending)
55
+ await sink.close()
56
+ },
57
+ }
58
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, readFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { rmSync } from 'node:fs'
6
+ import { spawn } from 'node:child_process'
7
+ import { buildRunArgs } from '../services/tuwunel.js'
8
+ import { captureChildToFile } from './capture-tuwunel.js'
9
+
10
+ const base = {
11
+ name: 'zooid-tuwunel',
12
+ hostPort: 8448,
13
+ paths: {
14
+ dataDir: '/abs/data/matrix',
15
+ dbDir: '/abs/data/matrix/db',
16
+ mediaDir: '/abs/data/matrix/media',
17
+ configDir: '/abs/data/matrix/config',
18
+ registrationsDir: '/abs/data/matrix/config/registrations',
19
+ tuwunelTomlPath: '/abs/data/matrix/config/tuwunel.toml',
20
+ appserviceYamlPath: '/abs/data/matrix/config/registrations/zooid.yaml',
21
+ envPath: '/abs/data/matrix/config/.env',
22
+ },
23
+ } as const
24
+
25
+ describe('buildRunArgs (post-floor)', () => {
26
+ it('no longer includes -d; container is foregrounded so the parent owns its stdio', () => {
27
+ const args = buildRunArgs({ ...base, engine: 'docker' })
28
+ expect(args).not.toContain('-d')
29
+ expect(args).toContain('--rm')
30
+ expect(args).toContain('--name')
31
+ })
32
+ })
33
+
34
+ describe('captureChildToFile', () => {
35
+ let dir: string
36
+ beforeEach(async () => {
37
+ dir = await mkdtemp(join(tmpdir(), 'zooid-obs-tw-'))
38
+ })
39
+ afterEach(() => {
40
+ rmSync(dir, { recursive: true, force: true })
41
+ })
42
+
43
+ it('tees stdout AND stderr of a child to the same log file in arrival order', async () => {
44
+ const path = join(dir, 'tuwunel.log')
45
+ const child = spawn(
46
+ process.execPath,
47
+ ['-e', 'process.stdout.write("alpha\\n"); process.stderr.write("beta\\n");'],
48
+ { stdio: ['ignore', 'pipe', 'pipe'] },
49
+ )
50
+ const done = captureChildToFile(child, path)
51
+ await new Promise<void>((r) => child.on('exit', () => r()))
52
+ await done
53
+ const text = await readFile(path, 'utf8')
54
+ expect(text).toContain('alpha')
55
+ expect(text).toContain('beta')
56
+ })
57
+ })
@@ -0,0 +1,24 @@
1
+ import { createWriteStream } from 'node:fs'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+ import type { ChildProcess } from 'node:child_process'
5
+
6
+ /**
7
+ * Tee a child's stdout and stderr to one log file, in arrival order.
8
+ * Returns a promise that resolves once the file has been flushed after
9
+ * the child exits.
10
+ */
11
+ export function captureChildToFile(
12
+ child: ChildProcess,
13
+ path: string,
14
+ ): Promise<void> {
15
+ return (async () => {
16
+ await mkdir(dirname(path), { recursive: true })
17
+ const stream = createWriteStream(path, { flags: 'a' })
18
+ if (child.stdout) child.stdout.on('data', (b: Buffer) => stream.write(b))
19
+ if (child.stderr) child.stderr.on('data', (b: Buffer) => stream.write(b))
20
+ await new Promise<void>((resolve) => {
21
+ child.on('exit', () => stream.end(() => resolve()))
22
+ })
23
+ })()
24
+ }