@zgeoff/atc 2.10.4 → 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 (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -1,34 +1,58 @@
1
1
  import { unlinkSync, writeFileSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
2
3
  import { dirname, join } from 'node:path';
3
4
  import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
5
+ import { DaemonError } from '../protocol/daemon-error';
4
6
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
5
7
  import type { EventMsg } from '../protocol/protocol';
6
8
  import type { HooksConfig } from '../shared/collect-hooks';
9
+ import type { TargetConfigError } from '../shared/collect-targets';
7
10
  import { findDaemonRecord } from '../shared/find-daemon-record';
11
+ import type { MessageID } from '../shared/message-id';
12
+ import { isRecord } from '../shared/report';
8
13
  import type { SessionID } from '../shared/session-id';
14
+ import { toMessageID } from '../shared/to-message-id';
9
15
  import { truncateToBytes } from '../shared/truncate-to-bytes';
16
+ import type { IdempotencyRecord } from '../store/idempotency-record';
10
17
  import type { MessageOwner } from '../store/message-owner';
11
- import type { MessageRecord } from '../store/message-record';
18
+ import type { MessageRecord, MessageStatus } from '../store/message-record';
12
19
  import { StateStore } from '../store/state-store';
20
+ import type { EventScope } from '../store/state-store';
13
21
  import type { TrailEntry } from '../store/trail-entry';
14
22
  import { ANSWER_BYTE_CAP } from './answer-byte-cap';
15
23
  import { AttachRegistry } from './attach-registry';
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';
16
28
  import { buildFleetEvents } from './build-fleet-events';
17
29
  import { buildMessageTrailEntry } from './build-message-trail-entry';
18
30
  import { buildReportTrailEntry } from './build-report-trail-entry';
19
31
  import { buildSessionEvent } from './build-session-event';
20
32
  import { buildSessionMessageEvent } from './build-session-message-event';
21
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';
22
37
  import { claimDaemonLock } from './claim-daemon-lock';
23
38
  import { DaemonConnection } from './daemon-connection';
24
- 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';
25
47
  import { EventSignal } from './event-signal';
26
48
  import { startHookServer } from './hooks';
27
49
  import type { HookEvent } from './hooks';
50
+ import { IdempotencyLedger } from './idempotency-ledger';
28
51
  import { loadTranscriptPage } from './load-transcript-page';
29
52
  import { makeHookRunner } from './make-hook-runner';
30
53
  import type { HookScope } from './make-hook-runner';
31
54
  import { mintMessageID } from './mint-message-id';
55
+ import { mintSessionID } from './mint-session-id';
32
56
  import { parseReport } from './parse-report';
33
57
  import { PermissionRegistry } from './permission-registry';
34
58
  import { restoreFleet } from './restore-fleet';
@@ -40,6 +64,7 @@ import type { Session, SessionDescriptor, SessionState } from './sessions';
40
64
  import { startEventsServer } from './start-events-server';
41
65
  import { startHeadlessTurn } from './start-headless-turn';
42
66
  import { TapRegistry } from './tap-registry';
67
+ import type { TargetAccess } from './target-access';
43
68
  import { writeDaemonRecord } from './write-daemon-record';
44
69
 
45
70
  export interface DaemonOptions {
@@ -55,6 +80,21 @@ export interface DaemonOptions {
55
80
  // grok adapter is unsupported, not a Claude spawn.
56
81
  readonly adapters?: readonly AgentAdapter[];
57
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
+
58
98
  // SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
59
99
  // fleet table once so upgrading keeps the restorable fleet.
60
100
  readonly dbPath: string;
@@ -105,6 +145,14 @@ export interface DaemonHandle {
105
145
  // it is refused.
106
146
  const TAP_GRACE_MS = 15_000;
107
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
+
108
156
  // How long startup waits for a daemon that is shutting down to release the
109
157
  // state lock before refusing to start.
110
158
  const LOCK_WAIT_MS = 2000;
@@ -144,7 +192,42 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
144
192
 
145
193
  const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
146
194
 
147
- 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
+
148
231
  const clients = new Set<DaemonConnection>();
149
232
 
150
233
  const runHooks = makeHookRunner(opts.hooks ?? {});
@@ -202,6 +285,61 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
202
285
  emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
203
286
  };
204
287
 
288
+ // A live session's scope also matches the rows it wrote under an earlier
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
+ };
320
+
321
+ // A message belongs to the live session holding its atc id or its agent
322
+ // session id, else to the atc id it was sent to.
323
+ const readMessageView = async (messageID: MessageID) => {
324
+ const record = await store.findMessageByID(messageID);
325
+
326
+ if (record === null) {
327
+ return null;
328
+ }
329
+
330
+ const owner = mgr.sessions.find(
331
+ (x) =>
332
+ x.id === record.atcID ||
333
+ (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
334
+ );
335
+
336
+ return {
337
+ session: owner?.id ?? record.atcID,
338
+ record,
339
+ answeredWith: await store.collectTurnSiblings(record),
340
+ };
341
+ };
342
+
205
343
  registry.onRequested = (req) => {
206
344
  emitEvent(
207
345
  {
@@ -309,15 +447,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
309
447
  const owner = s === undefined ? { atcID: e.atcId } : buildMessageOwner(s);
310
448
 
311
449
  try {
312
- const answered = await store.updateMessageAnswered(
313
- report.message,
450
+ // One statement answers the whole turn, so a reader that sees any
451
+ // member answered already finds every sibling answered beside it.
452
+ const answered = await store.updateMessagesAnswered(
453
+ report.messages,
314
454
  owner,
315
455
  truncateToBytes(report.answer, ANSWER_BYTE_CAP),
316
456
  Date.now(),
457
+ report.turn,
317
458
  );
318
459
 
319
- if (answered !== null) {
320
- await recordMessageStatus(e.atcId, answered);
460
+ for (const record of answered) {
461
+ await recordMessageStatus(e.atcId, record);
321
462
  }
322
463
  } catch {}
323
464
  };
@@ -385,7 +526,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
385
526
 
386
527
  const s = mgr.sessions.find((x) => x.id === sessionID);
387
528
 
388
- 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
+
389
535
  runtime?.screen?.updateDims(dims.cols, dims.rows);
390
536
 
391
537
  if (runtime !== undefined) {
@@ -616,38 +762,266 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
616
762
  }
617
763
  }, opts.reporterSocketPath);
618
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
+
619
955
  const ctx: DaemonContext = {
620
956
  build: opts.build,
957
+ daemonID: store.daemonID,
621
958
  collectSessions: () => mgr.collectDescriptors(),
622
959
  collectSpawnDirs: () => store.collectSpawnDirs(),
960
+ collectAgents: () => ({
961
+ daemon: {
962
+ hostname: hostname(),
963
+ platform: process.platform,
964
+ arch: process.arch,
965
+ build: opts.build,
966
+ },
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,
972
+ }),
623
973
  collectFleet: () => store.loadFleet(),
624
974
  loadLastUsedAgent: () => store.loadLastUsedAgent(),
625
975
  findAdapter: (kind) => mgr.findAdapter(kind),
626
- spawnSession: (p) => {
627
- const s = mgr.spawn(
628
- p.cwd,
629
- p.name,
630
- p.prompt,
631
- p.cols,
632
- p.rows,
633
- p.resume,
634
- p.namedBy,
635
- p.agent,
636
- p.parent,
637
- );
638
-
639
- 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);
640
979
 
641
- if (runtime !== undefined) {
642
- runtime.pendingLastUsed = true;
643
- 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
+ }
644
992
 
645
- runtime.screen = new ScreenModel(p.cols, p.rows);
993
+ if (target === null) {
994
+ throw new Error('a spawn without a target passed its checks');
646
995
  }
647
996
 
648
- 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
+ }
649
1003
 
650
- 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
+ });
651
1025
  },
652
1026
  updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
653
1027
  quitDaemon: () => {
@@ -662,10 +1036,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
662
1036
  }, 80);
663
1037
  },
664
1038
  killSession: async (id) => {
665
- if (!mgr.sessions.some((s) => s.id === id)) {
1039
+ const s = mgr.sessions.find((x) => x.id === id);
1040
+
1041
+ if (s === undefined) {
666
1042
  return false;
667
1043
  }
668
1044
 
1045
+ for (const live of [s, ...mgr.collectChildren(id)]) {
1046
+ if (live.pty !== null) {
1047
+ mgr.requireExecution(live, 'kill');
1048
+ }
1049
+ }
1050
+
669
1051
  for (const child of mgr.collectChildren(id)) {
670
1052
  runtimes.get(child.id)?.stopHeadlessRun();
671
1053
  }
@@ -693,6 +1075,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
693
1075
  return 'no_transcript';
694
1076
  }
695
1077
 
1078
+ mgr.requireExecution(s, 'kill');
1079
+ mgr.requireExecution(s, 'headless');
1080
+
696
1081
  const yanked = mgr.yankHeadless(id);
697
1082
 
698
1083
  if (yanked === null) {
@@ -731,9 +1116,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
731
1116
  }
732
1117
 
733
1118
  if (runtime !== undefined) {
734
- runtime.dims = { cols, rows };
735
- runtime.startedAt = null;
736
- runtime.tapAttached = false;
1119
+ runtime.resetBoot({ cols, rows });
737
1120
 
738
1121
  runtime.screen ??= new ScreenModel(cols, rows);
739
1122
  }
@@ -776,6 +1159,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
776
1159
  return 'dead';
777
1160
  }
778
1161
 
1162
+ mgr.requireExecution(s, 'attach');
779
1163
  attachments.attach(sessionID, client, dims);
780
1164
  mgr.attach(sessionID);
781
1165
 
@@ -816,7 +1200,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
816
1200
  return 'missing';
817
1201
  }
