@zgeoff/atc 3.3.1 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-agent-adapters.ts +21 -3
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +31 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/render-sdk-message.ts +57 -0
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/start-claude-headless-run.ts +2 -57
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +37 -12
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +122 -39
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/resolve-workspace-root.ts +10 -5
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/session-runtime.ts +0 -4
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/gateway.ts +2 -179
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +88 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway-cli.ts +327 -0
- package/src/run-gateway.ts +51 -18
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +24 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-execution-target.ts +25 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-imp-session-request.ts +53 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +26 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- 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-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -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/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +117 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-echo-claude.ts +21 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- 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/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/register-test-cleanup.ts +63 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-mcp-authorization.ts +7 -4
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +110 -0
- package/src/test-utils/setup-mcp-http.ts +26 -30
- package/src/test-utils/setup-temp-dir.ts +7 -6
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +29 -8
- package/src/test-utils/start-daemon-process.ts +193 -0
- package/src/test-utils/start-git-http-server.ts +28 -4
- package/src/test-utils/start-mcp-stdio.ts +190 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- 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 +105 -0
- package/src/test-utils/start-stub-impd-info.ts +38 -0
- package/src/test-utils/start-stub-impd.ts +143 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
- package/src/test-utils/start-stub-mcp-server.ts +55 -0
- package/src/test-utils/start-stub-recording-listener.ts +47 -0
- package/src/test-utils/start-stub-refusing-listener.ts +43 -0
- package/src/test-utils/start-stub-reporter-socket.ts +79 -0
- package/src/test-utils/start-stub-restore-daemon.ts +59 -0
- package/src/test-utils/start-stub-session-bridge.ts +100 -0
- package/src/test-utils/start-stub-silent-server.ts +38 -0
- package/src/test-utils/start-stub-source-daemon.ts +41 -0
- package/src/test-utils/start-stub-stalled-client.ts +61 -0
- package/src/test-utils/start-stub-stalled-listener.ts +44 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
- 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 +229 -0
- package/src/test-utils/start-tui-harness.ts +291 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +30 -17
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- package/src/test-utils/start-legacy-daemon.ts +0 -111
|
@@ -3,6 +3,7 @@ import { readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promise
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { isRecord } from '../shared/report';
|
|
5
5
|
import { withClaudeConfigLock } from './with-claude-config-lock';
|
|
6
|
+
import type { ClaudeConfigLockOptions } from './with-claude-config-lock';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Accepts the Claude CLI's folder trust for one exact directory in its
|
|
@@ -15,30 +16,36 @@ import { withClaudeConfigLock } from './with-claude-config-lock';
|
|
|
15
16
|
* Resolves to a function that takes the trust back, for a launch that
|
|
16
17
|
* fails before the CLI starts: it puts back the entry as it was, unless
|
|
17
18
|
* something has changed the entry since, which leaves it as it stands.
|
|
19
|
+
* The lock options apply to both the trust and its taking back.
|
|
18
20
|
*/
|
|
19
21
|
export async function updateClaudeProjectTrust(
|
|
20
22
|
configPath: string,
|
|
21
23
|
root: string,
|
|
24
|
+
lockOptions: ClaudeConfigLockOptions = {},
|
|
22
25
|
): Promise<() => Promise<void>> {
|
|
23
|
-
const previous = await withClaudeConfigLock(
|
|
24
|
-
|
|
26
|
+
const previous = await withClaudeConfigLock(
|
|
27
|
+
configPath,
|
|
28
|
+
async () => {
|
|
29
|
+
const config = await loadClaudeConfig(configPath);
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
const projects = isRecord(config['projects']) ? config['projects'] : {};
|
|
32
|
+
const entry = projects[root];
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
if (isRecord(entry) && entry['hasTrustDialogAccepted'] === true) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
const base = isRecord(entry) ? entry : {};
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
await writeClaudeConfig(configPath, {
|
|
41
|
+
...config,
|
|
42
|
+
projects: { ...projects, [root]: { ...base, hasTrustDialogAccepted: true } },
|
|
43
|
+
});
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
return { entry: isRecord(entry) ? entry : null };
|
|
46
|
+
},
|
|
47
|
+
lockOptions,
|
|
48
|
+
);
|
|
42
49
|
|
|
43
50
|
if (previous === null) {
|
|
44
51
|
return () => Promise.resolve();
|
|
@@ -50,22 +57,26 @@ export async function updateClaudeProjectTrust(
|
|
|
50
57
|
});
|
|
51
58
|
|
|
52
59
|
return () =>
|
|
53
|
-
withClaudeConfigLock(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
withClaudeConfigLock(
|
|
61
|
+
configPath,
|
|
62
|
+
async () => {
|
|
63
|
+
const config = await loadClaudeConfig(configPath);
|
|
64
|
+
|
|
65
|
+
const projects = isRecord(config['projects']) ? config['projects'] : {};
|
|
66
|
+
|
|
67
|
+
if (JSON.stringify(projects[root]) !== written) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { [root]: _trusted, ...others } = projects;
|
|
72
|
+
|
|
73
|
+
await writeClaudeConfig(configPath, {
|
|
74
|
+
...config,
|
|
75
|
+
projects: previous.entry === null ? others : { ...others, [root]: previous.entry },
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
lockOptions,
|
|
79
|
+
);
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
// The config as the CLI last wrote it; a missing file is an empty config,
|
|
@@ -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.
|
|
@@ -12,6 +14,16 @@ const REFRESH_MS = 1000;
|
|
|
12
14
|
const WAIT_MS = 5000;
|
|
13
15
|
const RETRY_MS = 50;
|
|
14
16
|
|
|
17
|
+
/**
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
export interface ClaudeConfigLockOptions {
|
|
23
|
+
readonly clock?: Clock;
|
|
24
|
+
readonly onBusy?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
/**
|
|
16
28
|
* Runs the callback while holding the lock the Claude CLI takes before it
|
|
17
29
|
* writes its global config: a directory beside the file named for it with
|
|
@@ -25,6 +37,7 @@ const RETRY_MS = 50;
|
|
|
25
37
|
export async function withClaudeConfigLock<T>(
|
|
26
38
|
configPath: string,
|
|
27
39
|
run: () => Promise<T>,
|
|
40
|
+
options: ClaudeConfigLockOptions = {},
|
|
28
41
|
): Promise<T> {
|
|
29
42
|
const lockPath = `${configPath}.lock`;
|
|
30
43
|
const deadline = Date.now() + WAIT_MS;
|
|
@@ -34,6 +47,8 @@ export async function withClaudeConfigLock<T>(
|
|
|
34
47
|
let held = await tryCreateLockDir(lockPath);
|
|
35
48
|
|
|
36
49
|
while (held === null) {
|
|
50
|
+
options.onBusy?.();
|
|
51
|
+
|
|
37
52
|
if (Date.now() > deadline) {
|
|
38
53
|
throw new Error(`timed out waiting for the Claude config lock ${lockPath}`);
|
|
39
54
|
}
|
|
@@ -43,21 +58,27 @@ export async function withClaudeConfigLock<T>(
|
|
|
43
58
|
held = await tryCreateLockDir(lockPath);
|
|
44
59
|
}
|
|
45
60
|
|
|
61
|
+
const clock = options.clock ?? systemClock;
|
|
46
62
|
let owned: OwnedLock = held;
|
|
47
63
|
let refreshing = Promise.resolve();
|
|
48
64
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
const scheduleRefresh = (): (() => void) =>
|
|
66
|
+
clock.schedule(() => {
|
|
67
|
+
refreshing = (async () => {
|
|
68
|
+
const refreshed = await refreshOwnedLock(lockPath, owned, clock.now());
|
|
69
|
+
|
|
70
|
+
owned = refreshed ?? owned;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
stopRefreshing = scheduleRefresh();
|
|
74
|
+
}, REFRESH_MS);
|
|
52
75
|
|
|
53
|
-
|
|
54
|
-
})();
|
|
55
|
-
}, REFRESH_MS);
|
|
76
|
+
let stopRefreshing = scheduleRefresh();
|
|
56
77
|
|
|
57
78
|
try {
|
|
58
79
|
return await run();
|
|
59
80
|
} finally {
|
|
60
|
-
|
|
81
|
+
stopRefreshing();
|
|
61
82
|
|
|
62
83
|
await refreshing;
|
|
63
84
|
|
|
@@ -105,10 +126,14 @@ function isExistsError(error: unknown): boolean {
|
|
|
105
126
|
return error instanceof Error && 'code' in error && error.code === 'EEXIST';
|
|
106
127
|
}
|
|
107
128
|
|
|
108
|
-
// Moves the lock's age forward while it is still the
|
|
109
|
-
// created, and resolves to the lock as it then stands;
|
|
110
|
-
// lock to go stale, as a holder that stopped refreshing it does.
|
|
111
|
-
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> {
|
|
112
137
|
const isOwned = await isOwnedLock(lockPath, owned);
|
|
113
138
|
|
|
114
139
|
if (!isOwned) {
|
|
@@ -116,7 +141,7 @@ async function refreshOwnedLock(lockPath: string, owned: OwnedLock): Promise<Own
|
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
try {
|
|
119
|
-
const now = new Date();
|
|
144
|
+
const now = new Date(nowMs);
|
|
120
145
|
|
|
121
146
|
await utimes(lockPath, now, now);
|
|
122
147
|
|
|
@@ -6,14 +6,17 @@ import { buildCLIArgv } from './build-cli-argv';
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Writes the atc-bridge mod that every Claude session loads with
|
|
9
|
-
* `--plugin-dir`, with the atc command it runs rendered
|
|
10
|
-
* and returns its folder. A file whose content already
|
|
11
|
-
* untouched, since any write reloads the mod in every running
|
|
12
|
-
* the folder is never cleared, since Claude Code keeps its own
|
|
13
|
-
* there.
|
|
9
|
+
* `--plugin-dir`, with the atc command it runs rendered in (by default this
|
|
10
|
+
* install's own), and returns its folder. A file whose content already
|
|
11
|
+
* matches is left untouched, since any write reloads the mod in every running
|
|
12
|
+
* session, and the folder is never cleared, since Claude Code keeps its own
|
|
13
|
+
* type files there.
|
|
14
14
|
*/
|
|
15
|
-
export function writeATCBridge(
|
|
16
|
-
|
|
15
|
+
export function writeATCBridge(
|
|
16
|
+
dir: string = join(stateDir, 'atc-bridge'),
|
|
17
|
+
argv: readonly string[] = buildCLIArgv(),
|
|
18
|
+
): string {
|
|
19
|
+
const files = buildATCBridgeFiles(argv);
|
|
17
20
|
|
|
18
21
|
for (const [path, content] of Object.entries(files)) {
|
|
19
22
|
const file = join(dir, path);
|
|
@@ -8,13 +8,19 @@ import type { HookSettingsProfile } from './build-hook-settings';
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Writes the settings file passed to a wrangled session as
|
|
11
|
-
* `claude --settings`, one per agent id, and returns
|
|
11
|
+
* `claude --settings`, one per agent id, into the folder `dir`, and returns
|
|
12
|
+
* its path. The statusline padding comes from the Claude settings in the
|
|
13
|
+
* user's home `homeDir`.
|
|
12
14
|
*/
|
|
13
|
-
export function writeHookSettings(
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
export function writeHookSettings(
|
|
16
|
+
profile: HookSettingsProfile,
|
|
17
|
+
dir: string = stateDir,
|
|
18
|
+
homeDir: string = resolveHomeDir(),
|
|
19
|
+
): string {
|
|
20
|
+
const file = join(dir, `hook-settings-${profile.id}.json`);
|
|
21
|
+
const settings = buildHookSettings(profile, readStatuslinePadding(homeDir));
|
|
16
22
|
|
|
17
|
-
mkdirSync(
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
18
24
|
writeFileSync(file, JSON.stringify(settings, null, 2));
|
|
19
25
|
|
|
20
26
|
return file;
|
|
@@ -24,9 +30,9 @@ export function writeHookSettings(profile: HookSettingsProfile): string {
|
|
|
24
30
|
* The padding on the user's own statusline, so the chained one lines up with
|
|
25
31
|
* it. An unreadable or unconfigured setting is no padding.
|
|
26
32
|
*/
|
|
27
|
-
function readStatuslinePadding(): number {
|
|
33
|
+
function readStatuslinePadding(homeDir: string): number {
|
|
28
34
|
try {
|
|
29
|
-
const raw = readFileSync(join(
|
|
35
|
+
const raw = readFileSync(join(homeDir, '.claude', 'settings.json'), 'utf8');
|
|
30
36
|
const user: unknown = JSON.parse(raw);
|
|
31
37
|
const statusLine = isRecord(user) ? user['statusLine'] : undefined;
|
|
32
38
|
const padding = isRecord(statusLine) ? statusLine['padding'] : undefined;
|
|
@@ -11,6 +11,8 @@ import { getBuild } from '../shared/get-build';
|
|
|
11
11
|
import { isProcessAlive } from '../shared/is-process-alive';
|
|
12
12
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
13
13
|
import { spawnATCDetached } from '../shared/spawn-atc-detached';
|
|
14
|
+
import { systemClock } from '../shared/system-clock';
|
|
15
|
+
import type { Clock } from '../shared/system-clock';
|
|
14
16
|
import { toAgentID } from '../shared/to-agent-id';
|
|
15
17
|
import { DaemonClient } from './daemon-client';
|
|
16
18
|
import { formatProtocolMismatch } from './format-protocol-mismatch';
|
|
@@ -30,6 +32,17 @@ export interface DaemonBoot {
|
|
|
30
32
|
readonly socketPath: string;
|
|
31
33
|
}
|
|
32
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
|
+
|
|
33
46
|
export interface DaemonBootOptions {
|
|
34
47
|
// Called when the daemon speaks another protocol version. Resolving true
|
|
35
48
|
// stops that daemon and boots one from this build, which ends every
|
|
@@ -42,8 +55,29 @@ export interface DaemonBootOptions {
|
|
|
42
55
|
// manager starts beside a managed daemon sets it, so the two never race
|
|
43
56
|
// for the state directory.
|
|
44
57
|
readonly waitForDaemonMs?: number;
|
|
58
|
+
|
|
59
|
+
// Called once per boot, when a waiting boot first finds no daemon and
|
|
60
|
+
// starts to wait instead of starting one; a retry after a protocol
|
|
61
|
+
// mismatch that waits again does not call it a second time.
|
|
62
|
+
readonly onWaitForDaemon?: () => void;
|
|
63
|
+
|
|
64
|
+
// The time and the timers a waiting boot reads for its deadline and its
|
|
65
|
+
// polls; the wall clock when absent.
|
|
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;
|
|
45
73
|
}
|
|
46
74
|
|
|
75
|
+
const PROCESS_PATHS: DaemonPaths = {
|
|
76
|
+
socketPath: daemonSocketPath,
|
|
77
|
+
recordFile: daemonRecordFile,
|
|
78
|
+
pidFile: daemonPidFile,
|
|
79
|
+
};
|
|
80
|
+
|
|
47
81
|
/**
|
|
48
82
|
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
49
83
|
* neither the computed socket nor the one in the daemon's record answers,
|
|
@@ -59,14 +93,34 @@ export interface DaemonBootOptions {
|
|
|
59
93
|
* its own boot would otherwise flag daemons that are already current.
|
|
60
94
|
*/
|
|
61
95
|
export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
|
|
96
|
+
let waited = false;
|
|
97
|
+
const clock = options.clock ?? systemClock;
|
|
98
|
+
const paths = options.paths ?? PROCESS_PATHS;
|
|
99
|
+
|
|
62
100
|
const wait =
|
|
63
101
|
options.waitForDaemonMs === undefined
|
|
64
102
|
? null
|
|
65
|
-
: {
|
|
103
|
+
: {
|
|
104
|
+
deadline: clock.now() + options.waitForDaemonMs,
|
|
105
|
+
timeoutMs: options.waitForDaemonMs,
|
|
106
|
+
clock,
|
|
107
|
+
paths,
|
|
108
|
+
onWait: () => {
|
|
109
|
+
if (!waited) {
|
|
110
|
+
waited = true;
|
|
111
|
+
options.onWaitForDaemon?.();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
66
115
|
|
|
67
116
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
68
117
|
const build = getBuild();
|
|
69
|
-
|
|
118
|
+
|
|
119
|
+
const opened =
|
|
120
|
+
wait === null
|
|
121
|
+
? await openOrBootDaemon(paths, options.paths === undefined)
|
|
122
|
+
: await waitForDaemon(wait);
|
|
123
|
+
|
|
70
124
|
const client = opened.client;
|
|
71
125
|
|
|
72
126
|
try {
|
|
@@ -91,7 +145,7 @@ export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise
|
|
|
91
145
|
|
|
92
146
|
const mismatch: ProtocolMismatch = {
|
|
93
147
|
socketPath: opened.socketPath,
|
|
94
|
-
daemonPID: findDaemonPID(opened.socketPath),
|
|
148
|
+
daemonPID: findDaemonPID(opened.socketPath, paths),
|
|
95
149
|
clientBuild: build,
|
|
96
150
|
clientProtocol: PROTOCOL_V,
|
|
97
151
|
daemonMessage: error.message,
|
|
@@ -119,19 +173,28 @@ interface OpenedDaemon {
|
|
|
119
173
|
readonly socketPath: string;
|
|
120
174
|
}
|
|
121
175
|
|
|
122
|
-
|
|
123
|
-
|
|
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);
|
|
124
183
|
|
|
125
184
|
if (opened !== null) {
|
|
126
185
|
return opened;
|
|
127
186
|
}
|
|
128
187
|
|
|
188
|
+
if (!canStart) {
|
|
189
|
+
throw new Error(formatGivenPathsFailure(paths));
|
|
190
|
+
}
|
|
191
|
+
|
|
129
192
|
await bootDaemonOnce();
|
|
130
193
|
|
|
131
|
-
const booted = await tryOpenKnownDaemon();
|
|
194
|
+
const booted = await tryOpenKnownDaemon(paths);
|
|
132
195
|
|
|
133
196
|
if (booted === null) {
|
|
134
|
-
throw new Error(formatBootFailure());
|
|
197
|
+
throw new Error(formatBootFailure(paths));
|
|
135
198
|
}
|
|
136
199
|
|
|
137
200
|
return booted;
|
|
@@ -142,24 +205,36 @@ async function openOrBootDaemon(): Promise<OpenedDaemon> {
|
|
|
142
205
|
interface DaemonWait {
|
|
143
206
|
readonly deadline: number;
|
|
144
207
|
readonly timeoutMs: number;
|
|
208
|
+
readonly clock: Clock;
|
|
209
|
+
readonly paths: DaemonPaths;
|
|
210
|
+
|
|
211
|
+
// Called on every miss; the boot reports only the first to its caller.
|
|
212
|
+
readonly onWait: () => void;
|
|
145
213
|
}
|
|
146
214
|
|
|
147
215
|
/**
|
|
148
216
|
* Polls the known sockets until a daemon answers, and never starts one.
|
|
217
|
+
* Each miss reports that the wait goes on.
|
|
149
218
|
*/
|
|
150
219
|
async function waitForDaemon(wait: DaemonWait): Promise<OpenedDaemon> {
|
|
151
220
|
for (;;) {
|
|
152
|
-
const opened = await tryOpenKnownDaemon();
|
|
221
|
+
const opened = await tryOpenKnownDaemon(wait.paths);
|
|
153
222
|
|
|
154
223
|
if (opened !== null) {
|
|
155
224
|
return opened;
|
|
156
225
|
}
|
|
157
226
|
|
|
158
|
-
|
|
159
|
-
|
|
227
|
+
wait.onWait();
|
|
228
|
+
|
|
229
|
+
if (wait.clock.now() >= wait.deadline) {
|
|
230
|
+
throw new Error(formatWaitFailure(wait));
|
|
160
231
|
}
|
|
161
232
|
|
|
162
|
-
|
|
233
|
+
const polled = Promise.withResolvers<void>();
|
|
234
|
+
|
|
235
|
+
wait.clock.schedule(polled.resolve, 100);
|
|
236
|
+
|
|
237
|
+
await polled.promise;
|
|
163
238
|
}
|
|
164
239
|
}
|
|
165
240
|
|
|
@@ -172,21 +247,19 @@ async function waitForHello(
|
|
|
172
247
|
sendHello: () => Promise<Readonly<Record<string, unknown>>>,
|
|
173
248
|
wait: DaemonWait,
|
|
174
249
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
);
|
|
184
|
-
});
|
|
250
|
+
const expired = Promise.withResolvers<never>();
|
|
251
|
+
|
|
252
|
+
const cancel = wait.clock.schedule(
|
|
253
|
+
() => {
|
|
254
|
+
expired.reject(new Error(formatWaitFailure(wait)));
|
|
255
|
+
},
|
|
256
|
+
Math.max(0, wait.deadline - wait.clock.now()),
|
|
257
|
+
);
|
|
185
258
|
|
|
186
259
|
try {
|
|
187
|
-
return await Promise.race([sendHello(), expired]);
|
|
260
|
+
return await Promise.race([sendHello(), expired.promise]);
|
|
188
261
|
} finally {
|
|
189
|
-
|
|
262
|
+
cancel();
|
|
190
263
|
}
|
|
191
264
|
}
|
|
192
265
|
|
|
@@ -195,16 +268,16 @@ async function waitForHello(
|
|
|
195
268
|
* daemon recorded in the state directory: a client whose environment lacks
|
|
196
269
|
* XDG_RUNTIME_DIR computes a different path from the daemon's.
|
|
197
270
|
*/
|
|
198
|
-
async function tryOpenKnownDaemon(): Promise<OpenedDaemon | null> {
|
|
199
|
-
const computed = await tryOpenDaemon(
|
|
271
|
+
async function tryOpenKnownDaemon(paths: DaemonPaths): Promise<OpenedDaemon | null> {
|
|
272
|
+
const computed = await tryOpenDaemon(paths.socketPath);
|
|
200
273
|
|
|
201
274
|
if (computed !== null) {
|
|
202
275
|
return computed;
|
|
203
276
|
}
|
|
204
277
|
|
|
205
|
-
const record = findDaemonRecord(
|
|
278
|
+
const record = findDaemonRecord(paths.recordFile);
|
|
206
279
|
|
|
207
|
-
if (record === null || record.socketPath ===
|
|
280
|
+
if (record === null || record.socketPath === paths.socketPath) {
|
|
208
281
|
return null;
|
|
209
282
|
}
|
|
210
283
|
|
|
@@ -230,7 +303,7 @@ const bootDaemonOnce = makeSingleFlight(async () => {
|
|
|
230
303
|
while (Date.now() < deadline) {
|
|
231
304
|
await Bun.sleep(100);
|
|
232
305
|
|
|
233
|
-
const probe = await tryOpenKnownDaemon();
|
|
306
|
+
const probe = await tryOpenKnownDaemon(PROCESS_PATHS);
|
|
234
307
|
|
|
235
308
|
if (probe !== null) {
|
|
236
309
|
probe.client.stop();
|
|
@@ -249,14 +322,24 @@ const bootDaemonOnce = makeSingleFlight(async () => {
|
|
|
249
322
|
// A live daemon this process cannot reach, such as one whose runtime
|
|
250
323
|
// directory a sandbox hides, needs a different fix than one that never
|
|
251
324
|
// started, so the message tells them apart.
|
|
252
|
-
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 {
|
|
253
335
|
return (
|
|
254
|
-
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`
|
|
255
338
|
);
|
|
256
339
|
}
|
|
257
340
|
|
|
258
|
-
function formatUnreachableDaemon(): string | null {
|
|
259
|
-
const record = findDaemonRecord(
|
|
341
|
+
function formatUnreachableDaemon(paths: DaemonPaths): string | null {
|
|
342
|
+
const record = findDaemonRecord(paths.recordFile);
|
|
260
343
|
|
|
261
344
|
if (record !== null && isProcessAlive(record.pid)) {
|
|
262
345
|
return `the atc daemon (pid ${record.pid}) is running, but its socket ${record.socketPath} is unreachable from here`;
|
|
@@ -267,20 +350,20 @@ function formatUnreachableDaemon(): string | null {
|
|
|
267
350
|
|
|
268
351
|
// A waiting caller was told not to start a daemon, so the message says so
|
|
269
352
|
// and points at starting the managed one.
|
|
270
|
-
function formatWaitFailure(
|
|
353
|
+
function formatWaitFailure(wait: DaemonWait): string {
|
|
271
354
|
return (
|
|
272
|
-
formatUnreachableDaemon() ??
|
|
273
|
-
`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`
|
|
274
357
|
);
|
|
275
358
|
}
|
|
276
359
|
|
|
277
360
|
// The pid of the daemon behind the socket that refused the handshake.
|
|
278
|
-
function findDaemonPID(socketPath: string): number | null {
|
|
361
|
+
function findDaemonPID(socketPath: string, paths: DaemonPaths): number | null {
|
|
279
362
|
return pickStaleDaemonPID({
|
|
280
363
|
socketPath,
|
|
281
|
-
record: findDaemonRecord(
|
|
282
|
-
pidFileSocketPath:
|
|
283
|
-
pidFilePID: findPidFilePID(
|
|
364
|
+
record: findDaemonRecord(paths.recordFile),
|
|
365
|
+
pidFileSocketPath: paths.socketPath,
|
|
366
|
+
pidFilePID: findPidFilePID(paths.pidFile),
|
|
284
367
|
});
|
|
285
368
|
}
|
|
286
369
|
|
package/src/client/dirs.ts
CHANGED
|
@@ -8,13 +8,15 @@ import { collectRootDirs } from '../shared/collect-root-dirs';
|
|
|
8
8
|
* to find a directory: where the client runs, then the daemon's spawn
|
|
9
9
|
* history (most recent first), then the configured roots, then zoxide's
|
|
10
10
|
* frecency list. A path that no longer exists is dropped, a duplicate keeps
|
|
11
|
-
* its first position, and an empty merge falls back to the home directory
|
|
11
|
+
* its first position, and an empty merge falls back to the home directory:
|
|
12
|
+
* `home` when given, otherwise the account's.
|
|
12
13
|
*/
|
|
13
14
|
export interface DirSources {
|
|
14
15
|
readonly cwd: string;
|
|
15
16
|
readonly recent: readonly string[];
|
|
16
17
|
readonly roots: readonly string[];
|
|
17
18
|
readonly zoxide: readonly string[];
|
|
19
|
+
readonly home?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export function collectDirs(sources: DirSources): string[] {
|
|
@@ -35,7 +37,7 @@ export function collectDirs(sources: DirSources): string[] {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
if (found.length === 0) {
|
|
38
|
-
found.push(homedir());
|
|
40
|
+
found.push(sources.home ?? homedir());
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
return found;
|
package/src/client/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { EventMsg } from '../protocol/protocol';
|
|
|
7
7
|
import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
|
|
8
8
|
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
9
9
|
import type { AgentID } from '../shared/agent-id';
|
|
10
|
-
import { loadConfig } from '../shared/config';
|
|
10
|
+
import { configFile, loadConfig } from '../shared/config';
|
|
11
11
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
12
12
|
import { bootDaemonClient } from './boot-daemon';
|
|
13
13
|
import { buildClientMachine } from './build-client-machine';
|
|
@@ -16,6 +16,7 @@ import { collectTargetPicks } from './collect-target-picks';
|
|
|
16
16
|
import { findFuzzyScore, formatDir } from './dirs';
|
|
17
17
|
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
18
18
|
import { KEY, isDown, isUp, planTextEdit } from './keys';
|
|
19
|
+
import { logClientEvent } from './log-client-event';
|
|
19
20
|
import { parseDaemonEvent } from './parse-daemon-event';
|
|
20
21
|
import { pickTabTarget } from './pick-tab-target';
|
|
21
22
|
import { resolveAgentMetadata } from './resolve-agent-metadata';
|
|
@@ -341,6 +342,15 @@ const picker = new SpawnPicker<MirrorSession>({
|
|
|
341
342
|
toMirrorSession,
|
|
342
343
|
upsertMirror,
|
|
343
344
|
hasDaemonFeature,
|
|
345
|
+
write: (chunk) => {
|
|
346
|
+
process.stdout.write(chunk);
|
|
347
|
+
},
|
|
348
|
+
cwd: process.cwd(),
|
|
349
|
+
configPath: configFile,
|
|
350
|
+
onDropAnswer: (kind) => {
|
|
351
|
+
logClientEvent(`dropped ${kind} answer`);
|
|
352
|
+
},
|
|
353
|
+
log: logClientEvent,
|
|
344
354
|
});
|
|
345
355
|
|
|
346
356
|
function openPicker(resume: boolean) {
|
|
@@ -721,6 +731,12 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
721
731
|
return;
|
|
722
732
|
}
|
|
723
733
|
|
|
734
|
+
if (ch === 'H') {
|
|
735
|
+
logClientEvent('ignored H on a session that cannot eject');
|
|
736
|
+
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
724
740
|
if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
|
|
725
741
|
void adoptSession(sel.id);
|
|
726
742
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Appends one line, a decision the client took without drawing anything, to
|
|
5
|
+
* the file `ATC_CLIENT_LOG` holds, and does nothing when the variable is
|
|
6
|
+
* unset or empty. A write that fails is dropped, so the log never stops the
|
|
7
|
+
* client. It writes off the screen because the client's stdout and stderr
|
|
8
|
+
* are the terminal it draws on.
|
|
9
|
+
*/
|
|
10
|
+
export function logClientEvent(line: string): void {
|
|
11
|
+
const path = process.env['ATC_CLIENT_LOG'];
|
|
12
|
+
|
|
13
|
+
if (path === undefined || path === '') {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
appendFileSync(path, `${line}\n`);
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|