agent-relay-runner 0.91.2 → 0.91.4

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": "agent-relay-runner",
3
- "version": "0.91.2",
3
+ "version": "0.91.4",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.70"
23
+ "agent-relay-sdk": "0.2.71"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.91.2",
4
+ "version": "0.91.4",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -9,7 +9,7 @@ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
9
9
  import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
10
10
  import { collectClaudeSessionEvents } from "./claude-transcript";
11
11
  import type { SessionEvent } from "../session-insights";
12
- import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
12
+ import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
13
13
  import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
14
14
  import { claudeProviderMessageText } from "./claude-delivery";
15
15
  import { buildRateLimitProviderState, gatedClaudeRateLimitStatus, parseClaudeRateLimitPane } from "../rate-limit";
@@ -223,7 +223,7 @@ export class ClaudeAdapter implements ProviderAdapter {
223
223
  // Create + bootstrap + auth-link the isolated config home (no-op for host base) so the
224
224
  // assembler's materialize step has somewhere to write the provisioned skills/plugins.
225
225
  prepareClaudeProfileHome(config);
226
- const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
226
+ const defaultArgs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
227
227
  // #557 — ONE assembler owns the provisioning/vanilla/instruction launch surface
228
228
  // (--setting-sources, --plugin-dir, --mcp-config, status-line, --append-system-prompt);
229
229
  // profile-projection describes the SAME assembled result, so report == launch. The
@@ -236,7 +236,7 @@ export class ClaudeAdapter implements ProviderAdapter {
236
236
  // Isolated profiles run their own CLAUDE_CONFIG_DIR and must never route through
237
237
  // claude-rig — claude-rig resolves host rig config (and a bare `claude-rig` with no
238
238
  // rig key just errors out). Only host-base profiles may use claude-rig.
239
- const usesClaudeRig = isClaudeRig && profileUsesHostProviderGlobals(config);
239
+ const usesClaudeRig = isClaudeRig && profileUsesClaudeHostProviderGlobals(config);
240
240
  const bypassRigDefaults = config.headless && usesClaudeRig && config.approvalMode !== "open";
241
241
  const rigPrefix = usesClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
242
242
  const command = !usesClaudeRig || bypassRigDefaults || (!config.rig && !findClaudeRigRC(config.cwd))
package/src/config.ts CHANGED
@@ -99,6 +99,11 @@ export function registrationTimeoutMsFromEnv(): number {
99
99
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 60_000;
100
100
  }
101
101
 
102
+ export function nativeSelfResumeTimeoutMsFromEnv(): number {
103
+ const parsed = Number(process.env.AGENT_RELAY_NATIVE_SELF_RESUME_TIMEOUT_MS);
104
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 120_000;
105
+ }
106
+
102
107
  export function agentProfileNameFromEnv(): string | undefined {
103
108
  return process.env.AGENT_RELAY_AGENT_PROFILE;
104
109
  }
@@ -7,9 +7,16 @@ import {
7
7
  type ProviderConfig,
8
8
  type RunnerSpawnConfig,
9
9
  } from "./adapter";
10
- import { hostUserScopeMcpServers, profileUsesHostProviderGlobals, providerHomePathFor } from "./profile-home";
10
+ import {
11
+ claudeProjectConfigProjectionOn,
12
+ hostUserScopeMcpServers,
13
+ profileUsesClaudeHostProviderGlobals,
14
+ providerHomePathFor,
15
+ } from "./profile-home";
11
16
  import {
12
17
  materializeResolvedAssets,
18
+ resolveClaudeProjectSkills,
19
+ resolveProjectClaudeSettings,
13
20
  resolveClaudeProvisionedPlugins,
14
21
  resolveClaudeProvisionedSkills,
15
22
  resolveCodexProvisionedSkills,
@@ -115,6 +122,10 @@ export interface AssembledLaunch {
115
122
  statusLine: boolean;
116
123
  /** Relay-provisioned skills materialized for this launch. */
117
124
  skills: AssembledAsset[];
125
+ /** Project `.claude/skills` materialized for this Claude launch (#668). */
126
+ projectSkills: AssembledAsset[];
127
+ /** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
128
+ projectSettings: { applied: boolean; keys: string[]; ignoredKeys: string[]; file?: string };
118
129
  /** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
119
130
  plugins: AssembledAsset[];
120
131
  mcp: AssembledMcp;
@@ -161,10 +172,27 @@ function hasSettingsArg(args: string[]): boolean {
161
172
  // --settings is already present (caller wins). Relocated from claude.ts (#557).
162
173
  export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
163
174
  if (argLists.some(hasSettingsArg)) return [];
164
- return ["--settings", JSON.stringify({
175
+ return ["--settings", JSON.stringify(relayStatusLineSettings())];
176
+ }
177
+
178
+ function relayStatusLineSettings(): Record<string, unknown> {
179
+ return {
165
180
  statusLine: { type: "command", command: "agent-relay context-probe --wrap", refreshInterval: 30 },
166
181
  showThinkingSummaries: true,
167
- })];
182
+ };
183
+ }
184
+
185
+ function claudeSettingsArgs(
186
+ projectSettings: Record<string, unknown>,
187
+ includeStatusLine: boolean,
188
+ ...argLists: string[][]
189
+ ): string[] {
190
+ if (argLists.some(hasSettingsArg)) return [];
191
+ const settings = {
192
+ ...projectSettings,
193
+ ...(includeStatusLine ? relayStatusLineSettings() : {}),
194
+ };
195
+ return Object.keys(settings).length ? ["--settings", JSON.stringify(settings)] : [];
168
196
  }
169
197
 
170
198
  // ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
@@ -198,11 +226,16 @@ function assembleInstructions(config: RunnerSpawnConfig): AssembledInstructions
198
226
  function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]): AssembledLaunch {
199
227
  const configHome = providerHomePathFor("claude", config);
200
228
  const vanilla = profileIsVanillaBase(config);
229
+ const projectConfigProjection = claudeProjectConfigProjectionOn(config);
201
230
  const relayPlugin = profileAllowsRelayFeature(config, "plugins");
202
231
  const relaySkills = profileAllowsRelayFeature(config, "skills");
203
232
  const statusLine = profileAllowsRelayFeature(config, "statusLine");
204
233
 
205
234
  const skills = configHome ? resolveClaudeProvisionedSkills(configHome, config) : [];
235
+ const projectSkills = configHome
236
+ ? resolveClaudeProjectSkills(configHome, config, new Set(skills.map((s) => s.name)))
237
+ : [];
238
+ const projectSettings = resolveProjectClaudeSettings(config);
206
239
  const provisionedPlugins = configHome ? resolveClaudeProvisionedPlugins(configHome, config) : [];
207
240
 
208
241
  // Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
@@ -212,7 +245,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
212
245
  // host plugin dirs → ONLY host-base profiles; isolated/vanilla must not pull them back in.
213
246
  const relayPluginDirs = relayPlugin ? [CLAUDE_RELAY_PLUGIN_DIR] : [];
214
247
  const provisionedPluginDirs = provisionedPlugins.map((p) => p.dir);
215
- const hostPluginDirs = profileUsesHostProviderGlobals(config) ? providerConfig.pluginDirs : [];
248
+ const hostPluginDirs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.pluginDirs : [];
216
249
  const pluginDirs = [...new Set([...relayPluginDirs, ...provisionedPluginDirs, ...hostPluginDirs])];
217
250
 
218
251
  const provisionedMcp = profileProvisioning(config).mcpServers;
@@ -265,7 +298,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
265
298
  // still excluded) when the launch both uses the host config home AND its mcp mode is host.
266
299
  // An isolated/minimal base has its own config home and never had the real host's user MCP,
267
300
  // so it stays relay+provisioned only even if a profile sets mode:"host".
268
- const usesHostMcp = profileUsesHostProviderGlobals(config)
301
+ const usesHostMcp = profileUsesClaudeHostProviderGlobals(config)
269
302
  && (!config.agentProfile || config.agentProfile.mcp.mode === "host");
270
303
  const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
271
304
  const hostMcpServers = Object.keys(hostMcp).filter(
@@ -277,7 +310,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
277
310
  // Launch order matches the historical claude buildSpawnArgs block exactly so existing
278
311
  // arg assertions hold: vanilla scope → plugin dirs → MCP → status-line → append.
279
312
  const args = [
280
- ...(vanilla ? ["--setting-sources", "user"] : []),
313
+ ...(vanilla || projectConfigProjection ? ["--setting-sources", "user"] : []),
281
314
  ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
282
315
  ...claudeMcpLaunchArgs({
283
316
  relayUrl: config.relayUrl,
@@ -287,7 +320,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
287
320
  hostServers: hostMcp,
288
321
  strict: strictMcp,
289
322
  }),
290
- ...(statusLine ? sessionStatusLineSettingsArgs(hostDefaultArgs, config.providerArgs) : []),
323
+ ...claudeSettingsArgs(projectSettings.settings, statusLine, hostDefaultArgs, config.providerArgs),
291
324
  ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
292
325
  ];
293
326
 
@@ -302,6 +335,13 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
302
335
  relayPlugin,
303
336
  statusLine,
304
337
  skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
338
+ projectSkills: projectSkills.map((s) => ({ name: s.name, dir: s.dir })),
339
+ projectSettings: {
340
+ applied: projectSettings.applied,
341
+ keys: projectSettings.keys,
342
+ ignoredKeys: projectSettings.ignoredKeys,
343
+ ...(projectSettings.file ? { file: projectSettings.file } : {}),
344
+ },
305
345
  plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
306
346
  mcp: {
307
347
  relayEndpoint, strict: strictMcp, servers: mcpServers, projectServers, hostServers: hostMcpServers,
@@ -371,6 +411,8 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
371
411
  relayPlugin: false,
372
412
  statusLine: false,
373
413
  skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
414
+ projectSkills: [],
415
+ projectSettings: { applied: false, keys: [], ignoredKeys: [] },
374
416
  plugins: [],
375
417
  mcp: {
376
418
  relayEndpoint, strict: false, servers: mcpServers, projectServers, hostServers: [],
@@ -389,7 +431,7 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
389
431
  */
390
432
  export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
391
433
  if (provider === "codex") return assembleCodex(config, providerConfig);
392
- const hostDefaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
434
+ const hostDefaultArgs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
393
435
  return assembleClaude(config, providerConfig, hostDefaultArgs);
394
436
  }
395
437
 
@@ -403,7 +445,9 @@ export function materializeLaunchAssembly(provider: SpawnProvider, config: Runne
403
445
  const configHome = providerHomePathFor(provider, config);
404
446
  if (!configHome) return;
405
447
  if (provider === "claude") {
406
- materializeResolvedAssets(resolveClaudeProvisionedSkills(configHome, config));
448
+ const skills = resolveClaudeProvisionedSkills(configHome, config);
449
+ materializeResolvedAssets(skills);
450
+ materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set(skills.map((s) => s.name))));
407
451
  materializeResolvedAssets(resolveClaudeProvisionedPlugins(configHome, config));
408
452
  return;
409
453
  }
@@ -0,0 +1,202 @@
1
+ import { errMessage } from "agent-relay-sdk";
2
+ import type { ProviderStatusEvent, SemanticStatus } from "./adapter";
3
+ import { nativeSelfResumeTimeoutMsFromEnv } from "./config";
4
+
5
+ interface RunnerTimelineEvent {
6
+ status: string;
7
+ id?: string;
8
+ timestamp: number;
9
+ title?: string;
10
+ body?: string;
11
+ icon?: string;
12
+ metadata?: Record<string, unknown>;
13
+ }
14
+
15
+ export interface NativeSelfResumeHandoff {
16
+ commandId: string;
17
+ generation?: number;
18
+ compactedAt?: number;
19
+ timer: ReturnType<typeof setTimeout>;
20
+ resolve: (result: Record<string, unknown>) => void;
21
+ reject: (error: Error) => void;
22
+ promise: Promise<Record<string, unknown>>;
23
+ }
24
+
25
+ export function isNativeSelfResumeCompact(type: string, params: Record<string, unknown>): boolean {
26
+ if (type !== "agent.compact") return false;
27
+ const selfResume = params.selfResume;
28
+ return Boolean(selfResume && typeof selfResume === "object" && !Array.isArray(selfResume) && (selfResume as Record<string, unknown>).mode === "native");
29
+ }
30
+
31
+ export class NativeSelfResumeTracker {
32
+ private current?: NativeSelfResumeHandoff;
33
+
34
+ get active(): NativeSelfResumeHandoff | undefined {
35
+ return this.current;
36
+ }
37
+
38
+ begin(commandId: string, params: Record<string, unknown>): NativeSelfResumeHandoff {
39
+ this.finish(this.current);
40
+ let resolve!: (result: Record<string, unknown>) => void;
41
+ let reject!: (error: Error) => void;
42
+ const promise = new Promise<Record<string, unknown>>((res, rej) => {
43
+ resolve = res;
44
+ reject = rej;
45
+ });
46
+ const selfResume = params.selfResume && typeof params.selfResume === "object" && !Array.isArray(params.selfResume)
47
+ ? params.selfResume as Record<string, unknown>
48
+ : undefined;
49
+ const generation = typeof selfResume?.generation === "number" ? selfResume.generation : undefined;
50
+ const timeoutMs = nativeSelfResumeTimeoutMsFromEnv();
51
+ const handoff: NativeSelfResumeHandoff = {
52
+ commandId,
53
+ generation,
54
+ promise,
55
+ resolve,
56
+ reject,
57
+ timer: setTimeout(() => {
58
+ if (this.current !== handoff) return;
59
+ this.current = undefined;
60
+ reject(new Error(`native self-resume timed out after ${Math.round(timeoutMs / 1000)}s`));
61
+ }, timeoutMs),
62
+ };
63
+ this.current = handoff;
64
+ return handoff;
65
+ }
66
+
67
+ finish(handoff?: NativeSelfResumeHandoff): void {
68
+ if (!handoff) return;
69
+ clearTimeout(handoff.timer);
70
+ if (this.current === handoff) this.current = undefined;
71
+ }
72
+
73
+ resolve(handoff: NativeSelfResumeHandoff, result: Record<string, unknown>): void {
74
+ this.finish(handoff);
75
+ handoff.resolve(result);
76
+ }
77
+
78
+ reject(handoff: NativeSelfResumeHandoff, error: Error): void {
79
+ this.finish(handoff);
80
+ handoff.reject(error);
81
+ }
82
+
83
+ noteCompacted(timestamp: number = Date.now()): void {
84
+ if (!this.current) return;
85
+ this.current.compactedAt = timestamp;
86
+ }
87
+ }
88
+
89
+ interface NativeSelfResumeRecoveryOptions {
90
+ handoff: NativeSelfResumeHandoff;
91
+ tracker: NativeSelfResumeTracker;
92
+ providerSessionId: string;
93
+ status: SemanticStatus;
94
+ diagnostics: Record<string, unknown>;
95
+ now: number;
96
+ publishTimeline(event: RunnerTimelineEvent): void;
97
+ setTerminalProviderExit(marker: Record<string, unknown>): void;
98
+ setProviderStatus(update: ProviderStatusEvent): void;
99
+ restartProvider(resumeId?: string): Promise<void>;
100
+ publishStatus(): void;
101
+ scheduleDrain(): void;
102
+ }
103
+
104
+ export async function recoverNativeSelfResumeExit(opts: NativeSelfResumeRecoveryOptions): Promise<void> {
105
+ const resumeId = typeof opts.diagnostics.claudeResumeId === "string" && opts.diagnostics.claudeResumeId.length > 0
106
+ ? opts.diagnostics.claudeResumeId
107
+ : undefined;
108
+ if (opts.handoff.compactedAt && resumeId) {
109
+ await recoverWithClaudeResume(opts, resumeId);
110
+ return;
111
+ }
112
+ await recoverWithFreshLaunch(opts, resumeId);
113
+ }
114
+
115
+ async function recoverWithClaudeResume(opts: NativeSelfResumeRecoveryOptions, resumeId: string): Promise<void> {
116
+ opts.publishTimeline({
117
+ status: "provider.restart_decision",
118
+ id: `provider-self-resume-relaunch-${opts.providerSessionId}-${opts.now}`,
119
+ timestamp: Date.now(),
120
+ title: "Self-resume relaunching",
121
+ body: `Claude exited during native self-resume; runner is relaunching with claude --resume ${resumeId}.`,
122
+ icon: "ti-refresh",
123
+ metadata: selfResumeMetadata(opts.handoff, opts.diagnostics, {
124
+ decision: "resume-native-self-resume",
125
+ reason: "self-resume-exit-after-compact",
126
+ claudeResumeId: resumeId,
127
+ compactedAt: opts.handoff.compactedAt,
128
+ }),
129
+ });
130
+ try {
131
+ await opts.restartProvider(resumeId);
132
+ opts.publishStatus();
133
+ opts.scheduleDrain();
134
+ opts.tracker.resolve(opts.handoff, { recovered: true, via: "claude-resume", claudeResumeId: resumeId });
135
+ } catch (error) {
136
+ const message = errMessage(error);
137
+ opts.setTerminalProviderExit(selfResumeMetadata(opts.handoff, opts.diagnostics, {
138
+ reason: "self-resume-relaunch-failed",
139
+ claudeResumeId: resumeId,
140
+ compactedAt: opts.handoff.compactedAt,
141
+ lastError: message,
142
+ }));
143
+ opts.setProviderStatus({
144
+ status: "offline",
145
+ reason: "provider-turn",
146
+ id: `provider-self-resume-relaunch-failed-${opts.providerSessionId}`,
147
+ clear: ["provider-turn", "subagent", "background-script"],
148
+ });
149
+ opts.tracker.reject(opts.handoff, new Error(`self-resume relaunch failed: ${message}`));
150
+ }
151
+ }
152
+
153
+ async function recoverWithFreshLaunch(opts: NativeSelfResumeRecoveryOptions, resumeId?: string): Promise<void> {
154
+ opts.publishTimeline({
155
+ status: "provider.restart_decision",
156
+ id: `provider-self-resume-fresh-launch-${opts.providerSessionId}-${opts.now}`,
157
+ timestamp: Date.now(),
158
+ title: "Self-resume fresh-launching",
159
+ body: "Claude exited during native self-resume before a compacted marker; runner is fresh-launching so Relay can inject the persisted continuation.",
160
+ icon: "ti-refresh",
161
+ metadata: selfResumeMetadata(opts.handoff, opts.diagnostics, {
162
+ decision: "fresh-launch-native-self-resume",
163
+ reason: "self-resume-exit-before-compact",
164
+ ...(resumeId ? { claudeResumeId: resumeId } : {}),
165
+ }),
166
+ });
167
+ try {
168
+ await opts.restartProvider();
169
+ opts.publishStatus();
170
+ opts.scheduleDrain();
171
+ opts.tracker.resolve(opts.handoff, {
172
+ recovered: true,
173
+ via: "fresh-launch",
174
+ ...(resumeId ? { claudeResumeId: resumeId } : {}),
175
+ });
176
+ } catch (error) {
177
+ const message = errMessage(error);
178
+ opts.setTerminalProviderExit(selfResumeMetadata(opts.handoff, opts.diagnostics, {
179
+ reason: "self-resume-fresh-launch-failed",
180
+ ...(resumeId ? { claudeResumeId: resumeId } : {}),
181
+ lastError: message,
182
+ }));
183
+ opts.setProviderStatus({
184
+ status: "offline",
185
+ reason: "provider-turn",
186
+ id: `provider-self-resume-fresh-launch-failed-${opts.providerSessionId}`,
187
+ clear: ["provider-turn", "subagent", "background-script"],
188
+ });
189
+ opts.tracker.reject(opts.handoff, new Error(`self-resume fresh launch failed: ${message}`));
190
+ }
191
+ }
192
+
193
+ function selfResumeMetadata(handoff: NativeSelfResumeHandoff, diagnostics: Record<string, unknown>, extra: Record<string, unknown>): Record<string, unknown> {
194
+ return {
195
+ eventType: "provider.restart_decision",
196
+ selfResume: true,
197
+ selfResumeCommandId: handoff.commandId,
198
+ selfResumeGeneration: handoff.generation ?? null,
199
+ ...extra,
200
+ ...diagnostics,
201
+ };
202
+ }
@@ -16,8 +16,18 @@ export function profileUsesHostProviderGlobals(config: { agentProfile?: RunnerSp
16
16
  return !config.agentProfile || config.agentProfile.base === "host";
17
17
  }
18
18
 
19
- function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
20
- return Boolean(config.agentProfile && config.agentProfile.base !== "host");
19
+ export function claudeProjectConfigProjectionOn(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
20
+ return Boolean(config.agentProfile?.projectSkills || config.agentProfile?.projectSettings);
21
+ }
22
+
23
+ export function profileUsesClaudeHostProviderGlobals(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
24
+ return profileUsesHostProviderGlobals(config) && !claudeProjectConfigProjectionOn(config);
25
+ }
26
+
27
+ function profileRequiresIsolatedHome(provider: "claude" | "codex", config: RunnerSpawnConfig): boolean {
28
+ if (!config.agentProfile) return false;
29
+ if (config.agentProfile.base !== "host") return true;
30
+ return provider === "claude" && claudeProjectConfigProjectionOn(config);
21
31
  }
22
32
 
23
33
  // #557 — PURE: the isolated provider config home path a launch WILL use, or undefined
@@ -25,7 +35,7 @@ function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
25
35
  // computes the home this way to report it + build CLAUDE_CONFIG_DIR/skill/plugin paths
26
36
  // without side effects; prepare*ProfileHome creates it at the same path at spawn time.
27
37
  export function providerHomePathFor(provider: "claude" | "codex", config: RunnerSpawnConfig): string | undefined {
28
- if (!profileRequiresIsolatedHome(config)) return undefined;
38
+ if (!profileRequiresIsolatedHome(provider, config)) return undefined;
29
39
  return providerHomePath(provider, config);
30
40
  }
31
41
 
@@ -56,7 +66,7 @@ const CLAUDE_AUTH_ITEMS = [".credentials.json", "statsig"];
56
66
  // instance-keyed home, run the provider-specific first-run bootstrap. The
57
67
  // bootstrap step is the only genuinely provider-specific part.
58
68
  function prepareProviderHome(provider: "claude" | "codex", config: RunnerSpawnConfig): ProviderHome | undefined {
59
- if (!profileRequiresIsolatedHome(config)) return undefined;
69
+ if (!profileRequiresIsolatedHome(provider, config)) return undefined;
60
70
  const target = providerHomePath(provider, config);
61
71
  mkdirSync(target, { recursive: true });
62
72
  const authLinked = provider === "codex"
@@ -34,6 +34,8 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
34
34
  add(provisioningPluginsEntry(assembled));
35
35
  add(provisioningMcpEntry(assembled));
36
36
  add(projectMcpEntry(assembled));
37
+ add(projectSkillsEntry(assembled));
38
+ add(projectSettingsEntry(assembled));
37
39
  add(sharedMcpEntry(assembled));
38
40
  if (profile) add(filesystemEntry(profile));
39
41
  add(configHomeEntry(assembled));
@@ -159,14 +161,30 @@ function projectMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
159
161
  : `Project .mcp.json servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
160
162
  }
161
163
 
162
- // #672 shared host-listener MCP provenance. `not-applicable` when the profile/spawn didn't
163
- // opt in (the default); `applied` reports the per-agent `callmux bridge` routing the agent's
164
- // non-identity MCP (tokenlean, github) through the shared listener it points at. The server SET
165
- // is owned by the listener config, so this surfaces the route + URL, not an enumerated list.
164
+ function projectSkillsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
165
+ const optedIn = Boolean(a.profile?.projectSkills);
166
+ if (!optedIn) return notApplicable("projectSkills", "off", "Project .claude/skills projection is off (opt in via the projectSkills profile/spawn option).");
167
+ if (a.provider !== "claude") return notApplicable("projectSkills", "unsupported", "Project .claude/skills projection applies to Claude launches only.");
168
+ if (!a.projectSkills.length) return applied("projectSkills", "none", "Project .claude/skills projection is on, but the spawn cwd declared no valid skill dirs.");
169
+ const names = a.projectSkills.map((s) => s.name).join(", ");
170
+ return applied("projectSkills", `${a.projectSkills.length}`, `Project .claude skills materialized into Relay's managed Claude config home (${names}); host/global skills are excluded.`);
171
+ }
172
+
173
+ function projectSettingsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
174
+ const optedIn = Boolean(a.profile?.projectSettings);
175
+ if (!optedIn) return notApplicable("projectSettings", "off", "Project .claude/settings.json projection is off (opt in via the projectSettings profile/spawn option).");
176
+ if (a.provider !== "claude") return notApplicable("projectSettings", "unsupported", "Project .claude/settings.json projection applies to Claude launches only.");
177
+ const ignored = a.projectSettings.ignoredKeys.length ? `; ignored security keys: ${a.projectSettings.ignoredKeys.join(", ")}` : "";
178
+ return applied("projectSettings", a.projectSettings.keys.length ? `${a.projectSettings.keys.length} keys` : "none", `Project .claude/settings.json was sanitized and injected through --settings; Relay approvalMode/permission gates remain authoritative${ignored}.`);
179
+ }
180
+
181
+ // #672/#689 — shared host-listener MCP provenance. `applied` reports the per-agent `callmux
182
+ // bridge` route. `not-applicable` means the resolved profile kept shared MCP off (for example a
183
+ // zero-host profile, explicit false, or global kill-switch normalization before launch).
166
184
  function sharedMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
167
185
  const optedIn = Boolean(a.profile?.sharedMcp);
168
186
  const listener = a.mcp.sharedListener;
169
- if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off (opt in via the sharedMcp profile/spawn option); non-identity MCP is injected per-agent.");
187
+ if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off for this resolved profile; non-identity MCP is injected per-agent.");
170
188
  return applied("sharedMcp", "on", a.provider === "claude"
171
189
  ? `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"', injected into --mcp-config (survives --strict-mcp-config); session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`
172
190
  : `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"' emitted as -c mcp_servers.* overrides; session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`);
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
4
  import type { ProvisioningAssetFile, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
@@ -74,6 +74,88 @@ export function resolveProjectMcpServers(
74
74
  return out;
75
75
  }
76
76
 
77
+ export function resolveClaudeProjectSkills(
78
+ claudeHome: string,
79
+ config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
80
+ reservedNames: ReadonlySet<string> = new Set(),
81
+ ): ResolvedAsset[] {
82
+ if (!config.agentProfile?.projectSkills) return [];
83
+ const skillsRoot = join(config.cwd, ".claude", "skills");
84
+ if (!existsSync(skillsRoot)) return [];
85
+ const targetRoot = join(claudeHome, "skills");
86
+ const resolved: ResolvedAsset[] = [];
87
+ for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
88
+ if (!entry.isDirectory() || reservedNames.has(entry.name)) continue;
89
+ const source = join(skillsRoot, entry.name);
90
+ if (!existsSync(join(source, "SKILL.md"))) continue;
91
+ resolved.push({
92
+ name: entry.name,
93
+ dir: join(targetRoot, assetDirName(entry.name)),
94
+ source: { kind: "link", source },
95
+ });
96
+ }
97
+ return resolved.sort((a, b) => a.name.localeCompare(b.name));
98
+ }
99
+
100
+ const UNSAFE_PROJECT_CLAUDE_SETTINGS = new Set([
101
+ "apiKeyHelper",
102
+ "allowDangerouslySkipPermissions",
103
+ "allowedTools",
104
+ "disabledMcpjsonServers",
105
+ "disallowedTools",
106
+ "enableAllProjectMcpServers",
107
+ "enabledMcpjsonServers",
108
+ "env",
109
+ "hooks",
110
+ "mcpServers",
111
+ "permissionMode",
112
+ "permissions",
113
+ "statusLine",
114
+ "tools",
115
+ ]);
116
+
117
+ export interface ResolvedProjectClaudeSettings {
118
+ applied: boolean;
119
+ settings: Record<string, unknown>;
120
+ keys: string[];
121
+ ignoredKeys: string[];
122
+ file?: string;
123
+ }
124
+
125
+ export function resolveProjectClaudeSettings(
126
+ config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
127
+ ): ResolvedProjectClaudeSettings {
128
+ if (!config.agentProfile?.projectSettings) return { applied: false, settings: {}, keys: [], ignoredKeys: [] };
129
+ const file = join(config.cwd, ".claude", "settings.json");
130
+ if (!existsSync(file)) return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
131
+ let parsed: unknown;
132
+ try {
133
+ parsed = JSON.parse(readFileSync(file, "utf8"));
134
+ } catch (err) {
135
+ console.warn(`project .claude/settings.json projection: failed to parse ${file}: ${(err as Error).message}`);
136
+ return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
137
+ }
138
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
139
+ return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
140
+ }
141
+ const settings: Record<string, unknown> = {};
142
+ const ignoredKeys: string[] = [];
143
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
144
+ if (UNSAFE_PROJECT_CLAUDE_SETTINGS.has(key)) {
145
+ ignoredKeys.push(key);
146
+ continue;
147
+ }
148
+ settings[key] = value;
149
+ }
150
+ return {
151
+ applied: true,
152
+ settings,
153
+ keys: Object.keys(settings).sort(),
154
+ ignoredKeys: ignoredKeys.sort(),
155
+ file,
156
+ };
157
+ }
158
+
77
159
  // A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
