@zgeoff/atc 2.11.0 → 2.12.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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. package/src/workspace/workspace-source.ts +8 -0
@@ -2,13 +2,18 @@ import { unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
5
+ import { DaemonError } from '../protocol/daemon-error';
5
6
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
6
7
  import type { EventMsg } from '../protocol/protocol';
7
8
  import type { HooksConfig } from '../shared/collect-hooks';
9
+ import type { TargetConfigError } from '../shared/collect-targets';
8
10
  import { findDaemonRecord } from '../shared/find-daemon-record';
9
11
  import type { MessageID } from '../shared/message-id';
12
+ import { isRecord } from '../shared/report';
10
13
  import type { SessionID } from '../shared/session-id';
14
+ import { toMessageID } from '../shared/to-message-id';
11
15
  import { truncateToBytes } from '../shared/truncate-to-bytes';
16
+ import type { IdempotencyRecord } from '../store/idempotency-record';
12
17
  import type { MessageOwner } from '../store/message-owner';
13
18
  import type { MessageRecord, MessageStatus } from '../store/message-record';
14
19
  import { StateStore } from '../store/state-store';
@@ -17,22 +22,37 @@ import type { TrailEntry } from '../store/trail-entry';
17
22
  import { ANSWER_BYTE_CAP } from './answer-byte-cap';
18
23
  import { AttachRegistry } from './attach-registry';
19
24
  import { buildAgentList } from './build-agent-list';
25
+ import { buildConfigRevision } from './build-config-revision';
26
+ import { buildExecutionTargets } from './build-execution-targets';
27
+ import type { ExecutionTarget } from './build-execution-targets';
20
28
  import { buildFleetEvents } from './build-fleet-events';
21
29
  import { buildMessageTrailEntry } from './build-message-trail-entry';
22
30
  import { buildReportTrailEntry } from './build-report-trail-entry';
23
31
  import { buildSessionEvent } from './build-session-event';
24
32
  import { buildSessionMessageEvent } from './build-session-message-event';
25
33
  import { buildSessionReportEvent } from './build-session-report-event';
34
+ import { buildTargetAccess } from './build-target-access';
35
+ import { buildTargetForbiddenError } from './build-target-forbidden-error';
36
+ import { buildTargetList } from './build-target-list';
26
37
  import { claimDaemonLock } from './claim-daemon-lock';
27
38
  import { DaemonConnection } from './daemon-connection';
28
- import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
39
+ import type {
40
+ DaemonContext,
41
+ MessageRefusal,
42
+ OutputClient,
43
+ SpawnParams,
44
+ TapClient,
45
+ } from './daemon-connection';
46
+ import { EffectRemainsError } from './effect-remains-error';
29
47
  import { EventSignal } from './event-signal';
30
48
  import { startHookServer } from './hooks';
31
49
  import type { HookEvent } from './hooks';
50
+ import { IdempotencyLedger } from './idempotency-ledger';
32
51
  import { loadTranscriptPage } from './load-transcript-page';
33
52
  import { makeHookRunner } from './make-hook-runner';
34
53
  import type { HookScope } from './make-hook-runner';
35
54
  import { mintMessageID } from './mint-message-id';
55
+ import { mintSessionID } from './mint-session-id';
36
56
  import { parseReport } from './parse-report';
37
57
  import { PermissionRegistry } from './permission-registry';
38
58
  import { restoreFleet } from './restore-fleet';
@@ -44,6 +64,7 @@ import type { Session, SessionDescriptor, SessionState } from './sessions';
44
64
  import { startEventsServer } from './start-events-server';
45
65
  import { startHeadlessTurn } from './start-headless-turn';
46
66
  import { TapRegistry } from './tap-registry';
67
+ import type { TargetAccess } from './target-access';
47
68
  import { writeDaemonRecord } from './write-daemon-record';
48
69
 
49
70
  export interface DaemonOptions {
@@ -59,6 +80,21 @@ export interface DaemonOptions {
59
80
  // grok adapter is unsupported, not a Claude spawn.
60
81
  readonly adapters?: readonly AgentAdapter[];
61
82
 
83
+ // Where sessions run, in config order; one `local` target on the local
84
+ // pseudo-terminal provider when unset. A spawn without a target runs on
85
+ // the default one: `local` when unset and the targets hold it, and none
86
+ // otherwise, which refuses such a spawn.
87
+ readonly targets?: readonly ExecutionTarget[];
88
+ readonly defaultTarget?: string | null;
89
+
90
+ // The target config problems the daemon started with. A target they cover
91
+ // refuses every session, and `agents.list` returns them.
92
+ readonly targetErrors?: readonly TargetConfigError[];
93
+
94
+ // The targets each principal may use; unset or null when the config has
95
+ // no principals, which leaves every principal the implicit local target.
96
+ readonly principals?: ReadonlyMap<string, readonly string[]> | null;
97
+
62
98
  // SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
63
99
  // fleet table once so upgrading keeps the restorable fleet.
64
100
  readonly dbPath: string;
@@ -109,6 +145,14 @@ export interface DaemonHandle {
109
145
  // it is refused.
110
146
  const TAP_GRACE_MS = 15_000;
111
147
 
148
+ // The principal every request on the local socket acts as.
149
+ const LOCAL_PRINCIPAL = 'local';
150
+
151
+ // A completed idempotency key is kept this long after it completed, and the
152
+ // sweep that drops older ones runs this often.
153
+ const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
154
+ const IDEMPOTENCY_SWEEP_MS = 60 * 60 * 1000;
155
+
112
156
  // How long startup waits for a daemon that is shutting down to release the
113
157
  // state lock before refusing to start.
114
158
  const LOCK_WAIT_MS = 2000;
@@ -148,7 +192,42 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
148
192
 
149
193
  const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
150
194
 
151
- const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? []);
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.
197
+ await store.reconcileIdempotencyKeys(Date.now());
198
+
199
+ await tryRemoveExpiredIdempotencyKeys(store);
200
+
201
+ const idempotencySweep = setInterval(() => {
202
+ void tryRemoveExpiredIdempotencyKeys(store);
203
+ }, IDEMPOTENCY_SWEEP_MS);
204
+
205
+ idempotencySweep.unref();
206
+
207
+ const targets =
208
+ opts.targets ?? buildExecutionTargets([{ id: 'local', provider: 'local-pty', options: {} }]);
209
+
210
+ const targetErrors = opts.targetErrors ?? [];
211
+ const principals = opts.principals ?? null;
212
+
213
+ const targetsByID = new Map(targets.map((target) => [target.id, target]));
214
+
215
+ const defaultTarget =
216
+ opts.defaultTarget === undefined
217
+ ? (targets.find((target) => target.id === 'local')?.id ?? null)
218
+ : opts.defaultTarget;
219
+
220
+ const configRevision = buildConfigRevision(targets, defaultTarget, targetErrors);
221
+
222
+ const mgr = new SessionManager(
223
+ opts.adapter,
224
+ store,
225
+ opts.statusPath,
226
+ opts.adapters ?? [],
227
+ targets,
228
+ targetErrors,
229
+ );
230
+
152
231
  const clients = new Set<DaemonConnection>();
153
232
 
154
233
  const runHooks = makeHookRunner(opts.hooks ?? {});
@@ -207,11 +286,37 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
207
286
  };
208
287
 
209
288
  // 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
- });
289
+ // atc id, through the agent session id a restore carries on. Under an
290
+ // access, the scope holds only the sessions on targets the access holds,
291
+ // so a session outside it matches nothing, as a session never seen does.
292
+ const buildEventScope = (
293
+ sessionID: SessionID | null,
294
+ access: TargetAccess | null,
295
+ ): EventScope | null => {
296
+ if (access === null) {
297
+ if (sessionID === null) {
298
+ return null;
299
+ }
300
+
301
+ const agentSessionID = mgr.sessions.find((x) => x.id === sessionID)?.agentSessionID;
302
+
303
+ return {
304
+ atcIDs: [sessionID],
305
+ agentSessionIDs: agentSessionID === undefined ? [] : [agentSessionID],
306
+ };
307
+ }
308
+
309
+ const visible = mgr.sessions.filter(
310
+ (x) => access.canUse(x) && (sessionID === null || x.id === sessionID),
311
+ );
312
+
313
+ return {
314
+ atcIDs: visible.map((x) => x.id),
315
+ agentSessionIDs: visible.flatMap((x) =>
316
+ x.agentSessionID === undefined ? [] : [x.agentSessionID],
317
+ ),
318
+ };
319
+ };
215
320
 
