agent-relay-runner 0.10.21 → 0.10.22

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.10.21",
3
+ "version": "0.10.22",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.10.21",
4
+ "version": "0.10.22",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -35,6 +35,20 @@ relay_post_timeline_status() {
35
35
  relay_post_status "$1" "${2:-provider-turn}" "" "" "" "" "${3:-}" "$4"
36
36
  }
37
37
 
38
+ relay_post_session_turn() {
39
+ # Phase 1 live-session lane: hand the transcript path to the runner so it can
40
+ # capture the assistant turn for the dashboard chat. Synchronous on purpose —
41
+ # the captured turn clears the reply obligation before the stop decision check
42
+ # below, so the agent is not nagged to /reply (and does not re-emit).
43
+ local transcript_path="${1:-}"
44
+ local port="${AGENT_RELAY_RUNNER_PORT:-}"
45
+ [ -z "$port" ] && return 0
46
+ [ -z "$transcript_path" ] && return 0
47
+ curl -fsS -X POST "http://127.0.0.1:${port}/session-turn" \
48
+ -H 'Content-Type: application/json' \
49
+ -d "{\"transcriptPath\":\"$(relay_json_escape "$transcript_path")\"}" >/dev/null 2>&1 || true
50
+ }
51
+
38
52
  relay_pending_reply_stop_decision() {
39
53
  local port="${AGENT_RELAY_RUNNER_PORT:-}"
40
54
  [ -z "$port" ] && return 0
@@ -5,6 +5,7 @@ source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/
5
5
  payload="$(cat || true)"
6
6
  stop_hook_active="$(relay_json_bool_field stop_hook_active "$payload")"
7
7
  if [ "$stop_hook_active" != "true" ]; then
8
+ relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")"
8
9
  stop_decision="$(relay_pending_reply_stop_decision)"
9
10
  if [ -n "$stop_decision" ]; then
10
11
  printf '%s\n' "$stop_decision"
@@ -20,10 +20,12 @@ Examples:
20
20
  ```bash
21
21
  agent-relay /reply 206 "Sounds good, I'll take a look"
22
22
  agent-relay /reply 42 "Done — the fix is in commit abc123"
23
- agent-relay /reply 42 --stdin < response.md
23
+ agent-relay /reply 42 --stdin < .agent-relay/sessions/$AGENT_RELAY_ID/tmp/reply.md
24
24
  agent-relay /reply 42 --body-file response.md
25
25
  ```
26
26
 
27
27
  Use `--stdin` or `--body-file` for long replies so shell quoting does not mangle the body. Oversized replies are automatically uploaded as an Agent Relay artifact and sent as an attached concise reply.
28
28
 
29
+ The relay creates this per-session scratch dir for you (it is git-ignored locally), so staging there never pollutes the working tree and never collides with other agents. No cleanup needed — the dir is reaped when your session ends.
30
+
29
31
  Do not send a separate Relay follow-up that only confirms the reply was sent. The CLI output is enough local confirmation.
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: reply
3
- description: Reply to an Agent Relay message by ID. Auto-routes to the sender and inherits channel context, so no target is needed. Use when the user invokes /reply or asks to reply to a specific relay message, especially with stdin/file for long replies.
3
+ description: Reply to an Agent Relay message by ID. Auto-routes to the sender and inherits channel context no target needed. Use when the user invokes /reply or asks to reply to a specific relay message, especially with stdin/file for long replies.
4
4
  argument-hint: "<messageId> <message|--stdin|--body-file PATH>"
5
+ allowed-tools: [Bash]
5
6
  ---
6
7
 
7
8
  # Agent Relay Reply
@@ -12,17 +13,19 @@ Run:
12
13
  agent-relay /reply $ARGUMENTS
13
14
  ```
14
15
 
15
- The server auto-routes the reply to the original sender and inherits the channel if applicable. No target or channel ID is needed.
16
+ The server auto-routes the reply to the original sender and inherits the channel (Telegram, Slack, etc.) if applicable. No target or channel ID needed.
16
17
 
17
18
  Examples:
18
19
 
19
20
  ```bash
20
21
  agent-relay /reply 206 "Sounds good, I'll take a look"
21
- agent-relay /reply 42 "Done; the fix is in commit abc123"
22
- agent-relay /reply 42 --stdin < response.md
22
+ agent-relay /reply 42 "Done the fix is in commit abc123"
23
+ agent-relay /reply 42 --stdin < .agent-relay/sessions/$AGENT_RELAY_ID/tmp/reply.md
23
24
  agent-relay /reply 42 --body-file response.md
24
25
  ```
25
26
 
26
27
  Use `--stdin` or `--body-file` for long replies so shell quoting does not mangle the body. Oversized replies are automatically uploaded as an Agent Relay artifact and sent as an attached concise reply.
27
28
 
28
- Report the sent message id and resolved target briefly.
29
+ The relay creates this per-session scratch dir for you (it is git-ignored locally), so staging there never pollutes the working tree and never collides with other agents. No cleanup needed — the dir is reaped when your session ends.
30
+
31
+ Do not send a separate Relay follow-up that only confirms the reply was sent. The CLI output is enough local confirmation.
@@ -7,6 +7,27 @@ const REMINDER_EVERY_DELIVERIES = 5;
7
7
  interface ClaudeDeliveryTextOptions {
8
8
  deliveryCount: number;
9
9
  readOnly?: boolean;
10
+ // false = isolated profile with no relay plugin/CLI surface. The agent cannot /reply or
11
+ // /claim, so strip the reply-reminder block and the server-baked claim-task instruction —
12
+ // it's just confusing noise in a clean-room run. Defaults to true (relay-aware agents).
13
+ relaySurface?: boolean;
14
+ }
15
+
16
+ // Server-baked trailing line from taskMessageBody() in src/db.ts. Meaningless to an isolated
17
+ // agent (sole claimant, no relay CLI), so drop it from delivered text when there's no surface.
18
+ const TASK_CLAIM_INSTRUCTION = "Claim this task before working it, then update task status when finished.";
19
+
20
+ function stripRelayScaffolding(body: string): string {
21
+ const lines = body.split("\n");
22
+ const popTrailingBlanks = () => {
23
+ while (lines.length && (lines.at(-1) ?? "").trim() === "") lines.pop();
24
+ };
25
+ popTrailingBlanks();
26
+ if (lines.at(-1) === TASK_CLAIM_INSTRUCTION) {
27
+ lines.pop();
28
+ popTrailingBlanks();
29
+ }
30
+ return lines.join("\n");
10
31
  }
11
32
 
12
33
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -47,23 +68,24 @@ function latestReplyableMessage(messages: Message[]): Message | undefined {
47
68
  .at(-1);
48
69
  }
49
70
 
50
- function formatMessage(message: Message): string {
71
+ function formatMessage(message: Message, relaySurface: boolean): string {
51
72
  const subject = message.subject ? `Subject: ${message.subject}\n` : "";
52
73
  const isMemoryContext = isMemoryInjection(message);
53
74
  const canFetchMessage = isPersistedRelayMessage(message);
54
- const shouldPreview = canFetchMessage && message.body.length > PROVIDER_MESSAGE_BODY_PREVIEW_CHARS;
75
+ const rawBody = relaySurface ? message.body : stripRelayScaffolding(message.body);
76
+ const shouldPreview = canFetchMessage && rawBody.length > PROVIDER_MESSAGE_BODY_PREVIEW_CHARS;
55
77
  const preview = shouldPreview
56
78
  ? {
57
- body: message.body.slice(0, PROVIDER_MESSAGE_BODY_PREVIEW_CHARS),
79
+ body: rawBody.slice(0, PROVIDER_MESSAGE_BODY_PREVIEW_CHARS),
58
80
  truncated: true,
59
81
  }
60
82
  : {
61
- body: message.body,
83
+ body: rawBody,
62
84
  truncated: false,
63
85
  };
64
86
  const truncationGuidance = preview.truncated
65
87
  ? [
66
- `[truncated: showing first ${PROVIDER_MESSAGE_BODY_PREVIEW_CHARS} of ${message.body.length} chars]`,
88
+ `[truncated: showing first ${PROVIDER_MESSAGE_BODY_PREVIEW_CHARS} of ${rawBody.length} chars]`,
67
89
  `Read full: agent-relay get-message ${message.id}`,
68
90
  `Body only: agent-relay get-message ${message.id} --body`,
69
91
  ].join("\n")
@@ -99,9 +121,11 @@ function replyReminder(message: Message, readOnly: boolean): string {
99
121
  }
100
122
 
101
123
  export function claudeProviderMessageText(messages: Message[], options: ClaudeDeliveryTextOptions): string {
102
- const sections = messages.map(formatMessage);
124
+ const relaySurface = options.relaySurface !== false;
125
+ const sections = messages.map((message) => formatMessage(message, relaySurface));
103
126
  const replyable = latestReplyableMessage(messages);
104
- if (replyable && shouldShowReplyReminder(options.deliveryCount)) {
127
+ // Isolated agents have no way to reply through Relay — never append the reminder.
128
+ if (relaySurface && replyable && shouldShowReplyReminder(options.deliveryCount)) {
105
129
  sections.push(replyReminder(replyable, options.readOnly === true));
106
130
  }
107
131
  return sections.join("\n\n");
@@ -0,0 +1,76 @@
1
+ // Phase 1 live-session lane: extract the user-visible assistant turn from a
2
+ // Claude Code transcript JSONL so it can be surfaced in the dashboard chat
3
+ // without the agent re-emitting it via /reply (zero agent tokens).
4
+ //
5
+ // Transcript shape (one JSON object per line):
6
+ // { type: "user", message: { content: "..." | [{type:"text"|"tool_result", ...}] } }
7
+ // { type: "assistant", message: { content: [{type:"thinking"|"text"|"tool_use", ...}], stop_reason } }
8
+ //
9
+ // The "current turn" is everything after the last *real* user prompt (a user
10
+ // entry carrying text, not just tool_result blocks). We collect the assistant
11
+ // `text` blocks from that turn — thinking and tool_use are dropped.
12
+
13
+ interface TranscriptBlock {
14
+ type?: string;
15
+ text?: string;
16
+ }
17
+
18
+ interface TranscriptMessage {
19
+ role?: string;
20
+ content?: string | TranscriptBlock[];
21
+ stop_reason?: string;
22
+ }
23
+
24
+ interface TranscriptEntry {
25
+ type?: string;
26
+ message?: TranscriptMessage;
27
+ }
28
+
29
+ function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
30
+ if (!message || !Array.isArray(message.content)) return [];
31
+ return message.content.filter((b): b is TranscriptBlock => Boolean(b) && typeof b === "object");
32
+ }
33
+
34
+ function isRealUserPrompt(entry: TranscriptEntry): boolean {
35
+ if (entry.type !== "user") return false;
36
+ const content = entry.message?.content;
37
+ if (typeof content === "string") return content.trim().length > 0;
38
+ // An array user entry is a real prompt only if it carries a text block;
39
+ // pure tool_result entries are mid-turn plumbing, not a new prompt.
40
+ return blocks(entry.message).some((b) => b.type === "text" && Boolean(b.text?.trim()));
41
+ }
42
+
43
+ function assistantText(entry: TranscriptEntry): string {
44
+ if (entry.type !== "assistant") return "";
45
+ return blocks(entry.message)
46
+ .filter((b) => b.type === "text" && typeof b.text === "string")
47
+ .map((b) => b.text!.trim())
48
+ .filter(Boolean)
49
+ .join("\n\n");
50
+ }
51
+
52
+ /**
53
+ * Returns the concatenated assistant `text` for the most recent turn (since the
54
+ * last real user prompt), or "" if there is nothing user-visible to surface.
55
+ */
56
+ export function extractLastAssistantTurn(jsonl: string): string {
57
+ const lines = jsonl.split("\n");
58
+ let collected: string[] = [];
59
+ for (const line of lines) {
60
+ const trimmed = line.trim();
61
+ if (!trimmed) continue;
62
+ let entry: TranscriptEntry;
63
+ try {
64
+ entry = JSON.parse(trimmed) as TranscriptEntry;
65
+ } catch {
66
+ continue;
67
+ }
68
+ if (isRealUserPrompt(entry)) {
69
+ collected = [];
70
+ continue;
71
+ }
72
+ const text = assistantText(entry);
73
+ if (text) collected.push(text);
74
+ }
75
+ return collected.join("\n\n").trim();
76
+ }
@@ -1,9 +1,10 @@
1
- import { existsSync } from "node:fs";
2
- import { homedir } from "node:os";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import type { Message } from "agent-relay-sdk";
5
5
  import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
6
6
  import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
7
+ import { claudeProviderMessageText } from "./claude-delivery";
7
8
 
8
9
  export class ClaudeAdapter implements ProviderAdapter {
9
10
  readonly provider = "claude";
@@ -58,10 +59,35 @@ export class ClaudeAdapter implements ProviderAdapter {
58
59
  return { method: "tmux-inject", command: "/clear" };
59
60
  }
60
61
 
61
- async deliver(_process: ManagedProcess, messages: Message[]): Promise<void> {
62
- const monitor = _process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
63
- if (!monitor?.deliver) throw new Error("Claude monitor delivery is unavailable");
64
- await monitor.deliver(messages);
62
+ async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
63
+ const monitor = process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
64
+ // A monitor object always exists for headless claude (it proxies to the runner
65
+ // control server), so its presence says nothing about whether a monitor process
66
+ // is actually connected. `monitorless` is set at spawn when the profile disables
67
+ // the relay plugin — then no monitor will ever connect, so we must not route
68
+ // through it (it would throw "no Claude monitor connected" forever); we inject via
69
+ // tmux instead. For monitored profiles we keep the monitor path (and its retry on
70
+ // transient startup races) to avoid double-delivering against a late monitor.
71
+ const monitorless = process.meta?.monitorless === true;
72
+ if (!monitorless && monitor?.deliver) {
73
+ await monitor.deliver(messages);
74
+ return;
75
+ }
76
+ // Inject straight into the interactive tmux session via send-keys — the same
77
+ // transport deliverInitialPrompt uses. Keeps the agent on interactive Claude (the
78
+ // user's Max quota), never `claude -p`/API, and lets isolated automation agents
79
+ // receive their task (a claimable message) and any mid-session messages (e.g. the
80
+ // budget "wrap up" warning) that a launch --prompt can't carry.
81
+ const session = process.meta?.tmuxSession as string | undefined;
82
+ const socket = process.meta?.tmuxSocket as string | undefined;
83
+ if (!session || !tmuxHasSession(session, socket)) {
84
+ throw new Error("Claude monitor delivery is unavailable and no tmux session for fallback");
85
+ }
86
+ await waitForClaudeInputReady(session, CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
87
+ // Monitorless = isolated profile with no relay plugin/CLI surface. Strip relay
88
+ // interaction scaffolding (reply reminder, claim-task instruction) — the agent has
89
+ // no way to /reply or /claim, so it's just confusing noise in a clean-room run.
90
+ await submitTextToTmux(session, claudeProviderMessageText(messages, { deliveryCount: 1, relaySurface: !monitorless }), socket);
65
91
  }
66
92
 
67
93
  async deliverInitialPrompt(process: ManagedProcess, prompt: string): Promise<void> {
@@ -78,9 +104,13 @@ export class ClaudeAdapter implements ProviderAdapter {
78
104
  const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
79
105
  const pluginDirs = profileAllowsRelayFeature(config, "plugins") ? [...new Set([pluginRoot, ...providerConfig.pluginDirs])] : [];
80
106
  const isClaudeRig = /claude-rig/.test(providerConfig.command);
81
- const bypassRigDefaults = config.headless && isClaudeRig && config.approvalMode !== "open";
82
- const rigPrefix = isClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
83
- const command = bypassRigDefaults || (isClaudeRig && !config.rig && !findClaudeRigRC(config.cwd))
107
+ // Isolated profiles run their own CLAUDE_CONFIG_DIR and must never route through
108
+ // claude-rig claude-rig resolves host rig config (and a bare `claude-rig` with no
109
+ // rig key just errors out). Only host-base profiles may use claude-rig.
110
+ const usesClaudeRig = isClaudeRig && profileUsesHostProviderGlobals(config);
111
+ const bypassRigDefaults = config.headless && usesClaudeRig && config.approvalMode !== "open";
112
+ const rigPrefix = usesClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
113
+ const command = !usesClaudeRig || bypassRigDefaults || (!config.rig && !findClaudeRigRC(config.cwd))
84
114
  ? "claude"
85
115
  : providerConfig.command;
86
116
  const providerArgs = config.headless
@@ -102,13 +132,22 @@ export class ClaudeAdapter implements ProviderAdapter {
102
132
  env: {
103
133
  ...config.env,
104
134
  ...(profileHome ? { CLAUDE_CONFIG_DIR: profileHome.path } : {}),
135
+ // Plain `claude` (isolated profiles) runs its own CLAUDE_CONFIG_DIR and never
136
+ // routes through claude-rig, so it never inherits the host's long-lived
137
+ // CLAUDE_CODE_OAUTH_TOKEN. Without it, claude falls back to the (often stale)
138
+ // file-based .credentials.json and bails to /login (401). claude-rig launches
139
+ // inject their own token, so only thread it for the plain-claude path, and
140
+ // never override an explicit per-launch value.
141
+ ...(command === "claude" && process.env.CLAUDE_CODE_OAUTH_TOKEN && !config.env?.CLAUDE_CODE_OAUTH_TOKEN
142
+ ? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN }
143
+ : {}),
105
144
  AGENT_RELAY_PROVIDER: "claude",
106
145
  ...(config.effort ? { CLAUDE_CODE_EFFORT_LEVEL: config.effort } : {}),
107
146
  },
108
147
  };
109
148
  }
110
149
 
111
- buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[] } {
150
+ buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript?: string } {
112
151
  const sessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
113
152
  const socketName = tmuxSocketName(sessionName);
114
153
  const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
@@ -121,8 +160,18 @@ export class ClaudeAdapter implements ProviderAdapter {
121
160
  }
122
161
  }
123
162
 
124
- tmuxArgs.push("-c", spawnArgs.cwd, shellCmd);
125
- return { sessionName, socketName, args: tmuxArgs };
163
+ // tmux new-session has a hard limit on command length. When the inline shell
164
+ // command is too long (e.g. fork with conversation history in --append-system-prompt),
165
+ // externalize it to a launcher script that tmux executes instead.
166
+ const MAX_TMUX_CMD_LEN = 2000;
167
+ let launcherScript: string | undefined;
168
+ if (shellCmd.length > MAX_TMUX_CMD_LEN) {
169
+ launcherScript = writeLauncherScript(sessionName, shellCmd);
170
+ tmuxArgs.push("-c", spawnArgs.cwd, `bash ${shellQuote(launcherScript)}`);
171
+ } else {
172
+ tmuxArgs.push("-c", spawnArgs.cwd, shellCmd);
173
+ }
174
+ return { sessionName, socketName, args: tmuxArgs, launcherScript };
126
175
  }
127
176
 
128
177
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
@@ -158,6 +207,9 @@ export class ClaudeAdapter implements ProviderAdapter {
158
207
  process: undefined,
159
208
  meta: {
160
209
  monitor: config.monitor,
210
+ // When the profile disables the relay plugin, the bundled monitor never
211
+ // loads, so no monitor will ever connect — deliver() must use tmux injection.
212
+ monitorless: !profileAllowsRelayFeature(config, "plugins"),
161
213
  tmuxSession: sessionName,
162
214
  tmuxSocket: socketName,
163
215
  },
@@ -294,13 +346,18 @@ function captureTmuxPane(sessionName: string, socketName?: string): string {
294
346
  }
295
347
 
296
348
  export function claudePaneLooksReady(text: string): boolean {
297
- return text.includes("Claude Code")
298
- && (
299
- text.includes("bypass permissions")
300
- || text.includes("/effort")
301
- || text.includes("What's new")
302
- || text.includes("Welcome back")
303
- );
349
+ // Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
350
+ // conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
351
+ // a run) must detect readiness from the PERSISTENT input-box footer that Claude always
352
+ // renders at the prompt. Requiring the banner made every delivery after the first screen
353
+ // of output time out as "input not ready". Any one of these strong, Claude-specific
354
+ // markers means the TUI is up and the input box exists.
355
+ return text.includes("bypass permissions") // footer in --dangerously-skip-permissions mode
356
+ || text.includes("shift+tab to cycle") // mode-cycle footer hint (persistent)
357
+ || text.includes("? for shortcuts") // default footer hint (persistent)
358
+ || text.includes("/effort")
359
+ || text.includes("Welcome back")
360
+ || text.includes("Claude Code");
304
361
  }
305
362
 
306
363
  async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
@@ -349,6 +406,16 @@ async function submitTextToTmux(sessionName: string, text: string, socketName?:
349
406
  }
350
407
  }
351
408
 
409
+ const LAUNCHER_DIR = join(tmpdir(), "agent-relay-launchers");
410
+
411
+ function writeLauncherScript(sessionName: string, shellCmd: string): string {
412
+ mkdirSync(LAUNCHER_DIR, { recursive: true });
413
+ const sanitized = sessionName.replace(/[^a-zA-Z0-9._-]/g, "-");
414
+ const scriptPath = join(LAUNCHER_DIR, `${sanitized}.sh`);
415
+ writeFileSync(scriptPath, `#!/usr/bin/env bash\nexec ${shellCmd}\n`, { mode: 0o755 });
416
+ return scriptPath;
417
+ }
418
+
352
419
  export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
