@zgeoff/atc 2.5.1 → 2.7.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 +26 -0
- package/src/agents/claude-adapter.ts +6 -1
- package/src/agents/codex-adapter.ts +3 -1
- package/src/agents/gateway-adapter.ts +6 -0
- package/src/agents/grok-adapter.ts +3 -1
- package/src/agents/parse-claude-transcript-line.ts +107 -0
- package/src/cli.ts +46 -0
- package/src/client/daemon-client.ts +13 -1
- package/src/daemon/answer-byte-cap.ts +1 -0
- package/src/daemon/build-fleet-events.ts +35 -0
- package/src/daemon/build-session-message-event.ts +29 -0
- package/src/daemon/daemon-connection.ts +337 -0
- package/src/daemon/daemon.ts +333 -6
- package/src/daemon/event-signal.ts +54 -0
- package/src/daemon/hooks.ts +13 -4
- package/src/daemon/load-transcript-page.ts +101 -0
- package/src/daemon/mint-message-id.ts +12 -0
- package/src/daemon/parse-report.ts +35 -0
- package/src/daemon/session-runtime.ts +9 -0
- package/src/daemon/sessions.ts +51 -10
- package/src/daemon/tap-registry.ts +67 -0
- package/src/mcp-server.ts +164 -1
- package/src/protocol/decode-cursor.ts +29 -0
- package/src/protocol/encode-cursor.ts +12 -0
- package/src/protocol/request-param-schemas.ts +30 -0
- package/src/report.ts +32 -0
- package/src/shared/message-id.ts +7 -0
- package/src/shared/report-kinds.ts +3 -0
- package/src/shared/report.ts +18 -2
- package/src/shared/to-message-id.ts +11 -0
- package/src/shared/truncate-to-bytes.ts +21 -0
- package/src/store/fleet-entry.ts +20 -0
- package/src/store/message-owner.ts +9 -0
- package/src/store/message-record.ts +18 -0
- package/src/store/run-migrations.ts +94 -3
- package/src/store/state-store.ts +272 -4
- package/src/tap.ts +120 -0
|
@@ -37,6 +37,15 @@ export class SessionRuntime {
|
|
|
37
37
|
// clears it, and a spawn that never reports leaves it stranded harmlessly.
|
|
38
38
|
pendingLastUsed = false;
|
|
39
39
|
|
|
40
|
+
// When the agent reported SessionStart since its terminal last booted, or
|
|
41
|
+
// null before it has. A message queues for a grace window after that, and
|
|
42
|
+
// beyond it only if a tap has attached since the boot.
|
|
43
|
+
startedAt: number | null = null;
|
|
44
|
+
|
|
45
|
+
// Whether a tap has attached since the terminal last booted. A tap that
|
|
46
|
+
// later dropped may be restarting, so it keeps the inbox open.
|
|
47
|
+
tapAttached = false;
|
|
48
|
+
|
|
40
49
|
dispose(): void {
|
|
41
50
|
clearTimeout(this.resizeTimer);
|
|
42
51
|
clearTimeout(this.detectTimer);
|
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
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { MessageID } from '../shared/message-id';
|
|
2
|
+
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
|
|
4
|
+
interface TapEntry<TClient> {
|
|
5
|
+
readonly client: TClient;
|
|
6
|
+
readonly claimed: Set<MessageID>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Which connection taps each session's inbox; one tap per session, the
|
|
11
|
+
* latest wins; each message is handed to a tap once.
|
|
12
|
+
*/
|
|
13
|
+
export class TapRegistry<TClient> {
|
|
14
|
+
private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Makes the client the session's tap and returns the client it displaced,
|
|
18
|
+
* or null when there was none or it is the same connection.
|
|
19
|
+
*/
|
|
20
|
+
attach(sessionID: SessionID, client: TClient): TClient | null {
|
|
21
|
+
const previous = this.bySession.get(sessionID)?.client ?? null;
|
|
22
|
+
|
|
23
|
+
this.bySession.set(sessionID, { client, claimed: new Set() });
|
|
24
|
+
|
|
25
|
+
return previous === client ? null : previous;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
detachAll(client: TClient): void {
|
|
29
|
+
for (const [sessionID, entry] of this.bySession) {
|
|
30
|
+
if (entry.client === client) {
|
|
31
|
+
this.bySession.delete(sessionID);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Forgets the session's tap and returns its client, or null when the
|
|
38
|
+
* session had none.
|
|
39
|
+
*/
|
|
40
|
+
removeSession(sessionID: SessionID): TClient | null {
|
|
41
|
+
const previous = this.bySession.get(sessionID)?.client ?? null;
|
|
42
|
+
|
|
43
|
+
this.bySession.delete(sessionID);
|
|
44
|
+
|
|
45
|
+
return previous;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hasTap(sessionID: SessionID): boolean {
|
|
49
|
+
return this.bySession.has(sessionID);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isTap(sessionID: SessionID, client: TClient): boolean {
|
|
53
|
+
return this.bySession.get(sessionID)?.client === client;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
claimDelivery(sessionID: SessionID, messageID: MessageID): TClient | null {
|
|
57
|
+
const entry = this.bySession.get(sessionID);
|
|
58
|
+
|
|
59
|
+
if (entry === undefined || entry.claimed.has(messageID)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
entry.claimed.add(messageID);
|
|
64
|
+
|
|
65
|
+
return entry.client;
|
|
66
|
+
}
|
|
67
|
+
}
|
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,56 @@ 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
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'atc_session_message',
|
|
192
|
+
description:
|
|
193
|
+
"Send a session a message and get its id back; poll atc_message_get with the id for its status and answer. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
session: { type: 'string', description: 'The atc session id, from atc_session_list' },
|
|
198
|
+
text: { type: 'string', description: 'The message text' },
|
|
199
|
+
from: {
|
|
200
|
+
type: 'string',
|
|
201
|
+
description:
|
|
202
|
+
'Who the message is from; defaults to the calling session id, or mcp outside a session',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
required: ['session', 'text'],
|
|
206
|
+
additionalProperties: false,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'atc_message_get',
|
|
211
|
+
description:
|
|
212
|
+
'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, and the sentAt, deliveredAt, and answeredAt timestamps. Poll it until the status is answered.',
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
message: { type: 'string', description: 'The message id atc_session_message returned' },
|
|
217
|
+
},
|
|
218
|
+
required: ['message'],
|
|
219
|
+
additionalProperties: false,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
125
222
|
];
|
|
126
223
|
|
|
127
224
|
/**
|
|
@@ -138,6 +235,10 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
138
235
|
|
|
139
236
|
let buffer = '';
|
|
140
237
|
|
|
238
|
+
// Each request runs on its own, so a long poll never holds up the others;
|
|
239
|
+
// responses carry their request's id.
|
|
240
|
+
const inFlight = new Set<Promise<void>>();
|
|
241
|
+
|
|
141
242
|
for await (const chunk of Bun.stdin.stream()) {
|
|
142
243
|
buffer += decoder.decode(chunk, { stream: true });
|
|
143
244
|
|
|
@@ -150,10 +251,25 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
150
251
|
continue;
|
|
151
252
|
}
|
|
152
253
|
|
|
153
|
-
|
|
254
|
+
const finished = Promise.withResolvers<void>();
|
|
255
|
+
|
|
256
|
+
inFlight.add(finished.promise);
|
|
257
|
+
|
|
258
|
+
void (async () => {
|
|
259
|
+
try {
|
|
260
|
+
await applyRPCLine(client, build, line);
|
|
261
|
+
} catch {
|
|
262
|
+
// A failed line gets no response, the way a malformed one gets none.
|
|
263
|
+
} finally {
|
|
264
|
+
inFlight.delete(finished.promise);
|
|
265
|
+
finished.resolve();
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
154
268
|
}
|
|
155
269
|
}
|
|
156
270
|
|
|
271
|
+
await Promise.all(inFlight);
|
|
272
|
+
|
|
157
273
|
client.stop();
|
|
158
274
|
}
|
|
159
275
|
|
|
@@ -317,6 +433,53 @@ async function runTool(
|
|
|
317
433
|
|
|
318
434
|
return JSON.stringify(ok['dirs'], null, 2);
|
|
319
435
|
}
|
|
436
|
+
case 'atc_session_get': {
|
|
437
|
+
const ok = await client.sendRequest('session.get', { session: args['session'] });
|
|
438
|
+
|
|
439
|
+
return JSON.stringify(ok, null, 2);
|
|
440
|
+
}
|
|
441
|
+
case 'atc_session_read': {
|
|
442
|
+
const ok = await client.sendRequest('session.read', {
|
|
443
|
+
session: args['session'],
|
|
444
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
445
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return JSON.stringify(ok, null, 2);
|
|
449
|
+
}
|
|
450
|
+
case 'atc_events_read': {
|
|
451
|
+
const ok = await client.sendRequest('events.read', {
|
|
452
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
453
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
454
|
+
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return JSON.stringify(ok, null, 2);
|
|
458
|
+
}
|
|
459
|
+
case 'atc_session_message': {
|
|
460
|
+
const caller = process.env['ATC_SESSION_ID'];
|
|
461
|
+
const given = args['from'];
|
|
462
|
+
let from = 'mcp';
|
|
463
|
+
|
|
464
|
+
if (typeof given === 'string' && given !== '') {
|
|
465
|
+
from = given;
|
|
466
|
+
} else if (caller !== undefined && caller !== '') {
|
|
467
|
+
from = caller;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const ok = await client.sendRequest('session.message', {
|
|
471
|
+
session: args['session'],
|
|
472
|
+
text: args['text'],
|
|
473
|
+
from,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return JSON.stringify(ok, null, 2);
|
|
477
|
+
}
|
|
478
|
+
case 'atc_message_get': {
|
|
479
|
+
const ok = await client.sendRequest('message.get', { message: args['message'] });
|
|
480
|
+
|
|
481
|
+
return JSON.stringify(ok, null, 2);
|
|
482
|
+
}
|
|
320
483
|
default: {
|
|
321
484
|
throw new Error(`unknown tool '${name}'`);
|
|
322
485
|
}
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { buildOptionalBoolean } from '../shared/build-optional-boolean';
|
|
3
3
|
import { buildOptionalString } from '../shared/build-optional-string';
|
|
4
4
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
5
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
5
6
|
import { toSessionID } from '../shared/to-session-id';
|
|
6
7
|
|
|
7
8
|
const EJECT_DEFAULT_PROMPT =
|
|
@@ -87,6 +88,27 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
87
88
|
.refine((v) => v.request !== '' && v.decision !== '', {
|
|
88
89
|
message: 'permission.respond requires a request and a decision',
|
|
89
90
|
}),
|
|
91
|
+
'session.get': SESSION_DEFAULTED,
|
|
92
|
+
'session.read': SESSION_DEFAULTED.extend({
|
|
93
|
+
cursor: buildOptionalCursor(),
|
|
94
|
+
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
95
|
+
}),
|
|
96
|
+
'events.read': z.object({
|
|
97
|
+
cursor: buildOptionalCursor(),
|
|
98
|
+
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
99
|
+
waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
|
|
100
|
+
}),
|
|
101
|
+
'session.message': SESSION_DEFAULTED.extend({
|
|
102
|
+
from: buildDefaultedNonEmptyString('unknown'),
|
|
103
|
+
text: buildDefaultedString(''),
|
|
104
|
+
}).refine((v) => v.text !== '', { message: 'session.message requires text' }),
|
|
105
|
+
'session.tap': SESSION_DEFAULTED,
|
|
106
|
+
'message.get': z
|
|
107
|
+
.object({ message: buildDefaultedString('').transform(toMessageID) })
|
|
108
|
+
.refine((v) => v.message !== '', { message: 'message.get requires a message' }),
|
|
109
|
+
'message.ack': SESSION_DEFAULTED.extend({
|
|
110
|
+
message: buildDefaultedString('').transform(toMessageID),
|
|
111
|
+
}).refine((v) => v.message !== '', { message: 'message.ack requires a message' }),
|
|
90
112
|
} as const;
|
|
91
113
|
|
|
92
114
|
function buildDefaultedString(fallback: string) {
|
|
@@ -115,3 +137,11 @@ function buildDefaultedNonEmptyString(fallback: string) {
|
|
|
115
137
|
z.string().default(fallback),
|
|
116
138
|
);
|
|
117
139
|
}
|
|
140
|
+
|
|
141
|
+
// An empty cursor reads as no cursor, the way a client starting fresh sends it.
|
|
142
|
+
function buildOptionalCursor() {
|
|
143
|
+
return z.preprocess(
|
|
144
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
145
|
+
z.string().optional(),
|
|
146
|
+
);
|
|
147
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sendReport } from './shared/report';
|
|
2
|
+
import { REPORT_KINDS } from './shared/report-kinds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs inside wrangled sessions: reads the final text from stdin verbatim
|
|
6
|
+
* and forwards it to the atc socket as a Report envelope of the given kind
|
|
7
|
+
* for the given message. Always exits 0 so it never blocks the session it
|
|
8
|
+
* reports on.
|
|
9
|
+
*/
|
|
10
|
+
export async function runReport(kind: string, message: string): Promise<void> {
|
|
11
|
+
try {
|
|
12
|
+
const sock = process.env['ATC_SOCKET'];
|
|
13
|
+
const atcId = process.env['ATC_SESSION_ID'];
|
|
14
|
+
|
|
15
|
+
if (
|
|
16
|
+
sock !== undefined &&
|
|
17
|
+
sock !== '' &&
|
|
18
|
+
atcId !== undefined &&
|
|
19
|
+
atcId !== '' &&
|
|
20
|
+
REPORT_KINDS.some((known) => known === kind) &&
|
|
21
|
+
message !== ''
|
|
22
|
+
) {
|
|
23
|
+
const answer = await new Response(Bun.stdin.stream()).text();
|
|
24
|
+
|
|
25
|
+
const line = `${JSON.stringify({ atcId, event: 'Report', payload: { kind, message, answer } })}\n`;
|
|
26
|
+
|
|
27
|
+
await sendReport(sock, line, 2000);
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
package/src/shared/report.ts
CHANGED
|
@@ -4,12 +4,28 @@
|
|
|
4
4
|
async function sendLine(sock: string, line: string): Promise<void> {
|
|
5
5
|
const resolvers = Promise.withResolvers<void>();
|
|
6
6
|
|
|
7
|
+
const bytes = new TextEncoder().encode(line);
|
|
8
|
+
|
|
9
|
+
let written = 0;
|
|
10
|
+
|
|
7
11
|
await Bun.connect({
|
|
8
12
|
unix: sock,
|
|
9
13
|
socket: {
|
|
14
|
+
// A write returns the bytes the socket accepted and drops the rest, so
|
|
15
|
+
// a line larger than the socket buffer goes out across drain calls.
|
|
10
16
|
open(s) {
|
|
11
|
-
s.write(
|
|
12
|
-
|
|
17
|
+
written += s.write(bytes.subarray(written));
|
|
18
|
+
|
|
19
|
+
if (written >= bytes.length) {
|
|
20
|
+
s.end();
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
drain(s) {
|
|
24
|
+
written += s.write(bytes.subarray(written));
|
|
25
|
+
|
|
26
|
+
if (written >= bytes.length) {
|
|
27
|
+
s.end();
|
|
28
|
+
}
|
|
13
29
|
},
|
|
14
30
|
close() {
|
|
15
31
|
resolvers.resolve();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MessageID } from './message-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trusts a string as an atc message id. This is the one point in the
|
|
5
|
+
* codebase where a plain string becomes a `MessageID`, and it adds no
|
|
6
|
+
* runtime check: the caller asserts the string identifies an atc message.
|
|
7
|
+
*/
|
|
8
|
+
export function toMessageID(id: string): MessageID {
|
|
9
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- the one point where a string is trusted as a MessageID
|
|
10
|
+
return id as MessageID;
|
|
11
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
2
|
+
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
|
|
4
|
+
// Which session a message belongs to: the atc id it was sent to, or the
|
|
5
|
+
// agent session id that survives a restore's re-minted atc id.
|
|
6
|
+
export interface MessageOwner {
|
|
7
|
+
readonly atcID: SessionID;
|
|
8
|
+
readonly agentSessionID?: AgentSessionID;
|
|
9
|
+
}
|