216
321
  // A message belongs to the live session holding its atc id or its agent
217
322
  // session id, else to the atc id it was sent to.
@@ -421,7 +526,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
421
526
 
422
527
  const s = mgr.sessions.find((x) => x.id === sessionID);
423
528
 
424
- s?.pty?.resize(dims.cols, dims.rows);
529
+ // A host that cannot resize keeps its terminal at the size it started
530
+ // with; the screen model still follows the clients.
531
+ if (s !== undefined && (mgr.findProvider(s)?.capabilities.resize ?? false)) {
532
+ s.pty?.resize(dims.cols, dims.rows);
533
+ }
534
+
425
535
  runtime?.screen?.updateDims(dims.cols, dims.rows);
426
536
 
427
537
  if (runtime !== undefined) {
@@ -652,8 +762,199 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
652
762
  }
653
763
  }, opts.reporterSocketPath);
654
764
 
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
+ try {
771
+ return startSpawnedSession(p, id);
772
+ } catch (error) {
773
+ try {
774
+ await mgr.removeFailedSpawn(id);
775
+ } catch (cleanupError) {
776
+ throw new EffectRemainsError(
777
+ `spawn of session ${id} failed and taking it back failed too`,
778
+ {
779
+ cause: cleanupError,
780
+ },
781
+ );
782
+ }
783
+
784
+ throw error;
785
+ }
786
+ };
787
+
788
+ const startSpawnedSession = (p: SpawnParams, id: SessionID): SessionDescriptor => {
789
+ const s = mgr.spawn(
790
+ p.cwd,
791
+ p.name,
792
+ p.prompt,
793
+ p.cols,
794
+ p.rows,
795
+ p.resume,
796
+ p.namedBy,
797
+ p.agent,
798
+ p.parent,
799
+ p.overrides,
800
+ id,
801
+ p.target,
802
+ );
803
+
804
+ const runtime = runtimes.get(s.id);
805
+
806
+ if (runtime !== undefined) {
807
+ runtime.pendingLastUsed = true;
808
+ runtime.dims = { cols: p.cols, rows: p.rows };
809
+
810
+ runtime.screen = new ScreenModel(p.cols, p.rows);
811
+ }
812
+
813
+ void store.recordSpawnDir(p.cwd);
814
+
815
+ return getDescriptor(mgr, s.id);
816
+ };
817
+
818
+ // A retried spawn answers with the session as it stands now when it is
819
+ // listed, else with the descriptor the first spawn answered. A key that
820
+ // start-up reconciliation completed has no stored answer, and its session
821
+ // lists only once the fleet is restored.
822
+ const loadSpawnReplay = (record: IdempotencyRecord): Readonly<Record<string, unknown>> => {
823
+ const listed = mgr.collectDescriptors().find((d) => d.id === record.effectRef);
824
+
825
+ if (listed !== undefined) {
826
+ return { session: listed };
827
+ }
828
+
829
+ const stored: unknown = record.result === null ? null : JSON.parse(record.result);
830
+
831
+ if (isRecord(stored)) {
832
+ return stored;
833
+ }
834
+
835
+ throw new DaemonError(
836
+ 'no_such_session',
837
+ `the spawn under idempotency key '${record.key}' created session '${record.effectRef}', which is not listed; restore the fleet to list it`,
838
+ { effectRef: record.effectRef },
839
+ );
840
+ };
841
+
842
+ const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL);
843
+
844
+ // Throws the refusal a fresh spawn to the target gets when the access
845
+ // does not reach the target, and the identity, a held spawn key recorded
846
+ // for its session. A key that records none is refused: nothing it holds
847
+ // shows where its session ran.
848
+ const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
849
+ const bound = record.effectTarget;
850
+
851
+ if (bound === null) {
852
+ throw buildTargetForbiddenError(findReplayTarget(record));
853
+ }
854
+
855
+ if (!access.canUse(bound)) {
856
+ throw buildTargetForbiddenError(bound.target);
857
+ }
858
+ };
859
+
860
+ // Why the session refuses a message right now, or null when it takes one.
861
+ const findMessageRefusal = (sessionID: SessionID): MessageRefusal | null => {
862
+ const s = mgr.sessions.find((x) => x.id === sessionID);
863
+
864
+ if (s === undefined) {
865
+ return 'missing';
866
+ }
867
+
868
+ if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
869
+ return 'dead';
870
+ }
871
+
872
+ if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
873
+ return 'unsupported';
874
+ }
875
+
876
+ const runtime = runtimes.get(sessionID);
877
+
878
+ if (
879
+ runtime !== undefined &&
880
+ runtime.startedAt !== null &&
881
+ !runtime.tapAttached &&
882
+ Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
883
+ ) {
884
+ return 'no_tap';
885
+ }
886
+
887
+ return null;
888
+ };
889
+
890
+ // Writes the message under the id given, notes it in the trail, and hands
891
+ // it to the session's tap when one is attached. Only the write itself can
892
+ // reject: every later step swallows its own failure, so a rejection means
893
+ // no message was written.
894
+ const writeAcceptedMessage = async (
895
+ sessionID: SessionID,
896
+ from: string,
897
+ text: string,
898
+ id: MessageID,
899
+ ): Promise<MessageRecord> => {
900
+ const s = mgr.sessions.find((x) => x.id === sessionID);
901
+ const previousWrite = lastMessageWrite;
902
+ const written = Promise.withResolvers<void>();
903
+
904
+ lastMessageWrite = written.promise;
905
+
906
+ await previousWrite;
907
+
908
+ const record: MessageRecord = {
909
+ id,
910
+ atcID: sessionID,
911
+ ...(s?.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
912
+ from,
913
+ text,
914
+ status: 'accepted',
915
+ sentAt: Date.now(),
916
+ };
917
+
918
+ try {
919
+ await store.writeMessage(record);
920
+
921
+ await recordMessageStatus(sessionID, record);
922
+ } finally {
923
+ written.resolve();
924
+ }
925
+
926
+ await drainInbox(sessionID);
927
+
928
+ return record;
929
+ };
930
+
931
+ // A retried send answers with the message's current status, so a caller
932
+ // that lost the first answer learns where its message stands now.
933
+ const loadMessageReplay = async (
934
+ record: IdempotencyRecord,
935
+ ): Promise<Readonly<Record<string, unknown>>> => {
936
+ const current = await store.findMessageByID(toMessageID(record.effectRef));
937
+
938
+ if (current !== null) {
939
+ return { message: current.id, status: current.status };
940
+ }
941
+
942
+ const stored: unknown = record.result === null ? null : JSON.parse(record.result);
943
+
944
+ if (isRecord(stored)) {
945
+ return stored;
946
+ }
947
+
948
+ throw new DaemonError(
949
+ 'internal',
950
+ `the message under idempotency key '${record.key}' has no stored row`,
951
+ { effectRef: record.effectRef },
952
+ );
953
+ };
954
+
655
955
  const ctx: DaemonContext = {
656
956
  build: opts.build,
957
+ daemonID: store.daemonID,
657
958
  collectSessions: () => mgr.collectDescriptors(),
658
959
  collectSpawnDirs: () => store.collectSpawnDirs(),
659
960
  collectAgents: () => ({
@@ -664,35 +965,63 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
664
965
  build: opts.build,
665
966
  },
666
967
  agents: buildAgentList(mgr.collectAdapters(), (bin) => Bun.which(bin) !== null),
968
+ targets: buildTargetList(targets, defaultTarget),
969
+ spawnDefaults: { agent: 'claude', target: defaultTarget },
970
+ configRevision,
971
+ targetErrors,
667
972
  }),
668
973
  collectFleet: () => store.loadFleet(),
669
974
  loadLastUsedAgent: () => store.loadLastUsedAgent(),
670
975
  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);
