zooid 0.15.0 → 0.16.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 +1 -1
- package/dist/bin.js +49 -2
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-35IKQPC5.js → chunk-HE7DVDJA.js} +1294 -772
- package/dist/chunk-HE7DVDJA.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/build-registry.context.test.ts +1 -0
- package/src/build-registry.test.ts +1 -0
- package/src/build-registry.ts +3 -0
- package/src/build-registry.zod043.test.ts +1 -0
- package/src/build-registry.zod044.test.ts +1 -0
- package/src/commands/dev.ts +3 -0
- package/src/commands/start.ts +2 -0
- package/src/daemon/start-daemon.ts +3 -0
- package/src/observability/capture-agent.ts +35 -1
- package/src/observability/lifecycle-log.test.ts +48 -0
- package/src/observability/lifecycle-log.ts +29 -0
- package/src/observability/observability.integration.test.ts +22 -0
- package/src/web/static.test.ts +14 -0
- package/src/web/static.ts +3 -0
- package/dist/chunk-35IKQPC5.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AcpRuntime, ApprovalCorrelator, TapEvent, ZooidConfig, AcpAgentRegistry } from '@zooid/core';
|
|
1
|
+
import { AcpRuntime, ApprovalCorrelator, TapEvent, SessionLifecycleEvent, ZooidConfig, AcpAgentRegistry } from '@zooid/core';
|
|
2
2
|
import { SpawnRegistry } from '@zooid/context-mcp';
|
|
3
3
|
|
|
4
4
|
interface BuildAcpRegistryOptions {
|
|
@@ -8,6 +8,7 @@ interface BuildAcpRegistryOptions {
|
|
|
8
8
|
approvals?: ApprovalCorrelator;
|
|
9
9
|
/** Observability tap forwarded to each AcpClient. */
|
|
10
10
|
onTap?: (agentName: string, event: TapEvent) => void;
|
|
11
|
+
onLifecycle?: (agentName: string, event: SessionLifecycleEvent) => void;
|
|
11
12
|
/**
|
|
12
13
|
* Per-agent state root (`<dataRoot>/agents/`). When set, each AcpClient
|
|
13
14
|
* persists its `(threadId → sessionId)` map under
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"zooid": {
|
|
28
|
-
"webVersion": "0.
|
|
28
|
+
"webVersion": "0.13.0"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=22"
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"tar": "^7.5.16",
|
|
44
44
|
"web-push": "^3.6.7",
|
|
45
45
|
"yaml": "^2.5.0",
|
|
46
|
-
"@zooid/
|
|
47
|
-
"@zooid/
|
|
48
|
-
"@zooid/
|
|
49
|
-
"@zooid/
|
|
50
|
-
"@zooid/pi-extension": "^0.13.
|
|
51
|
-
"@zooid/runtime-
|
|
52
|
-
"@zooid/transport-matrix": "^0.
|
|
53
|
-
"@zooid/transport-http": "^0.
|
|
46
|
+
"@zooid/context-mcp": "^0.16.1",
|
|
47
|
+
"@zooid/acp-client": "^0.16.1",
|
|
48
|
+
"@zooid/runtime-local": "^0.16.1",
|
|
49
|
+
"@zooid/core": "^0.16.1",
|
|
50
|
+
"@zooid/pi-extension": "^0.13.3",
|
|
51
|
+
"@zooid/runtime-docker": "^0.16.1",
|
|
52
|
+
"@zooid/transport-matrix": "^0.16.1",
|
|
53
|
+
"@zooid/transport-http": "^0.16.1"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@agentclientprotocol/sdk": "^0.21.0",
|
package/src/build-registry.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ContextSpawnFactory,
|
|
11
11
|
type MountConfig,
|
|
12
12
|
type TapEvent,
|
|
13
|
+
type SessionLifecycleEvent,
|
|
13
14
|
type ZooidConfig,
|
|
14
15
|
type TransportContextProvider,
|
|
15
16
|
} from '@zooid/core'
|
|
@@ -29,6 +30,7 @@ export interface BuildAcpRegistryOptions {
|
|
|
29
30
|
approvals?: ApprovalCorrelator
|
|
30
31
|
/** Observability tap forwarded to each AcpClient. */
|
|
31
32
|
onTap?: (agentName: string, event: TapEvent) => void
|
|
33
|
+
onLifecycle?: (agentName: string, event: SessionLifecycleEvent) => void
|
|
32
34
|
/**
|
|
33
35
|
* Per-agent state root (`<dataRoot>/agents/`). When set, each AcpClient
|
|
34
36
|
* persists its `(threadId → sessionId)` map under
|
|
@@ -314,6 +316,7 @@ export function buildAcpRegistry(
|
|
|
314
316
|
cwd: cwdByAgent,
|
|
315
317
|
approvals: opts.approvals,
|
|
316
318
|
onTap: opts.onTap,
|
|
319
|
+
onLifecycle: opts.onLifecycle,
|
|
317
320
|
agentsDir: opts.agentsDir,
|
|
318
321
|
contextSpawns,
|
|
319
322
|
onSessionEstablished: (agentName, sessionKey, sessionId) =>
|
package/src/commands/dev.ts
CHANGED
|
@@ -208,6 +208,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
208
208
|
// the web client without needing an invite.
|
|
209
209
|
publicWorkforceSpace: true,
|
|
210
210
|
onTap: (agentName, event) => captures[agentName]?.onTap(event),
|
|
211
|
+
onLifecycle: (agentName, event) => captures[agentName]?.onLifecycle(event),
|
|
211
212
|
prepullLog: (line) => {
|
|
212
213
|
t.output = line.replace(/^\[zooid\]\s+/, '').trim()
|
|
213
214
|
},
|
|
@@ -251,6 +252,8 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
251
252
|
const app = webStatic({
|
|
252
253
|
webRoot,
|
|
253
254
|
homeserverUrl: homeserver,
|
|
255
|
+
// Same default as the daemon's space provisioning (start-daemon.ts).
|
|
256
|
+
workforceSpace: matrix.transport.space ?? 'dev',
|
|
254
257
|
...(ctx.daemon?.vapidPublicKey
|
|
255
258
|
? {
|
|
256
259
|
// Tuwunel runs in a container; `localhost` here would
|
package/src/commands/start.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
import type { CliFlags } from '@zooid/core'
|
|
4
4
|
import { resolveDataLayout } from '../bootstrap/data-layout.js'
|
|
5
5
|
import { startDaemon } from '../daemon/start-daemon.js'
|
|
6
|
+
import { logLifecycle } from '../observability/lifecycle-log.js'
|
|
6
7
|
|
|
7
8
|
export interface StartFlags extends CliFlags {
|
|
8
9
|
printToken?: boolean
|
|
@@ -21,6 +22,7 @@ export async function runStart(flags: StartFlags): Promise<void> {
|
|
|
21
22
|
cliFlags: flags,
|
|
22
23
|
installSignalHandlers: true,
|
|
23
24
|
agentsDir: layout.agentsDir,
|
|
25
|
+
onLifecycle: (agentName, event) => logLifecycle(agentName, event),
|
|
24
26
|
})
|
|
25
27
|
console.log(`zooid listening on http://localhost:${handle.port}`)
|
|
26
28
|
for (const name of handle.agentNames) console.log(` agent: ${name}`)
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
mergeCliFlags,
|
|
14
14
|
type CliFlags,
|
|
15
15
|
type TapEvent,
|
|
16
|
+
type SessionLifecycleEvent,
|
|
16
17
|
} from '@zooid/core'
|
|
17
18
|
import { createApp } from '@zooid/transport-http'
|
|
18
19
|
import {
|
|
@@ -49,6 +50,7 @@ export interface StartDaemonOpts {
|
|
|
49
50
|
adminUserId?: string
|
|
50
51
|
/** Observability tap forwarded to each AcpClient. */
|
|
51
52
|
onTap?: (agentName: string, event: TapEvent) => void
|
|
53
|
+
onLifecycle?: (agentName: string, event: SessionLifecycleEvent) => void
|
|
52
54
|
/**
|
|
53
55
|
* Per-agent state root (`<dataRoot>/agents/`). Threaded into
|
|
54
56
|
* `buildAcpRegistry` so each AcpClient persists `sessionId`s across
|
|
@@ -137,6 +139,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
137
139
|
const registry = buildAcpRegistry(config, {
|
|
138
140
|
approvals,
|
|
139
141
|
onTap: opts.onTap,
|
|
142
|
+
onLifecycle: opts.onLifecycle,
|
|
140
143
|
agentsDir: opts.agentsDir,
|
|
141
144
|
contextSpawnRegistry,
|
|
142
145
|
daemonSockPaths: contextSockets.paths,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TapEvent } from '@zooid/core'
|
|
1
|
+
import type { TapEvent, SessionLifecycleEvent } from '@zooid/core'
|
|
2
2
|
import type { LogPaths } from './paths.js'
|
|
3
3
|
import { JsonlSink, shouldCaptureUpdate, type Verbosity } from './file-sink.js'
|
|
4
4
|
|
|
@@ -19,13 +19,28 @@ export interface WireAgentCaptureOpts {
|
|
|
19
19
|
|
|
20
20
|
export interface AgentCapture {
|
|
21
21
|
onTap: (event: TapEvent) => void
|
|
22
|
+
onLifecycle: (event: SessionLifecycleEvent) => void
|
|
23
|
+
readLifecycleCounters: () => LifecycleCounters
|
|
22
24
|
close: () => Promise<void>
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
export interface LifecycleCounters {
|
|
28
|
+
closeAttempts: number
|
|
29
|
+
closeSuccesses: number
|
|
30
|
+
sessionsReclaimed: number
|
|
31
|
+
recoveries: Record<'cached' | 'resume' | 'load' | 'new', number>
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
export function wireAgentCapture(opts: WireAgentCaptureOpts): AgentCapture {
|
|
26
35
|
const sink = new JsonlSink(opts.paths.agentTap(opts.agent))
|
|
27
36
|
const now = opts.now ?? (() => new Date())
|
|
28
37
|
const pending: Promise<unknown>[] = []
|
|
38
|
+
const counters: LifecycleCounters = {
|
|
39
|
+
closeAttempts: 0,
|
|
40
|
+
closeSuccesses: 0,
|
|
41
|
+
sessionsReclaimed: 0,
|
|
42
|
+
recoveries: { cached: 0, resume: 0, load: 0, new: 0 },
|
|
43
|
+
}
|
|
29
44
|
|
|
30
45
|
const onTap = (event: TapEvent): void => {
|
|
31
46
|
if (event.kind === 'session_update') {
|
|
@@ -50,6 +65,25 @@ export function wireAgentCapture(opts: WireAgentCaptureOpts): AgentCapture {
|
|
|
50
65
|
|
|
51
66
|
return {
|
|
52
67
|
onTap,
|
|
68
|
+
onLifecycle: (event) => {
|
|
69
|
+
if (event.reason && event.outcome !== 'unsupported') counters.closeAttempts++
|
|
70
|
+
if (event.reason && event.outcome === 'closed') {
|
|
71
|
+
counters.closeSuccesses++
|
|
72
|
+
counters.sessionsReclaimed++
|
|
73
|
+
}
|
|
74
|
+
if (event.outcome === 'recovered' && event.recoveryMethod) counters.recoveries[event.recoveryMethod]++
|
|
75
|
+
pending.push(sink.write({
|
|
76
|
+
ts: now().toISOString(),
|
|
77
|
+
agent: opts.agent,
|
|
78
|
+
kind: 'session_lifecycle',
|
|
79
|
+
session_key: event.sessionKey,
|
|
80
|
+
session_id: event.sessionId,
|
|
81
|
+
reason: event.reason,
|
|
82
|
+
outcome: event.outcome,
|
|
83
|
+
recovery_method: event.recoveryMethod,
|
|
84
|
+
}))
|
|
85
|
+
},
|
|
86
|
+
readLifecycleCounters: () => ({ ...counters, recoveries: { ...counters.recoveries } }),
|
|
53
87
|
close: async () => {
|
|
54
88
|
await Promise.all(pending)
|
|
55
89
|
await sink.close()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { SessionLifecycleEvent } from '@zooid/core'
|
|
3
|
+
import { formatLifecycleLine, logLifecycle } from './lifecycle-log.js'
|
|
4
|
+
|
|
5
|
+
const base = { agentId: 'architect', sessionKey: '$root', sessionId: 's-1' }
|
|
6
|
+
|
|
7
|
+
function sink() {
|
|
8
|
+
return { log: vi.fn(), error: vi.fn() }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('lifecycle log', () => {
|
|
12
|
+
it('formats closes with reason and outcome', () => {
|
|
13
|
+
expect(formatLifecycleLine('architect', { ...base, reason: 'idle', outcome: 'closed' })).toBe(
|
|
14
|
+
'[lifecycle] architect key=$root session=s-1 idle → closed',
|
|
15
|
+
)
|
|
16
|
+
expect(formatLifecycleLine('architect', { ...base, reason: 'clear', outcome: 'closed' })).toBe(
|
|
17
|
+
'[lifecycle] architect key=$root session=s-1 clear → closed',
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('formats recoveries with their method', () => {
|
|
22
|
+
expect(
|
|
23
|
+
formatLifecycleLine('architect', { ...base, outcome: 'recovered', recoveryMethod: 'resume' }),
|
|
24
|
+
).toBe('[lifecycle] architect key=$root session=s-1 recovered (resume)')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('skips cached hits, which fire on every prompt to a live session', () => {
|
|
28
|
+
expect(
|
|
29
|
+
formatLifecycleLine('architect', { ...base, outcome: 'recovered', recoveryMethod: 'cached' }),
|
|
30
|
+
).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('routes failed closes to stderr and the rest to stdout', () => {
|
|
34
|
+
const out = sink()
|
|
35
|
+
logLifecycle('architect', { ...base, reason: 'idle', outcome: 'failed' }, out)
|
|
36
|
+
logLifecycle('architect', { ...base, reason: 'idle', outcome: 'closed' }, out)
|
|
37
|
+
expect(out.error).toHaveBeenCalledWith('[lifecycle] architect key=$root session=s-1 idle → failed')
|
|
38
|
+
expect(out.log).toHaveBeenCalledTimes(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('skips unsupported, which the ACP client already warns about', () => {
|
|
42
|
+
const out = sink()
|
|
43
|
+
const event: SessionLifecycleEvent = { ...base, reason: 'idle', outcome: 'unsupported' }
|
|
44
|
+
logLifecycle('architect', event, out)
|
|
45
|
+
expect(out.log).not.toHaveBeenCalled()
|
|
46
|
+
expect(out.error).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SessionLifecycleEvent } from '@zooid/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One grep-able line per ACP session lifecycle event, for `zooid start`'s
|
|
5
|
+
* stdout/stderr ([ZOD089]). Carries identities and outcomes only — never
|
|
6
|
+
* prompt or transcript content. Skipped: `unsupported` (the ACP client
|
|
7
|
+
* already warns once per agent) and `cached` recoveries, which fire on every
|
|
8
|
+
* prompt to a live session and would add a line per message.
|
|
9
|
+
*/
|
|
10
|
+
export function formatLifecycleLine(agentName: string, event: SessionLifecycleEvent): string | null {
|
|
11
|
+
if (event.outcome === 'unsupported') return null
|
|
12
|
+
if (event.recoveryMethod === 'cached') return null
|
|
13
|
+
const what =
|
|
14
|
+
event.outcome === 'recovered'
|
|
15
|
+
? `recovered (${event.recoveryMethod ?? 'unknown'})`
|
|
16
|
+
: `${event.reason ?? 'close'} → ${event.outcome}`
|
|
17
|
+
return `[lifecycle] ${agentName} key=${event.sessionKey} session=${event.sessionId} ${what}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function logLifecycle(
|
|
21
|
+
agentName: string,
|
|
22
|
+
event: SessionLifecycleEvent,
|
|
23
|
+
out: Pick<Console, 'log' | 'error'> = console,
|
|
24
|
+
): void {
|
|
25
|
+
const line = formatLifecycleLine(agentName, event)
|
|
26
|
+
if (!line) return
|
|
27
|
+
if (event.outcome === 'failed') out.error(line)
|
|
28
|
+
else out.log(line)
|
|
29
|
+
}
|
|
@@ -16,6 +16,28 @@ describe('agent capture end-to-end', () => {
|
|
|
16
16
|
rmSync(dataDir, { recursive: true, force: true })
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
+
it('captures lifecycle identity and counts only successful reclamation', async () => {
|
|
20
|
+
const now = new Date('2026-05-06T10:00:00Z')
|
|
21
|
+
const paths = resolveLogPaths({ dataDir, now })
|
|
22
|
+
await ensureDayFolder(paths)
|
|
23
|
+
const cap = wireAgentCapture({ agent: 'docs', paths, verbosity: 'default', matrixContext: () => null, now: () => now })
|
|
24
|
+
cap.onLifecycle({ agentId: 'docs', sessionKey: 'root', sessionId: 's1', outcome: 'recovered', recoveryMethod: 'new' })
|
|
25
|
+
cap.onLifecycle({ agentId: 'docs', sessionKey: 'root', sessionId: 's1', reason: 'idle', outcome: 'closed' })
|
|
26
|
+
cap.onLifecycle({ agentId: 'docs', sessionKey: 'root', sessionId: 's1', outcome: 'recovered', recoveryMethod: 'resume' })
|
|
27
|
+
cap.onLifecycle({ agentId: 'docs', sessionKey: 'root', sessionId: 's1', reason: 'clear', outcome: 'failed' })
|
|
28
|
+
expect(cap.readLifecycleCounters()).toEqual({
|
|
29
|
+
closeAttempts: 2,
|
|
30
|
+
closeSuccesses: 1,
|
|
31
|
+
sessionsReclaimed: 1,
|
|
32
|
+
recoveries: { cached: 0, resume: 1, load: 0, new: 1 },
|
|
33
|
+
})
|
|
34
|
+
await cap.close()
|
|
35
|
+
const rows = (await readFile(paths.agentTap('docs'), 'utf8')).trim().split('\n').map((line) => JSON.parse(line))
|
|
36
|
+
expect(rows).toHaveLength(4)
|
|
37
|
+
expect(rows[1]).toMatchObject({ kind: 'session_lifecycle', session_key: 'root', session_id: 's1', reason: 'idle', outcome: 'closed' })
|
|
38
|
+
expect(rows[1]).not.toHaveProperty('prompt_text')
|
|
39
|
+
})
|
|
40
|
+
|
|
19
41
|
it('writes one JSONL line per tap event with envelope + turn correlation', async () => {
|
|
20
42
|
const now = new Date('2026-05-06T10:00:00Z')
|
|
21
43
|
const paths = resolveLogPaths({ dataDir, now })
|
package/src/web/static.test.ts
CHANGED
|
@@ -64,6 +64,20 @@ describe('webStatic', () => {
|
|
|
64
64
|
})
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
+
it('serves workforce_space when configured, default or custom', async () => {
|
|
68
|
+
for (const space of ['dev', 'hq']) {
|
|
69
|
+
const app = webStatic({
|
|
70
|
+
webRoot: dir,
|
|
71
|
+
homeserverUrl: 'http://localhost:8448',
|
|
72
|
+
workforceSpace: space,
|
|
73
|
+
})
|
|
74
|
+
expect(await (await app.request('/config.json')).json()).toEqual({
|
|
75
|
+
homeserver_url: 'http://localhost:8448',
|
|
76
|
+
workforce_space: space,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
67
81
|
it('omits both when unconfigured, so the client falls back instead of half-subscribing', async () => {
|
|
68
82
|
const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
|
|
69
83
|
expect(await (await app.request('/config.json')).json()).toEqual({
|
package/src/web/static.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { Hono } from 'hono'
|
|
|
5
5
|
export interface WebStaticOpts {
|
|
6
6
|
webRoot: string
|
|
7
7
|
homeserverUrl: string
|
|
8
|
+
/** Workforce space alias localpart, copied from `matrix.space`. */
|
|
9
|
+
workforceSpace?: string
|
|
8
10
|
pushGatewayUrl?: string
|
|
9
11
|
vapidPublicKey?: string
|
|
10
12
|
}
|
|
@@ -33,6 +35,7 @@ export function webStatic(opts: WebStaticOpts): Hono {
|
|
|
33
35
|
app.get('/config.json', (c) =>
|
|
34
36
|
c.json({
|
|
35
37
|
homeserver_url: opts.homeserverUrl,
|
|
38
|
+
...(opts.workforceSpace ? { workforce_space: opts.workforceSpace } : {}),
|
|
36
39
|
...(opts.pushGatewayUrl ? { push_gateway_url: opts.pushGatewayUrl } : {}),
|
|
37
40
|
...(opts.vapidPublicKey ? { vapid_public_key: opts.vapidPublicKey } : {}),
|
|
38
41
|
}),
|