@zgeoff/atc 2.12.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 +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/index.ts +50 -1
- 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 +4 -2
- package/src/daemon/build-scoped-context.ts +4 -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 +97 -4
- package/src/daemon/daemon.ts +298 -41
- 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 +571 -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 +9 -2
- package/src/mcp/require-daemon-features.ts +3 -0
- package/src/mcp/run-tool.ts +27 -8
- package/src/protocol/daemon-features.ts +11 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +66 -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 +76 -0
- package/src/store/state-store.ts +182 -21
- 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/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
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';
|
|
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';
|
|
@@ -51,6 +55,7 @@ import { IdempotencyLedger } from './idempotency-ledger';
|
|
|
51
55
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
52
56
|
import { makeHookRunner } from './make-hook-runner';
|
|
53
57
|
import type { HookScope } from './make-hook-runner';
|
|
58
|
+
import { materializeWorkspace } from './materialize-workspace';
|
|
54
59
|
import { mintMessageID } from './mint-message-id';
|
|
55
60
|
import { mintSessionID } from './mint-session-id';
|
|
56
61
|
import { parseReport } from './parse-report';
|
|
@@ -63,6 +68,7 @@ import { SessionManager } from './sessions';
|
|
|
63
68
|
import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
64
69
|
import { startEventsServer } from './start-events-server';
|
|
65
70
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
71
|
+
import { startSessionBridge } from './start-session-bridge';
|
|
66
72
|
import { TapRegistry } from './tap-registry';
|
|
67
73
|
import type { TargetAccess } from './target-access';
|
|
68
74
|
import { writeDaemonRecord } from './write-daemon-record';
|
|
@@ -129,9 +135,16 @@ export interface DaemonOptions {
|
|
|
129
135
|
readonly restoreBootTimeoutMs?: number;
|
|
130
136
|
readonly tapGraceMs?: number;
|
|
131
137
|
|
|
138
|
+
// How long a confirm token from `session.forget` stays usable.
|
|
139
|
+
readonly forgetConfirmMs?: number;
|
|
140
|
+
|
|
132
141
|
// Called after a client-requested quit has stopped the daemon; the real
|
|
133
142
|
// entrypoint exits the process, tests leave it unset.
|
|
134
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;
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
export interface DaemonHandle {
|
|
@@ -145,6 +158,9 @@ export interface DaemonHandle {
|
|
|
145
158
|
// it is refused.
|
|
146
159
|
const TAP_GRACE_MS = 15_000;
|
|
147
160
|
|
|
161
|
+
// How long a confirm token from a forget that destroys a host stays usable.
|
|
162
|
+
const FORGET_CONFIRM_MS = 60_000;
|
|
163
|
+
|
|
148
164
|
// The principal every request on the local socket acts as.
|
|
149
165
|
const LOCAL_PRINCIPAL = 'local';
|
|
150
166
|
|
|
@@ -192,8 +208,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
192
208
|
|
|
193
209
|
const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
|
|
194
210
|
|
|
195
|
-
// Before any request is served: a
|
|
196
|
-
//
|
|
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());
|
|
197
215
|
await store.reconcileIdempotencyKeys(Date.now());
|
|
198
216
|
|
|
199
217
|
await tryRemoveExpiredIdempotencyKeys(store);
|
|
@@ -205,7 +223,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
205
223
|
idempotencySweep.unref();
|
|
206
224
|
|
|
207
225
|
const targets =
|
|
208
|
-
opts.targets ??
|
|
226
|
+
opts.targets ??
|
|
227
|
+
buildExecutionTargets([{ id: 'local', provider: 'local-pty', options: {} }]).targets;
|
|
209
228
|
|
|
210
229
|
const targetErrors = opts.targetErrors ?? [];
|
|
211
230
|
const principals = opts.principals ?? null;
|
|
@@ -228,6 +247,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
228
247
|
targetErrors,
|
|
229
248
|
);
|
|
230
249
|
|
|
250
|
+
if (opts.log !== undefined) {
|
|
251
|
+
mgr.log = opts.log;
|
|
252
|
+
}
|
|
253
|
+
|
|
231
254
|
const clients = new Set<DaemonConnection>();
|
|
232
255
|
|
|
233
256
|
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
@@ -265,14 +288,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
265
288
|
const eventSignal = new EventSignal();
|
|
266
289
|
|
|
267
290
|
// A trail write that fails never fails the message request or report behind it.
|
|
268
|
-
|
|
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
|
+
|
|
269
296
|
try {
|
|
270
|
-
await store.recordTrailEntry(entry);
|
|
297
|
+
written = await store.recordTrailEntry(entry);
|
|
271
298
|
} catch {
|
|
272
|
-
return;
|
|
299
|
+
return false;
|
|
273
300
|
}
|
|
274
301
|
|
|
275
|
-
|
|
302
|
+
if (written) {
|
|
303
|
+
eventSignal.emit();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return written;
|
|
276
307
|
};
|
|
277
308
|
|
|
278
309
|
// Notes a message status change in the trail before broadcasting it, so a
|
|
@@ -419,7 +450,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
419
450
|
} catch {}
|
|
420
451
|
};
|
|
421
452
|
|
|
422
|
-
|
|
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) => {
|
|
423
457
|
const report = parseReport(e.payload);
|
|
424
458
|
|
|
425
459
|
if (report === null) {
|
|
@@ -433,11 +467,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
433
467
|
const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
|
|
434
468
|
const reportedAt = Date.now();
|
|
435
469
|
|
|
436
|
-
|
|
437
|
-
|
|
470
|
+
const entry = buildReportTrailEntry(
|
|
471
|
+
sender.id,
|
|
472
|
+
sender.agentSessionID,
|
|
473
|
+
capped,
|
|
474
|
+
reportedAt,
|
|
475
|
+
reportID,
|
|
438
476
|
);
|
|
439
477
|
|
|
440
|
-
|
|
478
|
+
const written = await recordTrailEntry(entry);
|
|
479
|
+
|
|
480
|
+
if (written) {
|
|
481
|
+
emitEvent(
|
|
482
|
+
buildSessionReportEvent(sender.id, capped, reportedAt),
|
|
483
|
+
findHookScope(sender.id),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
441
486
|
}
|
|
442
487
|
|
|
443
488
|
return;
|
|
@@ -588,6 +633,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
588
633
|
}
|
|
589
634
|
};
|
|
590
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
|
+
|
|
591
650
|
mgr.onOutput = (s, data) => {
|
|
592
651
|
const runtime = runtimes.get(s.id);
|
|
593
652
|
|
|
@@ -713,7 +772,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
713
772
|
);
|
|
714
773
|
};
|
|
715
774
|
|
|
716
|
-
const
|
|
775
|
+
const applyHookEvent = (e: HookEvent) => {
|
|
717
776
|
if (e.event === 'Report') {
|
|
718
777
|
void applyReport(e);
|
|
719
778
|
|
|
@@ -760,15 +819,53 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
760
819
|
void store.writeLastUsedAgent(started.agent);
|
|
761
820
|
}
|
|
762
821
|
}
|
|
763
|
-
}
|
|
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;
|
|
764
864
|
|
|
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
865
|
try {
|
|
771
|
-
|
|
866
|
+
const session = await startSpawnedSession(p, id, materialized);
|
|
867
|
+
|
|
868
|
+
return warnings.length === 0 ? { session } : { session, warnings };
|
|
772
869
|
} catch (error) {
|
|
773
870
|
try {
|
|
774
871
|
await mgr.removeFailedSpawn(id);
|
|
@@ -785,8 +882,44 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
785
882
|
}
|
|
786
883
|
};
|
|
787
884
|
|
|
788
|
-
|
|
789
|
-
|
|
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(
|
|
790
923
|
p.cwd,
|
|
791
924
|
p.name,
|
|
792
925
|
p.prompt,
|
|
@@ -799,15 +932,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
799
932
|
p.overrides,
|
|
800
933
|
id,
|
|
801
934
|
p.target,
|
|
935
|
+
materialized,
|
|
802
936
|
);
|
|
803
937
|
|
|
804
938
|
const runtime = runtimes.get(s.id);
|
|
805
939
|
|
|
806
940
|
if (runtime !== undefined) {
|
|
807
941
|
runtime.pendingLastUsed = true;
|
|
808
|
-
runtime.dims = { cols: p.cols, rows: p.rows };
|
|
809
|
-
|
|
810
|
-
runtime.screen = new ScreenModel(p.cols, p.rows);
|
|
811
942
|
}
|
|
812
943
|
|
|
813
944
|
void store.recordSpawnDir(p.cwd);
|
|
@@ -857,6 +988,49 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
857
988
|
}
|
|
858
989
|
};
|
|
859
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
|
+
|
|
860
1034
|
// Why the session refuses a message right now, or null when it takes one.
|
|
861
1035
|
const findMessageRefusal = (sessionID: SessionID): MessageRefusal | null => {
|
|
862
1036
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -952,6 +1126,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
952
1126
|
);
|
|
953
1127
|
};
|
|
954
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
|
+
};
|
|
1140
|
+
|
|
955
1141
|
const ctx: DaemonContext = {
|
|
956
1142
|
build: opts.build,
|
|
957
1143
|
daemonID: store.daemonID,
|
|
@@ -996,9 +1182,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
996
1182
|
|
|
997
1183
|
return target;
|
|
998
1184
|
},
|
|
1185
|
+
requireWorkspaceTarget: (target) => {
|
|
1186
|
+
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1187
|
+
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1188
|
+
},
|
|
999
1189
|
spawnSession: (plan, keyed, access) => {
|
|
1000
1190
|
if (keyed === null) {
|
|
1001
|
-
return startSpawn(plan(), mintSessionID())
|
|
1191
|
+
return startSpawn(plan(), mintSessionID());
|
|
1002
1192
|
}
|
|
1003
1193
|
|
|
1004
1194
|
const effectRef = mintSessionID();
|
|
@@ -1007,7 +1197,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1007
1197
|
operation: 'session.spawn',
|
|
1008
1198
|
keyed,
|
|
1009
1199
|
effectRef,
|
|
1010
|
-
start:
|
|
1200
|
+
start: () => startSpawn(plan(), effectRef),
|
|
1011
1201
|
settle: () => mgr.writeFleet(),
|
|
1012
1202
|
replay: (record) => {
|
|
1013
1203
|
if (access !== null) {
|
|
@@ -1044,20 +1234,52 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1044
1234
|
|
|
1045
1235
|
for (const live of [s, ...mgr.collectChildren(id)]) {
|
|
1046
1236
|
if (live.pty !== null) {
|
|
1047
|
-
mgr.requireExecution(live,
|
|
1237
|
+
mgr.requireExecution(live, mgr.pickKillCapability(live));
|
|
1048
1238
|
}
|
|
1049
1239
|
}
|
|
1050
1240
|
|
|
1051
|
-
|
|
1052
|
-
runtimes.get(child.id)?.stopHeadlessRun();
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
runtimes.get(id)?.stopHeadlessRun();
|
|
1241
|
+
const set = [s, ...mgr.collectChildren(id)];
|
|
1056
1242
|
|
|
1057
1243
|
await mgr.kill(id);
|
|
1058
1244
|
|
|
1245
|
+
stopEndedHeadlessRuns(set);
|
|
1246
|
+
|
|
1059
1247
|
return true;
|
|
1060
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
|
+
},
|
|
1061
1283
|
ejectSession: (id, prompt) => {
|
|
1062
1284
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1063
1285
|
|
|
@@ -1101,7 +1323,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1101
1323
|
|
|
1102
1324
|
return 'ok';
|
|
1103
1325
|
},
|
|
1104
|
-
adoptSession: (id, cols, rows) => {
|
|
1326
|
+
adoptSession: async (id, cols, rows) => {
|
|
1105
1327
|
if (!hasResumableTranscript(mgr, id)) {
|
|
1106
1328
|
return 'no_transcript';
|
|
1107
1329
|
}
|
|
@@ -1109,18 +1331,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1109
1331
|
const runtime = runtimes.get(id);
|
|
1110
1332
|
|
|
1111
1333
|
runtime?.stopHeadlessRun();
|
|
1112
|
-
|
|
1334
|
+
|
|
1335
|
+
const adopted = await mgr.adoptTerminal(id, cols, rows);
|
|
1113
1336
|
|
|
1114
1337
|
if (adopted === null) {
|
|
1115
1338
|
return 'missing';
|
|
1116
1339
|
}
|
|
1117
1340
|
|
|
1118
|
-
if (runtime !== undefined) {
|
|
1119
|
-
runtime.resetBoot({ cols, rows });
|
|
1120
|
-
|
|
1121
|
-
runtime.screen ??= new ScreenModel(cols, rows);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
1341
|
scheduleResize(id);
|
|
1125
1342
|
|
|
1126
1343
|
return 'ok';
|
|
@@ -1233,6 +1450,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1233
1450
|
|
|
1234
1451
|
return 'ok';
|
|
1235
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
|
+
},
|
|
1236
1477
|
resizeSession: (client, sessionID, dims) => {
|
|
1237
1478
|
if (!attachments.updateDims(sessionID, client, dims)) {
|
|
1238
1479
|
return false;
|
|
@@ -1493,7 +1734,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1493
1734
|
server.stop(true);
|
|
1494
1735
|
eventsServer?.stop();
|
|
1495
1736
|
reporter.stop(true);
|
|
1496
|
-
mgr.
|
|
1737
|
+
mgr.detachAll();
|
|
1738
|
+
|
|
1739
|
+
for (const target of targets) {
|
|
1740
|
+
target.provider?.dispose();
|
|
1741
|
+
}
|
|
1497
1742
|
|
|
1498
1743
|
for (const runtime of runtimes.values()) {
|
|
1499
1744
|
runtime.dispose();
|
|
@@ -1557,6 +1802,12 @@ function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
|
|
|
1557
1802
|
return false;
|
|
1558
1803
|
}
|
|
1559
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
|
+
|
|
1560
1811
|
return adapter.canResume({
|
|
1561
1812
|
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
1562
1813
|
...(s.transcriptSource === undefined ? {} : { transcriptSource: s.transcriptSource }),
|
|
@@ -1584,6 +1835,12 @@ async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boole
|
|
|
1584
1835
|
return true;
|
|
1585
1836
|
}
|
|
1586
1837
|
|
|
1838
|
+
interface ConfirmToken {
|
|
1839
|
+
readonly session: SessionID;
|
|
1840
|
+
readonly expiresAt: number;
|
|
1841
|
+
readonly used: boolean;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1587
1844
|
// Carries a message refusal out of a keyed send's start, so the claim drops
|
|
1588
1845
|
// and the refusal still answers the request.
|
|
1589
1846
|
class MessageRefusedError extends Error {
|