@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.
Files changed (75) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/boot-daemon.ts +47 -21
  23. package/src/client/format-protocol-mismatch.ts +37 -0
  24. package/src/client/index.ts +104 -2
  25. package/src/client/ui.ts +8 -0
  26. package/src/daemon/build-execution-targets.ts +49 -10
  27. package/src/daemon/build-imp-provider.ts +68 -0
  28. package/src/daemon/build-report-trail-entry.ts +6 -2
  29. package/src/daemon/build-report-view.ts +41 -0
  30. package/src/daemon/build-scoped-context.ts +9 -1
  31. package/src/daemon/build-session-lifecycle.ts +52 -0
  32. package/src/daemon/build-tar-archive.ts +85 -0
  33. package/src/daemon/daemon-connection.ts +134 -4
  34. package/src/daemon/daemon.ts +306 -42
  35. package/src/daemon/execution-provider.ts +109 -4
  36. package/src/daemon/hooks.ts +5 -17
  37. package/src/daemon/imp-client-port.ts +343 -0
  38. package/src/daemon/imp-harness.ts +618 -0
  39. package/src/daemon/imp-port-error.ts +18 -0
  40. package/src/daemon/imp-port.ts +246 -0
  41. package/src/daemon/imp-provider.ts +601 -0
  42. package/src/daemon/is-binding-current.ts +43 -0
  43. package/src/daemon/local-pty-provider.ts +62 -3
  44. package/src/daemon/materialize-workspace.ts +574 -0
  45. package/src/daemon/parse-hook-line.ts +31 -0
  46. package/src/daemon/pick-session-state.ts +15 -0
  47. package/src/daemon/restore-fleet.ts +41 -23
  48. package/src/daemon/screen-model.ts +7 -0
  49. package/src/daemon/sessions.ts +574 -61
  50. package/src/daemon/start-headless-turn.ts +1 -1
  51. package/src/daemon/start-session-bridge.ts +294 -0
  52. package/src/mcp/mcp-tools.ts +41 -3
  53. package/src/mcp/reconnecting-caller.ts +1 -0
  54. package/src/mcp/require-daemon-features.ts +4 -0
  55. package/src/mcp/run-tool.ts +32 -8
  56. package/src/protocol/daemon-features.ts +14 -0
  57. package/src/protocol/protocol.ts +23 -0
  58. package/src/protocol/request-param-schemas.ts +72 -0
  59. package/src/report.ts +37 -2
  60. package/src/run-bridge-tap.ts +241 -0
  61. package/src/shared/collect-clean-env.ts +9 -1
  62. package/src/shared/open-bridge-socket.ts +86 -0
  63. package/src/shared/send-bridge-request.ts +47 -0
  64. package/src/statusline.ts +34 -3
  65. package/src/store/fleet-entry.ts +19 -0
  66. package/src/store/run-migrations.ts +85 -0
  67. package/src/store/state-store.ts +226 -21
  68. package/src/store/trail-entry.ts +7 -0
  69. package/src/store/workspace-materialization.ts +70 -0
  70. package/src/tap.ts +11 -0
  71. package/src/workspace/check-url-credentials.ts +59 -0
  72. package/src/workspace/create-workspace-clone.ts +13 -1
  73. package/src/workspace/repository-env-vars.ts +22 -0
  74. package/src/workspace/run-git.ts +2 -18
  75. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -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 key left in progress by a daemon that
196
- // stopped mid-effect is settled first, so no retry can race it.
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 ?? buildExecutionTargets([{ id: 'local', provider: 'local-pty', options: {} }]);
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
- const recordTrailEntry = async (entry: TrailEntry) => {
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
- eventSignal.emit();
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
- const applyReport = async (e: HookEvent) => {
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
- await recordTrailEntry(
437
- buildReportTrailEntry(sender.id, sender.agentSessionID, capped, reportedAt),
471
+ const entry = buildReportTrailEntry(
472
+ sender.id,
473
+ sender.agentSessionID,
474
+ capped,
475
+ reportedAt,
476
+ reportID,
438
477
  );
439
478
 
440
- emitEvent(buildSessionReportEvent(sender.id, capped, reportedAt), findHookScope(sender.id));
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 reporter = startHookServer((e) => {
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
- }, opts.reporterSocketPath);
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
- return startSpawnedSession(p, id);
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
- const startSpawnedSession = (p: SpawnParams, id: SessionID): SessionDescriptor => {
789
- const s = mgr.spawn(
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()).then((session) => ({ session }));
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: async () => ({ session: await startSpawn(plan(), effectRef) }),
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, 'kill');
1239
+ mgr.requireExecution(live, mgr.pickKillCapability(live));
1048
1240
  }
1049
1241
  }
1050
1242
 
1051
- for (const child of mgr.collectChildren(id)) {
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
- const adopted = mgr.adoptTerminal(id, cols, rows);
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.killAll();
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 {