@zgeoff/atc 0.1.12 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +50 -12
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/spawn-picker.ts +305 -0
  25. package/src/client/to-mirror-session.ts +65 -0
  26. package/src/{ui.ts → client/ui.ts} +13 -8
  27. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  28. package/src/daemon/build-session-event.ts +43 -0
  29. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  30. package/src/{daemon.ts → daemon/daemon.ts} +142 -312
  31. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  32. package/src/daemon/mint-session-id.ts +12 -0
  33. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  34. package/src/daemon/restore-fleet.ts +141 -0
  35. package/src/daemon/run-eject-handoff.ts +33 -0
  36. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  37. package/src/daemon/session-runtime.ts +64 -0
  38. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  39. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  40. package/src/daemon/start-headless-turn.ts +57 -0
  41. package/src/hook-report.ts +2 -2
  42. package/src/mcp-server.ts +33 -44
  43. package/src/protocol/parse-request-params.ts +31 -0
  44. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  45. package/src/protocol/request-param-schemas.ts +109 -0
  46. package/src/shared/agent-session-id.ts +8 -0
  47. package/src/shared/build-optional-boolean.ts +10 -0
  48. package/src/shared/build-optional-string-array.ts +14 -0
  49. package/src/shared/build-optional-string.ts +10 -0
  50. package/src/shared/collect-gateways.ts +113 -0
  51. package/src/{config.ts → shared/config.ts} +47 -31
  52. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  53. package/src/shared/get-record.ts +20 -0
  54. package/src/shared/session-id.ts +8 -0
  55. package/src/shared/to-agent-session-id.ts +12 -0
  56. package/src/shared/to-session-id.ts +11 -0
  57. package/src/shared/to-shell-arg.ts +7 -0
  58. package/src/statusline.ts +2 -2
  59. package/src/store/bun-sqlite-driver.ts +96 -0
  60. package/src/store/fleet-entry.ts +72 -0
  61. package/src/store/run-migrations.ts +242 -0
  62. package/src/store/state-store.ts +226 -0
  63. package/src/format-overlay-agent-mark.ts +0 -17
  64. package/src/state-store.ts +0 -236
  65. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  66. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  67. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  68. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  69. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  70. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  71. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  72. /package/src/{report.ts → shared/report.ts} +0 -0
  73. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  74. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
@@ -1,9 +1,11 @@
1
1
  import { unlinkSync } from 'node:fs';
2
- import { socketPath } from './config';
3
- import { isRecord } from './report';
2
+ import { socketPath } from '../shared/config';
3
+ import { isRecord } from '../shared/report';
4
+ import type { SessionID } from '../shared/session-id';
5
+ import { toSessionID } from '../shared/to-session-id';
4
6
 
5
7
  export interface HookEvent {
6
- atcId: string;
8
+ atcId: SessionID;
7
9
  event: string;
8
10
  payload: Record<string, unknown>;
9
11
  }
@@ -37,7 +39,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
37
39
  isRecord(parsed['payload'])
