@zgeoff/atc 2.6.0 → 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 +4 -0
- package/src/agents/claude-adapter.ts +2 -0
- package/src/agents/codex-adapter.ts +2 -0
- package/src/agents/gateway-adapter.ts +2 -0
- package/src/agents/grok-adapter.ts +2 -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-session-message-event.ts +29 -0
- package/src/daemon/daemon-connection.ts +193 -0
- package/src/daemon/daemon.ts +245 -3
- package/src/daemon/hooks.ts +13 -4
- 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/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 +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/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
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
|
+
}
|
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
|
+
}
|