@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -124,6 +124,10 @@ export interface AgentAdapter {
124
124
 
125
125
  // The detector stack's screen tier; null when hooks are authoritative.
126
126
  readonly screenDetector: ScreenDetector | null;
127
+
128
+ // Whether a session under this agent can take inbox messages through a tap;
129
+ // false refuses every message as unsupported.
130
+ readonly takesMessages: boolean;
127
131
  readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
128
132
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
129
133
  readonly loadName: (
@@ -47,6 +47,8 @@ export class ClaudeAdapter implements AgentAdapter {
47
47
 
48
48
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
49
49
 
50
+ readonly takesMessages = true;
51
+
50
52
  private readonly config: Config;
51
53
 
52
54
  // Written on first spawn so constructing the adapter touches no state.
@@ -47,6 +47,8 @@ export class CodexAdapter implements AgentAdapter {
47
47
  // Codex's hooks are authoritative; no screen heuristics needed.
48
48
  readonly screenDetector = null;
49
49
 
50
+ readonly takesMessages = false;
51
+
50
52
  private readonly config: Config;
51
53
 
52
54
  constructor(config: Config) {
@@ -37,6 +37,8 @@ export class GatewayAdapter implements AgentAdapter {
37
37
  // The gateway runs the Claude CLI, which writes the same transcript.
38
38
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
39
39
 
40
+ readonly takesMessages = true;
41
+
40
42
  private readonly gateway: GatewayConfig;
41
43
 
42
44
  private readonly claude: ClaudeAdapter;
@@ -60,6 +60,8 @@ export class GrokAdapter implements AgentAdapter {
60
60
  // Grok's hooks are authoritative; no screen heuristics needed.
61
61
  readonly screenDetector = null;
62
62
 
63
+ readonly takesMessages = false;
64
+
63
65
  private readonly config: Config;
64
66
 
65
67
  private readonly hookState = new Map<SessionID, GrokSessionHookState>();
package/src/cli.ts CHANGED
@@ -63,6 +63,10 @@ const main = defineCommand({
63
63
  const restoreBootTimeoutMs =
64
64
  Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
65
65
 
66
+ // How long a started session may go without a tap before a message
67
+ // to it is refused. Tests pin it to 0 to reach the refusal at once.
68
+ const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
69
+
66
70
  const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
67
71
  headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
68
72
  );
@@ -89,6 +93,9 @@ const main = defineCommand({
89
93
  pidPath: config.daemonPidFile,
90
94
  hooks: cfg.hooks,
91
95
  restoreBootTimeoutMs,
96
+ ...(Number.isFinite(graceOverride) && graceOverride >= 0
97
+ ? { tapGraceMs: graceOverride }
98
+ : {}),
92
99
  ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
93
100
  onQuit: () => process.exit(0),
94
101
  });
@@ -152,6 +159,45 @@ const main = defineCommand({
152
159
  await reporter.runHookReport();
153
160
  },
154
161
  }),
162
+ tap: () =>
163
+ defineCommand({
164
+ meta: {
165
+ name: 'tap',
166
+ description: "Stream a session's inbox to stdout as NDJSON, acking each message",
167
+ },
168
+ args: {
169
+ session: {
170
+ type: 'string',
171
+ required: true,
172
+ description: 'The atc session id to tap',
173
+ },
174
+ },
175
+ async run(ctx) {
176
+ const tap = await import('./tap');
177
+
178
+ await tap.runTap(ctx.args.session);
179
+ },
180
+ }),
181
+ report: () =>
182
+ defineCommand({
183
+ meta: {
184
+ name: 'report',
185
+ description: 'Report a message event from a wrangled session to the atc socket',
186
+ hidden: true,
187
+ },
188
+
189
+ // Neither arg is required: a citty usage error exits nonzero, and
190
+ // reporters must always exit 0.
191
+ args: {
192
+ kind: { type: 'positional', required: false, default: '' },
193
+ message: { type: 'string', default: '' },
194
+ },
195
+ async run(ctx) {
196
+ const reporter = await import('./report');
197
+
198
+ await reporter.runReport(ctx.args.kind, ctx.args.message);
199
+ },
200
+ }),
155
201
  statusline: () =>
156
202
  defineCommand({
157
203
  meta: {
@@ -16,10 +16,14 @@ interface Pending {
16
16
  export class DaemonClient {
17
17
  onEvent: (event: EventMsg) => void = () => {};
18
18
 
19
+ onClose: () => void = () => {};
20
+
19
21
  private queue: OutboundQueue | null = null;
20
22
 
21
23
  private buffer = '';
22
24
 
25
+ private readonly decoder = new TextDecoder();
26
+
23
27
  private nextID = 1;
24
28
 
25
29
  private readonly pending = new Map<number, Pending>();
@@ -33,13 +37,14 @@ export class DaemonClient {
33
37
  unix: socketPath,
34
38
  socket: {
35
39
  data(_s, buf) {
36
- client.applyChunk(buf.toString());
40
+ client.applyChunk(client.decodeChunk(buf));
37
41
  },
38
42
  drain() {
39
43
  client.queue?.drain();
40
44
  },
41
45
  close() {
42
46
  client.drainPending('connection closed');
47
+ client.onClose();
43
48
  },
44
49
  error() {},
45
50
  },
@@ -74,6 +79,13 @@ export class DaemonClient {
74
79
  this.drainPending('client closed');
75
80
  }
76
81
 
82
+ // Decodes with state kept across reads, so a multi-byte character split
83
+ // between two reads decodes whole.
84
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
85
+ private decodeChunk(buf: Uint8Array): string {
86
+ return this.decoder.decode(buf, { stream: true });
87
+ }
88
+
77
89
  private applyChunk(chunk: string): void {
78
90
  this.buffer += chunk;
79
91
 
@@ -0,0 +1 @@
1
+ export const ANSWER_BYTE_CAP = 65_536;
@@ -0,0 +1,29 @@
1
+ import { truncateDetail } from '../agents/truncate-detail';
2
+ import { PROTOCOL_V } from '../protocol/protocol';
3
+ import type { EventMsg } from '../protocol/protocol';
4
+ import type { SessionID } from '../shared/session-id';
5
+ import type { MessageRecord } from '../store/message-record';
6
+
7
+ /**
8
+ * The `SessionMessage` event for one message status change, addressed by
9
+ * the atc session id the message currently belongs to. The event carries
10
+ * short previews of the text and answer, never the full content, so a
11
+ * broadcast stays small; `message.get` returns the full message. Delivery
12
+ * and answer fields appear only once they are set.
13
+ */
14
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
15
+ export function buildSessionMessageEvent(sessionID: SessionID, record: MessageRecord): EventMsg {
16
+ return {
17
+ v: PROTOCOL_V,
18
+ ev: 'SessionMessage',
19
+ s: sessionID,
20
+ message: record.id,
21
+ status: record.status,
22
+ from: record.from,
23
+ textPreview: truncateDetail(record.text),
24
+ sentAt: record.sentAt,
25
+ ...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
26
+ ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
27
+ ...(record.answer === undefined ? {} : { answerPreview: truncateDetail(record.answer) }),
28
+ };
29
+ }
@@ -13,8 +13,10 @@ import {
13
13
  encodeMessage,
14
14
  } from '../protocol/protocol';
15
15
  import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
16
+ import type { MessageID } from '../shared/message-id';
16
17
  import type { SessionID } from '../shared/session-id';
17
18
  import type { FleetEntry } from '../store/fleet-entry';
19
+ import type { MessageRecord } from '../store/message-record';
18
20
  import type { Dims } from './attach-registry';
19
21
  import type { FleetEvent } from './build-fleet-events';
20
22
  import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
@@ -100,6 +102,18 @@ export interface DaemonContext {
100
102
  limit: number,
101
103
  waitMs: number,
102
104
  ) => Promise<FleetEvent[]>;
105
+ readonly writeSessionMessage: (
106
+ sessionID: SessionID,
107
+ from: string,
108
+ text: string,
109
+ ) => Promise<MessageRecord | 'missing' | 'dead' | 'unsupported' | 'no_tap'>;
110
+ readonly readMessage: (messageID: MessageID) => Promise<MessageView | null>;
111
+ readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
112
+ readonly ackMessage: (
113
+ client: TapClient,
114
+ sessionID: SessionID,
115
+ messageID: MessageID,
116
+ ) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
103
117
  }
104
118
 
105
119
  // The slice of a connection the attach bookkeeping needs: identity plus the
@@ -108,6 +122,18 @@ export interface OutputClient {
108
122
  readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
109
123
  }
110
124
 
125
+ // One message as `message.get` reports it: the session it belongs to now and
126
+ // every field of the record.
127
+ interface MessageView {
128
+ readonly session: SessionID;
129
+ readonly record: MessageRecord;
130
+ }
131
+
132
+ // The slice of a connection a tap subscription needs.
133
+ export interface TapClient {
134
+ readonly sendEvent: (event: EventMsg) => void;
135
+ }
136
+
111
137
  interface PeerSocket extends SocketWriter {
112
138
  readonly end: () => void;
113
139
  }
@@ -121,6 +147,8 @@ export class DaemonConnection {
121
147
 
122
148
  private buffer = '';
123
149
 
150
+ private readonly decoder = new TextDecoder();
151
+
124
152
  private helloed = false;
125
153
 
126
154
  // The handshake answer is written before any other response on this
@@ -165,6 +193,13 @@ export class DaemonConnection {
165
193
  }
166
194
  }
167
195
 
196
+ // Decodes with state kept across reads, so a multi-byte character split
197
+ // between two reads decodes whole.
198
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
199
+ decodeChunk(buf: Uint8Array): string {
200
+ return this.decoder.decode(buf, { stream: true });
201
+ }
202
+
168
203
  applyChunk(chunk: string): void {
169
204
  const buffered = this.buffer + chunk;
170
205
 
@@ -463,6 +498,26 @@ export class DaemonConnection {
463
498
 
464
499
  return;
465
500
  }
501
+ case 'session.message': {
502
+ await this.applySessionMessage(req);
503
+
504
+ return;
505
+ }
506
+ case 'session.tap': {
507
+ this.applyTap(req);
508
+
509
+ return;
510
+ }
511
+ case 'message.get': {
512
+ await this.applyMessageGet(req);
513
+
514
+ return;
515
+ }
516
+ case 'message.ack': {
517
+ await this.applyMessageAck(req);
518
+
519
+ return;
520
+ }
466
521
  case 'fleet.restore': {
467
522
  const parsed = parseRequestParams('fleet.restore', req.p);
468
523
 
@@ -775,6 +830,144 @@ export class DaemonConnection {
775
830
  });
776
831
  }
777
832
 
833
+ private async applySessionMessage(req: RequestMsg): Promise<void> {
834
+ const parsed = parseRequestParams('session.message', req.p);
835
+
836
+ if (!parsed.ok) {
837
+ this.sendErr(req.id, 'bad_args', parsed.message);
838
+
839
+ return;
840
+ }
841
+
842
+ const sessionID = parsed.data.session;
843
+
844
+ const result = await this.ctx.writeSessionMessage(
845
+ sessionID,
846
+ parsed.data.from,
847
+ parsed.data.text,
848
+ );
849
+
850
+ if (result === 'missing') {
851
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
852
+
853
+ return;
854
+ }
855
+
856
+ if (result === 'dead') {
857
+ this.sendErr(req.id, 'session_dead', `session '${sessionID}' has no live process`);
858
+
859
+ return;
860
+ }
861
+
862
+ if (result === 'unsupported') {
863
+ this.sendErr(
864
+ req.id,
865
+ 'unsupported',
866
+ `session '${sessionID}' cannot take messages: its agent has no message tap`,
867
+ );
868
+
869
+ return;
870
+ }
871
+
872
+ if (result === 'no_tap') {
873
+ this.sendErr(req.id, 'unsupported', `session '${sessionID}' never attached a message tap`);
874
+
875
+ return;
876
+ }
877
+
878
+ this.sendOk(req.id, { message: result.id, status: result.status });
879
+ }
880
+
881
+ private applyTap(req: RequestMsg): void {
882
+ const parsed = parseRequestParams('session.tap', req.p);
883
+
884
+ if (!parsed.ok) {
885
+ this.sendErr(req.id, 'bad_args', parsed.message);
886
+
887
+ return;
888
+ }
889
+
890
+ const sessionID = parsed.data.session;
891
+ const result = this.ctx.attachTap(this, sessionID);
892
+
893
+ if (result === 'missing') {
894
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
895
+
896
+ return;
897
+ }
898
+
899
+ if (result === 'unsupported') {
900
+ this.sendErr(req.id, 'unsupported', `session '${sessionID}' cannot take messages`);
901
+
902
+ return;
903
+ }
904
+
905
+ this.sendOk(req.id, {});
906
+ }
907
+
908
+ private async applyMessageGet(req: RequestMsg): Promise<void> {
909
+ const parsed = parseRequestParams('message.get', req.p);
910
+
911
+ if (!parsed.ok) {
912
+ this.sendErr(req.id, 'bad_args', parsed.message);
913
+
914
+ return;
915
+ }
916
+
917
+ const messageID = parsed.data.message;
918
+
919
+ const view = await this.ctx.readMessage(messageID);
920
+
921
+ if (view === null) {
922
+ this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
923
+
924
+ return;
925
+ }
926
+
927
+ const record = view.record;
928
+
929
+ this.sendOk(req.id, {
930
+ message: record.id,
931
+ session: view.session,
932
+ from: record.from,
933
+ text: record.text,
934
+ status: record.status,
935
+ sentAt: record.sentAt,
936
+ ...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
937
+ ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
938
+ ...(record.answer === undefined ? {} : { answer: record.answer }),
939
+ });
940
+ }
941
+
942
+ private async applyMessageAck(req: RequestMsg): Promise<void> {
943
+ const parsed = parseRequestParams('message.ack', req.p);
944
+
945
+ if (!parsed.ok) {
946
+ this.sendErr(req.id, 'bad_args', parsed.message);
947
+
948
+ return;
949
+ }
950
+
951
+ const sessionID = parsed.data.session;
952
+ const messageID = parsed.data.message;
953
+
954
+ const result = await this.ctx.ackMessage(this, sessionID, messageID);
955
+
956
+ if (result === 'not_tapping') {
957
+ this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
958
+
959
+ return;
960
+ }
961
+
962
+ if (result === 'unknown') {
963
+ this.sendErr(req.id, 'bad_args', `no message '${messageID}' for session '${sessionID}'`);
964
+
965
+ return;
966
+ }
967
+
968
+ this.sendOk(req.id, { message: result.id, status: result.status });
969
+ }
970
+
778
971
  private async applySessionVerb(
779
972
  req: RequestMsg,
780
973
  method: 'session.kill' | 'session.ack',