353
420
  const keys = new Set<string>();
354
421
  for (const key of Object.keys(env)) {
@@ -372,10 +439,14 @@ export function findClaudeRigRC(cwd: string): string | null {
372
439
  const home = homedir();
373
440
  let dir = resolve(cwd);
374
441
  while (true) {
442
+ // Stop at $HOME without inspecting it: ~/.claude-rig is the claude-rig tool's
443
+ // own config home, not a per-project rc marker. Matching it would make every
444
+ // project under $HOME look like a claude-rig project.
445
+ if (dir === home) return null;
375
446
  const rc = join(dir, ".claude-rig");
376
447
  if (existsSync(rc)) return rc;
377
448
  const parent = resolve(dir, "..");
378
- if (parent === dir || dir === home) return null;
449
+ if (parent === dir) return null;
379
450
  dir = parent;
380
451
  }
381
452
  }
@@ -20,6 +20,10 @@ interface ControlServerOptions {
20
20
  onStatus(status: ProviderStatusEvent): void;
21
21
  onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
22
22
  onReplyObligations?(): Promise<ReplyObligation[]>;
23
+ // Phase 1 live-session lane: a provider Stop hook hands over its transcript
24
+ // path so the runner can capture the assistant turn and surface it in the
25
+ // dashboard chat without the agent re-emitting it via /reply.
26
+ onSessionTurn?(input: { transcriptPath: string }): Promise<void>;
23
27
  }
24
28
 
25
29
  export function startControlServer(options: ControlServerOptions): ControlServer {
@@ -59,6 +63,9 @@ export function startControlServer(options: ControlServerOptions): ControlServer
59
63
  if (url.pathname === "/permissions/request" && req.method === "POST") {
60
64
  return handlePermissionRequest(req, options, pendingPermissionRequests);
61
65
  }
66
+ if (url.pathname === "/session-turn" && req.method === "POST") {
67
+ return handleSessionTurn(req, options);
68
+ }
62
69
  if (url.pathname === "/monitor") {
63
70
  const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
64
71
  return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
@@ -241,6 +248,22 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
241
248
  };
242
249
  }
243
250
 
251
+ async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
252
+ if (!options.onSessionTurn) return Response.json({ ok: false, reason: "session capture unavailable" });
253
+ const body = await req.json().catch(() => null);
254
+ const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
255
+ if (!transcriptPath) return Response.json({ ok: false, reason: "transcriptPath required" }, { status: 400 });
256
+ // Awaited on purpose: the Stop hook posts this synchronously before its
257
+ // reply-obligation check, so the captured turn must be persisted (and the
258
+ // obligation cleared) before this response returns.
259
+ try {
260
+ await options.onSessionTurn({ transcriptPath });
261
+ return Response.json({ ok: true });
262
+ } catch (error) {
263
+ return Response.json({ ok: false, reason: error instanceof Error ? error.message : String(error) }, { status: 500 });
264
+ }
265
+ }
266
+
244
267
  async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