976
+ buildTargetAccess: (principal) => buildTargetAccess(principals, targetsByID, principal),
977
+ findSessionGrant: (id) => {
978
+ const s = mgr.sessions.find((x) => x.id === id);
685
979
 
686
- if (runtime !== undefined) {
687
- runtime.pendingLastUsed = true;
688
- runtime.dims = { cols: p.cols, rows: p.rows };
980
+ return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
981
+ },
982
+ findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
983
+ collectChildIDs: (id) => mgr.collectChildren(id).map((child) => child.id),
984
+ findPermissionSession: (request) => registry.findSessionID(request),
985
+ resolveSpawnTarget: (requested) => {
986
+ const target = requested ?? defaultTarget;
987
+ const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
988
+
989
+ if (refusal !== null) {
990
+ throw refusal;
991
+ }
689
992
 
690
- runtime.screen = new ScreenModel(p.cols, p.rows);
993
+ if (target === null) {
994
+ throw new Error('a spawn without a target passed its checks');
691
995
  }
692
996
 
693
- void store.recordSpawnDir(p.cwd);
997
+ return target;
998
+ },
999
+ spawnSession: (plan, keyed, access) => {
1000
+ if (keyed === null) {
1001
+ return startSpawn(plan(), mintSessionID()).then((session) => ({ session }));
1002
+ }
694
1003
 
695
- return getDescriptor(mgr, s.id);
1004
+ const effectRef = mintSessionID();
1005
+
1006
+ return ledger.run({
1007
+ operation: 'session.spawn',
1008
+ keyed,
1009
+ effectRef,
1010
+ start: async () => ({ session: await startSpawn(plan(), effectRef) }),
1011
+ settle: () => mgr.writeFleet(),
1012
+ replay: (record) => {
1013
+ if (access !== null) {
1014
+ requireReplayInReach(record, access);
1015
+ }
1016
+
1017
+ return loadSpawnReplay(record);
1018
+ },
1019
+ findEffectTarget: () => {
1020
+ const s = mgr.sessions.find((x) => x.id === effectRef);
1021
+
1022
+ return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
1023
+ },
1024
+ });
696
1025
  },
697
1026
  updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
698
1027
  quitDaemon: () => {
@@ -707,10 +1036,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
707
1036
  }, 80);
