@humwick/runner 0.130.1 → 0.130.3

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.
Files changed (84) hide show
  1. package/package.json +12 -16
  2. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  3. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +31 -1
  4. package/src/adapters/claude-effort.ts +2 -4
  5. package/src/adapters/claude-headless.ts +1 -1
  6. package/src/adapters/claude-launch-exit.ts +26 -0
  7. package/src/adapters/claude-session-probe.ts +1 -1
  8. package/src/adapters/claude-tmux.ts +38 -10
  9. package/src/adapters/claude.ts +28 -75
  10. package/src/adapters/codex-launch-config.ts +4 -5
  11. package/src/adapters/codex.ts +1 -1
  12. package/src/adapters/opencode-provider.ts +7 -22
  13. package/src/adapters/opencode.ts +4 -4
  14. package/src/adapters/provider-quota-harvest.ts +20 -71
  15. package/src/agent-runner/credentials.ts +42 -12
  16. package/src/agent-runner/inbox.ts +27 -0
  17. package/src/agent-runner/lifecycle-milestones.ts +2 -4
  18. package/src/agent-runner/liveness-exchange.ts +104 -0
  19. package/src/bin/claude.ts +4 -0
  20. package/src/bin/codex.ts +4 -0
  21. package/src/bin/opencode.ts +4 -0
  22. package/src/claude-system-prompt.ts +49 -0
  23. package/src/index.ts +5 -2
  24. package/src/launch-assembly.ts +14 -10
  25. package/src/profile-home.ts +1 -1
  26. package/src/project-instructions.ts +2 -2
  27. package/src/provider-command.ts +18 -0
  28. package/src/provider-setup.ts +35 -0
  29. package/src/providers.ts +2 -1
  30. package/src/runner-core.ts +32 -38
  31. package/src/runner-helpers.ts +0 -8
  32. package/src/session-scratch.ts +6 -2
  33. package/src/spawn-input.ts +2 -1
  34. package/src/test-run-dedup.ts +59 -56
  35. package/node_modules/@humwick/offload/LICENSE +0 -167
  36. package/node_modules/@humwick/offload/README.md +0 -57
  37. package/node_modules/@humwick/offload/dist/async-runner.d.ts +0 -18
  38. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +0 -1
  39. package/node_modules/@humwick/offload/dist/async-runner.js +0 -158
  40. package/node_modules/@humwick/offload/dist/async-runner.js.map +0 -1
  41. package/node_modules/@humwick/offload/dist/envelope.d.ts +0 -25
  42. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +0 -1
  43. package/node_modules/@humwick/offload/dist/envelope.js +0 -80
  44. package/node_modules/@humwick/offload/dist/envelope.js.map +0 -1
  45. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +0 -10
  46. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +0 -1
  47. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +0 -59
  48. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +0 -1
  49. package/node_modules/@humwick/offload/dist/index.d.ts +0 -5
  50. package/node_modules/@humwick/offload/dist/index.d.ts.map +0 -1
  51. package/node_modules/@humwick/offload/dist/index.js +0 -5
  52. package/node_modules/@humwick/offload/dist/index.js.map +0 -1
  53. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +0 -62
  54. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +0 -1
  55. package/node_modules/@humwick/offload/dist/subprocess-runner.js +0 -295
  56. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +0 -1
  57. package/node_modules/@humwick/offload/dist/types.d.ts +0 -111
  58. package/node_modules/@humwick/offload/dist/types.d.ts.map +0 -1
  59. package/node_modules/@humwick/offload/dist/types.js +0 -176
  60. package/node_modules/@humwick/offload/dist/types.js.map +0 -1
  61. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +0 -23
  62. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +0 -1
  63. package/node_modules/@humwick/offload/dist/worker-pool.js +0 -435
  64. package/node_modules/@humwick/offload/dist/worker-pool.js.map +0 -1
  65. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +0 -2
  66. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +0 -1
  67. package/node_modules/@humwick/offload/dist/worker-runtime.js +0 -27
  68. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +0 -1
  69. package/node_modules/@humwick/offload/dist/worker.d.ts +0 -5
  70. package/node_modules/@humwick/offload/dist/worker.d.ts.map +0 -1
  71. package/node_modules/@humwick/offload/dist/worker.js +0 -1
  72. package/node_modules/@humwick/offload/dist/worker.js.map +0 -1
  73. package/node_modules/@humwick/offload/package.json +0 -42
  74. package/node_modules/@humwick/offload/src/async-runner.ts +0 -170
  75. package/node_modules/@humwick/offload/src/envelope.ts +0 -92
  76. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +0 -49
  77. package/node_modules/@humwick/offload/src/index.ts +0 -4
  78. package/node_modules/@humwick/offload/src/subprocess-runner.ts +0 -334
  79. package/node_modules/@humwick/offload/src/types.ts +0 -235
  80. package/node_modules/@humwick/offload/src/worker-pool.ts +0 -463
  81. package/node_modules/@humwick/offload/src/worker-runtime.ts +0 -32
  82. package/node_modules/@humwick/offload/src/worker.ts +0 -5
  83. package/src/agent-runner/http-liveness.ts +0 -85
  84. package/src/gate-run-lease-probe.ts +0 -38
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@humwick/runner",
3
- "version": "0.130.1",
3
+ "version": "0.130.3",
4
4
  "description": "Unified provider lifecycle runner for Humwick",
5
5
  "type": "module",
6
6
  "bin": {
7
- "claude-relay": "src/index.ts",
8
- "codex-relay": "src/index.ts",
9
- "opencode-relay": "src/index.ts",
10
- "claude-humwick": "src/index.ts",
11
- "codex-humwick": "src/index.ts",
12
- "opencode-humwick": "src/index.ts"
7
+ "claude-relay": "src/bin/claude.ts",
8
+ "codex-relay": "src/bin/codex.ts",
9
+ "opencode-relay": "src/bin/opencode.ts",
10
+ "claude-humwick": "src/bin/claude.ts",
11
+ "codex-humwick": "src/bin/codex.ts",
12
+ "opencode-humwick": "src/bin/opencode.ts"
13
13
  },
