@humwick/runner 0.130.5 → 0.130.6

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 (211) hide show
  1. package/agent-bin/humwick +2 -2
  2. package/agent-bin/humwick-browser +4 -0
  3. package/hooks/fff-search-redirect/fff-search-redirect.cjs +1 -1
  4. package/hooks/web-tools-redirect/web-tools-redirect.cjs +2 -2
  5. package/package.json +7 -10
  6. package/plugins/browser/skills/playwright-cli/SKILL.md +8 -6
  7. package/plugins/claude/.claude-plugin/plugin.json +3 -3
  8. package/plugins/claude/claude-statusline-probe.cjs +26 -16
  9. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +47 -17
  10. package/plugins/claude/hooks/gate-run-guard.sh +3 -3
  11. package/plugins/claude/hooks/gate-run-guard.ts +5 -5
  12. package/plugins/claude/hooks/kill-guard.sh +4 -4
  13. package/plugins/claude/hooks/kill-guard.ts +1 -1
  14. package/plugins/claude/hooks/permission-request.sh +1 -1
  15. package/plugins/claude/hooks/post-compact.sh +1 -1
  16. package/plugins/claude/hooks/post-tool-use.sh +3 -3
  17. package/plugins/claude/hooks/pre-compact.sh +2 -2
  18. package/plugins/claude/hooks/read-only-bash-guard.sh +4 -4
  19. package/plugins/claude/hooks/relay-status.sh +32 -32
  20. package/plugins/claude/hooks/session-end.sh +2 -2
  21. package/plugins/claude/hooks/session-start.sh +3 -3
  22. package/plugins/claude/hooks/shell-trust.ts +1 -1
  23. package/plugins/claude/hooks/stop-failure.sh +2 -2
  24. package/plugins/claude/hooks/stop.sh +6 -6
  25. package/plugins/claude/hooks/subagent-skill-policy.cjs +8 -6
  26. package/plugins/claude/hooks/subagent-stop.sh +3 -3
  27. package/plugins/claude/hooks/user-prompt-submit.sh +1 -1
  28. package/plugins/claude/hooks/wait-loop-guard.ts +3 -3
  29. package/plugins/claude/monitors/monitors.json +2 -2
  30. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +96 -62
  31. package/plugins/claude/monitors/relay-monitor.ts +5 -5
  32. package/plugins/claude/skills/label/SKILL.md +3 -3
  33. package/plugins/claude/skills/message/SKILL.md +5 -5
  34. package/plugins/claude/skills/react/SKILL.md +1 -1
  35. package/plugins/claude/skills/read-message/SKILL.md +4 -4
  36. package/plugins/claude/skills/reply/SKILL.md +5 -5
  37. package/plugins/claude/skills/send-claimable/SKILL.md +5 -5
  38. package/plugins/claude/skills/slice-first/SKILL.md +5 -3
  39. package/plugins/claude/skills/slice-first/references/plan-recipe.md +30 -29
  40. package/plugins/claude/skills/status/SKILL.md +1 -1
  41. package/plugins/claude/skills/tags/SKILL.md +5 -5
  42. package/plugins/claude/skills/task/SKILL.md +10 -10
  43. package/plugins/claude-output-style/.claude-plugin/plugin.json +1 -1
  44. package/plugins/claude-output-style/output-styles/{agent-relay.md → humwick.md} +1 -1
  45. package/plugins/codex/skills/guide/SKILL.md +1 -1
  46. package/plugins/codex/skills/label/SKILL.md +3 -3
  47. package/plugins/codex/skills/message/SKILL.md +5 -5
  48. package/plugins/codex/skills/react/SKILL.md +1 -1
  49. package/plugins/codex/skills/read-message/SKILL.md +4 -4
  50. package/plugins/codex/skills/reply/SKILL.md +5 -5
  51. package/plugins/codex/skills/send-claimable/SKILL.md +5 -5
  52. package/plugins/codex/skills/slice-first/SKILL.md +5 -3
  53. package/plugins/codex/skills/slice-first/references/plan-recipe.md +30 -29
  54. package/plugins/codex/skills/status/SKILL.md +1 -1
  55. package/plugins/codex/skills/tags/SKILL.md +5 -5
  56. package/plugins/codex/skills/task/SKILL.md +11 -10
  57. package/src/adapter.ts +95 -92
  58. package/src/adapters/claude-api.ts +8 -11
  59. package/src/adapters/claude-context-command.ts +1 -1
  60. package/src/adapters/claude-credentials.ts +7 -3
  61. package/src/adapters/claude-delivery-lanes.ts +2 -2
  62. package/src/adapters/claude-delivery.ts +16 -16
  63. package/src/adapters/claude-effort.ts +2 -2
  64. package/src/adapters/claude-exit.ts +2 -2
  65. package/src/adapters/claude-headless-mirror.ts +18 -2
  66. package/src/adapters/claude-headless-refusal.ts +3 -3
  67. package/src/adapters/claude-headless.ts +11 -10
  68. package/src/adapters/claude-launch-exit.ts +1 -1
  69. package/src/adapters/claude-monitor-delivery-plan.ts +36 -16
  70. package/src/adapters/claude-permission.ts +5 -5
  71. package/src/adapters/claude-prompt-gates.ts +9 -9
  72. package/src/adapters/claude-prompt-hook.ts +2 -2
  73. package/src/adapters/claude-session-capture.ts +6 -6
  74. package/src/adapters/claude-session-probe.ts +25 -25
  75. package/src/adapters/claude-shutdown.ts +11 -11
  76. package/src/adapters/claude-status-detectors.ts +31 -31
  77. package/src/adapters/claude-subagents.ts +7 -7
  78. package/src/adapters/claude-submit-confirmation.ts +6 -6
  79. package/src/adapters/claude-tmux.ts +43 -43
  80. package/src/adapters/claude-token-usage-capture.ts +5 -5
  81. package/src/adapters/claude-tool-policy.ts +7 -7
  82. package/src/adapters/claude-transcript.ts +24 -24
  83. package/src/adapters/claude.ts +100 -100
  84. package/src/adapters/codex-agent-message-capture.ts +26 -63
  85. package/src/adapters/codex-app-server.ts +1 -1
  86. package/src/adapters/codex-client.ts +9 -9
  87. package/src/adapters/codex-effective-config.ts +1 -1
  88. package/src/adapters/codex-launch-config.ts +5 -5
  89. package/src/adapters/codex-rollout-context.ts +2 -2
  90. package/src/adapters/codex-skill-discovery.ts +1 -1
  91. package/src/adapters/codex-token-usage-capture.ts +16 -16
  92. package/src/adapters/codex-tool-output.ts +4 -4
  93. package/src/adapters/codex-version.ts +3 -3
  94. package/src/adapters/codex.ts +64 -64
  95. package/src/adapters/opencode-api.ts +1 -1
  96. package/src/adapters/opencode-context.ts +20 -22
  97. package/src/adapters/opencode-model-registry.ts +7 -7
  98. package/src/adapters/opencode-provider.ts +8 -8
  99. package/src/adapters/opencode-provisioning.ts +30 -0
  100. package/src/adapters/opencode-server-retry.ts +1 -1
  101. package/src/adapters/opencode-soft-steer.ts +5 -5
  102. package/src/adapters/opencode-spawn.ts +18 -14
  103. package/src/adapters/opencode-token-usage-capture.ts +12 -12
  104. package/src/adapters/opencode-tool-events.ts +15 -15
  105. package/src/adapters/opencode.ts +232 -232
  106. package/src/adapters/project-shell.ts +11 -11
  107. package/src/adapters/service-access.ts +6 -6
  108. package/src/adapters/tmux-offload.ts +9 -9
  109. package/src/adapters/tool-summary.ts +5 -5
  110. package/src/agent-redact.ts +1 -1
  111. package/src/agent-runner/commands/dispatch.ts +10 -10
  112. package/src/agent-runner/commands/lifecycle.ts +2 -2
  113. package/src/agent-runner/commands/prompt.ts +19 -19
  114. package/src/agent-runner/credentials.ts +5 -5
  115. package/src/agent-runner/inbox.ts +1 -1
  116. package/src/agent-runner/lifecycle-milestones.ts +3 -3
  117. package/src/agent-runner/lifecycle-phase.ts +2 -2
  118. package/src/agent-runner/liveness-exchange.ts +4 -10
  119. package/src/agent-runner/registration.ts +8 -8
  120. package/src/agent-runner/runner-context.ts +9 -14
  121. package/src/agent-runner/session-mirror.ts +12 -12
  122. package/src/agent-runner/task-claims.ts +2 -2
  123. package/src/agent-runner/token-usage-coverage.ts +1 -1
  124. package/src/agent-runner/turn-report.ts +12 -12
  125. package/src/api-budget-accounting.ts +3 -3
  126. package/src/area-memory.ts +6 -6
  127. package/src/artifact-path-materialize.ts +1 -1
  128. package/src/async-guard.ts +5 -5
  129. package/src/attachment-cache.ts +17 -22
  130. package/src/background-script-reconcile.ts +6 -6
  131. package/src/bounded-log.ts +1 -1
  132. package/src/busy-reconciler.ts +22 -22
  133. package/src/claim-tracker.ts +3 -3
  134. package/src/claude-settings.ts +8 -8
  135. package/src/claude-system-prompt.ts +1 -1
  136. package/src/claude-workspace-trust.ts +1 -1
  137. package/src/config.ts +62 -61
  138. package/src/context-boundary.ts +5 -5
  139. package/src/context-command.ts +3 -3
  140. package/src/control-server.ts +32 -32
  141. package/src/delivery-result.ts +2 -2
  142. package/src/effort-command.ts +2 -2
  143. package/src/empty-turn.ts +22 -14
  144. package/src/free-port.ts +1 -1
  145. package/src/gate-run-config.ts +3 -3
  146. package/src/host-asset-plan.ts +24 -17
  147. package/src/host-claude-config-worker.ts +2 -2
  148. package/src/host-sources.ts +2 -2
  149. package/src/index.ts +16 -16
  150. package/src/launch-assembly.ts +94 -96
  151. package/src/liveness.ts +15 -15
  152. package/src/logger.ts +3 -3
  153. package/src/mcp-outbox.ts +3 -3
  154. package/src/mcp-preflight.ts +2 -2
  155. package/src/mcp-source-selection.ts +2 -2
  156. package/src/mcp-tool-deferral.ts +1 -1
  157. package/src/mcp-vault-env.ts +2 -2
  158. package/src/message-body-config/config.ts +4 -4
  159. package/src/mid-turn-delivery.ts +2 -2
  160. package/src/native-memory-lever.ts +8 -8
  161. package/src/outbox.ts +15 -15
  162. package/src/pending-permission-store.ts +1 -1
  163. package/src/profile-home.ts +19 -19
  164. package/src/profile-projection.ts +26 -24
  165. package/src/project-instructions.ts +5 -5
  166. package/src/provider-auth-warning.ts +17 -0
  167. package/src/provider-double.ts +1 -1
  168. package/src/provider-hold.ts +3 -3
  169. package/src/provider-home-marker.ts +1 -1
  170. package/src/provider-home-tombstone.ts +1 -1
  171. package/src/provider-input-queue.ts +2 -2
  172. package/src/provider-integration.ts +15 -13
  173. package/src/provider-setup.ts +15 -3
  174. package/src/providers.ts +1 -1
  175. package/src/provisioning.ts +37 -40
  176. package/src/question-decision.ts +2 -2
  177. package/src/quota.ts +0 -6
  178. package/src/rate-limit.ts +24 -13
  179. package/src/refused-prompt-store.ts +2 -2
  180. package/src/relay-injection-events.ts +2 -2
  181. package/src/relay-instructions.ts +5 -5
  182. package/src/relay-mcp-proxy.long-turn-e2e.ts +13 -13
  183. package/src/relay-mcp-proxy.ts +57 -58
  184. package/src/relay-mcp.ts +55 -69
  185. package/src/reply-obligation-cache.ts +9 -9
  186. package/src/response-capture-report.ts +9 -9
  187. package/src/response-routing.ts +1 -1
  188. package/src/runner-core.ts +209 -222
  189. package/src/runner-exit.ts +1 -1
  190. package/src/runner-helpers.ts +10 -10
  191. package/src/runner-insights.ts +4 -2
  192. package/src/runner-mcp-proxy.ts +3 -3
  193. package/src/runner-spawn-config.ts +3 -3
  194. package/src/runtime-token-file.ts +1 -1
  195. package/src/runtime-token-remint.ts +1 -1
  196. package/src/service-access.ts +2 -2
  197. package/src/session-destroy.ts +1 -1
  198. package/src/session-insights.ts +1 -1
  199. package/src/session-scratch.ts +24 -37
  200. package/src/spawn-input.ts +6 -6
  201. package/src/startup-config-fetch.ts +4 -4
  202. package/src/subreaper.ts +3 -5
  203. package/src/test-run-dedup.ts +13 -13
  204. package/src/transcript-context.ts +2 -2
  205. package/src/transport-errors.ts +1 -1
  206. package/src/turn-opener.ts +1 -1
  207. package/src/turn-reconcile-event.ts +2 -2
  208. package/src/vault-proxy-hop.ts +2 -2
  209. package/src/web-tools.ts +2 -2
  210. package/agent-bin/agent-relay-browser +0 -10
  211. package/src/adapters/provider-quota-harvest.ts +0 -76
