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.
- package/README.md +5 -5
- package/dist/bin.js +195 -156
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-R6EDLH23.js → chunk-PDSJJWQW.js} +1928 -990
- package/dist/chunk-PDSJJWQW.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/bin.ts +1 -1
- package/src/bootstrap/configs.ts +19 -6
- package/src/bootstrap/derive.ts +3 -2
- package/src/bootstrap/identity-bootstrap.integration.test.ts +53 -0
- package/src/bootstrap/registration-url.test.ts +14 -0
- package/src/bootstrap/registration-url.ts +7 -0
- package/src/commands/dev.ts +9 -9
- package/src/commands/init/generators.test.ts +30 -12
- package/src/commands/init/generators.ts +23 -12
- package/src/commands/init/prompts.ts +9 -34
- package/src/commands/init/registry.test.ts +2 -2
- package/src/commands/init/registry.ts +9 -8
- package/src/commands/init/scaffold-roundtrip.test.ts +38 -0
- package/src/commands/init.test.ts +17 -12
- package/src/commands/init.ts +2 -4
- package/src/daemon/pull-wiring.test.ts +14 -0
- package/src/daemon/pull-wiring.ts +4 -0
- package/src/daemon/start-daemon-pull.integration.test.ts +95 -0
- package/src/daemon/start-daemon.ts +62 -15
- package/src/daemon/sync-cursors.test.ts +38 -0
- package/src/daemon/sync-cursors.ts +31 -0
- package/src/web/fetch.integration.test.ts +4 -4
- package/src/web/fetch.test.ts +4 -4
- package/src/web/fetch.ts +2 -2
- package/src/web/pin.test.ts +2 -2
- package/src/web/pin.ts +2 -2
- package/src/web/resolve.test.ts +5 -5
- package/src/web/resolve.ts +5 -5
- package/src/web/test-helpers.ts +1 -1
- 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
|
|
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-
|
|
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
|
-
|
|
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'
|
|
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'
|
|
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
|
})
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
+
/** Optional model pin. Omitted by default — the harness uses its own default. */
|
|
25
25
|
model?: string
|
|
26
|
-
/**
|
|
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,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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
package/src/web/fetch.test.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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(`/
|
|
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\/
|
|
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/
|
|
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/
|
|
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.
|
package/src/web/pin.test.ts
CHANGED
|
@@ -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.
|
|
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: {
|
|
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?: {
|
|
7
|
+
zooid?: { webVersion?: string }
|
|
8
8
|
}
|
|
9
|
-
return pkg.zooid?.
|
|
9
|
+
return pkg.zooid?.webVersion
|
|
10
10
|
} catch {
|
|
11
11
|
return undefined
|
|
12
12
|
}
|
package/src/web/resolve.test.ts
CHANGED
|
@@ -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, '
|
|
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/
|
|
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(/
|
|
65
|
+
).rejects.toThrow(/webVersion/)
|
|
66
66
|
})
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
describe('webSourcePackage', () => {
|
|
70
|
-
it('returns the source package dir when sibling
|
|
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, '
|
|
72
|
+
expect(webSourcePackage(cliRoot)).toBe(join(root, 'zooid-clients', 'packages', 'web'))
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
it('returns null outside the monorepo', () => {
|
package/src/web/resolve.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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
|
|
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, '
|
|
41
|
+
const candidate = join(workspaceRoot, 'zooid-clients', 'packages', 'web')
|
|
42
42
|
return existsSync(join(candidate, 'package.json')) ? candidate : null
|
|
43
43
|
}
|
package/src/web/test-helpers.ts
CHANGED
|
@@ -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/
|
|
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
|
},
|