@zgeoff/atc 2.15.0 → 2.15.2

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 (67) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +3 -38
  3. package/src/agents/build-hook-settings.ts +1 -1
  4. package/src/agents/claude-adapter.ts +3 -3
  5. package/src/agents/codex-adapter.ts +3 -3
  6. package/src/agents/gateway-adapter.ts +3 -3
  7. package/src/agents/grok-adapter.ts +3 -3
  8. package/src/agents/start-claude-headless-run.ts +1 -1
  9. package/src/client/boot-daemon.ts +2 -2
  10. package/src/client/collect-agent-picks.ts +1 -1
  11. package/src/client/daemon-client.ts +2 -1
  12. package/src/client/format-overlay-agent-mark.ts +1 -1
  13. package/src/client/index.ts +4 -2
  14. package/src/client/pick-tab-target.ts +2 -2
  15. package/src/client/spawn-picker.ts +1 -1
  16. package/src/client/to-mirror-session.ts +3 -3
  17. package/src/client/ui.ts +3 -3
  18. package/src/daemon/build-fleet-events.ts +10 -4
  19. package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
  20. package/src/daemon/build-message-trail-entry.ts +1 -1
  21. package/src/daemon/build-report-trail-entry.ts +1 -1
  22. package/src/daemon/build-report-view.ts +10 -4
  23. package/src/daemon/build-scoped-context.ts +64 -77
  24. package/src/daemon/build-session-message-event.ts +1 -1
  25. package/src/daemon/daemon-connection.ts +206 -288
  26. package/src/daemon/daemon-context.ts +291 -0
  27. package/src/daemon/daemon.ts +250 -94
  28. package/src/daemon/drain-inbox.ts +85 -0
  29. package/src/daemon/execution-provider.ts +12 -0
  30. package/src/daemon/hooks.ts +1 -7
  31. package/src/daemon/idempotency-ledger.ts +78 -18
  32. package/src/daemon/imp-harness.ts +41 -0
  33. package/src/daemon/is-tree-in-reach.ts +32 -0
  34. package/src/daemon/local-pty-provider.ts +45 -0
  35. package/src/daemon/parse-hook-line.ts +1 -1
  36. package/src/daemon/sessions.ts +175 -123
  37. package/src/daemon/start-headless-turn.ts +3 -3
  38. package/src/daemon/start-session-bridge.ts +2 -2
  39. package/src/daemon/tap-registry.ts +43 -5
  40. package/src/mcp/collect-clients.ts +1 -1
  41. package/src/mcp/collect-grants.ts +1 -1
  42. package/src/mcp/find-client-name.ts +1 -1
  43. package/src/mcp/mcp-auth-schema.ts +37 -0
  44. package/src/mcp/open-mcp-auth.ts +2 -2
  45. package/src/mcp/reconnecting-caller.ts +15 -6
  46. package/src/mcp/remove-client.ts +1 -1
  47. package/src/mcp/revoke-grant.ts +1 -1
  48. package/src/mcp/types.ts +0 -38
  49. package/src/mcp-http-server.ts +2 -1
  50. package/src/protocol/adapter-event.ts +25 -0
  51. package/src/protocol/count-session-states.ts +16 -0
  52. package/src/protocol/daemon-channel.ts +17 -0
  53. package/src/protocol/hook-event.ts +11 -0
  54. package/src/protocol/pinned-group-key.ts +2 -0
  55. package/src/protocol/session-state.ts +5 -0
  56. package/src/protocol/sort-grouped-session-views.ts +29 -0
  57. package/src/protocol/sort-session-views.ts +41 -0
  58. package/src/protocol/sortable-session-view.ts +13 -0
  59. package/src/shared/agent-id.ts +6 -0
  60. package/src/shared/collect-gateways.ts +1 -1
  61. package/src/shared/to-agent-id.ts +11 -0
  62. package/src/store/fleet-entry.ts +2 -2
  63. package/src/store/run-migrations.ts +55 -4
  64. package/src/store/state-store.ts +49 -13
  65. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  66. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  67. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