78
160
  // (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
79
161
  // source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
@@ -39,7 +39,9 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
39
39
  if (!supportsLaunchAssembly(input.provider)) return events;
40
40
  const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
41
41
  const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
42
+ + assembled.projectSkills.length
42
43
  + assembled.mcp.projectServers.length
44
+ + (assembled.projectSettings.applied ? 1 : 0)
43
45
  + (assembled.relaySkills ? 1 : 0)
44
46
  + (assembled.relayPlugin ? 1 : 0)
45
47
  + (assembled.mcp.relayEndpoint ? 1 : 0)
@@ -51,6 +53,8 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
51
53
  assembled.statusLine ? "status line" : "",
52
54
  assembled.mcp.relayEndpoint ? "relay MCP" : "",
53
55
  assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
56
+ assembled.projectSkills.length ? `${assembled.projectSkills.length} project skill${assembled.projectSkills.length === 1 ? "" : "s"}` : "",
57
+ assembled.projectSettings.applied ? "project settings" : "",
54
58
  assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
55
59
  assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
56
60
  assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
@@ -70,6 +74,8 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
70
74
  relayPlugin: assembled.relayPlugin,
71
75
  statusLine: assembled.statusLine,
72
76
  skills: assembled.skills,
77
+ projectSkills: assembled.projectSkills,
78
+ projectSettings: assembled.projectSettings,
73
79
  plugins: assembled.plugins,