245
268
  const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
246
269
  const status = body?.status;
@@ -17,14 +17,35 @@ function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
17
17
  return Boolean(config.agentProfile && config.agentProfile.base !== "host");
18
18
  }
19
19
 
20
+ // First-run bootstrap (provider layer)
21
+ // -------------------------------------
22
+ // Isolated profile homes are keyed by instanceId, so every spawn gets a brand
23
+ // new CLAUDE_CONFIG_DIR / CODEX_HOME — to the provider it always looks like the
24
+ // first time it has ever run, even in a repo we've used before. Without
25
+ // bootstrapping, the CLI stalls on first-run gates (theme/onboarding picker,
26
+ // "do you trust this folder?") and the agent never starts; the runner then sees
27
+ // repeated ~2s exits and gives up. bootstrap<Provider>FirstRun makes a fresh
28
+ // home launch-ready non-interactively, guaranteeing three things:
29
+ // 1. host auth is linked in,
30
+ // 2. first-run / onboarding flags are pre-accepted (where the provider has them),
31
+ // 3. the cwd workspace is trusted.
32
+ // Workspace trust is independent of repoInstructions: "ignore" means don't load
33
+ // the repo's CLAUDE.md / AGENTS.md as behavioral instructions, NOT "don't
34
+ // operate here" — an isolated agent must still read/edit/execute in its own cwd.
35
+ // (claude-rig / host-base launches dodge all of this via onboarded host config +
36
+ // --dangerously-skip-permissions; only isolated homes need the bootstrap.)
37
+
20
38
  export function prepareCodexProfileHome(config: RunnerSpawnConfig): ProviderHome | undefined {
21
39
  if (!profileRequiresIsolatedHome(config)) return undefined;
22
40
  const target = providerHomePath("codex", config);
23
41
  mkdirSync(target, { recursive: true });
24
- trustCodexProjectIfAllowed(target, config);
42
+ return { path: target, authLinked: bootstrapCodexFirstRun(target, config) };
43
+ }
44
+
45
+ function bootstrapCodexFirstRun(codexHome: string, config: RunnerSpawnConfig): string[] {
46
+ trustWorkspaceForCodex(codexHome, config);
25
47
  const sourceHome = process.env.CODEX_HOME || join(homedir(), ".codex");
26
- const authLinked = linkExistingAuthItems(sourceHome, target, ["auth.json", "installation_id"]);
27
- return { path: target, authLinked };
48
+ return linkExistingAuthItems(sourceHome, codexHome, ["auth.json", "installation_id"]);
28
49
  }
