zooid 0.9.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.
@@ -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,
@@ -191,6 +208,9 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
191
208
  matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
192
209
  new URL(matrix.transport.homeserver).hostname
193
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]))
194
214
  const transport = createMatrixTransport({
195
215
  agents: registry,
196
216
  approvals,
@@ -200,13 +220,27 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
200
220
  adminUserId: opts.adminUserId,
201
221
  botUserId: asUserId,
202
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
+ },
203
232
  })
204
- const requestedPort = matrix.transport.port ?? 9000
205
- // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
206
- // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
207
- // events back to host.docker.internal:<port>.
208
- server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
209
- port = await listenAsync(server)
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({
@@ -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
+ }
@@ -16,7 +16,7 @@ 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
22
  writeFileSync(join(webPkg, 'package.json'), '{"name":"@zooid/web"}')
@@ -69,7 +69,7 @@ describe('ensureWebRoot', () => {
69
69
  describe('webSourcePackage', () => {
70
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', () => {
@@ -35,9 +35,9 @@ export async function ensureWebRoot(opts: EnsureWebRootOptions): Promise<string>
35
35
 
36
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
  }