@@ -2,12 +2,15 @@ import { randomUUID } from 'node:crypto';
2
2
  import { unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { hostname, tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
5
+ import type { AgentAdapter } from '../agents/agent-adapter';
6
6
  import { planTypedLineInput } from '../agents/plan-typed-line-input';
7
+ import type { AdapterEvent } from '../protocol/adapter-event';
7
8
  import { DaemonError } from '../protocol/daemon-error';
9
+ import type { HookEvent } from '../protocol/hook-event';
8
10
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
9
11
  import type { EventMsg } from '../protocol/protocol';
10
12
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
+ import type { SessionState } from '../protocol/session-state';
11
14
  import type { HooksConfig } from '../shared/collect-hooks';
12
15
  import type { TargetConfigError } from '../shared/collect-targets';
13
16
  import { findDaemonRecord } from '../shared/find-daemon-record';
@@ -30,6 +33,7 @@ import { buildConfigRevision } from './build-config-revision';
30
33
  import { buildExecutionTargets } from './build-execution-targets';
31
34
  import type { ExecutionTarget } from './build-execution-targets';
32
35
  import { buildFleetEvents } from './build-fleet-events';
36
+ import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
33
37
  import { buildMessageTrailEntry } from './build-message-trail-entry';
34
38
  import { buildReportTrailEntry } from './build-report-trail-entry';
35
39
  import { buildReportView } from './build-report-view';
@@ -47,12 +51,14 @@ import type {
47
51
  OutputClient,
48
52
  SpawnParams,
49
53
  TapClient,
50
- } from './daemon-connection';
54
+ } from './daemon-context';
55
+ import { drainInbox } from './drain-inbox';
56
+ import type { InboxSource } from './drain-inbox';
51
57
  import { EffectRemainsError } from './effect-remains-error';
52
58
  import { EventSignal } from './event-signal';
53
59
  import { startHookServer } from './hooks';
54
- import type { HookEvent } from './hooks';
55
60
  import { IdempotencyLedger } from './idempotency-ledger';
61
+ import { isTreeInReach } from './is-tree-in-reach';
56
62
  import { loadTranscriptPage } from './load-transcript-page';
57
63
  import { makeHookRunner } from './make-hook-runner';
58
64
  import type { HookScope } from './make-hook-runner';
@@ -66,7 +72,7 @@ import { runEjectHandoff } from './run-eject-handoff';
66
72
  import { ScreenModel } from './screen-model';
67
73
  import { SessionRuntime } from './session-runtime';
68
74
  import { SessionManager } from './sessions';
69
- import type { Session, SessionDescriptor, SessionState } from './sessions';
75
+ import type { Session, SessionDescriptor } from './sessions';
70
76
  import { startEventsServer } from './start-events-server';
71
77
  import { startHeadlessTurn } from './start-headless-turn';
72
78
  import { startSessionBridge } from './start-session-bridge';
@@ -319,8 +325,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
319
325
 
320
326
  // A live session's scope also matches the rows it wrote under an earlier
321
327
  // atc id, through the agent session id a restore carries on. Under an
322
- // access, the scope holds only the sessions on targets the access holds,
323
- // so a session outside it matches nothing, as a session never seen does.
328
+ // access, the scope holds only the atc ids of the sessions whose whole
329
+ // tree is on targets the access holds, so a session outside it matches
330
+ // nothing, as a session never seen does. It holds no agent session id there: a session on
331
+ // another target can resume the same agent session, and its rows would
332
+ // match.
324
333
  const buildEventScope = (
325
334
  sessionID: SessionID | null,
326
335
  access: TargetAccess | null,
@@ -339,19 +348,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
339
348
  }
340
349
 
341
350
  const visible = mgr.sessions.filter(
342
- (x) => access.canUse(x) && (sessionID === null || x.id === sessionID),
351
+ (x) =>
352
+ (sessionID === null || x.id === sessionID) && isTreeInReach(mgr.sessions, x.id, access),
343
353
  );
344
354
 
345
- return {
346
- atcIDs: visible.map((x) => x.id),
347
- agentSessionIDs: visible.flatMap((x) =>
348
- x.agentSessionID === undefined ? [] : [x.agentSessionID],
349
- ),
350
- };
355
+ return { atcIDs: visible.map((x) => x.id), agentSessionIDs: [] };
351
356
  };
