@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.
Files changed (39) hide show
  1. package/README.md +4 -0
  2. package/package.json +3 -1
  3. package/src/agents/agent-adapter.ts +11 -0
  4. package/src/agents/atc-bridge-files.ts +11 -0
  5. package/src/agents/build-cli-argv.ts +13 -0
  6. package/src/agents/build-cli-command.ts +4 -9
  7. package/src/agents/claude-adapter.ts +23 -2
  8. package/src/agents/codex-adapter.ts +2 -0
  9. package/src/agents/gateway-adapter.ts +23 -1
  10. package/src/agents/grok-adapter.ts +2 -0
  11. package/src/agents/write-atc-bridge.ts +42 -0
  12. package/src/agents/write-hook-settings.ts +2 -1
  13. package/src/cli.ts +51 -0
  14. package/src/client/daemon-client.ts +13 -1
  15. package/src/daemon/answer-byte-cap.ts +1 -0
  16. package/src/daemon/build-headless-env.ts +23 -0
  17. package/src/daemon/build-session-message-event.ts +29 -0
  18. package/src/daemon/build-session-report-event.ts +24 -0
  19. package/src/daemon/daemon-connection.ts +200 -0
  20. package/src/daemon/daemon.ts +264 -3
  21. package/src/daemon/hooks.ts +13 -4
  22. package/src/daemon/mint-message-id.ts +12 -0
  23. package/src/daemon/parse-report.ts +44 -0
  24. package/src/daemon/session-runtime.ts +9 -0
  25. package/src/daemon/start-headless-run.ts +10 -2
  26. package/src/daemon/start-headless-turn.ts +1 -0
  27. package/src/daemon/tap-registry.ts +67 -0
  28. package/src/mcp-server.ts +56 -0
  29. package/src/protocol/request-param-schemas.ts +12 -0
  30. package/src/report.ts +59 -0
  31. package/src/shared/message-id.ts +7 -0
  32. package/src/shared/report-kinds.ts +1 -0
  33. package/src/shared/report.ts +18 -2
  34. package/src/shared/to-message-id.ts +11 -0
  35. package/src/store/message-owner.ts +9 -0
  36. package/src/store/message-record.ts +18 -0
  37. package/src/store/run-migrations.ts +52 -2
  38. package/src/store/state-store.ts +144 -2
  39. 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 { collectCleanEnv } from '../shared/collect-clean-env';
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: collectCleanEnv(),
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);
@@ -31,6 +31,7 @@ export function startHeadlessTurn(
31
31
  prompt,
32
32
  ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
33
33
  permissionMode: 'auto',
34
+ sessionID,
34
35
  },
35
36
  {
36
37
  onOutput: (text) => {
@@ -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,7 @@
1
+ import type { Tagged } from 'type-fest';
2
+
3
+ /**
4
+ * An atc-minted message id; keys the messages table and every message
5
+ * request and event.
6
+ */
7
+ export type MessageID = Tagged<string, 'MessageID'>;
@@ -0,0 +1 @@
1
+ export const REPORT_KINDS = ['answered', 'note'] as const;
@@ -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(line);
12
- s.end();
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 prefs have carried one shape
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 = {
@@ -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, and the spawn-directory
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
+ }