@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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +26 -0
  3. package/src/agents/claude-adapter.ts +6 -1
  4. package/src/agents/codex-adapter.ts +3 -1
  5. package/src/agents/gateway-adapter.ts +6 -0
  6. package/src/agents/grok-adapter.ts +3 -1
  7. package/src/agents/parse-claude-transcript-line.ts +107 -0
  8. package/src/cli.ts +46 -0
  9. package/src/client/daemon-client.ts +13 -1
  10. package/src/daemon/answer-byte-cap.ts +1 -0
  11. package/src/daemon/build-fleet-events.ts +35 -0
  12. package/src/daemon/build-session-message-event.ts +29 -0
  13. package/src/daemon/daemon-connection.ts +337 -0
  14. package/src/daemon/daemon.ts +333 -6
  15. package/src/daemon/event-signal.ts +54 -0
  16. package/src/daemon/hooks.ts +13 -4
  17. package/src/daemon/load-transcript-page.ts +101 -0
  18. package/src/daemon/mint-message-id.ts +12 -0
  19. package/src/daemon/parse-report.ts +35 -0
  20. package/src/daemon/session-runtime.ts +9 -0
  21. package/src/daemon/sessions.ts +51 -10
  22. package/src/daemon/tap-registry.ts +67 -0
  23. package/src/mcp-server.ts +164 -1
  24. package/src/protocol/decode-cursor.ts +29 -0
  25. package/src/protocol/encode-cursor.ts +12 -0
  26. package/src/protocol/request-param-schemas.ts +30 -0
  27. package/src/report.ts +32 -0
  28. package/src/shared/message-id.ts +7 -0
  29. package/src/shared/report-kinds.ts +3 -0
  30. package/src/shared/report.ts +18 -2
  31. package/src/shared/to-message-id.ts +11 -0
  32. package/src/shared/truncate-to-bytes.ts +21 -0
  33. package/src/store/fleet-entry.ts +20 -0
  34. package/src/store/message-owner.ts +9 -0
  35. package/src/store/message-record.ts +18 -0
  36. package/src/store/run-migrations.ts +94 -3
  37. package/src/store/state-store.ts +272 -4
  38. package/src/tap.ts +120 -0
@@ -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;
@@ -12,6 +13,9 @@ interface FleetTable {
12
13
  agent: string;
13
14
  exited: number;
14
15
  parent: string | null;
16
+ prompt: string | null;
17
+ result: string | null;
18
+ transcript_path: string | null;
15
19
  }
16
20
 
