@zgeoff/atc 2.15.0 → 2.15.1

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 (60) 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-message-trail-entry.ts +1 -1
  19. package/src/daemon/build-report-trail-entry.ts +1 -1
  20. package/src/daemon/build-scoped-context.ts +1 -1
  21. package/src/daemon/build-session-message-event.ts +1 -1
  22. package/src/daemon/daemon-connection.ts +4 -237
  23. package/src/daemon/daemon-context.ts +240 -0
  24. package/src/daemon/daemon.ts +9 -5
  25. package/src/daemon/execution-provider.ts +12 -0
  26. package/src/daemon/hooks.ts +1 -7
  27. package/src/daemon/idempotency-ledger.ts +78 -18
  28. package/src/daemon/imp-harness.ts +41 -0
  29. package/src/daemon/local-pty-provider.ts +45 -0
  30. package/src/daemon/parse-hook-line.ts +1 -1
  31. package/src/daemon/sessions.ts +116 -104
  32. package/src/daemon/start-headless-turn.ts +3 -3
  33. package/src/daemon/start-session-bridge.ts +2 -2
  34. package/src/mcp/collect-clients.ts +1 -1
  35. package/src/mcp/collect-grants.ts +1 -1
  36. package/src/mcp/find-client-name.ts +1 -1
  37. package/src/mcp/mcp-auth-schema.ts +37 -0
  38. package/src/mcp/open-mcp-auth.ts +2 -2
  39. package/src/mcp/reconnecting-caller.ts +15 -6
  40. package/src/mcp/remove-client.ts +1 -1
  41. package/src/mcp/revoke-grant.ts +1 -1
  42. package/src/mcp/types.ts +0 -38
  43. package/src/mcp-http-server.ts +2 -1
  44. package/src/protocol/adapter-event.ts +25 -0
  45. package/src/protocol/count-session-states.ts +16 -0
  46. package/src/protocol/daemon-channel.ts +17 -0
  47. package/src/protocol/hook-event.ts +11 -0
  48. package/src/protocol/pinned-group-key.ts +2 -0
  49. package/src/protocol/session-state.ts +5 -0
  50. package/src/protocol/sort-grouped-session-views.ts +29 -0
  51. package/src/protocol/sort-session-views.ts +41 -0
  52. package/src/protocol/sortable-session-view.ts +13 -0
  53. package/src/shared/agent-id.ts +6 -0
  54. package/src/shared/collect-gateways.ts +1 -1
  55. package/src/shared/to-agent-id.ts +11 -0
  56. package/src/store/fleet-entry.ts +2 -2
  57. package/src/store/state-store.ts +5 -4
  58. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  59. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  60. /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;
@@ -475,6 +487,12 @@ export class SessionManager {
475
487
  return null;
476
488
  }
477
489
 
490
+ const settled = await this.isRollbackSettled(id);
491
+
492
+ if (!settled) {
493
+ return null;
494
+ }
495
+
478
496
  const adapter = this.findAdapter(s.agent);
479
497
 
