zooid 0.6.0 → 0.7.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 (78) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1797 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-N5POSZX5.js +31000 -0
  6. package/dist/chunk-N5POSZX5.js.map +1 -0
  7. package/dist/index.d.ts +40 -0
  8. package/dist/index.js +4 -3788
  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-BT_v3DKu.js +295 -0
  14. package/dist/web/assets/index-DJTghnF-.css +1 -0
  15. package/dist/web/assets/index-JZMMlqDP.js +118066 -0
  16. package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +42 -22
  20. package/src/bin.ts +112 -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 +142 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/commands/dev-cascade.test.ts +38 -0
  38. package/src/commands/dev-cascade.ts +35 -0
  39. package/src/commands/dev-data-layout.test.ts +55 -0
  40. package/src/commands/dev.ts +308 -0
  41. package/src/commands/init/generators.test.ts +97 -0
  42. package/src/commands/init/generators.ts +119 -0
  43. package/src/commands/init/prompts.ts +123 -0
  44. package/src/commands/init/registry.test.ts +33 -0
  45. package/src/commands/init/registry.ts +75 -0
  46. package/src/commands/init/sniff.test.ts +41 -0
  47. package/src/commands/init/sniff.ts +30 -0
  48. package/src/commands/init.test.ts +131 -0
  49. package/src/commands/init.ts +158 -0
  50. package/src/commands/logs.test.ts +105 -0
  51. package/src/commands/logs.ts +108 -0
  52. package/src/commands/start.ts +28 -0
  53. package/src/commands/status.test.ts +73 -0
  54. package/src/commands/status.ts +100 -0
  55. package/src/daemon/start-daemon.test.ts +67 -0
  56. package/src/daemon/start-daemon.ts +243 -0
  57. package/src/index.ts +2 -0
  58. package/src/observability/capture-agent.ts +58 -0
  59. package/src/observability/capture-tuwunel.test.ts +57 -0
  60. package/src/observability/capture-tuwunel.ts +24 -0
  61. package/src/observability/file-sink.test.ts +68 -0
  62. package/src/observability/file-sink.ts +78 -0
  63. package/src/observability/observability.integration.test.ts +162 -0
  64. package/src/observability/paths.test.ts +104 -0
  65. package/src/observability/paths.ts +94 -0
  66. package/src/services/tuwunel.test.ts +64 -0
  67. package/src/services/tuwunel.ts +91 -0
  68. package/src/web/resolve.test.ts +50 -0
  69. package/src/web/resolve.ts +31 -0
  70. package/src/web/static.test.ts +51 -0
  71. package/src/web/static.ts +61 -0
  72. package/src/web/watch.ts +103 -0
  73. package/dist/chunk-67ZRMVHO.js +0 -174
  74. package/dist/chunk-AR456MHY.js +0 -29
  75. package/dist/chunk-VBGU2NST.js +0 -139
  76. package/dist/client-4VMFEFDX.js +0 -22
  77. package/dist/config-2KK5GX42.js +0 -27
  78. 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,100 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import chalk from 'chalk'