818
1202
 
1203
+ // A headless turn runs only through the session's own target, never
1204
+ // anywhere else, and a dead session takes no input.
819
1205
  if (s.kind === 'headless') {
1206
+ mgr.requireExecution(s, 'headless');
1207
+
1208
+ if (s.state === 'exited') {
1209
+ return 'dead';
1210
+ }
1211
+
820
1212
  if ((runtimes.get(sessionID)?.headlessRun ?? null) !== null) {
821
1213
  return 'busy';
822
1214
  }
@@ -836,6 +1228,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
836
1228
  return 'dead';
837
1229
  }
838
1230
 
1231
+ mgr.requireExecution(s, 'input');
839
1232
  s.pty.write(data);
840
1233
 
841
1234
  return 'ok';
@@ -905,82 +1298,80 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
905
1298
 
906
1299
  return { path, page };
907
1300
  },
908
- readEvents: async (afterID, limit, waitMs) => {
1301
+ readEvents: async (afterID, limit, waitMs, sessionID, access) => {
909
1302
  const deadline = Date.now() + waitMs;
1303
+ const scope = buildEventScope(sessionID, access);
910
1304
 
911
- // A wake for an event the read leaves out (a heartbeat) loops back to
912
- // wait out the rest of the window.
1305
+ // A wake for an event the read leaves out (a heartbeat, or another
1306
+ // session's event under a session filter) loops back to wait out the
1307
+ // rest of the window.
913
1308
  for (;;) {
914
1309
  const generation = eventSignal.generation;
915
1310
 
1311
+ // A read from a cursor takes one row past the limit to learn whether
1312
+ // more follow; the latest events have nothing after them.
916
1313
  const rows =
917
1314
  afterID === null
918
- ? await store.collectLatestEvents(limit)
919
- : await store.collectEventsAfter(afterID, limit);
1315
+ ? await store.collectLatestEvents(limit, scope)
1316
+ : await store.collectEventsAfter(afterID, limit + 1, scope);
920
1317
 
921
1318
  const remaining = deadline - Date.now();
922
1319
 
923
1320
  if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
924
- return buildFleetEvents(rows, mgr.collectDescriptors());
1321
+ return {
1322
+ events: buildFleetEvents(rows.slice(0, limit), mgr.collectDescriptors()),
1323
+ more: rows.length > limit,
1324
+ };
925
1325
  }
926
1326
 
927
1327
  await eventSignal.waitForNext(generation, remaining);
928
1328
  }
929
1329
  },
