@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,284 @@
1
+ import { errMessage, type Message } from "@humwick/sdk";
2
+ import { providerAttachmentLocalPaths, providerAttachmentText, PromptAcceptedError, type ManagedProcess, type PromptOfferOptions, type ProviderInputObserver, type ProviderPermissionDecision, type ProviderPermissionDecisionInput } from "../../adapter";
3
+ import { ProviderSubmitFailure } from "../../provider-submit-failure";
4
+ import type { EmptyTurnArm } from "../../empty-turn";
5
+ import type { ProviderInputTicket } from "../../provider-input-queue";
6
+ import { isTurnInProgressDeliveryError } from "../../runner-helpers";
7
+ import { logger } from "../../logger";
8
+ import type { RunnerTimelineEvent } from "../../turn-reconcile-event";
9
+ import type { RunnerLaunchOptions } from "../runner-context";
10
+
11
+ interface PromptCommandsDeps {
12
+ options: RunnerLaunchOptions;
13
+ agentId: string;
14
+ process(): ManagedProcess | undefined;
15
+ isStopped(): boolean;
16
+ armInput(now: number, mode?: "ladder", initial?: boolean): EmptyTurnArm;
17
+ disarmInput(arm: EmptyTurnArm): void;
18
+ inputObserver(arm: EmptyTurnArm): ProviderInputObserver;
19
+ recordInjectedPrompt(prompt: string, imagePaths?: string[]): void;
20
+ offerPromptOrPark(prompt: string, options: PromptOfferOptions, ticket?: ProviderInputTicket, onSubmitHeld?: () => void, heldFor?: string): Promise<"accepted" | "parked" | "not-durable" | "submit-held">;
21
+ offerProviderInput<T>(run: (observer?: ProviderInputObserver) => Promise<T>, offeredNothing?: (result: T) => boolean): Promise<T>;
22
+ terminalFailure(): Readonly<{ message: string }> | undefined;
23
+ sessionLog(message: string): void;
24
+ publishRunnerTimelineEvent(event: RunnerTimelineEvent): void;
25
+ holdFailedProviderSubmit(detail: string, held: { resend: () => Promise<unknown> }): void;
26
+ resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean | undefined;
27
+ reservePark(): ProviderInputTicket;
28
+ notePendingPrompt(messageId: number): void;
29
+ cacheAttachments(messages: Message[]): Promise<Message[]>;
30
+ parkPromptBehindSubmitHold(params: Record<string, unknown>, key: string, turn: ProviderInputTicket): Promise<Record<string, unknown>>;
31
+ waitForProviderTurnClear(): Promise<void>;
32
+ pendingQuestionApprovalId(): string | undefined;
33
+ clearPendingQuestionHold(): void;
34
+ publishStatus(): void;
35
+ }
36
+
37
+ // #329: a cold isolated profile (first-run trust/onboarding/install gates) can take far longer
38
+ // than the warm-restart window to render an input-ready TUI, so the first prompt delivery gets a
39
+ // generous timeout. If it still misses, the prompt is re-attempted on the next provider ready
40
+ // signal (short timeout, since it just went idle), up to a bounded number of attempts.
41
+ const INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS = 30_000;
42
+ const INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS = 10_000;
43
+ const MAX_INITIAL_PROMPT_ATTEMPTS = 6;
44
+ const PROMPT_NOT_DURABLE = "prompt refused under a provider hold and could not be parked durably; not delivered";
45
+ /** Who a prompt.inject came from (every producer stamps its session message's sender); parked copies keep it (#2664). */
46
+ export const promptOrigin = (params: Record<string, unknown>): string => [params.from, params.requestedBy].find((id): id is string => typeof id === "string" && id !== "") ?? "user";
47
+
48
+ export class PromptCommands {
49
+ // #329: a spawn-time initial prompt whose first delivery timed out before the TUI was
50
+ // ready. Held (not dropped) for a ready-signal-driven re-attempt; cleared on success.
51
+ private pendingInitialPrompt?: string;
52
+ private deliveringInitialPrompt = false;
53
+ private initialPromptAttempts = 0;
54
+
55
+ constructor(private readonly deps: PromptCommandsDeps) {}
56
+
57
+ noteLaunchSeededPrompt(opts: { resumeId?: string; suppressPrompt?: boolean }): string | undefined {
58
+ const launchSeededPrompt = this.deps.options.adapter.seedsInitialPromptAtLaunch && !opts.resumeId && !opts.suppressPrompt ? this.deps.options.prompt?.trim() : ""; if (launchSeededPrompt) { this.deps.recordInjectedPrompt(launchSeededPrompt); this.deps.armInput(Date.now()); }
59
+ return launchSeededPrompt;
60
+ }
61
+
62
+ async startLaunchSeededTurn(): Promise<void> {
63
+ if (this.deps.process()?.meta?.launchSeededTurn !== true) return;
64
+ try {
65
+ await this.deps.options.adapter.startLaunchSeededTurn?.(this.deps.process()!);
66
+ } catch (error) {
67
+ this.deps.sessionLog(`launch-seeded turn watch failed: ${errMessage(error)}`);
68
+ }
69
+ }
70
+
71
+ async deliverInitialPrompt(): Promise<void> {
72
+ const prompt = this.deps.options.prompt?.trim();
73
+ if (!prompt || this.deps.options.adapter.seedsInitialPromptAtLaunch) return;
74
+ await this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS);
75
+ }
76
+
77
+ // Deliver the spawn-time first prompt, surviving a cold-start TUI that isn't input-ready yet
78
+ // (#329). Timeout stashes it for ready-signal retry and surfaces the stall on the timeline.
79
+ private async attemptInitialPromptDelivery(prompt: string, readyTimeoutMs: number): Promise<void> {
80
+ if (this.deliveringInitialPrompt || this.deps.isStopped() || !this.deps.process() || !this.deps.options.adapter.deliverInitialPrompt) return;
81
+ this.deliveringInitialPrompt = true;
82
+ const attempt = (this.initialPromptAttempts += 1);
83
+ let arm: EmptyTurnArm | undefined;
84
+ try {
85
+ const startedAt = Date.now();
86
+ // #2389 — armed BEFORE the delivery is awaited. On OpenCode `deliverInitialPrompt` awaits the
87
+ // synchronous /message POST, which returns only when the assistant message is complete; an arm
88
+ // taken on resolution lands at or after that turn's own idle edge and misses it entirely.
89
+ arm = this.deps.armInput(Date.now(), "ladder", true);
90
+ this.deps.recordInjectedPrompt(prompt);
91
+ const outcome = await this.deps.offerPromptOrPark(prompt, { readyTimeoutMs, isInitial: true, inputObserver: this.deps.inputObserver(arm) });
92
+ // #2619 — a refused launch prompt that could not be made durable stays pending (re-offered
93
+ // on the next ready edge); a durably parked one redelivers from the inbox.
94
+ this.pendingInitialPrompt = outcome === "not-durable" ? prompt : undefined;
95
+ if (outcome !== "accepted") {
96
+ this.deps.sessionLog(`initial prompt attempt ${attempt} refused under a provider hold (${prompt.length} chars); ${outcome === "parked" ? "parked for redelivery" : "not durable, kept pending"}`);
97
+ return;
98
+ }
99
+ // #2397 round 3 review (LOW-5r) — a synchronous-endpoint adapter (OpenCode) can resolve this
100
+ // call NORMALLY after running its own bounded retry+reroll loop into a TERMINAL provider
101
+ // failure (see failTurn) rather than throwing — throwing here would route into this method's
102
+ // own retry-and-give-up path, re-sending the same prompt on top of the budget the adapter
103
+ // already exhausted. `this.deps.terminalFailure()` is ground truth for that outcome: it is set by
104
+ // the adapter's own `error` status and cleared by the `busy` edge THIS delivery started with
105
+ // (setProviderStatus, ~line 1841), so a set value here can only have been raised during this
106
+ // very call, never stale. Without this check, a freshly spawned agent's terminally-failed
107
+ // first turn — the exact incident #2397 was filed on — was still logged "delivered".
108
+ if (this.deps.terminalFailure()) {
109
+ this.deps.sessionLog(`initial prompt attempt ${attempt} ended in a terminal provider failure (${prompt.length} chars, ${Date.now() - startedAt}ms): ${this.deps.terminalFailure()!.message}`);
110
+ } else {
111
+ // #2389 — delivery used to be logged ONLY on exception, so a spawn that idled forever left no
112
+ // evidence of whether its first prompt ever reached the provider; establishing that took
113
+ // SQLite archaeology on retained provider state. Say so on success too.
114
+ this.deps.sessionLog(`initial prompt delivered on attempt ${attempt} (${prompt.length} chars, ${Date.now() - startedAt}ms)`);
115
+ }
116
+ } catch (error) {
117
+ // Nothing reached the provider, so nothing is owed — unless a turn started meanwhile, which
118
+ // disarmInput checks: a synchronous-endpoint transport can throw after the model already ran.
119
+ if (arm) this.deps.disarmInput(arm);
120
+ // #2534 — the adapter proved the provider ACCEPTED and persisted this prompt (matched to the
121
+ // outbound request identity) before its completion await failed (e.g. the #2425 whole-turn
122
+ // abort on a long first turn). The provider is already working on it, so requeueing would
123
+ // run the same assignment a second time — and with the acceptance-keyed markers retained,
124
+ // the retry would also no longer carry the launch briefing. Drop it like a normal success.
125
+ if (error instanceof PromptAcceptedError) {
126
+ this.pendingInitialPrompt = undefined;
127
+ this.deps.sessionLog(`initial prompt attempt ${attempt} was accepted by the provider before its completion await failed (${prompt.length} chars); kept as delivered, not requeued: ${errMessage(error)}`);
128
+ return;
129
+ }
130
+ const giveUp = (error instanceof ProviderSubmitFailure && !error.retrySafe) || attempt >= MAX_INITIAL_PROMPT_ATTEMPTS;
131
+ this.pendingInitialPrompt = giveUp ? undefined : prompt;
132
+ logger.error("runner", `initial prompt attempt ${attempt} failed${giveUp ? " (giving up)" : "; will retry on next ready signal"}: ${errMessage(error)}`);
133
+ this.deps.publishRunnerTimelineEvent({ status: "prompt_failed", timestamp: Date.now(), title: "Initial prompt not delivered", body: errMessage(error), metadata: { attempts: attempt, terminal: giveUp } });
134
+ if (error instanceof ProviderSubmitFailure && giveUp) this.deps.holdFailedProviderSubmit(`Initial provider prompt submission unconfirmed after ${attempt} attempt(s): ${error.message}`, { resend: () => this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS) });
135
+ } finally {
136
+ this.deliveringInitialPrompt = false;
137
+ }
138
+ }
139
+
140
+ async respondToPermissionDecision(params: Record<string, unknown>): Promise<Record<string, unknown>> {
141
+ const approvalId = typeof params.approvalId === "string" ? params.approvalId : "";
142
+ const decision = typeof params.decision === "string" ? params.decision : "";
143
+ if (!approvalId) throw new Error("approvalId required");
144
+ if (decision !== "approve" && decision !== "approve-session" && decision !== "deny" && decision !== "abort" && decision !== "answer") {
145
+ throw new Error("decision must be approve, approve-session, deny, abort, or answer");
146
+ }
147
+ const answersRaw = params.answers;
148
+ const answers = answersRaw && typeof answersRaw === "object" && !Array.isArray(answersRaw)
149
+ ? Object.fromEntries(
150
+ Object.entries(answersRaw as Record<string, unknown>).filter(([, v]) => typeof v === "string") as [string, string][],
151
+ )
152
+ : undefined;
153
+ if (decision === "answer" && (!answers || Object.keys(answers).length === 0)) {
154
+ throw new Error("answers required for answer decision");
155
+ }
156
+ const input: ProviderPermissionDecisionInput = {
157
+ approvalId,
158
+ decision: decision as ProviderPermissionDecision,
159
+ ...(typeof params.reason === "string" ? { reason: params.reason } : {}),
160
+ ...(answers ? { answers } : {}),
161
+ };
162
+ if (this.deps.resolvePermissionDecision(input)) {
163
+ return { approvalId, decision, provider: this.deps.options.provider };
164
+ }
165
+ if (!this.deps.process()) throw new Error("provider process is unavailable");
166
+ if (!this.deps.options.adapter.respondToPermissionDecision) throw new Error("provider does not support permission decisions");
167
+ const result = await this.deps.options.adapter.respondToPermissionDecision(this.deps.process()!, input);
168
+ return { approvalId, decision, ...(result ? { providerResult: result } : {}) };
169
+ }
170
+
171
+ async injectPrompt(params: Record<string, unknown>, ticket?: ProviderInputTicket, commandId?: string): Promise<Record<string, unknown>> {
172
+ const body = typeof params.body === "string" ? params.body : "";
173
+ if (!body) throw new Error("body required");
174
+ if (!this.deps.process()) throw new Error("provider process is unavailable");
175
+ if (!this.deps.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
176
+ const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
177
+ // A prompt that raced the hold's publication (the server 409s once it sees the hold) parks behind the held item,
178
+ // decided in its FIFO slot only, so parking follows the input order.
179
+ const parkKey = `submit-hold-prompt:${messageId ?? commandId ?? crypto.randomUUID()}`;
180
+ let parkTurn: ProviderInputTicket | undefined;
181
+ const reservePark = () => { parkTurn = this.deps.reservePark(); };
182
+ const attachments = Array.isArray(params.attachments)
183
+ ? params.attachments.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item))
184
+ : [];
185
+ if (messageId) this.deps.notePendingPrompt(messageId);
186
+ const { prompt, imagePaths } = await this.promptInjectionText(body, messageId, attachments);
187
+ // Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
188
+ // prompt already created its own session message shown in the dashboard.
189
+ this.deps.recordInjectedPrompt(prompt.trim(), imagePaths);
190
+ // #1228: prompt injection is a manual dashboard "continue"/chat-box send, not the
191
+ // agent's genuine first turn — never label it as the initial prompt.
192
+ // #2389/#2539 — offer through the ONE provider-input seam: arm the empty-turn watch at
193
+ // hand-off, serialize through the FIFO behind any reserved ticket, disarm on rejection.
194
+ // #2543 — a busy recipient (the adapter answered with the shared turn-in-progress signal
195
+ // instead of an unguarded start) DEFERS: bounded wait for the provider turn to clear, then
196
+ // one re-offer on the idle path through a fresh FIFO slot — the same shape as the #2539
197
+ // context-injection defer. A timed-out wait (or any other failure) throws so the command
198
+ // fails visibly — never a silent drop.
199
+ const submitHeld = (outcome: string) => outcome === "submit-held";
200
+ try {
201
+ const outcome = await this.deps.offerProviderInput((inputObserver) => this.deps.offerPromptOrPark(prompt, { isInitial: false, imagePaths, inputObserver }, ticket, reservePark, promptOrigin(params)), submitHeld);
202
+ if (outcome === "not-durable") throw new Error(PROMPT_NOT_DURABLE);
203
+ if (outcome === "submit-held") return this.deps.parkPromptBehindSubmitHold(params, parkKey, parkTurn!);
204
+ } catch (error) {
205
+ if (!isTurnInProgressDeliveryError(error)) {
206
+ if (error instanceof ProviderSubmitFailure && !error.retrySafe) this.deps.holdFailedProviderSubmit(`Dashboard prompt submission unconfirmed: ${error.message}`, { resend: () => this.injectPrompt(params) });
207
+ throw error;
208
+ }
209
+ this.deps.sessionLog("prompt injection deferred behind the active provider turn; delivering when it ends");
210
+ await this.deps.waitForProviderTurnClear();
211
+ this.deps.recordInjectedPrompt(prompt.trim(), imagePaths); // refresh the echo-dedup window past the wait
212
+ const outcome = await this.deps.offerProviderInput((inputObserver) => this.deps.offerPromptOrPark(prompt, { isInitial: false, imagePaths, inputObserver }, undefined, reservePark, promptOrigin(params)), submitHeld);
213
+ if (outcome === "not-durable") throw new Error(PROMPT_NOT_DURABLE);
214
+ if (outcome === "submit-held") return this.deps.parkPromptBehindSubmitHold(params, parkKey, parkTurn!);
215
+ }
216
+ return { injected: true, messageId, attachmentCount: attachments.length };
217
+ }
218
+
219
+ // #1173: the resolution side of the deny-then-hold AskUserQuestion flow (see control-server.ts's
220
+ // handlePermissionRequest). By the time this fires, the original PreToolUse hook has long since
221
+ // been answered with `deny` — there's no live HTTP request to respond to — so the human's answer
222
+ // (or dismissal) is delivered by clearing the pending-question hold and injecting a normal
223
+ // user prompt via the same path the dashboard chat box uses.
224
+ async injectAnswerPrompt(input: { approvalId: string; text?: string }): Promise<void> {
225
+ const heldApprovalId = this.deps.pendingQuestionApprovalId();
226
+ if (heldApprovalId && heldApprovalId !== input.approvalId) return;
227
+ if (heldApprovalId === input.approvalId) {
228
+ this.deps.clearPendingQuestionHold();
229
+ this.deps.publishStatus();
230
+ }
231
+ const text = input.text ?? "The question was dismissed or timed out before the user answered. Proceed on your best judgment, or re-ask if you still need a decision.";
232
+ if (!this.deps.process() || !this.deps.options.adapter.deliverInitialPrompt) {
233
+ this.deps.sessionLog(`could not inject AskUserQuestion answer for ${input.approvalId}: provider process/prompt-injection unavailable`);
234
+ return;
235
+ }
236
+ this.deps.recordInjectedPrompt(text.trim());
237
+ // #1228: this resolves a held AskUserQuestion mid-session — never the initial prompt.
238
+ // #2389/#2539 — the answer unblocks a turn that is waiting on it, so it arms.
239
+ // #2543 — offered through the same seam with the same busy policy: the adapter's shared
240
+ // turn-in-progress refusal defers (bounded turn-clear wait + one re-offer) instead of an
241
+ // unguarded start; any other failure stays best-effort logged, exactly as before.
242
+ const deliver = async (inputObserver?: ProviderInputObserver) => {
243
+ if (await this.deps.offerPromptOrPark(text, { isInitial: false, inputObserver }, undefined, undefined, input.text !== undefined ? "user" : undefined) === "not-durable") throw new Error(PROMPT_NOT_DURABLE);
244
+ };
245
+ try {
246
+ await this.deps.offerProviderInput(deliver);
247
+ } catch (error) {
248
+ if (!isTurnInProgressDeliveryError(error)) {
249
+ this.deps.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(error)}`);
250
+ return;
251
+ }
252
+ try {
253
+ this.deps.sessionLog(`AskUserQuestion answer for ${input.approvalId} deferred behind the active provider turn; delivering when it ends`);
254
+ await this.deps.waitForProviderTurnClear();
255
+ this.deps.recordInjectedPrompt(text.trim()); // refresh the echo-dedup window past the wait
256
+ await this.deps.offerProviderInput(deliver);
257
+ } catch (retryError) {
258
+ this.deps.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(retryError)}`);
259
+ }
260
+ }
261
+ }
262
+
263
+ private async promptInjectionText(body: string, messageId: number | undefined, attachments: Record<string, unknown>[]): Promise<{ prompt: string; imagePaths: string[] }> {
264
+ if (!attachments.length) return { prompt: body, imagePaths: [] };
265
+ const messages = await this.deps.cacheAttachments([{
266
+ id: messageId ?? 0,
267
+ from: "user",
268
+ to: this.deps.agentId,
269
+ kind: "session",
270
+ body,
271
+ payload: { attachments },
272
+ readBy: [],
273
+ createdAt: Date.now(),
274
+ }]);
275
+ const attachmentText = providerAttachmentText(messages[0]!);
276
+ return { prompt: attachmentText ? `${body}\n\n${attachmentText}` : body, imagePaths: providerAttachmentLocalPaths(messages) };
277
+ }
278
+
279
+ retryInitialPrompt(): void {
280
+ if (this.pendingInitialPrompt && !this.deliveringInitialPrompt) {
281
+ void this.attemptInitialPromptDelivery(this.pendingInitialPrompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS);
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,208 @@
1
+ import type { HumwickHttpClient } from "@humwick/sdk";
2
+ import { orchestratorUrlFromEnv } from "../config";
3
+ import { httpErrorKey, runtimeTokenRenewDelayMs, runtimeTokenRenewTimerSchedule } from "../runner-helpers";
4
+ import { rederiveRuntimeToken, TokenRotation } from "../runtime-token-remint";
5
+
6
+ type RenewedTokenRecord = Awaited<ReturnType<HumwickHttpClient["renewRuntimeToken"]>>["record"];
7
+ type RotationStatus = "runtime-token-renewed" | "runtime-token-reminted";
8
+ interface CredentialsDeps {
9
+ isStopped(): boolean;
10
+ renewRuntimeToken: HumwickHttpClient["renewRuntimeToken"];
11
+ remintRuntimeToken: HumwickHttpClient["remintRuntimeToken"];
12
+ onTokenChanged(token: string, record: RenewedTokenRecord): void;
13
+ resumeLivenessAndPublishRotation(status: RotationStatus, jti: string): void;
14
+ publishRenewalFailure(): void;
15
+ logRunnerDiagnostic(message: string): void;
16
+ reportBackgroundRejection(scope: string, status: string, title: string, error: unknown): void;
17
+ }
18
+
19
+ const TOKEN_RENEW_LOG_INTERVAL_MS = 5 * 60 * 1000;
20
+ const TOKEN_RENEW_RETRY_MS = 60_000;
21
+ // Debounce reactive token recovery so a burst of 401-ing calls in the same window
22
+ // triggers a single re-mint attempt, not one per failing request.
23
+ const REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS = 10_000;
24
+
25
+ export class Credentials {
26
+ private currentToken?: string;
27
+ private currentTokenJti?: string;
28
+ private currentTokenProfileId?: string;
29
+ private currentTokenExpiresAt?: number;
30
+ private tokenRenewTimer?: Timer;
31
+ private readonly tokenRotation = new TokenRotation();
32
+ private tokenRenewLastLog?: { key: string; at: number };
33
+ private reactiveTokenRecoveryAt?: number;
34
+
35
+ constructor(options: { token?: string; tokenJti?: string; tokenProfileId?: string; tokenExpiresAt?: number }, private readonly deps: CredentialsDeps) {
36
+ this.currentToken = options.token;
37
+ this.currentTokenJti = options.tokenJti;
38
+ this.currentTokenProfileId = options.tokenProfileId;
39
+ this.currentTokenExpiresAt = options.tokenExpiresAt;
40
+ }
41
+
42
+ current(): string | undefined { return this.currentToken; }
43
+ get jti(): string | undefined { return this.currentTokenJti; }
44
+ get profileId(): string | undefined { return this.currentTokenProfileId; }
45
+ get expiresAt(): number | undefined { return this.currentTokenExpiresAt; }
46
+
47
+ stop(): void {
48
+ if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
49
+ this.tokenRenewTimer = undefined;
50
+ }
51
+
52
+ // Timer/edge callers cannot await renewal; report an escaping rejection so it cannot
53
+ // silently strand the runner off the bus (#1617).
54
+ private renewRuntimeTokenInBackground(): void {
55
+ void this.renewRuntimeToken().catch((error) => this.deps.reportBackgroundRejection("runtime token renewal", "runtime-token-renewal-failed", "Runtime token renewal aborted", error));
56
+ }
57
+
58
+ // The TTL timer cannot detect revocation: recover on auth failure, coalescing a burst.
59
+ // Orchestrator re-mint heals stale/expired tokens but preserves hard revocation (#484).
60
+ recoverRuntimeTokenAfterAuthFailure(source: string): void {
61
+ if (this.deps.isStopped() || this.tokenRotation.inFlight) return;
62
+ if (!this.isRuntimeTokenRenewable() && !this.canRemintViaOrchestrator()) return;
63
+ const now = Date.now();
64
+ if (this.reactiveTokenRecoveryAt && now - this.reactiveTokenRecoveryAt < REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS) return;
65
+ this.reactiveTokenRecoveryAt = now;
66
+ this.deps.logRunnerDiagnostic(`[runner] relay auth failure on ${source}; recovering runtime token`);
67
+ this.renewRuntimeTokenInBackground();
68
+ }
69
+
70
+ scheduleRuntimeTokenRenewal(delayMs?: number): void {
71
+ if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
72
+ this.tokenRenewTimer = undefined;
73
+ if (this.deps.isStopped()) return;
74
+ const canSelfRenew = this.isRuntimeTokenRenewable();
75
+ const canRemint = this.canRemintViaOrchestrator();
76
+ // Keep the renewal clock ticking as long as the session can recover its token
77
+ // by EITHER path. Without the re-mint fallback an expired token would stop the
78
+ // timer forever (the old deadlock that stranded live agents off the bus).
79
+ if (!canSelfRenew && !canRemint) return;
80
+ let computedDelay = delayMs;
81
+ if (computedDelay === undefined) {
82
+ computedDelay = canSelfRenew
83
+ ? runtimeTokenRenewDelayMs(this.currentTokenExpiresAt!, Date.now())
84
+ : TOKEN_RENEW_RETRY_MS; // expired but re-mintable → retry via orchestrator soon
85
+ if (computedDelay === undefined) computedDelay = TOKEN_RENEW_RETRY_MS;
86
+ }
87
+ const schedule = runtimeTokenRenewTimerSchedule(computedDelay);
88
+ if (!schedule) return;
89
+ this.tokenRenewTimer = setTimeout(() => {
90
+ this.tokenRenewTimer = undefined;
91
+ if (!schedule.renew) {
92
+ this.scheduleRuntimeTokenRenewal();
93
+ return;
94
+ }
95
+ this.renewRuntimeTokenInBackground();
96
+ }, schedule.delayMs);
97
+ }
98
+
99
+ // Can the runner self-renew right now? Requires a non-expired runner-profile token
100
+ // (the relay rejects renewal of an expired token).
101
+ private isRuntimeTokenRenewable(): boolean {
102
+ return Boolean(
103
+ this.currentToken &&
104
+ this.currentTokenExpiresAt &&
105
+ this.currentTokenExpiresAt * 1000 > Date.now() &&
106
+ (this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
107
+ );
108
+ }
109
+
110
+ // Can the runner recover its token via the orchestrator? Works even when the token
111
+ // is already expired — the orchestrator's standing credential is the authority.
112
+ private canRemintViaOrchestrator(): boolean {
113
+ return Boolean(
114
+ orchestratorUrlFromEnv() &&
115
+ this.currentToken &&
116
+ (this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
117
+ );
118
+ }
119
+
120
+ private async renewRuntimeToken(): Promise<void> {
121
+ if (this.deps.isStopped() || this.tokenRotation.inFlight || !this.currentToken) return;
122
+ await this.tokenRotation.run(async () => {
123
+ // Preferred path: self-renew directly against the relay while the token is
124
+ // still valid. Cheapest and needs no orchestrator round-trip.
125
+ if (this.isRuntimeTokenRenewable()) {
126
+ try {
127
+ const renewed = await this.deps.renewRuntimeToken();
128
+ this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-renewed");
129
+ return;
130
+ } catch (error) {
131
+ this.logRuntimeTokenRenewalFailure(error);
132
+ // Relay unreachable or token rejected — fall through to orchestrator re-mint.
133
+ }
134
+ }
135
+ // Recovery path: token expired, or self-renew failed. Ask the orchestrator —
136
+ // it holds a long-lived credential and can mint a fresh runner token, so a
137
+ // live session heals instead of being stranded off the bus.
138
+ if (this.canRemintViaOrchestrator() && await this.remintViaOrchestrator()) return;
139
+ this.deps.publishRenewalFailure();
140
+ this.scheduleRuntimeTokenRenewal(TOKEN_RENEW_RETRY_MS);
141
+ });
142
+ }
143
+
144
+ // Install state before core fan-out, then resume liveness and renew on the same clock.
145
+ private applyRenewedToken(
146
+ token: string,
147
+ record: RenewedTokenRecord,
148
+ status: RotationStatus,
149
+ ): void {
150
+ this.currentToken = token;
151
+ this.currentTokenJti = record.jti;
152
+ this.currentTokenProfileId = record.profileId ?? this.currentTokenProfileId;
153
+ this.currentTokenExpiresAt = record.expiresAt;
154
+ this.deps.onTokenChanged(token, record);
155
+ this.reactiveTokenRecoveryAt = undefined;
156
+ this.deps.resumeLivenessAndPublishRotation(status, record.jti);
157
+ this.scheduleRuntimeTokenRenewal();
158
+ }
159
+
160
+ // Recover the runtime token through the orchestrator. The runner proxies its own
161
+ // (possibly expired) token; the orchestrator re-mints it via the relay using its
162
+ // standing credential. Returns true on success.
163
+ private async remintViaOrchestrator(): Promise<boolean> {
164
+ const orchUrl = orchestratorUrlFromEnv();
165
+ if (!orchUrl || !this.currentToken) return false;
166
+ try {
167
+ const res = await fetch(`${orchUrl.replace(/\/+$/, "")}/api/runtime-tokens/runner-renew`, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ token: this.currentToken }),
171
+ signal: AbortSignal.timeout(10_000),
172
+ });
173
+ if (!res.ok) return false;
174
+ const renewed = await res.json() as { token?: string; record?: { jti: string; profileId?: string; expiresAt?: number } };
175
+ if (!renewed?.token || !renewed.record) return false;
176
+ this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-reminted");
177
+ this.deps.logRunnerDiagnostic(`[runner] runtime token re-minted via orchestrator (jti ${renewed.record.jti})`);
178
+ return true;
179
+ } catch (error) {
180
+ this.logRuntimeTokenRenewalFailure(error);
181
+ return false;
182
+ }
183
+ }
184
+
185
+ // #1245 — a newly allowed tool group or scope reaches the next generation without a restart.
186
+ async remintRuntimeTokenAtBoundary(reason: "clear" | "compact"): Promise<Record<string, unknown>> {
187
+ try {
188
+ // Queued behind a renewal in flight, so it re-derives from the token that renewal installs.
189
+ return await this.tokenRotation.runNext(async () => {
190
+ if (this.deps.isStopped() || !this.currentToken || this.currentTokenProfileId !== "provider-agent") return { status: "skipped", reason: "no spawned runner token" };
191
+ return rederiveRuntimeToken({ remintRuntimeToken: this.deps.remintRuntimeToken }, reason, (token, record) => this.applyRenewedToken(token, record, "runtime-token-reminted"), (message) => this.deps.logRunnerDiagnostic(message));
192
+ });
193
+ } finally {
194
+ this.scheduleRuntimeTokenRenewal(); // a renewal timer that fired meanwhile returned without re-arming
195
+ }
196
+ }
197
+
198
+ private logRuntimeTokenRenewalFailure(error: unknown): void {
199
+ const key = httpErrorKey(error);
200
+ const now = Date.now();
201
+ if (
202
+ this.tokenRenewLastLog?.key === key &&
203
+ now - this.tokenRenewLastLog.at < TOKEN_RENEW_LOG_INTERVAL_MS
204
+ ) return;
205
+ this.tokenRenewLastLog = { key, at: now };
206
+ this.deps.logRunnerDiagnostic(`[runner] runtime token renewal failed: ${String(error)}`);
207
+ }
208
+ }
@@ -0,0 +1,85 @@
1
+ import { errMessage, type HumwickHttpClient } from "@humwick/sdk";
2
+ import { httpErrorKey, isHttpAuthError, runnerAgentStatus } from "../runner-helpers";
3
+ import type { ClaimTracker } from "../claim-tracker";
4
+
5
+ const HTTP_LIVENESS_INTERVAL_MS = 20_000;
6
+
7
+ interface HttpLivenessDeps {
8
+ http: Pick<HumwickHttpClient, "heartbeat">;
9
+ agentId: string;
10
+ instanceId: string;
11
+ logIntervalMs: number;
12
+ isStopped(): boolean;
13
+ isTerminalStatusLatched(): boolean;
14
+ currentStatus: ClaimTracker["currentStatus"];
15
+ publishQuota(): Promise<void>;
16
+ bootstrapUnreadMessages(): Promise<void>;
17
+ triggerEffort(): void;
18
+ recoverRuntimeTokenAfterAuthFailure(source: string): void;
19
+ logRunnerDiagnostic(message: string): void;
20
+ }
21
+
22
+ export class HttpLiveness {
23
+ private httpLivenessTimer?: Timer;
24
+ private httpLivenessInFlight = false;
25
+ private httpLivenessAuthFailed = false;
26
+ private httpLivenessLastLog?: { key: string; at: number };
27
+
28
+ constructor(private readonly deps: HttpLivenessDeps) {}
29
+
30
+ stop(): void {
31
+ if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
32
+ this.httpLivenessTimer = undefined;
33
+ }
34
+
35
+ resetAuthFailure(): void { this.httpLivenessAuthFailed = false; }
36
+
37
+ start(): void {
38
+ if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
39
+ void this.publishHttpLiveness();
40
+ this.httpLivenessTimer = setInterval(() => {
41
+ void this.publishHttpLiveness();
42
+ }, HTTP_LIVENESS_INTERVAL_MS);
43
+ }
44
+
45
+ private async publishHttpLiveness(): Promise<void> {
46
+ if (this.deps.isStopped() || this.deps.isTerminalStatusLatched() || this.httpLivenessInFlight || this.httpLivenessAuthFailed) return;
47
+ this.httpLivenessInFlight = true;
48
+ const status = this.deps.currentStatus();
49
+ const agentStatus = runnerAgentStatus(status);
50
+ try {
51
+ // #3606 — one call; the hub writes and emits only when status or readiness actually changed.
52
+ await this.deps.http.heartbeat(this.deps.agentId, this.deps.instanceId, { status: agentStatus, ready: agentStatus !== "offline" });
53
+ await this.deps.publishQuota().catch((error) => this.deps.logRunnerDiagnostic(`passive provider quota publish failed: ${errMessage(error)}`));
54
+ await this.deps.bootstrapUnreadMessages(); this.deps.triggerEffort();
55
+ } catch (error) {
56
+ if (!this.deps.isStopped()) this.handleHttpLivenessFailure(error);
57
+ } finally {
58
+ this.httpLivenessInFlight = false;
59
+ }
60
+ }
61
+
62
+ private handleHttpLivenessFailure(error: unknown): void {
63
+ const authFailed = isHttpAuthError(error);
64
+ this.logHttpLivenessFailure(error, authFailed);
65
+ if (!authFailed) return;
66
+ this.httpLivenessAuthFailed = true;
67
+ if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
68
+ this.httpLivenessTimer = undefined;
69
+ // A 401/403 here is the only timely signal that the token died — stopping the
70
+ // liveness timer means there is no second chance, so recover from THIS failure.
71
+ this.deps.recoverRuntimeTokenAfterAuthFailure("http-liveness");
72
+ }
73
+
74
+ private logHttpLivenessFailure(error: unknown, authFailed: boolean): void {
75
+ const key = httpErrorKey(error);
76
+ const now = Date.now();
77
+ if (
78
+ this.httpLivenessLastLog?.key === key &&
79
+ now - this.httpLivenessLastLog.at < this.deps.logIntervalMs
80
+ ) return;
81
+ this.httpLivenessLastLog = { key, at: now };
82
+ const suffix = authFailed ? "auth failed; stopping HTTP liveness retries until restart" : String(error);
83
+ this.deps.logRunnerDiagnostic(`[runner] HTTP liveness update failed: ${suffix}`);
84
+ }
85
+ }