agent-relay-runner 0.39.0 → 0.40.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.39.0",
3
+ "version": "0.40.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.24"
23
+ "agent-relay-sdk": "0.2.25"
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.39.0",
4
+ "version": "0.40.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
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 {
@@ -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
- const buffered = itemId ? this.itemTextBuffers.get(itemId) : undefined;
499
- const text = (codexReasoningText(item) || buffered || "").trim();
500
- if (text) this.sessionEventCb({ type: "reasoning", origin: "provider", body: text, ...(turnId ? { turnId } : {}) });
501
- if (itemId) this.itemTextBuffers.delete(itemId);
502
- if (itemId) this.itemTextBufferTypes.delete(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
- this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "completed", ...(turnId ? { turnId } : {}) });
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
- if (tool) this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "running", streaming: true, ...(turnId ? { turnId } : {}) });
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
@@ -251,6 +251,12 @@ export class AgentRunner {
251
251
  // its final response. Set when a provider-turn starts, cleared when it ends.
252
252
  private currentTurnId?: string;
253
253
  private currentTurnStartedAt?: number;
254
+ // True while a turn that was already in flight is being compacted. Claude's PostCompact
255
+ // hook posts a single `idle`, but a mid-turn compaction RESUMES the turn afterward — so
256
+ // treating that idle as a turn end (flip idle, kill the reasoning tail) makes chat go dark
257
+ // until the next prompt. Set when `compacting` arrives with a turn running; cleared on the
258
+ // genuine end (a plain idle with no compaction timeline).
259
+ private compactionMidTurn = false;
254
260
  // Prompt-echo dedup: a short, time-bounded queue of prompts the runner itself
255
261
  // injected (chat box or initial prompt) that are still awaiting their matching
256
262
  // UserPromptSubmit echo. A single slot dropped earlier entries when several prompts
@@ -1112,10 +1118,28 @@ export class AgentRunner {
1112
1118
  } else if (status === "idle" || status === "busy") {
1113
1119
  this.terminalFailure = undefined;
1114
1120
  }
1121
+ // Compaction lifecycle (Claude PreCompact→`compacting` busy / PostCompact→`compacted`
1122
+ // idle). A `compacted` idle for a turn that predated compaction is a hook artifact — the
1123
+ // turn resumes — so it must NOT end the turn. Discriminate on whether a turn was running,
1124
+ // captured BEFORE the busy logic below mints a currentTurnId (it does for /compact at idle).
1125
+ const timelineStatus = typeof update !== "string" ? update.timeline?.status : undefined;
1126
+ if (timelineStatus === "compacting") {
1127
+ this.compactionMidTurn = this.currentTurnId !== undefined;
1128
+ } else if (timelineStatus === "compacted") {
1129
+ this.publishCompactionNotice();
1130
+ if (this.compactionMidTurn) {
1131
+ // Keep the turn (and its live reasoning tail) alive; the genuine Stop hook ends it.
1132
+ // pendingTimelineEvent (the marker) was set above and is consumed by publishStatus.
1133
+ this.sessionLog(`compaction completed mid-turn (turn ${this.currentTurnId ?? "?"}) — staying busy`);
1134
+ this.publishStatus();
1135
+ return;
1136
+ }
1137
+ }
1115
1138
  if (status === "busy" && reason === "provider-turn") {
1116
1139
  if (!this.currentTurnId) {
1117
1140
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1118
1141
  this.currentTurnStartedAt = Date.now();
1142
+ this.compactionMidTurn = false;
1119
1143
  this.sessionLog(`turn started (turn ${this.currentTurnId})`);
1120
1144
  }
1121
1145
  this.armBusyReconciler();
@@ -1123,6 +1147,7 @@ export class AgentRunner {
1123
1147
  if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
1124
1148
  this.currentTurnId = undefined;
1125
1149
  this.currentTurnStartedAt = undefined;
1150
+ this.compactionMidTurn = false;
1126
1151
  this.disarmBusyReconciler();
1127
1152
  this.stopReasoningTail();
1128
1153
  }
@@ -1239,8 +1264,12 @@ export class AgentRunner {
1239
1264
  // retried until it lands. occurredAt is stamped now so a queued event reports when it
1240
1265
  // truly happened, not when the server finally accepted it. Routed through the fast-lane
1241
1266
  // sessionOutbox (#332) so a transient trace failure can't head-of-line block real messages.
1267
+ // A stepId-bearing step (Codex tool running→completed, streamed reasoning/response) uses a
1268
+ // STABLE idempotency key so the server upserts the row in place instead of appending a dup.
1269
+ const stepId = input.session.stepId;
1242
1270
  this.sessionOutbox.enqueue({
1243
1271
  kind: "session-message",
1272
+ ...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
1244
1273
  payload: {
1245
1274
  from: input.from,
1246
1275
  to: input.to,
@@ -1534,6 +1563,7 @@ export class AgentRunner {
1534
1563
  ...(event.label ? { label: event.label } : {}),
1535
1564
  ...(event.status ? { status: event.status } : {}),
1536
1565
  ...(event.streaming !== undefined ? { streaming: event.streaming } : {}),
1566
+ ...(event.stepId ? { stepId: event.stepId } : {}),
1537
1567
  },
1538
1568
  });
1539
1569
  }
@@ -1617,6 +1647,7 @@ export class AgentRunner {
1617
1647
  this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
1618
1648
  this.claims.clearWorkKind("provider-turn");
1619
1649
  this.currentTurnId = undefined;
1650
+ this.compactionMidTurn = false;
1620
1651
  this.publishStatus();
1621
1652
  }
1622
1653
 
@@ -1697,6 +1728,18 @@ export class AgentRunner {
1697
1728
  this.reasoningTail = undefined;
1698
1729
  }
1699
1730
 
1731
+ // Mirror a discreet, durable "context compacted" marker into chat via the existing
1732
+ // session-mirror lane (not a parallel channel). `notice` renders as an inline timeline
1733
+ // marker (never a bubble) and survives reload, unlike the ephemeral timeline-status one.
1734
+ private publishCompactionNotice(): void {
1735
+ this.publishSessionEvent({
1736
+ from: this.agentId,
1737
+ to: "user",
1738
+ body: "🗜 Context compacted",
1739
+ session: { type: "notice", origin: "provider", label: "compacted", ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}) },
1740
+ });
1741
+ }
1742
+
1700
1743
  private publishStatus(): void {
1701
1744
  this.claims.expire();
1702
1745
  const status = this.claims.currentStatus();