14
14
  "files": [
15
15
  "src/**/*.ts",
@@ -29,12 +29,11 @@
29
29
  "directory": "runner"
30
30
  },
31
31
  "dependencies": {
32
- "@humwick/offload": "0.0.0",
33
- "@humwick/providers": "0.104.7",
34
- "@humwick/sdk": "0.2.136",
32
+ "@humwick/offload": "0.130.3",
33
+ "@humwick/providers": "0.104.9",
34
+ "@humwick/sdk": "0.2.138",
35
35
  "callmux": "0.25.0",
36
- "unbash": "6.0.0",
37
- "p-queue": "^9.3.3"
36
+ "unbash": "6.0.0"
38
37
  },
39
38
  "devDependencies": {
40
39
  "@types/bun": "1.4.2",
@@ -42,8 +41,5 @@
42
41
  },
43
42
  "engines": {
44
43
  "bun": ">=1.4.4"
45
- },
46
- "bundleDependencies": [
47
- "@humwick/offload"
48
- ]
44
+ }
49
45
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Humwick runner bridge for Claude Code",
4
- "version": "0.130.1",
4
+ "version": "0.130.3",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -260,6 +260,9 @@ function classifyGateRun(command, commands = {}) {
260
260
  return null;
261
261
  }
262
262
 
263
+ // sdk/src/types/host.ts
264
+ var HOST_GATE_KINDS = ["land", "iterate", "validation"];
265
+
263
266
  // sdk/src/land-gates.ts
264
267
  import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve3 } from "path";
265
268
 
@@ -420,7 +423,34 @@ function cleanLandGatesConfigFull(value, worktreePath) {
420
423
  const requirements = cleanLandGateRequirements(value);
421
424
  const cd = value.cd === true;
422
425
  const scope = cleanLandGateScope(value);
423
- return { gates, ...deliveryPolicy, ...iterate ? { iterate } : {}, ...cd ? { cd } : {}, ...placement ? { placement } : {}, ...requirements ? { requirements } : {}, ...scope };
426
+ const resources = cleanGateResources(value);
427
+ return { gates, ...resources ? { resources } : {}, ...deliveryPolicy, ...iterate ? { iterate } : {}, ...cd ? { cd } : {}, ...placement ? { placement } : {}, ...requirements ? { requirements } : {}, ...scope };
428
+ }
429
+ function cleanGateResources(value) {
430
+ if (!isRecord(value))
431
+ throw new ValidationError("land-gates config must be an object");
432
+ if (value.resources === undefined)
433
+ return;
434
+ if (!isRecord(value.resources))
435
+ throw new ValidationError("resources must be an object");
436
+ const resources = {};
437
+ for (const [kind, request] of Object.entries(value.resources)) {
438
+ if (!HOST_GATE_KINDS.includes(kind))
439
+ throw new ValidationError(`resources: unknown gate kind '${kind}'; use land, iterate or validation`);
440
+ if (!isRecord(request))
441
+ throw new ValidationError(`resources.${kind} must be an object`);
442
+ const parsed = {};
443
+ for (const [key, n] of Object.entries(request)) {
444
+ if (key !== "cpus" && key !== "memoryBytes")
445
+ throw new ValidationError(`resources.${kind}: unknown field '${key}'`);
446
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0 || key === "memoryBytes" && !Number.isSafeInteger(n)) {
447
+ throw new ValidationError(`resources.${kind}.${key} must be a positive ${key === "memoryBytes" ? "safe integer" : "finite number"}`);
448
+ }
449
+ parsed[key] = n;
450
+ }
451
+ resources[kind] = parsed;
452
+ }
453
+ return resources;
424
454
  }
