@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
@@ -1,21 +1,25 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import type {
3
- AdapterEvent,
4
3
  AgentAdapter,
5
- AgentID,
6
4
  SpawnOptions,
7
5
  SpawnOverrides,
8
6
  SpawnPlan,
9
7
  } from '../agents/agent-adapter';
10
- import { truncateDetail } from '../agents/truncate-detail';
8
+ import type { AdapterEvent } from '../protocol/adapter-event';
9
+ import { countSessionStates } from '../protocol/count-session-states';
11
10
  import { DaemonError } from '../protocol/daemon-error';
11
+ import type { HookEvent } from '../protocol/hook-event';
12
12
  import type { ErrorCode } from '../protocol/protocol';
13
+ import type { SessionState } from '../protocol/session-state';
14
+ import { sortSessionViews } from '../protocol/sort-session-views';
15
+ import type { AgentID } from '../shared/agent-id';
13
16
  import type { AgentSessionID } from '../shared/agent-session-id';
14
17
  import type { TargetConfigError } from '../shared/collect-targets';
15
18
  import { socketPath, statusFile } from '../shared/config';
16
19
  import type { DaemonID } from '../shared/daemon-id';
17
20
  import { resolveRepoRoot } from '../shared/resolve-repo-root';
18
21
  import type { SessionID } from '../shared/session-id';
22
+ import { truncateDetail } from '../shared/truncate-detail';
19
23
  import { truncateToBytes } from '../shared/truncate-to-bytes';
20
24
  import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
21
25
  import type { SessionWorkspace } from '../store/workspace-materialization';
@@ -31,14 +35,11 @@ import type {
31
35
  HarnessRelay,
32
36
  } from './execution-provider';
33
37
  import { findExecutionRefusal } from './find-execution-refusal';
34
- import type { HookEvent } from './hooks';
35
38
  import type { BridgeBinding } from './is-binding-current';
36
39
  import { LocalPTYProvider } from './local-pty-provider';
37
40
  import { mintSessionID } from './mint-session-id';
38
41
  import { pickSessionState } from './pick-session-state';
39
42
 
40
- export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
41
-
42
43
  export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
43
44
 
44
45
  // The over-the-wire view of a session: everything but the harness handle,
