@zgeoff/atc 2.6.0 → 2.8.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/README.md +4 -0
- package/package.json +3 -1
- package/src/agents/agent-adapter.ts +11 -0
- package/src/agents/atc-bridge-files.ts +11 -0
- package/src/agents/build-cli-argv.ts +13 -0
- package/src/agents/build-cli-command.ts +4 -9
- package/src/agents/claude-adapter.ts +23 -2
- package/src/agents/codex-adapter.ts +2 -0
- package/src/agents/gateway-adapter.ts +23 -1
- package/src/agents/grok-adapter.ts +2 -0
- package/src/agents/write-atc-bridge.ts +42 -0
- package/src/agents/write-hook-settings.ts +2 -1
- package/src/cli.ts +51 -0
- package/src/client/daemon-client.ts +13 -1
- package/src/daemon/answer-byte-cap.ts +1 -0
- package/src/daemon/build-headless-env.ts +23 -0
- package/src/daemon/build-session-message-event.ts +29 -0
- package/src/daemon/build-session-report-event.ts +24 -0
- package/src/daemon/daemon-connection.ts +200 -0
- package/src/daemon/daemon.ts +264 -3
- package/src/daemon/hooks.ts +13 -4
- package/src/daemon/mint-message-id.ts +12 -0
- package/src/daemon/parse-report.ts +44 -0
- package/src/daemon/session-runtime.ts +9 -0
- package/src/daemon/start-headless-run.ts +10 -2
- package/src/daemon/start-headless-turn.ts +1 -0
- package/src/daemon/tap-registry.ts +67 -0
- package/src/mcp-server.ts +56 -0
- package/src/protocol/request-param-schemas.ts +12 -0
- package/src/report.ts +59 -0
- package/src/shared/message-id.ts +7 -0
- package/src/shared/report-kinds.ts +1 -0
- package/src/shared/report.ts +18 -2
- package/src/shared/to-message-id.ts +11 -0
- package/src/store/message-owner.ts +9 -0
- package/src/store/message-record.ts +18 -0
- package/src/store/run-migrations.ts +52 -2
- package/src/store/state-store.ts +144 -2
- package/src/tap.ts +120 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { MessageID } from '../shared/message-id';
|
|
3
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
4
|
+
|
|
5
|
+
interface AnsweredReport {
|
|
6
|
+
readonly kind: 'answered';
|
|
7
|
+
readonly message: MessageID;
|
|
8
|
+
readonly answer: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NoteReport {
|
|
12
|
+
readonly kind: 'note';
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly text: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Report = AnsweredReport | NoteReport;
|
|
18
|
+
|
|
19
|
+
const REPORT_SCHEMA = z.discriminatedUnion('kind', [
|
|
20
|
+
z.object({ kind: z.literal('answered'), message: z.string().min(1), answer: z.string() }),
|
|
21
|
+
z.object({ kind: z.literal('note'), label: z.string().min(1).max(64), text: z.string().min(1) }),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses a Report envelope's payload into the report its kind discriminates,
|
|
26
|
+
* or null when it matches no known kind.
|
|
27
|
+
*/
|
|
28
|
+
export function parseReport(payload: Readonly<Record<string, unknown>>): Report | null {
|
|
29
|
+
const parsed = REPORT_SCHEMA.safeParse(payload);
|
|
30
|
+
|
|
31
|
+
if (!parsed.success) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (parsed.data.kind === 'note') {
|
|
36
|
+
return { kind: 'note', label: parsed.data.label, text: parsed.data.text };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
kind: 'answered',
|
|
41
|
+
message: toMessageID(parsed.data.message),
|
|
42
|
+
answer: parsed.data.answer,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -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);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
|
-
import {
|
|
3
|
+
import { socketPath } from '../shared/config';
|
|
4
4
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
5
5
|
import { isRecord } from '../shared/report';
|
|
6
|
+
import type { SessionID } from '../shared/session-id';
|
|
7
|
+
import { buildHeadlessEnv } from './build-headless-env';
|
|
6
8
|
import { resolveHeadlessExecutable } from './resolve-headless-executable';
|
|
7
9
|
|
|
8
10
|
const PERMISSION_MODES = [
|
|
@@ -21,6 +23,8 @@ interface HeadlessRunOptions {
|
|
|
21
23
|
readonly resume?: AgentSessionID;
|
|
22
24
|
readonly permissionMode?: string;
|
|
23
25
|
readonly settings?: string;
|
|
26
|
+
readonly sessionID?: SessionID;
|
|
27
|
+
readonly pluginDir?: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
interface HeadlessRunHooks {
|
|
@@ -54,7 +58,11 @@ export function startHeadlessRun(
|
|
|
54
58
|
prompt: opts.prompt,
|
|
55
59
|
options: {
|
|
56
60
|
cwd: opts.cwd,
|
|
57
|
-
env:
|
|
61
|
+
env: buildHeadlessEnv({
|
|
62
|
+
socketPath,
|
|
63
|
+
...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
|
|
64
|
+
...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
|
|
65
|
+
}),
|
|
58
66
|
abortController: controller,
|
|
59
67
|
stderr: (data: string) => {
|
|
60
68
|
stderrTail = `${stderrTail}${data}`.slice(-2000);
|
|
@@ -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
|
@@ -187,6 +187,38 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
187
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
188
|
inputSchema: EVENTS_READ_INPUT,
|
|
189
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
|
+
},
|
|
190
222
|
];
|
|
191
223
|
|
|
192
224
|
/**
|
|
@@ -424,6 +456,30 @@ async function runTool(
|
|
|
424
456
|
|
|
425
457
|
return JSON.stringify(ok, null, 2);
|
|
426
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
|
+
}
|
|
427
483
|
default: {
|
|
428
484
|
throw new Error(`unknown tool '${name}'`);
|
|
429
485
|
}
|
|
@@ -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 =
|
|
@@ -97,6 +98,17 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
97
98
|
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
98
99
|
waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
|
|
99
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' }),
|
|
100
112
|
} as const;
|
|
101
113
|
|
|
102
114
|
function buildDefaultedString(fallback: string) {
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { sendReport } from './shared/report';
|
|
2
|
+
import { REPORT_KINDS } from './shared/report-kinds';
|
|
3
|
+
|
|
4
|
+
interface ReportOptions {
|
|
5
|
+
readonly message: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
|
|
11
|
+
* atc socket as a Report envelope of the given kind. An `answered` report
|
|
12
|
+
* carries stdin as the final text for the given message; a `note` carries
|
|
13
|
+
* it as text for the user under the given label, `progress` when none is
|
|
14
|
+
* given. Always exits 0 so it never blocks the session it reports on.
|
|
15
|
+
*/
|
|
16
|
+
export async function runReport(kind: string, options: ReportOptions): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
const sock = process.env['ATC_SOCKET'];
|
|
19
|
+
const atcId = process.env['ATC_SESSION_ID'];
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
sock !== undefined &&
|
|
23
|
+
sock !== '' &&
|
|
24
|
+
atcId !== undefined &&
|
|
25
|
+
atcId !== '' &&
|
|
26
|
+
REPORT_KINDS.some((known) => known === kind)
|
|
27
|
+
) {
|
|
28
|
+
const stdin = await new Response(Bun.stdin.stream()).text();
|
|
29
|
+
|
|
30
|
+
const payload = buildReportPayload(kind, options, stdin);
|
|
31
|
+
|
|
32
|
+
if (payload !== null) {
|
|
33
|
+
const line = `${JSON.stringify({ atcId, event: 'Report', payload })}\n`;
|
|
34
|
+
|
|
35
|
+
await sendReport(sock, line, 2000);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildReportPayload(
|
|
44
|
+
kind: string,
|
|
45
|
+
options: ReportOptions,
|
|
46
|
+
stdin: string,
|
|
47
|
+
): Record<string, string> | null {
|
|
48
|
+
if (kind === 'answered') {
|
|
49
|
+
return options.message === '' ? null : { kind, message: options.message, answer: stdin };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (kind === 'note') {
|
|
53
|
+
return stdin.trim() === ''
|
|
54
|
+
? null
|
|
55
|
+
: { kind, label: options.label === '' ? 'progress' : options.label, text: stdin };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const REPORT_KINDS = ['answered', 'note'] as const;
|
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,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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
2
|
+
import type { MessageID } from '../shared/message-id';
|
|
3
|
+
import type { SessionID } from '../shared/session-id';
|
|
4
|
+
|
|
5
|
+
export type MessageStatus = 'accepted' | 'delivered' | 'answered';
|
|
6
|
+
|
|
7
|
+
export interface MessageRecord {
|
|
8
|
+
readonly id: MessageID;
|
|
9
|
+
readonly atcID: SessionID;
|
|
10
|
+
readonly agentSessionID?: AgentSessionID;
|
|
11
|
+
readonly from: string;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly status: MessageStatus;
|
|
14
|
+
readonly sentAt: number;
|
|
15
|
+
readonly deliveredAt?: number;
|
|
16
|
+
readonly answeredAt?: number;
|
|
17
|
+
readonly answer?: string;
|
|
18
|
+
}
|
|
@@ -2,6 +2,7 @@ import { sql } from 'kysely';
|
|
|
2
2
|
import type { Generated, Kysely } from 'kysely';
|
|
3
3
|
import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
|
|
4
4
|
import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
|
|
5
|
+
import type { MessageStatus } from './message-record';
|
|
5
6
|
|
|
6
7
|
interface FleetTable {
|
|
7
8
|
agent_session_id: string;
|
|
@@ -38,6 +39,19 @@ interface PrefsTable {
|
|
|
38
39
|
value: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
interface MessagesTable {
|
|
43
|
+
id: string;
|
|
44
|
+
atc_id: string;
|
|
45
|
+
agent_session_id: string | null;
|
|
46
|
+
sender: string;
|
|
47
|
+
text: string;
|
|
48
|
+
status: MessageStatus;
|
|
49
|
+
sent_at: number;
|
|
50
|
+
delivered_at: number | null;
|
|
51
|
+
answered_at: number | null;
|
|
52
|
+
answer: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
/**
|
|
42
56
|
* The state store's schema: what the query builder and the migration
|
|
43
57
|
* ladder both build against.
|
|
@@ -47,13 +61,14 @@ export interface StateStoreSchema {
|
|
|
47
61
|
events: EventsTable;
|
|
48
62
|
spawn_history: SpawnHistoryTable;
|
|
49
63
|
prefs: PrefsTable;
|
|
64
|
+
messages: MessagesTable;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
// Every shape the fleet table has shipped with: the oldest carries only
|
|
53
68
|
// agent_session_id under its Claude-era name plus name and cwd, and each
|
|
54
69
|
// later step adds one column the daemon grew to depend on. events later gains
|
|
55
|
-
// columns of its own, while spawn_history and
|
|
56
|
-
// since they were added.
|
|
70
|
+
// columns of its own, while spawn_history, prefs, and messages have carried one
|
|
71
|
+
// shape since they were added.
|
|
57
72
|
const MIGRATIONS: Record<string, Migration> = {
|
|
58
73
|
'001_create_initial_schema': {
|
|
59
74
|
async up(db: Kysely<StateStoreSchema>) {
|
|
@@ -166,6 +181,41 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
166
181
|
);
|
|
167
182
|
},
|
|
168
183
|
},
|
|
184
|
+
'011_create_messages': {
|
|
185
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
186
|
+
await db.schema
|
|
187
|
+
.createTable('messages')
|
|
188
|
+
.ifNotExists()
|
|
189
|
+
.addColumn('id', 'text', (c) => c.primaryKey())
|
|
190
|
+
.addColumn('atc_id', 'text', (c) => c.notNull())
|
|
191
|
+
.addColumn('agent_session_id', 'text')
|
|
192
|
+
.addColumn('sender', 'text', (c) => c.notNull())
|
|
193
|
+
.addColumn('text', 'text', (c) => c.notNull())
|
|
194
|
+
.addColumn('status', 'text', (c) => c.notNull())
|
|
195
|
+
.addColumn('sent_at', 'integer', (c) => c.notNull())
|
|
196
|
+
.addColumn('delivered_at', 'integer')
|
|
197
|
+
.addColumn('answered_at', 'integer')
|
|
198
|
+
.addColumn('answer', 'text')
|
|
199
|
+
.execute();
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
'012_index_messages_by_owner': {
|
|
203
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
204
|
+
await db.schema
|
|
205
|
+
.createIndex('messages_atc_id_status_sent_at')
|
|
206
|
+
.ifNotExists()
|
|
207
|
+
.on('messages')
|
|
208
|
+
.columns(['atc_id', 'status', 'sent_at'])
|
|
209
|
+
.execute();
|
|
210
|
+
|
|
211
|
+
await db.schema
|
|
212
|
+
.createIndex('messages_agent_session_id_status_sent_at')
|
|
213
|
+
.ifNotExists()
|
|
214
|
+
.on('messages')
|
|
215
|
+
.columns(['agent_session_id', 'status', 'sent_at'])
|
|
216
|
+
.execute();
|
|
217
|
+
},
|
|
218
|
+
},
|
|
169
219
|
};
|
|
170
220
|
|
|
171
221
|
const PROVIDER: MigrationProvider = {
|
package/src/store/state-store.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
|
|
4
|
+
import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
|
|
4
5
|
import { toAgentID } from '../agents/agent-adapter';
|
|
5
6
|
import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
|
|
6
7
|
import type { HookEvent } from '../daemon/hooks';
|
|
7
8
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
9
|
+
import type { MessageID } from '../shared/message-id';
|
|
8
10
|
import type { SessionID } from '../shared/session-id';
|
|
9
11
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
12
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
10
13
|
import { toSessionID } from '../shared/to-session-id';
|
|
11
14
|
import { BunSqliteDriver } from './bun-sqlite-driver';
|
|
12
15
|
import { parseFleetEntry } from './fleet-entry';
|
|
13
16
|
import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
|
|
17
|
+
import type { MessageOwner } from './message-owner';
|
|
18
|
+
import type { MessageRecord } from './message-record';
|
|
14
19
|
import { runMigrations } from './run-migrations';
|
|
15
20
|
import type { StateStoreSchema } from './run-migrations';
|
|
16
21
|
|
|
@@ -32,8 +37,8 @@ export interface StoredEvent {
|
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
39
|
* Daemon state in one SQLite store: the restorable fleet, the hook-event
|
|
35
|
-
* trail that events.read and lastActivityAt read,
|
|
36
|
-
* history. The statusline contract
|
|
40
|
+
* trail that events.read and lastActivityAt read, the spawn-directory
|
|
41
|
+
* history, and the per-session message inbox. The statusline contract
|
|
37
42
|
* file (status.json) stays a plain file because reporters inside wrangled
|
|
38
43
|
* sessions read it without speaking to the daemon. An existing fleet.json
|
|
39
44
|
* seeds the fleet table once, so upgrading never loses a restorable fleet.
|
|
@@ -293,6 +298,115 @@ export class StateStore {
|
|
|
293
298
|
.execute();
|
|
294
299
|
}
|
|
295
300
|
|
|
301
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
|
|
302
|
+
async writeMessage(record: MessageRecord): Promise<void> {
|
|
303
|
+
await this.db
|
|
304
|
+
.insertInto('messages')
|
|
305
|
+
.values({
|
|
306
|
+
id: record.id,
|
|
307
|
+
atc_id: record.atcID,
|
|
308
|
+
agent_session_id: record.agentSessionID ?? null,
|
|
309
|
+
sender: record.from,
|
|
310
|
+
text: record.text,
|
|
311
|
+
status: record.status,
|
|
312
|
+
sent_at: record.sentAt,
|
|
313
|
+
delivered_at: record.deliveredAt ?? null,
|
|
314
|
+
answered_at: record.answeredAt ?? null,
|
|
315
|
+
answer: record.answer ?? null,
|
|
316
|
+
})
|
|
317
|
+
.execute();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async collectPendingMessages(owner: MessageOwner): Promise<MessageRecord[]> {
|
|
321
|
+
const rows = await this.db
|
|
322
|
+
.selectFrom('messages')
|
|
323
|
+
.selectAll()
|
|
324
|
+
.where('status', '=', 'accepted')
|
|
325
|
+
.where((eb) => buildOwnerFilter(eb, owner))
|
|
326
|
+
.orderBy('sent_at', 'asc')
|
|
327
|
+
.orderBy(sql`rowid`, 'asc')
|
|
328
|
+
.execute();
|
|
329
|
+
|
|
330
|
+
return rows.map((row) => toMessageRecord(row));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async findMessage(id: MessageID, owner: MessageOwner): Promise<MessageRecord | null> {
|
|
334
|
+
const row = await this.db
|
|
335
|
+
.selectFrom('messages')
|
|
336
|
+
.selectAll()
|
|
337
|
+
.where('id', '=', id)
|
|
338
|
+
.where((eb) => buildOwnerFilter(eb, owner))
|
|
339
|
+
.executeTakeFirst();
|
|
340
|
+
|
|
341
|
+
return row === undefined ? null : toMessageRecord(row);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async findMessageByID(id: MessageID): Promise<MessageRecord | null> {
|
|
345
|
+
const row = await this.db
|
|
346
|
+
.selectFrom('messages')
|
|
347
|
+
.selectAll()
|
|
348
|
+
.where('id', '=', id)
|
|
349
|
+
.executeTakeFirst();
|
|
350
|
+
|
|
351
|
+
return row === undefined ? null : toMessageRecord(row);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async updateMessageDelivered(
|
|
355
|
+
id: MessageID,
|
|
356
|
+
owner: MessageOwner,
|
|
357
|
+
at: number,
|
|
358
|
+
): Promise<MessageRecord | null> {
|
|
359
|
+
const row = await this.db
|
|
360
|
+
.updateTable('messages')
|
|
361
|
+
.set({ status: 'delivered', delivered_at: at })
|
|
362
|
+
.where('id', '=', id)
|
|
363
|
+
.where('status', '=', 'accepted')
|
|
364
|
+
.where((eb) => buildOwnerFilter(eb, owner))
|
|
365
|
+
.returningAll()
|
|
366
|
+
.executeTakeFirst();
|
|
367
|
+
|
|
368
|
+
return row === undefined ? null : toMessageRecord(row);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async updateMessageAnswered(
|
|
372
|
+
id: MessageID,
|
|
373
|
+
owner: MessageOwner,
|
|
374
|
+
answer: string,
|
|
375
|
+
at: number,
|
|
376
|
+
): Promise<MessageRecord | null> {
|
|
377
|
+
const row = await this.db
|
|
378
|
+
.updateTable('messages')
|
|
379
|
+
.set({ status: 'answered', answered_at: at, answer })
|
|
380
|
+
.where('id', '=', id)
|
|
381
|
+
.where('status', 'in', ['accepted', 'delivered'])
|
|
382
|
+
.where((eb) => buildOwnerFilter(eb, owner))
|
|
383
|
+
.returningAll()
|
|
384
|
+
.executeTakeFirst();
|
|
385
|
+
|
|
386
|
+
return row === undefined ? null : toMessageRecord(row);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Messages sent before the agent reported its session id carry no agent
|
|
390
|
+
// session id; this stamps them once it is known, and moves every message
|
|
391
|
+
// from a previous agent session id to a changed one.
|
|
392
|
+
async updateMessageOwner(
|
|
393
|
+
atcID: SessionID,
|
|
394
|
+
previous: AgentSessionID | undefined,
|
|
395
|
+
next: AgentSessionID,
|
|
396
|
+
): Promise<void> {
|
|
397
|
+
await this.db
|
|
398
|
+
.updateTable('messages')
|
|
399
|
+
.set({ agent_session_id: next })
|
|
400
|
+
.where((eb) => {
|
|
401
|
+
const unstamped = eb.and([eb('atc_id', '=', atcID), eb('agent_session_id', 'is', null)]);
|
|
402
|
+
|
|
403
|
+
return previous === undefined
|
|
404
|
+
? unstamped
|
|
405
|
+
: eb.or([unstamped, eb('agent_session_id', '=', previous)]);
|
|
406
|
+
})
|
|
407
|
+
.execute();
|
|
408
|
+
}
|
|
409
|
+
|
|
296
410
|
async stop(): Promise<void> {
|
|
297
411
|
await this.db.destroy();
|
|
298
412
|
|
|
@@ -361,3 +475,31 @@ function buildStoredEvents(rows: readonly EventRow[]): StoredEvent[] {
|
|
|
361
475
|
|
|
362
476
|
return events;
|
|
363
477
|
}
|
|
478
|
+
|
|
479
|
+
function buildOwnerFilter(
|
|
480
|
+
eb: ExpressionBuilder<StateStoreSchema, 'messages'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
|
|
481
|
+
owner: MessageOwner,
|
|
482
|
+
): Expression<SqlBool> {
|
|
483
|
+
const byAtcID = eb('atc_id', '=', owner.atcID);
|
|
484
|
+
|
|
485
|
+
return owner.agentSessionID === undefined
|
|
486
|
+
? byAtcID
|
|
487
|
+
: eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
|
|
491
|
+
return {
|
|
492
|
+
id: toMessageID(row.id),
|
|
493
|
+
atcID: toSessionID(row.atc_id),
|
|
494
|
+
...(row.agent_session_id === null
|
|
495
|
+
? {}
|
|
496
|
+
: { agentSessionID: toAgentSessionID(row.agent_session_id) }),
|
|
497
|
+
from: row.sender,
|
|
498
|
+
text: row.text,
|
|
499
|
+
status: row.status,
|
|
500
|
+
sentAt: row.sent_at,
|
|
501
|
+
...(row.delivered_at === null ? {} : { deliveredAt: row.delivered_at }),
|
|
502
|
+
...(row.answered_at === null ? {} : { answeredAt: row.answered_at }),
|
|
503
|
+
...(row.answer === null ? {} : { answer: row.answer }),
|
|
504
|
+
};
|
|
505
|
+
}
|