425
455
  function cleanDeliveryGatePolicy(value) {
426
456
  if (!isRecord(value))
@@ -1,5 +1,5 @@
1
1
  import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
- import { findProviderCatalog, findProviderCatalogModel, type SpawnProvider } from "@humwick/sdk";
2
+ import { type SpawnProvider } from "@humwick/sdk";
3
3
  import { tmuxCommand, tmuxHasSession } from "@humwick/sdk/tmux-utils";
4
4
  import type { ManagedProcess, RunnerSpawnConfig } from "../adapter";
5
5
  import { effortSwitchEnabledFromEnv } from "../config";
@@ -24,9 +24,7 @@ export function claudeEffortLaunch(effort: string | undefined, inheritedEnv: Rec
24
24
 
25
25
  /** Persistent transports can switch effort. Host-base `/effort` would save into the human's own settings. */
26
26
  export function claudeEffortSwitchMeta(provider: SpawnProvider, config: RunnerSpawnConfig, oneShot: boolean): { effortSwitch?: true } {
27
- const catalog = findProviderCatalog(provider);
28
- const model = catalog && findProviderCatalogModel(catalog, config.model ?? catalog.defaultModel);
29
- return effortSwitchEnabledFromEnv() && !oneShot && providerHomePathFor(provider, config) && model?.efforts.length ? { effortSwitch: true } : {};
27
+ return effortSwitchEnabledFromEnv() && !oneShot && providerHomePathFor(provider, config) && (config.modelRecord?.efforts?.length || config.effort) ? { effortSwitch: true } : {};
30
28
  }
31
29
 
32
30
  interface ClaudeEffortOutcome { applied?: string; message: string }
@@ -137,7 +137,7 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
137
137
  if (!config.headless) throw new Error("Claude stream-json transport requires --headless");
138
138
  // Use the existing provisioning, permission policy and credential-isolation seam.
139
139
  // Prompts enter through stdin only; no positional/one-shot prompt at launch.
140
- const args = this.launch.buildSpawnArgs({ ...config, prompt: undefined, rig: undefined }, { ...providerConfig, command: "claude" });
140
+ const args = this.launch.buildSpawnArgs({ ...config, prompt: undefined, rig: undefined }, providerConfig);
141
141
  const transportArgs: string[] = [];
142
142
  for (let i = 0; i < args.args.length; i++) {
143
143
  const arg = args.args[i]!;
@@ -0,0 +1,26 @@
1
+ import { readIncrementalFileRange, statIncrementalFile } from "./incremental-file";
2
+ import type { ProviderStatusUpdate } from "../adapter";
3
+ import { readOneShotExitCode } from "../session-scratch";
4
+
5
+ /** Read only the diagnostic tail, even after a long provider session. */
6
+ async function stderrTail(path: string): Promise<string> {
7
+ try {
8
+ const { size } = await statIncrementalFile(path);
9
+ return new TextDecoder().decode(await readIncrementalFileRange(path, Math.max(0, size - 4096), size)).trim();
10
+ } catch { return ""; }
11
+ }
12
+
13
+ // #3830 — a vanished pane is not evidence of a successful provider launch.
14
+ export async function claudeLaunchExitStatus(exitCodeFile: string | undefined, ready: boolean, oneShot: boolean, paneTail = ""): Promise<ProviderStatusUpdate> {
15
+ const code = exitCodeFile ? await readOneShotExitCode(exitCodeFile) : undefined;
16
+ // A successful interactive exec replaces the shell and its EXIT trap.
17
+ if ((code === undefined && ready && !oneShot) || (code === "0" && (ready || oneShot))) return "offline";
18
+ const tail = exitCodeFile ? await stderrTail(`${exitCodeFile}.stderr`) : "";
19
+ const reason = ready || oneShot ? "provider-exited" : "spawn_failed";
20
+ const message = `Claude ${ready || oneShot ? "provider exited" : "launch failed before ready"} (${code === undefined ? "exit code unavailable" : `exit code ${code}`})${tail || paneTail ? `: ${tail || paneTail.slice(-4096)}` : ""}`;
21
+ return {
22
+ status: "error",
23
+ providerState: { state: "failed", reason, message, source: "provider", terminal: true },
24
+ metadata: { terminalFailureReason: reason, terminalFailureMessage: message },
25
+ };
26
+ }
@@ -10,7 +10,7 @@
10
10
  // This is the single home (DRY) for reading + version-gating + mapping that probe.
11
11
  // `probeActivity`, the BusyReconciler backstop, and the monitorless turn-watch all
12
12
  // delegate here; the legacy pane-scrape stays only as the graceful fallback for
13
- // pre-2.1.119 / probe-missing sessions (e.g. a claude-rig host whose real config
13
+ // pre-2.1.119 / probe-missing sessions (e.g. a host whose real config
14
14
  // home the runner can't resolve). Schema is reverse-engineered (pbauermeister/
15
15
  // claude-busy-monitor), so we gate on `version`/presence and degrade gracefully if
16
16
  // Anthropic ever drifts it.
@@ -1,4 +1,4 @@
1
- import { relayEnv } from "@humwick/sdk/env";
1
+ import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
2
  import { envPipeSource, prepareEnvPipe, providerSecretEnvKeys } from "@humwick/sdk/child-env";
3
3
  import { PROVIDER_SCRATCH_ENV_KEYS } from "@humwick/sdk/provider-scratch";
4
4
  // Claude tmux transport plumbing (extracted from ./claude for the file-size
@@ -15,12 +15,12 @@ import { TMUX_SESSION_OWNER_OPTION, managedTmuxSocketName, renderTmuxSessionTarg
15
15
  import { claudeClearCommitted, claudeConversationAnchor, claudePaneIsBusy, claudePaneLooksReady } from "./claude-status-detectors";
16
16
  import { ClaudeSubmitFence, ClaudeSubmitTranscriptProbe, claudeSubmitRecoveryConfirmed, submitClaudePromptConfirmed } from "./claude-submit-confirmation";
17
17
  import type { ClaudePromptHookExpectation, ClaudePromptHookLedger } from "./claude-prompt-hook";
18
- import type { ProviderInputObserver } from "../adapter";
18
+ import type { ProviderInputObserver, RunnerSpawnConfig, SpawnArgs } from "../adapter";
19
19
  import { logger } from "../logger";
20
20
  import { claudeClearSessionVerdict, type ClaudeClearSessionReading } from "./claude-session-probe";
21
21
  import { tmuxOwnerIdFromEnv } from "../config";
22
22
  import { spawnSyncBounded } from "../bounded-spawn-sync";
23
- import { launcherGatePathForSession, launcherScriptPathForSession, readOneShotExitCode } from "../session-scratch";
23
+ import { launcherGatePathForSession, launcherScriptPathForSession } from "../session-scratch";
24
24
  import { captureTmuxPaneAsync, captureTmuxVisiblePaneAsync, loadTmuxBufferFromStdin, runTmuxInput, tmuxSessionPresence, type TmuxReadOpts, type TmuxSessionPresence } from "./tmux-offload";
25
25
 
26
26
  export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
@@ -50,11 +50,6 @@ export function seedClaudeInitialPrompt(args: string[], prompt: string, options:
50
50
  return { stdinFile: promptFile, exitCodeFile, stdinContents: prompt };
51
51
  }
52
52
 
53
- /** A missing sentinel means the one-shot process was killed before its shell could report success. */
54
- export async function claudeOneShotExitStatus(exitCodeFile?: string): Promise<"offline" | "error"> {
55
- if (!exitCodeFile) return "offline";
56
- return (await readOneShotExitCode(exitCodeFile)) === "0" ? "offline" : "error";
57
- }
58
53
 
59
54
  export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
60
55
  if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
@@ -487,8 +482,12 @@ export function writeLauncherScript(sessionName: string, env: Record<string, str
487
482
  const unsets = unsetKeys.map((key) => `unset ${shellEnvKey(key)}`).join("\n");
488
483
  const gate = gateFifo ? `read -r _ < ${shellQuote(gateFifo)} || true\n` : "";
489
484
  const stdin = stdinFile ? `exec < ${shellQuote(stdinFile)}\n` : "";
490
- const command = exitCodeFile ? `${shellCmd}\nexit_code=$?\nprintf '%s' "$exit_code" > ${shellQuote(exitCodeFile)}\nexit "$exit_code"` : `exec ${shellCmd}`;
491
- writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}${Object.keys(env).length ? `${envPipeSource()}\n` : ""}${unsets ? `${unsets}\n` : ""}rm -f -- "$0"\n${gate}${stdin}${command}\n`, { mode: 0o700 });
485
+ const codeFile = exitCodeFile ?? `${scriptPath}.exit-code`;
486
+ // Interactive Claude must own pane_pid for its session probe. execfail lets a
487
+ // failed exec return to EXIT; print mode keeps a parent to record completion (#3830).
488
+ const recordExit = `trap ${shellQuote(`exit_code=$?; printf '%s' "$exit_code" > ${shellQuote(codeFile)}; exit "$exit_code"`)} EXIT`;
489
+ const command = exitCodeFile ? `( ${shellCmd} )` : `exec ${shellCmd}`;
490
+ writeFileSync(scriptPath, `#!/usr/bin/env bash\numask 077\nshopt -s execfail\nrm -f -- ${shellQuote(codeFile)} ${shellQuote(`${codeFile}.stderr`)}\n${recordExit}\nexec 2> >(tee ${shellQuote(`${codeFile}.stderr`)} >&2)\n${exports ? `${exports}\n` : ""}${Object.keys(env).length ? `${envPipeSource()}\n` : ""}${unsets ? `${unsets}\n` : ""}rm -f -- "$0"\n${gate}${stdin}${command}\n`, { mode: 0o700 });
492
491
  return scriptPath;
493
492
  }
494
493
 
@@ -546,3 +545,32 @@ export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<str
546
545
  }
547
546
  return [...keys].sort();
548
547
  }
548
+
549
+ export function buildClaudeTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs, provider: string): { sessionName: string; socketName: string; args: string[]; launcherScript: string; gateFifo?: string; printMode: boolean } {
550
+ const requestedSessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
551
+ // Keep the socket derived from the orchestrator's stable identity, but carry
552
+ // tmux's rendered session name everywhere we later target the pane. Without
553
+ // this, `opus-4.8` is created as `opus-4_8` and every watcher/restart lookup
554
+ // immediately misses it.
555
+ const socketName = tmuxSocketName(requestedSessionName); const sessionName = tmuxSessionTargetName(requestedSessionName);
556
+ const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
557
+ const tmuxArgs = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
558
+
559
+ const envKeys = tmuxEnvKeys(spawnArgs.env, config.providerConfig.env, provider);
560
+ const env: Record<string, string> = {};
561
+ for (const key of envKeys) if (relayEnv(spawnArgs.env, key) !== undefined) writeChildEnv(env, { [key]: relayEnv(spawnArgs.env, key)! });
562
+
563
+ // Only gate the launch behind pipe-pane attachment when there's a log file to
564
+ // protect (#1317) — no point serializing dev/interactive spawns that never
565
+ // read AGENT_RELAY_LOG_FILE.
566
+ const gateFifo = relayEnv(spawnArgs.env, "AGENT_RELAY_LOG_FILE") ? launcherGatePathForSession(sessionName) : undefined;
567
+
568
+ // tmux new-session has a hard command-length limit that includes every argv item,
569
+ // including `-e KEY=value`. The script holds the command; its private FIFO
570
+ // carries env values without writing credentials to disk or argv.
571
+ const printMode = spawnArgs.args.some(arg => arg === "-p" || arg === "--print");
572
+ const exitCodeFile = spawnArgs.exitCodeFile ?? (printMode ? `${launcherScriptPathForSession(sessionName)}.exit-code` : undefined);
573
+ const launcherScript = writeLauncherScript(sessionName, env, shellCmd, gateFifo, spawnArgs.stdinFile, exitCodeFile, spawnArgs.envUnset);
574
+ tmuxArgs.push("-c", spawnArgs.cwd, `bash ${shellQuote(launcherScript)}`);
575
+ return { sessionName, socketName, args: tmuxArgs, launcherScript, gateFifo, printMode };
576
+ }
@@ -3,9 +3,9 @@ import { claudeSubagentEnv, claudeSubagentPolicy, configureClaudeSubagentLaunch,
3
3
  import { submitClaudeContextCommand } from "./claude-context-command";
4
4
  import { scopeClaudeCredentialEnv } from "./claude-credentials";
5
5
  import { claudeEffortLaunch, claudeEffortSwitchMeta, switchClaudeEffort } from "./claude-effort";
6
- import { existsSync } from "node:fs";
7
- import { homedir } from "node:os";
8
- import { join, resolve } from "node:path";
6
+ import { claudeLaunchExitStatus } from "./claude-launch-exit";
7
+ import { claudeCallerAppends } from "../claude-system-prompt";
8
+ import { resolveLaunchCommand } from "../provider-command";
9
9
  import { fireAndForget, sanitizeLaunchBoundary, sanitizeLaunchText, type LivenessSignal, type Message } from "@humwick/sdk";
10
10
  import { shellEscape as shellQuote } from "@humwick/sdk/shell-utils";
11
11
  import { tmuxCommand, tmuxHasSession } from "@humwick/sdk/tmux-utils";
@@ -25,13 +25,13 @@ import { claudeActivityFromProbeAndPane, claudeBypassDialogDisplayGate, claudeBy
25
25
  import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
26
26
  import { claudeToolHookPaths, extractLatestContextUsage } from "./claude-transcript";
27
27
  import { ClaudeTranscriptTokenUsageCapture } from "./claude-token-usage-capture";
28
- import { CLAUDE_CLEAR_COMMIT_CAPTURE, CLAUDE_TMUX_READY_TIMEOUT_MS, claudeOneShotExitStatus, confirmClaudeSubmitRecovery, confirmTmuxSessionGone, createLauncherGate, createTmuxSessionWithEnv, releaseClaudeSubmitFence, releaseLauncherGate, seedClaudeInitialPrompt, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSessionTargetName, tmuxSocketName, waitForClaudeClearCommitted, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
28
+ import { CLAUDE_CLEAR_COMMIT_CAPTURE, CLAUDE_TMUX_READY_TIMEOUT_MS, buildClaudeTmuxArgs, confirmClaudeSubmitRecovery, confirmTmuxSessionGone, createLauncherGate, createTmuxSessionWithEnv, releaseClaudeSubmitFence, releaseLauncherGate, seedClaudeInitialPrompt, submitTextToTmux, waitForClaudeClearCommitted, waitForClaudeInputReady } from "./claude-tmux";
29
29
  import { captureTmuxPaneAsync, captureTmuxVisiblePaneAsync, resolveClaudePanePidAsync, tmuxSessionPresence } from "./tmux-offload";
30
30
  import { claudeShutdownGraceMs, shutdownClaudeTmux, type ClaudeShutdownDeps } from "./claude-shutdown";
31
31
  import { claudePermissionPromptHandler } from "./claude-permission";
32
32
  import { claudeHarnessTrim, claudeProfileDisallowedToolsArgs } from "./claude-tool-policy";
33
33
  import { claudeProjectShellEnv } from "./project-shell";
34
- import { launcherGatePathForSession, stageOneShotPrompt } from "../session-scratch";
34
+ import { stageOneShotPrompt } from "../session-scratch";
35
35
 
36
36
  type MonitorlessTurnWatchDeps = {
37
37
  resolvePanePid: typeof resolveClaudePanePidAsync;
@@ -635,32 +635,22 @@ export class ClaudeAdapter implements ProviderAdapter {
635
635
  // #557 — ONE assembler owns the provisioning/vanilla/instruction launch surface
636
636
  // (--setting-sources, --plugin-dir, --mcp-config, status-line, --append-system-prompt);
637
637
  // profile-projection describes the SAME assembled result, so report == launch. The
638
- // adapter only adds the bits the assembler does not own: claude-rig command resolution,
638
+ // adapter only adds the bits the assembler does not own: launch command resolution,
639
639
  // headless permission flags, --model, and the positional prompt. materializeLaunchAssembly
640
640
  // writes the provisioned skills/plugins the assembled args point at.
641
641
  const assembled = assembleLaunch(this.provider, config, providerConfig);
642
- materializeLaunchAssembly(this.provider, config);
642
+ materializeLaunchAssembly(this.provider, config, assembled);
643
643
  const defaultArgs = assembled.hostDefaultArgs;
644
- const isClaudeRig = /claude-rig/.test(providerConfig.command);
645
- // Profiled launches run their own CLAUDE_CONFIG_DIR (#3387) and must never route through
646
- // claude-rig — claude-rig resolves host rig config and would redirect the assembled home.
647
- // Only a profile-less (user-wrapped) session may use claude-rig.
648
- const usesClaudeRig = isClaudeRig && !assembled.configHome;
649
- const bypassRigDefaults = config.headless && usesClaudeRig && config.approvalMode !== "open";
650
- const rigPrefix = usesClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
651
- const command = !usesClaudeRig || bypassRigDefaults || (!config.rig && !findClaudeRigRC(config.cwd))
652
- ? "claude"
653
- : providerConfig.command;
654
- const providerArgs = config.headless
644
+ const command = resolveLaunchCommand(providerConfig.command);
645
+ const providerArgs = claudeCallerAppends(config.headless
655
646
  ? claudeLaunchArgs(defaultArgs, config.providerArgs, config.approvalMode)
656
- : [...defaultArgs, ...config.providerArgs];
647
+ : [...defaultArgs, ...config.providerArgs]).args;
657
648
  // #1096 — when the assembler merged the caller's own `--settings` into relay's (so the
658
649
  // baseline hooks/projectSettings/statusLine survive), drop the caller's now-redundant
659
650
  // `--settings` here so exactly ONE reaches the launch (relay's merged copy in assembled.args).
660
651
  // #2453 — the coding/review profiles' --disallowedTools trim unions in at this single
661
652
  // claude-family launch seam (helpers + per-profile config in ./claude-tool-policy).
662
653
  const args = claudeProfileDisallowedToolsArgs([
663
- ...rigPrefix,
664
654
  ...assembled.args,
665
655
  ...(assembled.callerSettingsConsumed ? stripSettingsArgs(providerArgs) : providerArgs),
666
656
  ], config);
@@ -673,12 +663,10 @@ export class ClaudeAdapter implements ProviderAdapter {
673
663
  ...config.env,
674
664
  // #557 — the assembler owns the isolated config-home env (CLAUDE_CONFIG_DIR).
675
665
  ...assembled.env,
676
- // Plain `claude` (isolated profiles) runs its own CLAUDE_CONFIG_DIR and never
677
- // routes through claude-rig, so it never inherits the host's long-lived
678
- // CLAUDE_CODE_OAUTH_TOKEN. Without it, claude falls back to the (often stale)
679
- // file-based .credentials.json and bails to /login (401). claude-rig launches
680
- // inject their own token, so only thread it for the plain-claude path, and
681
- // never override an explicit per-launch value.
666
+ // Bare `claude` runs its own CLAUDE_CONFIG_DIR and so never inherits the host's long-lived
667
+ // CLAUDE_CODE_OAUTH_TOKEN; without it claude falls back to the (often stale) file-based
668
+ // .credentials.json and bails to /login (401). A custom wrapper command manages its own
669
+ // token, and an explicit per-launch value always wins.
682
670
  ...(command === "claude" && process.env.CLAUDE_CODE_OAUTH_TOKEN && !config.env?.CLAUDE_CODE_OAUTH_TOKEN
683
671
  ? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN }
684
672
  : {}),
@@ -758,36 +746,13 @@ export class ClaudeAdapter implements ProviderAdapter {
758
746
  });
759
747
  }
760
748
 
761
- buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript: string; gateFifo?: string } {
762
- const requestedSessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
763
- // Keep the socket derived from the orchestrator's stable identity, but carry
764
- // tmux's rendered session name everywhere we later target the pane. Without
765
- // this, `opus-4.8` is created as `opus-4_8` and every watcher/restart lookup
766
- // immediately misses it.
767
- const socketName = tmuxSocketName(requestedSessionName); const sessionName = tmuxSessionTargetName(requestedSessionName);
768
- const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
769
- const tmuxArgs = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
770
-
771
- const envKeys = tmuxEnvKeys(spawnArgs.env, config.providerConfig.env, this.provider);
772
- const env: Record<string, string> = {};
773
- for (const key of envKeys) if (relayEnv(spawnArgs.env, key) !== undefined) writeChildEnv(env, { [key]: relayEnv(spawnArgs.env, key)! });
774
-
775
- // Only gate the launch behind pipe-pane attachment when there's a log file to
776
- // protect (#1317) — no point serializing dev/interactive spawns that never
777
- // read AGENT_RELAY_LOG_FILE.
778
- const gateFifo = relayEnv(spawnArgs.env, "AGENT_RELAY_LOG_FILE") ? launcherGatePathForSession(sessionName) : undefined;
779
-
780
- // tmux new-session has a hard command-length limit that includes every argv item,
781
- // including `-e KEY=value`. The script holds the command; its private FIFO
782
- // carries env values without writing credentials to disk or argv.
783
- const launcherScript = writeLauncherScript(sessionName, env, shellCmd, gateFifo, spawnArgs.stdinFile, spawnArgs.exitCodeFile, spawnArgs.envUnset);
784
- tmuxArgs.push("-c", spawnArgs.cwd, `bash ${shellQuote(launcherScript)}`);
785
- return { sessionName, socketName, args: tmuxArgs, launcherScript, gateFifo };
749
+ buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): ReturnType<typeof buildClaudeTmuxArgs> {
750
+ return buildClaudeTmuxArgs(config, spawnArgs, this.provider);
786
751
  }
787
752
 
788
753
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
789
754
  if (spawnArgs.stdinFile && spawnArgs.exitCodeFile && spawnArgs.stdinContents !== undefined) await stageOneShotPrompt(spawnArgs.stdinFile, spawnArgs.exitCodeFile, spawnArgs.stdinContents);
790
- const { sessionName, socketName, args: tmuxArgs, launcherScript, gateFifo } = this.buildTmuxArgs(config, spawnArgs);
755
+ const { sessionName, socketName, args: tmuxArgs, launcherScript, gateFifo, printMode } = this.buildTmuxArgs(config, spawnArgs);
791
756
  const providerEpoch = ++this.providerEpoch;
792
757
  this.modelUnavailableObservationState = initialClaudeModelUnavailableObservationState();
793
758
  this.connectionRetryActive = false;
@@ -802,7 +767,8 @@ export class ClaudeAdapter implements ProviderAdapter {
802
767
 
803
768
  await createTmuxSessionWithEnv(sessionName, socketName, tmuxArgs, launcherScript, spawnArgs.env, config.providerConfig.env, gateFifo, this.provider);
804
769
 
805
- this.watchTmuxSession(sessionName, socketName, spawnArgs.exitCodeFile);
770
+ const exitCodeFile = spawnArgs.exitCodeFile ?? `${launcherScript}.exit-code`;
771
+ this.watchTmuxSession(sessionName, socketName, exitCodeFile, printMode);
806
772
  // #1838 review MED — resolve the pane pid OFF the loop now that the session exists and cache
807
773
  // it, so the synchronous resolveBusyPid() never has to shell out on a hot busy edge. Fenced
808
774
  // on providerEpoch so a resolve that lands after a restart can't stamp a stale pid.
@@ -832,14 +798,15 @@ export class ClaudeAdapter implements ProviderAdapter {
832
798
  tmuxSession: sessionName,
833
799
  tmuxSocket: socketName,
834
800
  launcherScript,
835
- ...claudeEffortSwitchMeta(this.provider, config, Boolean(spawnArgs.exitCodeFile)),
836
- ...(spawnArgs.exitCodeFile ? { oneShotExitCodeFile: spawnArgs.exitCodeFile } : {}),
801
+ exitCodeFile,
802
+ ...claudeEffortSwitchMeta(this.provider, config, printMode),
803
+ printMode,
837
804
  providerEpoch,
838
805
  },
839
806
  };
840
807
  }
841
808
 
842
- private watchTmuxSession(sessionName: string, socketName?: string, oneShotExitCodeFile?: string): void {
809
+ private watchTmuxSession(sessionName: string, socketName?: string, exitCodeFile?: string, oneShot = false): void {
843
810
  if (this.tmuxWatcher) clearInterval(this.tmuxWatcher);
844
811
  // #1838 row 12 — the highest-frequency blocking site in the runner (every 2s for the
845
812
  // agent's whole lifetime). Its two tmux subprocesses (has-session + capture-pane) now run
@@ -849,6 +816,7 @@ export class ClaudeAdapter implements ProviderAdapter {
849
816
  // probe on the same pane. `this.tmuxWatcher !== watcher` still fences a predecessor tick
850
817
  // whose session was replaced by a respawn.
851
818
  let tickInFlight = false;
819
+ let lastPane = "";
852
820
  const watcher = setInterval(() => {
853
821
  if (this.tmuxWatcher !== watcher || tickInFlight) return;
854
822
  tickInFlight = true;
@@ -866,7 +834,7 @@ export class ClaudeAdapter implements ProviderAdapter {
866
834
  // confirm itself only returns true when BOTH reads are `absent` (#1838 HIGH-2).
867
835
  const gone = await confirmTmuxSessionGone(sessionName, socketName).catch(() => false);
868
836
  if (this.tmuxWatcher !== watcher || !gone) return;
869
- const exitStatus = await claudeOneShotExitStatus(oneShotExitCodeFile);
837
+ const exitStatus = await claudeLaunchExitStatus(exitCodeFile, this.startupReadyObserved, oneShot, lastPane);
870
838
  if (this.tmuxWatcher !== watcher) return;
871
839
  clearInterval(watcher); this.tmuxWatcher = undefined; this.publishConfirmedTmuxExit(exitStatus);
872
840
  return;
@@ -879,6 +847,7 @@ export class ClaudeAdapter implements ProviderAdapter {
879
847
  }
880
848
  // #1838 HIGH-1: fence again after the capture await, before any observation/status emit.
881
849
  if (this.tmuxWatcher !== watcher) return;
850
+ lastPane = pane;
882
851
  // A reused tmux pane can leave an old ready footer in scrollback. Until the
883
852
  // current launch is visibly ready, derive both readiness and modal state
884
853
  // from the visible grid. After that, avoid an extra capture every tick.
@@ -938,7 +907,7 @@ export class ClaudeAdapter implements ProviderAdapter {
938
907
  this.tmuxWatcher = watcher;
939
908
  }
940
909
 
941
- private publishConfirmedTmuxExit(status: "offline" | "error"): void {
910
+ private publishConfirmedTmuxExit(status: ProviderStatusUpdate): void {
942
911
  // The pane and turn pollers are independent. Invalidate the turn generation BEFORE publishing
943
912
  // the terminal edge so an in-flight incarnation/activity read cannot land a later idle and
944
913
  // resurrect the row with its shutting-down lifecycle pill still latched (#2505).
@@ -962,7 +931,7 @@ export class ClaudeAdapter implements ProviderAdapter {
962
931
  const socketName = process.meta?.tmuxSocket as string | undefined;
963
932
  const turnWatchResume = this.turnWatchResume?.process === process ? this.turnWatchResume : undefined;
964
933
  this.statusWatchersStopped = false;
965
- if (sessionName) this.watchTmuxSession(sessionName, socketName, process.meta?.oneShotExitCodeFile as string | undefined);
934
+ if (sessionName) this.watchTmuxSession(sessionName, socketName, process.meta?.exitCodeFile as string | undefined, process.meta?.printMode === true);
966
935
  if (turnWatchResume) await this.beginMonitorlessTurnWatch(process, turnWatchResume.sessionName, turnWatchResume.socketName, false);
967
936
  }
968
937
 
@@ -1058,22 +1027,6 @@ export { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy,
1058
1027
  // Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
1059
1028
  export { shellQuote };
1060
1029
 
1061
- export function findClaudeRigRC(cwd: string): string | null {
1062
- const home = homedir();
1063
- let dir = resolve(cwd);
1064
- while (true) {
1065
- // Stop at $HOME without inspecting it: ~/.claude-rig is the claude-rig tool's
1066
- // own config home, not a per-project rc marker. Matching it would make every
1067
- // project under $HOME look like a claude-rig project.
1068
- if (dir === home) return null;
1069
- const rc = join(dir, ".claude-rig");
1070
- if (existsSync(rc)) return rc;
1071
- const parent = resolve(dir, "..");
1072
- if (parent === dir) return null;
1073
- dir = parent;
1074
- }
1075
- }
1076
-
1077
1030
  async function terminateSingleProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
1078
1031
  const proc = process.process;
1079
1032
  if (!proc) return "killed"; // nothing to kill — vacuously terminal
@@ -10,6 +10,7 @@ import { hostProviderDefaultArgs } from "../host-asset-plan";
10
10
  import { bundledSkillConfigArgs } from "../launch-assembly";
11
11
  import { codexRetiredMcpConfigArgs } from "./codex-effective-config";
12
12
  import { codexWebToolsConfigArgs } from "../web-tools";
13
+ import type { ResolvedModelRecord } from "@humwick/sdk/provider-catalog";
13
14
  import { tomlString } from "../relay-mcp";
14
15
 
15
16
  export const DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 12_000;
@@ -31,14 +32,12 @@ export const DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 12_000;
31
32
  // advertise longer polls to the model (#2776): agents are steered to end the turn and take the wake.
32
33
  const CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS = 900000;
33
34
 
34
- export function codexModelConfigArgs(model?: string, effort?: string): string[] {
35
+ export function codexModelConfigArgs(model?: string, effort?: string, record?: ResolvedModelRecord): string[] {
35
36
  const args: string[] = [];
36
37
  if (model) args.push("-c", `model=${tomlString(model)}`);
37
38
  if (effort) args.push("-c", `model_reasoning_effort=${tomlString(effort)}`);
38
- // The catalog alias `astra` resolves to this provider model before runner launch.
39
- if (model === "gpt-6-astra") {
40
- args.push("-c", "model_context_window=1000000", "-c", "model_auto_compact_token_limit=272000");
41
- }
39
+ if (record?.contextWindowTokens) args.push("-c", `model_context_window=${record.contextWindowTokens}`);
40
+ if (record?.contextWindowTokens && record.contextThreshold) args.push("-c", `model_auto_compact_token_limit=${Math.floor(record.contextWindowTokens * record.contextThreshold.selfCompactUtilization)}`);
42
41
  return args;
43
42
  }
44
43
 
@@ -688,7 +688,7 @@ export class CodexAdapter implements ProviderAdapter {
688
688
  ? [
689
689
  "app-server",
690
690
  ...codexAppServerConfigArgs(defaultArgs, config.providerArgs),
691
- ...codexModelConfigArgs(config.model, config.effort),
691
+ ...codexModelConfigArgs(config.model, config.effort, config.modelRecord),
692
692
  ...codexApprovalConfigArgs(config.approvalMode),
693
693
  ...codexToolOutputTokenLimitConfigArgs(config), ...codexHarnessConfigArgs(config), ...codexWebToolsConfigArgs(config),
694
694
  ...assembled.args,
@@ -35,14 +35,6 @@ export function openCodeBackendApiKeyEnvVar(backendID: string): string | undefin
35
35
  return OPENCODE_STATIC_BACKENDS[backendID]?.envVar;
36
36
  }
37
37
 
38
- /** Catalog alias → provider model (older runner protocols carry the alias without a record). */
39
- function openCodeAliasResolved(model: string | undefined, manifest: ProviderManifest, provider: string): string {
40
- const alias = model?.trim() || manifest.catalog.defaultModel;
41
- const raw = manifest.catalog.models.find((entry) => entry.alias === alias)?.providerModel || alias;
42
- if (!raw) throw new Error(`OpenCode provider ${provider} has no default model.`);
43
- return raw;
44
- }
45
-
46
38
  /**
47
39
  * #2659 — the launch model: THE one normalization of a spawn's model through the shared seam
48
40
  * (`resolveOpenCodeModelId`). A server-threaded record's qualified name wins over the alias.
@@ -51,8 +43,9 @@ function openCodeAliasResolved(model: string | undefined, manifest: ProviderMani
51
43
  * default-backend model id.
52
44
  */
53
45
  export function openCodeLaunchModel(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord, registry?: OpenCodeModelRegistry): OpenCodeModelId {
54
- const { manifest, backend } = openCodeBackend(provider);
55
- const input = record?.qualifiedModel || openCodeAliasResolved(model, manifest, provider);
46
+ const { backend } = openCodeBackend(provider);
47
+ const input = record?.qualifiedModel || model?.trim();
48
+ if (!input) throw new Error(`OpenCode provider ${provider} requires a hub-resolved model.`);
56
49
  return resolveOpenCodeModelId(input, backend.opencodeProviderID, registry);
57
50
  }
58
51
 
@@ -111,18 +104,10 @@ export function openCodeCustomBackendIDs(): string[] {
111
104
  return getAllManifests().flatMap((entry) => entry.opencode && OPENCODE_STATIC_BACKENDS[entry.opencode.opencodeProviderID]?.custom ? [entry.opencode.opencodeProviderID] : []);
112
105
  }
113
106
 
114
- /** Read the current backend's declared context window from the manifest catalog. */
115
- export function openCodeContextWindowTokens(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord): number | undefined {
116
- // #2599 — precedence payload > manifest > undefined: the record's window wins; a malformed
117
- // record window never reaches here (the shared parser drops it), so no second gate is needed.
118
- if (typeof record?.contextWindowTokens === "number" && Number.isFinite(record.contextWindowTokens) && record.contextWindowTokens > 0) {
119
- openCodeBackend(provider); // keep the unknown-provider hard error on the record path too.
120
- return record.contextWindowTokens;
121
- }
122
- const { manifest } = openCodeBackend(provider);
123
- const { modelID } = openCodeLaunchModel(model, provider);
124
- const value = manifest.catalog.models.find((entry) => entry.alias === modelID || entry.providerModel === modelID)?.limits?.contextWindowTokens?.value;
125
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
107
+ /** Context limits come from the hub-resolved launch record. */
108
+ export function openCodeContextWindowTokens(_model: string | undefined, provider = "opencode", record?: ResolvedModelRecord): number | undefined {
109
+ openCodeBackend(provider);
110
+ return record?.contextWindowTokens;
126
111
  }
127
112
 
128
113
  /**
@@ -1505,7 +1505,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
1505
1505
  const tokens = countTokens(info?.tokens);
1506
1506
  const sessionUnattributable = Boolean(activeSessionId && sessionID !== activeSessionId);
1507
1507
  if (process && role === "assistant" && tokens !== undefined && !sessionUnattributable) {
1508
- const model = stringValue(process.meta?.model) ?? openCodeQualifiedModel(undefined, this.provider);
1508
+ const model = stringValue(process.meta?.model);
1509
1509
  this.reportContextUsage(process, model, tokens);
1510
1510
  }
1511
1511
  // #2484 — same door, reused for the spine report: model/createdAt prefer the message's
@@ -1516,7 +1516,7 @@ export class OpenCodeAdapter implements ProviderAdapter {
1516
1516
  scheduleOpenCodeTokenUsage(this.tokenUsagePoster, {
1517
1517
  provider: this.provider,
1518
1518
  sessionId: activeSessionId ?? sessionID,
1519
- model: openCodeMessageModel(info) ?? stringValue(process.meta?.model) ?? openCodeQualifiedModel(undefined, this.provider),
1519
+ model: openCodeMessageModel(info) ?? stringValue(process.meta?.model),
1520
1520
  messageId: id,
1521
1521
  tokens: info?.tokens,
1522
1522
  createdAt: openCodeMessageObservedAt(info),
@@ -1832,8 +1832,8 @@ export class OpenCodeAdapter implements ProviderAdapter {
1832
1832
  for (const relayContextInput = prepareOpenCodeRelayContext(process, text);;) {
1833
1833
  const client = process.meta?.client as OpenCodeClient | undefined;
1834
1834
  const sessionId = stringValue(process.meta?.sessionId);
1835
- const model = stringValue(process.meta?.model) ?? openCodeQualifiedModel(undefined, this.provider);
1836
- if (!client || !sessionId) throw new Error("OpenCode session is not ready");
1835
+ const model = stringValue(process.meta?.model);
1836
+ if (!client || !sessionId || !model) throw new Error("OpenCode session is not ready with a hub-resolved model");
1837
1837
  // #2397 round 5 review (LOW-2) — raised only once we're actually about to run an attempt
1838
1838
  // (past the guard above), so a "session not ready" throw never emits a close for a turn
1839
1839
  // that never started (never emitted its own busy edge below).