29
50
 
30
51
  export function prepareClaudeProfileHome(config: RunnerSpawnConfig): ProviderHome | undefined {
@@ -35,9 +56,44 @@ export function prepareClaudeProfileHome(config: RunnerSpawnConfig): ProviderHom
35
56
  // surface. An isolated-research profile (relay.context disabled) must not get
36
57
  // agent-relay communication instructions written into its config home.
37
58
  if (profileAllowsRelayFeature(config, "context")) writeClaudeRelayManual(target);
59
+ return { path: target, authLinked: bootstrapClaudeFirstRun(target, config) };
60
+ }
61
+
62
+ function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig): string[] {
63
+ seedClaudeConfigIfMissing(claudeHome, config);
38
64
  const sourceHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
39
- const authLinked = linkExistingAuthItems(sourceHome, target, [".credentials.json", "statsig"]);
40
- return { path: target, authLinked };
65
+ return linkExistingAuthItems(sourceHome, claudeHome, [".credentials.json", "statsig"]);
66
+ }
67
+
68
+ function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig): void {
69
+ const path = join(claudeHome, ".claude.json");
70
+ if (existsSync(path)) return;
71
+ const host = readHostClaudeConfig();
72
+ const seed: Record<string, unknown> = {
73
+ hasCompletedOnboarding: true,
74
+ theme: typeof host?.theme === "string" ? host.theme : "dark",
75
+ };
76
+ if (typeof host?.lastOnboardingVersion === "string") seed.lastOnboardingVersion = host.lastOnboardingVersion;
77
+ // Open approval = headless launch adds --dangerously-skip-permissions, which has
78
+ // its OWN first-run gate ("Bypass Permissions mode" accept/exit). Pre-accept it,
79
+ // or the agent stalls there exactly like the onboarding/trust gates.
80
+ if (config.approvalMode === "open") seed.bypassPermissionsModeAccepted = true;
81
+ if (config.cwd) {
82
+ seed.projects = {
83
+ [resolve(config.cwd)]: { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
84
+ };
85
+ }
86
+ writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
87
+ }
88
+
89
+ function readHostClaudeConfig(): Record<string, unknown> | undefined {
90
+ try {
91
+ const hostPath = join(homedir(), ".claude.json");
92
+ if (!existsSync(hostPath)) return undefined;
93
+ return JSON.parse(readFileSync(hostPath, "utf8")) as Record<string, unknown>;
94
+ } catch {
95
+ return undefined;
96
+ }
41
97
  }
42
98
 
43
99
  function providerHomePath(provider: "claude" | "codex", config: RunnerSpawnConfig): string {
@@ -63,8 +119,8 @@ function linkExistingAuthItems(sourceHome: string, targetHome: string, items: st
63
119
  return linked;
64
120
  }
65
121
 
66
- function trustCodexProjectIfAllowed(codexHome: string, config: RunnerSpawnConfig): void {
67
- if (config.agentProfile?.instructions.repoInstructions === "ignore") return;
122
+ function trustWorkspaceForCodex(codexHome: string, config: RunnerSpawnConfig): void {
123
+ if (!config.cwd) return;
68
124
  const path = join(codexHome, "config.toml");
69
125
  const project = tomlBasicString(resolve(config.cwd));
70
126
  const section = `[projects.${project}]`;
package/src/runner.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { hostname } from "node:os";
2
2
  import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
3
4
  import { dirname, join } from "node:path";
4
5
  import type { AgentProfile, ContextState, Message, ProviderCapabilities, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
5
6
  import { RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
@@ -8,9 +9,11 @@ import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissio
8
9
  import { messagesWithCachedAttachments } from "./attachment-cache";
9
10
  import { ClaimTracker } from "./claim-tracker";
10
11
  import { startControlServer, type ControlServer } from "./control-server";
12
+ import { extractLastAssistantTurn } from "./adapters/claude-transcript";
11
13
  import { agentProfileProjectionReport } from "./profile-projection";
12
14
  import { profileUsesHostProviderGlobals } from "./profile-home";
13
15
  import { runtimeMetadata } from "./version";
16
+ import { ensureSessionScratch, reapSessionScratch, sweepStaleSessions, type SessionScratchLayout } from "./session-scratch";
14
17
 
15
18
  interface RunnerOptions {
16
19
  provider: string;
@@ -99,6 +102,8 @@ export class AgentRunner {
99
102
  private readonly pendingMessages = new Map<number, Message>();
100
103
  private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
101
104
  private pendingTimelineEvent?: { status: string; id?: string; timestamp: number };
105
+ private pendingPromptMessageId?: number;
106
+ private scratch?: SessionScratchLayout;
102
107
 
103
108
  constructor(private readonly options: RunnerOptions) {
104
109
  this.agentId = options.agentId ?? options.runnerId;
@@ -177,6 +182,7 @@ export class AgentRunner {
177
182
  onStatus: (status) => this.setProviderStatus(status),
178
183
  onTerminalAttachSpec: () => this.terminalAttachSpec(),
179
184
  onReplyObligations: () => this.http.listReplyObligations(this.agentId),
185
+ onSessionTurn: (input) => this.publishSessionTurn(input),
180
186
  });
181
187
  this.writeRunnerInfoFile();
182
188
  this.options.adapter.onStatusChange((status) => {
@@ -195,6 +201,8 @@ export class AgentRunner {
195
201
  });
196
202
  this.bus.on("error", (code, message) => this.handleBusError(String(code), String(message)));
197
203
  await this.bus.connect();
204
+ this.ensureScratch();
205
+ void this.sweepStaleScratch();
198
206
  this.process = await this.spawnProvider();
199
207
  this.writeRunnerInfoFile();
200
208
  this.processStartedAt = Date.now();
@@ -210,6 +218,11 @@ export class AgentRunner {
210
218
  async stop(): Promise<void> {
211
219
  const alreadyStopped = this.stopped;
212
220
  this.stopped = true;
221
+ reapSessionScratch({
222
+ agentId: this.agentId,
223
+ cwd: this.options.cwd,
224
+ fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
225
+ });
213
226
  if (!alreadyStopped) await this.bus.statusAsync({ agentStatus: "offline", ready: false });
214
227
  if (this.process && !alreadyStopped) {
215
228
  await this.options.adapter.shutdown(this.process, {
@@ -239,6 +252,7 @@ export class AgentRunner {
239
252
  AGENT_RELAY_RUNNER_ID: this.options.runnerId,
240
253
  AGENT_RELAY_PROVIDER_SESSION_ID: this.providerSessionId,
241
254
  AGENT_RELAY_ID: this.agentId,
255
+ ...(this.scratch ? { AGENT_RELAY_SCRATCH_DIR: this.scratch.tmpDir } : {}),
242
256
  AGENT_RELAY_URL: this.options.relayUrl,
243
257
  AGENT_RELAY_APPROVAL: this.options.approvalMode,
244
258
  ...(this.currentToken ? { AGENT_RELAY_TOKEN: this.currentToken } : {}),
@@ -397,7 +411,7 @@ export class AgentRunner {
397
411
  private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
398
412
  const target = typeof command?.target === "string" ? command.target : this.agentId;
399
413
  if (target !== this.agentId && target !== this.options.runnerId) return;
400
- if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision") return;
414
+ if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "prompt.inject") return;
401
415
 
402
416
  const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
403
417
  if (exitAfterCommand) {
@@ -425,6 +439,8 @@ export class AgentRunner {
425
439
  providerResult = await this.injectContext(params);
426
440
  } else if (type === "agent.permissionDecision") {
427
441
  providerResult = await this.respondToPermissionDecision(params);
442
+ } else if (type === "prompt.inject") {
443
+ providerResult = await this.injectPrompt(params);
428
444
  } else await this.shutdownProvider(type === "agent.kill", commandTimeoutMs(params));
429
445
  await this.updateCommand(commandId, "succeeded", {
430
446
  action: type,
@@ -496,6 +512,17 @@ export class AgentRunner {
496
512
  return { approvalId, decision, ...(result ? { providerResult: result } : {}) };
497
513
  }
498
514
 
515
+ private async injectPrompt(params: Record<string, unknown>): Promise<Record<string, unknown>> {
516
+ const body = typeof params.body === "string" ? params.body : "";
517
+ if (!body) throw new Error("body required");
518
+ if (!this.process) throw new Error("provider process is unavailable");
519
+ if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
520
+ const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
521
+ if (messageId) this.pendingPromptMessageId = messageId;
522
+ await this.options.adapter.deliverInitialPrompt(this.process, body);
523
+ return { injected: true, messageId };
524
+ }
525
+
499
526
  private async restartProvider(): Promise<void> {
500
527
  this.restartInProgress = true;
501
528
  try {
@@ -632,6 +659,52 @@ export class AgentRunner {
632
659
  this.publishStatus();
633
660
  }
634
661
 
662
+ // Phase 1 live-session lane: capture the assistant turn from the Claude
663
+ // transcript and post it as an observed "session" message so it shows in the
664
+ // dashboard chat with zero agent tokens. Posting it as a reply to the
665
+ // triggering message also clears the reply obligation, so the Stop hook no
666
+ // longer nags the agent to /reply — which is what made it re-emit before.
667
+ private async publishSessionTurn(input: { transcriptPath: string }): Promise<void> {
668
+ // Find the triggering message to reply to: either a pending prompt injection
669
+ // (Phase 2 direct lane) or a reply obligation from the dashboard human.
670
+ let replyToMessageId: number | undefined;
671
+ const pendingPrompt = this.pendingPromptMessageId;
672
+ if (pendingPrompt) {
673
+ replyToMessageId = pendingPrompt;
674
+ this.pendingPromptMessageId = undefined;
675
+ } else {
676
+ try {
677
+ const obligations = await this.http.listReplyObligations(this.agentId);
678
+ const obligation = [...obligations].reverse().find((o) => o.from === "user");
679
+ replyToMessageId = obligation?.messageId;
680
+ } catch {
681
+ return;
682
+ }
683
+ }
684
+ if (!replyToMessageId) return;
685
+
686
+ let jsonl: string;
687
+ try {
688
+ jsonl = await readFile(input.transcriptPath, "utf8");
689
+ } catch {
690
+ return;
691
+ }
692
+ const body = extractLastAssistantTurn(jsonl);
693
+ if (!body) return;
694
+
695
+ try {
696
+ await this.http.sendMessage({
697
+ from: this.agentId,
698
+ to: "user",
699
+ replyTo: replyToMessageId,
700
+ kind: "session",
701
+ body,
702
+ });
703
+ } catch (error) {
704
+ this.logRunnerDiagnostic(`session turn capture failed: ${error instanceof Error ? error.message : String(error)}`);
705
+ }
706
+ }
707
+
635
708
  private publishStatus(): void {
636
709
  const status = this.claims.currentStatus();
637
710
  const agentStatus = runnerAgentStatus(status);
@@ -735,6 +808,33 @@ export class AgentRunner {
735
808
  }
736
809
  }
737
810
 
811
+ private ensureScratch(): void {
812
+ try {
813
+ this.scratch = ensureSessionScratch({
814
+ agentId: this.agentId,
815
+ cwd: this.options.cwd,
816
+ fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
817
+ });
818
+ } catch (error) {
819
+ this.logRunnerDiagnostic(`session scratch setup failed: ${error instanceof Error ? error.message : String(error)}`);
820
+ }
821
+ }
822
+
823
+ private async sweepStaleScratch(): Promise<void> {
824
+ try {
825
+ const agents = await this.http.listAgents().catch(() => [] as Awaited<ReturnType<RelayHttpClient["listAgents"]>>);
826
+ const keep = new Set(agents.map((a) => a.id));
827
+ keep.add(this.agentId);
828
+ const removed = sweepStaleSessions({
829
+ cwd: this.options.cwd,
830
+ fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
831
+ keepAgentIds: keep,
832
+ now: Date.now(),
833
+ });
834
+ if (removed.length) this.logRunnerDiagnostic(`swept ${removed.length} stale session scratch dir(s)`);
835
+ } catch {}
836
+ }
837
+
738
838
  private scheduleRuntimeTokenRenewal(delayMs?: number): void {
739
839
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
740
840
  this.tokenRenewTimer = undefined;
@@ -1069,6 +1169,10 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
1069
1169
  },
1070
1170
  ...runtimeProviderContextCapabilities(options, contextStats),
1071
1171
  ...runtimeProviderTerminalCapabilities(options),
1172
+ liveSession: {
1173
+ capture: true,
1174
+ inject: Boolean(options.adapter.deliverInitialPrompt),
1175
+ },
1072
1176
  source: "runtime",
1073
1177
  confidence: "reported",
1074
1178
  lastUpdatedAt: options.startedAt,
@@ -0,0 +1,169 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+
5
+ export const SCRATCH_DIR_NAME = ".agent-relay";
6
+ // The local-ignore entry. Leading + trailing slash scopes it to the dir at the
7
+ // base, matching git's gitignore semantics.
8
+ const EXCLUDE_ENTRY = "/.agent-relay/";
9
+
10
+ export interface SessionScratchLayout {
11
+ baseDir: string; // dir that contains .agent-relay (cwd, or fallback base dir)
12
+ rootDir: string; // <base>/.agent-relay
13
+ sessionsDir: string; // <base>/.agent-relay/sessions
14
+ sessionDir: string; // <base>/.agent-relay/sessions/<id>
15
+ tmpDir: string; // <base>/.agent-relay/sessions/<id>/tmp
16
+ replyFile: string; // <tmp>/reply.md
17
+ }
18
+
19
+ export interface SessionScratchTarget {
20
+ agentId: string;
21
+ cwd: string;
22
+ // Orchestrator base dir, used only when cwd is not writable. NEVER home — a
23
+ // home fallback would place scratch above the orchestrator base dir and break
24
+ // cwd containment.
25
+ fallbackBaseDir?: string;
26
+ }
27
+
28
+ function isWritableDir(dir: string): boolean {
29
+ try {
30
+ if (!statSync(dir).isDirectory()) return false;
31
+ accessSync(dir, constants.W_OK);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ // Prefer cwd; fall back to the orchestrator base dir only when cwd is not a
39
+ // writable dir. Returns cwd as a last resort so callers can still attempt mkdir
40
+ // (and surface the real error) rather than silently picking an unrelated path.
41
+ export function resolveScratchBase(cwd: string, fallbackBaseDir?: string): string {
42
+ if (isWritableDir(cwd)) return cwd;
43
+ if (fallbackBaseDir && isWritableDir(fallbackBaseDir)) return fallbackBaseDir;
44
+ return cwd;
45
+ }
46
+
47
+ export function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
48
+ const rootDir = join(baseDir, SCRATCH_DIR_NAME);
49
+ const sessionsDir = join(rootDir, "sessions");
50
+ const sessionDir = join(sessionsDir, agentId);
51
+ const tmpDir = join(sessionDir, "tmp");
52
+ return { baseDir, rootDir, sessionsDir, sessionDir, tmpDir, replyFile: join(tmpDir, "reply.md") };
53
+ }
54
+
55
+ // Resolve git's exclude file for baseDir, correctly handling plain repos,
56
+ // worktrees, and submodules (git rev-parse returns the shared commondir's
57
+ // info/exclude). Returns null when baseDir is not inside a git work tree.
58
+ function gitExcludePath(baseDir: string): string | null {
59
+ try {
60
+ const out = execFileSync("git", ["-C", baseDir, "rev-parse", "--git-path", "info/exclude"], {
61
+ encoding: "utf8",
62
+ stdio: ["ignore", "pipe", "ignore"],
63
+ }).trim();
64
+ if (!out) return null;
65
+ return isAbsolute(out) ? out : resolve(baseDir, out);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // Idempotently append EXCLUDE_ENTRY to an ignore file (grep-before-write).
72
+ function appendIgnoreEntry(file: string): void {
73
+ let existing = "";
74
+ try {
75
+ existing = readFileSync(file, "utf8");
76
+ } catch {}
77
+ const wanted = EXCLUDE_ENTRY.trim();
78
+ if (existing.split("\n").some((line) => line.trim() === wanted)) return;
79
+ mkdirSync(dirname(file), { recursive: true });
80
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
81
+ appendFileSync(file, `${prefix}${EXCLUDE_ENTRY}\n`);
82
+ }
83
+
84
+ // Ensure `.agent-relay/` is locally ignored. Prefers .git/info/exclude (git's
85
+ // local, uncommitted ignore list) so no tracked file is ever modified; falls
86
+ // back to .gitignore only when baseDir is not a git repo. Returns which file was
87
+ // used (for logging/tests).
88
+ export function ensureScratchIgnored(baseDir: string): "exclude" | "gitignore" {
89
+ const excludePath = gitExcludePath(baseDir);
90
+ if (excludePath) {
91
+ appendIgnoreEntry(excludePath);
92
+ return "exclude";
93
+ }
94
+ appendIgnoreEntry(join(baseDir, ".gitignore"));
95
+ return "gitignore";
96
+ }
97
+
98
+ // SessionStart: create the session tmp dir and ensure local ignore. Dir
99
+ // creation is the load-bearing part; ignore setup is best-effort.
100
+ export function ensureSessionScratch(target: SessionScratchTarget): SessionScratchLayout {
101
+ const base = resolveScratchBase(target.cwd, target.fallbackBaseDir);
102
+ const layout = sessionScratchLayout(base, target.agentId);
103
+ mkdirSync(layout.tmpDir, { recursive: true });
104
+ try {
105
+ ensureScratchIgnored(base);
106
+ } catch {
107
+ // best-effort: a missing ignore entry never pollutes git because the dir is
108
+ // already created and the entry is the only thing at risk.
109
+ }
110
+ return layout;
111
+ }
112
+
113
+ function dedupeBases(bases: Array<string | undefined>): string[] {
114
+ const seen = new Set<string>();
115
+ const out: string[] = [];
116
+ for (const b of bases) {
117
+ if (!b || seen.has(b)) continue;
118
+ seen.add(b);
119
+ out.push(b);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // SessionEnd: remove this session's dir from every candidate base.
125
+ export function reapSessionScratch(target: SessionScratchTarget): void {
126
+ for (const base of dedupeBases([target.cwd, target.fallbackBaseDir])) {
127
+ const { sessionDir } = sessionScratchLayout(base, target.agentId);
128
+ try {
129
+ rmSync(sessionDir, { recursive: true, force: true });
130
+ } catch {}
131
+ }
132
+ }
133
+
134
+ export interface SweepOptions {
135
+ cwd: string;
136
+ fallbackBaseDir?: string;
137
+ // Agent ids to keep (currently-known agents + self). Any session dir whose id
138
+ // is not in this set AND older than minAgeMs is removed.
139
+ keepAgentIds: Set<string>;
140
+ now: number;
141
+ minAgeMs?: number;
142
+ }
143
+
144
+ // Periodic sweep of leftover session dirs from agents no longer known to the
145
+ // relay. The minAgeMs grace avoids racing a peer that just created its dir but
146
+ // has not yet appeared in the agent list.
147
+ export function sweepStaleSessions(opts: SweepOptions): string[] {
148
+ const minAge = opts.minAgeMs ?? 60 * 60 * 1000;
149
+ const removed: string[] = [];
150
+ for (const base of dedupeBases([opts.cwd, opts.fallbackBaseDir])) {
151
+ const sessionsDir = join(base, SCRATCH_DIR_NAME, "sessions");
152
+ let ids: string[];
153
+ try {
154
+ ids = readdirSync(sessionsDir);
155
+ } catch {
156
+ continue;
157
+ }
158
+ for (const id of ids) {
159
+ if (opts.keepAgentIds.has(id)) continue;
160
+ const dir = join(sessionsDir, id);
161
+ try {
162
+ if (opts.now - statSync(dir).mtimeMs < minAge) continue;
163
+ rmSync(dir, { recursive: true, force: true });
164
+ removed.push(dir);
165
+ } catch {}
166
+ }
167
+ }
168
+ return removed;
169
+ }