3
+ import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
4
+ import { deriveHomeserverShape } from '../bootstrap/derive.js'
5
+
6
+ export interface StatusFlags {
7
+ cwd?: string
8
+ dataDir: string
9
+ /** Tuwunel host port. If omitted, derived from zooid.yaml's matrix transport. */
10
+ port?: number
11
+ }
12
+
13
+ export interface StatusReport {
14
+ tuwunel: { status: 'up' | 'down'; url: string }
15
+ daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
16
+ agents: { name: string; userId: string; trigger: string }[]
17
+ }
18
+
19
+ async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
20
+ try {
21
+ const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
22
+ return r.status > 0
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export async function collectStatus(opts: {
29
+ cwd: string
30
+ tuwunelUrl: string
31
+ }): Promise<StatusReport> {
32
+ const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
33
+ const tuwunel: StatusReport['tuwunel'] = {
34
+ status: tuwunelUp ? 'up' : 'down',
35
+ url: opts.tuwunelUrl,
36
+ }
37
+
38
+ const found = findConfigFile(opts.cwd)
39
+ if (!found) {
40
+ return {
41
+ tuwunel,
42
+ daemon: { status: 'unknown', reason: 'no zooid.yaml' },
43
+ agents: [],
44
+ }
45
+ }
46
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
47
+ const matrixEntry = Object.entries(cfg.transports).find(
48
+ ([, t]) => t.type === 'matrix',
49
+ ) as [string, { port?: number }] | undefined
50
+ const daemonPort = matrixEntry?.[1]?.port ?? 8080
51
+ const daemonUrl = `http://localhost:${daemonPort}`
52
+ const daemonUp = await probe(daemonUrl)
53
+ const agents: StatusReport['agents'] = []
54
+ for (const a of Object.values(cfg.agents)) {
55
+ if (a.matrix) {
56
+ agents.push({
57
+ name: a.name,
58
+ userId: a.matrix.user_id,
59
+ trigger: a.matrix.trigger,
60
+ })
61
+ }
62
+ }
63
+ return {
64
+ tuwunel,
65
+ daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
66
+ agents,
67
+ }
68
+ }
69
+
70
+ export async function runStatus(flags: StatusFlags): Promise<void> {
71
+ const cwd = flags.cwd ?? process.cwd()
72
+ let port = flags.port
73
+ if (port === undefined) {
74
+ const found = findConfigFile(cwd)
75
+ if (found) {
76
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
77
+ const matrix = findMatrixTransport(cfg)
78
+ if (matrix) {
79
+ const userIds = Object.values(cfg.agents)
80
+ .filter((a) => a.matrix?.transport === matrix.name)
81
+ .map((a) => a.matrix!.user_id)
82
+ port = deriveHomeserverShape(matrix.transport, userIds).port
83
+ }
84
+ }
85
+ }
86
+ const tuwunelUrl = `http://localhost:${port ?? 8448}`
87
+ const s = await collectStatus({ cwd, tuwunelUrl })
88
+ const fmt = (st: 'up' | 'down' | 'unknown'): string =>
89
+ st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
90
+ process.stdout.write(
91
+ [
92
+ `tuwunel ${fmt(s.tuwunel.status)} ${s.tuwunel.url}`,
93
+ `daemon ${fmt(s.daemon.status)} ${'url' in s.daemon ? s.daemon.url : s.daemon.reason}`,
94
+ ...s.agents.map(
95
+ (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
96
+ ),
97
+ '',
98
+ ].join('\n'),
99
+ )
100
+ }
@@ -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,243 @@
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
+
33
+ export interface StartDaemonOpts {
34
+ configPath?: string
35
+ cwd?: string
36
+ cliFlags?: CliFlags
37
+ installSignalHandlers?: boolean
38
+ /** Admin Matrix user ID. Forwarded to the matrix transport for room bootstrap. */
39
+ adminUserId?: string
40
+ /** Observability tap forwarded to each AcpClient. */
41
+ onTap?: (agentName: string, event: TapEvent) => void
42
+ /**
43
+ * Per-agent state root (`<dataRoot>/agents/`). Threaded into
44
+ * `buildAcpRegistry` so each AcpClient persists `sessionId`s across
45
+ * daemon restarts.
46
+ */
47
+ agentsDir?: string
48
+ }
49
+
50
+ export interface DaemonHandle {
51
+ port: number
52
+ agentNames: string[]
53
+ stop(): Promise<void>
54
+ whenStopped: Promise<void>
55
+ }
56
+
57
+ function listenAsync(server: ServerType): Promise<number> {
58
+ return new Promise((resolve) => {
59
+ const check = () => {
60
+ const addr = server.address() as AddressInfo | string | null
61
+ if (addr && typeof addr === 'object') resolve(addr.port)
62
+ else setImmediate(check)
63
+ }
64
+ check()
65
+ })
66
+ }
67
+
68
+ function closeAsync(server: ServerType): Promise<void> {
69
+ return new Promise((resolve) => {
70
+ server.close(() => resolve())
71
+ })
72
+ }
73
+
74
+ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHandle> {
75
+ const cwd = opts.cwd ?? process.cwd()
76
+ const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
77
+ if (!found) throw new Error('zooid.yaml is required')
78
+ const base = loadZooidConfig(readFileSync(found.path, 'utf8'))
79
+ const config = mergeCliFlags(base, opts.cliFlags ?? {})
80
+
81
+ const approvals = new ApprovalCorrelator()
82
+
83
+ const daemonSockPath = opts.agentsDir
84
+ ? join(opts.agentsDir, '..', 'run', 'context.sock')
85
+ : join(tmpdir(), `zooid-context-${process.pid}.sock`)
86
+ await mkdir(dirname(daemonSockPath), { recursive: true }).catch(() => {})
87
+ const contextSpawnRegistry = new SpawnRegistry()
88
+ let contextSocket: DaemonSocketHandle | null = null
89
+ try {
90
+ contextSocket = await startDaemonSocketServer({
91
+ sockPath: daemonSockPath,
92
+ registry: contextSpawnRegistry,
93
+ })
94
+ } catch (err) {
95
+ console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
96
+ }
97
+
98
+ const registry = buildAcpRegistry(config, {
99
+ approvals,
100
+ onTap: opts.onTap,
101
+ agentsDir: opts.agentsDir,
102
+ contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
103
+ daemonSockPath: contextSocket ? daemonSockPath : undefined,
104
+ })
105
+ const agentNames = Object.keys(config.agents)
106
+
107
+ console.log(
108
+ `[context] socket=${daemonSockPath} ` +
109
+ `status=${contextSocket ? 'listening' : 'disabled'} ` +
110
+ `agents={${agentNames
111
+ .map((n) => `${n}:${registry.hasContextSpawn(n) ? 'yes' : 'no'}`)
112
+ .join(', ')}}`,
113
+ )
114
+
115
+ let server: ServerType | null = null
116
+ let stopped = false
117
+ let resolveStopped!: () => void
118
+ const whenStopped = new Promise<void>((r) => {
119
+ resolveStopped = r
120
+ })
121
+
122
+ const matrix = findMatrixTransport(config)
123
+ let port: number
124
+
125
+ if (matrix) {
126
+ const client = new MatrixClient({
127
+ homeserver: matrix.transport.homeserver,
128
+ asToken: matrix.transport.as_token,
129
+ })
130
+ const bindings: AgentBinding[] = []
131
+ for (const a of Object.values(config.agents)) {
132
+ if (a.matrix?.transport !== matrix.name) continue
133
+ const binding: AgentBinding = {
134
+ name: a.name,
135
+ userId: a.matrix.user_id,
136
+ rooms: a.matrix.rooms,
137
+ trigger: a.matrix.trigger,
138
+ }
139
+ if (a.matrix.display_name !== undefined) binding.displayName = a.matrix.display_name
140
+ bindings.push(binding)
141
+ }
142
+ const transport = createMatrixTransport({
143
+ agents: registry,
144
+ approvals,
145
+ client,
146
+ bindings,
147
+ hsToken: matrix.transport.hs_token,
148
+ adminUserId: opts.adminUserId,
149
+ })
150
+ const requestedPort = matrix.transport.port ?? 8080
151
+ // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
152
+ // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
153
+ // events back to host.docker.internal:<port>.
154
+ server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
155
+ port = await listenAsync(server)
156
+
157
+ // user_namespace is a regex like `@.*:localhost`; the part after the last
158
+ // `:` is the homeserver's server_name. Fall back to the homeserver URL's
159
+ // host if the namespace shape is unexpected.
160
+ const serverName =
161
+ matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
162
+ new URL(matrix.transport.homeserver).hostname
163
+ const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
164
+ const spaceLocalpart = matrix.transport.space ?? 'dev'
165
+ let spaceRoomId: string | undefined
166
+ try {
167
+ spaceRoomId = await ensureWorkforceSpace({
168
+ client,
169
+ asUserId,
170
+ serverName,
171
+ spaceLocalpart,
172
+ preset: 'public_chat',
173
+ })
174
+ console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
175
+ } catch (err) {
176
+ console.warn('[matrix] workforce space provisioning failed:', err)
177
+ }
178
+
179
+ // Bootstrap *after* the listener is up: registerBot/createRoom/joinRoom
180
+ // each trigger Tuwunel to push events back to the AS, and Tuwunel only
181
+ // retries those a few times before dropping. Listening first ensures
182
+ // those pushes don't hit a connection-refused. Bootstrap also runs
183
+ // *after* the space exists so BotPool can attach each agent room as
184
+ // m.space.child while joining it.
185
+ await transport.bootstrap({ spaceRoomId, asUserId })
186
+
187
+ if (spaceRoomId) {
188
+ try {
189
+ const publisher: PublisherHandle = await startWorkforcePublisher({
190
+ client,
191
+ spaceRoomId,
192
+ asUserId,
193
+ getAgents: () => bindings,
194
+ })
195
+ console.log(`[matrix] published eco.zoon.workforce (${bindings.length} agents)`)
196
+ void publisher
197
+ } catch (err) {
198
+ console.warn('[matrix] workforce roster publication failed:', err)
199
+ }
200
+ }
201
+ } else {
202
+ const http = findHttpTransport(config)
203
+ if (!http) throw new Error('no transport declared in zooid.yaml')
204
+ const token = process.env.ZOOID_TOKEN
205
+ if (!token) throw new Error('ZOOID_TOKEN is required for http transport')
206
+ const app = createApp({ agents: registry, approvals, token })
207
+ server = serve({ fetch: app.fetch, port: http.transport.port, hostname: '0.0.0.0' })
208
+ port = await listenAsync(server)
209
+ }
210
+
211
+ const stop = async (): Promise<void> => {
212
+ if (stopped) return whenStopped
213
+ stopped = true
214
+ try {
215
+ if (server) await closeAsync(server)
216
+ } catch {
217
+ // swallow
218
+ }
219
+ try {
220
+ await registry.stopAll()
221
+ } catch (err) {
222
+ console.error('stopAll:', err)
223
+ }
224
+ try {
225
+ if (contextSocket) await contextSocket.close()
226
+ await unlink(daemonSockPath).catch(() => {})
227
+ } catch {
228
+ // swallow
229
+ }
230
+ resolveStopped()
231
+ }
232
+
233
+ if (opts.installSignalHandlers !== false) {
234
+ const handler = (sig: NodeJS.Signals): void => {
235
+ console.log(`received ${sig}, stopping agents...`)
236
+ void stop().then(() => process.exit(0))
237
+ }
238
+ process.on('SIGINT', () => handler('SIGINT'))
239
+ process.on('SIGTERM', () => handler('SIGTERM'))
240
+ }
241
+
242
+ return { port, agentNames, stop, whenStopped }
243
+ }
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
+ }