@@ -46,10 +46,7 @@ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
46
46
  this.nativeAllowanceMicroUsd = this.reservation.allowanceMicroUsd - this.costMicroUsd;
47
47
  return { ...built, args: [...args, "--max-budget-usd", String(this.nativeAllowanceMicroUsd / 1_000_000),
48
48
  ...(this.resumeSession ? ["--resume", this.resumeSession] : [])],
49
- env: resolveProviderApiAuth(this.provider, built.env, {
50
- ANTHROPIC_API_KEY_FILE: providerConfig.env.ANTHROPIC_API_KEY_FILE ?? process.env.ANTHROPIC_API_KEY_FILE
51
- ?? "~/.config/agent-relay/secrets/anthropic-api.key",
52
- }, config.env) };
49
+ env: resolveProviderApiAuth(this.provider, built.env, providerConfig.env, config.env) };
53
50
  }
54
51
 
55
52
  override async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
@@ -59,10 +56,10 @@ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
59
56
  // Validate fresh auth before reserving; no model child starts on denied/unknown accounting.
60
57
  resolveProviderApiAuth(this.provider, config.env, { ...config.providerConfig.env,
61
58
  ANTHROPIC_API_KEY_FILE: config.providerConfig.env.ANTHROPIC_API_KEY_FILE ?? process.env.ANTHROPIC_API_KEY_FILE
62
- ?? "~/.config/agent-relay/secrets/anthropic-api.key" });
59
+ ?? "~/.config/humwick/secrets/anthropic-api.key" });
63
60
  const endedSession = this.managed && (this.managed.process as Bun.Subprocess).exitCode !== null && this.reservation
