@zgeoff/atc 2.14.0 → 2.15.1

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 (71) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +3 -38
  3. package/src/agents/build-hook-settings.ts +1 -1
  4. package/src/agents/claude-adapter.ts +3 -3
  5. package/src/agents/codex-adapter.ts +3 -3
  6. package/src/agents/gateway-adapter.ts +3 -3
  7. package/src/agents/grok-adapter.ts +3 -3
  8. package/src/agents/start-claude-headless-run.ts +1 -1
  9. package/src/client/boot-daemon.ts +2 -2
  10. package/src/client/collect-agent-picks.ts +1 -1
  11. package/src/client/daemon-client.ts +7 -23
  12. package/src/client/format-overlay-agent-mark.ts +1 -1
  13. package/src/client/index.ts +4 -2
  14. package/src/client/pick-tab-target.ts +2 -2
  15. package/src/client/spawn-picker.ts +1 -1
  16. package/src/client/to-mirror-session.ts +3 -3
  17. package/src/client/ui.ts +3 -3
  18. package/src/daemon/build-imp-provider.ts +70 -23
  19. package/src/daemon/build-message-trail-entry.ts +1 -1
  20. package/src/daemon/build-report-trail-entry.ts +1 -1
  21. package/src/daemon/build-scoped-context.ts +1 -1
  22. package/src/daemon/build-session-message-event.ts +1 -1
  23. package/src/daemon/daemon-connection.ts +9 -253
  24. package/src/daemon/daemon-context.ts +240 -0
  25. package/src/daemon/daemon.ts +9 -5
  26. package/src/daemon/execution-provider.ts +12 -0
  27. package/src/daemon/hooks.ts +6 -24
  28. package/src/daemon/idempotency-ledger.ts +78 -18
  29. package/src/daemon/imp-client-port.ts +83 -27
  30. package/src/daemon/imp-harness.ts +41 -0
  31. package/src/daemon/imp-provider.ts +5 -11
  32. package/src/daemon/local-pty-provider.ts +45 -0
  33. package/src/daemon/parse-hook-line.ts +1 -1
  34. package/src/daemon/read-imp-token.ts +30 -0
  35. package/src/daemon/sessions.ts +134 -107
  36. package/src/daemon/start-headless-turn.ts +3 -3
  37. package/src/daemon/start-session-bridge.ts +2 -2
  38. package/src/mcp/collect-clients.ts +1 -1
  39. package/src/mcp/collect-grants.ts +1 -1
  40. package/src/mcp/find-client-name.ts +1 -1
  41. package/src/mcp/mcp-auth-schema.ts +37 -0
  42. package/src/mcp/open-mcp-auth.ts +2 -2
  43. package/src/mcp/reconnecting-caller.ts +15 -6
  44. package/src/mcp/remove-client.ts +1 -1
  45. package/src/mcp/revoke-grant.ts +1 -1
  46. package/src/mcp/types.ts +0 -38
  47. package/src/mcp-http-server.ts +2 -1
  48. package/src/mcp-server.ts +3 -14
  49. package/src/protocol/adapter-event.ts +25 -0
  50. package/src/protocol/count-session-states.ts +16 -0
  51. package/src/protocol/daemon-channel.ts +17 -0
  52. package/src/protocol/hook-event.ts +11 -0
  53. package/src/protocol/line-decoder.ts +34 -0
  54. package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
  55. package/src/protocol/pinned-group-key.ts +2 -0
  56. package/src/protocol/session-state.ts +5 -0
  57. package/src/protocol/sort-grouped-session-views.ts +29 -0
  58. package/src/protocol/sort-session-views.ts +41 -0
  59. package/src/protocol/sortable-session-view.ts +13 -0
  60. package/src/report.ts +1 -1
  61. package/src/run-bridge-tap.ts +2 -2
  62. package/src/shared/agent-id.ts +6 -0
  63. package/src/shared/collect-gateways.ts +1 -1
  64. package/src/shared/to-agent-id.ts +11 -0
  65. package/src/statusline.ts +1 -1
  66. package/src/store/fleet-entry.ts +6 -3
  67. package/src/store/state-store.ts +80 -19
  68. /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
  69. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  70. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  71. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