17
21
  interface EventsTable {
@@ -21,6 +25,8 @@ interface EventsTable {
21
25
  event: string;
22
26
  message: string | null;
23
27
  session_id: string | null;
28
+ kind: string | null;
29
+ detail: string | null;
24
30
  }
25
31
 
26
32
  interface SpawnHistoryTable {
@@ -33,6 +39,19 @@ interface PrefsTable {
33
39
  value: string;
34
40
  }
35
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
+
36
55
  /**
37
56
  * The state store's schema: what the query builder and the migration
38
57
  * ladder both build against.
@@ -42,13 +61,14 @@ export interface StateStoreSchema {
42
61
  events: EventsTable;
43
62
  spawn_history: SpawnHistoryTable;
44
63
  prefs: PrefsTable;
64
+ messages: MessagesTable;
45
65
  }
46
66
 
47
67
  // Every shape the fleet table has shipped with: the oldest carries only
48
68
  // agent_session_id under its Claude-era name plus name and cwd, and each
49
- // later step adds one column the daemon grew to depend on. events,
50
- // spawn_history, and prefs have carried one shape since they were added, so
51
- // they need only their initial creation.
69
+ // later step adds one column the daemon grew to depend on. events later gains
70
+ // columns of its own, while spawn_history, prefs, and messages have carried one
71
+ // shape since they were added.
52
72
  const MIGRATIONS: Record<string, Migration> = {
53
73
  '001_create_initial_schema': {
54
74
  async up(db: Kysely<StateStoreSchema>) {
@@ -125,6 +145,77 @@ const MIGRATIONS: Record<string, Migration> = {
125
145
  await db.schema.alterTable('fleet').addColumn('parent', 'text').execute();
126
146
  },
127
147
  },
148
+ '008_add_fleet_prompt_result_transcript': {
149
+ async up(db: Kysely<StateStoreSchema>) {
150
+ await db.schema.alterTable('fleet').addColumn('prompt', 'text').execute();
151
+ await db.schema.alterTable('fleet').addColumn('result', 'text').execute();
152
+ await db.schema.alterTable('fleet').addColumn('transcript_path', 'text').execute();
153
+ },
154
+ },
155
+ '009_add_events_kind_detail': {
156
+ async up(db: Kysely<StateStoreSchema>) {
157
+ await db.schema.alterTable('events').addColumn('kind', 'text').execute();
158
+ await db.schema.alterTable('events').addColumn('detail', 'text').execute();
159
+ },
160
+ },
161
+ '010_add_events_trail_indexes': {
162
+ async up(db: Kysely<StateStoreSchema>) {
163
+ await db.schema
164
+ .createIndex('events_atc_id_ts')
165
+ .ifNotExists()
166
+ .on('events')
167
+ .columns(['atc_id', 'ts'])
168
+ .execute();
169
+
170
+ await db.schema
171
+ .createIndex('events_session_id_ts')
172
+ .ifNotExists()
173
+ .on('events')
174
+ .columns(['session_id', 'ts'])
175
+ .execute();
176
+
177
+ // Partial, so the trail reads walk only the rows they return. The
178
+ // predicate text must match the reads' filter for SQLite to pick it.
179
+ await sql`CREATE INDEX IF NOT EXISTS events_trail ON events (id) WHERE kind IS NOT NULL AND kind != 'heartbeat'`.execute(
180
+ db,
181
+ );
182
+ },
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
+ },
128
219
  };
129
220
 
130
221
  const PROVIDER: MigrationProvider = {
@@ -1,20 +1,44 @@
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
- import type { AgentID } from '../agents/agent-adapter';
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';
10
+ import type { SessionID } from '../shared/session-id';
8
11
  import { toAgentSessionID } from '../shared/to-agent-session-id';
12
+ import { toMessageID } from '../shared/to-message-id';
13
+ import { toSessionID } from '../shared/to-session-id';
9
14
  import { BunSqliteDriver } from './bun-sqlite-driver';
10
15
  import { parseFleetEntry } from './fleet-entry';
11
- import type { FleetEntry } from './fleet-entry';
16
+ import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
17
+ import type { MessageOwner } from './message-owner';
18
+ import type { MessageRecord } from './message-record';
12
19
  import { runMigrations } from './run-migrations';
13
20
  import type { StateStoreSchema } from './run-migrations';
14
21
 
22
+ // Spelled as the partial index's predicate so SQLite can match them.
23
+ const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
24
+
25
+ export interface StoredEvent {
26
+ readonly id: number;
27
+
28
+ // Epoch ms of ts.
29
+ readonly at: number;
30
+ readonly atcID: SessionID;
31
+ readonly agentSessionID: AgentSessionID | null;
32
+ readonly kind: string;
33
+
34
+ // The event's detail, else the hook's message.
35
+ readonly detail: string | null;
36
+ }
37
+
15
38
  /**
16
39
  * Daemon state in one SQLite store: the restorable fleet, the hook-event
17
- * debug trail, and the spawn-directory 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
18
42
  * file (status.json) stays a plain file because reporters inside wrangled
19
43
  * sessions read it without speaking to the daemon. An existing fleet.json
20
44
  * seeds the fleet table once, so upgrading never loses a restorable fleet.
@@ -72,6 +96,9 @@ export class StateStore {
72
96
  'agent',
73
97
  'exited',
74
98
  'parent',
99
+ 'prompt',
100
+ 'result',
101
+ 'transcript_path',
75
102
  ])
76
103
  .execute();
77
104
 
@@ -87,6 +114,9 @@ export class StateStore {
87
114
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
88
115
  ...(row.exited === 0 ? {} : { exited: true }),
89
116
  ...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
117
+ ...(row.prompt === null ? {} : { prompt: row.prompt }),
118
+ ...(row.result === null ? {} : { result: row.result }),
119
+ ...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
90
120
  });
91
121
  }
92
122
 
@@ -132,6 +162,9 @@ export class StateStore {
132
162
  agent: entry.agent,
133
163
  exited: entry.exited === true ? 1 : 0,
134
164
  parent: entry.parent ?? null,
165
+ prompt: entry.prompt ?? null,
166
+ result: entry.result ?? null,
167
+ transcript_path: entry.transcriptPath ?? null,
135
168
  })
136
169
  .orReplace()
137
170
  .execute();
@@ -139,7 +172,27 @@ export class StateStore {
139
172
  });
140
173
  }
141
174
 
142
- async recordEvent(e: HookEvent): Promise<void> {
175
+ // Rewrites fields on one existing row and leaves every other row as it
176
+ // stands. A session without a row yet is a no-op: the next fleet write
177
+ // inserts it with these fields.
178
+ async updateFleetEntry(agentSessionID: AgentSessionID, fields: FleetEntryUpdate): Promise<void> {
179
+ const values = {
180
+ ...(fields.result === undefined ? {} : { result: fields.result }),
181
+ ...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
182
+ };
183
+
184
+ if (Object.keys(values).length === 0) {
185
+ return;
186
+ }
187
+
188
+ await this.db
189
+ .updateTable('fleet')
190
+ .set(values)
191
+ .where('agent_session_id', '=', agentSessionID)
192
+ .execute();
193
+ }
194
+
195
+ async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
143
196
  const rawMessage = e.payload['message'];
144
197
  const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
145
198
  const message = typeof rawMessage === 'string' ? rawMessage : null;
@@ -154,10 +207,57 @@ export class StateStore {
154
207
  event: e.event,
155
208
  message,
156
209
  session_id: sessionID,
210
+ kind: ev === null ? null : ev.kind,
211
+ detail: ev?.detail ?? null,
157
212
  })
158
213
  .execute();
159
214
  }
160
215
 
216
+ async collectEventsAfter(afterID: number, limit: number): Promise<StoredEvent[]> {
217
+ const rows = await this.db
218
+ .selectFrom('events')
219
+ .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
220
+ .where('id', '>', afterID)
221
+ .where(TRAIL_FILTER)
222
+ .orderBy('id', 'asc')
223
+ .limit(limit)
224
+ .execute();
225
+
226
+ return buildStoredEvents(rows);
227
+ }
228
+
229
+ async collectLatestEvents(limit: number): Promise<StoredEvent[]> {
230
+ const rows = await this.db
231
+ .selectFrom('events')
232
+ .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
233
+ .where(TRAIL_FILTER)
234
+ .orderBy('id', 'desc')
235
+ .limit(limit)
236
+ .execute();
237
+
238
+ return buildStoredEvents(rows).toReversed();
239
+ }
240
+
241
+ // Matches the session by either id, since a restore re-mints the atc id
242
+ // while the agent session id carries on.
243
+ async loadLastActivityAt(
244
+ atcID: SessionID,
245
+ agentSessionID: AgentSessionID | undefined,
246
+ ): Promise<number | null> {
247
+ const row = await this.db
248
+ .selectFrom('events')
249
+ .select(sql<string | null>`MAX(ts)`.as('ts'))
250
+ .where((eb) =>
251
+ eb.or([
252
+ eb('atc_id', '=', atcID),
253
+ ...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
254
+ ]),
255
+ )
256
+ .executeTakeFirst();
257
+
258
+ return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
259
+ }
260
+
161
261
  async recordSpawnDir(cwd: string): Promise<void> {
162
262
  await this.db
163
263
  .insertInto('spawn_history')
@@ -198,6 +298,115 @@ export class StateStore {
198
298
  .execute();
199
299
  }
200
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
+
201
410
  async stop(): Promise<void> {
202
411
  await this.db.destroy();
203
412
 
@@ -235,3 +444,62 @@ export class StateStore {
235
444
  } catch {}
236
445
  }
237
446
  }
447
+
448
+ interface EventRow {
449
+ readonly id: number;
450
+ readonly ts: string;
451
+ readonly atc_id: string;
452
+ readonly session_id: string | null;
453
+ readonly kind: string | null;
454
+ readonly detail: string | null;
455
+ readonly message: string | null;
456
+ }
457
+
458
+ function buildStoredEvents(rows: readonly EventRow[]): StoredEvent[] {
459
+ const events: StoredEvent[] = [];
460
+
461
+ for (const row of rows) {
462
+ if (row.kind === null) {
463
+ continue;
464
+ }
465
+
466
+ events.push({
467
+ id: row.id,
468
+ at: Date.parse(row.ts),
469
+ atcID: toSessionID(row.atc_id),
470
+ agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
471
+ kind: row.kind,
472
+ detail: row.detail ?? row.message,
473
+ });
474
+ }
475
+
476
+ return events;
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
+ }
package/src/tap.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import { DaemonClient } from './client/daemon-client';
3
+ import { DaemonError } from './protocol/daemon-error';
4
+ import { daemonSocketPath } from './shared/config';
5
+ import { getBuild } from './shared/get-build';
6
+
7
+ const INBOX_MESSAGE_SCHEMA = z.looseObject({
8
+ message: z.string(),
9
+ from: z.string(),
10
+ text: z.string(),
11
+ sentAt: z.number(),
12
+ });
13
+
14
+ interface InboxMessage {
15
+ readonly message: string;
16
+ readonly from: string;
17
+ readonly text: string;
18
+ readonly sentAt: number;
19
+ }
20
+
21
+ /**
22
+ * Streams one session's inbox to stdout, one NDJSON line per message, and
23
+ * acks each message once its line is written. It talks to the daemon that
24
+ * is already running and never boots one: a tap runs inside the session it
25
+ * serves, so a restart from here would take the whole fleet down. Exits 1
26
+ * with a hint when no daemon listens or the session cannot be tapped, and
27
+ * 0 once the daemon closes the connection or ends the subscription because
28
+ * another tap replaced it or the session was removed.
29
+ */
30
+ export async function runTap(session: string): Promise<void> {
31
+ let client: DaemonClient;
32
+
33
+ try {
34
+ client = await DaemonClient.open(daemonSocketPath);
35
+ } catch {
36
+ console.error(`atc tap: no daemon at ${daemonSocketPath} — start atc first`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const closed = Promise.withResolvers<void>();
41
+ let written: Promise<void> = Promise.resolve();
42
+
43
+ client.onClose = () => {
44
+ closed.resolve();
45
+ };
46
+
47
+ client.onEvent = (event) => {
48
+ if (event.ev === 'InboxClosed' && event['s'] === session) {
49
+ closed.resolve();
50
+
51
+ return;
52
+ }
53
+
54
+ if (event.ev !== 'InboxMessage' || event['s'] !== session) {
55
+ return;
56
+ }
57
+
58
+ const parsed = INBOX_MESSAGE_SCHEMA.safeParse(event);
59
+
60
+ if (!parsed.success) {
61
+ return;
62
+ }
63
+
64
+ const msg: InboxMessage = {
65
+ message: parsed.data.message,
66
+ from: parsed.data.from,
67
+ text: parsed.data.text,
68
+ sentAt: parsed.data.sentAt,
69
+ };
70
+
71
+ const previous = written;
72
+
73
+ written = (async () => {
74
+ await previous;
75
+ await ackInboxMessage(client, session, msg);
76
+ })();
77
+ };
78
+
79
+ try {
80
+ await client.sendHello(getBuild());
81
+ await client.sendRequest('session.tap', { session });
82
+ } catch (error) {
83
+ console.error(`atc tap: ${formatError(error)}`);
84
+ client.stop();
85
+ process.exit(1);
86
+ }
87
+
88
+ await closed.promise;
89
+ await written;
90
+
91
+ process.exit(0);
92
+ }
93
+
94
+ interface RequestSender {
95
+ readonly sendRequest: DaemonClient['sendRequest'];
96
+ }
97
+
98
+ async function ackInboxMessage(
99
+ client: RequestSender,
100
+ session: string,
101
+ msg: InboxMessage,
102
+ ): Promise<void> {
103
+ const line = `${JSON.stringify({ id: msg.message, from: msg.from, text: msg.text, sentAt: msg.sentAt })}\n`;
104
+
105
+ try {
106
+ await Bun.write(Bun.stdout, line);
107
+ } catch {
108
+ process.exit(1);
109
+ }
110
+
111
+ try {
112
+ await client.sendRequest('message.ack', { session, message: msg.message });
113
+ } catch (error) {
114
+ console.error(`atc tap: ${formatError(error)}`);
115
+ }
116
+ }
117
+
118
+ function formatError(error: unknown): string {
119
+ return error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
120
+ }