708
1037
  },
709
1038
  killSession: async (id) => {
710
- if (!mgr.sessions.some((s) => s.id === id)) {
1039
+ const s = mgr.sessions.find((x) => x.id === id);
1040
+
1041
+ if (s === undefined) {
711
1042
  return false;
712
1043
  }
713
1044
 
1045
+ for (const live of [s, ...mgr.collectChildren(id)]) {
1046
+ if (live.pty !== null) {
1047
+ mgr.requireExecution(live, 'kill');
1048
+ }
1049
+ }
1050
+
714
1051
  for (const child of mgr.collectChildren(id)) {
715
1052
  runtimes.get(child.id)?.stopHeadlessRun();
716
1053
  }
@@ -738,6 +1075,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
738
1075
  return 'no_transcript';
739
1076
  }
740
1077
 
1078
+ mgr.requireExecution(s, 'kill');
1079
+ mgr.requireExecution(s, 'headless');
1080
+
741
1081
  const yanked = mgr.yankHeadless(id);
742
1082
 
743
1083
  if (yanked === null) {
@@ -776,9 +1116,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
776
1116
  }
777
1117
 
778
1118
  if (runtime !== undefined) {
779
- runtime.dims = { cols, rows };
780
- runtime.startedAt = null;
781
- runtime.tapAttached = false;
1119
+ runtime.resetBoot({ cols, rows });
782
1120
 
783
1121
  runtime.screen ??= new ScreenModel(cols, rows);
784
1122
  }
@@ -821,6 +1159,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
821
1159
  return 'dead';
822
1160
  }