352
357
 
353
- // A message belongs to the live session holding its atc id or its agent
354
- // session id, else to the atc id it was sent to.
358
+ // The sessions that may name a trail row: under an access, only those
359
+ // whose whole tree is on targets it holds.
360
+ const collectNamingDescriptors = (access: TargetAccess | null): SessionDescriptor[] => {
361
+ const reached = new Set(
362
+ mgr.sessions
363
+ .filter((x) => access === null || isTreeInReach(mgr.sessions, x.id, access))
364
+ .map((x) => x.id),
365
+ );
366
+
367
+ return mgr.collectDescriptors().filter((d) => reached.has(d.id));
368
+ };
369
+
370
+ // A message belongs to the live session holding its atc id, else to the
371
+ // one holding its agent session id, else to the atc id it was sent to.
355
372
  const readMessageView = async (messageID: MessageID) => {
356
373
  const record = await store.findMessageByID(messageID);
357
374
 
@@ -359,11 +376,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
359
376
  return null;
360
377
  }
361
378
 
362
- const owner = mgr.sessions.find(
363
- (x) =>
364
- x.id === record.atcID ||
365
- (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
366
- );
379
+ const owner =
380
+ mgr.sessions.find((x) => x.id === record.atcID) ??
381
+ mgr.sessions.find(
382
+ (x) => record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID,
383
+ );
367
384
 
368
385
  return {
369
386
  session: owner?.id ?? record.atcID,
@@ -406,50 +423,17 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
406
423
  // the order of their sent times, which the inbox drains by.
407
424
  let lastMessageWrite: Promise<void> = Promise.resolve();
408
425
 
409
- // Hands one pending message to the session's tap, once, and reports whether
410
- // it did. The event goes to the tap connection alone: it never reaches
411
- // other clients, the events socket, or hooks.
412
- const sendInboxMessage = (sessionID: SessionID, record: MessageRecord): boolean => {
413
- const tap = taps.claimDelivery(sessionID, record.id);
414
-
415
- if (tap === null) {
416
- return false;
417
- }
418
-
419
- tap.sendEvent({
420
- v: PROTOCOL_V,
421
- ev: 'InboxMessage',
422
- s: sessionID,
423
- message: record.id,
424
- from: record.from,
425
- text: record.text,
426
- sentAt: record.sentAt,
427
- });
426
+ const inboxSource: InboxSource = {
427
+ taps,
428
+ findLinkedOwner: (sessionID) => {
429
+ const s = mgr.sessions.find((x) => x.id === sessionID);
428
430
 
429
- return true;
431
+ return s === undefined ? null : buildMessageOwner(s);
432
+ },
433
+ collectPendingMessages: (owner) => store.collectPendingMessages(owner),
430
434
  };
431
435
 
432
- // Reads the backlog from the store before sending anything, so a tap's
433
- // ok response is always queued ahead of its first message. It sends one
434
- // unclaimed message per call and the tap's ack calls it again, so the
435
- // backlog never outgrows the connection's outbound queue.
436
- const drainInbox = async (sessionID: SessionID) => {
437
- const s = mgr.sessions.find((x) => x.id === sessionID);
438
-
439
- if (s === undefined) {
440
- return;
441
- }
442
-
443
- try {
444
- const pending = await store.collectPendingMessages(buildMessageOwner(s));
445
-
446
- for (const record of pending) {
447
- if (sendInboxMessage(sessionID, record)) {
448
- return;
449
- }
450
- }
451
- } catch {}
452
- };
436
+ const drainSessionInbox = (sessionID: SessionID) => drainInbox(sessionID, inboxSource);
453
437
 
454
438
  // A report id, which a remote session's reporter gives each report,
455
439
  // makes a resent note land once; a resent answer changes nothing, since
@@ -839,8 +823,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
839
823
 
840
824
  return true;
841
825
  },
842
- attachTap: (client, sessionID) => ctx.attachTap(client, sessionID),
843
- ackMessage: (client, sessionID, messageID) => ctx.ackMessage(client, sessionID, messageID),
826
+ attachTap: (client, sessionID) => ctx.attachTap(client, sessionID, null),
827
+ ackMessage: (client, sessionID, messageID) =>
828
+ ctx.ackMessage(client, sessionID, messageID, null),
844
829
  detachTap: (client) => {
845
830
  taps.detachAll(client);
846
831
  },
@@ -856,6 +841,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
856
841
  const startSpawn = async (
857
842
  p: SpawnParams,
858
843
  id: SessionID,
844
+ requireInReach: () => void = () => {},
859
845
  ): Promise<Readonly<Record<string, unknown>>> => {
860
846
  const prepared =
861
847
  p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
@@ -864,7 +850,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
864
850
  const warnings = materialized === null ? [] : materialized.warnings;
865
851
 
866
852
  try {
867
- const session = await startSpawnedSession(p, id, materialized);
853
+ const session = await startSpawnedSession(p, id, materialized, requireInReach);
868
854
 
869
855
  return warnings.length === 0 ? { session } : { session, warnings };
870
856
  } catch (error) {
@@ -920,6 +906,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
920
906
  workspace: SessionWorkspace;
921
907
  withheldEnv: readonly string[];
922
908
  }> | null,
909
+ requireInReach: () => void,
923
910
  ): Promise<SessionDescriptor> => {
924
911
  const s = await mgr.spawn(
925
912
  p.cwd,
@@ -935,6 +922,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
935
922
  id,
936
923
  p.target,
937
924
  materialized,
925
+ requireInReach,
938
926
  );
939
927
 
940
928
  const runtime = runtimes.get(s.id);
@@ -943,7 +931,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
943
931
  runtime.pendingLastUsed = true;
944
932
  }
945
933
 
946
- void store.recordSpawnDir(p.cwd);
934
+ void store.recordSpawnDir(p.cwd, { target: s.target, targetIdentity: s.targetIdentity });
947
935
 
948
936
  return getDescriptor(mgr, s.id);
949
937
  };
@@ -972,12 +960,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
972
960
  );
