agent-relay-runner 0.39.0 → 0.41.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 +12 -3
- package/src/adapters/claude.ts +14 -3
- package/src/adapters/codex.ts +38 -7
- package/src/runner.ts +104 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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.26"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
package/src/adapter.ts
CHANGED
|
@@ -36,13 +36,17 @@ export type ProviderStatusUpdate = SemanticStatus | ProviderStatusEvent;
|
|
|
36
36
|
* same lane Claude's transcript capture uses. Provider-independent boundary.
|
|
37
37
|
*/
|
|
38
38
|
export interface ProviderSessionEvent {
|
|
39
|
-
type: "prompt" | "response" | "reasoning" | "tool";
|
|
39
|
+
type: "prompt" | "response" | "narration" | "reasoning" | "tool";
|
|
40
40
|
body: string;
|
|
41
41
|
origin?: "chat" | "terminal" | "provider";
|
|
42
42
|
turnId?: string;
|
|
43
43
|
label?: string;
|
|
44
44
|
status?: "running" | "completed" | "failed";
|
|
45
45
|
streaming?: boolean;
|
|
46
|
+
/** Stable provider-side step id (Codex app-server item id). Carried into
|
|
47
|
+
* MessageSessionMeta.stepId so the server upserts the step's row in place instead of
|
|
48
|
+
* appending a duplicate (a tool's running→completed, a streamed reasoning row). */
|
|
49
|
+
stepId?: string;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export interface ProviderConfig {
|
|
@@ -132,7 +136,8 @@ export interface ProviderAdapter {
|
|
|
132
136
|
provider: string;
|
|
133
137
|
spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
|
|
134
138
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
135
|
-
compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
139
|
+
compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
|
|
140
|
+
compactSupportsInstructions?: boolean;
|
|
136
141
|
clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
137
142
|
// Normalize the session so far into the provider-agnostic SessionEvent stream the
|
|
138
143
|
// Insights context-ratio signal (#183/#184) reduces. Called by the runner's
|
|
@@ -143,6 +148,10 @@ export interface ProviderAdapter {
|
|
|
143
148
|
// ignore it and return their accumulated log. Return null when there is nothing to
|
|
144
149
|
// measure. Best-effort: may be omitted by providers without a session view yet.
|
|
145
150
|
collectSessionEvents?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null>;
|
|
151
|
+
// Full searchable transcript/archive source for destructive boundaries. The runner
|
|
152
|
+
// slices the returned stream by cursor, so adapters should return the session-so-far
|
|
153
|
+
// view when they have one.
|
|
154
|
+
collectSessionArchiveSegment?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null>;
|
|
146
155
|
// Interrupt the in-flight turn without ending the session (ESC for Claude's
|
|
147
156
|
// tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
|
|
148
157
|
// the runner boundary; each adapter does what its provider actually supports.
|
|
@@ -181,7 +190,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
|
|
|
181
190
|
return config.agentProfile?.relay?.[feature] !== false;
|
|
182
191
|
}
|
|
183
192
|
|
|
184
|
-
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_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — 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`;
|
|
193
|
+
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_compact_and_resume, relay_recall, 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_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — 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`;
|
|
185
194
|
|
|
186
195
|
// #306 — deliver the FULL message body by default. Only a pathological body beyond this
|
|
187
196
|
// high cap truncates (with a get-message hint) so it can't nuke an agent's context; the 99%
|
package/src/adapters/claude.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { claudeProviderMessageText } from "./claude-delivery";
|
|
|
15
15
|
|
|
16
16
|
export class ClaudeAdapter implements ProviderAdapter {
|
|
17
17
|
readonly provider = "claude";
|
|
18
|
+
readonly compactSupportsInstructions = true;
|
|
18
19
|
// #352: initial prompt is seeded as Claude's positional launch arg (buildSpawnArgs) — reliable,
|
|
19
20
|
// no send-keys/onboarding race; tells the runner to skip the redundant post-launch delivery.
|
|
20
21
|
readonly seedsInitialPromptAtLaunch = true;
|
|
@@ -55,12 +56,13 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
55
56
|
await terminateSingleProcess(process, opts);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
59
|
+
async compact(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown>> {
|
|
59
60
|
const session = process.meta?.tmuxSession as string | undefined;
|
|
60
61
|
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
61
62
|
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for compact");
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const command = opts?.instructions ? `/compact ${opts.instructions}` : "/compact";
|
|
64
|
+
await submitTextToTmux(session, command, socket);
|
|
65
|
+
return { method: "tmux-inject", command };
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
@@ -84,6 +86,15 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
84
86
|
return collectClaudeSessionEvents(jsonl);
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
async collectSessionArchiveSegment(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null> {
|
|
90
|
+
if (!ctx.transcriptPath) return null;
|
|
91
|
+
try {
|
|
92
|
+
return await readFile(ctx.transcriptPath, "utf8");
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
88
99
|
const session = process.meta?.tmuxSession as string | undefined;
|
|
89
100
|
const socket = process.meta?.tmuxSocket as string | undefined;
|
package/src/adapters/codex.ts
CHANGED
|
@@ -476,11 +476,18 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
476
476
|
const type = stringValue(item.type);
|
|
477
477
|
const turnId = this.activeTurnId;
|
|
478
478
|
const itemId = codexItemId(item);
|
|
479
|
+
// A completed non-reasoning item ends the reasoning segment that preceded it — flush the
|
|
480
|
+
// buffered reasoning first so it lands in transcript order, ahead of this tool/message.
|
|
481
|
+
if (type !== "reasoning") this.flushBufferedReasoning();
|
|
479
482
|
if (type === "agentMessage") {
|
|
480
483
|
const text = (stringValue(item.text) ?? (itemId ? this.itemTextBuffers.get(itemId) : undefined))?.trim();
|
|
481
484
|
if (text) {
|
|
482
485
|
this.turnMessages.push(text);
|
|
483
486
|
this.recordInsightEvent({ type: "turn" }); // a substantive assistant turn
|
|
487
|
+
// Stream the assistant text into the trace as narration (Claude parity). The closing
|
|
488
|
+
// response bubble at turn end repeats the final block; the dashboard suppresses the
|
|
489
|
+
// matching narration so it isn't shown twice.
|
|
490
|
+
this.sessionEventCb({ type: "narration", origin: "provider", body: text, ...(turnId ? { turnId } : {}) });
|
|
484
491
|
}
|
|
485
492
|
if (itemId) this.itemTextBuffers.delete(itemId);
|
|
486
493
|
if (itemId) this.itemTextBufferTypes.delete(itemId);
|
|
@@ -495,23 +502,43 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
495
502
|
return;
|
|
496
503
|
}
|
|
497
504
|
if (type === "reasoning") {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (itemId)
|
|
505
|
+
// Codex has no reliable reasoning item/completed carrying text — the stream IS the deltas.
|
|
506
|
+
// Keep the longer of (accumulated deltas, item.content) in the buffer and emit at the next
|
|
507
|
+
// boundary (next item / turn end), the same coarse signal the Claude reasoning tail uses.
|
|
508
|
+
const fromItem = codexReasoningText(item);
|
|
509
|
+
if (itemId && fromItem) {
|
|
510
|
+
const existing = this.itemTextBuffers.get(itemId) ?? "";
|
|
511
|
+
if (fromItem.length >= existing.length) this.itemTextBuffers.set(itemId, fromItem);
|
|
512
|
+
this.itemTextBufferTypes.set(itemId, "reasoning");
|
|
513
|
+
}
|
|
503
514
|
return;
|
|
504
515
|
}
|
|
505
516
|
const tool = codexToolSummary(type, item);
|
|
506
517
|
if (tool) {
|
|
507
518
|
this.recordInsightEvent({ type: "tool", name: codexInsightToolName(type, item) });
|
|
508
519
|
if (codexItemFailed(item)) this.recordInsightEvent({ type: "tool_error" });
|
|
509
|
-
|
|
520
|
+
// stepId = the app-server item id: the server upserts the running step emitted on
|
|
521
|
+
// item/started into this completed state IN PLACE, so the tool persists as ONE row.
|
|
522
|
+
this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "completed", ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
|
|
510
523
|
}
|
|
511
524
|
if (itemId) this.itemTextBuffers.delete(itemId);
|
|
512
525
|
if (itemId) this.itemTextBufferTypes.delete(itemId);
|
|
513
526
|
}
|
|
514
527
|
|
|
528
|
+
// Emit any buffered reasoning as discrete trace blocks (appended, in transcript order). Codex
|
|
529
|
+
// streams reasoning as deltas with no reliable completion event, so we flush coarsely at item
|
|
530
|
+
// and turn boundaries — the signal the Claude reasoning tail uses — never a codex-only timer.
|
|
531
|
+
private flushBufferedReasoning(): void {
|
|
532
|
+
const turnId = this.activeTurnId;
|
|
533
|
+
for (const [itemId, text] of [...this.itemTextBuffers.entries()]) {
|
|
534
|
+
if (this.itemTextBufferTypes.get(itemId) !== "reasoning") continue;
|
|
535
|
+
this.itemTextBuffers.delete(itemId);
|
|
536
|
+
this.itemTextBufferTypes.delete(itemId);
|
|
537
|
+
const body = text.trim();
|
|
538
|
+
if (body) this.sessionEventCb({ type: "reasoning", origin: "provider", body, ...(turnId ? { turnId } : {}) });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
515
542
|
// #183/#184: append to the session-event log with a soft cap. On overflow we drop the
|
|
516
543
|
// oldest half; the runner detects the resulting length shrink and resets its segment
|
|
517
544
|
// cursor (worst case: one slightly-truncated datapoint on a pathologically long session).
|
|
@@ -537,8 +564,11 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
537
564
|
const turnId = this.activeTurnId;
|
|
538
565
|
|
|
539
566
|
if (method.includes("/started") || method.includes(".started")) {
|
|
567
|
+
// A new item starting ends the prior reasoning segment — flush it ahead of this step.
|
|
568
|
+
this.flushBufferedReasoning();
|
|
540
569
|
const tool = codexToolSummary(type, item ?? params ?? {});
|
|
541
|
-
|
|
570
|
+
// stepId = item id so the server upserts this running step → completed IN PLACE (one row).
|
|
571
|
+
if (tool) this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "running", streaming: true, ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
|
|
542
572
|
return;
|
|
543
573
|
}
|
|
544
574
|
|
|
@@ -570,6 +600,7 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
570
600
|
}
|
|
571
601
|
|
|
572
602
|
private finishMainTurn(): void {
|
|
603
|
+
this.flushBufferedReasoning(); // surface any trailing reasoning before the closing response
|
|
573
604
|
this.flushTurnResponse();
|
|
574
605
|
const turnId = this.activeTurnId;
|
|
575
606
|
this.activeTurnId = undefined;
|
package/src/runner.ts
CHANGED
|
@@ -231,6 +231,8 @@ export class AgentRunner {
|
|
|
231
231
|
// (transcript rotated, Codex buffer trimmed) resets the cursor.
|
|
232
232
|
private insightsObserved = 0;
|
|
233
233
|
private insightsCursorKey = "";
|
|
234
|
+
private archiveObservedChars = 0;
|
|
235
|
+
private archiveCursorKey = "";
|
|
234
236
|
// Memoized repo-name project id for insight observations (resolved once; involves a
|
|
235
237
|
// git toplevel lookup for direct agents). Aggregates by repo, not per-session worktree.
|
|
236
238
|
private insightProjectName?: string;
|
|
@@ -251,6 +253,12 @@ export class AgentRunner {
|
|
|
251
253
|
// its final response. Set when a provider-turn starts, cleared when it ends.
|
|
252
254
|
private currentTurnId?: string;
|
|
253
255
|
private currentTurnStartedAt?: number;
|
|
256
|
+
// True while a turn that was already in flight is being compacted. Claude's PostCompact
|
|
257
|
+
// hook posts a single `idle`, but a mid-turn compaction RESUMES the turn afterward — so
|
|
258
|
+
// treating that idle as a turn end (flip idle, kill the reasoning tail) makes chat go dark
|
|
259
|
+
// until the next prompt. Set when `compacting` arrives with a turn running; cleared on the
|
|
260
|
+
// genuine end (a plain idle with no compaction timeline).
|
|
261
|
+
private compactionMidTurn = false;
|
|
254
262
|
// Prompt-echo dedup: a short, time-bounded queue of prompts the runner itself
|
|
255
263
|
// injected (chat box or initial prompt) that are still awaiting their matching
|
|
256
264
|
// UserPromptSubmit echo. A single slot dropped earlier entries when several prompts
|
|
@@ -731,7 +739,9 @@ export class AgentRunner {
|
|
|
731
739
|
else if (type === "agent.reconnect") this.publishStatus();
|
|
732
740
|
else if (type === "agent.compact") {
|
|
733
741
|
if (!this.options.adapter.compact || !this.process) throw new Error("provider does not support compact");
|
|
734
|
-
providerResult = await this.options.adapter.compact(this.process
|
|
742
|
+
providerResult = await this.options.adapter.compact(this.process, {
|
|
743
|
+
instructions: typeof params.instructions === "string" ? params.instructions : undefined,
|
|
744
|
+
});
|
|
735
745
|
} else if (type === "agent.clearContext") {
|
|
736
746
|
if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
|
|
737
747
|
providerResult = await this.options.adapter.clearContext(this.process);
|
|
@@ -762,7 +772,11 @@ export class AgentRunner {
|
|
|
762
772
|
} finally {
|
|
763
773
|
this.claims.finishClaim("command", commandId);
|
|
764
774
|
if (exitAfterCommand) {
|
|
765
|
-
|
|
775
|
+
if (params.preserveRegistration === true) {
|
|
776
|
+
await this.http.setStatus(this.agentId, "offline", this.options.instanceId).catch(() => {});
|
|
777
|
+
} else {
|
|
778
|
+
await this.http.deleteAgent(this.agentId).catch(() => {});
|
|
779
|
+
}
|
|
766
780
|
if (this.options.exitProcessOnShutdown !== false) {
|
|
767
781
|
setTimeout(() => void this.stop().catch((error) => {
|
|
768
782
|
logger.error("lifecycle", `stop after command failed: ${error}`);
|
|
@@ -1112,10 +1126,28 @@ export class AgentRunner {
|
|
|
1112
1126
|
} else if (status === "idle" || status === "busy") {
|
|
1113
1127
|
this.terminalFailure = undefined;
|
|
1114
1128
|
}
|
|
1129
|
+
// Compaction lifecycle (Claude PreCompact→`compacting` busy / PostCompact→`compacted`
|
|
1130
|
+
// idle). A `compacted` idle for a turn that predated compaction is a hook artifact — the
|
|
1131
|
+
// turn resumes — so it must NOT end the turn. Discriminate on whether a turn was running,
|
|
1132
|
+
// captured BEFORE the busy logic below mints a currentTurnId (it does for /compact at idle).
|
|
1133
|
+
const timelineStatus = typeof update !== "string" ? update.timeline?.status : undefined;
|
|
1134
|
+
if (timelineStatus === "compacting") {
|
|
1135
|
+
this.compactionMidTurn = this.currentTurnId !== undefined;
|
|
1136
|
+
} else if (timelineStatus === "compacted") {
|
|
1137
|
+
this.publishCompactionNotice();
|
|
1138
|
+
if (this.compactionMidTurn) {
|
|
1139
|
+
// Keep the turn (and its live reasoning tail) alive; the genuine Stop hook ends it.
|
|
1140
|
+
// pendingTimelineEvent (the marker) was set above and is consumed by publishStatus.
|
|
1141
|
+
this.sessionLog(`compaction completed mid-turn (turn ${this.currentTurnId ?? "?"}) — staying busy`);
|
|
1142
|
+
this.publishStatus();
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1115
1146
|
if (status === "busy" && reason === "provider-turn") {
|
|
1116
1147
|
if (!this.currentTurnId) {
|
|
1117
1148
|
this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
|
|
1118
1149
|
this.currentTurnStartedAt = Date.now();
|
|
1150
|
+
this.compactionMidTurn = false;
|
|
1119
1151
|
this.sessionLog(`turn started (turn ${this.currentTurnId})`);
|
|
1120
1152
|
}
|
|
1121
1153
|
this.armBusyReconciler();
|
|
@@ -1123,6 +1155,7 @@ export class AgentRunner {
|
|
|
1123
1155
|
if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
|
|
1124
1156
|
this.currentTurnId = undefined;
|
|
1125
1157
|
this.currentTurnStartedAt = undefined;
|
|
1158
|
+
this.compactionMidTurn = false;
|
|
1126
1159
|
this.disarmBusyReconciler();
|
|
1127
1160
|
this.stopReasoningTail();
|
|
1128
1161
|
}
|
|
@@ -1239,8 +1272,12 @@ export class AgentRunner {
|
|
|
1239
1272
|
// retried until it lands. occurredAt is stamped now so a queued event reports when it
|
|
1240
1273
|
// truly happened, not when the server finally accepted it. Routed through the fast-lane
|
|
1241
1274
|
// sessionOutbox (#332) so a transient trace failure can't head-of-line block real messages.
|
|
1275
|
+
// A stepId-bearing step (Codex tool running→completed, streamed reasoning/response) uses a
|
|
1276
|
+
// STABLE idempotency key so the server upserts the row in place instead of appending a dup.
|
|
1277
|
+
const stepId = input.session.stepId;
|
|
1242
1278
|
this.sessionOutbox.enqueue({
|
|
1243
1279
|
kind: "session-message",
|
|
1280
|
+
...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
|
|
1244
1281
|
payload: {
|
|
1245
1282
|
from: input.from,
|
|
1246
1283
|
to: input.to,
|
|
@@ -1272,6 +1309,13 @@ export class AgentRunner {
|
|
|
1272
1309
|
});
|
|
1273
1310
|
return;
|
|
1274
1311
|
}
|
|
1312
|
+
if (record.kind === "continuation-archive") {
|
|
1313
|
+
await this.http.recordContinuationArchive({
|
|
1314
|
+
...(record.payload as Parameters<RelayHttpClient["recordContinuationArchive"]>[0]),
|
|
1315
|
+
occurredAt: record.occurredAt,
|
|
1316
|
+
});
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1275
1319
|
if (record.kind === "mcp-tool-call") {
|
|
1276
1320
|
await this.deliverBufferedMcpCall(record);
|
|
1277
1321
|
return;
|
|
@@ -1405,11 +1449,14 @@ export class AgentRunner {
|
|
|
1405
1449
|
this.publishFinalizing(reason);
|
|
1406
1450
|
try {
|
|
1407
1451
|
await Promise.race([
|
|
1408
|
-
|
|
1452
|
+
Promise.all([
|
|
1453
|
+
this.captureContextRatio(reason, opts),
|
|
1454
|
+
this.captureContinuationArchive(reason, opts),
|
|
1455
|
+
]).then(() => undefined),
|
|
1409
1456
|
new Promise<void>((resolve) => setTimeout(resolve, PRE_DESTROY_TIMEOUT_MS)),
|
|
1410
1457
|
]);
|
|
1411
1458
|
} catch (error) {
|
|
1412
|
-
this.sessionLog(`
|
|
1459
|
+
this.sessionLog(`pre-destroy capture failed: ${errMessage(error)}`);
|
|
1413
1460
|
}
|
|
1414
1461
|
// For exit-bound transitions the runner won't be alive afterward to drain the durable
|
|
1415
1462
|
// outbox, so block (bounded) on delivering what capture just enqueued. This runs before
|
|
@@ -1449,6 +1496,30 @@ export class AgentRunner {
|
|
|
1449
1496
|
return (this.insightProjectName ??= resolveProjectName(this.options.cwd, this.options.workspace));
|
|
1450
1497
|
}
|
|
1451
1498
|
|
|
1499
|
+
private async captureContinuationArchive(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
|
|
1500
|
+
const adapter = this.options.adapter;
|
|
1501
|
+
if (!adapter.collectSessionArchiveSegment || !this.process) return;
|
|
1502
|
+
const transcriptPath = opts?.transcriptPath ?? this.lastTranscriptPath;
|
|
1503
|
+
const archive = await adapter.collectSessionArchiveSegment(this.process, { transcriptPath });
|
|
1504
|
+
if (!archive) return;
|
|
1505
|
+
const key = transcriptPath ?? `session:${this.providerSessionId}`;
|
|
1506
|
+
if (key !== this.archiveCursorKey || archive.length < this.archiveObservedChars) {
|
|
1507
|
+
this.archiveCursorKey = key;
|
|
1508
|
+
this.archiveObservedChars = 0;
|
|
1509
|
+
}
|
|
1510
|
+
const segment = archive.slice(this.archiveObservedChars).trim();
|
|
1511
|
+
this.archiveObservedChars = archive.length;
|
|
1512
|
+
if (!segment) return;
|
|
1513
|
+
this.outbox.enqueue({
|
|
1514
|
+
kind: "continuation-archive",
|
|
1515
|
+
payload: {
|
|
1516
|
+
agentId: this.agentId,
|
|
1517
|
+
segment,
|
|
1518
|
+
},
|
|
1519
|
+
});
|
|
1520
|
+
this.sessionLog(`continuation archive queued (${segment.length} chars, ${reason})`);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1452
1523
|
private async captureContextRatio(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
|
|
1453
1524
|
const adapter = this.options.adapter;
|
|
1454
1525
|
if (!adapter.collectSessionEvents || !this.process) return;
|
|
@@ -1534,6 +1605,7 @@ export class AgentRunner {
|
|
|
1534
1605
|
...(event.label ? { label: event.label } : {}),
|
|
1535
1606
|
...(event.status ? { status: event.status } : {}),
|
|
1536
1607
|
...(event.streaming !== undefined ? { streaming: event.streaming } : {}),
|
|
1608
|
+
...(event.stepId ? { stepId: event.stepId } : {}),
|
|
1537
1609
|
},
|
|
1538
1610
|
});
|
|
1539
1611
|
}
|
|
@@ -1617,6 +1689,7 @@ export class AgentRunner {
|
|
|
1617
1689
|
this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
|
|
1618
1690
|
this.claims.clearWorkKind("provider-turn");
|
|
1619
1691
|
this.currentTurnId = undefined;
|
|
1692
|
+
this.compactionMidTurn = false;
|
|
1620
1693
|
this.publishStatus();
|
|
1621
1694
|
}
|
|
1622
1695
|
|
|
@@ -1697,6 +1770,18 @@ export class AgentRunner {
|
|
|
1697
1770
|
this.reasoningTail = undefined;
|
|
1698
1771
|
}
|
|
1699
1772
|
|
|
1773
|
+
// Mirror a discreet, durable "context compacted" marker into chat via the existing
|
|
1774
|
+
// session-mirror lane (not a parallel channel). `notice` renders as an inline timeline
|
|
1775
|
+
// marker (never a bubble) and survives reload, unlike the ephemeral timeline-status one.
|
|
1776
|
+
private publishCompactionNotice(): void {
|
|
1777
|
+
this.publishSessionEvent({
|
|
1778
|
+
from: this.agentId,
|
|
1779
|
+
to: "user",
|
|
1780
|
+
body: "🗜 Context compacted",
|
|
1781
|
+
session: { type: "notice", origin: "provider", label: "compacted", ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}) },
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1700
1785
|
private publishStatus(): void {
|
|
1701
1786
|
this.claims.expire();
|
|
1702
1787
|
const status = this.claims.currentStatus();
|
|
@@ -2023,7 +2108,7 @@ export class AgentRunner {
|
|
|
2023
2108
|
const meta: Record<string, unknown> = {
|
|
2024
2109
|
providerCapabilities: runtimeProviderCapabilities(
|
|
2025
2110
|
this.options,
|
|
2026
|
-
context
|
|
2111
|
+
context,
|
|
2027
2112
|
probeModel,
|
|
2028
2113
|
),
|
|
2029
2114
|
...(terminalSession ? { tmuxSession: terminalSession } : {}),
|
|
@@ -2276,7 +2361,7 @@ interface ProbeModelInfo {
|
|
|
2276
2361
|
effort?: string;
|
|
2277
2362
|
}
|
|
2278
2363
|
|
|
2279
|
-
function runtimeProviderCapabilities(options: RunnerOptions,
|
|
2364
|
+
function runtimeProviderCapabilities(options: RunnerOptions, contextState?: ContextState, probeModel?: ProbeModelInfo): ProviderCapabilities {
|
|
2280
2365
|
const model = options.model ?? probeModel?.model;
|
|
2281
2366
|
const effort = options.effort ?? probeModel?.effort;
|
|
2282
2367
|
const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
|
|
@@ -2306,7 +2391,7 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
|
|
|
2306
2391
|
confidence: "reported",
|
|
2307
2392
|
lastUpdatedAt: options.startedAt,
|
|
2308
2393
|
},
|
|
2309
|
-
...runtimeProviderContextCapabilities(options,
|
|
2394
|
+
...runtimeProviderContextCapabilities(options, contextState),
|
|
2310
2395
|
...runtimeProviderTerminalCapabilities(options),
|
|
2311
2396
|
liveSession: {
|
|
2312
2397
|
capture: true,
|
|
@@ -2375,14 +2460,22 @@ function appliedAgentProfileMetadata(provider: string, profile: AgentProfile): R
|
|
|
2375
2460
|
};
|
|
2376
2461
|
}
|
|
2377
2462
|
|
|
2378
|
-
function runtimeProviderContextCapabilities(options: RunnerOptions,
|
|
2379
|
-
const context: NonNullable<ProviderCapabilities["context"]> = {};
|
|
2380
|
-
|
|
2381
|
-
if (
|
|
2463
|
+
function runtimeProviderContextCapabilities(options: RunnerOptions, contextState?: ContextState): Pick<ProviderCapabilities, "context"> {
|
|
2464
|
+
const context: NonNullable<ProviderCapabilities["context"]> = { resume: "none" };
|
|
2465
|
+
const supportsManagedContext = options.provider === "codex" || (options.provider === "claude" && options.headless);
|
|
2466
|
+
if (contextState) {
|
|
2467
|
+
context.stats = { source: contextState.source, confidence: contextState.confidence };
|
|
2468
|
+
if (typeof contextState.tokensMax === "number" && Number.isFinite(contextState.tokensMax)) {
|
|
2469
|
+
context.windowTokens = contextState.tokensMax;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
if (supportsManagedContext) {
|
|
2382
2473
|
context.compact = true;
|
|
2383
2474
|
context.clear = true;
|
|
2384
2475
|
}
|
|
2385
2476
|
context.inject = true;
|
|
2477
|
+
if (supportsManagedContext && options.adapter.compact && options.adapter.compactSupportsInstructions) context.resume = "native";
|
|
2478
|
+
else if (supportsManagedContext && options.adapter.clearContext) context.resume = "clear-inject";
|
|
2386
2479
|
return Object.keys(context).length ? { context } : {};
|
|
2387
2480
|
}
|
|
2388
2481
|
|