@zgeoff/atc 2.14.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 (71) 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 +7 -23
  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-imp-provider.ts +70 -23
  19. package/src/daemon/build-message-trail-entry.ts +1 -1
  20. package/src/daemon/build-report-trail-entry.ts +1 -1
  21. package/src/daemon/build-scoped-context.ts +1 -1
  22. package/src/daemon/build-session-message-event.ts +1 -1
  23. package/src/daemon/daemon-connection.ts +9 -253
  24. package/src/daemon/daemon-context.ts +240 -0
  25. package/src/daemon/daemon.ts +9 -5
  26. package/src/daemon/execution-provider.ts +12 -0
  27. package/src/daemon/hooks.ts +6 -24
  28. package/src/daemon/idempotency-ledger.ts +78 -18
  29. package/src/daemon/imp-client-port.ts +83 -27
  30. package/src/daemon/imp-harness.ts +41 -0
  31. package/src/daemon/imp-provider.ts +5 -11
  32. package/src/daemon/local-pty-provider.ts +45 -0
  33. package/src/daemon/parse-hook-line.ts +1 -1
  34. package/src/daemon/read-imp-token.ts +30 -0
  35. package/src/daemon/sessions.ts +134 -107
  36. package/src/daemon/start-headless-turn.ts +3 -3
  37. package/src/daemon/start-session-bridge.ts +2 -2
  38. package/src/mcp/collect-clients.ts +1 -1
  39. package/src/mcp/collect-grants.ts +1 -1
  40. package/src/mcp/find-client-name.ts +1 -1
  41. package/src/mcp/mcp-auth-schema.ts +37 -0
  42. package/src/mcp/open-mcp-auth.ts +2 -2
  43. package/src/mcp/reconnecting-caller.ts +15 -6
  44. package/src/mcp/remove-client.ts +1 -1
  45. package/src/mcp/revoke-grant.ts +1 -1
  46. package/src/mcp/types.ts +0 -38
  47. package/src/mcp-http-server.ts +2 -1
  48. package/src/mcp-server.ts +3 -14
  49. package/src/protocol/adapter-event.ts +25 -0
  50. package/src/protocol/count-session-states.ts +16 -0
  51. package/src/protocol/daemon-channel.ts +17 -0
  52. package/src/protocol/hook-event.ts +11 -0
  53. package/src/protocol/line-decoder.ts +34 -0
  54. package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
  55. package/src/protocol/pinned-group-key.ts +2 -0
  56. package/src/protocol/session-state.ts +5 -0
  57. package/src/protocol/sort-grouped-session-views.ts +29 -0
  58. package/src/protocol/sort-session-views.ts +41 -0
  59. package/src/protocol/sortable-session-view.ts +13 -0
  60. package/src/report.ts +1 -1
  61. package/src/run-bridge-tap.ts +2 -2
  62. package/src/shared/agent-id.ts +6 -0
  63. package/src/shared/collect-gateways.ts +1 -1
  64. package/src/shared/to-agent-id.ts +11 -0
  65. package/src/statusline.ts +1 -1
  66. package/src/store/fleet-entry.ts +6 -3
  67. package/src/store/state-store.ts +80 -19
  68. /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
  69. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  70. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  71. /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 {
@@ -232,6 +237,17 @@ export class SessionManager {
232
237
  // of the same session does not start a second harness.
233
238
  private readonly adopting = new Set<SessionID>();
234
239
 
240
+ // Sessions dropped from the list on purpose whose rows the next fleet
241
+ // write deletes; each stays here until a write carrying it lands.
242
+ private readonly removedIDs = new Set<SessionID>();
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
+
235
251
  // The epoch the next harness start or attach takes, unique across every
236
252
  // session this manager holds.
237
253
  private nextBridgeEpoch = 1;
@@ -471,6 +487,12 @@ export class SessionManager {
471
487
  return null;
472
488
  }
473
489
 
490
+ const settled = await this.isRollbackSettled(id);
491
+
492
+ if (!settled) {
493
+ return null;
494
+ }
495
+
474
496
  const adapter = this.findAdapter(s.agent);
475
497
 
476
498
  if (adapter === null) {
@@ -494,8 +516,14 @@ export class SessionManager {
494
516
  this.adopting.delete(id);
495
517
  }
496
518
 
497
- // A kill or a second adopt can land while the host wakes.
498
- 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
+ ) {
499
527
  return null;
500
528
  }
501
529
 
@@ -889,8 +917,11 @@ export class SessionManager {
889
917
 
890
918
  // Takes back a spawn that failed after its process started: the process
891
919
  // dies and the session leaves the list and the fleet, so the spawn leaves
892
- // nothing behind. It resolves only once the fleet without the session is
893
- // 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
894
925
  // never registered is left alone.
895
926
  async removeFailedSpawn(id: SessionID): Promise<void> {
896
927
  const s = this.sessions.find((x) => x.id === id);
@@ -899,13 +930,87 @@ export class SessionManager {
899
930
  return;
900
931
  }
901
932
 
902
- s.pty?.kill();
903
- s.pty = null;
933
+ const pty = s.pty;
904
934
 
905
- this.remove(s);
906
- this.emitChange();
935
+ this.rollingBack.add(id);
907
936
 
908
- 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;
909
1014
  }
910
1015
 
911
1016
  collectDescriptors(): SessionDescriptor[] {
@@ -1350,6 +1455,8 @@ export class SessionManager {
1350
1455
  private remove(s: Session) {
1351
1456
  this.sessions = this.sessions.filter((x) => x.id !== s.id);
1352
1457
 
1458
+ this.removedIDs.add(s.id);
1459
+
1353
1460
  if (this.focusedId === s.id) {
1354
1461
  this.focusedId = null;
1355
1462
  }
@@ -1383,8 +1490,11 @@ export class SessionManager {
1383
1490
  }
1384
1491
 
1385
1492
  // Persisted continuously so a crash (or quit) leaves a restorable fleet.
1386
- // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
1387
- // so the last known fleet survives for `R` restore. Sessions whose
1493
+ // A write covers the listed sessions and the ones dropped on purpose
1494
+ // since the last write; a stored row the daemon has not restored yet
1495
+ // stays as it is, so a write before `R` keeps it restorable. Deliberate
1496
+ // kills rewrite the fleet; unexpected session/atc deaths do not, so the
1497
+ // last known fleet survives for `R` restore. Sessions whose
1388
1498
  // terminal is gone persist as exited entries, so the killed archive
1389
1499
  // survives a daemon restart too. A session the agent has not yet given
1390
1500
  // its own session id persists as well, under its atc session id alone.
@@ -1416,7 +1526,13 @@ export class SessionManager {
1416
1526
  });
1417
1527
  }
1418
1528
 
1419
- await this.store.writeFleet(fleet);
1529
+ const removed = [...this.removedIDs];
1530
+
1531
+ await this.store.writeFleet(fleet, removed);
1532
+
1533
+ for (const id of removed) {
1534
+ this.removedIDs.delete(id);
1535
+ }
1420
1536
  }
1421
1537
 
1422
1538
  // A write nobody awaits that fails leaves the stored rows as they were,
@@ -1467,95 +1583,6 @@ export class SessionManager {
1467
1583
  }
1468
1584
  }
1469
1585
 
1470
- export function countSessionStates(
1471
- list: readonly { readonly state: SessionState }[],
1472
- ): Record<SessionState, number> {
1473
- const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
1474
-
1475
- for (const s of list) {
1476
- c[s.state]++;
1477
- }
1478
-
1479
- return c;
1480
- }
1481
-
1482
- interface SortableSessionView {
1483
- readonly id: string;
1484
- readonly parent: string | null;
1485
- readonly state: SessionState;
1486
- readonly pinned: boolean;
1487
- readonly lastAttachedAt: number;
1488
- readonly createdAt: number;
1489
- }
1490
-
1491
- // Overlay order: pinned sessions first in most-recently-attached order, then
1492
- // everyone else by urgency — who needs you, finished turns, busy, dead —
1493
- // with most-recently-attached breaking ties inside each state. A
1494
- // sub-session sits directly under its parent, ranked among its siblings
1495
- // alone, so its attention never moves the parent's row; a sub-session whose
1496
- // parent is not listed ranks as a top-level row.
1497
- export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
1498
- const rank: Record<SessionState, number> = {
1499
- needs_you: 0,
1500
- done: 1,
1501
- running: 2,
1502
- exited: 3,
1503
- };
1504
-
1505
- const ranked = [...list].toSorted((a, b) => {
1506
- if (a.pinned !== b.pinned) {
1507
- return a.pinned ? -1 : 1;
1508
- }
1509
-
1510
- const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
1511
-
1512
- return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
1513
- });
1514
-
1515
- const listed = new Set(ranked.map((s) => s.id));
1516
-
1517
- const sorted: T[] = [];
1518
-
1519
- for (const s of ranked) {
1520
- if (s.parent !== null && listed.has(s.parent)) {
1521
- continue;
1522
- }
1523
-
1524
- sorted.push(s, ...ranked.filter((child) => child.parent === s.id));
1525
- }
1526
-
1527
- return sorted;
1528
- }
1529
-
1530
- // Never a filesystem path, so a repository can't collide with it.
1531
- export const PINNED_GROUP_KEY = ' pinned';
1532
-
1533
- // Overlay display order for the grouped view: the flat sort with each
1534
- // repository's sessions pulled together at the position of its best-ranked
1535
- // member, so the renderer's adjacency-based headers appear once per group.
1536
- // Pinned sessions form their own leading group. A sub-session keys by its
1537
- // parent, so a set never splits across groups.
1538
- export function sortGroupedSessionViews<
1539
- T extends SortableSessionView & { readonly repoRoot: string },
1540
- >(list: readonly T[]): T[] {
1541
- const byID = new Map(list.map((s) => [s.id, s]));
1542
- const buckets = new Map<string, T[]>();
1543
-
1544
- for (const s of sortSessionViews(list)) {
1545
- const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
1546
- const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
1547
- const bucket = buckets.get(key);
1548
-
1549
- if (bucket === undefined) {
1550
- buckets.set(key, [s]);
1551
- } else {
1552
- bucket.push(s);
1553
- }
1554
- }
1555
-
1556
- return [...buckets.values()].flat();
1557
- }
1558
-
1559
1586
  function buildLifecycle(s: Session): SessionLifecycle {
1560
1587
  return buildSessionLifecycle({
1561
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,
package/src/mcp-server.ts CHANGED
@@ -2,6 +2,7 @@ import { bootDaemonClient } from './client/boot-daemon';
2
2
  import { answerRPCRequest } from './mcp/answer-rpc-request';
3
3
  import { requireDaemonFeatures } from './mcp/require-daemon-features';
4
4
  import type { FleetCaller, ToolContext } from './mcp/types';
5
+ import { LineDecoder } from './protocol/line-decoder';
5
6
 
6
7
  /**
7
8
  * An MCP server over stdio bridging to the atc daemon: any MCP client —
@@ -34,26 +35,14 @@ export async function runMCPServer(build: string): Promise<void> {
34
35
  sender: { kind: 'default', name: callerSessionID ?? 'mcp' },
35
36
  };
36
37
 
37
- const decoder = new TextDecoder('utf-8');
38
-
39
- let buffer = '';
38
+ const decoder = new LineDecoder();
40
39
 
41
40
  // Each request runs on its own, so a long poll never holds up the others;
42
41
  // responses carry their request's id.
43
42
  const inFlight = new Set<Promise<void>>();
44
43
 
45
44
  for await (const chunk of Bun.stdin.stream()) {
46
- buffer += decoder.decode(chunk, { stream: true });
47
-
48
- const lines = buffer.split('\n');
49
-
50
- buffer = lines.pop() ?? '';
51
-
52
- for (const line of lines) {
53
- if (line.trim() === '') {
54
- continue;
55
- }
56
-
45
+ for (const line of decoder.splitChunk(chunk)) {
57
46
  const finished = Promise.withResolvers<void>();
58
47
 
59
48
  inFlight.add(finished.promise);
@@ -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
+ }