agent-relay-runner 0.129.11 → 0.129.12
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/package.json +3 -3
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +8 -8
- package/src/adapter.ts +113 -11
- package/src/adapters/claude-delivery.ts +1 -7
- package/src/adapters/claude-exit.ts +289 -0
- package/src/adapters/claude-prompt-gates.ts +59 -79
- package/src/adapters/claude-session-capture.ts +13 -2
- package/src/adapters/claude-session-probe.ts +98 -7
- package/src/adapters/claude-shutdown.ts +149 -0
- package/src/adapters/claude-status-detectors.ts +201 -69
- package/src/adapters/claude-tmux.ts +94 -5
- package/src/adapters/claude-transcript-tail.ts +22 -3
- package/src/adapters/claude-transcript.ts +95 -1
- package/src/adapters/claude.ts +179 -76
- package/src/adapters/codex.ts +46 -8
- package/src/busy-reconciler.ts +67 -29
- package/src/config.ts +16 -0
- package/src/launch-assembly.ts +251 -82
- package/src/native-memory-lever.ts +308 -0
- package/src/outbox.ts +22 -4
- package/src/profile-projection.ts +45 -1
- package/src/provider-input-queue.ts +100 -0
- package/src/rate-limit.ts +106 -36
- package/src/relay-injection-events.ts +24 -0
- package/src/relay-instructions.ts +9 -0
- package/src/relay-mcp-proxy.ts +79 -8
- package/src/relay-mcp.ts +217 -8
- package/src/runner-core.ts +323 -47
- package/src/runner-helpers.ts +10 -0
- package/src/transcript-context.ts +83 -0
- package/src/turn-reconcile-event.ts +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.129.
|
|
3
|
+
"version": "0.129.12",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"directory": "runner"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"agent-relay-providers": "0.104.
|
|
26
|
-
"agent-relay-sdk": "0.2.
|
|
25
|
+
"agent-relay-providers": "0.104.6",
|
|
26
|
+
"agent-relay-sdk": "0.2.130",
|
|
27
27
|
"callmux": "0.24.2"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
@@ -47,8 +47,8 @@ var RUNNER_PROMPT_TEMPLATE_DEFAULTS = [
|
|
|
47
47
|
category: "runner",
|
|
48
48
|
title: "Codex Relay Context",
|
|
49
49
|
description: "Relay context note injected into Codex sessions.",
|
|
50
|
-
template: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval.
|
|
51
|
-
defaultTemplate: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval.
|
|
50
|
+
template: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. Prefer Relay MCP tools for the ops they cover, but WHICH relay_* tools you have depends on your role: a worker loads a lean implementer set (delivery, workspace status, the message round-trip, memory, blackboard, artifacts, timers, agent discovery, self-care), a coordinator loads the full surface \u2014 so use only the tools your session actually exposes and do not assume a specific tool is present. You never need to know or pass your own agent id \u2014 relay fills it from your token. Your identity is injected at startup as the `## Agent Relay Identity` block, so you never need an identity tool. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own \u2014 find them later with relay_find_agents spawnedBy:me. For replies, prefer relay_reply or relay_send_message; CLI fallback: agent-relay /reply <messageId> \"<your reply>\". If a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. CLI-FIRST for GitHub and code intel (zero always-loaded tool-schema cost): use the `gh` CLI for all GitHub work \u2014 do NOT reach for GitHub MCP tools \u2014 and the `tl` (tokenlean) CLI for code intel (e.g. `tl run`, `tl pack`). The `agent-relay` CLI covers a specific set of relay ops: `agent-relay message`/`reply`/`get-message` and `agent-relay workspace <status|deps|list|claim|release>`; use those instead of the matching MCP tools. Other relay capabilities (memory, blackboard, timers, artifacts, and agent discovery via relay_find_agents) have no equivalent CLI and stay as MCP tools in your set \u2014 call them directly. For non-relay tools routed through callmux (web search, code search, reasoning, etc.): when the callmux meta-tools are present, call callmux_search_tools to find a tool, then callmux_call to invoke it on demand. For command details, run: agent-relay /guide",
|
|
51
|
+
defaultTemplate: "[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. Prefer Relay MCP tools for the ops they cover, but WHICH relay_* tools you have depends on your role: a worker loads a lean implementer set (delivery, workspace status, the message round-trip, memory, blackboard, artifacts, timers, agent discovery, self-care), a coordinator loads the full surface \u2014 so use only the tools your session actually exposes and do not assume a specific tool is present. You never need to know or pass your own agent id \u2014 relay fills it from your token. Your identity is injected at startup as the `## Agent Relay Identity` block, so you never need an identity tool. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own \u2014 find them later with relay_find_agents spawnedBy:me. For replies, prefer relay_reply or relay_send_message; CLI fallback: agent-relay /reply <messageId> \"<your reply>\". If a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. CLI-FIRST for GitHub and code intel (zero always-loaded tool-schema cost): use the `gh` CLI for all GitHub work \u2014 do NOT reach for GitHub MCP tools \u2014 and the `tl` (tokenlean) CLI for code intel (e.g. `tl run`, `tl pack`). The `agent-relay` CLI covers a specific set of relay ops: `agent-relay message`/`reply`/`get-message` and `agent-relay workspace <status|deps|list|claim|release>`; use those instead of the matching MCP tools. Other relay capabilities (memory, blackboard, timers, artifacts, and agent discovery via relay_find_agents) have no equivalent CLI and stay as MCP tools in your set \u2014 call them directly. For non-relay tools routed through callmux (web search, code search, reasoning, etc.): when the callmux meta-tools are present, call callmux_search_tools to find a tool, then callmux_call to invoke it on demand. For command details, run: agent-relay /guide",
|
|
52
52
|
variables: []
|
|
53
53
|
},
|
|
54
54
|
{
|
|
@@ -56,8 +56,8 @@ var RUNNER_PROMPT_TEMPLATE_DEFAULTS = [
|
|
|
56
56
|
category: "runner",
|
|
57
57
|
title: "Runner Workspace Lifecycle Note",
|
|
58
58
|
description: "Lifecycle note injected by the runner for isolated workspaces.",
|
|
59
|
-
template: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "While iterating, self-validate by running only the tests affected by your change \u2014 use your project's affected-test command if it has one; the full test gate still runs automatically at delivery, so scoping down while you iterate is safe.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success
|
|
60
|
-
defaultTemplate: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "While iterating, self-validate by running only the tests affected by your change \u2014 use your project's affected-test command if it has one; the full test gate still runs automatically at delivery, so scoping down while you iterate is safe.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success
|
|
59
|
+
template: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "While iterating, self-validate by running only the tests affected by your change \u2014 use your project's affected-test command if it has one; the full test gate still runs automatically at delivery, so scoping down while you iterate is safe.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success Relay keeps this worktree on its current post-land branch; keep working there and the next delivery rebases it against the latest base. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
|
|
60
|
+
defaultTemplate: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "While iterating, self-validate by running only the tests affected by your change \u2014 use your project's affected-test command if it has one; the full test gate still runs automatically at delivery, so scoping down while you iterate is safe.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success Relay keeps this worktree on its current post-land branch; keep working there and the next delivery rebases it against the latest base. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
|
|
61
61
|
variables: ["branch", "baseRef"]
|
|
62
62
|
},
|
|
63
63
|
{
|
|
@@ -118,6 +118,10 @@ function relayReplyActionText(messageId) {
|
|
|
118
118
|
function relayReplyReminderText(messageId) {
|
|
119
119
|
return `If this batch needs a response, send one useful reply to the latest relevant message with ${relayReplyActionText(messageId)}`;
|
|
120
120
|
}
|
|
121
|
+
var REMINDER_EVERY_DELIVERIES = 5;
|
|
122
|
+
function shouldShowReplyReminder(deliveryCount) {
|
|
123
|
+
return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
|
|
124
|
+
}
|
|
121
125
|
function workspaceDepsNote(input) {
|
|
122
126
|
if (input.mode !== "isolated")
|
|
123
127
|
return "";
|
|
@@ -277,7 +281,6 @@ function providerAttachmentText(message) {
|
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
// runner/src/adapters/claude-delivery.ts
|
|
280
|
-
var REMINDER_EVERY_DELIVERIES = 5;
|
|
281
284
|
var TASK_CLAIM_INSTRUCTION = "Claim this task before working it, then update task status when finished.";
|
|
282
285
|
function stripRelayScaffolding(body) {
|
|
283
286
|
const lines2 = body.split(`
|
|
@@ -314,9 +317,6 @@ function providerSenderLabel(message) {
|
|
|
314
317
|
const isHumanChannel = Boolean(source && (isRecord(source.telegram) || isRecord(source.slack) || isRecord(source.discord)));
|
|
315
318
|
return isHumanChannel ? `human user via ${message.from}` : message.from;
|
|
316
319
|
}
|
|
317
|
-
function shouldShowReplyReminder(deliveryCount) {
|
|
318
|
-
return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
|
|
319
|
-
}
|
|
320
320
|
function latestReplyableMessage(messages) {
|
|
321
321
|
return messages.filter((message) => isPersistedRelayMessage2(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false).at(-1);
|
|
322
322
|
}
|
package/src/adapter.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
import type { AgentProfile, InteractivePrompt, LivenessSignal, Message, MessageFinalityConfidence, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
|
|
1
|
+
import type { AgentProfile, InteractivePrompt, LivenessSignal, Message, MessageFinalityConfidence, MessageTurnEndReason, ProviderState, ToolCallCategory } from "agent-relay-sdk";
|
|
2
2
|
import { isRecord } from "agent-relay-sdk/types/guards";
|
|
3
3
|
import type { LivenessInputs } from "./liveness";
|
|
4
4
|
import type { SessionEvent } from "./session-insights";
|
|
5
5
|
import { messageBodyMaxCharsFromEnv } from "./message-body-config";
|
|
6
|
-
import { relayReplyReminderText } from "./relay-instructions";
|
|
6
|
+
import { relayReplyReminderText, shouldShowReplyReminder } from "./relay-instructions";
|
|
7
7
|
import { renderRunnerTemplate } from "./template-resolver";
|
|
8
8
|
|
|
9
9
|
export type SemanticStatus = "idle" | "busy" | "offline" | "error";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* #1656 — the independent pane read is THREE-VALUED on its non-busy side, and collapsing it to a
|
|
13
|
+
* boolean was the defect. `false` used to mean both "captured the pane, no spinner" (evidence the
|
|
14
|
+
* turn is over) and "could not read the pane at all" (no evidence of anything), and the busy
|
|
15
|
+
* backstop's force-clear consumed the second as if it were the first: absence of evidence taken
|
|
16
|
+
* as consent, on a turn that was still running.
|
|
17
|
+
*
|
|
18
|
+
* - `busy` — captured, the pane visibly reads busy. Vetoes a clear.
|
|
19
|
+
* - `not-busy` — captured, no busy chrome. The ONLY positive evidence the turn is over.
|
|
20
|
+
* - `no-session` — the pane doesn't exist to be read (no tmux session).
|
|
21
|
+
* - `unreadable` — the capture failed. Says nothing about the provider, only about the read.
|
|
22
|
+
*
|
|
23
|
+
* The last two are both `unknown` to the reconciler and behave identically there; they are kept
|
|
24
|
+
* distinct so the session log names which failure happened. Neither may ever authorize a mutation.
|
|
25
|
+
*/
|
|
26
|
+
export type PaneBusyReading = "busy" | "not-busy" | "no-session" | "unreadable";
|
|
10
27
|
type ProviderWorkKind = "provider-turn" | "subagent" | "background-script";
|
|
11
28
|
|
|
12
29
|
export interface ProviderStatusEvent {
|
|
@@ -33,10 +50,43 @@ export interface ProviderStatusEvent {
|
|
|
33
50
|
pids?: number[];
|
|
34
51
|
providerState?: ProviderState;
|
|
35
52
|
metadata?: Record<string, unknown>;
|
|
53
|
+
// #1235 — the live transcript path for a hookless (monitorless) provider turn. When an
|
|
54
|
+
// adapter that learns turn boundaries WITHOUT Stop/UserPromptSubmit hooks (Claude's tmux
|
|
55
|
+
// monitorless watch) supplies this on a `provider-turn` busy/idle event, the runner drives
|
|
56
|
+
// the transcript reasoning tail natively: start on turn-start, capture the final response on
|
|
57
|
+
// turn-end. Absent for hook-driven agents, whose capture stays hook-triggered (no double-run).
|
|
58
|
+
transcriptPath?: string;
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
export type ProviderStatusUpdate = SemanticStatus | ProviderStatusEvent;
|
|
39
62
|
|
|
63
|
+
/**
|
|
64
|
+
* #1651 — a pane-derived DIAGNOSTIC, and the ONLY thing a pane observation is allowed to
|
|
65
|
+
* produce. Deliberately structurally incapable of mutating runner state: it carries no
|
|
66
|
+
* SemanticStatus, no work-kind `clear`, and no ProviderState, so no amount of pane text can
|
|
67
|
+
* end a provider turn, clear subagent work, complete a task claim, place a scheduling hold,
|
|
68
|
+
* decide a restart, or license terminal wording. Those mutations require positively
|
|
69
|
+
* correlated structured evidence (hooks, StopFailure, process exit) — never a string match
|
|
70
|
+
* against TUI chrome that agent output can quote verbatim.
|
|
71
|
+
*
|
|
72
|
+
* A detector that wants to say something about the pane emits one of these via
|
|
73
|
+
* {@link ProviderAdapter.onPaneObservation}; the runner records it and nothing else.
|
|
74
|
+
*/
|
|
75
|
+
export interface PaneObservation {
|
|
76
|
+
/** Stable event id, e.g. "claude.pane_gate_candidate". */
|
|
77
|
+
eventType: string;
|
|
78
|
+
/** Always "pane" — names the (untrusted) evidence class this reading came from. */
|
|
79
|
+
source: "pane";
|
|
80
|
+
/** Adapter-scoped detector id: a prompt-gate id, "connection-retry", "model-unavailable". */
|
|
81
|
+
detectorId: string;
|
|
82
|
+
observedAt: number;
|
|
83
|
+
sessionName?: string;
|
|
84
|
+
/** The pane line that triggered the observation, when there is one. Diagnostic only. */
|
|
85
|
+
message?: string;
|
|
86
|
+
/** Detector-specific extras. Diagnostic only — never read for a control decision. */
|
|
87
|
+
detail?: Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
/**
|
|
41
91
|
* A session-mirror event surfaced by an adapter that learns about session
|
|
42
92
|
* activity through provider events rather than hooks/transcripts (e.g. the Codex
|
|
@@ -58,6 +108,8 @@ export interface ProviderSessionEvent {
|
|
|
58
108
|
/** Whether this response may clear a direct user obligation; Codex keeps this false. */
|
|
59
109
|
replyToUserObligation?: boolean;
|
|
60
110
|
label?: string;
|
|
111
|
+
/** Provider-independent tool-call category (#1498), adapter-resolved; only set when type is "tool". */
|
|
112
|
+
category?: ToolCallCategory;
|
|
61
113
|
status?: "running" | "completed" | "failed";
|
|
62
114
|
streaming?: boolean;
|
|
63
115
|
/** Stable provider-side step id (Codex app-server item id). Carried into
|
|
@@ -180,6 +232,11 @@ export interface RunnerSpawnConfig {
|
|
|
180
232
|
// Stage 2 (#215): the MCP endpoint the agent connects to — the runner-local proxy URL when the
|
|
181
233
|
// proxy is active. Undefined → the adapter targets the relay's MCP endpoint directly (Stage 1).
|
|
182
234
|
relayMcpEndpoint?: string;
|
|
235
|
+
// #1450: the per-session proxy secret the agent authenticates to the runner-local proxy with (the
|
|
236
|
+
// same value exported as AGENT_RELAY_SESSION_TOKEN). Set only when the proxy is active; the Codex
|
|
237
|
+
// per-agent metaOnly callmux uses it to reach the relay endpoint on-demand THROUGH the proxy, so
|
|
238
|
+
// identity rides the proxy's live-token injection and no relay token is ever written to disk.
|
|
239
|
+
mcpProxySecret?: string;
|
|
183
240
|
// #672: the shared host-listener URL the per-agent `callmux bridge` connects to, when the
|
|
184
241
|
// profile/spawn opted into `agentProfile.sharedMcp`. Resolved runner-side (env → default);
|
|
185
242
|
// the launch assembler only emits the bridge entry when sharedMcp is on.
|
|
@@ -230,6 +287,15 @@ export interface ProviderPermissionDecisionReason {
|
|
|
230
287
|
message?: string;
|
|
231
288
|
}
|
|
232
289
|
|
|
290
|
+
// Fresh, per-turn context usage a transcript-backed adapter reads from its
|
|
291
|
+
// session log (#1512). tokensUsed is the context-window token sum for the turn;
|
|
292
|
+
// occurredAt is the wall-clock ms of the turn when the log stamps one.
|
|
293
|
+
export interface ProviderContextUsage {
|
|
294
|
+
tokensUsed: number;
|
|
295
|
+
model?: string;
|
|
296
|
+
occurredAt?: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
233
299
|
export interface ProviderAdapter {
|
|
234
300
|
provider: string;
|
|
235
301
|
statusSourceId?: string;
|
|
@@ -240,6 +306,24 @@ export interface ProviderAdapter {
|
|
|
240
306
|
compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
|
|
241
307
|
compactSupportsInstructions?: boolean;
|
|
242
308
|
clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
309
|
+
// #1588 — when true, the runner delivers a self-resume clear-inject continuation IN-PROCESS,
|
|
310
|
+
// coupled to the very same agent.clearContext command (deliverCoupledContinuationAfterClear),
|
|
311
|
+
// rather than leaving it to the separate reactive agent.injectContext command. Set this ONLY for
|
|
312
|
+
// providers whose clearContext is a fire-and-forget keystroke with no structured completion
|
|
313
|
+
// barrier — Claude's tmux `/clear` — where a separate inject would race the still-processing
|
|
314
|
+
// async clear and get swallowed (the #1588 silent mind-wipe). Providers whose clearContext is an
|
|
315
|
+
// awaited atomic call leave this unset: Codex's `client.threadStart()` returns the confirmed,
|
|
316
|
+
// ready new thread id BEFORE clearContext resolves, so the await IS the barrier — there is no
|
|
317
|
+
// race and the existing separate-inject-to-a-ready-thread flow (backstopped by the server-side
|
|
318
|
+
// #1023 sweep) stays correct and untouched.
|
|
319
|
+
couplesClearInjectContinuation?: boolean;
|
|
320
|
+
// Derive the current context-window token usage from a transcript-backed
|
|
321
|
+
// provider's session log (#1512). The runner calls this every heartbeat with the
|
|
322
|
+
// latest transcript so context stats keep advancing per-turn even when a provider's
|
|
323
|
+
// out-of-band probe (Claude's status-line command) goes silent after a
|
|
324
|
+
// compact/clear/native-resume. Pure parse — no process state. Event-stream
|
|
325
|
+
// providers (Codex) report context through `meta.getContext` instead and omit this.
|
|
326
|
+
contextUsageFromTranscript?(jsonl: string): ProviderContextUsage | undefined;
|
|
243
327
|
// Normalize the session so far into the provider-agnostic SessionEvent stream the
|
|
244
328
|
// Insights context-ratio signal (#183/#184) reduces. Called by the runner's
|
|
245
329
|
// pre-session-destroy seam before any compact/clear/restart/shutdown. The runner owns
|
|
@@ -265,11 +349,19 @@ export interface ProviderAdapter {
|
|
|
265
349
|
// turn was interrupted from the web terminal so no Stop hook fired). "unknown"
|
|
266
350
|
// means the provider can't be cheaply probed and the reconciler should defer.
|
|
267
351
|
probeActivity?(process: ManagedProcess): Promise<"busy" | "idle" | "unknown">;
|
|
268
|
-
// #769 P1-A: an independent "is the provider still visibly busy?" read, consulted
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
352
|
+
// #769 P1-A: an independent "is the provider still visibly busy?" read, consulted by the
|
|
353
|
+
// busy backstop — a probeActivity()=idle must not clear a live turn while this reads `busy`
|
|
354
|
+
// (defense-in-depth when the primary probe can momentarily read idle mid-turn, e.g. a
|
|
355
|
+
// connection retry). #1656: three-valued on purpose. Only `not-busy` is evidence the turn
|
|
356
|
+
// ended; `no-session`/`unreadable` are unknown and must never authorize a clear.
|
|
357
|
+
probePaneBusy?(process: ManagedProcess): Promise<PaneBusyReading>;
|
|
358
|
+
// #1138: resolve a real OS pid for a busy provider-turn whose ManagedProcess carries
|
|
359
|
+
// none directly (e.g. Claude's tmux launch — process.pid is undefined, only a tmux
|
|
360
|
+
// session/socket). The runner calls this as a fallback when a busy report supplies no
|
|
361
|
+
// explicit pids, so the #650 liveness reconcile (and the stale-turn backstop) has real
|
|
362
|
+
// proof-of-life instead of only an elapsed-time heuristic. Returning undefined leaves
|
|
363
|
+
// the runner's existing process.pid fallback (or no pid at all) unchanged.
|
|
364
|
+
resolveBusyPid?(process: ManagedProcess): number | undefined;
|
|
273
365
|
terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
|
|
274
366
|
respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
|
|
275
367
|
captureSessionTurn?(input: ProviderSessionTurnInput, ctx: ProviderSessionTurnContext): Promise<ProviderSessionTurnResult | void>;
|
|
@@ -294,6 +386,11 @@ export interface ProviderAdapter {
|
|
|
294
386
|
seedsInitialPromptAtLaunch?: boolean;
|
|
295
387
|
deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
|
|
296
388
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
|
|
389
|
+
// #1651 — subscribe to pane-derived DIAGNOSTICS. Separate from onStatusChange on purpose:
|
|
390
|
+
// this channel's payload cannot carry a status, a clear, or a providerState, so wiring a
|
|
391
|
+
// detector here is a structural guarantee that it cannot mutate runner state. Optional;
|
|
392
|
+
// adapters with no pane to scrape simply never call it.
|
|
393
|
+
onPaneObservation?(cb: (observation: PaneObservation) => void): void;
|
|
297
394
|
// Subscribe to session-mirror events from providers. Batch callbacks preserve
|
|
298
395
|
// shared idempotency for multiple trace steps discovered in one provider poll.
|
|
299
396
|
onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
|
|
@@ -516,7 +613,14 @@ export function providerAttachmentText(message: Message): string | undefined {
|
|
|
516
613
|
].join("\n");
|
|
517
614
|
}
|
|
518
615
|
|
|
519
|
-
export
|
|
616
|
+
export interface ProviderMessageTextOptions {
|
|
617
|
+
// #1570 SUB-A — how many deliveries this session has sent so far (1 = first). Gates the
|
|
618
|
+
// reply-reminder block so it lands once up front and then periodically, instead of on every
|
|
619
|
+
// replyable message. Defaults to 1 (always show) for call sites that don't track a count.
|
|
620
|
+
deliveryCount?: number;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function providerMessageText(messages: Message[], options: ProviderMessageTextOptions = {}): string {
|
|
520
624
|
const uniqueMessages = dedupePersistedMessages(messages);
|
|
521
625
|
const replyable = latestReplyableMessage(uniqueMessages);
|
|
522
626
|
const maxChars = providerMessageBodyMaxChars();
|
|
@@ -534,20 +638,18 @@ export function providerMessageText(messages: Message[]): string {
|
|
|
534
638
|
if (isMemoryContext) {
|
|
535
639
|
return [
|
|
536
640
|
`[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
|
|
537
|
-
"Guide: agent-relay /guide",
|
|
538
641
|
providerAttachmentText(message),
|
|
539
642
|
`${subject}${preview.body}`,
|
|
540
643
|
].join("\n");
|
|
541
644
|
}
|
|
542
645
|
return [
|
|
543
646
|
`[relay message #${message.id} from ${providerSenderLabel(message)}]`,
|
|
544
|
-
"Guide: agent-relay /guide",
|
|
545
647
|
truncationGuidance,
|
|
546
648
|
providerAttachmentText(message),
|
|
547
649
|
`${subject}${preview.body}`,
|
|
548
650
|
].join("\n");
|
|
549
651
|
});
|
|
550
|
-
if (replyable) {
|
|
652
|
+
if (replyable && shouldShowReplyReminder(options.deliveryCount ?? 1)) {
|
|
551
653
|
sections.push([
|
|
552
654
|
"[agent-relay reply reminder]",
|
|
553
655
|
relayReplyReminderText(replyable.id),
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { Message } from "agent-relay-sdk";
|
|
2
2
|
import { isRecord } from "agent-relay-sdk/types/guards";
|
|
3
3
|
import { dedupePersistedMessages, isNotificationMessage, NOTIFICATION_NUDGE, previewDeliveredBody, providerAttachmentText, providerMessageBodyMaxChars } from "../adapter";
|
|
4
|
-
import { relayReplyReminderText } from "../relay-instructions";
|
|
5
|
-
|
|
6
|
-
const REMINDER_EVERY_DELIVERIES = 5;
|
|
4
|
+
import { relayReplyReminderText, shouldShowReplyReminder } from "../relay-instructions";
|
|
7
5
|
|
|
8
6
|
interface ClaudeDeliveryTextOptions {
|
|
9
7
|
deliveryCount: number;
|
|
@@ -59,10 +57,6 @@ function providerSenderLabel(message: Message): string {
|
|
|
59
57
|
return isHumanChannel ? `human user via ${message.from}` : message.from;
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
function shouldShowReplyReminder(deliveryCount: number): boolean {
|
|
63
|
-
return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
60
|
function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
67
61
|
return messages
|
|
68
62
|
.filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// #1647 — the ONE module allowed to type Claude's exit command, or the Enter that
|
|
2
|
+
// answers its exit-confirmation dialog, into a managed pane.
|
|
3
|
+
//
|
|
4
|
+
// A 13.4h coordinator was destroyed when the runner's graceful-shutdown path typed
|
|
5
|
+
// `/exit` into a pane whose turn was LIVE (a turn had started 18s earlier and never
|
|
6
|
+
// ended), and the shutdown grace loop then pressed Enter on the resulting
|
|
7
|
+
// "Background work is running — 1. Exit anyway" modal. The shutdown request itself
|
|
8
|
+
// was legitimate and audited; what was missing was any correlation between the
|
|
9
|
+
// request and the state of the process it was about to kill.
|
|
10
|
+
//
|
|
11
|
+
// Two structural guarantees live here, and both are enforced at RUNTIME, not by
|
|
12
|
+
// convention:
|
|
13
|
+
//
|
|
14
|
+
// 1. CAPABILITY. Neither writer takes a session name. Both take a ClaudeExitGrant,
|
|
15
|
+
// and a grant can only come out of `authorizeClaudeGracefulExit`, which in turn
|
|
16
|
+
// demands a shutdown intent minted by an explicit lifecycle command. Grants are
|
|
17
|
+
// tracked in a module-private WeakSet, so a forged object literal is rejected —
|
|
18
|
+
// a detector cannot reach these functions even by fabricating the type.
|
|
19
|
+
//
|
|
20
|
+
// 2. CORRELATION. Authorization requires the observed provider epoch, process
|
|
21
|
+
// identity, tmux session, provider session id and turn state to match the
|
|
22
|
+
// intent EXACTLY, with positive evidence the turn has ended. Anything else —
|
|
23
|
+
// a live turn, an UNKNOWN turn, a stale intent, a re-used intent — is denied.
|
|
24
|
+
// Fail-closed in every direction: absent evidence is never treated as consent.
|
|
25
|
+
//
|
|
26
|
+
// `claude-exit.guard.test.ts` fails the build if any pane-detector module can reach
|
|
27
|
+
// this file through the import graph.
|
|
28
|
+
import { isClaudeExitConfirmationDialog } from "agent-relay-sdk/claude-exit-dialog";
|
|
29
|
+
import { tmuxCommand } from "agent-relay-sdk/tmux-utils";
|
|
30
|
+
import { captureTmuxPane, submitTextToTmux } from "./claude-tmux";
|
|
31
|
+
|
|
32
|
+
export const CLAUDE_EXIT_COMMAND = "/exit";
|
|
33
|
+
|
|
34
|
+
// A shutdown intent describes the process the operator asked to stop. It stops
|
|
35
|
+
// being usable once the request is older than this, so a shutdown that queued
|
|
36
|
+
// behind a slow teardown can never fire against a later turn of the same session.
|
|
37
|
+
export const CLAUDE_SHUTDOWN_INTENT_TTL_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
// What the pane/probe says about the correlated turn. "unknown" is NOT "probably
|
|
40
|
+
// idle" — it is the absence of evidence, and it denies exactly like "live".
|
|
41
|
+
export type ClaudeTurnState = "ended" | "live" | "unknown";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The audited lifecycle request, pinned to the exact process it was issued for.
|
|
45
|
+
* Minted only from an explicit shutdown/restart command — never from a detector.
|
|
46
|
+
*/
|
|
47
|
+
export interface ClaudeShutdownIntent {
|
|
48
|
+
/** Runner provider epoch: bumped on every provider spawn, so a post-restart process never matches. */
|
|
49
|
+
readonly epoch: number;
|
|
50
|
+
/** OS pid of the managed process at request time (undefined for tmux-managed panes). */
|
|
51
|
+
readonly pid?: number;
|
|
52
|
+
/** Claude's own process-start marker from the intrinsic session probe. */
|
|
53
|
+
readonly procStart: string | null;
|
|
54
|
+
readonly sessionName: string;
|
|
55
|
+
/** Claude's session id from the intrinsic probe; null when the probe is unavailable. */
|
|
56
|
+
readonly providerSessionId: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Identity of the conversation the request was accepted against. Claude's intrinsic
|
|
59
|
+
* probe exposes no per-turn id, so this is the finest granularity actually available
|
|
60
|
+
* (see `claudeTurnKey`) and it catches process/session replacement, not turn rollover.
|
|
61
|
+
* The "don't type into a live turn" guarantee therefore rests on the `turn` gate
|
|
62
|
+
* below, NOT on this field — stated plainly so nobody reads more safety into it.
|
|
63
|
+
*/
|
|
64
|
+
readonly turnKey: string;
|
|
65
|
+
readonly issuedAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Live facts about the pane, sampled at the moment we consider typing `/exit`. */
|
|
69
|
+
export interface ClaudeExitObservation {
|
|
70
|
+
readonly epoch: number;
|
|
71
|
+
readonly pid?: number;
|
|
72
|
+
readonly procStart: string | null;
|
|
73
|
+
readonly sessionName: string;
|
|
74
|
+
readonly providerSessionId: string | null;
|
|
75
|
+
readonly turn: ClaudeTurnState;
|
|
76
|
+
readonly turnKey: string;
|
|
77
|
+
readonly now: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ClaudeExitDenial =
|
|
81
|
+
| "intent-consumed"
|
|
82
|
+
| "intent-stale"
|
|
83
|
+
| "epoch-mismatch"
|
|
84
|
+
| "process-identity-mismatch"
|
|
85
|
+
| "session-mismatch"
|
|
86
|
+
| "provider-session-mismatch"
|
|
87
|
+
| "provider-session-unknown"
|
|
88
|
+
| "turn-changed"
|
|
89
|
+
| "turn-live"
|
|
90
|
+
| "turn-unknown";
|
|
91
|
+
|
|
92
|
+
// Denials that no amount of further polling can clear: the request does not describe
|
|
93
|
+
// the process in front of us, or it has already been spent. The shutdown loop stops
|
|
94
|
+
// waiting on these and goes straight to out-of-band termination. Everything else
|
|
95
|
+
// ("turn-live", "turn-unknown", "provider-session-unknown") is a not-yet, so the loop
|
|
96
|
+
// keeps sampling until the grace window closes.
|
|
97
|
+
const PERMANENT_DENIALS: ReadonlySet<ClaudeExitDenial> = new Set([
|
|
98
|
+
"intent-consumed", "intent-stale", "epoch-mismatch",
|
|
99
|
+
"process-identity-mismatch", "session-mismatch", "provider-session-mismatch", "turn-changed",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
export function claudeExitDenialIsPermanent(reason: ClaudeExitDenial): boolean {
|
|
103
|
+
return PERMANENT_DENIALS.has(reason);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ClaudeExitDecision =
|
|
107
|
+
| { readonly authorized: true; readonly grant: ClaudeExitGrant }
|
|
108
|
+
| { readonly authorized: false; readonly reason: ClaudeExitDenial };
|
|
109
|
+
|
|
110
|
+
declare const CLAUDE_EXIT_GRANT: unique symbol;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One-shot capability to type `/exit` into one specific pane, and to answer the
|
|
114
|
+
* ONE dialog that `/exit` raises. Opaque and unforgeable: the brand blocks it at
|
|
115
|
+
* compile time and the ISSUED registry blocks it at runtime.
|
|
116
|
+
*/
|
|
117
|
+
export interface ClaudeExitGrant {
|
|
118
|
+
readonly [CLAUDE_EXIT_GRANT]: true;
|
|
119
|
+
readonly sessionName: string;
|
|
120
|
+
readonly socketName?: string;
|
|
121
|
+
readonly authorizedAt: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface GrantState {
|
|
125
|
+
/** Set once `/exit` has actually been written through this grant. */
|
|
126
|
+
exitSubmittedAt?: number;
|
|
127
|
+
/** True when a dialog was already on screen BEFORE our `/exit` — it is not ours to answer. */
|
|
128
|
+
dialogPredatesExit: boolean;
|
|
129
|
+
answeredAt?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Module-private. Nothing outside this file can add to either, which is what makes
|
|
133
|
+
// a fabricated grant object inert rather than merely type-invalid.
|
|
134
|
+
const ISSUED_GRANTS = new WeakSet<ClaudeExitGrant>();
|
|
135
|
+
const GRANT_STATE = new WeakMap<ClaudeExitGrant, GrantState>();
|
|
136
|
+
const CONSUMED_INTENTS = new WeakSet<ClaudeShutdownIntent>();
|
|
137
|
+
|
|
138
|
+
/** Record the audited lifecycle request. Minting does not authorize anything by itself. */
|
|
139
|
+
export function mintClaudeShutdownIntent(facts: Omit<ClaudeShutdownIntent, "issuedAt"> & { issuedAt?: number }): ClaudeShutdownIntent {
|
|
140
|
+
return {
|
|
141
|
+
epoch: facts.epoch,
|
|
142
|
+
...(facts.pid === undefined ? {} : { pid: facts.pid }),
|
|
143
|
+
procStart: facts.procStart,
|
|
144
|
+
sessionName: facts.sessionName,
|
|
145
|
+
providerSessionId: facts.providerSessionId,
|
|
146
|
+
turnKey: facts.turnKey,
|
|
147
|
+
issuedAt: facts.issuedAt ?? Date.now(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The whole policy, as a pure decision over (intent, observation). Kept separate
|
|
153
|
+
* from grant minting so the rules are unit-testable without touching tmux.
|
|
154
|
+
*/
|
|
155
|
+
export function decideClaudeGracefulExit(
|
|
156
|
+
intent: ClaudeShutdownIntent,
|
|
157
|
+
observed: ClaudeExitObservation,
|
|
158
|
+
consumed: boolean,
|
|
159
|
+
): ClaudeExitDenial | undefined {
|
|
160
|
+
if (consumed) return "intent-consumed";
|
|
161
|
+
if (observed.now - intent.issuedAt > CLAUDE_SHUTDOWN_INTENT_TTL_MS) return "intent-stale";
|
|
162
|
+
if (observed.epoch !== intent.epoch) return "epoch-mismatch";
|
|
163
|
+
if (observed.sessionName !== intent.sessionName) return "session-mismatch";
|
|
164
|
+
// pid/procStart: only compare what BOTH sides know. A pid that changed under us is
|
|
165
|
+
// decisive; a pid nobody recorded cannot be, so it falls through to the turn gate.
|
|
166
|
+
if (intent.pid !== undefined && observed.pid !== undefined && intent.pid !== observed.pid) return "process-identity-mismatch";
|
|
167
|
+
if (intent.procStart !== null && observed.procStart !== null && intent.procStart !== observed.procStart) return "process-identity-mismatch";
|
|
168
|
+
// The provider session id is the strongest identity we have, so absence of it at
|
|
169
|
+
// exit time is disqualifying rather than ignorable: without it we cannot tell this
|
|
170
|
+
// session from its successor.
|
|
171
|
+
if (observed.providerSessionId === null) return "provider-session-unknown";
|
|
172
|
+
if (intent.providerSessionId !== null && intent.providerSessionId !== observed.providerSessionId) return "provider-session-mismatch";
|
|
173
|
+
// #1647's actual failure: the pane was mid-stream. Only POSITIVE end-of-turn
|
|
174
|
+
// evidence authorizes typing into it.
|
|
175
|
+
if (observed.turn === "live") return "turn-live";
|
|
176
|
+
if (observed.turn !== "ended") return "turn-unknown";
|
|
177
|
+
// An "ended" verdict for a DIFFERENT turn than the one we were asked about means a
|
|
178
|
+
// new turn started and finished since; the request no longer describes reality.
|
|
179
|
+
if (observed.turnKey !== intent.turnKey) return "turn-changed";
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Consume the intent and mint a grant, or explain the denial. One-shot: the intent
|
|
185
|
+
* is burned on the first authorization, so a retry loop cannot type `/exit` twice.
|
|
186
|
+
*/
|
|
187
|
+
export function authorizeClaudeGracefulExit(
|
|
188
|
+
intent: ClaudeShutdownIntent,
|
|
189
|
+
observed: ClaudeExitObservation,
|
|
190
|
+
socketName?: string,
|
|
191
|
+
): ClaudeExitDecision {
|
|
192
|
+
const denial = decideClaudeGracefulExit(intent, observed, CONSUMED_INTENTS.has(intent));
|
|
193
|
+
if (denial) return { authorized: false, reason: denial };
|
|
194
|
+
CONSUMED_INTENTS.add(intent);
|
|
195
|
+
const grant = {
|
|
196
|
+
sessionName: intent.sessionName,
|
|
197
|
+
...(socketName === undefined ? {} : { socketName }),
|
|
198
|
+
authorizedAt: observed.now,
|
|
199
|
+
} as ClaudeExitGrant;
|
|
200
|
+
ISSUED_GRANTS.add(grant);
|
|
201
|
+
GRANT_STATE.set(grant, { dialogPredatesExit: false });
|
|
202
|
+
return { authorized: true, grant };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface ClaudeExitWriterDeps {
|
|
206
|
+
capture?: (sessionName: string, socketName?: string) => string;
|
|
207
|
+
submit?: (sessionName: string, text: string, socketName?: string) => Promise<void>;
|
|
208
|
+
sendEnter?: (sessionName: string, socketName?: string) => void;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function grantStateOrThrow(grant: ClaudeExitGrant): GrantState {
|
|
212
|
+
// The runtime half of the capability check. A detector that fabricates
|
|
213
|
+
// `{} as ClaudeExitGrant` to satisfy the compiler still lands here and throws.
|
|
214
|
+
if (!ISSUED_GRANTS.has(grant)) throw new Error("claude exit: grant was not issued by authorizeClaudeGracefulExit");
|
|
215
|
+
const state = GRANT_STATE.get(grant);
|
|
216
|
+
if (!state) throw new Error("claude exit: grant has no state");
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type ClaudeExitSubmitResult = "submitted" | "already-submitted" | "dialog-already-open";
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Type `/exit` into the granted pane, once.
|
|
224
|
+
*
|
|
225
|
+
* Snapshots the pane first: if an exit dialog is ALREADY on screen we neither type
|
|
226
|
+
* nor later answer it, because a modal we did not cause is not ours to dismiss —
|
|
227
|
+
* that is the "causally bound" half of the guard.
|
|
228
|
+
*/
|
|
229
|
+
export async function submitClaudeExitCommand(grant: ClaudeExitGrant, deps: ClaudeExitWriterDeps = {}): Promise<ClaudeExitSubmitResult> {
|
|
230
|
+
const state = grantStateOrThrow(grant);
|
|
231
|
+
if (state.exitSubmittedAt !== undefined) return "already-submitted";
|
|
232
|
+
const capture = deps.capture ?? captureTmuxPane;
|
|
233
|
+
const submit = deps.submit ?? submitTextToTmux;
|
|
234
|
+
let pane: string | undefined;
|
|
235
|
+
try {
|
|
236
|
+
pane = capture(grant.sessionName, grant.socketName);
|
|
237
|
+
} catch {
|
|
238
|
+
// Capture failure is not evidence of a clean pane. Fail closed and let the
|
|
239
|
+
// caller's out-of-band kill-session backstop terminate the process instead.
|
|
240
|
+
state.dialogPredatesExit = true;
|
|
241
|
+
return "dialog-already-open";
|
|
242
|
+
}
|
|
243
|
+
if (isClaudeExitConfirmationDialog(pane)) {
|
|
244
|
+
state.dialogPredatesExit = true;
|
|
245
|
+
return "dialog-already-open";
|
|
246
|
+
}
|
|
247
|
+
await submit(grant.sessionName, CLAUDE_EXIT_COMMAND, grant.socketName);
|
|
248
|
+
state.exitSubmittedAt = Date.now();
|
|
249
|
+
return "submitted";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type ClaudeExitConfirmResult = "confirmed" | "no-dialog" | "exit-not-submitted" | "dialog-predates-exit" | "already-answered";
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Answer the exit-confirmation dialog raised by OUR `/exit`, once.
|
|
256
|
+
*
|
|
257
|
+
* Every non-"confirmed" result means no key was written. In particular the Enter is
|
|
258
|
+
* unreachable unless this same grant already submitted `/exit` and no dialog was
|
|
259
|
+
* open beforehand — so it can only ever dismiss the modal it caused.
|
|
260
|
+
*/
|
|
261
|
+
export function confirmClaudeExitDialogUnderGrant(grant: ClaudeExitGrant, deps: ClaudeExitWriterDeps = {}): ClaudeExitConfirmResult {
|
|
262
|
+
const state = grantStateOrThrow(grant);
|
|
263
|
+
if (state.dialogPredatesExit) return "dialog-predates-exit";
|
|
264
|
+
if (state.exitSubmittedAt === undefined) return "exit-not-submitted";
|
|
265
|
+
if (state.answeredAt !== undefined) return "already-answered";
|
|
266
|
+
const capture = deps.capture ?? captureTmuxPane;
|
|
267
|
+
let pane: string;
|
|
268
|
+
try {
|
|
269
|
+
pane = capture(grant.sessionName, grant.socketName);
|
|
270
|
+
} catch {
|
|
271
|
+
return "no-dialog";
|
|
272
|
+
}
|
|
273
|
+
if (!isClaudeExitConfirmationDialog(pane)) return "no-dialog";
|
|
274
|
+
const sendEnter = deps.sendEnter ?? defaultSendEnter;
|
|
275
|
+
try {
|
|
276
|
+
sendEnter(grant.sessionName, grant.socketName);
|
|
277
|
+
} catch {
|
|
278
|
+
// Best-effort: the caller's kill-session backstop still guarantees termination.
|
|
279
|
+
return "no-dialog";
|
|
280
|
+
}
|
|
281
|
+
state.answeredAt = Date.now();
|
|
282
|
+
return "confirmed";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function defaultSendEnter(sessionName: string, socketName?: string): void {
|
|
286
|
+
Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, "Enter"), {
|
|
287
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
288
|
+
});
|
|
289
|
+
}
|