@humwick/runner 0.0.0-stage → 0.130.1

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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +42 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,47 @@
1
+ import { exitRunner } from "../../runner-exit";
2
+
3
+ // A child whose bind and announcement are controlled independently by its parent test.
4
+ const url = new URL(process.env.CODEX_APP_SERVER_URL!);
5
+ let server: ReturnType<typeof Bun.serve>;
6
+ let buffered = "";
7
+ await Bun.connect({
8
+ hostname: "127.0.0.1",
9
+ port: Number(process.env.TEST_CONTROL_PORT),
10
+ socket: {
11
+ open(socket) { socket.write("booted\n"); },
12
+ data(socket, data) {
13
+ buffered += data.toString();
14
+ const lines = buffered.split("\n");
15
+ buffered = lines.pop()!;
16
+ for (const command of lines) {
17
+ if (command === "bind") {
18
+ server = Bun.serve({
19
+ hostname: url.hostname, port: Number(url.port),
20
+ fetch(req, srv) {
21
+ if (srv.upgrade(req, { data: undefined })) return;
22
+ return new Response(null, { status: 400 });
23
+ },
24
+ websocket: {
25
+ message(ws, raw) {
26
+ const request = JSON.parse(String(raw));
27
+ if (request.id !== undefined) ws.send(JSON.stringify({ id: request.id, result: { fixture: true } }));
28
+ },
29
+ },
30
+ });
31
+ socket.write("bound\n");
32
+ } else if (command === "partial") {
33
+ process.stderr.write("codex app-server (WebSockets)\n listening on: ws://127.");
34
+ socket.write("partial\n");
35
+ } else if (command === "announce") {
36
+ process.stderr.write(`0.0.1:${server.port}\n`);
37
+ } else if (command === "wrong-port") {
38
+ process.stderr.write(" listening on: ws://127.0.0.1:1\n");
39
+ } else if (command === "fail") {
40
+ process.stderr.write("fatal: fixture bind failed: address already in use\n");
41
+ exitRunner(42, "fixture bind failure");
42
+ }
43
+ }
44
+ },
45
+ close() { exitRunner(0); },
46
+ },
47
+ });
@@ -0,0 +1,243 @@
1
+ import { relayEnv } from "@humwick/sdk/env";
2
+ import type { Message, ProviderState } from "@humwick/sdk";
3
+ import { resolveProviderApiAuth } from "@humwick/sdk/child-env";
4
+ import { usdToMicroUsd } from "@humwick/sdk";
5
+ import type { ApiBudgetDecision, ApiBudgetReservation } from "@humwick/sdk";
6
+ import type { DeliverResult, ManagedProcess, PromptOfferOptions, ProviderConfig, ProviderDeliverContext, RunnerSpawnConfig, ShutdownOutcome, SpawnArgs } from "../adapter";
7
+ import { ApiBudgetAccounting, isTransientBudgetFailure } from "../api-budget-accounting";
8
+ import { ProviderSubmitFailure } from "../provider-submit-failure";
9
+ import { buildRateLimitProviderState } from "../rate-limit";
10
+ import { ClaudeHeadlessAdapter } from "./claude-headless";
11
+
12
+ /** Native API auth plus a pre-launch reservation on the fleet's single durable ledger. */
13
+ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
14
+ private readonly budget = new ApiBudgetAccounting("claude-api", (action, body) => this.rpc(action, body), next => this.adoptReservation(next));
15
+ private get reservation() { return this.budget.reservation; }
16
+ private set reservation(value: ApiBudgetReservation | undefined) { this.budget.reservation = value; }
17
+ private get costMicroUsd() { return this.budget.costMicroUsd; }
18
+ private set costMicroUsd(value: number) { this.budget.costMicroUsd = value; }
19
+ private get accounting() { return this.budget.pending; }
20
+ private config?: RunnerSpawnConfig;
21
+ private managed?: ManagedProcess;
22
+ private held = false;
23
+ private budgetState?: ProviderState;
24
+ private heartbeat?: ReturnType<typeof setInterval>;
25
+ private expiry?: ReturnType<typeof setTimeout>;
26
+ private nativeCostMicroUsd = 0;
27
+ private nativeAllowanceMicroUsd = 0;
28
+ private nativeStartMicroUsd = 0;
29
+ private rollover = false;
30
+ private resumeSession?: string;
31
+
32
+ constructor() { super("claude-api"); }
33
+
34
+ override buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
35
+ if (!this.reservation) throw new ProviderSubmitFailure("Claude API requires a shared budget reservation before launch", false);
36
+ const built = super.buildSpawnArgs(config, providerConfig);
37
+ const args: string[] = [];
38
+ for (let i = 0; i < built.args.length; i++) {
39
+ const arg = built.args[i]!;
40
+ if (/^(?:--resume|--continue|-r|-c)(?:=|$)/.test(arg)) throw new ProviderSubmitFailure("Claude API resume requires separate accounting proof", false);
41
+ if (arg === "--max-budget-usd") { i++; continue; }
42
+ if (arg.startsWith("--max-budget-usd=")) continue;
43
+ args.push(arg);
44
+ }
45
+ this.nativeStartMicroUsd = this.costMicroUsd;
46
+ this.nativeAllowanceMicroUsd = this.reservation.allowanceMicroUsd - this.costMicroUsd;
47
+ return { ...built, args: [...args, "--max-budget-usd", String(this.nativeAllowanceMicroUsd / 1_000_000),
48
+ ...(this.resumeSession ? ["--resume", this.resumeSession] : [])],
49
+ env: resolveProviderApiAuth(this.provider, built.env, {
50
+ ANTHROPIC_API_KEY_FILE: providerConfig.env.ANTHROPIC_API_KEY_FILE ?? process.env.ANTHROPIC_API_KEY_FILE
51
+ ?? "~/.config/agent-relay/secrets/anthropic-api.key",
52
+ }) };
53
+ }
54
+
55
+ override async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
56
+ if (this.heartbeat) clearInterval(this.heartbeat);
57
+ clearTimeout(this.expiry);
58
+ this.config = config;
59
+ // Validate fresh auth before reserving; no model child starts on denied/unknown accounting.
60
+ resolveProviderApiAuth(this.provider, config.env, { ...config.providerConfig.env,
61
+ ANTHROPIC_API_KEY_FILE: config.providerConfig.env.ANTHROPIC_API_KEY_FILE ?? process.env.ANTHROPIC_API_KEY_FILE
62
+ ?? "~/.config/agent-relay/secrets/anthropic-api.key" });
63
+ const endedSession = this.managed && (this.managed.process as Bun.Subprocess).exitCode !== null && this.reservation
64
+ ? { sessionId: this.reservation.sessionId, costMicroUsd: this.costMicroUsd } : undefined;
65
+ const decision = await this.budget.reserve(relayEnv(config.env, "AGENT_RELAY_PROVIDER_SESSION_ID") ?? crypto.randomUUID(), endedSession);
66
+ if (!decision.allowed || !decision.reservation) throw new ProviderSubmitFailure(`Claude API admission refused: ${decision.reason ?? "invalid reservation"}`, false);
67
+ this.managed = undefined;
68
+ this.held = false; this.costMicroUsd = 0;
69
+ this.budgetState = undefined;
70
+ this.nativeCostMicroUsd = 0; this.rollover = false; this.resumeSession = undefined;
71
+ this.reservation = decision.reservation;
72
+ try { this.managed = await super.spawn(config); }
73
+ catch (e) { this.held = true; throw e; } // Uncertain launch keeps the full reservation.
74
+ const managed = this.managed;
75
+ this.watchNativeProcess();
76
+ return managed;
77
+ }
78
+
79
+ private watchNativeProcess() {
80
+ if (this.heartbeat) clearInterval(this.heartbeat);
81
+ const managed = this.managed!, child = managed.process as Bun.Subprocess;
82
+ const heartbeat = this.heartbeat = setInterval(() => { if (managed === this.managed) this.queueReport(); }, 5_000);
83
+ this.armExpiry();
84
+ void child.exited.finally(() => { if (managed === this.managed && managed.process === child) clearTimeout(this.expiry); clearInterval(heartbeat); }).catch(() => {});
85
+ }
86
+
87
+ /** One incarnation-owned expiry timer; re-read the window on every arm so a moved endsAt takes effect immediately (#3312). */
88
+ private armExpiry() {
89
+ clearTimeout(this.expiry);
90
+ const managed = this.managed, reservation = this.reservation;
91
+ if (!managed || !reservation) return;
92
+ this.armedEndsAt = reservation.endsAt;
93
+ const remaining = reservation.endsAt - Date.now();
94
+ if (remaining <= 0) this.stopForBudget("budget-window-ended");
95
+ else this.expiry = setTimeout(() => { if (managed === this.managed) this.armExpiry(); }, Math.min(remaining, 2_147_483_647));
96
+ }
97
+
98
+ private armedEndsAt?: number;
99
+
100
+ private adoptReservation(next: ApiBudgetReservation) {
101
+ const moved = next.endsAt !== this.armedEndsAt;
102
+ this.reservation = next;
103
+ if (moved) this.armExpiry();
104
+ }
105
+
106
+ override async deliver(process: ManagedProcess, messages: Message[], ctx?: ProviderDeliverContext): Promise<number[] | DeliverResult> {
107
+ if (this.held) throw new ProviderSubmitFailure("Claude API budget requires operator action", false);
108
+ if (messages.length && this.rollover && !await this.prepareInput(process)) return { acknowledgedIds: [], providerHeldIds: messages.map(m => m.id) };
109
+ return super.deliver(process, messages, ctx);
110
+ }
111
+
112
+ override async deliverInitialPrompt(process: ManagedProcess, prompt: string, options?: PromptOfferOptions) {
113
+ if (!await this.prepareInput(process)) return "held" as const;
114
+ return super.deliverInitialPrompt(process, prompt, options);
115
+ }
116
+
117
+ private async prepareInput(process: ManagedProcess): Promise<boolean> {
118
+ await this.accounting;
119
+ if (this.held || !this.reservation || Date.now() >= this.reservation.endsAt) throw new ProviderSubmitFailure("Claude API budget is held or expired", false);
120
+ let decision: ApiBudgetDecision;
121
+ try { decision = await this.reportBudget(this.reservation.sessionId, { costMicroUsd: this.costMicroUsd, ...(this.rollover ? { renew: true } : {}) }); }
122
+ catch (e) {
123
+ if (isTransientBudgetFailure(e)) {
124
+ // Park the input under a transient hold: the relay's bounded resume backoff retries it, no operator step (#3373).
125
+ this.emitStatus({ status: "idle", providerState: { ...buildRateLimitProviderState({ kind: "transient_overload",
126
+ errorType: "budget_accounting_unavailable", message: "budget-accounting-unavailable", source: this.provider }),
127
+ label: "Claude API budget ledger unreachable; retrying" } });
128
+ return false;
129
+ }
130
+ this.accountingFailed(e, process);
131
+ throw new ProviderSubmitFailure("Claude API accounting refused input", false, e);
132
+ }
133
+ if (!decision.allowed) { this.stopForBudget(decision.reason ?? "budget-exhausted"); return false; }
134
+ if (this.rollover) {
135
+ try {
136
+ this.resumeSession = this.nativeSessionId(process);
137
+ if (!this.resumeSession) throw new Error("Claude API rollover has no native session to resume");
138
+ await this.replaceAtBoundary(process, this.config!);
139
+ this.rollover = false;
140
+ this.watchNativeProcess();
141
+ } catch (e) {
142
+ this.accountingFailed(e, process);
143
+ throw new ProviderSubmitFailure("Claude API accounting refused input", !this.held, e);
144
+ }
145
+ }
146
+ return true;
147
+ }
148
+
149
+ protected override onNativeResult(event: Record<string, unknown>) {
150
+ try {
151
+ if (typeof event.total_cost_usd !== "number") throw new Error("Missing final native spend");
152
+ const nativeCost = usdToMicroUsd(event.total_cost_usd);
153
+ this.costMicroUsd += Math.max(0, nativeCost - this.nativeCostMicroUsd);
154
+ this.nativeCostMicroUsd = Math.max(this.nativeCostMicroUsd, nativeCost);
155
+ if (this.costMicroUsd - this.nativeStartMicroUsd >= this.nativeAllowanceMicroUsd / 2) this.rollover = true;
156
+ this.queueReport();
157
+ } catch { this.stopForBudget("budget-final-spend-unknown"); }
158
+ }
159
+
160
+ protected override nativeResultHold(event: Record<string, unknown>): ProviderState | undefined {
161
+ if (event.subtype !== "error_max_budget_usd" || !this.managed) return undefined;
162
+ this.rollover = true;
163
+ this.retireAtBoundary(this.managed);
164
+ if (this.budgetState) return this.budgetState;
165
+ return { state: "blocked", reason: "rate_limit", kind: "usage_limit", source: this.provider,
166
+ label: "Claude API allowance reached; renewing reservation", resetAt: Date.now(), enteredAt: Date.now() };
167
+ }
168
+
169
+ override async compact(process: ManagedProcess, opts?: { instructions?: string }) {
170
+ if (!await this.prepareInput(process)) throw new ProviderSubmitFailure("Claude API budget refused compaction", !this.held);
171
+ return super.compact(process, opts);
172
+ }
173
+
174
+ override async clearContext(process: ManagedProcess) {
175
+ if (!await this.prepareInput(process)) throw new ProviderSubmitFailure("Claude API budget refused context clear", !this.held);
176
+ const result = await super.clearContext(process);
177
+ this.nativeCostMicroUsd = 0;
178
+ this.rollover = true;
179
+ return result;
180
+ }
181
+
182
+ private queueReport() {
183
+ const managed = this.managed, reservation = this.reservation;
184
+ // Serialize reports so a later input cannot race the result's accounting/refusal.
185
+ this.budget.queue(async () => {
186
+ if (!reservation || managed !== this.managed || this.held || this.budget.settlement) return;
187
+ const decision = await this.reportBudget(reservation.sessionId, { costMicroUsd: this.costMicroUsd, ...(this.rollover ? { renew: true } : {}) });
188
+ if (managed !== this.managed) return;
189
+ if (!decision.allowed) this.stopForBudget(decision.reason ?? "budget-exhausted");
190
+ }, e => this.accountingFailed(e, managed));
191
+ }
192
+
193
+ private accountingFailed(error: unknown, managed?: ManagedProcess) {
194
+ if (managed !== this.managed) return;
195
+ // The native --max-budget-usd cap and the expiry timer bound spend to the reservation while the
196
+ // ledger is unreachable, so a transient failure never stops the provider; new input still needs a report (#3373).
197
+ if (isTransientBudgetFailure(error)) return;
198
+ this.stopForBudget("budget-accounting-unavailable");
199
+ }
200
+
201
+ private stopForBudget(reason: string) {
202
+ if (this.held) return;
203
+ this.held = true;
204
+ this.budgetState = { state: "blocked", reason: "rate_limit", kind: "usage_limit",
205
+ source: this.provider, label: "Claude API budget held", message: reason,
206
+ recommendedAction: "Check the shared API credit grant and restart after restoring headroom", enteredAt: Date.now() };
207
+ try { this.emitStatus({ status: "idle", clear: ["provider-turn"], providerState: this.budgetState }); }
208
+ finally { if (this.managed) void this.shutdown(this.managed, { graceful: false, timeoutMs: 1_000 }).catch(() => {}); }
209
+ }
210
+
211
+ override async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
212
+ if (process !== this.managed) return super.shutdown(process, opts);
213
+ if (this.heartbeat) clearInterval(this.heartbeat);
214
+ this.rollover = false;
215
+ const result = await super.shutdown(process, opts);
216
+ if ((process.process as Bun.Subprocess).exitCode !== null) {
217
+ await process.meta?.nativeExit;
218
+ await this.settle(process);
219
+ }
220
+ return result;
221
+ }
222
+
223
+ protected override async onNativeExit(child: Bun.Subprocess, _code: number, retired: boolean) {
224
+ const managed = this.managed;
225
+ if (managed?.process === child && !(this.rollover && retired)) await this.settle(managed);
226
+ }
227
+
228
+ private async settle(managed: ManagedProcess) {
229
+ await this.accounting;
230
+ if (managed !== this.managed || !this.reservation) return;
231
+ // Exit observation and explicit shutdown share one idempotent settlement attempt.
232
+ try { await this.budget.settle(); } catch { this.held = true; }
233
+ }
234
+
235
+ /** The ledger's reservation is authoritative: adopt its window so a reconfigured grant reaches running sessions. */
236
+ private async reportBudget(sessionId: string, body: { costMicroUsd: number; renew?: boolean }): Promise<ApiBudgetDecision> {
237
+ return this.budget.report(body);
238
+ }
239
+
240
+ private rpc(action: "reserve" | "report", body: Record<string, unknown>): Promise<ApiBudgetDecision> {
241
+ return this.budget.transport(this.config!, action, body);
242
+ }
243
+ }
@@ -0,0 +1,16 @@
1
+ import { tmuxCommand } from "@humwick/sdk/tmux-utils";
2
+ import { claudePaneIsBusy } from "./claude-status-detectors";
3
+ import { claudeComposerText } from "./claude-submit-confirmation";
4
+ import { captureTmuxVisiblePaneAsync, runTmuxInput } from "./tmux-offload";
5
+
6
+ /** Slash commands must reach an idle, empty composer as literal keystrokes (#2794). */
7
+ export async function submitClaudeContextCommand(session: string, command: "/compact" | "/clear" | `/effort ${string}`, socket?: string, signal?: AbortSignal, onSubmitted?: () => void): Promise<void> {
8
+ signal?.throwIfAborted();
9
+ const pane = await captureTmuxVisiblePaneAsync(session, socket, { styled: true });
10
+ signal?.throwIfAborted();
11
+ if (claudePaneIsBusy(pane) || claudeComposerText(pane) !== "") throw new Error("Claude context command requires an idle empty composer");
12
+ await runTmuxInput("tmux-context-command", tmuxCommand(socket, "send-keys", "-t", session, "-l", "--", command));
13
+ signal?.throwIfAborted();
14
+ onSubmitted?.();
15
+ await runTmuxInput("tmux-context-submit", tmuxCommand(socket, "send-keys", "-t", session, "C-m"));
16
+ }
@@ -0,0 +1,22 @@
1
+ import { writeChildEnv } from "@humwick/sdk/env";
2
+ import { CLAUDE_FAMILY_AMBIENT_CREDENTIAL_ENV_KEYS } from "@humwick/providers";
3
+ import { resolveFileEnv } from "@humwick/sdk/child-env";
4
+
5
+ /**
6
+ * #2513 — keep only the credentials this claude-family provider owns (claude-api owns the host paid-key
7
+ * file; other ids drop the primary subscription's ambient keys). A dropped key is also pushed to `envUnset`.
8
+ */
9
+ export function scopeClaudeCredentialEnv(provider: string, providerEnv: Record<string, string | undefined>, env: Record<string, string>, envUnset: string[]): void {
10
+ const credentialKeys = provider === "claude"
11
+ ? (process.env.ANTHROPIC_API_KEY_FILE ? ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_FILE"] : [])
12
+ : CLAUDE_FAMILY_AMBIENT_CREDENTIAL_ENV_KEYS;
13
+ const scoped = resolveFileEnv(providerEnv, { keys: credentialKeys });
14
+ for (const key of credentialKeys) {
15
+ const providerScoped = scoped[key];
16
+ if (providerScoped) writeChildEnv(env, { [key]: providerScoped });
17
+ else {
18
+ writeChildEnv(env, {}, [key]);
19
+ envUnset.push(key);
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,28 @@
1
+ import type { Message } from "@humwick/sdk";
2
+ import { isHumanSenderMessage, type DeliverResult } from "../adapter";
3
+ import { logger } from "../logger";
4
+
5
+ /**
6
+ * #3415 — Claude Code labels Monitor events "NOT USER INPUT", so human senders go in as a tmux user turn,
7
+ * never Monitor, never stubbed; the rest stays on Monitor (all of it without a tmux pane). Kept out of
8
+ * claude-delivery.ts: the provisioned Monitor bundle imports it and the logger would pull the provider catalog in.
9
+ */
10
+ export async function deliverClaudeByLane(messages: Message[], lanes: {
11
+ userTurn?: (batch: Message[]) => Promise<DeliverResult>;
12
+ monitor: (batch: Message[], reportsOffer: boolean) => Promise<DeliverResult>;
13
+ narrowTurnOpener?: (batch: Message[]) => () => void;
14
+ }): Promise<DeliverResult> {
15
+ const userTurn = lanes.userTurn ? messages.filter(isHumanSenderMessage) : [];
16
+ const background = messages.filter((message) => !userTurn.includes(message));
17
+ if (userTurn.length === 0 || !lanes.userTurn) return lanes.monitor(background, true);
18
+ if (background.length === 0) return lanes.userTurn(userTurn);
19
+ // The typed turn answers only what it carried: a Monitor message may still fail, or fold into the turn as its own event.
20
+ const disarm = lanes.narrowTurnOpener?.(userTurn);
21
+ // The user turn goes first, so its throw (a #2664 submit fence) propagates before any Monitor event; it alone reports the input offer.
22
+ const typed = await lanes.userTurn(userTurn).catch((error: unknown) => { disarm?.(); throw error; });
23
+ const monitored = await lanes.monitor(background, false).catch((error: unknown): DeliverResult => {
24
+ logger.warn("claude", `monitor delivery failed after a user-turn submit; requeueing ${background.length} message(s): ${error}`);
25
+ return { acknowledgedIds: [] };
26
+ });
27
+ return { acknowledgedIds: [...typed.acknowledgedIds, ...monitored.acknowledgedIds] };
28
+ }
@@ -0,0 +1,166 @@
1
+ import type { Message } from "@humwick/sdk";
2
+ import { dedupePersistedMessages, isMemoryInjection, isNotificationMessage, isPersistedRelayMessage, isReactionNotification, isSelfResumeInjection, NOTIFICATION_NUDGE, providerAttachmentText, providerSenderLabel } from "../adapter";
3
+ import { receivesTurnResponseAutomatically } from "../response-routing";
4
+ import { planClaudeMonitorDelivery, type MonitorDeliveryPlan } from "./claude-monitor-delivery-plan";
5
+
6
+ interface ClaudeDeliveryTextOptions {
7
+ // Isolated profiles have no Relay surface, so omit claim instructions and notification nudges.
8
+ relaySurface?: boolean;
9
+ // #1935 — chars already emitted into the SAME batched monitor event ahead of this text (the
10
+ // once-per-session context/workspace block the plugin logs on first delivery). Counted against
11
+ // the per-event cap so the over-limit stub gate accounts for the prefix, not just the message.
12
+ reservedChars?: number;
13
+ // #1935 — enable the over-limit stub gate. Only the monitor delivery path is subject to the
14
+ // harness per-event/per-line caps; the tmux fallback pastes full text straight into the pane and
15
+ // must NOT stub (a monitorless agent may have no get-message surface). Defaults to off; the
16
+ // relay-monitor plugin opts in.
17
+ monitorLimited?: boolean;
18
+ // #2310 — directory to spill an over-cap must-keep (non-fetchable) section's full content to,
19
+ // in place of ever inlining a truncated copy. See planClaudeMonitorDelivery's spillDir doc.
20
+ spillDir?: string;
21
+ }
22
+
23
+ // Server-baked trailing line from taskMessageBody() in src/db.ts. Meaningless to an isolated
24
+ // agent (sole claimant, no relay CLI), so drop it from delivered text when there's no surface.
25
+ const TASK_CLAIM_INSTRUCTION = "Claim this task before working it, then update task status when finished.";
26
+
27
+ function stripRelayScaffolding(body: string): string {
28
+ const bodyLines = body.split("\n");
29
+ const popTrailingBlanks = () => {
30
+ while (bodyLines.length && (bodyLines.at(-1) ?? "").trim() === "") bodyLines.pop();
31
+ };
32
+ popTrailingBlanks();
33
+ if (bodyLines.at(-1) === TASK_CLAIM_INSTRUCTION) {
34
+ bodyLines.pop();
35
+ popTrailingBlanks();
36
+ }
37
+ return bodyLines.join("\n");
38
+ }
39
+
40
+ function formatMessage(message: Message, relaySurface: boolean): string {
41
+ const subject = message.subject ? `Subject: ${message.subject}\n` : "";
42
+ const isMemoryContext = isMemoryInjection(message);
43
+ const rawBody = relaySurface ? message.body : stripRelayScaffolding(message.body);
44
+
45
+ if (isMemoryContext) {
46
+ return [
47
+ `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
48
+ providerAttachmentText(message),
49
+ `${subject}${rawBody}`,
50
+ ].filter(Boolean).join("\n");
51
+ }
52
+
53
+ return [
54
+ receivesTurnResponseAutomatically(message.from) ? undefined : `[relay message #${message.id} from ${providerSenderLabel(message)}]`,
55
+ providerAttachmentText(message),
56
+ `${subject}${rawBody}`,
57
+ ].filter(Boolean).join("\n");
58
+ }
59
+
60
+ // #1935 — a message whose full section would blow the monitor size limit is delivered as a minimal
61
+ // stub: sender, a one-line gist (subject, else the first sentence of the body), thread, and the
62
+ // fetch hint. Only persisted, fetchable, non-memory messages qualify (see isStubbable below) —
63
+ // non-fetchable content is routed by the planner to a spill pointer or a truncation notice instead.
64
+ const STUB_GIST_MAX_CHARS = 200;
65
+
66
+ function clampToLine(text: string, max = STUB_GIST_MAX_CHARS): string {
67
+ const collapsed = text.replace(/\s+/g, " ").trim();
68
+ return collapsed.length > max ? `${collapsed.slice(0, max - 1).trimEnd()}…` : collapsed;
69
+ }
70
+
71
+ function firstSentence(body: string): string {
72
+ const trimmed = body.trim();
73
+ if (!trimmed) return "";
74
+ const match = trimmed.match(/^[\s\S]*?[.!?](?=\s|$)/);
75
+ return clampToLine(match ? match[0] : trimmed);
76
+ }
77
+
78
+ // #2310 — three sub-classes of over-limit section, each routed differently by the planner:
79
+ // fetchable (isStubbable) — persisted (id>0), retrievable via get-message → clean meta-only
80
+ // stub. Covers ordinary messages AND persisted reaction
81
+ // notifications (LOW-7: a persisted reaction must never be
82
+ // silently self-truncated with no way back to its content — it
83
+ // gets the same get-message pointer as any other message).
84
+ // must-keep (everything — no get-message path and no acceptable loss (memory context,
85
+ // else, default) self-resume continuation) → spilled byte-exact to disk and
86
+ // replaced with a small pointer, never raw-emitted, never dropped.
87
+ // disposable (isReactionNotification, — no get-message path but losing it is fine (an
88
+ // non-persisted only) ephemeral, never-persisted reaction) → self-truncated
89
+ // to one event with an explicit notice.
90
+ function isStubbable(message: Message): boolean {
91
+ return isPersistedRelayMessage(message) && !isMemoryInjection(message);
92
+ }
93
+
94
+ function nonStubbableSectionKind(message: Message): "must-keep" | "disposable" {
95
+ return isReactionNotification(message) ? "disposable" : "must-keep";
96
+ }
97
+
98
+ function nonStubbableSectionLabel(message: Message): string {
99
+ if (isSelfResumeInjection(message)) return "self-resume continuation";
100
+ if (isMemoryInjection(message)) return "memory injection";
101
+ if (isReactionNotification(message)) return "reaction notification";
102
+ return "content";
103
+ }
104
+
105
+ // #2844 — a task-delivery notice carries its actionable fields in the payload (see
106
+ // notifyDelivery in src/services/task-delivery.ts), so the stub inlines them: disposition, head
107
+ // sha, blocker count and next action. The coordinator can act without an extra get-message
108
+ // round-trip; the full body stays behind the fetch hint.
109
+ function taskDeliverableSummary(message: Message): string[] | undefined {
110
+ const payload = (message.payload ?? {}) as unknown as Record<string, unknown>;
111
+ if (payload.kind !== "task.deliverable") return undefined;
112
+ const lines: string[] = [];
113
+ const taskId = typeof payload.taskId === "number" ? payload.taskId : undefined;
114
+ const disposition = typeof payload.disposition === "string" && payload.disposition ? payload.disposition : undefined;
115
+ if (taskId !== undefined || disposition) {
116
+ lines.push(clampToLine(`Task${taskId !== undefined ? ` T${taskId}` : ""}${disposition ? ` · ${disposition}` : ""}`));
117
+ }
118
+ const headSha = typeof payload.headSha === "string" && payload.headSha ? payload.headSha : undefined;
119
+ const blockerCount = typeof payload.blockerCount === "number" ? payload.blockerCount : undefined;
120
+ if (headSha || blockerCount !== undefined) {
121
+ lines.push(clampToLine(`Head: ${headSha ?? "?"} · Blockers: ${blockerCount ?? "?"}`));
122
+ }
123
+ const nextAction = typeof payload.nextAction === "string" && payload.nextAction ? payload.nextAction : undefined;
124
+ if (nextAction) lines.push(clampToLine(`Next: ${nextAction}`));
125
+ return lines.length > 0 ? lines : undefined;
126
+ }
127
+
128
+ function stubMessage(message: Message): string {
129
+ const gist = message.subject ? `Subject: ${clampToLine(message.subject)}` : firstSentence(message.body);
130
+ const summary = taskDeliverableSummary(message);
131
+ const fetchTarget = message.messageType === "final-turn" && message.sourceMessageId ? message.sourceMessageId : message.id;
132
+ const finalTurn = message.messageType === "final-turn" ? " | final turn" : "";
133
+ return [
134
+ receivesTurnResponseAutomatically(message.from) ? undefined : `[relay message #${message.id} from ${providerSenderLabel(message)}${finalTurn}]`,
135
+ gist || undefined,
136
+ ...(summary ?? []),
137
+ message.threadId !== undefined ? `Thread: #${message.threadId}` : undefined,
138
+ `Over monitor size limit — full ${message.messageType === "final-turn" ? "turn" : "message"}: relay_get_message #${fetchTarget} (CLI: agent-relay get-message ${fetchTarget})`,
139
+ ].filter(Boolean).join("\n");
140
+ }
141
+
142
+ export function claudeMonitorDeliveryPlan(messages: Message[], options: ClaudeDeliveryTextOptions = {}): MonitorDeliveryPlan {
143
+ const relaySurface = options.relaySurface !== false;
144
+ const uniqueMessages = dedupePersistedMessages(messages);
145
+ const sections = uniqueMessages.map((message) => ({
146
+ id: isPersistedRelayMessage(message) ? message.id : undefined,
147
+ full: formatMessage(message, relaySurface),
148
+ ...(isStubbable(message)
149
+ ? { stub: stubMessage(message) }
150
+ : { kind: nonStubbableSectionKind(message), label: nonStubbableSectionLabel(message) }),
151
+ }));
152
+ if (relaySurface && uniqueMessages.length > 0 && uniqueMessages.every(message => isNotificationMessage(message) && !receivesTurnResponseAutomatically(message.from))) {
153
+ sections.push({ id: undefined, full: NOTIFICATION_NUDGE, kind: "disposable", label: "notification" });
154
+ }
155
+ if (!options.monitorLimited) {
156
+ return { events: [{ text: sections.map((section) => section.full).join("\n\n"), messageIds: sections.flatMap((section) => section.id === undefined ? [] : [section.id]) }] };
157
+ }
158
+ return planClaudeMonitorDelivery(sections, { reservedChars: options.reservedChars, spillDir: options.spillDir });
159
+ }
160
+
161
+ export function claudeProviderMessageText(messages: Message[], options: ClaudeDeliveryTextOptions = {}): string {
162
+ const plan = claudeMonitorDeliveryPlan(messages, options);
163
+ // Tmux is not Monitor-limited and retains its traditional single paste. The Monitor artifact
164
+ // consumes `claudeMonitorDeliveryPlan` directly so it can emit cap-compliant events.
165
+ return plan.events.map((event) => event.text).join("\n\n");
166
+ }
@@ -0,0 +1,94 @@
1
+ import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
+ import { findProviderCatalog, findProviderCatalogModel, type SpawnProvider } from "@humwick/sdk";
3
+ import { tmuxCommand, tmuxHasSession } from "@humwick/sdk/tmux-utils";
4
+ import type { ManagedProcess, RunnerSpawnConfig } from "../adapter";
5
+ import { effortSwitchEnabledFromEnv } from "../config";
6
+ import { providerHomePathFor } from "../profile-home";
7
+ import { submitClaudeContextCommand } from "./claude-context-command";
8
+ import { claudeProbeArgsFor, resolveManagedClaudeTranscriptPathAsync } from "./claude-session-probe";
9
+ import { readIncrementalFileRange, statIncrementalFile } from "./incremental-file";
10
+ import { captureTmuxVisiblePaneAsync, runTmuxInput } from "./tmux-offload";
11
+
12
+ // #2950 — Claude keeps its prompt cache across an effort switch (docs/design/2950-effort-switch-slice.md).
13
+ const EFFORT_CONFIRM_TIMEOUT_MS = 15_000;
14
+ const EFFORT_CONFIRM_POLL_MS = 200;
15
+ // CLAUDE_CODE_EFFORT_LEVEL pins the session: `/effort` then reports "overrides" and changes nothing.
16
+ const CLAUDE_EFFORT_ENV = "CLAUDE_CODE_EFFORT_LEVEL";
17
+
18
+ /** Launch effort as `--effort` while the slice flag is on (carrying an inherited pin over), else the legacy pinning env. */
19
+ export function claudeEffortLaunch(effort: string | undefined, inheritedEnv: Record<string, string | undefined>): { args: string[]; env: Record<string, string>; unset: string[] } {
20
+ if (!effortSwitchEnabledFromEnv()) return { args: [], env: effort ? writeChildEnv({}, { [CLAUDE_EFFORT_ENV]: effort }) : {}, unset: [] };
21
+ const launchEffort = effort ?? relayEnv(inheritedEnv, CLAUDE_EFFORT_ENV)?.trim();
22
+ return { args: launchEffort ? ["--effort", launchEffort] : [], env: {}, unset: [CLAUDE_EFFORT_ENV] };
23
+ }
24
+
25
+ /** Persistent transports can switch effort. Host-base `/effort` would save into the human's own settings. */
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 } : {};
30
+ }
31
+
32
+ interface ClaudeEffortOutcome { applied?: string; message: string }
33
+
34
+ /** After a turn, the TUI asks before switching and warns of a full re-read; measured reuse is 0.999, so accept. */
35
+ export function claudeEffortConfirmDialog(pane: string, effort: string): boolean {
36
+ return pane.includes("Change effort level?") && pane.includes(`1. Yes, switch to ${effort}`);
37
+ }
38
+
39
+ function entryText(entry: Record<string, unknown>): string {
40
+ const content = (entry.message as { content?: unknown } | undefined)?.content ?? entry.content;
41
+ if (typeof content === "string") return content;
42
+ if (!Array.isArray(content)) return "";
43
+ return content.map((part) => (typeof part?.text === "string" ? part.text : "")).join("");
44
+ }
45
+
46
+ /** The stdout `/effort <effort>` recorded in transcript lines written after the submit. */
47
+ export function claudeEffortOutcome(jsonl: string, effort: string): ClaudeEffortOutcome | undefined {
48
+ let ours = false;
49
+ for (const line of jsonl.split("\n")) {
50
+ let entry: Record<string, unknown>;
51
+ try { entry = JSON.parse(line); } catch { continue; }
52
+ const text = entryText(entry);
53
+ if (text.includes("<command-name>/effort</command-name>")) {
54
+ ours = text.includes(`<command-args>${effort}</command-args>`);
55
+ continue;
56
+ }
57
+ const stdout = ours ? /<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/.exec(text)?.[1]?.trim() : undefined;
58
+ if (stdout === undefined) continue;
59
+ const applied = /^Set effort level to ([a-z]+)/.exec(stdout)?.[1] ?? /set to '([a-z]+)' instead/.exec(stdout)?.[1];
60
+ return { ...(applied ? { applied } : {}), message: stdout };
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ export async function switchClaudeEffort(process: ManagedProcess, effort: string): Promise<{ effort: string; message: string; confirmedCacheDialog?: true }> {
66
+ const session = process.meta?.tmuxSession as string | undefined;
67
+ const socket = process.meta?.tmuxSocket as string | undefined;
68
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for effort");
69
+ const transcriptPath = await resolveManagedClaudeTranscriptPathAsync(claudeProbeArgsFor(process, session, socket));
70
+ if (!transcriptPath) throw new Error("Claude transcript is unavailable, so an effort change cannot be confirmed");
71
+ const start = await statIncrementalFile(transcriptPath).then((info) => info.size, () => 0);
72
+ await submitClaudeContextCommand(session, `/effort ${effort}`, socket);
73
+ const deadline = Date.now() + EFFORT_CONFIRM_TIMEOUT_MS;
74
+ let accepted = false;
75
+ while (Date.now() < deadline) {
76
+ await Bun.sleep(EFFORT_CONFIRM_POLL_MS);
77
+ const size = await statIncrementalFile(transcriptPath).then((info) => info.size, () => start);
78
+ const outcome = size > start ? claudeEffortOutcome(new TextDecoder().decode(await readIncrementalFileRange(transcriptPath, start, size)), effort) : undefined;
79
+ if (!outcome) {
80
+ if (!accepted && claudeEffortConfirmDialog(await captureTmuxVisiblePaneAsync(session, socket).catch(() => ""), effort)) {
81
+ await runTmuxInput("tmux-effort-confirm", tmuxCommand(socket, "send-keys", "-t", session, "1"));
82
+ accepted = true;
83
+ }
84
+ continue;
85
+ }
86
+ if (!outcome.applied) throw new Error(`Claude refused the effort change: ${outcome.message}`);
87
+ return { effort: outcome.applied, message: outcome.message, ...(accepted ? { confirmedCacheDialog: true } : {}) };
88
+ }
89
+ // Never leave the dialog up: the next delivery would type into it.
90
+ if ((await captureTmuxVisiblePaneAsync(session, socket).catch(() => "")).includes("Change effort level?")) {
91
+ await runTmuxInput("tmux-effort-cancel", tmuxCommand(socket, "send-keys", "-t", session, "Escape"));
92
+ }
93
+ throw new Error(`Claude did not confirm /effort ${effort} within ${EFFORT_CONFIRM_TIMEOUT_MS}ms`);
94
+ }