@humwick/runner 0.0.0-stage → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +46 -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,730 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
+
import type { InteractivePrompt, Message, OperatorDecisionFileInput, ReplyObligation } from "@humwick/sdk";
|
|
3
|
+
import { errMessage, isRecord } from "@humwick/sdk";
|
|
4
|
+
import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
5
|
+
import { obligationNagEligible } from "./reply-obligation-cache";
|
|
6
|
+
import { relayReplyActionText } from "./relay-instructions";
|
|
7
|
+
import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
|
|
8
|
+
import { buildRateLimitProviderState } from "./rate-limit";
|
|
9
|
+
import { bestEffortDeletePendingPermissionRequest, bestEffortListPendingPermissionRequests, bestEffortUpsertPendingPermissionRequest, type PendingPermissionRequestRecord, type PendingPermissionStore } from "./pending-permission-store";
|
|
10
|
+
|
|
11
|
+
// Provider permission hooks can wait a long time. Resolve the server-side wait with
|
|
12
|
+
// a margin under the provider hook timeout so the server returns a deterministic
|
|
13
|
+
// decision before the provider abandons the hook.
|
|
14
|
+
const PERMISSION_WAIT_MS = 880_000;
|
|
15
|
+
// Distinct from a human dismissal ("Dismissed/Denied from Humwick dashboard") so the
|
|
16
|
+
// agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
|
|
17
|
+
function permissionTimeoutReason(view?: Pick<InteractivePrompt, "toolName" | "title">): string {
|
|
18
|
+
const tool = view?.toolName?.trim() || view?.title?.replace(/^Approve\s+/i, "").trim() || "provider tool";
|
|
19
|
+
return `Permission request for ${tool} timed out after ~15 minutes without a coordinator decision; the tool was denied (timeout, not a dismissal). Re-ask if you still need it.`;
|
|
20
|
+
}
|
|
21
|
+
const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
|
|
22
|
+
const RESOLVED_APPROVAL_LIMIT = 1024;
|
|
23
|
+
// #1173: the immediate PreToolUse response for a "questions" prompt (see the deny-then-hold
|
|
24
|
+
// branch in handlePermissionRequest below). Bridges the hand-off so the model doesn't read a
|
|
25
|
+
// bare denial and re-ask — the real answer follows shortly as an injected user prompt.
|
|
26
|
+
const PENDING_QUESTION_DENY_REASON =
|
|
27
|
+
"The user is answering this question directly via Agent Relay, not through this tool call — do not re-ask it. Their answer will follow as your next message; wait for it and continue from there.";
|
|
28
|
+
|
|
29
|
+
// A hook that failed in a way it could not handle itself reports here so the
|
|
30
|
+
// failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
|
|
31
|
+
// log; Phase 2 (#196) will additionally route it through the runner outbox to the
|
|
32
|
+
// server.
|
|
33
|
+
export interface HookFatalReport {
|
|
34
|
+
hook: string;
|
|
35
|
+
error: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MonitorSocketData {
|
|
39
|
+
kind: "monitor";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type MonitorSocket = ServerWebSocket<MonitorSocketData>;
|
|
43
|
+
|
|
44
|
+
interface PendingPermissionWaiter {
|
|
45
|
+
resolve(input: ProviderPermissionDecisionInput): void;
|
|
46
|
+
timer: Timer;
|
|
47
|
+
suppressResolvedStatus?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ControlServer {
|
|
51
|
+
port: number;
|
|
52
|
+
url: string;
|
|
53
|
+
stop(): void;
|
|
54
|
+
deliverToMonitor(messages: Message[], timeoutMs?: number): Promise<number[]>;
|
|
55
|
+
resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ControlServerOptions {
|
|
59
|
+
onStatus(status: ProviderStatusEvent): void;
|
|
60
|
+
onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
|
|
61
|
+
// #2459 — the CURRENT non-secret attachment generation (derived from the provider's active
|
|
62
|
+
// session id), read by the orchestrator before terminal input and on stream connect/
|
|
63
|
+
// reconnect to detect a session rotation. Returns ONLY that digest — never the session id,
|
|
64
|
+
// never a credential. Absent handler ⇒ endpoint reports no generation, which the
|
|
65
|
+
// orchestrator treats as "nothing to check" (e.g. codex's attach target cannot go stale).
|
|
66
|
+
onTerminalGeneration?(): Promise<string | undefined>;
|
|
67
|
+
onReplyObligations?(): Promise<ReplyObligation[]>;
|
|
68
|
+
// Provider session-final and pre-flush hooks hand over their capture source so
|
|
69
|
+
// the runner can surface the provider turn in dashboard chat without the agent
|
|
70
|
+
// re-emitting it via /reply.
|
|
71
|
+
onSubagentUsage?(transcriptPath: string): Promise<void>;
|
|
72
|
+
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean; toolUseOccurredAt?: number } | void>;
|
|
73
|
+
onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
|
|
74
|
+
onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
|
|
75
|
+
// #1173: a "questions" (AskUserQuestion) prompt's own PreToolUse hook is answered with `deny`
|
|
76
|
+
// immediately (see the branch in handlePermissionRequest) instead of held open for the human,
|
|
77
|
+
// so there is no live HTTP request left to answer once the decision resolves. This delivers
|
|
78
|
+
// that resolution instead: `text` is the prompt to inject as a normal user turn.
|
|
79
|
+
onAnswerInject?(input: { approvalId: string; text?: string }): Promise<void> | void;
|
|
80
|
+
// #2914: files a "questions" prompt as a durable operator decision; undefined = not filed, so the
|
|
81
|
+
// question falls back to the live deny-then-hold relay above.
|
|
82
|
+
onQuestionDecision?(input: OperatorDecisionFileInput): Promise<{ id: number } | undefined>;
|
|
83
|
+
// A provider user-prompt hook hands over text typed directly into the session
|
|
84
|
+
// so the runner can mirror it into dashboard chat.
|
|
85
|
+
onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string; firedAt?: number }): Promise<void>;
|
|
86
|
+
// A session-start hook checks in with its fire time, proving the hook path works (#2664).
|
|
87
|
+
onHookCheck?(input: { firedAt?: number }): void;
|
|
88
|
+
onToolUse?(input: unknown, signal: AbortSignal): Promise<string>;
|
|
89
|
+
// A provider session-boundary hook signals an imminent context reset or
|
|
90
|
+
// termination so the runner can run end-of-session work before the operation.
|
|
91
|
+
onSessionBoundary?(input: { reason?: string; transcriptPath?: string }): Promise<void>;
|
|
92
|
+
onSessionStart?(input: { source?: string; providerSessionId?: string }): string | undefined;
|
|
93
|
+
// Phase 1 observability (#198): a hook reporting an unhandled failure. The
|
|
94
|
+
// control server already logs it FATAL; this is the seam for Phase 2 to also
|
|
95
|
+
// surface it to the server via the runner outbox.
|
|
96
|
+
onHookFatal?(report: HookFatalReport): void;
|
|
97
|
+
// Test seam: how long the permission hook waits for a dashboard decision before
|
|
98
|
+
// resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
|
|
99
|
+
permissionWaitMs?: number;
|
|
100
|
+
instanceId?: string;
|
|
101
|
+
pendingPermissionStore?: PendingPermissionStore;
|
|
102
|
+
permissionPromptHandler?: ProviderPermissionPromptHandler;
|
|
103
|
+
// #3648 — the agent runs in `open` approval mode: answer every permission request at once.
|
|
104
|
+
autoApprovePermissions?: boolean;
|
|
105
|
+
providerSourceId?: string;
|
|
106
|
+
stopObligationPath?: string;
|
|
107
|
+
monitorUnavailableMessage?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
|
|
111
|
+
|
|
112
|
+
export function startControlServer(options: ControlServerOptions): ControlServer {
|
|
113
|
+
const monitors = new Set<MonitorSocket>();
|
|
114
|
+
const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
|
|
115
|
+
const pendingPermissionRequests = new Map<string, PendingPermissionWaiter>();
|
|
116
|
+
// approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
|
|
117
|
+
// reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
|
|
118
|
+
// is an idempotent no-op instead of a silent command failure (#693).
|
|
119
|
+
const resolvedApprovals = new Set<string>();
|
|
120
|
+
let server!: Server<MonitorSocketData>;
|
|
121
|
+
recoverStoredPermissionRequests(options);
|
|
122
|
+
|
|
123
|
+
server = Bun.serve<MonitorSocketData>({
|
|
124
|
+
hostname: "127.0.0.1",
|
|
125
|
+
port: 0,
|
|
126
|
+
fetch(req, srv) {
|
|
127
|
+
const url = new URL(req.url);
|
|
128
|
+
if (url.pathname === "/health") return Response.json({ ok: true });
|
|
129
|
+
if (url.pathname === "/status" && req.method === "GET") return Response.json({ ok: true });
|
|
130
|
+
if (url.pathname === "/status" && req.method === "POST") {
|
|
131
|
+
return handleStatus(req, options);
|
|
132
|
+
}
|
|
133
|
+
if (url.pathname === "/terminal/attach-spec" && req.method === "GET") {
|
|
134
|
+
if (!options.onTerminalAttachSpec) return Response.json({ error: "terminal attach is unavailable" }, { status: 404 });
|
|
135
|
+
return options.onTerminalAttachSpec()
|
|
136
|
+
// #2459 round 1 (review A) — strip any credential-bearing env record from the body:
|
|
137
|
+
// credentials travel ONLY by runner-staged env file (spec.envFile), so nothing
|
|
138
|
+
// secret ever serializes into this HTTP response.
|
|
139
|
+
.then((spec) => {
|
|
140
|
+
const safeSpec: Record<string, unknown> = { ...spec };
|
|
141
|
+
delete safeSpec.env;
|
|
142
|
+
return Response.json(safeSpec);
|
|
143
|
+
})
|
|
144
|
+
.catch((error) => Response.json({ error: errMessage(error) }, { status: 409 }));
|
|
145
|
+
}
|
|
146
|
+
if (url.pathname === "/terminal/generation" && req.method === "GET") {
|
|
147
|
+
// #2459 — deliberately returns ONLY the non-secret generation digest. No session id,
|
|
148
|
+
// no password, no attach detail: this endpoint exists so the orchestrator can detect
|
|
149
|
+
// staleness, and anything more would widen the secret surface for no benefit.
|
|
150
|
+
if (!options.onTerminalGeneration) return Response.json({});
|
|
151
|
+
return options.onTerminalGeneration()
|
|
152
|
+
.then((generation) => Response.json(generation ? { generation } : {}))
|
|
153
|
+
.catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
|
|
154
|
+
}
|
|
155
|
+
if (url.pathname === "/reply-obligations" && req.method === "GET") {
|
|
156
|
+
if (!options.onReplyObligations) return Response.json({ pending: false, count: 0 });
|
|
157
|
+
return options.onReplyObligations()
|
|
158
|
+
.then((obligations) => Response.json(replyObligationSummary(obligations)))
|
|
159
|
+
.catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname === `/reply-obligations/${options.stopObligationPath ?? "turn-stop"}` && req.method === "GET") {
|
|
162
|
+
if (!options.onReplyObligations) return Response.json({});
|
|
163
|
+
return options.onReplyObligations()
|
|
164
|
+
.then((obligations) => Response.json(replyObligationStopDecision(obligations)))
|
|
165
|
+
.catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
|
|
166
|
+
}
|
|
167
|
+
if (url.pathname === "/permissions/request" && req.method === "POST") {
|
|
168
|
+
return handlePermissionRequest(req, options, pendingPermissionRequests);
|
|
169
|
+
}
|
|
170
|
+
if (url.pathname === "/subagent-usage" && req.method === "POST") {
|
|
171
|
+
return req.json().then(async (body) => {
|
|
172
|
+
if (!isRecord(body) || typeof body.agent_transcript_path !== "string" || !body.agent_transcript_path) return Response.json({ error: "agent_transcript_path required" }, { status: 400 });
|
|
173
|
+
if (!options.onSubagentUsage) return Response.json({ error: "sub-agent usage capture unavailable" }, { status: 503 });
|
|
174
|
+
await options.onSubagentUsage(body.agent_transcript_path);
|
|
175
|
+
return Response.json({ ok: true });
|
|
176
|
+
}).catch((error) => Response.json({ error: errMessage(error) }, { status: 500 }));
|
|
177
|
+
}
|
|
178
|
+
if (url.pathname === "/session-turn" && req.method === "POST") {
|
|
179
|
+
return handleSessionTurn(req, options);
|
|
180
|
+
}
|
|
181
|
+
if (url.pathname === "/user-prompt" && req.method === "POST") {
|
|
182
|
+
return handleUserPrompt(req, url, options);
|
|
183
|
+
}
|
|
184
|
+
if (url.pathname === "/hook-check" && req.method === "POST") {
|
|
185
|
+
options.onHookCheck?.(hookFiredAt(url));
|
|
186
|
+
return Response.json({ ok: Boolean(options.onHookCheck) });
|
|
187
|
+
}
|
|
188
|
+
if (url.pathname === "/tool-use" && req.method === "POST") {
|
|
189
|
+
return req.json().then(async (body) => {
|
|
190
|
+
const content = await options.onToolUse?.(body, AbortSignal.any([req.signal, AbortSignal.timeout(3_500)]));
|
|
191
|
+
return new Response(content ?? "");
|
|
192
|
+
}).catch((error) => Response.json({ error: errMessage(error) }, { status: 400 }));
|
|
193
|
+
}
|
|
194
|
+
if (url.pathname === "/session-boundary" && req.method === "POST") {
|
|
195
|
+
return handleSessionBoundary(req, options);
|
|
196
|
+
}
|
|
197
|
+
if (url.pathname === "/session-start" && req.method === "POST") {
|
|
198
|
+
return req.json().then((body) => new Response(isRecord(body) ? options.onSessionStart?.({
|
|
199
|
+
source: typeof body.source === "string" ? body.source : undefined,
|
|
200
|
+
providerSessionId: typeof body.providerSessionId === "string" ? body.providerSessionId : undefined,
|
|
201
|
+
}) ?? "" : "")).catch(() => new Response("", { status: 400 }));
|
|
202
|
+
}
|
|
203
|
+
if (url.pathname === "/log-level" && req.method === "GET") {
|
|
204
|
+
return Response.json({ level: logger.getLevel(), levels: LOG_LEVELS });
|
|
205
|
+
}
|
|
206
|
+
if (url.pathname === "/log-level" && req.method === "POST") {
|
|
207
|
+
return handleLogLevel(req);
|
|
208
|
+
}
|
|
209
|
+
if (url.pathname === "/hook-fatal" && req.method === "POST") {
|
|
210
|
+
return handleHookFatal(req, options);
|
|
211
|
+
}
|
|
212
|
+
if (url.pathname === "/rate-limit" && req.method === "POST") {
|
|
213
|
+
return handleRateLimit(req, options);
|
|
214
|
+
}
|
|
215
|
+
if (url.pathname === "/monitor") {
|
|
216
|
+
const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
|
|
217
|
+
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
218
|
+
}
|
|
219
|
+
return new Response("not found", { status: 404 });
|
|
220
|
+
},
|
|
221
|
+
websocket: {
|
|
222
|
+
open(ws) {
|
|
223
|
+
monitors.add(ws);
|
|
224
|
+
},
|
|
225
|
+
message(_ws, raw) {
|
|
226
|
+
const parsed = parseJson(raw);
|
|
227
|
+
if (parsed?.type === "message.delivered" && Array.isArray(parsed.messageIds)) {
|
|
228
|
+
const deliveryId = typeof parsed.deliveryId === "string" ? parsed.deliveryId : "";
|
|
229
|
+
const pending = pendingDeliveries.get(deliveryId);
|
|
230
|
+
if (pending) {
|
|
231
|
+
clearTimeout(pending.timer);
|
|
232
|
+
pendingDeliveries.delete(deliveryId);
|
|
233
|
+
pending.resolve(parsed.messageIds.filter((id): id is number => Number.isSafeInteger(id)));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
close(ws) {
|
|
238
|
+
monitors.delete(ws);
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const port = server.port;
|
|
244
|
+
if (port === undefined) throw new Error("runner control server did not bind a port");
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
port,
|
|
248
|
+
url: `http://127.0.0.1:${port}`,
|
|
249
|
+
stop() {
|
|
250
|
+
for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
|
|
251
|
+
pendingDeliveries.clear();
|
|
252
|
+
for (const pending of pendingPermissionRequests.values()) clearTimeout(pending.timer);
|
|
253
|
+
for (const [approvalId, pending] of pendingPermissionRequests.entries()) {
|
|
254
|
+
const decision = withDecisionReason({
|
|
255
|
+
approvalId,
|
|
256
|
+
decision: "deny",
|
|
257
|
+
reason: PERMISSION_STOP_REASON,
|
|
258
|
+
}, "dismissed");
|
|
259
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
260
|
+
pending.resolve(decision);
|
|
261
|
+
}
|
|
262
|
+
pendingPermissionRequests.clear();
|
|
263
|
+
server.stop(true);
|
|
264
|
+
},
|
|
265
|
+
async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
|
|
266
|
+
if (monitors.size === 0) throw new Error(options.monitorUnavailableMessage ?? "no provider monitor connected");
|
|
267
|
+
const deliveryId = crypto.randomUUID();
|
|
268
|
+
const payload = JSON.stringify({ type: "message.deliver", deliveryId, messages });
|
|
269
|
+
return await new Promise<number[]>((resolve, reject) => {
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
pendingDeliveries.delete(deliveryId);
|
|
272
|
+
reject(new Error("monitor delivery timed out"));
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
pendingDeliveries.set(deliveryId, { resolve, timer });
|
|
275
|
+
for (const monitor of monitors) monitor.send(payload);
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean {
|
|
279
|
+
const pending = pendingPermissionRequests.get(input.approvalId);
|
|
280
|
+
if (!pending) {
|
|
281
|
+
// Idempotent: a duplicate decision for an already-resolved approval is a
|
|
282
|
+
// benign no-op, not a dropped answer that fails the command (#693).
|
|
283
|
+
return resolvedApprovals.has(input.approvalId);
|
|
284
|
+
}
|
|
285
|
+
clearTimeout(pending.timer);
|
|
286
|
+
pendingPermissionRequests.delete(input.approvalId);
|
|
287
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
|
|
288
|
+
recordResolvedApproval(resolvedApprovals, input.approvalId);
|
|
289
|
+
pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
|
|
290
|
+
if (!pending.suppressResolvedStatus) {
|
|
291
|
+
options.onStatus({
|
|
292
|
+
status: "busy",
|
|
293
|
+
reason: "provider-turn",
|
|
294
|
+
providerState: {
|
|
295
|
+
state: "active",
|
|
296
|
+
reason: "permissionResolved",
|
|
297
|
+
label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
|
|
298
|
+
source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
|
|
299
|
+
updatedAt: Date.now(),
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return true;
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function recordResolvedApproval(resolvedApprovals: Set<string>, approvalId: string, limit = RESOLVED_APPROVAL_LIMIT): void {
|
|
309
|
+
resolvedApprovals.delete(approvalId);
|
|
310
|
+
resolvedApprovals.add(approvalId);
|
|
311
|
+
while (resolvedApprovals.size > limit) {
|
|
312
|
+
const oldest = resolvedApprovals.values().next().value;
|
|
313
|
+
if (!oldest) break;
|
|
314
|
+
resolvedApprovals.delete(oldest);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
|
|
319
|
+
const obligation = obligations[0];
|
|
320
|
+
if (!obligation) return { pending: false, count: 0 };
|
|
321
|
+
const subject = obligation.subject ? ` (${obligation.subject})` : "";
|
|
322
|
+
return {
|
|
323
|
+
pending: true,
|
|
324
|
+
count: obligations.length,
|
|
325
|
+
obligation,
|
|
326
|
+
reason: `Pending Agent Relay response for message #${obligation.messageId} from ${obligation.from}${subject}. If it still needs an answer, use ${relayReplyActionText(obligation.messageId)}. If multiple delivered messages are part of one current request, answer once to the latest relevant message. If the useful response was already delivered through Relay, do not send a separate status-only confirmation.`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function replyObligationStopDecision(obligations: ReplyObligation[]): Record<string, unknown> {
|
|
331
|
+
// #418 — never block the turn to nag for a `user` obligation: the operator already receives
|
|
332
|
+
// this turn via the session mirror, so an explicit /reply double-delivers. Only agent↔agent /
|
|
333
|
+
// spawner obligations (#413) gate the turn here; the mirror's replyTo-linking clears the user
|
|
334
|
+
// one. (The /reply-obligations summary endpoint is left unfiltered — the obligation IS pending
|
|
335
|
+
// until the mirror lands, so observability stays accurate.)
|
|
336
|
+
const enforceable = obligations.filter(obligationNagEligible);
|
|
337
|
+
const summary = replyObligationSummary(enforceable);
|
|
338
|
+
if (summary.pending !== true) return {};
|
|
339
|
+
return {
|
|
340
|
+
decision: "block",
|
|
341
|
+
reason: summary.reason,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function recoverStoredPermissionRequests(options: ControlServerOptions): void {
|
|
346
|
+
const store = options.pendingPermissionStore;
|
|
347
|
+
if (!store) return;
|
|
348
|
+
for (const record of bestEffortListPendingPermissionRequests(store)) {
|
|
349
|
+
bestEffortDeletePendingPermissionRequest(store, record.approvalId);
|
|
350
|
+
const decisionReason: ProviderPermissionDecisionReason = {
|
|
351
|
+
kind: Date.now() >= record.hookDeadlineAt ? "timeout" : "dismissed",
|
|
352
|
+
message: Date.now() >= record.hookDeadlineAt ? permissionTimeoutReason(record.view) : PERMISSION_STOP_REASON,
|
|
353
|
+
};
|
|
354
|
+
options.onPermissionAbandoned?.({ ...record, reason: decisionReason.message ?? PERMISSION_STOP_REASON, decisionReason });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function withDecisionReason(input: ProviderPermissionDecisionInput, kind: ProviderPermissionDecisionReason["kind"]): ProviderPermissionDecisionInput {
|
|
359
|
+
if (input.decisionReason) return input;
|
|
360
|
+
return {
|
|
361
|
+
...input,
|
|
362
|
+
decisionReason: {
|
|
363
|
+
kind,
|
|
364
|
+
...(input.reason ? { message: input.reason } : {}),
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function handlePermissionRequest(
|
|
370
|
+
req: Request,
|
|
371
|
+
options: ControlServerOptions,
|
|
372
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>,
|
|
373
|
+
): Promise<Response> {
|
|
374
|
+
const handler = options.permissionPromptHandler;
|
|
375
|
+
if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
|
|
376
|
+
const occurredAt = Date.now();
|
|
377
|
+
const body = await req.json().catch(() => null);
|
|
378
|
+
if (!isRecord(body)) return Response.json({});
|
|
379
|
+
const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
|
|
380
|
+
const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
|
|
381
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
|
|
382
|
+
const approvalId = crypto.randomUUID();
|
|
383
|
+
|
|
384
|
+
const probeView = handler.buildView(approvalId, body, true);
|
|
385
|
+
const answerInput = permissionRequestQuestionAnswers(body);
|
|
386
|
+
if (body.hook_event_name === "PermissionRequest" && probeView.kind === "questions" && answerInput) {
|
|
387
|
+
return Response.json(handler.buildHookResponse({ approvalId, decision: "answer", answers: answerInput }, body));
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
(body.hook_event_name === "PreToolUse" || body.hook_event_name === "PermissionRequest")
|
|
391
|
+
&& probeView.kind === "questions"
|
|
392
|
+
&& handler.buildAnswerInjectionPrompt
|
|
393
|
+
) {
|
|
394
|
+
const decision = await fileQuestionDecision(options, handler, body);
|
|
395
|
+
if (decision) return Response.json(handler.buildHookResponse({ approvalId, decision: "deny", reason: decisionFiledReason(decision.id) }, body));
|
|
396
|
+
return holdQuestionWithImmediateDeny({
|
|
397
|
+
options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (options.autoApprovePermissions) return autoApprovePermission(options, handler, approvalId, body, occurredAt);
|
|
402
|
+
const { decision } = await awaitPermissionDecision({
|
|
403
|
+
options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
|
|
404
|
+
blockedReason: "permissionRequest",
|
|
405
|
+
recommendedAction: "Review the permission request in Agent Relay.",
|
|
406
|
+
});
|
|
407
|
+
return Response.json(handler.buildHookResponse(decision, body));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// #3648 — bypass mode does not cover every provider check (Claude's dangerous-rm check on a variable
|
|
411
|
+
// target still asks). An open agent has no one to answer, so the request is approved, not held.
|
|
412
|
+
const OPEN_MODE_APPROVAL_REASON = "Auto-approved: the agent runs in open approval mode.";
|
|
413
|
+
|
|
414
|
+
async function autoApprovePermission(
|
|
415
|
+
options: ControlServerOptions,
|
|
416
|
+
handler: ProviderPermissionPromptHandler,
|
|
417
|
+
approvalId: string,
|
|
418
|
+
body: Record<string, unknown>,
|
|
419
|
+
occurredAt: number,
|
|
420
|
+
): Promise<Response> {
|
|
421
|
+
const decision: ProviderPermissionDecisionInput = { approvalId, decision: "approve", decisionReason: { kind: "answered", message: OPEN_MODE_APPROVAL_REASON } };
|
|
422
|
+
if (options.onPermissionResolved) {
|
|
423
|
+
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(handler.buildView(approvalId, body, true), decision, body, occurredAt))).catch(() => {});
|
|
424
|
+
}
|
|
425
|
+
return Response.json(handler.buildHookResponse(decision, body));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function permissionRequestQuestionAnswers(body: Record<string, unknown>): Record<string, string> | undefined {
|
|
429
|
+
if (body.hook_event_name !== "PermissionRequest") return undefined;
|
|
430
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
431
|
+
if (!isRecord(toolInput.answers)) return undefined;
|
|
432
|
+
const answers = Object.fromEntries(
|
|
433
|
+
Object.entries(toolInput.answers)
|
|
434
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0),
|
|
435
|
+
);
|
|
436
|
+
return Object.keys(answers).length ? answers : undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function fileQuestionDecision(options: ControlServerOptions, handler: ProviderPermissionPromptHandler, body: Record<string, unknown>): Promise<{ id: number } | undefined> {
|
|
440
|
+
const request = options.onQuestionDecision ? handler.buildDecisionRequest?.(body) : undefined;
|
|
441
|
+
return request ? options.onQuestionDecision!(request).catch(() => undefined) : undefined;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function decisionFiledReason(id: number): string {
|
|
445
|
+
return `Agent Relay filed this as operator decision #${id}; the user answers it on the Relay dashboard. Do not re-ask it and do not wait in this turn: the answer arrives later as a Relay message ("Decision #${id} answered"). Continue other work or end your turn. Next time, file questions with relay_decision directly.`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function holdQuestionWithImmediateDeny(params: {
|
|
449
|
+
options: ControlServerOptions;
|
|
450
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
|
|
451
|
+
handler: ProviderPermissionPromptHandler;
|
|
452
|
+
approvalId: string;
|
|
453
|
+
body: Record<string, unknown>;
|
|
454
|
+
occurredAt: number;
|
|
455
|
+
transcriptPath: string;
|
|
456
|
+
toolName?: string;
|
|
457
|
+
toolInput?: unknown;
|
|
458
|
+
}): Response {
|
|
459
|
+
const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput } = params;
|
|
460
|
+
// #1173/#1194: a fresh "questions" prompt (the provider's AskUserQuestion) must not block
|
|
461
|
+
// its own hook response while the human answers. Denying immediately makes the provider persist
|
|
462
|
+
// the buffered turn plus rejected tool_result, so the pre-flush harvest below can publish the
|
|
463
|
+
// reasoning before the dashboard form appears. The eventual answer is injected as a normal prompt.
|
|
464
|
+
const response = Response.json(handler.buildHookResponse(
|
|
465
|
+
{ approvalId, decision: "deny", reason: PENDING_QUESTION_DENY_REASON },
|
|
466
|
+
body,
|
|
467
|
+
));
|
|
468
|
+
void awaitPermissionDecision({
|
|
469
|
+
options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
|
|
470
|
+
blockedReason: "pendingQuestion",
|
|
471
|
+
recommendedAction: "Answer the question in Agent Relay.",
|
|
472
|
+
suppressResolvedStatus: true,
|
|
473
|
+
}).then(({ decision, view }) => decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length
|
|
474
|
+
? handler.buildAnswerInjectionPrompt!(view, decision, body)
|
|
475
|
+
: pendingQuestionDismissalPrompt(decision),
|
|
476
|
+
).then(
|
|
477
|
+
(text) => options.onAnswerInject?.({ approvalId, text }),
|
|
478
|
+
// #2913: the inject is the only release of the runner's question hold — never skip it.
|
|
479
|
+
() => options.onAnswerInject?.({ approvalId }),
|
|
480
|
+
).catch(() => {});
|
|
481
|
+
return response;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Shared by both the traditional block-until-decided flow and the #1173 deny-then-hold flow:
|
|
485
|
+
// pre-flushes narrative (#435/#1116), builds+persists the interactive view, publishes the
|
|
486
|
+
// blocked providerState, and resolves once a decision arrives (or the wait times out). Callers
|
|
487
|
+
// differ only in what happens with the resolved decision (build a hook response vs. inject an
|
|
488
|
+
// answer prompt), so that part is left to them.
|
|
489
|
+
async function awaitPermissionDecision(params: {
|
|
490
|
+
options: ControlServerOptions;
|
|
491
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
|
|
492
|
+
handler: ProviderPermissionPromptHandler;
|
|
493
|
+
approvalId: string;
|
|
494
|
+
body: Record<string, unknown>;
|
|
495
|
+
occurredAt: number;
|
|
496
|
+
transcriptPath: string;
|
|
497
|
+
toolName?: string;
|
|
498
|
+
toolInput?: unknown;
|
|
499
|
+
blockedReason: string;
|
|
500
|
+
recommendedAction: string;
|
|
501
|
+
suppressResolvedStatus?: boolean;
|
|
502
|
+
}): Promise<{ decision: ProviderPermissionDecisionInput; view: InteractivePrompt }> {
|
|
503
|
+
const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput, blockedReason, recommendedAction, suppressResolvedStatus } = params;
|
|
504
|
+
// #435/#1116: flush any un-mirrored narrative before the blocking form appears in
|
|
505
|
+
// chat. The adapter owns how to settle its capture source; the transport only
|
|
506
|
+
// forwards the hook payload as an anchor and stamps the returned truth value.
|
|
507
|
+
let reasoningSettled = true;
|
|
508
|
+
// Defaults to the hook's own wall-clock receipt time; refined below to the transcript's own
|
|
509
|
+
// tool_use event time when the pre-flush harvest can resolve it (#1185) — the hook can receive
|
|
510
|
+
// before the provider flushes/timestamps that entry, which otherwise sorts the resolved prompt
|
|
511
|
+
// ahead of the narration that led up to it.
|
|
512
|
+
let resolvedOccurredAt = occurredAt;
|
|
513
|
+
if (transcriptPath && options.onSessionTurn) {
|
|
514
|
+
const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
|
|
515
|
+
if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
|
|
516
|
+
if (result && typeof result.toolUseOccurredAt === "number") resolvedOccurredAt = result.toolUseOccurredAt;
|
|
517
|
+
}
|
|
518
|
+
const view = handler.buildView(approvalId, body, reasoningSettled);
|
|
519
|
+
const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
|
|
520
|
+
const hookDeadlineAt = occurredAt + waitMs;
|
|
521
|
+
bestEffortUpsertPendingPermissionRequest(options.pendingPermissionStore, {
|
|
522
|
+
approvalId,
|
|
523
|
+
view,
|
|
524
|
+
hookDeadlineAt,
|
|
525
|
+
ownerInstanceId: options.instanceId ?? "",
|
|
526
|
+
occurredAt,
|
|
527
|
+
});
|
|
528
|
+
options.onStatus({
|
|
529
|
+
status: "busy",
|
|
530
|
+
reason: "provider-turn",
|
|
531
|
+
providerState: {
|
|
532
|
+
state: "blocked",
|
|
533
|
+
reason: blockedReason,
|
|
534
|
+
label: view.title,
|
|
535
|
+
recommendedAction,
|
|
536
|
+
source: handler.source,
|
|
537
|
+
pendingApproval: view,
|
|
538
|
+
updatedAt: Date.now(),
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
|
|
543
|
+
const timer = setTimeout(() => {
|
|
544
|
+
pendingPermissionRequests.delete(approvalId);
|
|
545
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
546
|
+
if (!suppressResolvedStatus) {
|
|
547
|
+
options.onStatus({
|
|
548
|
+
status: "busy",
|
|
549
|
+
reason: "provider-turn",
|
|
550
|
+
providerState: {
|
|
551
|
+
state: "active",
|
|
552
|
+
reason: "permissionTimedOut",
|
|
553
|
+
label: "Provider approval timed out",
|
|
554
|
+
source: handler.source,
|
|
555
|
+
updatedAt: Date.now(),
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
resolve(withDecisionReason({ approvalId, decision: "deny", reason: permissionTimeoutReason(view) }, "timeout"));
|
|
560
|
+
}, waitMs);
|
|
561
|
+
pendingPermissionRequests.set(approvalId, { resolve, timer, suppressResolvedStatus });
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// A timeout is a resolved prompt too. Persist it with its distinct timeout reason so
|
|
565
|
+
// the coordinator can see how the hold ended instead of only watching it disappear.
|
|
566
|
+
if (options.onPermissionResolved) {
|
|
567
|
+
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, resolvedOccurredAt))).catch(() => {});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { decision, view };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function pendingQuestionDismissalPrompt(decision: ProviderPermissionDecisionInput): string {
|
|
574
|
+
const timedOut = decision.decisionReason?.kind === "timeout";
|
|
575
|
+
const event = timedOut ? "timed out before the user answered" : "was dismissed before the user answered";
|
|
576
|
+
return `Your previous AskUserQuestion ${event}. Proceed on your best judgment, or re-ask if you still need a decision.`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
580
|
+
if (!options.onSessionTurn) return Response.json({ ok: false, reason: "session capture unavailable" });
|
|
581
|
+
const body = await req.json().catch(() => null);
|
|
582
|
+
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
|
|
583
|
+
const lastAssistantMessage = isRecord(body) ? body.lastAssistantMessage : undefined;
|
|
584
|
+
const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
|
|
585
|
+
// transcriptPath is the primary source, but it can be absent (known CC Stop edge case).
|
|
586
|
+
// lastAssistantMessage is the fallback for exactly that — accept either; reject only when
|
|
587
|
+
// both are missing so the turn still reaches the dashboard chat (#264).
|
|
588
|
+
if (!transcriptPath && lastAssistantMessage === undefined) {
|
|
589
|
+
return Response.json({ ok: false, reason: "transcriptPath or lastAssistantMessage required" }, { status: 400 });
|
|
590
|
+
}
|
|
591
|
+
// Awaited on purpose: the Stop hook posts this synchronously before its
|
|
592
|
+
// reply-obligation check, so the captured turn must be persisted (and the
|
|
593
|
+
// obligation cleared) before this response returns.
|
|
594
|
+
try {
|
|
595
|
+
await options.onSessionTurn({ transcriptPath, lastAssistantMessage, ...(promptId ? { promptId } : {}) });
|
|
596
|
+
return Response.json({ ok: true });
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return Response.json({ ok: false, reason: errMessage(error) }, { status: 500 });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** `?firedAt=<epoch ms>`: when the hook ran, stamped by the hook itself (#2664). */
|
|
603
|
+
function hookFiredAt(url: URL): { firedAt?: number } {
|
|
604
|
+
const raw = url.searchParams.get("firedAt") ?? "";
|
|
605
|
+
const firedAt = /^\d{1,16}$/.test(raw) ? Number(raw) : NaN;
|
|
606
|
+
return Number.isSafeInteger(firedAt) ? { firedAt } : {};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// The body is the provider hook's raw stdin payload (Claude: prompt/transcript_path/prompt_id).
|
|
610
|
+
async function handleUserPrompt(req: Request, url: URL, options: ControlServerOptions): Promise<Response> {
|
|
611
|
+
if (!options.onUserPrompt) return Response.json({ ok: false, reason: "prompt echo unavailable" });
|
|
612
|
+
const body = await req.json().catch(() => null);
|
|
613
|
+
const field = (key: string) => isRecord(body) && typeof body[key] === "string" && body[key] ? body[key] as string : undefined;
|
|
614
|
+
const prompt = field("prompt") ?? "";
|
|
615
|
+
if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
|
|
616
|
+
const transcriptPath = field("transcript_path");
|
|
617
|
+
const promptId = field("prompt_id");
|
|
618
|
+
const { firedAt } = hookFiredAt(url);
|
|
619
|
+
// Fire-and-forget: the hook must not block the provider's turn on relay round-trips.
|
|
620
|
+
void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath, ...(promptId ? { promptId } : {}), ...(firedAt !== undefined ? { firedAt } : {}) })).catch(() => {});
|
|
621
|
+
return Response.json({ ok: true });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function handleSessionBoundary(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
625
|
+
if (!options.onSessionBoundary) return Response.json({ ok: false, reason: "session-boundary capture unavailable" });
|
|
626
|
+
const body = await req.json().catch(() => null);
|
|
627
|
+
const reason = isRecord(body) && typeof body.reason === "string" ? body.reason : undefined;
|
|
628
|
+
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
|
|
629
|
+
// Fire-and-forget: a lifecycle hook must not block the provider compacting or exiting.
|
|
630
|
+
void Promise.resolve(options.onSessionBoundary({ reason, transcriptPath })).catch(() => {});
|
|
631
|
+
return Response.json({ ok: true });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function handleLogLevel(req: Request): Promise<Response> {
|
|
635
|
+
const body = await req.json().catch(() => null);
|
|
636
|
+
const level = parseLogLevel(isRecord(body) && typeof body.level === "string" ? body.level : undefined);
|
|
637
|
+
if (!level) return Response.json({ error: `level must be one of: ${LOG_LEVELS.join(", ")}` }, { status: 400 });
|
|
638
|
+
const previous = logger.getLevel();
|
|
639
|
+
logger.setLevel(level);
|
|
640
|
+
logger.info("logger", `log level set to ${level} (was ${previous}) via control port`);
|
|
641
|
+
return Response.json({ ok: true, level, previous });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function handleHookFatal(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
645
|
+
const body = await req.json().catch(() => null);
|
|
646
|
+
const hook = isRecord(body) && typeof body.hook === "string" && body.hook.trim() ? body.hook.trim() : "unknown";
|
|
647
|
+
const error = isRecord(body) && typeof body.error === "string" ? body.error : "(no detail)";
|
|
648
|
+
// Never silent: a hook that couldn't handle its own failure lands here as FATAL.
|
|
649
|
+
logger.fatal(`hook:${hook}`, error);
|
|
650
|
+
try { options.onHookFatal?.({ hook, error }); } catch { /* reporting must never throw back at the hook */ }
|
|
651
|
+
return Response.json({ ok: true });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
655
|
+
const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
|
|
656
|
+
const status = body?.status;
|
|
657
|
+
if (status !== "idle" && status !== "busy" && status !== "offline" && status !== "error") {
|
|
658
|
+
return Response.json({ error: "status must be idle, busy, offline, or error" }, { status: 400 });
|
|
659
|
+
}
|
|
660
|
+
const reason = body?.reason;
|
|
661
|
+
if (reason !== undefined && reason !== "provider-turn" && reason !== "subagent" && reason !== "background-script") {
|
|
662
|
+
return Response.json({ error: "reason must be provider-turn, subagent, or background-script" }, { status: 400 });
|
|
663
|
+
}
|
|
664
|
+
const isWorkKind = (item: unknown): item is "provider-turn" | "subagent" | "background-script" =>
|
|
665
|
+
item === "provider-turn" || item === "subagent" || item === "background-script";
|
|
666
|
+
const clear = Array.isArray(body?.clear) ? body.clear.filter(isWorkKind) : undefined;
|
|
667
|
+
// #650: optional pids the runner reconciles against process liveness so a tracked
|
|
668
|
+
// command/shell killed out-of-band clears the busy marker instead of stranding it.
|
|
669
|
+
const pidsInput = Array.isArray((body as { pids?: unknown })?.pids) ? (body as { pids: unknown[] }).pids : undefined;
|
|
670
|
+
const pids = pidsInput
|
|
671
|
+
?.map((p) => (typeof p === "number" ? p : Number(p)))
|
|
672
|
+
.filter((p) => Number.isInteger(p) && p > 0);
|
|
673
|
+
const timeline = statusTimelineEvent(body);
|
|
674
|
+
const update: ProviderStatusEvent = {
|
|
675
|
+
status,
|
|
676
|
+
...(typeof body?.providerSessionId === "string" ? { providerSessionId: body.providerSessionId } : {}),
|
|
677
|
+
...(reason ? { reason } : {}),
|
|
678
|
+
...(clear?.length ? { clear: [...new Set(clear)] } : {}),
|
|
679
|
+
...(timeline ? { timeline } : {}),
|
|
680
|
+
...(typeof body?.id === "string" ? { id: body.id } : {}),
|
|
681
|
+
...(pids?.length ? { pids } : {}),
|
|
682
|
+
...(typeof body?.label === "string" ? { label: body.label } : {}),
|
|
683
|
+
...(typeof body?.role === "string" ? { role: body.role } : {}),
|
|
684
|
+
...(typeof body?.parentId === "string" ? { parentId: body.parentId } : {}),
|
|
685
|
+
...(body?.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? { metadata: body.metadata as Record<string, unknown> } : {}),
|
|
686
|
+
};
|
|
687
|
+
options.onStatus(update);
|
|
688
|
+
return Response.json({ ok: true, ...update });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// #286: the stop-failure hook reports a usage/rate-limit stall here. Build the
|
|
692
|
+
// provider-neutral `blocked` state (reason rate_limit) so the dashboard shows it
|
|
693
|
+
// distinctly (never as idle/available) and the relay's resume sweep can lift it
|
|
694
|
+
// at reset. The agent stays `idle` underneath (the turn truly ended); the runner
|
|
695
|
+
// carries the hold via its rateLimitHold field, not an active-work claim.
|
|
696
|
+
async function handleRateLimit(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
697
|
+
const body = await req.json().catch(() => null);
|
|
698
|
+
if (!isRecord(body)) return Response.json({ error: "invalid body" }, { status: 400 });
|
|
699
|
+
const errorType = typeof body.errorType === "string" && body.errorType ? body.errorType : "rate_limit";
|
|
700
|
+
// CC's reset_time is unix SECONDS ("if available"); normalize to ms for the relay.
|
|
701
|
+
const resetTimeSec = typeof body.resetTime === "number" && Number.isFinite(body.resetTime) ? body.resetTime : undefined;
|
|
702
|
+
const resetAt = resetTimeSec !== undefined ? Math.round(resetTimeSec * 1000) : undefined;
|
|
703
|
+
const errorMessage = typeof body.errorMessage === "string" && body.errorMessage ? body.errorMessage : undefined;
|
|
704
|
+
options.onStatus({
|
|
705
|
+
status: "idle",
|
|
706
|
+
clear: ["subagent"],
|
|
707
|
+
providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: options.providerSourceId ?? "provider" }),
|
|
708
|
+
});
|
|
709
|
+
return Response.json({ ok: true, errorType, ...(resetAt ? { resetAt } : {}) });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function statusTimelineEvent(body: Partial<ProviderStatusEvent> | null): ProviderStatusEvent["timeline"] | undefined {
|
|
713
|
+
const timeline = body?.timeline;
|
|
714
|
+
if (!timeline || typeof timeline !== "object" || Array.isArray(timeline)) return undefined;
|
|
715
|
+
if (typeof timeline.status !== "string" || !timeline.status) return undefined;
|
|
716
|
+
return {
|
|
717
|
+
status: timeline.status,
|
|
718
|
+
...(typeof timeline.id === "string" ? { id: timeline.id } : {}),
|
|
719
|
+
...(typeof timeline.timestamp === "number" && Number.isFinite(timeline.timestamp) ? { timestamp: timeline.timestamp } : {}),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function parseJson(raw: string | Buffer): Record<string, unknown> | null {
|
|
724
|
+
try {
|
|
725
|
+
const parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString());
|
|
726
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
|
727
|
+
} catch {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
}
|