@zgeoff/atc 2.12.0 → 2.14.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.
- package/README.md +12 -11
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +134 -4
- package/src/daemon/daemon.ts +306 -42
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +574 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { HeadlessRunEvents, HeadlessRunner } from './agent-adapter';
|
|
2
|
+
import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs one headless turn through the Claude CLI; the daemon command supplies
|
|
6
|
+
* the Agent SDK implementation, and tests supply a recorder.
|
|
7
|
+
*/
|
|
8
|
+
export type ClaudeHeadlessRun = (
|
|
9
|
+
opts: ClaudeHeadlessRunRequest,
|
|
10
|
+
hooks: HeadlessRunEvents,
|
|
11
|
+
) => { readonly stop: () => void };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where and how a Claude CLI headless turn runs: the binary, the permission
|
|
15
|
+
* mode its adapter resolved, the mod folder, and, for a gateway, the
|
|
16
|
+
* settings file that carries its backend. The folder and file are written
|
|
17
|
+
* on first use.
|
|
18
|
+
*/
|
|
19
|
+
interface ClaudeHeadlessTarget {
|
|
20
|
+
readonly claudeBin: string;
|
|
21
|
+
readonly permissionMode: string;
|
|
22
|
+
readonly pluginDir: () => string;
|
|
23
|
+
readonly settings?: () => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Turns the Claude CLI's headless run into the neutral runner the daemon
|
|
28
|
+
* calls. Every headless turn runs under the permission mode its adapter
|
|
29
|
+
* resolved for the agent's sessions.
|
|
30
|
+
*/
|
|
31
|
+
export function makeClaudeHeadlessRunner(
|
|
32
|
+
run: ClaudeHeadlessRun,
|
|
33
|
+
target: ClaudeHeadlessTarget,
|
|
34
|
+
): HeadlessRunner {
|
|
35
|
+
return (opts, hooks) =>
|
|
36
|
+
run(
|
|
37
|
+
{
|
|
38
|
+
...opts,
|
|
39
|
+
claudeBin: target.claudeBin,
|
|
40
|
+
permissionMode: target.permissionMode,
|
|
41
|
+
pluginDir: target.pluginDir(),
|
|
42
|
+
...(target.settings === undefined ? {} : { settings: target.settings() }),
|
|
43
|
+
},
|
|
44
|
+
hooks,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TerminalInputModes } from './agent-adapter';
|
|
2
|
+
|
|
3
|
+
const PASTE_START = '\u001B[200~';
|
|
4
|
+
const PASTE_END = '\u001B[201~';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pastes a line into a TUI that tells a paste from typing, then submits it
|
|
8
|
+
* with a carriage return as its own write. Such a TUI takes a newline inside
|
|
9
|
+
* a burst of input as part of the text, so a line typed with its newline
|
|
10
|
+
* stays unsent in the composer. Between bracketed paste markers the whole
|
|
11
|
+
* text is one paste event, newlines included, and the carriage return after
|
|
12
|
+
* the closing marker is a separate Enter key event however the two writes
|
|
13
|
+
* arrive. Paste markers inside the text are dropped, so the text can neither
|
|
14
|
+
* end the paste early nor start one of its own. A TUI that has not turned
|
|
15
|
+
* bracketed paste on gets the text unmarked, and may still read both writes
|
|
16
|
+
* as one burst.
|
|
17
|
+
*/
|
|
18
|
+
export function planPastedLineInput(text: string, modes: TerminalInputModes): readonly string[] {
|
|
19
|
+
const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
|
|
20
|
+
|
|
21
|
+
return modes.bracketedPaste ? [`${PASTE_START}${unmarked}${PASTE_END}`, '\r'] : [unmarked, '\r'];
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types a line the way a terminal user does: the text and a newline in one
|
|
3
|
+
* write. A TUI that reads input a line at a time takes the newline as the
|
|
4
|
+
* submit key.
|
|
5
|
+
*/
|
|
6
|
+
export function planTypedLineInput(text: string): readonly string[] {
|
|
7
|
+
return [`${text}\n`];
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { findClaudePermissionMode } from './find-claude-permission-mode';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The permission mode a Claude CLI agent's headless turns run in: the mode
|
|
5
|
+
* its configuration sets, else auto, since no human is at a terminal to
|
|
6
|
+
* answer a prompt.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveClaudePermissionMode(
|
|
9
|
+
args: readonly string[],
|
|
10
|
+
settings: Readonly<Record<string, unknown>> | undefined,
|
|
11
|
+
): string {
|
|
12
|
+
return findClaudePermissionMode(args, settings) ?? 'auto';
|
|
13
|
+
}
|
|
@@ -1,36 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { socketPath } from '../shared/config';
|
|
1
|
+
import { truncateSummary } from '../daemon/truncate-summary';
|
|
3
2
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
4
3
|
import { isRecord } from '../shared/report';
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import { resolveHeadlessExecutable } from './resolve-headless-executable';
|
|
8
|
-
import { truncateSummary } from './truncate-summary';
|
|
9
|
-
|
|
10
|
-
const PERMISSION_MODES = [
|
|
11
|
-
'default',
|
|
12
|
-
'acceptEdits',
|
|
13
|
-
'bypassPermissions',
|
|
14
|
-
'plan',
|
|
15
|
-
'auto',
|
|
16
|
-
'dontAsk',
|
|
17
|
-
] as const;
|
|
18
|
-
|
|
19
|
-
// The effort levels the Agent SDK accepts.
|
|
20
|
-
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
21
|
-
|
|
22
|
-
interface HeadlessRunOptions {
|
|
23
|
-
readonly claudeBin: string;
|
|
24
|
-
readonly cwd: string;
|
|
25
|
-
readonly prompt: string;
|
|
26
|
-
readonly resume?: AgentSessionID;
|
|
27
|
-
readonly permissionMode?: string;
|
|
28
|
-
readonly settings?: string;
|
|
29
|
-
readonly sessionID?: SessionID;
|
|
30
|
-
readonly pluginDir?: string;
|
|
31
|
-
readonly model?: string;
|
|
32
|
-
readonly effort?: string;
|
|
33
|
-
}
|
|
4
|
+
import { buildClaudeQueryOptions } from './build-claude-query-options';
|
|
5
|
+
import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
|
|
34
6
|
|
|
35
7
|
interface HeadlessRunHooks {
|
|
36
8
|
readonly onOutput: (text: string) => void;
|
|
@@ -48,16 +20,14 @@ interface HeadlessRunHandle {
|
|
|
48
20
|
* done (with the turn's whole result) or needs_you (errors, turn limits,
|
|
49
21
|
* anything a human must look at).
|
|
50
22
|
*/
|
|
51
|
-
export function
|
|
52
|
-
opts:
|
|
23
|
+
export function startClaudeHeadlessRun(
|
|
24
|
+
opts: ClaudeHeadlessRunRequest,
|
|
53
25
|
hooks: HeadlessRunHooks,
|
|
54
26
|
): HeadlessRunHandle {
|
|
55
27
|
const controller = new AbortController();
|
|
56
28
|
|
|
57
29
|
void (async () => {
|
|
58
30
|
try {
|
|
59
|
-
const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
|
|
60
|
-
const effort = EFFORT_LEVELS.find((level) => level === opts.effort);
|
|
61
31
|
let stderrTail = '';
|
|
62
32
|
|
|
63
33
|
// The SDK is the heaviest module in the daemon's graph, and only a
|
|
@@ -68,28 +38,11 @@ export function startHeadlessRun(
|
|
|
68
38
|
const stream = sdk.query({
|
|
69
39
|
prompt: opts.prompt,
|
|
70
40
|
options: {
|
|
71
|
-
|
|
72
|
-
env: buildHeadlessEnv({
|
|
73
|
-
socketPath,
|
|
74
|
-
...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
|
|
75
|
-
...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
|
|
76
|
-
}),
|
|
41
|
+
...buildClaudeQueryOptions(opts, isCompiledBinary()),
|
|
77
42
|
abortController: controller,
|
|
78
43
|
stderr: (data: string) => {
|
|
79
44
|
stderrTail = `${stderrTail}${data}`.slice(-2000);
|
|
80
45
|
},
|
|
81
|
-
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
82
|
-
...(mode === undefined ? {} : { permissionMode: mode }),
|
|
83
|
-
|
|
84
|
-
// The session's own model and effort, so the turn runs as its
|
|
85
|
-
// terminal did.
|
|
86
|
-
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
87
|
-
...(effort === undefined ? {} : { effort }),
|
|
88
|
-
|
|
89
|
-
// The same generated file the terminal spawn passes, so the turn
|
|
90
|
-
// runs against the session's own backend and instrumentation.
|
|
91
|
-
...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
|
|
92
|
-
...resolveHeadlessExecutable(opts.claudeBin, isCompiledBinary()),
|
|
93
46
|
},
|
|
94
47
|
});
|
|
95
48
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { stateDir } from '../shared/config';
|
|
4
|
-
import {
|
|
4
|
+
import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
5
5
|
import { buildCLIArgv } from './build-cli-argv';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -13,7 +13,7 @@ import { buildCLIArgv } from './build-cli-argv';
|
|
|
13
13
|
* there.
|
|
14
14
|
*/
|
|
15
15
|
export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): string {
|
|
16
|
-
const files =
|
|
16
|
+
const files = buildATCBridgeFiles(buildCLIArgv());
|
|
17
17
|
|
|
18
18
|
for (const [path, content] of Object.entries(files)) {
|
|
19
19
|
const file = join(dir, path);
|
|
@@ -29,10 +29,6 @@ export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): stri
|
|
|
29
29
|
return dir;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function renderATCCLIModule(argv: readonly string[]): string {
|
|
33
|
-
return `export const ATC_CLI: readonly string[] = ${JSON.stringify(argv)};\n`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
32
|
function tryReadFileText(file: string): string | null {
|
|
37
33
|
try {
|
|
38
34
|
return readFileSync(file, 'utf8');
|
package/src/cli.ts
CHANGED
|
@@ -159,15 +159,17 @@ const main = defineCommand({
|
|
|
159
159
|
const grok = await import('./agents/grok-adapter');
|
|
160
160
|
const codex = await import('./agents/codex-adapter');
|
|
161
161
|
const gateway = await import('./agents/gateway-adapter');
|
|
162
|
-
const headless = await import('./
|
|
162
|
+
const headless = await import('./agents/start-claude-headless-run');
|
|
163
163
|
const targets = await import('./daemon/build-execution-targets');
|
|
164
164
|
|
|
165
165
|
// Test harnesses shrink the outbound queue to force overflow
|
|
166
166
|
// deterministically; unset means the production default.
|
|
167
167
|
const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
|
|
168
168
|
const cfg = config.loadConfig();
|
|
169
|
+
const built = targets.buildExecutionTargets(cfg.targets);
|
|
170
|
+
const targetErrors = [...cfg.targetErrors, ...built.errors];
|
|
169
171
|
|
|
170
|
-
for (const error of
|
|
172
|
+
for (const error of targetErrors) {
|
|
171
173
|
const line =
|
|
172
174
|
error.scope === 'config'
|
|
173
175
|
? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
|
|
@@ -192,18 +194,12 @@ const main = defineCommand({
|
|
|
192
194
|
// to it is refused. Tests pin it to 0 to reach the refusal at once.
|
|
193
195
|
const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
|
|
194
196
|
|
|
195
|
-
const claudeAdapter = new claude.ClaudeAdapter(cfg,
|
|
196
|
-
headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
|
|
197
|
-
);
|
|
198
|
-
|
|
197
|
+
const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
|
|
199
198
|
const grokAdapter = new grok.GrokAdapter(cfg);
|
|
200
199
|
const codexAdapter = new codex.CodexAdapter(cfg);
|
|
201
200
|
|
|
202
201
|
const gatewayAdapters = cfg.gateways.map(
|
|
203
|
-
(entry) =>
|
|
204
|
-
new gateway.GatewayAdapter(entry, cfg, (runOpts, hooks) =>
|
|
205
|
-
headless.startHeadlessRun({ ...runOpts, claudeBin: entry.bin }, hooks),
|
|
206
|
-
),
|
|
202
|
+
(entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
|
|
207
203
|
);
|
|
208
204
|
|
|
209
205
|
let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
|
|
@@ -220,9 +216,9 @@ const main = defineCommand({
|
|
|
220
216
|
legacyFleetPath: config.legacyFleetFile,
|
|
221
217
|
pidPath: config.daemonPidFile,
|
|
222
218
|
hooks: cfg.hooks,
|
|
223
|
-
targets:
|
|
219
|
+
targets: built.targets,
|
|
224
220
|
defaultTarget: cfg.defaultTarget,
|
|
225
|
-
targetErrors
|
|
221
|
+
targetErrors,
|
|
226
222
|
principals: cfg.principals,
|
|
227
223
|
restoreBootTimeoutMs,
|
|
228
224
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
@@ -4,15 +4,18 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { toAgentID } from '../agents/agent-adapter';
|
|
6
6
|
import type { AgentID } from '../agents/agent-adapter';
|
|
7
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
7
8
|
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
8
9
|
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
10
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
9
11
|
import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
|
|
10
12
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
11
13
|
import { getBuild } from '../shared/get-build';
|
|
12
14
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
13
15
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
14
|
-
import { isRecord } from '../shared/report';
|
|
15
16
|
import { DaemonClient } from './daemon-client';
|
|
17
|
+
import { formatProtocolMismatch } from './format-protocol-mismatch';
|
|
18
|
+
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
16
19
|
import { pickStaleDaemonPID } from './pick-stale-daemon-pid';
|
|
17
20
|
|
|
18
21
|
export interface DaemonBoot {
|
|
@@ -28,18 +31,28 @@ export interface DaemonBoot {
|
|
|
28
31
|
readonly socketPath: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export interface DaemonBootOptions {
|
|
35
|
+
// Called when the daemon speaks another protocol version. Resolving true
|
|
36
|
+
// stops that daemon and boots one from this build, which ends every
|
|
37
|
+
// session it hosts; without the callback, or resolving false, the boot
|
|
38
|
+
// rejects and the daemon keeps running.
|
|
39
|
+
readonly onProtocolMismatch?: (mismatch: ProtocolMismatch) => Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
33
44
|
* neither the computed socket nor the one in the daemon's record answers.
|
|
34
|
-
* Overlapping calls in one process share a single boot. A daemon from an
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
45
|
+
* Overlapping calls in one process share a single boot. A daemon from an
|
|
46
|
+
* older build stays in service, since stopping it would end every hosted
|
|
47
|
+
* session, and is reported as stale so the caller can offer a deliberate
|
|
48
|
+
* restart. A daemon on another protocol version stays in service too: the
|
|
49
|
+
* boot rejects with `protocol_mismatch` and a message holding both builds,
|
|
50
|
+
* both versions, and the way to restart it, unless the caller's
|
|
51
|
+
* `onProtocolMismatch` confirms a restart. The expected build is read from
|
|
52
|
+
* disk on every attempt: a long-lived caller holding a build string from
|
|
53
|
+
* its own boot would otherwise flag daemons that are already current.
|
|
41
54
|
*/
|
|
42
|
-
export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
55
|
+
export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
|
|
43
56
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
44
57
|
const build = getBuild();
|
|
45
58
|
|
|
@@ -60,21 +73,35 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
60
73
|
} catch (error) {
|
|
61
74
|
client.stop();
|
|
62
75
|
|
|
63
|
-
if (attempt > 0 || !
|
|
76
|
+
if (attempt > 0 || !(error instanceof DaemonError) || error.code !== 'protocol_mismatch') {
|
|
64
77
|
throw error;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
|
-
|
|
80
|
+
const mismatch: ProtocolMismatch = {
|
|
81
|
+
socketPath: opened.socketPath,
|
|
82
|
+
daemonPID: findDaemonPID(opened.socketPath),
|
|
83
|
+
clientBuild: build,
|
|
84
|
+
clientProtocol: PROTOCOL_V,
|
|
85
|
+
daemonMessage: error.message,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Without a pid there is no daemon this client could stop, so the
|
|
89
|
+
// caller is not asked.
|
|
90
|
+
if (
|
|
91
|
+
mismatch.daemonPID === null ||
|
|
92
|
+
options.onProtocolMismatch === undefined ||
|
|
93
|
+
!(await options.onProtocolMismatch(mismatch))
|
|
94
|
+
) {
|
|
95
|
+
throw new DaemonError('protocol_mismatch', formatProtocolMismatch(mismatch));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await stopDaemon(mismatch.daemonPID);
|
|
68
99
|
}
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
throw new Error('the atc daemon could not be restarted');
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
function isProtocolMismatch(error: unknown): boolean {
|
|
75
|
-
return isRecord(error) && error['code'] === 'protocol_mismatch';
|
|
76
|
-
}
|
|
77
|
-
|
|
78
105
|
interface OpenedDaemon {
|
|
79
106
|
readonly client: DaemonClient;
|
|
80
107
|
readonly socketPath: string;
|
|
@@ -177,18 +204,17 @@ function isProcessAlive(pid: number): boolean {
|
|
|
177
204
|
}
|
|
178
205
|
}
|
|
179
206
|
|
|
180
|
-
|
|
181
|
-
|
|
207
|
+
// The pid of the daemon behind the socket that refused the handshake.
|
|
208
|
+
function findDaemonPID(socketPath: string): number | null {
|
|
209
|
+
return pickStaleDaemonPID({
|
|
182
210
|
socketPath,
|
|
183
211
|
record: findDaemonRecord(daemonRecordFile),
|
|
184
212
|
pidFileSocketPath: daemonSocketPath,
|
|
185
213
|
pidFilePID: findPidFilePID(),
|
|
186
214
|
});
|
|
215
|
+
}
|
|
187
216
|
|
|
188
|
-
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
217
|
+
async function stopDaemon(pid: number): Promise<void> {
|
|
192
218
|
try {
|
|
193
219
|
process.kill(pid, 'SIGTERM');
|
|
194
220
|
} catch {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A daemon that refused this client's handshake because it speaks another
|
|
2
|
+
// protocol version.
|
|
3
|
+
export interface ProtocolMismatch {
|
|
4
|
+
readonly socketPath: string;
|
|
5
|
+
|
|
6
|
+
// The daemon's pid from its record or pid file, or null when neither
|
|
7
|
+
// belongs to the socket that refused.
|
|
8
|
+
readonly daemonPID: number | null;
|
|
9
|
+
|
|
10
|
+
readonly clientBuild: string;
|
|
11
|
+
readonly clientProtocol: number;
|
|
12
|
+
|
|
13
|
+
// The daemon's own refusal, which holds its build and protocol version.
|
|
14
|
+
readonly daemonMessage: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Describes a daemon on another protocol version: both builds and both
|
|
19
|
+
* versions, that it was left running, and how to restart it on purpose.
|
|
20
|
+
*/
|
|
21
|
+
export function formatProtocolMismatch(mismatch: ProtocolMismatch): string {
|
|
22
|
+
const pid = mismatch.daemonPID === null ? 'pid unknown' : `pid ${mismatch.daemonPID}`;
|
|
23
|
+
|
|
24
|
+
// Without a pid the TUI cannot stop the daemon either, so the restart is
|
|
25
|
+
// left to the user.
|
|
26
|
+
const restart =
|
|
27
|
+
mismatch.daemonPID === null
|
|
28
|
+
? `To restart it, find its pid with \`ss -xlp | grep ${mismatch.socketPath}\` on Linux or \`lsof -U | grep ${mismatch.socketPath}\` on macOS, stop that process, and run \`atc\` again: every hosted session ends, and \`R\` respawns them from their transcripts.`
|
|
29
|
+
: 'To restart it, run `atc` from the build you want and confirm its restart prompt: every hosted session ends, and the fleet is restored on the new daemon.';
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
`the atc daemon (${pid}, socket ${mismatch.socketPath}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
|
|
33
|
+
`The daemon answered: ${mismatch.daemonMessage}`,
|
|
34
|
+
'It was left running, so the sessions it hosts keep running.',
|
|
35
|
+
restart,
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { match } from 'ts-pattern';
|
|
|
2
2
|
import { createActor } from 'xstate';
|
|
3
3
|
import type { AgentID } from '../agents/agent-adapter';
|
|
4
4
|
import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
|
|
5
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
5
6
|
import type { EventMsg } from '../protocol/protocol';
|
|
6
7
|
import { loadConfig } from '../shared/config';
|
|
7
8
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
@@ -9,6 +10,7 @@ import { bootDaemonClient } from './boot-daemon';
|
|
|
9
10
|
import { buildClientMachine } from './build-client-machine';
|
|
10
11
|
import { buildLeaderChords } from './build-leader-chords';
|
|
11
12
|
import { findFuzzyScore, formatDir } from './dirs';
|
|
13
|
+
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
12
14
|
import { KEY, isDown, isUp, planTextEdit } from './keys';
|
|
13
15
|
import { parseDaemonEvent } from './parse-daemon-event';
|
|
14
16
|
import { pickTabTarget } from './pick-tab-target';
|
|
@@ -20,6 +22,10 @@ import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar,
|
|
|
20
22
|
let overlaySelected = 0;
|
|
21
23
|
let confirmKill = false;
|
|
22
24
|
|
|
25
|
+
// The session whose forget waits on a confirm because it destroys the
|
|
26
|
+
// session's host, or null.
|
|
27
|
+
let confirmDestroyID: string | null = null;
|
|
28
|
+
|
|
23
29
|
// The client's mirror of the daemon's fleet, kept fresh by events.
|
|
24
30
|
let fleet: MirrorSession[] = [];
|
|
25
31
|
|
|
@@ -95,6 +101,36 @@ async function sendQuiet(m: string, p?: Readonly<Record<string, unknown>>) {
|
|
|
95
101
|
} catch {}
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
// A kill the daemon refuses because forgetting the session destroys its
|
|
105
|
+
// host asks once more before the forget.
|
|
106
|
+
async function sendKill(sessionID: string) {
|
|
107
|
+
try {
|
|
108
|
+
await client.sendRequest('session.kill', { session: sessionID });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error instanceof DaemonError && error.code === 'confirmation_required') {
|
|
111
|
+
confirmDestroyID = sessionID;
|
|
112
|
+
|
|
113
|
+
if (service.getSnapshot().value === 'overlay') {
|
|
114
|
+
renderOverlay();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Takes the confirm token the daemon offers and hands it straight back:
|
|
121
|
+
// the operator already confirmed in the overlay.
|
|
122
|
+
async function sendForget(sessionID: string) {
|
|
123
|
+
try {
|
|
124
|
+
const offered = await client.sendRequest('session.forget', { session: sessionID });
|
|
125
|
+
|
|
126
|
+
const confirmToken = offered['confirmToken'];
|
|
127
|
+
|
|
128
|
+
if (typeof confirmToken === 'string') {
|
|
129
|
+
await client.sendRequest('session.forget', { session: sessionID, confirmToken });
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
|
|
98
134
|
async function attach(sessionID: string) {
|
|
99
135
|
service.send({ type: 'ATTACH', sessionID });
|
|
100
136
|
|
|
@@ -215,6 +251,7 @@ function renderOverlay() {
|
|
|
215
251
|
agentMarks,
|
|
216
252
|
selected: overlaySelected,
|
|
217
253
|
confirmKill,
|
|
254
|
+
confirmDestroy: confirmDestroyID !== null,
|
|
218
255
|
filter: overlayFilter,
|
|
219
256
|
stale: daemonStale && !daemonRestarting,
|
|
220
257
|
grouped: overlayGrouped,
|
|
@@ -225,6 +262,7 @@ function renderOverlay() {
|
|
|
225
262
|
|
|
226
263
|
function openOverlay() {
|
|
227
264
|
confirmKill = false;
|
|
265
|
+
confirmDestroyID = null;
|
|
228
266
|
overlayFilter = null;
|
|
229
267
|
|
|
230
268
|
const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
|
|
@@ -510,7 +548,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
510
548
|
|
|
511
549
|
if (confirmKill) {
|
|
512
550
|
if (buf[0] === 0x79 /* y */ && sel !== undefined) {
|
|
513
|
-
void
|
|
551
|
+
void sendKill(sel.id);
|
|
514
552
|
}
|
|
515
553
|
|
|
516
554
|
confirmKill = false;
|
|
@@ -520,6 +558,18 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
520
558
|
return;
|
|
521
559
|
}
|
|
522
560
|
|
|
561
|
+
if (confirmDestroyID !== null) {
|
|
562
|
+
if (buf[0] === 0x79 /* y */) {
|
|
563
|
+
void sendForget(confirmDestroyID);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
confirmDestroyID = null;
|
|
567
|
+
|
|
568
|
+
renderOverlay();
|
|
569
|
+
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
523
573
|
if (applyOverlayFilterKey(buf)) {
|
|
524
574
|
return;
|
|
525
575
|
}
|
|
@@ -732,7 +782,17 @@ const service = createActor(
|
|
|
732
782
|
}),
|
|
733
783
|
);
|
|
734
784
|
|
|
735
|
-
|
|
785
|
+
// A daemon on another protocol is restarted only when the user confirms
|
|
786
|
+
// it, and the fleet it hosted is then restored on the new one.
|
|
787
|
+
let restartedOnBoot = false;
|
|
788
|
+
|
|
789
|
+
const boot = await bootDaemonClient({
|
|
790
|
+
onProtocolMismatch: async (mismatch) => {
|
|
791
|
+
restartedOnBoot = await waitForRestartConsent(mismatch);
|
|
792
|
+
|
|
793
|
+
return restartedOnBoot;
|
|
794
|
+
},
|
|
795
|
+
});
|
|
736
796
|
|
|
737
797
|
let client = boot.client;
|
|
738
798
|
let daemonStale = boot.stale;
|
|
@@ -741,8 +801,50 @@ let daemonRestarting = false;
|
|
|
741
801
|
lastUsedAgent = boot.lastUsedAgent;
|
|
742
802
|
client.onEvent = applyDaemonEvent;
|
|
743
803
|
|
|
804
|
+
if (restartedOnBoot) {
|
|
805
|
+
await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
|
|
806
|
+
}
|
|
807
|
+
|
|
744
808
|
await refreshMirror();
|
|
745
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Asks on the terminal whether to restart a daemon that speaks another
|
|
812
|
+
* protocol, reading a single key. Anything but `y` declines, and so does a
|
|
813
|
+
* terminal that cannot answer.
|
|
814
|
+
*/
|
|
815
|
+
async function waitForRestartConsent(mismatch: ProtocolMismatch): Promise<boolean> {
|
|
816
|
+
if (!process.stdin.isTTY) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
process.stderr.write(
|
|
821
|
+
[
|
|
822
|
+
`atc: the daemon (pid ${mismatch.daemonPID}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
|
|
823
|
+
`The daemon answered: ${mismatch.daemonMessage}`,
|
|
824
|
+
'Restart it now? Every session it hosts ends, then the fleet is restored on the new daemon. [y/N] ',
|
|
825
|
+
].join('\n'),
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
process.stdin.setRawMode(true);
|
|
829
|
+
process.stdin.resume();
|
|
830
|
+
|
|
831
|
+
const key = await new Promise<string>((resolve) => {
|
|
832
|
+
process.stdin.once('data', (buf: Buffer) => {
|
|
833
|
+
resolve(buf.toString());
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
process.stdin.setRawMode(false);
|
|
838
|
+
process.stdin.pause();
|
|
839
|
+
|
|
840
|
+
const confirmed = key === 'y' || key === 'Y';
|
|
841
|
+
const echo = confirmed ? 'y\n' : 'n\n';
|
|
842
|
+
|
|
843
|
+
process.stderr.write(echo);
|
|
844
|
+
|
|
845
|
+
return confirmed;
|
|
846
|
+
}
|
|
847
|
+
|
|
746
848
|
async function refreshMirror() {
|
|
747
849
|
const list = await client.sendRequest('session.list');
|
|
748
850
|
|
package/src/client/ui.ts
CHANGED
|
@@ -196,6 +196,10 @@ export interface OverlayView {
|
|
|
196
196
|
agentMarks: Readonly<Record<AgentID, string>>;
|
|
197
197
|
selected: number;
|
|
198
198
|
confirmKill: boolean;
|
|
199
|
+
|
|
200
|
+
// A kill the daemon refused because forgetting the session destroys its
|
|
201
|
+
// host waits on a second confirm.
|
|
202
|
+
confirmDestroy: boolean;
|
|
199
203
|
filter: string | null;
|
|
200
204
|
stale: boolean;
|
|
201
205
|
grouped: boolean;
|
|
@@ -271,6 +275,10 @@ export function drawOverlay(view: OverlayView) {
|
|
|
271
275
|
hint = formatKillConfirm(selected, view.sessions);
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
if (view.confirmDestroy) {
|
|
279
|
+
hint = 'forget destroys its host and everything on it? y / n';
|
|
280
|
+
}
|
|
281
|
+
|
|
274
282
|
rowsList.push(dimRow(width, hint), boxBottom(width));
|
|
275
283
|
|
|
276
284
|
drawBox(rowsList);
|