zooid 0.8.0 → 0.9.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 (37) hide show
  1. package/README.md +5 -5
  2. package/dist/bin.js +195 -156
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-R6EDLH23.js → chunk-PDSJJWQW.js} +1928 -990
  5. package/dist/chunk-PDSJJWQW.js.map +1 -0
  6. package/dist/index.js +1 -1
  7. package/package.json +10 -10
  8. package/src/bin.ts +1 -1
  9. package/src/bootstrap/configs.ts +19 -6
  10. package/src/bootstrap/derive.ts +3 -2
  11. package/src/bootstrap/identity-bootstrap.integration.test.ts +53 -0
  12. package/src/bootstrap/registration-url.test.ts +14 -0
  13. package/src/bootstrap/registration-url.ts +7 -0
  14. package/src/commands/dev.ts +9 -9
  15. package/src/commands/init/generators.test.ts +30 -12
  16. package/src/commands/init/generators.ts +23 -12
  17. package/src/commands/init/prompts.ts +9 -34
  18. package/src/commands/init/registry.test.ts +2 -2
  19. package/src/commands/init/registry.ts +9 -8
  20. package/src/commands/init/scaffold-roundtrip.test.ts +38 -0
  21. package/src/commands/init.test.ts +17 -12
  22. package/src/commands/init.ts +2 -4
  23. package/src/daemon/pull-wiring.test.ts +14 -0
  24. package/src/daemon/pull-wiring.ts +4 -0
  25. package/src/daemon/start-daemon-pull.integration.test.ts +95 -0
  26. package/src/daemon/start-daemon.ts +62 -15
  27. package/src/daemon/sync-cursors.test.ts +38 -0
  28. package/src/daemon/sync-cursors.ts +31 -0
  29. package/src/web/fetch.integration.test.ts +4 -4
  30. package/src/web/fetch.test.ts +4 -4
  31. package/src/web/fetch.ts +2 -2
  32. package/src/web/pin.test.ts +2 -2
  33. package/src/web/pin.ts +2 -2
  34. package/src/web/resolve.test.ts +5 -5
  35. package/src/web/resolve.ts +5 -5
  36. package/src/web/test-helpers.ts +1 -1
  37. package/dist/chunk-R6EDLH23.js.map +0 -1
@@ -19,12 +19,11 @@ function loadYaml(path: string): unknown {
19
19
  }
20
20
 