930
- writeSessionMessage: async (sessionID, from, text) => {
931
- 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);
932
1333
 
933
- if (s === undefined) {
934
- return 'missing';
935
- }
1334
+ if (refusal !== null) {
1335
+ return refusal;
1336
+ }
936
1337
 
937
- if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
938
- return 'dead';
939
- }
1338
+ const record = await writeAcceptedMessage(sessionID, from, text, mintMessageID());
940
1339
 
941
- if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
942
- return 'unsupported';
1340
+ return { message: record.id, status: record.status };
943
1341
  }
944
1342
 
945
- const runtime = runtimes.get(sessionID);
1343
+ const effectRef = mintMessageID();
946
1344
 
947
- if (
948
- runtime !== undefined &&
949
- runtime.startedAt !== null &&
950
- !runtime.tapAttached &&
951
- Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
952
- ) {
953
- return 'no_tap';
954
- }
1345
+ try {
1346
+ return await ledger.run({
1347
+ operation: 'session.message',
1348
+ keyed,
1349
+ effectRef,
955
1350
 
956
- const previousWrite = lastMessageWrite;
957
- 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);
958
1354
 
959
- lastMessageWrite = written.promise;
1355
+ if (refusal !== null) {
1356
+ throw new MessageRefusedError(refusal);
1357
+ }
960
1358
 
961
- await previousWrite;
1359
+ const record = await writeAcceptedMessage(sessionID, from, text, effectRef);
962
1360
 
963
- const record: MessageRecord = {
964
- id: mintMessageID(),
965
- atcID: sessionID,
966
- ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
967
- from,
968
- text,
969
- status: 'accepted',
970
- sentAt: Date.now(),
971
- };
1361
+ return { message: record.id, status: record.status };
1362
+ },
972
1363
 
973
- try {
974
- 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
+ }
975
1372
 
976
- await recordMessageStatus(sessionID, record);
977
- } finally {
978
- written.resolve();
1373
+ throw error;
979
1374
  }
