@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,414 @@
|
|
|
1
|
+
// #766/#768 — Claude Code's intrinsic, machine-readable session probe.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code ≥2.1.119 writes `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` and rewrites
|
|
4
|
+
// it on every status transition. It carries an authoritative `status` field
|
|
5
|
+
// (busy|shell|idle|waiting, plus `waitingFor` when waiting) — the very
|
|
6
|
+
// "machine-readable ready/busy signal" the old pane-scrape comment claimed didn't
|
|
7
|
+
// exist. The runner manages the claude pid and its CLAUDE_CONFIG_DIR, so this is a
|
|
8
|
+
// direct keyed read — no enumeration, no cwd/solo-multi disambiguation.
|
|
9
|
+
//
|
|
10
|
+
// This is the single home (DRY) for reading + version-gating + mapping that probe.
|
|
11
|
+
// `probeActivity`, the BusyReconciler backstop, and the monitorless turn-watch all
|
|
12
|
+
// delegate here; the legacy pane-scrape stays only as the graceful fallback for
|
|
13
|
+
// pre-2.1.119 / probe-missing sessions (e.g. a claude-rig host whose real config
|
|
14
|
+
// home the runner can't resolve). Schema is reverse-engineered (pbauermeister/
|
|
15
|
+
// claude-busy-monitor), so we gate on `version`/presence and degrade gracefully if
|
|
16
|
+
// Anthropic ever drifts it.
|
|
17
|
+
import { readFile, stat } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { isPidAlive } from "@humwick/sdk/process-utils";
|
|
21
|
+
import type { ManagedProcess } from "../adapter";
|
|
22
|
+
import { resolveClaudePanePidAsync } from "./tmux-offload";
|
|
23
|
+
|
|
24
|
+
/** Raw probe status; "unknown" = absent / unparseable / version-gated out. */
|
|
25
|
+
export type ClaudeProbeStatus = "busy" | "shell" | "idle" | "waiting" | "unknown";
|
|
26
|
+
|
|
27
|
+
/** The 3-valued activity the reconciler + monitorless turn-watch consume. */
|
|
28
|
+
export type ClaudeProbeActivity = "busy" | "idle" | "unknown";
|
|
29
|
+
|
|
30
|
+
/** First Claude Code version that writes the session probe. Below this → fallback. */
|
|
31
|
+
export const CLAUDE_PROBE_MIN_VERSION = "2.1.119";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The probe half of an activity read: the mapped 3-valued activity (undefined when the probe is
|
|
35
|
+
* absent/old/unparseable → the caller falls back to the pane) plus `busyStale`: the busy verdict
|
|
36
|
+
* predates a turn-cancelling keystroke relay itself sent (#1621). Age never discredits it (#3026):
|
|
37
|
+
* Claude writes neither probe nor transcript during a long think, so a frozen busy is held and the
|
|
38
|
+
* notify-only prolonged-hold watchdog surfaces a turn that really died out of band.
|
|
39
|
+
*/
|
|
40
|
+
export function claudeProbeVerdict(
|
|
41
|
+
reading: ClaudeSessionReading | undefined,
|
|
42
|
+
opts: { paneInterruptAt?: number; lastActivityAt?: number } = {},
|
|
43
|
+
): { activity: ClaudeProbeActivity | undefined; busyStale: boolean } {
|
|
44
|
+
const activity = reading?.available ? claudeProbeActivity(reading.status) : undefined;
|
|
45
|
+
if (activity !== "busy" || !reading || opts.paneInterruptAt === undefined || reading.updatedAt === null) {
|
|
46
|
+
return { activity, busyStale: false };
|
|
47
|
+
}
|
|
48
|
+
// #1642 — a transcript append after the interrupt means the cancel did not take.
|
|
49
|
+
const cancelDidNotTake = opts.lastActivityAt !== undefined && opts.lastActivityAt > opts.paneInterruptAt;
|
|
50
|
+
return { activity, busyStale: reading.updatedAt <= opts.paneInterruptAt && !cancelDidNotTake };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ClaudeSessionReading {
|
|
54
|
+
/** Raw probe status, or "unknown" when the probe is absent/old/unparseable. */
|
|
55
|
+
status: ClaudeProbeStatus;
|
|
56
|
+
/** `waitingFor` reason when status==="waiting" (e.g. "approve Bash"), else null. */
|
|
57
|
+
waitingFor: string | null;
|
|
58
|
+
/** Probe `version`, or null when unavailable. */
|
|
59
|
+
version: string | null;
|
|
60
|
+
/** `procStart` (process start-time token: `/proc/<pid>/stat` field 22, or UTC `ps` lstart off Linux), or null. */
|
|
61
|
+
procStart: string | null;
|
|
62
|
+
/** `updatedAt` epoch-ms of the last probe write, or null. */
|
|
63
|
+
updatedAt: number | null;
|
|
64
|
+
/** Claude's own session UUID (#1235) — names the transcript file. Null when absent. */
|
|
65
|
+
sessionId: string | null;
|
|
66
|
+
/** The session's cwd (#1235) — slugified into the transcript's project dir. Null when absent. */
|
|
67
|
+
cwd: string | null;
|
|
68
|
+
/** True iff a present, parseable, version-gated probe produced `status`. */
|
|
69
|
+
available: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const UNAVAILABLE: ClaudeSessionReading = { status: "unknown", waitingFor: null, version: null, procStart: null, updatedAt: null, sessionId: null, cwd: null, available: false };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Process-liveness seam for the staleness guard (#769 P1-B), injectable in tests.
|
|
76
|
+
* procStartOf — the start-time token of a LIVE pid (to compare against the probe's
|
|
77
|
+
* `procStart`), or null when the pid is dead/unreadable. Catches both a frozen
|
|
78
|
+
* probe from a SIGKILLed claude AND a sub-second pid-reuse window (a reused pid
|
|
79
|
+
* has a different start time).
|
|
80
|
+
* pidAlive — liveness fallback for probes that predate `procStart`.
|
|
81
|
+
*/
|
|
82
|
+
export interface ClaudeProbeLivenessAsync {
|
|
83
|
+
procStartOf(pid: number): Promise<string | null>;
|
|
84
|
+
pidAlive(pid: number): boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** field 22 (starttime) of a `/proc/<pid>/stat` body; comm (field 2) may hold spaces and ')'. */
|
|
88
|
+
function procStatStartTime(stat: string): string | null {
|
|
89
|
+
return stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/)[19] ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// #1838 rows 14/15 — off-loop: the 4s reconciler / 1.5s turn-watch tick never blocks on the read.
|
|
93
|
+
async function readProcStartTimeAsync(pid: number): Promise<string | null> {
|
|
94
|
+
try {
|
|
95
|
+
return procStatStartTime(await readFile(`/proc/${pid}/stat`, "utf8"));
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// #3151 — off Linux, Claude stamps procStart as `ps -o lstart=` in UTC ("Sun Oct 25 13:01:19 2026").
|
|
102
|
+
const PS_LSTART_TIMEOUT_MS = 5_000;
|
|
103
|
+
const psLstartArgv = (pid: number): string[] => ["ps", "-o", "lstart=", "-p", String(pid)];
|
|
104
|
+
const psLstartEnv = (): Record<string, string | undefined> => ({ ...process.env, TZ: "UTC", LC_ALL: "C" });
|
|
105
|
+
|
|
106
|
+
/** Whitespace-normalized start-time token; `ps` pads lstart, and pads a one-digit day. */
|
|
107
|
+
function procStartToken(raw: string | null | undefined): string | null {
|
|
108
|
+
const token = raw?.replace(/\s+/g, " ").trim();
|
|
109
|
+
return token || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readPsLstartUtcAsync(pid: number): Promise<string | null> {
|
|
113
|
+
try {
|
|
114
|
+
const child = Bun.spawn(psLstartArgv(pid), { env: psLstartEnv(), stdout: "pipe", stderr: "ignore", timeout: PS_LSTART_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
115
|
+
const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
116
|
+
return exitCode === 0 ? procStartToken(stdout) : null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Default liveness. On Linux `procStart` in the probe is `/proc/<pid>/stat` field 22
|
|
123
|
+
// (starttime, clock-ticks since boot) — verified equal live on CC 2.1.193; elsewhere it is UTC `ps` lstart.
|
|
124
|
+
// NB: we deliberately do NOT gate on `updatedAt` freshness — Claude only rewrites
|
|
125
|
+
// the probe on a status *transition*, so a legitimately busy turn keeps a frozen
|
|
126
|
+
// `updatedAt` for its whole duration (verified: a 90s+ busy turn never rewrote it).
|
|
127
|
+
// A staleness threshold would force a long busy turn back to the pane-scrape and
|
|
128
|
+
// reintroduce the #766 idle-while-working bug; pid-liveness + procStart cover the
|
|
129
|
+
// SIGKILL-frozen and pid-reuse cases without that hazard. `available` is not
|
|
130
|
+
// time-gated, and neither is the busy verdict (#3026, see claudeProbeVerdict).
|
|
131
|
+
/** The off-loop probe liveness check for `platform`: `/proc` on Linux, UTC `ps` lstart elsewhere. */
|
|
132
|
+
export function claudeProbeLivenessAsync(
|
|
133
|
+
platform: string = process.platform,
|
|
134
|
+
lstart: (pid: number) => Promise<string | null> = readPsLstartUtcAsync,
|
|
135
|
+
): ClaudeProbeLivenessAsync {
|
|
136
|
+
return { procStartOf: platform === "linux" ? readProcStartTimeAsync : lstart, pidAlive: isPidAlive };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const DEFAULT_LIVENESS_ASYNC = claudeProbeLivenessAsync();
|
|
140
|
+
|
|
141
|
+
/** Whether a probe's recorded procStart names the process that owns `pid` now. */
|
|
142
|
+
function procStartMatches(recorded: string, live: string | null): boolean {
|
|
143
|
+
return live !== null && procStartToken(recorded) === procStartToken(live);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Stable process-incarnation token for a pane PID. A session probe's own procStart only
|
|
148
|
+
* proves that probe agrees with whichever process owns the number *now*; monitorless watches
|
|
149
|
+
* must instead pin the pane incarnation they started with so PID reuse cannot cross sessions.
|
|
150
|
+
*/
|
|
151
|
+
export async function claudeProcessIncarnationAsync(pid: number): Promise<string | undefined> {
|
|
152
|
+
return (await DEFAULT_LIVENESS_ASYNC.procStartOf(pid)) ?? undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The per-session probe path the runner reads directly (keyed by claude's pid). */
|
|
156
|
+
export function claudeSessionProbePath(configHome: string, pid: number): string {
|
|
157
|
+
return join(configHome, "sessions", `${pid}.json`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Dotted-version compare; true iff `version` >= `min`. Missing/non-numeric → gate closed. */
|
|
161
|
+
export function claudeVersionAtLeast(version: string | null | undefined, min: string): boolean {
|
|
162
|
+
if (!version) return false;
|
|
163
|
+
const actual = version.split(".");
|
|
164
|
+
const wanted = min.split(".");
|
|
165
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
166
|
+
const x = Number.parseInt(actual[i] ?? "0", 10);
|
|
167
|
+
const y = Number.parseInt(wanted[i] ?? "0", 10);
|
|
168
|
+
if (Number.isNaN(x)) return false; // unparseable build → fall back, don't trust the probe
|
|
169
|
+
if (x !== y) return x > y;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeStatus(value: unknown): ClaudeProbeStatus {
|
|
175
|
+
switch (value) {
|
|
176
|
+
case "busy":
|
|
177
|
+
case "shell":
|
|
178
|
+
case "idle":
|
|
179
|
+
case "waiting":
|
|
180
|
+
return value;
|
|
181
|
+
default:
|
|
182
|
+
return "unknown";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Pure: parse a probe JSON body + version-gate it. Malformed / old / unknown status → unavailable. */
|
|
187
|
+
export function parseClaudeSessionProbe(raw: string, minVersion = CLAUDE_PROBE_MIN_VERSION): ClaudeSessionReading {
|
|
188
|
+
let parsed: unknown;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(raw);
|
|
191
|
+
} catch {
|
|
192
|
+
return UNAVAILABLE;
|
|
193
|
+
}
|
|
194
|
+
if (!parsed || typeof parsed !== "object") return UNAVAILABLE;
|
|
195
|
+
const probe = parsed as Record<string, unknown>;
|
|
196
|
+
const version = typeof probe.version === "string" ? probe.version : null;
|
|
197
|
+
const procStart = typeof probe.procStart === "string" ? probe.procStart : null;
|
|
198
|
+
const updatedAt = typeof probe.updatedAt === "number" ? probe.updatedAt : null;
|
|
199
|
+
const sessionId = typeof probe.sessionId === "string" && probe.sessionId.trim() ? probe.sessionId : null;
|
|
200
|
+
const cwd = typeof probe.cwd === "string" && probe.cwd.trim() ? probe.cwd : null;
|
|
201
|
+
if (!claudeVersionAtLeast(version, minVersion)) return { ...UNAVAILABLE, version, procStart, updatedAt, sessionId, cwd };
|
|
202
|
+
const status = normalizeStatus(probe.status);
|
|
203
|
+
if (status === "unknown") return { status: "unknown", waitingFor: null, version, procStart, updatedAt, sessionId, cwd, available: false };
|
|
204
|
+
const waitingFor = typeof probe.waitingFor === "string" && probe.waitingFor.trim() ? probe.waitingFor : null;
|
|
205
|
+
return { status, waitingFor, version, procStart, updatedAt, sessionId, cwd, available: true };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Read + parse the probe for `pid` under `configHome`. Missing file (older CC, a config home the
|
|
210
|
+
* runner can't resolve, or claude not yet started) → unavailable, so the caller falls back to the
|
|
211
|
+
* pane-scrape. Off-loop (#1838): every caller is a timer tick or the async `/exit` correlator.
|
|
212
|
+
*/
|
|
213
|
+
export async function readClaudeSessionStatusAsync(
|
|
214
|
+
configHome: string,
|
|
215
|
+
pid: number,
|
|
216
|
+
opts: { minVersion?: string; liveness?: ClaudeProbeLivenessAsync } = {},
|
|
217
|
+
): Promise<ClaudeSessionReading> {
|
|
218
|
+
let raw: string;
|
|
219
|
+
try {
|
|
220
|
+
raw = await readFile(claudeSessionProbePath(configHome, pid), "utf8");
|
|
221
|
+
} catch {
|
|
222
|
+
return UNAVAILABLE;
|
|
223
|
+
}
|
|
224
|
+
const reading = parseClaudeSessionProbe(raw, opts.minVersion ?? CLAUDE_PROBE_MIN_VERSION);
|
|
225
|
+
if (!reading.available) return reading;
|
|
226
|
+
// #769 P1-B: a frozen probe from a SIGKILLed claude (or a reused pid) would report a stale
|
|
227
|
+
// busy/shell forever. Verify the pid still belongs to the same process.
|
|
228
|
+
const live = opts.liveness ?? DEFAULT_LIVENESS_ASYNC;
|
|
229
|
+
if (reading.procStart) {
|
|
230
|
+
if (!procStartMatches(reading.procStart, await live.procStartOf(pid))) return { ...reading, status: "unknown", available: false };
|
|
231
|
+
} else if (!live.pidAlive(pid)) {
|
|
232
|
+
return { ...reading, status: "unknown", available: false };
|
|
233
|
+
}
|
|
234
|
+
return reading;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface ManagedClaudeProbeArgs {
|
|
238
|
+
pid?: number;
|
|
239
|
+
tmuxSession?: string;
|
|
240
|
+
tmuxSocket?: string;
|
|
241
|
+
configHomeEnv?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The probe-lookup args for a tmux-launched Claude ManagedProcess. One home for the
|
|
246
|
+
* pid/session/socket/config-home tuple every caller of {@link readManagedClaudeStatusAsync}
|
|
247
|
+
* needs to assemble — the CLAUDE_CONFIG_DIR dig in particular was already being repeated.
|
|
248
|
+
*/
|
|
249
|
+
export function claudeProbeArgsFor(process: ManagedProcess, tmuxSession: string, tmuxSocket?: string): ManagedClaudeProbeArgs {
|
|
250
|
+
const env = process.meta?.env as Record<string, string | undefined> | undefined;
|
|
251
|
+
return { pid: process.pid, tmuxSession, tmuxSocket, configHomeEnv: env?.CLAUDE_CONFIG_DIR };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* #2261 — a clear-commit probe reading: Claude's own session UUID together with the pid it was
|
|
256
|
+
* read for. A committed `/clear` mints a NEW session, so a rotation of `sessionId` OBSERVED FOR
|
|
257
|
+
* THE SAME PID is machine-readable proof the loaded conversation is gone.
|
|
258
|
+
*/
|
|
259
|
+
export interface ClaudeClearSessionReading { sessionId: string; pid: number }
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read one, or null for every "could not find out" outcome: no resolvable pid, no probe file, a
|
|
263
|
+
* version-gated-out or liveness-failed reading (`available === false`, which is what rules out a
|
|
264
|
+
* SIGKILL-frozen probe and a reused pid — #769 P1-B), or a probe carrying no sessionId at all.
|
|
265
|
+
* Deliberately total (never throws, never distinguishes flavours of absence): its only consumer
|
|
266
|
+
* treats anything short of a confirmed same-pid rotation as no evidence.
|
|
267
|
+
*/
|
|
268
|
+
export async function readClaudeClearSessionAsync(args: ManagedClaudeProbeArgs): Promise<ClaudeClearSessionReading | null> {
|
|
269
|
+
try {
|
|
270
|
+
const managed = await readManagedClaudeStatusAsync(args);
|
|
271
|
+
if (!managed?.reading.available || !managed.reading.sessionId) return null;
|
|
272
|
+
return { sessionId: managed.reading.sessionId, pid: managed.pid };
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* #2261 — bind a clear-commit reader to ONE pane pid, resolved up front.
|
|
280
|
+
*
|
|
281
|
+
* The barrier polls ~50 times inside its 10s budget. Re-resolving the pane pid per poll spawned a
|
|
282
|
+
* `tmux display-message` each time (the pid is undefined for a tmux launch), and — worse — let the
|
|
283
|
+
* pid CHANGE mid-wait: a restarted claude whose predecessor's `sessions/<pid>.json` still exists
|
|
284
|
+
* would surface a different sessionId and read as a rotation. Pinning the pid makes both go away;
|
|
285
|
+
* `undefined` (no resolvable pid) means no probe evidence is available at all.
|
|
286
|
+
*/
|
|
287
|
+
export async function bindClaudeClearSessionReader(
|
|
288
|
+
process: ManagedProcess,
|
|
289
|
+
tmuxSession: string,
|
|
290
|
+
tmuxSocket?: string,
|
|
291
|
+
): Promise<(() => Promise<ClaudeClearSessionReading | null>) | undefined> {
|
|
292
|
+
const pid = await resolveClaudePanePidAsync(process.pid, tmuxSession, tmuxSocket);
|
|
293
|
+
if (pid === undefined) return undefined;
|
|
294
|
+
const args = { ...claudeProbeArgsFor(process, tmuxSession, tmuxSocket), pid };
|
|
295
|
+
return () => readClaudeClearSessionAsync(args);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** What the probe says about whether the `/clear` committed. `unknown` = no usable evidence. */
|
|
299
|
+
export type ClaudeClearSessionVerdict = "rotated" | "unchanged" | "unknown";
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* #2261 — compare a pre-clear reading against a current one. Both must be present AND describe the
|
|
303
|
+
* SAME pid; anything else is `unknown`, never a verdict. A `rotated` verdict is positive proof the
|
|
304
|
+
* clear committed; an `unchanged` one is positive proof it has NOT (yet) — the probe is
|
|
305
|
+
* authoritative in both directions whenever it is readable on both sides.
|
|
306
|
+
*/
|
|
307
|
+
export function claudeClearSessionVerdict(
|
|
308
|
+
preClear: ClaudeClearSessionReading | null | undefined,
|
|
309
|
+
current: ClaudeClearSessionReading | null | undefined,
|
|
310
|
+
): ClaudeClearSessionVerdict {
|
|
311
|
+
if (!preClear || !current || current.pid !== preClear.pid) return "unknown";
|
|
312
|
+
return current.sessionId === preClear.sessionId ? "unchanged" : "rotated";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Resolve the managed claude's pid (direct, or the tmux pane pid: the launcher `exec`s claude) and
|
|
317
|
+
* config home, then read its probe. undefined when the pid can't be resolved.
|
|
318
|
+
*/
|
|
319
|
+
export async function readManagedClaudeStatusAsync(
|
|
320
|
+
args: ManagedClaudeProbeArgs,
|
|
321
|
+
liveness?: ClaudeProbeLivenessAsync,
|
|
322
|
+
): Promise<{ reading: ClaudeSessionReading; pid: number; configHome: string } | undefined> {
|
|
323
|
+
const pid = await resolveClaudePanePidAsync(args.pid, args.tmuxSession, args.tmuxSocket);
|
|
324
|
+
if (pid === undefined) return undefined;
|
|
325
|
+
const configHome = claudeConfigHome(args.configHomeEnv);
|
|
326
|
+
return { reading: await readClaudeSessionStatusAsync(configHome, pid, { liveness }), pid, configHome };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The transcript JSONL path Claude Code writes for a session (#1235). Claude stores
|
|
331
|
+
* per-cwd transcripts at `<configHome>/projects/<cwd-slug>/<sessionId>.jsonl`, where
|
|
332
|
+
* the slug replaces every non-alphanumeric char in the absolute cwd with `-` (verified
|
|
333
|
+
* live on CC 2.1.205: `/home/u/p/.agent-relay/x` → `-home-u-p--agent-relay-x`). This is
|
|
334
|
+
* the hookless path the monitorless turn-watch feeds to the transcript reasoning tail so
|
|
335
|
+
* isolated/lean Claude workers (no Stop/UserPromptSubmit hooks) still mirror chat.
|
|
336
|
+
*/
|
|
337
|
+
/** The config home a Claude process uses: its CLAUDE_CONFIG_DIR, the runner's, or the host default. */
|
|
338
|
+
export function claudeConfigHome(configHomeEnv?: string): string {
|
|
339
|
+
return configHomeEnv || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function claudeTranscriptPath(configHome: string, cwd: string, sessionId: string): string {
|
|
343
|
+
return join(configHome, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* #1642 — the transcript's last-modified time in epoch-ms, or undefined when it can't be read
|
|
348
|
+
* (absent/old probe with no sessionId+cwd, or the file not yet created). Claude appends to this
|
|
349
|
+
* JSONL on every turn event, so its mtime is the runner's hookless "events still flowing"
|
|
350
|
+
* heartbeat — the signal that keeps an actively-streaming turn's frozen busy probe from being
|
|
351
|
+
* aged out to the pane. A failed stat is silence, never a claim the turn ended: undefined leaves
|
|
352
|
+
* #1621 untouched.
|
|
353
|
+
*
|
|
354
|
+
* #1838 rows 14/15 — the only caller (the busy reconciler / turn watch, via activityFromProbeOrPane)
|
|
355
|
+
* is now off the event loop, so this is the sole variant and uses `fs/promises`. (The synchronous
|
|
356
|
+
* version was removed in the #1838 round-3 review: it had no production callers once the hot path
|
|
357
|
+
* went async — see the critical-loop ratchet note for claude-session-probe.ts.)
|
|
358
|
+
*/
|
|
359
|
+
export async function claudeTranscriptMtimeMsAsync(
|
|
360
|
+
reading: Pick<ClaudeSessionReading, "sessionId" | "cwd"> | undefined,
|
|
361
|
+
configHome: string | undefined,
|
|
362
|
+
): Promise<number | undefined> {
|
|
363
|
+
const sessionId = reading?.sessionId;
|
|
364
|
+
const cwd = reading?.cwd;
|
|
365
|
+
if (!sessionId || !cwd || !configHome) return undefined;
|
|
366
|
+
try {
|
|
367
|
+
return (await stat(claudeTranscriptPath(configHome, cwd, sessionId))).mtimeMs;
|
|
368
|
+
} catch {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Resolve the live transcript path for the managed claude via its session probe (#1235): the
|
|
375
|
+
* probe carries `sessionId` + `cwd`, and the runner knows the config home. undefined when the
|
|
376
|
+
* probe is absent/old or the pid can't be resolved (graceful degrade: no native capture).
|
|
377
|
+
*/
|
|
378
|
+
export async function resolveManagedClaudeTranscriptPathAsync(args: {
|
|
379
|
+
pid?: number;
|
|
380
|
+
tmuxSession?: string;
|
|
381
|
+
tmuxSocket?: string;
|
|
382
|
+
configHomeEnv?: string;
|
|
383
|
+
}): Promise<string | undefined> {
|
|
384
|
+
const managed = await readManagedClaudeStatusAsync(args);
|
|
385
|
+
if (!managed) return undefined;
|
|
386
|
+
const { sessionId, cwd } = managed.reading;
|
|
387
|
+
if (!sessionId || !cwd) return undefined;
|
|
388
|
+
return claudeTranscriptPath(managed.configHome, cwd, sessionId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Map the intrinsic probe status to the 3-valued activity. The whole point of #766:
|
|
393
|
+
* busy → busy — a turn is running, foreground tools included; the old
|
|
394
|
+
* pane-scrape misread it as idle and force-cleared.
|
|
395
|
+
* idle/shell → idle — `shell` is Claude's `idle` while a background local_bash task
|
|
396
|
+
* is alive (2.1.310: `status==="idle" && hasLiveLocalBash`).
|
|
397
|
+
* Relay's own monitor is one, so a managed session at rest
|
|
398
|
+
* reads `shell`, including right after an ESC (#2202).
|
|
399
|
+
* waiting → unknown — a permission/input dialog is a parked (not done) turn, so
|
|
400
|
+
* we neither assert compute-busy nor let the backstop clear
|
|
401
|
+
* the live turn. `waitingFor` is surfaced separately for
|
|
402
|
+
* permission/asking detection.
|
|
403
|
+
*/
|
|
404
|
+
export function claudeProbeActivity(status: ClaudeProbeStatus): ClaudeProbeActivity {
|
|
405
|
+
switch (status) {
|
|
406
|
+
case "busy":
|
|
407
|
+
return "busy";
|
|
408
|
+
case "idle":
|
|
409
|
+
case "shell":
|
|
410
|
+
return "idle";
|
|
411
|
+
default:
|
|
412
|
+
return "unknown";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// #1647 — the Claude tmux shutdown sequence: observe, correlate, and only then
|
|
2
|
+
// (maybe) exit gracefully. Split out of ./claude at the #300 ratchet; the adapter
|
|
3
|
+
// keeps the watcher teardown and delegates the rest here.
|
|
4
|
+
//
|
|
5
|
+
// The policy itself lives in ./claude-exit. This module is the caller that samples
|
|
6
|
+
// live state and drives the loop.
|
|
7
|
+
import { managedTmuxSocketName, prepareTmuxPaneStop, prepareTmuxStop, tmuxHasSession, type PreparedTmuxStop } from "@humwick/sdk/tmux-utils";
|
|
8
|
+
import { tmuxOwnerIdFromEnv } from "../config";
|
|
9
|
+
import type { ManagedProcess, ShutdownOutcome } from "../adapter";
|
|
10
|
+
import { claudeProbeActivity, claudeProbeArgsFor, readManagedClaudeStatusAsync, type ClaudeProbeLivenessAsync } from "./claude-session-probe";
|
|
11
|
+
import { claudePaneIsBusy } from "./claude-status-detectors";
|
|
12
|
+
import { captureTmuxPaneAsync } from "./tmux-offload";
|
|
13
|
+
import { authorizeClaudeGracefulExit, claudeExitDenialIsPermanent, confirmClaudeExitDialogUnderGrant, mintClaudeShutdownIntent, submitClaudeExitCommand } from "./claude-exit";
|
|
14
|
+
import type { ClaudeExitGrant, ClaudeExitObservation, ClaudeTurnState } from "./claude-exit";
|
|
15
|
+
|
|
16
|
+
export function claudeShutdownGraceMs(timeoutMs: number): number {
|
|
17
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
|
|
18
|
+
return Math.min(timeoutMs, 10_000);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The turn identity a shutdown intent is correlated against. Claude's intrinsic probe
|
|
22
|
+
// has no turn id, so the pair (sessionId, procStart) is the finest-grained identity
|
|
23
|
+
// available: it changes on any restart or session swap, which is exactly the
|
|
24
|
+
// "different process than the one you asked me to stop" case the token must catch.
|
|
25
|
+
export function claudeTurnKey(sessionId: string | null, procStart: string | null): string {
|
|
26
|
+
return `${sessionId ?? "?"}:${procStart ?? "?"}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sample the facts a graceful exit must correlate against.
|
|
31
|
+
*
|
|
32
|
+
* Turn state is deliberately fail-closed: it reads "ended" ONLY on positive evidence
|
|
33
|
+
* from the intrinsic probe (Claude's own status file, which also carries the sessionId
|
|
34
|
+
* and procStart that bind this reading to this exact process). The pane scrape is kept
|
|
35
|
+
* as a VETO — it can turn "ended" into "live", never the reverse — because
|
|
36
|
+
* `claudePaneIsBusy` alone is exactly what false-negatived while the killed coordinator
|
|
37
|
+
* was mid-stream. No probe, no exit.
|
|
38
|
+
*/
|
|
39
|
+
export async function observeClaudeExitState(
|
|
40
|
+
process: ManagedProcess,
|
|
41
|
+
sessionName: string,
|
|
42
|
+
socketName: string | undefined,
|
|
43
|
+
fallbackEpoch: number,
|
|
44
|
+
seams: { liveness?: ClaudeProbeLivenessAsync; capturePane?: typeof captureTmuxPaneAsync } = {},
|
|
45
|
+
): Promise<ClaudeExitObservation> {
|
|
46
|
+
// #3152 — the shared off-loop reader, so a macOS `ps` lstart procStart binds this reading too.
|
|
47
|
+
const managed = await readManagedClaudeStatusAsync(claudeProbeArgsFor(process, sessionName, socketName), seams.liveness);
|
|
48
|
+
const reading = managed?.reading;
|
|
49
|
+
// #3186 — a managed Claude at rest reads `shell` (Relay's monitor is a live background task).
|
|
50
|
+
const probeIdle = reading?.available === true && claudeProbeActivity(reading.status) === "idle";
|
|
51
|
+
let turn: ClaudeTurnState = probeIdle ? "ended" : reading?.available === true ? "live" : "unknown";
|
|
52
|
+
if (turn === "ended") {
|
|
53
|
+
try {
|
|
54
|
+
if (claudePaneIsBusy(await (seams.capturePane ?? captureTmuxPaneAsync)(sessionName, socketName))) turn = "live";
|
|
55
|
+
} catch {
|
|
56
|
+
turn = "unknown"; // A pane we cannot read is not a pane we may type into.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
epoch: (process.meta?.providerEpoch as number | undefined) ?? fallbackEpoch,
|
|
61
|
+
...(process.pid === undefined ? {} : { pid: process.pid }),
|
|
62
|
+
procStart: reading?.procStart ?? null,
|
|
63
|
+
sessionName,
|
|
64
|
+
providerSessionId: reading?.sessionId ?? null,
|
|
65
|
+
turn,
|
|
66
|
+
turnKey: claudeTurnKey(reading?.sessionId ?? null, reading?.procStart ?? null),
|
|
67
|
+
now: Date.now(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Injectable seams so the shutdown loop (which otherwise only talks to a live tmux
|
|
72
|
+
// server) is unit-testable — same pattern as ClaudeClearCommitDeps.
|
|
73
|
+
export interface ClaudeShutdownDeps {
|
|
74
|
+
hasSession?: (sessionName: string, socketName?: string) => boolean;
|
|
75
|
+
observe?: (process: ManagedProcess, sessionName: string, socketName?: string) => ClaudeExitObservation | Promise<ClaudeExitObservation>;
|
|
76
|
+
submitExit?: (grant: ClaudeExitGrant) => Promise<unknown>;
|
|
77
|
+
confirmExit?: (grant: ClaudeExitGrant) => unknown;
|
|
78
|
+
// #1746 — the reap now returns a terminal outcome (it verifies death, it does not merely
|
|
79
|
+
// fire a kill-session). Tests that only care that the reap ran may still return void.
|
|
80
|
+
reap?: (sessionName: string, socketName?: string) => ShutdownOutcome | void | Promise<ShutdownOutcome | void>;
|
|
81
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
82
|
+
prepareStop?: (sessionName: string, socketName?: string) => Promise<PreparedTmuxStop>;
|
|
83
|
+
preparePaneStop?: typeof prepareTmuxPaneStop;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function shutdownClaudeTmux(
|
|
87
|
+
process: ManagedProcess,
|
|
88
|
+
sessionName: string,
|
|
89
|
+
opts: { graceful: boolean; timeoutMs: number },
|
|
90
|
+
socketName: string | undefined,
|
|
91
|
+
fallbackEpoch: number,
|
|
92
|
+
deps: ClaudeShutdownDeps = {},
|
|
93
|
+
): Promise<ShutdownOutcome> {
|
|
94
|
+
const hasSession = deps.hasSession ?? tmuxHasSession;
|
|
95
|
+
const observe = deps.observe ?? ((p, s, k) => observeClaudeExitState(p, s, k, fallbackEpoch));
|
|
96
|
+
const submitExit = deps.submitExit ?? submitClaudeExitCommand;
|
|
97
|
+
const confirmExit = deps.confirmExit ?? confirmClaudeExitDialogUnderGrant;
|
|
98
|
+
const sleep = deps.sleep ?? Bun.sleep;
|
|
99
|
+
const stop = deps.reap
|
|
100
|
+
? async () => (await deps.reap!(sessionName, socketName)) ?? "killed"
|
|
101
|
+
: await prepareClaudeStop(sessionName, socketName, deps);
|
|
102
|
+
|
|
103
|
+
if (opts.graceful && hasSession(sessionName, socketName)) {
|
|
104
|
+
// The audited lifecycle request, pinned to the process it was issued against.
|
|
105
|
+
// Everything below is gated on it; nothing here can be reached from a detector.
|
|
106
|
+
const observed = await observe(process, sessionName, socketName);
|
|
107
|
+
const intent = mintClaudeShutdownIntent({
|
|
108
|
+
epoch: observed.epoch,
|
|
109
|
+
...(observed.pid === undefined ? {} : { pid: observed.pid }),
|
|
110
|
+
procStart: observed.procStart,
|
|
111
|
+
sessionName,
|
|
112
|
+
providerSessionId: observed.providerSessionId,
|
|
113
|
+
turnKey: observed.turnKey,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
let grant: ClaudeExitGrant | undefined;
|
|
117
|
+
const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
// Session disappearance still requires verification of the captured descendants.
|
|
120
|
+
if (!hasSession(sessionName, socketName)) return stop();
|
|
121
|
+
if (!grant) {
|
|
122
|
+
// #1647 — the guard that would have saved the coordinator. A turn that is live
|
|
123
|
+
// (or that we cannot positively confirm has ended) authorizes nothing, so we
|
|
124
|
+
// keep waiting and let the out-of-band reap below terminate the process if the
|
|
125
|
+
// turn never settles.
|
|
126
|
+
const decision = authorizeClaudeGracefulExit(intent, await observe(process, sessionName, socketName), socketName);
|
|
127
|
+
if (decision.authorized) {
|
|
128
|
+
grant = decision.grant;
|
|
129
|
+
await submitExit(grant);
|
|
130
|
+
} else if (claudeExitDenialIsPermanent(decision.reason)) {
|
|
131
|
+
// Nothing this loop can observe later will change the verdict — waiting out
|
|
132
|
+
// the grace window would only delay the reap. Terminate out of band now.
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// `/exit` while a background task is running raises Claude's "Exit anyway /
|
|
137
|
+
// Move to background / Stay" confirmation (#1514). Answering it is allowed ONLY
|
|
138
|
+
// through the same grant that submitted that `/exit`, and only for a dialog that
|
|
139
|
+
// was not already on screen beforehand.
|
|
140
|
+
confirmExit(grant);
|
|
141
|
+
}
|
|
142
|
+
await sleep(200);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The out-of-band backstop, and the path #1647 prefers: it terminates the process
|
|
147
|
+
// without typing anything into the pane, so it is always reached when the guard above
|
|
148
|
+
// declines to authorize a graceful exit. #1746 — this is now a VERIFIED, escalating
|
|
149
|
+
// force-kill that returns a terminal outcome, not a fire-and-forget kill-session.
|
|
150
|
+
return stop();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function prepareClaudeStop(sessionName: string, socketName: string | undefined, deps: ClaudeShutdownDeps): Promise<() => Promise<ShutdownOutcome>> {
|
|
154
|
+
const prepare = () => (deps.prepareStop ?? ((name, socket) => prepareTmuxStop({ name, socket,
|
|
155
|
+
onFailure: (failure) => console.error("[claude] Managed tmux stop failed", JSON.stringify({
|
|
156
|
+
at: new Date().toISOString(), name, socket, ...failure,
|
|
157
|
+
})),
|
|
158
|
+
})))(sessionName, socketName);
|
|
159
|
+
const preparePane = async () => socketName === managedTmuxSocketName(sessionName)
|
|
160
|
+
? (deps.preparePaneStop ?? prepareTmuxPaneStop)({ name: sessionName, socket: socketName, owner: tmuxOwnerIdFromEnv() }).catch(() => undefined)
|
|
161
|
+
: undefined;
|
|
162
|
+
let fallback = await preparePane();
|
|
163
|
+
const stop = await prepare().catch(() => undefined);
|
|
164
|
+
const capturedGone = () => stop?.verifyCaptured?.() ?? Promise.resolve(true);
|
|
165
|
+
const paneGone = () => fallback?.gone?.() ?? Promise.resolve(true);
|
|
166
|
+
return async () => {
|
|
167
|
+
if (stop && (!fallback || await fallback.matches() || await fallback.gone?.())
|
|
168
|
+
&& await stop() && await capturedGone() && await paneGone()) return "killed";
|
|
169
|
+
if (!stop || stop.captureComplete === false) {
|
|
170
|
+
fallback ??= await preparePane();
|
|
171
|
+
// Fence both sides of the retry: a replacement cannot borrow the failed capture.
|
|
172
|
+
if (fallback && await fallback.matches()) {
|
|
173
|
+
const retry = await prepare().catch(() => undefined);
|
|
174
|
+
if (retry && await fallback.matches() && await retry() && await capturedGone() && await paneGone()) return "killed";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (fallback && await fallback.stop() && (!stop || stop.captureComplete === false || await stop())
|
|
178
|
+
&& await capturedGone() && await paneGone()) return "killed";
|
|
179
|
+
return "failed-to-kill";
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Shared portable teardown verifies the pane, descendants, and private tmux server. */
|
|
184
|
+
export async function terminateClaudeTmux(
|
|
185
|
+
sessionName: string,
|
|
186
|
+
socketName: string | undefined,
|
|
187
|
+
deps: ClaudeShutdownDeps = {},
|
|
188
|
+
): Promise<ShutdownOutcome> {
|
|
189
|
+
return (await prepareClaudeStop(sessionName, socketName, deps))();
|
|
190
|
+
}
|