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