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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +1 -1
- package/src/adapters/claude-delivery.ts +1 -2
- package/src/adapters/claude-transcript.ts +39 -1
- package/src/adapters/claude.ts +17 -2
- package/src/adapters/codex-client.ts +5 -0
- package/src/adapters/codex.ts +182 -54
- package/src/attachment-cache.ts +3 -3
- package/src/config.ts +1 -3
- package/src/control-server.ts +2 -2
- package/src/logger.ts +2 -2
- package/src/outbox.ts +4 -4
- package/src/profile-home.ts +1 -1
- package/src/relay-mcp-proxy.ts +2 -2
- package/src/reply-obligation-cache.ts +2 -2
- package/src/runner.ts +13 -5
- package/src/session-insights.ts +2 -2
- package/src/session-scratch.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "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.
|
|
23
|
+
"agent-relay-sdk": "0.2.19"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
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
|
-
|
|
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);
|
package/src/adapters/claude.ts
CHANGED
|
@@ -48,7 +48,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
48
48
|
await this.shutdownTmux(tmuxSession, opts, tmuxSocket);
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
|
-
await
|
|
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
|
|
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
|
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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"
|
|
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)
|
|
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
|
-
|
|
463
|
-
|
|
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
|
|
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;
|
package/src/attachment-cache.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
+
interface AttachmentCacheClient {
|
|
11
11
|
downloadArtifact(id: string): Promise<{ stream: ReadableStream<Uint8Array>; meta: Artifact }>;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/control-server.ts
CHANGED
|
@@ -237,7 +237,7 @@ async function handlePermissionRequest(
|
|
|
237
237
|
return Response.json(claudePermissionHookResponse(decision, body));
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
type OutboxMode = "append" | "coalesce";
|
|
25
25
|
|
|
26
|
-
|
|
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
|
-
|
|
49
|
+
type OutboxSend = (record: OutboxRecord) => Promise<void>;
|
|
50
50
|
|
|
51
|
-
|
|
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.
|
package/src/profile-home.ts
CHANGED
|
@@ -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
|
-
|
|
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 });
|
package/src/relay-mcp-proxy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
+
type ReplyObligationFetch = () => Promise<ReplyObligation[]>;
|
|
20
20
|
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2067
|
+
function latestClaudeResumeIdFromLogFile(path: string): string | undefined {
|
|
2060
2068
|
let fd: number | undefined;
|
|
2061
2069
|
try {
|
|
2062
2070
|
const stat = statSync(path);
|
package/src/session-insights.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function isGatheringTool(name: string): boolean {
|
|
|
37
37
|
return GATHERING_NAME.test(name);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
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
|
-
|
|
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. */
|
package/src/session-scratch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|