@zgeoff/atc 2.34.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 +1 -0
- package/src/daemon/build-tar-archive.ts +33 -8
- package/src/daemon/daemon-connection.ts +3 -0
- package/src/daemon/daemon.ts +51 -1
- package/src/daemon/restore-fleet.ts +66 -2
- package/src/daemon/sessions.ts +22 -2
- package/src/mcp/mcp-tools.ts +4 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +7 -0
- package/src/protocol/daemon-features.ts +3 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- 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 +34 -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
|
@@ -457,6 +457,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
457
457
|
? {}
|
|
458
458
|
: { listen: { host: listen.host, port: listen.port, tokenFile } }),
|
|
459
459
|
restoreBootTimeoutMs,
|
|
460
|
+
resumeInterruptedTurns: cfg.resumeInterruptedTurns,
|
|
460
461
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
461
462
|
? { tapGraceMs: graceOverride }
|
|
462
463
|
: {}),
|
|
@@ -12,8 +12,9 @@ const BLOCK = 512;
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Packs files into a POSIX ustar archive that `tar -x` unpacks, each with
|
|
15
|
-
* its parent directories created on the way. A path longer than
|
|
16
|
-
*
|
|
15
|
+
* its parent directories created on the way. A path longer than 100 bytes
|
|
16
|
+
* splits at a slash into ustar's prefix and name fields, and a path that
|
|
17
|
+
* fits no such split is refused.
|
|
17
18
|
*/
|
|
18
19
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
|
|
19
20
|
export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
|
|
@@ -50,15 +51,11 @@ export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
|
|
|
50
51
|
function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
51
52
|
const encoder = new TextEncoder();
|
|
52
53
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
if (name.length > 100) {
|
|
56
|
-
throw new Error(`archive path is longer than 100 bytes: ${path}`);
|
|
57
|
-
}
|
|
54
|
+
const split = splitArchivePath(path);
|
|
58
55
|
|
|
59
56
|
const header = new Uint8Array(BLOCK);
|
|
60
57
|
|
|
61
|
-
header.set(name, 0);
|
|
58
|
+
header.set(encoder.encode(split.name), 0);
|
|
62
59
|
header.set(encoder.encode(formatOctal(mode, 8)), 100);
|
|
63
60
|
header.set(encoder.encode(formatOctal(0, 8)), 108);
|
|
64
61
|
header.set(encoder.encode(formatOctal(0, 8)), 116);
|
|
@@ -71,6 +68,7 @@ function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
|
71
68
|
header[156] = '0'.codePointAt(0) ?? 0;
|
|
72
69
|
|
|
73
70
|
header.set(encoder.encode('ustar\u000000'), 257);
|
|
71
|
+
header.set(encoder.encode(split.prefix), 345);
|
|
74
72
|
|
|
75
73
|
const checksum = header.reduce((total, byte) => total + byte, 0);
|
|
76
74
|
|
|
@@ -79,6 +77,33 @@ function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
|
79
77
|
return header;
|
|
80
78
|
}
|
|
81
79
|
|
|
80
|
+
const NAME_BYTES = 100;
|
|
81
|
+
const PREFIX_BYTES = 155;
|
|
82
|
+
|
|
83
|
+
// The ustar fields a path fills: the whole path as the name when it fits,
|
|
84
|
+
// otherwise the part before the last slash that leaves a name short enough,
|
|
85
|
+
// with that slash dropped, as the prefix.
|
|
86
|
+
function splitArchivePath(path: string): { readonly prefix: string; readonly name: string } {
|
|
87
|
+
if (countBytes(path) <= NAME_BYTES) {
|
|
88
|
+
return { prefix: '', name: path };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (let at = path.indexOf('/'); at !== -1; at = path.indexOf('/', at + 1)) {
|
|
92
|
+
const prefix = path.slice(0, at);
|
|
93
|
+
const name = path.slice(at + 1);
|
|
94
|
+
|
|
95
|
+
if (countBytes(prefix) <= PREFIX_BYTES && countBytes(name) <= NAME_BYTES && name !== '') {
|
|
96
|
+
return { prefix, name };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error(`archive path does not fit a ustar header: ${path}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function countBytes(text: string): number {
|
|
104
|
+
return new TextEncoder().encode(text).length;
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
// An octal number padded to fill its field, ending in a NUL.
|
|
83
108
|
function formatOctal(value: number, width: number): string {
|
|
84
109
|
return `${value.toString(8).padStart(width - 1, '0')}\u0000`;
|
|
@@ -1034,6 +1034,9 @@ export class DaemonConnection {
|
|
|
1034
1034
|
...(data.trustClonedWorkspace === undefined
|
|
1035
1035
|
? {}
|
|
1036
1036
|
: { trustClonedWorkspace: data.trustClonedWorkspace }),
|
|
1037
|
+
...(data.resumeInterruptedTurns === undefined
|
|
1038
|
+
? {}
|
|
1039
|
+
: { resumeInterruptedTurns: data.resumeInterruptedTurns }),
|
|
1037
1040
|
},
|
|
1038
1041
|
target,
|
|
1039
1042
|
workspace: data.workspace ?? null,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -157,6 +157,11 @@ export interface DaemonOptions {
|
|
|
157
157
|
readonly restoreBootTimeoutMs?: number;
|
|
158
158
|
readonly tapGraceMs?: number;
|
|
159
159
|
|
|
160
|
+
// Whether a fleet restore sends a session that was mid-turn when the
|
|
161
|
+
// previous daemon stopped one message to carry on, for a session spawned
|
|
162
|
+
// without its own choice; off when unset.
|
|
163
|
+
readonly resumeInterruptedTurns?: boolean;
|
|
164
|
+
|
|
160
165
|
// How long a confirm token from `session.forget` stays usable.
|
|
161
166
|
readonly forgetConfirmMs?: number;
|
|
162
167
|
|
|
@@ -281,6 +286,11 @@ const LOCK_WAIT_MS = 2000;
|
|
|
281
286
|
* because transports guarantee byte integrity and such a line means a buggy
|
|
282
287
|
* or hostile peer.
|
|
283
288
|
*/
|
|
289
|
+
// The sender and opening of the message a restore sends a session whose
|
|
290
|
+
// turn the previous daemon's stop cut off.
|
|
291
|
+
const RESUME_SENDER = 'atc';
|
|
292
|
+
const RESUME_LEAD = 'atc restarted the daemon';
|
|
293
|
+
|
|
284
294
|
export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
285
295
|
let stopDaemon: (() => Promise<void>) | null = null;
|
|
286
296
|
|
|
@@ -1308,6 +1318,37 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1308
1318
|
return record;
|
|
1309
1319
|
};
|
|
1310
1320
|
|
|
1321
|
+
const startedAt = new Date().toISOString();
|
|
1322
|
+
|
|
1323
|
+
// Sends a restored session the message that carries on a turn the
|
|
1324
|
+
// previous daemon's stop cut off. A resume message still pending from an
|
|
1325
|
+
// earlier restart covers this one, so a session never holds two.
|
|
1326
|
+
const sendResumeMessage = async (s: Session): Promise<void> => {
|
|
1327
|
+
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
1328
|
+
|
|
1329
|
+
if (
|
|
1330
|
+
pending.some((record) => record.from === RESUME_SENDER && record.text.startsWith(RESUME_LEAD))
|
|
1331
|
+
) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const refusal = findMessageRefusal(s.id);
|
|
1336
|
+
|
|
1337
|
+
if (refusal !== null) {
|
|
1338
|
+
mgr.log(`atc sent session ${s.id} no resume message (${refusal})`);
|
|
1339
|
+
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
await writeAcceptedMessage(
|
|
1344
|
+
s.id,
|
|
1345
|
+
RESUME_SENDER,
|
|
1346
|
+
`${RESUME_LEAD} at ${startedAt}; your last turn was interrupted. Check the state of anything you had in flight, then continue.`,
|
|
1347
|
+
mintMessageID(),
|
|
1348
|
+
() => {},
|
|
1349
|
+
);
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1311
1352
|
// A retried send answers with the message's current status, so a caller
|
|
1312
1353
|
// that lost the first answer learns where its message stands now.
|
|
1313
1354
|
const loadMessageReplay = async (
|
|
@@ -1776,7 +1817,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1776
1817
|
attachments.findEffectiveDims(sessionID) ??
|
|
1777
1818
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
1778
1819
|
restoreFleet: (cols, rows) =>
|
|
1779
|
-
restoreFleet({
|
|
1820
|
+
restoreFleet({
|
|
1821
|
+
mgr,
|
|
1822
|
+
store,
|
|
1823
|
+
findRuntime,
|
|
1824
|
+
cols,
|
|
1825
|
+
rows,
|
|
1826
|
+
capMs: opts.restoreBootTimeoutMs ?? 0,
|
|
1827
|
+
resumeInterruptedTurns: opts.resumeInterruptedTurns ?? false,
|
|
1828
|
+
sendResumeMessage,
|
|
1829
|
+
}),
|
|
1780
1830
|
readSessionRecord: async (id, access) => {
|
|
1781
1831
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1782
1832
|
|
|
@@ -16,6 +16,12 @@ export interface RestoreFleetParams {
|
|
|
16
16
|
// it has booted before moving on regardless; zero waits on the signal
|
|
17
17
|
// alone.
|
|
18
18
|
readonly capMs: number;
|
|
19
|
+
|
|
20
|
+
// Whether a session spawned without its own choice gets one message to
|
|
21
|
+
// carry on a turn the previous daemon's stop cut off, and how that
|
|
22
|
+
// message is sent once the session's terminal is adopted.
|
|
23
|
+
readonly resumeInterruptedTurns: boolean;
|
|
24
|
+
readonly sendResumeMessage: (s: Session) => Promise<void>;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -23,7 +29,9 @@ export interface RestoreFleetParams {
|
|
|
23
29
|
* the rest by recency, registers every one as a terminal-less session so
|
|
24
30
|
* the whole fleet lists at once, then adopts terminals one at a time,
|
|
25
31
|
* waiting for each session to report it has booted before starting the
|
|
26
|
-
* next.
|
|
32
|
+
* next. A session that was mid-turn when the previous daemon stopped, and
|
|
33
|
+
* that resumes interrupted turns, gets one message to carry on once its
|
|
34
|
+
* terminal is adopted.
|
|
27
35
|
*/
|
|
28
36
|
export async function restoreFleet(params: RestoreFleetParams): Promise<number> {
|
|
29
37
|
const mgr = params.mgr;
|
|
@@ -32,6 +40,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
32
40
|
const cols = params.cols;
|
|
33
41
|
const rows = params.rows;
|
|
34
42
|
const capMs = params.capMs;
|
|
43
|
+
const sendResumeMessage = params.sendResumeMessage;
|
|
35
44
|
|
|
36
45
|
const hasLiveSession = (entry: FleetEntry) =>
|
|
37
46
|
mgr.sessions.some(
|
|
@@ -83,11 +92,33 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
83
92
|
.filter((r) => r.revive || r.session.state !== 'exited')
|
|
84
93
|
.map((r) => r.session);
|
|
85
94
|
|
|
95
|
+
// Only a session this restore registers comes from the previous daemon's
|
|
96
|
+
// fleet; a listed one this daemon already ran is revived by hand. The
|
|
97
|
+
// trail is read before any terminal is adopted, since a revived agent's
|
|
98
|
+
// own events move it on.
|
|
99
|
+
const interrupted = await collectInterruptedTurns(
|
|
100
|
+
mgr,
|
|
101
|
+
store,
|
|
102
|
+
registered.filter((r) => !r.revive && r.session.state !== 'exited').map((r) => r.session),
|
|
103
|
+
params.resumeInterruptedTurns,
|
|
104
|
+
);
|
|
105
|
+
|
|
86
106
|
// A session whose target refuses to start a terminal stays listed without
|
|
87
|
-
// one, and the restore moves on to the next.
|
|
107
|
+
// one, and the restore moves on to the next. A resume message that fails
|
|
108
|
+
// to send is logged, and the session runs on without it.
|
|
88
109
|
const adoptQueued = async (s: Session): Promise<boolean> => {
|
|
89
110
|
const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
|
|
90
111
|
|
|
112
|
+
if (adopted !== null && interrupted.has(s.id)) {
|
|
113
|
+
try {
|
|
114
|
+
await sendResumeMessage(s);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
mgr.log(
|
|
117
|
+
`atc could not send session ${s.id} its resume message (${error instanceof Error ? error.message : String(error)})`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
91
122
|
return adopted !== null;
|
|
92
123
|
};
|
|
93
124
|
|
|
@@ -174,6 +205,39 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
174
205
|
return registered.length;
|
|
175
206
|
}
|
|
176
207
|
|
|
208
|
+
// The sessions whose last turn event in the trail is a submitted prompt:
|
|
209
|
+
// the previous daemon stopped while their turn ran. A session's own choice
|
|
210
|
+
// beats the config, and an agent that takes no atc messages has no path for
|
|
211
|
+
// the resume message. Only a harness on the daemon's own machine ends with
|
|
212
|
+
// the daemon; one on a host with a lifecycle of its own runs on, so its
|
|
213
|
+
// turn was never cut off and a revive attaches to it as it stands.
|
|
214
|
+
async function collectInterruptedTurns(
|
|
215
|
+
mgr: SessionManager,
|
|
216
|
+
store: StateStore,
|
|
217
|
+
sessions: readonly Session[],
|
|
218
|
+
configured: boolean,
|
|
219
|
+
): Promise<ReadonlySet<SessionID>> {
|
|
220
|
+
const interrupted = new Set<SessionID>();
|
|
221
|
+
|
|
222
|
+
for (const s of sessions) {
|
|
223
|
+
if (
|
|
224
|
+
!(s.resumeInterruptedTurns ?? configured) ||
|
|
225
|
+
s.attachment !== 'local' ||
|
|
226
|
+
mgr.findAdapter(s.agent)?.takesMessages !== true
|
|
227
|
+
) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const kind = await store.findLatestTurnKind(s.id, s.agentSessionID);
|
|
232
|
+
|
|
233
|
+
if (kind === 'prompt-submitted') {
|
|
234
|
+
interrupted.add(s.id);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return interrupted;
|
|
239
|
+
}
|
|
240
|
+
|
|
177
241
|
// A stored entry matches a listed session by its atc session id, or by its
|
|
178
242
|
// agent session id when a listed session resumed the same agent session.
|
|
179
243
|
function isSameSession(s: Session, entry: FleetEntry): boolean {
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { posix } from 'node:path';
|
|
|
3
3
|
import type {
|
|
4
4
|
AgentAdapter,
|
|
5
5
|
GuestPaths,
|
|
6
|
+
GuestSpawnPlan,
|
|
6
7
|
SpawnOptions,
|
|
7
8
|
SpawnOverrides,
|
|
8
9
|
SpawnPlan,
|
|
@@ -183,6 +184,10 @@ export interface Session {
|
|
|
183
184
|
// The epoch of the session's latest harness start or attach, which the
|
|
184
185
|
// daemon's bridge to that harness is bound to; 0 before the first.
|
|
185
186
|
bridgeEpoch: number;
|
|
187
|
+
|
|
188
|
+
// Whether a fleet restore sends the session one message to carry on a
|
|
189
|
+
// turn a daemon restart cut off; absent follows the daemon's config.
|
|
190
|
+
resumeInterruptedTurns?: boolean;
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
// A session's ready workspace and the variables its harnesses go without.
|
|
@@ -569,6 +574,9 @@ export class SessionManager {
|
|
|
569
574
|
attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
|
|
570
575
|
hostKey: entry.hostKey ?? entry.sessionID,
|
|
571
576
|
bridgeEpoch: 0,
|
|
577
|
+
...(entry.resumeInterruptedTurns === undefined
|
|
578
|
+
? {}
|
|
579
|
+
: { resumeInterruptedTurns: entry.resumeInterruptedTurns }),
|
|
572
580
|
};
|
|
573
581
|
|
|
574
582
|
this.sessions.push(session);
|
|
@@ -1125,6 +1133,9 @@ export class SessionManager {
|
|
|
1125
1133
|
suspended: false,
|
|
1126
1134
|
hostKey,
|
|
1127
1135
|
bridgeEpoch: binding.epoch,
|
|
1136
|
+
...(overrides.resumeInterruptedTurns === undefined
|
|
1137
|
+
? {}
|
|
1138
|
+
: { resumeInterruptedTurns: overrides.resumeInterruptedTurns }),
|
|
1128
1139
|
};
|
|
1129
1140
|
|
|
1130
1141
|
this.attachHarness(session, pty, this.hasHostLifecycle(target));
|
|
@@ -2082,7 +2093,9 @@ export class SessionManager {
|
|
|
2082
2093
|
hostKey: SessionID,
|
|
2083
2094
|
target: string,
|
|
2084
2095
|
dir: string,
|
|
2085
|
-
|
|
2096
|
+
|
|
2097
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
|
|
2098
|
+
planned: GuestSpawnPlan['files'],
|
|
2086
2099
|
): Promise<void> {
|
|
2087
2100
|
await provider.prepareHost({
|
|
2088
2101
|
host: hostKey,
|
|
@@ -2105,7 +2118,11 @@ export class SessionManager {
|
|
|
2105
2118
|
}
|
|
2106
2119
|
}
|
|
2107
2120
|
|
|
2108
|
-
const files = Object.entries(planned).map(([path,
|
|
2121
|
+
const files = Object.entries(planned).map(([path, file]) =>
|
|
2122
|
+
typeof file === 'string' || file instanceof Uint8Array
|
|
2123
|
+
? { path, content: file }
|
|
2124
|
+
: { path, content: file.content, mode: file.mode },
|
|
2125
|
+
);
|
|
2109
2126
|
|
|
2110
2127
|
if (files.length > 0) {
|
|
2111
2128
|
await provider.transferArchive(buildTarArchive(files), dir, hostKey);
|
|
@@ -2895,6 +2912,9 @@ export class SessionManager {
|
|
|
2895
2912
|
targetIdentity: s.targetIdentity,
|
|
2896
2913
|
...(s.desired === 'run' ? {} : { desired: s.desired }),
|
|
2897
2914
|
...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
|
|
2915
|
+
...(s.resumeInterruptedTurns === undefined
|
|
2916
|
+
? {}
|
|
2917
|
+
: { resumeInterruptedTurns: s.resumeInterruptedTurns }),
|
|
2898
2918
|
});
|
|
2899
2919
|
}
|
|
2900
2920
|
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -65,6 +65,9 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
65
65
|
trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
|
|
66
66
|
"Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
|
|
67
67
|
),
|
|
68
|
+
resumeInterruptedTurns: SPAWN_SCHEMA.shape.resumeInterruptedTurns.describe(
|
|
69
|
+
"Whether atc sends the session one message to carry on when a daemon restart cuts off its turn. The message goes out when the fleet is restored, only to a session whose last recorded event was a submitted prompt, and only to an agent that takes atc messages. Omit it to follow the daemon's resumeInterruptedTurns config, which is off by default.",
|
|
70
|
+
),
|
|
68
71
|
detached: z
|
|
69
72
|
.boolean()
|
|
70
73
|
.optional()
|
|
@@ -420,6 +423,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
420
423
|
target: 'spawn.target',
|
|
421
424
|
workspace: 'spawn.workspace',
|
|
422
425
|
trustClonedWorkspace: 'spawn.workspace.trust',
|
|
426
|
+
resumeInterruptedTurns: 'spawn.resumeInterruptedTurns',
|
|
423
427
|
},
|
|
424
428
|
},
|
|
425
429
|
},
|
|
@@ -26,6 +26,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
26
26
|
'transport.tcp': 'a TCP connection to the daemon',
|
|
27
27
|
'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
|
|
28
28
|
'session.auth': 'session.auth.revoke and session.auth.rebind',
|
|
29
|
+
'spawn.resumeInterruptedTurns': "atc_session_spawn's resumeInterruptedTurns",
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -58,6 +58,9 @@ export function runTool(
|
|
|
58
58
|
...(args['trustClonedWorkspace'] === undefined
|
|
59
59
|
? {}
|
|
60
60
|
: { trustClonedWorkspace: args['trustClonedWorkspace'] }),
|
|
61
|
+
...(args['resumeInterruptedTurns'] === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { resumeInterruptedTurns: args['resumeInterruptedTurns'] }),
|
|
61
64
|
cols: 100,
|
|
62
65
|
rows: 30,
|
|
63
66
|
};
|
|
@@ -78,6 +81,9 @@ export function runTool(
|
|
|
78
81
|
const trustFeatures: readonly DaemonFeature[] =
|
|
79
82
|
args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
|
|
80
83
|
|
|
84
|
+
const resumeFeatures: readonly DaemonFeature[] =
|
|
85
|
+
args['resumeInterruptedTurns'] === undefined ? [] : ['spawn.resumeInterruptedTurns'];
|
|
86
|
+
|
|
81
87
|
// Only a daemon that picks a git workspace's directory takes one
|
|
82
88
|
// without a cwd.
|
|
83
89
|
const autoDirFeatures: readonly DaemonFeature[] =
|
|
@@ -91,6 +97,7 @@ export function runTool(
|
|
|
91
97
|
...targetFeatures,
|
|
92
98
|
...workspaceFeatures,
|
|
93
99
|
...trustFeatures,
|
|
100
|
+
...resumeFeatures,
|
|
94
101
|
...autoDirFeatures,
|
|
95
102
|
];
|
|
96
103
|
|
|
@@ -85,6 +85,9 @@ export const DAEMON_FEATURES = [
|
|
|
85
85
|
// `session.auth.revoke` and `session.auth.rebind` exist, open to the
|
|
86
86
|
// daemon's owner only.
|
|
87
87
|
'session.auth',
|
|
88
|
+
|
|
89
|
+
// `session.spawn` takes `resumeInterruptedTurns`.
|
|
90
|
+
'spawn.resumeInterruptedTurns',
|
|
88
91
|
] as const;
|
|
89
92
|
|
|
90
93
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
|
@@ -196,6 +196,12 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
196
196
|
.boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
|
|
197
197
|
.optional(),
|
|
198
198
|
|
|
199
|
+
// Whether a fleet restore after a daemon restart sends the session one
|
|
200
|
+
// message to carry on an interrupted turn; absent follows the config.
|
|
201
|
+
resumeInterruptedTurns: z
|
|
202
|
+
.boolean({ error: 'session.spawn resumeInterruptedTurns must be a boolean' })
|
|
203
|
+
.optional(),
|
|
204
|
+
|
|
199
205
|
// The session the new one is a sub-session of; absent or empty spawns a
|
|
200
206
|
// top-level session.
|
|
201
207
|
parent: z.preprocess(
|
package/src/shared/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { buildOptionalBoolean } from './build-optional-boolean';
|
|
4
5
|
import { buildOptionalString } from './build-optional-string';
|
|
5
6
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
6
7
|
import { collectAuthProfiles } from './collect-auth-profiles';
|
|
@@ -65,6 +66,11 @@ export interface Config {
|
|
|
65
66
|
|
|
66
67
|
// The workspace config problems, one line each.
|
|
67
68
|
workspaceErrors: readonly string[];
|
|
69
|
+
|
|
70
|
+
// Whether a fleet restore after a daemon restart sends a session that
|
|
71
|
+
// was mid-turn one message to carry on; a spawn can override it per
|
|
72
|
+
// session.
|
|
73
|
+
resumeInterruptedTurns: boolean;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/**
|
|
@@ -109,6 +115,7 @@ const DEFAULTS: Config = {
|
|
|
109
115
|
principals: null,
|
|
110
116
|
principalErrors: [],
|
|
111
117
|
workspaceErrors: [],
|
|
118
|
+
resumeInterruptedTurns: false,
|
|
112
119
|
};
|
|
113
120
|
|
|
114
121
|
const configDir = join(resolveHomeDir(), '.config', 'atc');
|
|
@@ -148,6 +155,7 @@ const CONFIG_SCHEMA = z.object({
|
|
|
148
155
|
targets: z.unknown().optional(),
|
|
149
156
|
defaultTarget: z.unknown().optional(),
|
|
150
157
|
principals: z.unknown().optional(),
|
|
158
|
+
resumeInterruptedTurns: buildOptionalBoolean(),
|
|
151
159
|
});
|
|
152
160
|
|
|
153
161
|
/**
|
|
@@ -336,6 +344,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
336
344
|
principals: principals.principals,
|
|
337
345
|
principalErrors: principals.errors,
|
|
338
346
|
workspaceErrors: workspaces.errors,
|
|
347
|
+
resumeInterruptedTurns: parsed.data.resumeInterruptedTurns ?? DEFAULTS.resumeInterruptedTurns,
|
|
339
348
|
};
|
|
340
349
|
}
|
|
341
350
|
|
package/src/statusline.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
2
|
import { join } from 'node:path';
|
|
3
|
+
import { resolveAgentHome } from './agents/resolve-agent-home';
|
|
4
4
|
import { sendBridgeRequest } from './protocol/send-bridge-request';
|
|
5
5
|
import { statusFile } from './shared/config';
|
|
6
6
|
import { isRecord, sendReport } from './shared/report';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Runs as the statusLine command injected into wrangled sessions. Chains the
|
|
10
|
-
* user's own statusline (from
|
|
10
|
+
* user's own statusline (from settings.json in the Claude config folder,
|
|
11
|
+
* `$CLAUDE_CONFIG_DIR` or ~/.claude), appends the atc fleet
|
|
11
12
|
* segment, and heartbeats the session id back to the atc socket, with the
|
|
12
13
|
* agent id the command gave it. Always exits 0 so it never breaks the
|
|
13
14
|
* session it renders for.
|
|
@@ -37,7 +38,7 @@ export async function runStatusline(agent: string): Promise<void> {
|
|
|
37
38
|
let chained = '';
|
|
38
39
|
|
|
39
40
|
try {
|
|
40
|
-
const settingsPath = join(
|
|
41
|
+
const settingsPath = join(resolveAgentHome('CLAUDE_CONFIG_DIR', '.claude'), 'settings.json');
|
|
41
42
|
const rawSettings = readFileSync(settingsPath, 'utf8');
|
|
42
43
|
const settings: unknown = JSON.parse(rawSettings);
|
|
43
44
|
const statusLine = isRecord(settings) ? settings['statusLine'] : undefined;
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -67,6 +67,11 @@ export interface FleetEntry {
|
|
|
67
67
|
// parent's when the two share one host. A row without one runs on a host
|
|
68
68
|
// of its own.
|
|
69
69
|
readonly hostKey?: SessionID;
|
|
70
|
+
|
|
71
|
+
// Whether a fleet restore after a daemon restart sends the session one
|
|
72
|
+
// message to carry on a turn the restart cut off; a row without one
|
|
73
|
+
// follows the daemon's config.
|
|
74
|
+
readonly resumeInterruptedTurns?: boolean;
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
export interface FleetStore {
|
|
@@ -34,6 +34,10 @@ interface FleetTable {
|
|
|
34
34
|
// its parent's when the two share one host. Null for a row from before
|
|
35
35
|
// hosts were recorded, which runs on its own.
|
|
36
36
|
host_key: string | null;
|
|
37
|
+
|
|
38
|
+
// Whether a restore sends the session one message to carry on an
|
|
39
|
+
// interrupted turn: 1 or 0 as the spawn chose, null to follow the config.
|
|
40
|
+
resume_interrupted_turns: number | null;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
interface EventsTable {
|
|
@@ -549,6 +553,14 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
549
553
|
.execute();
|
|
550
554
|
},
|
|
551
555
|
},
|
|
556
|
+
'026_add_fleet_resume_interrupted_turns': {
|
|
557
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
558
|
+
await db.schema
|
|
559
|
+
.alterTable('fleet')
|
|
560
|
+
.addColumn('resume_interrupted_turns', 'integer')
|
|
561
|
+
.execute();
|
|
562
|
+
},
|
|
563
|
+
},
|
|
552
564
|
};
|
|
553
565
|
|
|
554
566
|
const PROVIDER: MigrationProvider = {
|
package/src/store/state-store.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type {
|
|
|
38
38
|
|
|
39
39
|
// Spelled as the partial index's predicate so SQLite can match them.
|
|
40
40
|
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
41
|
+
const TURN_KINDS = ['prompt-submitted', 'turn-done', 'needs-input', 'ended'];
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Some sessions' slice of the event trail: rows under their atc ids, plus
|
|
@@ -210,6 +211,7 @@ export class StateStore {
|
|
|
210
211
|
'workspace.withheld_env',
|
|
211
212
|
'fleet.desired',
|
|
212
213
|
'fleet.host_key',
|
|
214
|
+
'fleet.resume_interrupted_turns',
|
|
213
215
|
])
|
|
214
216
|
.execute();
|
|
215
217
|
|
|
@@ -239,6 +241,9 @@ export class StateStore {
|
|
|
239
241
|
...buildWithheldEnvField(row.withheld_env),
|
|
240
242
|
...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
|
|
241
243
|
...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
|
|
244
|
+
...(row.resume_interrupted_turns === null
|
|
245
|
+
? {}
|
|
246
|
+
: { resumeInterruptedTurns: row.resume_interrupted_turns !== 0 }),
|
|
242
247
|
});
|
|
243
248
|
}
|
|
244
249
|
|
|
@@ -346,6 +351,10 @@ export class StateStore {
|
|
|
346
351
|
target_identity: entry.targetIdentity ?? null,
|
|
347
352
|
desired: entry.desired ?? null,
|
|
348
353
|
host_key: entry.hostKey ?? null,
|
|
354
|
+
resume_interrupted_turns:
|
|
355
|
+
entry.resumeInterruptedTurns === undefined
|
|
356
|
+
? null
|
|
357
|
+
: Number(entry.resumeInterruptedTurns),
|
|
349
358
|
})
|
|
350
359
|
.execute();
|
|
351
360
|
|
|
@@ -539,6 +548,31 @@ export class StateStore {
|
|
|
539
548
|
return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
|
|
540
549
|
}
|
|
541
550
|
|
|
551
|
+
// The kind of the session's latest event that moves a turn: a prompt
|
|
552
|
+
// submitted, a turn done, a request for input, or the session's end.
|
|
553
|
+
// Null when the trail holds none. Matches the session by either id, as
|
|
554
|
+
// the activity read does.
|
|
555
|
+
async findLatestTurnKind(
|
|
556
|
+
atcID: SessionID,
|
|
557
|
+
agentSessionID: AgentSessionID | undefined,
|
|
558
|
+
): Promise<string | null> {
|
|
559
|
+
const row = await this.db
|
|
560
|
+
.selectFrom('events')
|
|
561
|
+
.select('kind')
|
|
562
|
+
.where('kind', 'in', TURN_KINDS)
|
|
563
|
+
.where((eb) =>
|
|
564
|
+
eb.or([
|
|
565
|
+
eb('atc_id', '=', atcID),
|
|
566
|
+
...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
|
|
567
|
+
]),
|
|
568
|
+
)
|
|
569
|
+
.orderBy('id', 'desc')
|
|
570
|
+
.limit(1)
|
|
571
|
+
.executeTakeFirst();
|
|
572
|
+
|
|
573
|
+
return row?.kind ?? null;
|
|
574
|
+
}
|
|
575
|
+
|
|
542
576
|
async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
|
|
543
577
|
await this.db
|
|
544
578
|
.insertInto('spawn_history')
|