@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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral safe-summary + secret redaction for the one-line tool-activity row.
|
|
3
|
+
*
|
|
4
|
+
* Claude (`summarizeToolUse`), Codex (`codexToolSummary`) and OpenCode (`opencode-tool-events`)
|
|
5
|
+
* all render the SAME discreet activity row (TraceView `ToolCallRow`), and each had — or would
|
|
6
|
+
* have added — its own copy of "collapse whitespace → clip to ~200 chars". That is the exact
|
|
7
|
+
* duplicate-helper class this repo's DRY ratchet guards against, and neither existing copy
|
|
8
|
+
* redacted secrets. This is the single home: one-line, redact, clip. Adapters keep only their
|
|
9
|
+
* own field/category mapping and call in here for the safe text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isAbsolute, resolve } from "node:path";
|
|
13
|
+
import { isRecord, stringValue } from "@humwick/sdk";
|
|
14
|
+
|
|
15
|
+
/** The shared activity-row budget. A summary LONGER than this is clipped to 197 chars + a
|
|
16
|
+
* one-char ellipsis — byte-identical to the pre-existing Claude/Codex clip (`length > 200`), so an
|
|
17
|
+
* existing 199/200-char summary stays unchanged and no row shifts. */
|
|
18
|
+
const SUMMARY_MAX_CHARS = 200;
|
|
19
|
+
const SUMMARY_CLIP_CHARS = 197;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Candidate arg fields, in priority order, that carry a human-meaningful one-line summary of a
|
|
23
|
+
* tool call across providers. Covers Claude's snake_case input keys and the camelCase variants
|
|
24
|
+
* OpenCode's built-in tools use (`filePath`), so one list serves every adapter.
|
|
25
|
+
*/
|
|
26
|
+
const SUMMARY_CANDIDATE_KEYS = [
|
|
27
|
+
"command",
|
|
28
|
+
"file_path",
|
|
29
|
+
"filePath",
|
|
30
|
+
"path",
|
|
31
|
+
"pattern",
|
|
32
|
+
"query",
|
|
33
|
+
"url",
|
|
34
|
+
"description",
|
|
35
|
+
"prompt",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/** Collapse all whitespace runs to single spaces and trim. Non-strings become "". */
|
|
39
|
+
export function oneLineText(value: unknown): string {
|
|
40
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Best-effort redaction of credential-shaped substrings before a tool's command/args land durably
|
|
45
|
+
* in the chat mirror / a report-up. This is NOT a proof that no secret can ever leak — a one-line
|
|
46
|
+
* free-text summary of arbitrary tool input cannot make that guarantee — so it layers three
|
|
47
|
+
* conservative, structural nets and errs toward over-redaction (a real secret surfacing is always
|
|
48
|
+
* the worse outcome, #2354):
|
|
49
|
+
* 1. positional shapes — URL basic-auth, `Authorization: Bearer/Basic …`;
|
|
50
|
+
* 2. known provider key prefixes — OpenRouter/Anthropic/OpenAI `sk-…`, GitHub `gh?_`, Slack
|
|
51
|
+
* `xox…`, AWS `AKIA…`;
|
|
52
|
+
* 3. labelled assignments — any KEY/TOKEN/SECRET/PASSWORD/CREDENTIAL-ish name (bare `KEY=` too),
|
|
53
|
+
* with a quoted value (whitespace/newlines allowed inside quotes) or a bare value;
|
|
54
|
+
* 4. dotted base64url tokens — a JWT (header-anchored: any `eyJ…`-led dotted run, redacted whole)
|
|
55
|
+
* or a high-entropy `id.secret` pair, which the single-run backstop below can't see because a
|
|
56
|
+
* `.` is not a legal token terminator;
|
|
57
|
+
* 5. unlabelled high-entropy tokens — a long mixed upper/lower/digit run, or a base64 run, that
|
|
58
|
+
* isn't a plain lowercase-hex hash/UUID.
|
|
59
|
+
* Ordinary paths, lowercase-hex SHAs, UUIDs, and version strings are deliberately left readable.
|
|
60
|
+
*/
|
|
61
|
+
// A name is "sensitive" when it contains one of these words (so bare `KEY`, `API_KEY`, `authToken`,
|
|
62
|
+
// `db_password` all match). `auth` is intentionally absent — `Authorization:` is handled by the
|
|
63
|
+
// bearer/basic rule, and `auth_token`/`authToken` already match via `token`.
|
|
64
|
+
const SENSITIVE_NAME = "(?:key|token|secret|password|passwd|passphrase|credential)";
|
|
65
|
+
|
|
66
|
+
export function redactSecrets(text: string): string {
|
|
67
|
+
if (!text) return text;
|
|
68
|
+
let out = text;
|
|
69
|
+
// 1a. URL basic-auth: scheme://user:password@host → scheme://[redacted]@host
|
|
70
|
+
out = out.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, "$1[redacted]@");
|
|
71
|
+
// 1b. Authorization headers / bearer + basic tokens.
|
|
72
|
+
out = out.replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]");
|
|
73
|
+
// 2. Known provider key prefixes.
|
|
74
|
+
out = out.replace(/\bsk-(?:or|ant|proj|live|test)-[A-Za-z0-9_-]{8,}/gi, "[redacted-key]");
|
|
75
|
+
out = out.replace(/\bsk-[A-Za-z0-9]{16,}/g, "[redacted-key]");
|
|
76
|
+
out = out.replace(/\bgh[pousr]_[A-Za-z0-9]{16,}/g, "[redacted-key]");
|
|
77
|
+
out = out.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}/gi, "[redacted-key]");
|
|
78
|
+
out = out.replace(/\bAKIA[0-9A-Z]{12,}/g, "[redacted-key]");
|
|
79
|
+
// 3a. Labelled assignment with a QUOTED value — whitespace/newlines allowed inside the quotes.
|
|
80
|
+
out = out.replace(
|
|
81
|
+
new RegExp(`([A-Za-z0-9_.\\-]*${SENSITIVE_NAME}[A-Za-z0-9_.\\-]*["']?\\s*[=:]\\s*)(["'])(?:\\\\.|(?!\\2)[\\s\\S])*\\2`, "gi"),
|
|
82
|
+
"$1$2[redacted]$2",
|
|
83
|
+
);
|
|
84
|
+
// 3b. Labelled assignment with a BARE (unquoted) value — up to the next whitespace/delimiter.
|
|
85
|
+
out = out.replace(
|
|
86
|
+
new RegExp(`([A-Za-z0-9_.\\-]*${SENSITIVE_NAME}[A-Za-z0-9_.\\-]*["']?\\s*[=:]\\s*)[^\\s"'&,;}]+`, "gi"),
|
|
87
|
+
"$1[redacted]",
|
|
88
|
+
);
|
|
89
|
+
// 4. Dotted base64url tokens the single-run backstop (rule 5) cannot see: a `.` is not a legal
|
|
90
|
+
// token terminator there, so a JWT / dotted `id.secret` slips through whole. URL delimiters
|
|
91
|
+
// (`/ ? &`) count as boundaries too, so a query-string- or path-embedded token stays bounded.
|
|
92
|
+
// 4a. JWT — anchored on the HEADER, not on segment lengths. A compact JWS/JWT always begins
|
|
93
|
+
// `eyJ` (base64url of `{"`); once that header is present, redact the ENTIRE dotted run
|
|
94
|
+
// wholesale regardless of how short the payload/signature segments are (a `{}`-payload is the
|
|
95
|
+
// valid 3-char segment `e30`) or whether a trailing signature is empty. The header IS the
|
|
96
|
+
// signal, so no entropy check — a JWT is a credential whatever its later segments look like.
|
|
97
|
+
out = out.replace(
|
|
98
|
+
/(^|[\s=:"'(,[/?&])(eyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*){1,4})(?=$|[\s"')\],;/?&])/g,
|
|
99
|
+
"$1[redacted-token]",
|
|
100
|
+
);
|
|
101
|
+
// 4b. Generic dotted `id.secret` pair — each dot-joined segment >=12 chars AND the run
|
|
102
|
+
// high-entropy (mixed upper+lower+digit), so a clearly credential-shaped pair like a 15.15
|
|
103
|
+
// run is caught, while dotted hostnames, package names, versions and file paths (short and/or
|
|
104
|
+
// low-entropy segments — none carry the upper+lower+digit combo) stay readable.
|
|
105
|
+
out = out.replace(
|
|
106
|
+
/(^|[\s=:"'(,[/?&])([A-Za-z0-9_-]{12,}(?:\.[A-Za-z0-9_-]{12,}){1,2})(?=$|[\s"')\],;/?&])/g,
|
|
107
|
+
(match, pre: string, tok: string) => {
|
|
108
|
+
const bare = tok.replace(/\./g, "");
|
|
109
|
+
const highEntropy = /[A-Z]/.test(bare) && /[a-z]/.test(bare) && /[0-9]/.test(bare);
|
|
110
|
+
return highEntropy ? `${pre}[redacted-token]` : match;
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
// 5. Unlabelled high-entropy token backstop: a >=24-char base64url run that is either mixed
|
|
114
|
+
// upper+lower+digit or carries base64 markers (+//). A plain lowercase-hex or all-lowercase
|
|
115
|
+
// run (git SHA, UUID) is left alone so ordinary identifiers stay readable.
|
|
116
|
+
out = out.replace(/(^|[\s=:"'(,[&?])([A-Za-z0-9_+/-]{24,}={0,2})(?=$|[\s"')\],;&?])/g, (match, pre: string, tok: string) => {
|
|
117
|
+
const highEntropy = (/[A-Z]/.test(tok) && /[a-z]/.test(tok) && /[0-9]/.test(tok)) || /[+/]/.test(tok);
|
|
118
|
+
return highEntropy ? `${pre}[redacted-token]` : match;
|
|
119
|
+
});
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Redact then clip to the shared activity-row budget. The single exit through which every
|
|
124
|
+
* adapter's summary text passes, so redaction can never be skipped. Clips only when the redacted
|
|
125
|
+
* text is LONGER than the budget (`> 200`), byte-identical to the pre-existing Claude/Codex clip. */
|
|
126
|
+
export function clipToolSummary(text: string): string {
|
|
127
|
+
const redacted = redactSecrets(text);
|
|
128
|
+
return redacted.length > SUMMARY_MAX_CHARS ? `${redacted.slice(0, SUMMARY_CLIP_CHARS)}…` : redacted;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pick the first present candidate arg field from a tool's input and return its one-line value,
|
|
133
|
+
* or "" when none is present. `extraKeys` lets an adapter prepend provider-specific field names
|
|
134
|
+
* ahead of the shared list without re-implementing the scan.
|
|
135
|
+
*/
|
|
136
|
+
export function summarizeToolInput(input: Record<string, unknown> | undefined, extraKeys: readonly string[] = []): string {
|
|
137
|
+
if (!input) return "";
|
|
138
|
+
for (const key of [...extraKeys, ...SUMMARY_CANDIDATE_KEYS]) {
|
|
139
|
+
const value = input[key];
|
|
140
|
+
if (typeof value === "string" && value.trim()) return oneLineText(value);
|
|
141
|
+
}
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// #2437 — the structured-params sibling of the one-line summary above: the FULL tool-call
|
|
146
|
+
// input/arguments, redacted then size-capped for the dashboard trace's expandable detail (the
|
|
147
|
+
// one-line `body` stays a short label; this is the "show me everything" view). Own budget, own
|
|
148
|
+
// key list — deliberately separate constants from the one-line summary's so tuning one never
|
|
149
|
+
// silently reshapes the other.
|
|
150
|
+
const PARAMS_DEFAULT_MAX_BYTES = 3200;
|
|
151
|
+
const PARAMS_TRUNCATION_MARKER = "…[truncated]";
|
|
152
|
+
// Field names that get their VALUE wholesale-redacted regardless of shape (a bare `[redacted]`,
|
|
153
|
+
// not run through redactSecrets — the key name alone is reason enough not to show any of it).
|
|
154
|
+
const SENSITIVE_PARAM_KEY = /token|secret|password|key|authorization/i;
|
|
155
|
+
const PARAMS_MAX_DEPTH = 12;
|
|
156
|
+
|
|
157
|
+
function redactParamsValue(value: unknown, depth: number): unknown {
|
|
158
|
+
if (depth > PARAMS_MAX_DEPTH) return "[redacted-depth]";
|
|
159
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
160
|
+
if (Array.isArray(value)) return value.map((entry) => redactParamsValue(entry, depth + 1));
|
|
161
|
+
if (value && typeof value === "object") {
|
|
162
|
+
const out: Record<string, unknown> = {};
|
|
163
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
164
|
+
out[key] = SENSITIVE_PARAM_KEY.test(key) ? "[redacted]" : redactParamsValue(entry, depth + 1);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Redact then size-cap a tool call's raw input/arguments for the dashboard trace's expandable
|
|
173
|
+
* detail (#2437). Redaction runs FIRST, over the whole structured value — every string passes
|
|
174
|
+
* through the shared `redactSecrets` net, and any object key matching
|
|
175
|
+
* `/token|secret|password|key|authorization/i` is replaced wholesale — so a later byte-budget cut
|
|
176
|
+
* can never land mid-secret. Only THEN is the redacted value serialized and size-capped: within
|
|
177
|
+
* `maxBytes` it is returned as the still-structured (object/array/primitive) value so the caller's
|
|
178
|
+
* JSON viewer pretty-prints it; over budget, the serialized text is sliced to `maxBytes` UTF-8
|
|
179
|
+
* bytes and a trailing `…[truncated]` marker appended, returned as a plain string. `undefined`/
|
|
180
|
+
* `null` input passes through unchanged so a tool with no captured input round-trips cleanly.
|
|
181
|
+
*/
|
|
182
|
+
export function redactAndCapParams(input: unknown, maxBytes: number = PARAMS_DEFAULT_MAX_BYTES): unknown {
|
|
183
|
+
if (input === undefined || input === null) return input;
|
|
184
|
+
const redacted = redactParamsValue(input, 0);
|
|
185
|
+
let serialized: string;
|
|
186
|
+
try {
|
|
187
|
+
serialized = JSON.stringify(redacted) ?? "";
|
|
188
|
+
} catch {
|
|
189
|
+
return "[unserializable]";
|
|
190
|
+
}
|
|
191
|
+
if (Buffer.byteLength(serialized, "utf8") <= maxBytes) return redacted;
|
|
192
|
+
const truncated = Buffer.from(serialized, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
193
|
+
return `${truncated}${PARAMS_TRUNCATION_MARKER}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** #3411 — the absolute file paths among a tool's raw input values: a relative path resolves
|
|
197
|
+
* against `cwd` when one is known and is dropped otherwise. Undefined when none remain. */
|
|
198
|
+
export function absoluteToolPaths(candidates: readonly unknown[], cwd?: string): string[] | undefined {
|
|
199
|
+
const paths: string[] = [];
|
|
200
|
+
for (const candidate of candidates) {
|
|
201
|
+
if (typeof candidate !== "string" || !candidate.trim()) continue;
|
|
202
|
+
if (isAbsolute(candidate)) paths.push(candidate);
|
|
203
|
+
else if (cwd) paths.push(resolve(cwd, candidate));
|
|
204
|
+
}
|
|
205
|
+
return paths.length ? paths : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** #3508 — the files a Codex shell command reads: Codex parses each read into `commandActions`
|
|
209
|
+
* ({ type: "read", path }), so a read touches its file's area just as an edit does. */
|
|
210
|
+
export function codexReadPaths(item: Record<string, unknown>, cwd?: string): string[] | undefined {
|
|
211
|
+
const actions = Array.isArray(item.commandActions) ? item.commandActions.filter(isRecord) : [];
|
|
212
|
+
const reads = actions.filter((a) => a.type === "read").map((a) => stringValue(a.path) ?? "").filter(Boolean);
|
|
213
|
+
return absoluteToolPaths(reads, stringValue(item.cwd) ?? cwd);
|
|
214
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { AGENT_REDACT_FILES, AGENT_REDACT_VERSION, agentRedactInstallDir, agentRedactConfigPath, errMessage, isRecord, type ProvisioningHookSet } from "@humwick/sdk";
|
|
4
|
+
import { humwickHome, redactionEnabledFromEnv } from "./config";
|
|
5
|
+
import { parseHookSet } from "./project-hooks";
|
|
6
|
+
|
|
7
|
+
// #3025 — agent-redact post-tool hooks for every runner-managed launch. The orchestrator installs
|
|
8
|
+
// the pinned binary and the hook entries its `install` command writes (agent-redact-provisioning.ts).
|
|
9
|
+
// A host without them launches agents without redaction and says so: an agent must always run.
|
|
10
|
+
|
|
11
|
+
/** What reached the launch. Reported on every launch (relay-injection-events.ts). */
|
|
12
|
+
export type AgentRedactState =
|
|
13
|
+
| { state: "on"; version: string; mechanism: string }
|
|
14
|
+
| { state: "off" }
|
|
15
|
+
| { state: "unavailable"; reason: string };
|
|
16
|
+
|
|
17
|
+
type AgentRedactAbsent = Exclude<AgentRedactState, { state: "on" }>;
|
|
18
|
+
|
|
19
|
+
export interface AgentRedactInstall {
|
|
20
|
+
claudeHooks: ProvisioningHookSet;
|
|
21
|
+
codexHooks: ProvisioningHookSet;
|
|
22
|
+
opencodePlugin: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
export function resolveAgentRedact(home: string = humwickHome()): AgentRedactInstall | AgentRedactAbsent {
|
|
27
|
+
if (!redactionEnabledFromEnv()) return { state: "off" };
|
|
28
|
+
const dir = agentRedactInstallDir(home);
|
|
29
|
+
if (!existsSync(join(dir, AGENT_REDACT_FILES.binary))) {
|
|
30
|
+
return { state: "unavailable", reason: `agent-redact ${AGENT_REDACT_VERSION} is not installed on this host (${dir})` };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const opencodePlugin = join(dir, AGENT_REDACT_FILES.opencodePlugin);
|
|
34
|
+
if (!existsSync(opencodePlugin)) throw new Error(`${opencodePlugin} is missing`);
|
|
35
|
+
return {
|
|
36
|
+
claudeHooks: readPostToolUseHooks(join(dir, AGENT_REDACT_FILES.claudeHooks)),
|
|
37
|
+
codexHooks: readPostToolUseHooks(join(dir, AGENT_REDACT_FILES.codexHooks)),
|
|
38
|
+
opencodePlugin,
|
|
39
|
+
};
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { state: "unavailable", reason: `agent-redact ${AGENT_REDACT_VERSION} hook entries are unusable: ${errMessage(err)}` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isAgentRedactInstall(value: AgentRedactInstall | AgentRedactAbsent): value is AgentRedactInstall {
|
|
46
|
+
return !("state" in value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function agentRedactOn(mechanism: string): AgentRedactState {
|
|
50
|
+
return { state: "on", version: AGENT_REDACT_VERSION, mechanism };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Hooks inherit the provider's env. An operator's own AGENT_REDACT_CONFIG wins over Relay's. */
|
|
54
|
+
export function agentRedactEnv(redaction: AgentRedactState): Record<string, string> {
|
|
55
|
+
return redaction.state === "on" ? { AGENT_REDACT_CONFIG: process.env.AGENT_REDACT_CONFIG || agentRedactConfigPath() } : {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Puts the plugin in (or takes it out of) an OpenCode config dir. True when the plugin is in place. */
|
|
59
|
+
export function syncOpenCodeRedactPlugin(opencodeConfigDir: string, redaction: AgentRedactState, home: string = humwickHome()): boolean {
|
|
60
|
+
const target = join(opencodeConfigDir, "plugins", "agent-redact.ts");
|
|
61
|
+
if (redaction.state !== "on") {
|
|
62
|
+
rmSync(target, { force: true });
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
mkdirSync(join(opencodeConfigDir, "plugins"), { recursive: true, mode: 0o700 });
|
|
66
|
+
copyFileSync(join(agentRedactInstallDir(home), AGENT_REDACT_FILES.opencodePlugin), target);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readPostToolUseHooks(path: string): ProvisioningHookSet {
|
|
71
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
72
|
+
const hooks = parseHookSet(isRecord(parsed) ? parsed.hooks : undefined);
|
|
73
|
+
if (!hooks.PostToolUse?.length) throw new Error(`${path} has no PostToolUse hook`);
|
|
74
|
+
return hooks;
|
|
75
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { errMessage, isRecord } from "@humwick/sdk";
|
|
2
|
+
import type { ShutdownOutcome } from "../../adapter";
|
|
3
|
+
import type { ProviderInputTicket } from "../../provider-input-queue";
|
|
4
|
+
import type { NativeSelfResumeHandoff } from "../../native-self-resume";
|
|
5
|
+
import { PROVIDER_INPUT_COMMAND_TYPES } from "../../runner-helpers";
|
|
6
|
+
import { boundaryReasonForCommand, type LifecycleAction, type SessionDestroyReason } from "../../session-destroy";
|
|
7
|
+
import { exitRunner } from "../../runner-exit";
|
|
8
|
+
import { logger } from "../../logger";
|
|
9
|
+
import { ContextSubmitAborted } from "../../context-boundary";
|
|
10
|
+
import type { RunnerLaunchOptions } from "../runner-context";
|
|
11
|
+
|
|
12
|
+
interface CommandDispatchDeps {
|
|
13
|
+
agentId: string;
|
|
14
|
+
options: RunnerLaunchOptions;
|
|
15
|
+
reserveInput(): ProviderInputTicket;
|
|
16
|
+
disarmForcedContext(type: string): void;
|
|
17
|
+
beginExitCommand(): void;
|
|
18
|
+
setLifecycleAction(action: LifecycleAction | undefined): void;
|
|
19
|
+
isStopped(): boolean;
|
|
20
|
+
beginNativeSelfResume(type: string, params: Record<string, unknown>, commandId: string): NativeSelfResumeHandoff | undefined;
|
|
21
|
+
runNativeSelfResume<T>(handoff: NativeSelfResumeHandoff | undefined, operation: () => Promise<T>): Promise<T>;
|
|
22
|
+
rejectNativeSelfResume(handoff: NativeSelfResumeHandoff, error: Error): void;
|
|
23
|
+
finishNativeSelfResume(handoff: NativeSelfResumeHandoff): void;
|
|
24
|
+
beginSelfResumeClearFence(type: string, params: Record<string, unknown>, commandId: string): void;
|
|
25
|
+
startCommandClaim(commandId: string): void;
|
|
26
|
+
finishCommandClaim(commandId: string): void;
|
|
27
|
+
runPreSessionDestroy(reason: SessionDestroyReason): Promise<void>;
|
|
28
|
+
publishStatus(): void;
|
|
29
|
+
reconnect(): void;
|
|
30
|
+
restart(params: Record<string, unknown>): Promise<void>;
|
|
31
|
+
compactCommand(params: Record<string, unknown>, handoff: NativeSelfResumeHandoff | undefined, commandId: string): Promise<Record<string, unknown>>;
|
|
32
|
+
clearContextCommand(params: Record<string, unknown>, ticket: ProviderInputTicket | undefined, commandId: string): Promise<Record<string, unknown>>;
|
|
33
|
+
releaseSubmitHold(params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
34
|
+
interrupt(params: Record<string, unknown>): Promise<Record<string, unknown> | void>;
|
|
35
|
+
injectContext(params: Record<string, unknown>, ticket?: ProviderInputTicket): Promise<Record<string, unknown>>;
|
|
36
|
+
respondToPermissionDecision(params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
37
|
+
injectPrompt(params: Record<string, unknown>, ticket?: ProviderInputTicket, commandId?: string): Promise<Record<string, unknown>>;
|
|
38
|
+
shutdown(type: string, params: Record<string, unknown>): Promise<ShutdownOutcome>;
|
|
39
|
+
finishClearCommand(commandId: string): void;
|
|
40
|
+
setOffline(): Promise<unknown>;
|
|
41
|
+
deleteAgent(): Promise<unknown>;
|
|
42
|
+
stop(): Promise<void>;
|
|
43
|
+
updateCommand(commandId: string, input: { status: string; result?: Record<string, unknown>; error?: string }): Promise<unknown>;
|
|
44
|
+
/** `GET /api/commands/:id` — the full record behind a bounded bus event (#3606). */
|
|
45
|
+
fetchCommand(commandId: string): Promise<Response>;
|
|
46
|
+
/** #3398 — the command's destructive input reached the provider and no reset has settled it. */
|
|
47
|
+
destructiveInputUnsettled(commandId: string): boolean;
|
|
48
|
+
checkAcknowledgement(ack: unknown, status: string): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class CommandDispatch {
|
|
52
|
+
constructor(private readonly deps: CommandDispatchDeps) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The bus dispatch seam (#1565 F1).
|
|
56
|
+
*
|
|
57
|
+
* Commands that put a payload into the provider's input channel take their place in the
|
|
58
|
+
* `providerInput` queue HERE, synchronously, before the first `await` — so the order the relay
|
|
59
|
+
* created them in (continuation first, memory beside it) is the order they are delivered in, no
|
|
60
|
+
* matter how long each command's own bookkeeping takes. Everything else keeps dispatching
|
|
61
|
+
* fire-and-forget: the gate is on the wire, not on command handling.
|
|
62
|
+
*
|
|
63
|
+
* The ticket is released when the command settles even if delivery never happened (a throw before
|
|
64
|
+
* the deliver, an unsupported provider), so a failed command can never wedge the stream.
|
|
65
|
+
*/
|
|
66
|
+
dispatchCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): void {
|
|
67
|
+
const ticket = PROVIDER_INPUT_COMMAND_TYPES.has(type) ? this.deps.reserveInput() : undefined;
|
|
68
|
+
void this.handleCommand(type, params, commandId, command, ticket).finally(() => ticket?.release());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>, ticket?: ProviderInputTicket): Promise<void> {
|
|
72
|
+
const target = typeof command?.target === "string" ? command.target : this.deps.agentId;
|
|
73
|
+
if (target !== this.deps.agentId && target !== this.deps.options.runnerId) return;
|
|
74
|
+
if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "agent.interrupt" && type !== "agent.releaseSubmitHold" && type !== "prompt.inject") return;
|
|
75
|
+
// #3606 — a bus event carries params whole only up to a size budget; read the rest by id.
|
|
76
|
+
if (command?.paramsOmitted === true) {
|
|
77
|
+
try { params = await this.loadCommandParams(commandId); } catch (error) {
|
|
78
|
+
await this.updateCommand(commandId, "failed", undefined, `command params unavailable: ${errMessage(error)}`).catch(() => {});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.deps.disarmForcedContext(type);
|
|
84
|
+
|
|
85
|
+
const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
|
|
86
|
+
if (exitAfterCommand) this.deps.beginExitCommand();
|
|
87
|
+
// #1746 — the confirmed terminal outcome of an exit command's provider teardown. Stays
|
|
88
|
+
// undefined if shutdownProvider never ran or threw; only a positive `killed` authorizes
|
|
89
|
+
// deregistration (a roster removal must imply the process is gone).
|
|
90
|
+
let killOutcome: ShutdownOutcome | undefined;
|
|
91
|
+
const nativeSelfResume = this.deps.beginNativeSelfResume(type, params, commandId);
|
|
92
|
+
this.deps.beginSelfResumeClearFence(type, params, commandId);
|
|
93
|
+
this.deps.startCommandClaim(commandId);
|
|
94
|
+
try {
|
|
95
|
+
await this.deps.runNativeSelfResume(nativeSelfResume, () => this.updateCommand(commandId, "accepted"));
|
|
96
|
+
await this.deps.runNativeSelfResume(nativeSelfResume, () => this.updateCommand(commandId, "running"));
|
|
97
|
+
// Pre-session-destroy seam (#183): for destructive transitions, run end-of-session
|
|
98
|
+
// work (Insights capture, #184) BEFORE the invasive operation, surfaced as a
|
|
99
|
+
// non-addressable "finalizing" state so the agent isn't mistaken for merely busy.
|
|
100
|
+
const destroyReason = boundaryReasonForCommand(type);
|
|
101
|
+
if (destroyReason) await this.deps.runNativeSelfResume(nativeSelfResume, () => this.deps.runPreSessionDestroy(destroyReason));
|
|
102
|
+
// Move from the transient finalizing window to the executing teardown state (or drop
|
|
103
|
+
// it entirely for compact/clear, which complete promptly once capture is done).
|
|
104
|
+
if (exitAfterCommand) this.deps.setLifecycleAction(type === "agent.kill" ? "killing" : "shutting-down");
|
|
105
|
+
else if (type === "agent.restart") this.deps.setLifecycleAction("restarting");
|
|
106
|
+
else this.deps.setLifecycleAction(undefined);
|
|
107
|
+
if (!exitAfterCommand) this.deps.publishStatus();
|
|
108
|
+
let providerResult: Record<string, unknown> | void = undefined;
|
|
109
|
+
if (type === "agent.restart") await this.deps.restart(params);
|
|
110
|
+
else if (type === "agent.reconnect") this.deps.reconnect();
|
|
111
|
+
else if (type === "agent.compact") {
|
|
112
|
+
providerResult = await this.deps.compactCommand(params, nativeSelfResume, commandId);
|
|
113
|
+
} else if (type === "agent.clearContext") {
|
|
114
|
+
providerResult = await this.deps.clearContextCommand(params, ticket, commandId);
|
|
115
|
+
} else if (type === "agent.releaseSubmitHold") {
|
|
116
|
+
providerResult = await this.deps.releaseSubmitHold(params);
|
|
117
|
+
} else if (type === "agent.interrupt") {
|
|
118
|
+
providerResult = await this.deps.interrupt(params);
|
|
119
|
+
} else if (type === "agent.injectContext") {
|
|
120
|
+
providerResult = await this.deps.injectContext(params, ticket);
|
|
121
|
+
} else if (type === "agent.permissionDecision") {
|
|
122
|
+
providerResult = await this.deps.respondToPermissionDecision(params);
|
|
123
|
+
} else if (type === "prompt.inject") {
|
|
124
|
+
providerResult = await this.deps.injectPrompt(params, ticket, commandId);
|
|
125
|
+
} else {
|
|
126
|
+
killOutcome = await this.deps.shutdown(type, params);
|
|
127
|
+
}
|
|
128
|
+
await this.updateCommand(commandId, "succeeded", {
|
|
129
|
+
action: type,
|
|
130
|
+
agentId: this.deps.agentId,
|
|
131
|
+
runnerId: this.deps.options.runnerId,
|
|
132
|
+
policyName: this.deps.options.policyName,
|
|
133
|
+
spawnRequestId: this.deps.options.spawnRequestId,
|
|
134
|
+
reason: typeof params.reason === "string" ? params.reason : undefined,
|
|
135
|
+
...(providerResult ? { providerResult } : {}),
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (nativeSelfResume) this.deps.rejectNativeSelfResume(nativeSelfResume, error instanceof Error ? error : new Error(errMessage(error)));
|
|
139
|
+
this.deps.finishCommandClaim(commandId);
|
|
140
|
+
this.deps.publishStatus();
|
|
141
|
+
// An unsettled destructive input can still reset the context: Relay replaces the process (#3398).
|
|
142
|
+
// One that never reached the provider says so: nothing can still apply, and Relay closes its obligation (#3441).
|
|
143
|
+
const unsettled = this.deps.destructiveInputUnsettled(commandId) ? { destructiveInputUnsettled: true }
|
|
144
|
+
: error instanceof ContextSubmitAborted ? { destructiveInputSubmitted: false } : undefined;
|
|
145
|
+
await this.updateCommand(commandId, "failed", unsettled, errMessage(error)).catch(() => {});
|
|
146
|
+
} finally {
|
|
147
|
+
this.deps.finishCommandClaim(commandId);
|
|
148
|
+
this.deps.finishClearCommand(commandId);
|
|
149
|
+
if (exitAfterCommand) {
|
|
150
|
+
if (params.preserveRegistration === true) {
|
|
151
|
+
await this.deps.setOffline().catch(() => {});
|
|
152
|
+
} else if (killOutcome === "killed") {
|
|
153
|
+
// #1746 — CONFIRM-DEAD-BEFORE-DEREGISTER. Only remove the roster row once the
|
|
154
|
+
// provider is positively confirmed gone. If the force-kill could not confirm death
|
|
155
|
+
// (killOutcome "failed-to-kill", or undefined because shutdownProvider threw), leave
|
|
156
|
+
// the agent registered so it stays visible (as "kill-failed") rather than becoming a
|
|
157
|
+
// silent zombie — a roster removal must imply the process is dead.
|
|
158
|
+
await this.deps.deleteAgent().catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
if (this.deps.options.exitProcessOnShutdown !== false) {
|
|
161
|
+
setTimeout(() => void this.deps.stop().catch((error) => {
|
|
162
|
+
logger.error("lifecycle", `stop after command failed: ${error}`);
|
|
163
|
+
}).finally(() => exitRunner(0, type === "agent.kill" ? "agent.kill command" : "agent.shutdown command")), 10);
|
|
164
|
+
}
|
|
165
|
+
} else if (!this.deps.isStopped()) {
|
|
166
|
+
if (nativeSelfResume) this.deps.finishNativeSelfResume(nativeSelfResume);
|
|
167
|
+
this.deps.setLifecycleAction(undefined);
|
|
168
|
+
this.deps.publishStatus();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async loadCommandParams(commandId: string): Promise<Record<string, unknown>> {
|
|
174
|
+
const response = await this.deps.fetchCommand(commandId);
|
|
175
|
+
if (!response.ok) throw new Error(`command lookup failed: ${response.status}`);
|
|
176
|
+
const command: unknown = await response.json();
|
|
177
|
+
if (!isRecord(command) || !isRecord(command.params)) throw new Error("command lookup returned no params");
|
|
178
|
+
return command.params;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<void> {
|
|
182
|
+
const ack = await this.deps.updateCommand(commandId, { status, ...(result ? { result } : {}), ...(error ? { error } : {}) });
|
|
183
|
+
this.deps.checkAcknowledgement(ack, status);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { errMessage } from "@humwick/sdk";
|
|
2
|
+
import type { ManagedProcess, ShutdownOutcome } from "../../adapter";
|
|
3
|
+
import type { RestartProviderOptions } from "../../runner-helpers";
|
|
4
|
+
import { commandTimeoutMs, restartOptionsFromCommand } from "../../runner-helpers";
|
|
5
|
+
import type { LifecycleAction } from "../../session-destroy";
|
|
6
|
+
import type { RunnerLaunchOptions } from "../runner-context";
|
|
7
|
+
import { logger } from "../../logger";
|
|
8
|
+
|
|
9
|
+
interface LifecycleCommandsDeps {
|
|
10
|
+
options: RunnerLaunchOptions;
|
|
11
|
+
agentId: string;
|
|
12
|
+
process(): ManagedProcess | undefined;
|
|
13
|
+
restartProvider(options: RestartProviderOptions): Promise<void>;
|
|
14
|
+
shutdownProvider(hard: boolean, timeoutMs: number): Promise<ShutdownOutcome>;
|
|
15
|
+
releaseSubmitHold(resend: boolean): Promise<Record<string, unknown>>;
|
|
16
|
+
hasProviderTurn(): boolean;
|
|
17
|
+
sessionLog(message: string): void;
|
|
18
|
+
resetEmptyTurns(): void;
|
|
19
|
+
scheduleInterruptReconcile(): void;
|
|
20
|
+
waitForProviderTurnClear(): Promise<void>;
|
|
21
|
+
injectPrompt(params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
22
|
+
markStopped(): void;
|
|
23
|
+
armTerminal(): void;
|
|
24
|
+
setLifecycleAction(action: LifecycleAction | undefined): void;
|
|
25
|
+
publishStatus(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class LifecycleCommands {
|
|
29
|
+
constructor(private readonly deps: LifecycleCommandsDeps) {}
|
|
30
|
+
|
|
31
|
+
restart(params: Record<string, unknown>): Promise<void> {
|
|
32
|
+
return this.deps.restartProvider(restartOptionsFromCommand(params));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
reconnect(): void { this.deps.publishStatus(); }
|
|
36
|
+
|
|
37
|
+
releaseSubmitHold(params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
38
|
+
return this.deps.releaseSubmitHold(params.resend === true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async interrupt(params: Record<string, unknown>): Promise<Record<string, unknown> | void> {
|
|
42
|
+
let providerResult: Record<string, unknown> | void = undefined;
|
|
43
|
+
if (!this.deps.options.adapter.interrupt || !this.deps.process()) throw new Error("provider does not support interrupt");
|
|
44
|
+
const redirect = params.redirect && typeof params.redirect === "object" && !Array.isArray(params.redirect)
|
|
45
|
+
? params.redirect as Record<string, unknown>
|
|
46
|
+
: undefined;
|
|
47
|
+
const hasProviderTurn = this.deps.hasProviderTurn();
|
|
48
|
+
if (hasProviderTurn || !redirect) {
|
|
49
|
+
this.deps.sessionLog(`interrupt requested via ${typeof params.requestedVia === "string" ? params.requestedVia : "dashboard"}`);
|
|
50
|
+
// #2389 — an interrupted turn always ends with no output, and that state already has its
|
|
51
|
+
// own notice (`agent.turn_interrupted`). It must not also draw an empty-turn strike.
|
|
52
|
+
this.deps.resetEmptyTurns();
|
|
53
|
+
providerResult = await this.deps.options.adapter.interrupt(this.deps.process()!);
|
|
54
|
+
this.deps.scheduleInterruptReconcile();
|
|
55
|
+
}
|
|
56
|
+
if (redirect && hasProviderTurn) await this.deps.waitForProviderTurnClear();
|
|
57
|
+
if (redirect) providerResult = { interrupt: providerResult, redirect: await this.deps.injectPrompt(redirect) };
|
|
58
|
+
return providerResult;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async shutdown(type: string, params: Record<string, unknown>): Promise<ShutdownOutcome> {
|
|
62
|
+
let killOutcome: ShutdownOutcome;
|
|
63
|
+
try {
|
|
64
|
+
killOutcome = await this.deps.shutdownProvider(type === "agent.kill", commandTimeoutMs(params));
|
|
65
|
+
} catch (shutdownError) {
|
|
66
|
+
// #1725 fix-forward — a throw INSIDE shutdownProvider (e.g. tmux missing from PATH ⇒
|
|
67
|
+
// defaultKillSession's Bun.spawnSync throws ENOENT) previously left killOutcome undefined
|
|
68
|
+
// and the "kill-failed" pill unset (that pill is set inside shutdownProvider, which never
|
|
69
|
+
// completed) — so the confirm-dead gate below deleted nothing AND published nothing, wedging
|
|
70
|
+
// the agent at "shutting-down" forever while the process exited anyway. Treat the throw
|
|
71
|
+
// exactly like a returned "failed-to-kill": surface the terminal pill and proceed, so the
|
|
72
|
+
// row reflects reality (a visible, re-targetable kill-failed) instead of an indefinite
|
|
73
|
+
// in-flight state, and the confirm-dead gate keeps it registered rather than deregistering
|
|
74
|
+
// a process whose death was never confirmed.
|
|
75
|
+
killOutcome = "failed-to-kill";
|
|
76
|
+
this.deps.markStopped();
|
|
77
|
+
this.deps.armTerminal();
|
|
78
|
+
this.deps.setLifecycleAction("kill-failed");
|
|
79
|
+
this.deps.publishStatus();
|
|
80
|
+
logger.error("lifecycle", `provider shutdown threw for ${this.deps.agentId}: ${errMessage(shutdownError)}; treating as failed-to-kill and keeping agent registered`);
|
|
81
|
+
}
|
|
82
|
+
return killOutcome;
|
|
83
|
+
}
|
|
84
|
+
}
|