@humwick/runner 0.130.4 → 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.
- package/LICENSE +661 -167
- package/agent-bin/humwick +2 -2
- package/agent-bin/humwick-browser +4 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +1 -1
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +2 -2
- package/package.json +8 -11
- package/plugins/browser/skills/playwright-cli/SKILL.md +8 -6
- package/plugins/claude/.claude-plugin/plugin.json +3 -3
- package/plugins/claude/claude-statusline-probe.cjs +26 -16
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +47 -17
- package/plugins/claude/hooks/gate-run-guard.sh +3 -3
- package/plugins/claude/hooks/gate-run-guard.ts +5 -5
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +1 -1
- package/plugins/claude/hooks/kill-guard.sh +4 -4
- package/plugins/claude/hooks/kill-guard.ts +2 -2
- package/plugins/claude/hooks/permission-request.sh +1 -1
- package/plugins/claude/hooks/post-compact.sh +1 -1
- package/plugins/claude/hooks/post-tool-use.sh +3 -3
- package/plugins/claude/hooks/pre-compact.sh +2 -2
- package/plugins/claude/hooks/read-only-bash-guard.sh +4 -4
- package/plugins/claude/hooks/relay-status.sh +32 -32
- package/plugins/claude/hooks/session-end.sh +2 -2
- package/plugins/claude/hooks/session-start.sh +3 -3
- package/plugins/claude/hooks/shell-trust.ts +1 -1
- package/plugins/claude/hooks/stop-failure.sh +2 -2
- package/plugins/claude/hooks/stop.sh +6 -6
- package/plugins/claude/hooks/subagent-skill-policy.cjs +8 -6
- package/plugins/claude/hooks/subagent-stop.sh +3 -3
- package/plugins/claude/hooks/user-prompt-submit.sh +1 -1
- package/plugins/claude/hooks/wait-loop-guard.ts +3 -3
- package/plugins/claude/monitors/monitors.json +2 -2
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +96 -62
- package/plugins/claude/monitors/relay-monitor.ts +5 -5
- package/plugins/claude/skills/label/SKILL.md +3 -3
- package/plugins/claude/skills/message/SKILL.md +5 -5
- package/plugins/claude/skills/react/SKILL.md +1 -1
- package/plugins/claude/skills/read-message/SKILL.md +4 -4
- package/plugins/claude/skills/reply/SKILL.md +5 -5
- package/plugins/claude/skills/send-claimable/SKILL.md +5 -5
- package/plugins/claude/skills/slice-first/SKILL.md +5 -3
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +30 -29
- package/plugins/claude/skills/status/SKILL.md +3 -3
- package/plugins/claude/skills/tags/SKILL.md +5 -5
- package/plugins/claude/skills/task/SKILL.md +10 -10
- package/plugins/claude-output-style/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-output-style/output-styles/{agent-relay.md → humwick.md} +1 -1
- package/plugins/codex/skills/guide/SKILL.md +3 -3
- package/plugins/codex/skills/label/SKILL.md +3 -3
- package/plugins/codex/skills/message/SKILL.md +5 -5
- package/plugins/codex/skills/react/SKILL.md +1 -1
- package/plugins/codex/skills/read-message/SKILL.md +4 -4
- package/plugins/codex/skills/reply/SKILL.md +5 -5
- package/plugins/codex/skills/send-claimable/SKILL.md +5 -5
- package/plugins/codex/skills/slice-first/SKILL.md +5 -3
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +30 -29
- package/plugins/codex/skills/status/SKILL.md +3 -3
- package/plugins/codex/skills/tags/SKILL.md +5 -5
- package/plugins/codex/skills/task/SKILL.md +11 -10
- package/src/adapter.ts +95 -92
- package/src/adapters/claude-api.ts +8 -11
- package/src/adapters/claude-context-command.ts +1 -1
- package/src/adapters/claude-credentials.ts +7 -3
- package/src/adapters/claude-delivery-lanes.ts +2 -2
- package/src/adapters/claude-delivery.ts +16 -16
- package/src/adapters/claude-effort.ts +2 -2
- package/src/adapters/claude-exit.ts +2 -2
- package/src/adapters/claude-headless-mirror.ts +18 -2
- package/src/adapters/claude-headless-refusal.ts +3 -3
- package/src/adapters/claude-headless.ts +11 -10
- package/src/adapters/claude-launch-exit.ts +1 -1
- package/src/adapters/claude-monitor-delivery-plan.ts +36 -16
- package/src/adapters/claude-permission.ts +5 -5
- package/src/adapters/claude-prompt-gates.ts +9 -9
- package/src/adapters/claude-prompt-hook.ts +2 -2
- package/src/adapters/claude-session-capture.ts +6 -6
- package/src/adapters/claude-session-probe.ts +25 -25
- package/src/adapters/claude-shutdown.ts +11 -11
- package/src/adapters/claude-status-detectors.ts +31 -31
- package/src/adapters/claude-subagents.ts +7 -7
- package/src/adapters/claude-submit-confirmation.ts +6 -6
- package/src/adapters/claude-tmux.ts +43 -43
- package/src/adapters/claude-token-usage-capture.ts +5 -5
- package/src/adapters/claude-tool-policy.ts +7 -7
- package/src/adapters/claude-transcript.ts +24 -24
- package/src/adapters/claude.ts +100 -100
- package/src/adapters/codex-agent-message-capture.ts +26 -63
- package/src/adapters/codex-app-server.ts +1 -1
- package/src/adapters/codex-client.ts +9 -9
- package/src/adapters/codex-effective-config.ts +1 -1
- package/src/adapters/codex-launch-config.ts +5 -5
- package/src/adapters/codex-rollout-context.ts +2 -2
- package/src/adapters/codex-skill-discovery.ts +1 -1
- package/src/adapters/codex-token-usage-capture.ts +16 -16
- package/src/adapters/codex-tool-output.ts +4 -4
- package/src/adapters/codex-version.ts +3 -3
- package/src/adapters/codex.ts +64 -64
- package/src/adapters/opencode-api.ts +1 -1
- package/src/adapters/opencode-context.ts +20 -22
- package/src/adapters/opencode-model-registry.ts +7 -7
- package/src/adapters/opencode-provider.ts +8 -8
- package/src/adapters/opencode-provisioning.ts +30 -0
- package/src/adapters/opencode-server-retry.ts +1 -1
- package/src/adapters/opencode-soft-steer.ts +5 -5
- package/src/adapters/opencode-spawn.ts +18 -14
- package/src/adapters/opencode-token-usage-capture.ts +12 -12
- package/src/adapters/opencode-tool-events.ts +15 -15
- package/src/adapters/opencode.ts +232 -232
- package/src/adapters/project-shell.ts +11 -11
- package/src/adapters/service-access.ts +6 -6
- package/src/adapters/tmux-offload.ts +9 -9
- package/src/adapters/tool-summary.ts +5 -5
- package/src/agent-redact.ts +1 -1
- package/src/agent-runner/commands/dispatch.ts +10 -10
- package/src/agent-runner/commands/lifecycle.ts +2 -2
- package/src/agent-runner/commands/prompt.ts +19 -19
- package/src/agent-runner/credentials.ts +5 -5
- package/src/agent-runner/inbox.ts +1 -1
- package/src/agent-runner/lifecycle-milestones.ts +3 -3
- package/src/agent-runner/lifecycle-phase.ts +2 -2
- package/src/agent-runner/liveness-exchange.ts +4 -10
- package/src/agent-runner/registration.ts +8 -8
- package/src/agent-runner/runner-context.ts +9 -14
- package/src/agent-runner/session-mirror.ts +12 -12
- package/src/agent-runner/task-claims.ts +2 -2
- package/src/agent-runner/token-usage-coverage.ts +1 -1
- package/src/agent-runner/turn-report.ts +12 -12
- package/src/api-budget-accounting.ts +3 -3
- package/src/area-memory.ts +6 -6
- package/src/artifact-path-materialize.ts +1 -1
- package/src/async-guard.ts +5 -5
- package/src/attachment-cache.ts +17 -22
- package/src/background-script-reconcile.ts +6 -6
- package/src/bounded-log.ts +1 -1
- package/src/busy-reconciler.ts +22 -22
- package/src/claim-tracker.ts +3 -3
- package/src/claude-settings.ts +8 -8
- package/src/claude-system-prompt.ts +1 -1
- package/src/claude-workspace-trust.ts +1 -1
- package/src/config.ts +62 -61
- package/src/context-boundary.ts +5 -5
- package/src/context-command.ts +3 -3
- package/src/control-server.ts +32 -32
- package/src/delivery-result.ts +2 -2
- package/src/effort-command.ts +2 -2
- package/src/empty-turn.ts +22 -14
- package/src/free-port.ts +1 -1
- package/src/gate-run-config.ts +3 -3
- package/src/host-asset-plan.ts +24 -17
- package/src/host-claude-config-worker.ts +2 -2
- package/src/host-sources.ts +2 -2
- package/src/index.ts +16 -16
- package/src/launch-assembly.ts +94 -96
- package/src/liveness.ts +15 -15
- package/src/logger.ts +3 -3
- package/src/mcp-outbox.ts +3 -3
- package/src/mcp-preflight.ts +2 -2
- package/src/mcp-source-selection.ts +2 -2
- package/src/mcp-tool-deferral.ts +1 -1
- package/src/mcp-vault-env.ts +2 -2
- package/src/message-body-config/config.ts +4 -4
- package/src/mid-turn-delivery.ts +2 -2
- package/src/native-memory-lever.ts +8 -8
- package/src/outbox.ts +15 -15
- package/src/pending-permission-store.ts +1 -1
- package/src/profile-home.ts +19 -19
- package/src/profile-projection.ts +26 -24
- package/src/project-instructions.ts +5 -5
- package/src/provider-auth-warning.ts +17 -0
- package/src/provider-double.ts +1 -1
- package/src/provider-hold.ts +3 -3
- package/src/provider-home-marker.ts +1 -1
- package/src/provider-home-tombstone.ts +1 -1
- package/src/provider-input-queue.ts +2 -2
- package/src/provider-integration.ts +15 -13
- package/src/provider-setup.ts +15 -3
- package/src/providers.ts +1 -1
- package/src/provisioning.ts +37 -40
- package/src/question-decision.ts +2 -2
- package/src/quota.ts +0 -6
- package/src/rate-limit.ts +24 -13
- package/src/refused-prompt-store.ts +2 -2
- package/src/relay-injection-events.ts +2 -2
- package/src/relay-instructions.ts +5 -5
- package/src/relay-mcp-proxy.long-turn-e2e.ts +13 -13
- package/src/relay-mcp-proxy.ts +57 -58
- package/src/relay-mcp.ts +55 -69
- package/src/reply-obligation-cache.ts +9 -9
- package/src/response-capture-report.ts +9 -9
- package/src/response-routing.ts +1 -1
- package/src/runner-core.ts +209 -222
- package/src/runner-exit.ts +1 -1
- package/src/runner-helpers.ts +10 -10
- package/src/runner-insights.ts +4 -2
- package/src/runner-mcp-proxy.ts +3 -3
- package/src/runner-spawn-config.ts +3 -3
- package/src/runtime-token-file.ts +1 -1
- package/src/runtime-token-remint.ts +1 -1
- package/src/service-access.ts +2 -2
- package/src/session-destroy.ts +1 -1
- package/src/session-insights.ts +1 -1
- package/src/session-scratch.ts +24 -37
- package/src/spawn-input.ts +6 -6
- package/src/startup-config-fetch.ts +4 -4
- package/src/subreaper.ts +3 -5
- package/src/test-run-dedup.ts +13 -13
- package/src/transcript-context.ts +2 -2
- package/src/transport-errors.ts +1 -1
- package/src/turn-opener.ts +1 -1
- package/src/turn-reconcile-event.ts +2 -2
- package/src/vault-proxy-hop.ts +2 -2
- package/src/web-tools.ts +2 -2
- package/agent-bin/agent-relay-browser +0 -10
- package/plugins/claude/skills/disconnect/SKILL.md +0 -16
- package/plugins/claude/skills/pair/SKILL.md +0 -30
- package/plugins/codex/skills/disconnect/SKILL.md +0 -15
- package/plugins/codex/skills/pair/SKILL.md +0 -29
- 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/
|
|
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, "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
`[
|
|
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
|
-
//
|
|
61
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 ($
|
|
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
|
-
//
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
|
336
|
+
return readableEvent(chunk, reserved);
|
|
317
337
|
}
|