@zgeoff/atc 0.1.12 → 1.0.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.
- package/README.md +50 -12
- package/package.json +7 -2
- package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
- package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
- package/src/agents/build-hook-settings.ts +59 -0
- package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
- package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
- package/src/agents/gateway-adapter.ts +106 -0
- package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
- package/src/agents/resolve-agent-home.ts +13 -0
- package/src/agents/truncate-detail.ts +7 -0
- package/src/agents/write-hook-settings.ts +37 -0
- package/src/cli.ts +25 -14
- package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
- package/src/client/build-client-machine.ts +119 -0
- package/src/client/collect-agent-picks.ts +31 -0
- package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
- package/src/{dirs.ts → client/dirs.ts} +8 -0
- package/src/client/format-overlay-agent-mark.ts +15 -0
- package/src/{index.ts → client/index.ts} +181 -550
- package/src/client/keys.ts +111 -0
- package/src/client/parse-daemon-event.ts +114 -0
- package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
- package/src/client/plan-vacated-rows.ts +26 -0
- package/src/client/spawn-picker.ts +305 -0
- package/src/client/to-mirror-session.ts +65 -0
- package/src/{ui.ts → client/ui.ts} +27 -8
- package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
- package/src/daemon/build-session-event.ts +43 -0
- package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
- package/src/{daemon.ts → daemon/daemon.ts} +148 -313
- package/src/{hooks.ts → daemon/hooks.ts} +6 -4
- package/src/daemon/mint-session-id.ts +12 -0
- package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
- package/src/daemon/restore-fleet.ts +141 -0
- package/src/daemon/run-eject-handoff.ts +33 -0
- package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
- package/src/daemon/session-runtime.ts +64 -0
- package/src/{sessions.ts → daemon/sessions.ts} +77 -133
- package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
- package/src/daemon/start-headless-turn.ts +57 -0
- package/src/hook-report.ts +2 -2
- package/src/mcp-server.ts +33 -44
- package/src/protocol/parse-request-params.ts +31 -0
- package/src/{protocol.ts → protocol/protocol.ts} +1 -1
- package/src/protocol/request-param-schemas.ts +109 -0
- package/src/shared/agent-session-id.ts +8 -0
- package/src/shared/build-optional-boolean.ts +10 -0
- package/src/shared/build-optional-string-array.ts +14 -0
- package/src/shared/build-optional-string.ts +10 -0
- package/src/shared/collect-gateways.ts +113 -0
- package/src/{config.ts → shared/config.ts} +47 -31
- package/src/{get-build.ts → shared/get-build.ts} +21 -6
- package/src/shared/get-record.ts +20 -0
- package/src/shared/session-id.ts +8 -0
- package/src/shared/to-agent-session-id.ts +12 -0
- package/src/shared/to-session-id.ts +11 -0
- package/src/shared/to-shell-arg.ts +7 -0
- package/src/statusline.ts +2 -2
- package/src/store/bun-sqlite-driver.ts +96 -0
- package/src/store/fleet-entry.ts +72 -0
- package/src/store/run-migrations.ts +242 -0
- package/src/store/state-store.ts +226 -0
- package/src/format-overlay-agent-mark.ts +0 -17
- package/src/state-store.ts +0 -236
- /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
- /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
- /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
- /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
- /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
- /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
- /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
- /package/src/{report.ts → shared/report.ts} +0 -0
- /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
- /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 '
|
|
3
|
-
import { isRecord } from '
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 '
|
|
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
|
+
}
|