@zgeoff/atc 3.4.0 → 3.4.2
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/package.json +2 -2
- package/src/agents/build-agent-adapters.ts +21 -3
- package/src/agents/claude-adapter.ts +4 -3
- package/src/agents/codex-adapter.ts +20 -0
- package/src/agents/gateway-adapter.ts +2 -2
- package/src/agents/plan-claude-line-input.ts +44 -0
- package/src/agents/render-sdk-message.ts +57 -0
- package/src/agents/start-claude-headless-run.ts +2 -57
- package/src/agents/with-claude-config-lock.ts +28 -15
- package/src/client/boot-daemon.ts +75 -25
- package/src/client/resolve-workspace-root.ts +10 -5
- package/src/daemon/session-runtime.ts +0 -4
- package/src/gateway.ts +2 -179
- package/src/mcp/mcp-tools.ts +1 -1
- package/src/mcp-http-server.ts +13 -10
- package/src/run-gateway-cli.ts +327 -0
- package/src/run-gateway.ts +49 -18
- package/src/sources/github/run-gh.ts +9 -0
- package/src/test-utils/build-mock-execution-target.ts +25 -0
- package/src/test-utils/build-mock-imp-session-request.ts +53 -0
- package/src/test-utils/build-mock-message-record.ts +7 -3
- package/src/test-utils/build-mock-stored-event.ts +20 -0
- package/src/test-utils/build-mock-stored-row.ts +23 -0
- package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
- package/src/test-utils/build-stub-held-gh.ts +5 -7
- package/src/test-utils/build-stub-held-zoxide.ts +4 -6
- package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
- package/src/test-utils/build-stub-recording-filter.ts +9 -0
- package/src/test-utils/build-stub-recording-git.ts +8 -0
- package/src/test-utils/create-git-fixture.ts +5 -8
- package/src/test-utils/create-stub-claude.ts +3 -1
- package/src/test-utils/create-stub-echo-claude.ts +21 -0
- package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +39 -5
- package/src/test-utils/create-stub-recording-codex.ts +20 -0
- package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
- package/src/test-utils/create-stub-systemd.ts +10 -4
- package/src/test-utils/get-only-imp-name.ts +2 -2
- package/src/test-utils/register-test-cleanup.ts +63 -0
- package/src/test-utils/run-mcp-authorization.ts +7 -4
- package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +2 -2
- package/src/test-utils/setup-mcp-home.ts +24 -13
- package/src/test-utils/setup-mcp-http.ts +15 -7
- package/src/test-utils/setup-temp-dir.ts +7 -6
- package/src/test-utils/start-cut-proxy.ts +6 -2
- package/src/test-utils/start-daemon-process.ts +25 -24
- package/src/test-utils/start-git-http-server.ts +9 -3
- package/src/test-utils/start-mcp-stdio.ts +17 -22
- package/src/test-utils/start-stub-answering-listener.ts +65 -0
- package/src/test-utils/start-stub-closing-listener.ts +31 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
- package/src/test-utils/start-stub-impd-info.ts +10 -4
- package/src/test-utils/start-stub-impd.ts +8 -3
- package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
- package/src/test-utils/start-stub-mcp-server.ts +7 -2
- package/src/test-utils/start-stub-recording-listener.ts +47 -0
- package/src/test-utils/start-stub-refusing-listener.ts +9 -4
- package/src/test-utils/start-stub-reporter-socket.ts +6 -4
- package/src/test-utils/start-stub-restore-daemon.ts +5 -4
- package/src/test-utils/start-stub-session-bridge.ts +6 -4
- package/src/test-utils/start-stub-silent-server.ts +10 -5
- package/src/test-utils/start-stub-source-daemon.ts +6 -4
- package/src/test-utils/start-stub-stalled-client.ts +61 -0
- package/src/test-utils/start-stub-stalled-listener.ts +13 -9
- package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
- package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
- package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
- package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
- package/src/test-utils/start-test-daemon.ts +20 -6
- package/src/test-utils/start-tui-harness.ts +15 -8
- package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
- /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.2",
|
|
4
4
|
"description": "Terminal control tower for coding-agent sessions",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/atc#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/atc/issues",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json",
|
|
32
32
|
"test:timings": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json --update-timings",
|
|
33
33
|
"test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/daemon-*.test.ts",
|
|
34
|
-
"test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000
|
|
34
|
+
"test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/gateway-*.test.ts",
|
|
35
35
|
"test:atc-bridge": "bash scripts/with-test-home.sh bash scripts/test-atc-bridge.sh",
|
|
36
36
|
"test:isolation": "bash scripts/check-test-isolation.sh",
|
|
37
37
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { Config } from '../shared/config';
|
|
2
|
-
import type { AgentAdapter } from './agent-adapter';
|
|
2
|
+
import type { AgentAdapter, ClaudeHostPaths } from './agent-adapter';
|
|
3
3
|
import { ClaudeAdapter } from './claude-adapter';
|
|
4
4
|
import { CodexAdapter } from './codex-adapter';
|
|
5
5
|
import { GatewayAdapter } from './gateway-adapter';
|
|
6
6
|
import { GrokAdapter } from './grok-adapter';
|
|
7
7
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Where the Claude and gateway adapters write their settings file and their
|
|
11
|
+
* copy of the atc-bridge mod, and the home whose Claude config they read. A
|
|
12
|
+
* field left out takes atc's state folder or the running user's home.
|
|
13
|
+
*/
|
|
14
|
+
interface AgentAdapterPaths extends ClaudeHostPaths {
|
|
15
|
+
readonly bridgeTarget?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
19
|
* One adapter per registry entry, in registry order. A Claude entry with a
|
|
11
20
|
* base URL is a gateway, and one without is stock Claude.
|
|
@@ -13,7 +22,10 @@ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
|
13
22
|
export function buildAgentAdapters(
|
|
14
23
|
config: Config,
|
|
15
24
|
headlessRun: ClaudeHeadlessRun | null = null,
|
|
25
|
+
paths: AgentAdapterPaths = {},
|
|
16
26
|
): AgentAdapter[] {
|
|
27
|
+
const { bridgeTarget, ...hostPaths } = paths;
|
|
28
|
+
|
|
17
29
|
return config.agents.map((entry): AgentAdapter => {
|
|
18
30
|
if (entry.kind === 'codex') {
|
|
19
31
|
return new CodexAdapter(entry, config);
|
|
@@ -24,9 +36,15 @@ export function buildAgentAdapters(
|
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
if (entry.baseURL === undefined) {
|
|
27
|
-
return new ClaudeAdapter(entry, config, headlessRun);
|
|
39
|
+
return new ClaudeAdapter(entry, config, headlessRun, bridgeTarget, hostPaths);
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
return new GatewayAdapter(
|
|
42
|
+
return new GatewayAdapter(
|
|
43
|
+
{ ...entry, baseURL: entry.baseURL },
|
|
44
|
+
config,
|
|
45
|
+
headlessRun,
|
|
46
|
+
bridgeTarget,
|
|
47
|
+
hostPaths,
|
|
48
|
+
);
|
|
31
49
|
});
|
|
32
50
|
}
|
|
@@ -48,7 +48,7 @@ import { loadClaudeConfigBundle } from './load-claude-config-bundle';
|
|
|
48
48
|
import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
|
|
49
49
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
50
50
|
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
51
|
-
import {
|
|
51
|
+
import { planClaudeLineInput } from './plan-claude-line-input';
|
|
52
52
|
import { resolveAgentHome } from './resolve-agent-home';
|
|
53
53
|
import { resolveClaudeGlobalConfigPath } from './resolve-claude-global-config-path';
|
|
54
54
|
import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
|
|
@@ -88,8 +88,9 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
88
88
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
89
89
|
|
|
90
90
|
// Claude's TUI takes a long burst of input as a paste and keeps its
|
|
91
|
-
// newline in the composer, so a line is pasted and then submitted
|
|
92
|
-
|
|
91
|
+
// newline in the composer, so a line is pasted and then submitted, with a
|
|
92
|
+
// leading slash command's name typed so the command still runs.
|
|
93
|
+
readonly planLineInput = planClaudeLineInput;
|
|
93
94
|
|
|
94
95
|
readonly profile: AgentProfile;
|
|
95
96
|
|
|
@@ -53,6 +53,11 @@ type CodexHookPayload = z.infer<typeof CODEX_HOOK_PAYLOAD_SCHEMA>;
|
|
|
53
53
|
// The spellings of the Codex CLI's model flag.
|
|
54
54
|
const CODEX_MODEL_FLAGS = ['-m', '--model'];
|
|
55
55
|
|
|
56
|
+
// The Codex CLI flag that runs a terminal without the shared background
|
|
57
|
+
// server, and the flag that connects it to a remote server instead.
|
|
58
|
+
const NO_DAEMON_FLAG = '--no-daemon';
|
|
59
|
+
const REMOTE_FLAG = '--remote';
|
|
60
|
+
|
|
56
61
|
/**
|
|
57
62
|
* The Codex CLI adapter: spawn arguments, hook payload mapping, resume
|
|
58
63
|
* semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
|
|
@@ -118,6 +123,14 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
118
123
|
return {
|
|
119
124
|
bin: this.entry.bin,
|
|
120
125
|
args: [
|
|
126
|
+
// Codex's shared background server keeps the environment of the
|
|
127
|
+
// terminal that started it and runs every thread's hooks and tools
|
|
128
|
+
// there, so a terminal on it reports as that first session. Each
|
|
129
|
+
// terminal therefore runs its own server. The configured arguments
|
|
130
|
+
// already carry the flag, or connect to a remote server, which Codex
|
|
131
|
+
// refuses to combine with it; either way Codex takes no second copy.
|
|
132
|
+
...(this.entry.args.some(isDaemonChoiceFlag) ? [] : [NO_DAEMON_FLAG]),
|
|
133
|
+
|
|
121
134
|
// A model override replaces any model flag the configured arguments
|
|
122
135
|
// carry, and travels as its own argument.
|
|
123
136
|
...(opts.model === undefined
|
|
@@ -420,3 +433,10 @@ function buildCodexSpawnOptions(codexArgs: readonly string[]): SpawnOptionSpecs
|
|
|
420
433
|
},
|
|
421
434
|
};
|
|
422
435
|
}
|
|
436
|
+
|
|
437
|
+
// Whether a configured argument already decides which server a terminal
|
|
438
|
+
// uses: the flag that runs it without the shared one, or the remote server
|
|
439
|
+
// flag in either of its spellings.
|
|
440
|
+
function isDaemonChoiceFlag(arg: string): boolean {
|
|
441
|
+
return arg === NO_DAEMON_FLAG || arg === REMOTE_FLAG || arg.startsWith(`${REMOTE_FLAG}=`);
|
|
442
|
+
}
|
|
@@ -38,7 +38,7 @@ import { findFlagValue } from './find-flag-value';
|
|
|
38
38
|
import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
|
|
39
39
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
40
40
|
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
41
|
-
import {
|
|
41
|
+
import { planClaudeLineInput } from './plan-claude-line-input';
|
|
42
42
|
import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
|
|
43
43
|
import { writeATCBridge } from './write-atc-bridge';
|
|
44
44
|
import { writeHookSettings } from './write-hook-settings';
|
|
@@ -68,7 +68,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
68
68
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
69
69
|
|
|
70
70
|
// The gateway runs the Claude CLI, whose TUI takes a line the same way.
|
|
71
|
-
readonly planLineInput =
|
|
71
|
+
readonly planLineInput = planClaudeLineInput;
|
|
72
72
|
|
|
73
73
|
readonly takesMessages = true;
|
|
74
74
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { TerminalInputModes } from './agent-adapter';
|
|
2
|
+
import { planPastedLineInput } from './plan-pasted-line-input';
|
|
3
|
+
|
|
4
|
+
const PASTE_START = '\u001B[200~';
|
|
5
|
+
const PASTE_END = '\u001B[201~';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pastes a line into the Claude TUI and submits it, as the pasted line
|
|
9
|
+
* planner does, except for a line that opens with a slash command and an
|
|
10
|
+
* argument. Claude takes a long paste as pasted text and sends it as a
|
|
11
|
+
* message, so such a line has its command name and the spaces after it typed
|
|
12
|
+
* as keys and only the argument pasted, then one carriage return. The
|
|
13
|
+
* command then runs at any argument length. A TUI that has not turned
|
|
14
|
+
* bracketed paste on gets every line as the pasted line planner writes it.
|
|
15
|
+
*/
|
|
16
|
+
export function planClaudeLineInput(text: string, modes: TerminalInputModes): readonly string[] {
|
|
17
|
+
const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
|
|
18
|
+
const command = modes.bracketedPaste ? findSlashCommand(unmarked) : null;
|
|
19
|
+
|
|
20
|
+
if (command === null) {
|
|
21
|
+
return planPastedLineInput(text, modes);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return [command.typed, `${PASTE_START}${command.argument}${PASTE_END}`, '\r'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A slash, a command name that may hold a plugin namespace, at least one
|
|
28
|
+
// space, then the argument. A path such as `/tmp/out` has a slash after its
|
|
29
|
+
// first name, so it never matches.
|
|
30
|
+
const SLASH_COMMAND = /^(?<typed>\/[A-Za-z0-9][\w:-]* +)(?<argument>.+)$/su;
|
|
31
|
+
|
|
32
|
+
function findSlashCommand(text: string): { typed: string; argument: string } | null {
|
|
33
|
+
const groups = SLASH_COMMAND.exec(text)?.groups;
|
|
34
|
+
const typed = groups?.['typed'];
|
|
35
|
+
const argument = groups?.['argument'];
|
|
36
|
+
|
|
37
|
+
// An argument of spaces alone leaves a bare command, which the line
|
|
38
|
+
// pastes whole like any short line.
|
|
39
|
+
if (typed === undefined || argument === undefined || argument.trim() === '') {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { typed, argument };
|
|
44
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import { truncateSummary } from '../shared/truncate-summary';
|
|
3
|
+
|
|
4
|
+
interface RenderableMessage {
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly subtype?: string;
|
|
7
|
+
readonly result?: string;
|
|
8
|
+
readonly message?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One structured SDK message → zero or one plain-text line for the session's
|
|
13
|
+
* output pipe: assistant text verbatim, tool calls as compact one-liners,
|
|
14
|
+
* results as a closing line.
|
|
15
|
+
*/
|
|
16
|
+
export function renderSDKMessage(message: RenderableMessage): string | null {
|
|
17
|
+
if (message.type === 'assistant') {
|
|
18
|
+
const inner = message.message;
|
|
19
|
+
|
|
20
|
+
if (!isRecord(inner) || !Array.isArray(inner['content'])) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const content = inner['content'];
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
|
|
27
|
+
for (const raw of content) {
|
|
28
|
+
const block: unknown = raw;
|
|
29
|
+
|
|
30
|
+
if (!isRecord(block)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
block['type'] === 'text' &&
|
|
36
|
+
typeof block['text'] === 'string' &&
|
|
37
|
+
block['text'].trim() !== ''
|
|
38
|
+
) {
|
|
39
|
+
lines.push(block['text'].replaceAll('\n', '\r\n'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (block['type'] === 'tool_use' && typeof block['name'] === 'string') {
|
|
43
|
+
lines.push(`⚙ ${block['name']} ${truncateSummary(JSON.stringify(block['input'] ?? {}))}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return lines.length === 0 ? null : lines.join('\r\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (message.type === 'result') {
|
|
51
|
+
return message.subtype === 'success'
|
|
52
|
+
? `— headless turn done: ${truncateSummary(message.result ?? '')}`
|
|
53
|
+
: `— headless turn stopped: ${message.subtype ?? 'unknown'}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
2
|
-
import { isRecord } from '../shared/report';
|
|
3
2
|
import { truncateSummary } from '../shared/truncate-summary';
|
|
4
3
|
import { buildClaudeQueryOptions } from './build-claude-query-options';
|
|
5
4
|
import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
|
|
5
|
+
import { renderSDKMessage } from './render-sdk-message';
|
|
6
6
|
|
|
7
7
|
interface HeadlessRunHooks {
|
|
8
8
|
readonly onOutput: (text: string) => void;
|
|
@@ -47,7 +47,7 @@ export function startClaudeHeadlessRun(
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
for await (const message of stream) {
|
|
50
|
-
const rendered =
|
|
50
|
+
const rendered = renderSDKMessage(message);
|
|
51
51
|
|
|
52
52
|
if (rendered !== null) {
|
|
53
53
|
hooks.onOutput(`${rendered}\r\n`);
|
|
@@ -84,58 +84,3 @@ export function startClaudeHeadlessRun(
|
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
-
|
|
88
|
-
interface RenderableMessage {
|
|
89
|
-
readonly type: string;
|
|
90
|
-
readonly subtype?: string;
|
|
91
|
-
readonly result?: string;
|
|
92
|
-
readonly message?: unknown;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* One structured SDK message → zero or one plain-text line for the session's
|
|
97
|
-
* output pipe: assistant text verbatim, tool calls as compact one-liners,
|
|
98
|
-
* results as a closing line.
|
|
99
|
-
*/
|
|
100
|
-
export function renderSdkMessage(message: RenderableMessage): string | null {
|
|
101
|
-
if (message.type === 'assistant') {
|
|
102
|
-
const inner = message.message;
|
|
103
|
-
|
|
104
|
-
if (!isRecord(inner) || !Array.isArray(inner['content'])) {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const content = inner['content'];
|
|
109
|
-
const lines: string[] = [];
|
|
110
|
-
|
|
111
|
-
for (const raw of content) {
|
|
112
|
-
const block: unknown = raw;
|
|
113
|
-
|
|
114
|
-
if (!isRecord(block)) {
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
block['type'] === 'text' &&
|
|
120
|
-
typeof block['text'] === 'string' &&
|
|
121
|
-
block['text'].trim() !== ''
|
|
122
|
-
) {
|
|
123
|
-
lines.push(block['text'].replaceAll('\n', '\r\n'));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (block['type'] === 'tool_use' && typeof block['name'] === 'string') {
|
|
127
|
-
lines.push(`⚙ ${block['name']} ${truncateSummary(JSON.stringify(block['input'] ?? {}))}`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return lines.length === 0 ? null : lines.join('\r\n');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (message.type === 'result') {
|
|
135
|
-
return message.subtype === 'success'
|
|
136
|
-
? `— headless turn done: ${truncateSummary(message.result ?? '')}`
|
|
137
|
-
: `— headless turn stopped: ${message.subtype ?? 'unknown'}`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { mkdir, rmdir, stat, utimes } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
+
import type { Clock } from '../shared/system-clock';
|
|
4
|
+
import { systemClock } from '../shared/system-clock';
|
|
3
5
|
|
|
4
6
|
// A lock older than this belongs to a holder that died: the Claude CLI's
|
|
5
7
|
// lock library refreshes a held lock's age well inside it.
|
|
@@ -13,11 +15,12 @@ const WAIT_MS = 5000;
|
|
|
13
15
|
const RETRY_MS = 50;
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
|
-
* Tuning a caller may pass:
|
|
17
|
-
* callback run each time the lock is found held by
|
|
18
|
+
* Tuning a caller may pass: the clock that times and stamps each refresh of
|
|
19
|
+
* a held lock's age, and a callback run each time the lock is found held by
|
|
20
|
+
* someone else.
|
|
18
21
|
*/
|
|
19
22
|
export interface ClaudeConfigLockOptions {
|
|
20
|
-
readonly
|
|
23
|
+
readonly clock?: Clock;
|
|
21
24
|
readonly onBusy?: () => void;
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -55,21 +58,27 @@ export async function withClaudeConfigLock<T>(
|
|
|
55
58
|
held = await tryCreateLockDir(lockPath);
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
const clock = options.clock ?? systemClock;
|
|
58
62
|
let owned: OwnedLock = held;
|
|
59
63
|
let refreshing = Promise.resolve();
|
|
60
64
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
const scheduleRefresh = (): (() => void) =>
|
|
66
|
+
clock.schedule(() => {
|
|
67
|
+
refreshing = (async () => {
|
|
68
|
+
const refreshed = await refreshOwnedLock(lockPath, owned, clock.now());
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
owned = refreshed ?? owned;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
stopRefreshing = scheduleRefresh();
|
|
74
|
+
}, REFRESH_MS);
|
|
75
|
+
|
|
76
|
+
let stopRefreshing = scheduleRefresh();
|
|
68
77
|
|
|
69
78
|
try {
|
|
70
79
|
return await run();
|
|
71
80
|
} finally {
|
|
72
|
-
|
|
81
|
+
stopRefreshing();
|
|
73
82
|
|
|
74
83
|
await refreshing;
|
|
75
84
|
|
|
@@ -117,10 +126,14 @@ function isExistsError(error: unknown): boolean {
|
|
|
117
126
|
return error instanceof Error && 'code' in error && error.code === 'EEXIST';
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
// Moves the lock's age forward while it is still the
|
|
121
|
-
// created, and resolves to the lock as it then stands;
|
|
122
|
-
// lock to go stale, as a holder that stopped refreshing it does.
|
|
123
|
-
async function refreshOwnedLock(
|
|
129
|
+
// Moves the lock's age forward to the given time while it is still the
|
|
130
|
+
// directory this call created, and resolves to the lock as it then stands;
|
|
131
|
+
// null leaves the lock to go stale, as a holder that stopped refreshing it does.
|
|
132
|
+
async function refreshOwnedLock(
|
|
133
|
+
lockPath: string,
|
|
134
|
+
owned: OwnedLock,
|
|
135
|
+
nowMs: number,
|
|
136
|
+
): Promise<OwnedLock | null> {
|
|
124
137
|
const isOwned = await isOwnedLock(lockPath, owned);
|
|
125
138
|
|
|
126
139
|
if (!isOwned) {
|
|
@@ -128,7 +141,7 @@ async function refreshOwnedLock(lockPath: string, owned: OwnedLock): Promise<Own
|
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
try {
|
|
131
|
-
const now = new Date();
|
|
144
|
+
const now = new Date(nowMs);
|
|
132
145
|
|
|
133
146
|
await utimes(lockPath, now, now);
|
|
134
147
|
|
|
@@ -32,6 +32,17 @@ export interface DaemonBoot {
|
|
|
32
32
|
readonly socketPath: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The files that locate a running daemon: the socket this environment
|
|
37
|
+
* computes, the record the daemon writes in its state directory, and its
|
|
38
|
+
* pid file.
|
|
39
|
+
*/
|
|
40
|
+
export interface DaemonPaths {
|
|
41
|
+
readonly socketPath: string;
|
|
42
|
+
readonly recordFile: string;
|
|
43
|
+
readonly pidFile: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
export interface DaemonBootOptions {
|
|
36
47
|
// Called when the daemon speaks another protocol version. Resolving true
|
|
37
48
|
// stops that daemon and boots one from this build, which ends every
|
|
@@ -53,8 +64,20 @@ export interface DaemonBootOptions {
|
|
|
53
64
|
// The time and the timers a waiting boot reads for its deadline and its
|
|
54
65
|
// polls; the wall clock when absent.
|
|
55
66
|
readonly clock?: Clock;
|
|
67
|
+
|
|
68
|
+
// Where the boot looks for a running daemon and its pid; this process's
|
|
69
|
+
// own paths when absent. `atc daemon` takes no paths of its own, only the
|
|
70
|
+
// environment's, so a boot given paths never starts a daemon: when none
|
|
71
|
+
// answers there, it rejects at once.
|
|
72
|
+
readonly paths?: DaemonPaths;
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
const PROCESS_PATHS: DaemonPaths = {
|
|
76
|
+
socketPath: daemonSocketPath,
|
|
77
|
+
recordFile: daemonRecordFile,
|
|
78
|
+
pidFile: daemonPidFile,
|
|
79
|
+
};
|
|
80
|
+
|
|
58
81
|
/**
|
|
59
82
|
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
60
83
|
* neither the computed socket nor the one in the daemon's record answers,
|
|
@@ -72,6 +95,7 @@ export interface DaemonBootOptions {
|
|
|
72
95
|
export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
|
|
73
96
|
let waited = false;
|
|
74
97
|
const clock = options.clock ?? systemClock;
|
|
98
|
+
const paths = options.paths ?? PROCESS_PATHS;
|
|
75
99
|
|
|
76
100
|
const wait =
|
|
77
101
|
options.waitForDaemonMs === undefined
|
|
@@ -80,6 +104,7 @@ export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise
|
|
|
80
104
|
deadline: clock.now() + options.waitForDaemonMs,
|
|
81
105
|
timeoutMs: options.waitForDaemonMs,
|
|
82
106
|
clock,
|
|
107
|
+
paths,
|
|
83
108
|
onWait: () => {
|
|
84
109
|
if (!waited) {
|
|
85
110
|
waited = true;
|
|
@@ -90,7 +115,12 @@ export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise
|
|
|
90
115
|
|
|
91
116
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
92
117
|
const build = getBuild();
|
|
93
|
-
|
|
118
|
+
|
|
119
|
+
const opened =
|
|
120
|
+
wait === null
|
|
121
|
+
? await openOrBootDaemon(paths, options.paths === undefined)
|
|
122
|
+
: await waitForDaemon(wait);
|
|
123
|
+
|
|
94
124
|
const client = opened.client;
|
|
95
125
|
|
|
96
126
|
try {
|
|
@@ -115,7 +145,7 @@ export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise
|
|
|
115
145
|
|
|
116
146
|
const mismatch: ProtocolMismatch = {
|
|
117
147
|
socketPath: opened.socketPath,
|
|
118
|
-
daemonPID: findDaemonPID(opened.socketPath),
|
|
148
|
+
daemonPID: findDaemonPID(opened.socketPath, paths),
|
|
119
149
|
clientBuild: build,
|
|
120
150
|
clientProtocol: PROTOCOL_V,
|
|
121
151
|
daemonMessage: error.message,
|
|
@@ -143,19 +173,28 @@ interface OpenedDaemon {
|
|
|
143
173
|
readonly socketPath: string;
|
|
144
174
|
}
|
|
145
175
|
|
|
146
|
-
|
|
147
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Opens the daemon at the known paths, starting one first when none
|
|
178
|
+
* answers and the boot may start one. A boot that may not start one, since
|
|
179
|
+
* a daemon it started would listen elsewhere, rejects instead.
|
|
180
|
+
*/
|
|
181
|
+
async function openOrBootDaemon(paths: DaemonPaths, canStart: boolean): Promise<OpenedDaemon> {
|
|
182
|
+
const opened = await tryOpenKnownDaemon(paths);
|
|
148
183
|
|
|
149
184
|
if (opened !== null) {
|
|
150
185
|
return opened;
|
|
151
186
|
}
|
|
152
187
|
|
|
188
|
+
if (!canStart) {
|
|
189
|
+
throw new Error(formatGivenPathsFailure(paths));
|
|
190
|
+
}
|
|
191
|
+
|
|
153
192
|
await bootDaemonOnce();
|
|
154
193
|
|
|
155
|
-
const booted = await tryOpenKnownDaemon();
|
|
194
|
+
const booted = await tryOpenKnownDaemon(paths);
|
|
156
195
|
|
|
157
196
|
if (booted === null) {
|
|
158
|
-
throw new Error(formatBootFailure());
|
|
197
|
+
throw new Error(formatBootFailure(paths));
|
|
159
198
|
}
|
|
160
199
|
|
|
161
200
|
return booted;
|
|
@@ -167,6 +206,7 @@ interface DaemonWait {
|
|
|
167
206
|
readonly deadline: number;
|
|
168
207
|
readonly timeoutMs: number;
|
|
169
208
|
readonly clock: Clock;
|
|
209
|
+
readonly paths: DaemonPaths;
|
|
170
210
|
|
|
171
211
|
// Called on every miss; the boot reports only the first to its caller.
|
|
172
212
|
readonly onWait: () => void;
|
|
@@ -178,7 +218,7 @@ interface DaemonWait {
|
|
|
178
218
|
*/
|
|
179
219
|
async function waitForDaemon(wait: DaemonWait): Promise<OpenedDaemon> {
|
|
180
220
|
for (;;) {
|
|
181
|
-
const opened = await tryOpenKnownDaemon();
|
|
221
|
+
const opened = await tryOpenKnownDaemon(wait.paths);
|
|
182
222
|
|
|
183
223
|
if (opened !== null) {
|
|
184
224
|
return opened;
|
|
@@ -187,7 +227,7 @@ async function waitForDaemon(wait: DaemonWait): Promise<OpenedDaemon> {
|
|
|
187
227
|
wait.onWait();
|
|
188
228
|
|
|
189
229
|
if (wait.clock.now() >= wait.deadline) {
|
|
190
|
-
throw new Error(formatWaitFailure(wait
|
|
230
|
+
throw new Error(formatWaitFailure(wait));
|
|
191
231
|
}
|
|
192
232
|
|
|
193
233
|
const polled = Promise.withResolvers<void>();
|
|
@@ -211,7 +251,7 @@ async function waitForHello(
|
|
|
211
251
|
|
|
212
252
|
const cancel = wait.clock.schedule(
|
|
213
253
|
() => {
|
|
214
|
-
expired.reject(new Error(formatWaitFailure(wait
|
|
254
|
+
expired.reject(new Error(formatWaitFailure(wait)));
|
|
215
255
|
},
|
|
216
256
|
Math.max(0, wait.deadline - wait.clock.now()),
|
|
217
257
|
);
|
|
@@ -228,16 +268,16 @@ async function waitForHello(
|
|
|
228
268
|
* daemon recorded in the state directory: a client whose environment lacks
|
|
229
269
|
* XDG_RUNTIME_DIR computes a different path from the daemon's.
|
|
230
270
|
*/
|
|
231
|
-
async function tryOpenKnownDaemon(): Promise<OpenedDaemon | null> {
|
|
232
|
-
const computed = await tryOpenDaemon(
|
|
271
|
+
async function tryOpenKnownDaemon(paths: DaemonPaths): Promise<OpenedDaemon | null> {
|
|
272
|
+
const computed = await tryOpenDaemon(paths.socketPath);
|
|
233
273
|
|
|
234
274
|
if (computed !== null) {
|
|
235
275
|
return computed;
|
|
236
276
|
}
|
|
237
277
|
|
|
238
|
-
const record = findDaemonRecord(
|
|
278
|
+
const record = findDaemonRecord(paths.recordFile);
|
|
239
279
|
|
|
240
|
-
if (record === null || record.socketPath ===
|
|
280
|
+
if (record === null || record.socketPath === paths.socketPath) {
|
|
241
281
|
return null;
|
|
242
282
|
}
|
|
243
283
|
|
|
@@ -263,7 +303,7 @@ const bootDaemonOnce = makeSingleFlight(async () => {
|
|
|
263
303
|
while (Date.now() < deadline) {
|
|
264
304
|
await Bun.sleep(100);
|
|
265
305
|
|
|
266
|
-
const probe = await tryOpenKnownDaemon();
|
|
306
|
+
const probe = await tryOpenKnownDaemon(PROCESS_PATHS);
|
|
267
307
|
|
|
268
308
|
if (probe !== null) {
|
|
269
309
|
probe.client.stop();
|
|
@@ -282,14 +322,24 @@ const bootDaemonOnce = makeSingleFlight(async () => {
|
|
|
282
322
|
// A live daemon this process cannot reach, such as one whose runtime
|
|
283
323
|
// directory a sandbox hides, needs a different fix than one that never
|
|
284
324
|
// started, so the message tells them apart.
|
|
285
|
-
function formatBootFailure(): string {
|
|
325
|
+
function formatBootFailure(paths: DaemonPaths): string {
|
|
326
|
+
return (
|
|
327
|
+
formatUnreachableDaemon(paths) ??
|
|
328
|
+
'the atc daemon did not come up; try `atc daemon` for its output'
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// A boot given its own paths starts no daemon, since `atc daemon` would
|
|
333
|
+
// listen at this process's paths instead, so the message says so.
|
|
334
|
+
function formatGivenPathsFailure(paths: DaemonPaths): string {
|
|
286
335
|
return (
|
|
287
|
-
formatUnreachableDaemon() ??
|
|
336
|
+
formatUnreachableDaemon(paths) ??
|
|
337
|
+
`no atc daemon answered at ${paths.socketPath}, and a boot given its own daemon paths does not start one; start \`atc daemon\` where it listens there first`
|
|
288
338
|
);
|
|
289
339
|
}
|
|
290
340
|
|
|
291
|
-
function formatUnreachableDaemon(): string | null {
|
|
292
|
-
const record = findDaemonRecord(
|
|
341
|
+
function formatUnreachableDaemon(paths: DaemonPaths): string | null {
|
|
342
|
+
const record = findDaemonRecord(paths.recordFile);
|
|
293
343
|
|
|
294
344
|
if (record !== null && isProcessAlive(record.pid)) {
|
|
295
345
|
return `the atc daemon (pid ${record.pid}) is running, but its socket ${record.socketPath} is unreachable from here`;
|
|
@@ -300,20 +350,20 @@ function formatUnreachableDaemon(): string | null {
|
|
|
300
350
|
|
|
301
351
|
// A waiting caller was told not to start a daemon, so the message says so
|
|
302
352
|
// and points at starting the managed one.
|
|
303
|
-
function formatWaitFailure(
|
|
353
|
+
function formatWaitFailure(wait: DaemonWait): string {
|
|
304
354
|
return (
|
|
305
|
-
formatUnreachableDaemon() ??
|
|
306
|
-
`no atc daemon answered at ${
|
|
355
|
+
formatUnreachableDaemon(wait.paths) ??
|
|
356
|
+
`no atc daemon answered at ${wait.paths.socketPath} within ${wait.timeoutMs / 1000}s, and this process does not start one; start \`atc daemon\` (or the service that runs it) first`
|
|
307
357
|
);
|
|
308
358
|
}
|
|
309
359
|
|
|
310
360
|
// The pid of the daemon behind the socket that refused the handshake.
|
|
311
|
-
function findDaemonPID(socketPath: string): number | null {
|
|
361
|
+
function findDaemonPID(socketPath: string, paths: DaemonPaths): number | null {
|
|
312
362
|
return pickStaleDaemonPID({
|
|
313
363
|
socketPath,
|
|
314
|
-
record: findDaemonRecord(
|
|
315
|
-
pidFileSocketPath:
|
|
316
|
-
pidFilePID: findPidFilePID(
|
|
364
|
+
record: findDaemonRecord(paths.recordFile),
|
|
365
|
+
pidFileSocketPath: paths.socketPath,
|
|
366
|
+
pidFilePID: findPidFilePID(paths.pidFile),
|
|
317
367
|
});
|
|
318
368
|
}
|
|
319
369
|
|
|
@@ -21,13 +21,18 @@ const DEFAULT_ROOT = '~/.local/share/atc/workspaces';
|
|
|
21
21
|
* root, else the global root, else the default. On the daemon's own
|
|
22
22
|
* machine a leading `~` expands to the home directory. On any other target
|
|
23
23
|
* the root must be absolute, since `~` there is a home this client cannot
|
|
24
|
-
* see, so a root that is not is refused with the config key to set.
|
|
24
|
+
* see, so a root that is not is refused with the config key to set. The
|
|
25
|
+
* home directory defaults to the user's own.
|
|
25
26
|
*/
|
|
26
|
-
export function resolveWorkspaceRoot(
|
|
27
|
+
export function resolveWorkspaceRoot(
|
|
28
|
+
config: WorkspacesConfig,
|
|
29
|
+
target: RootTarget,
|
|
30
|
+
home: string = resolveHomeDir(),
|
|
31
|
+
): WorkspaceRoot {
|
|
27
32
|
const configured = config.targetRoots.get(target.id) ?? config.root;
|
|
28
33
|
|
|
29
34
|
if (target.inPlace) {
|
|
30
|
-
const root = expandHome(configured ?? DEFAULT_ROOT);
|
|
35
|
+
const root = expandHome(configured ?? DEFAULT_ROOT, home);
|
|
31
36
|
|
|
32
37
|
return root.startsWith('/')
|
|
33
38
|
? { ok: true, root }
|
|
@@ -44,6 +49,6 @@ export function resolveWorkspaceRoot(config: WorkspacesConfig, target: RootTarge
|
|
|
44
49
|
return { ok: true, root: configured };
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
function expandHome(dir: string): string {
|
|
48
|
-
return dir === '~' || dir.startsWith('~/') ? `${
|
|
52
|
+
function expandHome(dir: string, home: string): string {
|
|
53
|
+
return dir === '~' || dir.startsWith('~/') ? `${home}${dir.slice(1)}` : dir;
|
|
49
54
|
}
|