@zgeoff/atc 2.34.0 → 2.36.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.34.0",
3
+ "version": "2.36.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -42,6 +42,7 @@
42
42
  "@anthropic-ai/claude-agent-sdk": "0.3.222",
43
43
  "@better-auth/mcp": "1.7.6",
44
44
  "@better-auth/oauth-provider": "1.7.6",
45
+ "@modelcontextprotocol/sdk": "1.31.0",
45
46
  "@xterm/addon-serialize": "0.14.0",
46
47
  "@xterm/headless": "6.0.0",
47
48
  "@zgeoff/imp-client": "0.27.0",
@@ -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 clone.
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, 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. `argv` is the CLI's own command line. The
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
- ): SpawnPlan & { readonly env: Readonly<Record<string, string>> } {
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, and the CLI's own command
53
- // line.
54
- const SEED_CONFIG_SCRIPT =
55
- 'mkdir -p "$1" && { [ -e "$1/.claude.json" ] || cp "$2" "$1/.claude.json"; } && shift 2 && exec "$@"';
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. A credential, endpoint, or provider variable
193
- // in the configured settings, or in the host's environment, would keep
194
- // the CLI from sending the placeholder, so either refuses the start.
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 ustar
16
- * holds is refused.
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 name = encoder.encode(path);
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,
@@ -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({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
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 {
@@ -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
- planned: Readonly<Record<string, string>>,
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, content]) => ({ path, content }));
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
 
@@ -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
  /**
@@ -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(
@@ -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 ~/.claude/settings.json), appends the atc fleet
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(homedir(), '.claude', 'settings.json');
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;
@@ -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 = {
@@ -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')