agent-relay-runner 0.129.11 → 0.129.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.129.11",
3
+ "version": "0.129.13",
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.5",
26
- "agent-relay-sdk": "0.2.129",
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": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.129.11",
4
+ "version": "0.129.13",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -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. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. 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; use relay_whoami only for the full verbose identity dump. 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>. 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. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. 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; use relay_whoami only for the full verbose identity dump. 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>. For command details, run: agent-relay /guide",
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 you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. 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 you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. 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."),
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
- // ONLY as a veto at the reconciler's force-clear moment a probeActivity()=idle
270
- // must not clear a live turn while this returns true (defense-in-depth when the
271
- // primary probe can momentarily read idle mid-turn, e.g. a connection retry).
272
- probePaneBusy?(process: ManagedProcess): Promise<boolean>;
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 function providerMessageText(messages: Message[]): string {
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
+ }