@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,548 @@
|
|
|
1
|
+
import { relayEnv } from "@humwick/sdk/env";
|
|
2
|
+
import { envPipeSource, prepareEnvPipe, providerSecretEnvKeys } from "@humwick/sdk/child-env";
|
|
3
|
+
import { PROVIDER_SCRATCH_ENV_KEYS } from "@humwick/sdk/provider-scratch";
|
|
4
|
+
// Claude tmux transport plumbing (extracted from ./claude for the file-size
|
|
5
|
+
// ratchet, #300/#291): session/socket naming, pane capture, input-ready gating,
|
|
6
|
+
// paste/submit injection, launcher-script materialization, and env-key selection.
|
|
7
|
+
// The adapter (./claude) keeps the lifecycle decisions; this module owns the
|
|
8
|
+
// mechanical tmux interactions they ride on. Public symbols are re-exported from
|
|
9
|
+
// ./claude so existing import paths (including claude.test.ts) keep resolving.
|
|
10
|
+
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { sanitizeFsName } from "@humwick/sdk/fs-name";
|
|
13
|
+
import { shellEscape as shellQuote } from "@humwick/sdk/shell-utils";
|
|
14
|
+
import { TMUX_SESSION_OWNER_OPTION, managedTmuxSocketName, renderTmuxSessionTarget, tmuxCommand, tmuxHasSession } from "@humwick/sdk/tmux-utils";
|
|
15
|
+
import { claudeClearCommitted, claudeConversationAnchor, claudePaneIsBusy, claudePaneLooksReady } from "./claude-status-detectors";
|
|
16
|
+
import { ClaudeSubmitFence, ClaudeSubmitTranscriptProbe, claudeSubmitRecoveryConfirmed, submitClaudePromptConfirmed } from "./claude-submit-confirmation";
|
|
17
|
+
import type { ClaudePromptHookExpectation, ClaudePromptHookLedger } from "./claude-prompt-hook";
|
|
18
|
+
import type { ProviderInputObserver } from "../adapter";
|
|
19
|
+
import { logger } from "../logger";
|
|
20
|
+
import { claudeClearSessionVerdict, type ClaudeClearSessionReading } from "./claude-session-probe";
|
|
21
|
+
import { tmuxOwnerIdFromEnv } from "../config";
|
|
22
|
+
import { spawnSyncBounded } from "../bounded-spawn-sync";
|
|
23
|
+
import { launcherGatePathForSession, launcherScriptPathForSession, readOneShotExitCode } from "../session-scratch";
|
|
24
|
+
import { captureTmuxPaneAsync, captureTmuxVisiblePaneAsync, loadTmuxBufferFromStdin, runTmuxInput, tmuxSessionPresence, type TmuxReadOpts, type TmuxSessionPresence } from "./tmux-offload";
|
|
25
|
+
|
|
26
|
+
export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
|
|
27
|
+
export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
|
|
28
|
+
export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
|
|
29
|
+
export const CLAUDE_MAX_INLINE_PROMPT_BYTES = 96 * 1024;
|
|
30
|
+
const TMUX_SOCKET_PATH_SAFE_BYTES = 100;
|
|
31
|
+
const promptSubmitFence = new ClaudeSubmitFence();
|
|
32
|
+
const promptSubmitSessionKey = (session: string, socket?: string) => `${socket ?? "default"}:${session}`;
|
|
33
|
+
|
|
34
|
+
// Claude normally accepts its first user turn as a positional argument. Linux limits one
|
|
35
|
+
// argv element well below a useful large task brief, and tmux paste is not a submission
|
|
36
|
+
// guarantee during onboarding. A transient headless Claude can instead consume the exact
|
|
37
|
+
// assignment from stdin in `-p` mode, preserving its user-message role.
|
|
38
|
+
export function seedClaudeInitialPrompt(args: string[], prompt: string, options: { headless: boolean; lifecycle?: string; scratchDir?: string }): { stdinFile: string; exitCodeFile: string; stdinContents: string } | undefined {
|
|
39
|
+
if (new TextEncoder().encode(prompt).byteLength <= CLAUDE_MAX_INLINE_PROMPT_BYTES) {
|
|
40
|
+
args.push(prompt);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (!options.headless || options.lifecycle !== "transient") {
|
|
44
|
+
throw new Error("oversized Claude initial prompt requires a transient headless launch; refusing a racy or role-changing fallback");
|
|
45
|
+
}
|
|
46
|
+
if (!options.scratchDir) throw new Error("oversized Claude initial prompt requires AGENT_RELAY_SCRATCH_DIR");
|
|
47
|
+
const promptFile = join(options.scratchDir, "claude-initial-assignment.md");
|
|
48
|
+
const exitCodeFile = join(options.scratchDir, "claude-initial-exit-code");
|
|
49
|
+
args.push("-p", "--input-format", "text");
|
|
50
|
+
return { stdinFile: promptFile, exitCodeFile, stdinContents: prompt };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A missing sentinel means the one-shot process was killed before its shell could report success. */
|
|
54
|
+
export async function claudeOneShotExitStatus(exitCodeFile?: string): Promise<"offline" | "error"> {
|
|
55
|
+
if (!exitCodeFile) return "offline";
|
|
56
|
+
return (await readOneShotExitCode(exitCodeFile)) === "0" ? "offline" : "error";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
|
|
60
|
+
if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
|
|
61
|
+
return `${prefix}-${instanceId.slice(0, 8)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// tmux silently rewrites `.` and `:` in new session names to `_` (a colon also
|
|
65
|
+
// separates the session/window/pane portions of a target). All later `-t` lookups
|
|
66
|
+
// must use the rendered name or `has-session` misses the session tmux just created.
|
|
67
|
+
// Delegates to the shared SDK renderer so the runner and orchestrator (which must match
|
|
68
|
+
// the rendered name when resolving a shutdown target, #1746 RC-1) can never diverge.
|
|
69
|
+
export function tmuxSessionTargetName(sessionName: string): string {
|
|
70
|
+
return renderTmuxSessionTarget(sessionName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function tmuxSocketName(sessionName: string): string {
|
|
74
|
+
// `tmux -L` puts this value at <TMUX_TMPDIR>/tmux-<uid>/<socket>. Unix
|
|
75
|
+
// socket paths are limited to roughly 104-108 bytes, so a display-oriented
|
|
76
|
+
// session name must never flow into the path unbounded. Hash the complete
|
|
77
|
+
// session identity (including the orchestrator's agent/spawn suffix) into a
|
|
78
|
+
// fixed-size token; the readable session name remains unchanged in tmux/UI.
|
|
79
|
+
const socketName = managedTmuxSocketName(sessionName);
|
|
80
|
+
assertTmuxSocketPathFits(socketName);
|
|
81
|
+
return socketName;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pre-#1353 socket selector, used only for one-way cleanup when a runner first
|
|
85
|
+
// starts after upgrading. Do not assert its path length: an overlong legacy
|
|
86
|
+
// name is exactly what the new fixed-size selector is designed to escape.
|
|
87
|
+
export function legacyTmuxSocketName(sessionName: string): string {
|
|
88
|
+
return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createOrReuseTmuxSession(sessionName: string, socketName: string, tmuxArgs: string[]): boolean {
|
|
92
|
+
const targetSessionName = tmuxSessionTargetName(sessionName);
|
|
93
|
+
spawnSyncBounded(tmuxCommand(socketName, "kill-session", "-t", targetSessionName), {
|
|
94
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
95
|
+
});
|
|
96
|
+
// Upgrade cleanup: a pre-#1353 runner used the readable session name as its
|
|
97
|
+
// selector. Best-effort removal prevents a legacy pane surviving beside its
|
|
98
|
+
// replacement; an overlong legacy selector can fail here harmlessly.
|
|
99
|
+
spawnSyncBounded(tmuxCommand(legacyTmuxSocketName(sessionName), "kill-session", "-t", targetSessionName), {
|
|
100
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
101
|
+
});
|
|
102
|
+
const result = spawnSyncBounded(tmuxCommand(socketName, ...tmuxArgs), {
|
|
103
|
+
stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
104
|
+
});
|
|
105
|
+
const stderr = result.stderr.toString().trim();
|
|
106
|
+
// A restart can race a still-live predecessor. Reuse the uniquely named,
|
|
107
|
+
// confirmed session rather than failing the healthy agent on "duplicate".
|
|
108
|
+
const reusable = tmuxSessionCreationFailureIsReusable(stderr, targetSessionName, socketName);
|
|
109
|
+
if (result.exitCode !== 0 && !reusable) {
|
|
110
|
+
throw new Error(`tmux session creation failed: ${stderr || (result.signalCode ? `signal ${result.signalCode}` : `exit code ${result.exitCode}`)}`);
|
|
111
|
+
}
|
|
112
|
+
if (result.exitCode === 0) promptSubmitFence.clear(promptSubmitSessionKey(targetSessionName, socketName));
|
|
113
|
+
// #1514 — stamp the owning orchestrator's state-home id (passed to this runner via
|
|
114
|
+
// env) onto the session so the orchestrator's wedged-session reaper reaps ONLY the
|
|
115
|
+
// sessions it positively owns, never a co-tenant orchestrator's on the same host.
|
|
116
|
+
setTmuxSessionOwner(targetSessionName, tmuxOwnerIdFromEnv(), socketName);
|
|
117
|
+
return result.exitCode === 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function createTmuxSessionWithEnv(sessionName: string, socketName: string, args: string[], script: string, spawnEnv: Record<string, string>, providerEnv: Record<string, string>, gateFifo?: string, provider = "claude"): Promise<void> {
|
|
121
|
+
const keys = tmuxEnvKeys(spawnEnv, providerEnv, provider);
|
|
122
|
+
const env = Object.fromEntries(keys.filter((key) => relayEnv(spawnEnv, key) !== undefined).map((key) => [key, relayEnv(spawnEnv, key)!]));
|
|
123
|
+
const pipe = await prepareEnvPipe(script, env);
|
|
124
|
+
try {
|
|
125
|
+
if (createOrReuseTmuxSession(sessionName, socketName, args)) await pipe.sent();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
await runTmuxInput("tmux-launch-cleanup", tmuxCommand(socketName, "kill-session", "-t", tmuxSessionTargetName(sessionName))).catch(() => {});
|
|
128
|
+
if (gateFifo) await releaseLauncherGate(gateFifo);
|
|
129
|
+
throw error;
|
|
130
|
+
} finally { await pipe.cleanup(); }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function tmuxSessionCreationFailureIsReusable(
|
|
134
|
+
stderr: string,
|
|
135
|
+
sessionName: string,
|
|
136
|
+
socketName: string,
|
|
137
|
+
hasSession: (session: string, socket?: string) => boolean = tmuxHasSession,
|
|
138
|
+
): boolean {
|
|
139
|
+
return /duplicate session/i.test(stderr)
|
|
140
|
+
&& hasSession(tmuxSessionTargetName(sessionName), socketName);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// #1514 — stamp the owning orchestrator's state-home id onto the tmux session as a
|
|
144
|
+
// user-option. The orchestrator's wedged-session reaper reads it back and reaps ONLY
|
|
145
|
+
// sessions whose owner id matches its own, so it can never reap a session belonging
|
|
146
|
+
// to a different orchestrator that happens to share the UID + tmux prefix on this
|
|
147
|
+
// host. Best-effort: a failure just leaves the session untagged, which the reaper
|
|
148
|
+
// treats as "not positively owned" and skips (fail-safe).
|
|
149
|
+
export function setTmuxSessionOwner(sessionName: string, ownerId: string | undefined, socketName?: string): void {
|
|
150
|
+
if (!ownerId) return;
|
|
151
|
+
spawnSyncBounded(tmuxCommand(socketName, "set-option", "-t", sessionName, TMUX_SESSION_OWNER_OPTION, ownerId), {
|
|
152
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function tmuxSocketPath(socketName: string, socketDir = defaultTmuxSocketDir()): string {
|
|
157
|
+
return join(socketDir, socketName);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function assertTmuxSocketPathFits(socketName: string, socketDir = defaultTmuxSocketDir()): void {
|
|
161
|
+
const path = tmuxSocketPath(socketName, socketDir);
|
|
162
|
+
const bytes = Buffer.byteLength(path);
|
|
163
|
+
if (bytes <= TMUX_SOCKET_PATH_SAFE_BYTES) return;
|
|
164
|
+
throw new Error(`tmux socket directory is too long (socket path is ${bytes} bytes; must be <= ${TMUX_SOCKET_PATH_SAFE_BYTES}); shorten TMUX_TMPDIR`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A single failed `has-session` can be transient (and historically was at the
|
|
168
|
+
// Unix socket path boundary). Call after an initial miss and only declare the
|
|
169
|
+
// session gone if a delayed confirmation misses too.
|
|
170
|
+
export async function confirmTmuxSessionGone(
|
|
171
|
+
sessionName: string,
|
|
172
|
+
socketName?: string,
|
|
173
|
+
delayMs = 250,
|
|
174
|
+
// #1838 row 12 — off the event loop via the tri-state `tmuxSessionPresence` (#1838 review
|
|
175
|
+
// HIGH-2). Death is confirmed ONLY when BOTH the immediate and the delayed read are the
|
|
176
|
+
// positive `absent` (tmux exited 1); a `present` OR an `unreadable` (timeout/abort on a
|
|
177
|
+
// wedged-but-live server) at either point means NOT gone, so a slow read can never be
|
|
178
|
+
// mistaken for a dead session. Unit tests inject their own presence override.
|
|
179
|
+
presence: (session: string, socket?: string) => TmuxSessionPresence | Promise<TmuxSessionPresence> = tmuxSessionPresence,
|
|
180
|
+
): Promise<boolean> {
|
|
181
|
+
if ((await presence(sessionName, socketName)) !== "absent") return false;
|
|
182
|
+
await Bun.sleep(delayMs);
|
|
183
|
+
return (await presence(sessionName, socketName)) === "absent";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function defaultTmuxSocketDir(): string {
|
|
187
|
+
const base = process.env.TMUX_TMPDIR || "/tmp";
|
|
188
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : "user";
|
|
189
|
+
return join(base, `tmux-${uid}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function captureTmuxPane(sessionName: string, socketName?: string): string {
|
|
193
|
+
const result = spawnSyncBounded(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
|
|
194
|
+
stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
195
|
+
});
|
|
196
|
+
if (result.exitCode !== 0) {
|
|
197
|
+
const stderr = result.stderr.toString().trim();
|
|
198
|
+
throw new Error(`tmux capture-pane failed: ${stderr || (result.signalCode ? `signal ${result.signalCode}` : `exit code ${result.exitCode}`)}`);
|
|
199
|
+
}
|
|
200
|
+
return result.stdout.toString();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// #1514's exit-dialog confirmation used to live here as
|
|
204
|
+
// `confirmClaudeExitDialogIfPresent(sessionName, socketName)` — an Enter writer that
|
|
205
|
+
// any caller holding a session name could invoke. #1647 removed it: taking a session
|
|
206
|
+
// name meant nothing in the type system stopped a pane detector from calling it, and
|
|
207
|
+
// the production kill was exactly that Enter answering an "Exit anyway" modal on a
|
|
208
|
+
// live pane. It now lives in ./claude-exit, where both it and the `/exit` writer take
|
|
209
|
+
// an unforgeable one-shot ClaudeExitGrant instead.
|
|
210
|
+
|
|
211
|
+
// #1838 review BLOCKER + round-3 MED — this readiness poller runs on the hot delivery path
|
|
212
|
+
// (every deliver / deliverInitialPrompt / monitor self-resume settle) and used to call the
|
|
213
|
+
// SYNCHRONOUS tmuxHasSession + captureTmuxPane every 200ms; a single wedged tmux call froze the
|
|
214
|
+
// whole runner event loop for the full 10s deadline. Both reads now go OFF the loop and use the
|
|
215
|
+
// tri-state presence (only a positive `absent` ends the wait with the session-exited error; an
|
|
216
|
+
// `unreadable`/transient read just retries).
|
|
217
|
+
//
|
|
218
|
+
// Round-3 MED: the deadline is now ACTUALLY absolute. Checking Date.now() only BETWEEN calls was
|
|
219
|
+
// not enough — each subprocess still got the runner's ~10s default deadline + kill grace, so a
|
|
220
|
+
// waitForClaudeInputReady(...,500) could hold the serialized provider-input ticket ~12s. An
|
|
221
|
+
// AbortController fires at the outer deadline AND the REMAINING budget is threaded into each read
|
|
222
|
+
// as its own timeout, so no single read can outlive the caller's deadline (bar the irreducible
|
|
223
|
+
// SIGKILL grace).
|
|
224
|
+
export async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
|
|
225
|
+
const deadline = Date.now() + timeoutMs;
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const abortTimer = setTimeout(() => controller.abort(), timeoutMs);
|
|
228
|
+
abortTimer.unref?.();
|
|
229
|
+
try {
|
|
230
|
+
for (;;) {
|
|
231
|
+
const remaining = deadline - Date.now();
|
|
232
|
+
if (remaining <= 0) break;
|
|
233
|
+
const readOpts: TmuxReadOpts = { signal: controller.signal, timeoutMs: remaining };
|
|
234
|
+
const presence = await tmuxSessionPresence(sessionName, socketName, readOpts);
|
|
235
|
+
if (presence === "absent") throw new Error("tmux session exited before Claude became ready");
|
|
236
|
+
if (presence === "present") {
|
|
237
|
+
try {
|
|
238
|
+
if (claudePaneLooksReady(await captureTmuxVisiblePaneAsync(sessionName, socketName, readOpts))) return;
|
|
239
|
+
} catch { /* transient/aborted capture — retry until the deadline rather than fail delivery */ }
|
|
240
|
+
}
|
|
241
|
+
await Bun.sleep(Math.min(200, Math.max(0, deadline - Date.now())));
|
|
242
|
+
}
|
|
243
|
+
throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(abortTimer);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const CLAUDE_CLEAR_COMMIT_POLL_MS = 200;
|
|
250
|
+
|
|
251
|
+
/** #2850 — consecutive idle+ready polls (~1s) a vacuous clear needs when the probe can't attest. */
|
|
252
|
+
export const CLAUDE_VACUOUS_CLEAR_READY_POLLS = 5;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* #2261 — the capture scope the clear-commit barrier reads, pinned as a named export so the
|
|
256
|
+
* invariant is assertable. It MUST be the visible-screen capture: `/clear` repaints only the
|
|
257
|
+
* visible screen (no `ESC[3J`), so a scrollback capture keeps returning the wiped conversation —
|
|
258
|
+
* and with it the anchor — for the entire timeout, making a committed clear unconfirmable.
|
|
259
|
+
*/
|
|
260
|
+
export const CLAUDE_CLEAR_COMMIT_CAPTURE = captureTmuxVisiblePaneAsync;
|
|
261
|
+
|
|
262
|
+
// Injectable seams so the polling loop (which otherwise only talks to a live tmux server) is
|
|
263
|
+
// unit-testable — same pattern as confirmTmuxSessionGone's `hasSession` override.
|
|
264
|
+
export interface ClaudeClearCommitDeps {
|
|
265
|
+
capture?: (sessionName: string, socketName?: string, opts?: TmuxReadOpts) => string | Promise<string>;
|
|
266
|
+
presence?: (sessionName: string, socketName?: string, opts?: TmuxReadOpts) => TmuxSessionPresence | Promise<TmuxSessionPresence>;
|
|
267
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
268
|
+
/** #2261 — the probe reading taken immediately BEFORE the `/clear` submit (null = unavailable). */
|
|
269
|
+
preClearSession?: ClaudeClearSessionReading | null;
|
|
270
|
+
/** #2261 — re-read it each poll, pid-pinned by the caller. Absent → anchor-only (legacy). */
|
|
271
|
+
readSession?: () => Promise<ClaudeClearSessionReading | null>;
|
|
272
|
+
/** #2849 — true only when `preClearPane` is a successful capture (not the "" fallback
|
|
273
|
+
* left by a throw). Lets a provably-empty pre-clear pane confirm via idle+ready. */
|
|
274
|
+
preClearCaptured?: boolean;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// #1588 — CLEAR-AWARE settle barrier for the self-resume clear+inject coupling. Unlike
|
|
278
|
+
// waitForClaudeInputReady (which returns on the first persistent-footer match — present BOTH
|
|
279
|
+
// before and after `/clear`, the F2 race), this requires POSITIVE evidence the `/clear` actually
|
|
280
|
+
// committed: it snapshots a distinctive pre-clear conversation anchor and only reports success
|
|
281
|
+
// once the pane is idle, input-ready, AND that anchor is gone (the loaded conversation was wiped).
|
|
282
|
+
// Returns a BOOLEAN rather than throwing: `true` = clear positively confirmed committed; `false` =
|
|
283
|
+
// timed out / session vanished / capture failed / no anchor derivable (uncertain). The caller MUST
|
|
284
|
+
// treat `false` as "not confirmed" and report continuationDelivered:false so the #1023 backstop
|
|
285
|
+
// re-injects — never as a wipe. A settle timeout is therefore surfaced, not swallowed.
|
|
286
|
+
export async function waitForClaudeClearCommitted(
|
|
287
|
+
sessionName: string,
|
|
288
|
+
preClearPane: string,
|
|
289
|
+
timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS,
|
|
290
|
+
socketName?: string,
|
|
291
|
+
deps: ClaudeClearCommitDeps = {},
|
|
292
|
+
): Promise<boolean> {
|
|
293
|
+
// #1838 review — same off-loop + tri-state + ABSOLUTE-deadline treatment as
|
|
294
|
+
// waitForClaudeInputReady (this poller also ran sync captureTmuxPane on the clear path, and had
|
|
295
|
+
// the same between-calls-only deadline, round-3 MED). A positive `absent` returns false (clear
|
|
296
|
+
// not confirmed → the #1023 backstop re-injects, the safe outcome); an `unreadable` read or a
|
|
297
|
+
// transient capture failure retries until the deadline instead of blocking or giving up early.
|
|
298
|
+
// #2261 — the VISIBLE screen, never the `-S -80` scrollback window: `/clear` repaints only the
|
|
299
|
+
// visible screen, so a scrollback capture keeps returning the wiped conversation (and with it the
|
|
300
|
+
// anchor) for the whole timeout. That single mismatch made a committed clear unconfirmable on any
|
|
301
|
+
// pane with history — proven live: the clear-inject at 22:15:10.185Z minted its new Claude
|
|
302
|
+
// session 112ms later (new transcript file), yet the barrier polled the full 10s and returned
|
|
303
|
+
// false. `preClearPane` must be a visible-screen capture too, so the anchor is a line the clear
|
|
304
|
+
// can actually repaint away.
|
|
305
|
+
const capture = deps.capture ?? CLAUDE_CLEAR_COMMIT_CAPTURE;
|
|
306
|
+
const presence = deps.presence ?? tmuxSessionPresence;
|
|
307
|
+
const sleep = deps.sleep ?? Bun.sleep;
|
|
308
|
+
const anchor = claudeConversationAnchor(preClearPane);
|
|
309
|
+
const { preClearSession, readSession } = deps;
|
|
310
|
+
// #2849 — a positively-captured pane with no anchor held no conversation, so the
|
|
311
|
+
// clear is vacuous. A failed capture ("") also yields no anchor but must stay
|
|
312
|
+
// fail-closed (preClearCaptured false). Confirmation still needs the settle below.
|
|
313
|
+
const preClearEmpty = deps.preClearCaptured === true && anchor === undefined;
|
|
314
|
+
// #2849r1/#2850 — with no probe verdict a ready pane may predate the commit (#1588),
|
|
315
|
+
// so a vacuous clear needs a CLAUDE_VACUOUS_CLEAR_READY_POLLS streak. A failed capture,
|
|
316
|
+
// a busy/non-ready pane or an `unchanged` probe reading resets it.
|
|
317
|
+
let readyStreak = 0;
|
|
318
|
+
// #2261 — the new session is minted ~112ms BEFORE the TUI finishes repainting (measured on the
|
|
319
|
+
// live repro), and inside that window an un-cleared pane still reads non-busy + input-ready. So
|
|
320
|
+
// a rotation only counts once it has been observed on TWO CONSECUTIVE polls, which lands the
|
|
321
|
+
// confirmation after the repaint rather than inside the #1588 injection window. One poll
|
|
322
|
+
// interval (200ms) out of a 10s budget.
|
|
323
|
+
let rotationSeen = false;
|
|
324
|
+
const deadline = Date.now() + timeoutMs;
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
const abortTimer = setTimeout(() => controller.abort(), timeoutMs);
|
|
327
|
+
abortTimer.unref?.();
|
|
328
|
+
try {
|
|
329
|
+
for (;;) {
|
|
330
|
+
const remaining = deadline - Date.now();
|
|
331
|
+
if (remaining <= 0) break;
|
|
332
|
+
const readOpts: TmuxReadOpts = { signal: controller.signal, timeoutMs: remaining };
|
|
333
|
+
if ((await presence(sessionName, socketName, readOpts)) === "absent") return false;
|
|
334
|
+
let pane: string;
|
|
335
|
+
try {
|
|
336
|
+
pane = await capture(sessionName, socketName, readOpts);
|
|
337
|
+
} catch {
|
|
338
|
+
readyStreak = 0;
|
|
339
|
+
// #1838 round-4 MED — clamp the poll sleep to the remaining budget so a wait started
|
|
340
|
+
// with < CLAUDE_CLEAR_COMMIT_POLL_MS left can't overshoot the absolute deadline.
|
|
341
|
+
await sleep(Math.min(CLAUDE_CLEAR_COMMIT_POLL_MS, Math.max(0, deadline - Date.now())));
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
// A throw/absent reading yields the `unknown` verdict, so the poll falls back to the anchor
|
|
345
|
+
// test (and, failing that, to the backstop) exactly as it did before the probe existed.
|
|
346
|
+
const verdict = claudeClearSessionVerdict(preClearSession, readSession ? await readSession().catch(() => null) : null);
|
|
347
|
+
const settled = verdict === "rotated" && !rotationSeen ? "unknown" : verdict;
|
|
348
|
+
rotationSeen = verdict === "rotated";
|
|
349
|
+
readyStreak = verdict !== "unchanged" && !claudePaneIsBusy(pane) && claudePaneLooksReady(pane) ? readyStreak + 1 : 0;
|
|
350
|
+
const settledReady = readyStreak >= CLAUDE_VACUOUS_CLEAR_READY_POLLS;
|
|
351
|
+
if (claudeClearCommitted(anchor, pane, { session: settled, preClearEmpty, settledReady })) return true;
|
|
352
|
+
// #1838 round-4 MED — same clamp on the steady-state poll.
|
|
353
|
+
await sleep(Math.min(CLAUDE_CLEAR_COMMIT_POLL_MS, Math.max(0, deadline - Date.now())));
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
} finally {
|
|
357
|
+
clearTimeout(abortTimer);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// #1838 row 16 — the provider input transport (up to 50 spawns per delivered payload).
|
|
362
|
+
// Wave 2a moves every tmux subprocess here OFF the event loop via the offload runner, and the
|
|
363
|
+
// payload rides on STDIN (`load-buffer -`) rather than in argv.
|
|
364
|
+
//
|
|
365
|
+
// The old `set-buffer -b <name> <text>` put the whole delivered message in argv — an
|
|
366
|
+
// ARG_MAX exposure: a large enough message (Claude briefs are routinely multi-KB and can be
|
|
367
|
+
// far larger) overflows the platform's argument limit and the spawn fails outright. tmux
|
|
368
|
+
// `load-buffer` reading from stdin has no such bound, so the buffer load is unconditionally
|
|
369
|
+
// safe now. Every step is side-effecting (it mutates a buffer or types into the pane) and
|
|
370
|
+
// at-most-once — see tmux-offload.ts's header for why a re-run could never double-send.
|
|
371
|
+
//
|
|
372
|
+
// User prompts opt into visible-composer confirmation. Context commands retain their own
|
|
373
|
+
// command-specific commit barriers; the tmux transport still sends those once.
|
|
374
|
+
async function deleteTmuxBuffer(bufferName: string, socketName?: string): Promise<void> {
|
|
375
|
+
// Best-effort: a failed delete must never fail (or mask) the delivery outcome. #1838 review
|
|
376
|
+
// LOW — always runs, so a buffer loaded-but-not-cleaned (e.g. a paste timeout) can't leak.
|
|
377
|
+
await runTmuxInput("tmux-delete-buffer", tmuxCommand(socketName, "delete-buffer", "-b", bufferName)).catch(() => {});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Claude user prompts need one bracketed paste with LF intact; slash commands stay literal. */
|
|
381
|
+
export function claudePasteBufferCommand(sessionName: string, bufferName: string, socketName?: string, confirmSubmit = false): string[] {
|
|
382
|
+
return tmuxCommand(socketName, "paste-buffer", ...(confirmSubmit ? ["-p", "-r"] : []), "-b", bufferName, "-t", sessionName);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** One event-driven recovery probe; no polling loop runs while the provider is blocked. */
|
|
386
|
+
export async function confirmClaudeSubmitRecovery(sessionName: string, socketName?: string, turnStarted = false): Promise<boolean> {
|
|
387
|
+
return promptSubmitFence.recover(promptSubmitSessionKey(sessionName, socketName), turnStarted);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function releaseClaudeSubmitFence(sessionName: string, socketName?: string): void {
|
|
391
|
+
promptSubmitFence.clear(promptSubmitSessionKey(sessionName, socketName));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function submitTextToTmux(sessionName: string, text: string, socketName?: string, options: { confirmSubmit?: boolean; transcriptPath?: string; resolveTranscriptPath?: () => Promise<string | undefined>; promptHooks?: ClaudePromptHookLedger; imagePaths?: readonly string[]; inputObserver?: ProviderInputObserver } = {}): Promise<void> {
|
|
395
|
+
const bufferName = `agent-relay-${crypto.randomUUID()}`;
|
|
396
|
+
const paste = async () => {
|
|
397
|
+
try {
|
|
398
|
+
// Load via stdin so large prompts never hit the platform argv limit.
|
|
399
|
+
await loadTmuxBufferFromStdin(bufferName, text, socketName);
|
|
400
|
+
// tmux otherwise converts every LF to CR, which can submit each paragraph
|
|
401
|
+
// of a dashboard prompt as a separate turn. Claude requests bracketed paste;
|
|
402
|
+
// preserve LF and wrap user prompts so its editor receives one paste event.
|
|
403
|
+
await runTmuxInput("tmux-paste-buffer", claudePasteBufferCommand(sessionName, bufferName, socketName, options.confirmSubmit));
|
|
404
|
+
} finally {
|
|
405
|
+
await deleteTmuxBuffer(bufferName, socketName);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
if (options.confirmSubmit) {
|
|
409
|
+
// Hook mode only on evidence: a stamped hook has reached the runner from this Claude.
|
|
410
|
+
const hooks = options.promptHooks?.hookPathVerified ? options.promptHooks : undefined;
|
|
411
|
+
if (options.promptHooks && !hooks) logger.warn("claude", `no stamped hook seen yet; confirming the submit by the composer (${sessionName})`);
|
|
412
|
+
// The composer (plus the transcript) confirms hookless submits and recovers any failed one.
|
|
413
|
+
const transcript = (options.transcriptPath || options.resolveTranscriptPath)
|
|
414
|
+
? new ClaudeSubmitTranscriptProbe(options.resolveTranscriptPath ?? (async () => options.transcriptPath)) : undefined;
|
|
415
|
+
await transcript?.start(options.transcriptPath);
|
|
416
|
+
let expectation: ClaudePromptHookExpectation | undefined;
|
|
417
|
+
let offeredAt: number | undefined;
|
|
418
|
+
const confirmConsumption = async () => {
|
|
419
|
+
const consumed = expectation?.accepted() || (offeredAt !== undefined && await transcript?.confirms(text, offeredAt, options.imagePaths));
|
|
420
|
+
if (consumed) options.inputObserver?.consumed();
|
|
421
|
+
return consumed === true;
|
|
422
|
+
};
|
|
423
|
+
const startedAt = Date.now();
|
|
424
|
+
await promptSubmitFence.run(promptSubmitSessionKey(sessionName, socketName), async (markPasteStarted) => {
|
|
425
|
+
expectation = hooks?.expect(text, options.imagePaths);
|
|
426
|
+
let entered = false;
|
|
427
|
+
const mode = await submitClaudePromptConfirmed({
|
|
428
|
+
...(expectation ? { hook: expectation } : {}),
|
|
429
|
+
paste: async () => { markPasteStarted(); await paste(); },
|
|
430
|
+
readPane: (timeoutMs) => captureTmuxVisiblePaneAsync(sessionName, socketName, { timeoutMs, styled: true }),
|
|
431
|
+
sendEnter: async () => {
|
|
432
|
+
if (!entered) { offeredAt = Date.now(); options.inputObserver?.offered(offeredAt, Boolean(hooks || transcript)); }
|
|
433
|
+
entered = true;
|
|
434
|
+
await runTmuxInput("tmux-send-keys-submit", tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY));
|
|
435
|
+
},
|
|
436
|
+
cancelCopyMode: async () => {
|
|
437
|
+
await runTmuxInput("tmux-cancel-copy-mode", tmuxCommand(socketName, "send-keys", "-t", sessionName, "-X", "cancel")).catch(() => {});
|
|
438
|
+
},
|
|
439
|
+
delayBeforeFirstEnterMs: CLAUDE_TMUX_SUBMIT_DELAY_MS,
|
|
440
|
+
onRetry: (attempt) => logger.warn("claude", `Claude input still in composer after Enter; retrying submit key ${attempt}/3 (${sessionName})`),
|
|
441
|
+
}).catch((error) => {
|
|
442
|
+
if (hooks && entered && !expectation!.accepted()) {
|
|
443
|
+
hooks.hookPathLost();
|
|
444
|
+
logger.warn("claude", `no UserPromptSubmit hook after Enter; hook path unverified until the next stamped hook (${sessionName})`);
|
|
445
|
+
}
|
|
446
|
+
throw error;
|
|
447
|
+
});
|
|
448
|
+
await confirmConsumption();
|
|
449
|
+
expectation?.release();
|
|
450
|
+
logger.info("claude", `Claude prompt submit confirmed via ${mode} in ${Date.now() - startedAt}ms (${sessionName})`);
|
|
451
|
+
}, async (turnStarted) => await confirmConsumption() || claudeSubmitRecoveryConfirmed({
|
|
452
|
+
readPane: (timeoutMs) => captureTmuxVisiblePaneAsync(sessionName, socketName, { timeoutMs, styled: true }),
|
|
453
|
+
...(transcript ? { readNewUserPrompts: () => transcript.poll() } : {}),
|
|
454
|
+
paneShowsTurnStarted: claudePaneIsBusy,
|
|
455
|
+
}, turnStarted));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Submit the pasted input with a SINGLE send-keys C-m: one send, throw-on-failure, no retry —
|
|
459
|
+
// delivery-acknowledgment semantics identical to main (see the header note + #1844).
|
|
460
|
+
await paste();
|
|
461
|
+
await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
|
|
462
|
+
options.inputObserver?.offered(Date.now(), false);
|
|
463
|
+
await runTmuxInput("tmux-send-keys-submit", tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// The script keeps the provider command out of tmux argv. Environment values
|
|
467
|
+
// arrive through a private FIFO; only the recovery identity is written here.
|
|
468
|
+
// `gateFifo`, when given, is read from (blocking) before the real command execs
|
|
469
|
+
// (#1317): the caller creates the FIFO before the tmux session exists, attaches
|
|
470
|
+
// `pipe-pane` once the pane is up, then writes to the FIFO to release the gate.
|
|
471
|
+
// That guarantees no provider output can be produced before pipe-pane is capturing
|
|
472
|
+
// it — a process that dies in milliseconds previously produced a 0-byte log because
|
|
473
|
+
// pipe-pane was attached in a separate call *after* the session (and its command)
|
|
474
|
+
// had already started. If the FIFO doesn't exist (e.g. mkfifo unavailable), the
|
|
475
|
+
// redirection fails, bash prints a warning and continues straight to `exec` — same
|
|
476
|
+
// racy-but-working behavior as before this fix, not a hard failure.
|
|
477
|
+
export function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string, gateFifo?: string, stdinFile?: string, exitCodeFile?: string, unsetKeys: string[] = []): string {
|
|
478
|
+
const scriptPath = launcherScriptPathForSession(sessionName);
|
|
479
|
+
const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
|
|
480
|
+
const exports = Object.entries(env)
|
|
481
|
+
.filter(([key]) => key === "AGENT_RELAY_ID")
|
|
482
|
+
.map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
|
|
483
|
+
.join("\n");
|
|
484
|
+
// #2513 — exports only ADD to the tmux server's inherited environment; a key that must
|
|
485
|
+
// not reach the pane (an ambient credential a second-subscription launch must shed)
|
|
486
|
+
// needs an explicit unset, absence from `env` is not enough.
|
|
487
|
+
const unsets = unsetKeys.map((key) => `unset ${shellEnvKey(key)}`).join("\n");
|
|
488
|
+
const gate = gateFifo ? `read -r _ < ${shellQuote(gateFifo)} || true\n` : "";
|
|
489
|
+
const stdin = stdinFile ? `exec < ${shellQuote(stdinFile)}\n` : "";
|
|
490
|
+
const command = exitCodeFile ? `${shellCmd}\nexit_code=$?\nprintf '%s' "$exit_code" > ${shellQuote(exitCodeFile)}\nexit "$exit_code"` : `exec ${shellCmd}`;
|
|
491
|
+
writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}${Object.keys(env).length ? `${envPipeSource()}\n` : ""}${unsets ? `${unsets}\n` : ""}rm -f -- "$0"\n${gate}${stdin}${command}\n`, { mode: 0o700 });
|
|
492
|
+
return scriptPath;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Create the gate FIFO before the tmux session starts. Best-effort: if `mkfifo`
|
|
496
|
+
// is unavailable, the launcher script's `read < fifo` simply fails-and-continues
|
|
497
|
+
// (see writeLauncherScript), so callers don't need to branch on this return value.
|
|
498
|
+
export function createLauncherGate(gateFifo: string): void {
|
|
499
|
+
try { rmSync(gateFifo, { force: true }); } catch {}
|
|
500
|
+
spawnSyncBounded(["mkfifo", "-m", "600", gateFifo], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Release a launcher gate created by `createLauncherGate`, unblocking the
|
|
504
|
+
// launcher script's `read`. Opening a FIFO for writing blocks until a reader is
|
|
505
|
+
// present, so this runs the write in a short-lived subprocess with a timeout
|
|
506
|
+
// guard: if nothing ever reads the FIFO (session/launcher failed to start), we
|
|
507
|
+
// give up rather than hang the caller forever.
|
|
508
|
+
export async function releaseLauncherGate(gateFifo: string, timeoutMs = 5_000): Promise<void> {
|
|
509
|
+
try {
|
|
510
|
+
const proc = Bun.spawn(["sh", "-c", `printf x > ${shellQuote(gateFifo)}`], {
|
|
511
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
512
|
+
});
|
|
513
|
+
const timedOut = await Promise.race([
|
|
514
|
+
proc.exited.then(() => false),
|
|
515
|
+
Bun.sleep(timeoutMs).then(() => true),
|
|
516
|
+
]);
|
|
517
|
+
if (timedOut) {
|
|
518
|
+
try { proc.kill(); } catch {}
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
// best-effort — a stuck launcher gate is no worse than the pre-#1317 race.
|
|
522
|
+
} finally {
|
|
523
|
+
try { rmSync(gateFifo, { force: true }); } catch {}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function shellEnvKey(key: string): string {
|
|
528
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
|
|
529
|
+
throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>, provider = "claude"): string[] {
|
|
533
|
+
// #1222 — PATH is always carried through, not just AGENT_RELAY_/CLAUDE_ vars, so the
|
|
534
|
+
// launcher script's exported session PATH (built by buildSpawnArgs) reaches
|
|
535
|
+
// the tmux pane instead of whatever ambient PATH the tmux server happened to inherit.
|
|
536
|
+
// #3084 — likewise the agent's private TMPDIR/TMP/TEMP: without them the pane got the tmux
|
|
537
|
+
// server's host-shared TMPDIR while only CLAUDE_CODE_TMPDIR was per-agent.
|
|
538
|
+
const keys = new Set<string>(["PATH", ...PROVIDER_SCRATCH_ENV_KEYS, ...providerSecretEnvKeys(provider)]);
|
|
539
|
+
for (const key of Object.keys(env)) {
|
|
540
|
+
if ((key.startsWith("AGENT_RELAY_") || key.startsWith("HUMWICK_")) || key.startsWith("CLAUDE_")) {
|
|
541
|
+
keys.add(key);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const key of Object.keys(providerEnv)) {
|
|
545
|
+
keys.add(key);
|
|
546
|
+
}
|
|
547
|
+
return [...keys].sort();
|
|
548
|
+
}
|