980
-
981
- await drainInbox(sessionID);
982
-
983
- return record;
984
1375
  },
985
1376
  attachTap: (client, sessionID) => {
986
1377
  const s = mgr.sessions.find((x) => x.id === sessionID);
@@ -1005,20 +1396,38 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1005
1396
 
1006
1397
  return 'ok';
1007
1398
  },
1008
- readMessage: async (messageID) => {
1009
- const record = await store.findMessageByID(messageID);
1399
+ readMessage: async (messageID, waitMs) => {
1400
+ const deadline = Date.now() + waitMs;
1401
+ let initialStatus: MessageStatus | null = null;
1010
1402
 
1011
- if (record === null) {
1012
- return null;
1013
- }
1403
+ // Every message status change writes a trail entry and wakes the
1404
+ // signal, so a wake re-reads the message and returns once its status
1405
+ // moved from the status at call time. An answered message has no
1406
+ // further status to wait for.
1407
+ for (;;) {
1408
+ const generation = eventSignal.generation;
1014
1409
 
1015
- const owner = mgr.sessions.find(
1016
- (x) =>
1017
- x.id === record.atcID ||
1018
- (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
1019
- );
1410
+ const view = await readMessageView(messageID);
1411
+
1412
+ if (view === null) {
1413
+ return null;
1414
+ }
1020
1415
 
1021
- return { session: owner?.id ?? record.atcID, record };
1416
+ initialStatus ??= view.record.status;
1417
+
1418
+ const remaining = deadline - Date.now();
1419
+
1420
+ if (
1421
+ view.record.status !== initialStatus ||
1422
+ view.record.status === 'answered' ||
1423
+ remaining <= 0 ||
1424
+ eventSignal.disposed
1425
+ ) {
1426
+ return view;
1427
+ }
1428
+
1429
+ await eventSignal.waitForNext(generation, remaining);
1430
+ }
1022
1431
  },
1023
1432
  ackMessage: async (client, sessionID, messageID) => {
1024
1433
  const s = mgr.sessions.find((x) => x.id === sessionID);
@@ -1079,6 +1488,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1079
1488
  client.dispose();
1080
1489
  }
1081
1490
 
1491
+ clearInterval(idempotencySweep);
1492
+
1082
1493
  server.stop(true);
1083
1494
  eventsServer?.stop();
1084
1495
  reporter.stop(true);
@@ -1161,3 +1572,39 @@ function getDescriptor(mgr: SessionManager, id: SessionID): SessionDescriptor {
1161
1572
 
1162
1573
  return d;
1163
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
+ }