21
21
  describe('runInit — claude subscription path', () => {
22
- it('writes yaml + AGENTS.md + .claude/settings.json + .gitignore (no .env, no opencode.json)', async () => {
22
+ it('writes yaml + AGENTS.md + .claude/settings.json + .gitignore (no model, no .env, no opencode.json)', async () => {
23
23
  await runInit({
24
24
  dir,
25
25
  preset: 'claude',
26
26
  auth: 'subscription',
27
- model: 'claude-sonnet-4-6',
28
27
  })
29
28
 
30
29
  expect(existsSync(join(dir, 'zooid.yaml'))).toBe(true)
@@ -36,11 +35,20 @@ describe('runInit — claude subscription path', () => {
36
35
  expect(existsSync(join(dir, 'agents/zooid-assistant/opencode.json'))).toBe(false)
37
36
 
38
37
  const cfg = loadYaml(join(dir, 'zooid.yaml')) as {
39
- agents: { 'zooid-assistant': { acp: { preset: string; model: string } } }
38
+ agents: { 'zooid-assistant': { acp: { preset: string; model?: string } } }
39
+ }
40
+ // No model — the harness chooses its own default.
41
+ expect(cfg.agents['zooid-assistant'].acp).toEqual({ preset: 'claude' })
42
+ })
43
+
44
+ it('pins a model when --model is passed', async () => {
45
+ await runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-opus-4-8' })
46
+ const cfg = loadYaml(join(dir, 'zooid.yaml')) as {
47
+ agents: { 'zooid-assistant': { acp: { preset: string; model?: string } } }
40
48
  }
41
49
  expect(cfg.agents['zooid-assistant'].acp).toEqual({
42
50
  preset: 'claude',
43
- model: 'claude-sonnet-4-6',
51
+ model: 'claude-opus-4-8',
44
52
  })
45
53
  })
46
54
  })
@@ -51,7 +59,6 @@ describe('runInit — claude api-key path', () => {
51
59
  dir,
52
60
  preset: 'claude',
53
61
  auth: 'api-key',
54
- model: 'claude-sonnet-4-6',
55
62
  apiKey: 'sk-ant-xyz',
56
63
  })
57
64
 
@@ -60,12 +67,11 @@ describe('runInit — claude api-key path', () => {
60
67
  })
61
68
 
62
69
  describe('runInit — opencode opencode-go path', () => {
63
- it('writes opencode.json with opencode-go provider + env-interpolated apiKey', async () => {
70
+ it('writes opencode.json with opencode-go provider + env-interpolated apiKey, no model', async () => {
64
71
  await runInit({
65
72
  dir,
66
73
  preset: 'opencode',
67
74
  provider: 'opencode-go',
68
- model: 'kimi-k2.6',
69
75
  apiKey: 'sk-opencode-zzz',
70
76
  })
71
77
 
@@ -73,7 +79,8 @@ describe('runInit — opencode opencode-go path', () => {
73
79
  const oc = JSON.parse(
74
80
  readFileSync(join(dir, 'agents/zooid-assistant/opencode.json'), 'utf8'),
75
81
  )
76
- expect(oc.model).toBe('opencode-go/kimi-k2.6')
82
+ // No model — opencode picks its own default (e.g. bigpickle).
83
+ expect(oc).not.toHaveProperty('model')
77
84
  expect(oc.provider['opencode-go'].options.apiKey).toBe('{env:OPENCODE_API_KEY}')
78
85
  expect(oc.permission.webfetch).toBe('allow')
79
86
 
@@ -90,7 +97,7 @@ describe('runInit — idempotency', () => {
90
97
  it('errors on non-empty dir without --force', async () => {
91
98
  writeFileSync(join(dir, 'preexisting.txt'), 'x')
92
99
  await expect(
93
- runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-sonnet-4-6' }),
100
+ runInit({ dir, preset: 'claude', auth: 'subscription' }),
94
101
  ).rejects.toThrow(/non-empty/i)
95
102
  })
96
103
 
@@ -99,7 +106,7 @@ describe('runInit — idempotency', () => {
99
106
  writeFileSync(join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 9')
100
107
  require('node:fs').mkdirSync(join(dir, 'node_modules'))
101
108
  require('node:fs').mkdirSync(join(dir, '.git'))
102
- await runInit({ dir, preset: 'claude', auth: 'subscription', model: 'claude-sonnet-4-6' })
109
+ await runInit({ dir, preset: 'claude', auth: 'subscription' })
103
110
  expect(existsSync(join(dir, 'zooid.yaml'))).toBe(true)
104
111
  })
105
112
 
@@ -109,7 +116,6 @@ describe('runInit — idempotency', () => {
109
116
  dir,
110
117
  preset: 'claude',
111
118
  auth: 'subscription',
112
- model: 'claude-sonnet-4-6',
113
119
  force: true,
114
120
  })
115
121
  expect(readFileSync(join(dir, 'zooid.yaml'), 'utf8')).toContain('preexisting: true')
@@ -122,7 +128,6 @@ describe('runInit — idempotency', () => {
122
128
  dir,
123
129
  preset: 'claude',
124
130
  auth: 'subscription',
125
- model: 'claude-sonnet-4-6',
126
131
  force: true,
127
132
  overwrite: true,
128
133
  })
@@ -21,9 +21,9 @@ export interface InitOptions {
21
21
  preset: 'claude' | 'codex' | 'opencode'
22
22
  /** Required for claude/codex; ignored for opencode. */
23
23
  auth?: 'subscription' | 'api-key'
24
- /** Required for claude/codex; required for opencode (paired with `provider`). */
24
+ /** Optional model pin. Omitted by default the harness uses its own default. */
25
25
  model?: string
26
- /** Required for opencode. */
26
+ /** opencode provider id; defaults to `opencode-go` in the wizard. */
27
27
  provider?: string
28
28
  /** Required on api-key path; required for opencode. */
29
29
  apiKey?: string
@@ -68,7 +68,6 @@ export async function runInit(opts: InitOptions): Promise<void> {
68
68
  const writes: WriteSpec[] = []
69
69
 
70
70
  if (opts.preset === 'claude' || opts.preset === 'codex') {
71
- if (!opts.model) throw new Error(`--model is required for preset ${opts.preset}`)
72
71
  if (!opts.auth) throw new Error('--auth (subscription|api-key) is required for claude/codex')
73
72
  const meta = findSimplePreset(opts.preset)!
74
73
 
@@ -101,7 +100,6 @@ export async function runInit(opts: InitOptions): Promise<void> {
101
100
  const isCustom = opts.provider === 'custom'
102
101
  const providerMeta = isCustom ? undefined : findOpencodeProvider(opts.provider)
103
102
  if (!isCustom && !providerMeta) throw new Error(`unknown opencode provider: ${opts.provider}`)
104
- if (!isCustom && !opts.model) throw new Error('--model is required (paired with --provider)')
105
103
  if (!isCustom && !opts.apiKey) throw new Error('--api-key is required for opencode')
106
104
 
107
105
  writes.push({ path: 'zooid.yaml', content: generateZooidYaml({ preset: 'opencode' }) })
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { shouldBindHttpListener } from './pull-wiring.js'
3
+
4
+ describe('shouldBindHttpListener', () => {
5
+ it('binds in appservice (push) mode — homeserver pushes to the daemon', () => {
6
+ expect(shouldBindHttpListener('appservice')).toBe(true)
7
+ })
8
+ it('does NOT bind in client (pull) mode — daemon polls outbound /sync', () => {
9
+ expect(shouldBindHttpListener('client')).toBe(false)
10
+ })
11
+ it('defaults to binding when mode is undefined', () => {
12
+ expect(shouldBindHttpListener(undefined)).toBe(true)
13
+ })
14
+ })
@@ -0,0 +1,4 @@
1
+ /** Push (`appservice`) binds an inbound listener; pull (`client`) does not. */
2
+ export function shouldBindHttpListener(mode: 'appservice' | 'client' | undefined): boolean {
3
+ return mode !== 'client'
4
+ }
@@ -0,0 +1,95 @@
1
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } 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 { startDaemon } from './start-daemon.js'
6
+
7
+ let dir: string
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), 'zooid-pull-'))
10
+ })
11
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
12
+
13
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
14
+
15
+ // Permissive stub: bootstrap CS calls all succeed with a benign body; the
16
+ // /sync endpoint returns one page (carrying next_batch `s-1`) then idles. The
17
+ // idle page is *paced* (~150ms) to mimic the long-poll — SyncLoop.run() has no
18
+ // inter-tick delay, so an instant stub would busy-loop. This proves the daemon
19
+ // selects + drives the pull loop; event→runTurn dispatch is covered in ZOD064.
20
+ function pullFetchStub() {
21
+ let synced = false
22
+ return vi.fn(async (input: string | URL) => {
23
+ const url = new URL(typeof input === 'string' ? input : input.toString())
24
+ if (url.pathname === '/_matrix/client/v3/sync') {
25
+ if (synced) {
26
+ await delay(150)
27
+ return new Response(JSON.stringify({ next_batch: 's-idle', rooms: { join: {} } }), {
28
+ status: 200,
29
+ })
30
+ }
31
+ synced = true
32
+ return new Response(JSON.stringify({ next_batch: 's-1', rooms: { join: {} } }), {
33
+ status: 200,
34
+ })
35
+ }
36
+ if (url.pathname.endsWith('/account/whoami')) {
37
+ return new Response(JSON.stringify({ user_id: '@laptop:zoon.eco' }), { status: 200 })
38
+ }
39
+ // createRoom / register / join / send / state — benign shapes.
40
+ if (url.pathname.endsWith('/createRoom')) {
41
+ return new Response(JSON.stringify({ room_id: '!stub:zoon.eco' }), { status: 200 })
42
+ }
43
+ return new Response('{}', { status: 200 })
44
+ }) as unknown as typeof globalThis.fetch
45
+ }
46
+
47
+ describe('startDaemon — matrix client (pull) mode', () => {
48
+ it('binds no HTTP listener, runs the sync loop, persists a cursor, stops cleanly', async () => {
49
+ const yamlPath = join(dir, 'zooid.yaml')
50
+ writeFileSync(
51
+ yamlPath,
52
+ [
53
+ 'runtime: local',
54
+ 'workstation: laptop',
55
+ 'transports:',
56
+ ' matrix:',
57
+ ' homeserver: https://zoon.eco',
58
+ ' mode: client',
59
+ ' as_token: test-as-token',
60
+ ' hs_token: test-hs-token',
61
+ 'agents:',
62
+ ' docs:',
63
+ ' workdir: .',
64
+ ' acp: { preset: claude }',
65
+ ' matrix:',
66
+ ' transport: matrix',
67
+ " rooms: ['#general']",
68
+ ].join('\n'),
69
+ )
70
+
71
+ const agentsDir = join(dir, 'data', 'agents')
72
+ const handle = await startDaemon({
73
+ configPath: yamlPath,
74
+ installSignalHandlers: false,
75
+ agentsDir,
76
+ fetch: pullFetchStub(),
77
+ })
78
+
79
+ // (a) No inbound listener in pull mode.
80
+ expect(handle.port).toBe(0)
81
+ expect(handle.agentNames).toEqual(['docs'])
82
+
83
+ // (c) Cursor persisted for the agent after a sync page is consumed, beside
84
+ // sessions.json under <agentsDir>/<agentName>/. run() ticks async.
85
+ const sincePath = join(agentsDir, 'docs', 'sync-since')
86
+ await vi.waitFor(() => expect(existsSync(sincePath)).toBe(true), {
87
+ timeout: 3000,
88
+ interval: 50,
89
+ })
90
+
91
+ // (b) Clean, idempotent shutdown stops the loop (no hanging long-poll).
92
+ await handle.stop()
93
+ await handle.stop()
94
+ })
95
+ })
@@ -24,6 +24,7 @@ import {
24
24
  startWorkforcePublisher,
25
25
  type AgentBinding,
26
26
  type PublisherHandle,
27
+ type SyncLoop,
27
28
  } from '@zooid/transport-matrix'
28
29
  import {
29
30
  SpawnRegistry,
@@ -32,6 +33,8 @@ import {
32
33
  } from '@zooid/context-mcp'
33
34
  import { buildAcpRegistry } from '../build-registry.js'
34
35
  import { prepullImages } from '../prepull-images.js'
36
+ import { makeSyncCursorStore } from './sync-cursors.js'
37
+ import { shouldBindHttpListener } from './pull-wiring.js'
35
38
 
36
39
  export interface StartDaemonOpts {
37
40
  configPath?: string
@@ -66,6 +69,11 @@ export interface StartDaemonOpts {
66
69
  * task instead of leaving the user staring at an unmoving spinner.
67
70
  */
68
71
  prepullLog?: (line: string) => void
72
+ /**
73
+ * Override the global fetch used by the daemon's MatrixClient. Test seam for
74
+ * driving pull mode against canned /sync responses without a homeserver.
75
+ */
76
+ fetch?: typeof globalThis.fetch
69
77
  }
70
78
 
71
79
  export interface DaemonHandle {
@@ -149,6 +157,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
149
157
  )
150
158
 
151
159
  let server: ServerType | null = null
160
+ let syncLoops: SyncLoop[] | undefined
152
161
  let stopped = false
153
162
  let resolveStopped!: () => void
154
163
  const whenStopped = new Promise<void>((r) => {
@@ -159,9 +168,17 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
159
168
  let port: number
160
169
 
161
170
  if (matrix) {
171
+ const mode = matrix.transport.mode ?? 'appservice'
172
+ // Pull (client) mode persists a per-agent `since` cursor for offline resume,
173
+ // so it needs a data dir. Push mode doesn't.
174
+ if (mode === 'client' && !opts.agentsDir) {
175
+ throw new Error('matrix client (pull) mode requires a data dir for since-cursor persistence')
176
+ }
177
+ const cursors = opts.agentsDir ? makeSyncCursorStore(opts.agentsDir) : undefined
162
178
  const client = new MatrixClient({
163
179
  homeserver: matrix.transport.homeserver,
164
180
  asToken: matrix.transport.as_token,
181
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
165
182
  })
166
183
  const mediaClient = new MediaClient({
167
184
  homeserver: matrix.transport.homeserver,
@@ -184,6 +201,16 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
184
201
  binding.agentWorkspacePath = isContainerRuntime ? '/workspace' : workspaceDir
185
202
  bindings.push(binding)
186
203
  }
204
+ // user_namespace is a regex like `@.*:localhost`; the part after the last
205
+ // `:` is the homeserver's server_name. Fall back to the homeserver URL's
206
+ // host if the namespace shape is unexpected.
207
+ const serverName =
208
+ matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
209
+ new URL(matrix.transport.homeserver).hostname
210
+ const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
211
+ // Pull mode's loadSince/saveSince are keyed by MXID; the cursor store is
212
+ // keyed by agent name. Translate via the bindings we just built.
213
+ const nameByUserId = new Map(bindings.map((b) => [b.userId, b.name]))
187
214
  const transport = createMatrixTransport({
188
215
  agents: registry,
189
216
  approvals,
@@ -191,22 +218,29 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
191
218
  bindings,
192
219
  hsToken: matrix.transport.hs_token,
193
220
  adminUserId: opts.adminUserId,
221
+ botUserId: asUserId,
194
222
  media: mediaClient,
223
+ mode,
224
+ loadSince: (uid) => {
225
+ const name = nameByUserId.get(uid)
226
+ return name && cursors ? cursors.loadSince(name) : null
227
+ },
228
+ saveSince: (uid, since) => {
229
+ const name = nameByUserId.get(uid)
230
+ if (name && cursors) cursors.saveSince(name, since)
231
+ },
195
232
  })
196
- const requestedPort = matrix.transport.port ?? 9000
197
- // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
198
- // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
199
- // events back to host.docker.internal:<port>.
200
- server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
201
- port = await listenAsync(server)
202
-
203
- // user_namespace is a regex like `@.*:localhost`; the part after the last
204
- // `:` is the homeserver's server_name. Fall back to the homeserver URL's
205
- // host if the namespace shape is unexpected.
206
- const serverName =
207
- matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
208
- new URL(matrix.transport.homeserver).hostname
209
- const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
233
+ if (shouldBindHttpListener(mode)) {
234
+ const requestedPort = matrix.transport.port ?? 9000
235
+ // Bind 0.0.0.0 explicitly @hono/node-server defaults to IPv6-only on
236
+ // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
237
+ // events back to host.docker.internal:<port>.
238
+ server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
239
+ port = await listenAsync(server)
240
+ } else {
241
+ // Pull (client) mode runs outbound /sync loops; no inbound listener.
242
+ port = 0
243
+ }
210
244
  const spaceLocalpart = matrix.transport.space ?? 'dev'
211
245
  const adminUserIds = opts.adminUserId ? [opts.adminUserId] : []
212
246
  let spaceRoomId: string | undefined
@@ -233,6 +267,14 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
233
267
  // m.space.child while joining it.
234
268
  await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds })
235
269
 
270
+ // Pull mode: start the outbound /sync loops after bootstrap so the rooms
271
+ // each agent syncs already exist. Loops long-poll until stop().
272
+ syncLoops = transport.syncLoops
273
+ if (syncLoops?.length) {
274
+ for (const loop of syncLoops) void loop.run()
275
+ console.log(`[matrix] pull mode: ${syncLoops.length} sync loop(s) running`)
276
+ }
277
+
236
278
  if (spaceRoomId) {
237
279
  try {
238
280
  const generalRoomId = await ensureDefaultChannel({
@@ -253,7 +295,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
253
295
  asUserId,
254
296
  getAgents: () => bindings,
255
297
  })
256
- console.log(`[matrix] published eco.zoon.workforce (${bindings.length} agents)`)
298
+ console.log(`[matrix] published dev.zooid.workforce (${bindings.length} agents)`)
257
299
  void publisher
258
300
  } catch (err) {
259
301
  console.warn('[matrix] workforce roster publication failed:', err)
@@ -272,6 +314,11 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
272
314
  const stop = async (): Promise<void> => {
273
315
  if (stopped) return whenStopped
274
316
  stopped = true
317
+ try {
318
+ if (syncLoops) for (const loop of syncLoops) loop.stop()
319
+ } catch {
320
+ // swallow
321
+ }
275
322
  try {
276
323
  if (server) await closeAsync(server)
277
324
  } catch {
@@ -0,0 +1,38 @@
1
+ import { mkdtempSync, rmSync, existsSync, readFileSync } 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 { makeSyncCursorStore } from './sync-cursors.js'
6
+
7
+ let agentsDir: string
8
+ beforeEach(() => {
9
+ agentsDir = mkdtempSync(join(tmpdir(), 'zooid-agents-'))
10
+ })
11
+ afterEach(() => rmSync(agentsDir, { recursive: true, force: true }))
12
+
13
+ describe('makeSyncCursorStore', () => {
14
+ it('returns null before any save (cold start)', () => {
15
+ const store = makeSyncCursorStore(agentsDir)
16
+ expect(store.loadSince('docs')).toBeNull()
17
+ })
18
+
19
+ it('round-trips a since cursor per agent name', () => {
20
+ const store = makeSyncCursorStore(agentsDir)
21
+ store.saveSince('docs', 's42')
22
+ store.saveSince('triage', 's7')
23
+ expect(store.loadSince('docs')).toBe('s42')
24
+ expect(store.loadSince('triage')).toBe('s7')
25
+ })
26
+
27
+ it('persists across store instances (simulates daemon restart)', () => {
28
+ makeSyncCursorStore(agentsDir).saveSince('docs', 's99')
29
+ expect(makeSyncCursorStore(agentsDir).loadSince('docs')).toBe('s99')
30
+ })
31
+
32
+ it('writes sync-since beside sessions.json under <agentsDir>/<agentName>/', () => {
33
+ makeSyncCursorStore(agentsDir).saveSince('docs', 's1')
34
+ const path = join(agentsDir, 'docs', 'sync-since')
35
+ expect(existsSync(path)).toBe(true)
36
+ expect(readFileSync(path, 'utf8')).toBe('s1')
37
+ })
38
+ })
@@ -0,0 +1,31 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export interface SyncCursorStore {
5
+ loadSince(agentName: string): string | null
6
+ saveSince(agentName: string, since: string): void
7
+ }
8
+
9
+ /**
10
+ * File-backed `since` cursor store: `<agentsDir>/<agentName>/sync-since`, next
11
+ * to the `(threadId → sessionId)` `sessions.json` buildAcpRegistry writes. Same
12
+ * per-agent durable-state root, so an agent's restart-surviving state lives in
13
+ * one folder. Agent names are validated kebab-case keys (ZOD063 isValidAgentKey),
14
+ * so they're safe path segments — no hashing needed.
15
+ */
16
+ export function makeSyncCursorStore(agentsDir: string): SyncCursorStore {
17
+ const fileFor = (agentName: string): string => join(agentsDir, agentName, 'sync-since')
18
+ return {
19
+ loadSince(agentName) {
20
+ try {
21
+ return readFileSync(fileFor(agentName), 'utf8').trim() || null
22
+ } catch {
23
+ return null
24
+ }
25
+ },
26
+ saveSince(agentName, since) {
27
+ mkdirSync(join(agentsDir, agentName), { recursive: true })
28
+ writeFileSync(fileFor(agentName), since, 'utf8')
29
+ },
30
+ }
31
+ }
@@ -10,26 +10,26 @@ import { makeBundleTgz } from './test-helpers.js'
10
10
  let server: Server
11
11
  let base = ''
12
12
  const { tgz, integrity } = makeBundleTgz({
13
- 'package/package.json': '{"name":"@zooid/zoon-web"}',
13
+ 'package/package.json': '{"name":"@zooid/web"}',
14
14
  'package/dist/index.html': '<html>integration</html>',
15
15
  })
16
16
 
17
17
  beforeAll(async () => {
18
18
  server = createServer((req, res) => {
19
- if (req.url === '/@zooid/zoon-web') {
19
+ if (req.url === '/@zooid/web') {
20
20
  res.setHeader('content-type', 'application/json')
21
21
  res.end(
22
22
  JSON.stringify({
23
23
  versions: {
24
24
  '0.1.0': {
25
- dist: { tarball: `${base}/@zooid/zoon-web/-/zoon-web-0.1.0.tgz`, integrity },
25
+ dist: { tarball: `${base}/@zooid/web/-/web-0.1.0.tgz`, integrity },
26
26
  },
27
27
  },
28
28
  }),
29
29
  )
30
30
  return
31
31
  }
32
- if (req.url === '/@zooid/zoon-web/-/zoon-web-0.1.0.tgz') {
32
+ if (req.url === '/@zooid/web/-/web-0.1.0.tgz') {
33
33
  res.setHeader('content-type', 'application/octet-stream')
34
34
  res.end(tgz)
35
35
  return
@@ -9,14 +9,14 @@ function registryFetch(opts: { tgz: Buffer; integrity: string; version?: string
9
9
  const version = opts.version ?? '0.1.0'
10
10
  return vi.fn(async (url: string | URL) => {
11
11
  const u = String(url)
12
- if (u === 'https://registry.npmjs.org/@zooid/zoon-web') {
12
+ if (u === 'https://registry.npmjs.org/@zooid/web') {
13
13
  return new Response(
14
14
  JSON.stringify({
15
15
  'dist-tags': { latest: version },
16
16
  versions: {
17
17
  [version]: {
18
18
  dist: {
19
- tarball: `https://registry.npmjs.org/@zooid/zoon-web/-/zoon-web-${version}.tgz`,
19
+ tarball: `https://registry.npmjs.org/@zooid/web/-/web-${version}.tgz`,
20
20
  integrity: opts.integrity,
21
21
  },
22
22
  },
@@ -25,7 +25,7 @@ function registryFetch(opts: { tgz: Buffer; integrity: string; version?: string
25
25
  { status: 200 },
26
26
  )
27
27
  }
28
- if (u.endsWith(`/zoon-web-${version}.tgz`)) {
28
+ if (u.endsWith(`/web-${version}.tgz`)) {
29
29
  return new Response(new Uint8Array(opts.tgz), { status: 200 })
30
30
  }
31
31
  return new Response('not found', { status: 404 })
@@ -86,7 +86,7 @@ describe('fetchWebBundle', () => {
86
86
  const { tgz, integrity } = makeBundleTgz()
87
87
  const f = registryFetch({ tgz, integrity, version: '0.0.9' })
88
88
  await expect(fetchWebBundle({ version: '0.1.0', cacheDir, fetch: f })).rejects.toThrow(
89
- /@zooid\/zoon-web@0\.1\.0/,
89
+ /@zooid\/web@0\.1\.0/,
90
90
  )
91
91
  } finally {
92
92
  teardown()
package/src/web/fetch.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from 'node:path'
3
3
  import { createHash, randomUUID } from 'node:crypto'
4
4
  import * as tar from 'tar'
5
5
 
6
- const PKG = '@zooid/zoon-web'
6
+ const PKG = '@zooid/web'
7
7
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
8
8
 
9
9
  export interface FetchWebBundleOptions {
@@ -14,7 +14,7 @@ export interface FetchWebBundleOptions {
14
14
  }
15
15
 
16
16
  /**
17
- * Ensure <cacheDir>/<version> holds the extracted dist/ of @zooid/zoon-web.
17
+ * Ensure <cacheDir>/<version> holds the extracted dist/ of @zooid/web.
18
18
  * Atomic: extracts into a temp dir and renames, so a crash never leaves a
19
19
  * half-populated version dir. A populated version dir (index.html present)
20
20
  * is the completion marker.
@@ -9,10 +9,10 @@ beforeEach(() => (dir = mkdtempSync(join(tmpdir(), 'zooid-pin-'))))
9
9
  afterEach(() => rmSync(dir, { recursive: true, force: true }))
10
10
 
11
11
  describe('readZoonWebPin', () => {
12
- it('reads zooid.zoonWebVersion from the cli package.json', () => {
12
+ it('reads zooid.webVersion from the cli package.json', () => {
13
13
  writeFileSync(
14
14
  join(dir, 'package.json'),
15
- JSON.stringify({ name: 'zooid', zooid: { zoonWebVersion: '0.1.0' } }),
15
+ JSON.stringify({ name: 'zooid', zooid: { webVersion: '0.1.0' } }),
16
16
  )
17
17
  expect(readZoonWebPin(dir)).toBe('0.1.0')
18
18
  })
package/src/web/pin.ts CHANGED
@@ -4,9 +4,9 @@ import { join } from 'node:path'
4
4
  export function readZoonWebPin(cliRoot: string): string | undefined {
5
5
  try {
6
6
  const pkg = JSON.parse(readFileSync(join(cliRoot, 'package.json'), 'utf8')) as {
7
- zooid?: { zoonWebVersion?: string }
7
+ zooid?: { webVersion?: string }
8
8
  }
9
- return pkg.zooid?.zoonWebVersion
9
+ return pkg.zooid?.webVersion
10
10
  } catch {
11
11
  return undefined
12
12
  }
@@ -16,10 +16,10 @@ afterEach(() => {
16
16
  function makeSibling(rootDir: string): { cliRoot: string; webDist: string } {
17
17
  const cliRoot = join(rootDir, 'zooid', 'packages', 'cli')
18
18
  mkdirSync(cliRoot, { recursive: true })
19
- const webPkg = join(rootDir, 'zoon', 'packages', 'web')
19
+ const webPkg = join(rootDir, 'zooid-clients', 'packages', 'web')
20
20
  const webDist = join(webPkg, 'dist')
21
21
  mkdirSync(webDist, { recursive: true })
22
- writeFileSync(join(webPkg, 'package.json'), '{"name":"@zooid/zoon-web"}')
22
+ writeFileSync(join(webPkg, 'package.json'), '{"name":"@zooid/web"}')
23
23
  writeFileSync(join(webDist, 'index.html'), '<html/>')
24
24
  return { cliRoot, webDist }
25
25
  }
@@ -62,14 +62,14 @@ describe('ensureWebRoot', () => {
62
62
  mkdirSync(cliRoot, { recursive: true })
63
63
  await expect(
64
64
  ensureWebRoot({ cliRoot, cacheDir: join(root, 'cache'), version: undefined, fetchBundle: vi.fn() }),
65
- ).rejects.toThrow(/zoonWebVersion/)
65
+ ).rejects.toThrow(/webVersion/)
66
66
  })
67
67
  })
68
68
 
69
69
  describe('webSourcePackage', () => {
70
- it('returns the source package dir when sibling zoon/packages/web exists', () => {
70
+ it('returns the source package dir when sibling zooid-clients/packages/web exists', () => {
71
71
  const { cliRoot } = makeSibling(root)
72
- expect(webSourcePackage(cliRoot)).toBe(join(root, 'zoon', 'packages', 'web'))
72
+ expect(webSourcePackage(cliRoot)).toBe(join(root, 'zooid-clients', 'packages', 'web'))
73
73
  })
74
74
 
75
75
  it('returns null outside the monorepo', () => {
@@ -23,21 +23,21 @@ export async function ensureWebRoot(opts: EnsureWebRootOptions): Promise<string>
23
23
 
24
24
  if (!opts.version) {
25
25
  throw new Error(
26
- `No @zooid/zoon-web version pin (zooid.zoonWebVersion) in the cli package.json ` +
26
+ `No @zooid/web version pin (zooid.webVersion) in the cli package.json ` +
27
27
  `and no monorepo sibling build found.\n` +
28
28
  `Set ${ENV_OVERRIDE} to a built dist, or run from the monorepo.`,
29
29
  )
30
30
  }
31
- opts.onProgress?.(`Fetching @zooid/zoon-web ${opts.version}…`)
31
+ opts.onProgress?.(`Fetching @zooid/web ${opts.version}…`)
32
32
  const fetchBundleFn = opts.fetchBundle ?? fetchWebBundle
33
33
  return fetchBundleFn({ version: opts.version, cacheDir: opts.cacheDir })
34
34
  }
35
35
 
36
- // Returns the path to the in-tree @zooid/zoon-web package (zoon/packages/web) if the
36
+ // Returns the path to the in-tree @zooid/web package (zooid-clients/packages/web) if the
37
37
  // CLI is running from the monorepo source. Returns null when running from an
38
- // installed package (no sibling zoon/ tree).
38
+ // installed package (no sibling zooid-clients/ tree).
39
39
  export function webSourcePackage(cliRoot: string): string | null {
40
40
  const workspaceRoot = dirname(dirname(dirname(cliRoot)))
41
- const candidate = join(workspaceRoot, 'zoon', 'packages', 'web')
41
+ const candidate = join(workspaceRoot, 'zooid-clients', 'packages', 'web')
42
42
  return existsSync(join(candidate, 'package.json')) ? candidate : null
43
43
  }
@@ -6,7 +6,7 @@ import * as tar from 'tar'
6
6
 
7
7
  export function makeBundleTgz(
8
8
  files: Record<string, string> = {
9
- 'package/package.json': '{"name":"@zooid/zoon-web"}',
9
+ 'package/package.json': '{"name":"@zooid/web"}',
10
10
  'package/dist/index.html': '<html>zoon</html>',
11
11
  'package/dist/assets/app.js': '// app',
12
12
  },