@zgeoff/atc 2.12.0 → 2.14.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 +12 -11
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -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 +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- 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 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +134 -4
- package/src/daemon/daemon.ts +306 -42
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- 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 +62 -3
- package/src/daemon/materialize-workspace.ts +574 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -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/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { hostname } from 'node:os';
|
|
3
|
+
import { hostname, tmpdir } 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';
|
|
5
7
|
import { DaemonError } from '../protocol/daemon-error';
|
|
6
8
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
7
9
|
import type { EventMsg } from '../protocol/protocol';
|
|
10
|
+
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
8
11
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
9
12
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
10
13
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
@@ -19,6 +22,7 @@ import type { MessageRecord, MessageStatus } from '../store/message-record';
|
|
|
19
22
|
import { StateStore } from '../store/state-store';
|
|
20
23
|
import type { EventScope } from '../store/state-store';
|
|
21
24
|
import type { TrailEntry } from '../store/trail-entry';
|
|
25
|
+
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
22
26
|
import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
23
27
|
import { AttachRegistry } from './attach-registry';
|
|
24
28
|
import { buildAgentList } from './build-agent-list';
|
|
@@ -28,6 +32,7 @@ import type { ExecutionTarget } from './build-execution-targets';
|
|
|
28
32
|
import { buildFleetEvents } from './build-fleet-events';
|
|
29
33
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
30
34
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
35
|
+
import { buildReportView } from './build-report-view';
|
|
31
36
|
import { buildSessionEvent } from './build-session-event';
|
|
32
37
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
33
38
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
@@ -51,6 +56,7 @@ import { IdempotencyLedger } from './idempotency-ledger';
|
|
|
51
56
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
52
57
|
import { makeHookRunner } from './make-hook-runner';
|
|
53
58
|
import type { HookScope } from './make-hook-runner';
|
|
59
|
+
import { materializeWorkspace } from './materialize-workspace';
|
|
54
60
|
import { mintMessageID } from './mint-message-id';
|
|
55
61
|
import { mintSessionID } from './mint-session-id';
|
|
56
62
|
import { parseReport } from './parse-report';
|
|
@@ -63,6 +69,7 @@ import { SessionManager } from './sessions';
|
|
|
63
69
|
import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
64
70
|
import { startEventsServer } from './start-events-server';
|
|
65
71
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
72
|
+
import { startSessionBridge } from './start-session-bridge';
|
|
66
73
|
import { TapRegistry } from './tap-registry';
|
|
67
74
|
import type { TargetAccess } from './target-access';
|
|
68
75
|
import { writeDaemonRecord } from './write-daemon-record';
|
|
@@ -129,9 +136,16 @@ export interface DaemonOptions {
|
|
|
129
136
|
readonly restoreBootTimeoutMs?: number;
|
|
130
137
|
readonly tapGraceMs?: number;
|
|
131
138
|
|
|
139
|
+
// How long a confirm token from `session.forget` stays usable.
|
|
140
|
+
readonly forgetConfirmMs?: number;
|
|
141
|
+
|
|
132
142
|
// Called after a client-requested quit has stopped the daemon; the real
|
|
133
143
|
// entrypoint exits the process, tests leave it unset.
|
|
134
144
|
readonly onQuit?: () => void;
|
|
145
|
+
|
|
146
|
+
// Where background failures are reported, one line at a time; stderr
|
|
147
|
+
// when unset.
|
|
148
|
+
readonly log?: (line: string) => void;
|
|
135
149
|
}
|
|
136
150
|
|
|
137
151
|
export interface DaemonHandle {
|
|
@@ -145,6 +159,9 @@ export interface DaemonHandle {
|
|
|
145
159
|
// it is refused.
|
|
146
160
|
const TAP_GRACE_MS = 15_000;
|
|
147
161
|
|
|
162
|
+
// How long a confirm token from a forget that destroys a host stays usable.
|
|
163
|
+
const FORGET_CONFIRM_MS = 60_000;
|
|
164
|
+
|
|
148
165
|
// The principal every request on the local socket acts as.
|
|
149
166
|
const LOCAL_PRINCIPAL = 'local';
|
|
150
167
|
|
|
@@ -192,8 +209,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
192
209
|
|
|
193
210
|
const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
|
|
194
211
|
|
|
195
|
-
// Before any request is served: a
|
|
196
|
-
//
|
|
212
|
+
// Before any request is served: a workspace a stopped daemon left short
|
|
213
|
+
// of ready fails as interrupted, and a key left in progress by a daemon
|
|
214
|
+
// that stopped mid-effect is settled, so no retry can race either.
|
|
215
|
+
await store.reconcileMaterializations(Date.now());
|
|
197
216
|
await store.reconcileIdempotencyKeys(Date.now());
|
|
198
217
|
|
|
199
218
|
await tryRemoveExpiredIdempotencyKeys(store);
|
|
@@ -205,7 +224,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
205
224
|
idempotencySweep.unref();
|
|
206
225
|
|
|
207
226
|
const targets =
|
|
208
|
-
opts.targets ??
|
|
227
|
+
opts.targets ??
|
|
228
|
+
buildExecutionTargets([{ id: 'local', provider: 'local-pty', options: {} }]).targets;
|
|
209
229
|
|
|
210
230
|
const targetErrors = opts.targetErrors ?? [];
|
|
211
231
|
const principals = opts.principals ?? null;
|
|
@@ -228,6 +248,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
228
248
|
targetErrors,
|
|
229
249
|
);
|
|
230
250
|
|
|
251
|
+
if (opts.log !== undefined) {
|
|
252
|
+
mgr.log = opts.log;
|
|
253
|
+
}
|
|
254
|
+
|
|
231
255
|
const clients = new Set<DaemonConnection>();
|
|
232
256
|
|
|
233
257
|
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
@@ -265,14 +289,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
265
289
|
const eventSignal = new EventSignal();
|
|
266
290
|
|
|
267
291
|
// A trail write that fails never fails the message request or report behind it.
|
|
268
|
-
|
|
292
|
+
// Returns false when the entry was not written: a failed write, or a
|
|
293
|
+
// report the trail already holds.
|
|
294
|
+
const recordTrailEntry = async (entry: TrailEntry): Promise<boolean> => {
|
|
295
|
+
let written: boolean;
|
|
296
|
+
|
|
269
297
|
try {
|
|
270
|
-
await store.recordTrailEntry(entry);
|
|
298
|
+
written = await store.recordTrailEntry(entry);
|
|
271
299
|
} catch {
|
|
272
|
-
return;
|
|
300
|
+
return false;
|
|
273
301
|
}
|
|
274
302
|
|
|
275
|
-
|
|
303
|
+
if (written) {
|
|
304
|
+
eventSignal.emit();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return written;
|
|
276
308
|
};
|
|
277
309
|
|
|
278
310
|
// Notes a message status change in the trail before broadcasting it, so a
|
|
@@ -419,7 +451,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
419
451
|
} catch {}
|
|
420
452
|
};
|
|
421
453
|
|
|
422
|
-
|
|
454
|
+
// A report id, which a remote session's reporter gives each report,
|
|
455
|
+
// makes a resent note land once; a resent answer changes nothing, since
|
|
456
|
+
// only an unanswered message takes one.
|
|
457
|
+
const applyReport = async (e: HookEvent, reportID?: string) => {
|
|
423
458
|
const report = parseReport(e.payload);
|
|
424
459
|
|
|
425
460
|
if (report === null) {
|
|
@@ -433,11 +468,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
433
468
|
const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
|
|
434
469
|
const reportedAt = Date.now();
|
|
435
470
|
|
|
436
|
-
|
|
437
|
-
|
|
471
|
+
const entry = buildReportTrailEntry(
|
|
472
|
+
sender.id,
|
|
473
|
+
sender.agentSessionID,
|
|
474
|
+
capped,
|
|
475
|
+
reportedAt,
|
|
476
|
+
reportID,
|
|
438
477
|
);
|
|
439
478
|
|
|
440
|
-
|
|
479
|
+
const written = await recordTrailEntry(entry);
|
|
480
|
+
|
|
481
|
+
if (written) {
|
|
482
|
+
emitEvent(
|
|
483
|
+
buildSessionReportEvent(sender.id, capped, reportedAt),
|
|
484
|
+
findHookScope(sender.id),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
441
487
|
}
|
|
442
488
|
|
|
443
489
|
return;
|
|
@@ -588,6 +634,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
588
634
|
}
|
|
589
635
|
};
|
|
590
636
|
|
|
637
|
+
// A spawned terminal starts on a fresh screen; a revived one keeps the
|
|
638
|
+
// screen its session already had.
|
|
639
|
+
mgr.onBoot = (s, cols, rows) => {
|
|
640
|
+
const runtime = runtimes.get(s.id);
|
|
641
|
+
|
|
642
|
+
if (runtime === undefined) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
runtime.resetBoot({ cols, rows });
|
|
647
|
+
|
|
648
|
+
runtime.screen ??= new ScreenModel(cols, rows);
|
|
649
|
+
};
|
|
650
|
+
|
|
591
651
|
mgr.onOutput = (s, data) => {
|
|
592
652
|
const runtime = runtimes.get(s.id);
|
|
593
653
|
|
|
@@ -713,7 +773,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
713
773
|
);
|
|
714
774
|
};
|
|
715
775
|
|
|
716
|
-
const
|
|
776
|
+
const applyHookEvent = (e: HookEvent) => {
|
|
717
777
|
if (e.event === 'Report') {
|
|
718
778
|
void applyReport(e);
|
|
719
779
|
|
|
@@ -760,15 +820,53 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
760
820
|
void store.writeLastUsedAgent(started.agent);
|
|
761
821
|
}
|
|
762
822
|
}
|
|
763
|
-
}
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const reporter = startHookServer(applyHookEvent, opts.reporterSocketPath);
|
|
826
|
+
|
|
827
|
+
// A remote harness reports through a socket that serves it alone, so a
|
|
828
|
+
// line that names any other session is dropped.
|
|
829
|
+
mgr.onRelay = (binding, relay) => {
|
|
830
|
+
startSessionBridge(relay, binding, {
|
|
831
|
+
findSession: (sessionID) => mgr.sessions.find((x) => x.id === sessionID),
|
|
832
|
+
applyHookEvent,
|
|
833
|
+
applyReport: async (sessionID, payload, reportID) => {
|
|
834
|
+
if (parseReport(payload) === null) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
await applyReport({ atcId: sessionID, event: 'Report', payload: { ...payload } }, reportID);
|
|
839
|
+
|
|
840
|
+
return true;
|
|
841
|
+
},
|
|
842
|
+
attachTap: (client, sessionID) => ctx.attachTap(client, sessionID),
|
|
843
|
+
ackMessage: (client, sessionID, messageID) => ctx.ackMessage(client, sessionID, messageID),
|
|
844
|
+
detachTap: (client) => {
|
|
845
|
+
taps.detachAll(client);
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// A spawn with a workspace source materializes it first, and the session
|
|
851
|
+
// registers only once its workspace is ready, so no session ever lists
|
|
852
|
+
// over a half-built checkout. A spawn that throws once its process has
|
|
853
|
+
// started takes the session back before it throws, so a failed start
|
|
854
|
+
// leaves nothing running and a keyed retry spawns once. When taking it
|
|
855
|
+
// back fails too, the session may still stand, and the throw says so.
|
|
856
|
+
const startSpawn = async (
|
|
857
|
+
p: SpawnParams,
|
|
858
|
+
id: SessionID,
|
|
859
|
+
): Promise<Readonly<Record<string, unknown>>> => {
|
|
860
|
+
const prepared =
|
|
861
|
+
p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
|
|
862
|
+
|
|
863
|
+
const materialized = prepared?.kind === 'ready' ? prepared : null;
|
|
864
|
+
const warnings = materialized === null ? [] : materialized.warnings;
|
|
764
865
|
|
|
765
|
-
// A spawn that throws once its process has started takes the session back
|
|
766
|
-
// before it throws, so a failed start leaves nothing running and a keyed
|
|
767
|
-
// retry spawns once. When taking it back fails too, the session may still
|
|
768
|
-
// stand, and the throw says so.
|
|
769
|
-
const startSpawn = async (p: SpawnParams, id: SessionID): Promise<SessionDescriptor> => {
|
|
770
866
|
try {
|
|
771
|
-
|
|
867
|
+
const session = await startSpawnedSession(p, id, materialized);
|
|
868
|
+
|
|
869
|
+
return warnings.length === 0 ? { session } : { session, warnings };
|
|
772
870
|
} catch (error) {
|
|
773
871
|
try {
|
|
774
872
|
await mgr.removeFailedSpawn(id);
|
|
@@ -785,8 +883,45 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
785
883
|
}
|
|
786
884
|
};
|
|
787
885
|
|
|
788
|
-
|
|
789
|
-
|
|
886
|
+
// Materializes a spawn's workspace on its target. The target's identity
|
|
887
|
+
// binds when the materialization starts, and every provider call after
|
|
888
|
+
// passes the execution gate against that binding.
|
|
889
|
+
const materializeSpawnWorkspace = (
|
|
890
|
+
p: SpawnParams,
|
|
891
|
+
id: SessionID,
|
|
892
|
+
source: SpawnWorkspaceSource,
|
|
893
|
+
) => {
|
|
894
|
+
const bound = mgr.requireExecution({ target: p.target, targetIdentity: null }, 'run');
|
|
895
|
+
const binding = { target: p.target, targetIdentity: bound.identity };
|
|
896
|
+
|
|
897
|
+
return materializeWorkspace(
|
|
898
|
+
{
|
|
899
|
+
sessionID: id,
|
|
900
|
+
target: p.target,
|
|
901
|
+
dir: p.cwd,
|
|
902
|
+
source,
|
|
903
|
+
inPlace: bound.provider.kind === 'local-pty',
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
requireProvider: (capability) => mgr.requireExecution(binding, capability).provider,
|
|
907
|
+
store,
|
|
908
|
+
log: (line) => {
|
|
909
|
+
mgr.log(line);
|
|
910
|
+
},
|
|
911
|
+
stagingRoot: tmpdir(),
|
|
912
|
+
},
|
|
913
|
+
);
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const startSpawnedSession = async (
|
|
917
|
+
p: SpawnParams,
|
|
918
|
+
id: SessionID,
|
|
919
|
+
materialized: Readonly<{
|
|
920
|
+
workspace: SessionWorkspace;
|
|
921
|
+
withheldEnv: readonly string[];
|
|
922
|
+
}> | null,
|
|
923
|
+
): Promise<SessionDescriptor> => {
|
|
924
|
+
const s = await mgr.spawn(
|
|
790
925
|
p.cwd,
|
|
791
926
|
p.name,
|
|
792
927
|
p.prompt,
|
|
@@ -799,15 +934,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
799
934
|
p.overrides,
|
|
800
935
|
id,
|
|
801
936
|
p.target,
|
|
937
|
+
materialized,
|
|
802
938
|
);
|
|
803
939
|
|
|
804
940
|
const runtime = runtimes.get(s.id);
|
|
805
941
|
|
|
806
942
|
if (runtime !== undefined) {
|
|
807
943
|
runtime.pendingLastUsed = true;
|
|
808
|
-
runtime.dims = { cols: p.cols, rows: p.rows };
|
|
809
|
-
|
|
810
|
-
runtime.screen = new ScreenModel(p.cols, p.rows);
|
|
811
944
|
}
|
|
812
945
|
|
|
813
946
|
void store.recordSpawnDir(p.cwd);
|
|
@@ -857,6 +990,49 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
857
990
|
}
|
|
858
991
|
};
|
|
859
992
|
|
|
993
|
+
// Each confirm token a forget handed out, by token: the session it
|
|
994
|
+
// forgets, when it stops being taken, and whether a forget took it. A
|
|
995
|
+
// token stays known for one more lifetime after it expires, so a late
|
|
996
|
+
// forget learns it expired rather than that it never existed.
|
|
997
|
+
const confirmTokens = new Map<string, ConfirmToken>();
|
|
998
|
+
|
|
999
|
+
const forgetConfirmMs = opts.forgetConfirmMs ?? FORGET_CONFIRM_MS;
|
|
1000
|
+
|
|
1001
|
+
const claimConfirmToken = (sessionID: SessionID, token: string) => {
|
|
1002
|
+
const now = Date.now();
|
|
1003
|
+
|
|
1004
|
+
for (const [held, entry] of confirmTokens) {
|
|
1005
|
+
if (entry.expiresAt + forgetConfirmMs < now) {
|
|
1006
|
+
confirmTokens.delete(held);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const entry = confirmTokens.get(token);
|
|
1011
|
+
let reason: 'unknown' | 'used' | 'expired' | null = null;
|
|
1012
|
+
|
|
1013
|
+
if (entry === undefined || entry.session !== sessionID) {
|
|
1014
|
+
reason = 'unknown';
|
|
1015
|
+
} else if (entry.used) {
|
|
1016
|
+
reason = 'used';
|
|
1017
|
+
} else if (entry.expiresAt <= now) {
|
|
1018
|
+
reason = 'expired';
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (reason !== null) {
|
|
1022
|
+
throw new DaemonError(
|
|
1023
|
+
'confirm_token_invalid',
|
|
1024
|
+
`confirm token for session ${sessionID} is ${reason}; call session.forget without a token for a new one`,
|
|
1025
|
+
{ reason },
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
confirmTokens.set(token, {
|
|
1030
|
+
session: sessionID,
|
|
1031
|
+
expiresAt: entry?.expiresAt ?? now,
|
|
1032
|
+
used: true,
|
|
1033
|
+
});
|
|
1034
|
+
};
|
|
1035
|
+
|
|
860
1036
|
// Why the session refuses a message right now, or null when it takes one.
|
|
861
1037
|
const findMessageRefusal = (sessionID: SessionID): MessageRefusal | null => {
|
|
862
1038
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -952,6 +1128,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
952
1128
|
);
|
|
953
1129
|
};
|
|
954
1130
|
|
|
1131
|
+
// Stops the headless runs a kill or forget ended, once the manager has
|
|
1132
|
+
// acted: a session it removed already lost its run with its runtime, a
|
|
1133
|
+
// session it left exited loses it here, and a sub-session it promoted, or
|
|
1134
|
+
// any session of a kill or forget that failed, keeps working.
|
|
1135
|
+
const stopEndedHeadlessRuns = (set: readonly Session[]) => {
|
|
1136
|
+
for (const s of set) {
|
|
1137
|
+
if (s.state === 'exited') {
|
|
1138
|
+
runtimes.get(s.id)?.stopHeadlessRun();
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
|
|
955
1143
|
const ctx: DaemonContext = {
|
|
956
1144
|
build: opts.build,
|
|
957
1145
|
daemonID: store.daemonID,
|
|
@@ -996,9 +1184,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
996
1184
|
|
|
997
1185
|
return target;
|
|
998
1186
|
},
|
|
1187
|
+
requireWorkspaceTarget: (target) => {
|
|
1188
|
+
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1189
|
+
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1190
|
+
},
|
|
999
1191
|
spawnSession: (plan, keyed, access) => {
|
|
1000
1192
|
if (keyed === null) {
|
|
1001
|
-
return startSpawn(plan(), mintSessionID())
|
|
1193
|
+
return startSpawn(plan(), mintSessionID());
|
|
1002
1194
|
}
|
|
1003
1195
|
|
|
1004
1196
|
const effectRef = mintSessionID();
|
|
@@ -1007,7 +1199,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1007
1199
|
operation: 'session.spawn',
|
|
1008
1200
|
keyed,
|
|
1009
1201
|
effectRef,
|
|
1010
|
-
start:
|
|
1202
|
+
start: () => startSpawn(plan(), effectRef),
|
|
1011
1203
|
settle: () => mgr.writeFleet(),
|
|
1012
1204
|
replay: (record) => {
|
|
1013
1205
|
if (access !== null) {
|
|
@@ -1044,20 +1236,52 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1044
1236
|
|
|
1045
1237
|
for (const live of [s, ...mgr.collectChildren(id)]) {
|
|
1046
1238
|
if (live.pty !== null) {
|
|
1047
|
-
mgr.requireExecution(live,
|
|
1239
|
+
mgr.requireExecution(live, mgr.pickKillCapability(live));
|
|
1048
1240
|
}
|
|
1049
1241
|
}
|
|
1050
1242
|
|
|
1051
|
-
|
|
1052
|
-
runtimes.get(child.id)?.stopHeadlessRun();
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
runtimes.get(id)?.stopHeadlessRun();
|
|
1243
|
+
const set = [s, ...mgr.collectChildren(id)];
|
|
1056
1244
|
|
|
1057
1245
|
await mgr.kill(id);
|
|
1058
1246
|
|
|
1247
|
+
stopEndedHeadlessRuns(set);
|
|
1248
|
+
|
|
1059
1249
|
return true;
|
|
1060
1250
|
},
|
|
1251
|
+
|
|
1252
|
+
// A forget on a target that cannot destroy its host forgets at once. On
|
|
1253
|
+
// one that can, a forget without a token checks the target and answers
|
|
1254
|
+
// with a token, and the forget that carries the token destroys the host.
|
|
1255
|
+
forgetSession: async (id, confirmToken) => {
|
|
1256
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
1257
|
+
|
|
1258
|
+
if (s === undefined) {
|
|
1259
|
+
return 'missing';
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (mgr.findProvider(s)?.capabilities.destroy === true) {
|
|
1263
|
+
mgr.requireExecution(s, 'destroy');
|
|
1264
|
+
|
|
1265
|
+
if (confirmToken === undefined) {
|
|
1266
|
+
const token = randomUUID();
|
|
1267
|
+
const expiresAt = Date.now() + forgetConfirmMs;
|
|
1268
|
+
|
|
1269
|
+
confirmTokens.set(token, { session: id, expiresAt, used: false });
|
|
1270
|
+
|
|
1271
|
+
return { confirmToken: token, expiresAt };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
claimConfirmToken(id, confirmToken);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const set = [s, ...mgr.collectChildren(id)];
|
|
1278
|
+
|
|
1279
|
+
const destroyed = await mgr.forget(id);
|
|
1280
|
+
|
|
1281
|
+
stopEndedHeadlessRuns(set);
|
|
1282
|
+
|
|
1283
|
+
return { forgotten: true, destroyed };
|
|
1284
|
+
},
|
|
1061
1285
|
ejectSession: (id, prompt) => {
|
|
1062
1286
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1063
1287
|
|
|
@@ -1101,7 +1325,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1101
1325
|
|
|
1102
1326
|
return 'ok';
|
|
1103
1327
|
},
|
|
1104
|
-
adoptSession: (id, cols, rows) => {
|
|
1328
|
+
adoptSession: async (id, cols, rows) => {
|
|
1105
1329
|
if (!hasResumableTranscript(mgr, id)) {
|
|
1106
1330
|
return 'no_transcript';
|
|
1107
1331
|
}
|
|
@@ -1109,18 +1333,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1109
1333
|
const runtime = runtimes.get(id);
|
|
1110
1334
|
|
|
1111
1335
|
runtime?.stopHeadlessRun();
|
|
1112
|
-
|
|
1336
|
+
|
|
1337
|
+
const adopted = await mgr.adoptTerminal(id, cols, rows);
|
|
1113
1338
|
|
|
1114
1339
|
if (adopted === null) {
|
|
1115
1340
|
return 'missing';
|
|
1116
1341
|
}
|
|
1117
1342
|
|
|
1118
|
-
if (runtime !== undefined) {
|
|
1119
|
-
runtime.resetBoot({ cols, rows });
|
|
1120
|
-
|
|
1121
|
-
runtime.screen ??= new ScreenModel(cols, rows);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
1343
|
scheduleResize(id);
|
|
1125
1344
|
|
|
1126
1345
|
return 'ok';
|
|
@@ -1233,6 +1452,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1233
1452
|
|
|
1234
1453
|
return 'ok';
|
|
1235
1454
|
},
|
|
1455
|
+
writeSessionLine: (sessionID, text) => {
|
|
1456
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
1457
|
+
|
|
1458
|
+
// A headless turn takes the line as its prompt, and a missing or dead
|
|
1459
|
+
// session refuses a line as it refuses raw input.
|
|
1460
|
+
if (s === undefined || s.kind === 'headless' || s.pty === null) {
|
|
1461
|
+
return ctx.writeSessionInput(sessionID, text);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
mgr.requireExecution(s, 'input');
|
|
1465
|
+
|
|
1466
|
+
const bracketedPaste = runtimes.get(sessionID)?.screen?.hasBracketedPaste() ?? false;
|
|
1467
|
+
const adapter = mgr.findAdapter(s.agent);
|
|
1468
|
+
const writes = adapter?.planLineInput?.(text, { bracketedPaste }) ?? planTypedLineInput(text);
|
|
1469
|
+
|
|
1470
|
+
// Every write goes out in the tick the request arrives in, so no input
|
|
1471
|
+
// overtakes the line and none lands between the text and its submit
|
|
1472
|
+
// key.
|
|
1473
|
+
for (const data of writes) {
|
|
1474
|
+
s.pty.write(data);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return 'ok';
|
|
1478
|
+
},
|
|
1236
1479
|
resizeSession: (client, sessionID, dims) => {
|
|
1237
1480
|
if (!attachments.updateDims(sessionID, client, dims)) {
|
|
1238
1481
|
return false;
|
|
@@ -1327,6 +1570,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1327
1570
|
await eventSignal.waitForNext(generation, remaining);
|
|
1328
1571
|
}
|
|
1329
1572
|
},
|
|
1573
|
+
readReport: async (id, access) => {
|
|
1574
|
+
const stored = await store.findReport(id, buildEventScope(null, access));
|
|
1575
|
+
|
|
1576
|
+
return stored === null ? null : buildReportView(stored, mgr.collectDescriptors());
|
|
1577
|
+
},
|
|
1330
1578
|
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1331
1579
|
if (keyed === null) {
|
|
1332
1580
|
const refusal = findMessageRefusal(sessionID);
|
|
@@ -1493,7 +1741,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1493
1741
|
server.stop(true);
|
|
1494
1742
|
eventsServer?.stop();
|
|
1495
1743
|
reporter.stop(true);
|
|
1496
|
-
mgr.
|
|
1744
|
+
mgr.detachAll();
|
|
1745
|
+
|
|
1746
|
+
for (const target of targets) {
|
|
1747
|
+
target.provider?.dispose();
|
|
1748
|
+
}
|
|
1497
1749
|
|
|
1498
1750
|
for (const runtime of runtimes.values()) {
|
|
1499
1751
|
runtime.dispose();
|
|
@@ -1557,6 +1809,12 @@ function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
|
|
|
1557
1809
|
return false;
|
|
1558
1810
|
}
|
|
1559
1811
|
|
|
1812
|
+
// A remote session's transcript lives in its host, out of the daemon's
|
|
1813
|
+
// reach, so the agent's session id alone makes it resumable.
|
|
1814
|
+
if (mgr.findProvider(s)?.remote === true) {
|
|
1815
|
+
return s.agentSessionID !== undefined;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1560
1818
|
return adapter.canResume({
|
|
1561
1819
|
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
1562
1820
|
...(s.transcriptSource === undefined ? {} : { transcriptSource: s.transcriptSource }),
|
|
@@ -1584,6 +1842,12 @@ async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boole
|
|
|
1584
1842
|
return true;
|
|
1585
1843
|
}
|
|
1586
1844
|
|
|
1845
|
+
interface ConfirmToken {
|
|
1846
|
+
readonly session: SessionID;
|
|
1847
|
+
readonly expiresAt: number;
|
|
1848
|
+
readonly used: boolean;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1587
1851
|
// Carries a message refusal out of a keyed send's start, so the claim drops
|
|
1588
1852
|
// and the refusal still answers the request.
|
|
1589
1853
|
class MessageRefusedError extends Error {
|