@@ -1,9 +1,9 @@
1
1
  import { basename, isAbsolute } from 'node:path';
2
- import type { AgentAdapter, AgentID, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
3
2
  import { DaemonError } from '../protocol/daemon-error';
4
3
  import { DAEMON_FEATURES } from '../protocol/daemon-features';
5
4
  import { decodeCursor } from '../protocol/decode-cursor';
6
5
  import { encodeCursor } from '../protocol/encode-cursor';
6
+ import { LineDecoder } from '../protocol/line-decoder';
7
7
  import { OutboundQueue } from '../protocol/outbound-queue';
8
8
  import type { SocketWriter } from '../protocol/outbound-queue';
9
9
  import { parseRequestParams } from '../protocol/parse-request-params';
@@ -15,248 +15,16 @@ import {
15
15
  encodeMessage,
16
16
  } from '../protocol/protocol';
17
17
  import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
18
- import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
19
- import type { TargetConfigError } from '../shared/collect-targets';
20
- import type { DaemonID } from '../shared/daemon-id';
21
- import type { MessageID } from '../shared/message-id';
18
+ import type { AgentID } from '../shared/agent-id';
22
19
  import { isRecord } from '../shared/report';
23
20
  import type { SessionID } from '../shared/session-id';
24
21
  import { toSessionID } from '../shared/to-session-id';
25
- import type { FleetEntry } from '../store/fleet-entry';
26
- import type { MessageRecord } from '../store/message-record';
27
- import type { Dims } from './attach-registry';
28
- import type { AgentEntry } from './build-agent-list';
29
- import type { FleetEvent } from './build-fleet-events';
30
22
  import { buildPayloadHash } from './build-payload-hash';
31
- import type { ReportView } from './build-report-view';
32
23
  import { buildScopedContext } from './build-scoped-context';
33
- import type { TargetEntry } from './build-target-list';
34
- import type { KeyedRequest } from './idempotency-ledger';
35
- import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
24
+ import type { DaemonContext, SpawnParams } from './daemon-context';
25
+ import type { TranscriptPosition } from './load-transcript-page';
36
26
  import { parseSpawnOverrides } from './parse-spawn-overrides';
37
- import type { AnswerResult } from './permission-registry';
38
- import type { ScreenText } from './screen-model';
39
- import type { SessionDescriptor } from './sessions';
40
- import type { TargetAccess, TargetGrant } from './target-access';
41
-
42
- export interface SpawnParams {
43
- readonly cwd: string;
44
- readonly name: string;
45
- readonly prompt: string;
46
- readonly cols: number;
47
- readonly rows: number;
48
- readonly resume: SpawnOptions['resume'];
49
- readonly namedBy: 'user' | 'auto';
50
- readonly agent: AgentID;
51
- readonly parent: SessionID | null;
52
- readonly overrides: SpawnOverrides;
53
- readonly target: string;
54
-
55
- // Where the session's working directory comes from; null runs the
56
- // session in cwd as it stands.
57
- readonly workspace: SpawnWorkspaceSource | null;
58
- }
59
-
60
- interface SessionRecord {
61
- readonly session: SessionDescriptor;
62
- readonly prompt: string | null;
63
- readonly lastActivityAt: number;
64
- readonly pending: { readonly message: string } | null;
65
- readonly result: string | null;
66
- }
67
-
68
- // A transcript page with the file it came from, so a cursor into a replaced
69
- // transcript is distinguishable from one into a grown transcript.
70
- /**
71
- * What `session.forget` answers: the token a forget that destroys a host
72
- * must carry, with the time it stops being taken; or the forgotten session,
73
- * with whether its host was destroyed.
74
- */
75
- type ForgetResult =
76
- | { readonly confirmToken: string; readonly expiresAt: number }
77
- | { readonly forgotten: true; readonly destroyed: boolean };
78
-
79
- interface SessionTranscriptRead {
80
- readonly path: string;
81
- readonly page: TranscriptPage;
82
- }
83
-
84
- export interface DaemonContext {
85
- readonly build: string;
86
- readonly daemonID: DaemonID;
87
- readonly collectSessions: () => SessionDescriptor[];
88
- readonly collectSpawnDirs: () => Promise<string[]>;
89
- readonly collectAgents: () => AgentList;
90
- readonly collectFleet: () => Promise<FleetEntry[]>;
91
- readonly loadLastUsedAgent: () => Promise<AgentID>;
92
- readonly findAdapter: (id: AgentID) => AgentAdapter | null;
93
-
94
- // The targets a principal may use.
95
- readonly buildTargetAccess: (principal: string) => TargetAccess;
96
-
97
- // The target and identity a session is bound to, or null when no
98
- // session holds the id.
99
- readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
100
-
101
- // The identity a target holds now, or null when the config holds no such
102
- // target.
103
- readonly findTargetIdentity: (target: string) => string | null;
104
-
105
- // The ids of a session's sub-sessions.
106
- readonly collectChildIDs: (id: SessionID) => SessionID[];
107
-
108
- // The session a permission request belongs to, answered or not, or null
109
- // for an unknown request.
110
- readonly findPermissionSession: (request: string) => SessionID | null;
111
-
112
- // The target a spawn runs on: the one it names, else the default. Throws
113
- // the refusal for a target the spawn cannot run on, and for a spawn
114
- // without a target when the config gives no default.
115
- readonly resolveSpawnTarget: (requested: string | undefined) => string;
116
-
117
- // Throws the refusal for a target that cannot materialize a workspace:
118
- // one whose provider cannot both transfer an archive and run a command.
119
- readonly requireWorkspaceTarget: (target: string) => void;
120
-
121
- // Runs the plan, which throws the refusal for a spawn it refuses, then
122
- // spawns. Answers with the `session.spawn` ok payload, which a keyed
123
- // retry replays as the first spawn answered it while the access, when
124
- // there is one, still reaches the target the spawn's session was bound to.
125
- readonly spawnSession: (
126
- plan: () => SpawnParams,
127
- keyed: KeyedRequest | null,
128
- access: TargetAccess | null,
129
- ) => Promise<Readonly<Record<string, unknown>>>;
130
- readonly killSession: (id: SessionID) => Promise<boolean>;
131
-
132
- // Forgets a session, or answers with the token a forget that destroys a
133
- // host must carry. Throws the refusal for a token it does not take.
134
- readonly forgetSession: (
135
- id: SessionID,
136
- confirmToken: string | undefined,
137
- ) => Promise<ForgetResult | 'missing'>;
138
- readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
139
- readonly quitDaemon: () => void;
140
- readonly ackSession: (id: SessionID) => boolean;
141
- readonly buildResumeCommand: (id: SessionID) => string | null;
142
- readonly readSessionScreen: (id: SessionID) => Promise<ScreenText | 'missing' | 'no_screen'>;
143
- readonly answerPermission: (request: string, decision: string) => AnswerResult;
144
- readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
145
- readonly attachSession: (
146
- client: OutputClient,
147
- sessionID: SessionID,
148
- dims: Dims,
149
- ) => 'ok' | 'missing' | 'dead';
150
- readonly detachSession: (client: OutputClient, sessionID: SessionID) => void;
151
- readonly detachClient: (client: OutputClient) => void;
152
- readonly writeSessionInput: (
153
- sessionID: SessionID,
154
- data: string,
155
- ) => 'busy' | 'ok' | 'missing' | 'dead';
156
-
157
- // Types a line into the session and submits it the way the session's
158
- // agent accepts a line, as opposed to the raw bytes the input write takes.
159
- readonly writeSessionLine: (
160
- sessionID: SessionID,
161
- text: string,
162
- ) => 'busy' | 'ok' | 'missing' | 'dead';
163
- readonly ejectSession: (
164
- id: SessionID,
165
- prompt: string,
166
- ) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
167
- readonly adoptSession: (
168
- id: SessionID,
169
- cols: number,
170
- rows: number,
171
- ) => Promise<'ok' | 'missing' | 'no_transcript'>;
172
- readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
173
- readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
174
- readonly queueBytes?: number;
175
- readonly getEffectiveDims: (sessionID: SessionID) => Dims;
176
- readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
177
- readonly loadSessionTranscript: (
178
- id: SessionID,
179
- from: TranscriptPosition | null,
180
- limit: number,
181
- ) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
182
-
183
- // Reads the trail, leaving out each event of a session outside the access
184
- // when there is one.
185
- readonly readEvents: (
186
- afterID: number | null,
187
- limit: number,
188
- waitMs: number,
189
- sessionID: SessionID | null,
190
- access: TargetAccess | null,
191
- ) => Promise<EventsPage>;
192
-
193
- // One report by the trail id of its event, or null for a trail id that
194
- // holds no report, or whose report's session is outside the access when
195
- // there is one.
196
- readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
197
-
198
- // Answers with the `session.message` ok payload, which a keyed retry
199
- // replays with the message's current status, or with the refusal.
200
- readonly writeSessionMessage: (
201
- sessionID: SessionID,
202
- from: string,
203
- text: string,
204
- keyed: KeyedRequest | null,
205
- ) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
206
- readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
207
- readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
208
- readonly ackMessage: (
209
- client: TapClient,
210
- sessionID: SessionID,
211
- messageID: MessageID,
212
- ) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
213
- }
214
-
215
- // The slice of a connection the attach bookkeeping needs: identity plus the
216
- // ability to receive output events.
217
- export interface OutputClient {
218
- readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
219
- }
220
-
221
- // The `agents.list` answer: the host the daemon runs on, each agent, each
222
- // execution target, what a spawn without either runs with, a digest of
223
- // the target config, and the target config problems the daemon started
224
- // with.
225
- interface AgentList {
226
- readonly daemon: {
227
- readonly hostname: string;
228
- readonly platform: string;
229
- readonly arch: string;
230
- readonly build: string;
231
- };
232
- readonly agents: readonly AgentEntry[];
233
- readonly targets: readonly TargetEntry[];
234
- readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
235
- readonly configRevision: string;
236
- readonly targetErrors: readonly TargetConfigError[];
237
- }
238
-
239
- // One `events.read` answer: the events, and whether more follow them.
240
- interface EventsPage {
241
- readonly events: readonly FleetEvent[];
242
- readonly more: boolean;
243
- }
244
-
245
- // One message as `message.get` reports it: the session it belongs to now,
246
- // every field of the record, and the other messages its turn answered.
247
- interface MessageView {
248
- readonly session: SessionID;
249
- readonly record: MessageRecord;
250
- readonly answeredWith: readonly MessageID[];
251
- }
252
-
253
- // Why a session refuses a message before the daemon accepts it.
254
- export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
255
-
256
- // The slice of a connection a tap subscription needs.
257
- export interface TapClient {
258
- readonly sendEvent: (event: EventMsg) => void;
259
- }
27
+ import type { TargetAccess } from './target-access';
260
28
 
261
29
  // The targets a request may use and the namespace of its idempotency keys.
262
30
  interface RequestScope {
@@ -278,9 +46,7 @@ export class DaemonConnection {
278
46
 
279
47
  private readonly queue: OutboundQueue;
280
48
 
281
- private buffer = '';
282
-
283
- private readonly decoder = new TextDecoder();
49
+ private readonly lines = new LineDecoder();
284
50
 
285
51
  private helloed = false;
286
52
 
@@ -352,28 +118,18 @@ export class DaemonConnection {
352
118
  // between two reads decodes whole.
353
119
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
354
120
  decodeChunk(buf: Uint8Array): string {
355
- return this.decoder.decode(buf, { stream: true });
121
+ return this.lines.decodeText(buf);
356
122
  }
357
123
 
358
124
  applyChunk(chunk: string): void {
359
- const buffered = this.buffer + chunk;
360
-
361
- if (buffered.length > MAX_LINE) {
125
+ if (this.lines.pendingLength + chunk.length > MAX_LINE) {
362
126
  this.sendErr(0, 'bad_args', `line exceeds ${MAX_LINE} bytes`);
363
127
  this.peer.end();
364
128
 
365
129
  return;
366
130
  }
367
131
 
368
- const lines = buffered.split('\n');
369
-
370
- this.buffer = lines.pop() ?? '';
371
-
372
- for (const line of lines) {
373
- if (line.trim() === '') {
374
- continue;
375
- }
376
-
132
+ for (const line of this.lines.splitText(chunk)) {
377
133
  if (!this.applyLine(line)) {
378
134
  this.peer.end();
379
135
 
@@ -0,0 +1,240 @@
1
+ import type { AgentAdapter, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
2
+ import type { EventMsg } from '../protocol/protocol';
3
+ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
4
+ import type { AgentID } from '../shared/agent-id';
5
+ import type { TargetConfigError } from '../shared/collect-targets';
6
+ import type { DaemonID } from '../shared/daemon-id';
7
+ import type { MessageID } from '../shared/message-id';
8
+ import type { SessionID } from '../shared/session-id';
9
+ import type { FleetEntry } from '../store/fleet-entry';
10
+ import type { MessageRecord } from '../store/message-record';
11
+ import type { Dims } from './attach-registry';
12
+ import type { AgentEntry } from './build-agent-list';
13
+ import type { FleetEvent } from './build-fleet-events';
14
+ import type { ReportView } from './build-report-view';
15
+ import type { TargetEntry } from './build-target-list';
16
+ import type { KeyedRequest } from './idempotency-ledger';
17
+ import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
18
+ import type { AnswerResult } from './permission-registry';
19
+ import type { ScreenText } from './screen-model';
20
+ import type { SessionDescriptor } from './sessions';
21
+ import type { TargetAccess, TargetGrant } from './target-access';
22
+
23
+ export interface SpawnParams {
24
+ readonly cwd: string;
25
+ readonly name: string;
26
+ readonly prompt: string;
27
+ readonly cols: number;
28
+ readonly rows: number;
29
+ readonly resume: SpawnOptions['resume'];
30
+ readonly namedBy: 'user' | 'auto';
31
+ readonly agent: AgentID;
32
+ readonly parent: SessionID | null;
33
+ readonly overrides: SpawnOverrides;
34
+ readonly target: string;
35
+
36
+ // Where the session's working directory comes from; null runs the
37
+ // session in cwd as it stands.
38
+ readonly workspace: SpawnWorkspaceSource | null;
39
+ }
40
+
41
+ interface SessionRecord {
42
+ readonly session: SessionDescriptor;
43
+ readonly prompt: string | null;
44
+ readonly lastActivityAt: number;
45
+ readonly pending: { readonly message: string } | null;
46
+ readonly result: string | null;
47
+ }
48
+
49
+ /**
50
+ * What `session.forget` answers: the token a forget that destroys a host
51
+ * must carry, with the time it stops being taken; or the forgotten session,
52
+ * with whether its host was destroyed.
53
+ */
54
+ type ForgetResult =
55
+ | { readonly confirmToken: string; readonly expiresAt: number }
56
+ | { readonly forgotten: true; readonly destroyed: boolean };
57
+
58
+ // A transcript page with the file it came from, so a cursor into a replaced
59
+ // transcript is distinguishable from one into a grown transcript.
60
+ interface SessionTranscriptRead {
61
+ readonly path: string;
62
+ readonly page: TranscriptPage;
63
+ }
64
+
65
+ // The `agents.list` answer: the host the daemon runs on, each agent, each
66
+ // execution target, what a spawn without either runs with, a digest of
67
+ // the target config, and the target config problems the daemon started
68
+ // with.
69
+ interface AgentList {
70
+ readonly daemon: {
71
+ readonly hostname: string;
72
+ readonly platform: string;
73
+ readonly arch: string;
74
+ readonly build: string;
75
+ };
76
+ readonly agents: readonly AgentEntry[];
77
+ readonly targets: readonly TargetEntry[];
78
+ readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
79
+ readonly configRevision: string;
80
+ readonly targetErrors: readonly TargetConfigError[];
81
+ }
82
+
83
+ // The slice of a connection the attach bookkeeping needs: identity plus the
84
+ // ability to receive output events.
85
+ export interface OutputClient {
86
+ readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
87
+ }
88
+
89
+ // One `events.read` answer: the events, and whether more follow them.
90
+ interface EventsPage {
91
+ readonly events: readonly FleetEvent[];
92
+ readonly more: boolean;
93
+ }
94
+
95
+ // One message as `message.get` reports it: the session it belongs to now,
96
+ // every field of the record, and the other messages its turn answered.
97
+ interface MessageView {
98
+ readonly session: SessionID;
99
+ readonly record: MessageRecord;
100
+ readonly answeredWith: readonly MessageID[];
101
+ }
102
+
103
+ // Why a session refuses a message before the daemon accepts it.
104
+ export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
105
+
106
+ // The slice of a connection a tap subscription needs.
107
+ export interface TapClient {
108
+ readonly sendEvent: (event: EventMsg) => void;
109
+ }
110
+
111
+ export interface DaemonContext {
112
+ readonly build: string;
113
+ readonly daemonID: DaemonID;
114
+ readonly collectSessions: () => SessionDescriptor[];
115
+ readonly collectSpawnDirs: () => Promise<string[]>;
116
+ readonly collectAgents: () => AgentList;
117
+ readonly collectFleet: () => Promise<FleetEntry[]>;
118
+ readonly loadLastUsedAgent: () => Promise<AgentID>;
119
+ readonly findAdapter: (id: AgentID) => AgentAdapter | null;
120
+
121
+ // The targets a principal may use.
122
+ readonly buildTargetAccess: (principal: string) => TargetAccess;
123
+
124
+ // The target and identity a session is bound to, or null when no
125
+ // session holds the id.
126
+ readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
127
+
128
+ // The identity a target holds now, or null when the config holds no such
129
+ // target.
130
+ readonly findTargetIdentity: (target: string) => string | null;
131
+
132
+ // The ids of a session's sub-sessions.
133
+ readonly collectChildIDs: (id: SessionID) => SessionID[];
134
+
135
+ // The session a permission request belongs to, answered or not, or null
136
+ // for an unknown request.
137
+ readonly findPermissionSession: (request: string) => SessionID | null;
138
+
139
+ // The target a spawn runs on: the one it names, else the default. Throws
140
+ // the refusal for a target the spawn cannot run on, and for a spawn
141
+ // without a target when the config gives no default.
142
+ readonly resolveSpawnTarget: (requested: string | undefined) => string;
143
+
144
+ // Throws the refusal for a target that cannot materialize a workspace:
145
+ // one whose provider cannot both transfer an archive and run a command.
146
+ readonly requireWorkspaceTarget: (target: string) => void;
147
+
148
+ // Runs the plan, which throws the refusal for a spawn it refuses, then
149
+ // spawns. Answers with the `session.spawn` ok payload, which a keyed
150
+ // retry replays as the first spawn answered it while the access, when
151
+ // there is one, still reaches the target the spawn's session was bound to.
152
+ readonly spawnSession: (
153
+ plan: () => SpawnParams,
154
+ keyed: KeyedRequest | null,
155
+ access: TargetAccess | null,
156
+ ) => Promise<Readonly<Record<string, unknown>>>;
157
+ readonly killSession: (id: SessionID) => Promise<boolean>;
158
+
159
+ // Forgets a session, or answers with the token a forget that destroys a
160
+ // host must carry. Throws the refusal for a token it does not take.
161
+ readonly forgetSession: (
162
+ id: SessionID,
163
+ confirmToken: string | undefined,
164
+ ) => Promise<ForgetResult | 'missing'>;
165
+ readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
166
+ readonly quitDaemon: () => void;
167
+ readonly ackSession: (id: SessionID) => boolean;
168
+ readonly buildResumeCommand: (id: SessionID) => string | null;
169
+ readonly readSessionScreen: (id: SessionID) => Promise<ScreenText | 'missing' | 'no_screen'>;
170
+ readonly answerPermission: (request: string, decision: string) => AnswerResult;
171
+ readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
172
+ readonly attachSession: (
173
+ client: OutputClient,
174
+ sessionID: SessionID,
175
+ dims: Dims,
176
+ ) => 'ok' | 'missing' | 'dead';
177
+ readonly detachSession: (client: OutputClient, sessionID: SessionID) => void;
178
+ readonly detachClient: (client: OutputClient) => void;
179
+ readonly writeSessionInput: (
180
+ sessionID: SessionID,
181
+ data: string,
182
+ ) => 'busy' | 'ok' | 'missing' | 'dead';
183
+
184
+ // Types a line into the session and submits it the way the session's
185
+ // agent accepts a line, as opposed to the raw bytes the input write takes.
186
+ readonly writeSessionLine: (
187
+ sessionID: SessionID,
188
+ text: string,
189
+ ) => 'busy' | 'ok' | 'missing' | 'dead';
190
+ readonly ejectSession: (
191
+ id: SessionID,
192
+ prompt: string,
193
+ ) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
194
+ readonly adoptSession: (
195
+ id: SessionID,
196
+ cols: number,
197
+ rows: number,
198
+ ) => Promise<'ok' | 'missing' | 'no_transcript'>;
199
+ readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
200
+ readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
201
+ readonly queueBytes?: number;
202
+ readonly getEffectiveDims: (sessionID: SessionID) => Dims;
203
+ readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
204
+ readonly loadSessionTranscript: (
205
+ id: SessionID,
206
+ from: TranscriptPosition | null,
207
+ limit: number,
208
+ ) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
209
+
210
+ // Reads the trail, leaving out each event of a session outside the access
211
+ // when there is one.
212
+ readonly readEvents: (
213
+ afterID: number | null,
214
+ limit: number,
215
+ waitMs: number,
216
+ sessionID: SessionID | null,
217
+ access: TargetAccess | null,
218
+ ) => Promise<EventsPage>;
219
+
220
+ // One report by the trail id of its event, or null for a trail id that
221
+ // holds no report, or whose report's session is outside the access when
222
+ // there is one.
223
+ readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
224
+
225
+ // Answers with the `session.message` ok payload, which a keyed retry
226
+ // replays with the message's current status, or with the refusal.
227
+ readonly writeSessionMessage: (
228
+ sessionID: SessionID,
229
+ from: string,
230
+ text: string,
231
+ keyed: KeyedRequest | null,
232
+ ) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
233
+ readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
234
+ readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
235
+ readonly ackMessage: (
236
+ client: TapClient,
237
+ sessionID: SessionID,
238
+ messageID: MessageID,
239
+ ) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
240
+ }
@@ -2,12 +2,15 @@ import { randomUUID } from 'node:crypto';
2
2
  import { unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { hostname, tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
5
+ import type { AgentAdapter } from '../agents/agent-adapter';
6
6
  import { planTypedLineInput } from '../agents/plan-typed-line-input';
7
+ import type { AdapterEvent } from '../protocol/adapter-event';
7
8
  import { DaemonError } from '../protocol/daemon-error';
9
+ import type { HookEvent } from '../protocol/hook-event';
8
10
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
9
11
  import type { EventMsg } from '../protocol/protocol';
10
12
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
+ import type { SessionState } from '../protocol/session-state';
11
14
  import type { HooksConfig } from '../shared/collect-hooks';
12
15
  import type { TargetConfigError } from '../shared/collect-targets';
13
16
  import { findDaemonRecord } from '../shared/find-daemon-record';
@@ -47,11 +50,10 @@ import type {
47
50
  OutputClient,
48
51
  SpawnParams,
49
52
  TapClient,
50
- } from './daemon-connection';
53
+ } from './daemon-context';
51
54
  import { EffectRemainsError } from './effect-remains-error';
52
55
  import { EventSignal } from './event-signal';
53
56
  import { startHookServer } from './hooks';
54
- import type { HookEvent } from './hooks';
55
57
  import { IdempotencyLedger } from './idempotency-ledger';
56
58
  import { loadTranscriptPage } from './load-transcript-page';
57
59
  import { makeHookRunner } from './make-hook-runner';
@@ -66,7 +68,7 @@ import { runEjectHandoff } from './run-eject-handoff';
66
68
  import { ScreenModel } from './screen-model';
67
69
  import { SessionRuntime } from './session-runtime';
68
70
  import { SessionManager } from './sessions';
69
- import type { Session, SessionDescriptor, SessionState } from './sessions';
71
+ import type { Session, SessionDescriptor } from './sessions';
70
72
  import { startEventsServer } from './start-events-server';
71
73
  import { startHeadlessTurn } from './start-headless-turn';
72
74
  import { startSessionBridge } from './start-session-bridge';
@@ -972,7 +974,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
972
974
  );
973
975
  };
974
976
 
975
- const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL);
977
+ const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL, (line) => {
978
+ mgr.log(line);
979
+ });
976
980
 
977
981
  // Throws the refusal a fresh spawn to the target gets when the access
978
982
  // does not reach the target, and the identity, a held spawn key recorded
@@ -167,8 +167,20 @@ export interface HarnessHandle {
167
167
  ) => HarnessSubscription;
168
168
  readonly write: (data: string) => void;
169
169
  readonly resize: (cols: number, rows: number) => void;
170
+
171
+ // Sends the harness's process the signal that ends it, and returns without
172
+ // waiting for it to exit.
170
173
  readonly kill: () => void;
171
174
 
175
+ // Ends the harness's process with a signal it cannot catch or ignore, on
176
+ // a provider that can send one; absent on a provider that cannot.
177
+ readonly killForced?: () => void;
178
+
179
+ // Resolves true once the harness's process has exited, and false when the
180
+ // wait runs out first or the harness stops being followed without an
181
+ // exit, such as a host that went to sleep with the process inside.
182
+ readonly waitForExit: (timeoutMs: number) => Promise<boolean>;
183
+
172
184
  // Stops following the harness and leaves its process running, for a host
173
185
  // that keeps it after the daemon lets go; on a host that cannot keep it,
174
186
  // the process ends as a kill ends it. No listener fires after a detach.
@@ -1,20 +1,12 @@
1
1
  import { unlinkSync } from 'node:fs';
2
+ import type { HookEvent } from '../protocol/hook-event';
3
+ import { LineDecoder } from '../protocol/line-decoder';
2
4
  import { socketPath } from '../shared/config';
3
- import type { SessionID } from '../shared/session-id';
4
5
  import { parseHookLine } from './parse-hook-line';
5
6
 
6
- export interface HookEvent {
7
- atcId: SessionID;
8
- event: string;
9
- payload: Record<string, unknown>;
10
- }
11
-
12
- // Per-connection read state: the unterminated tail of the line in progress,
13
- // and a streaming decoder so a multi-byte character split across two reads
14
- // decodes whole.
7
+ // Per-connection read state: the connection's own line framing.
15
8
  interface HookConnection {
16
- pending: string;
17
- readonly decoder: TextDecoder;
9
+ readonly lines: LineDecoder;
18
10
  }
19
11
 
20
12
  export function startHookServer(onEvent: (e: HookEvent) => void, path: string = socketPath) {
@@ -26,17 +18,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
26
18
  unix: path,
27
19
  socket: {
28
20
  data(socket, buf) {
29
- const conn = socket.data;
30
- const buffered = conn.pending + conn.decoder.decode(buf, { stream: true });
31
- const lines = buffered.split('\n');
32
-
33
- conn.pending = lines.pop() ?? '';
34
-
35
- for (const line of lines) {
36
- if (line.trim() === '') {
37
- continue;
38
- }
39
-
21
+ for (const line of socket.data.lines.splitChunk(buf)) {
40
22
  const event = parseHookLine(line);
41
23
 
42
24
  if (event !== null) {
@@ -45,7 +27,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
45
27
  }
46
28
  },
47
29
  open(socket) {
48
- socket.data = { pending: '', decoder: new TextDecoder() };
30
+ socket.data = { lines: new LineDecoder() };
49
31
  },
50
32
  error() {},
51
33
  },