@humwick/runner 0.0.0-stage → 0.130.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +167 -0
- package/agent-bin/agent-relay-browser +10 -0
- package/agent-bin/humwick +10 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
- package/hooks/fff-search-redirect/hooks.json +13 -0
- package/hooks/rg-r-guard/hooks.json +18 -0
- package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/hooks/web-tools-redirect/hooks.json +13 -0
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
- package/node_modules/@humwick/offload/LICENSE +167 -0
- package/node_modules/@humwick/offload/README.md +57 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
- package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.js +80 -0
- package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
- package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/index.js +5 -0
- package/node_modules/@humwick/offload/dist/index.js.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
- package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/types.js +176 -0
- package/node_modules/@humwick/offload/dist/types.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.js +1 -0
- package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
- package/node_modules/@humwick/offload/package.json +42 -0
- package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
- package/node_modules/@humwick/offload/src/envelope.ts +92 -0
- package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
- package/node_modules/@humwick/offload/src/index.ts +4 -0
- package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
- package/node_modules/@humwick/offload/src/types.ts +235 -0
- package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
- package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
- package/node_modules/@humwick/offload/src/worker.ts +5 -0
- package/package.json +47 -4
- package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
- package/plugins/claude/.claude-plugin/plugin.json +8 -0
- package/plugins/claude/claude-statusline-probe.cjs +144 -0
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
- package/plugins/claude/hooks/gate-run-guard.sh +9 -0
- package/plugins/claude/hooks/gate-run-guard.ts +237 -0
- package/plugins/claude/hooks/hooks.json +158 -0
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
- package/plugins/claude/hooks/kill-guard.sh +341 -0
- package/plugins/claude/hooks/kill-guard.ts +337 -0
- package/plugins/claude/hooks/permission-request.sh +22 -0
- package/plugins/claude/hooks/post-compact.sh +10 -0
- package/plugins/claude/hooks/post-tool-use.sh +8 -0
- package/plugins/claude/hooks/pre-compact.sh +12 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
- package/plugins/claude/hooks/relay-status.sh +373 -0
- package/plugins/claude/hooks/session-end.sh +26 -0
- package/plugins/claude/hooks/session-start.sh +31 -0
- package/plugins/claude/hooks/shell-trust.ts +28 -0
- package/plugins/claude/hooks/stop-failure.sh +47 -0
- package/plugins/claude/hooks/stop.sh +48 -0
- package/plugins/claude/hooks/subagent-guard.cjs +26 -0
- package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
- package/plugins/claude/hooks/subagent-start.sh +13 -0
- package/plugins/claude/hooks/subagent-stop.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
- package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
- package/plugins/claude/monitors/monitors.json +7 -0
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
- package/plugins/claude/monitors/relay-monitor.ts +58 -0
- package/plugins/claude/skills/disconnect/SKILL.md +16 -0
- package/plugins/claude/skills/label/SKILL.md +23 -0
- package/plugins/claude/skills/message/SKILL.md +40 -0
- package/plugins/claude/skills/pair/SKILL.md +30 -0
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +44 -0
- package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
- package/plugins/claude/skills/slice-first/SKILL.md +32 -0
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/claude/skills/status/SKILL.md +16 -0
- package/plugins/claude/skills/tags/SKILL.md +25 -0
- package/plugins/claude/skills/task/SKILL.md +28 -0
- package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
- package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +40 -0
- package/plugins/codex/skills/pair/SKILL.md +29 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +44 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
- package/plugins/codex/skills/slice-first/SKILL.md +32 -0
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/plugins/codex/skills/task/SKILL.md +28 -0
- package/src/adapter.ts +1061 -0
- package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
- package/src/adapters/claude-api.ts +243 -0
- package/src/adapters/claude-context-command.ts +16 -0
- package/src/adapters/claude-credentials.ts +22 -0
- package/src/adapters/claude-delivery-lanes.ts +28 -0
- package/src/adapters/claude-delivery.ts +166 -0
- package/src/adapters/claude-effort.ts +94 -0
- package/src/adapters/claude-exit.ts +290 -0
- package/src/adapters/claude-headless-mirror.ts +88 -0
- package/src/adapters/claude-headless-refusal.ts +51 -0
- package/src/adapters/claude-headless.ts +617 -0
- package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
- package/src/adapters/claude-permission.ts +243 -0
- package/src/adapters/claude-prompt-gates.ts +269 -0
- package/src/adapters/claude-prompt-hook.ts +145 -0
- package/src/adapters/claude-session-capture.ts +293 -0
- package/src/adapters/claude-session-probe.ts +414 -0
- package/src/adapters/claude-shutdown.ts +190 -0
- package/src/adapters/claude-status-detectors.ts +426 -0
- package/src/adapters/claude-subagents.ts +93 -0
- package/src/adapters/claude-submit-confirmation.ts +287 -0
- package/src/adapters/claude-tmux.ts +548 -0
- package/src/adapters/claude-token-usage-capture.ts +253 -0
- package/src/adapters/claude-tool-policy.ts +165 -0
- package/src/adapters/claude-transcript-tail.ts +193 -0
- package/src/adapters/claude-transcript.ts +812 -0
- package/src/adapters/claude.ts +1097 -0
- package/src/adapters/codex-agent-message-capture.ts +189 -0
- package/src/adapters/codex-app-server.ts +70 -0
- package/src/adapters/codex-client.ts +362 -0
- package/src/adapters/codex-effective-config.ts +87 -0
- package/src/adapters/codex-launch-config.ts +153 -0
- package/src/adapters/codex-rollout-context.ts +272 -0
- package/src/adapters/codex-skill-discovery.ts +18 -0
- package/src/adapters/codex-token-usage-capture.ts +163 -0
- package/src/adapters/codex-tool-output.ts +53 -0
- package/src/adapters/codex-turn-outcome.ts +27 -0
- package/src/adapters/codex-version.ts +64 -0
- package/src/adapters/codex.ts +1442 -0
- package/src/adapters/incremental-file.ts +110 -0
- package/src/adapters/opencode-api.ts +71 -0
- package/src/adapters/opencode-context.ts +277 -0
- package/src/adapters/opencode-model-registry.ts +126 -0
- package/src/adapters/opencode-provider.ts +169 -0
- package/src/adapters/opencode-server-retry.ts +50 -0
- package/src/adapters/opencode-soft-steer.ts +302 -0
- package/src/adapters/opencode-spawn.ts +145 -0
- package/src/adapters/opencode-subscribe.ts +62 -0
- package/src/adapters/opencode-token-usage-capture.ts +187 -0
- package/src/adapters/opencode-tool-events.ts +302 -0
- package/src/adapters/opencode-tools-config.ts +17 -0
- package/src/adapters/opencode.ts +2264 -0
- package/src/adapters/project-shell.ts +80 -0
- package/src/adapters/provider-launch-config.ts +21 -0
- package/src/adapters/provider-quota-harvest.ts +127 -0
- package/src/adapters/service-access.ts +153 -0
- package/src/adapters/tmux-offload.ts +207 -0
- package/src/adapters/tool-summary.ts +214 -0
- package/src/agent-redact.ts +75 -0
- package/src/agent-runner/commands/dispatch.ts +185 -0
- package/src/agent-runner/commands/lifecycle.ts +84 -0
- package/src/agent-runner/commands/prompt.ts +284 -0
- package/src/agent-runner/credentials.ts +208 -0
- package/src/agent-runner/http-liveness.ts +85 -0
- package/src/agent-runner/lifecycle-milestones.ts +207 -0
- package/src/agent-runner/lifecycle-phase.ts +80 -0
- package/src/agent-runner/registration.ts +163 -0
- package/src/agent-runner/runner-context.ts +158 -0
- package/src/agent-runner/session-mirror.ts +278 -0
- package/src/agent-runner/task-claims.ts +116 -0
- package/src/agent-runner/token-usage-coverage.ts +54 -0
- package/src/agent-runner/turn-report.ts +269 -0
- package/src/api-budget-accounting.ts +91 -0
- package/src/area-memory.ts +255 -0
- package/src/artifact-path-materialize.ts +52 -0
- package/src/async-guard.ts +35 -0
- package/src/attachment-cache-sweep.ts +31 -0
- package/src/attachment-cache.ts +195 -0
- package/src/background-script-reconcile.ts +106 -0
- package/src/bounded-log.ts +59 -0
- package/src/bounded-spawn-sync.ts +18 -0
- package/src/busy-reconciler.ts +282 -0
- package/src/claim-tracker.ts +140 -0
- package/src/claude-settings.ts +167 -0
- package/src/claude-workspace-trust.ts +289 -0
- package/src/config.ts +547 -0
- package/src/context-boundary.ts +101 -0
- package/src/context-command.ts +151 -0
- package/src/context-probe-state.ts +6 -0
- package/src/continuation-archive.ts +179 -0
- package/src/control-server.ts +730 -0
- package/src/delivery-policy.ts +9 -0
- package/src/delivery-result.ts +24 -0
- package/src/effort-command.ts +125 -0
- package/src/empty-turn.ts +816 -0
- package/src/free-port.ts +25 -0
- package/src/fs-offload.ts +26 -0
- package/src/gate-run-config.ts +35 -0
- package/src/gate-run-lease-probe.ts +38 -0
- package/src/host-asset-plan.ts +450 -0
- package/src/host-claude-config-worker.ts +34 -0
- package/src/host-sources.ts +40 -0
- package/src/inbox-carrier.ts +7 -0
- package/src/index.ts +386 -0
- package/src/launch-assembly.ts +955 -0
- package/src/liveness.ts +105 -0
- package/src/logger.ts +108 -0
- package/src/mcp-outbox.ts +204 -0
- package/src/mcp-preflight.ts +86 -0
- package/src/mcp-source-selection.ts +54 -0
- package/src/mcp-tool-deferral.ts +35 -0
- package/src/mcp-vault-env.ts +25 -0
- package/src/message-body-config/config.ts +16 -0
- package/src/message-body-config.ts +1 -0
- package/src/mid-turn-delivery.ts +43 -0
- package/src/native-memory-lever.ts +329 -0
- package/src/native-self-resume.ts +244 -0
- package/src/outbox.ts +453 -0
- package/src/park-post.ts +17 -0
- package/src/pending-permission-store.ts +148 -0
- package/src/process-meta.ts +9 -0
- package/src/profile-home.ts +419 -0
- package/src/profile-projection.ts +411 -0
- package/src/project-hooks.ts +65 -0
- package/src/project-instructions.ts +119 -0
- package/src/provider-double.ts +59 -0
- package/src/provider-hold.ts +228 -0
- package/src/provider-home-marker.ts +3 -0
- package/src/provider-home-tombstone.ts +4 -0
- package/src/provider-input-queue.ts +134 -0
- package/src/provider-integration.ts +93 -0
- package/src/provider-processes.ts +17 -0
- package/src/provider-submit-failure.ts +15 -0
- package/src/providers.ts +35 -0
- package/src/provisioning.ts +703 -0
- package/src/question-decision.ts +18 -0
- package/src/quota.ts +22 -0
- package/src/rate-limit.ts +351 -0
- package/src/refused-prompt-store.ts +36 -0
- package/src/relay-injection-events.ts +161 -0
- package/src/relay-instructions.ts +107 -0
- package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
- package/src/relay-mcp-proxy.ts +704 -0
- package/src/relay-mcp.ts +583 -0
- package/src/reply-obligation-cache.ts +211 -0
- package/src/response-capture-report.ts +111 -0
- package/src/response-routing.ts +6 -0
- package/src/runner-core.ts +2955 -0
- package/src/runner-exit.ts +12 -0
- package/src/runner-helpers.ts +582 -0
- package/src/runner-insights.ts +105 -0
- package/src/runner-mcp-proxy.ts +46 -0
- package/src/runner-spawn-config.ts +68 -0
- package/src/runner-stale-scratch-sweep.ts +21 -0
- package/src/runner.ts +9 -0
- package/src/runtime-token-file.ts +45 -0
- package/src/runtime-token-remint.ts +56 -0
- package/src/service-access.ts +88 -0
- package/src/session-destroy.ts +27 -0
- package/src/session-insights.ts +118 -0
- package/src/session-scratch.ts +296 -0
- package/src/spawn-input.ts +147 -0
- package/src/startup-config-fetch.ts +48 -0
- package/src/subreaper.ts +113 -0
- package/src/template-resolver.ts +29 -0
- package/src/test-run-dedup.ts +435 -0
- package/src/transcript-context.ts +99 -0
- package/src/transport-errors.ts +48 -0
- package/src/turn-opener.ts +55 -0
- package/src/turn-reconcile-event.ts +88 -0
- package/src/vault-proxy-hop.ts +99 -0
- package/src/version.ts +44 -0
- package/src/web-tool-call-proxy.ts +128 -0
- package/src/web-tools.ts +59 -0
- package/README.md +0 -3
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join as joinPath } from "node:path";
|
|
3
|
+
import { fitsMonitorDelivery, monitorDeliveryLineMaxChars, monitorDeliveryMaxChars } from "../adapter";
|
|
4
|
+
|
|
5
|
+
export interface MonitorDeliverySection {
|
|
6
|
+
/** Persisted message id when this section represents a message; absent for delivery scaffolding. */
|
|
7
|
+
id?: number;
|
|
8
|
+
full: string;
|
|
9
|
+
stub?: string;
|
|
10
|
+
// #2310 — how to represent `full` when it (or the section it's batched with) doesn't fit under
|
|
11
|
+
// the Monitor's caps and there's no `stub` to fall back to. Only meaningful when `stub` is
|
|
12
|
+
// absent (fetchable sections always resolve via their stub).
|
|
13
|
+
// "must-keep" (default) — content with no get-message path and no acceptable loss (memory
|
|
14
|
+
// context, self-resume continuation). Spilled byte-exact to `spillDir` and
|
|
15
|
+
// replaced with a pointer event. Never raw-emitted, never dropped, and NEVER
|
|
16
|
+
// degrades to a bare disposable-style truncation: when it genuinely can't be
|
|
17
|
+
// delivered this cycle (no spill dir, write failure, or no reference fits the
|
|
18
|
+
// caps), it gets an explicit, distinct "could not be delivered" notice instead.
|
|
19
|
+
// "disposable" — content with no get-message path but acceptable loss (delivery scaffolding
|
|
20
|
+
// like the reply reminder). Self-truncated with an explicit notice.
|
|
21
|
+
kind?: "must-keep" | "disposable";
|
|
22
|
+
/** Label used in a must-keep section's spill pointer / filename. Default "content". */
|
|
23
|
+
label?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MonitorDeliveryPlan {
|
|
27
|
+
events: Array<{ text: string; messageIds: number[] }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PlanClaudeMonitorDeliveryOptions {
|
|
31
|
+
reservedChars?: number;
|
|
32
|
+
// #2310 — directory to spill an over-cap must-keep section's full content to (byte-exact),
|
|
33
|
+
// replacing the delivered event with a small "read this file" pointer. There is no supported
|
|
34
|
+
// way to emit more than one Monitor event per delivery cycle (the harness coalesces same-tick
|
|
35
|
+
// stdout into one event), so a must-keep section that's too large to inline can never be
|
|
36
|
+
// 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
|
|
38
|
+
// section gets an explicit "could not be delivered" notice — NEVER raw-emitted for the harness
|
|
39
|
+
// to truncate mid-content, and never a bare/ambiguous truncation either.
|
|
40
|
+
spillDir?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RenderedSection = MonitorDeliverySection & { text: string };
|
|
44
|
+
|
|
45
|
+
const join = (items: string[]) => items.join("\n\n");
|
|
46
|
+
const idsOf = (items: Array<{ id?: number }>) => items.flatMap((item) => (item.id === undefined ? [] : [item.id]));
|
|
47
|
+
const DISPOSABLE_TRUNCATION_NOTICE = "\n[…truncated]";
|
|
48
|
+
|
|
49
|
+
// Cuts `text` to at most `budget` chars, further cutting a run of non-newline characters once it
|
|
50
|
+
// would exceed `lineMaxChars` — the two caps the harness enforces independently. A pure prefix
|
|
51
|
+
// slice (never rearranges/rewraps), so the result is always a byte-exact prefix of `text` — except
|
|
52
|
+
// that the cut point is nudged back by one UTF-16 code unit when it would otherwise land inside a
|
|
53
|
+
// surrogate pair (e.g. an emoji), never emitting a lone unpaired surrogate.
|
|
54
|
+
function truncateToCaps(text: string, budget: number, lineMaxChars: number): string {
|
|
55
|
+
let charCount = 0;
|
|
56
|
+
let lineCount = 0;
|
|
57
|
+
let cut = 0;
|
|
58
|
+
while (cut < text.length) {
|
|
59
|
+
const ch = text[cut];
|
|
60
|
+
const nextCharCount = charCount + 1;
|
|
61
|
+
const nextLineCount = ch === "\n" ? 0 : lineCount + 1;
|
|
62
|
+
if (nextCharCount > budget || nextLineCount > lineMaxChars) break;
|
|
63
|
+
charCount = nextCharCount;
|
|
64
|
+
lineCount = nextLineCount;
|
|
65
|
+
cut++;
|
|
66
|
+
}
|
|
67
|
+
if (cut > 0 && cut < text.length) {
|
|
68
|
+
const before = text.charCodeAt(cut - 1);
|
|
69
|
+
const after = text.charCodeAt(cut);
|
|
70
|
+
if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) cut--;
|
|
71
|
+
}
|
|
72
|
+
return text.slice(0, cut);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function selfTruncateDisposableSection(section: RenderedSection, reserved: number): string {
|
|
76
|
+
const maxChars = monitorDeliveryMaxChars();
|
|
77
|
+
const lineMaxChars = monitorDeliveryLineMaxChars();
|
|
78
|
+
const budget = Math.max(1, maxChars - reserved - DISPOSABLE_TRUNCATION_NOTICE.length);
|
|
79
|
+
const body = truncateToCaps(section.text, budget, lineMaxChars);
|
|
80
|
+
return body.length < section.text.length ? `${body}${DISPOSABLE_TRUNCATION_NOTICE}` : body;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// #2310 review round 4 P0-1 — bounds on the two places a caller-supplied `label` gets embedded so
|
|
84
|
+
// neither can blow a cap on its own: the filename component (unbounded here previously caused
|
|
85
|
+
// ENAMETOOLONG on write, silently turning a usable spill into an undeliverable notice) and the
|
|
86
|
+
// short display form used in notice/pointer text. The full uuid in the filename is NOT shortened —
|
|
87
|
+
// it's already fixed-length and collision-safe; only the caller-controlled label is bounded.
|
|
88
|
+
const MAX_FILENAME_LABEL_CHARS = 20;
|
|
89
|
+
const MAX_DISPLAY_LABEL_CHARS = 40;
|
|
90
|
+
|
|
91
|
+
function boundLabel(label: string, maxChars: number): string {
|
|
92
|
+
const trimmed = (label ?? "").trim();
|
|
93
|
+
if (!trimmed) return "content";
|
|
94
|
+
return trimmed.length > maxChars ? `${trimmed.slice(0, Math.max(1, maxChars - 1))}…` : trimmed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sanitizeForFilename(label: string): string {
|
|
98
|
+
const cleaned = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "content";
|
|
99
|
+
if (cleaned.length <= MAX_FILENAME_LABEL_CHARS) return cleaned;
|
|
100
|
+
return cleaned.slice(0, MAX_FILENAME_LABEL_CHARS).replace(/-+$/g, "") || "content";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// #2310 P1 — the spilled file (and the dir it lands in) can hold sensitive content (memory
|
|
104
|
+
// context, self-resume continuation), so both are session-private: dir 0700, file 0600. `mode` on
|
|
105
|
+
// mkdirSync/writeFileSync is still subject to umask, so re-assert both with chmodSync afterward —
|
|
106
|
+
// best-effort (a chmod failure here shouldn't sacrifice the write; the caller's own try/catch
|
|
107
|
+
// around this whole function is the real safety net if something is fundamentally unwritable).
|
|
108
|
+
function writeSpillFileTight(dir: string, filePath: string, content: string): void {
|
|
109
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
110
|
+
try { chmodSync(dir, 0o700); } catch {}
|
|
111
|
+
writeFileSync(filePath, content, { encoding: "utf8", mode: 0o600 });
|
|
112
|
+
try { chmodSync(filePath, 0o600); } catch {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// #2310 review round 3 P0 — a must-keep section must NEVER degrade to a bare disposable
|
|
116
|
+
// truncation ("[…truncated]" with no way back to the rest) — that's indistinguishable from
|
|
117
|
+
// ordinary lossy content. When it genuinely can't be delivered in full this cycle, say so
|
|
118
|
+
// explicitly and distinctly instead. `reason` names WHY (surfaced verbatim to the agent).
|
|
119
|
+
//
|
|
120
|
+
// This IS a real, unrecovered content loss for the cycle — not a fail-closed retry. Fail-closed
|
|
121
|
+
// (withhold the ack, let the caller redeliver) isn't available here: unlike a persisted/fetchable
|
|
122
|
+
// message (real id, tracked through the poll+ack+requeue path), a must-keep injection is built
|
|
123
|
+
// with id 0 (see runner-core.ts injectContext) specifically because it's a one-shot push with no
|
|
124
|
+
// persisted-message retry contract, and its single-shot caller doesn't inspect the delivery result
|
|
125
|
+
// to decide whether to re-offer it. Building real fail-closed retry would mean giving these
|
|
126
|
+
// injections a trackable identity and teaching that caller to retry on a missing ack — a genuine
|
|
127
|
+
// architecture change, out of scope here. The explicit notice is the honest floor: it can't
|
|
128
|
+
// 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
|
|
130
|
+
// independently-complete, candidates — never by truncating a single label-prefixed string (a huge
|
|
131
|
+
// label pushed the classified marker text itself past the caps, so a raw prefix-truncation of the
|
|
132
|
+
// old label-first shape could slice down to a bare "[LLLL…" containing none of "could NOT be
|
|
133
|
+
// delivered"/"content lost"). Each tier drops the LEAST essential part first (char count, then the
|
|
134
|
+
// caller's label, then the specific reason) while the classified "undeliverable" marker survives
|
|
135
|
+
// every tier down to the last.
|
|
136
|
+
function mustKeepUndeliverableNotice(section: RenderedSection, reserved: number, reason: string): string {
|
|
137
|
+
const label = boundLabel(section.label ?? "content", MAX_DISPLAY_LABEL_CHARS);
|
|
138
|
+
const candidates = [
|
|
139
|
+
`[${label}: ${section.full.length} chars could NOT be delivered in full — ${reason}]`,
|
|
140
|
+
`[${label}: could NOT be delivered in full — ${reason}]`,
|
|
141
|
+
`[could NOT be delivered in full — ${reason}]`,
|
|
142
|
+
`[content lost — ${reason}]`,
|
|
143
|
+
`[content lost this delivery]`,
|
|
144
|
+
];
|
|
145
|
+
for (const candidate of candidates) {
|
|
146
|
+
if (fitsMonitorDelivery(candidate, { reserved })) return candidate;
|
|
147
|
+
}
|
|
148
|
+
// Cap-safety net for a pathological cap override where even the shortest fixed-shape candidate
|
|
149
|
+
// above doesn't fit: hard-truncate the shortest (already label-free, reason-free) one as an
|
|
150
|
+
// absolute last resort — never a bare/ambiguous "[…truncated]".
|
|
151
|
+
const maxChars = monitorDeliveryMaxChars();
|
|
152
|
+
const lineMaxChars = monitorDeliveryLineMaxChars();
|
|
153
|
+
return truncateToCaps(candidates[candidates.length - 1]!, Math.max(1, maxChars - reserved), lineMaxChars);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// #2310 — write the section's full, untouched content to disk and point at it instead of ever
|
|
157
|
+
// inlining a truncated/rewritten copy. The reference is built as a cascade of progressively
|
|
158
|
+
// SHORTER but always independently-complete candidates, most useful first — never by truncating a
|
|
159
|
+
// single label-prefixed string (review round 4 P0-1: a huge label must never cost us the file
|
|
160
|
+
// reference itself, the one truly essential part):
|
|
161
|
+
// 1. The absolute file path (most directly actionable).
|
|
162
|
+
// 2. A short reference — filename plus "in your session scratch tmp dir" — for when the
|
|
163
|
+
// absolute path alone busts the per-line cap (a long cwd/agent-id; review round 3 P0 case a).
|
|
164
|
+
// 3. Filename plus a terse "(scratch dir)" note, for an even tighter per-line cap.
|
|
165
|
+
// 4. The bare filename alone — the filename is always short and bounded (MAX_FILENAME_LABEL_CHARS
|
|
166
|
+
// caps the label component; the fixed uuid/prefix/suffix are constant), so this should always
|
|
167
|
+
// fit even when 1-3 don't.
|
|
168
|
+
// Each is tried both with and without the decorative `[label — spilled to disk, read it now]`
|
|
169
|
+
// bracket line (least essential — dropped first) before escalating to a shorter reference.
|
|
170
|
+
// An explicit "could not be delivered" notice is the last resort when there's no spill dir, the
|
|
171
|
+
// write fails, or (pathologically) even the bare filename doesn't fit (review round 3 P0 case b).
|
|
172
|
+
function spillMustKeepSection(
|
|
173
|
+
section: RenderedSection,
|
|
174
|
+
spillDir: string | undefined,
|
|
175
|
+
reserved: number,
|
|
176
|
+
): { text: string; spilled: boolean } {
|
|
177
|
+
const label = section.label ?? "content";
|
|
178
|
+
if (!spillDir) return { text: mustKeepUndeliverableNotice(section, reserved, "scratch dir unavailable"), spilled: false };
|
|
179
|
+
|
|
180
|
+
let filePath: string;
|
|
181
|
+
let fileName: string;
|
|
182
|
+
try {
|
|
183
|
+
fileName = `monitor-injection-${sanitizeForFilename(label)}-${crypto.randomUUID()}.md`;
|
|
184
|
+
filePath = joinPath(spillDir, fileName);
|
|
185
|
+
writeSpillFileTight(spillDir, filePath, section.full);
|
|
186
|
+
} catch {
|
|
187
|
+
return { text: mustKeepUndeliverableNotice(section, reserved, "scratch dir write failed"), spilled: false };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const bracket = `[${boundLabel(label, MAX_DISPLAY_LABEL_CHARS)} — spilled to disk, read it now]`;
|
|
191
|
+
const references = [
|
|
192
|
+
`Full content (byte-exact): ${filePath}`,
|
|
193
|
+
`File: ${fileName} — in your session scratch tmp dir ($AGENT_RELAY_SCRATCH_DIR)`,
|
|
194
|
+
`File: ${fileName} (scratch dir)`,
|
|
195
|
+
`File: ${fileName}`,
|
|
196
|
+
];
|
|
197
|
+
for (const reference of references) {
|
|
198
|
+
const withBracket = `${bracket}\n${reference}`;
|
|
199
|
+
if (fitsMonitorDelivery(withBracket, { reserved })) return { text: withBracket, spilled: true };
|
|
200
|
+
if (fitsMonitorDelivery(reference, { reserved })) return { text: reference, spilled: true };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The content IS safely spilled — this only means no reference form fits THIS delivery's caps
|
|
204
|
+
// (an extreme cap override). Say so explicitly rather than silently dropping the only pointer.
|
|
205
|
+
return {
|
|
206
|
+
text: mustKeepUndeliverableNotice(section, reserved, "spilled successfully but no reference fits this delivery's caps"),
|
|
207
|
+
spilled: false,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type SpillResult = RenderedSection & { spilled: boolean };
|
|
212
|
+
|
|
213
|
+
// #2310 review round 4 P0-2 — `items` is a mix of two DIFFERENT outcomes from spillMustKeepSection:
|
|
214
|
+
// truly spilled pointers (file exists, safe to defer into an aggregate "N more spilled" count) and
|
|
215
|
+
// undeliverable notices (no spillDir / write failed — nothing exists to point at). The two must
|
|
216
|
+
// never be conflated: deferring an undeliverable notice into the "spilled" count is a FALSE claim
|
|
217
|
+
// (the agent would go looking for a file that was never written). Keeps as many items as fit,
|
|
218
|
+
// leaving room for trailing notice(s) naming exactly how many of EACH kind were deferred.
|
|
219
|
+
function packWithDeferredNotice(items: SpillResult[], reserved: number): RenderedSection[] {
|
|
220
|
+
let kept: SpillResult[] = [];
|
|
221
|
+
const DEFERRED_NOTICE_BUDGET = 100;
|
|
222
|
+
for (const item of items) {
|
|
223
|
+
const candidate = [...kept, item];
|
|
224
|
+
if (fitsMonitorDelivery(join(candidate.map((entry) => entry.text)), { reserved: reserved + DEFERRED_NOTICE_BUDGET })) kept = candidate;
|
|
225
|
+
}
|
|
226
|
+
const deferred = items.filter((item) => !kept.includes(item));
|
|
227
|
+
if (deferred.length > 0) {
|
|
228
|
+
const deferredSpilled = deferred.filter((item) => item.spilled).length;
|
|
229
|
+
const deferredFailed = deferred.length - deferredSpilled;
|
|
230
|
+
const parts: string[] = [];
|
|
231
|
+
if (deferredSpilled > 0) parts.push(`${deferredSpilled} more spilled injection${deferredSpilled === 1 ? "" : "s"} — see session scratch dir`);
|
|
232
|
+
if (deferredFailed > 0) parts.push(`${deferredFailed} injection${deferredFailed === 1 ? "" : "s"} could NOT be delivered — content lost`);
|
|
233
|
+
const notice = `[${parts.join("; ")}]`;
|
|
234
|
+
kept = [...kept, { full: notice, text: notice, spilled: false }];
|
|
235
|
+
}
|
|
236
|
+
return kept;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Render each section once, then build ONE cap-compliant event (there is no supported way to emit
|
|
241
|
+
* more than one Monitor event per delivery cycle — the harness coalesces same-tick stdout back
|
|
242
|
+
* into a single event). No over-cap section is ever raw-emitted for the harness to truncate
|
|
243
|
+
* (#2310):
|
|
244
|
+
*
|
|
245
|
+
* - 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
|
|
247
|
+
* 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
|
|
249
|
+
* dropped must-keep section, unlike a dropped fetchable one, is unrecoverable — it has no
|
|
250
|
+
* persisted id to retry via).
|
|
251
|
+
* - Fetchable (stubbed) and disposable sections are then greedily folded into whatever budget
|
|
252
|
+
* remains around the guaranteed set. A fetchable section whose stub still doesn't fit is simply
|
|
253
|
+
* skipped and left for the runner to requeue on the next poll (it stays retrievable via
|
|
254
|
+
* get-message either way); a disposable section that can't be fit even self-truncated is the
|
|
255
|
+
* one acceptable loss here.
|
|
256
|
+
*/
|
|
257
|
+
export function planClaudeMonitorDelivery(
|
|
258
|
+
sections: MonitorDeliverySection[],
|
|
259
|
+
options: PlanClaudeMonitorDeliveryOptions = {},
|
|
260
|
+
): MonitorDeliveryPlan {
|
|
261
|
+
const reserved = options.reservedChars ?? 0;
|
|
262
|
+
const full = join(sections.map((section) => section.full));
|
|
263
|
+
if (fitsMonitorDelivery(full, { reserved })) {
|
|
264
|
+
return { events: [{ text: full, messageIds: idsOf(sections) }] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const rendered: RenderedSection[] = sections.map((section) => ({ ...section, text: section.stub ?? section.full }));
|
|
268
|
+
const allStubbed = join(rendered.map((section) => section.text));
|
|
269
|
+
if (fitsMonitorDelivery(allStubbed, { reserved })) {
|
|
270
|
+
return { events: [{ text: allStubbed, messageIds: idsOf(rendered) }] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const mustKeep = rendered.filter((section) => !section.stub && section.kind !== "disposable");
|
|
274
|
+
const optional = rendered.filter((section) => section.stub || section.kind === "disposable");
|
|
275
|
+
|
|
276
|
+
// Prefer each must-keep section's raw text (no unnecessary spill) when the FULL guaranteed set
|
|
277
|
+
// already fits together; only escalate to spilling ALL of them once that's not possible — a
|
|
278
|
+
// spilled pointer is small enough that the escalated set should then always coexist.
|
|
279
|
+
let guaranteed = mustKeep;
|
|
280
|
+
if (mustKeep.length > 0 && !fitsMonitorDelivery(join(mustKeep.map((item) => item.text)), { reserved })) {
|
|
281
|
+
const spilled: SpillResult[] = mustKeep.map((section) => {
|
|
282
|
+
const result = spillMustKeepSection(section, options.spillDir, reserved);
|
|
283
|
+
return { ...section, text: result.text, spilled: result.spilled };
|
|
284
|
+
});
|
|
285
|
+
if (fitsMonitorDelivery(join(spilled.map((item) => item.text)), { reserved })) {
|
|
286
|
+
guaranteed = spilled;
|
|
287
|
+
} else {
|
|
288
|
+
// Pathological (not expected in practice — pointers are small): even every pointer together
|
|
289
|
+
// doesn't fit. Every successfully spilled section is ALREADY safe on disk, so nothing from
|
|
290
|
+
// those is truly lost — keep as many items as fit and make the rest an EXPLICIT deferred
|
|
291
|
+
// count instead of silently vanishing, correctly classified as spilled vs. undeliverable.
|
|
292
|
+
guaranteed = packWithDeferredNotice(spilled, reserved);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let chunk: RenderedSection[] = guaranteed;
|
|
297
|
+
for (const section of optional) {
|
|
298
|
+
const candidate = [...chunk, section];
|
|
299
|
+
if (fitsMonitorDelivery(join(candidate.map((item) => item.text)), { reserved })) {
|
|
300
|
+
chunk = candidate;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (section.stub) {
|
|
304
|
+
// Fetchable, but even its stub doesn't fit here — skip it (requeues on the next poll) and
|
|
305
|
+
// keep scanning; a later section must not be starved by this.
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Disposable: try a self-truncated notice instead of the (already too large) full text; if
|
|
309
|
+
// even that can't be fit alongside the guaranteed set, this is the one acceptable loss.
|
|
310
|
+
const substitute: RenderedSection = { ...section, text: selfTruncateDisposableSection(section, reserved) };
|
|
311
|
+
const withSubstitute = [...chunk, substitute];
|
|
312
|
+
if (fitsMonitorDelivery(join(withSubstitute.map((item) => item.text)), { reserved })) {
|
|
313
|
+
chunk = withSubstitute;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { events: chunk.length ? [{ text: join(chunk.map((item) => item.text)), messageIds: idsOf(chunk) }] : [] };
|
|
317
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { InteractivePrompt, OperatorDecisionFileInput } from "@humwick/sdk";
|
|
2
|
+
import { isRecord } from "@humwick/sdk";
|
|
3
|
+
import type { ProviderPermissionDecisionInput, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt } from "../adapter";
|
|
4
|
+
|
|
5
|
+
function decisionReasonMessage(decision: ProviderPermissionDecisionInput, fallback: string): string {
|
|
6
|
+
return decision.reason || decision.decisionReason?.message || fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const claudePermissionPromptHandler: ProviderPermissionPromptHandler = {
|
|
10
|
+
source: "claude",
|
|
11
|
+
resolvedLabel: "Claude approval resolved",
|
|
12
|
+
buildView: claudePermissionApprovalView,
|
|
13
|
+
buildHookResponse: claudePermissionHookResponse,
|
|
14
|
+
buildResolvedPrompt: claudeResolvedPermissionPrompt,
|
|
15
|
+
buildAnswerInjectionPrompt: claudeAnswerInjectionPrompt,
|
|
16
|
+
buildDecisionRequest: claudeDecisionRequest,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const RECOMMENDED_LABEL = /\(recommended\)\s*$/i;
|
|
20
|
+
const DECISION_TITLE_MAX = 200;
|
|
21
|
+
|
|
22
|
+
// #2914: AskUserQuestion → operator decision, 1:1. Claude marks its pick with a "(Recommended)" label.
|
|
23
|
+
function claudeDecisionRequest(body: Record<string, unknown>): OperatorDecisionFileInput | undefined {
|
|
24
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
25
|
+
if (body.tool_name !== "AskUserQuestion" || !Array.isArray(toolInput.questions)) return undefined;
|
|
26
|
+
const asked = toolInput.questions.filter(isRecord);
|
|
27
|
+
if (!asked.length) return undefined;
|
|
28
|
+
const questions = asked.map((question) => {
|
|
29
|
+
const options = (Array.isArray(question.options) ? question.options : []).filter(isRecord)
|
|
30
|
+
.filter((option) => typeof option.label === "string" && option.label.trim())
|
|
31
|
+
.map((option) => ({
|
|
32
|
+
label: String(option.label).trim(),
|
|
33
|
+
...(typeof option.description === "string" && option.description.trim() ? { description: option.description.trim() } : {}),
|
|
34
|
+
}));
|
|
35
|
+
const recommended = options.findIndex((option) => RECOMMENDED_LABEL.test(option.label));
|
|
36
|
+
return {
|
|
37
|
+
prompt: typeof question.question === "string" ? question.question.trim() : "",
|
|
38
|
+
kind: !options.length ? "text" as const : question.multiSelect === true ? "multi" as const : "single" as const,
|
|
39
|
+
...(options.length ? { options: options.map((option, index) => index === recommended ? { ...option, recommended: true } : option) } : {}),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
const headers = asked.map((question) => typeof question.header === "string" ? question.header.trim() : "").filter(Boolean);
|
|
43
|
+
const title = headers.length ? headers.join(" · ") : questions[0]!.prompt;
|
|
44
|
+
return { title: title.length > DECISION_TITLE_MAX ? `${title.slice(0, DECISION_TITLE_MAX - 1)}…` : title, questions };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
|
|
48
|
+
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
49
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
50
|
+
if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
|
|
51
|
+
const count = toolInput.questions.length;
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
kind: "questions",
|
|
55
|
+
toolName,
|
|
56
|
+
title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
|
|
57
|
+
body: "",
|
|
58
|
+
questions: toolInput.questions as InteractivePrompt["questions"],
|
|
59
|
+
choices: [],
|
|
60
|
+
reasoningSettled,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (toolName === "ExitPlanMode") {
|
|
64
|
+
const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
|
|
65
|
+
? toolInput.plan
|
|
66
|
+
: JSON.stringify(toolInput);
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
kind: "plan",
|
|
70
|
+
toolName,
|
|
71
|
+
title: "Claude is ready to code",
|
|
72
|
+
body: plan,
|
|
73
|
+
choices: [
|
|
74
|
+
{ id: "approve", label: "Approve plan" },
|
|
75
|
+
{ id: "deny", label: "Keep planning" },
|
|
76
|
+
],
|
|
77
|
+
reasoningSettled,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
81
|
+
const description = typeof toolInput.description === "string" ? toolInput.description : "";
|
|
82
|
+
const bodyText = [
|
|
83
|
+
command || description || JSON.stringify(toolInput),
|
|
84
|
+
typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
|
|
85
|
+
typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
|
|
86
|
+
].filter(Boolean).join("\n");
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
|
|
90
|
+
toolName,
|
|
91
|
+
title: `Approve ${toolName}`,
|
|
92
|
+
body: bodyText,
|
|
93
|
+
choices: [
|
|
94
|
+
{ id: "approve", label: "Approve" },
|
|
95
|
+
{ id: "approve-session", label: "Approve session" },
|
|
96
|
+
{ id: "deny", label: "Deny" },
|
|
97
|
+
{ id: "abort", label: "Abort" },
|
|
98
|
+
],
|
|
99
|
+
reasoningSettled,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
|
|
104
|
+
if (body.hook_event_name === "PreToolUse") {
|
|
105
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
106
|
+
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
107
|
+
return {
|
|
108
|
+
hookSpecificOutput: {
|
|
109
|
+
hookEventName: "PreToolUse",
|
|
110
|
+
permissionDecision: "allow",
|
|
111
|
+
updatedInput: { ...toolInput, answers: decision.answers },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
hookSpecificOutput: {
|
|
117
|
+
hookEventName: "PreToolUse",
|
|
118
|
+
permissionDecision: "deny",
|
|
119
|
+
permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const hookEventName = "PermissionRequest";
|
|
124
|
+
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
125
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
126
|
+
return {
|
|
127
|
+
hookSpecificOutput: {
|
|
128
|
+
hookEventName,
|
|
129
|
+
decision: {
|
|
130
|
+
behavior: "allow",
|
|
131
|
+
updatedInput: { ...toolInput, answers: decision.answers },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (decision.decision === "approve" || decision.decision === "approve-session") {
|
|
137
|
+
const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
|
|
138
|
+
return {
|
|
139
|
+
hookSpecificOutput: {
|
|
140
|
+
hookEventName,
|
|
141
|
+
decision: {
|
|
142
|
+
behavior: "allow",
|
|
143
|
+
...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
hookSpecificOutput: {
|
|
150
|
+
hookEventName,
|
|
151
|
+
decision: {
|
|
152
|
+
behavior: "deny",
|
|
153
|
+
message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
|
|
154
|
+
interrupt: decision.decision === "abort",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt {
|
|
161
|
+
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
162
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
163
|
+
const kind: ProviderPermissionResolvedPrompt["kind"] = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
|
|
164
|
+
? view.kind
|
|
165
|
+
: toolName.toLowerCase() === "bash" ? "command" : "tool";
|
|
166
|
+
const questions = Array.isArray(view.questions)
|
|
167
|
+
? view.questions
|
|
168
|
+
: Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
169
|
+
const promptBody = typeof view.body === "string" ? view.body : "";
|
|
170
|
+
const title = typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`;
|
|
171
|
+
const decisionLabel = claudePermissionDecisionLabel(kind, decision);
|
|
172
|
+
return {
|
|
173
|
+
provider: "claude",
|
|
174
|
+
kind,
|
|
175
|
+
title,
|
|
176
|
+
body: claudeResolvedPermissionBody({ kind, title, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
|
|
177
|
+
...(promptBody ? { promptBody } : {}),
|
|
178
|
+
toolName,
|
|
179
|
+
hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
|
|
180
|
+
approvalId: decision.approvalId,
|
|
181
|
+
decision: decision.decision,
|
|
182
|
+
decisionLabel,
|
|
183
|
+
decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
|
|
184
|
+
occurredAt,
|
|
185
|
+
resolvedAt: Date.now(),
|
|
186
|
+
...(questions ? { questions } : {}),
|
|
187
|
+
...(decision.answers ? { answers: decision.answers } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// #1173: AskUserQuestion's PreToolUse hook is answered with `deny` immediately (see
|
|
192
|
+
// control-server.ts) so Claude flushes its buffered reasoning instead of blocking on the human.
|
|
193
|
+
// The user's actual answer arrives afterward as a normal injected prompt, not as a hook
|
|
194
|
+
// response — this renders that prompt's text from the same view/decision shape the old
|
|
195
|
+
// blocking flow used, so the model sees a clear, structured hand-off instead of silence.
|
|
196
|
+
function claudeAnswerInjectionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string {
|
|
197
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
198
|
+
const questions = Array.isArray(view.questions) ? view.questions : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
199
|
+
const rows = resolvedQuestionRows(questions, decision.answers, "(no answer given)");
|
|
200
|
+
const qa = rows.map((row) => `Q: ${row.question}\nA: ${row.answer}`).join("\n\n");
|
|
201
|
+
return [
|
|
202
|
+
"This was answered by the user via the Agent Relay dashboard — the AskUserQuestion tool call itself was intercepted and will not resolve; it will not return a result. Here is their answer:",
|
|
203
|
+
qa,
|
|
204
|
+
"Continue the task using their answer(s) above — do not call AskUserQuestion again for the same question(s), and do not wait for the original tool call to return.",
|
|
205
|
+
].join("\n\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
|
|
209
|
+
if (input.kind === "questions") {
|
|
210
|
+
return [
|
|
211
|
+
input.title,
|
|
212
|
+
input.decisionLabel,
|
|
213
|
+
...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
|
|
214
|
+
].filter(Boolean).join("\n\n");
|
|
215
|
+
}
|
|
216
|
+
const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
|
|
217
|
+
return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
|
|
221
|
+
const answerMap = answers ?? {};
|
|
222
|
+
const rows = (questions ?? []).map((question, index) => {
|
|
223
|
+
const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
|
|
224
|
+
const label = typeof record.question === "string" && record.question.trim()
|
|
225
|
+
? record.question.trim()
|
|
226
|
+
: typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
|
|
227
|
+
const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
|
|
228
|
+
return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
|
|
229
|
+
});
|
|
230
|
+
return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function claudePermissionDecisionLabel(kind: string, input: ProviderPermissionDecisionInput): string {
|
|
234
|
+
if (input.decisionReason?.kind === "timeout") return "Timed out";
|
|
235
|
+
const decision = input.decision;
|
|
236
|
+
if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
|
|
237
|
+
if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
|
|
238
|
+
if (decision === "approve-session") return "Approved for session";
|
|
239
|
+
if (decision === "approve") return "Approved";
|
|
240
|
+
if (decision === "abort") return "Aborted";
|
|
241
|
+
if (decision === "answer") return "Answered";
|
|
242
|
+
return "Denied";
|
|
243
|
+
}
|