74
80
  mcp: assembled.mcp,
75
81
  },
@@ -16,7 +16,7 @@ import { startControlServer, type ControlServer } from "./control-server";
16
16
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
17
17
  import { Outbox, type OutboxRecord } from "./outbox";
18
18
  import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
19
- import { profileUsesHostProviderGlobals } from "./profile-home";
19
+ import { profileUsesClaudeHostProviderGlobals, profileUsesHostProviderGlobals } from "./profile-home";
20
20
  import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
21
21
  import { RelayMcpProxy } from "./relay-mcp-proxy";
22
22
  import { runtimeMetadata } from "./version";
@@ -30,6 +30,7 @@ import { runnerLaunchInjectionSessionEvents, type RunnerRelayInjectionEvent } fr
30
30
  import { providerTerminalSession, providerTerminalSocket } from "./process-meta";
31
31
  import { capsFromEnv, logLevelFromEnv, mcpProxyEnabledFromEnv, orchestratorBaseDirFromEnv, orchestratorUrlFromEnv, registrationTimeoutMsFromEnv, runnerInfoFileFromEnv, runnerLogFileFromEnv, runnerOutboxDirWithInfoFallback, sessionDebugEnabled, tagsFromEnv, workspaceModeFromEnv } from "./config";
32
32
  import { boundaryReasonForCommand, PRE_DESTROY_TIMEOUT_MS, reasonExitsRunner, type LifecycleAction, type SessionDestroyReason } from "./session-destroy";