64
61
  ? { sessionId: this.reservation.sessionId, costMicroUsd: this.costMicroUsd } : undefined;
65
- const decision = await this.budget.reserve(relayEnv(config.env, "AGENT_RELAY_PROVIDER_SESSION_ID") ?? crypto.randomUUID(), endedSession);
62
+ const decision = await this.budget.reserve(relayEnv(config.env, "HUMWICK_PROVIDER_SESSION_ID") ?? crypto.randomUUID(), endedSession);
66
63
  if (!decision.allowed || !decision.reservation) throw new ProviderSubmitFailure(`Claude API admission refused: ${decision.reason ?? "invalid reservation"}`, false);
67
64
  this.managed = undefined;
68
65
  this.held = false; this.costMicroUsd = 0;
@@ -84,7 +81,7 @@ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
84
81
  void child.exited.finally(() => { if (managed === this.managed && managed.process === child) clearTimeout(this.expiry); clearInterval(heartbeat); }).catch(() => {});
85
82
  }
86
83
 
87
- /** One incarnation-owned expiry timer; re-read the window on every arm so a moved endsAt takes effect immediately (#3312). */
84
+ /** One incarnation-owned expiry timer; re-read the window on every arm so a moved endsAt takes effect immediately. */
88
85
  private armExpiry() {
89
86
  clearTimeout(this.expiry);
90
87
  const managed = this.managed, reservation = this.reservation;
@@ -121,7 +118,7 @@ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
121
118
  try { decision = await this.reportBudget(this.reservation.sessionId, { costMicroUsd: this.costMicroUsd, ...(this.rollover ? { renew: true } : {}) }); }
122
119
  catch (e) {
123
120
  if (isTransientBudgetFailure(e)) {
124
- // Park the input under a transient hold: the relay's bounded resume backoff retries it, no operator step (#3373).
121
+ // Park the input under a transient hold: the relay's bounded resume backoff retries it, no operator step.
125
122
  this.emitStatus({ status: "idle", providerState: { ...buildRateLimitProviderState({ kind: "transient_overload",
126
123
  errorType: "budget_accounting_unavailable", message: "budget-accounting-unavailable", source: this.provider }),
127
124
  label: "Claude API budget ledger unreachable; retrying" } });
@@ -162,7 +159,7 @@ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
162
159
  this.rollover = true;
163
160
  this.retireAtBoundary(this.managed);
164
161
  if (this.budgetState) return this.budgetState;
165
- return { state: "blocked", reason: "rate_limit", kind: "usage_limit", source: this.provider,
162
+ return { state: "blocked", reason: "rate_limit", kind: "usage_limit", usageLimitScope: "session", source: this.provider,
166
163
  label: "Claude API allowance reached; renewing reservation", resetAt: Date.now(), enteredAt: Date.now() };
167
164
  }
168
165
 
@@ -193,7 +190,7 @@ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
193
190
  private accountingFailed(error: unknown, managed?: ManagedProcess) {
194
191
  if (managed !== this.managed) return;
195
192
  // The native --max-budget-usd cap and the expiry timer bound spend to the reservation while the
196
- // ledger is unreachable, so a transient failure never stops the provider; new input still needs a report (#3373).
193
+ // ledger is unreachable, so a transient failure never stops the provider; new input still needs a report.
197
194
  if (isTransientBudgetFailure(error)) return;
198
195
  this.stopForBudget("budget-accounting-unavailable");
199
196
  }
@@ -201,7 +198,7 @@ export class ClaudeApiAdapter extends ClaudeHeadlessAdapter {
201
198
  private stopForBudget(reason: string) {
202
199
  if (this.held) return;
203
200
  this.held = true;
204
- this.budgetState = { state: "blocked", reason: "rate_limit", kind: "usage_limit",
201
+ this.budgetState = { state: "blocked", reason: "rate_limit", kind: "usage_limit", usageLimitScope: "session",
205
202
  source: this.provider, label: "Claude API budget held", message: reason,
206
203
  recommendedAction: "Check the shared API credit grant and restart after restoring headroom", enteredAt: Date.now() };
207
204
  try { this.emitStatus({ status: "idle", clear: ["provider-turn"], providerState: this.budgetState }); }
@@ -3,7 +3,7 @@ import { claudePaneIsBusy } from "./claude-status-detectors";
3
3
  import { claudeComposerText } from "./claude-submit-confirmation";
4
4
  import { captureTmuxVisiblePaneAsync, runTmuxInput } from "./tmux-offload";
5
5
 
6
- /** Slash commands must reach an idle, empty composer as literal keystrokes (#2794). */
6
+ /** Slash commands must reach an idle, empty composer as literal keystrokes. */
7
7
  export async function submitClaudeContextCommand(session: string, command: "/compact" | "/clear" | `/effort ${string}`, socket?: string, signal?: AbortSignal, onSubmitted?: () => void): Promise<void> {
8
8
  signal?.throwIfAborted();
9
9
  const pane = await captureTmuxVisiblePaneAsync(session, socket, { styled: true });
@@ -1,9 +1,9 @@
1
- import { writeChildEnv } from "@humwick/sdk/env";
1
+ import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
2
  import { CLAUDE_FAMILY_AMBIENT_CREDENTIAL_ENV_KEYS } from "@humwick/providers";
3
3
  import { resolveFileEnv } from "@humwick/sdk/child-env";
4
4
 
5
5
  /**
6
- * #2513 — keep only the credentials this claude-family provider owns (claude-api owns the host paid-key
6
+ * keep only the credentials this claude-family provider owns (claude-api owns the host paid-key
7
7
  * file; other ids drop the primary subscription's ambient keys). A dropped key is also pushed to `envUnset`.
8
8
  */
9
9
  export function scopeClaudeCredentialEnv(provider: string, providerEnv: Record<string, string | undefined>, env: Record<string, string>, envUnset: string[]): void {
@@ -13,10 +13,14 @@ export function scopeClaudeCredentialEnv(provider: string, providerEnv: Record<s
13
13
  const scoped = resolveFileEnv(providerEnv, { keys: credentialKeys });
14
14
  for (const key of credentialKeys) {
15
15
  const providerScoped = scoped[key];
16
- if (providerScoped) writeChildEnv(env, { [key]: providerScoped });
16
+ if (providerScoped) writeChildEnv(env, { [key]: providerScoped }, []);
17
17
  else {
18
18
  writeChildEnv(env, {}, [key]);
19
19
  envUnset.push(key);
20
20
  }
21
21
  }
22
+ // tmux must not resurrect credentials absent from the probed child environment.
23
+ for (const key of CLAUDE_FAMILY_AMBIENT_CREDENTIAL_ENV_KEYS) {
24
+ if (!relayEnv(env, key) && !envUnset.includes(key)) envUnset.push(key);
25
+ }
22
26
  }
@@ -3,7 +3,7 @@ import { isHumanSenderMessage, type DeliverResult } from "../adapter";
3
3
  import { logger } from "../logger";
4
4
 
5
5
  /**
6
- * #3415 — Claude Code labels Monitor events "NOT USER INPUT", so human senders go in as a tmux user turn,
6
+ * Claude Code labels Monitor events "NOT USER INPUT", so human senders go in as a tmux user turn,
7
7
  * never Monitor, never stubbed; the rest stays on Monitor (all of it without a tmux pane). Kept out of
8
8
  * claude-delivery.ts: the provisioned Monitor bundle imports it and the logger would pull the provider catalog in.
9
9
  */
@@ -18,7 +18,7 @@ export async function deliverClaudeByLane(messages: Message[], lanes: {
18
18
  if (background.length === 0) return lanes.userTurn(userTurn);
19
19
  // The typed turn answers only what it carried: a Monitor message may still fail, or fold into the turn as its own event.
20
20
  const disarm = lanes.narrowTurnOpener?.(userTurn);
21
- // The user turn goes first, so its throw (a #2664 submit fence) propagates before any Monitor event; it alone reports the input offer.
21
+ // The user turn goes first, so its throw propagates before any Monitor event; it alone reports the input offer.
22
22
  const typed = await lanes.userTurn(userTurn).catch((error: unknown) => { disarm?.(); throw error; });
23
23
  const monitored = await lanes.monitor(background, false).catch((error: unknown): DeliverResult => {
24
24
  logger.warn("claude", `monitor delivery failed after a user-turn submit; requeueing ${background.length} message(s): ${error}`);
@@ -1,21 +1,21 @@
1
1
  import type { Message } from "@humwick/sdk";
2
- import { dedupePersistedMessages, isMemoryInjection, isNotificationMessage, isPersistedRelayMessage, isReactionNotification, isSelfResumeInjection, NOTIFICATION_NUDGE, providerAttachmentText, providerSenderLabel } from "../adapter";
2
+ import { dedupePersistedMessages, isMemoryInjection, isNotificationMessage, isPersistedRelayMessage, isReactionNotification, isSelfResumeInjection, monitorDeliveryLineMaxChars, NOTIFICATION_NUDGE, providerAttachmentText, providerSenderLabel } from "../adapter";
3
3
  import { receivesTurnResponseAutomatically } from "../response-routing";
4
- import { planClaudeMonitorDelivery, type MonitorDeliveryPlan } from "./claude-monitor-delivery-plan";
4
+ import { planClaudeMonitorDelivery, truncateToCaps, type MonitorDeliveryPlan } from "./claude-monitor-delivery-plan";
5
5
 
6
6
  interface ClaudeDeliveryTextOptions {
7
7
  // Isolated profiles have no Relay surface, so omit claim instructions and notification nudges.
8
8
  relaySurface?: boolean;
9
- // #1935 — chars already emitted into the SAME batched monitor event ahead of this text (the
9
+ // chars already emitted into the SAME batched monitor event ahead of this text (the
10
10
  // once-per-session context/workspace block the plugin logs on first delivery). Counted against
11
11
  // the per-event cap so the over-limit stub gate accounts for the prefix, not just the message.
12
12
  reservedChars?: number;
13
- // #1935 — enable the over-limit stub gate. Only the monitor delivery path is subject to the
13
+ // enable the over-limit stub gate. Only the monitor delivery path is subject to the
14
14
  // harness per-event/per-line caps; the tmux fallback pastes full text straight into the pane and
15
15
  // must NOT stub (a monitorless agent may have no get-message surface). Defaults to off; the
16
16
  // relay-monitor plugin opts in.
17
17
  monitorLimited?: boolean;
18
- // #2310 — directory to spill an over-cap must-keep (non-fetchable) section's full content to,
18
+ // directory to spill an over-cap must-keep (non-fetchable) section's full content to,
19
19
  // in place of ever inlining a truncated copy. See planClaudeMonitorDelivery's spillDir doc.
20
20
  spillDir?: string;
21
21
  }
@@ -44,7 +44,7 @@ function formatMessage(message: Message, relaySurface: boolean): string {
44
44
 
45
45
  if (isMemoryContext) {
46
46
  return [
47
- `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
47
+ `[humwick ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
48
48
  providerAttachmentText(message),
49
49
  `${subject}${rawBody}`,
50
50
  ].filter(Boolean).join("\n");
@@ -57,10 +57,8 @@ function formatMessage(message: Message, relaySurface: boolean): string {
57
57
  ].filter(Boolean).join("\n");
58
58
  }
59
59
 
60
- // #1935 — a message whose full section would blow the monitor size limit is delivered as a minimal
61
- // stub: sender, a one-line gist (subject, else the first sentence of the body), thread, and the
62
- // fetch hint. Only persisted, fetchable, non-memory messages qualify (see isStubbable below) —
63
- // non-fetchable content is routed by the planner to a spill pointer or a truncation notice instead.
60
+ // over-cap messages get a body preview plus retrieval guidance when space permits.
61
+ // Compact stubs preserve batch admission under tight caps; non-fetchable content uses spill files.
64
62
  const STUB_GIST_MAX_CHARS = 200;
65
63
 
66
64
  function clampToLine(text: string, max = STUB_GIST_MAX_CHARS): string {
@@ -75,7 +73,7 @@ function firstSentence(body: string): string {
75
73
  return clampToLine(match ? match[0] : trimmed);
76
74
  }
77
75
 
78
- // #2310 — three sub-classes of over-limit section, each routed differently by the planner:
76
+ // three sub-classes of over-limit section, each routed differently by the planner:
79
77
  // fetchable (isStubbable) — persisted (id>0), retrievable via get-message → clean meta-only
80
78
  // stub. Covers ordinary messages AND persisted reaction
81
79
  // notifications (LOW-7: a persisted reaction must never be
@@ -102,7 +100,7 @@ function nonStubbableSectionLabel(message: Message): string {
102
100
  return "content";
103
101
  }
104
102
 
105
- // #2844 — a task-delivery notice carries its actionable fields in the payload (see
103
+ // a task-delivery notice carries its actionable fields in the payload (see
106
104
  // notifyDelivery in src/services/task-delivery.ts), so the stub inlines them: disposition, head
107
105
  // sha, blocker count and next action. The coordinator can act without an extra get-message
108
106
  // round-trip; the full body stays behind the fetch hint.
@@ -125,17 +123,19 @@ function taskDeliverableSummary(message: Message): string[] | undefined {
125
123
  return lines.length > 0 ? lines : undefined;
126
124
  }
127
125
 
128
- function stubMessage(message: Message): string {
126
+ function stubMessage(message: Message, includePreview = false): string {
129
127
  const gist = message.subject ? `Subject: ${clampToLine(message.subject)}` : firstSentence(message.body);
128
+ const preview = includePreview ? truncateToCaps(message.body, 400, monitorDeliveryLineMaxChars()) : "";
130
129
  const summary = taskDeliverableSummary(message);
131
130
  const fetchTarget = message.messageType === "final-turn" && message.sourceMessageId ? message.sourceMessageId : message.id;
132
131
  const finalTurn = message.messageType === "final-turn" ? " | final turn" : "";
133
132
  return [
134
133
  receivesTurnResponseAutomatically(message.from) ? undefined : `[relay message #${message.id} from ${providerSenderLabel(message)}${finalTurn}]`,
135
- gist || undefined,
134
+ message.subject || !preview ? gist || undefined : undefined,
136
135
  ...(summary ?? []),
137
136
  message.threadId !== undefined ? `Thread: #${message.threadId}` : undefined,
138
- `Over monitor size limit — full ${message.messageType === "final-turn" ? "turn" : "message"}: relay_get_message #${fetchTarget} (CLI: agent-relay get-message ${fetchTarget})`,
137
+ preview ? `Body preview (${preview.length} of ${message.body.length} chars):\n${preview}` : undefined,
138
+ `Over monitor size limit — full ${message.messageType === "final-turn" ? "turn" : "message"}: humwick_get_message #${fetchTarget} (CLI: humwick get-message ${fetchTarget})`,
139
139
  ].filter(Boolean).join("\n");
140
140
  }
141
141
 
@@ -146,7 +146,7 @@ export function claudeMonitorDeliveryPlan(messages: Message[], options: ClaudeDe
146
146
  id: isPersistedRelayMessage(message) ? message.id : undefined,
147
147
  full: formatMessage(message, relaySurface),
148
148
  ...(isStubbable(message)
149
- ? { stub: stubMessage(message) }
149
+ ? { stub: stubMessage(message), preview: stubMessage(message, true) }
150
150
  : { kind: nonStubbableSectionKind(message), label: nonStubbableSectionLabel(message) }),
151
151
  }));
152
152
  if (relaySurface && uniqueMessages.length > 0 && uniqueMessages.every(message => isNotificationMessage(message) && !receivesTurnResponseAutomatically(message.from))) {
@@ -9,7 +9,7 @@ import { claudeProbeArgsFor, resolveManagedClaudeTranscriptPathAsync } from "./c
9
9
  import { readIncrementalFileRange, statIncrementalFile } from "./incremental-file";
10
10
  import { captureTmuxVisiblePaneAsync, runTmuxInput } from "./tmux-offload";
11
11
 
12
- // #2950 — Claude keeps its prompt cache across an effort switch (docs/design/2950-effort-switch-slice.md).
12
+ // Claude keeps its prompt cache across an effort switch (docs/design/2950-effort-switch-slice.md).
13
13
  const EFFORT_CONFIRM_TIMEOUT_MS = 15_000;
14
14
  const EFFORT_CONFIRM_POLL_MS = 200;
15
15
  // CLAUDE_CODE_EFFORT_LEVEL pins the session: `/effort` then reports "overrides" and changes nothing.
@@ -17,7 +17,7 @@ const CLAUDE_EFFORT_ENV = "CLAUDE_CODE_EFFORT_LEVEL";
17
17
 
18
18
  /** Launch effort as `--effort` while the slice flag is on (carrying an inherited pin over), else the legacy pinning env. */
19
19
  export function claudeEffortLaunch(effort: string | undefined, inheritedEnv: Record<string, string | undefined>): { args: string[]; env: Record<string, string>; unset: string[] } {
20
- if (!effortSwitchEnabledFromEnv()) return { args: [], env: effort ? writeChildEnv({}, { [CLAUDE_EFFORT_ENV]: effort }) : {}, unset: [] };
20
+ if (!effortSwitchEnabledFromEnv()) return { args: [], env: effort ? writeChildEnv({}, { [CLAUDE_EFFORT_ENV]: effort }, []) : {}, unset: [] };
21
21
  const launchEffort = effort ?? relayEnv(inheritedEnv, CLAUDE_EFFORT_ENV)?.trim();
22
22
  return { args: launchEffort ? ["--effort", launchEffort] : [], env: {}, unset: [CLAUDE_EFFORT_ENV] };
23
23
  }
@@ -1,4 +1,4 @@
1
- // #1647 — the ONE module allowed to type Claude's exit command, or the Enter that
1
+ // the ONE module allowed to type Claude's exit command, or the Enter that
2
2
  // answers its exit-confirmation dialog, into a managed pane.
3
3
  //
4
4
  // A 13.4h coordinator was destroyed when the runner's graceful-shutdown path typed
@@ -171,7 +171,7 @@ export function decideClaudeGracefulExit(
171
171
  // session from its successor.
172
172
  if (observed.providerSessionId === null) return "provider-session-unknown";
173
173
  if (intent.providerSessionId !== null && intent.providerSessionId !== observed.providerSessionId) return "provider-session-mismatch";
174
- // #1647's actual failure: the pane was mid-stream. Only POSITIVE end-of-turn
174
+ // actual failure: the pane was mid-stream. Only POSITIVE end-of-turn
175
175
  // evidence authorizes typing into it.
176
176
  if (observed.turn === "live") return "turn-live";
177
177
  if (observed.turn !== "ended") return "turn-unknown";
@@ -4,7 +4,7 @@ import { freshContextState, providerContextState } from "../transcript-context";
4
4
  import { claudeAssistantSteps, claudeToolResults, sumUsageTokens, turnStepEvent, type TranscriptMessage, type TurnStep } from "./claude-transcript";
5
5
 
6
6
  /**
7
- * Turns the root session's stream-json events into session-mirror steps and context usage (#2771).
7
+ * Turns the root session's stream-json events into session-mirror steps and context usage.
8
8
  * A tool row opens `running` on its tool_use and settles in place (stepId = tool_use id) on its tool_result.
9
9
  */
10
10
  export class ClaudeHeadlessMirror {
@@ -12,6 +12,13 @@ export class ClaudeHeadlessMirror {
12
12
  private readonly contextWindows = new Map<string, number>();
13
13
  private usage?: { tokensUsed: number; model?: string; at: number };
14
14
  private clearedAt?: number;
15
+ // the result upgrades matching closing narration instead of appending its text again.
16
+ private closingNarration?: { body: string; turnId?: string; stepId: string };
17
+
18
+ responseStepId(body: string, turnId: string): string | undefined {
19
+ const closing = this.closingNarration;
20
+ return closing?.turnId === turnId && closing.body.trim() === body.trim() ? closing.stepId : undefined;
21
+ }
15
22
 
16
23
  observe(event: Record<string, unknown>, turnId: string | undefined): ProviderSessionEvent[] {
17
24
  // Subagent events carry their parent's tool_use id; like transcript sidechains they stay out of the root mirror.
@@ -22,13 +29,21 @@ export class ClaudeHeadlessMirror {
22
29
  this.noteUsage(message, occurredAt);
23
30
  // Claude's synthetic API-error message is a transcript row, not model output.
24
31
  const diagnostic = event.is_api_error_message === true;
25
- return claudeAssistantSteps(message, occurredAt).map((step) => {
32
+ const entryId = typeof event.uuid === "string" ? event.uuid : crypto.randomUUID();
33
+ return claudeAssistantSteps(message, occurredAt).map((step, index) => {
34
+ this.closingNarration = undefined;
35
+ if (step.type === "narration" && !diagnostic) {
36
+ const stepId = `assistant:${entryId}:${index}`;
37
+ this.closingNarration = { body: step.text, turnId, stepId };
38
+ return { ...stepEvent(step, turnId), stepId };
39
+ }
26
40
  if (step.type !== "tool" || !step.toolUseId) return stepEvent(step, turnId, undefined, diagnostic);
27
41
  this.openTools.set(step.toolUseId, step);
28
42
  return stepEvent(step, turnId, "running");
29
43
  });
30
44
  }
31
45
  if (event.type === "user") {
46
+ this.closingNarration = undefined;
32
47
  return claudeToolResults(message).flatMap(({ toolUseId, isError }) => {
33
48
  const step = this.openTools.get(toolUseId);
34
49
  if (!step) return [];
@@ -56,6 +71,7 @@ export class ClaudeHeadlessMirror {
56
71
 
57
72
  /** After a clear the context is explicitly empty; omitting it would leave the card on the old usage. */
58
73
  clearContext(): void {
74
+ this.closingNarration = undefined;
59
75
  this.usage = undefined;
60
76
  this.clearedAt = Date.now();
61
77
  }
@@ -1,7 +1,7 @@
1
1
  import type { ProviderState } from "@humwick/sdk";
2
2
  import { buildProviderRefusalHold, isProviderOverload, isProviderThrottle, isProviderUsageLimit, isProxyAuthFailure, parseClaudeRateLimitPane } from "../rate-limit";
3
3
 
4
- /** What Claude's native error result says about a refused turn (#2771). */
4
+ /** What Claude's native error result says about a refused turn. */
5
5
  export interface ClaudeTurnError {
6
6
  /** The synthetic API-error assistant message's `error` class (rate_limit, overloaded, ...). */
7
7
  error?: string;
@@ -17,7 +17,7 @@ export function claudeApiErrorOf(event: Record<string, unknown>): string | undef
17
17
  return event.type === "assistant" && event.is_api_error_message === true && typeof event.error === "string" ? event.error : undefined;
18
18
  }
19
19
 
20
- /** Error text is authoritative even when Claude omits its API-error metadata (#3544). */
20
+ /** Error text is authoritative even when Claude omits its API-error metadata. */
21
21
  export function claudeTurnError(result: Record<string, unknown>, error: string | undefined): ClaudeTurnError | undefined {
22
22
  if (result.is_error !== true && result.terminal_reason !== "api_error" && typeof result.api_error_status !== "number" && error === undefined) return undefined;
23
23
  const text = typeof result.result === "string" ? result.result : "";
@@ -40,7 +40,7 @@ export function claudeRefusalHold(turn: ClaudeTurnError, source: string): Provid
40
40
  return buildProviderRefusalHold({ message: turn.text, detail: turn.text || `Claude ${turn.error ?? turn.status}`, errorType: turn.error, statusCode: turn.status, source, ...(resetAt ? { resetAt } : {}) });
41
41
  }
42
42
 
43
- /** Preserve the provider's cause even when its error result has subtype=success (#3544). */
43
+ /** Preserve the provider's cause even when its error result has subtype=success. */
44
44
  export function claudeResultFailure(result: Record<string, unknown>) {
45
45
  const text = typeof result.result === "string" ? result.result.trim() : "";
46
46
  const budget = result.subtype === "error_max_budget_usd" || /\b(?:budget|allowance)\b.*\b(?:exceeded|exhausted|reached)\b/i.test(text);
@@ -45,7 +45,7 @@ interface HeadlessState {
45
45
  abandonedCommands: Set<string>;
46
46
  /** The live turn's API-error class, from the synthetic error message that precedes its error result. */
47
47
  apiError?: string;
48
- /** Turn the provider refused under a rate-limit/quota/overload hold (#2771). */
48
+ /** Turn the provider refused under a rate-limit/quota/overload hold. */
49
49
  refusedTurnId?: string;
50
50
  /** Resolves a delivery waiting for the turn's first outcome: true if it was refused. */
51
51
  refusalWatch?: { turnId: string; settle(refused: boolean): void };
@@ -81,7 +81,7 @@ export function createClaudeTransportAdapter(provider: string, args: string[], l
81
81
  return streaming ? new ClaudeHeadlessAdapter(provider) : new ClaudeAdapter({ provider });
82
82
  }
83
83
 
84
- /** Persistent native Claude print-mode transport (#2754, #2771). */
84
+ /** Persistent native Claude print-mode transport. */
85
85
  export class ClaudeHeadlessAdapter implements ProviderAdapter {
86
86
  readonly statusSourceId: string;
87
87
  readonly permissionPromptHandler = claudePermissionPromptHandler;
@@ -152,12 +152,12 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
152
152
  transportArgs.push(arg);
153
153
  }
154
154
  return { ...args, args: [...transportArgs, "--print", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--replay-user-messages"],
155
- env: writeChildEnv({}, { ...args.env, AGENT_RELAY_CLAUDE_TRANSPORT: "stream-json" }) };
155
+ env: writeChildEnv({}, { ...args.env, HUMWICK_CLAUDE_TRANSPORT: "stream-json" }, []) };
156
156
  }
157
157
 
158
- async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
158
+ async spawn(config: RunnerSpawnConfig, preparedArgs?: SpawnArgs): Promise<ManagedProcess> {
159
159
  await verifyClaudeSubagentVersion(config);
160
- const args = this.buildSpawnArgs(config, config.providerConfig);
160
+ const args = preparedArgs ?? this.buildSpawnArgs(config, config.providerConfig);
161
161
  this.watching = true;
162
162
  const child = Bun.spawn([args.command, ...args.args], {
163
163
  cwd: args.cwd,
@@ -234,7 +234,7 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
234
234
  this.emitStatus({ status: "idle", reason: "provider-turn", clear: ["provider-turn"], id: turnId, ...this.transcriptStatus(state) });
235
235
  return;
236
236
  }
237
- // A throttle, quota wall or overload refusal is neither delivered nor failed: hold, then resume (#2619).
237
+ // A throttle, quota wall or overload refusal is neither delivered nor failed: hold, then resume.
238
238
  const turnError = claudeTurnError(event, apiError);
239
239
  const hold = nativeHold ?? (turnError && claudeRefusalHold(turnError, this.statusSourceId));
240
240
  if (hold) {
@@ -254,7 +254,7 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
254
254
  // Stopped watchers intentionally drop finals during teardown; resume cannot replay them.
255
255
  if (this.watching && typeof event.result === "string" && event.result.trim()) {
256
256
  this.response({ type: "response", body: event.result, origin: "provider", turnId, final: true,
257
- confidence: "definitive", endedBy: "completed", replyToUserObligation: true, replyRoute });
257
+ confidence: "definitive", endedBy: "completed", replyToUserObligation: true, replyRoute, stepId: state.mirror.responseStepId(event.result, turnId), status: "completed" });
258
258
  }
259
259
  this.emitStatus({ status: "idle", reason: "provider-turn", clear: ["provider-turn"], id: turnId, ...this.transcriptStatus(state) });
260
260
  }).catch((error) => {
@@ -265,7 +265,7 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
265
265
  child.kill("SIGKILL");
266
266
  });
267
267
  const nativeExit = child.exited.then(async (code) => {
268
- // The final result can still be buffered when the process exits (#3314).
268
+ // The final result can still be buffered when the process exits.
269
269
  await stdout;
270
270
  await this.onNativeExit(child, code, state.stopping);
271
271
  this.abandonPending(state, "Claude exited");
@@ -295,7 +295,7 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
295
295
  if (!messages.length) return [];
296
296
  state.replyRoute = deliveredBatchReplyRoute(messages);
297
297
  let outcome: void | "held";
298
- try { outcome = await this.deliverInitialPrompt(process, claudeProviderMessageText(messages)); }
298
+ try { outcome = await this.deliverInitialPrompt(process, claudeProviderMessageText(messages), { inputObserver: ctx?.inputObserver }); }
299
299
  catch (error) { state.replyRoute = undefined; throw error; }
300
300
  const turnId = state.submittedTurnId;
301
301
  const ids = messages.map((message) => message.id);
@@ -315,6 +315,7 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
315
315
  state.turnId = state.submittedTurnId = turnId;
316
316
  state.apiError = undefined;
317
317
  state.submitConfirmed = false;
318
+ options?.inputObserver?.offered(Date.now(), false);
318
319
  this.emitStatus({ status: "busy", reason: "provider-turn", id: turnId, pids: [state.child.pid], ...this.transcriptStatus(state) });
319
320
  let timer: ReturnType<typeof setTimeout> | undefined;
320
321
  let timeoutMs = state.initialized ? options?.readyTimeoutMs ?? 30_000 : Math.max(options?.readyTimeoutMs ?? 0, 120_000);
@@ -505,7 +506,7 @@ export class ClaudeHeadlessAdapter implements ProviderAdapter {
505
506
  }, this.contextTimeouts.recovery);
506
507
  }
507
508
 
508
- /** #3398 — a reset no live command waits for (a timed-out or abandoned one, or Claude's own) is still a reset. */
509
+ /** a reset no live command waits for (a timed-out or abandoned one, or Claude's own) is still a reset. */
509
510
  private observeUnwaitedReset(state: HeadlessState, event: Record<string, unknown>): void {
510
511
  const compacted = event.type === "system" && event.subtype === "compact_boundary";
511
512
  if (!compacted && event.type !== "conversation_reset") return;
@@ -10,7 +10,7 @@ async function stderrTail(path: string): Promise<string> {
10
10
  } catch { return ""; }
11
11
  }
12
12
 
13
- // #3830 — a vanished pane is not evidence of a successful provider launch.
13
+ // a vanished pane is not evidence of a successful provider launch.
14
14
  export async function claudeLaunchExitStatus(exitCodeFile: string | undefined, ready: boolean, oneShot: boolean, paneTail = ""): Promise<ProviderStatusUpdate> {
15
15
  const code = exitCodeFile ? await readOneShotExitCode(exitCodeFile) : undefined;
16
16
  // A successful interactive exec replaces the shell and its EXIT trap.
@@ -7,7 +7,9 @@ export interface MonitorDeliverySection {
7
7
  id?: number;
8
8
  full: string;
9
9
  stub?: string;
10
- // #2310 — how to represent `full` when it (or the section it's batched with) doesn't fit under
10
+ /** Body preview with retrieval guidance, preferred over a compact stub when space permits. */
11
+ preview?: string;
12
+ // how to represent `full` when it (or the section it's batched with) doesn't fit under
11
13
  // the Monitor's caps and there's no `stub` to fall back to. Only meaningful when `stub` is
12
14
  // absent (fetchable sections always resolve via their stub).
13
15
  // "must-keep" (default) — content with no get-message path and no acceptable loss (memory
@@ -29,12 +31,12 @@ export interface MonitorDeliveryPlan {
29
31
 
30
32
  export interface PlanClaudeMonitorDeliveryOptions {
31
33
  reservedChars?: number;
32
- // #2310 — directory to spill an over-cap must-keep section's full content to (byte-exact),
34
+ // directory to spill an over-cap must-keep section's full content to (byte-exact),
33
35
  // replacing the delivered event with a small "read this file" pointer. There is no supported
34
36
  // way to emit more than one Monitor event per delivery cycle (the harness coalesces same-tick
35
37
  // stdout into one event), so a must-keep section that's too large to inline can never be
36
38
  // chunked across events — spilling to disk is the only loss-free route. Typically the agent's
37
- // session scratch tmp dir (AGENT_RELAY_SCRATCH_DIR). When omitted, or the write fails, the
39
+ // session scratch tmp dir (HUMWICK_SCRATCH_DIR). When omitted, or the write fails, the
38
40
  // section gets an explicit "could not be delivered" notice — NEVER raw-emitted for the harness
39
41
  // to truncate mid-content, and never a bare/ambiguous truncation either.
40
42
  spillDir?: string;
@@ -51,7 +53,7 @@ const DISPOSABLE_TRUNCATION_NOTICE = "\n[…truncated]";
51
53
  // slice (never rearranges/rewraps), so the result is always a byte-exact prefix of `text` — except
52
54
  // that the cut point is nudged back by one UTF-16 code unit when it would otherwise land inside a
53
55
  // surrogate pair (e.g. an emoji), never emitting a lone unpaired surrogate.
54
- function truncateToCaps(text: string, budget: number, lineMaxChars: number): string {
56
+ export function truncateToCaps(text: string, budget: number, lineMaxChars: number): string {
55
57
  let charCount = 0;
56
58
  let lineCount = 0;
57
59
  let cut = 0;
@@ -80,7 +82,7 @@ function selfTruncateDisposableSection(section: RenderedSection, reserved: numbe
80
82
  return body.length < section.text.length ? `${body}${DISPOSABLE_TRUNCATION_NOTICE}` : body;
81
83
  }
82
84
 
83
- // #2310 review round 4 P0-1 — bounds on the two places a caller-supplied `label` gets embedded so
85
+ // review round 4 P0-1 — bounds on the two places a caller-supplied `label` gets embedded so
84
86
  // neither can blow a cap on its own: the filename component (unbounded here previously caused
85
87
  // ENAMETOOLONG on write, silently turning a usable spill into an undeliverable notice) and the
86
88
  // short display form used in notice/pointer text. The full uuid in the filename is NOT shortened —
@@ -100,7 +102,7 @@ function sanitizeForFilename(label: string): string {
100
102
  return cleaned.slice(0, MAX_FILENAME_LABEL_CHARS).replace(/-+$/g, "") || "content";
101
103
  }
102
104
 
103
- // #2310 P1 — the spilled file (and the dir it lands in) can hold sensitive content (memory
105
+ // P1 — the spilled file (and the dir it lands in) can hold sensitive content (memory
104
106
  // context, self-resume continuation), so both are session-private: dir 0700, file 0600. `mode` on
105
107
  // mkdirSync/writeFileSync is still subject to umask, so re-assert both with chmodSync afterward —
106
108
  // best-effort (a chmod failure here shouldn't sacrifice the write; the caller's own try/catch
@@ -112,7 +114,7 @@ function writeSpillFileTight(dir: string, filePath: string, content: string): vo
112
114
  try { chmodSync(filePath, 0o600); } catch {}
113
115
  }
114
116
 
115
- // #2310 review round 3 P0 — a must-keep section must NEVER degrade to a bare disposable
117
+ // review round 3 P0 — a must-keep section must NEVER degrade to a bare disposable
116
118
  // truncation ("[…truncated]" with no way back to the rest) — that's indistinguishable from
117
119
  // ordinary lossy content. When it genuinely can't be delivered in full this cycle, say so
118
120
  // explicitly and distinctly instead. `reason` names WHY (surfaced verbatim to the agent).
@@ -126,7 +128,7 @@ function writeSpillFileTight(dir: string, filePath: string, content: string): vo
126
128
  // injections a trackable identity and teaching that caller to retry on a missing ack — a genuine
127
129
  // architecture change, out of scope here. The explicit notice is the honest floor: it can't
128
130
  // silently vanish, even though it can't self-heal either.
129
- // #2310 review round 4 P0-1 — built as a cascade of progressively SHORTER, but always
131
+ // review round 4 P0-1 — built as a cascade of progressively SHORTER, but always
130
132
  // independently-complete, candidates — never by truncating a single label-prefixed string (a huge
131
133
  // label pushed the classified marker text itself past the caps, so a raw prefix-truncation of the
132
134
  // old label-first shape could slice down to a bare "[LLLL…" containing none of "could NOT be
@@ -153,7 +155,7 @@ function mustKeepUndeliverableNotice(section: RenderedSection, reserved: number,
153
155
  return truncateToCaps(candidates[candidates.length - 1]!, Math.max(1, maxChars - reserved), lineMaxChars);
154
156
  }
155
157
 
156
- // #2310 — write the section's full, untouched content to disk and point at it instead of ever
158
+ // write the section's full, untouched content to disk and point at it instead of ever
157
159
  // inlining a truncated/rewritten copy. The reference is built as a cascade of progressively
158
160
  // SHORTER but always independently-complete candidates, most useful first — never by truncating a
159
161
  // single label-prefixed string (review round 4 P0-1: a huge label must never cost us the file
@@ -190,7 +192,7 @@ function spillMustKeepSection(
190
192
  const bracket = `[${boundLabel(label, MAX_DISPLAY_LABEL_CHARS)} — spilled to disk, read it now]`;
191
193
  const references = [
192
194
  `Full content (byte-exact): ${filePath}`,
193
- `File: ${fileName} — in your session scratch tmp dir ($AGENT_RELAY_SCRATCH_DIR)`,
195
+ `File: ${fileName} — in your session scratch tmp dir ($HUMWICK_SCRATCH_DIR)`,
194
196
  `File: ${fileName} (scratch dir)`,
195
197
  `File: ${fileName}`,
196
198
  ];
@@ -210,7 +212,26 @@ function spillMustKeepSection(
210
212
 
211
213
  type SpillResult = RenderedSection & { spilled: boolean };
212
214
 
213
- // #2310 review round 4 P0-2 — `items` is a mix of two DIFFERENT outcomes from spillMustKeepSection:
215
+ // reserve room for every selected message before spending spare space on readable bodies.
216
+ function readableEvent(items: RenderedSection[], reserved: number): MonitorDeliveryPlan {
217
+ const texts = items.map(item => item.text);
218
+ let totalChars = texts.reduce((sum, text) => sum + text.length, Math.max(0, texts.length - 1) * 2);
219
+ for (let i = 0; i < items.length; i++) {
220
+ const item = items[i]!;
221
+ if (!item.stub) continue;
222
+ for (const candidate of [item.full, item.preview]) {
223
+ if (candidate === undefined) continue;
224
+ const otherChars = totalChars - texts[i]!.length;
225
+ if (!fitsMonitorDelivery(candidate, { reserved: reserved + otherChars })) continue;
226
+ texts[i] = candidate;
227
+ totalChars = otherChars + candidate.length;
228
+ break;
229
+ }
230
+ }
231
+ return { events: items.length ? [{ text: join(texts), messageIds: idsOf(items) }] : [] };
232
+ }
233
+
234
+ // review round 4 P0-2 — `items` is a mix of two DIFFERENT outcomes from spillMustKeepSection:
214
235
  // truly spilled pointers (file exists, safe to defer into an aggregate "N more spilled" count) and
215
236
  // undeliverable notices (no spillDir / write failed — nothing exists to point at). The two must
216
237
  // never be conflated: deferring an undeliverable notice into the "spilled" count is a FALSE claim
@@ -240,12 +261,11 @@ function packWithDeferredNotice(items: SpillResult[], reserved: number): Rendere
240
261
  * Render each section once, then build ONE cap-compliant event (there is no supported way to emit
241
262
  * more than one Monitor event per delivery cycle — the harness coalesces same-tick stdout back
242
263
  * into a single event). No over-cap section is ever raw-emitted for the harness to truncate
243
- * (#2310):
244
264
  *
245
265
  * - Every "must-keep" section (no stub, no acceptable loss — memory context, self-resume
246
- * continuation) is GUARANTEED a place in the event. #2310 P0 — with two or more oversized
266
+ * continuation) is GUARANTEED a place in the event. P0 — with two or more oversized
247
267
  * must-keep sections in one delivery, EACH is spilled to its own file and every pointer is
248
- * included; none is ever silently replaced/dropped to make room for another (#2291-class: a
268
+ * included; none is ever silently replaced/dropped to make room for another (class: a
249
269
  * dropped must-keep section, unlike a dropped fetchable one, is unrecoverable — it has no
250
270
  * persisted id to retry via).
251
271
  * - Fetchable (stubbed) and disposable sections are then greedily folded into whatever budget
@@ -267,7 +287,7 @@ export function planClaudeMonitorDelivery(
267
287
  const rendered: RenderedSection[] = sections.map((section) => ({ ...section, text: section.stub ?? section.full }));
268
288
  const allStubbed = join(rendered.map((section) => section.text));
269
289
  if (fitsMonitorDelivery(allStubbed, { reserved })) {
270
- return { events: [{ text: allStubbed, messageIds: idsOf(rendered) }] };
290
+ return readableEvent(rendered, reserved);
271
291
  }
272
292
 
273
293
  const mustKeep = rendered.filter((section) => !section.stub && section.kind !== "disposable");
@@ -313,5 +333,5 @@ export function planClaudeMonitorDelivery(
313
333
  chunk = withSubstitute;
314
334
  }
315
335
  }
316
- return { events: chunk.length ? [{ text: join(chunk.map((item) => item.text)), messageIds: idsOf(chunk) }] : [] };
336
+ return readableEvent(chunk, reserved);
317
337
  }