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,261 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildAcpRegistry } from './build-registry.js'
3
+ import type { ZooidConfig } from '@zooid/core'
4
+
5
+ const baseTransports = {
6
+ m1: {
7
+ type: 'matrix' as const,
8
+ homeserver: 'http://localhost:8448',
9
+ as_token: 't',
10
+ hs_token: 'h',
11
+ sender_localpart: 'z',
12
+ user_namespace: '@.*:localhost',
13
+ },
14
+ }
15
+
16
+ function mkCfg(over: Partial<ZooidConfig['agents']['alice']> = {}): ZooidConfig {
17
+ return {
18
+ runtime: 'docker',
19
+ transports: baseTransports,
20
+ agents: {
21
+ alice: {
22
+ name: 'alice',
23
+ workdir: './agents/alice',
24
+ hooks: {},
25
+ acp: { preset: 'claude' },
26
+ approval_timeout_ms: 0,
27
+ matrix: {
28
+ transport: 'm1',
29
+ user_id: '@alice:localhost',
30
+ rooms: ['!r:localhost'],
31
+ trigger: 'mention',
32
+ },
33
+ ...over,
34
+ },
35
+ },
36
+ hooks: {},
37
+ } as ZooidConfig
38
+ }
39
+
40
+ describe('buildAcpRegistry — mounts (ZOD044)', () => {
41
+ it('auto-injects the workspace mount with id "workspace"', () => {
42
+ const cfg = mkCfg()
43
+ const reg = buildAcpRegistry(cfg, {
44
+ configDir: '/example',
45
+ dataDir: '/data',
46
+ daemonHome: '/home/zooid',
47
+ })
48
+ const mounts = reg.resolveSpawnMounts('alice')
49
+ const ws = mounts.find((m) => m.target === '/workspace')
50
+ expect(ws).toMatchObject({
51
+ path: '/example/agents/alice',
52
+ target: '/workspace',
53
+ mode: 'rw',
54
+ })
55
+ })
56
+
57
+ it('overrides cwd to /workspace when workspace mount is active', () => {
58
+ const cfg = mkCfg()
59
+ const reg = buildAcpRegistry(cfg, {
60
+ configDir: '/example',
61
+ dataDir: '/data',
62
+ daemonHome: '/home/zooid',
63
+ })
64
+ expect(reg.resolveSpawnCwd('alice')).toBe('/workspace')
65
+ })
66
+
67
+ it('disable_mounts: [workspace] skips auto-mount and passes workdir through to cwd', () => {
68
+ const cfg = mkCfg({
69
+ workdir: '/baked/workspace',
70
+ container: { disable_mounts: ['workspace'] },
71
+ })
72
+ const reg = buildAcpRegistry(cfg, {
73
+ configDir: '/example',
74
+ dataDir: '/data',
75
+ daemonHome: '/home/zooid',
76
+ })
77
+ const mounts = reg.resolveSpawnMounts('alice')
78
+ expect(mounts.find((m) => m.target === '/workspace')).toBeUndefined()
79
+ expect(reg.resolveSpawnCwd('alice')).toBe('/baked/workspace')
80
+ })
81
+
82
+ it('layers preset mounts after workspace, filtered by disable_mounts', () => {
83
+ const cfg = mkCfg({ container: { disable_mounts: ['home'] } })
84
+ const reg = buildAcpRegistry(cfg, {
85
+ configDir: '/example',
86
+ dataDir: '/data',
87
+ daemonHome: '/home/zooid',
88
+ })
89
+ const mounts = reg.resolveSpawnMounts('alice')
90
+ // claude declares one `home` mount; with home disabled, only workspace remains.
91
+ const targets = mounts.map((m) => m.target)
92
+ expect(targets).toContain('/workspace')
93
+ expect(targets).not.toContain('/root/.claude')
94
+ })
95
+
96
+ it('preset `home` mount points at the daemon user host home, not <dataDir>/agents/<name>', () => {
97
+ const cfg = mkCfg()
98
+ const reg = buildAcpRegistry(cfg, {
99
+ configDir: '/example',
100
+ dataDir: '/data',
101
+ daemonHome: '/home/zooid',
102
+ })
103
+ const mounts = reg.resolveSpawnMounts('alice')
104
+ const home = mounts.find((m) => m.target === '/root/.claude')
105
+ expect(home).toBeDefined()
106
+ expect(home!.path).toBe('/home/zooid/.claude')
107
+ expect(home!.path).not.toContain('/data/agents/alice')
108
+ })
109
+
110
+ it('user-declared mounts append after preset mounts', () => {
111
+ const cfg = mkCfg({
112
+ container: {
113
+ mounts: [{ host: '/srv/shared', target: '/shared', mode: 'rw' }],
114
+ },
115
+ })
116
+ const reg = buildAcpRegistry(cfg, {
117
+ configDir: '/example',
118
+ dataDir: '/data',
119
+ daemonHome: '/home/zooid',
120
+ })
121
+ const mounts = reg.resolveSpawnMounts('alice')
122
+ expect(mounts[mounts.length - 1]).toMatchObject({
123
+ path: '/srv/shared',
124
+ target: '/shared',
125
+ })
126
+ })
127
+
128
+ it('throws on unknown disable_mounts id', () => {
129
+ const cfg = mkCfg({ container: { disable_mounts: ['nope'] } })
130
+ expect(() =>
131
+ buildAcpRegistry(cfg, {
132
+ configDir: '/example',
133
+ dataDir: '/data',
134
+ daemonHome: '/home/zooid',
135
+ }),
136
+ ).toThrow(/disable_mounts.*unknown id.*"nope"/i)
137
+ })
138
+
139
+ it('runtime: local — no mounts emitted, cwd is workdir verbatim', () => {
140
+ const cfg = mkCfg()
141
+ cfg.runtime = 'local'
142
+ const reg = buildAcpRegistry(cfg, {
143
+ configDir: '/example',
144
+ dataDir: '/data',
145
+ daemonHome: '/home/zooid',
146
+ })
147
+ expect(reg.resolveSpawnMounts('alice')).toEqual([])
148
+ expect(reg.resolveSpawnCwd('alice')).toBe('./agents/alice')
149
+ })
150
+ })
151
+
152
+ describe('buildAcpRegistry — image resolution (ZOD044)', () => {
153
+ it('uses agent.container.image when set (highest precedence)', () => {
154
+ const cfg = mkCfg({ container: { image: 'agent-img:v1' } })
155
+ cfg.container = { image: 'workforce-img:v1' }
156
+ const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
157
+ expect(reg.resolveSpawnImage('alice')).toBe('agent-img:v1')
158
+ })
159
+
160
+ it('falls back to workforce-level container.image', () => {
161
+ const cfg = mkCfg()
162
+ cfg.container = { image: 'workforce-img:v1' }
163
+ const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
164
+ expect(reg.resolveSpawnImage('alice')).toBe('workforce-img:v1')
165
+ })
166
+
167
+ it('falls back to preset.image (claude → ghcr default)', () => {
168
+ const cfg = mkCfg() // preset: claude, no container.image anywhere
169
+ const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
170
+ expect(reg.resolveSpawnImage('alice')).toBe('ghcr.io/zooid-ai/agent-claude-code:latest')
171
+ })
172
+
173
+ it('runtime: docker — throws when an agent has no resolvable image (preset has none)', () => {
174
+ const cfg = mkCfg({ acp: { preset: 'cline' } })
175
+ expect(() =>
176
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
177
+ ).toThrow(/runtime: docker requires a container image[\s\S]*alice[\s\S]*cline/i)
178
+ })
179
+
180
+ it('runtime: podman — same startup error', () => {
181
+ const cfg = mkCfg({ acp: { preset: 'gemini' } })
182
+ cfg.runtime = 'podman'
183
+ expect(() =>
184
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
185
+ ).toThrow(/runtime: podman requires a container image/i)
186
+ })
187
+
188
+ it('runtime: local — never errors on missing image (image is unused)', () => {
189
+ const cfg = mkCfg({ acp: { preset: 'cline' } })
190
+ cfg.runtime = 'local'
191
+ expect(() =>
192
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
193
+ ).not.toThrow()
194
+ })
195
+
196
+ it('lists every unresolved agent in a single error', () => {
197
+ const base = mkCfg()
198
+ const cfg: ZooidConfig = {
199
+ runtime: 'podman',
200
+ transports: baseTransports,
201
+ agents: {
202
+ alice: { ...base.agents.alice!, acp: { preset: 'cline' } },
203
+ bob: {
204
+ ...base.agents.alice!,
205
+ name: 'bob',
206
+ acp: { preset: 'kiro' },
207
+ matrix: {
208
+ transport: 'm1',
209
+ user_id: '@bob:localhost',
210
+ rooms: ['!r:localhost'],
211
+ trigger: 'mention',
212
+ },
213
+ },
214
+ },
215
+ hooks: {},
216
+ } as ZooidConfig
217
+ expect(() =>
218
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
219
+ ).toThrow(/alice[\s\S]*cline[\s\S]*bob[\s\S]*kiro/)
220
+ })
221
+ })
222
+
223
+ describe('buildAcpRegistry — startup discoverability (ZOD044)', () => {
224
+ it('logs one resolved-image line per agent with provenance', () => {
225
+ const lines: string[] = []
226
+ const cfg = mkCfg()
227
+ buildAcpRegistry(cfg, {
228
+ configDir: '/example',
229
+ dataDir: '/data',
230
+ log: (line) => lines.push(line),
231
+ })
232
+ expect(
233
+ lines.some((l) =>
234
+ /agent alice.*image=ghcr\.io\/zooid-ai\/agent-claude-code.*source=preset:claude/.test(l),
235
+ ),
236
+ ).toBe(true)
237
+ })
238
+
239
+ it('reports source=agent when the agent overrides the preset default', () => {
240
+ const lines: string[] = []
241
+ const cfg = mkCfg({ container: { image: 'custom:v3' } })
242
+ buildAcpRegistry(cfg, {
243
+ configDir: '/example',
244
+ dataDir: '/data',
245
+ log: (line) => lines.push(line),
246
+ })
247
+ expect(lines.some((l) => /agent alice.*image=custom:v3.*source=agent/.test(l))).toBe(true)
248
+ })
249
+
250
+ it('reports source=workforce when only workforce container.image is set', () => {
251
+ const lines: string[] = []
252
+ const cfg = mkCfg()
253
+ cfg.container = { image: 'wf:v1' }
254
+ buildAcpRegistry(cfg, {
255
+ configDir: '/example',
256
+ dataDir: '/data',
257
+ log: (line) => lines.push(line),
258
+ })
259
+ expect(lines.some((l) => /agent alice.*image=wf:v1.*source=workforce/.test(l))).toBe(true)
260
+ })
261
+ })
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { buildShutdown } from './dev-cascade.js'
3
+
4
+ describe('buildShutdown', () => {
5
+ it('stops in order: ui → daemon → tuwunel and is idempotent', async () => {
6
+ const calls: string[] = []
7
+ const stopUi = vi.fn(async () => {
8
+ calls.push('ui')
9
+ })
10
+ const stopDaemon = vi.fn(async () => {
11
+ calls.push('daemon')
12
+ })
13
+ const stopTuwunel = vi.fn(async () => {
14
+ calls.push('tuwunel')
15
+ })
16
+
17
+ const shutdown = buildShutdown({ stopUi, stopDaemon, stopTuwunel })
18
+ await shutdown()
19
+ expect(calls).toEqual(['ui', 'daemon', 'tuwunel'])
20
+
21
+ await shutdown()
22
+ expect(stopUi).toHaveBeenCalledTimes(1)
23
+ expect(stopDaemon).toHaveBeenCalledTimes(1)
24
+ expect(stopTuwunel).toHaveBeenCalledTimes(1)
25
+ })
26
+
27
+ it('continues stopping later layers even if an earlier one throws', async () => {
28
+ const stopUi = vi.fn(async () => {
29
+ throw new Error('ui boom')
30
+ })
31
+ const stopDaemon = vi.fn(async () => {})
32
+ const stopTuwunel = vi.fn(async () => {})
33
+ const shutdown = buildShutdown({ stopUi, stopDaemon, stopTuwunel })
34
+ await shutdown()
35
+ expect(stopDaemon).toHaveBeenCalled()
36
+ expect(stopTuwunel).toHaveBeenCalled()
37
+ })
38
+ })
@@ -0,0 +1,35 @@
1
+ export interface ShutdownLayers {
2
+ stopUi: () => Promise<void>
3
+ stopDaemon: () => Promise<void>
4
+ stopTuwunel: () => Promise<void>
5
+ stopWebWatch?: () => Promise<void>
6
+ /** Flush per-agent ACP taps before stopping the daemon. */
7
+ stopCaptures?: () => Promise<void>
8
+ /** Resolves once Tuwunel's captured stdio file is flushed. */
9
+ finalizeTuwunelCapture?: () => Promise<void>
10
+ }
11
+
12
+ export function buildShutdown(layers: ShutdownLayers): () => Promise<void> {
13
+ let started: Promise<void> | null = null
14
+ return () => {
15
+ if (started) return started
16
+ started = (async () => {
17
+ const order = [
18
+ 'stopUi',
19
+ 'stopWebWatch',
20
+ 'stopCaptures',
21
+ 'stopDaemon',
22
+ 'stopTuwunel',
23
+ 'finalizeTuwunelCapture',
24
+ ] as const
25
+ for (const step of order) {
26
+ try {
27
+ await layers[step]?.()
28
+ } catch (err) {
29
+ console.error(`${step}:`, err)
30
+ }
31
+ }
32
+ })()
33
+ return started
34
+ }
35
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { rmSync, existsSync } from 'node:fs'
6
+ import { resolveDataLayout } from '../bootstrap/data-layout.js'
7
+ import { resolvePaths } from '../bootstrap/paths.js'
8
+ import { writeBootstrapConfigs } from '../bootstrap/configs.js'
9
+
10
+ // This test exercises the *layout boundary*: given a data root, the bootstrap
11
+ // helpers write matrix-specific files under <root>/matrix/ and never elsewhere.
12
+ // It does not boot tuwunel.
13
+
14
+ describe('dev bootstrap honours the data-root layout', () => {
15
+ let root: string
16
+ beforeEach(async () => {
17
+ root = await mkdtemp(join(tmpdir(), 'zooid-dev-layout-'))
18
+ })
19
+ afterEach(() => {
20
+ rmSync(root, { recursive: true, force: true })
21
+ })
22
+
23
+ it('writes tuwunel.toml + appservice.yaml under <root>/matrix/, agents/ stays empty', () => {
24
+ const layout = resolveDataLayout(root)
25
+ const paths = resolvePaths(layout.matrixDir)
26
+ writeBootstrapConfigs({
27
+ paths,
28
+ serverName: 'localhost',
29
+ asToken: 'as-token',
30
+ hsToken: 'hs-token',
31
+ senderLocalpart: 'zooid',
32
+ userNamespace: '@.*:localhost',
33
+ })
34
+
35
+ // Matrix-side files exist where expected.
36
+ expect(existsSync(join(root, 'matrix', 'config', 'tuwunel.toml'))).toBe(true)
37
+ expect(
38
+ existsSync(join(root, 'matrix', 'config', 'registrations', 'zooid.yaml')),
39
+ ).toBe(true)
40
+ expect(existsSync(join(root, 'matrix', 'db'))).toBe(true)
41
+ expect(existsSync(join(root, 'matrix', 'media'))).toBe(true)
42
+
43
+ // Crucially: nothing writes to <root>/matrix/logs or <root>/matrix/agents.
44
+ expect(existsSync(join(root, 'matrix', 'logs'))).toBe(false)
45
+ expect(existsSync(join(root, 'matrix', 'agents'))).toBe(false)
46
+
47
+ // agents/ is reserved for ZOD028 and not pre-created by bootstrap.
48
+ expect(existsSync(join(root, 'agents'))).toBe(false)
49
+ })
50
+
51
+ it('agentDir(id) resolves a deterministic path even before the dir exists', () => {
52
+ const layout = resolveDataLayout(root)
53
+ expect(layout.agentDir('docs')).toBe(join(root, 'agents', 'docs'))
54
+ })
55
+ })