33
+ import { isNativeSelfResumeCompact, NativeSelfResumeTracker, recoverNativeSelfResumeExit } from "./native-self-resume";
33
34
  import {
34
35
  appliedAgentProfileMetadata,
35
36
  commandTimeoutMs,
@@ -38,8 +39,8 @@ import {
38
39
  isContextState,
39
40
  isHttpAuthError,
40
41
  isHttpStatusError,
41
- latestClaudeResumeIdFromLogFile,
42
42
  lifecycleCapabilities,
43
+ providerExitDiagnostics,
43
44
  providerStateFromActiveWork,
44
45
  registerWithinDeadline,
45
46
  relayBusUrl,
@@ -198,6 +199,7 @@ export class AgentRunner {
198
199
  // terminal `agent.exited` event with a #636 diagnosis instead of the death going silent. Cleared
199
200
  // on any genuine restart back to busy/idle.
200
201
  private terminalProviderExit?: Record<string, unknown>;
202
+ private nativeSelfResume = new NativeSelfResumeTracker();
201
203
  private exitCommandInProgress = false;
202
204
  private restartInProgress = false;
203
205
  // Set for the whole unexpected-exit restart, including the pre-restart backoff
@@ -521,10 +523,10 @@ export class AgentRunner {
521
523
  return mode === "isolated";
522
524
  }
523
525
 
524
- private async spawnProvider(): Promise<ManagedProcess> {
526
+ private async spawnProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<ManagedProcess> {
525
527
  this.providerSessionId = crypto.randomUUID();
526
528
  this.lastTranscriptPath = undefined;
527
- const includeProviderGlobals = profileUsesHostProviderGlobals(this.options);
529
+ const includeProviderGlobals = this.options.provider === "claude" ? profileUsesClaudeHostProviderGlobals(this.options) : profileUsesHostProviderGlobals(this.options);
528
530
  const env = {
529
531
  ...process.env as Record<string, string>,
530
532
  ...(includeProviderGlobals ? this.options.providerConfig.env : {}),
@@ -571,10 +573,10 @@ export class AgentRunner {
571
573
  ...(this.options.rig ? { rig: this.options.rig } : {}),
572
574
  ...(this.options.profile ? { profile: this.options.profile } : {}),
573
575
  ...(this.options.agentProfile ? { agentProfile: this.options.agentProfile } : {}),
574
- ...(this.options.prompt ? { prompt: this.options.prompt } : {}),
576
+ ...(this.options.prompt && !opts.resumeId && !opts.suppressPrompt ? { prompt: this.options.prompt } : {}),
575
577
  ...(this.options.systemPromptAppend ? { systemPromptAppend: this.options.systemPromptAppend } : {}),
576
578
  ...(this.options.tmuxSession ? { tmuxSession: this.options.tmuxSession } : {}),
577
- providerArgs: this.options.providerArgs,
579
+ providerArgs: opts.resumeId ? [...this.options.providerArgs, "--resume", opts.resumeId] : this.options.providerArgs,
578
580
  providerConfig: this.options.providerConfig,
579
581
  env,
580
582
  controlPort: this.control!.port,
@@ -588,7 +590,7 @@ export class AgentRunner {
588
590
  deliver: (messages) => this.control!.deliverToMonitor(messages),
589
591
  },
590
592
  };
591
- const launchSeededPrompt = this.options.adapter.seedsInitialPromptAtLaunch ? this.options.prompt?.trim() : ""; if (launchSeededPrompt) this.recordInjectedPrompt(launchSeededPrompt);
593
+ const launchSeededPrompt = this.options.adapter.seedsInitialPromptAtLaunch && !opts.resumeId && !opts.suppressPrompt ? this.options.prompt?.trim() : ""; if (launchSeededPrompt) this.recordInjectedPrompt(launchSeededPrompt);
592
594
  const managedProcess = await this.options.adapter.spawn(config);
593
595
  const injectionEvents = runnerLaunchInjectionSessionEvents({ carried: this.options.relayInjectionEvents, provider: this.options.provider, config, providerConfig: this.options.providerConfig, agentId: this.agentId, providerSessionId: this.providerSessionId });
594
596
  for (const event of injectionEvents) {
@@ -744,6 +746,9 @@ export class AgentRunner {
744
746
 
745
747
  const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
746
748
  if (exitAfterCommand) this.exitCommandInProgress = true;
749
+ const nativeSelfResume = isNativeSelfResumeCompact(type, params)
750
+ ? this.nativeSelfResume.begin(commandId, params)
751
+ : undefined;
747
752
  this.claims.startClaim("command", commandId);
748
753
  try {
749
754
  await this.updateCommand(commandId, "accepted");
@@ -767,6 +772,10 @@ export class AgentRunner {
767
772
  providerResult = await this.options.adapter.compact(this.process, {
768
773
  instructions: typeof params.instructions === "string" ? params.instructions : undefined,
769
774
  });
775
+ if (nativeSelfResume) {
776
+ const handoff = await nativeSelfResume.promise;
777
+ providerResult = { ...(providerResult ?? {}), nativeSelfResume: handoff };
778
+ }
770
779
  } else if (type === "agent.clearContext") {
771
780
  if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
772
781
  providerResult = await this.options.adapter.clearContext(this.process);
@@ -793,6 +802,7 @@ export class AgentRunner {
793
802
  ...(providerResult ? { providerResult } : {}),
794
803
  });
795
804
  } catch (error) {
805
+ if (nativeSelfResume) this.nativeSelfResume.finish(nativeSelfResume);
796
806
  await this.updateCommand(commandId, "failed", undefined, errMessage(error)).catch(() => {});
797
807
  } finally {
798
808
  this.claims.finishClaim("command", commandId);
@@ -808,6 +818,7 @@ export class AgentRunner {
808
818
  }).finally(() => process.exit(0)), 10);
809
819
  }
810
820
  } else if (!this.stopped) {
821
+ if (nativeSelfResume) this.nativeSelfResume.finish(nativeSelfResume);
811
822
  this.lifecycleAction = undefined;
812
823
  this.publishStatus();
813
824
  }
@@ -904,7 +915,7 @@ export class AgentRunner {
904
915
  return attachmentText ? `${body}\n\n${attachmentText}` : body;
905
916
  }
906
917
 
907
- private async restartProvider(): Promise<void> {
918
+ private async restartProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<void> {
908
919
  this.restartInProgress = true;
909
920
  try {
910
921
  if (this.process) {
@@ -918,7 +929,7 @@ export class AgentRunner {
918
929
  this.claims.clearWorkKind("subagent");
919
930
  this.claims.clearWorkKind("background-script");
920
931
  if (this.stopped) return;
921
- this.process = await this.spawnProvider();
932
+ this.process = await this.spawnProvider(opts);
922
933
  this.processStartedAt = Date.now();
923
934
  } finally {
924
935
  this.restartInProgress = false;
@@ -956,7 +967,7 @@ export class AgentRunner {
956
967
  const recent = this.unexpectedExitTimes.filter((time) => now - time <= UNEXPECTED_EXIT_WINDOW_MS);
957
968
  recent.push(now);
958
969
  this.unexpectedExitTimes.splice(0, this.unexpectedExitTimes.length, ...recent);
959
- const diagnostics = this.providerExitDiagnostics(status, runtimeMs);
970
+ const diagnostics = providerExitDiagnostics({ status, runtimeMs, processMeta: this.process?.meta, hasProcess: Boolean(this.process?.process), exitCommandInProgress: this.exitCommandInProgress, stopped: this.stopped, restartInProgress: this.restartInProgress, restartPending: this.restartPending, headless: this.options.headless, provider: this.options.provider, logFile: runnerLogFileFromEnv() });
960
971
 
961
972
  this.publishRunnerTimelineEvent({
962
973
  status: "provider.exit_detected",
@@ -971,6 +982,12 @@ export class AgentRunner {
971
982
  },
972
983
  });
973
984
 
985
+ const nativeSelfResume = this.nativeSelfResume.active;
986
+ if (nativeSelfResume && this.options.provider === "claude") {
987
+ await recoverNativeSelfResumeExit({ handoff: nativeSelfResume, tracker: this.nativeSelfResume, providerSessionId: this.providerSessionId, status, diagnostics, now, publishTimeline: (event) => this.publishRunnerTimelineEvent(event), setTerminalProviderExit: (marker) => { this.terminalProviderExit = marker; }, setProviderStatus: (update) => this.setProviderStatus(update), restartProvider: (resumeId) => this.restartProvider(resumeId ? { resumeId } : { suppressPrompt: true }), publishStatus: () => this.publishStatus(), scheduleDrain: () => this.scheduleDrain() });
988
+ return;
989
+ }
990
+
974
991
  if (this.shouldStopUnexpectedProviderExit(diagnostics)) {
975
992
  const hasResumeId = typeof diagnostics.claudeResumeId === "string" && diagnostics.claudeResumeId.length > 0;
976
993
  logger.warn("lifecycle", `${this.options.provider} exited; leaving agent offline for manual recovery`);
@@ -1104,28 +1121,6 @@ export class AgentRunner {
1104
1121
  this.publishStatus();
1105
1122
  }
1106
1123
 
1107
- private providerExitDiagnostics(status: SemanticStatus, runtimeMs: number): Record<string, unknown> {
1108
- const tmuxSession = typeof this.process?.meta?.tmuxSession === "string" ? this.process.meta.tmuxSession : undefined;
1109
- const tmuxSocket = typeof this.process?.meta?.tmuxSocket === "string" ? this.process.meta.tmuxSocket : undefined;
1110
- const exitSource = tmuxSession ? "tmux-session-ended" : this.process?.process ? "process-exit" : "provider-status";
1111
- const logFile = runnerLogFileFromEnv();
1112
- const claudeResumeId = this.options.provider === "claude" && logFile ? latestClaudeResumeIdFromLogFile(logFile) : undefined;
1113
- return {
1114
- status,
1115
- runtimeMs: Number.isFinite(runtimeMs) ? runtimeMs : null,
1116
- exitSource,
1117
- exitCommandInProgress: this.exitCommandInProgress,
1118
- stopped: this.stopped,
1119
- restartInProgress: this.restartInProgress,
1120
- restartPending: this.restartPending,
1121
- headless: this.options.headless,
1122
- hasTerminalSession: Boolean(tmuxSession),
1123
- tmuxSession: tmuxSession ?? null,
1124
- tmuxSocket: tmuxSocket ?? null,
1125
- claudeResumeId: claudeResumeId ?? null,
1126
- };
1127
- }
1128
-
1129
1124
  private async updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<void> {
1130
1125
  await this.bus.updateCommand(commandId, { status, ...(result ? { result } : {}), ...(error ? { error } : {}) });
1131
1126
  }
@@ -1222,6 +1217,7 @@ export class AgentRunner {
1222
1217
  if (timelineStatus === "compacting") {
1223
1218
  this.compactionMidTurn = this.currentTurnId !== undefined;
1224
1219
  } else if (timelineStatus === "compacted") {
1220
+ this.nativeSelfResume.noteCompacted(typeof update !== "string" && typeof update.timeline?.timestamp === "number" ? update.timeline.timestamp : Date.now());
1225
1221
  this.publishCompactionNotice();
1226
1222
  if (this.compactionMidTurn) {
1227
1223
  // Keep the turn (and its live reasoning tail) alive; the genuine Stop hook ends it.
@@ -1304,6 +1300,9 @@ export class AgentRunner {
1304
1300
  this.rateLimitHold = hold;
1305
1301
  if (fresh) this.publishRateLimitNotice(hold);
1306
1302
  }
1303
+ if (status === "idle" && this.nativeSelfResume.active) {
1304
+ this.nativeSelfResume.resolve(this.nativeSelfResume.active, { resumed: true, via: "provider-idle" });
1305
+ }
1307
1306
  this.publishStatus();
1308
1307
  }
1309
1308
 
@@ -157,6 +157,39 @@ export function latestClaudeResumeIdFromLogFile(path: string): string | undefine
157
157
  }
158
158
  }
159
159
 
160
+ export function providerExitDiagnostics(input: {
161
+ status: SemanticStatus;
162
+ runtimeMs: number;
163
+ processMeta?: Record<string, unknown>;
164
+ hasProcess: boolean;
165
+ exitCommandInProgress: boolean;
166
+ stopped: boolean;
167
+ restartInProgress: boolean;
168
+ restartPending: boolean;
169
+ headless: boolean;
170
+ provider: string;
171
+ logFile?: string;
172
+ }): Record<string, unknown> {
173
+ const tmuxSession = typeof input.processMeta?.tmuxSession === "string" ? input.processMeta.tmuxSession : undefined;
174
+ const tmuxSocket = typeof input.processMeta?.tmuxSocket === "string" ? input.processMeta.tmuxSocket : undefined;
175
+ const exitSource = tmuxSession ? "tmux-session-ended" : input.hasProcess ? "process-exit" : "provider-status";
176
+ const claudeResumeId = input.provider === "claude" && input.logFile ? latestClaudeResumeIdFromLogFile(input.logFile) : undefined;
177
+ return {
178
+ status: input.status,
179
+ runtimeMs: Number.isFinite(input.runtimeMs) ? input.runtimeMs : null,
180
+ exitSource,
181
+ exitCommandInProgress: input.exitCommandInProgress,
182
+ stopped: input.stopped,
183
+ restartInProgress: input.restartInProgress,
184
+ restartPending: input.restartPending,
185
+ headless: input.headless,
186
+ hasTerminalSession: Boolean(tmuxSession),
187
+ tmuxSession: tmuxSession ?? null,
188
+ tmuxSocket: tmuxSocket ?? null,
189
+ claudeResumeId: claudeResumeId ?? null,
190
+ };
191
+ }
192
+
160
193
  export function commandTimeoutMs(params: Record<string, unknown>, fallback = 10_000): number {
161
194
  const raw = params.timeoutMs;
162
195
  if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return fallback;