@@ -181,6 +182,10 @@ interface MaterializedSpawn {
181
182
  // stored identity ran on.
182
183
  const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
183
184
 
185
+ // How long a failed spawn's rollback waits for the killed process to exit,
186
+ // once after its kill and once more after a forced kill.
187
+ const FAILED_SPAWN_EXIT_WAIT_MS = 2000;
188
+
184
189
  // The target a session runs on and the identity it is bound to there; null
185
190
  // binds a new session to the target as it stands now.
186
191
  interface TargetBinding {
@@ -236,6 +241,13 @@ export class SessionManager {
236
241
  // write deletes; each stays here until a write carrying it lands.
237
242
  private readonly removedIDs = new Set<SessionID>();
238
243
 
244
+ // Failed spawns whose rollback is running, and those whose killed process
245
+ // it could not confirm gone, each with the harness it ran in, so no revive
246
+ // starts a second harness while the first may still run.
247
+ private readonly rollingBack = new Set<SessionID>();
248
+
249
+ private readonly unconfirmedKills = new Map<SessionID, HarnessHandle>();
250
+
239
251
  // The epoch the next harness start or attach takes, unique across every
240
252
  // session this manager holds.
241
253
  private nextBridgeEpoch = 1;
@@ -467,14 +479,27 @@ export class SessionManager {
467
479
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
468
480
  // same agent session id. On a remote host the host wakes first, and a
469
481
  // harness still running inside it is attached rather than started again;
470
- // every other session left asleep on that host comes back with it.
471
- async adoptTerminal(id: SessionID, cols: number, rows: number): Promise<Session | null> {
482
+ // every other session left asleep on that host comes back with it. The
483
+ // caller's check runs again after each await, and a failed check leaves
484
+ // the session as it was.
485
+ async adoptTerminal(
486
+ id: SessionID,
487
+ cols: number,
488
+ rows: number,
489
+ canProceed: () => boolean = () => true,
490
+ ): Promise<Session | null> {
472
491
  const s = this.sessions.find((x) => x.id === id);
473
492
 
474
493
  if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
475
494
  return null;
476
495
  }
477
496
 
497
+ const settled = await this.isRollbackSettled(id);
498
+
499
+ if (!settled || !canProceed()) {
500
+ return null;
501
+ }
502
+
478
503
  const adapter = this.findAdapter(s.agent);
479
504
 
480
505
  if (adapter === null) {
@@ -498,8 +523,16 @@ export class SessionManager {
498
523
  this.adopting.delete(id);
499
524
  }
500
525
 
501
- // A kill or a second adopt can land while the host wakes.
502
- if (s.pty !== null || !this.sessions.includes(s)) {
526
+ // A kill, a second adopt, a failed spawn's rollback, or a change that
527
+ // takes the session out of the caller's reach can land while the host
528
+ // wakes.
529
+ if (
530
+ !canProceed() ||
531
+ s.pty !== null ||
532
+ !this.sessions.includes(s) ||
533
+ this.rollingBack.has(id) ||
534
+ this.unconfirmedKills.has(id)
535
+ ) {
503
536
  return null;
504
537
  }
505
538
 
@@ -705,6 +738,7 @@ export class SessionManager {
705
738
  id: SessionID = mintSessionID(),
706
739
  target = 'local',
707
740
  materialized: MaterializedSpawn | null = null,
741
+ requireInReach: () => void = () => {},
708
742
  ): Promise<Session> {
709
743
  const adapter = this.findAdapter(agent);
710
744
 
@@ -727,6 +761,10 @@ export class SessionManager {
727
761
  ...overrides,
728
762
  });
729
763
 
764
+ // The caller's check runs again once the host is ready, before the
765
+ // harness starts.
766
+ requireInReach();
767
+
730
768
  const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
731
769
 
732
770
  const pty = provider.spawnHarness({
@@ -893,8 +931,11 @@ export class SessionManager {
893
931
 
894
932
  // Takes back a spawn that failed after its process started: the process
895
933
  // dies and the session leaves the list and the fleet, so the spawn leaves
896
- // nothing behind. It resolves only once the fleet without the session is
897
- // durable, and throws when the kill or that write fails. A session that
934
+ // nothing behind. It resolves only once the process has exited and the
935
+ // fleet without the session is durable, and throws when the kill, the
936
+ // exit, or that write cannot be confirmed. A session whose exit is not
937
+ // confirmed stays listed, so the spawn's caller can still find it, and
938
+ // no revive starts it until its process is found gone. A session that
898
939
  // never registered is left alone.
899
940
  async removeFailedSpawn(id: SessionID): Promise<void> {
900
941
  const s = this.sessions.find((x) => x.id === id);
@@ -903,13 +944,87 @@ export class SessionManager {
903
944
  return;
904
945
  }
905
946
 
906
- s.pty?.kill();
907
- s.pty = null;
947
+ const pty = s.pty;
908
948
 
909
- this.remove(s);
910
- this.emitChange();
949
+ this.rollingBack.add(id);
911
950
 
912
- await this.writeFleet();
951
+ try {
952
+ if (pty !== null) {
953
+ await this.killFailedSpawn(id, pty);
954
+ }
955
+
956
+ // A harness other than the killed one would outlive the rollback.
957
+ if (s.pty !== null && s.pty !== pty) {
958
+ throw new Error(`session ${id} started another harness while its spawn rolled back`);
959
+ }
960
+
961
+ s.pty = null;
962
+
963
+ this.remove(s);
964
+ this.emitChange();
965
+
966
+ await this.writeFleet();
967
+ } finally {
968
+ this.rollingBack.delete(id);
969
+ }
970
+ }
971
+
972
+ // Kills a failed spawn's harness and waits for its exit. A kill that
973
+ // throws or an exit it cannot confirm leaves the harness recorded as
974
+ // possibly running, which blocks revives of the session.
975
+ private async killFailedSpawn(id: SessionID, pty: HarnessHandle): Promise<void> {
976
+ this.unconfirmedKills.set(id, pty);
977
+ pty.kill();
978
+
979
+ const exited = await this.waitForKilledExit(pty);
980
+
981
+ if (!exited) {
982
+ throw new Error(`session ${id} did not exit after its kill`);
983
+ }
984
+
985
+ this.unconfirmedKills.delete(id);
986
+ }
987
+
988
+ // Waits for a killed harness to exit, ending it with a signal it cannot
989
+ // ignore when the first wait runs out and its provider can send one.
990
+ private async waitForKilledExit(pty: HarnessHandle): Promise<boolean> {
991
+ const exited = await pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
992
+
993
+ if (exited) {
994
+ return true;
995
+ }
996
+
997
+ if (pty.killForced === undefined) {
998
+ return false;
999
+ }
1000
+
1001
+ pty.killForced();
1002
+
1003
+ return pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
1004
+ }
1005
+
1006
+ // Whether a failed spawn's rollback leaves the session free to revive:
1007
+ // none is running, and any process it killed is gone now. A session whose
1008
+ // killed process may still run stays blocked until a later check finds it
1009
+ // gone.
1010
+ private async isRollbackSettled(id: SessionID): Promise<boolean> {
1011
+ if (this.rollingBack.has(id)) {
1012
+ return false;
1013
+ }
1014
+
1015
+ const killed = this.unconfirmedKills.get(id);
1016
+
1017
+ if (killed !== undefined) {
1018
+ const gone = await killed.waitForExit(0);
1019
+
1020
+ if (!gone) {
1021
+ return false;
1022
+ }
1023
+
1024
+ this.unconfirmedKills.delete(id);
1025
+ }
1026
+
1027
+ return true;
913
1028
  }
914
1029
 
915
1030
  collectDescriptors(): SessionDescriptor[] {
@@ -1147,7 +1262,8 @@ export class SessionManager {
1147
1262
  // every harness on it kept inside, and fails whole when the host stays
1148
1263
  // awake. A dead session on a target that can destroy its host is not
1149
1264
  // forgotten by a kill: forgetting it destroys the host, which takes a
1150
- // confirmed forget.
1265
+ // confirmed forget. The set is taken before the first await, so a
1266
+ // sub-session spawned while the kill waits on a host is not part of it.
1151
1267
  async kill(id: SessionID): Promise<void> {
1152
1268
  const s = this.sessions.find((x) => x.id === id);
1153
1269
 
@@ -1155,11 +1271,15 @@ export class SessionManager {
1155
1271
  return;
1156
1272
  }
1157
1273
 
1274
+ const children = this.collectChildren(id);
1275
+
1158
1276
  if (s.pty) {
1159
1277
  await this.stopHarness(s);
1160
1278
 
1161
- for (const child of this.collectChildren(id)) {
1162
- await this.tryStopHarness(child);
1279
+ for (const child of children) {
1280
+ if (this.sessions.includes(child) && child.parent === id) {
1281
+ await this.tryStopHarness(child);
1282
+ }
1163
1283
  }
1164
1284
  } else {
1165
1285
  if (this.findProvider(s)?.capabilities.destroy === true) {
@@ -1170,8 +1290,7 @@ export class SessionManager {
1170
1290
  );
1171
1291
  }
1172
1292
 
1173
- this.updateForgottenChildren(id);
1174
- this.remove(s);
1293
+ this.removeWithChildren(s, children);
1175
1294
  }
1176
1295
 
1177
1296
  await this.writeFleet();
@@ -1187,7 +1306,9 @@ export class SessionManager {
1187
1306
  * kept asleep in that host is refused until the host wakes. Its dead
1188
1307
  * sub-sessions on other hosts go with it, unless their own target can
1189
1308
  * destroy their host, and its live ones become top-level. A failed destroy
1190
- * throws before anything is forgotten.
1309
+ * throws before anything is forgotten. The sub-sessions it may forget are
1310
+ * taken before the first await; one spawned while the forget waits on a
1311
+ * host becomes top-level.
1191
1312
  */
1192
1313
  async forget(id: SessionID): Promise<boolean> {
1193
1314
  const s = this.sessions.find((x) => x.id === id);
@@ -1196,6 +1317,7 @@ export class SessionManager {
1196
1317
  return false;
1197
1318
  }
1198
1319
 
1320
+ const children = this.collectChildren(id);
1199
1321
  const provider = this.findProvider(s);
1200
1322
  const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
1201
1323
 
@@ -1229,8 +1351,7 @@ export class SessionManager {
1229
1351
  this.killTerminal(s);
1230
1352
  }
1231
1353
 
1232
- this.updateForgottenChildren(id);
1233
- this.remove(s);
1354
+ this.removeWithChildren(s, children);
1234
1355
 
1235
1356
  await this.writeFleet();
1236
1357
 
@@ -1239,22 +1360,42 @@ export class SessionManager {
1239
1360
  return destroys;
1240
1361
  }
1241
1362
 
1242
- // A forgotten parent's dead sub-sessions go with it, except one whose own
1243
- // target can destroy its host: forgetting that one destroys the host, which
1244
- // takes its own confirmed forget. Every sub-session that stays becomes
1245
- // top-level.
1246
- private updateForgottenChildren(id: SessionID): void {
1247
- for (const child of this.collectChildren(id)) {
1363
+ // Removes a forgotten session and its dead sub-sessions, except one whose
1364
+ // own target can destroy its host: forgetting that one destroys the host,
1365
+ // which takes its own confirmed forget. Only a sub-session among the given
1366
+ // ones may go. Every sub-session that stays becomes top-level before the
1367
+ // session's removal is announced, and is announced after it, so no
1368
+ // announcement ever finds a sub-session whose parent is gone or a parent
1369
+ // standing without the sub-sessions that leave with it.
1370
+ private removeWithChildren(s: Session, forgettable: readonly Session[]): void {
1371
+ const kept: Session[] = [];
1372
+ const dropped: Session[] = [];
1373
+
1374
+ for (const child of this.collectChildren(s.id)) {
1248
1375
  const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
1249
1376
 
1250
- if (live || this.findProvider(child)?.capabilities.destroy === true) {
1377
+ if (
1378
+ live ||
1379
+ !forgettable.includes(child) ||
1380
+ this.findProvider(child)?.capabilities.destroy === true
1381
+ ) {
1251
1382
  child.parent = null;
1252
1383
 
1253
- this.onEvent('state', child);
1384
+ kept.push(child);
1254
1385
  } else {
1255
- this.remove(child);
1386
+ dropped.push(child);
1256
1387
  }
1257
1388
  }
1389
+
1390
+ this.remove(s);
1391
+
1392
+ for (const child of kept) {
1393
+ this.onEvent('state', child);
1394
+ }
1395
+
1396
+ for (const child of dropped) {
1397
+ this.remove(child);
1398
+ }
1258
1399
  }
1259
1400
 
1260
1401
  /**
@@ -1482,95 +1623,6 @@ export class SessionManager {
1482
1623
  }
1483
1624
  }
1484
1625
 
1485
- export function countSessionStates(
1486
- list: readonly { readonly state: SessionState }[],
1487
- ): Record<SessionState, number> {
1488
- const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
1489
-
1490
- for (const s of list) {
1491
- c[s.state]++;
1492
- }
1493
-
1494
- return c;
1495
- }
1496
-
1497
- interface SortableSessionView {
1498
- readonly id: string;
1499
- readonly parent: string | null;
1500
- readonly state: SessionState;
1501
- readonly pinned: boolean;
1502
- readonly lastAttachedAt: number;
1503
- readonly createdAt: number;
1504
- }
1505
-
1506
- // Overlay order: pinned sessions first in most-recently-attached order, then
1507
- // everyone else by urgency — who needs you, finished turns, busy, dead —
1508
- // with most-recently-attached breaking ties inside each state. A
1509
- // sub-session sits directly under its parent, ranked among its siblings
1510
- // alone, so its attention never moves the parent's row; a sub-session whose
1511
- // parent is not listed ranks as a top-level row.
1512
- export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
1513
- const rank: Record<SessionState, number> = {
1514
- needs_you: 0,
1515
- done: 1,
1516
- running: 2,
1517
- exited: 3,
1518
- };
1519
-
1520
- const ranked = [...list].toSorted((a, b) => {
1521
- if (a.pinned !== b.pinned) {
1522
- return a.pinned ? -1 : 1;
1523
- }
1524
-
1525
- const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
1526
-
1527
- return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
1528
- });
1529
-
1530
- const listed = new Set(ranked.map((s) => s.id));
1531
-
1532
- const sorted: T[] = [];
1533
-
1534
- for (const s of ranked) {
1535
- if (s.parent !== null && listed.has(s.parent)) {
1536
- continue;
1537
- }
1538
-
1539
- sorted.push(s, ...ranked.filter((child) => child.parent === s.id));
1540
- }
1541
-
1542
- return sorted;
1543
- }
1544
-
1545
- // Never a filesystem path, so a repository can't collide with it.
1546
- export const PINNED_GROUP_KEY = ' pinned';
1547
-
1548
- // Overlay display order for the grouped view: the flat sort with each
1549
- // repository's sessions pulled together at the position of its best-ranked
1550
- // member, so the renderer's adjacency-based headers appear once per group.
1551
- // Pinned sessions form their own leading group. A sub-session keys by its
1552
- // parent, so a set never splits across groups.
1553
- export function sortGroupedSessionViews<
1554
- T extends SortableSessionView & { readonly repoRoot: string },
1555
- >(list: readonly T[]): T[] {
1556
- const byID = new Map(list.map((s) => [s.id, s]));
1557
- const buckets = new Map<string, T[]>();
1558
-
1559
- for (const s of sortSessionViews(list)) {
1560
- const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
1561
- const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
1562
- const bucket = buckets.get(key);
1563
-
1564
- if (bucket === undefined) {
1565
- buckets.set(key, [s]);
1566
- } else {
1567
- bucket.push(s);
1568
- }
1569
- }
1570
-
1571
- return [...buckets.values()].flat();
1572
- }
1573
-
1574
1626
  function buildLifecycle(s: Session): SessionLifecycle {
1575
1627
  return buildSessionLifecycle({
1576
1628
  desired: s.desired,
@@ -1,9 +1,9 @@
1
- import type { AdapterEvent } from '../agents/agent-adapter';
2
- import { truncateDetail } from '../agents/truncate-detail';
1
+ import type { AdapterEvent } from '../protocol/adapter-event';
3
2
  import type { SessionID } from '../shared/session-id';
3
+ import { truncateDetail } from '../shared/truncate-detail';
4
+ import { truncateSummary } from '../shared/truncate-summary';
4
5
  import type { SessionRuntime } from './session-runtime';
5
6
  import type { SessionManager } from './sessions';
6
- import { truncateSummary } from './truncate-summary';
7
7
 
8
8
  /**
9
9
  * Starts one headless turn for a session: finds its adapter's headless
@@ -1,12 +1,12 @@
1
1
  import { z } from 'zod';
2
+ import type { HookEvent } from '../protocol/hook-event';
2
3
  import type { EventMsg } from '../protocol/protocol';
3
4
  import type { MessageID } from '../shared/message-id';
4
5
  import type { SessionID } from '../shared/session-id';
5
6
  import { toMessageID } from '../shared/to-message-id';
6
7
  import type { MessageRecord } from '../store/message-record';
7
- import type { TapClient } from './daemon-connection';
8
+ import type { TapClient } from './daemon-context';
8
9
  import type { HarnessRelay } from './execution-provider';
9
- import type { HookEvent } from './hooks';
10
10
  import { isBindingCurrent } from './is-binding-current';
11
11
  import type { BridgeBinding } from './is-binding-current';
12
12
  import { parseHookLine } from './parse-hook-line';
@@ -4,6 +4,23 @@ import type { SessionID } from '../shared/session-id';
4
4
  interface TapEntry<TClient> {
5
5
  readonly client: TClient;
6
6
  readonly claimed: Set<MessageID>;
7
+
8
+ // Whether the tap also takes messages held under the session's agent
9
+ // session id, as opposed to its atc id alone.
10
+ readonly linked: boolean;
11
+
12
+ // Which attach made this tap; every attach makes a new one.
13
+ readonly generation: number;
14
+ }
15
+
16
+ /**
17
+ * A session's tap as a drain finds it before it waits: the attach that made
18
+ * it and whether it is linked. A delivery made for it lands only while the
19
+ * same attach holds the tap.
20
+ */
21
+ export interface TapGeneration {
22
+ readonly generation: number;
23
+ readonly linked: boolean;
7
24
  }
8
25
 
9
26
  /**
@@ -13,14 +30,24 @@ interface TapEntry<TClient> {
13
30
  export class TapRegistry<TClient> {
14
31
  private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
15
32
 
33
+ private nextGeneration = 0;
34
+
16
35
  /**
17
36
  * Makes the client the session's tap and returns the client it displaced,
18
- * or null when there was none or it is the same connection.
37
+ * or null when there was none or it is the same connection. A linked tap
38
+ * also takes the messages held under the session's agent session id.
19
39
  */
20
- attach(sessionID: SessionID, client: TClient): TClient | null {
40
+ attach(sessionID: SessionID, client: TClient, linked = true): TClient | null {
21
41
  const previous = this.bySession.get(sessionID)?.client ?? null;
22
42
 
23
- this.bySession.set(sessionID, { client, claimed: new Set() });
43
+ this.nextGeneration += 1;
44
+
45
+ this.bySession.set(sessionID, {
46
+ client,
47
+ claimed: new Set(),
48
+ linked,
49
+ generation: this.nextGeneration,
50
+ });
24
51
 
25
52
  return previous === client ? null : previous;
26
53
  }
@@ -45,6 +72,12 @@ export class TapRegistry<TClient> {
45
72
  return previous;
46
73
  }
47
74
 
75
+ findTap(sessionID: SessionID): TapGeneration | null {
76
+ const entry = this.bySession.get(sessionID);
77
+
78
+ return entry === undefined ? null : { generation: entry.generation, linked: entry.linked };
79
+ }
80
+
48
81
  hasTap(sessionID: SessionID): boolean {
49
82
  return this.bySession.has(sessionID);
50
83
  }
@@ -53,10 +86,15 @@ export class TapRegistry<TClient> {
53
86
  return this.bySession.get(sessionID)?.client === client;
54
87
  }
55
88
 
56
- claimDelivery(sessionID: SessionID, messageID: MessageID): TClient | null {
89
+ /**
90
+ * Claims the message for the session's tap and returns its client, or
91
+ * null when the tap already took it or another attach replaced the tap
92
+ * the delivery was made for.
93
+ */
94
+ claimDelivery(sessionID: SessionID, messageID: MessageID, generation: number): TClient | null {
57
95
  const entry = this.bySession.get(sessionID);
58
96
 
59
- if (entry === undefined || entry.claimed.has(messageID)) {
97
+ if (entry === undefined || entry.generation !== generation || entry.claimed.has(messageID)) {
60
98
  return null;
61
99
  }
62
100
 
@@ -1,6 +1,6 @@
1
1
  import type { Kysely } from 'kysely';
2
2
  import { collectJSONStrings } from './collect-json-strings';
3
- import type { MCPAuthSchema } from './types';
3
+ import type { MCPAuthSchema } from './mcp-auth-schema';
4
4
 
5
5
  /**
6
6
  * A client added with `atc clients add`.
@@ -2,7 +2,7 @@ import type { Kysely } from 'kysely';
2
2
  import { GRANT_SCOPES } from '../shared/grant-scope';
3
3
  import type { GrantScope } from '../shared/grant-scope';
4
4
  import { collectJSONStrings } from './collect-json-strings';
5
- import type { MCPAuthSchema } from './types';
5
+ import type { MCPAuthSchema } from './mcp-auth-schema';
6
6
 
7
7
  /**
8
8
  * One authorization a client holds: every refresh token rotated from one
@@ -1,5 +1,5 @@
1
1
  import type { Kysely } from 'kysely';
2
- import type { MCPAuthSchema } from './types';
2
+ import type { MCPAuthSchema } from './mcp-auth-schema';
3
3
 
4
4
  /**
5
5
  * The name a client was added under, or null for an unknown client id or a
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The columns of the authorization server's database that atc reads and
3
+ * writes itself. better-auth owns the tables and their migrations; dates are
4
+ * ISO 8601 strings and string lists are JSON arrays.
5
+ */
6
+ export interface MCPAuthSchema {
7
+ readonly oauthClient: {
8
+ readonly id: string;
9
+ readonly clientId: string;
10
+ readonly name: string | null;
11
+ readonly redirectUris: string;
12
+ readonly createdAt: string | null;
13
+ };
14
+ readonly oauthAccessToken: {
15
+ readonly id: string;
16
+ readonly token: string;
17
+ readonly clientId: string;
18
+ readonly sessionId: string | null;
19
+ readonly authorizationCodeId: string | null;
20
+ };
21
+ readonly oauthRefreshToken: {
22
+ readonly id: string;
23
+ readonly clientId: string;
24
+ readonly sessionId: string | null;
25
+ readonly authorizationCodeId: string | null;
26
+ readonly scopes: string;
27
+ readonly createdAt: string;
28
+ readonly expiresAt: string;
29
+ readonly revoked: string | null;
30
+ };
31
+ readonly oauthConsent: { readonly id: string; readonly clientId: string };
32
+ readonly oauthResource: { readonly identifier: string };
33
+ readonly session: { readonly id: string; readonly expiresAt: string };
34
+
35
+ // When each grant last reached /mcp, keyed by the grant's authorization code id.
36
+ readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
37
+ }
@@ -8,11 +8,11 @@ import { betterAuth } from 'better-auth';
8
8
  import type { BetterAuthPlugin } from 'better-auth';
9
9
  import { getMigrations } from 'better-auth/db/migration';
10
10
  import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
11
+ import { BunSqliteDriver } from '../shared/bun-sqlite-driver';
11
12
  import { GRANT_SCOPES } from '../shared/grant-scope';
12
- import { BunSqliteDriver } from '../store/bun-sqlite-driver';
13
13
  import { buildOwnerPlugin } from './build-owner-plugin';
14
14
  import { deriveTokenHash } from './derive-token-hash';
15
- import type { MCPAuthSchema } from './types';
15
+ import type { MCPAuthSchema } from './mcp-auth-schema';
16
16
 
17
17
  interface MCPAuthOptions {
18
18
  readonly dbPath: string;