@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,426 @@
|
|
|
1
|
+
import { extractClaudeModelUnavailableMessage } from "@humwick/sdk";
|
|
2
|
+
import type { PaneObservation, ProviderStatusUpdate } from "../adapter";
|
|
3
|
+
import type { ClaudeClearSessionVerdict, ClaudeProbeActivity } from "./claude-session-probe";
|
|
4
|
+
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
5
|
+
|
|
6
|
+
// LEGACY PANE-SCRAPE FALLBACK (#766/#768) — busy/idle is now driven primarily by
|
|
7
|
+
// Claude Code's intrinsic session probe (readClaudeSessionStatusAsync, ./claude-session-probe):
|
|
8
|
+
// `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` carries a machine-readable `status` field
|
|
9
|
+
// on CC ≥2.1.119. These two pane heuristics are the SOLE fallback for pre-2.1.119 /
|
|
10
|
+
// probe-missing sessions, and claudePaneLooksReady still backs the input-readiness gate
|
|
11
|
+
// (waitForClaudeInputReady) + the prompt-gate busy guard — so they stay. They string-match
|
|
12
|
+
// CC's TUI chrome against captured tmux scrollback (~80 lines), so they break whenever CC
|
|
13
|
+
// restyles its footer/banner. Deliberately substring/regex based (no machine-readable
|
|
14
|
+
// signal existed when these were written). Known break conditions, so the next CC restyle
|
|
15
|
+
// is a fast fix rather than a hunt:
|
|
16
|
+
// readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
|
|
17
|
+
// "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
|
|
18
|
+
// the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
|
|
19
|
+
// busy (claudePaneIsBusy) breaks if CC drops BOTH the live "… (<elapsed>" spinner
|
|
20
|
+
// counter and the 2.1.218 "Thought for <elapsed> (ctrl+o to expand)" activity line
|
|
21
|
+
// (the "esc to interrupt" hint was already dropped in 2.1.x).
|
|
22
|
+
// FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
|
|
23
|
+
// transcript discussing "esc to interrupt", or this very comment shown in a pane)
|
|
24
|
+
// reads as ready. Busy checks gate themselves on the LAST N lines (the live
|
|
25
|
+
// footer), including direct callers such as the prompt-gate guard.
|
|
26
|
+
// History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
|
|
27
|
+
export function claudePaneLooksReady(text: string): boolean {
|
|
28
|
+
// A quoted dialog can remain above a LIVE composer. Its persistent footer is
|
|
29
|
+
// stronger evidence than modal text elsewhere in the visible pane.
|
|
30
|
+
if (text.includes("bypass permissions on") || text.includes("shift+tab to cycle")
|
|
31
|
+
|| text.includes("? for shortcuts") || text.includes("/effort")) return true;
|
|
32
|
+
// The 2.1.282 bypass modal contains both "Claude Code" and "bypass permissions".
|
|
33
|
+
// Those generic readiness witnesses otherwise submit the initial prompt into the
|
|
34
|
+
// acceptance dialog instead of waiting for an input composer.
|
|
35
|
+
if (claudeBypassPermissionDialog(text)) return false;
|
|
36
|
+
// Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
|
|
37
|
+
// conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
|
|
38
|
+
// a run) must detect readiness from the PERSISTENT input-box footer that Claude always
|
|
39
|
+
// renders at the prompt. Requiring the banner made every delivery after the first screen
|
|
40
|
+
// of output time out as "input not ready". Any one of these strong, Claude-specific
|
|
41
|
+
// markers means the TUI is up and the input box exists.
|
|
42
|
+
return text.includes("Welcome back")
|
|
43
|
+
|| text.includes("Claude Code");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The visible 2.1.282 acceptance modal, before Claude has an input composer. */
|
|
47
|
+
export function claudeBypassPermissionDialog(text: string): boolean {
|
|
48
|
+
return /running in Bypass Permissions mode/i.test(text)
|
|
49
|
+
&& /^\s*[❯>]?\s*No, exit\s*$/im.test(text)
|
|
50
|
+
&& /^\s*[❯>]?\s*Yes, I accept\s*$/im.test(text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Adapter-owned copy for the startup modal's display-only blocked state. */
|
|
54
|
+
export function claudeBypassDialogDisplayGate(sessionName: string, observedAt: number): NonNullable<PaneObservation["displayGate"]> {
|
|
55
|
+
return { phase: "show", timelineTitle: "Claude waiting at bypass permissions dialog", providerState: {
|
|
56
|
+
state: "blocked", reason: "permissionRequest", label: "Claude bypass acceptance dialog",
|
|
57
|
+
message: "Claude is waiting for Bypass Permissions acceptance in its terminal.",
|
|
58
|
+
source: `claude-pane:${sessionName}`,
|
|
59
|
+
recommendedAction: "Open the terminal and select Yes, I accept.", enteredAt: observedAt,
|
|
60
|
+
} };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The live spinner: "✶ Perambulating… (2m 17s · ↓ 8.7k tokens)", "… (", optional hook or
|
|
64
|
+
// tool status ("running PostToolUse hook · ", #3026), then "[Nh ][Nm ]Ns". Anchored on the
|
|
65
|
+
// gerund ellipsis so it can't match the "… +N lines (ctrl+o to expand)" truncation marker,
|
|
66
|
+
// the idle input box, or the "/btw … without interrupting Claude's current work" hint.
|
|
67
|
+
const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:[^()\n]*·\s*)?(?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
|
|
68
|
+
// Claude Code 2.1.218 replaced the gerund spinner during its thinking phase with
|
|
69
|
+
// "Thought for 13s (ctrl+o to expand)" (and, after a tool call starts,
|
|
70
|
+
// "Thought for 12s, called callmux (ctrl+o to expand)"). The intrinsic session
|
|
71
|
+
// probe remains the primary, version-robust activity source, but 2.1.218 can leave
|
|
72
|
+
// that probe idle during this phase. Treat this live footer as a busy veto so a
|
|
73
|
+
// monitorless worker cannot false-idle before its first assistant output.
|
|
74
|
+
const CLAUDE_BUSY_THOUGHT_RE = /^\s*Thought for (?:\d+h\s+)?(?:\d+m\s+)?\d+s(?:,\s*called [^(\n]+)?\s*\(ctrl\+o to expand\)\s*$/im;
|
|
75
|
+
const CLAUDE_ACTIVITY_FOOTER_LINES = 10;
|
|
76
|
+
/** A full-row rule; the composer's input rows sit between two of them. */
|
|
77
|
+
export const CLAUDE_COMPOSER_RULE_RE = /^\s*─{10,}\s*$/;
|
|
78
|
+
// Input rows plus the two or three status rows under the composer; a taller composer falls back.
|
|
79
|
+
const CLAUDE_COMPOSER_SEARCH_LINES = 30;
|
|
80
|
+
const CLAUDE_ACTIVITY_BLOCK_MAX_LINES = 40;
|
|
81
|
+
// A notification Claude right-aligns on the row above the composer ("You've used 80% of …").
|
|
82
|
+
const CLAUDE_RIGHT_ALIGNED_NOTICE_RE = /^\s{20,}\S/;
|
|
83
|
+
|
|
84
|
+
interface ClaudePaneRegions {
|
|
85
|
+
/** The composer and its status rows; without a recognizable composer, the last rows. */
|
|
86
|
+
footer: string;
|
|
87
|
+
/** The block right above the composer, where the live spinner or thinking row renders. */
|
|
88
|
+
activity?: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// #2202 — find the composer instead of counting rows from the bottom. Inline rendering
|
|
92
|
+
// (CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN) pads the capture with blank rows below it, and a
|
|
93
|
+
// completed "Thought for …" row can sit within any fixed window above it.
|
|
94
|
+
function claudePaneRegions(text: string): ClaudePaneRegions {
|
|
95
|
+
const lines = text.trimEnd().split("\n");
|
|
96
|
+
const composer = claudeComposerRuleRow(lines);
|
|
97
|
+
if (composer === undefined) return { footer: lines.slice(-CLAUDE_ACTIVITY_FOOTER_LINES).join("\n") };
|
|
98
|
+
let row = composer - 1;
|
|
99
|
+
while (row >= 0 && (!lines[row]!.trim() || CLAUDE_RIGHT_ALIGNED_NOTICE_RE.test(lines[row]!))) row--;
|
|
100
|
+
const activity: string[] = [];
|
|
101
|
+
for (const floor = row - CLAUDE_ACTIVITY_BLOCK_MAX_LINES; row > floor && row >= 0 && lines[row]!.trim(); row--) activity.unshift(lines[row]!);
|
|
102
|
+
return { footer: lines.slice(composer).join("\n"), activity };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The rule row right above the composer's `❯` input row, searched near the bottom of the pane. */
|
|
106
|
+
function claudeComposerRuleRow(lines: string[]): number | undefined {
|
|
107
|
+
for (let i = lines.length - 1; i >= Math.max(1, lines.length - CLAUDE_COMPOSER_SEARCH_LINES); i--) {
|
|
108
|
+
if (lines[i]!.trimStart().startsWith("❯") && CLAUDE_COMPOSER_RULE_RE.test(lines[i - 1]!)) return i - 1;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function claudePaneIsBusy(text: string): boolean {
|
|
114
|
+
return claudeRegionsAreBusy(claudePaneRegions(text));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function claudeRegionsAreBusy({ footer, activity }: ClaudePaneRegions): boolean {
|
|
118
|
+
// Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
|
|
119
|
+
// dropped that hint. Keep both older signals and the 2.1.218 thinking line so
|
|
120
|
+
// the pane veto works across those formats.
|
|
121
|
+
if (footer.includes("esc to interrupt")) return true;
|
|
122
|
+
if (!activity) return CLAUDE_BUSY_SPINNER_RE.test(footer) || CLAUDE_BUSY_THOUGHT_RE.test(footer);
|
|
123
|
+
// Rows rejoin so a counter Claude wrapped at a narrow width still reads (#3026). A thinking
|
|
124
|
+
// row is live only as the block's head: a completed one stays above "⎿ Interrupted" or output.
|
|
125
|
+
return CLAUDE_BUSY_SPINNER_RE.test(activity.join(" ")) || CLAUDE_BUSY_THOUGHT_RE.test(claudeActivityHead(activity));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The block's last row above any tip Claude prints under its spinner. */
|
|
129
|
+
function claudeActivityHead(activity: string[]): string {
|
|
130
|
+
const tip = activity.findIndex((row) => /^\s*⎿\s+Tip:/.test(row));
|
|
131
|
+
return (tip >= 0 ? activity.slice(0, tip) : activity).at(-1) ?? "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// #1588 — persistent TUI chrome that is present at the prompt regardless of whether a
|
|
135
|
+
// conversation is loaded, so it must NEVER be picked as a pre-clear conversation anchor.
|
|
136
|
+
// (Includes the claudePaneLooksReady footer markers plus the box/hint chrome around them.)
|
|
137
|
+
const CLAUDE_CHROME_MARKERS = [
|
|
138
|
+
"bypass permissions",
|
|
139
|
+
"shift+tab to cycle",
|
|
140
|
+
"? for shortcuts",
|
|
141
|
+
"/effort",
|
|
142
|
+
"Welcome back",
|
|
143
|
+
"Claude Code",
|
|
144
|
+
"esc to interrupt",
|
|
145
|
+
"ctrl+o to expand",
|
|
146
|
+
"/btw",
|
|
147
|
+
"Tip:",
|
|
148
|
+
"Tips for getting started",
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
// #1588 — pick a distinctive line from the PRE-clear pane that belongs to the loaded
|
|
152
|
+
// conversation body (not the persistent chrome/footer), so its DISAPPEARANCE after `/clear`
|
|
153
|
+
// is positive evidence the conversation was actually wiped. claudePaneLooksReady alone can't
|
|
154
|
+
// tell "before clear" from "after clear" — its footer markers are present in both — so the
|
|
155
|
+
// self-resume barrier (waitForClaudeClearCommitted) keys off this anchor instead. Returns the
|
|
156
|
+
// longest content-rich, chrome-free line, or undefined when the pane has no such line (e.g. an
|
|
157
|
+
// already-empty conversation) — in which case the clear can't be positively confirmed and the
|
|
158
|
+
// caller falls to continuationDelivered:false so the #1023 backstop covers it.
|
|
159
|
+
export function claudeConversationAnchor(pane: string): string | undefined {
|
|
160
|
+
let best: string | undefined;
|
|
161
|
+
for (const raw of claudeConversationRows(pane)) {
|
|
162
|
+
const line = raw.trim();
|
|
163
|
+
if (line.length < 20) continue;
|
|
164
|
+
if (CLAUDE_CHROME_MARKERS.some((marker) => line.includes(marker))) continue;
|
|
165
|
+
// Require substantial alphanumeric content so a box-border / ASCII-art / punctuation
|
|
166
|
+
// line can never serve as the anchor (those can reappear on the fresh cleared screen).
|
|
167
|
+
const alnum = (line.match(/[A-Za-z0-9]/g) ?? []).length;
|
|
168
|
+
if (alnum < 12) continue;
|
|
169
|
+
if (!best || line.length > best.length) best = line;
|
|
170
|
+
}
|
|
171
|
+
return best;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const CLAUDE_BANNER_RE = /Claude Code v\d/;
|
|
175
|
+
// A turn opens with the user's prompt or an assistant, tool or event bullet.
|
|
176
|
+
const CLAUDE_TURN_ROW_RE = /^\s*[❯>⏺●]/;
|
|
177
|
+
|
|
178
|
+
// #3151 — the rows `/clear` wipes: none of the startup header (banner, model, cwd, tip), which it
|
|
179
|
+
// repaints, and nothing from the composer down, whose status rows change on their own.
|
|
180
|
+
function claudeConversationRows(pane: string): string[] {
|
|
181
|
+
const lines = pane.trimEnd().split("\n");
|
|
182
|
+
const end = claudeComposerRuleRow(lines) ?? lines.length;
|
|
183
|
+
const banner = lines.findIndex((line) => CLAUDE_BANNER_RE.test(line));
|
|
184
|
+
if (banner < 0 || banner >= end) return lines.slice(0, end);
|
|
185
|
+
const start = lines.findIndex((line, row) => row > banner && row < end && CLAUDE_TURN_ROW_RE.test(line));
|
|
186
|
+
return start < 0 ? [] : lines.slice(start, end);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// #1588 — CLEAR-AWARE completion check for the self-resume barrier. The `/clear` keystroke is
|
|
190
|
+
// async in the Claude TUI: submitTextToTmux returns as soon as the keys are typed, and the
|
|
191
|
+
// persistent footer (claudePaneLooksReady) matches the STILL-UNCLEARED pane, so accepting the
|
|
192
|
+
// first ready match let the continuation race the async clear and get wiped (the #1588 window).
|
|
193
|
+
// A clear is treated as COMMITTED only with positive evidence: the pane is (a) not busy (the
|
|
194
|
+
// clear/redraw finished processing), (b) back at the input-ready footer, and (c) no longer shows
|
|
195
|
+
// the pre-clear conversation anchor (the loaded conversation is gone). Any of these missing →
|
|
196
|
+
// NOT committed → keep waiting (and, on timeout, the caller reports continuationDelivered:false
|
|
197
|
+
// so the backstop re-injects — uncertainty resolves toward the backstop, never toward a wipe).
|
|
198
|
+
//
|
|
199
|
+
// #2261 — the anchor test above is the FALLBACK signal, not the primary one, and `currentPane`
|
|
200
|
+
// MUST be a VISIBLE-SCREEN capture (captureTmuxVisiblePaneAsync). Claude's `/clear` repaints only
|
|
201
|
+
// the visible screen, so an anchor that had already scrolled into tmux history stays in a
|
|
202
|
+
// scrollback capture forever and the barrier can never confirm a clear that plainly committed.
|
|
203
|
+
export interface ClaudeClearEvidence {
|
|
204
|
+
/**
|
|
205
|
+
* #2261 — the intrinsic probe's verdict on Claude's session id (./claude-session-probe), ALREADY
|
|
206
|
+
* SETTLED by the caller across a poll interval. Default `unknown` (no probe / pre-2.1.119 /
|
|
207
|
+
* unreadable / liveness-failed / pid changed) keeps the pure anchor behavior.
|
|
208
|
+
*/
|
|
209
|
+
session?: ClaudeClearSessionVerdict;
|
|
210
|
+
/** #2849 — pre-clear pane captured AND anchorless: vacuous clear, so idle+ready
|
|
211
|
+
* suffices. Never set from a failed capture — that stays fail-closed. */
|
|
212
|
+
preClearEmpty?: boolean;
|
|
213
|
+
/** #2849r1/#2850 — the poller saw a CLAUDE_VACUOUS_CLEAR_READY_POLLS idle+ready streak.
|
|
214
|
+
* A ready pane can predate the /clear commit (#1588), and with no probe verdict this
|
|
215
|
+
* streak is the only commit evidence. Set by the poller. */
|
|
216
|
+
settledReady?: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function claudeClearCommitted(
|
|
220
|
+
preClearAnchor: string | undefined,
|
|
221
|
+
currentPane: string,
|
|
222
|
+
evidence: ClaudeClearEvidence = {},
|
|
223
|
+
): boolean {
|
|
224
|
+
if (claudePaneIsBusy(currentPane)) return false;
|
|
225
|
+
if (!claudePaneLooksReady(currentPane)) return false;
|
|
226
|
+
// #2261 PRIMARY signal — machine-readable and version-robust: committing `/clear` mints a NEW
|
|
227
|
+
// Claude session (fresh sessionId + fresh transcript file), so a same-pid session rotation is
|
|
228
|
+
// direct proof the loaded conversation is gone, independent of any TUI chrome.
|
|
229
|
+
//
|
|
230
|
+
// It is authoritative in BOTH directions, and that is what keeps it a STRENGTHENING rather than
|
|
231
|
+
// a relaxation. `unchanged` means the probe positively says the clear has not committed, so it
|
|
232
|
+
// must VETO — never let the (visible-screen-only) anchor heuristic confirm over it, e.g. when
|
|
233
|
+
// unrelated output has merely scrolled the anchor off the screen. The busy/ready gates above
|
|
234
|
+
// still gate both verdicts, so the #1588 ordering property (never inject while the clear is
|
|
235
|
+
// still processing) is untouched. `unknown` is no evidence: it falls through to the anchor test,
|
|
236
|
+
// whose failure still resolves to false → the #1023 backstop re-injects.
|
|
237
|
+
if (evidence.session === "rotated") return true;
|
|
238
|
+
if (evidence.session === "unchanged") return false;
|
|
239
|
+
// #2849r1 — vacuous clear, BELOW the session veto: an unchanged session still
|
|
240
|
+
// refuses, and a lone ready poll may be the pre-clear footer (#1588), so both
|
|
241
|
+
// settledReady (a long consecutive-ready streak, #2850) and preClearEmpty are required.
|
|
242
|
+
if (evidence.preClearEmpty === true && evidence.settledReady === true) return true;
|
|
243
|
+
if (!preClearAnchor) return false;
|
|
244
|
+
return !currentPane.includes(preClearAnchor);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface ClaudeActivityReadOptions {
|
|
248
|
+
/** #1621: the probe's busy assertion predates an interrupt relay sent (claudeProbeVerdict),
|
|
249
|
+
* so it may describe a turn that was cancelled. */
|
|
250
|
+
probeBusyStale?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* #1980 U2 — when the intrinsic session probe is UNAVAILABLE (pre-2.1.119 / unresolvable
|
|
253
|
+
* config-home), may a bare input-ready-looking pane be read as `idle`?
|
|
254
|
+
* true (default) — legacy pane-scrape turn-boundary detection (the monitorless turn-watch):
|
|
255
|
+
* an input-ready footer ends the turn.
|
|
256
|
+
* false — the busy BACKSTOP path: an input-ready-looking footer is NOT positive evidence a
|
|
257
|
+
* turn ended — a long foreground tool renders the exact same prompt box while fully
|
|
258
|
+
* alive (#766/#1980) — so with no probe to corroborate it is SILENCE, reported as
|
|
259
|
+
* `unknown` (the backstop holds the turn) rather than `idle` (which force-cancels it).
|
|
260
|
+
* This is what stops the `no-busy-observed` false-cancel from ever being reached.
|
|
261
|
+
*/
|
|
262
|
+
paneFallbackIsIdle?: boolean;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function claudeActivityFromProbeAndPane(
|
|
266
|
+
probe: ClaudeProbeActivity | undefined,
|
|
267
|
+
pane: string,
|
|
268
|
+
opts: ClaudeActivityReadOptions = {},
|
|
269
|
+
): ClaudeProbeActivity {
|
|
270
|
+
const regions = claudePaneRegions(pane);
|
|
271
|
+
// The pane's live activity is the strongest busy evidence there is and always wins.
|
|
272
|
+
if (claudeRegionsAreBusy(regions)) return "busy";
|
|
273
|
+
const ready = claudePaneLooksReady(regions.footer);
|
|
274
|
+
if (probe === "busy") {
|
|
275
|
+
// Probe-busy is authoritative (#766, #3026). Only after relay's own interrupt (#1621) may a
|
|
276
|
+
// positively input-ready footer overrule it; a shelled-out or unreadable pane keeps it busy.
|
|
277
|
+
return opts.probeBusyStale && ready ? "idle" : "busy";
|
|
278
|
+
}
|
|
279
|
+
// A probe-`idle` is the provider's OWN end-of-turn assertion (positive evidence); a positively
|
|
280
|
+
// input-ready pane corroborates it, an ambiguous pane holds as unknown.
|
|
281
|
+
if (probe === "idle") return ready ? "idle" : "unknown";
|
|
282
|
+
// probe is undefined/unknown here (unavailable, or Claude `waiting`): the pane-scrape fallback.
|
|
283
|
+
// #1980 U2 — an input-ready footer is only trustworthy as turn-end when the caller opts in
|
|
284
|
+
// (paneFallbackIsIdle, the turn-boundary path). For the busy backstop (opt-out) it is silence,
|
|
285
|
+
// held as `unknown`, so a live foreground tool the probe cannot see is never force-cancelled.
|
|
286
|
+
if (ready && (opts.paneFallbackIsIdle ?? true)) return "idle";
|
|
287
|
+
return "unknown";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// #286: detect the subscription usage-limit modal in the pane and turn it into the
|
|
291
|
+
// provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
|
|
292
|
+
// at reset. Unlike the pane model-rejection observation below, it remains a legacy status path.
|
|
293
|
+
export function claudeRateLimitStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
294
|
+
const parsed = parseClaudeRateLimitPane(text);
|
|
295
|
+
if (!parsed) return null;
|
|
296
|
+
return {
|
|
297
|
+
status: "idle",
|
|
298
|
+
clear: ["subagent"],
|
|
299
|
+
providerState: buildRateLimitProviderState({
|
|
300
|
+
errorType: parsed.errorType ?? "session_limit", kind: parsed.kind ?? "usage_limit",
|
|
301
|
+
...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
|
|
302
|
+
message: parsed.message,
|
|
303
|
+
source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
|
|
304
|
+
}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// #769 — detect the API connection-retry banner in the live terminal footer. Three stable
|
|
309
|
+
// anchors from the banner text: "Unable to connect to API", "Retrying in <N>s",
|
|
310
|
+
// "attempt <N>/<M>". Gated on the LAST N lines so agent output that merely QUOTES these
|
|
311
|
+
// strings (e.g. quoting this comment, or a discussion of retry logic) doesn't false-positive —
|
|
312
|
+
// the live banner always lives in the footer; quoted strings appear in the scrollback body.
|
|
313
|
+
// Break condition: if CC renames/removes any anchor substring the detector stops firing — a
|
|
314
|
+
// fast fix once the new footer wording is known, and one with no correctness consequence now
|
|
315
|
+
// that this is advisory.
|
|
316
|
+
//
|
|
317
|
+
// #1651 — this returns a DIAGNOSTIC, not a status. It used to return
|
|
318
|
+
// `status: "idle"` + `clear: ["subagent"]` + a blocked `conn_retry` providerState. The runner
|
|
319
|
+
// treats an idle provider-turn as the turn ENDING (runner-core setProviderStatus), which
|
|
320
|
+
// clears the work record and calls completeObservedTaskClaims(): retry chrome rendered in a
|
|
321
|
+
// pane could therefore COMPLETE THE TASK CLAIMS OF A TURN THAT WAS STILL RUNNING. The footer
|
|
322
|
+
// gate did not prevent this — the banner is genuinely in the footer during a live turn, which
|
|
323
|
+
// is exactly when the detector fired.
|
|
324
|
+
//
|
|
325
|
+
// The turn stays live/unknown throughout the retries. Claude's own retry ladder is long
|
|
326
|
+
// (measured at 179s for a refused connection), but lateness is not permission to infer failure
|
|
327
|
+
// from pane text: only a correlated StopFailure may end the turn, after the native ladder
|
|
328
|
+
// exhausts. Disappearance likewise proves nothing — it only clears the diagnostic flag.
|
|
329
|
+
const CLAUDE_CONN_RETRY_FOOTER_LINES = 10;
|
|
330
|
+
const CLAUDE_CONN_RETRY_ANCHOR_RE = /Unable to connect to API/;
|
|
331
|
+
const CLAUDE_CONN_RETRY_RATE_RE = /Retrying in \d+s/;
|
|
332
|
+
const CLAUDE_CONN_RETRY_ATTEMPT_RE = /attempt \d+\/\d+/;
|
|
333
|
+
|
|
334
|
+
export function claudeConnectionRetryObservation(text: string, sessionName?: string, nowMs?: number): PaneObservation | null {
|
|
335
|
+
const footer = text.split("\n").slice(-CLAUDE_CONN_RETRY_FOOTER_LINES).join("\n");
|
|
336
|
+
if (
|
|
337
|
+
!CLAUDE_CONN_RETRY_ANCHOR_RE.test(footer) ||
|
|
338
|
+
!CLAUDE_CONN_RETRY_RATE_RE.test(footer) ||
|
|
339
|
+
!CLAUDE_CONN_RETRY_ATTEMPT_RE.test(footer)
|
|
340
|
+
) return null;
|
|
341
|
+
return {
|
|
342
|
+
eventType: "claude.pane_connection_retry_candidate",
|
|
343
|
+
source: "pane",
|
|
344
|
+
detectorId: "connection-retry",
|
|
345
|
+
observedAt: nowMs ?? Date.now(),
|
|
346
|
+
...(sessionName ? { sessionName } : {}),
|
|
347
|
+
message: "Pane shows Claude's API connection-retry banner. Advisory only: the turn is unchanged.",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The falling edge. It used to publish a bare `idle` — an unconditional turn-end with no
|
|
352
|
+
* evidence behind it at all. A banner leaving the footer proves nothing about the turn, so
|
|
353
|
+
* this is a diagnostic too: it clears the caller's flag and says so. */
|
|
354
|
+
export function claudeConnectionRetryClearedObservation(sessionName?: string, nowMs?: number): PaneObservation {
|
|
355
|
+
return {
|
|
356
|
+
eventType: "claude.pane_connection_retry_cleared",
|
|
357
|
+
source: "pane",
|
|
358
|
+
detectorId: "connection-retry",
|
|
359
|
+
observedAt: nowMs ?? Date.now(),
|
|
360
|
+
...(sessionName ? { sessionName } : {}),
|
|
361
|
+
message: "Pane no longer shows the connection-retry banner. Advisory only: proves nothing about the turn.",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// #1651 — this returns a DIAGNOSTIC, not a status. It used to return `status: "error"` +
|
|
366
|
+
// `clear: ["provider-turn", "subagent"]` + a TERMINAL failed provider state + suppressed
|
|
367
|
+
// restart from a single pane match — reproduced live: the harmless sentence "The test fixture
|
|
368
|
+
// says: not an available model" triggered every one of those, from a pane, where the matched
|
|
369
|
+
// text may simply be an agent quoting a fixture, a code comment, or this very file.
|
|
370
|
+
//
|
|
371
|
+
// A pane match is never terminal. Only a correlated StopFailure whose native error type
|
|
372
|
+
// explicitly denotes model-not-found/unavailable (not yet wired — see #1651's Direction
|
|
373
|
+
// section) may end a turn this way. This detector only notices.
|
|
374
|
+
//
|
|
375
|
+
// Scoped to the trailing modal/composer structural block: the same positional technique
|
|
376
|
+
// rate-limit.ts's livePaneBlock uses (Claude Code pins its composer to the bottom of the
|
|
377
|
+
// screen, separated from the conversation by a blank row, and draws blocking dialogs in that
|
|
378
|
+
// same spot), reimplemented locally rather than importing that module's private helper. An
|
|
379
|
+
// agent's own scrollback quoting/discussing the rejection text sits above the blank row and
|
|
380
|
+
// so can never reach this scan.
|
|
381
|
+
//
|
|
382
|
+
// Repetition is the caller's job, not this function's: claude.ts requires the SAME message on
|
|
383
|
+
// two consecutive polls before it will even publish the diagnostic — this stays pure so it can
|
|
384
|
+
// be unit tested without threading adapter state through it.
|
|
385
|
+
export function claudeModelUnavailableObservation(text: string, sessionName?: string, nowMs?: number): PaneObservation | null {
|
|
386
|
+
const block = trailingStructuralBlock(text);
|
|
387
|
+
const message = extractClaudeModelUnavailableMessage(block);
|
|
388
|
+
if (!message) return null;
|
|
389
|
+
return {
|
|
390
|
+
eventType: "claude.pane_model_unavailable_candidate",
|
|
391
|
+
source: "pane",
|
|
392
|
+
detectorId: "model-unavailable",
|
|
393
|
+
observedAt: nowMs ?? Date.now(),
|
|
394
|
+
...(sessionName ? { sessionName } : {}),
|
|
395
|
+
message,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface ClaudeModelUnavailableObservationState {
|
|
400
|
+
message?: string;
|
|
401
|
+
reported: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function initialClaudeModelUnavailableObservationState(): ClaudeModelUnavailableObservationState {
|
|
405
|
+
return { reported: false };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Require two identical reads before a pane candidate reaches diagnostic telemetry. */
|
|
409
|
+
export function debounceClaudeModelUnavailableObservation(
|
|
410
|
+
observation: PaneObservation | null,
|
|
411
|
+
state: ClaudeModelUnavailableObservationState,
|
|
412
|
+
): { state: ClaudeModelUnavailableObservationState; observation?: PaneObservation } {
|
|
413
|
+
if (!observation?.message) return { state: initialClaudeModelUnavailableObservationState() };
|
|
414
|
+
if (state.message !== observation.message) return { state: { message: observation.message, reported: false } };
|
|
415
|
+
if (state.reported) return { state };
|
|
416
|
+
return { state: { ...state, reported: true }, observation };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function trailingStructuralBlock(text: string): string {
|
|
420
|
+
const lines = text.split(/\r?\n/);
|
|
421
|
+
let end = lines.length;
|
|
422
|
+
while (end > 0 && lines[end - 1]!.trim() === "") end--;
|
|
423
|
+
let start = end;
|
|
424
|
+
while (start > 0 && lines[start - 1]!.trim() !== "") start--;
|
|
425
|
+
return lines.slice(start, end).join("\n");
|
|
426
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
|
|
2
|
+
import { harnessSubagentsEnabled } from "@humwick/sdk";
|
|
3
|
+
import { claudeModelFamily, extractCallerSettings } from "../claude-settings";
|
|
4
|
+
import { shellQuote } from "@humwick/sdk/shell-utils";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import type { ProvisioningHookSet } from "@humwick/sdk";
|
|
7
|
+
import type { RunnerSpawnConfig, SpawnArgs } from "../adapter";
|
|
8
|
+
import { claudeVersionAtLeast } from "./claude-session-probe";
|
|
9
|
+
|
|
10
|
+
export function claudeSubagentPolicy(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">) {
|
|
11
|
+
const options = config.agentProfile?.providerOptions?.claude;
|
|
12
|
+
if (!config.agentProfile || options?.subagentModel === null) return undefined;
|
|
13
|
+
return { model: options?.subagentModel ?? "sonnet", effort: options?.subagentMaxEffort ?? "high", contextTokens: options?.subagentContextTokens ?? 200_000 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function claudeSubagentEnv(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">): Record<string, string> {
|
|
17
|
+
const policy = claudeSubagentPolicy(config);
|
|
18
|
+
const enabled = writeChildEnv({}, { AGENT_RELAY_HARNESS_SUBAGENTS: harnessSubagentsEnabled(config) ? "1" : "0" });
|
|
19
|
+
return policy ? writeChildEnv({}, { ...enabled,
|
|
20
|
+
CLAUDE_CODE_SUBAGENT_MODEL: policy.model,
|
|
21
|
+
CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "1",
|
|
22
|
+
CLAUDE_CODE_FORK_SUBAGENT: "0",
|
|
23
|
+
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "0",
|
|
24
|
+
CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
|
|
25
|
+
AGENT_RELAY_CLAUDE_SUBAGENT_CAP: "1",
|
|
26
|
+
}) : writeChildEnv({}, { ...enabled, AGENT_RELAY_CLAUDE_SUBAGENT_CAP: "0" });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function claudeSubagentSettings(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile" | "model">): Record<string, unknown> {
|
|
30
|
+
const policy = claudeSubagentPolicy(config);
|
|
31
|
+
if (!policy) return harnessSubagentsEnabled(config) ? {} : { disableAllHooks: false };
|
|
32
|
+
const mainFamily = claudeModelFamily(config.model);
|
|
33
|
+
// Model settings apply to both parent and children, including canonicalized aliases (#3424).
|
|
34
|
+
const modelSettings = mainFamily && mainFamily !== policy.model
|
|
35
|
+
? { [policy.model]: { maxEffortLevel: policy.effort, autoCompactWindow: policy.contextTokens } } : undefined;
|
|
36
|
+
return { disableAllHooks: false, ...(modelSettings ? { modelSettings } : {}), autoCompactEnabled: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Always supplied through settings, including profiles with the Relay plugin switched off.
|
|
40
|
+
export function claudeSubagentHooks(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">): ProvisioningHookSet {
|
|
41
|
+
const stop = { SubagentStop: [{ hooks: [{ type: "command" as const, command: `bash "${resolve(import.meta.dir, "../../plugins/claude/hooks/subagent-stop.sh")}"`, timeout: 5 }] }] };
|
|
42
|
+
if (harnessSubagentsEnabled(config) && !claudeSubagentPolicy(config)) return stop;
|
|
43
|
+
const guard = { type: "command" as const, command: `bun ${shellQuote(resolve(import.meta.dir, "../../plugins/claude/hooks/subagent-guard.cjs"))}`, timeout: 5, onFailure: "block" as const };
|
|
44
|
+
return { ...stop,
|
|
45
|
+
PreToolUse: [{ matcher: "Agent|Task|Skill|TeamCreate|TeamDelete|SendMessage|Workflow", hooks: [guard] }],
|
|
46
|
+
UserPromptExpansion: [{ hooks: [guard] }],
|
|
47
|
+
UserPromptSubmit: [{ hooks: [guard] }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function assertClaudeSubagentVersion(version: string): void {
|
|
52
|
+
if (!claudeVersionAtLeast(version, "2.1.296")) throw new Error(`Claude sub-agent caps require Claude Code >=2.1.296; installed: ${version || "unknown"}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function verifyClaudeSubagentVersion(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile" | "env" | "cwd">): Promise<void> {
|
|
56
|
+
if (!claudeSubagentPolicy(config)) return;
|
|
57
|
+
const child = Bun.spawn(["claude", "--version"], { cwd: config.cwd, env: config.env, stdout: "pipe", stderr: "pipe", timeout: 10_000 });
|
|
58
|
+
const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
59
|
+
if (exit !== 0) throw new Error(`Cannot verify Claude sub-agent cap support: ${stderr.trim() || exit}`);
|
|
60
|
+
assertClaudeSubagentVersion(stdout.trim());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pass source locations to the runtime resolver; never inspect skill definitions at launch.
|
|
64
|
+
export function configureClaudeSubagentLaunch(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">, launch: Pick<SpawnArgs, "args" | "env" | "cwd">): void {
|
|
65
|
+
if (harnessSubagentsEnabled(config) && !claudeSubagentPolicy(config)) return;
|
|
66
|
+
const values = (flag: string, variadic = false) => {
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (let i = 0; i < launch.args.length; i++) {
|
|
69
|
+
const arg = launch.args[i]!;
|
|
70
|
+
if (arg.startsWith(`${flag}=`)) out.push(arg.slice(flag.length + 1));
|
|
71
|
+
else if (arg === flag) do {
|
|
72
|
+
if (!launch.args[i + 1] || launch.args[i + 1]!.startsWith("-")) break;
|
|
73
|
+
out.push(launch.args[++i]!);
|
|
74
|
+
} while (variadic);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
if (launch.args.some((arg) => /^--(?:bare|safe-mode|autocompact)(?:=|$)/.test(arg))) {
|
|
79
|
+
throw new Error("Capped Claude profiles cannot use --bare, --safe-mode or --autocompact; they suppress mandatory hooks or override compaction policy");
|
|
80
|
+
}
|
|
81
|
+
if (["CLAUDE_CODE_SIMPLE", "CLAUDE_CODE_SAFE_MODE"].some((key) => ["1", "true"].includes(relayEnv(launch.env, key) ?? ""))) throw new Error("Capped Claude profiles cannot suppress mandatory hooks through ambient bare or safe mode");
|
|
82
|
+
const sources = (values("--setting-sources").at(-1) ?? "user,project,local").split(",");
|
|
83
|
+
if (!sources.includes("user")) throw new Error("Capped Claude profiles require user settings to load mandatory hooks");
|
|
84
|
+
const settings = extractCallerSettings([launch.args], launch.cwd)?.settings ?? {};
|
|
85
|
+
const callerEnv = settings.env as Record<string, unknown> | undefined;
|
|
86
|
+
const protectedEnv = [...Object.keys(claudeSubagentEnv(config)), "CLAUDE_CODE_SIMPLE", "CLAUDE_CODE_SAFE_MODE", "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "DISABLE_AUTO_COMPACT", "AGENT_RELAY_CLAUDE_SKILL_SOURCES"];
|
|
87
|
+
if (callerEnv && protectedEnv.some((key) => key in callerEnv)) throw new Error("Capped Claude profiles cannot override child policy through --settings env");
|
|
88
|
+
writeChildEnv(launch.env, { AGENT_RELAY_CLAUDE_SKILL_SOURCES: JSON.stringify({ cwd: launch.cwd,
|
|
89
|
+
sources,
|
|
90
|
+
directories: values("--add-dir", true), plugins: values("--plugin-dir", true),
|
|
91
|
+
settings: { enabledPlugins: settings.enabledPlugins, skillOverrides: settings.skillOverrides },
|
|
92
|
+
}) });
|
|
93
|
+
}
|