823
1161
 
1162
+ mgr.requireExecution(s, 'attach');
824
1163
  attachments.attach(sessionID, client, dims);
825
1164
  mgr.attach(sessionID);
826
1165
 
@@ -861,7 +1200,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
861
1200
  return 'missing';
862
1201
  }
863
1202
 
1203
+ // A headless turn runs only through the session's own target, never
1204
+ // anywhere else, and a dead session takes no input.
864
1205
  if (s.kind === 'headless') {
1206
+ mgr.requireExecution(s, 'headless');
1207
+
1208
+ if (s.state === 'exited') {
1209
+ return 'dead';
1210
+ }
1211
+
865
1212
  if ((runtimes.get(sessionID)?.headlessRun ?? null) !== null) {
866
1213
  return 'busy';
867
1214
  }
@@ -881,6 +1228,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
881
1228
  return 'dead';
882
1229
  }
883
1230
 
1231
+ mgr.requireExecution(s, 'input');
884
1232
  s.pty.write(data);
885
1233
 
886
1234
  return 'ok';
@@ -950,9 +1298,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
950
1298
 
951
1299
  return { path, page };
952
1300
  },
953
- readEvents: async (afterID, limit, waitMs, sessionID) => {
1301
+ readEvents: async (afterID, limit, waitMs, sessionID, access) => {
954
1302
  const deadline = Date.now() + waitMs;
955
- const scope = sessionID === null ? null : buildEventScope(sessionID);
1303
+ const scope = buildEventScope(sessionID, access);
956
1304
 
957
1305
  // A wake for an event the read leaves out (a heartbeat, or another
958
1306
  // session's event under a session filter) loops back to wait out the
@@ -979,60 +1327,51 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
979
1327
  await eventSignal.waitForNext(generation, remaining);
980
1328
  }
981
1329
  },