973
961
  };
974
962
 
975
- const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL);
963
+ const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL, (line) => {
964
+ mgr.log(line);
965
+ });
976
966
 
977
967
  // Throws the refusal a fresh spawn to the target gets when the access
978
968
  // does not reach the target, and the identity, a held spawn key recorded
979
969
  // for its session. A key that records none is refused: nothing it holds
980
- // shows where its session ran.
970
+ // shows where its session ran. A session the daemon still holds is
971
+ // refused the same way when the access does not reach its whole tree.
981
972
  const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
982
973
  const bound = record.effectTarget;
983
974
 
@@ -985,11 +976,48 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
985
976
  throw buildTargetForbiddenError(findReplayTarget(record));
986
977
  }
987
978
 
988
- if (!access.canUse(bound)) {
979
+ const session = mgr.sessions.find((x) => x.id === record.effectRef);
980
+
981
+ if (
982
+ !access.canUse(bound) ||
983
+ (session !== undefined && !isTreeInReach(mgr.sessions, session.id, access))
984
+ ) {
989
985
  throw buildTargetForbiddenError(bound.target);
990
986
  }
991
987
  };
992
988
 
989
+ // Throws the refusal of a replay out of reach when a held spawn key's
990
+ // session is no longer live and the access does not reach the whole tree
991
+ // its fleet rows hold, as the live check refuses a live tree. A session
992
+ // with no fleet row has no tree left to check.
993
+ const requireStoredTreeInReach = async (
994
+ record: IdempotencyRecord,
995
+ access: TargetAccess,
996
+ ): Promise<void> => {
997
+ if (mgr.sessions.some((x) => x.id === record.effectRef)) {
998
+ return;
999
+ }
1000
+
1001
+ const fleet = await store.loadFleet();
1002
+
1003
+ const members = fleet.map((entry) => {
1004
+ const grant = buildGrantFromFleetEntry(entry);
1005
+
1006
+ return {
1007
+ id: entry.sessionID,
1008
+ parent: entry.parent ?? null,
1009
+ target: grant.target,
1010
+ targetIdentity: grant.targetIdentity,
1011
+ };
1012
+ });
1013
+
1014
+ const own = members.find((member) => member.id === record.effectRef);
1015
+
1016
+ if (own !== undefined && !isTreeInReach(members, own.id, access)) {
1017
+ throw buildTargetForbiddenError(record.effectTarget?.target ?? findReplayTarget(record));
1018
+ }
1019
+ };
1020
+
993
1021
  // Each confirm token a forget handed out, by token: the session it
