@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
package/src/store/state-store.ts
CHANGED
|
@@ -2,19 +2,38 @@ import { Database } from 'bun:sqlite';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
|
|
4
4
|
import { toAgentID } from '../agents/agent-adapter';
|
|
5
|
-
import type { AgentID } from '../agents/agent-adapter';
|
|
5
|
+
import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
|
|
6
6
|
import type { HookEvent } from '../daemon/hooks';
|
|
7
7
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
8
|
+
import type { SessionID } from '../shared/session-id';
|
|
8
9
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
10
|
+
import { toSessionID } from '../shared/to-session-id';
|
|
9
11
|
import { BunSqliteDriver } from './bun-sqlite-driver';
|
|
10
12
|
import { parseFleetEntry } from './fleet-entry';
|
|
11
|
-
import type { FleetEntry } from './fleet-entry';
|
|
13
|
+
import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
|
|
12
14
|
import { runMigrations } from './run-migrations';
|
|
13
15
|
import type { StateStoreSchema } from './run-migrations';
|
|
14
16
|
|
|
17
|
+
// Spelled as the partial index's predicate so SQLite can match them.
|
|
18
|
+
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
19
|
+
|
|
20
|
+
export interface StoredEvent {
|
|
21
|
+
readonly id: number;
|
|
22
|
+
|
|
23
|
+
// Epoch ms of ts.
|
|
24
|
+
readonly at: number;
|
|
25
|
+
readonly atcID: SessionID;
|
|
26
|
+
readonly agentSessionID: AgentSessionID | null;
|
|
27
|
+
readonly kind: string;
|
|
28
|
+
|
|
29
|
+
// The event's detail, else the hook's message.
|
|
30
|
+
readonly detail: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
/**
|
|
16
34
|
* Daemon state in one SQLite store: the restorable fleet, the hook-event
|
|
17
|
-
*
|
|
35
|
+
* trail that events.read and lastActivityAt read, and the spawn-directory
|
|
36
|
+
* history. The statusline contract
|
|
18
37
|
* file (status.json) stays a plain file because reporters inside wrangled
|
|
19
38
|
* sessions read it without speaking to the daemon. An existing fleet.json
|
|
20
39
|
* seeds the fleet table once, so upgrading never loses a restorable fleet.
|
|
@@ -72,6 +91,9 @@ export class StateStore {
|
|
|
72
91
|
'agent',
|
|
73
92
|
'exited',
|
|
74
93
|
'parent',
|
|
94
|
+
'prompt',
|
|
95
|
+
'result',
|
|
96
|
+
'transcript_path',
|
|
75
97
|
])
|
|
76
98
|
.execute();
|
|
77
99
|
|
|
@@ -87,6 +109,9 @@ export class StateStore {
|
|
|
87
109
|
...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
|
|
88
110
|
...(row.exited === 0 ? {} : { exited: true }),
|
|
89
111
|
...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
|
|
112
|
+
...(row.prompt === null ? {} : { prompt: row.prompt }),
|
|
113
|
+
...(row.result === null ? {} : { result: row.result }),
|
|
114
|
+
...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
|
|
90
115
|
});
|
|
91
116
|
}
|
|
92
117
|
|
|
@@ -132,6 +157,9 @@ export class StateStore {
|
|
|
132
157
|
agent: entry.agent,
|
|
133
158
|
exited: entry.exited === true ? 1 : 0,
|
|
134
159
|
parent: entry.parent ?? null,
|
|
160
|
+
prompt: entry.prompt ?? null,
|
|
161
|
+
result: entry.result ?? null,
|
|
162
|
+
transcript_path: entry.transcriptPath ?? null,
|
|
135
163
|
})
|
|
136
164
|
.orReplace()
|
|
137
165
|
.execute();
|
|
@@ -139,7 +167,27 @@ export class StateStore {
|
|
|
139
167
|
});
|
|
140
168
|
}
|
|
141
169
|
|
|
142
|
-
|
|
170
|
+
// Rewrites fields on one existing row and leaves every other row as it
|
|
171
|
+
// stands. A session without a row yet is a no-op: the next fleet write
|
|
172
|
+
// inserts it with these fields.
|
|
173
|
+
async updateFleetEntry(agentSessionID: AgentSessionID, fields: FleetEntryUpdate): Promise<void> {
|
|
174
|
+
const values = {
|
|
175
|
+
...(fields.result === undefined ? {} : { result: fields.result }),
|
|
176
|
+
...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (Object.keys(values).length === 0) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await this.db
|
|
184
|
+
.updateTable('fleet')
|
|
185
|
+
.set(values)
|
|
186
|
+
.where('agent_session_id', '=', agentSessionID)
|
|
187
|
+
.execute();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
|
|
143
191
|
const rawMessage = e.payload['message'];
|
|
144
192
|
const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
|
|
145
193
|
const message = typeof rawMessage === 'string' ? rawMessage : null;
|
|
@@ -154,10 +202,57 @@ export class StateStore {
|
|
|
154
202
|
event: e.event,
|
|
155
203
|
message,
|
|
156
204
|
session_id: sessionID,
|
|
205
|
+
kind: ev === null ? null : ev.kind,
|
|
206
|
+
detail: ev?.detail ?? null,
|
|
157
207
|
})
|
|
158
208
|
.execute();
|
|
159
209
|
}
|
|
160
210
|
|
|
211
|
+
async collectEventsAfter(afterID: number, limit: number): Promise<StoredEvent[]> {
|
|
212
|
+
const rows = await this.db
|
|
213
|
+
.selectFrom('events')
|
|
214
|
+
.select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
|
|
215
|
+
.where('id', '>', afterID)
|
|
216
|
+
.where(TRAIL_FILTER)
|
|
217
|
+
.orderBy('id', 'asc')
|
|
218
|
+
.limit(limit)
|
|
219
|
+
.execute();
|
|
220
|
+
|
|
221
|
+
return buildStoredEvents(rows);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async collectLatestEvents(limit: number): Promise<StoredEvent[]> {
|
|
225
|
+
const rows = await this.db
|
|
226
|
+
.selectFrom('events')
|
|
227
|
+
.select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
|
|
228
|
+
.where(TRAIL_FILTER)
|
|
229
|
+
.orderBy('id', 'desc')
|
|
230
|
+
.limit(limit)
|
|
231
|
+
.execute();
|
|
232
|
+
|
|
233
|
+
return buildStoredEvents(rows).toReversed();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Matches the session by either id, since a restore re-mints the atc id
|
|
237
|
+
// while the agent session id carries on.
|
|
238
|
+
async loadLastActivityAt(
|
|
239
|
+
atcID: SessionID,
|
|
240
|
+
agentSessionID: AgentSessionID | undefined,
|
|
241
|
+
): Promise<number | null> {
|
|
242
|
+
const row = await this.db
|
|
243
|
+
.selectFrom('events')
|
|
244
|
+
.select(sql<string | null>`MAX(ts)`.as('ts'))
|
|
245
|
+
.where((eb) =>
|
|
246
|
+
eb.or([
|
|
247
|
+
eb('atc_id', '=', atcID),
|
|
248
|
+
...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
|
|
249
|
+
]),
|
|
250
|
+
)
|
|
251
|
+
.executeTakeFirst();
|
|
252
|
+
|
|
253
|
+
return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
|
|
254
|
+
}
|
|
255
|
+
|
|
161
256
|
async recordSpawnDir(cwd: string): Promise<void> {
|
|
162
257
|
await this.db
|
|
163
258
|
.insertInto('spawn_history')
|
|
@@ -235,3 +330,34 @@ export class StateStore {
|
|
|
235
330
|
} catch {}
|
|
236
331
|
}
|
|
237
332
|
}
|
|
333
|
+
|
|
334
|
+
interface EventRow {
|
|
335
|
+
readonly id: number;
|
|
336
|
+
readonly ts: string;
|
|
337
|
+
readonly atc_id: string;
|
|
338
|
+
readonly session_id: string | null;
|
|
339
|
+
readonly kind: string | null;
|
|
340
|
+
readonly detail: string | null;
|
|
341
|
+
readonly message: string | null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function buildStoredEvents(rows: readonly EventRow[]): StoredEvent[] {
|
|
345
|
+
const events: StoredEvent[] = [];
|
|
346
|
+
|
|
347
|
+
for (const row of rows) {
|
|
348
|
+
if (row.kind === null) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
events.push({
|
|
353
|
+
id: row.id,
|
|
354
|
+
at: Date.parse(row.ts),
|
|
355
|
+
atcID: toSessionID(row.atc_id),
|
|
356
|
+
agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
|
|
357
|
+
kind: row.kind,
|
|
358
|
+
detail: row.detail ?? row.message,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return events;
|
|
363
|
+
}
|