@zgeoff/atc 2.11.0 → 2.13.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/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,38 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { hostname } from 'node:os';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
|
|
6
|
+
import { planTypedLineInput } from '../agents/plan-typed-line-input';
|
|
7
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
5
8
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
6
9
|
import type { EventMsg } from '../protocol/protocol';
|
|
10
|
+
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
7
11
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
12
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
8
13
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
9
14
|
import type { MessageID } from '../shared/message-id';
|
|
15
|
+
import { isRecord } from '../shared/report';
|
|
10
16
|
import type { SessionID } from '../shared/session-id';
|
|
17
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
11
18
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
19
|
+
import type { IdempotencyRecord } from '../store/idempotency-record';
|
|
12
20
|
import type { MessageOwner } from '../store/message-owner';
|
|
13
21
|
import type { MessageRecord, MessageStatus } from '../store/message-record';
|
|
14
22
|
import { StateStore } from '../store/state-store';
|
|
15
23
|
import type { EventScope } from '../store/state-store';
|
|
16
24
|
import type { TrailEntry } from '../store/trail-entry';
|
|
25
|
+
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
17
26
|
import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
18
27
|
import { AttachRegistry } from './attach-registry';
|
|
19
28
|
import { buildAgentList } from './build-agent-list';
|
|
29
|
+
import { buildConfigRevision } from './build-config-revision';
|
|
30
|
+
import { buildExecutionTargets } from './build-execution-targets';
|
|
31
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
20
32
|
import { buildFleetEvents } from './build-fleet-events';
|
|
21
33
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
22
34
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
23
35
|
import { buildSessionEvent } from './build-session-event';
|
|
24
36
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
25
37
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
38
|
+
import { buildTargetAccess } from './build-target-access';
|
|
39
|
+
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
40
|
+
import { buildTargetList } from './build-target-list';
|
|
26
41
|
import { claimDaemonLock } from './claim-daemon-lock';
|
|
27
42
|
import { DaemonConnection } from './daemon-connection';
|
|
28
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
DaemonContext,
|
|
45
|
+
MessageRefusal,
|
|
46
|
+
OutputClient,
|
|
47
|
+
SpawnParams,
|
|
48
|
+
TapClient,
|
|
49
|
+
} from './daemon-connection';
|
|
50
|
+
import { EffectRemainsError } from './effect-remains-error';
|
|
29
51
|
import { EventSignal } from './event-signal';
|
|
30
52
|
import { startHookServer } from './hooks';
|
|
31
53
|
import type { HookEvent } from './hooks';
|
|
54
|
+
import { IdempotencyLedger } from './idempotency-ledger';
|
|
32
55
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
33
56
|
import { makeHookRunner } from './make-hook-runner';
|
|
34
57
|
import type { HookScope } from './make-hook-runner';
|
|
58
|
+
import { materializeWorkspace } from './materialize-workspace';
|
|
35
59
|
import { mintMessageID } from './mint-message-id';
|
|
60
|
+
import { mintSessionID } from './mint-session-id';
|
|
36
61
|
import { parseReport } from './parse-report';
|
|
37
62
|
import { PermissionRegistry } from './permission-registry';
|
|
38
63
|
import { restoreFleet } from './restore-fleet';
|
|
@@ -43,7 +68,9 @@ import { SessionManager } from './sessions';
|
|
|
43
68
|
import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
44
69
|
import { startEventsServer } from './start-events-server';
|
|
45
70
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
71
|
+
import { startSessionBridge } from './start-session-bridge';
|
|
46
72
|
import { TapRegistry } from './tap-registry';
|
|
73
|
+
import type { TargetAccess } from './target-access';
|
|
47
74
|
import { writeDaemonRecord } from './write-daemon-record';
|
|
48
75
|
|
|
49
76
|
export interface DaemonOptions {
|
|
@@ -59,6 +86,21 @@ export interface DaemonOptions {
|
|
|
59
86
|
// grok adapter is unsupported, not a Claude spawn.
|
|
60
87
|
readonly adapters?: readonly AgentAdapter[];
|
|
61
88
|
|
|
89
|
+
// Where sessions run, in config order; one `local` target on the local
|
|
90
|
+
// pseudo-terminal provider when unset. A spawn without a target runs on
|
|
91
|
+
// the default one: `local` when unset and the targets hold it, and none
|
|
92
|
+
// otherwise, which refuses such a spawn.
|
|
93
|
+
readonly targets?: readonly ExecutionTarget[];
|
|
94
|
+
readonly defaultTarget?: string | null;
|
|
95
|
+
|
|
96
|
+
// The target config problems the daemon started with. A target they cover
|
|
97
|
+
// refuses every session, and `agents.list` returns them.
|
|
98
|
+
readonly targetErrors?: readonly TargetConfigError[];
|
|
99
|
+
|
|
100
|
+
// The targets each principal may use; unset or null when the config has
|
|
101
|
+
// no principals, which leaves every principal the implicit local target.
|
|
102
|
+
readonly principals?: ReadonlyMap<string, readonly string[]> | null;
|
|
103
|
+
|
|
62
104
|
// SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
|
|
63
105
|
// fleet table once so upgrading keeps the restorable fleet.
|
|
64
106
|
readonly dbPath: string;
|
|
@@ -93,9 +135,16 @@ export interface DaemonOptions {
|
|
|
93
135
|
readonly restoreBootTimeoutMs?: number;
|
|
94
136
|
readonly tapGraceMs?: number;
|
|
95
137
|
|
|
138
|
+
// How long a confirm token from `session.forget` stays usable.
|
|
139
|
+
readonly forgetConfirmMs?: number;
|
|
140
|
+
|
|
96
141
|
// Called after a client-requested quit has stopped the daemon; the real
|
|
97
142
|
// entrypoint exits the process, tests leave it unset.
|
|
98
143
|
readonly onQuit?: () => void;
|
|
144
|
+
|
|
145
|
+
// Where background failures are reported, one line at a time; stderr
|
|
146
|
+
// when unset.
|
|
147
|
+
readonly log?: (line: string) => void;
|
|
99
148
|
}
|
|
100
149
|
|
|
101
150
|
export interface DaemonHandle {
|
|
@@ -109,6 +158,17 @@ export interface DaemonHandle {
|
|
|
109
158
|
// it is refused.
|
|
110
159
|
const TAP_GRACE_MS = 15_000;
|
|
111
160
|
|
|
161
|
+
// How long a confirm token from a forget that destroys a host stays usable.
|
|
162
|
+
const FORGET_CONFIRM_MS = 60_000;
|
|
163
|
+
|
|
164
|
+
// The principal every request on the local socket acts as.
|
|
165
|
+
const LOCAL_PRINCIPAL = 'local';
|
|
166
|
+
|
|
167
|
+
// A completed idempotency key is kept this long after it completed, and the
|
|
168
|
+
// sweep that drops older ones runs this often.
|
|
169
|
+
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
170
|
+
const IDEMPOTENCY_SWEEP_MS = 60 * 60 * 1000;
|
|
171
|
+
|
|
112
172
|
// How long startup waits for a daemon that is shutting down to release the
|
|
113
173
|
// state lock before refusing to start.
|
|
114
174
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -148,7 +208,49 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
148
208
|
|
|
149
209
|
const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
|
|
150
210
|
|
|
151
|
-
|
|
211
|
+
// Before any request is served: a workspace a stopped daemon left short
|
|
212
|
+
// of ready fails as interrupted, and a key left in progress by a daemon
|
|
213
|
+
// that stopped mid-effect is settled, so no retry can race either.
|
|
214
|
+
await store.reconcileMaterializations(Date.now());
|
|
215
|
+
await store.reconcileIdempotencyKeys(Date.now());
|
|
216
|
+
|
|
217
|
+
await tryRemoveExpiredIdempotencyKeys(store);
|
|
218
|
+
|
|
219
|
+
const idempotencySweep = setInterval(() => {
|
|
220
|
+
void tryRemoveExpiredIdempotencyKeys(store);
|
|
221
|
+
}, IDEMPOTENCY_SWEEP_MS);
|
|
222
|
+
|
|
223
|
+
idempotencySweep.unref();
|
|
224
|
+
|
|
225
|
+
const targets =
|
|
226
|
+
opts.targets ??
|
|
227
|
+
buildExecutionTargets([{ id: 'local', provider: 'local-pty', options: {} }]).targets;
|
|
228
|
+
|
|
229
|
+
const targetErrors = opts.targetErrors ?? [];
|
|
230
|
+
const principals = opts.principals ?? null;
|
|
231
|
+
|
|
232
|
+
const targetsByID = new Map(targets.map((target) => [target.id, target]));
|
|
233
|
+
|
|
234
|
+
const defaultTarget =
|
|
235
|
+
opts.defaultTarget === undefined
|
|
236
|
+
? (targets.find((target) => target.id === 'local')?.id ?? null)
|
|
237
|
+
: opts.defaultTarget;
|
|
238
|
+
|
|
239
|
+
const configRevision = buildConfigRevision(targets, defaultTarget, targetErrors);
|
|
240
|
+
|
|
241
|
+
const mgr = new SessionManager(
|
|
242
|
+
opts.adapter,
|
|
243
|
+
store,
|
|
244
|
+
opts.statusPath,
|
|
245
|
+
opts.adapters ?? [],
|
|
246
|
+
targets,
|
|
247
|
+
targetErrors,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (opts.log !== undefined) {
|
|
251
|
+
mgr.log = opts.log;
|
|
252
|
+
}
|
|
253
|
+
|
|
152
254
|
const clients = new Set<DaemonConnection>();
|
|
153
255
|
|
|
154
256
|
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
@@ -186,14 +288,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
186
288
|
const eventSignal = new EventSignal();
|
|
187
289
|
|
|
188
290
|
// A trail write that fails never fails the message request or report behind it.
|
|
189
|
-
|
|
291
|
+
// Returns false when the entry was not written: a failed write, or a
|
|
292
|
+
// report the trail already holds.
|
|
293
|
+
const recordTrailEntry = async (entry: TrailEntry): Promise<boolean> => {
|
|
294
|
+
let written: boolean;
|
|
295
|
+
|
|
190
296
|
try {
|
|
191
|
-
await store.recordTrailEntry(entry);
|
|
297
|
+
written = await store.recordTrailEntry(entry);
|
|
192
298
|
} catch {
|
|
193
|
-
return;
|
|
299
|
+
return false;
|
|
194
300
|
}
|
|
195
301
|
|
|
196
|
-
|
|
302
|
+
if (written) {
|
|
303
|
+
eventSignal.emit();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return written;
|
|
197
307
|
};
|
|
198
308
|
|
|
199
309
|
// Notes a message status change in the trail before broadcasting it, so a
|
|
@@ -207,11 +317,37 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
207
317
|
};
|
|
208
318
|
|
|
209
319
|
// A live session's scope also matches the rows it wrote under an earlier
|
|
210
|
-
// atc id, through the agent session id a restore carries on.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
320
|
+
// atc id, through the agent session id a restore carries on. Under an
|
|
321
|
+
// access, the scope holds only the sessions on targets the access holds,
|
|
322
|
+
// so a session outside it matches nothing, as a session never seen does.
|
|
323
|
+
const buildEventScope = (
|
|
324
|
+
sessionID: SessionID | null,
|
|
325
|
+
access: TargetAccess | null,
|
|
326
|
+
): EventScope | null => {
|
|
327
|
+
if (access === null) {
|
|
328
|
+
if (sessionID === null) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const agentSessionID = mgr.sessions.find((x) => x.id === sessionID)?.agentSessionID;
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
atcIDs: [sessionID],
|
|
336
|
+
agentSessionIDs: agentSessionID === undefined ? [] : [agentSessionID],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const visible = mgr.sessions.filter(
|
|
341
|
+
(x) => access.canUse(x) && (sessionID === null || x.id === sessionID),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
atcIDs: visible.map((x) => x.id),
|
|
346
|
+
agentSessionIDs: visible.flatMap((x) =>
|
|
347
|
+
x.agentSessionID === undefined ? [] : [x.agentSessionID],
|
|
348
|
+
),
|
|
349
|
+
};
|
|
350
|
+
};
|
|
215
351
|
|
|
216
352
|
// A message belongs to the live session holding its atc id or its agent
|
|
217
353
|
// session id, else to the atc id it was sent to.
|
|
@@ -314,7 +450,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
314
450
|
} catch {}
|
|
315
451
|
};
|
|
316
452
|
|
|
317
|
-
|
|
453
|
+
// A report id, which a remote session's reporter gives each report,
|
|
454
|
+
// makes a resent note land once; a resent answer changes nothing, since
|
|
455
|
+
// only an unanswered message takes one.
|
|
456
|
+
const applyReport = async (e: HookEvent, reportID?: string) => {
|
|
318
457
|
const report = parseReport(e.payload);
|
|
319
458
|
|
|
320
459
|
if (report === null) {
|
|
@@ -328,11 +467,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
328
467
|
const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
|
|
329
468
|
const reportedAt = Date.now();
|
|
330
469
|
|
|
331
|
-
|
|
332
|
-
|
|
470
|
+
const entry = buildReportTrailEntry(
|
|
471
|
+
sender.id,
|
|
472
|
+
sender.agentSessionID,
|
|
473
|
+
capped,
|
|
474
|
+
reportedAt,
|
|
475
|
+
reportID,
|
|
333
476
|
);
|
|
334
477
|
|
|
335
|
-
|
|
478
|
+
const written = await recordTrailEntry(entry);
|
|
479
|
+
|
|
480
|
+
if (written) {
|
|
481
|
+
emitEvent(
|
|
482
|
+
buildSessionReportEvent(sender.id, capped, reportedAt),
|
|
483
|
+
findHookScope(sender.id),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
336
486
|
}
|
|
337
487
|
|
|
338
488
|
return;
|
|
@@ -421,7 +571,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
421
571
|
|
|
422
572
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
423
573
|
|
|
424
|
-
|
|
574
|
+
// A host that cannot resize keeps its terminal at the size it started
|
|
575
|
+
// with; the screen model still follows the clients.
|
|
576
|
+
if (s !== undefined && (mgr.findProvider(s)?.capabilities.resize ?? false)) {
|
|
577
|
+
s.pty?.resize(dims.cols, dims.rows);
|
|
578
|
+
}
|
|
579
|
+
|
|
425
580
|
runtime?.screen?.updateDims(dims.cols, dims.rows);
|
|
426
581
|
|
|
427
582
|
if (runtime !== undefined) {
|
|
@@ -478,6 +633,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
478
633
|
}
|
|
479
634
|
};
|
|
480
635
|
|
|
636
|
+
// A spawned terminal starts on a fresh screen; a revived one keeps the
|
|
637
|
+
// screen its session already had.
|
|
638
|
+
mgr.onBoot = (s, cols, rows) => {
|
|
639
|
+
const runtime = runtimes.get(s.id);
|
|
640
|
+
|
|
641
|
+
if (runtime === undefined) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
runtime.resetBoot({ cols, rows });
|
|
646
|
+
|
|
647
|
+
runtime.screen ??= new ScreenModel(cols, rows);
|
|
648
|
+
};
|
|
649
|
+
|
|
481
650
|
mgr.onOutput = (s, data) => {
|
|
482
651
|
const runtime = runtimes.get(s.id);
|
|
483
652
|
|
|
@@ -603,7 +772,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
603
772
|
);
|
|
604
773
|
};
|
|
605
774
|
|
|
606
|
-
const
|
|
775
|
+
const applyHookEvent = (e: HookEvent) => {
|
|
607
776
|
if (e.event === 'Report') {
|
|
608
777
|
void applyReport(e);
|
|
609
778
|
|
|
@@ -650,10 +819,328 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
650
819
|
void store.writeLastUsedAgent(started.agent);
|
|
651
820
|
}
|
|
652
821
|
}
|
|
653
|
-
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const reporter = startHookServer(applyHookEvent, opts.reporterSocketPath);
|
|
825
|
+
|
|
826
|
+
// A remote harness reports through a socket that serves it alone, so a
|
|
827
|
+
// line that names any other session is dropped.
|
|
828
|
+
mgr.onRelay = (binding, relay) => {
|
|
829
|
+
startSessionBridge(relay, binding, {
|
|
830
|
+
findSession: (sessionID) => mgr.sessions.find((x) => x.id === sessionID),
|
|
831
|
+
applyHookEvent,
|
|
832
|
+
applyReport: async (sessionID, payload, reportID) => {
|
|
833
|
+
if (parseReport(payload) === null) {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
await applyReport({ atcId: sessionID, event: 'Report', payload: { ...payload } }, reportID);
|
|
838
|
+
|
|
839
|
+
return true;
|
|
840
|
+
},
|
|
841
|
+
attachTap: (client, sessionID) => ctx.attachTap(client, sessionID),
|
|
842
|
+
ackMessage: (client, sessionID, messageID) => ctx.ackMessage(client, sessionID, messageID),
|
|
843
|
+
detachTap: (client) => {
|
|
844
|
+
taps.detachAll(client);
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
// A spawn with a workspace source materializes it first, and the session
|
|
850
|
+
// registers only once its workspace is ready, so no session ever lists
|
|
851
|
+
// over a half-built checkout. A spawn that throws once its process has
|
|
852
|
+
// started takes the session back before it throws, so a failed start
|
|
853
|
+
// leaves nothing running and a keyed retry spawns once. When taking it
|
|
854
|
+
// back fails too, the session may still stand, and the throw says so.
|
|
855
|
+
const startSpawn = async (
|
|
856
|
+
p: SpawnParams,
|
|
857
|
+
id: SessionID,
|
|
858
|
+
): Promise<Readonly<Record<string, unknown>>> => {
|
|
859
|
+
const prepared =
|
|
860
|
+
p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
|
|
861
|
+
|
|
862
|
+
const materialized = prepared?.kind === 'ready' ? prepared : null;
|
|
863
|
+
const warnings = materialized === null ? [] : materialized.warnings;
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const session = await startSpawnedSession(p, id, materialized);
|
|
867
|
+
|
|
868
|
+
return warnings.length === 0 ? { session } : { session, warnings };
|
|
869
|
+
} catch (error) {
|
|
870
|
+
try {
|
|
871
|
+
await mgr.removeFailedSpawn(id);
|
|
872
|
+
} catch (cleanupError) {
|
|
873
|
+
throw new EffectRemainsError(
|
|
874
|
+
`spawn of session ${id} failed and taking it back failed too`,
|
|
875
|
+
{
|
|
876
|
+
cause: cleanupError,
|
|
877
|
+
},
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
throw error;
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// Materializes a spawn's workspace on its target. The target's identity
|
|
886
|
+
// binds when the materialization starts, and every provider call after
|
|
887
|
+
// passes the execution gate against that binding.
|
|
888
|
+
const materializeSpawnWorkspace = (
|
|
889
|
+
p: SpawnParams,
|
|
890
|
+
id: SessionID,
|
|
891
|
+
source: SpawnWorkspaceSource,
|
|
892
|
+
) => {
|
|
893
|
+
const bound = mgr.requireExecution({ target: p.target, targetIdentity: null }, 'run');
|
|
894
|
+
const binding = { target: p.target, targetIdentity: bound.identity };
|
|
895
|
+
|
|
896
|
+
return materializeWorkspace(
|
|
897
|
+
{
|
|
898
|
+
sessionID: id,
|
|
899
|
+
target: p.target,
|
|
900
|
+
dir: p.cwd,
|
|
901
|
+
source,
|
|
902
|
+
inPlace: bound.provider.kind === 'local-pty',
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
requireProvider: (capability) => mgr.requireExecution(binding, capability).provider,
|
|
906
|
+
store,
|
|
907
|
+
log: (line) => {
|
|
908
|
+
mgr.log(line);
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const startSpawnedSession = async (
|
|
915
|
+
p: SpawnParams,
|
|
916
|
+
id: SessionID,
|
|
917
|
+
materialized: Readonly<{
|
|
918
|
+
workspace: SessionWorkspace;
|
|
919
|
+
withheldEnv: readonly string[];
|
|
920
|
+
}> | null,
|
|
921
|
+
): Promise<SessionDescriptor> => {
|
|
922
|
+
const s = await mgr.spawn(
|
|
923
|
+
p.cwd,
|
|
924
|
+
p.name,
|
|
925
|
+
p.prompt,
|
|
926
|
+
p.cols,
|
|
927
|
+
p.rows,
|
|
928
|
+
p.resume,
|
|
929
|
+
p.namedBy,
|
|
930
|
+
p.agent,
|
|
931
|
+
p.parent,
|
|
932
|
+
p.overrides,
|
|
933
|
+
id,
|
|
934
|
+
p.target,
|
|
935
|
+
materialized,
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
const runtime = runtimes.get(s.id);
|
|
939
|
+
|
|
940
|
+
if (runtime !== undefined) {
|
|
941
|
+
runtime.pendingLastUsed = true;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
void store.recordSpawnDir(p.cwd);
|
|
945
|
+
|
|
946
|
+
return getDescriptor(mgr, s.id);
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// A retried spawn answers with the session as it stands now when it is
|
|
950
|
+
// listed, else with the descriptor the first spawn answered. A key that
|
|
951
|
+
// start-up reconciliation completed has no stored answer, and its session
|
|
952
|
+
// lists only once the fleet is restored.
|
|
953
|
+
const loadSpawnReplay = (record: IdempotencyRecord): Readonly<Record<string, unknown>> => {
|
|
954
|
+
const listed = mgr.collectDescriptors().find((d) => d.id === record.effectRef);
|
|
955
|
+
|
|
956
|
+
if (listed !== undefined) {
|
|
957
|
+
return { session: listed };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
961
|
+
|
|
962
|
+
if (isRecord(stored)) {
|
|
963
|
+
return stored;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
throw new DaemonError(
|
|
967
|
+
'no_such_session',
|
|
968
|
+
`the spawn under idempotency key '${record.key}' created session '${record.effectRef}', which is not listed; restore the fleet to list it`,
|
|
969
|
+
{ effectRef: record.effectRef },
|
|
970
|
+
);
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL);
|
|
974
|
+
|
|
975
|
+
// Throws the refusal a fresh spawn to the target gets when the access
|
|
976
|
+
// does not reach the target, and the identity, a held spawn key recorded
|
|
977
|
+
// for its session. A key that records none is refused: nothing it holds
|
|
978
|
+
// shows where its session ran.
|
|
979
|
+
const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
|
|
980
|
+
const bound = record.effectTarget;
|
|
981
|
+
|
|
982
|
+
if (bound === null) {
|
|
983
|
+
throw buildTargetForbiddenError(findReplayTarget(record));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (!access.canUse(bound)) {
|
|
987
|
+
throw buildTargetForbiddenError(bound.target);
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
// Each confirm token a forget handed out, by token: the session it
|
|
992
|
+
// forgets, when it stops being taken, and whether a forget took it. A
|
|
993
|
+
// token stays known for one more lifetime after it expires, so a late
|
|
994
|
+
// forget learns it expired rather than that it never existed.
|
|
995
|
+
const confirmTokens = new Map<string, ConfirmToken>();
|
|
996
|
+
|
|
997
|
+
const forgetConfirmMs = opts.forgetConfirmMs ?? FORGET_CONFIRM_MS;
|
|
998
|
+
|
|
999
|
+
const claimConfirmToken = (sessionID: SessionID, token: string) => {
|
|
1000
|
+
const now = Date.now();
|
|
1001
|
+
|
|
1002
|
+
for (const [held, entry] of confirmTokens) {
|
|
1003
|
+
if (entry.expiresAt + forgetConfirmMs < now) {
|
|
1004
|
+
confirmTokens.delete(held);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const entry = confirmTokens.get(token);
|
|
1009
|
+
let reason: 'unknown' | 'used' | 'expired' | null = null;
|
|
1010
|
+
|
|
1011
|
+
if (entry === undefined || entry.session !== sessionID) {
|
|
1012
|
+
reason = 'unknown';
|
|
1013
|
+
} else if (entry.used) {
|
|
1014
|
+
reason = 'used';
|
|
1015
|
+
} else if (entry.expiresAt <= now) {
|
|
1016
|
+
reason = 'expired';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (reason !== null) {
|
|
1020
|
+
throw new DaemonError(
|
|
1021
|
+
'confirm_token_invalid',
|
|
1022
|
+
`confirm token for session ${sessionID} is ${reason}; call session.forget without a token for a new one`,
|
|
1023
|
+
{ reason },
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
confirmTokens.set(token, {
|
|
1028
|
+
session: sessionID,
|
|
1029
|
+
expiresAt: entry?.expiresAt ?? now,
|
|
1030
|
+
used: true,
|
|
1031
|
+
});
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// Why the session refuses a message right now, or null when it takes one.
|
|
1035
|
+
const findMessageRefusal = (sessionID: SessionID): MessageRefusal | null => {
|
|
1036
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1037
|
+
|
|
1038
|
+
if (s === undefined) {
|
|
1039
|
+
return 'missing';
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
|
|
1043
|
+
return 'dead';
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
|
|
1047
|
+
return 'unsupported';
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const runtime = runtimes.get(sessionID);
|
|
1051
|
+
|
|
1052
|
+
if (
|
|
1053
|
+
runtime !== undefined &&
|
|
1054
|
+
runtime.startedAt !== null &&
|
|
1055
|
+
!runtime.tapAttached &&
|
|
1056
|
+
Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
|
|
1057
|
+
) {
|
|
1058
|
+
return 'no_tap';
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return null;
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
// Writes the message under the id given, notes it in the trail, and hands
|
|
1065
|
+
// it to the session's tap when one is attached. Only the write itself can
|
|
1066
|
+
// reject: every later step swallows its own failure, so a rejection means
|
|
1067
|
+
// no message was written.
|
|
1068
|
+
const writeAcceptedMessage = async (
|
|
1069
|
+
sessionID: SessionID,
|
|
1070
|
+
from: string,
|
|
1071
|
+
text: string,
|
|
1072
|
+
id: MessageID,
|
|
1073
|
+
): Promise<MessageRecord> => {
|
|
1074
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1075
|
+
const previousWrite = lastMessageWrite;
|
|
1076
|
+
const written = Promise.withResolvers<void>();
|
|
1077
|
+
|
|
1078
|
+
lastMessageWrite = written.promise;
|
|
1079
|
+
|
|
1080
|
+
await previousWrite;
|
|
1081
|
+
|
|
1082
|
+
const record: MessageRecord = {
|
|
1083
|
+
id,
|
|
1084
|
+
atcID: sessionID,
|
|
1085
|
+
...(s?.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
1086
|
+
from,
|
|
1087
|
+
text,
|
|
1088
|
+
status: 'accepted',
|
|
1089
|
+
sentAt: Date.now(),
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
await store.writeMessage(record);
|
|
1094
|
+
|
|
1095
|
+
await recordMessageStatus(sessionID, record);
|
|
1096
|
+
} finally {
|
|
1097
|
+
written.resolve();
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
await drainInbox(sessionID);
|
|
1101
|
+
|
|
1102
|
+
return record;
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// A retried send answers with the message's current status, so a caller
|
|
1106
|
+
// that lost the first answer learns where its message stands now.
|
|
1107
|
+
const loadMessageReplay = async (
|
|
1108
|
+
record: IdempotencyRecord,
|
|
1109
|
+
): Promise<Readonly<Record<string, unknown>>> => {
|
|
1110
|
+
const current = await store.findMessageByID(toMessageID(record.effectRef));
|
|
1111
|
+
|
|
1112
|
+
if (current !== null) {
|
|
1113
|
+
return { message: current.id, status: current.status };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
1117
|
+
|
|
1118
|
+
if (isRecord(stored)) {
|
|
1119
|
+
return stored;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
throw new DaemonError(
|
|
1123
|
+
'internal',
|
|
1124
|
+
`the message under idempotency key '${record.key}' has no stored row`,
|
|
1125
|
+
{ effectRef: record.effectRef },
|
|
1126
|
+
);
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
// Stops the headless runs a kill or forget ended, once the manager has
|
|
1130
|
+
// acted: a session it removed already lost its run with its runtime, a
|
|
1131
|
+
// session it left exited loses it here, and a sub-session it promoted, or
|
|
1132
|
+
// any session of a kill or forget that failed, keeps working.
|
|
1133
|
+
const stopEndedHeadlessRuns = (set: readonly Session[]) => {
|
|
1134
|
+
for (const s of set) {
|
|
1135
|
+
if (s.state === 'exited') {
|
|
1136
|
+
runtimes.get(s.id)?.stopHeadlessRun();
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
654
1140
|
|
|
655
1141
|
const ctx: DaemonContext = {
|
|
656
1142
|
build: opts.build,
|
|
1143
|
+
daemonID: store.daemonID,
|
|
657
1144
|
collectSessions: () => mgr.collectDescriptors(),
|
|
658
1145
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
659
1146
|
collectAgents: () => ({
|
|
@@ -664,35 +1151,67 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
664
1151
|
build: opts.build,
|
|
665
1152
|
},
|
|
666
1153
|
agents: buildAgentList(mgr.collectAdapters(), (bin) => Bun.which(bin) !== null),
|
|
1154
|
+
targets: buildTargetList(targets, defaultTarget),
|
|
1155
|
+
spawnDefaults: { agent: 'claude', target: defaultTarget },
|
|
1156
|
+
configRevision,
|
|
1157
|
+
targetErrors,
|
|
667
1158
|
}),
|
|
668
1159
|
collectFleet: () => store.loadFleet(),
|
|
669
1160
|
loadLastUsedAgent: () => store.loadLastUsedAgent(),
|
|
670
1161
|
findAdapter: (kind) => mgr.findAdapter(kind),
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
p.name,
|
|
675
|
-
p.prompt,
|
|
676
|
-
p.cols,
|
|
677
|
-
p.rows,
|
|
678
|
-
p.resume,
|
|
679
|
-
p.namedBy,
|
|
680
|
-
p.agent,
|
|
681
|
-
p.parent,
|
|
682
|
-
);
|
|
683
|
-
|
|
684
|
-
const runtime = runtimes.get(s.id);
|
|
1162
|
+
buildTargetAccess: (principal) => buildTargetAccess(principals, targetsByID, principal),
|
|
1163
|
+
findSessionGrant: (id) => {
|
|
1164
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
685
1165
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1166
|
+
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
1167
|
+
},
|
|
1168
|
+
findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
|
|
1169
|
+
collectChildIDs: (id) => mgr.collectChildren(id).map((child) => child.id),
|
|
1170
|
+
findPermissionSession: (request) => registry.findSessionID(request),
|
|
1171
|
+
resolveSpawnTarget: (requested) => {
|
|
1172
|
+
const target = requested ?? defaultTarget;
|
|
1173
|
+
const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
|
|
1174
|
+
|
|
1175
|
+
if (refusal !== null) {
|
|
1176
|
+
throw refusal;
|
|
1177
|
+
}
|
|
689
1178
|
|
|
690
|
-
|
|
1179
|
+
if (target === null) {
|
|
1180
|
+
throw new Error('a spawn without a target passed its checks');
|
|
691
1181
|
}
|
|
692
1182
|
|
|
693
|
-
|
|
1183
|
+
return target;
|
|
1184
|
+
},
|
|
1185
|
+
requireWorkspaceTarget: (target) => {
|
|
1186
|
+
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1187
|
+
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1188
|
+
},
|
|
1189
|
+
spawnSession: (plan, keyed, access) => {
|
|
1190
|
+
if (keyed === null) {
|
|
1191
|
+
return startSpawn(plan(), mintSessionID());
|
|
1192
|
+
}
|
|
694
1193
|
|
|
695
|
-
|
|
1194
|
+
const effectRef = mintSessionID();
|
|
1195
|
+
|
|
1196
|
+
return ledger.run({
|
|
1197
|
+
operation: 'session.spawn',
|
|
1198
|
+
keyed,
|
|
1199
|
+
effectRef,
|
|
1200
|
+
start: () => startSpawn(plan(), effectRef),
|
|
1201
|
+
settle: () => mgr.writeFleet(),
|
|
1202
|
+
replay: (record) => {
|
|
1203
|
+
if (access !== null) {
|
|
1204
|
+
requireReplayInReach(record, access);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
return loadSpawnReplay(record);
|
|
1208
|
+
},
|
|
1209
|
+
findEffectTarget: () => {
|
|
1210
|
+
const s = mgr.sessions.find((x) => x.id === effectRef);
|
|
1211
|
+
|
|
1212
|
+
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
696
1215
|
},
|
|
697
1216
|
updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
|
|
698
1217
|
quitDaemon: () => {
|
|
@@ -707,20 +1226,60 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
707
1226
|
}, 80);
|
|
708
1227
|
},
|
|
709
1228
|
killSession: async (id) => {
|
|
710
|
-
|
|
1229
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
1230
|
+
|
|
1231
|
+
if (s === undefined) {
|
|
711
1232
|
return false;
|
|
712
1233
|
}
|
|
713
1234
|
|
|
714
|
-
for (const
|
|
715
|
-
|
|
1235
|
+
for (const live of [s, ...mgr.collectChildren(id)]) {
|
|
1236
|
+
if (live.pty !== null) {
|
|
1237
|
+
mgr.requireExecution(live, mgr.pickKillCapability(live));
|
|
1238
|
+
}
|
|
716
1239
|
}
|
|
717
1240
|
|
|
718
|
-
|
|
1241
|
+
const set = [s, ...mgr.collectChildren(id)];
|
|
719
1242
|
|
|
720
1243
|
await mgr.kill(id);
|
|
721
1244
|
|
|
1245
|
+
stopEndedHeadlessRuns(set);
|
|
1246
|
+
|
|
722
1247
|
return true;
|
|
723
1248
|
},
|
|
1249
|
+
|
|
1250
|
+
// A forget on a target that cannot destroy its host forgets at once. On
|
|
1251
|
+
// one that can, a forget without a token checks the target and answers
|
|
1252
|
+
// with a token, and the forget that carries the token destroys the host.
|
|
1253
|
+
forgetSession: async (id, confirmToken) => {
|
|
1254
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
1255
|
+
|
|
1256
|
+
if (s === undefined) {
|
|
1257
|
+
return 'missing';
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (mgr.findProvider(s)?.capabilities.destroy === true) {
|
|
1261
|
+
mgr.requireExecution(s, 'destroy');
|
|
1262
|
+
|
|
1263
|
+
if (confirmToken === undefined) {
|
|
1264
|
+
const token = randomUUID();
|
|
1265
|
+
const expiresAt = Date.now() + forgetConfirmMs;
|
|
1266
|
+
|
|
1267
|
+
confirmTokens.set(token, { session: id, expiresAt, used: false });
|
|
1268
|
+
|
|
1269
|
+
return { confirmToken: token, expiresAt };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
claimConfirmToken(id, confirmToken);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const set = [s, ...mgr.collectChildren(id)];
|
|
1276
|
+
|
|
1277
|
+
const destroyed = await mgr.forget(id);
|
|
1278
|
+
|
|
1279
|
+
stopEndedHeadlessRuns(set);
|
|
1280
|
+
|
|
1281
|
+
return { forgotten: true, destroyed };
|
|
1282
|
+
},
|
|
724
1283
|
ejectSession: (id, prompt) => {
|
|
725
1284
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
726
1285
|
|
|
@@ -738,6 +1297,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
738
1297
|
return 'no_transcript';
|
|
739
1298
|
}
|
|
740
1299
|
|
|
1300
|
+
mgr.requireExecution(s, 'kill');
|
|
1301
|
+
mgr.requireExecution(s, 'headless');
|
|
1302
|
+
|
|
741
1303
|
const yanked = mgr.yankHeadless(id);
|
|
742
1304
|
|
|
743
1305
|
if (yanked === null) {
|
|
@@ -761,7 +1323,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
761
1323
|
|
|
762
1324
|
return 'ok';
|
|
763
1325
|
},
|
|
764
|
-
adoptSession: (id, cols, rows) => {
|
|
1326
|
+
adoptSession: async (id, cols, rows) => {
|
|
765
1327
|
if (!hasResumableTranscript(mgr, id)) {
|
|
766
1328
|
return 'no_transcript';
|
|
767
1329
|
}
|
|
@@ -769,20 +1331,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
769
1331
|
const runtime = runtimes.get(id);
|
|
770
1332
|
|
|
771
1333
|
runtime?.stopHeadlessRun();
|
|
772
|
-
|
|
1334
|
+
|
|
1335
|
+
const adopted = await mgr.adoptTerminal(id, cols, rows);
|
|
773
1336
|
|
|
774
1337
|
if (adopted === null) {
|
|
775
1338
|
return 'missing';
|
|
776
1339
|
}
|
|
777
1340
|
|
|
778
|
-
if (runtime !== undefined) {
|
|
779
|
-
runtime.dims = { cols, rows };
|
|
780
|
-
runtime.startedAt = null;
|
|
781
|
-
runtime.tapAttached = false;
|
|
782
|
-
|
|
783
|
-
runtime.screen ??= new ScreenModel(cols, rows);
|
|
784
|
-
}
|
|
785
|
-
|
|
786
1341
|
scheduleResize(id);
|
|
787
1342
|
|
|
788
1343
|
return 'ok';
|
|
@@ -821,6 +1376,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
821
1376
|
return 'dead';
|
|
822
1377
|
}
|
|
823
1378
|
|
|
1379
|
+
mgr.requireExecution(s, 'attach');
|
|
824
1380
|
attachments.attach(sessionID, client, dims);
|
|
825
1381
|
mgr.attach(sessionID);
|
|
826
1382
|
|
|
@@ -861,7 +1417,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
861
1417
|
return 'missing';
|
|
862
1418
|
}
|
|
863
1419
|
|
|
1420
|
+
// A headless turn runs only through the session's own target, never
|
|
1421
|
+
// anywhere else, and a dead session takes no input.
|
|
864
1422
|
if (s.kind === 'headless') {
|
|
1423
|
+
mgr.requireExecution(s, 'headless');
|
|
1424
|
+
|
|
1425
|
+
if (s.state === 'exited') {
|
|
1426
|
+
return 'dead';
|
|
1427
|
+
}
|
|
1428
|
+
|
|
865
1429
|
if ((runtimes.get(sessionID)?.headlessRun ?? null) !== null) {
|
|
866
1430
|
return 'busy';
|
|
867
1431
|
}
|
|
@@ -881,10 +1445,35 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
881
1445
|
return 'dead';
|
|
882
1446
|
}
|
|
883
1447
|
|
|
1448
|
+
mgr.requireExecution(s, 'input');
|
|
884
1449
|
s.pty.write(data);
|
|
885
1450
|
|
|
886
1451
|
return 'ok';
|
|
887
1452
|
},
|
|
1453
|
+
writeSessionLine: (sessionID, text) => {
|
|
1454
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1455
|
+
|
|
1456
|
+
// A headless turn takes the line as its prompt, and a missing or dead
|
|
1457
|
+
// session refuses a line as it refuses raw input.
|
|
1458
|
+
if (s === undefined || s.kind === 'headless' || s.pty === null) {
|
|
1459
|
+
return ctx.writeSessionInput(sessionID, text);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
mgr.requireExecution(s, 'input');
|
|
1463
|
+
|
|
1464
|
+
const bracketedPaste = runtimes.get(sessionID)?.screen?.hasBracketedPaste() ?? false;
|
|
1465
|
+
const adapter = mgr.findAdapter(s.agent);
|
|
1466
|
+
const writes = adapter?.planLineInput?.(text, { bracketedPaste }) ?? planTypedLineInput(text);
|
|
1467
|
+
|
|
1468
|
+
// Every write goes out in the tick the request arrives in, so no input
|
|
1469
|
+
// overtakes the line and none lands between the text and its submit
|
|
1470
|
+
// key.
|
|
1471
|
+
for (const data of writes) {
|
|
1472
|
+
s.pty.write(data);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
return 'ok';
|
|
1476
|
+
},
|
|
888
1477
|
resizeSession: (client, sessionID, dims) => {
|
|
889
1478
|
if (!attachments.updateDims(sessionID, client, dims)) {
|
|
890
1479
|
return false;
|
|
@@ -950,9 +1539,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
950
1539
|
|
|
951
1540
|
return { path, page };
|
|
952
1541
|
},
|
|
953
|
-
readEvents: async (afterID, limit, waitMs, sessionID) => {
|
|
1542
|
+
readEvents: async (afterID, limit, waitMs, sessionID, access) => {
|
|
954
1543
|
const deadline = Date.now() + waitMs;
|
|
955
|
-
const scope =
|
|
1544
|
+
const scope = buildEventScope(sessionID, access);
|
|
956
1545
|
|
|
957
1546
|
// A wake for an event the read leaves out (a heartbeat, or another
|
|
958
1547
|
// session's event under a session filter) loops back to wait out the
|
|
@@ -979,60 +1568,51 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
979
1568
|
await eventSignal.waitForNext(generation, remaining);
|
|
980
1569
|
}
|
|
981
1570
|
},
|
|
982
|
-
writeSessionMessage: async (sessionID, from, text) => {
|
|
983
|
-
|
|
1571
|
+
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1572
|
+
if (keyed === null) {
|
|
1573
|
+
const refusal = findMessageRefusal(sessionID);
|
|
984
1574
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1575
|
+
if (refusal !== null) {
|
|
1576
|
+
return refusal;
|
|
1577
|
+
}
|
|
988
1578
|
|
|
989
|
-
|
|
990
|
-
return 'dead';
|
|
991
|
-
}
|
|
1579
|
+
const record = await writeAcceptedMessage(sessionID, from, text, mintMessageID());
|
|
992
1580
|
|
|
993
|
-
|
|
994
|
-
return 'unsupported';
|
|
1581
|
+
return { message: record.id, status: record.status };
|
|
995
1582
|
}
|
|
996
1583
|
|
|
997
|
-
const
|
|
1584
|
+
const effectRef = mintMessageID();
|
|
998
1585
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
) {
|
|
1005
|
-
return 'no_tap';
|
|
1006
|
-
}
|
|
1586
|
+
try {
|
|
1587
|
+
return await ledger.run({
|
|
1588
|
+
operation: 'session.message',
|
|
1589
|
+
keyed,
|
|
1590
|
+
effectRef,
|
|
1007
1591
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1592
|
+
// A refusal throws, so the claim drops and a retry runs fresh.
|
|
1593
|
+
start: async () => {
|
|
1594
|
+
const refusal = findMessageRefusal(sessionID);
|
|
1010
1595
|
|
|
1011
|
-
|
|
1596
|
+
if (refusal !== null) {
|
|
1597
|
+
throw new MessageRefusedError(refusal);
|
|
1598
|
+
}
|
|
1012
1599
|
|
|
1013
|
-
|
|
1600
|
+
const record = await writeAcceptedMessage(sessionID, from, text, effectRef);
|
|
1014
1601
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
atcID: sessionID,
|
|
1018
|
-
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
1019
|
-
from,
|
|
1020
|
-
text,
|
|
1021
|
-
status: 'accepted',
|
|
1022
|
-
sentAt: Date.now(),
|
|
1023
|
-
};
|
|
1602
|
+
return { message: record.id, status: record.status };
|
|
1603
|
+
},
|
|
1024
1604
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1605
|
+
// The message row is the effect, and the start already wrote it.
|
|
1606
|
+
settle: () => Promise.resolve(),
|
|
1607
|
+
replay: (record) => loadMessageReplay(record),
|
|
1608
|
+
});
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
if (error instanceof MessageRefusedError) {
|
|
1611
|
+
return error.refusal;
|
|
1612
|
+
}
|
|
1027
1613
|
|
|
1028
|
-
|
|
1029
|
-
} finally {
|
|
1030
|
-
written.resolve();
|
|
1614
|
+
throw error;
|
|
1031
1615
|
}
|
|
1032
|
-
|
|
1033
|
-
await drainInbox(sessionID);
|
|
1034
|
-
|
|
1035
|
-
return record;
|
|
1036
1616
|
},
|
|
1037
1617
|
attachTap: (client, sessionID) => {
|
|
1038
1618
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -1149,10 +1729,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1149
1729
|
client.dispose();
|
|
1150
1730
|
}
|
|
1151
1731
|
|
|
1732
|
+
clearInterval(idempotencySweep);
|
|
1733
|
+
|
|
1152
1734
|
server.stop(true);
|
|
1153
1735
|
eventsServer?.stop();
|
|
1154
1736
|
reporter.stop(true);
|
|
1155
|
-
mgr.
|
|
1737
|
+
mgr.detachAll();
|
|
1738
|
+
|
|
1739
|
+
for (const target of targets) {
|
|
1740
|
+
target.provider?.dispose();
|
|
1741
|
+
}
|
|
1156
1742
|
|
|
1157
1743
|
for (const runtime of runtimes.values()) {
|
|
1158
1744
|
runtime.dispose();
|
|
@@ -1216,6 +1802,12 @@ function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
|
|
|
1216
1802
|
return false;
|
|
1217
1803
|
}
|
|
1218
1804
|
|
|
1805
|
+
// A remote session's transcript lives in its host, out of the daemon's
|
|
1806
|
+
// reach, so the agent's session id alone makes it resumable.
|
|
1807
|
+
if (mgr.findProvider(s)?.remote === true) {
|
|
1808
|
+
return s.agentSessionID !== undefined;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1219
1811
|
return adapter.canResume({
|
|
1220
1812
|
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
1221
1813
|
...(s.transcriptSource === undefined ? {} : { transcriptSource: s.transcriptSource }),
|
|
@@ -1231,3 +1823,45 @@ function getDescriptor(mgr: SessionManager, id: SessionID): SessionDescriptor {
|
|
|
1231
1823
|
|
|
1232
1824
|
return d;
|
|
1233
1825
|
}
|
|
1826
|
+
|
|
1827
|
+
// A sweep that fails leaves the keys for the next one.
|
|
1828
|
+
async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boolean> {
|
|
1829
|
+
try {
|
|
1830
|
+
await store.removeExpiredIdempotencyKeys(Date.now() - IDEMPOTENCY_TTL_MS);
|
|
1831
|
+
} catch {
|
|
1832
|
+
return false;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
interface ConfirmToken {
|
|
1839
|
+
readonly session: SessionID;
|
|
1840
|
+
readonly expiresAt: number;
|
|
1841
|
+
readonly used: boolean;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Carries a message refusal out of a keyed send's start, so the claim drops
|
|
1845
|
+
// and the refusal still answers the request.
|
|
1846
|
+
class MessageRefusedError extends Error {
|
|
1847
|
+
readonly refusal: MessageRefusal;
|
|
1848
|
+
|
|
1849
|
+
constructor(refusal: MessageRefusal) {
|
|
1850
|
+
super(`message refused: ${refusal}`);
|
|
1851
|
+
|
|
1852
|
+
this.refusal = refusal;
|
|
1853
|
+
this.name = 'MessageRefusedError';
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// The target a held spawn key's stored answer holds its session on, for a
|
|
1858
|
+
// refusal of its replay; the answer holds no other target to name.
|
|
1859
|
+
function findReplayTarget(record: IdempotencyRecord): string {
|
|
1860
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
1861
|
+
const session = isRecord(stored) ? stored['session'] : null;
|
|
1862
|
+
const locator = isRecord(session) ? session['locator'] : null;
|
|
1863
|
+
|
|
1864
|
+
return isRecord(locator) && typeof locator['targetID'] === 'string'
|
|
1865
|
+
? locator['targetID']
|
|
1866
|
+
: 'unknown';
|
|
1867
|
+
}
|