@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,282 @@
|
|
|
1
|
+
import { errMessage } from "@humwick/sdk";
|
|
2
|
+
import type { PaneBusyReading } from "./adapter";
|
|
3
|
+
|
|
4
|
+
type ProviderActivity = "busy" | "idle" | "unknown";
|
|
5
|
+
|
|
6
|
+
const BUSY_RECONCILE_POLL_MS = 4_000;
|
|
7
|
+
const BUSY_RECONCILE_IDLE_CONFIRM = 8;
|
|
8
|
+
const BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY = 15;
|
|
9
|
+
const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
|
|
10
|
+
const PROVIDER_TURN_CLEAR_TIMEOUT_MS = 60_000;
|
|
11
|
+
// #1980 U2 — how many consecutive reconcile ticks a live turn may be HELD (never force-cleared,
|
|
12
|
+
// because there is no positive end-evidence) before the backstop escalates a NOTIFY-ONLY
|
|
13
|
+
// watchdog. At the 4s poll this is ~20 min. The escalation NEVER cancels the turn (a 44-min gate
|
|
14
|
+
// is legitimate); it only surfaces "this turn has been held a long time" to the coordinator, so a
|
|
15
|
+
// genuinely wedged-but-active agent (a frozen `busy`, or a pane-scrape-only long tool the runner
|
|
16
|
+
// cannot prove idle) is seen instead of silently cancelled. One-shot per turn (reset on arm/disarm).
|
|
17
|
+
//
|
|
18
|
+
// #2010 INVARIANT: this interval (BUSY_RECONCILE_WATCHDOG_TICKS * BUSY_RECONCILE_POLL_MS, ~20
|
|
19
|
+
// min) must stay >= the server's STUCK_BUSY_PROVIDER_TURN_MS threshold (src/config.ts, default
|
|
20
|
+
// 15 min but operator-configurable via AGENT_RELAY_STUCK_BUSY_PROVIDER_TURN_MS). The server-side
|
|
21
|
+
// gate in auditProviderTurnWatchdog (src/services/turn-reconcile-audit.ts) only wakes the
|
|
22
|
+
// coordinator once staleForMs (time since real last progress) reaches that threshold. If an
|
|
23
|
+
// operator raises the server threshold ABOVE this interval, a genuinely dead-from-the-start turn
|
|
24
|
+
// could reach the watchdog tick count while staleForMs is still under the (raised) threshold —
|
|
25
|
+
// the gate would then suppress a real stall. Keep this interval the larger of the two, or the
|
|
26
|
+
// suppression re-enters.
|
|
27
|
+
export const BUSY_RECONCILE_WATCHDOG_TICKS = 300;
|
|
28
|
+
|
|
29
|
+
interface BusyReconcilerDeps {
|
|
30
|
+
isStopped(): boolean;
|
|
31
|
+
hasProcess(): boolean;
|
|
32
|
+
currentStatus(): "idle" | "busy" | "offline" | "error";
|
|
33
|
+
isProviderBlocked(): boolean;
|
|
34
|
+
hasProviderTurn(): boolean;
|
|
35
|
+
canProbeActivity(): boolean;
|
|
36
|
+
probeActivity(): Promise<ProviderActivity> | undefined;
|
|
37
|
+
// #769 P1-A / #1656: optional independent pane-busy read. `busy` vetoes a clear; `not-busy`
|
|
38
|
+
// is the positive corroboration every counted idle tick requires; anything else is unknown
|
|
39
|
+
// and holds the turn. Omitted entirely by providers with no pane to read.
|
|
40
|
+
probePaneBusy?(): Promise<PaneBusyReading> | undefined;
|
|
41
|
+
clearProviderTurn(reason: string): void;
|
|
42
|
+
// #1980 U2 — escalate a NOTIFY-ONLY watchdog for a live turn the backstop has HELD past the
|
|
43
|
+
// prolonged-hold threshold (a frozen-active `busy`, or a pane-scrape-only long tool that can
|
|
44
|
+
// never be positively proven idle — probeActivity reads `unknown`, not `idle`, so the clear
|
|
45
|
+
// paths are never reached). The runner turns this into a coordinator notification; it MUST NOT
|
|
46
|
+
// cancel the turn. Optional: providers/tests without a watchdog simply hold silently.
|
|
47
|
+
escalateWatchdog?(reason: string): void;
|
|
48
|
+
/** Every no-provider-turn edge (#2984: a pending effort change applies here). */
|
|
49
|
+
turnCleared?(): void;
|
|
50
|
+
sessionDebug(message: string): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class BusyReconciler {
|
|
54
|
+
private idleStreak = 0;
|
|
55
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
56
|
+
private sawBusy = false;
|
|
57
|
+
// #1980 U2 — consecutive reconcile ticks this live turn has been held without ending, and the
|
|
58
|
+
// one-shot guard so the prolonged-hold watchdog notifies at most once per turn. Both reset on
|
|
59
|
+
// arm/disarm (a new turn starts a fresh hold budget).
|
|
60
|
+
private heldTicks = 0;
|
|
61
|
+
private watchdogEscalated = false;
|
|
62
|
+
// #1838 row 14 — the activity probe now runs OFF the runner's event loop (async tmux
|
|
63
|
+
// subprocess + probe reads), so a single reconcile can outlast the 4s poll interval when a
|
|
64
|
+
// tmux socket is slow. This guard drops any timer tick that fires while the previous
|
|
65
|
+
// reconcile is still in flight, so slow probes can never stack N concurrent reads on one
|
|
66
|
+
// pane. It is only consulted on the TIMER path (runInBackground); tests that drive run()
|
|
67
|
+
// directly and await it to completion are unaffected — the flag is always clear by the next
|
|
68
|
+
// call.
|
|
69
|
+
private reconcileInFlight = false;
|
|
70
|
+
// #1838 round-3 HIGH — a monotonic generation, the same await-fence the turn watcher uses.
|
|
71
|
+
// Captured at the start of a reconcile and re-checked after EVERY await before any streak
|
|
72
|
+
// mutation or clearProviderTurn: a disarm / re-arm / turn clear that happens while an off-loop
|
|
73
|
+
// probe is in flight means the verdict describes a STALE turn, so it must not clear a
|
|
74
|
+
// successor. Bumped on arm, disarm, and every turn-clear signal (providerTurnCleared) — the
|
|
75
|
+
// three points at which the turn identity this reconciler is judging can change.
|
|
76
|
+
private generation = 0;
|
|
77
|
+
private readonly providerTurnClearWaiters = new Set<(error?: Error) => void>();
|
|
78
|
+
|
|
79
|
+
constructor(private readonly deps: BusyReconcilerDeps) {}
|
|
80
|
+
|
|
81
|
+
arm(): void {
|
|
82
|
+
if (this.timer || !this.deps.canProbeActivity()) return;
|
|
83
|
+
this.generation++;
|
|
84
|
+
this.idleStreak = 0;
|
|
85
|
+
this.sawBusy = false;
|
|
86
|
+
this.heldTicks = 0;
|
|
87
|
+
this.watchdogEscalated = false;
|
|
88
|
+
this.timer = setInterval(() => this.runInBackground(), BUSY_RECONCILE_POLL_MS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
disarm(): void {
|
|
92
|
+
this.generation++;
|
|
93
|
+
if (this.timer) clearInterval(this.timer);
|
|
94
|
+
this.timer = undefined;
|
|
95
|
+
this.idleStreak = 0;
|
|
96
|
+
this.sawBusy = false;
|
|
97
|
+
this.heldTicks = 0;
|
|
98
|
+
this.watchdogEscalated = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// #1838 round-3 HIGH — true when an in-flight reconcile that started at `gen` must abandon
|
|
102
|
+
// without mutating streak state or clearing: the generation moved (arm/disarm/turn clear), or
|
|
103
|
+
// the turn/process/lifecycle preconditions it verified BEFORE its await no longer hold. Cheap,
|
|
104
|
+
// synchronous, re-evaluated after each await.
|
|
105
|
+
private stale(gen: number): boolean {
|
|
106
|
+
return gen !== this.generation
|
|
107
|
+
|| this.deps.isStopped()
|
|
108
|
+
|| !this.deps.hasProcess()
|
|
109
|
+
|| this.deps.currentStatus() !== "busy"
|
|
110
|
+
|| this.deps.isProviderBlocked()
|
|
111
|
+
|| !this.deps.hasProviderTurn();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
scheduleInterruptReconcile(): void {
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
if (this.deps.isStopped() || !this.deps.hasProcess()) return;
|
|
117
|
+
this.reconcileInterruptInBackground();
|
|
118
|
+
}, INTERRUPT_RECONCILE_DELAY_MS);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// These two calls run from timers, so their promises have no caller that can observe a
|
|
122
|
+
// rejection. In particular clearProviderTurn can synchronously throw while publishing its
|
|
123
|
+
// durable timeline event during teardown. Catch at this boundary so an expected race cannot
|
|
124
|
+
// become Bun's process-fatal unhandled rejection; retain the error in the session log.
|
|
125
|
+
private runInBackground(): void {
|
|
126
|
+
// #1838 row 14 — skip this tick if the previous off-loop reconcile is still running.
|
|
127
|
+
if (this.reconcileInFlight) {
|
|
128
|
+
this.deps.sessionDebug("busy reconcile skipped: previous probe still in flight");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.reconcileInFlight = true;
|
|
132
|
+
void this.run()
|
|
133
|
+
.catch((error) => this.reportBackgroundRejection("busy reconcile", error))
|
|
134
|
+
.finally(() => { this.reconcileInFlight = false; });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private reconcileInterruptInBackground(): void {
|
|
138
|
+
void this.reconcileInterrupt().catch((error) => this.reportBackgroundRejection("post-interrupt reconcile", error));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private reportBackgroundRejection(scope: string, error: unknown): void {
|
|
142
|
+
// Logging must not rethrow from the rejection handler and recreate the same unhandled path.
|
|
143
|
+
try { this.deps.sessionDebug(`${scope} failed: ${errMessage(error)}`); } catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async reconcileInterrupt(): Promise<void> {
|
|
147
|
+
const gen = this.generation;
|
|
148
|
+
if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) return;
|
|
149
|
+
const probe = this.deps.probeActivity();
|
|
150
|
+
let activity: ProviderActivity = "unknown";
|
|
151
|
+
try { if (probe) activity = await probe; } catch { return; }
|
|
152
|
+
// #1838 round-3 HIGH: re-check after the probe await. Turn A was interrupted, its probe
|
|
153
|
+
// stalled, A ended and Turn B began — A's stale `idle` must NOT clear B. `stale(gen)` catches
|
|
154
|
+
// the disarm/re-arm/turn-clear (via generation) AND a status/turn that moved out from under us.
|
|
155
|
+
if (this.stale(gen)) return;
|
|
156
|
+
this.deps.sessionDebug(`post-interrupt reconcile probe=${activity}`);
|
|
157
|
+
if (activity === "idle") this.deps.clearProviderTurn("post-interrupt");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Await the same provider-turn-clear signal maintained by status events and the
|
|
161
|
+
* interrupt backstop. Redirect uses this boundary before starting its next turn. */
|
|
162
|
+
waitForProviderTurnClear(timeoutMs = PROVIDER_TURN_CLEAR_TIMEOUT_MS): Promise<void> {
|
|
163
|
+
if (!this.deps.hasProviderTurn()) return Promise.resolve();
|
|
164
|
+
return new Promise<void>((resolve, reject) => {
|
|
165
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
166
|
+
const finish = (error?: Error) => {
|
|
167
|
+
clearTimeout(timeout);
|
|
168
|
+
this.providerTurnClearWaiters.delete(finish);
|
|
169
|
+
if (error) reject(error); else resolve();
|
|
170
|
+
};
|
|
171
|
+
timeout = setTimeout(() => finish(new Error("timed out waiting for interrupted provider turn to clear")), timeoutMs);
|
|
172
|
+
timeout.unref?.();
|
|
173
|
+
this.providerTurnClearWaiters.add(finish);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
providerTurnCleared(): void {
|
|
178
|
+
// #1838 round-3 HIGH — a turn clear/replacement invalidates any in-flight reconcile that was
|
|
179
|
+
// judging the turn that just ended. Bump BEFORE the waiter-fan-out and the hasProviderTurn
|
|
180
|
+
// guard: this method is only invoked by the runner on a no-provider-turn edge, so it never
|
|
181
|
+
// fires during a legitimate active reconcile.
|
|
182
|
+
this.generation++;
|
|
183
|
+
if (this.deps.hasProviderTurn()) return;
|
|
184
|
+
for (const finish of [...this.providerTurnClearWaiters]) finish();
|
|
185
|
+
this.deps.turnCleared?.();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async run(): Promise<void> {
|
|
189
|
+
const gen = this.generation;
|
|
190
|
+
const probe = this.deps.probeActivity();
|
|
191
|
+
if (this.deps.isStopped() || !this.deps.hasProcess() || !probe) {
|
|
192
|
+
this.disarm();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) {
|
|
196
|
+
this.idleStreak = 0;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!this.deps.hasProviderTurn()) {
|
|
200
|
+
this.disarm();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// #1656: a probe we could not read is `unknown`, not `idle`. It breaks the streak
|
|
204
|
+
// exactly like any other non-idle reading — it must never be silently skipped past.
|
|
205
|
+
let activity: ProviderActivity;
|
|
206
|
+
try { activity = await probe; } catch { activity = "unknown"; }
|
|
207
|
+
// #1838 round-3 HIGH: re-check after the (off-loop) probe await, BEFORE touching sawBusy /
|
|
208
|
+
// idleStreak. A disarm/re-arm or turn clear while the probe was in flight means this verdict
|
|
209
|
+
// is for a stale turn — abandon rather than mutate the successor's streak.
|
|
210
|
+
if (this.stale(gen)) return;
|
|
211
|
+
// #1980 U2 — this tick observed a still-live, still-busy provider turn that did not end. A
|
|
212
|
+
// turn that is genuinely wedged-but-active never reaches a force-clear at all: a frozen
|
|
213
|
+
// foreground tool keeps reading `busy`, and a pane-scrape-only long tool reads `unknown` (not
|
|
214
|
+
// `idle`, so its streak never accumulates). Count the hold so such a turn is escalated to a
|
|
215
|
+
// notify-only watchdog rather than being either silently cancelled OR held forever in silence.
|
|
216
|
+
this.heldTicks += 1;
|
|
217
|
+
if (this.heldTicks >= BUSY_RECONCILE_WATCHDOG_TICKS) this.emitWatchdog("prolonged-hold");
|
|
218
|
+
if (activity === "busy") this.sawBusy = true;
|
|
219
|
+
if (activity !== "idle") {
|
|
220
|
+
this.idleStreak = 0;
|
|
221
|
+
this.deps.sessionDebug(`reconcile probe=${activity} sawBusy=${this.sawBusy} streak=${this.idleStreak}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// #769 P1-A / #1656: the probe alone does not get to end a turn. An independent pane read
|
|
225
|
+
// runs on EVERY idle tick — not just once at the force-clear moment — and the streak counts
|
|
226
|
+
// only ticks where it positively reported `not-busy`. That is the substance of #1656: the
|
|
227
|
+
// old counter measured "how long since I last saw busy", which a pane that cannot be read at
|
|
228
|
+
// all satisfies perfectly. It now measures accumulated positive evidence that the turn ended,
|
|
229
|
+
// and any tick that fails to produce such evidence resets it to zero.
|
|
230
|
+
const pane = await this.readPaneBusy();
|
|
231
|
+
// #1838 round-3 HIGH: re-check after the pane await too — this is the tick that can call
|
|
232
|
+
// clearProviderTurn, so a turn that was replaced during the pane read must not be cleared.
|
|
233
|
+
if (this.stale(gen)) return;
|
|
234
|
+
if (pane === "busy") {
|
|
235
|
+
this.idleStreak = 0;
|
|
236
|
+
this.sawBusy = true;
|
|
237
|
+
this.deps.sessionDebug("reconcile probe=idle vetoed: pane still reads busy");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (pane === "no-session" || pane === "unreadable") {
|
|
241
|
+
this.idleStreak = 0;
|
|
242
|
+
this.deps.sessionDebug(`reconcile probe=idle but pane=${pane} — unknown, holding the turn`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.idleStreak += 1;
|
|
246
|
+
const confirm = this.sawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
|
|
247
|
+
this.deps.sessionDebug(`reconcile not-busy pane=${pane ?? "none"} sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
|
|
248
|
+
if (this.idleStreak < confirm) return;
|
|
249
|
+
// #1980 U2 — reaching here means POSITIVE end-evidence: the activity read is `idle`, which for
|
|
250
|
+
// a probe-capable Claude session comes only from the provider's own idle assertion or a
|
|
251
|
+
// busy-verdict a real interrupt / trust-window decay turned idle — NOT from pane silence. A
|
|
252
|
+
// long foreground tool the runner cannot see now reads `unknown` (probeActivity, #1980 U2),
|
|
253
|
+
// whose streak resets above, so the `no-busy-observed` force-cancel that lost the 44-min gate
|
|
254
|
+
// is no longer reachable from silence. Genuine ends still clear here, with their reason +
|
|
255
|
+
// notification (the interrupted-turn audit downstream).
|
|
256
|
+
// Capture the reason BEFORE disarm(): disarm resets `sawBusy`, so reading it afterwards (as the
|
|
257
|
+
// original did) always reported `no-busy-observed`, mislabeling even a genuine sawBusy clear —
|
|
258
|
+
// the confirm threshold above already branched correctly on the pre-disarm value.
|
|
259
|
+
const reason = this.sawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)";
|
|
260
|
+
this.disarm();
|
|
261
|
+
this.deps.clearProviderTurn(reason);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// #1980 U2 — fire the notify-only watchdog at most once per turn. The reconciler NEVER cancels
|
|
265
|
+
// the turn on this path; it only asks the runner to tell the coordinator the turn is being held.
|
|
266
|
+
private emitWatchdog(reason: string): void {
|
|
267
|
+
if (this.watchdogEscalated) return;
|
|
268
|
+
this.watchdogEscalated = true;
|
|
269
|
+
this.deps.sessionDebug(`busy backstop holding live turn past threshold; escalating watchdog (${reason})`);
|
|
270
|
+
try { this.deps.escalateWatchdog?.(reason); } catch { /* a watchdog failure must never break reconcile */ }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The pane half of one tick. `undefined` means the provider wired no pane read at all (no
|
|
274
|
+
* pane exists to consult) — distinct from a pane that exists and could not be read, which is
|
|
275
|
+
* `unreadable` and blocks. A dep that rejects is `unreadable` for the same reason: a failed
|
|
276
|
+
* read is a fact about the read, never about the provider. */
|
|
277
|
+
private async readPaneBusy(): Promise<PaneBusyReading | undefined> {
|
|
278
|
+
const pending = this.deps.probePaneBusy?.();
|
|
279
|
+
if (!pending) return undefined;
|
|
280
|
+
try { return await pending; } catch { return "unreadable"; }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
type ClaimKind = "message" | "task" | "command";
|
|
2
|
+
type RunnerWorkKind = "provider-turn" | "subagent" | "background-script";
|
|
3
|
+
type RunnerBusyReason = RunnerWorkKind | ClaimKind;
|
|
4
|
+
type RunnerSemanticStatus = "idle" | "busy" | "offline" | "error";
|
|
5
|
+
|
|
6
|
+
interface ClaimRecord {
|
|
7
|
+
id: string;
|
|
8
|
+
kind: ClaimKind;
|
|
9
|
+
startedAt: number;
|
|
10
|
+
expiresAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WorkRecord {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: RunnerWorkKind;
|
|
16
|
+
label?: string;
|
|
17
|
+
role?: string;
|
|
18
|
+
parentId?: string;
|
|
19
|
+
metadata?: Record<string, unknown>;
|
|
20
|
+
// OS pids of the underlying process(es) this work tracks (#650). When present,
|
|
21
|
+
// a periodic liveness reconcile clears the record once every pid is dead — so a
|
|
22
|
+
// tracked process killed out-of-band (no exit event) can't strand the agent busy.
|
|
23
|
+
pids?: number[];
|
|
24
|
+
startedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ClaimTracker {
|
|
28
|
+
private status: RunnerSemanticStatus = "idle";
|
|
29
|
+
private readonly claims = new Map<string, ClaimRecord>();
|
|
30
|
+
private readonly work = new Map<string, WorkRecord>();
|
|
31
|
+
|
|
32
|
+
setProviderBusy(busy: boolean): boolean {
|
|
33
|
+
return busy ? this.startWork("provider-turn", "provider-turn") : this.finishWork("provider-turn", "provider-turn");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
startWork(kind: RunnerWorkKind, id: string = kind, details: Omit<Partial<WorkRecord>, "id" | "kind" | "startedAt"> = {}): boolean {
|
|
37
|
+
const before = this.currentStatus();
|
|
38
|
+
const key = `${kind}:${id}`;
|
|
39
|
+
const existing = this.work.get(key);
|
|
40
|
+
this.work.set(key, {
|
|
41
|
+
id,
|
|
42
|
+
kind,
|
|
43
|
+
label: details.label ?? existing?.label,
|
|
44
|
+
role: details.role ?? existing?.role,
|
|
45
|
+
parentId: details.parentId ?? existing?.parentId,
|
|
46
|
+
metadata: details.metadata ?? existing?.metadata,
|
|
47
|
+
pids: details.pids ?? existing?.pids,
|
|
48
|
+
startedAt: existing?.startedAt ?? Date.now(),
|
|
49
|
+
});
|
|
50
|
+
return before !== this.currentStatus();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
finishWork(kind: RunnerWorkKind, id: string = kind): boolean {
|
|
54
|
+
const before = this.currentStatus();
|
|
55
|
+
this.work.delete(`${kind}:${id}`);
|
|
56
|
+
return before !== this.currentStatus();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
clearWorkKind(kind: RunnerWorkKind): boolean {
|
|
60
|
+
const before = this.currentStatus();
|
|
61
|
+
for (const key of [...this.work.keys()]) {
|
|
62
|
+
if (key.startsWith(`${kind}:`)) this.work.delete(key);
|
|
63
|
+
}
|
|
64
|
+
return before !== this.currentStatus();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setTerminalStatus(status: "offline" | "error"): boolean {
|
|
68
|
+
const before = this.status;
|
|
69
|
+
this.status = status;
|
|
70
|
+
return before !== status;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
clearTerminalStatus(): boolean {
|
|
74
|
+
const before = this.status;
|
|
75
|
+
this.status = "idle";
|
|
76
|
+
return before !== this.status;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
startClaim(kind: ClaimKind, id: string, expiresAt?: number): boolean {
|
|
80
|
+
const before = this.currentStatus();
|
|
81
|
+
this.claims.set(`${kind}:${id}`, { kind, id, startedAt: Date.now(), expiresAt });
|
|
82
|
+
return before !== this.currentStatus();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
finishClaim(kind: ClaimKind, id: string): boolean {
|
|
86
|
+
const before = this.currentStatus();
|
|
87
|
+
this.claims.delete(`${kind}:${id}`);
|
|
88
|
+
return before !== this.currentStatus();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expire(now = Date.now()): boolean {
|
|
92
|
+
const before = this.currentStatus();
|
|
93
|
+
for (const [key, claim] of this.claims) {
|
|
94
|
+
if (claim.expiresAt !== undefined && claim.expiresAt <= now) this.claims.delete(key);
|
|
95
|
+
}
|
|
96
|
+
return before !== this.currentStatus();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
currentStatus(): RunnerSemanticStatus {
|
|
100
|
+
if (this.status === "offline" || this.status === "error") return this.status;
|
|
101
|
+
return this.work.size > 0 || this.claims.size > 0 ? "busy" : "idle";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reasons(): RunnerBusyReason[] {
|
|
105
|
+
const reasons = new Set<RunnerBusyReason>();
|
|
106
|
+
for (const item of this.work.values()) reasons.add(item.kind);
|
|
107
|
+
for (const claim of this.claims.values()) reasons.add(claim.kind);
|
|
108
|
+
return [...reasons];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
activeWork(): WorkRecord[] {
|
|
112
|
+
return [...this.work.values()];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True iff any active work carries pids the liveness reconcile can check (#650). */
|
|
116
|
+
hasLivenessTrackedWork(): boolean {
|
|
117
|
+
for (const item of this.work.values()) {
|
|
118
|
+
if (item.pids && item.pids.length > 0) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reconcile pid-bearing work against actual process liveness (#650): drop any
|
|
125
|
+
* record whose tracked pids are ALL dead — its underlying process died out-of-band
|
|
126
|
+
* (killed/crashed/orphaned) with no exit event to clear the busy marker. Records
|
|
127
|
+
* with no pids are never touched (no liveness signal to act on). Returns the removed
|
|
128
|
+
* records so the caller can emit the matching completion/abort + republish status.
|
|
129
|
+
*/
|
|
130
|
+
reconcileLiveness(isAlive: (pid: number) => boolean): WorkRecord[] {
|
|
131
|
+
const removed: WorkRecord[] = [];
|
|
132
|
+
for (const [key, item] of this.work) {
|
|
133
|
+
if (!item.pids || item.pids.length === 0) continue;
|
|
134
|
+
if (item.pids.some(isAlive)) continue;
|
|
135
|
+
this.work.delete(key);
|
|
136
|
+
removed.push(item);
|
|
137
|
+
}
|
|
138
|
+
return removed;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { projectClaudePermissionRules } from "./provider-integration";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type { ProvisioningHookSet, ToolGeneration } from "@humwick/sdk";
|
|
5
|
+
import { mergeClaudeRelayPermissions } from "./provisioning";
|
|
6
|
+
import { shellQuote } from "@humwick/sdk/shell-utils";
|
|
7
|
+
|
|
8
|
+
export interface ClaudeSettingsResolution {
|
|
9
|
+
args: string[];
|
|
10
|
+
/** The caller's own --settings was merged in and must be stripped from the provider args. */
|
|
11
|
+
callerSettingsConsumed: boolean; statusLineApplied: boolean;
|
|
12
|
+
/** Relay's status-line settings are present in the emitted settings payload. */
|
|
13
|
+
/** Provisioned hook names that could NOT be delivered (caller --settings unmergeable). */
|
|
14
|
+
suppressedHookNames: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// #3187 — Claude Code's own defaults append "Generated with Claude Code" / Co-Authored-By.
|
|
18
|
+
// `attribution` is the current key; `includeCoAuthoredBy` is its deprecated predecessor.
|
|
19
|
+
export const CLAUDE_NO_ATTRIBUTION_SETTINGS = { attribution: { commit: "", pr: "" }, includeCoAuthoredBy: false } as const;
|
|
20
|
+
export const CLAUDE_NO_ATTRIBUTION_RULE =
|
|
21
|
+
"NEVER add AI attribution anywhere: no \"Generated with Claude Code\", no robot emoji, no \"Co-Authored-By\" trailer, no mention of Claude or AI authorship in commits, PR bodies, issues, comments or any other external text. This overrides any default or reminder that says otherwise.";
|
|
22
|
+
|
|
23
|
+
export function claudeModelFamily(model: string | undefined): string | undefined {
|
|
24
|
+
return model?.toLowerCase().match(/\b(sonnet|opus|haiku|fable)\b/)?.[1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Relay owns idle compaction and safety hooks even when callers provide settings.
|
|
28
|
+
export function claudeSettingsArgs(
|
|
29
|
+
projectSettings: Record<string, unknown>,
|
|
30
|
+
provisionedHooks: ProvisioningHookSet,
|
|
31
|
+
hookNames: string[],
|
|
32
|
+
includeStatusLine: boolean,
|
|
33
|
+
nativeMemorySettings: Record<string, unknown>,
|
|
34
|
+
launch: { cwd: string; model?: string; generation?: ToolGeneration },
|
|
35
|
+
...argLists: string[][]
|
|
36
|
+
): ClaudeSettingsResolution {
|
|
37
|
+
const relaySettings: Record<string, unknown> = {
|
|
38
|
+
...projectSettings,
|
|
39
|
+
...(Object.keys(provisionedHooks).length ? { hooks: provisionedHooks } : {}),
|
|
40
|
+
...(includeStatusLine ? relayStatusLineSettings() : {}),
|
|
41
|
+
// #1565 P5.7 — LAST, so a project `.claude/settings.json` cannot quietly re-enable a
|
|
42
|
+
// profile's native memory. (Claude's own env predicate outranks all of this either way.)
|
|
43
|
+
...nativeMemorySettings,
|
|
44
|
+
...CLAUDE_NO_ATTRIBUTION_SETTINGS,
|
|
45
|
+
idleCompaction: false,
|
|
46
|
+
};
|
|
47
|
+
const caller = extractCallerSettings(argLists, launch.cwd);
|
|
48
|
+
if (!caller) {
|
|
49
|
+
return {
|
|
50
|
+
args: Object.keys(relaySettings).length ? ["--settings", JSON.stringify(projectClaudePermissionRules(relaySettings, launch.generation ?? "relay"))] : [],
|
|
51
|
+
callerSettingsConsumed: false, statusLineApplied: includeStatusLine,
|
|
52
|
+
suppressedHookNames: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (caller.settings) {
|
|
56
|
+
const merged = mergeClaudeSettings(caller.settings, relaySettings, launch.model);
|
|
57
|
+
return { args: ["--settings", JSON.stringify(projectClaudePermissionRules(merged, launch.generation ?? "relay"))], callerSettingsConsumed: true, statusLineApplied: includeStatusLine && merged.statusLine === relaySettings.statusLine, suppressedHookNames: [] };
|
|
58
|
+
}
|
|
59
|
+
throw new Error("Claude --settings must be a JSON object or a readable JSON settings file (idleCompaction must be disabled)");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The caller's `--settings` value, parsed as inline JSON when possible. `settings` is null for a
|
|
63
|
+
// file-path / malformed value (an inline object is required to merge relay settings in).
|
|
64
|
+
export function extractCallerSettings(argLists: string[][], cwd?: string): { settings: Record<string, unknown> | null } | null {
|
|
65
|
+
for (const list of argLists) {
|
|
66
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
67
|
+
const arg = list[i];
|
|
68
|
+
if (arg === "--settings") return { settings: parseCallerSettings(list[i + 1], cwd) };
|
|
69
|
+
if (arg?.startsWith("--settings=")) return { settings: parseCallerSettings(arg.slice("--settings=".length), cwd) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseCallerSettings(value: string | undefined, cwd?: string): Record<string, unknown> | null {
|
|
76
|
+
if (!value) return null;
|
|
77
|
+
const trimmed = value.trim();
|
|
78
|
+
if (!trimmed.startsWith("{")) {
|
|
79
|
+
if (!cwd) return null;
|
|
80
|
+
try { return parseCallerSettings(readFileSync(resolve(cwd, trimmed), "utf8")); }
|
|
81
|
+
catch { throw new Error(`Cannot read Claude --settings file: ${trimmed}`); }
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(trimmed);
|
|
85
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Merge relay settings into the caller's inline settings. The safety-critical `hooks` map is
|
|
92
|
+
// UNIONED per event (caller entries first, relay's baseline guard appended) so both fire; every
|
|
93
|
+
// other relay key is fill-if-absent (the caller's explicit value wins), preserving the historical
|
|
94
|
+
// "caller --settings wins" spirit for non-safety settings. Bypass acceptance follows the
|
|
95
|
+
// effective open approval mode: a caller's false value must not strand that launch in a modal.
|
|
96
|
+
function mergeClaudeSettings(caller: Record<string, unknown>, relay: Record<string, unknown>, mainModel: string | undefined): Record<string, unknown> {
|
|
97
|
+
const out: Record<string, unknown> = { ...caller };
|
|
98
|
+
for (const [key, value] of Object.entries(relay)) {
|
|
99
|
+
if (key === "modelSettings") {
|
|
100
|
+
const models = { ...(caller.modelSettings as Record<string, unknown> ?? {}) };
|
|
101
|
+
for (const [model, limits] of Object.entries(value as Record<string, unknown>)) {
|
|
102
|
+
const family = claudeModelFamily(model);
|
|
103
|
+
// Claude canonicalizes aliases and full IDs; enforce child limits without rewriting the main family.
|
|
104
|
+
if (family && family !== claudeModelFamily(mainModel)) for (const name of Object.keys(models)) {
|
|
105
|
+
if (claudeModelFamily(name) === family) models[name] = { ...(models[name] as Record<string, unknown>), ...(limits as Record<string, unknown>) };
|
|
106
|
+
}
|
|
107
|
+
models[model] = limits;
|
|
108
|
+
}
|
|
109
|
+
out.modelSettings = models;
|
|
110
|
+
}
|
|
111
|
+
else if (key === "claudeMdExcludes") out[key] = [...new Set([...(Array.isArray(caller[key]) ? caller[key] as unknown[] : []), ...(value as unknown[])])];
|
|
112
|
+
else if (key === "autoCompactEnabled" || key === "disableAllHooks") out[key] = value;
|
|
113
|
+
else if (key === "enabledPlugins") out[key] = { ...(caller[key] as Record<string, unknown> ?? {}), ...(value as Record<string, unknown>) };
|
|
114
|
+
else if (key === "hooks") out.hooks = mergeHookSets(caller.hooks, value);
|
|
115
|
+
else if (!(key in out) || key === "permissions" || key === "skipDangerousModePermissionPrompt" || key === "idleCompaction") out[key] = key === "permissions" ? mergeClaudeRelayPermissions(out, value).permissions : value;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function mergeHookSets(caller: unknown, relay: unknown): ProvisioningHookSet {
|
|
121
|
+
const base: ProvisioningHookSet = isHookSet(caller) ? { ...caller } : {};
|
|
122
|
+
if (!isHookSet(relay)) return base;
|
|
123
|
+
for (const [event, entries] of Object.entries(relay)) {
|
|
124
|
+
base[event] = [...(base[event] ?? []), ...entries];
|
|
125
|
+
}
|
|
126
|
+
return base;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isHookSet(value: unknown): value is ProvisioningHookSet {
|
|
130
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Strip every `--settings <json>` / `--settings=<json>` occurrence from a provider arg list. The
|
|
134
|
+
// adapter applies this to the caller args once the assembler has folded them into its own merged
|
|
135
|
+
// `--settings` (callerSettingsConsumed), so exactly one `--settings` reaches the launch (#1096).
|
|
136
|
+
export function stripSettingsArgs(args: string[]): string[] {
|
|
137
|
+
const result: string[] = [];
|
|
138
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
139
|
+
const arg = args[i];
|
|
140
|
+
if (arg === "--settings") { i += 1; continue; }
|
|
141
|
+
if (arg?.startsWith("--settings=")) continue;
|
|
142
|
+
if (arg !== undefined) result.push(arg);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function hasSettingsArg(args: string[]): boolean {
|
|
148
|
+
return args.some((arg) => arg === "--settings" || arg.startsWith("--settings="));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Claude relay status-line + readable-thinking settings, injected only when no caller
|
|
152
|
+
// --settings is already present (caller wins). Relocated from claude.ts (#557).
|
|
153
|
+
export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
|
|
154
|
+
if (argLists.some(hasSettingsArg)) return [];
|
|
155
|
+
return ["--settings", JSON.stringify(relayStatusLineSettings())];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function relayStatusLineSettings(): Record<string, unknown> {
|
|
159
|
+
return {
|
|
160
|
+
statusLine: { type: "command", command: `node ${shellQuote(claudeStatusLineProbePath())}`, refreshInterval: 30 },
|
|
161
|
+
showThinkingSummaries: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function claudeStatusLineProbePath(): string {
|
|
166
|
+
return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
|
|
167
|
+
}
|