994
1022
  // forgets, when it stops being taken, and whether a forget took it. A
995
1023
  // token stays known for one more lifetime after it expires, so a late
@@ -1067,11 +1095,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1067
1095
  // it to the session's tap when one is attached. Only the write itself can
1068
1096
  // reject: every later step swallows its own failure, so a rejection means
1069
1097
  // no message was written.
1098
+ // The caller's check runs again once the writes ahead of this one land,
1099
+ // before anything is written; a failed check writes nothing.
1070
1100
  const writeAcceptedMessage = async (
1071
1101
  sessionID: SessionID,
1072
1102
  from: string,
1073
1103
  text: string,
1074
1104
  id: MessageID,
1105
+ requireWriteInReach: () => void,
1075
1106
  ): Promise<MessageRecord> => {
1076
1107
  const s = mgr.sessions.find((x) => x.id === sessionID);
1077
1108
  const previousWrite = lastMessageWrite;
@@ -1092,6 +1123,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1092
1123
  };
1093
1124
 
1094
1125
  try {
1126
+ // The write boundary: the reach check and the start of the row write
1127
+ // run in one synchronous step, so no wait lets the session's tree
1128
+ // leave the access between the check and the write it allows.
1129
+ requireWriteInReach();
1130
+
1095
1131
  await store.writeMessage(record);
1096
1132
 
1097
1133
  await recordMessageStatus(sessionID, record);
@@ -1099,7 +1135,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1099
1135
  written.resolve();
1100
1136
  }
1101
1137
 
1102
- await drainInbox(sessionID);
1138
+ await drainSessionInbox(sessionID);
1103
1139
 
1104
1140
  return record;
1105
1141
  };
@@ -1144,7 +1180,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1144
1180
  build: opts.build,
1145
1181
  daemonID: store.daemonID,
1146
1182
  collectSessions: () => mgr.collectDescriptors(),
