@humwick/runner 0.0.0-stage → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +46 -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,105 @@
1
+ import { PROVIDER_TURN_EMPTY_TIMELINE_STATUS, PROVIDER_TURN_WATCHDOG_TIMELINE_STATUS, RECONCILED_TURN_TIMELINE_STATUS } from "@humwick/sdk";
2
+ import type { BusyPhase, LivenessSignal } from "@humwick/sdk";
3
+
4
+ // #2010 — these timeline statuses are the runner ADMITTING it could not confirm progress (a
5
+ // held-but-unproven turn), an end (a force-cleared one), or — #2389 — a turn that demonstrably
6
+ // produced NOTHING. Folding their `timestamp` in as
7
+ // progress would be circular: the watchdog fires exactly when the runner has NO positive
8
+ // evidence, yet its own publish (`timestamp: now`) would otherwise stamp that same status frame
9
+ // as freshly-progressing, poisoning any consumer (e.g. the server's prolonged-hold coordinator
10
+ // wake, #2010) that reads `lastProgressAt` off it. Every OTHER timeline transition (e.g. Claude's
11
+ // `compacting`) is genuine forward progress and must keep counting.
12
+ // #2389 belongs here most sharply of the three: an empty-turn event is the runner reporting the
13
+ // ABSENCE of output, so stamping its own publish as fresh progress would let the frame that says
14
+ // "this agent did nothing" be read as evidence that it just did something.
15
+ const NON_PROGRESS_TIMELINE_STATUSES: ReadonlySet<string> = new Set([
16
+ PROVIDER_TURN_WATCHDOG_TIMELINE_STATUS,
17
+ RECONCILED_TURN_TIMELINE_STATUS,
18
+ PROVIDER_TURN_EMPTY_TIMELINE_STATUS,
19
+ ]);
20
+
21
+ // The raw runner-tracked signals an adapter folds into a normalized LivenessSignal.
22
+ // This is the INPUT to the contract computation (#746): the runner owns this state
23
+ // (transport frames, the last provider event, the pending timeline transition, the
24
+ // active-work set), passes it to the adapter's `liveness()` verdict, and ships the
25
+ // resulting LivenessSignal on the agent card. Provider-raw interpretation stops here —
26
+ // nothing above the runner ever reads these fields again.
27
+ export interface LivenessInputs {
28
+ /** Runner→server transport frame/ack timestamps (sdk BusClient.transportState). */
29
+ transport?: { lastServerFrameAt?: number; lastHeartbeatAckAt?: number };
30
+ /** Epoch-ms of the most recent provider status/token event the runner observed. */
31
+ lastProviderEventAt?: number;
32
+ /** The most recent lifecycle/timeline transition the runner published (status + ts). */
33
+ timelineEvent?: { status?: string; timestamp?: number };
34
+ /** Active work records. The adapter classifies its own kinds; `provider-turn` is the
35
+ * bare turn, anything else (subagent, background-script, tool, subprocess) is live work. */
36
+ activeWork: Array<{ kind?: string; startedAt?: number }>;
37
+ /** Fallback progress floor when no other progress evidence exists yet (e.g. work start). */
38
+ fallbackProgressAt?: number;
39
+ /** #1980 — the runner's coarse provider status (`claims.currentStatus()`), used together with
40
+ * `providerBlocked` + `activeWork` to derive {@link LivenessSignal.busyPhase}. Provider-agnostic:
41
+ * `busy` + a live provider-turn ⇒ `active` (the provider is doing turn work — including a
42
+ * token-silent foreground `shell` gate), regardless of which provider. Omitted → no phase. */
43
+ providerStatus?: "idle" | "busy" | "offline" | "error";
44
+ /** #1980 — the runner's provider-hold flag (rate-limit / pendingQuestion / a `blocked`
45
+ * providerState, e.g. Claude `waiting`). When set, the phase is `blocked` — the provider is
46
+ * parked, not actively computing — and takes precedence over `active`. */
47
+ providerBlocked?: boolean;
48
+ }
49
+
50
+ function num(value: unknown): number {
51
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
52
+ }
53
+
54
+ /**
55
+ * The shared liveness fold (#725 / #746). Both reference adapters (Claude #713, Codex #724)
56
+ * route through this so the verdict is computed in ONE place; each adapter's `liveness()`
57
+ * decides WHICH raw signals it feeds in (Claude surfaces a `compacting` timeline transition
58
+ * as progress; Codex surfaces live subprocess work-kinds). The fold itself is provider-
59
+ * agnostic, which is the point of the contract: a new provider only has to feed its signals.
60
+ */
61
+ export function computeLivenessSignal(inputs: LivenessInputs): LivenessSignal {
62
+ const transportLiveAt = Math.max(
63
+ num(inputs.transport?.lastServerFrameAt),
64
+ num(inputs.transport?.lastHeartbeatAckAt),
65
+ );
66
+ // Progress = provider tokens OR a lifecycle/timeline transition (e.g. native compaction,
67
+ // #713) — whichever is latest, with the caller-supplied floor as the baseline. #2010: a
68
+ // timeline event that is itself an admission of no-confirmed-progress (the prolonged-hold
69
+ // watchdog, or a reconciled/force-cleared turn) must NOT count as progress — see
70
+ // NON_PROGRESS_TIMELINE_STATUSES above.
71
+ const timelineStatus = inputs.timelineEvent?.status;
72
+ const timelineProgressAt = timelineStatus !== undefined && NON_PROGRESS_TIMELINE_STATUSES.has(timelineStatus)
73
+ ? 0
74
+ : num(inputs.timelineEvent?.timestamp);
75
+ const lastProgressAt = Math.max(
76
+ num(inputs.lastProviderEventAt),
77
+ timelineProgressAt,
78
+ num(inputs.fallbackProgressAt),
79
+ );
80
+ // Any work-kind that is NOT the bare provider turn is live tool/subprocess work (#724):
81
+ // a wedged provider turn with a live `bun test` child must read as progressing.
82
+ const hasLiveWork = inputs.activeWork.some((work) => Boolean(work.kind && work.kind !== "provider-turn"));
83
+ const busyPhase = deriveBusyPhase(inputs, hasLiveWork);
84
+ return { transportLiveAt, lastProgressAt, hasLiveWork, ...(busyPhase ? { busyPhase } : {}) };
85
+ }
86
+
87
+ /**
88
+ * #1980 / #1069 — derive the coarse {@link BusyPhase} from the runner's own state. Provider-
89
+ * agnostic by construction (the point of the #746 contract): a live `provider-turn` while the
90
+ * runner status is `busy` is `active` for EVERY provider — for Claude that already covers both
91
+ * `busy` (thinking) and `shell` (a foreground gate), and for Codex it covers `inProgress`,
92
+ * because the adapter mints/holds the `provider-turn` work-kind for exactly those states. A
93
+ * provider hold (`providerBlocked`) is `blocked` and wins over `active` (a parked turn is not
94
+ * computing). Otherwise, live background-script work with no active turn is `passive` (#1856).
95
+ * `hasLiveWork` stays the narrower "is there background-script work" projection so its existing
96
+ * stuck-busy veto (#724, a live `bun test` child during an active turn) is unchanged; `passive`
97
+ * always implies `hasLiveWork`, but `active` deliberately does not.
98
+ */
99
+ function deriveBusyPhase(inputs: LivenessInputs, hasLiveWork: boolean): BusyPhase | undefined {
100
+ if (inputs.providerBlocked) return "blocked";
101
+ const hasProviderTurn = inputs.activeWork.some((work) => work.kind === "provider-turn");
102
+ if (inputs.providerStatus === "busy" && hasProviderTurn) return "active";
103
+ if (hasLiveWork) return "passive";
104
+ return undefined;
105
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { sanitizeFsName } from "@humwick/sdk/fs-name";
4
+ import { logDirFromEnv, logLevelFromEnv } from "./config";
5
+ import { appendBoundedLog, RUNNER_LOG_MAX_BYTES, RUNNER_LOG_RECORD_MAX_BYTES } from "./bounded-log";
6
+
7
+ // Phase 1 observability (#198): one leveled, runtime-togglable logger for the
8
+ // Runner and the provider adapters below it. Replaces the ad-hoc scatter of
9
+ // `console.error`, `logRunnerDiagnostic` (-> runner-<agent>.log) and
10
+ // `sessionLog`/`sessionDebug` (-> session-mirror-<agent>.log) with a single
11
+ // switch and a single greppable, ANSI-free sink.
12
+ //
13
+ // Sink: the per-agent `session-mirror-<agent>.log` — the file the orchestrator
14
+ // already surfaces to the dashboard log-viewer (captureSessionMirror). One place
15
+ // to look when anything in the Runner misbehaves.
16
+ //
17
+ // Level is read once from AGENT_RELAY_LOG_LEVEL (default "info") and can be
18
+ // flipped at runtime via the control port (no restart) — so a phase refactor can
19
+ // be watched at debug without bouncing the agent.
20
+
21
+ type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
22
+
23
+ const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40, fatal: 50 };
24
+ export const LOG_LEVELS = Object.keys(ORDER) as LogLevel[];
25
+
26
+ export function parseLogLevel(value: string | undefined | null): LogLevel | undefined {
27
+ if (!value) return undefined;
28
+ const v = value.trim().toLowerCase();
29
+ return (LOG_LEVELS as string[]).includes(v) ? (v as LogLevel) : undefined;
30
+ }
31
+
32
+ // Shared with the orchestrator (session-mirror reader) + runner outbox via the
33
+ // SDK helper, so all sites resolve the identical filename for a given agent id.
34
+ function safeLogName(value: string): string {
35
+ return sanitizeFsName(value, { replacement: "_", maxLen: 180 });
36
+ }
37
+
38
+ interface LoggerConfig {
39
+ agentId?: string;
40
+ level?: LogLevel;
41
+ headless?: boolean;
42
+ logDir?: string;
43
+ logFileMaxBytes?: number;
44
+ }
45
+
46
+ export class Logger {
47
+ private level: LogLevel;
48
+ private agentId: string;
49
+ private headless: boolean;
50
+ private logDir: string;
51
+ private readonly logFileMaxBytes: number;
52
+ private initializedPath?: string;
53
+
54
+ constructor(config: LoggerConfig = {}) {
55
+ this.level = config.level ?? parseLogLevel(logLevelFromEnv()) ?? "info";
56
+ this.agentId = config.agentId ?? "runner";
57
+ this.headless = config.headless ?? false;
58
+ this.logDir = config.logDir ?? logDirFromEnv() ?? join(process.env.HOME || ".", ".agent-relay", "logs");
59
+ this.logFileMaxBytes = config.logFileMaxBytes ?? RUNNER_LOG_MAX_BYTES;
60
+ }
61
+
62
+ // Bind the logger to a concrete agent once the runner knows its id. Preserves a
63
+ // level already set via env/runtime unless an explicit level is passed.
64
+ configure(config: LoggerConfig): void {
65
+ if (config.agentId !== undefined) this.agentId = config.agentId;
66
+ if (config.headless !== undefined) this.headless = config.headless;
67
+ if (config.logDir !== undefined) this.logDir = config.logDir;
68
+ if (config.level !== undefined) this.level = config.level;
69
+ }
70
+
71
+ setLevel(level: LogLevel): void { this.level = level; }
72
+ getLevel(): LogLevel { return this.level; }
73
+ isEnabled(level: LogLevel): boolean { return ORDER[level] >= ORDER[this.level]; }
74
+
75
+ debug(component: string, message: string): void { this.log("debug", component, message); }
76
+ info(component: string, message: string): void { this.log("info", component, message); }
77
+ warn(component: string, message: string): void { this.log("warn", component, message); }
78
+ error(component: string, message: string): void { this.log("error", component, message); }
79
+ fatal(component: string, message: string): void { this.log("fatal", component, message); }
80
+
81
+ log(level: LogLevel, component: string, message: string): void {
82
+ if (!this.isEnabled(level)) return;
83
+ const line = `[${new Date().toISOString()}] ${level.toUpperCase().padEnd(5)} [${component.slice(0, 256)}] ${oneLine(message.slice(0, RUNNER_LOG_RECORD_MAX_BYTES))}${message.length > RUNNER_LOG_RECORD_MAX_BYTES ? " …[truncated]" : ""}\n`;
84
+ try {
85
+ const path = join(this.logDir, `session-mirror-${safeLogName(this.agentId)}.log`);
86
+ const firstWrite = this.initializedPath !== path;
87
+ if (firstWrite) mkdirSync(this.logDir, { recursive: true });
88
+ appendBoundedLog(path, line, this.logFileMaxBytes, firstWrite);
89
+ this.initializedPath = path;
90
+ } catch {
91
+ this.initializedPath = undefined;
92
+ // Best-effort. If the per-agent file can't be written, surface error/fatal to
93
+ // stderr so it is not lost entirely (headless: lands in the orchestrator log).
94
+ if (ORDER[level] >= ORDER.error) { try { console.error(line.trimEnd()); } catch { /* give up */ } }
95
+ }
96
+ }
97
+ }
98
+
99
+ // Newlines would split one record across several log lines and break greppability;
100
+ // collapse them so a multi-line message stays one line.
101
+ function oneLine(message: string): string {
102
+ return message.replace(/\r?\n/g, " ⏎ ");
103
+ }
104
+
105
+ // Process-global logger. A runner process serves exactly one agent, so a singleton
106
+ // is the right scope; the runner calls configure() once it knows its id, and
107
+ // adapters import this instance directly (no constructor threading).
108
+ export const logger = new Logger();
@@ -0,0 +1,204 @@
1
+ import type { SendMessageInput } from "@humwick/sdk";
2
+ import type { HumwickHttpClient } from "@humwick/sdk";
3
+ import { errMessage, resendOnRotatedToken } from "@humwick/sdk";
4
+ import { relayMcpEndpoint } from "./relay-mcp";
5
+ import type { OutboxRecord } from "./outbox";
6
+ import { deliverContinuationArchiveRecord } from "./continuation-archive";
7
+ import { logger } from "./logger";
8
+ import { isDeterministicClientError, isHttpAuthError, isHttpStatusError } from "./runner-helpers";
9
+
10
+ interface RunnerOutboxDelivery {
11
+ record: OutboxRecord;
12
+ http: HumwickHttpClient;
13
+ relayUrl: string;
14
+ getToken?(): string | undefined;
15
+ updatePayload(seq: number, payload: unknown): void;
16
+ sessionLog(message: string): void;
17
+ recoverRuntimeTokenAfterAuthFailure(source: string): void;
18
+ }
19
+
20
+ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Promise<void> {
21
+ const { record, http } = input;
22
+ try {
23
+ if (record.kind === "session-message") {
24
+ await http.sendMessage({
25
+ ...(record.payload as SendMessageInput),
26
+ occurredAt: record.occurredAt,
27
+ idempotencyKey: record.idempotencyKey,
28
+ });
29
+ return;
30
+ }
31
+ if (record.kind === "session-message-batch") {
32
+ const messages = Array.isArray((record.payload as { messages?: unknown }).messages)
33
+ ? (record.payload as { messages: SendMessageInput[] }).messages
34
+ : [];
35
+ const batch = messages.map((message, index) => ({
36
+ ...message,
37
+ occurredAt: typeof message.occurredAt === "number" ? message.occurredAt : record.occurredAt,
38
+ idempotencyKey: message.idempotencyKey ?? `${record.idempotencyKey}:${index}`,
39
+ }));
40
+ try {
41
+ await http.sendMessages(batch);
42
+ } catch (error) {
43
+ if (!isDeterministicClientError(error)) throw error;
44
+ await deliverSessionBatchItems(input, batch);
45
+ }
46
+ return;
47
+ }
48
+ if (record.kind === "insight") {
49
+ await http.recordInsightObservation({
50
+ ...(record.payload as Parameters<HumwickHttpClient["recordInsightObservation"]>[0]),
51
+ occurredAt: record.occurredAt,
52
+ });
53
+ return;
54
+ }
55
+ if (record.kind === "continuation-archive") {
56
+ await deliverContinuationArchiveRecord({
57
+ record,
58
+ http,
59
+ updatePayload: input.updatePayload,
60
+ sessionLog: input.sessionLog,
61
+ });
62
+ return;
63
+ }
64
+ if (record.kind === "context-reset") {
65
+ const { agentId, ...fact } = record.payload as { agentId: string } & Parameters<HumwickHttpClient["recordContextReset"]>[1];
66
+ await http.recordContextReset(agentId, fact);
67
+ return;
68
+ }
69
+ if (record.kind === "mcp-tool-call") {
70
+ await deliverBufferedMcpCall(input);
71
+ return;
72
+ }
73
+ logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
74
+ } catch (error) {
75
+ if (isHttpStatusError(error, 409)) {
76
+ const reason = errMessage(error);
77
+ if (record.kind === "session-message-batch") {
78
+ const messages = sessionBatchMessages(record);
79
+ logger.error("outbox", `relay reported duplicate session batch; acking row seq=${record.seq} batchKey=${record.idempotencyKey} itemKeys=${sessionBatchItemKeys(record).join(",")} error=${reason}`);
80
+ await emitMirrorDropNotice(input, messages, messages.length, `duplicate idempotency key: ${reason}`, "duplicate-batch");
81
+ } else if (record.kind === "session-message") {
82
+ const message = record.payload as SendMessageInput;
83
+ logger.error("outbox", `relay reported duplicate session message; acking row seq=${record.seq} key=${record.idempotencyKey} error=${reason}`);
84
+ await emitMirrorDropNotice(input, [message], 1, `duplicate idempotency key: ${reason}`, "duplicate-message");
85
+ }
86
+ return;
87
+ }
88
+ if (isHttpAuthError(error)) input.recoverRuntimeTokenAfterAuthFailure("outbox");
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ async function deliverSessionBatchItems(input: RunnerOutboxDelivery, messages: SendMessageInput[]): Promise<void> {
94
+ const dropped: Array<{ message: SendMessageInput; reason: string }> = [];
95
+ for (const message of messages) {
96
+ try {
97
+ await input.http.sendMessage(message);
98
+ } catch (error) {
99
+ if (!isDeterministicClientError(error)) throw error;
100
+ const reason = errMessage(error);
101
+ logger.error("outbox", `dropping invalid session batch item key=${message.idempotencyKey ?? "(none)"} error=${reason}`);
102
+ dropped.push({ message, reason });
103
+ }
104
+ }
105
+ for (const item of dropped) {
106
+ await emitMirrorDropNotice(input, [item.message], 1, item.reason, "invalid-item");
107
+ }
108
+ }
109
+
110
+ // Deterministic session validation rejections are classified once in runner-helpers.ts
111
+ // (isDeterministicClientError) and shared with the outbox poison policy (#2433).
112
+
113
+ function sessionBatchItemKeys(record: OutboxRecord): string[] {
114
+ const messages = sessionBatchMessages(record);
115
+ return messages.map((message, index) => message.idempotencyKey ?? `${record.idempotencyKey}:${index}`);
116
+ }
117
+
118
+ function sessionBatchMessages(record: OutboxRecord): SendMessageInput[] {
119
+ return Array.isArray((record.payload as { messages?: unknown }).messages)
120
+ ? (record.payload as { messages: SendMessageInput[] }).messages
121
+ : [];
122
+ }
123
+
124
+ // The subset of RunnerOutboxDelivery the notice seam needs — also satisfied by the narrower
125
+ // Outbox.onPoison call site (#2433), which only holds the poisoned record + the HTTP client.
126
+ type MirrorDropNoticeSource = Pick<RunnerOutboxDelivery, "record"> & { http: HumwickHttpClient };
127
+
128
+ async function emitMirrorDropNotice(input: MirrorDropNoticeSource, messages: SendMessageInput[], count: number, reason: string, suffix: string): Promise<void> {
129
+ if (count <= 0) return;
130
+ const anchor = messages[0];
131
+ const from = typeof anchor?.from === "string" && anchor.from ? anchor.from : "system";
132
+ const to = typeof anchor?.to === "string" && anchor.to ? anchor.to : "user";
133
+ const occurredAt = typeof anchor?.occurredAt === "number" ? anchor.occurredAt : input.record.occurredAt;
134
+ const body = `${count} steps could not be mirrored: ${reason}`;
135
+ const idempotencyKey = `session-mirror-drop:${input.record.idempotencyKey}:${suffix}:${shortHash(body)}`;
136
+ logger.error("outbox", `session mirror drop notice seq=${input.record.seq} count=${count} reason=${reason} noticeKey=${idempotencyKey}`);
137
+ try {
138
+ await input.http.sendMessage({
139
+ from,
140
+ to,
141
+ kind: "session",
142
+ body,
143
+ idempotencyKey,
144
+ occurredAt,
145
+ payload: { session: { type: "notice", origin: "provider", label: "mirror-drop", stepId: idempotencyKey } },
146
+ });
147
+ } catch (error) {
148
+ logger.error("outbox", `failed to emit session mirror drop notice seq=${input.record.seq}; acking original row anyway: ${errMessage(error)}`);
149
+ }
150
+ }
151
+
152
+ function shortHash(value: string): string {
153
+ let h = 0x811c9dc5;
154
+ for (let i = 0; i < value.length; i++) {
155
+ h ^= value.charCodeAt(i);
156
+ h = Math.imul(h, 0x01000193);
157
+ }
158
+ return (h >>> 0).toString(36);
159
+ }
160
+
161
+ // #2433 — the Outbox.onPoison seam for SESSION rows. A permanently-dropped session-message /
162
+ // batch is REAL report-up/mirror loss (chat replyTo lineage rides the session outbox), so
163
+ // surface it in-session through the same notice channel as duplicate/invalid drops instead
164
+ // of only in a FATAL host log. Non-session kinds carry no chat lineage to notify about.
165
+ export async function emitSessionPoisonNotice(input: MirrorDropNoticeSource, reason: string): Promise<void> {
166
+ const { record } = input;
167
+ if (record.kind === "session-message-batch") {
168
+ const messages = sessionBatchMessages(record);
169
+ await emitMirrorDropNotice(input, messages, messages.length, reason, "poisoned");
170
+ return;
171
+ }
172
+ if (record.kind === "session-message") {
173
+ await emitMirrorDropNotice(input, [record.payload as SendMessageInput], 1, reason, "poisoned");
174
+ }
175
+ }
176
+
177
+ export async function deliverBufferedMcpCall(input: {
178
+ record: OutboxRecord;
179
+ relayUrl: string;
180
+ getToken?(): string | undefined;
181
+ recoverRuntimeTokenAfterAuthFailure(source: string): void;
182
+ }): Promise<void> {
183
+ const payload = input.record.payload as { tool: string; arguments: Record<string, unknown> };
184
+ const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: payload.tool, arguments: payload.arguments } });
185
+ const response = await resendOnRotatedToken(() => input.getToken?.(), (token) => fetch(relayMcpEndpoint(input.relayUrl), {
186
+ method: "POST",
187
+ headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
188
+ body,
189
+ }));
190
+ if (response.status === 401 || response.status === 403) {
191
+ input.recoverRuntimeTokenAfterAuthFailure("mcp-outbox");
192
+ throw new Error(`relay rejected buffered ${payload.tool} with ${response.status}`);
193
+ }
194
+ if (response.status >= 500) throw new Error(`relay ${response.status} on buffered ${payload.tool}`);
195
+ if (!response.ok) {
196
+ const body = await response.text().catch(() => "");
197
+ logger.warn("mcp-outbox", `buffered ${payload.tool} permanently rejected (${response.status}); dropping: ${body.slice(0, 200)}`);
198
+ return;
199
+ }
200
+ const json = await response.json().catch(() => null) as { error?: { message?: string } } | null;
201
+ if (json?.error) {
202
+ logger.warn("mcp-outbox", `buffered ${payload.tool} returned a tool error; dropping: ${json.error.message ?? "(no detail)"}`);
203
+ }
204
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { basename, isAbsolute } from "node:path";
4
+ import { PORTABLE_SHARED_MCP_SERVER_NAMES, activeSharedMcpServers, portableMcpDescriptorPath, type PortableMcpDescriptor, type ProvisioningMcpServer } from "@humwick/sdk";
5
+
6
+ // #2729 — a registry descriptor is authored on one host (e.g. the hub's `~/.npm-global/bin/<server>`)
7
+ // but launched on any. A stdio command that does not exist on THIS host would reach the agent as a
8
+ // broken server ("MCP server failed to start (ENOENT)"). A core shared server (tokenlean/...) is
9
+ // swapped for the descriptor this host's orchestrator provisioned (same binary + URL the shared
10
+ // callmux bridge runs); anything else is dropped with a warning.
11
+
12
+ const SCRIPT_INTERPRETERS = new Set(["node", "bun", "python", "python3", "tsx"]);
13
+ const warned = new Set<string>();
14
+
15
+ function hasUnresolvedPlaceholder(value: string): boolean {
16
+ return value.includes("$") || value.startsWith("~");
17
+ }
18
+
19
+ /**
20
+ * The first absolute path that makes this stdio descriptor unlaunchable here, or undefined. Only
21
+ * absolute paths are judged: they are the ones that go stale across hosts. Bare and relative
22
+ * commands resolve through the provider's own PATH/cwd rules, which are never second-guessed.
23
+ */
24
+ export function missingMcpExecutable(server: ProvisioningMcpServer): string | undefined {
25
+ const command = server.command;
26
+ if (!command || server.url || hasUnresolvedPlaceholder(command)) return undefined;
27
+ if (isAbsolute(command) && !existsSync(command)) return command;
28
+ if (!SCRIPT_INTERPRETERS.has(basename(command))) return undefined;
29
+ const script = server.args?.find((arg) => !arg.startsWith("-"));
30
+ return script && isAbsolute(script) && !hasUnresolvedPlaceholder(script) && !existsSync(script) ? script : undefined;
31
+ }
32
+
33
+ export interface McpPreflightOptions {
34
+ /** This launch carries the shared callmux bridge. */
35
+ sharedListener: boolean;
36
+ /** Names exempt from the check (their command is provisioned after launch resolution). */
37
+ skip?: readonly string[];
38
+ /** AGENT_RELAY_HOME of this host, where the orchestrator records provisioned core descriptors. */
39
+ home?: string;
40
+ }
41
+
42
+ function isCoreName(name: string): boolean {
43
+ return (PORTABLE_SHARED_MCP_SERVER_NAMES as readonly string[]).includes(name);
44
+ }
45
+
46
+ function readHostDescriptor(home: string | undefined, name: string): PortableMcpDescriptor | undefined {
47
+ if (!home || !isCoreName(name)) return undefined;
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(portableMcpDescriptorPath(home, name), "utf8")) as PortableMcpDescriptor;
50
+ return typeof parsed.command === "string" && !missingMcpExecutable(parsed) ? parsed : undefined;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ function warnOnce(name: string, path: string, message: string): void {
57
+ const key = `${name}\0${path}`;
58
+ if (warned.has(key)) return;
59
+ warned.add(key);
60
+ console.warn(`[runner] MCP server "${name}" on host ${hostname()}: ${path} does not exist here; ${message}`);
61
+ }
62
+
63
+ export function preflightProvisionedMcpServers(
64
+ servers: Record<string, ProvisioningMcpServer>,
65
+ opts: McpPreflightOptions,
66
+ ): Record<string, ProvisioningMcpServer> {
67
+ const kept: Record<string, ProvisioningMcpServer> = {};
68
+ for (const [name, server] of Object.entries(activeSharedMcpServers(servers))) {
69
+ const missing = opts.skip?.includes(name) ? undefined : missingMcpExecutable(server);
70
+ if (!missing) {
71
+ kept[name] = server;
72
+ continue;
73
+ }
74
+ if (server.serviceAccess?.services.length) throw new Error(`unsupported_execution_boundary:${name}`);
75
+ const local = readHostDescriptor(opts.home, name);
76
+ if (local) {
77
+ const { command: _command, args: _args, env: _env, configEnv: _configEnv, ...rest } = server;
78
+ kept[name] = { ...rest, command: local.command, ...(local.args ? { args: local.args } : {}), ...(local.env ? { env: local.env } : {}) };
79
+ warnOnce(name, missing, `using this host's provisioned ${local.command}`);
80
+ continue;
81
+ }
82
+ const viaBridge = opts.sharedListener && isCoreName(name);
83
+ warnOnce(name, missing, `omitted, ${viaBridge ? "its tools are served by the shared callmux bridge" : "no host-local fallback, so it is not provided to this agent"}`);
84
+ }
85
+ return kept;
86
+ }
@@ -0,0 +1,54 @@
1
+ import type { ProvisioningMcpServer } from "@humwick/sdk";
2
+ import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
3
+ import {
4
+ launchProvisionedMcpServers,
5
+ resolveMcpSourceShadows,
6
+ resolveProjectMcpServers,
7
+ type ResolvedSourceShadow,
8
+ } from "./provisioning";
9
+ import { isRelayMcpServerName, SHARED_MCP_SERVER_NAME, resolveSharedMcpUrl, sharedMcpBridgeServer } from "./relay-mcp";
10
+
11
+ export interface McpSourceSelection {
12
+ /** Higher-priority declarations kept separate for serializers with native host passthrough. */
13
+ declaredMcp: Record<string, ProvisioningMcpServer>;
14
+ hostMcp: Record<string, ProvisioningMcpServer>;
15
+ /** Flattened selection for providers that materialize host and declared servers together. */
16
+ effectiveMcp: Record<string, ProvisioningMcpServer>;
17
+ relayEndpoint: boolean;
18
+ mcpServers: string[];
19
+ projectServers: string[];
20
+ hostMcpServers: string[];
21
+ sharedListenerUrl?: string;
22
+ sourceShadows: ResolvedSourceShadow[];
23
+ }
24
+
25
+ // #2368 — providers load their native host declarations, then share this precedence policy.
26
+ // Serialization, service-access overlays and tool deferral consume the selected sources.
27
+ export function resolveMcpSources(
28
+ config: RunnerSpawnConfig,
29
+ hostMcp: Record<string, ProvisioningMcpServer>,
30
+ ): McpSourceSelection {
31
+ const provisionedMcp = launchProvisionedMcpServers(config);
32
+ const mcpServers = Object.keys(provisionedMcp).filter((name) => !isRelayMcpServerName(name));
33
+ const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
34
+ const projectMcpRaw = resolveProjectMcpServers(config);
35
+ const projectMcp = Object.fromEntries(
36
+ Object.entries(projectMcpRaw).filter(
37
+ ([name]) => !isRelayMcpServerName(name) && !(name in provisionedMcp),
38
+ ),
39
+ );
40
+ const projectServers = Object.keys(projectMcp);
41
+
42
+ // #2114 — sharedMcp is a resolved sibling opt-in, independent of ambient H.mcp.
43
+ const sharedListenerUrl = config.agentProfile?.sharedMcp ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
44
+ const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
45
+ ? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
46
+ : {};
47
+ const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
48
+ const effectiveMcp = { ...hostMcp, ...declaredMcp };
49
+ const hostMcpServers = Object.keys(hostMcp).filter(
50
+ (name) => !isRelayMcpServerName(name) && !(name in declaredMcp),
51
+ );
52
+ const sourceShadows = resolveMcpSourceShadows(projectMcpRaw, provisionedMcp, hostMcp, declaredMcp, sharedMcp, relayEndpoint);
53
+ return { declaredMcp, hostMcp, effectiveMcp, relayEndpoint, mcpServers, projectServers, hostMcpServers, sharedListenerUrl, sourceShadows };
54
+ }
@@ -0,0 +1,35 @@
1
+ import { getManifest } from "@humwick/providers";
2
+ import { CODEX_WORKER_ONDEMAND_TOOLS, isCodexLeanWorkerProfile, type AgentProfile } from "@humwick/sdk";
3
+ import { profileWebToolEnabled, WEB_TOOL_NAMES } from "@humwick/sdk/web-tools";
4
+
5
+ export const META_ONLY_CALLMUX_DEFERRAL_ROUTE = "meta-only-callmux" as const;
6
+
7
+ /** Resolve the route paired with runner-side manifest narrowing. A declaration that would hide
8
+ * tools without preserving them downstream is a launch error, never a degraded tool surface. */
9
+ export function runnerManagedToolDeferralRoute(provider: string): typeof META_ONLY_CALLMUX_DEFERRAL_ROUTE | undefined {
10
+ const manifest = getManifest(provider)?.mcpToolManifest;
11
+ if (manifest?.runnerManagedToolDeferral !== true) return undefined;
12
+ if (manifest.runnerManagedToolDeferralRoute !== META_ONLY_CALLMUX_DEFERRAL_ROUTE) {
13
+ throw new Error(
14
+ `provider ${provider} declares runnerManagedToolDeferral=true but has no paired meta-only callmux route`,
15
+ );
16
+ }
17
+ return manifest.runnerManagedToolDeferralRoute;
18
+ }
19
+
20
+ /** The exact production decision for hiding the on-demand Relay set from the direct MCP client. */
21
+ export function runnerManagedRelayOnDemandTools(
22
+ provider: string,
23
+ profile: (Pick<AgentProfile, "name" | "sharedMcp" | "webTools"> & { relay?: { mcp?: boolean } }) | undefined,
24
+ ): Set<string> | undefined {
25
+ const route = runnerManagedToolDeferralRoute(provider);
26
+ if (
27
+ !route
28
+ || !isCodexLeanWorkerProfile(profile?.name)
29
+ || !profile?.sharedMcp
30
+ || profile.relay?.mcp === false
31
+ // #3024 — a switched-off web tool keeps the flat bridge (launch-assembly), so nothing fronts this set.
32
+ || WEB_TOOL_NAMES.some((name) => !profileWebToolEnabled(profile, name))
33
+ ) return undefined;
34
+ return new Set(CODEX_WORKER_ONDEMAND_TOOLS);
35
+ }
@@ -0,0 +1,25 @@
1
+ import { writeChildEnv } from "@humwick/sdk/env";
2
+ import type { ProvisioningMcpServer } from "@humwick/sdk";
3
+ import { shellQuote } from "@humwick/sdk/shell-utils";
4
+
5
+ /** Codex has env_vars inheritance but no Claude-style interpolation in stdio env. */
6
+ export function codexVaultMcpEnv(server: ProvisioningMcpServer): ProvisioningMcpServer {
7
+ const env = writeChildEnv({}, { ...server.env });
8
+ const assignments: string[] = [];
9
+ const slots = new Set<string>();
10
+ for (const [key, value] of Object.entries(env)) {
11
+ const match = /^\$\{(AGENT_RELAY_VAULT_[A-F0-9]{24})\}$/.exec(value);
12
+ if (!match) continue;
13
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || !server.command) throw new Error("Invalid vault MCP environment projection");
14
+ assignments.push(`export ${key}="\${${match[1]}:?}"`);
15
+ slots.add(match[1]!);
16
+ writeChildEnv(env, {}, [key]);
17
+ }
18
+ if (!slots.size) return server;
19
+ const inherited = Array.isArray(server.env_vars) ? server.env_vars : Object.keys(server.env_vars ?? {});
20
+ return {
21
+ ...server, command: "/bin/sh",
22
+ args: ["-c", `${assignments.join("\n")}\nunset ${[...slots].map(shellQuote).join(" ")}\nexec "$@"`, "relay-mcp", server.command!, ...(server.args ?? [])],
23
+ env, env_vars: [...new Set([...inherited, ...slots])],
24
+ };
25
+ }
@@ -0,0 +1,16 @@
1
+ import { relayEnv } from "@humwick/sdk/env";
2
+ export function messageBodyMaxCharsFromEnv(): string | undefined {
3
+ return relayEnv(process.env, "AGENT_RELAY_MESSAGE_BODY_MAX_CHARS");
4
+ }
5
+
6
+ // #1935 — the Claude Code monitor delivery caps (empirically measured against the live harness on
7
+ // 2026-08-02: ~3000 chars per delivered event, ~500 chars per line). Overridable per deployment,
8
+ // since the right ceiling tracks the host's harness build — set on the host/orchestrator that
9
+ // spawns runners. See DEFAULT_MONITOR_DELIVERY_* in adapter.ts for the resolved defaults.
10
+ export function monitorDeliveryMaxCharsFromEnv(): string | undefined {
11
+ return relayEnv(process.env, "AGENT_RELAY_MONITOR_DELIVERY_MAX_CHARS");
12
+ }
13
+
14
+ export function monitorDeliveryLineMaxCharsFromEnv(): string | undefined {
15
+ return relayEnv(process.env, "AGENT_RELAY_MONITOR_DELIVERY_LINE_MAX_CHARS");
16
+ }
@@ -0,0 +1 @@
1
+ export { messageBodyMaxCharsFromEnv, monitorDeliveryMaxCharsFromEnv, monitorDeliveryLineMaxCharsFromEnv } from "./message-body-config/config";