agent-relay-runner 0.28.0 → 0.30.0

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.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.17"
23
+ "agent-relay-sdk": "0.2.19"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -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.28.0",
4
+ "version": "0.30.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -174,7 +174,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
174
174
 
175
175
  export const RELAY_CONTEXT = `[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_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present you can stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; 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`;
176
176
 
177
- const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
177
+ export const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
178
178
 
179
179
  function attachmentRefs(message: Message): Record<string, unknown>[] {
180
180
  const payloadRefs = message.payload?.attachments;
@@ -1,8 +1,7 @@
1
1
  import type { Message } from "agent-relay-sdk";
2
2
  import { isRecord } from "agent-relay-sdk";
3
- import { isNotificationMessage, NOTIFICATION_NUDGE, providerAttachmentText } from "../adapter";
3
+ import { isNotificationMessage, NOTIFICATION_NUDGE, PROVIDER_MESSAGE_BODY_PREVIEW_CHARS, providerAttachmentText } from "../adapter";
4
4
 
5
- const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
6
5
  const REMINDER_EVERY_DELIVERIES = 5;
7
6
 
8
7
  interface ClaudeDeliveryTextOptions {
@@ -21,7 +21,7 @@ interface TranscriptBlock {
21
21
  is_error?: boolean;
22
22
  }
23
23
 
24
- export interface TurnStep {
24
+ interface TurnStep {
25
25
  type: "narration" | "reasoning" | "tool";
26
26
  text: string;
27
27
  label?: string;
@@ -36,6 +36,16 @@ interface TranscriptMessage {
36
36
  interface TranscriptEntry {
37
37
  type?: string;
38
38
  message?: TranscriptMessage;
39
+ // Claude Code stamps every transcript entry with `isSidechain`: true for
40
+ // entries belonging to a Task (subagent) run, false for the root session.
41
+ // Current CC writes sidechains to a separate subagents/*.jsonl so they don't
42
+ // reach the root transcript the runner tails — but older CC inlined them, and
43
+ // the behavior can revert, so the chat-mirror parsers below defensively skip
44
+ // sidechain entries to keep a subagent's reasoning/tools/responses from
45
+ // leaking into the parent agent's chat. Insights parsers (collectClaudeSession-
46
+ // Events/countSubstantiveTurns) intentionally do NOT filter — changing them
47
+ // would shift the #184/#185 baselines, a separate concern.
48
+ isSidechain?: boolean;
39
49
  }
40
50
 
41
51
  function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
@@ -43,6 +53,11 @@ function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
43
53
  return message.content.filter((b): b is TranscriptBlock => Boolean(b) && typeof b === "object");
44
54
  }
45
55
 
56
+ /** True for a subagent (Task) transcript entry — see the note on TranscriptEntry.isSidechain. */
57
+ function isSidechainEntry(entry: TranscriptEntry): boolean {
58
+ return entry.isSidechain === true;
59
+ }
60
+
46
61
  function isRealUserPrompt(entry: TranscriptEntry): boolean {
47
62
  if (entry.type !== "user") return false;
48
63
  const content = entry.message?.content;
@@ -75,6 +90,7 @@ export function transcriptLooksComplete(jsonl: string): boolean {
75
90
  if (!trimmed) continue;
76
91
  try {
77
92
  const entry = JSON.parse(trimmed) as TranscriptEntry;
93
+ if (isSidechainEntry(entry)) continue;
78
94
  if (entry.type === "assistant") lastAssistantStopReason = entry.message?.stop_reason;
79
95
  } catch {
80
96
  continue;
@@ -99,6 +115,7 @@ export function extractLastAssistantTurn(jsonl: string): string {
99
115
  } catch {
100
116
  continue;
101
117
  }
118
+ if (isSidechainEntry(entry)) continue;
102
119
  if (isRealUserPrompt(entry)) {
103
120
  collected = [];
104
121
  continue;
@@ -128,6 +145,7 @@ export function extractFinalAssistantMessage(jsonl: string): string {
128
145
  } catch {
129
146
  continue;
130
147
  }
148
+ if (isSidechainEntry(entry)) continue;
131
149
  if (isRealUserPrompt(entry)) {
132
150
  pastLastUserPrompt = true;
133
151
  lastText = "";
@@ -166,6 +184,7 @@ export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
166
184
  } catch {
167
185
  continue;
168
186
  }
187
+ if (isSidechainEntry(entry)) continue;
169
188
  if (isRealUserPrompt(entry)) {
170
189
  steps = [];
171
190
  continue;
@@ -184,6 +203,25 @@ export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
184
203
  return steps;
185
204
  }
186
205
 
206
+ /**
207
+ * Stable dedup keys for a turn's steps, in order. Each key is salted with how many
208
+ * identical (type,label,text) steps preceded it in the same window — so running the
209
+ * same tool twice with identical input within a turn yields two distinct keys and
210
+ * both show in the activity trace (#265). Keying on occurrence-within-window rather
211
+ * than raw transcript index keeps the reasoning tailer idempotent when the "latest
212
+ * turn" window shrinks/resets mid-poll: a surviving step recomputes to the same or a
213
+ * lower occurrence, so an already-emitted step never re-fires.
214
+ */
215
+ export function stepDedupKeys(steps: TurnStep[]): string[] {
216
+ const counts = new Map<string, number>();
217
+ return steps.map((step) => {
218
+ const base = JSON.stringify([step.type, step.label ?? "", step.text]);
219
+ const occ = counts.get(base) ?? 0;
220
+ counts.set(base, occ + 1);
221
+ return JSON.stringify([step.type, step.label ?? "", step.text, occ]);
222
+ });
223
+ }
224
+
187
225
  /** Compact one-line summary of a tool invocation for the discreet activity row. */
188
226
  export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
189
227
  const str = (key: string): string | undefined => (input && typeof input[key] === "string" ? (input[key] as string) : undefined);
@@ -48,7 +48,7 @@ export class ClaudeAdapter implements ProviderAdapter {
48
48
  await this.shutdownTmux(tmuxSession, opts, tmuxSocket);
49
49
  return;
50
50
  }
51
- await terminateProcess(process, opts);
51
+ await terminateSingleProcess(process, opts);
52
52
  }
53
53
 
54
54
  async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
@@ -453,6 +453,21 @@ function captureTmuxPane(sessionName: string, socketName?: string): string {
453
453
  return result.stdout.toString();
454
454
  }
455
455
 
456
+ // ⚠ FRAGILE PANE HEURISTICS — both functions below string-match Claude Code's TUI
457
+ // chrome against captured tmux scrollback (~80 lines), so they break whenever CC
458
+ // restyles its footer/banner. They are deliberately substring/regex based because
459
+ // there's no machine-readable ready/busy signal from the TUI. Known break conditions,
460
+ // so the next CC restyle is a fast fix rather than a hunt:
461
+ // readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
462
+ // "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
463
+ // the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
464
+ // busy (claudePaneIsBusy) breaks if CC drops the live "… (<elapsed>" spinner counter
465
+ // (the cross-version anchor; the "esc to interrupt" hint was already dropped in 2.1.x).
466
+ // FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
467
+ // transcript discussing "esc to interrupt", or this very comment shown in a pane)
468
+ // reads as ready/busy. Tolerated because the markers are CC-specific enough to be
469
+ // rare in real output; if it bites, gate on the LAST N lines only (the live footer).
470
+ // History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
456
471
  export function claudePaneLooksReady(text: string): boolean {
457
472
  // Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
458
473
  // conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
@@ -571,7 +586,7 @@ export function findClaudeRigRC(cwd: string): string | null {
571
586
  }
572
587
  }
573
588
 
574
- async function terminateProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
589
+ async function terminateSingleProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
575
590
  const proc = process.process;
576
591
  if (!proc) return;
577
592
  try {
@@ -74,6 +74,8 @@ interface ThreadLoadedListResponse {
74
74
  nextCursor: string | null;
75
75
  }
76
76
 
77
+ export const CODEX_APP_CLIENT_EVENT_CAP = 5_000;
78
+
77
79
  export class CodexAppClient {
78
80
  private ws!: WebSocket;
79
81
  private nextId = 1;
@@ -256,6 +258,9 @@ export class CodexAppClient {
256
258
 
257
259
  private record(event: ClientEvent): void {
258
260
  this.events.push(event);
261
+ if (this.events.length > CODEX_APP_CLIENT_EVENT_CAP) {
262
+ this.events.splice(0, this.events.length - CODEX_APP_CLIENT_EVENT_CAP);
263
+ }
259
264
  for (const listener of this.listeners) listener(event);
260
265
  }
261
266
 
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
4
  import type { ContextState, Message } from "agent-relay-sdk";
5
5
  import { isRecord, stringValue } from "agent-relay-sdk";
6
- import { isPidAlive, killPid, waitForPidsExit } from "agent-relay-sdk/process-utils";
6
+ import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
7
7
  import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
8
8
  import { workspaceDepsNoteFromEnv } from "../relay-instructions";
9
9
  import { relayMcpCodexConfigArgs, tomlString } from "../relay-mcp";
@@ -18,6 +18,7 @@ function codexRelayContextBlock(): string {
18
18
  import { prepareCodexProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
19
19
  import { CodexAppClient, type ClientEvent } from "./codex-client";
20
20
 
21
+ export { processTreePidsFromTable };
21
22
  export const DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 12_000;
22
23
 
23
24
  type PendingCodexApproval = {
@@ -28,12 +29,22 @@ type PendingCodexApproval = {
28
29
  view: Record<string, unknown>;
29
30
  };
30
31
 
32
+ type PendingCodexCompact = {
33
+ threadId: string;
34
+ timeout: ReturnType<typeof setTimeout>;
35
+ resolve: () => void;
36
+ reject: (error: Error) => void;
37
+ };
38
+
39
+ const CODEX_COMPACT_TIMEOUT_MS = 10 * 60 * 1000;
40
+
31
41
  export class CodexAdapter implements ProviderAdapter {
32
42
  readonly provider = "codex";
33
43
  private statusCb: (status: ProviderStatusUpdate) => void = () => {};
34
44
  private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
35
45
  private readonly subagentThreads = new Map<string, { label?: string; role?: string; parentId?: string }>();
36
46
  private readonly pendingApprovals = new Map<string, PendingCodexApproval>();
47
+ private pendingCompact?: PendingCodexCompact;
37
48
  // Active turn id for the main thread, captured from turn/started so an interrupt
38
49
  // can target the in-flight turn. Cleared on turn/completed.
39
50
  private activeTurnId?: string;
@@ -41,6 +52,7 @@ export class CodexAdapter implements ProviderAdapter {
41
52
  // flushed as one session response on turn/completed (mirrors Claude's chatCaptureMode).
42
53
  private turnMessages: string[] = [];
43
54
  private readonly itemTextBuffers = new Map<string, string>();
55
+ private readonly itemTextBufferTypes = new Map<string, string>();
44
56
  private captureMode: "final" | "full" = "final";
45
57
  // #183/#184: the normalized session-event log for the current process lifetime, fed
46
58
  // from the same completed-item stream that drives the chat mirror. The runner slices
@@ -58,6 +70,21 @@ export class CodexAdapter implements ProviderAdapter {
58
70
  this.sessionEventCb = cb;
59
71
  }
60
72
 
73
+ private resetProcessState(): void {
74
+ this.resetThreadState();
75
+ this.sessionEvents = []; // fresh process -> fresh segment cursor (#184)
76
+ }
77
+
78
+ private resetThreadState(): void {
79
+ this.cancelPendingCompact(new Error("Codex compact canceled by thread reset"));
80
+ this.subagentThreads.clear();
81
+ this.pendingApprovals.clear();
82
+ this.activeTurnId = undefined;
83
+ this.turnMessages = [];
84
+ this.itemTextBuffers.clear();
85
+ this.itemTextBufferTypes.clear();
86
+ }
87
+
61
88
  async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
62
89
  const client = process.meta?.client as CodexAppClient | undefined;
63
90
  if (!client) throw new Error("Codex App Server client is unavailable");
@@ -68,11 +95,33 @@ export class CodexAdapter implements ProviderAdapter {
68
95
  return { method: "turn-interrupt", turnId: this.activeTurnId };
69
96
  }
70
97
 
71
- // Codex streams thread/status continuously, so the runner's claim state never
72
- // goes stale the way Claude's can after an out-of-band interrupt. No cheap probe
73
- // is needed — defer to the live status stream.
74
- async probeActivity(): Promise<"busy" | "idle" | "unknown"> {
75
- return "unknown";
98
+ async probeActivity(process: ManagedProcess): Promise<"busy" | "idle" | "unknown"> {
99
+ const client = process.meta?.client as CodexAppClient | undefined;
100
+ if (!client?.isConnected()) return "unknown";
101
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
102
+ if (!this.activeTurnId) return "idle";
103
+ if (!threadId) return "busy";
104
+ try {
105
+ const read = await client.threadRead(threadId, true);
106
+ const thread = isRecord(read.thread) ? read.thread : undefined;
107
+ const turns = Array.isArray(thread?.turns) ? thread.turns : [];
108
+ const activeTurn = turns.find((turn) => isRecord(turn) && stringValue(turn.id) === this.activeTurnId);
109
+ const turnStatus = isRecord(activeTurn) ? stringValue(activeTurn.status) : undefined;
110
+ if (turnStatus === "inProgress") return "busy";
111
+ if (turnStatus === "completed" || turnStatus === "interrupted" || turnStatus === "failed") {
112
+ this.finishMainTurn();
113
+ return "idle";
114
+ }
115
+ const threadStatus = statusType(thread?.status);
116
+ if (threadStatus === "active") return "busy";
117
+ if (threadStatus === "idle" || threadStatus === "notLoaded" || threadStatus === "systemError") {
118
+ this.finishMainTurn();
119
+ return "idle";
120
+ }
121
+ } catch {
122
+ return "unknown";
123
+ }
124
+ return "busy";
76
125
  }
77
126
 
78
127
  // The Codex app-server is headless and has no tmux session, but an unexpected
@@ -82,8 +131,8 @@ export class CodexAdapter implements ProviderAdapter {
82
131
  }
83
132
 
84
133
  async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
134
+ this.resetProcessState();
85
135
  this.captureMode = (config.providerConfig as ProviderConfig).chatCaptureMode ?? "final";
86
- this.sessionEvents = []; // fresh process → fresh segment cursor (#184)
87
136
  const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
88
137
  const appServer = Bun.spawn([args.command, ...args.args], {
89
138
  cwd: args.cwd,
@@ -142,7 +191,7 @@ export class CodexAdapter implements ProviderAdapter {
142
191
  async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
143
192
  const client = process.meta?.client as CodexAppClient | undefined;
144
193
  client?.close();
145
- await terminateProcess(process, opts);
194
+ await terminateProcessTree(process, opts);
146
195
  }
147
196
 
148
197
  async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
@@ -150,7 +199,7 @@ export class CodexAdapter implements ProviderAdapter {
150
199
  if (!client) throw new Error("Codex App Server client is unavailable");
151
200
  const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
152
201
  if (!threadId) throw new Error("Codex thread is not ready");
153
- await client.threadCompactStart(threadId);
202
+ this.statusCb({ status: "busy", reason: "provider-turn", timeline: { status: "compacting", timestamp: Date.now() } });
154
203
  const currentContext = isContextState(process.meta?.context) ? process.meta.context : undefined;
155
204
  if (currentContext) {
156
205
  process.meta = {
@@ -158,6 +207,30 @@ export class CodexAdapter implements ProviderAdapter {
158
207
  context: { ...currentContext, lifecycleState: "compacting", lastUpdatedAt: Date.now() },
159
208
  };
160
209
  }
210
+ const completion = this.waitForPendingCompact(threadId);
211
+ try {
212
+ await client.threadCompactStart(threadId);
213
+ await completion;
214
+ } catch (error) {
215
+ this.clearPendingCompact(threadId);
216
+ this.statusCb({ status: "idle", reason: "provider-turn" });
217
+ throw error;
218
+ }
219
+ const compactedAt = Date.now();
220
+ const compactingContext = isContextState(process.meta?.context) ? process.meta.context : currentContext;
221
+ if (compactingContext) {
222
+ process.meta = {
223
+ ...(process.meta ?? {}),
224
+ context: {
225
+ ...compactingContext,
226
+ lifecycleState: "cooling",
227
+ tasksSinceCompact: 0,
228
+ lastCompactedAt: compactedAt,
229
+ lastUpdatedAt: compactedAt,
230
+ },
231
+ };
232
+ }
233
+ this.statusCb({ status: "idle", reason: "provider-turn", timeline: { status: "compacted", timestamp: compactedAt } });
161
234
  return { threadId };
162
235
  }
163
236
 
@@ -165,7 +238,16 @@ export class CodexAdapter implements ProviderAdapter {
165
238
  const client = process.meta?.client as CodexAppClient | undefined;
166
239
  if (!client) throw new Error("Codex App Server client is unavailable");
167
240
  const previousThreadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : undefined;
168
- const started = await client.threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() });
241
+ this.statusCb({ status: "busy", reason: "provider-turn", timeline: { status: "clearing-context", timestamp: Date.now() } });
242
+ let started: Awaited<ReturnType<CodexAppClient["threadStart"]>>;
243
+ try {
244
+ started = await client.threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() });
245
+ } catch (error) {
246
+ this.statusCb({ status: "idle", reason: "provider-turn" });
247
+ throw error;
248
+ }
249
+ const clearedAt = Date.now();
250
+ this.resetThreadState();
169
251
  process.meta = {
170
252
  ...(process.meta ?? {}),
171
253
  threadId: started.thread.id,
@@ -176,11 +258,13 @@ export class CodexAdapter implements ProviderAdapter {
176
258
  warmTopics: [],
177
259
  activeMemories: [],
178
260
  tasksSinceCompact: 0,
179
- lastUpdatedAt: Date.now(),
261
+ lastCompactedAt: clearedAt,
262
+ lastUpdatedAt: clearedAt,
180
263
  source: "api",
181
264
  confidence: "reported",
182
265
  } satisfies ContextState,
183
266
  };
267
+ this.statusCb({ status: "idle", reason: "provider-turn", clear: ["subagent"], timeline: { status: "context-cleared", timestamp: clearedAt } });
184
268
  return { previousThreadId, threadId: started.thread.id };
185
269
  }
186
270
 
@@ -308,6 +392,12 @@ export class CodexAdapter implements ProviderAdapter {
308
392
  updatedAt: Date.now(),
309
393
  },
310
394
  });
395
+ } else {
396
+ const client = process.meta?.client as CodexAppClient | undefined;
397
+ logger.warn("codex", `rejecting unknown Codex server-request method: ${event.message.method}`);
398
+ client?.rejectServerRequest(event.message.id, -32601, `Method not found: ${event.message.method}`, {
399
+ method: event.message.method,
400
+ });
311
401
  }
312
402
  return;
313
403
  }
@@ -350,28 +440,30 @@ export class CodexAdapter implements ProviderAdapter {
350
440
  this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
351
441
  }
352
442
  }
353
- if (method.includes("turn/completed") || method.includes("turn.completed")) {
443
+ if (method.includes("turn/completed") || method.includes("turn.completed") || method.includes("turn/failed") || method.includes("turn.failed") || method.includes("turn/interrupted") || method.includes("turn.interrupted")) {
354
444
  if (threadId && this.subagentThreads.has(threadId)) {
355
445
  this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
356
446
  } else {
357
- this.flushTurnResponse();
358
- const completedTurnId = this.activeTurnId;
359
- this.activeTurnId = undefined;
360
- this.statusCb({ status: "idle", reason: "provider-turn", id: completedTurnId });
447
+ this.finishMainTurn();
361
448
  }
362
449
  }
450
+ if ((method.includes("thread/compacted") || method.includes("thread.compacted")) && threadId) {
451
+ this.completePendingCompact(threadId);
452
+ }
363
453
  if ((method.includes("item/completed") || method.includes("item.completed")) && !isSubagent) {
364
- this.handleCodexItem(isRecord(params?.item) ? params.item : undefined);
454
+ const item = isRecord(params?.item) ? params.item : undefined;
455
+ this.handleCodexItem(item);
456
+ if (codexItemIsCompactionComplete(item) && threadId) this.completePendingCompact(threadId);
365
457
  }
366
458
  if (!isSubagent) this.handleCodexItemDelta(method, params);
367
- if (method.includes("thread/status")) {
459
+ if (method.includes("thread/status") || method.includes("thread.status")) {
368
460
  const status = statusType(params?.status);
369
461
  if (threadId && this.subagentThreads.has(threadId)) {
370
462
  if (status === "active") this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
371
463
  if (status === "idle" || status === "notLoaded") this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
372
464
  } else {
373
465
  if (status === "active") this.statusCb({ status: "busy", reason: "provider-turn", providerState: this.providerStateFromThreadStatus(params?.status, params) });
374
- if (status === "idle") this.statusCb({ status: "idle", reason: "provider-turn" });
466
+ if (status === "idle" || status === "notLoaded" || status === "systemError") this.finishMainTurn();
375
467
  }
376
468
  }
377
469
  }
@@ -391,6 +483,7 @@ export class CodexAdapter implements ProviderAdapter {
391
483
  this.recordInsightEvent({ type: "turn" }); // a substantive assistant turn
392
484
  }
393
485
  if (itemId) this.itemTextBuffers.delete(itemId);
486
+ if (itemId) this.itemTextBufferTypes.delete(itemId);
394
487
  return;
395
488
  }
396
489
  if (type === "userMessage") {
@@ -406,6 +499,7 @@ export class CodexAdapter implements ProviderAdapter {
406
499
  const text = (codexReasoningText(item) || buffered || "").trim();
407
500
  if (text) this.sessionEventCb({ type: "reasoning", origin: "provider", body: text, ...(turnId ? { turnId } : {}) });
408
501
  if (itemId) this.itemTextBuffers.delete(itemId);
502
+ if (itemId) this.itemTextBufferTypes.delete(itemId);
409
503
  return;
410
504
  }
411
505
  const tool = codexToolSummary(type, item);
@@ -415,6 +509,7 @@ export class CodexAdapter implements ProviderAdapter {
415
509
  this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "completed", ...(turnId ? { turnId } : {}) });
416
510
  }
417
511
  if (itemId) this.itemTextBuffers.delete(itemId);
512
+ if (itemId) this.itemTextBufferTypes.delete(itemId);
418
513
  }
419
514
 
420
515
  // #183/#184: append to the session-event log with a soft cap. On overflow we drop the
@@ -449,7 +544,10 @@ export class CodexAdapter implements ProviderAdapter {
449
544
 
450
545
  if (type === "agentMessage" || type === "reasoning" || type === "plan") {
451
546
  const delta = codexDeltaText(params);
452
- if (delta && itemId) this.itemTextBuffers.set(itemId, `${this.itemTextBuffers.get(itemId) ?? ""}${delta}`);
547
+ if (delta && itemId) {
548
+ this.itemTextBuffers.set(itemId, `${this.itemTextBuffers.get(itemId) ?? ""}${delta}`);
549
+ if (type) this.itemTextBufferTypes.set(itemId, type);
550
+ }
453
551
  return;
454
552
  }
455
553
 
@@ -459,13 +557,69 @@ export class CodexAdapter implements ProviderAdapter {
459
557
  }
460
558
 
461
559
  private flushTurnResponse(): void {
462
- if (!this.turnMessages.length) return;
463
- const joined = this.captureMode === "full" ? this.turnMessages.join("\n\n") : this.turnMessages[this.turnMessages.length - 1]!;
560
+ const pendingAgentMessages = [...this.itemTextBuffers.entries()]
561
+ .filter(([itemId]) => this.itemTextBufferTypes.get(itemId) === "agentMessage")
562
+ .map(([, text]) => text.trim())
563
+ .filter(Boolean);
564
+ const messages = [...this.turnMessages, ...pendingAgentMessages];
565
+ if (!messages.length) return;
566
+ const joined = this.captureMode === "full" ? messages.join("\n\n") : messages[messages.length - 1]!;
464
567
  this.turnMessages = [];
465
568
  const text = joined.trim();
466
569
  if (text) this.sessionEventCb({ type: "response", origin: "provider", body: text, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
467
570
  }
468
571
 
572
+ private finishMainTurn(): void {
573
+ this.flushTurnResponse();
574
+ const turnId = this.activeTurnId;
575
+ this.activeTurnId = undefined;
576
+ this.pendingApprovals.clear();
577
+ this.itemTextBuffers.clear();
578
+ this.itemTextBufferTypes.clear();
579
+ this.statusCb({ status: "idle", reason: "provider-turn", id: turnId });
580
+ }
581
+
582
+ private waitForPendingCompact(threadId: string): Promise<void> {
583
+ if (this.pendingCompact) throw new Error("Codex compact is already in progress");
584
+ return new Promise<void>((resolve, reject) => {
585
+ const pending: PendingCodexCompact = {
586
+ threadId,
587
+ resolve,
588
+ reject,
589
+ timeout: setTimeout(() => {
590
+ if (this.pendingCompact !== pending) return;
591
+ this.pendingCompact = undefined;
592
+ reject(new Error(`Codex compact timed out after ${Math.round(CODEX_COMPACT_TIMEOUT_MS / 1000)}s`));
593
+ }, CODEX_COMPACT_TIMEOUT_MS),
594
+ };
595
+ this.pendingCompact = pending;
596
+ });
597
+ }
598
+
599
+ private completePendingCompact(threadId: string): boolean {
600
+ const pending = this.pendingCompact;
601
+ if (!pending || pending.threadId !== threadId) return false;
602
+ clearTimeout(pending.timeout);
603
+ this.pendingCompact = undefined;
604
+ pending.resolve();
605
+ return true;
606
+ }
607
+
608
+ private clearPendingCompact(threadId?: string): void {
609
+ const pending = this.pendingCompact;
610
+ if (!pending || (threadId && pending.threadId !== threadId)) return;
611
+ clearTimeout(pending.timeout);
612
+ this.pendingCompact = undefined;
613
+ }
614
+
615
+ private cancelPendingCompact(error: Error): void {
616
+ const pending = this.pendingCompact;
617
+ if (!pending) return;
618
+ clearTimeout(pending.timeout);
619
+ this.pendingCompact = undefined;
620
+ pending.reject(error);
621
+ }
622
+
469
623
  private providerStateFromThreadStatus(status: unknown, params?: Record<string, unknown>): Record<string, unknown> | undefined {
470
624
  const state = codexProviderStateFromThreadStatus(status, params);
471
625
  if (state?.state !== "blocked" || state.reason !== "waitingOnApproval" || state.pendingApproval) return state;
@@ -478,6 +632,11 @@ function codexItemId(item: Record<string, unknown> | undefined): string | undefi
478
632
  return stringValue(item?.itemId) ?? stringValue(item?.id);
479
633
  }
480
634
 
635
+ function codexItemIsCompactionComplete(item: Record<string, unknown> | undefined): boolean {
636
+ const type = stringValue(item?.type);
637
+ return type === "context_compaction" || type === "contextCompaction" || type === "compaction";
638
+ }
639
+
481
640
  function codexDeltaText(params: Record<string, unknown> | undefined): string {
482
641
  const delta = params?.delta;
483
642
  if (typeof delta === "string") return delta;
@@ -1014,7 +1173,7 @@ async function connectWithRetry(client: CodexAppClient, attempts = 40): Promise<
1014
1173
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1015
1174
  }
1016
1175
 
1017
- async function terminateProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
1176
+ async function terminateProcessTree(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
1018
1177
  const processes = [
1019
1178
  process.meta?.tui as Bun.Subprocess | undefined,
1020
1179
  process.meta?.appServer as Bun.Subprocess | undefined,
@@ -1043,37 +1202,6 @@ async function terminateProcess(process: ManagedProcess, opts: { graceful: boole
1043
1202
  }
1044
1203
  }
1045
1204
 
1046
- export function processTreePidsFromTable(table: string, rootPids: number[]): number[] {
1047
- const childrenByParent = new Map<number, number[]>();
1048
- for (const line of table.split("\n")) {
1049
- const match = line.trim().match(/^(\d+)\s+(\d+)$/);
1050
- if (!match) continue;
1051
- const pid = Number(match[1]);
1052
- const ppid = Number(match[2]);
1053
- if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
1054
- const children = childrenByParent.get(ppid) ?? [];
1055
- children.push(pid);
1056
- childrenByParent.set(ppid, children);
1057
- }
1058
-
1059
- const seen = new Set<number>();
1060
- const visit = (pid: number) => {
1061
- if (seen.has(pid)) return;
1062
- seen.add(pid);
1063
- for (const child of childrenByParent.get(pid) ?? []) visit(child);
1064
- };
1065
- for (const pid of rootPids) visit(pid);
1066
- return [...seen].sort((a, b) => b - a);
1067
- }
1068
-
1069
- async function processTreePids(rootPids: number[]): Promise<number[]> {
1070
- if (rootPids.length === 0) return [];
1071
- const proc = Bun.spawn(["ps", "-e", "-o", "pid=,ppid="], { stdout: "pipe", stderr: "ignore" });
1072
- const table = await new Response(proc.stdout).text();
1073
- await proc.exited;
1074
- return processTreePidsFromTable(table, rootPids);
1075
- }
1076
-
1077
1205
  function codexRelayContextEnabled(process: ManagedProcess): boolean {
1078
1206
  const config = process.meta?.config as RunnerSpawnConfig | undefined;
1079
1207
  return config ? profileAllowsRelayFeature(config, "context") : true;
@@ -7,11 +7,11 @@ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
7
7
 
8
8
  const DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
9
9
 
10
- export interface AttachmentCacheClient {
10
+ interface AttachmentCacheClient {
11
11
  downloadArtifact(id: string): Promise<{ stream: ReadableStream<Uint8Array>; meta: Artifact }>;
12
12
  }
13
13
 
14
- export interface AttachmentCacheOptions {
14
+ interface AttachmentCacheOptions {
15
15
  agentId: string;
16
16
  rootDir?: string;
17
17
  maxAgeMs?: number;
@@ -34,7 +34,7 @@ function attachmentRefs(message: Message): Record<string, unknown>[] {
34
34
  return refs.filter(isRecord);
35
35
  }
36
36
 
37
- export function attachmentCacheRoot(agentId: string, rootDir = process.env.AGENT_RELAY_ATTACHMENT_CACHE_DIR): string {
37
+ function attachmentCacheRoot(agentId: string, rootDir = process.env.AGENT_RELAY_ATTACHMENT_CACHE_DIR): string {
38
38
  return join(attachmentCacheBase(rootDir), safePathPart(agentId));
39
39
  }
40
40
 
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir, hostname } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import { stringValue } from "agent-relay-sdk";
4
+ import { DEFAULT_RELAY_URL, stringValue } from "agent-relay-sdk";
5
5
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
6
  import type { ProviderConfig } from "./adapter";
7
7
 
@@ -15,8 +15,6 @@ interface LoadedProviderConfig extends ProviderConfig {
15
15
  path: string;
16
16
  }
17
17
 
18
- const DEFAULT_RELAY_URL = "http://127.0.0.1:4850";
19
-
20
18
  function agentRelayHome(): string {
21
19
  return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
22
20
  }
@@ -237,7 +237,7 @@ async function handlePermissionRequest(
237
237
  return Response.json(claudePermissionHookResponse(decision, body));
238
238
  }
239
239
 
240
- export function claudePermissionApprovalView(id: string, body: Record<string, unknown>): Record<string, unknown> {
240
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>): Record<string, unknown> {
241
241
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
242
242
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
243
243
  // AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
@@ -299,7 +299,7 @@ export function claudePermissionApprovalView(id: string, body: Record<string, un
299
299
  };
300
300
  }
301
301
 
302
- export function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
302
+ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
303
303
  // AskUserQuestion comes through a PreToolUse hook. The only way to satisfy it
304
304
  // headlessly is permissionDecision "allow" + updatedInput carrying the answers
305
305
  // (echoing back the original questions). A bare "allow" is not sufficient, so
package/src/logger.ts CHANGED
@@ -16,7 +16,7 @@ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
16
16
  // flipped at runtime via the control port (no restart) — so a phase refactor can
17
17
  // be watched at debug without bouncing the agent.
18
18
 
19
- export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
19
+ type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
20
20
 
21
21
  const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40, fatal: 50 };
22
22
  export const LOG_LEVELS = Object.keys(ORDER) as LogLevel[];
@@ -33,7 +33,7 @@ function safeLogName(value: string): string {
33
33
  return sanitizeFsName(value, { replacement: "_", maxLen: 180 });
34
34
  }
35
35
 
36
- export interface LoggerConfig {
36
+ interface LoggerConfig {
37
37
  agentId?: string;
38
38
  level?: LogLevel;
39
39
  headless?: boolean;
package/src/outbox.ts CHANGED
@@ -21,9 +21,9 @@ import { logger } from "./logger";
21
21
  // last-wins and self-heals on reconnect (so it already satisfies "coalesce, don't replay
22
22
  // stale busyes"). The coalesce mode below exists so a future state event could migrate here.
23
23
 
24
- export type OutboxMode = "append" | "coalesce";
24
+ type OutboxMode = "append" | "coalesce";
25
25
 
26
- export interface OutboxEventInput {
26
+ interface OutboxEventInput {
27
27
  kind: string;
28
28
  payload: unknown;
29
29
  mode?: OutboxMode;
@@ -46,9 +46,9 @@ export interface OutboxRecord {
46
46
  }
47
47
 
48
48
  // The transport. Resolve = delivered (row deleted). Reject = failed (retried with backoff).
49
- export type OutboxSend = (record: OutboxRecord) => Promise<void>;
49
+ type OutboxSend = (record: OutboxRecord) => Promise<void>;
50
50
 
51
- export interface OutboxOptions {
51
+ interface OutboxOptions {
52
52
  agentId: string;
53
53
  send: OutboxSend;
54
54
  // Storage directory. Defaults to AGENT_RELAY_RUNNER_OUTBOX_DIR, else a per-host temp dir.
@@ -44,7 +44,7 @@ const CLAUDE_AUTH_ITEMS = [".credentials.json", "statsig"];
44
44
  // Shared skeleton for both providers: gate on isolated-profile, make the
45
45
  // instance-keyed home, run the provider-specific first-run bootstrap. The
46
46
  // bootstrap step is the only genuinely provider-specific part.
47
- export function prepareProviderHome(provider: "claude" | "codex", config: RunnerSpawnConfig): ProviderHome | undefined {
47
+ function prepareProviderHome(provider: "claude" | "codex", config: RunnerSpawnConfig): ProviderHome | undefined {
48
48
  if (!profileRequiresIsolatedHome(config)) return undefined;
49
49
  const target = providerHomePath(provider, config);
50
50
  mkdirSync(target, { recursive: true });
@@ -40,7 +40,7 @@ const SSE_KEEPALIVE_MS = 25_000;
40
40
  // The write tools whose loss during a relay outage is unacceptable and whose result the agent
41
41
  // does not need synchronously — safe to queue durably and replay on reconnect. Reads, claims
42
42
  // (409 contention), spawn/shutdown (need a real ack) are deliberately NOT bufferable.
43
- export const DEFAULT_BUFFERABLE_TOOLS = new Set<string>([
43
+ const DEFAULT_BUFFERABLE_TOOLS = new Set<string>([
44
44
  "relay_send_message",
45
45
  "relay_reply",
46
46
  "relay_workspace_ready",
@@ -59,7 +59,7 @@ const WORKTREE_ONLY_TOOLS = new Set<string>([
59
59
  "relay_workspace_land",
60
60
  ]);
61
61
 
62
- export interface ProxyContext {
62
+ interface ProxyContext {
63
63
  // The agent owns a live (non-terminal) isolated git worktree → workspace tools apply.
64
64
  isolatedWorktree: boolean;
65
65
  }
@@ -16,9 +16,9 @@ import { logger } from "./logger";
16
16
  // - A background interval keeps the snapshot warm; `markDirty()` requests an extra,
17
17
  // debounced refresh when state likely just changed (a message arrived, a turn ended).
18
18
 
19
- export type ReplyObligationFetch = () => Promise<ReplyObligation[]>;
19
+ type ReplyObligationFetch = () => Promise<ReplyObligation[]>;
20
20
 
21
- export interface ReplyObligationCacheOptions {
21
+ interface ReplyObligationCacheOptions {
22
22
  fetch: ReplyObligationFetch;
23
23
  // Background freshness backstop. Default 10s — well under any turn cadence, cheap.
24
24
  intervalMs?: number;
package/src/runner.ts CHANGED
@@ -11,7 +11,7 @@ import { ClaimTracker } from "./claim-tracker";
11
11
  import { startControlServer, type ControlServer } from "./control-server";
12
12
  import { ReplyObligationCache } from "./reply-obligation-cache";
13
13
  import { Outbox, type OutboxRecord } from "./outbox";
14
- import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, transcriptLooksComplete } from "./adapters/claude-transcript";
14
+ import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
15
15
  import { computeContextRatio } from "./session-insights";
16
16
  import { agentProfileProjectionReport } from "./profile-projection";
17
17
  import { profileUsesHostProviderGlobals } from "./profile-home";
@@ -1256,6 +1256,12 @@ export class AgentRunner {
1256
1256
  // the same pre-destroy seam the bus commands use. `clear`/`compact` continue the session;
1257
1257
  // anything else (logout, prompt_input_exit, other) is a real termination.
1258
1258
  private async handleSessionBoundary(input: { reason?: string; transcriptPath?: string }): Promise<void> {
1259
+ // Reason mapping is fail-safe-toward-termination: only the two known session-
1260
+ // CONTINUING reasons are special-cased; everything else (logout, prompt_input_exit,
1261
+ // other, AND any future reason) maps to "shutdown" → full pre-destroy capture.
1262
+ // ⚠ If Claude Code adds a new BENIGN/continuing boundary reason, add it here — until
1263
+ // then it will trigger a (harmless but wasteful) full context capture on a session
1264
+ // that isn't actually ending.
1259
1265
  const reason = input.reason === "compact" ? "compact"
1260
1266
  : input.reason === "clear" ? "clear"
1261
1267
  : "shutdown";
@@ -1494,6 +1500,8 @@ export class AgentRunner {
1494
1500
  // turn" window in the transcript can shrink/reset (a tool_result entry, a
1495
1501
  // mid-turn user line), and an index cursor would then either re-emit or stall
1496
1502
  // and drop the rest of the turn. A seen-set is idempotent under any reshuffle.
1503
+ // The signature is salted with each step's occurrence-within-window (stepDedupKeys)
1504
+ // so two identical steps in one turn — same tool, same input — both surface (#265).
1497
1505
  const seen = new Set<string>();
1498
1506
  const turnIdAtStart = this.currentTurnId;
1499
1507
  // On the first poll the new prompt usually hasn't landed in the transcript yet,
@@ -1509,16 +1517,16 @@ export class AgentRunner {
1509
1517
  try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
1510
1518
  let steps: ReturnType<typeof extractLatestTurnSteps>;
1511
1519
  try { steps = extractLatestTurnSteps(jsonl); } catch { return; }
1520
+ const keyed = stepDedupKeys(steps).map((sig, i) => ({ sig, step: steps[i]! }));
1512
1521
  if (!seeded) {
1513
1522
  seeded = true;
1514
1523
  if (transcriptLooksComplete(jsonl)) {
1515
- for (const s of steps) seen.add(JSON.stringify([s.type, s.label ?? "", s.text]));
1524
+ for (const { sig } of keyed) seen.add(sig);
1516
1525
  }
1517
1526
  }
1518
1527
  const turnId = this.currentTurnId ?? turnIdAtStart;
1519
1528
  let emitted = 0;
1520
- for (const step of steps) {
1521
- const sig = JSON.stringify([step.type, step.label ?? "", step.text]);
1529
+ for (const { sig, step } of keyed) {
1522
1530
  if (seen.has(sig)) continue;
1523
1531
  seen.add(sig);
1524
1532
  emitted += 1;
@@ -2056,7 +2064,7 @@ export function latestClaudeResumeIdFromText(text: string): string | undefined {
2056
2064
  return latest;
2057
2065
  }
2058
2066
 
2059
- export function latestClaudeResumeIdFromLogFile(path: string): string | undefined {
2067
+ function latestClaudeResumeIdFromLogFile(path: string): string | undefined {
2060
2068
  let fd: number | undefined;
2061
2069
  try {
2062
2070
  const stat = statSync(path);
@@ -37,7 +37,7 @@ export function isGatheringTool(name: string): boolean {
37
37
  return GATHERING_NAME.test(name);
38
38
  }
39
39
 
40
- export interface ContextRatioMetric {
40
+ interface ContextRatioMetric {
41
41
  /** Session-wide gathering fraction: gatheringCalls / totalToolCalls. The headline metric. */
42
42
  ratio: number;
43
43
  gatheringCalls: number;
@@ -49,7 +49,7 @@ export interface ContextRatioMetric {
49
49
  turns: number;
50
50
  }
51
51
 
52
- export interface SessionOutcomeProxy {
52
+ interface SessionOutcomeProxy {
53
53
  /** Real user prompts in the session — more back-and-forth ~ more clarification/correction. */
54
54
  userPrompts: number;
55
55
  /** tool_result blocks flagged is_error — failures/workarounds the agent hit. */
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
 
5
- export const SCRATCH_DIR_NAME = ".agent-relay";
5
+ const SCRATCH_DIR_NAME = ".agent-relay";
6
6
  // The local-ignore entry. Leading + trailing slash scopes it to the dir at the
7
7
  // base, matching git's gitignore semantics.
8
8
  const EXCLUDE_ENTRY = "/.agent-relay/";
@@ -16,7 +16,7 @@ export interface SessionScratchLayout {
16
16
  replyFile: string; // <tmp>/reply.md
17
17
  }
18
18
 
19
- export interface SessionScratchTarget {
19
+ interface SessionScratchTarget {
20
20
  agentId: string;
21
21
  cwd: string;
22
22
  // Orchestrator base dir, used only when cwd is not writable. NEVER home — a
@@ -44,7 +44,7 @@ export function resolveScratchBase(cwd: string, fallbackBaseDir?: string): strin
44
44
  return cwd;
45
45
  }
46
46
 
47
- export function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
47
+ function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
48
48
  const rootDir = join(baseDir, SCRATCH_DIR_NAME);
49
49
  const sessionsDir = join(rootDir, "sessions");
50
50
  const sessionDir = join(sessionsDir, agentId);
@@ -131,7 +131,7 @@ export function reapSessionScratch(target: SessionScratchTarget): void {
131
131
  }
132
132
  }
133
133
 
134
- export interface SweepOptions {
134
+ interface SweepOptions {
135
135
  cwd: string;
136
136
  fallbackBaseDir?: string;
137
137
  // Agent ids to keep (currently-known agents + self). Any session dir whose id