1147
- collectSpawnDirs: () => store.collectSpawnDirs(),
1183
+ collectSpawnDirs: async (access) => {
1184
+ const dirs = await store.collectSpawnDirs();
1185
+
1186
+ const reached = dirs.filter((dir) => access === null || access.canUse(dir.grant));
1187
+
1188
+ return [...new Set(reached.map((dir) => dir.cwd))];
1189
+ },
1148
1190
  collectAgents: () => ({
1149
1191
  daemon: {
1150
1192
  hostname: hostname(),
@@ -1168,8 +1210,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1168
1210
  return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
1169
1211
  },
1170
1212
  findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
1171
- collectChildIDs: (id) => mgr.collectChildren(id).map((child) => child.id),
1213
+ canSeeSession: (id, access) => isTreeInReach(mgr.sessions, id, access),
1214
+ isSessionVisible: () => true,
1172
1215
  findPermissionSession: (request) => registry.findSessionID(request),
1216
+ resolveSpawnParent: (id) => {
1217
+ const owner = mgr.sessions.find((x) => x.id === id);
1218
+
1219
+ if (owner === undefined) {
1220
+ return 'missing';
1221
+ }
1222
+
1223
+ return owner.parent ?? owner.id;
1224
+ },
1173
1225
  resolveSpawnTarget: (requested) => {
1174
1226
  const target = requested ?? defaultTarget;
1175
1227
  const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
@@ -1189,8 +1241,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1189
1241
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1190
1242
  },
1191
1243
  spawnSession: (plan, keyed, access) => {
1244
+ // Under an access, a spawn under a parent whose tree leaves the access
1245
+ // before the harness starts is refused as a spawn under an unknown
1246
+ // parent.
1247
+ const startInReach = (p: SpawnParams, id: SessionID) =>
1248
+ startSpawn(p, id, () => {
1249
+ if (
1250
+ access !== null &&
1251
+ p.parent !== null &&
1252
+ !isTreeInReach(mgr.sessions, p.parent, access)
1253
+ ) {
1254
+ throw new DaemonError('no_such_session', `no session '${p.parent}'`);
1255
+ }
1256
+ });
1257
+
1192
1258
  if (keyed === null) {
1193
- return startSpawn(plan(), mintSessionID());
1259
+ return startInReach(plan(), mintSessionID());
1194
1260
  }
1195
1261
 
1196
1262
  const effectRef = mintSessionID();
@@ -1199,13 +1265,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1199
1265
  operation: 'session.spawn',
1200
1266
  keyed,
1201
1267
  effectRef,
1202
- start: () => startSpawn(plan(), effectRef),
1268
+ start: () => startInReach(plan(), effectRef),
1203
1269
  settle: () => mgr.writeFleet(),
1204
- replay: (record) => {
1205
- if (access !== null) {
1206
- requireReplayInReach(record, access);
1270
+ replay: async (record) => {
1271
+ if (access === null) {
1272
+ return loadSpawnReplay(record);
1207
1273
  }
1208
1274
 
1275
+ requireReplayInReach(record, access);
1276
+
1277
+ await requireStoredTreeInReach(record, access);
1278
+
1279
+ // The tree may change while the fleet is read, so the live check
1280
+ // runs again in the step that answers.
1281
+ requireReplayInReach(record, access);
1282
+
1209
1283
  return loadSpawnReplay(record);
1210
1284
  },
1211
1285
  findEffectTarget: () => {
@@ -1325,7 +1399,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1325
1399
 
1326
1400
  return 'ok';
1327
1401
  },
1328
- adoptSession: async (id, cols, rows) => {
1402
+ adoptSession: async (id, cols, rows, access) => {
1329
1403
  if (!hasResumableTranscript(mgr, id)) {
1330
1404
  return 'no_transcript';
1331
1405
  }
@@ -1334,7 +1408,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1334
1408
 
1335
1409
  runtime?.stopHeadlessRun();
1336
1410
 
1337
- const adopted = await mgr.adoptTerminal(id, cols, rows);
1411
+ const adopted = await mgr.adoptTerminal(
1412
+ id,
1413
+ cols,
1414
+ rows,
1415
+ () => access === null || isTreeInReach(mgr.sessions, id, access),
1416
+ );
1338
1417
 
1339
1418
  if (adopted === null) {
1340
1419
  return 'missing';
@@ -1492,7 +1571,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1492
1571
  runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
1493
1572
  restoreFleet: (cols, rows) =>
1494
1573
  restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
1495
- readSessionRecord: async (id) => {
1574
+ readSessionRecord: async (id, access) => {
1496
1575
  const s = mgr.sessions.find((x) => x.id === id);
1497
1576
 
1498
1577
  if (s === undefined) {
@@ -1507,7 +1586,19 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1507
1586
  const result = s.result ?? null;
1508
1587
  const createdAt = s.createdAt;
1509
1588
 
1510
- const lastEventAt = await store.loadLastActivityAt(s.id, s.agentSessionID);
1589
+ // The agent session id links rows from before atc ids stayed stable,
1590
+ // unless a session on another target resumed the same agent session,
1591
+ // whose rows it would match too. Under an access it links nothing: a
1592
+ // session that shared it may be gone, its rows still in the trail.
1593
+ const shared = mgr.sessions.some(
1594
+ (x) =>
1595
+ x.agentSessionID === s.agentSessionID &&
1596
+ (x.target !== s.target || x.targetIdentity !== s.targetIdentity),
1597
+ );
1598
+
1599
+ const linked = access !== null || shared ? undefined : s.agentSessionID;
1600
+
1601
+ const lastEventAt = await store.loadLastActivityAt(s.id, linked);
1511
1602
 
1512
1603
  return {
1513
1604
  session,
@@ -1543,13 +1634,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1543
1634
  },
1544
1635
  readEvents: async (afterID, limit, waitMs, sessionID, access) => {
1545
1636
  const deadline = Date.now() + waitMs;
1546
- const scope = buildEventScope(sessionID, access);
1547
1637
 
1548
1638
  // A wake for an event the read leaves out (a heartbeat, or another
1549
1639
  // session's event under a session filter) loops back to wait out the
1550
- // rest of the window.
1640
+ // rest of the window. The scope is built again after every await, so
1641
+ // a session whose tree left the access while the read waited matches
1642
+ // nothing, and a read whose scope changed under its query runs again.
1551
1643
  for (;;) {
1552
1644
  const generation = eventSignal.generation;
1645
+ const scope = buildEventScope(sessionID, access);
1553
1646
 
1554
1647
  // A read from a cursor takes one row past the limit to learn whether
1555
1648
  // more follow; the latest events have nothing after them.
@@ -1558,11 +1651,24 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1558
1651
  ? await store.collectLatestEvents(limit, scope)
1559
1652
  : await store.collectEventsAfter(afterID, limit + 1, scope);
1560
1653
 
1654
+ if (
1655
+ access !== null &&
1656
+ JSON.stringify(buildEventScope(sessionID, access)) !== JSON.stringify(scope)
1657
+ ) {
1658
+ continue;
1659
+ }
1660
+
1561
1661
  const remaining = deadline - Date.now();
1562
1662
 
1563
1663
  if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
1664
+ const naming = collectNamingDescriptors(access);
1665
+
1666
+ // Under an access an event takes no alias, so each event keeps the
1667
+ // atc id it was recorded under for the check that sends it.
1668
+ const aliases = access === null ? naming : [];
1669
+
1564
1670
  return {
1565
- events: buildFleetEvents(rows.slice(0, limit), mgr.collectDescriptors()),
1671
+ events: buildFleetEvents(rows.slice(0, limit), naming, aliases),
1566
1672
  more: rows.length > limit,
1567
1673
  };
1568
1674
  }
@@ -1573,9 +1679,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1573
1679
  readReport: async (id, access) => {
1574
1680
  const stored = await store.findReport(id, buildEventScope(null, access));
1575
1681
 
1576
- return stored === null ? null : buildReportView(stored, mgr.collectDescriptors());
1682
+ if (stored === null) {
1683
+ return null;
1684
+ }
1685
+
1686
+ const naming = collectNamingDescriptors(access);
1687
+ const aliases = access === null ? naming : [];
1688
+
1689
+ // Under an access a report takes no alias, so a session in reach
1690
+ // never names a report whose own session left the access while the
1691
+ // query waited.
1692
+ return {
1693
+ owner: stored.atcID,
1694
+ view: buildReportView(stored, naming, aliases),
1695
+ };
1577
1696
  },
1578
- writeSessionMessage: async (sessionID, from, text, keyed) => {
1697
+ writeSessionMessage: async (sessionID, from, text, keyed, access) => {
1698
+ // Under an access, a session whose tree leaves the access before the
1699
+ // message is written refuses it as an unknown session does.
1700
+ const requireInReach = () => {
1701
+ if (access !== null && !isTreeInReach(mgr.sessions, sessionID, access)) {
1702
+ throw new MessageRefusedError('missing');
1703
+ }
1704
+ };
1705
+
1579
1706
  if (keyed === null) {
1580
1707
  const refusal = findMessageRefusal(sessionID);
1581
1708
 
@@ -1583,9 +1710,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1583
1710
  return refusal;
1584
1711
  }
1585
1712
 
1586
- const record = await writeAcceptedMessage(sessionID, from, text, mintMessageID());
1713
+ try {
1714
+ const record = await writeAcceptedMessage(
1715
+ sessionID,
1716
+ from,
1717
+ text,
1718
+ mintMessageID(),
1719
+ requireInReach,
1720
+ );
1587
1721
 
1588
- return { message: record.id, status: record.status };
1722
+ return { message: record.id, status: record.status };
1723
+ } catch (error) {
1724
+ if (error instanceof MessageRefusedError) {
1725
+ return error.refusal;
1726
+ }
1727
+
1728
+ throw error;
1729
+ }
1589
1730
  }
1590
1731
 
1591
1732
  const effectRef = mintMessageID();
@@ -1598,13 +1739,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1598
1739
 
1599
1740
  // A refusal throws, so the claim drops and a retry runs fresh.
1600
1741
  start: async () => {
1742
+ requireInReach();
1743
+
1601
1744
  const refusal = findMessageRefusal(sessionID);
1602
1745
 
1603
1746
  if (refusal !== null) {
1604
1747
  throw new MessageRefusedError(refusal);
1605
1748
  }
1606
1749
 
1607
- const record = await writeAcceptedMessage(sessionID, from, text, effectRef);
1750
+ const record = await writeAcceptedMessage(
1751
+ sessionID,
1752
+ from,
1753
+ text,
1754
+ effectRef,
1755
+ requireInReach,
1756
+ );
1608
1757
 
1609
1758
  return { message: record.id, status: record.status };
1610
1759
  },
@@ -1621,7 +1770,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1621
1770
  throw error;
1622
1771
  }
1623
1772
  },
1624
- attachTap: (client, sessionID) => {
1773
+ attachTap: (client, sessionID, access) => {
1625
1774
  const s = mgr.sessions.find((x) => x.id === sessionID);
1626
1775
 
1627
1776
  if (s === undefined) {
@@ -1632,7 +1781,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1632
1781
  return 'unsupported';
1633
1782
  }
1634
1783
 
1635
- emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
1784
+ emitInboxClosed(taps.attach(sessionID, client, access === null), sessionID, 'replaced');
1636
1785
 
1637
1786
  const runtime = runtimes.get(sessionID);
1638
1787
 
@@ -1640,10 +1789,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1640
1789
  runtime.tapAttached = true;
1641
1790
  }
1642
1791
 
1643
- void drainInbox(sessionID);
1792
+ void drainSessionInbox(sessionID);
1644
1793
 
1645
1794
  return 'ok';
1646
1795
  },
1796
+ detachTap: (client, sessionID) => {
1797
+ if (taps.isTap(sessionID, client)) {
1798
+ taps.removeSession(sessionID);
1799
+ }
1800
+ },
1647
1801
  readMessage: async (messageID, waitMs) => {
1648
1802
  const deadline = Date.now() + waitMs;
1649
1803
  let initialStatus: MessageStatus | null = null;
@@ -1677,18 +1831,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1677
1831
  await eventSignal.waitForNext(generation, remaining);
1678
1832
  }
1679
1833
  },
1680
- ackMessage: async (client, sessionID, messageID) => {
1834
+ ackMessage: async (client, sessionID, messageID, access) => {
1681
1835
  const s = mgr.sessions.find((x) => x.id === sessionID);
1682
1836
 
1683
1837
  if (s === undefined || !taps.isTap(sessionID, client)) {
1684
1838
  return 'not_tapping';
1685
1839
  }
1686
1840
 
1687
- const owner = buildMessageOwner(s);
1841
+ // Under an access, only a message sent to this atc id is the
1842
+ // session's to ack, as with the inbox drain.
1843
+ const owner = access === null ? buildMessageOwner(s) : { atcID: s.id };
1688
1844
 
1689
1845
  const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
1690
1846
 
1691
- void drainInbox(sessionID);
1847
+ void drainSessionInbox(sessionID);
1692
1848
 
1693
1849
  if (delivered !== null) {
1694
1850
  await recordMessageStatus(sessionID, delivered);