@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.
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +7 -23
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-imp-provider.ts +70 -23
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-scoped-context.ts +1 -1
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +9 -253
- package/src/daemon/daemon-context.ts +240 -0
- package/src/daemon/daemon.ts +9 -5
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +6 -24
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-client-port.ts +83 -27
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/imp-provider.ts +5 -11
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/read-imp-token.ts +30 -0
- package/src/daemon/sessions.ts +134 -107
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/mcp-server.ts +3 -14
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/line-decoder.ts +34 -0
- package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/report.ts +1 -1
- package/src/run-bridge-tap.ts +2 -2
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/statusline.ts +1 -1
- package/src/store/fleet-entry.ts +6 -3
- package/src/store/state-store.ts +80 -19
- /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /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 {
|
|
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 {
|
|
34
|
-
import type {
|
|
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 {
|
|
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
|
|
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.
|
|
121
|
+
return this.lines.decodeText(buf);
|
|
356
122
|
}
|
|
357
123
|
|
|
358
124
|
applyChunk(chunk: string): void {
|
|
359
|
-
|
|
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
|
|
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
|
+
}
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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 {
|
|
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-
|
|
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
|
|
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.
|
package/src/daemon/hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
30
|
+
socket.data = { lines: new LineDecoder() };
|
|
49
31
|
},
|
|
50
32
|
error() {},
|
|
51
33
|
},
|