480
498
  if (adapter === null) {
@@ -498,8 +516,14 @@ export class SessionManager {
498
516
  this.adopting.delete(id);
499
517
  }
500
518
 
501
- // A kill or a second adopt can land while the host wakes.
502
- if (s.pty !== null || !this.sessions.includes(s)) {
519
+ // A kill, a second adopt, or a failed spawn's rollback can land while
520
+ // the host wakes.
521
+ if (
522
+ s.pty !== null ||
523
+ !this.sessions.includes(s) ||
524
+ this.rollingBack.has(id) ||
525
+ this.unconfirmedKills.has(id)
526
+ ) {
503
527
  return null;
504
528
  }
505
529
 
@@ -893,8 +917,11 @@ export class SessionManager {
893
917
 
894
918
  // Takes back a spawn that failed after its process started: the process
895
919
  // 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
920
+ // nothing behind. It resolves only once the process has exited and the
921
+ // fleet without the session is durable, and throws when the kill, the
922
+ // exit, or that write cannot be confirmed. A session whose exit is not
923
+ // confirmed stays listed, so the spawn's caller can still find it, and
924
+ // no revive starts it until its process is found gone. A session that
898
925
  // never registered is left alone.
899
926
  async removeFailedSpawn(id: SessionID): Promise<void> {
900
927
  const s = this.sessions.find((x) => x.id === id);
@@ -903,13 +930,87 @@ export class SessionManager {
903
930
  return;
904
931
  }
905
932
 
906
- s.pty?.kill();
907
- s.pty = null;
933
+ const pty = s.pty;
908
934
 
909
- this.remove(s);
910
- this.emitChange();
935
+ this.rollingBack.add(id);
911
936
 
912
- await this.writeFleet();
937
+ try {
938
+ if (pty !== null) {
939
+ await this.killFailedSpawn(id, pty);
940
+ }
941
+
942
+ // A harness other than the killed one would outlive the rollback.
943
+ if (s.pty !== null && s.pty !== pty) {
944
+ throw new Error(`session ${id} started another harness while its spawn rolled back`);
945
+ }
946
+
947
+ s.pty = null;
948
+
949
+ this.remove(s);
950
+ this.emitChange();
951
+
952
+ await this.writeFleet();
953
+ } finally {
954
+ this.rollingBack.delete(id);
955
+ }
956
+ }
957
+
958
+ // Kills a failed spawn's harness and waits for its exit. A kill that
959
+ // throws or an exit it cannot confirm leaves the harness recorded as
960
+ // possibly running, which blocks revives of the session.
961
+ private async killFailedSpawn(id: SessionID, pty: HarnessHandle): Promise<void> {
962
+ this.unconfirmedKills.set(id, pty);
963
+ pty.kill();
964
+
965
+ const exited = await this.waitForKilledExit(pty);
966
+
967
+ if (!exited) {
968
+ throw new Error(`session ${id} did not exit after its kill`);
969
+ }
970
+
971
+ this.unconfirmedKills.delete(id);
972
+ }
973
+
974
+ // Waits for a killed harness to exit, ending it with a signal it cannot
975
+ // ignore when the first wait runs out and its provider can send one.
976
+ private async waitForKilledExit(pty: HarnessHandle): Promise<boolean> {
977
+ const exited = await pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
978
+
979
+ if (exited) {
980
+ return true;
981
+ }
982
+
983
+ if (pty.killForced === undefined) {
984
+ return false;
985
+ }
986
+
987
+ pty.killForced();
988
+
989
+ return pty.waitForExit(FAILED_SPAWN_EXIT_WAIT_MS);
990
+ }
991
+
992
+ // Whether a failed spawn's rollback leaves the session free to revive:
993
+ // none is running, and any process it killed is gone now. A session whose
994
+ // killed process may still run stays blocked until a later check finds it
995
+ // gone.
996
+ private async isRollbackSettled(id: SessionID): Promise<boolean> {
997
+ if (this.rollingBack.has(id)) {
998
+ return false;
999
+ }
1000
+
1001
+ const killed = this.unconfirmedKills.get(id);
1002
+
1003
+ if (killed !== undefined) {
1004
+ const gone = await killed.waitForExit(0);
1005
+
1006
+ if (!gone) {
1007
+ return false;
1008
+ }
1009
+
1010
+ this.unconfirmedKills.delete(id);
1011
+ }
1012
+
1013
+ return true;
913
1014
  }
914
1015
 
915
1016
  collectDescriptors(): SessionDescriptor[] {
@@ -1482,95 +1583,6 @@ export class SessionManager {
1482
1583
  }
1483
1584
  }
1484
1585
 
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
1586
  function buildLifecycle(s: Session): SessionLifecycle {
1575
1587
  return buildSessionLifecycle({
1576
1588
  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';
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { DaemonClient } from '../client/daemon-client';
2
+ import type { DaemonChannel } from '../protocol/daemon-channel';
3
3
  import type { DaemonFeature } from '../protocol/daemon-features';
4
4
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
5
5
  import { requireDaemonFeatures } from './require-daemon-features';
@@ -30,7 +30,7 @@ const KEYED_METHODS: ReadonlyMap<string, DaemonFeature> = new Map<string, Daemon
30
30
 
31
31
  // One handshaken connection and the features its daemon announced.
32
32
  interface DaemonConnection {
33
- readonly client: DaemonClient;
33
+ readonly client: DaemonChannel;
34
34
  readonly features: ReadonlySet<DaemonFeature>;
35
35
  }
36
36
 
@@ -48,13 +48,22 @@ export class ReconnectingCaller implements FleetCaller {
48
48
 
49
49
  private readonly build: string;
50
50
 
51
+ private readonly openChannel: (socketPath: string) => Promise<DaemonChannel>;
52
+
51
53
  private client: Promise<DaemonConnection> | null = null;
52
54
 
53
- private readonly closed = new WeakSet<DaemonClient>();
55
+ private readonly closed = new WeakSet<DaemonChannel>();
54
56
 
55
- constructor(socketPath: string, build: string) {
57
+ // The last argument connects to the daemon at a socket path; each channel
58
+ // it returns is handshaken here before a request rides it.
59
+ constructor(
60
+ socketPath: string,
61
+ build: string,
62
+ openChannel: (socketPath: string) => Promise<DaemonChannel>,
63
+ ) {
56
64
  this.socketPath = socketPath;
57
65
  this.build = build;
66
+ this.openChannel = openChannel;
58
67
  }
59
68
 
60
69
  // The required features are checked against each connection right before
@@ -141,10 +150,10 @@ export class ReconnectingCaller implements FleetCaller {
141
150
  }
142
151
  };
143
152
 
144
- let client: DaemonClient;
153
+ let client: DaemonChannel;
145
154
 
146
155
  try {
147
- client = await DaemonClient.open(this.socketPath);
156
+ client = await this.openChannel(this.socketPath);
148
157
  } catch (error) {
149
158
  resetClient();
150
159
  throw error;
@@ -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
  * Removes a client and everything it holds: its access and refresh tokens,
@@ -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
  * Revokes a grant: its access and refresh tokens go, along with the
package/src/mcp/types.ts CHANGED
@@ -25,44 +25,6 @@ export interface ToolContext {
25
25
  readonly sender: { readonly kind: 'fixed' | 'default'; readonly name: string };
26
26
  }
27
27
 
28
- /**
29
- * The columns of the authorization server's database that atc reads and
30
- * writes itself. better-auth owns the tables and their migrations; dates are
31
- * ISO 8601 strings and string lists are JSON arrays.
32
- */
33
- export interface MCPAuthSchema {
34
- readonly oauthClient: {
35
- readonly id: string;
36
- readonly clientId: string;
37
- readonly name: string | null;
38
- readonly redirectUris: string;
39
- readonly createdAt: string | null;
40
- };
41
- readonly oauthAccessToken: {
42
- readonly id: string;
43
- readonly token: string;
44
- readonly clientId: string;
45
- readonly sessionId: string | null;
46
- readonly authorizationCodeId: string | null;
47
- };
48
- readonly oauthRefreshToken: {
49
- readonly id: string;
50
- readonly clientId: string;
51
- readonly sessionId: string | null;
52
- readonly authorizationCodeId: string | null;
53
- readonly scopes: string;
54
- readonly createdAt: string;
55
- readonly expiresAt: string;
56
- readonly revoked: string | null;
57
- };
58
- readonly oauthConsent: { readonly id: string; readonly clientId: string };
59
- readonly oauthResource: { readonly identifier: string };
60
- readonly session: { readonly id: string; readonly expiresAt: string };
61
-
62
- // When each grant last reached /mcp, keyed by the grant's authorization code id.
63
- readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
64
- }
65
-
66
28
  /**
67
29
  * The authorization server's store: the better-auth instance and the
68
30
  * database under it.
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import { bootDaemonClient } from './client/boot-daemon';
3
+ import { DaemonClient } from './client/daemon-client';
3
4
  import { collectClients } from './mcp/collect-clients';
4
5
  import { openMCPAuth } from './mcp/open-mcp-auth';
5
6
  import { ReconnectingCaller } from './mcp/reconnecting-caller';
@@ -27,7 +28,7 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
27
28
 
28
29
  mkdirSync(stateDir, { recursive: true });
29
30
 
30
- const caller = new ReconnectingCaller(boot.socketPath, build);
31
+ const caller = new ReconnectingCaller(boot.socketPath, build, (path) => DaemonClient.open(path));
31
32
 
32
33
  const server = await startMCPHTTPServer({
33
34
  caller,
@@ -0,0 +1,25 @@
1
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
+
3
+ /**
4
+ * What an adapter makes of one hook event: the agent-neutral kind and the
5
+ * fields the daemon and the store act on.
6
+ */
7
+ export interface AdapterEvent {
8
+ kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
9
+ agentSessionID?: AgentSessionID;
10
+ message?: string;
11
+
12
+ // Fuller activity text than message: what the agent last said or was
13
+ // asked, for briefing. Bounded by the adapter.
14
+ detail?: string;
15
+
16
+ // Opaque handle the adapter can later pull a session name from.
17
+ nameSource?: string;
18
+
19
+ // Claude resume-existence path. Distinct from nameSource: a naming
20
+ // handle is not a resume gate.
21
+ transcriptSource?: string;
22
+
23
+ // The agent's whole final message for a finished turn; detail holds a bounded preview of it.
24
+ result?: string;
25
+ }
@@ -0,0 +1,16 @@
1
+ import type { SessionState } from './session-state';
2
+
3
+ /**
4
+ * How many of the sessions are in each state.
5
+ */
6
+ export function countSessionStates(
7
+ list: readonly { readonly state: SessionState }[],
8
+ ): Record<SessionState, number> {
9
+ const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
10
+
11
+ for (const s of list) {
12
+ c[s.state]++;
13
+ }
14
+
15
+ return c;
16
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * One open protocol connection to a daemon, as a caller that does not own
3
+ * the transport drives it: the handshake, correlated requests, a callback
4
+ * for when the connection ends, and closing it.
5
+ */
6
+ export interface DaemonChannel {
7
+ onClose: () => void;
8
+ readonly sendHello: (build: string) => Promise<Readonly<Record<string, unknown>>>;
9
+
10
+ // A request with a principal acts as that principal.
11
+ readonly sendRequest: (
12
+ m: string,
13
+ p?: Readonly<Record<string, unknown>>,
14
+ as?: string,
15
+ ) => Promise<Readonly<Record<string, unknown>>>;
16
+ readonly stop: () => void;
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+
3
+ /**
4
+ * One line a hook reporter sends: the atc session it reports on, the agent's
5
+ * hook event name, and the hook's payload as the agent gave it.
6
+ */
7
+ export interface HookEvent {
8
+ atcId: SessionID;
9
+ event: string;
10
+ payload: Record<string, unknown>;
11
+ }
@@ -0,0 +1,2 @@
1
+ // Never a filesystem path, so a repository can't collide with it.
2
+ export const PINNED_GROUP_KEY = ' pinned';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Where a session stands for the person watching it: busy, waiting on them,
3
+ * finished with a turn, or ended.
4
+ */
5
+ export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
@@ -0,0 +1,29 @@
1
+ import { PINNED_GROUP_KEY } from './pinned-group-key';
2
+ import { sortSessionViews } from './sort-session-views';
3
+ import type { SortableSessionView } from './sortable-session-view';
4
+
5
+ // Overlay display order for the grouped view: the flat sort with each
6
+ // repository's sessions pulled together at the position of its best-ranked
7
+ // member, so the renderer's adjacency-based headers appear once per group.
8
+ // Pinned sessions form their own leading group. A sub-session keys by its
9
+ // parent, so a set never splits across groups.
10
+ export function sortGroupedSessionViews<
11
+ T extends SortableSessionView & { readonly repoRoot: string },
12
+ >(list: readonly T[]): T[] {
13
+ const byID = new Map(list.map((s) => [s.id, s]));
14
+ const buckets = new Map<string, T[]>();
15
+
16
+ for (const s of sortSessionViews(list)) {
17
+ const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
18
+ const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
19
+ const bucket = buckets.get(key);
20
+
21
+ if (bucket === undefined) {
22
+ buckets.set(key, [s]);
23
+ } else {
24
+ bucket.push(s);
25
+ }
26
+ }
27
+
28
+ return [...buckets.values()].flat();
29
+ }