982
- writeSessionMessage: async (sessionID, from, text) => {
983
- const s = mgr.sessions.find((x) => x.id === sessionID);
1330
+ writeSessionMessage: async (sessionID, from, text, keyed) => {
1331
+ if (keyed === null) {
1332
+ const refusal = findMessageRefusal(sessionID);
984
1333
 
985
- if (s === undefined) {
986
- return 'missing';
987
- }
1334
+ if (refusal !== null) {
1335
+ return refusal;
1336
+ }
988
1337
 
989
- if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
990
- return 'dead';
991
- }
1338
+ const record = await writeAcceptedMessage(sessionID, from, text, mintMessageID());
992
1339
 
993
- if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
994
- return 'unsupported';
1340
+ return { message: record.id, status: record.status };
995
1341
  }
996
1342
 
997
- const runtime = runtimes.get(sessionID);
1343
+ const effectRef = mintMessageID();
998
1344
 
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
- }
1345
+ try {
1346
+ return await ledger.run({
1347
+ operation: 'session.message',
1348
+ keyed,
1349
+ effectRef,
1007
1350
 
1008
- const previousWrite = lastMessageWrite;
1009
- const written = Promise.withResolvers<void>();
1351
+ // A refusal throws, so the claim drops and a retry runs fresh.
1352
+ start: async () => {
1353
+ const refusal = findMessageRefusal(sessionID);
1010
1354
 
1011
- lastMessageWrite = written.promise;
1355
+ if (refusal !== null) {
1356
+ throw new MessageRefusedError(refusal);
1357
+ }
1012
1358
 
1013
- await previousWrite;
1359
+ const record = await writeAcceptedMessage(sessionID, from, text, effectRef);
1014
1360
 
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
- };
1361
+ return { message: record.id, status: record.status };
1362
+ },
1024
1363
 
1025
- try {
1026
- await store.writeMessage(record);
1364
+ // The message row is the effect, and the start already wrote it.
1365
+ settle: () => Promise.resolve(),
1366
+ replay: (record) => loadMessageReplay(record),
1367
+ });
1368
+ } catch (error) {
1369
+ if (error instanceof MessageRefusedError) {
1370
+ return error.refusal;
1371
+ }
1027
1372
 
1028
- await recordMessageStatus(sessionID, record);
1029
- } finally {
1030
- written.resolve();
1373
+ throw error;
1031
1374
  }
1032
-
1033
- await drainInbox(sessionID);
1034
-
1035
- return record;
1036
1375
  },
1037
1376
  attachTap: (client, sessionID) => {
1038
1377
  const s = mgr.sessions.find((x) => x.id === sessionID);
@@ -1149,6 +1488,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1149
1488
  client.dispose();
1150
1489
  }
1151
1490
 
1491
+ clearInterval(idempotencySweep);
1492
+
1152
1493
  server.stop(true);
1153
1494
  eventsServer?.stop();
1154
1495
  reporter.stop(true);
@@ -1231,3 +1572,39 @@ function getDescriptor(mgr: SessionManager, id: SessionID): SessionDescriptor {
1231
1572
 
1232
1573
  return d;
1233
1574
  }
1575
+
1576
+ // A sweep that fails leaves the keys for the next one.
1577
+ async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boolean> {
1578
+ try {
1579
+ await store.removeExpiredIdempotencyKeys(Date.now() - IDEMPOTENCY_TTL_MS);
1580
+ } catch {
1581
+ return false;
1582
+ }
1583
+
1584
+ return true;
1585
+ }
1586
+
1587
+ // Carries a message refusal out of a keyed send's start, so the claim drops
1588
+ // and the refusal still answers the request.
1589
+ class MessageRefusedError extends Error {
1590
+ readonly refusal: MessageRefusal;
1591
+
1592
+ constructor(refusal: MessageRefusal) {
1593
+ super(`message refused: ${refusal}`);
1594
+
1595
+ this.refusal = refusal;
1596
+ this.name = 'MessageRefusedError';
1597
+ }
1598
+ }
1599
+
1600
+ // The target a held spawn key's stored answer holds its session on, for a
1601
+ // refusal of its replay; the answer holds no other target to name.
1602
+ function findReplayTarget(record: IdempotencyRecord): string {
1603
+ const stored: unknown = record.result === null ? null : JSON.parse(record.result);
1604
+ const session = isRecord(stored) ? stored['session'] : null;
1605
+ const locator = isRecord(session) ? session['locator'] : null;
1606
+
1607
+ return isRecord(locator) && typeof locator['targetID'] === 'string'
1608
+ ? locator['targetID']
1609
+ : 'unknown';
1610
+ }