@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.
Files changed (71) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-agent-adapters.ts +21 -3
  3. package/src/agents/claude-adapter.ts +4 -3
  4. package/src/agents/codex-adapter.ts +20 -0
  5. package/src/agents/gateway-adapter.ts +2 -2
  6. package/src/agents/plan-claude-line-input.ts +44 -0
  7. package/src/agents/render-sdk-message.ts +57 -0
  8. package/src/agents/start-claude-headless-run.ts +2 -57
  9. package/src/agents/with-claude-config-lock.ts +28 -15
  10. package/src/client/boot-daemon.ts +75 -25
  11. package/src/client/resolve-workspace-root.ts +10 -5
  12. package/src/daemon/session-runtime.ts +0 -4
  13. package/src/gateway.ts +2 -179
  14. package/src/mcp/mcp-tools.ts +1 -1
  15. package/src/mcp-http-server.ts +13 -10
  16. package/src/run-gateway-cli.ts +327 -0
  17. package/src/run-gateway.ts +49 -18
  18. package/src/sources/github/run-gh.ts +9 -0
  19. package/src/test-utils/build-mock-execution-target.ts +25 -0
  20. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  21. package/src/test-utils/build-mock-message-record.ts +7 -3
  22. package/src/test-utils/build-mock-stored-event.ts +20 -0
  23. package/src/test-utils/build-mock-stored-row.ts +23 -0
  24. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  25. package/src/test-utils/build-stub-held-gh.ts +5 -7
  26. package/src/test-utils/build-stub-held-zoxide.ts +4 -6
  27. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  28. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  29. package/src/test-utils/build-stub-recording-git.ts +8 -0
  30. package/src/test-utils/create-git-fixture.ts +5 -8
  31. package/src/test-utils/create-stub-claude.ts +3 -1
  32. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  33. package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +39 -5
  34. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  35. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  36. package/src/test-utils/create-stub-systemd.ts +10 -4
  37. package/src/test-utils/get-only-imp-name.ts +2 -2
  38. package/src/test-utils/register-test-cleanup.ts +63 -0
  39. package/src/test-utils/run-mcp-authorization.ts +7 -4
  40. package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +2 -2
  41. package/src/test-utils/setup-mcp-home.ts +24 -13
  42. package/src/test-utils/setup-mcp-http.ts +15 -7
  43. package/src/test-utils/setup-temp-dir.ts +7 -6
  44. package/src/test-utils/start-cut-proxy.ts +6 -2
  45. package/src/test-utils/start-daemon-process.ts +25 -24
  46. package/src/test-utils/start-git-http-server.ts +9 -3
  47. package/src/test-utils/start-mcp-stdio.ts +17 -22
  48. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  49. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  50. package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
  51. package/src/test-utils/start-stub-impd-info.ts +10 -4
  52. package/src/test-utils/start-stub-impd.ts +8 -3
  53. package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
  54. package/src/test-utils/start-stub-mcp-server.ts +7 -2
  55. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  56. package/src/test-utils/start-stub-refusing-listener.ts +9 -4
  57. package/src/test-utils/start-stub-reporter-socket.ts +6 -4
  58. package/src/test-utils/start-stub-restore-daemon.ts +5 -4
  59. package/src/test-utils/start-stub-session-bridge.ts +6 -4
  60. package/src/test-utils/start-stub-silent-server.ts +10 -5
  61. package/src/test-utils/start-stub-source-daemon.ts +6 -4
  62. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  63. package/src/test-utils/start-stub-stalled-listener.ts +13 -9
  64. package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
  65. package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
  66. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  67. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  68. package/src/test-utils/start-test-daemon.ts +20 -6
  69. package/src/test-utils/start-tui-harness.ts +15 -8
  70. package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
  71. /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.0",
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 src/gateway.test.ts",
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({ ...entry, baseURL: entry.baseURL }, config, headlessRun);
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 { planPastedLineInput } from './plan-pasted-line-input';
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
- readonly planLineInput = planPastedLineInput;
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 { planPastedLineInput } from './plan-pasted-line-input';
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 = planPastedLineInput;
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 = renderSdkMessage(message);
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: how often a held lock's age is refreshed, and a
17
- * callback run each time the lock is found held by someone else.
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 refreshMs?: number;
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 timer = setInterval(() => {
62
- refreshing = (async () => {
63
- const refreshed = await refreshOwnedLock(lockPath, owned);
65
+ const scheduleRefresh = (): (() => void) =>
66
+ clock.schedule(() => {
67
+ refreshing = (async () => {
68
+ const refreshed = await refreshOwnedLock(lockPath, owned, clock.now());
64
69
 
65
- owned = refreshed ?? owned;
66
- })();
67
- }, options.refreshMs ?? REFRESH_MS);
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
- clearInterval(timer);
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 directory this call
121
- // created, and resolves to the lock as it then stands; null leaves the
122
- // lock to go stale, as a holder that stopped refreshing it does.
123
- async function refreshOwnedLock(lockPath: string, owned: OwnedLock): Promise<OwnedLock | null> {
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
- const opened = wait === null ? await openOrBootDaemon() : await waitForDaemon(wait);
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
- async function openOrBootDaemon(): Promise<OpenedDaemon> {
147
- const opened = await tryOpenKnownDaemon();
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.timeoutMs));
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.timeoutMs)));
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(daemonSocketPath);
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(daemonRecordFile);
278
+ const record = findDaemonRecord(paths.recordFile);
239
279
 
240
- if (record === null || record.socketPath === daemonSocketPath) {
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() ?? 'the atc daemon did not come up; try `atc daemon` for its output'
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(daemonRecordFile);
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(timeoutMs: number): string {
353
+ function formatWaitFailure(wait: DaemonWait): string {
304
354
  return (
305
- formatUnreachableDaemon() ??
306
- `no atc daemon answered at ${daemonSocketPath} within ${timeoutMs / 1000}s, and this process does not start one; start \`atc daemon\` (or the service that runs it) first`
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(daemonRecordFile),
315
- pidFileSocketPath: daemonSocketPath,
316
- pidFilePID: findPidFilePID(daemonPidFile),
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(config: WorkspacesConfig, target: RootTarget): WorkspaceRoot {
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('~/') ? `${resolveHomeDir()}${dir.slice(1)}` : dir;
52
+ function expandHome(dir: string, home: string): string {
53
+ return dir === '~' || dir.startsWith('~/') ? `${home}${dir.slice(1)}` : dir;
49
54
  }