@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,18 @@
1
+ import { COORDINATOR_PROFILE_NAMES, errMessage, type OperatorDecisionFileInput, type HumwickHttpClient } from "@humwick/sdk";
2
+
3
+ // #2914: a coordinator's AskUserQuestion becomes a durable operator decision (same store, dashboard card and
4
+ // answer-as-message as relay_decision). Any other agent, or a failed file, keeps the live relayed question.
5
+ export async function fileQuestionDecision(
6
+ deps: { http: Pick<HumwickHttpClient, "fileDecision">; profile?: string; log(message: string): void },
7
+ input: OperatorDecisionFileInput,
8
+ ): Promise<{ id: number } | undefined> {
9
+ if (!(COORDINATOR_PROFILE_NAMES as readonly string[]).includes(deps.profile ?? "")) return undefined;
10
+ try {
11
+ const decision = await deps.http.fileDecision(input);
12
+ deps.log(`AskUserQuestion filed as operator decision #${decision.id}`);
13
+ return { id: decision.id };
14
+ } catch (error) {
15
+ deps.log(`AskUserQuestion not filed as a decision (${errMessage(error)}); relaying it as a live question`);
16
+ return undefined;
17
+ }
18
+ }
package/src/quota.ts ADDED
@@ -0,0 +1,22 @@
1
+ export {
2
+ DEFAULT_PROVIDER_QUOTA_CONFIG,
3
+ QuotaRetryAfterError,
4
+ claudeRateLimitHeadersQuotaState,
5
+ claudeUsageQuotaState,
6
+ collectClaudeQuotaState,
7
+ collectCodexQuotaSample,
8
+ collectCodexQuotaState,
9
+ codexRateLimitsQuotaState,
10
+ credentialAccountKey,
11
+ defaultClaudeConfigDir,
12
+ defaultZaiCredentialsPath,
13
+ normalizeProviderQuotaConfig,
14
+ providerPayloadAccountKey,
15
+ resolveClaudeQuotaIdentity,
16
+ resolveClaudeStatuslineQuotaIdentity,
17
+ resolveCodexQuotaIdentity,
18
+ resolveStableClaudeQuotaIdentity,
19
+ resolveStableCodexQuotaIdentity,
20
+ resolveStableZaiQuotaIdentity,
21
+ type ProviderQuotaIdentity,
22
+ } from "@humwick/sdk/provider-quota";
@@ -0,0 +1,351 @@
1
+ // #286 — shared rate-limit hold construction + Claude pane detection.
2
+ //
3
+ // Two detection arms feed the SAME provider-neutral `blocked` hold:
4
+ // 1. The StopFailure hook (clean API-error turn-end: API-429/auth/billing) → control-server.
5
+ // 2. Pane-scrape of the subscription "session/weekly limit" modal (no hook exists for it —
6
+ // anthropics/claude-code#34817 was closed not-planned) → the Claude adapter.
7
+ // Both build the hold here so the shape stays identical, and the relay's resume sweep
8
+ // (services/rate-limit-resume.ts) lifts either once the window resets.
9
+
10
+ import { PROXY_AUTH_HOLD_KIND, type ProviderState } from "@humwick/sdk";
11
+
12
+ export const RATE_LIMIT_BLOCK_REASON = "rate_limit";
13
+
14
+ // Reject a parsed reset that's in the past or implausibly far out — a bad parse would
15
+ // otherwise resume too early (thrash) or hold for days. Beyond this we drop resetAt and
16
+ // fall back to the resume sweep's poll window.
17
+ const MAX_RESET_AHEAD_MS = 7 * 24 * 60 * 60 * 1000;
18
+
19
+ interface RateLimitHoldInput {
20
+ errorType?: string;
21
+ kind?: "usage_limit" | "transient_overload";
22
+ /** Unix ms the limit window resets, when known. */
23
+ resetAt?: number;
24
+ message?: string;
25
+ source?: string;
26
+ }
27
+
28
+ // #3577 — Claude reports a proxy that refused CONNECT as "Couldn't connect through your proxy
29
+ // (ERR_PROXY_TUNNEL)"; Agent Vault refuses an expired or revoked session with 407.
30
+ const PROXY_AUTH_FAILURE_RE = /ERR_PROXY_TUNNEL|proxy authentication required|\b407\b[^\n]*proxy|proxy[^\n]*\b407\b/i;
31
+
32
+ export function isProxyAuthFailure(message: string | undefined): boolean {
33
+ return PROXY_AUTH_FAILURE_RE.test(message ?? "");
34
+ }
35
+
36
+ /** The blocked providerState a rate-limit hold carries. Shared by every detection arm. */
37
+ export function buildRateLimitProviderState(input: RateLimitHoldInput): ProviderState {
38
+ const now = Date.now();
39
+ // A rejected proxy credential is no overload or usage wall: hold under its own kind, never auto-retried.
40
+ if (isProxyAuthFailure(input.message)) {
41
+ return {
42
+ state: "blocked",
43
+ reason: RATE_LIMIT_BLOCK_REASON,
44
+ label: "proxy auth failed: Vault proxy refused the session (407)",
45
+ recommendedAction: "The credential proxy rejected this agent's session; retries cannot clear it. Check its Vault service access, then restart the agent.",
46
+ kind: PROXY_AUTH_HOLD_KIND,
47
+ source: input.source ?? "claude",
48
+ errorType: input.errorType ?? PROXY_AUTH_HOLD_KIND,
49
+ enteredAt: now,
50
+ message: input.message,
51
+ updatedAt: now,
52
+ };
53
+ }
54
+ const resetAt = typeof input.resetAt === "number" && Number.isFinite(input.resetAt) ? input.resetAt : undefined;
55
+ const kind = input.kind ?? rateLimitKindForError(input.errorType);
56
+ const label = kind === "transient_overload"
57
+ ? "provider overloaded"
58
+ : resetAt ? `usage limit · resets ${formatResetClock(resetAt)}` : "usage limit reached";
59
+ return {
60
+ state: "blocked",
61
+ reason: RATE_LIMIT_BLOCK_REASON,
62
+ label,
63
+ recommendedAction: kind === "transient_overload"
64
+ ? "Holding briefly for provider overload; Humwick will retry with bounded backoff."
65
+ : "Holding until the usage window resets; Humwick auto-resumes the agent.",
66
+ kind,
67
+ source: input.source ?? "claude",
68
+ errorType: input.errorType ?? RATE_LIMIT_BLOCK_REASON,
69
+ enteredAt: now,
70
+ ...(resetAt ? { resetAt } : {}),
71
+ ...(input.message ? { message: input.message } : {}),
72
+ updatedAt: now,
73
+ };
74
+ }
75
+
76
+ function rateLimitKindForError(errorType: string | undefined): "usage_limit" | "transient_overload" {
77
+ return /^(overloaded|server_error|api_error|unknown|529|5\d\d)$/i.test(errorType ?? "") ? "transient_overload" : "usage_limit";
78
+ }
79
+
80
+ /** Host-local HH:MM for the chat notice / badge label (matches how the CLI shows "resets 3:45pm"). */
81
+ function formatResetClock(resetAtMs: number): string {
82
+ try {
83
+ return new Date(resetAtMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
84
+ } catch {
85
+ return new Date(resetAtMs).toISOString().slice(11, 16);
86
+ }
87
+ }
88
+
89
+ // The subscription limit line wordings seen in the wild (claude-auto-retry patterns +
90
+ // claude-auto-retry#15: "session"/"weekly limit" slipped past older regexes). These match
91
+ // PROSE as readily as the modal, so they are never sufficient on their own — see
92
+ // parseClaudeUsageLimitModal for the structural gate that decides what is a real hold.
93
+ const LIMIT_PHRASE_RE = /(?:hit (?:your|the)\s*(?:[\w-]+\s+)*limit|usage limit|\d+-hour limit|limit reached|out of (?:extra )?usage|rate limit(?:\s+(?:hit|reached|exceeded))?|weekly limit|session limit)/i;
94
+ const RESET_AT_RE = /\bresets?\b(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
95
+ const TRY_AGAIN_RE = /\btry again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
96
+
97
+ // #1611 — the limit phrase and its reset/retry must sit on the same line or within this many
98
+ // lines of each other. The pairing was originally allowed to span the WHOLE pane so a modal
99
+ // wrapping across rows still matched; that made the two halves collectable from unrelated
100
+ // places on screen. A modal wraps onto the very next row, so a tight window keeps the wrapped
101
+ // case (proven in rate-limit.test.ts) without the pane-wide collection.
102
+ const MAX_PAIR_GAP_LINES = 2;
103
+
104
+ interface PaneRateLimit {
105
+ /** The matched limit line, for the chat notice / observability. */
106
+ message: string;
107
+ /** Unix ms reset, when parseable from the pane (else undefined → resume falls back to poll). */
108
+ resetAt?: number;
109
+ /** Usage limits wait for reset; transient provider overloads retry quickly. */
110
+ kind?: "usage_limit" | "transient_overload";
111
+ errorType?: string;
112
+ }
113
+
114
+ /**
115
+ * Detect a Claude usage/rate-limit modal in a captured tmux pane and parse its reset time.
116
+ * Returns null when the pane shows no live hold. `nowMs` is injectable for tests.
117
+ */
118
+ export function parseClaudeRateLimitPane(text: string, nowMs: number = Date.now()): PaneRateLimit | null {
119
+ if (!text) return null;
120
+ const overload = parseClaudeTransientOverloadPane(text);
121
+ if (overload) return overload;
122
+ return parseClaudeUsageLimitModal(text, nowMs);
123
+ }
124
+
125
+ /**
126
+ * #1611 — the usage-limit modal, distinguished from the agent's OWN pane output.
127
+ *
128
+ * The capture (`capture-pane -S -80`) contains the agent's rendered conversation, so a
129
+ * phrase test over the whole pane fires on any agent that WRITES about rate limits. It did:
130
+ * a coordinator briefing a worker rendered `usage limit · resets 21:00` — which is the very
131
+ * label buildRateLimitProviderState() above produces — and marked itself blocked. Requiring
132
+ * the limit/reset PAIRING does not help either, because that one string carries both halves;
133
+ * neither does any test of line shape, since an agent working on this file renders exact
134
+ * modal fixtures (this comment is itself on screen while you read it).
135
+ *
136
+ * So the gate is POSITIONAL, not textual. Claude Code pins its composer (input box +
137
+ * status/hint footer) to the bottom of the screen, separated from the conversation by a blank
138
+ * row, and draws blocking dialogs in that same spot. Everything the agent itself renders is
139
+ * therefore ABOVE the last blank row; the live modal is BELOW it. We only scan that trailing
140
+ * block — no phrase the agent can emit reaches it.
141
+ *
142
+ * Break condition (accepted, deliberate): if Claude Code ever draws the limit dialog ABOVE a
143
+ * blank row (e.g. above a persistent status bar), this stops firing and the hold is missed —
144
+ * a false negative, which costs one retry via the resume sweep. That is the cheap direction:
145
+ * a false positive marks a healthy agent blocked, and `isAgentUnavailable` (src/db/
146
+ * agent-predicates.ts) then QUEUES its inbound messages until the fabricated reset — real
147
+ * work idled on a fabricated signal. See #1611.
148
+ */
149
+ function parseClaudeUsageLimitModal(text: string, nowMs: number): PaneRateLimit | null {
150
+ const block = livePaneBlock(text);
151
+ for (let i = 0; i < block.length; i++) {
152
+ const line = block[i]!;
153
+ if (!LIMIT_PHRASE_RE.test(line)) continue;
154
+ const message = cleanPaneLine(line);
155
+ // The reset may wrap onto a neighbouring row of the same modal, never further.
156
+ const window = block.slice(Math.max(0, i - MAX_PAIR_GAP_LINES), i + MAX_PAIR_GAP_LINES + 1).join("\n");
157
+
158
+ const relative = TRY_AGAIN_RE.exec(window);
159
+ if (relative) {
160
+ const unitMs = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 }[relative[2]!.toLowerCase()] ?? 0;
161
+ const resetAt = clampReset(nowMs + Number(relative[1]) * unitMs, nowMs);
162
+ return { message, ...(resetAt ? { resetAt } : {}) };
163
+ }
164
+ const reset = RESET_AT_RE.exec(window);
165
+ if (reset) {
166
+ const resetAt = clampReset(parseClockReset(reset, nowMs), nowMs);
167
+ return { message, ...(resetAt ? { resetAt } : {}) };
168
+ }
169
+ // Limit phrase but no reset/retry beside it — not confident it's the modal; skip.
170
+ }
171
+ return null;
172
+ }
173
+
174
+ // #2619 — account-level usage/quota walls as providers word them in an API error message (zai:
175
+ // "Usage limit reached for 5 hour. Your limit will reset at 2026-09-24 03:47:08"). Narrower than
176
+ // LIMIT_PHRASE_RE on purpose: a bare "rate limit" is a per-request throttle, not a quota wall.
177
+ const USAGE_LIMIT_MESSAGE_RE = /usage limit|usage exceeded|quota|limit (?:will )?resets?\b|insufficient (?:balance|credits?)/i;
178
+ const RESET_AT_TIMESTAMP_RE = /\bresets?\s+(?:at\s+)?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?\s*(?:Z|[+-]\d{2}:?\d{2}))/i;
179
+
180
+ type ProviderRefusal = { message: string; errorType?: string; statusCode?: number };
181
+
182
+ /** An account-level usage/quota wall: retrying in-session or re-rolling the session cannot clear it. */
183
+ export function isProviderUsageLimit(error: ProviderRefusal): boolean {
184
+ return USAGE_LIMIT_MESSAGE_RE.test(error.message) || /quota|usage_limit|tier_limit/i.test(error.errorType ?? "");
185
+ }
186
+
187
+ /** #2619 — a provider-wide overload (5xx): not a broken session, so never worth a re-roll. */
188
+ export function isProviderOverload(error: ProviderRefusal): boolean {
189
+ return (error.statusCode !== undefined && error.statusCode >= 500) || /overload/i.test(error.errorType ?? "");
190
+ }
191
+
192
+ // #2619 — per-request throttle wording. Some providers send it on a non-429 status flagged
193
+ // non-retryable (zai: 403 "Rate limit exceeded"); it is still a throttle, never an auth/model failure.
194
+ const THROTTLE_MESSAGE_RE = /rate[\s_-]?limit|too many requests|throttl/i;
195
+
196
+ /** A per-request throttle (429, or throttle wording on any status). It holds and retries, but it
197
+ * is NOT an account wall: see {@link isProviderUsageLimit} for that. */
198
+ export function isProviderThrottle(error: ProviderRefusal): boolean {
199
+ return error.statusCode === 429 || THROTTLE_MESSAGE_RE.test(error.message) || THROTTLE_MESSAGE_RE.test(error.errorType ?? "");
200
+ }
201
+
202
+ /** Any throttle / quota refusal. These hold as usage-class (reset time or the relay's fallback
203
+ * window, never exhausted) — only a 5xx/overload is a transient, backoff-class hold. */
204
+ function isProviderRateLimited(error: ProviderRefusal): boolean {
205
+ return isProviderThrottle(error) || isProviderUsageLimit(error);
206
+ }
207
+
208
+ /**
209
+ * #2619 — the hold a RETRYABLE provider API refusal (429 quota wall, 5xx overload) ends a turn in,
210
+ * instead of a terminal failure: a terminal `error` status resolves runner-core's provider exit,
211
+ * which is how every zai/OpenCode session died ~10 min into a quota wall. The same
212
+ * `blocked/rate_limit` hold the Claude arms raise: runner-core keeps it across the idle, the relay
213
+ * queues inbound mail, and its resume sweep wakes the agent at reset or on bounded backoff.
214
+ */
215
+ export function buildProviderRefusalHold(input: ProviderRefusal & { detail: string; source: string; resetAt?: number }, nowMs: number = Date.now()): ProviderState {
216
+ const usageLimit = isProviderRateLimited(input);
217
+ // A provider-stated reset (OpenCode's retry `next`, #2775) beats parsing one out of the message.
218
+ const resetAt = usageLimit ? clampReset(input.resetAt, nowMs) ?? providerLimitResetAt(input.message, nowMs) : undefined;
219
+ return buildRateLimitProviderState({
220
+ kind: usageLimit ? "usage_limit" : "transient_overload",
221
+ errorType: input.errorType ?? (input.statusCode !== undefined ? String(input.statusCode) : "provider_error"),
222
+ message: input.detail,
223
+ source: input.source,
224
+ ...(resetAt ? { resetAt } : {}),
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Reset time from a provider API error message, only when unambiguous: a relative "try again in
230
+ * N unit" or a timezone-qualified timestamp. A bare timestamp (zai's shape) has no zone, so it is
231
+ * left to the relay's fallback poll window rather than guessed.
232
+ */
233
+ function providerLimitResetAt(message: string, nowMs: number = Date.now()): number | undefined {
234
+ const relative = TRY_AGAIN_RE.exec(message);
235
+ if (relative) {
236
+ const unitMs = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 }[relative[2]!.toLowerCase()] ?? 0;
237
+ return clampReset(nowMs + Number(relative[1]) * unitMs, nowMs);
238
+ }
239
+ const absolute = RESET_AT_TIMESTAMP_RE.exec(message);
240
+ return absolute ? clampReset(Date.parse(absolute[1]!.replace(" ", "T").replace(/\s+/g, "")), nowMs) : undefined;
241
+ }
242
+
243
+ /**
244
+ * The live bottom block of a captured pane: the last run of consecutive non-blank rows
245
+ * (trailing blanks ignored). On a healthy Claude pane that is the composer + footer chrome;
246
+ * on a held one it is the modal that replaced them. Panes with no blank row at all (unit-test
247
+ * one-liners, a short modal-only capture) yield the whole text, which is what we want.
248
+ */
249
+ function livePaneBlock(text: string): string[] {
250
+ const lines = text.split(/\r?\n/);
251
+ let end = lines.length;
252
+ while (end > 0 && lines[end - 1]!.trim() === "") end--;
253
+ let start = end;
254
+ while (start > 0 && lines[start - 1]!.trim() !== "") start--;
255
+ return lines.slice(start, end);
256
+ }
257
+
258
+ // Unlike the usage-limit modal, Claude Code renders a transient API error as a CONVERSATION
259
+ // row (`● API Error: 529 Overloaded…`), i.e. in the body, above the composer — so the
260
+ // positional gate above cannot apply here without losing the detection entirely.
261
+ // #1611 — but the arm had the same own-output hole: the phrase was matched anywhere on any
262
+ // line, so an agent rendering this file's diff or a quoted error tripped it (reproduced live:
263
+ // the fragment `+ane("● API Error: 529 Ove` in a rendered patch marked the agent blocked).
264
+ // Claude's own error row STARTS with the error, after nothing but its bullet/box chrome, so
265
+ // anchor there: text that merely embeds the phrase mid-line no longer matches. A genuine
266
+ // error pane is unchanged.
267
+ const TRANSIENT_API_ERROR_RE = /^API Error:\s*((?:5\d\d)|529)\b[^\n]*(?:overload|server-side|temporar|try again|status\.claude\.com)?[^\n]*/i;
268
+ const TRANSIENT_5XX_RE = /^(?:5(?:00|02|03|04|20|29)|529)\b[^\n]*(?:overload|server-side|temporar|try again|status\.claude\.com)\b[^\n]*/i;
269
+ // Row chrome Claude prefixes its rendered lines with: whitespace, bullets/spinners, and the
270
+ // box-drawing block (U+2500–U+257F).
271
+ const PANE_ROW_PREFIX_RE = /^[\s─-╿●⏺✳✻✽⎿>❯»→✗✘ו*-]+/;
272
+
273
+ function parseClaudeTransientOverloadPane(text: string): PaneRateLimit | null {
274
+ for (const line of text.split(/\r?\n/)) {
275
+ const row = line.replace(PANE_ROW_PREFIX_RE, "");
276
+ const match = TRANSIENT_API_ERROR_RE.exec(row) ?? TRANSIENT_5XX_RE.exec(row);
277
+ if (!match) continue;
278
+ const code = match[1] ?? /\b(5\d\d|529)\b/.exec(match[0])?.[1];
279
+ return {
280
+ message: cleanPaneLine(line) || match[0].trim(),
281
+ kind: "transient_overload",
282
+ errorType: code === "529" ? "overloaded" : "server_error",
283
+ };
284
+ }
285
+ return null;
286
+ }
287
+
288
+ // The clean line containing the match offset, stripped of box-drawing chrome.
289
+ function limitLineAt(text: string, index: number): string {
290
+ const start = text.lastIndexOf("\n", index) + 1;
291
+ const endNl = text.indexOf("\n", index);
292
+ const end = endNl === -1 ? text.length : endNl;
293
+ return cleanPaneLine(text.slice(start, end));
294
+ }
295
+
296
+ function cleanPaneLine(line: string): string {
297
+ return line.replace(/[│┌┐└┘─┤├╭╮╯╰]/g, " ").replace(/\s+/g, " ").trim();
298
+ }
299
+
300
+ function clampReset(resetAt: number | undefined, nowMs: number): number | undefined {
301
+ if (resetAt === undefined || !Number.isFinite(resetAt)) return undefined;
302
+ if (resetAt <= nowMs || resetAt - nowMs > MAX_RESET_AHEAD_MS) return undefined;
303
+ return resetAt;
304
+ }
305
+
306
+ // Next occurrence of a wall-clock "H[:MM] am/pm" in the message's IANA timezone (if given
307
+ // and valid) or host-local. Timezone-aware so "resets 4:50pm (Asia/Shanghai)" is correct
308
+ // regardless of where the runner host sits.
309
+ function parseClockReset(match: RegExpExecArray, nowMs: number): number | undefined {
310
+ let hour = Number(match[1]);
311
+ const minute = match[2] ? Number(match[2]) : 0;
312
+ const meridiem = match[3]?.toLowerCase();
313
+ const tz = match[4]?.trim();
314
+ if (!Number.isFinite(hour) || hour > 23 || minute > 59) return undefined;
315
+ if (meridiem === "pm" && hour < 12) hour += 12;
316
+ if (meridiem === "am" && hour === 12) hour = 0;
317
+
318
+ if (tz && isValidTimeZone(tz)) {
319
+ const { y, mo, d, offsetMs } = tzWallAndOffset(tz, nowMs);
320
+ let target = Date.UTC(y, mo - 1, d, hour, minute, 0) - offsetMs;
321
+ if (target <= nowMs) target += 86_400_000;
322
+ return target;
323
+ }
324
+ const d = new Date(nowMs);
325
+ d.setHours(hour, minute, 0, 0);
326
+ let target = d.getTime();
327
+ if (target <= nowMs) target += 86_400_000;
328
+ return target;
329
+ }
330
+
331
+ function isValidTimeZone(tz: string): boolean {
332
+ try {
333
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ // The target TZ's wall-clock date and UTC offset at `atMs`, via Intl (no deps).
341
+ function tzWallAndOffset(tz: string, atMs: number): { y: number; mo: number; d: number; offsetMs: number } {
342
+ const dtf = new Intl.DateTimeFormat("en-US", {
343
+ timeZone: tz, hour12: false,
344
+ year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
345
+ });
346
+ const parts = Object.fromEntries(dtf.formatToParts(new Date(atMs)).map((p) => [p.type, p.value])) as Record<string, string>;
347
+ const y = Number(parts.year), mo = Number(parts.month), d = Number(parts.day);
348
+ const h = Number(parts.hour) % 24, mi = Number(parts.minute), s = Number(parts.second);
349
+ const asUtc = Date.UTC(y, mo - 1, d, h, mi, s);
350
+ return { y, mo, d, offsetMs: asUtc - atMs };
351
+ }
@@ -0,0 +1,36 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { sanitizeFsName } from "@humwick/sdk/fs-name";
6
+ import { runnerOutboxDirFromEnv } from "./config";
7
+
8
+ /** #2622: unconfirmed self-posts remain cancelled across restart, without an in-memory tombstone cap. */
9
+ export class RefusedPromptStore {
10
+ private readonly db: Database;
11
+
12
+ constructor(options: { agentId: string; dir?: string }) {
13
+ const dir = options.dir ?? runnerOutboxDirFromEnv() ?? join(tmpdir(), "agent-relay-outbox");
14
+ const name = sanitizeFsName(options.agentId, { replacement: "_", maxLen: 180, fallback: "agent" });
15
+ if (dir !== ":memory:") mkdirSync(dir, { recursive: true });
16
+ this.db = new Database(dir === ":memory:" ? dir : join(dir, `refused-prompts-${name}.sqlite`), { create: true });
17
+ this.db.exec("PRAGMA journal_mode = WAL");
18
+ this.db.exec("PRAGMA busy_timeout = 2000");
19
+ this.db.exec("CREATE TABLE IF NOT EXISTS unconfirmed_prompts (key TEXT PRIMARY KEY)");
20
+ }
21
+
22
+ begin(key: string): void {
23
+ // Persist before the first POST: even a crash while waiting for its response leaves cancellation evidence.
24
+ this.db.query("INSERT INTO unconfirmed_prompts (key) VALUES (?)").run(key);
25
+ }
26
+
27
+ confirm(key: string): void {
28
+ this.db.query("DELETE FROM unconfirmed_prompts WHERE key = ?").run(key);
29
+ }
30
+
31
+ isUnconfirmed(key: string): boolean {
32
+ return this.db.query("SELECT 1 FROM unconfirmed_prompts WHERE key = ?").get(key) !== null;
33
+ }
34
+
35
+ close(): void { this.db.close(); }
36
+ }
@@ -0,0 +1,161 @@
1
+ import type { MessageSessionMeta, RelayInjectionCategory } from "@humwick/sdk";
2
+ import { isSpawnProvider } from "@humwick/sdk";
3
+ import type { ProviderConfig, RunnerSpawnConfig } from "./adapter";
4
+ import { assembleLaunch } from "./launch-assembly";
5
+
6
+ export interface RunnerRelayInjectionEvent {
7
+ category: RelayInjectionCategory;
8
+ summary: string;
9
+ detail: Record<string, unknown>;
10
+ }
11
+
12
+ interface RunnerRelayInjectionSessionEvent {
13
+ from: string;
14
+ to: string;
15
+ body: string;
16
+ session: MessageSessionMeta;
17
+ }
18
+
19
+ interface KeyedRelayInjectionEvent {
20
+ key: string;
21
+ event: RunnerRelayInjectionEvent;
22
+ }
23
+
24
+ interface RunnerLaunchInjectionInput {
25
+ carried?: RunnerRelayInjectionEvent[];
26
+ provider: string;
27
+ config: RunnerSpawnConfig;
28
+ providerConfig: ProviderConfig;
29
+ agentId: string;
30
+ providerSessionId: string;
31
+ timestamp?: number;
32
+ }
33
+
34
+ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInjectionEvent[] {
35
+ let events: KeyedRelayInjectionEvent[] = (input.carried ?? []).map((event, index) => ({ key: `spawn-${index}`, event }));
36
+ if (!isSpawnProvider(input.provider)) return events;
37
+ const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
38
+ // OpenCode/zai transport systemPromptAppend only with the first accepted provider turn. The
39
+ // server-side composition events historically said "injected" at spawn time, which made the
40
+ // dashboard claim delivery even when no initial prompt/inbox turn had run. Preserve the event
41
+ // and its source details, but label the transport truth as prepared/pending until that turn.
42
+ if (assembled.launchContextPending) {
43
+ events = events.map(({ key, event }) => ({
44
+ key,
45
+ event: event.summary.startsWith("injected ")
46
+ ? {
47
+ ...event,
48
+ summary: `prepared ${event.summary.slice("injected ".length)}`,
49
+ detail: { ...event.detail, transportState: "pending-first-provider-turn" },
50
+ }
51
+ : event,
52
+ }));
53
+ }
54
+ // #1565 P5.7 — the native-memory lever gets its OWN `memory`-category event (the channel #1612
55
+ // established for the spawn-seam memory block), not a line in the tools summary: this decides
56
+ // whether the agent has one memory store or two, and a SUPPRESSED lever must be visible even on
57
+ // a launch that provisions nothing else and would otherwise return early below.
58
+ const lever = assembled.nativeMemory;
59
+ if (lever.policy !== "default") {
60
+ events.push({
61
+ key: "native-memory",
62
+ event: {
63
+ category: "memory",
64
+ summary: lever.suppressed
65
+ ? `native memory ${lever.policy}: PARTIALLY applied — ${lever.suppressed.reason}`
66
+ : `native memory ${lever.policy} (${lever.applied.join(", ") || "no lever"})`,
67
+ detail: {
68
+ source: "native-memory-lever",
69
+ provider: assembled.provider,
70
+ base: assembled.base,
71
+ policy: lever.policy,
72
+ applied: lever.applied,
73
+ ...(lever.suppressed ? { suppressed: lever.suppressed } : {}),
74
+ },
75
+ },
76
+ });
77
+ }
78
+ // #3025 — a host without agent-redact still launches the agent; that launch must say so loudly.
79
+ // Appended last, so readers that find the first "tools" event still get the provisioning summary.
80
+ const redaction = assembled.redaction;
81
+ const redactionEvents: KeyedRelayInjectionEvent[] = redaction.state === "unavailable" ? [{
82
+ key: "redaction",
83
+ event: {
84
+ category: "tools",
85
+ summary: `secret redaction UNAVAILABLE, tool output reaches the model unmasked: ${redaction.reason}`,
86
+ detail: { source: "agent-redact", provider: assembled.provider, ...redaction },
87
+ },
88
+ }] : [];
89
+ const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
90
+ + assembled.hooks.length
91
+ + assembled.projectSkills.length
92
+ + assembled.mcp.projectServers.length
93
+ + (assembled.projectSettings.applied ? 1 : 0)
94
+ + (assembled.relaySkills ? 1 : 0)
95
+ + (assembled.relayPlugin ? 1 : 0)
96
+ + (assembled.mcp.relayEndpoint ? 1 : 0)
97
+ + (assembled.statusLine ? 1 : 0)
98
+ + (redaction.state === "on" ? 1 : 0);
99
+ if (toolCount === 0) return [...events, ...redactionEvents];
100
+ const parts = [
101
+ assembled.relaySkills ? "relay skills" : "",
102
+ assembled.relayPlugin ? "relay plugin" : "",
103
+ assembled.statusLine ? "status line" : "",
104
+ assembled.mcp.relayEndpoint ? "relay MCP" : "",
105
+ assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
106
+ assembled.projectSkills.length ? `${assembled.projectSkills.length} project skill${assembled.projectSkills.length === 1 ? "" : "s"}` : "",
107
+ assembled.projectSettings.applied ? "project settings" : "",
108
+ assembled.hooks.length ? `${assembled.hooks.length} hook${assembled.hooks.length === 1 ? "" : "s"}` : "",
109
+ assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
110
+ assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
111
+ assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
112
+ redaction.state === "on" ? `secret redaction (agent-redact ${redaction.version}, ${redaction.mechanism})` : "",
113
+ ].filter(Boolean);
114
+ events.push({
115
+ key: "tools",
116
+ event: {
117
+ category: "tools",
118
+ summary: `provisioned ${parts.join(", ")}`,
119
+ detail: {
120
+ source: "launch-assembly",
121
+ provider: assembled.provider,
122
+ base: assembled.base,
123
+ vanilla: assembled.vanilla,
124
+ relayContext: assembled.relayContext,
125
+ relayContextPending: assembled.relayContextPending === true,
126
+ launchContextPending: assembled.launchContextPending === true,
127
+ relaySkills: assembled.relaySkills,
128
+ relayPlugin: assembled.relayPlugin,
129
+ statusLine: assembled.statusLine,
130
+ skills: assembled.skills,
131
+ projectSkills: assembled.projectSkills,
132
+ projectSettings: assembled.projectSettings,
133
+ hooks: assembled.hooks,
134
+ plugins: assembled.plugins,
135
+ mcp: assembled.mcp,
136
+ redaction,
137
+ },
138
+ },
139
+ });
140
+ return [...events, ...redactionEvents];
141
+ }
142
+
143
+ export function runnerLaunchInjectionSessionEvents(input: RunnerLaunchInjectionInput): RunnerRelayInjectionSessionEvent[] {
144
+ const timestamp = input.timestamp ?? Date.now();
145
+ return launchInjectionEvents(input).map(({ event, key }) => ({
146
+ from: input.agentId,
147
+ to: "user",
148
+ body: event.summary,
149
+ session: {
150
+ type: "relay-injection",
151
+ stepId: `relay-injection:${input.providerSessionId}:${key}`,
152
+ relayInjection: {
153
+ category: event.category,
154
+ summary: event.summary,
155
+ detail: event.detail,
156
+ agentId: input.agentId,
157
+ timestamp,
158
+ },
159
+ },
160
+ }));
161
+ }