38
40
  ) {
39
41
  onEvent({
40
- atcId: parsed['atcId'],
42
+ atcId: toSessionID(parsed['atcId']),
41
43
  event: parsed['event'],
42
44
  payload: parsed['payload'],
43
45
  });
@@ -0,0 +1,12 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+
3
+ let counter = 0;
4
+
5
+ /**
6
+ * A fresh atc session id: an incrementing counter joined to the current time
7
+ * in base36. Every session the session manager spawns or restores gets one.
8
+ */
9
+ export function mintSessionID(): SessionID {
10
+ // oxlint-disable-next-line no-unsafe-type-assertion -- this is where atc mints a session id
11
+ return `s${++counter}-${Date.now().toString(36)}` as SessionID;
12
+ }
@@ -1,6 +1,8 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+
1
3
  export interface PermissionRequest {
2
4
  readonly id: string;
3
- readonly sessionID: string;
5
+ readonly sessionID: SessionID;
4
6
  readonly message: string;
5
7
  readonly respondable: boolean;
6
8
  }
@@ -36,7 +38,7 @@ export class PermissionRegistry {
36
38
  this.timeoutMs = timeoutMs;
37
39
  }
38
40
 
39
- open(sessionID: string, message: string, respondable: boolean): PermissionRequest {
41
+ open(sessionID: SessionID, message: string, respondable: boolean): PermissionRequest {
40
42
  const id = `p${++this.counter}`;
41
43
  const req: PermissionRequest = { id, sessionID, message, respondable };
42
44
 
@@ -70,7 +72,7 @@ export class PermissionRegistry {
70
72
  return 'ok';
71
73
  }
72
74
 
73
- answerAll(sessionID: string, decision: string): void {
75
+ answerAll(sessionID: SessionID, decision: string): void {
74
76
  for (const [id, entry] of this.pending) {
75
77
  if (entry.req.sessionID === sessionID) {
76
78
  this.removeRequest(id, decision);
@@ -0,0 +1,141 @@
1
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
+ import type { SessionID } from '../shared/session-id';
3
+ import type { StateStore } from '../store/state-store';
4
+ import { ScreenModel } from './screen-model';
5
+ import type { SessionRuntime } from './session-runtime';
6
+ import type { Session, SessionManager } from './sessions';
7
+
8
+ export interface RestoreFleetParams {
9
+ readonly mgr: SessionManager;
10
+ readonly store: StateStore;
11
+ readonly findRuntime: (sessionID: SessionID) => SessionRuntime | undefined;
12
+ readonly cols: number;
13
+ readonly rows: number;
14
+
15
+ // Caps how long a staggered restore waits for a revived session to report
16
+ // it has booted before moving on regardless; zero waits on the signal
17
+ // alone.
18
+ readonly capMs: number;
19
+ }
20
+
21
+ /**
22
+ * Restores the fleet: dedupes stored entries against live sessions, orders
23
+ * the rest by recency, registers every one as a terminal-less session so
24
+ * the whole fleet lists at once, then adopts terminals one at a time,
25
+ * waiting for each session to report it has booted before starting the
26
+ * next.
27
+ */
28
+ export async function restoreFleet(params: RestoreFleetParams): Promise<number> {
29
+ const mgr = params.mgr;
30
+ const store = params.store;
31
+ const findRuntime = params.findRuntime;
32
+ const cols = params.cols;
33
+ const rows = params.rows;
34
+ const capMs = params.capMs;
35
+
36
+ const hasLiveSession = (agentSessionID: AgentSessionID) =>
37
+ mgr.sessions.some(
38
+ (s) =>
39
+ s.agentSessionID === agentSessionID &&
40
+ (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
41
+ );
42
+
43
+ const hasAnySession = (agentSessionID: AgentSessionID) =>
44
+ mgr.sessions.some((s) => s.agentSessionID === agentSessionID);
45
+
46
+ const recency = await store.collectFleetRecency();
47
+ const stored = await store.loadFleet();
48
+
49
+ // Most recently active sessions revive first, so the ones the user was
50
+ // just working in come back before long-idle ones; entries that never
51
+ // reported an event keep their stored order at the end. Exited entries
52
+ // dedupe against every listed session, so repeated restores never double
53
+ // up the killed archive.
54
+ const entries = stored
55
+ .filter((entry) =>
56
+ entry.exited === true
57
+ ? !hasAnySession(entry.agentSessionID)
58
+ : !hasLiveSession(entry.agentSessionID),
59
+ )
60
+ .toSorted((a, b) =>
61
+ (recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
62
+ );
63
+
64
+ // The whole fleet registers as terminal-less sessions up front, so the
65
+ // list shows every incoming session immediately instead of revealing them
66
+ // one boot at a time. Exited entries only register — they stay killed
67
+ // until revived by hand, so no terminal is adopted for them.
68
+ const registered = entries.map((entry) => mgr.restore(entry));
69
+ const queued = registered.filter((s) => s.state !== 'exited');
70
+
71
+ const adoptQueued = (s: Session): boolean => {
72
+ if (mgr.adoptTerminal(s.id, cols, rows) === null) {
73
+ return false;
74
+ }
75
+
76
+ const runtime = findRuntime(s.id);
77
+
78
+ if (runtime !== undefined) {
79
+ runtime.dims = { cols, rows };
80
+
81
+ runtime.screen ??= new ScreenModel(cols, rows);
82
+ }
83
+
84
+ return true;
85
+ };
86
+
87
+ const waitForBoot = (sessionID: SessionID): Promise<void> => {
88
+ const runtime = findRuntime(sessionID);
89
+
90
+ if (runtime === undefined) {
91
+ return Promise.resolve();
92
+ }
93
+
94
+ const settled = Promise.withResolvers<void>();
95
+ const timer = capMs > 0 ? setTimeout(settled.resolve, capMs) : undefined;
96
+
97
+ runtime.bootWaiter = settled.resolve;
98
+ runtime.bootTimer = timer;
99
+
100
+ return (async () => {
101
+ await settled.promise;
102
+
103
+ runtime.bootWaiter = null;
104
+
105
+ if (timer !== undefined) {
106
+ clearTimeout(timer);
107
+
108
+ runtime.bootTimer = undefined;
109
+ }
110
+ })();
111
+ };
112
+
113
+ const [first, ...rest] = queued;
114
+
115
+ if (first === undefined) {
116
+ return registered.length;
117
+ }
118
+
119
+ // The first terminal attaches synchronously so a caller can attach at
120
+ // once; each later one waits for the previous session to report it has
121
+ // booted, so a heavy fleet comes up one process at a time instead of all
122
+ // at once. A per-session cap keeps a session that never reports from
123
+ // stalling the rest.
124
+ const adoptRest = async (previous: Session | null) => {
125
+ let prev = previous;
126
+
127
+ for (const s of rest) {
128
+ if (prev !== null) {
129
+ await waitForBoot(prev.id);
130
+ }
131
+
132
+ prev = adoptQueued(s) ? s : null;
133
+ }
134
+ };
135
+
136
+ const firstBooted = adoptQueued(first) ? first : null;
137
+
138
+ void adoptRest(firstBooted);
139
+
140
+ return registered.length;
141
+ }
@@ -0,0 +1,33 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+ import type { SessionRuntime } from './session-runtime';
3
+
4
+ export interface EjectHandoffParams {
5
+ readonly sessionID: SessionID;
6
+ readonly prompt: string;
7
+ readonly settleMs: number;
8
+ readonly runtime: SessionRuntime;
9
+ readonly startHeadlessTurn: (sessionID: SessionID, prompt: string) => boolean;
10
+ }
11
+
12
+ /**
13
+ * Waits for the ejected terminal's final report (or a settle timeout,
14
+ * whichever comes first) before starting the headless turn — resuming an
15
+ * agent session while its old process is still shutting down corrupts the
16
+ * handoff.
17
+ */
18
+ export function runEjectHandoff(params: EjectHandoffParams): void {
19
+ const settled = Promise.withResolvers<void>();
20
+ const timer = setTimeout(settled.resolve, params.settleMs);
21
+
22
+ params.runtime.pendingEject = settled.resolve;
23
+
24
+ void (async () => {
25
+ await settled.promise;
26
+
27
+ clearTimeout(timer);
28
+
29
+ params.runtime.pendingEject = null;
30
+
31
+ params.startHeadlessTurn(params.sessionID, params.prompt);
32
+ })();
33
+ }
@@ -1,6 +1,6 @@
1
1
  import { SerializeAddon } from '@xterm/addon-serialize';
2
2
  import { Terminal } from '@xterm/headless';
3
- import { RESET_INPUT_MODES } from './reset-input-modes';
3
+ import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
4
4
 
5
5
  // The serializer already re-emits the modes the vt engine models (mouse
6
6
  // tracking, bracketed paste, focus events); these are the ones it drops.
@@ -0,0 +1,64 @@
1
+ import type { Dims } from './attach-registry';
2
+ import type { ScreenModel } from './screen-model';
3
+
4
+ // The daemon's own bookkeeping for one live headless run: enough to stop it
5
+ // on eject, kill, or session removal.
6
+ interface HeadlessRunHandle {
7
+ readonly stop: () => void;
8
+ }
9
+
10
+ /**
11
+ * Everything the daemon tracks for one session: its screen model, output
12
+ * sequence counter, PTY dims, its resize/detect/boot timers, its live
13
+ * headless run, and its pending eject and boot waiters. `dispose` releases
14
+ * all of it in one call, and is safe to call more than once.
15
+ */
16
+ export class SessionRuntime {
17
+ screen: ScreenModel | null = null;
18
+
19
+ seq = 0;
20
+
21
+ dims: Dims | null = null;
22
+
23
+ resizeTimer: ReturnType<typeof setTimeout> | undefined;
24
+
25
+ detectTimer: ReturnType<typeof setTimeout> | undefined;
26
+
27
+ bootTimer: ReturnType<typeof setTimeout> | undefined;
28
+
29
+ headlessRun: HeadlessRunHandle | null = null;
30
+
31
+ pendingEject: (() => void) | null = null;
32
+
33
+ bootWaiter: (() => void) | null = null;
34
+
35
+ // Whether a SessionStart hook is still owed for this session before its
36
+ // agent can be recorded as the last one used; a spawn sets it, the hook
37
+ // clears it, and a spawn that never reports leaves it stranded harmlessly.
38
+ pendingLastUsed = false;
39
+
40
+ dispose(): void {
41
+ clearTimeout(this.resizeTimer);
42
+ clearTimeout(this.detectTimer);
43
+ clearTimeout(this.bootTimer);
44
+
45
+ this.resizeTimer = undefined;
46
+ this.detectTimer = undefined;
47
+ this.bootTimer = undefined;
48
+ this.screen?.stop();
49
+ this.screen = null;
50
+
51
+ this.stopHeadlessRun();
52
+ this.pendingEject?.();
53
+ this.pendingEject = null;
54
+ this.bootWaiter?.();
55
+ this.bootWaiter = null;
56
+ }
57
+
58
+ // Ends the live headless run, if there is one, and forgets its handle so
59
+ // the session is free to start another.
60
+ stopHeadlessRun(): void {
61
+ this.headlessRun?.stop();
62
+ this.headlessRun = null;
63
+ }
64
+ }