@zgeoff/atc 2.5.1 → 2.6.0
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/package.json +1 -1
- package/src/agents/agent-adapter.ts +22 -0
- package/src/agents/claude-adapter.ts +4 -1
- package/src/agents/codex-adapter.ts +1 -1
- package/src/agents/gateway-adapter.ts +4 -0
- package/src/agents/grok-adapter.ts +1 -1
- package/src/agents/parse-claude-transcript-line.ts +107 -0
- package/src/daemon/build-fleet-events.ts +35 -0
- package/src/daemon/daemon-connection.ts +144 -0
- package/src/daemon/daemon.ts +88 -3
- package/src/daemon/event-signal.ts +54 -0
- package/src/daemon/load-transcript-page.ts +101 -0
- package/src/daemon/sessions.ts +51 -10
- package/src/mcp-server.ts +108 -1
- package/src/protocol/decode-cursor.ts +29 -0
- package/src/protocol/encode-cursor.ts +12 -0
- package/src/protocol/request-param-schemas.ts +18 -0
- package/src/shared/truncate-to-bytes.ts +21 -0
- package/src/store/fleet-entry.ts +20 -0
- package/src/store/run-migrations.ts +44 -3
- package/src/store/state-store.ts +130 -4
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { TranscriptRow } from '../agents/agent-adapter';
|
|
2
|
+
|
|
3
|
+
export interface TranscriptPosition {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly offset: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TranscriptPage {
|
|
9
|
+
readonly rows: TranscriptRow[];
|
|
10
|
+
readonly offset: number;
|
|
11
|
+
readonly more: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TranscriptPageRequest {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly from: TranscriptPosition | null;
|
|
17
|
+
readonly limit: number;
|
|
18
|
+
readonly maxBytes: number;
|
|
19
|
+
readonly parseLine: (line: string) => TranscriptRow | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadTranscriptPage(req: TranscriptPageRequest): Promise<TranscriptPage> {
|
|
23
|
+
const file = Bun.file(req.path);
|
|
24
|
+
|
|
25
|
+
if (!(await file.exists())) {
|
|
26
|
+
return {
|
|
27
|
+
rows: [],
|
|
28
|
+
offset: req.from?.path === req.path ? req.from.offset : 0,
|
|
29
|
+
more: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const size = file.size;
|
|
34
|
+
|
|
35
|
+
// One read window; a line longer than this is skipped unparsed.
|
|
36
|
+
const windowBytes = 1_048_576;
|
|
37
|
+
|
|
38
|
+
// A cursor from another file, or past the end of a replaced one, starts over.
|
|
39
|
+
let offset =
|
|
40
|
+
req.from !== null && req.from.path === req.path && req.from.offset <= size
|
|
41
|
+
? req.from.offset
|
|
42
|
+
: 0;
|
|
43
|
+
|
|
44
|
+
const rows: TranscriptRow[] = [];
|
|
45
|
+
|
|
46
|
+
const decoder = new TextDecoder();
|
|
47
|
+
|
|
48
|
+
let bytes = 0;
|
|
49
|
+
let skipping = false;
|
|
50
|
+
|
|
51
|
+
while (offset < size) {
|
|
52
|
+
const chunk = await file.slice(offset, Math.min(offset + windowBytes, size)).bytes();
|
|
53
|
+
|
|
54
|
+
let lineStart = 0;
|
|
55
|
+
let newline = chunk.indexOf(0x0a, lineStart);
|
|
56
|
+
|
|
57
|
+
if (newline === -1) {
|
|
58
|
+
// A trailing partial line is left for the next read. A short read means the
|
|
59
|
+
// file shrank underneath us, so it ends the scan rather than skipping a line.
|
|
60
|
+
if (chunk.length < Math.min(windowBytes, size - offset) || offset + chunk.length >= size) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
offset += chunk.length;
|
|
65
|
+
skipping = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
while (newline !== -1) {
|
|
70
|
+
const row = skipping
|
|
71
|
+
? null
|
|
72
|
+
: req.parseLine(decoder.decode(chunk.subarray(lineStart, newline)));
|
|
73
|
+
|
|
74
|
+
skipping = false;
|
|
75
|
+
|
|
76
|
+
if (row !== null) {
|
|
77
|
+
const rowBytes = Buffer.byteLength(JSON.stringify(row));
|
|
78
|
+
|
|
79
|
+
if (rows.length > 0 && bytes + rowBytes > req.maxBytes) {
|
|
80
|
+
return { rows, offset: offset + lineStart, more: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
rows.push(row);
|
|
84
|
+
|
|
85
|
+
bytes += rowBytes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
lineStart = newline + 1;
|
|
89
|
+
|
|
90
|
+
if (rows.length >= req.limit) {
|
|
91
|
+
return { rows, offset: offset + lineStart, more: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
newline = chunk.indexOf(0x0a, lineStart);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
offset += lineStart;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { rows, offset, more: false };
|
|
101
|
+
}
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { collectCleanEnv } from '../shared/collect-clean-env';
|
|
|
7
7
|
import { socketPath, statusFile } from '../shared/config';
|
|
8
8
|
import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
9
9
|
import type { SessionID } from '../shared/session-id';
|
|
10
|
+
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
10
11
|
import type { FleetEntry, FleetStore } from '../store/fleet-entry';
|
|
11
12
|
import type { HookEvent } from './hooks';
|
|
12
13
|
import { mintSessionID } from './mint-session-id';
|
|
@@ -75,6 +76,16 @@ export interface Session {
|
|
|
75
76
|
// The session that spawned this one as a sub-session; null for a
|
|
76
77
|
// top-level session. One level deep: a sub-session never owns another.
|
|
77
78
|
parent: SessionID | null;
|
|
79
|
+
|
|
80
|
+
// the prompt the session was spawned with
|
|
81
|
+
prompt?: string;
|
|
82
|
+
|
|
83
|
+
// the agent's final message from its latest finished turn, capped in size
|
|
84
|
+
result?: string;
|
|
85
|
+
|
|
86
|
+
// the transcript file the agent's hooks last reported. Kept apart from the
|
|
87
|
+
// resume check so a restored path never changes whether a revive is allowed.
|
|
88
|
+
transcriptPath?: string;
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
export class SessionManager {
|
|
@@ -180,6 +191,9 @@ export class SessionManager {
|
|
|
180
191
|
namedBy: 'auto',
|
|
181
192
|
createdAt: Date.now(),
|
|
182
193
|
parent: this.findByAgentSessionID(entry.parent)?.id ?? null,
|
|
194
|
+
...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
|
|
195
|
+
...(entry.result === undefined ? {} : { result: entry.result }),
|
|
196
|
+
...(entry.transcriptPath === undefined ? {} : { transcriptPath: entry.transcriptPath }),
|
|
183
197
|
};
|
|
184
198
|
|
|
185
199
|
this.sessions.push(session);
|
|
@@ -385,6 +399,7 @@ export class SessionManager {
|
|
|
385
399
|
namedBy,
|
|
386
400
|
createdAt: Date.now(),
|
|
387
401
|
parent,
|
|
402
|
+
...(prompt === '' ? {} : { prompt }),
|
|
388
403
|
};
|
|
389
404
|
|
|
390
405
|
pty.onData((d) => {
|
|
@@ -442,9 +457,9 @@ export class SessionManager {
|
|
|
442
457
|
return this.sessions.filter((s) => s.parent === id);
|
|
443
458
|
}
|
|
444
459
|
|
|
445
|
-
// Returns the normalized event
|
|
446
|
-
//
|
|
447
|
-
applyHook(e: HookEvent): AdapterEvent
|
|
460
|
+
// Returns the normalized event so the caller can key lifecycle bookkeeping
|
|
461
|
+
// and the event trail on it, or null when no session or adapter matches.
|
|
462
|
+
applyHook(e: HookEvent): AdapterEvent | null {
|
|
448
463
|
const s = this.sessions.find((x) => x.id === e.atcId);
|
|
449
464
|
|
|
450
465
|
if (!s) {
|
|
@@ -462,15 +477,26 @@ export class SessionManager {
|
|
|
462
477
|
// Reporters belong to the terminal process; once a session is headless,
|
|
463
478
|
// late reports from the dying terminal must not clobber its state.
|
|
464
479
|
if (s.kind === 'headless') {
|
|
465
|
-
return ev
|
|
480
|
+
return ev;
|
|
466
481
|
}
|
|
467
482
|
|
|
468
483
|
const focused = this.focusedId === s.id;
|
|
469
484
|
let dirty = false;
|
|
485
|
+
let persist = false;
|
|
486
|
+
const rowUpdate: { result?: string; transcriptPath?: string } = {};
|
|
487
|
+
|
|
488
|
+
if (ev.transcriptSource !== undefined) {
|
|
489
|
+
s.transcriptSource = ev.transcriptSource;
|
|
490
|
+
|
|
491
|
+
if (s.transcriptPath !== ev.transcriptSource) {
|
|
492
|
+
s.transcriptPath = ev.transcriptSource;
|
|
493
|
+
rowUpdate.transcriptPath = ev.transcriptSource;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
470
496
|
|
|
471
497
|
if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
|
|
472
498
|
s.agentSessionID = ev.agentSessionID;
|
|
473
|
-
|
|
499
|
+
persist = true;
|
|
474
500
|
dirty = true;
|
|
475
501
|
}
|
|
476
502
|
|
|
@@ -478,10 +504,6 @@ export class SessionManager {
|
|
|
478
504
|
s.lastDetail = ev.detail;
|
|
479
505
|
}
|
|
480
506
|
|
|
481
|
-
if (ev.transcriptSource !== undefined) {
|
|
482
|
-
s.transcriptSource = ev.transcriptSource;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
507
|
if (ev.nameSource !== undefined) {
|
|
486
508
|
void this.refreshName(s, ev.nameSource);
|
|
487
509
|
}
|
|
@@ -507,6 +529,13 @@ export class SessionManager {
|
|
|
507
529
|
s.unread = !focused;
|
|
508
530
|
s.lastMsg = 'turn done';
|
|
509
531
|
dirty = true;
|
|
532
|
+
|
|
533
|
+
if (ev.result !== undefined) {
|
|
534
|
+
// A runaway final message cannot bloat the fleet row.
|
|
535
|
+
s.result = truncateToBytes(ev.result, 16_384);
|
|
536
|
+
rowUpdate.result = s.result;
|
|
537
|
+
}
|
|
538
|
+
|
|
510
539
|
break;
|
|
511
540
|
}
|
|
512
541
|
case 'prompt-submitted': {
|
|
@@ -537,12 +566,21 @@ export class SessionManager {
|
|
|
537
566
|
}
|
|
538
567
|
}
|
|
539
568
|
|
|
569
|
+
// A fleet rewrite marks every session without a PTY as exited, so only a
|
|
570
|
+
// new agent session id (which adds or re-keys a row) takes it. Anything
|
|
571
|
+
// else touches this session's own row.
|
|
572
|
+
if (persist) {
|
|
573
|
+
void this.writeFleet();
|
|
574
|
+
} else if (s.agentSessionID !== undefined) {
|
|
575
|
+
void this.store.updateFleetEntry(s.agentSessionID, rowUpdate);
|
|
576
|
+
}
|
|
577
|
+
|
|
540
578
|
if (dirty) {
|
|
541
579
|
this.onEvent('state', s);
|
|
542
580
|
this.emitChange();
|
|
543
581
|
}
|
|
544
582
|
|
|
545
|
-
return ev
|
|
583
|
+
return ev;
|
|
546
584
|
}
|
|
547
585
|
|
|
548
586
|
private async refreshName(s: Session, source: string) {
|
|
@@ -731,6 +769,9 @@ export class SessionManager {
|
|
|
731
769
|
lastAttachedAt: s.lastAttachedAt,
|
|
732
770
|
...(live ? {} : { exited: true }),
|
|
733
771
|
...(parent === undefined ? {} : { parent }),
|
|
772
|
+
...(s.prompt === undefined ? {} : { prompt: s.prompt }),
|
|
773
|
+
...(s.result === undefined ? {} : { result: s.result }),
|
|
774
|
+
...(s.transcriptPath === undefined ? {} : { transcriptPath: s.transcriptPath }),
|
|
734
775
|
});
|
|
735
776
|
}
|
|
736
777
|
|
package/src/mcp-server.ts
CHANGED
|
@@ -53,6 +53,53 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
53
53
|
{ io: 'input' },
|
|
54
54
|
);
|
|
55
55
|
|
|
56
|
+
const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
57
|
+
SESSION_ID_BASE.extend({
|
|
58
|
+
cursor: z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe(
|
|
62
|
+
'The cursor a previous atc_session_read returned; omit to read from the start of the conversation',
|
|
63
|
+
),
|
|
64
|
+
limit: z
|
|
65
|
+
.number()
|
|
66
|
+
.int()
|
|
67
|
+
.min(1)
|
|
68
|
+
.max(200)
|
|
69
|
+
.optional()
|
|
70
|
+
.describe('Most rows to return; defaults to 50'),
|
|
71
|
+
}).strict(),
|
|
72
|
+
{ io: 'input' },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
76
|
+
z.strictObject({
|
|
77
|
+
cursor: z
|
|
78
|
+
.string()
|
|
79
|
+
.optional()
|
|
80
|
+
.describe(
|
|
81
|
+
'The cursor a previous atc_events_read returned; omit to get the most recent events',
|
|
82
|
+
),
|
|
83
|
+
limit: z
|
|
84
|
+
.number()
|
|
85
|
+
.int()
|
|
86
|
+
.min(1)
|
|
87
|
+
.max(200)
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('Most events to return; defaults to 50'),
|
|
90
|
+
waitMs: z
|
|
91
|
+
.number()
|
|
92
|
+
.int()
|
|
93
|
+
.min(0)
|
|
94
|
+
.max(30_000)
|
|
95
|
+
.optional()
|
|
96
|
+
.describe(
|
|
97
|
+
'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
|
|
98
|
+
),
|
|
99
|
+
}),
|
|
100
|
+
{ io: 'input' },
|
|
101
|
+
);
|
|
102
|
+
|
|
56
103
|
const TOOLS: readonly MCPTool[] = [
|
|
57
104
|
{
|
|
58
105
|
name: 'atc_session_list',
|
|
@@ -122,6 +169,24 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
122
169
|
description: 'List directories sessions were previously spawned from, most recent first.',
|
|
123
170
|
inputSchema: NO_INPUT,
|
|
124
171
|
},
|
|
172
|
+
{
|
|
173
|
+
name: 'atc_session_get',
|
|
174
|
+
description:
|
|
175
|
+
'Read one session in a single call: its descriptor (state, unread flag, last activity message), the prompt it was spawned with, when it last reported activity, the prompt or question it is waiting on while it needs you (read-only; answer it with atc_session_input), and the final message of its latest finished turn.',
|
|
176
|
+
inputSchema: SESSION_INPUT,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'atc_session_read',
|
|
180
|
+
description:
|
|
181
|
+
"Read a session's conversation a page at a time, oldest first: user and assistant messages with tool uses summarised. Pass the returned cursor to continue where you left off; more is true when the page stopped before the end. Claude sessions only; other agents answer unsupported.",
|
|
182
|
+
inputSchema: SESSION_READ_INPUT,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'atc_events_read',
|
|
186
|
+
description:
|
|
187
|
+
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended) since a cursor, oldest first, each with the session id and name. Without a cursor it returns the most recent events. Pass the returned cursor next time. waitMs holds the call open until an event arrives.',
|
|
188
|
+
inputSchema: EVENTS_READ_INPUT,
|
|
189
|
+
},
|
|
125
190
|
];
|
|
126
191
|
|
|
127
192
|
/**
|
|
@@ -138,6 +203,10 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
138
203
|
|
|
139
204
|
let buffer = '';
|
|
140
205
|
|
|
206
|
+
// Each request runs on its own, so a long poll never holds up the others;
|
|
207
|
+
// responses carry their request's id.
|
|
208
|
+
const inFlight = new Set<Promise<void>>();
|
|
209
|
+
|
|
141
210
|
for await (const chunk of Bun.stdin.stream()) {
|
|
142
211
|
buffer += decoder.decode(chunk, { stream: true });
|
|
143
212
|
|
|
@@ -150,10 +219,25 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
150
219
|
continue;
|
|
151
220
|
}
|
|
152
221
|
|
|
153
|
-
|
|
222
|
+
const finished = Promise.withResolvers<void>();
|
|
223
|
+
|
|
224
|
+
inFlight.add(finished.promise);
|
|
225
|
+
|
|
226
|
+
void (async () => {
|
|
227
|
+
try {
|
|
228
|
+
await applyRPCLine(client, build, line);
|
|
229
|
+
} catch {
|
|
230
|
+
// A failed line gets no response, the way a malformed one gets none.
|
|
231
|
+
} finally {
|
|
232
|
+
inFlight.delete(finished.promise);
|
|
233
|
+
finished.resolve();
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
154
236
|
}
|
|
155
237
|
}
|
|
156
238
|
|
|
239
|
+
await Promise.all(inFlight);
|
|
240
|
+
|
|
157
241
|
client.stop();
|
|
158
242
|
}
|
|
159
243
|
|
|
@@ -317,6 +401,29 @@ async function runTool(
|
|
|
317
401
|
|
|
318
402
|
return JSON.stringify(ok['dirs'], null, 2);
|
|
319
403
|
}
|
|
404
|
+
case 'atc_session_get': {
|
|
405
|
+
const ok = await client.sendRequest('session.get', { session: args['session'] });
|
|
406
|
+
|
|
407
|
+
return JSON.stringify(ok, null, 2);
|
|
408
|
+
}
|
|
409
|
+
case 'atc_session_read': {
|
|
410
|
+
const ok = await client.sendRequest('session.read', {
|
|
411
|
+
session: args['session'],
|
|
412
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
413
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return JSON.stringify(ok, null, 2);
|
|
417
|
+
}
|
|
418
|
+
case 'atc_events_read': {
|
|
419
|
+
const ok = await client.sendRequest('events.read', {
|
|
420
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
421
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
422
|
+
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return JSON.stringify(ok, null, 2);
|
|
426
|
+
}
|
|
320
427
|
default: {
|
|
321
428
|
throw new Error(`unknown tool '${name}'`);
|
|
322
429
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Cursor } from './encode-cursor';
|
|
3
|
+
|
|
4
|
+
const WIRE_CURSOR = z.discriminatedUnion('k', [
|
|
5
|
+
z.object({ k: z.literal('ev'), i: z.number().int().nonnegative() }),
|
|
6
|
+
z.object({ k: z.literal('tr'), p: z.string(), o: z.number().int().nonnegative() }),
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function decodeCursor(raw: string): Cursor | null {
|
|
10
|
+
let json: unknown;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
json = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parsed = WIRE_CURSOR.safeParse(json);
|
|
19
|
+
|
|
20
|
+
if (!parsed.success) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (parsed.data.k === 'ev') {
|
|
25
|
+
return { kind: 'events', id: parsed.data.i };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { kind: 'transcript', path: parsed.data.p, offset: parsed.data.o };
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Cursor =
|
|
2
|
+
| { readonly kind: 'events'; readonly id: number }
|
|
3
|
+
| { readonly kind: 'transcript'; readonly path: string; readonly offset: number };
|
|
4
|
+
|
|
5
|
+
export function encodeCursor(cursor: Cursor): string {
|
|
6
|
+
const wire =
|
|
7
|
+
cursor.kind === 'events'
|
|
8
|
+
? { k: 'ev', i: cursor.id }
|
|
9
|
+
: { k: 'tr', p: cursor.path, o: cursor.offset };
|
|
10
|
+
|
|
11
|
+
return Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url');
|
|
12
|
+
}
|
|
@@ -87,6 +87,16 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
87
87
|
.refine((v) => v.request !== '' && v.decision !== '', {
|
|
88
88
|
message: 'permission.respond requires a request and a decision',
|
|
89
89
|
}),
|
|
90
|
+
'session.get': SESSION_DEFAULTED,
|
|
91
|
+
'session.read': SESSION_DEFAULTED.extend({
|
|
92
|
+
cursor: buildOptionalCursor(),
|
|
93
|
+
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
94
|
+
}),
|
|
95
|
+
'events.read': z.object({
|
|
96
|
+
cursor: buildOptionalCursor(),
|
|
97
|
+
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
98
|
+
waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
|
|
99
|
+
}),
|
|
90
100
|
} as const;
|
|
91
101
|
|
|
92
102
|
function buildDefaultedString(fallback: string) {
|
|
@@ -115,3 +125,11 @@ function buildDefaultedNonEmptyString(fallback: string) {
|
|
|
115
125
|
z.string().default(fallback),
|
|
116
126
|
);
|
|
117
127
|
}
|
|
128
|
+
|
|
129
|
+
// An empty cursor reads as no cursor, the way a client starting fresh sends it.
|
|
130
|
+
function buildOptionalCursor() {
|
|
131
|
+
return z.preprocess(
|
|
132
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
133
|
+
z.string().optional(),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function truncateToBytes(text: string, maxBytes: number): string {
|
|
2
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes) {
|
|
3
|
+
return text;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
7
|
+
|
|
8
|
+
// The cut leaves room for the three-byte ellipsis, or drops the ellipsis
|
|
9
|
+
// when the cap is smaller than it, and backs up off any continuation byte
|
|
10
|
+
// so it never lands inside a code point.
|
|
11
|
+
const hasRoomForEllipsis = maxBytes >= 3;
|
|
12
|
+
let end = hasRoomForEllipsis ? maxBytes - 3 : Math.max(maxBytes, 0);
|
|
13
|
+
|
|
14
|
+
while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) {
|
|
15
|
+
end -= 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const head = bytes.subarray(0, end).toString('utf8');
|
|
19
|
+
|
|
20
|
+
return hasRoomForEllipsis ? `${head}…` : head;
|
|
21
|
+
}
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -18,11 +18,31 @@ export interface FleetEntry {
|
|
|
18
18
|
|
|
19
19
|
// The agent session id of the session this one is a sub-session of.
|
|
20
20
|
readonly parent?: AgentSessionID;
|
|
21
|
+
|
|
22
|
+
// The prompt the session was spawned with.
|
|
23
|
+
readonly prompt?: string;
|
|
24
|
+
|
|
25
|
+
// The agent's final message from the session's latest finished turn.
|
|
26
|
+
readonly result?: string;
|
|
27
|
+
|
|
28
|
+
// The transcript file the agent's hooks last reported for the session.
|
|
29
|
+
readonly transcriptPath?: string;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
export interface FleetStore {
|
|
24
33
|
readonly loadFleet: () => Promise<FleetEntry[]>;
|
|
25
34
|
readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
|
|
35
|
+
readonly updateFleetEntry: (
|
|
36
|
+
agentSessionID: AgentSessionID,
|
|
37
|
+
fields: FleetEntryUpdate,
|
|
38
|
+
) => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The fields a session rewrites on its own row while it runs, without
|
|
42
|
+
// touching any sibling row.
|
|
43
|
+
export interface FleetEntryUpdate {
|
|
44
|
+
readonly result?: string;
|
|
45
|
+
readonly transcriptPath?: string;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
// A stored fleet row's keys. name, cwd, and the resolved agentSessionID are
|
|
@@ -12,6 +12,9 @@ interface FleetTable {
|
|
|
12
12
|
agent: string;
|
|
13
13
|
exited: number;
|
|
14
14
|
parent: string | null;
|
|
15
|
+
prompt: string | null;
|
|
16
|
+
result: string | null;
|
|
17
|
+
transcript_path: string | null;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
interface EventsTable {
|
|
@@ -21,6 +24,8 @@ interface EventsTable {
|
|
|
21
24
|
event: string;
|
|
22
25
|
message: string | null;
|
|
23
26
|
session_id: string | null;
|
|
27
|
+
kind: string | null;
|
|
28
|
+
detail: string | null;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
interface SpawnHistoryTable {
|
|
@@ -46,9 +51,9 @@ export interface StateStoreSchema {
|
|
|
46
51
|
|
|
47
52
|
// Every shape the fleet table has shipped with: the oldest carries only
|
|
48
53
|
// agent_session_id under its Claude-era name plus name and cwd, and each
|
|
49
|
-
// later step adds one column the daemon grew to depend on. events
|
|
50
|
-
//
|
|
51
|
-
// they
|
|
54
|
+
// later step adds one column the daemon grew to depend on. events later gains
|
|
55
|
+
// columns of its own, while spawn_history and prefs have carried one shape
|
|
56
|
+
// since they were added.
|
|
52
57
|
const MIGRATIONS: Record<string, Migration> = {
|
|
53
58
|
'001_create_initial_schema': {
|
|
54
59
|
async up(db: Kysely<StateStoreSchema>) {
|
|
@@ -125,6 +130,42 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
125
130
|
await db.schema.alterTable('fleet').addColumn('parent', 'text').execute();
|
|
126
131
|
},
|
|
127
132
|
},
|
|
133
|
+
'008_add_fleet_prompt_result_transcript': {
|
|
134
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
135
|
+
await db.schema.alterTable('fleet').addColumn('prompt', 'text').execute();
|
|
136
|
+
await db.schema.alterTable('fleet').addColumn('result', 'text').execute();
|
|
137
|
+
await db.schema.alterTable('fleet').addColumn('transcript_path', 'text').execute();
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
'009_add_events_kind_detail': {
|
|
141
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
142
|
+
await db.schema.alterTable('events').addColumn('kind', 'text').execute();
|
|
143
|
+
await db.schema.alterTable('events').addColumn('detail', 'text').execute();
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
'010_add_events_trail_indexes': {
|
|
147
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
148
|
+
await db.schema
|
|
149
|
+
.createIndex('events_atc_id_ts')
|
|
150
|
+
.ifNotExists()
|
|
151
|
+
.on('events')
|
|
152
|
+
.columns(['atc_id', 'ts'])
|
|
153
|
+
.execute();
|
|
154
|
+
|
|
155
|
+
await db.schema
|
|
156
|
+
.createIndex('events_session_id_ts')
|
|
157
|
+
.ifNotExists()
|
|
158
|
+
.on('events')
|
|
159
|
+
.columns(['session_id', 'ts'])
|
|
160
|
+
.execute();
|
|
161
|
+
|
|
162
|
+
// Partial, so the trail reads walk only the rows they return. The
|
|
163
|
+
// predicate text must match the reads' filter for SQLite to pick it.
|
|
164
|
+
await sql`CREATE INDEX IF NOT EXISTS events_trail ON events (id) WHERE kind IS NOT NULL AND kind != 'heartbeat'`.execute(
|
|
165
|
+
db,
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
128
169
|
};
|
|
129
170
|
|
|
130
171
|
const PROVIDER: MigrationProvider = {
|