@zgeoff/atc 2.33.0 → 2.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +14 -2
- package/src/agents/build-claude-bundle-settings.ts +91 -0
- package/src/agents/build-claude-guest-launch.ts +21 -6
- package/src/agents/claude-adapter.ts +45 -9
- package/src/agents/claude-config-bundle-folder.ts +8 -0
- package/src/agents/load-claude-config-bundle.ts +169 -0
- package/src/cli.ts +2 -0
- package/src/client/index.ts +8 -0
- package/src/client/spawn-picker.ts +58 -6
- package/src/daemon/build-default-workspace-dir.ts +70 -0
- package/src/daemon/build-tar-archive.ts +33 -8
- package/src/daemon/daemon-connection.ts +28 -5
- package/src/daemon/daemon-context.ts +13 -0
- package/src/daemon/daemon.ts +77 -5
- package/src/daemon/materialize-workspace.ts +83 -11
- package/src/daemon/restore-fleet.ts +66 -2
- package/src/daemon/sessions.ts +80 -13
- package/src/mcp/mcp-tools.ts +8 -2
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +16 -1
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/request-param-schemas.ts +13 -1
- package/src/{client → shared}/build-workspace-destination.ts +19 -8
- package/src/shared/config.ts +9 -0
- package/src/statusline.ts +4 -3
- package/src/store/fleet-entry.ts +5 -0
- package/src/store/run-migrations.ts +12 -0
- package/src/store/state-store.ts +35 -0
- package/src/store/workspace-materialization.ts +4 -0
package/package.json
CHANGED
|
@@ -22,12 +22,15 @@ export interface SpawnOptions {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The per-launch choices a spawn asks for. Model and effort are checked
|
|
25
|
-
* against what the agent advertises; workspace trust requires a verified
|
|
25
|
+
* against what the agent advertises; workspace trust requires a verified
|
|
26
|
+
* clone; resuming an interrupted turn overrides the daemon's config for the
|
|
27
|
+
* session's whole life.
|
|
26
28
|
*/
|
|
27
29
|
export interface SpawnOverrides {
|
|
28
30
|
readonly trustClonedWorkspace?: boolean;
|
|
29
31
|
readonly model?: string;
|
|
30
32
|
readonly effort?: string;
|
|
33
|
+
readonly resumeInterruptedTurns?: boolean;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface SpawnPlan {
|
|
@@ -62,6 +65,15 @@ export interface AuthSelection {
|
|
|
62
65
|
readonly brokerRequired: boolean;
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* One file a guest plan ships: its text, its bytes, or its bytes with the
|
|
70
|
+
* permission bits it unpacks with.
|
|
71
|
+
*/
|
|
72
|
+
export type GuestFile =
|
|
73
|
+
| string
|
|
74
|
+
| Uint8Array
|
|
75
|
+
| { readonly content: Uint8Array; readonly mode: number };
|
|
76
|
+
|
|
65
77
|
/**
|
|
66
78
|
* A spawn on a remote host, with the files the harness reads there, keyed
|
|
67
79
|
* by their path inside the session's guest folder, and the variables the
|
|
@@ -69,7 +81,7 @@ export interface AuthSelection {
|
|
|
69
81
|
* overrides.
|
|
70
82
|
*/
|
|
71
83
|
export interface GuestSpawnPlan extends SpawnPlan {
|
|
72
|
-
readonly files: Readonly<Record<string,
|
|
84
|
+
readonly files: Readonly<Record<string, GuestFile>>;
|
|
73
85
|
readonly env?: Readonly<Record<string, string>>;
|
|
74
86
|
}
|
|
75
87
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The user settings a session's own Claude config folder starts with, from
|
|
5
|
+
* the host's own `settings.json`: the keys on the allow-list alone, an
|
|
6
|
+
* `env` block cut to its own allow-list, and a permission default of auto
|
|
7
|
+
* mode, which Claude Code honours at user scope only. Any other key stays on
|
|
8
|
+
* the host, so a setting that holds a credential, a plugin, or a host path
|
|
9
|
+
* never reaches a guest unless it is listed here. The statusline command
|
|
10
|
+
* reads the guest's copy of a script under the host's config folder: a path
|
|
11
|
+
* under any of `hostDirs`, the spellings of that folder, moves under
|
|
12
|
+
* `guestDir`.
|
|
13
|
+
*/
|
|
14
|
+
export function buildClaudeBundleSettings(
|
|
15
|
+
settings: unknown,
|
|
16
|
+
hostDirs: readonly string[],
|
|
17
|
+
guestDir: string,
|
|
18
|
+
): Record<string, unknown> {
|
|
19
|
+
const source = isRecord(settings) ? settings : {};
|
|
20
|
+
const shipped: Record<string, unknown> = {};
|
|
21
|
+
|
|
22
|
+
for (const key of SETTINGS_ALLOW_LIST) {
|
|
23
|
+
if (source[key] !== undefined) {
|
|
24
|
+
shipped[key] = source[key];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const env = buildBundleEnv(source['env']);
|
|
29
|
+
const statusLine = buildBundleStatusLine(source['statusLine'], hostDirs, guestDir);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...shipped,
|
|
33
|
+
...(env === null ? {} : { env }),
|
|
34
|
+
...(statusLine === null ? {} : { statusLine }),
|
|
35
|
+
permissions: {
|
|
36
|
+
...(isRecord(source['permissions']) ? source['permissions'] : {}),
|
|
37
|
+
defaultMode: 'auto',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SETTINGS_ALLOW_LIST = [
|
|
43
|
+
'model',
|
|
44
|
+
'effortLevel',
|
|
45
|
+
'advisorModel',
|
|
46
|
+
'outputStyle',
|
|
47
|
+
'autoCompactWindow',
|
|
48
|
+
'autoMode',
|
|
49
|
+
'attribution',
|
|
50
|
+
'includeCoAuthoredBy',
|
|
51
|
+
'skipAutoPermissionPrompt',
|
|
52
|
+
'skipWorkflowUsageWarning',
|
|
53
|
+
'editorMode',
|
|
54
|
+
'tui',
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
// The variables an `env` block may ship. A variable off this list may hold
|
|
58
|
+
// a credential, so it stays on the host.
|
|
59
|
+
const ENV_ALLOW_LIST: ReadonlySet<string> = new Set(['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS']);
|
|
60
|
+
|
|
61
|
+
function buildBundleEnv(env: unknown): Record<string, string> | null {
|
|
62
|
+
if (!isRecord(env)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const shipped = Object.fromEntries(
|
|
67
|
+
Object.entries(env).filter(
|
|
68
|
+
(entry): entry is [string, string] =>
|
|
69
|
+
ENV_ALLOW_LIST.has(entry[0]) && typeof entry[1] === 'string',
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return Object.keys(shipped).length === 0 ? null : shipped;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildBundleStatusLine(
|
|
77
|
+
statusLine: unknown,
|
|
78
|
+
hostDirs: readonly string[],
|
|
79
|
+
guestDir: string,
|
|
80
|
+
): Record<string, unknown> | null {
|
|
81
|
+
if (!isRecord(statusLine) || typeof statusLine['command'] !== 'string') {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const command = hostDirs.reduce(
|
|
86
|
+
(rewritten, hostDir) => rewritten.replaceAll(`${hostDir}/`, `${guestDir}/`),
|
|
87
|
+
statusLine['command'],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return { ...statusLine, command };
|
|
91
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SpawnPlan } from './agent-adapter';
|
|
2
|
+
import { CLAUDE_CONFIG_BUNDLE_FOLDER } from './claude-config-bundle-folder';
|
|
2
3
|
import { CLAUDE_CONFIG_SEED_FILE } from './claude-config-seed-file';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -7,7 +8,11 @@ import { CLAUDE_CONFIG_SEED_FILE } from './claude-config-seed-file';
|
|
|
7
8
|
* setting of the host's image reaches it. A shell copies the seed into the
|
|
8
9
|
* folder only when the folder holds no `.claude.json` yet, then runs the
|
|
9
10
|
* CLI, so a resumed session keeps the state the CLI wrote, a folder trust
|
|
10
|
-
* a person accepted included.
|
|
11
|
+
* a person accepted included. When the guest folder holds the config bundle
|
|
12
|
+
* staged under `bundleKey`, the shell replaces the bundle's entries in the
|
|
13
|
+
* config folder with the staged ones and removes every staged bundle, so
|
|
14
|
+
* each launch reads the bundle its own transfer carried and never one an
|
|
15
|
+
* earlier launch left. `argv` is the CLI's own command line. The
|
|
11
16
|
* shell exits before it runs the CLI when the host's environment sets any
|
|
12
17
|
* of `refusedEnv`, which must be variable names, and says which one.
|
|
13
18
|
*/
|
|
@@ -15,7 +20,8 @@ export function buildClaudeGuestLaunch(
|
|
|
15
20
|
guestDir: string,
|
|
16
21
|
argv: readonly string[],
|
|
17
22
|
refusedEnv: readonly string[] = [],
|
|
18
|
-
|
|
23
|
+
bundleKey = 'none',
|
|
24
|
+
): SpawnPlan & { readonly env: { readonly CLAUDE_CONFIG_DIR: string } } {
|
|
19
25
|
const configDir = `${guestDir}/${CLAUDE_CONFIG_FOLDER}`;
|
|
20
26
|
const guards = refusedEnv.map((name) => buildEnvGuard(name)).join('');
|
|
21
27
|
|
|
@@ -27,6 +33,8 @@ export function buildClaudeGuestLaunch(
|
|
|
27
33
|
'sh',
|
|
28
34
|
configDir,
|
|
29
35
|
`${guestDir}/${CLAUDE_CONFIG_SEED_FILE}`,
|
|
36
|
+
`${guestDir}/${CLAUDE_CONFIG_BUNDLE_FOLDER}`,
|
|
37
|
+
`${guestDir}/${CLAUDE_CONFIG_BUNDLE_FOLDER}/${bundleKey}`,
|
|
30
38
|
...argv,
|
|
31
39
|
],
|
|
32
40
|
env: { CLAUDE_CONFIG_DIR: configDir },
|
|
@@ -49,7 +57,14 @@ function buildEnvGuard(name: string): string {
|
|
|
49
57
|
const REFUSED_EXIT = 78;
|
|
50
58
|
const CLAUDE_CONFIG_FOLDER = 'claude-config';
|
|
51
59
|
|
|
52
|
-
// The arguments are the config folder, the seed,
|
|
53
|
-
// line.
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
// The arguments are the config folder, the seed, the folder bundles stage
|
|
61
|
+
// in, this launch's staged bundle, and the CLI's own command line. The
|
|
62
|
+
// removed entries are every top-level entry a bundle can hold, so one the
|
|
63
|
+
// host dropped leaves the config folder too.
|
|
64
|
+
const SEED_CONFIG_SCRIPT = [
|
|
65
|
+
'mkdir -p "$1"',
|
|
66
|
+
'{ [ -e "$1/.claude.json" ] || cp "$2" "$1/.claude.json"; }',
|
|
67
|
+
'{ [ ! -d "$4" ] || { rm -rf "$1/CLAUDE.md" "$1/settings.json" "$1/statusline.sh" "$1/agents" "$1/output-styles" "$1/skills" && cp -R "$4/." "$1/" && rm -rf "$3"; }; }',
|
|
68
|
+
'shift 4',
|
|
69
|
+
'exec "$@"',
|
|
70
|
+
].join(' && ');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
@@ -30,13 +31,16 @@ import { buildClaudeConfigSeed } from './build-claude-config-seed';
|
|
|
30
31
|
import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
|
|
31
32
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
32
33
|
import { buildHookSettings } from './build-hook-settings';
|
|
34
|
+
import { CLAUDE_CONFIG_BUNDLE_FOLDER } from './claude-config-bundle-folder';
|
|
33
35
|
import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
|
|
34
36
|
import { findClaudePermissionMode } from './find-claude-permission-mode';
|
|
35
37
|
import { findFlagValue } from './find-flag-value';
|
|
38
|
+
import { loadClaudeConfigBundle } from './load-claude-config-bundle';
|
|
36
39
|
import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
|
|
37
40
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
38
41
|
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
39
42
|
import { planPastedLineInput } from './plan-pasted-line-input';
|
|
43
|
+
import { resolveAgentHome } from './resolve-agent-home';
|
|
40
44
|
import { resolveClaudeGlobalConfigPath } from './resolve-claude-global-config-path';
|
|
41
45
|
import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
|
|
42
46
|
import { updateClaudeProjectTrust } from './update-claude-project-trust';
|
|
@@ -189,9 +193,11 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
189
193
|
// A settings file of the session's own per binding revision carries the
|
|
190
194
|
// placeholder, and so does the CLI's environment. The configured
|
|
191
195
|
// arguments go without a permission mode, so the mode the session's own
|
|
192
|
-
// user settings set applies.
|
|
193
|
-
//
|
|
194
|
-
//
|
|
196
|
+
// user settings set applies. Those user settings, with the rest of the
|
|
197
|
+
// config bundle, come from the host's own Claude config folder as it is
|
|
198
|
+
// at this launch, staged under a key of this launch's own. A credential, endpoint, or provider variable in the
|
|
199
|
+
// configured settings, or in the host's environment, would keep the CLI
|
|
200
|
+
// from sending the placeholder, so either refuses the start.
|
|
195
201
|
private planSubscriptionGuestSpawn(
|
|
196
202
|
opts: SpawnOptions,
|
|
197
203
|
dir: string,
|
|
@@ -206,12 +212,7 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
const settingsPath = `auth-r${auth.revision}/settings.json`;
|
|
209
|
-
|
|
210
|
-
const settings = buildHookSettings(
|
|
211
|
-
{ id: this.id, env: { ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } },
|
|
212
|
-
0,
|
|
213
|
-
argv,
|
|
214
|
-
);
|
|
215
|
+
const bundleKey = randomUUID();
|
|
215
216
|
|
|
216
217
|
const launch = buildClaudeGuestLaunch(
|
|
217
218
|
dir,
|
|
@@ -225,6 +226,21 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
225
226
|
),
|
|
226
227
|
],
|
|
227
228
|
[...OUTRANKING_VARIABLES],
|
|
229
|
+
bundleKey,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const bundle = loadClaudeConfigBundle(
|
|
233
|
+
resolveAgentHome('CLAUDE_CONFIG_DIR', '.claude'),
|
|
234
|
+
launch.env.CLAUDE_CONFIG_DIR,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const userSettings = bundle['settings.json'];
|
|
238
|
+
const padding = typeof userSettings === 'string' ? findStatuslinePadding(userSettings) : 0;
|
|
239
|
+
|
|
240
|
+
const settings = buildHookSettings(
|
|
241
|
+
{ id: this.id, env: { ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } },
|
|
242
|
+
padding,
|
|
243
|
+
argv,
|
|
228
244
|
);
|
|
229
245
|
|
|
230
246
|
return {
|
|
@@ -233,6 +249,12 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
233
249
|
files: {
|
|
234
250
|
[settingsPath]: JSON.stringify(settings, null, 2),
|
|
235
251
|
...buildClaudeConfigSeed(null),
|
|
252
|
+
...Object.fromEntries(
|
|
253
|
+
Object.entries(bundle).map(([path, content]) => [
|
|
254
|
+
`${CLAUDE_CONFIG_BUNDLE_FOLDER}/${bundleKey}/${path}`,
|
|
255
|
+
content,
|
|
256
|
+
]),
|
|
257
|
+
),
|
|
236
258
|
...Object.fromEntries(bridge),
|
|
237
259
|
},
|
|
238
260
|
env: { ...launch.env, ...auth.env },
|
|
@@ -464,6 +486,20 @@ const OUTRANKING_VARIABLES: ReadonlySet<string> = new Set([
|
|
|
464
486
|
'CLAUDE_CODE_USE_GATEWAY',
|
|
465
487
|
]);
|
|
466
488
|
|
|
489
|
+
// The padding of the statusline the bundle's user settings set, which atc's
|
|
490
|
+
// own statusline mirrors since it renders that one first, or none.
|
|
491
|
+
function findStatuslinePadding(settings: string): number {
|
|
492
|
+
try {
|
|
493
|
+
const parsed: unknown = JSON.parse(settings);
|
|
494
|
+
const statusLine = isRecord(parsed) ? parsed['statusLine'] : undefined;
|
|
495
|
+
const padding = isRecord(statusLine) ? statusLine['padding'] : undefined;
|
|
496
|
+
|
|
497
|
+
return typeof padding === 'number' ? padding : 0;
|
|
498
|
+
} catch {
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
467
503
|
// The aliases Claude Code documents for `--model`, each resolving to a model
|
|
468
504
|
// the account picks. A full model name is accepted as well.
|
|
469
505
|
const CLAUDE_MODEL_ALIASES = [
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Claude config bundle of a session travels inside its guest
|
|
3
|
+
* folder, each launch's bundle under a key of its own: beside the config
|
|
4
|
+
* folder rather than in it, so the launch replaces the bundle's own entries
|
|
5
|
+
* in the config folder whole, and an entry the host no longer holds leaves
|
|
6
|
+
* the config folder with it.
|
|
7
|
+
*/
|
|
8
|
+
export const CLAUDE_CONFIG_BUNDLE_FOLDER = 'claude-config-bundle';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import type { Stats } from 'node:fs';
|
|
3
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
4
|
+
import { resolveHomeDir } from '../shared/resolve-home-dir';
|
|
5
|
+
import type { GuestFile } from './agent-adapter';
|
|
6
|
+
import { buildClaudeBundleSettings } from './build-claude-bundle-settings';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The files of the Claude config bundle, read from the host's config folder
|
|
10
|
+
* `hostDir` and keyed by their path inside the guest's config folder
|
|
11
|
+
* `guestDir`: `CLAUDE.md`, the filtered `settings.json`, `statusline.sh`,
|
|
12
|
+
* every file under `agents/` and `output-styles/`, and each folder under
|
|
13
|
+
* `skills/` that holds a `SKILL.md`. Symlinks resolve to copies of what
|
|
14
|
+
* they point at, and a file its owner may execute unpacks executable.
|
|
15
|
+
* Nothing else in the host's folder ships, so its account state, history,
|
|
16
|
+
* credentials, and backups stay there, and so does any entry whose name
|
|
17
|
+
* starts with a dot. A symlink ships only when what it resolves to could
|
|
18
|
+
* ship by its own path: a file whose name starts with a dot never does, and
|
|
19
|
+
* neither does a file in the host's folder outside the shipped entries. A
|
|
20
|
+
* missing entry ships nothing.
|
|
21
|
+
*/
|
|
22
|
+
export function loadClaudeConfigBundle(
|
|
23
|
+
hostDir: string,
|
|
24
|
+
guestDir: string,
|
|
25
|
+
): Record<string, GuestFile> {
|
|
26
|
+
const hostSettings = readSettings(join(hostDir, 'settings.json'));
|
|
27
|
+
|
|
28
|
+
const settings = buildClaudeBundleSettings(
|
|
29
|
+
hostSettings,
|
|
30
|
+
buildHostDirSpellings(hostDir),
|
|
31
|
+
guestDir,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const files: Record<string, GuestFile> = {
|
|
35
|
+
'settings.json': JSON.stringify(settings, null, 2),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const hostReal = findStats(hostDir) === null ? hostDir : realpathSync(hostDir);
|
|
39
|
+
|
|
40
|
+
for (const name of ['CLAUDE.md', 'statusline.sh']) {
|
|
41
|
+
const path = join(hostDir, name);
|
|
42
|
+
const stats = findStats(path);
|
|
43
|
+
|
|
44
|
+
if (stats?.isFile() === true && shouldShipFile(path, hostReal)) {
|
|
45
|
+
files[name] = readBundleFile(path, stats.mode);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const name of ['agents', 'output-styles']) {
|
|
50
|
+
Object.assign(files, collectTreeFiles(join(hostDir, name), name, hostReal, new Set()));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const skills = join(hostDir, 'skills');
|
|
54
|
+
|
|
55
|
+
for (const name of readEntryNames(skills)) {
|
|
56
|
+
const path = join(skills, name);
|
|
57
|
+
|
|
58
|
+
if (findStats(join(path, 'SKILL.md'))?.isFile() === true) {
|
|
59
|
+
Object.assign(files, collectTreeFiles(path, `skills/${name}`, hostReal, new Set()));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The ways a command can spell the host's config folder: its path, and
|
|
67
|
+
// when it is the default folder in the user's home, the home-relative forms
|
|
68
|
+
// a shell expands.
|
|
69
|
+
function buildHostDirSpellings(hostDir: string): string[] {
|
|
70
|
+
return hostDir === join(resolveHomeDir(), '.claude')
|
|
71
|
+
? [hostDir, '~/.claude', '$HOME/.claude']
|
|
72
|
+
: [hostDir];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The host's settings, or none when the file is missing or is not JSON,
|
|
76
|
+
// which still ships the auto-mode default.
|
|
77
|
+
function readSettings(path: string): unknown {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The stats of what a path resolves to, or null for a missing path or a
|
|
86
|
+
// symlink that points nowhere.
|
|
87
|
+
function findStats(path: string): Stats | null {
|
|
88
|
+
try {
|
|
89
|
+
return statSync(path);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A folder's entry names, sorted, without the ones that start with a dot,
|
|
96
|
+
// or none when the path is not a folder.
|
|
97
|
+
function readEntryNames(dir: string): string[] {
|
|
98
|
+
if (findStats(dir)?.isDirectory() !== true) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return readdirSync(dir)
|
|
103
|
+
.filter((name) => !name.startsWith('.'))
|
|
104
|
+
.toSorted();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Every file under `dir` that may ship, keyed by its path under `prefix`,
|
|
108
|
+
// with symlinks followed. `seen` holds the real paths of the folders on the
|
|
109
|
+
// way down, so a symlink back up the tree ends there.
|
|
110
|
+
function collectTreeFiles(
|
|
111
|
+
dir: string,
|
|
112
|
+
prefix: string,
|
|
113
|
+
hostReal: string,
|
|
114
|
+
seen: ReadonlySet<string>,
|
|
115
|
+
): Record<string, GuestFile> {
|
|
116
|
+
const real = findStats(dir) === null ? null : realpathSync(dir);
|
|
117
|
+
|
|
118
|
+
if (real === null || seen.has(real)) {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const below = new Set([...seen, real]);
|
|
123
|
+
|
|
124
|
+
const files: Record<string, GuestFile> = {};
|
|
125
|
+
|
|
126
|
+
for (const name of readEntryNames(dir)) {
|
|
127
|
+
const path = join(dir, name);
|
|
128
|
+
const stats = findStats(path);
|
|
129
|
+
|
|
130
|
+
if (stats?.isFile() === true && shouldShipFile(path, hostReal)) {
|
|
131
|
+
files[`${prefix}/${name}`] = readBundleFile(path, stats.mode);
|
|
132
|
+
} else if (stats?.isDirectory() === true) {
|
|
133
|
+
Object.assign(files, collectTreeFiles(path, `${prefix}/${name}`, hostReal, below));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return files;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Whether the file a path resolves to may ship: its name starts with no
|
|
141
|
+
// dot, and inside the host's real folder `hostReal` it sits in a shipped
|
|
142
|
+
// entry, so a symlink never carries credentials, account state, or the
|
|
143
|
+
// unfiltered settings.
|
|
144
|
+
function shouldShipFile(path: string, hostReal: string): boolean {
|
|
145
|
+
const real = realpathSync(path);
|
|
146
|
+
|
|
147
|
+
if (basename(real).startsWith('.')) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const inside = relative(hostReal, real);
|
|
152
|
+
|
|
153
|
+
return inside.startsWith('..') || SHIPPED_ENTRIES.has(inside.split(sep)[0] ?? '');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SHIPPED_ENTRIES: ReadonlySet<string> = new Set([
|
|
157
|
+
'CLAUDE.md',
|
|
158
|
+
'statusline.sh',
|
|
159
|
+
'agents',
|
|
160
|
+
'output-styles',
|
|
161
|
+
'skills',
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
// A file's bytes, with an executable mode when its owner may execute it.
|
|
165
|
+
function readBundleFile(path: string, mode: number): GuestFile {
|
|
166
|
+
const content = readFileSync(path);
|
|
167
|
+
|
|
168
|
+
return (mode & 0o100) === 0 ? content : { content, mode: 0o755 };
|
|
169
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -452,10 +452,12 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
452
452
|
principals: cfg.principals,
|
|
453
453
|
sources: sources.sources,
|
|
454
454
|
gitTransports: cfg.workspaces.gitTransports,
|
|
455
|
+
workspaceRoots: { root: cfg.workspaces.root, targetRoots: cfg.workspaces.targetRoots },
|
|
455
456
|
...(listen === null || tokenFile === null
|
|
456
457
|
? {}
|
|
457
458
|
: { listen: { host: listen.host, port: listen.port, tokenFile } }),
|
|
458
459
|
restoreBootTimeoutMs,
|
|
460
|
+
resumeInterruptedTurns: cfg.resumeInterruptedTurns,
|
|
459
461
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
460
462
|
? { tapGraceMs: graceOverride }
|
|
461
463
|
: {}),
|
package/src/client/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { match } from 'ts-pattern';
|
|
|
2
2
|
import { createActor } from 'xstate';
|
|
3
3
|
import { countSessionStates } from '../protocol/count-session-states';
|
|
4
4
|
import { DaemonError } from '../protocol/daemon-error';
|
|
5
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
5
6
|
import type { EventMsg } from '../protocol/protocol';
|
|
6
7
|
import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
|
|
7
8
|
import { sortSessionViews } from '../protocol/sort-session-views';
|
|
@@ -339,12 +340,17 @@ const picker = new SpawnPicker<MirrorSession>({
|
|
|
339
340
|
attach,
|
|
340
341
|
toMirrorSession,
|
|
341
342
|
upsertMirror,
|
|
343
|
+
hasDaemonFeature,
|
|
342
344
|
});
|
|
343
345
|
|
|
344
346
|
function openPicker(resume: boolean) {
|
|
345
347
|
picker.open(resume);
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
function hasDaemonFeature(feature: DaemonFeature): boolean {
|
|
351
|
+
return daemonFeatures.has(feature);
|
|
352
|
+
}
|
|
353
|
+
|
|
348
354
|
function sendRequest(m: string, p?: Readonly<Record<string, unknown>>) {
|
|
349
355
|
return client.sendRequest(m, p);
|
|
350
356
|
}
|
|
@@ -832,6 +838,7 @@ const boot = await bootDaemonClient({
|
|
|
832
838
|
|
|
833
839
|
let client = boot.client;
|
|
834
840
|
let daemonStale = boot.stale;
|
|
841
|
+
let daemonFeatures = boot.features;
|
|
835
842
|
let daemonRestarting = false;
|
|
836
843
|
|
|
837
844
|
lastUsedAgent = boot.lastUsedAgent;
|
|
@@ -950,6 +957,7 @@ async function restartDaemon() {
|
|
|
950
957
|
|
|
951
958
|
client = next.client;
|
|
952
959
|
daemonStale = next.stale;
|
|
960
|
+
daemonFeatures = next.features;
|
|
953
961
|
lastUsedAgent = next.lastUsedAgent;
|
|
954
962
|
client.onEvent = applyDaemonEvent;
|
|
955
963
|
|