agent-relay-server 0.121.5 → 0.122.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/docs/openapi.json +1 -1
- package/package.json +6 -6
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +672 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/config.ts +1 -4
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +508 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +305 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +314 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +102 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +43 -0
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +118 -37
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +19 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +5 -12
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -12
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- package/public/assets/types-uVOXgvMT.js.map +0 -1
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { type Message } from "agent-relay-sdk";
|
|
6
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
7
|
+
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
8
|
+
import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
9
|
+
import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
|
|
10
|
+
import { computeLivenessSignal, type LivenessInputs } from "../liveness";
|
|
11
|
+
import type { LivenessSignal } from "agent-relay-sdk";
|
|
12
|
+
import { collectClaudeSessionEvents } from "./claude-transcript";
|
|
13
|
+
import type { SessionEvent } from "../session-insights";
|
|
14
|
+
import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
|
|
15
|
+
import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
|
|
16
|
+
import { claudeProviderMessageText } from "./claude-delivery";
|
|
17
|
+
import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
|
|
18
|
+
import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
|
|
19
|
+
import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
|
|
20
|
+
import { launcherScriptPathForSession } from "../session-scratch";
|
|
21
|
+
|
|
22
|
+
export class ClaudeAdapter implements ProviderAdapter {
|
|
23
|
+
readonly provider = "claude";
|
|
24
|
+
readonly compactSupportsInstructions = true;
|
|
25
|
+
// #352: initial prompt is seeded as Claude's positional launch arg (buildSpawnArgs) — reliable,
|
|
26
|
+
// no send-keys/onboarding race; tells the runner to skip the redundant post-launch delivery.
|
|
27
|
+
readonly seedsInitialPromptAtLaunch = true;
|
|
28
|
+
private statusCb: (status: ProviderStatusUpdate) => void = () => {};
|
|
29
|
+
private tmuxWatcher?: Timer;
|
|
30
|
+
private turnWatcher?: Timer;
|
|
31
|
+
private modelUnavailableReported = false;
|
|
32
|
+
private connectionRetryActive = false;
|
|
33
|
+
private promptGatePaneState: ClaudePromptGatePaneState = initialClaudePromptGatePaneState();
|
|
34
|
+
|
|
35
|
+
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
|
|
36
|
+
this.statusCb = cb;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// §4.1 contract verdict (#746). Claude reference (#713): Claude is token-silent during
|
|
40
|
+
// native compaction, but the runner publishes a `compacting` timeline transition while it
|
|
41
|
+
// runs — so `timelineEvent.timestamp` (folded into lastProgressAt) keeps a compacting Opus
|
|
42
|
+
// turn reading as progressing instead of wedged. Transport frames/acks + the server's
|
|
43
|
+
// lastSeen floor cover long token-silent thinking turns. The fold itself is shared; this
|
|
44
|
+
// override exists to document and own Claude's progress sources behind the contract.
|
|
45
|
+
liveness(inputs: LivenessInputs): LivenessSignal {
|
|
46
|
+
return computeLivenessSignal(inputs);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
|
|
50
|
+
const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
|
|
51
|
+
|
|
52
|
+
if (config.headless) {
|
|
53
|
+
return this.spawnHeadless(config, args);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const proc = Bun.spawn([args.command, ...args.args], {
|
|
57
|
+
cwd: args.cwd,
|
|
58
|
+
env: args.env,
|
|
59
|
+
stdin: "inherit",
|
|
60
|
+
stdout: "inherit",
|
|
61
|
+
stderr: "inherit",
|
|
62
|
+
});
|
|
63
|
+
void proc.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error"));
|
|
64
|
+
return { pid: proc.pid, process: proc, meta: { monitor: config.monitor, env: args.env } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
|
|
68
|
+
const tmuxSession = process.meta?.tmuxSession as string | undefined;
|
|
69
|
+
const tmuxSocket = process.meta?.tmuxSocket as string | undefined;
|
|
70
|
+
if (tmuxSession) {
|
|
71
|
+
await this.shutdownTmux(tmuxSession, opts, tmuxSocket);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await terminateSingleProcess(process, opts);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async compact(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown>> {
|
|
78
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
79
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
80
|
+
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for compact");
|
|
81
|
+
const command = opts?.instructions ? `/compact ${opts.instructions}` : "/compact";
|
|
82
|
+
await submitTextToTmux(session, command, socket);
|
|
83
|
+
return { method: "tmux-inject", command };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
87
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
88
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
89
|
+
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for clear");
|
|
90
|
+
await submitTextToTmux(session, "/clear", socket);
|
|
91
|
+
return { method: "tmux-inject", command: "/clear" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// #183/#184: parse the full Claude transcript into the shared SessionEvent stream. The
|
|
95
|
+
// runner slices per-segment, so we return the whole transcript's events each call.
|
|
96
|
+
async collectSessionEvents(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null> {
|
|
97
|
+
if (!ctx.transcriptPath) return null;
|
|
98
|
+
let jsonl: string;
|
|
99
|
+
try {
|
|
100
|
+
jsonl = await readFile(ctx.transcriptPath, "utf8");
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return collectClaudeSessionEvents(jsonl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async collectSessionArchiveSegment(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null> {
|
|
108
|
+
if (!ctx.transcriptPath) return null;
|
|
109
|
+
try {
|
|
110
|
+
return await readFile(ctx.transcriptPath, "utf8");
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
117
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
118
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
119
|
+
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session to interrupt");
|
|
120
|
+
// The same ESC the web terminal's aux key sends: cancels the in-flight turn
|
|
121
|
+
// and drops Claude back to its input box without ending the session.
|
|
122
|
+
const result = Bun.spawnSync(tmuxCommand(socket, "send-keys", "-t", session, "Escape"), {
|
|
123
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
124
|
+
});
|
|
125
|
+
if (result.exitCode !== 0) {
|
|
126
|
+
const stderr = result.stderr.toString().trim();
|
|
127
|
+
throw new Error(`tmux interrupt failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
128
|
+
}
|
|
129
|
+
return { method: "tmux-escape" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async probeActivity(process: ManagedProcess): Promise<"busy" | "idle" | "unknown"> {
|
|
133
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
134
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
135
|
+
if (!session || !tmuxHasSession(session, socket)) return "unknown";
|
|
136
|
+
return this.activityFromProbeOrPane(process, session, socket);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Shared probe-primary / pane-fallback activity read (#766/#768), used by both the
|
|
140
|
+
// reconciler-facing probeActivity and the monitorless turn-watch. Primary: Claude
|
|
141
|
+
// Code's intrinsic session probe (busy/shell→busy — the idle-while-working gap the
|
|
142
|
+
// pane-scrape misread; idle→idle; waiting→unknown, a parked turn the backstop must
|
|
143
|
+
// not force-clear). Legacy fallback: scrape the tmux pane (pre-2.1.119 / probe missing).
|
|
144
|
+
private activityFromProbeOrPane(process: ManagedProcess, session: string, socket?: string): ClaudeProbeActivity {
|
|
145
|
+
const managed = readManagedClaudeStatus({
|
|
146
|
+
pid: process.pid,
|
|
147
|
+
tmuxSession: session,
|
|
148
|
+
tmuxSocket: socket,
|
|
149
|
+
configHomeEnv: (process.meta?.env as Record<string, string | undefined> | undefined)?.CLAUDE_CONFIG_DIR,
|
|
150
|
+
});
|
|
151
|
+
if (managed?.reading.available) return claudeProbeActivity(managed.reading.status);
|
|
152
|
+
let pane: string;
|
|
153
|
+
try { pane = captureTmuxPane(session, socket); } catch { return "unknown"; }
|
|
154
|
+
if (claudePaneIsBusy(pane)) return "busy";
|
|
155
|
+
if (claudePaneLooksReady(pane)) return "idle";
|
|
156
|
+
return "unknown";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// #769 P1-A: an independent pane-busy read, consumed ONLY as a veto on the busy
|
|
160
|
+
// backstop's force-clear (BusyReconciler) — a probe-`idle` must never clear a live
|
|
161
|
+
// turn while the pane spinner still reads busy (a connection-retry/529 can write
|
|
162
|
+
// status:idle mid-turn without tripping the rate-limit pane detector — the #769
|
|
163
|
+
// gap). probeActivity stays probe-primary; this is a clear-time veto-toward-busy
|
|
164
|
+
// only. false on no session / capture failure (no veto).
|
|
165
|
+
async probePaneBusy(process: ManagedProcess): Promise<boolean> {
|
|
166
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
167
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
168
|
+
if (!session || !tmuxHasSession(session, socket)) return false;
|
|
169
|
+
try {
|
|
170
|
+
return claudePaneIsBusy(captureTmuxPane(session, socket));
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
|
|
177
|
+
const monitor = process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
|
|
178
|
+
// A monitor object always exists for headless claude (it proxies to the runner
|
|
179
|
+
// control server), so its presence says nothing about whether a monitor process
|
|
180
|
+
// is actually connected. `monitorless` is set at spawn when the profile disables
|
|
181
|
+
// the relay plugin — then no monitor will ever connect, so we must not route
|
|
182
|
+
// through it (it would throw "no Claude monitor connected" forever); we inject via
|
|
183
|
+
// tmux instead. For monitored profiles we keep the monitor path (and its retry on
|
|
184
|
+
// transient startup races) to avoid double-delivering against a late monitor.
|
|
185
|
+
const monitorless = process.meta?.monitorless === true;
|
|
186
|
+
if (!monitorless && monitor?.deliver) {
|
|
187
|
+
await monitor.deliver(messages);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Inject straight into the interactive tmux session via send-keys — the same
|
|
191
|
+
// transport deliverInitialPrompt uses. Keeps the agent on interactive Claude (the
|
|
192
|
+
// user's Max quota), never `claude -p`/API, and lets isolated automation agents
|
|
193
|
+
// receive their task (a claimable message) and any mid-session messages (e.g. the
|
|
194
|
+
// budget "wrap up" warning) that a launch --prompt can't carry.
|
|
195
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
196
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
197
|
+
if (!session || !tmuxHasSession(session, socket)) {
|
|
198
|
+
throw new Error("Claude monitor delivery is unavailable and no tmux session for fallback");
|
|
199
|
+
}
|
|
200
|
+
await waitForClaudeInputReady(session, CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
|
|
201
|
+
// Monitorless = isolated profile with no relay plugin/CLI surface. Strip relay
|
|
202
|
+
// interaction scaffolding (reply reminder, claim-task instruction) — the agent has
|
|
203
|
+
// no way to /reply or /claim, so it's just confusing noise in a clean-room run.
|
|
204
|
+
await submitTextToTmux(session, claudeProviderMessageText(messages, { deliveryCount: 1, relaySurface: !monitorless }), socket);
|
|
205
|
+
// Monitorless = no Stop/UserPromptSubmit hooks, so the runner gets no turn
|
|
206
|
+
// lifecycle and never marks the claimed task done (the way Codex's app-server
|
|
207
|
+
// status does) — the task lingers until the runtime budget cancels it. Scrape
|
|
208
|
+
// the tmux pane for the busy→idle transition to synthesize a provider-turn
|
|
209
|
+
// signal so isolated automation agents reach task "done" like Codex.
|
|
210
|
+
if (monitorless) this.beginMonitorlessTurnWatch(process, session, socket);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private beginMonitorlessTurnWatch(process: ManagedProcess, sessionName: string, socketName?: string): void {
|
|
214
|
+
this.stopTurnWatch();
|
|
215
|
+
// #769 P2-C: feed the resolved claude pid into the provider-turn liveness pids
|
|
216
|
+
// so #650's pid-liveness reconcile arms for headless/monitorless turns too (the
|
|
217
|
+
// process itself isn't tracked here — pid is undefined for tmux-managed claude).
|
|
218
|
+
// A dead pid means the turn is definitively over, so this can't falsely clear.
|
|
219
|
+
const livenessPid = resolveClaudePid(process.pid, sessionName, socketName);
|
|
220
|
+
// We just submitted a prompt, so a turn is starting. Emit a synthetic
|
|
221
|
+
// provider-turn busy now so the runner arms task-claim completion; the claim
|
|
222
|
+
// for this delivery is already registered before deliver() runs.
|
|
223
|
+
this.statusCb({ status: "busy", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID, ...(livenessPid ? { pids: [livenessPid] } : {}) });
|
|
224
|
+
let ticks = 0;
|
|
225
|
+
let idleStreak = 0;
|
|
226
|
+
let sawBusy = false;
|
|
227
|
+
this.turnWatcher = setInterval(() => {
|
|
228
|
+
ticks += 1;
|
|
229
|
+
if (!tmuxHasSession(sessionName, socketName) || ticks > CLAUDE_TURN_MAX_TICKS) {
|
|
230
|
+
// Session ended (the tmux watcher emits offline) or we hit the safety cap;
|
|
231
|
+
// stop without emitting idle and let budget/server reconcile resolve it.
|
|
232
|
+
this.stopTurnWatch();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// #768: drive turn boundaries off the intrinsic probe (busy/shell→busy,
|
|
236
|
+
// idle→idle), falling back to the pane-scrape when the probe is unavailable.
|
|
237
|
+
// shell→busy means a Bash gate no longer reads as a finished turn.
|
|
238
|
+
const activity = this.activityFromProbeOrPane(process, sessionName, socketName);
|
|
239
|
+
if (activity === "busy") {
|
|
240
|
+
sawBusy = true;
|
|
241
|
+
idleStreak = 0;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
idleStreak = activity === "idle" ? idleStreak + 1 : 0;
|
|
245
|
+
const confirmedIdle = idleStreak >= CLAUDE_TURN_IDLE_CONFIRM_TICKS;
|
|
246
|
+
// Require either an observed busy spinner or a grace window, so we never
|
|
247
|
+
// declare idle in the brief gap before Claude starts rendering the turn.
|
|
248
|
+
const turnObserved = sawBusy || ticks >= CLAUDE_TURN_QUICK_GRACE_TICKS;
|
|
249
|
+
if (confirmedIdle && turnObserved) {
|
|
250
|
+
this.stopTurnWatch();
|
|
251
|
+
this.statusCb({ status: "idle", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID });
|
|
252
|
+
}
|
|
253
|
+
}, CLAUDE_TURN_POLL_MS);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private stopTurnWatch(): void {
|
|
257
|
+
if (this.turnWatcher) {
|
|
258
|
+
clearInterval(this.turnWatcher);
|
|
259
|
+
this.turnWatcher = undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async deliverInitialPrompt(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void> {
|
|
264
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
265
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
266
|
+
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for initial prompt");
|
|
267
|
+
await waitForClaudeInputReady(session, options?.readyTimeoutMs ?? CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
|
|
268
|
+
await submitTextToTmux(session, prompt, socket);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
|
|
272
|
+
// Create + bootstrap + auth-link the isolated config home (no-op for host base) so the
|
|
273
|
+
// assembler's materialize step has somewhere to write the provisioned skills/plugins.
|
|
274
|
+
prepareClaudeProfileHome(config);
|
|
275
|
+
const defaultArgs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
|
|
276
|
+
// #557 — ONE assembler owns the provisioning/vanilla/instruction launch surface
|
|
277
|
+
// (--setting-sources, --plugin-dir, --mcp-config, status-line, --append-system-prompt);
|
|
278
|
+
// profile-projection describes the SAME assembled result, so report == launch. The
|
|
279
|
+
// adapter only adds the bits the assembler does not own: claude-rig command resolution,
|
|
280
|
+
// headless permission flags, --model, and the positional prompt. materializeLaunchAssembly
|
|
281
|
+
// writes the provisioned skills/plugins the assembled args point at.
|
|
282
|
+
const assembled = assembleLaunch("claude", config, providerConfig);
|
|
283
|
+
materializeLaunchAssembly("claude", config);
|
|
284
|
+
const isClaudeRig = /claude-rig/.test(providerConfig.command);
|
|
285
|
+
// Isolated profiles run their own CLAUDE_CONFIG_DIR and must never route through
|
|
286
|
+
// claude-rig — claude-rig resolves host rig config (and a bare `claude-rig` with no
|
|
287
|
+
// rig key just errors out). Only host-base profiles may use claude-rig.
|
|
288
|
+
const usesClaudeRig = isClaudeRig && profileUsesClaudeHostProviderGlobals(config);
|
|
289
|
+
const bypassRigDefaults = config.headless && usesClaudeRig && config.approvalMode !== "open";
|
|
290
|
+
const rigPrefix = usesClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
|
|
291
|
+
const command = !usesClaudeRig || bypassRigDefaults || (!config.rig && !findClaudeRigRC(config.cwd))
|
|
292
|
+
? "claude"
|
|
293
|
+
: providerConfig.command;
|
|
294
|
+
const providerArgs = config.headless
|
|
295
|
+
? claudeLaunchArgs(defaultArgs, config.providerArgs, config.approvalMode)
|
|
296
|
+
: [...defaultArgs, ...config.providerArgs];
|
|
297
|
+
const args = [
|
|
298
|
+
...rigPrefix,
|
|
299
|
+
...assembled.args,
|
|
300
|
+
...providerArgs,
|
|
301
|
+
];
|
|
302
|
+
if (config.model) args.push("--model", config.model);
|
|
303
|
+
// #352: seed the prompt as Claude's positional arg for ALL launches (headless included) —
|
|
304
|
+
// `claude "<prompt>"` seeds the first turn via Claude's own input handling, immune to the
|
|
305
|
+
// send-keys/onboarding race that silently dropped headless spawns. Long/special-char briefs
|
|
306
|
+
// are shell-safe (shellEscape + launcher-script externalization in buildTmuxArgs).
|
|
307
|
+
if (config.prompt) args.push(String(config.prompt));
|
|
308
|
+
return {
|
|
309
|
+
command,
|
|
310
|
+
args,
|
|
311
|
+
cwd: config.cwd,
|
|
312
|
+
env: {
|
|
313
|
+
...config.env,
|
|
314
|
+
// #557 — the assembler owns the isolated config-home env (CLAUDE_CONFIG_DIR).
|
|
315
|
+
...assembled.env,
|
|
316
|
+
// Plain `claude` (isolated profiles) runs its own CLAUDE_CONFIG_DIR and never
|
|
317
|
+
// routes through claude-rig, so it never inherits the host's long-lived
|
|
318
|
+
// CLAUDE_CODE_OAUTH_TOKEN. Without it, claude falls back to the (often stale)
|
|
319
|
+
// file-based .credentials.json and bails to /login (401). claude-rig launches
|
|
320
|
+
// inject their own token, so only thread it for the plain-claude path, and
|
|
321
|
+
// never override an explicit per-launch value.
|
|
322
|
+
...(command === "claude" && process.env.CLAUDE_CODE_OAUTH_TOKEN && !config.env?.CLAUDE_CODE_OAUTH_TOKEN
|
|
323
|
+
? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN }
|
|
324
|
+
: {}),
|
|
325
|
+
AGENT_RELAY_PROVIDER: "claude",
|
|
326
|
+
...(config.effort ? { CLAUDE_CODE_EFFORT_LEVEL: config.effort } : {}),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript: string } {
|
|
332
|
+
const sessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
|
|
333
|
+
const socketName = tmuxSocketName(sessionName);
|
|
334
|
+
const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
|
|
335
|
+
const tmuxArgs = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
|
|
336
|
+
|
|
337
|
+
const envKeys = tmuxEnvKeys(spawnArgs.env, config.providerConfig.env);
|
|
338
|
+
const env: Record<string, string> = {};
|
|
339
|
+
for (const key of envKeys) if (spawnArgs.env[key] !== undefined) env[key] = spawnArgs.env[key]!;
|
|
340
|
+
|
|
341
|
+
// tmux new-session has a hard command-length limit that includes every argv item,
|
|
342
|
+
// including `-e KEY=value`. Keep tmux argv bounded by putting both env payloads
|
|
343
|
+
// (profile JSON, tokens, etc.) and the provider command/prompt in a session script.
|
|
344
|
+
const launcherScript = writeLauncherScript(sessionName, env, shellCmd);
|
|
345
|
+
tmuxArgs.push("-c", spawnArgs.cwd, `bash ${shellQuote(launcherScript)}`);
|
|
346
|
+
return { sessionName, socketName, args: tmuxArgs, launcherScript };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
|
|
350
|
+
const { sessionName, socketName, args: tmuxArgs, launcherScript } = this.buildTmuxArgs(config, spawnArgs);
|
|
351
|
+
this.modelUnavailableReported = false;
|
|
352
|
+
this.connectionRetryActive = false;
|
|
353
|
+
this.promptGatePaneState = initialClaudePromptGatePaneState();
|
|
354
|
+
|
|
355
|
+
Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
|
|
356
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const result = Bun.spawnSync(tmuxCommand(socketName, ...tmuxArgs), {
|
|
360
|
+
stdin: "ignore",
|
|
361
|
+
stdout: "pipe",
|
|
362
|
+
stderr: "pipe",
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (result.exitCode !== 0) {
|
|
366
|
+
const stderr = result.stderr.toString().trim();
|
|
367
|
+
throw new Error(`tmux session creation failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this.watchTmuxSession(sessionName, socketName);
|
|
371
|
+
const logFile = spawnArgs.env.AGENT_RELAY_LOG_FILE;
|
|
372
|
+
if (logFile) {
|
|
373
|
+
Bun.spawnSync(tmuxCommand(socketName, "pipe-pane", "-o", "-t", sessionName, `cat >> ${shellQuote(logFile)}`), {
|
|
374
|
+
stdin: "ignore",
|
|
375
|
+
stdout: "ignore",
|
|
376
|
+
stderr: "ignore",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
pid: undefined, process: undefined, meta: {
|
|
382
|
+
env: spawnArgs.env,
|
|
383
|
+
monitor: config.monitor,
|
|
384
|
+
// When the profile disables the relay plugin, the bundled monitor never
|
|
385
|
+
// loads, so no monitor will ever connect — deliver() must use tmux injection.
|
|
386
|
+
monitorless: !profileAllowsRelayFeature(config, "plugins"),
|
|
387
|
+
tmuxSession: sessionName,
|
|
388
|
+
tmuxSocket: socketName,
|
|
389
|
+
launcherScript,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private watchTmuxSession(sessionName: string, socketName?: string): void {
|
|
395
|
+
if (this.tmuxWatcher) clearInterval(this.tmuxWatcher);
|
|
396
|
+
this.tmuxWatcher = setInterval(() => {
|
|
397
|
+
if (!tmuxHasSession(sessionName, socketName)) {
|
|
398
|
+
clearInterval(this.tmuxWatcher!);
|
|
399
|
+
this.tmuxWatcher = undefined;
|
|
400
|
+
this.statusCb("offline");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (this.modelUnavailableReported) return;
|
|
404
|
+
let pane = "";
|
|
405
|
+
try {
|
|
406
|
+
pane = captureTmuxPane(sessionName, socketName);
|
|
407
|
+
} catch {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const status = claudeModelUnavailableStatus(pane, sessionName);
|
|
411
|
+
if (status) {
|
|
412
|
+
this.modelUnavailableReported = true;
|
|
413
|
+
this.statusCb(status);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// #769: connection-retry banner detection. Emits a transient blocked hold while the
|
|
417
|
+
// pane shows the retry footer; clears to idle when the banner disappears (API recovered).
|
|
418
|
+
const connRetryStatus = claudeConnectionRetryStatus(pane, sessionName);
|
|
419
|
+
if (connRetryStatus) {
|
|
420
|
+
if (!this.connectionRetryActive) {
|
|
421
|
+
this.connectionRetryActive = true;
|
|
422
|
+
this.statusCb(connRetryStatus);
|
|
423
|
+
}
|
|
424
|
+
} else if (this.connectionRetryActive) {
|
|
425
|
+
this.connectionRetryActive = false;
|
|
426
|
+
this.statusCb("idle");
|
|
427
|
+
}
|
|
428
|
+
// #663: prompt-gate fallback. Known gates should be prevented before launch;
|
|
429
|
+
// this catches missed/new blocking panes with the same #655 debounce + busy
|
|
430
|
+
// guard used for rate-limit holds.
|
|
431
|
+
const promptGate = evaluateClaudePromptGatePane(pane, this.promptGatePaneState, {
|
|
432
|
+
sessionName,
|
|
433
|
+
busy: claudePaneIsBusy(pane),
|
|
434
|
+
});
|
|
435
|
+
this.promptGatePaneState = promptGate.state;
|
|
436
|
+
if (promptGate.action) {
|
|
437
|
+
this.sendPromptGateKeystroke(sessionName, promptGate.action.keystroke, socketName);
|
|
438
|
+
if (promptGate.status) this.statusCb(promptGate.status);
|
|
439
|
+
}
|
|
440
|
+
}, 2000);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Best-effort: a failed auto-dismiss must not wedge the pane watcher.
|
|
444
|
+
private sendPromptGateKeystroke(sessionName: string, keystroke: string, socketName?: string): void {
|
|
445
|
+
try {
|
|
446
|
+
Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, keystroke), { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
447
|
+
} catch {
|
|
448
|
+
// ignore; the timeline/status still publishes, and the next watcher tick can observe whether the pane moved.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async shutdownTmux(sessionName: string, opts: { graceful: boolean; timeoutMs: number }, socketName?: string): Promise<void> {
|
|
453
|
+
this.stopTurnWatch();
|
|
454
|
+
if (this.tmuxWatcher) {
|
|
455
|
+
clearInterval(this.tmuxWatcher);
|
|
456
|
+
this.tmuxWatcher = undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (opts.graceful && tmuxHasSession(sessionName, socketName)) {
|
|
460
|
+
await submitTextToTmux(sessionName, CLAUDE_EXIT_COMMAND, socketName);
|
|
461
|
+
|
|
462
|
+
const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
|
|
463
|
+
while (Date.now() < deadline) {
|
|
464
|
+
if (!tmuxHasSession(sessionName, socketName)) return;
|
|
465
|
+
await Bun.sleep(200);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
|
|
470
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export const CLAUDE_EXIT_COMMAND = "/exit";
|
|
476
|
+
export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
|
|
477
|
+
export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
|
|
478
|
+
export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
|
|
479
|
+
// Monitorless turn detection (Fix: isolated agents reach task "done" like Codex).
|
|
480
|
+
const CLAUDE_TURN_WATCH_ID = "tmux-turn";
|
|
481
|
+
const CLAUDE_TURN_POLL_MS = 1500;
|
|
482
|
+
const CLAUDE_TURN_IDLE_CONFIRM_TICKS = 3; // ~4.5s of stable idle before declaring done
|
|
483
|
+
const CLAUDE_TURN_QUICK_GRACE_TICKS = 8; // ~12s fallback when the spinner was never caught
|
|
484
|
+
const CLAUDE_TURN_MAX_TICKS = 3600; // ~90min safety cap so the timer never leaks
|
|
485
|
+
|
|
486
|
+
export function claudeShutdownGraceMs(timeoutMs: number): number {
|
|
487
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
|
|
488
|
+
return Math.min(timeoutMs, 10_000);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// #557 — sessionStatusLineSettingsArgs now lives in ../launch-assembly (the single
|
|
492
|
+
// launch-arg home); re-exported so existing ./claude consumers + tests keep their path.
|
|
493
|
+
export { sessionStatusLineSettingsArgs };
|
|
494
|
+
|
|
495
|
+
export function claudeLaunchArgs(defaultArgs: string[], providerArgs: string[], approvalMode?: string): string[] {
|
|
496
|
+
const stripToolArgs = approvalMode === "read-only";
|
|
497
|
+
return [
|
|
498
|
+
...stripClaudePermissionArgs(defaultArgs, stripToolArgs),
|
|
499
|
+
...stripClaudePermissionArgs(providerArgs, stripToolArgs),
|
|
500
|
+
...claudePermissionModeArgs(approvalMode),
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function claudePermissionModeArgs(approvalMode?: string): string[] {
|
|
505
|
+
if (approvalMode === "open") return ["--dangerously-skip-permissions"];
|
|
506
|
+
if (approvalMode === "read-only") return ["--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob,LS,Bash"];
|
|
507
|
+
return ["--permission-mode", "default"];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function stripClaudePermissionArgs(args: string[], stripToolArgs = false): string[] {
|
|
511
|
+
const result: string[] = [];
|
|
512
|
+
for (let i = 0; i < args.length; i++) {
|
|
513
|
+
const arg = args[i];
|
|
514
|
+
if (!arg) continue;
|
|
515
|
+
if (
|
|
516
|
+
arg === "--dangerously-skip-permissions" ||
|
|
517
|
+
arg === "--allow-dangerously-skip-permissions" ||
|
|
518
|
+
arg?.startsWith("--permission-mode=")
|
|
519
|
+
) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (arg === "--permission-mode") {
|
|
523
|
+
i++;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (stripToolArgs && /^--(?:tools|allowedTools|allowed-tools|disallowedTools|disallowed-tools)(?:=|$)/.test(arg)) {
|
|
527
|
+
if (!arg.includes("=")) i++;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
result.push(arg);
|
|
531
|
+
}
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
|
|
537
|
+
if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
|
|
538
|
+
return `${prefix}-${instanceId.slice(0, 8)}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function tmuxSocketName(sessionName: string): string {
|
|
542
|
+
return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Shared tmux helpers; tmuxHasSession re-exported for ./claude consumers + tests.
|
|
546
|
+
export { tmuxHasSession };
|
|
547
|
+
|
|
548
|
+
function captureTmuxPane(sessionName: string, socketName?: string): string {
|
|
549
|
+
const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
|
|
550
|
+
stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
551
|
+
});
|
|
552
|
+
if (result.exitCode !== 0) {
|
|
553
|
+
const stderr = result.stderr.toString().trim();
|
|
554
|
+
throw new Error(`tmux capture-pane failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
555
|
+
}
|
|
556
|
+
return result.stdout.toString();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Re-export pane-status detectors (moved to ./claude-status-detectors) so existing
|
|
560
|
+
// import paths (including claude.test.ts) continue to resolve via this module.
|
|
561
|
+
export { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
|
|
562
|
+
|
|
563
|
+
async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
|
|
564
|
+
const deadline = Date.now() + timeoutMs;
|
|
565
|
+
while (Date.now() < deadline) {
|
|
566
|
+
if (!tmuxHasSession(sessionName, socketName)) throw new Error("tmux session exited before Claude became ready");
|
|
567
|
+
if (claudePaneLooksReady(captureTmuxPane(sessionName, socketName))) return;
|
|
568
|
+
await Bun.sleep(200);
|
|
569
|
+
}
|
|
570
|
+
throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
|
|
574
|
+
const bufferName = `agent-relay-${crypto.randomUUID()}`;
|
|
575
|
+
const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
|
|
576
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
577
|
+
});
|
|
578
|
+
if (setResult.exitCode !== 0) {
|
|
579
|
+
const stderr = setResult.stderr.toString().trim();
|
|
580
|
+
throw new Error(`tmux set-buffer failed: ${stderr || `exit code ${setResult.exitCode}`}`);
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const pasteResult = Bun.spawnSync(tmuxCommand(socketName, "paste-buffer", "-b", bufferName, "-t", sessionName), {
|
|
584
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
585
|
+
});
|
|
586
|
+
if (pasteResult.exitCode !== 0) {
|
|
587
|
+
const stderr = pasteResult.stderr.toString().trim();
|
|
588
|
+
throw new Error(`tmux paste-buffer failed: ${stderr || `exit code ${pasteResult.exitCode}`}`);
|
|
589
|
+
}
|
|
590
|
+
} finally {
|
|
591
|
+
Bun.spawnSync(tmuxCommand(socketName, "delete-buffer", "-b", bufferName), {
|
|
592
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function submitTextToTmux(sessionName: string, text: string, socketName?: string): Promise<void> {
|
|
598
|
+
pasteTextIntoTmux(sessionName, text, socketName);
|
|
599
|
+
await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
|
|
600
|
+
const result = Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY), {
|
|
601
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
602
|
+
});
|
|
603
|
+
if (result.exitCode !== 0) {
|
|
604
|
+
const stderr = result.stderr.toString().trim();
|
|
605
|
+
throw new Error(`tmux submit failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
|
|
610
|
+
const scriptPath = launcherScriptPathForSession(sessionName);
|
|
611
|
+
const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
|
|
612
|
+
const exports = Object.entries(env)
|
|
613
|
+
.map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
|
|
614
|
+
.join("\n");
|
|
615
|
+
writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}exec ${shellCmd}\n`, { mode: 0o700 });
|
|
616
|
+
return scriptPath;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function shellEnvKey(key: string): string {
|
|
620
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
|
|
621
|
+
throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
|
|
625
|
+
const keys = new Set<string>();
|
|
626
|
+
for (const key of Object.keys(env)) {
|
|
627
|
+
if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
|
|
628
|
+
keys.add(key);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const key of Object.keys(providerEnv)) {
|
|
632
|
+
keys.add(key);
|
|
633
|
+
}
|
|
634
|
+
return [...keys].sort();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
|
|
638
|
+
export { shellQuote };
|
|
639
|
+
|
|
640
|
+
export function findClaudeRigRC(cwd: string): string | null {
|
|
641
|
+
const home = homedir();
|
|
642
|
+
let dir = resolve(cwd);
|
|
643
|
+
while (true) {
|
|
644
|
+
// Stop at $HOME without inspecting it: ~/.claude-rig is the claude-rig tool's
|
|
645
|
+
// own config home, not a per-project rc marker. Matching it would make every
|
|
646
|
+
// project under $HOME look like a claude-rig project.
|
|
647
|
+
if (dir === home) return null;
|
|
648
|
+
const rc = join(dir, ".claude-rig");
|
|
649
|
+
if (existsSync(rc)) return rc;
|
|
650
|
+
const parent = resolve(dir, "..");
|
|
651
|
+
if (parent === dir) return null;
|
|
652
|
+
dir = parent;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function terminateSingleProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
|
|
657
|
+
const proc = process.process;
|
|
658
|
+
if (!proc) return;
|
|
659
|
+
try {
|
|
660
|
+
proc.kill(opts.graceful ? "SIGTERM" : "SIGKILL");
|
|
661
|
+
} catch {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), opts.timeoutMs));
|
|
665
|
+
const result = await Promise.race([proc.exited, timeout]);
|
|
666
|
+
if (result === "timeout") {
|
|
667
|
+
try {
|
|
668
|
+
proc.kill("SIGKILL");
|
|
669
|
+
} catch {}
|
|
670
|
+
await proc.exited.catch(() => {});
|
|
671
|
+
}
|
|
672
|
+
}
|