@zgeoff/atc 2.11.0 → 2.13.0

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