@rynx-ai/runtime 0.1.11-beta.25 → 0.1.11-beta.26

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.
@@ -1,4 +1,4 @@
1
- import { codexUserContent } from "../input-resources.js";
1
+ import { codexUserEchoContent } from "../input-resources.js";
2
2
  import { codexResumeTerminalStatus, mapCodexItem, mapCodexNotification, } from "./mapping.js";
3
3
  const MCP_STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated";
4
4
  const MCP_TERMINAL_STATES = new Set(["ready", "failed", "cancelled"]);
@@ -13,14 +13,7 @@ function turnIdFrom(params) {
13
13
  function userMessageContent(item) {
14
14
  if (item.type !== "userMessage")
15
15
  return undefined;
16
- const parts = codexUserContent(item.content);
17
- if (parts.length === 0)
18
- return undefined;
19
- if (parts.every((part) => part.type === "input_text")) {
20
- const text = parts.map((part) => part.text).join("").trim();
21
- return text || undefined;
22
- }
23
- return parts;
16
+ return codexUserEchoContent(item.content);
24
17
  }
25
18
  /** Whether a notification implies the thread is now active (its first turn has
26
19
  * begun, so the rollout exists). Mirrors reference implementation's `_event_indicates_thread_active`. */
@@ -59,6 +52,20 @@ function isTurnScopedMethod(method) {
59
52
  function canRecoverMissedTurnFrom(method) {
60
53
  return method === "item/agentMessage/delta" || method === "item/plan/delta";
61
54
  }
55
+ /** Omnigent forwards these live deltas only for the bridge's exact active
56
+ * Codex turn. Assistant/plan deltas may recover a missed `turn/started` when
57
+ * fully scoped; command output never opens or recovers a Turn. */
58
+ function requiresMatchingActiveTurn(method) {
59
+ return canRecoverMissedTurnFrom(method) || method === "item/commandExecution/outputDelta";
60
+ }
61
+ /** Content Omnigent forwards independently from the active-turn status edge. */
62
+ function isIndependentTurnContent(method) {
63
+ return method === "item/started" ||
64
+ method === "item/reasoning/textDelta" ||
65
+ method === "item/reasoning/summaryTextDelta" ||
66
+ method === "item/fileChange/patchUpdated" ||
67
+ method === "turn/plan/updated";
68
+ }
62
69
  export class CodexSessionForwarder {
63
70
  client;
64
71
  sink;
@@ -87,6 +94,9 @@ export class CodexSessionForwarder {
87
94
  * (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
88
95
  anonCounters = new Map();
89
96
  pendingPlanImplementation = null;
97
+ /** Latest aggregate diff per Turn. Codex republishes the complete diff after
98
+ * every edit; only the terminal snapshot belongs in transcript history. */
99
+ turnDiffByTurn = new Map();
90
100
  replayingBackfill = false;
91
101
  constructor(client, sink, options = {}) {
92
102
  this.client = client;
@@ -122,6 +132,7 @@ export class CodexSessionForwarder {
122
132
  else {
123
133
  this.currentTurnIdValue = null;
124
134
  }
135
+ this.turnDiffByTurn.clear();
125
136
  }
126
137
  /** True while the provider owns an active turn, including the short interval
127
138
  * between injection acceptance and observer confirmation. */
@@ -251,6 +262,7 @@ export class CodexSessionForwarder {
251
262
  }
252
263
  this.currentTurnIdValue = null;
253
264
  this.activeSignaled = false;
265
+ this.turnDiffByTurn.clear();
254
266
  this.currentThreadIdValue = tid;
255
267
  this.sink.onThreadStarted?.(tid, normalizedForkedFromId);
256
268
  }
@@ -289,6 +301,21 @@ export class CodexSessionForwarder {
289
301
  if (method === "queue/status" && this.currentTurnIdValue === null)
290
302
  return;
291
303
  const carriedTurnId = turnIdFrom(params);
304
+ if (method === "turn/diff/updated") {
305
+ if (!carriedTurnId)
306
+ return;
307
+ const update = params;
308
+ const diff = typeof update?.diff === "string"
309
+ ? update.diff
310
+ : typeof update?.unifiedDiff === "string"
311
+ ? update.unifiedDiff
312
+ : "";
313
+ if (diff)
314
+ this.turnDiffByTurn.set(carriedTurnId, diff);
315
+ else
316
+ this.turnDiffByTurn.delete(carriedTurnId);
317
+ return;
318
+ }
292
319
  if (method === "turn/started") {
293
320
  this.beginTurn(carriedTurnId);
294
321
  this.sink.onTurnObserved?.(carriedTurnId);
@@ -296,12 +323,21 @@ export class CodexSessionForwarder {
296
323
  }
297
324
  if (isTerminalTurnMethod(method)) {
298
325
  if (!this.terminalBoundaryMatchesActiveTurn(carriedTurnId, notificationThreadId)) {
326
+ const staleDiff = this.consumeTurnDiff(carriedTurnId);
327
+ if (staleDiff && carriedTurnId) {
328
+ this.sink.onTurnContentEvent?.(carriedTurnId, staleDiff);
329
+ }
299
330
  return;
300
331
  }
301
332
  const hadActiveTurn = this.currentTurnIdValue !== null;
302
333
  const hadOpenResponse = this.turnOpen;
303
334
  const mapped = mapCodexNotification(method, params);
335
+ const terminalTurnId = carriedTurnId ?? this.currentTurnIdValue;
336
+ const turnDiff = this.consumeTurnDiff(terminalTurnId);
304
337
  if (!hadActiveTurn && !hadOpenResponse) {
338
+ if (turnDiff && terminalTurnId) {
339
+ this.sink.onTurnContentEvent?.(terminalTurnId, turnDiff);
340
+ }
305
341
  const turn = params?.turn;
306
342
  const recoveredStatus = mapped.fatalError ||
307
343
  method === "turn/failed" ||
@@ -323,10 +359,18 @@ export class CodexSessionForwarder {
323
359
  this.sink.onEvent(event);
324
360
  }
325
361
  this.scheduleCompletion(mapped.fatalError
326
- ? { kind: "error", error: mapped.fatalError }
362
+ ? { kind: "error", error: mapped.fatalError, ...(turnDiff ? { turnDiff } : {}) }
327
363
  : mapped.turnInterrupted
328
- ? { kind: "interrupted", ...(mapped.usage ? { usage: mapped.usage } : {}) }
329
- : { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
364
+ ? {
365
+ kind: "interrupted",
366
+ ...(mapped.usage ? { usage: mapped.usage } : {}),
367
+ ...(turnDiff ? { turnDiff } : {}),
368
+ }
369
+ : {
370
+ kind: "end",
371
+ ...(mapped.usage ? { usage: mapped.usage } : {}),
372
+ ...(turnDiff ? { turnDiff } : {}),
373
+ });
330
374
  return;
331
375
  }
332
376
  // A late event from an older turn must not replace the app-server's active
@@ -335,32 +379,66 @@ export class CodexSessionForwarder {
335
379
  this.currentTurnIdValue &&
336
380
  carriedTurnId !== this.currentTurnIdValue &&
337
381
  isTurnScopedMethod(method)) {
338
- return;
382
+ // Durable completed items and passive content belong to their carried
383
+ // response even if a newer Turn is active. Only active-turn deltas and
384
+ // lifecycle edges are stale with respect to the newer Turn.
385
+ if (method === "item/completed") {
386
+ // Continue into the completed-item dedup/order path below.
387
+ }
388
+ else if (isIndependentTurnContent(method) && this.sink.onTurnContentEvent) {
389
+ const mapped = mapCodexNotification(method, params);
390
+ for (const event of mapped.events) {
391
+ if (event.type !== "runtime_debug") {
392
+ this.sink.onTurnContentEvent(carriedTurnId, event);
393
+ }
394
+ }
395
+ return;
396
+ }
397
+ else {
398
+ return;
399
+ }
339
400
  }
340
- if (carriedTurnId && this.currentTurnIdValue === null) {
341
- if (canRecoverMissedTurnFrom(method)) {
342
- if (!this.notificationMatchesCurrentThread(notificationThreadId))
401
+ if (requiresMatchingActiveTurn(method)) {
402
+ if (this.currentTurnIdValue !== null) {
403
+ // An id-less delta is not attributable to the active Turn. Omnigent's
404
+ // `_is_active_turn_delta` applies the same exact-id requirement.
405
+ if (carriedTurnId !== this.currentTurnIdValue)
343
406
  return;
407
+ }
408
+ else if (carriedTurnId &&
409
+ canRecoverMissedTurnFrom(method) &&
410
+ this.notificationMatchesCurrentThread(notificationThreadId)) {
344
411
  this.currentTurnIdValue = carriedTurnId;
345
412
  this.ensureTurn();
346
413
  }
414
+ else {
415
+ return;
416
+ }
347
417
  }
348
418
  // Completed items (user echo, assistant, tool, reasoning) go through the
349
419
  // deduped path so a resume backfill and the live stream never double them.
350
420
  if (method === "item/completed") {
351
421
  const item = params?.item;
352
422
  if (item) {
423
+ if (carriedTurnId &&
424
+ this.currentTurnIdValue &&
425
+ carriedTurnId !== this.currentTurnIdValue) {
426
+ // This is durable content for an older response, not an ordering
427
+ // signal for the newer Turn's deferred assistant message.
428
+ this.processCompletedItem(item, carriedTurnId);
429
+ return;
430
+ }
353
431
  if (this.shouldDeferAssistantMessage(item)) {
354
- this.deferAssistantMessage(item);
432
+ this.deferAssistantMessage(item, carriedTurnId);
355
433
  this.refreshCompletionGrace();
356
434
  return;
357
435
  }
358
- // A late reasoning item belongs before the held final answer. Any other
359
- // completed item establishes that the held message was not final and
360
- // must retain its original position.
436
+ // Within one Turn, a late reasoning item belongs before the held final
437
+ // answer. Any other completed item establishes that the held message
438
+ // was not final and must retain its original position.
361
439
  if (item.type !== "reasoning")
362
440
  this.flushDeferredAssistantMessage();
363
- this.processCompletedItem(item);
441
+ this.processCompletedItem(item, carriedTurnId);
364
442
  this.refreshCompletionGrace();
365
443
  return;
366
444
  }
@@ -375,6 +453,19 @@ export class CodexSessionForwarder {
375
453
  if (canonicalEvents.some((event) => event.type !== "reasoning_delta" && event.type !== "reasoning_completed")) {
376
454
  this.flushDeferredAssistantMessage();
377
455
  }
456
+ // Omnigent's item/content channel is independent from its turn-status
457
+ // channel. A scoped item or reasoning delta that arrives after the terminal
458
+ // edge remains visible, but must not synthesize another running response.
459
+ if (!this.turnOpen &&
460
+ this.currentTurnIdValue === null &&
461
+ !mapped.fatalError &&
462
+ canonicalEvents.length > 0 &&
463
+ this.sink.onTurnContentEvent) {
464
+ for (const event of canonicalEvents) {
465
+ this.sink.onTurnContentEvent(carriedTurnId, event);
466
+ }
467
+ return;
468
+ }
378
469
  if (canonicalEvents.length || mapped.turnCompleted || mapped.fatalError) {
379
470
  this.ensureTurn();
380
471
  }
@@ -433,6 +524,10 @@ export class CodexSessionForwarder {
433
524
  if (this.pendingPlanImplementation?.turnId === completedTurnId) {
434
525
  this.pendingPlanImplementation = null;
435
526
  }
527
+ if (completion.turnDiff)
528
+ this.sink.onEvent(completion.turnDiff);
529
+ if (completedTurnId)
530
+ this.turnDiffByTurn.delete(completedTurnId);
436
531
  this.turnOpen = false;
437
532
  this.currentTurnIdValue = null;
438
533
  this.pendingCompletionTurnId = null;
@@ -519,23 +614,30 @@ export class CodexSessionForwarder {
519
614
  }
520
615
  /** Map + emit one completed codex item, deduped by a TOTAL key and routing the
521
616
  * user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
522
- processCompletedItem(item) {
523
- if (!this.claimCompletedItem(item))
617
+ processCompletedItem(item, completedTurnId) {
618
+ if (!this.claimCompletedItem(item, completedTurnId))
524
619
  return;
525
- this.emitCompletedItem(item);
620
+ this.emitCompletedItem(item, completedTurnId);
526
621
  }
527
- claimCompletedItem(item) {
528
- const { key, isAnon } = this.completedItemKey(item);
622
+ claimCompletedItem(item, completedTurnId) {
623
+ const { key, isAnon } = this.completedItemKey(item, completedTurnId);
529
624
  if (this.seenCompletedItems.has(key))
530
625
  return false;
531
626
  this.seenCompletedItems.add(key);
532
627
  if (isAnon)
533
- this.advanceAnonCounter();
628
+ this.advanceAnonCounter(completedTurnId);
534
629
  return true;
535
630
  }
536
- emitCompletedItem(item) {
631
+ emitCompletedItem(item, completedTurnId) {
632
+ const outsideTurnLifecycle = !this.turnOpen || Boolean(completedTurnId &&
633
+ this.currentTurnIdValue &&
634
+ completedTurnId !== this.currentTurnIdValue);
537
635
  const userContent = userMessageContent(item);
538
636
  if (userContent !== undefined) {
637
+ if (outsideTurnLifecycle && this.sink.onTurnContentUserMessage) {
638
+ this.sink.onTurnContentUserMessage(completedTurnId, userContent);
639
+ return;
640
+ }
539
641
  this.ensureTurn();
540
642
  this.sink.onUserMessage?.(userContent);
541
643
  return;
@@ -552,6 +654,12 @@ export class CodexSessionForwarder {
552
654
  const canonicalEvents = mapped.events.filter((event) => event.type !== "runtime_debug");
553
655
  if (canonicalEvents.length === 0)
554
656
  return;
657
+ if (outsideTurnLifecycle && this.sink.onTurnContentEvent) {
658
+ for (const event of canonicalEvents) {
659
+ this.sink.onTurnContentEvent(completedTurnId, event);
660
+ }
661
+ return;
662
+ }
555
663
  this.ensureTurn();
556
664
  for (const event of canonicalEvents)
557
665
  this.sink.onEvent(event);
@@ -562,11 +670,11 @@ export class CodexSessionForwarder {
562
670
  }
563
671
  return Boolean(item.text?.trim());
564
672
  }
565
- deferAssistantMessage(item) {
566
- if (!this.claimCompletedItem(item))
673
+ deferAssistantMessage(item, turnId) {
674
+ if (!this.claimCompletedItem(item, turnId))
567
675
  return;
568
676
  this.flushDeferredAssistantMessage();
569
- this.deferredAssistantMessage = item;
677
+ this.deferredAssistantMessage = { item, ...(turnId ? { turnId } : {}) };
570
678
  const graceMs = this.options.assistantMessageGraceMs ?? 0;
571
679
  this.assistantMessageTimer = setTimeout(() => this.flushDeferredAssistantMessage(), graceMs);
572
680
  this.assistantMessageTimer.unref?.();
@@ -575,26 +683,26 @@ export class CodexSessionForwarder {
575
683
  if (this.assistantMessageTimer)
576
684
  clearTimeout(this.assistantMessageTimer);
577
685
  this.assistantMessageTimer = null;
578
- const item = this.deferredAssistantMessage;
686
+ const deferred = this.deferredAssistantMessage;
579
687
  this.deferredAssistantMessage = null;
580
- if (item)
581
- this.emitCompletedItem(item);
688
+ if (deferred)
689
+ this.emitCompletedItem(deferred.item, deferred.turnId);
582
690
  }
583
691
  /** Build a total dedup key for one completed item. Stable-id items use
584
692
  * `threadId:turnId:item.id` — identical across replay + live, so the second
585
693
  * delivery is dropped. Items without a codex id fall back to a per-(thread,turn)
586
694
  * position counter (peeked here; advanced only on a successful claim, via
587
695
  * {@link advanceAnonCounter}). Mirrors reference implementation `_completed_item_key`. */
588
- completedItemKey(item) {
696
+ completedItemKey(item, completedTurnId) {
589
697
  const threadId = this.currentThreadIdValue ?? "thread";
590
- const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
698
+ const turnId = completedTurnId ?? this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
591
699
  if (item.id)
592
700
  return { key: `${threadId}:${turnId}:${item.id}`, isAnon: false };
593
701
  const n = this.anonCounters.get(`${threadId}:${turnId}`) ?? 0;
594
702
  return { key: `${threadId}:${turnId}:anon:${n}`, isAnon: true };
595
703
  }
596
- advanceAnonCounter() {
597
- const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
704
+ advanceAnonCounter(completedTurnId) {
705
+ const turnId = completedTurnId ?? this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
598
706
  const k = `${this.currentThreadIdValue ?? "thread"}:${turnId}`;
599
707
  this.anonCounters.set(k, (this.anonCounters.get(k) ?? 0) + 1);
600
708
  }
@@ -604,6 +712,15 @@ export class CodexSessionForwarder {
604
712
  this.sink.onTurnStart(this.currentTurnIdValue ?? undefined);
605
713
  }
606
714
  }
715
+ consumeTurnDiff(turnId) {
716
+ if (!turnId)
717
+ return undefined;
718
+ const diff = this.turnDiffByTurn.get(turnId);
719
+ this.turnDiffByTurn.delete(turnId);
720
+ return diff
721
+ ? { type: "turn_diff", callId: `codex_turn_diff_${turnId}`, diff }
722
+ : undefined;
723
+ }
607
724
  /** Start (or confirm) the app-server's authoritative active turn. A newer
608
725
  * start supersedes an older response whose terminal edge arrived late; a
609
726
  * pending Traex completion is flushed first so its final item grace remains
@@ -1,9 +1,3 @@
1
- /**
2
- * Pure codex app-server notification → {@link AgentEvent} mapping. Extracted from
3
- * the executor so BOTH the per-turn executor and the persistent session
4
- * forwarder (which mirrors every thread turn — web- AND TUI-initiated — into the
5
- * canonical log, reference implementation's codex-native model) share one mapping.
6
- */
7
1
  import type { AgentEvent } from "@rynx-ai/core";
8
2
  import type { ThreadItem } from "./protocol.js";
9
3
  export interface CodexMapResult {
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Pure codex app-server notification → {@link AgentEvent} mapping. Extracted from
3
+ * the executor so BOTH the per-turn executor and the persistent session
4
+ * forwarder (which mirrors every thread turn — web- AND TUI-initiated — into the
5
+ * canonical log, reference implementation's codex-native model) share one mapping.
6
+ */
7
+ import { extname, isAbsolute } from "node:path";
1
8
  function isRecord(value) {
2
9
  return value !== null && typeof value === "object" && !Array.isArray(value);
3
10
  }
@@ -115,6 +122,23 @@ function webSearchInput(item) {
115
122
  }
116
123
  return { id: item.id, query: item.query };
117
124
  }
125
+ function fileChangeSummary(changes) {
126
+ const lines = [];
127
+ for (const change of changes) {
128
+ if (!isRecord(change))
129
+ continue;
130
+ const kind = isRecord(change.kind) && typeof change.kind.type === "string" && change.kind.type
131
+ ? change.kind.type
132
+ : "change";
133
+ lines.push(`${kind} ${String(change.path)}`);
134
+ }
135
+ return lines.join("\n");
136
+ }
137
+ function isSupportedAbsoluteImagePath(path) {
138
+ if (!isAbsolute(path))
139
+ return false;
140
+ return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"].includes(extname(path).toLowerCase());
141
+ }
118
142
  /** Map one thread item (`item/started` | `item/completed`) to events. */
119
143
  export function mapCodexItem(method, item) {
120
144
  const events = [];
@@ -144,16 +168,18 @@ export function mapCodexItem(method, item) {
144
168
  return { events };
145
169
  }
146
170
  case "fileChange": {
171
+ const fileChange = item;
172
+ const changes = Array.isArray(fileChange.changes) ? fileChange.changes : [];
147
173
  events.push({
148
174
  type: "tool",
149
175
  event: isStart ? "on_tool_start" : "on_tool_end",
150
- name: "file_change",
151
- input: isStart ? { id: item.id } : undefined,
176
+ name: "apply_patch",
177
+ input: isStart || isEnd ? { id: item.id, ...(changes.length ? { changes } : {}) } : undefined,
152
178
  output: isEnd
153
179
  ? {
154
180
  id: item.id,
155
- changes: item.changes,
156
- status: item.status,
181
+ status: fileChange.status,
182
+ aggregatedOutput: fileChangeSummary(changes),
157
183
  }
158
184
  : undefined,
159
185
  data: { method, item },
@@ -199,6 +225,30 @@ export function mapCodexItem(method, item) {
199
225
  });
200
226
  return { events };
201
227
  }
228
+ case "imageGeneration": {
229
+ const image = item;
230
+ events.push({
231
+ type: "tool",
232
+ event: isStart ? "on_tool_start" : "on_tool_end",
233
+ name: "image_generation",
234
+ input: isStart ? { id: image.id } : undefined,
235
+ output: isEnd
236
+ ? {
237
+ id: image.id,
238
+ status: image.status,
239
+ generatedImage: {
240
+ ...(!image.savedPath || !isSupportedAbsoluteImagePath(image.savedPath)
241
+ ? { result: image.result }
242
+ : {}),
243
+ revisedPrompt: image.revisedPrompt,
244
+ ...(image.savedPath ? { savedPath: image.savedPath } : {}),
245
+ },
246
+ }
247
+ : undefined,
248
+ data: { method, item: { ...image, result: image.result ? "[image data omitted]" : "" } },
249
+ });
250
+ return { events };
251
+ }
202
252
  case "agentMessage": {
203
253
  const text = item.text?.trim() ?? "";
204
254
  if (isEnd && text) {
@@ -267,7 +267,16 @@ export interface WebSearchItem extends ThreadItemBase {
267
267
  type: "other";
268
268
  } | null;
269
269
  }
270
- export type ThreadItem = UserMessageItem | AgentMessageItem | ReasoningItem | PlanItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | DynamicToolCallItem | WebSearchItem | (ThreadItemBase & Record<string, unknown>);
270
+ export interface ImageGenerationItem extends ThreadItemBase {
271
+ type: "imageGeneration";
272
+ status: string;
273
+ revisedPrompt: string | null;
274
+ /** Base64 image bytes supplied when no durable saved path is available. */
275
+ result: string;
276
+ /** Codex App Server guarantees this is absolute when present. */
277
+ savedPath?: string;
278
+ }
279
+ export type ThreadItem = UserMessageItem | AgentMessageItem | ReasoningItem | PlanItem | CommandExecutionItem | FileChangeItem | McpToolCallItem | DynamicToolCallItem | WebSearchItem | ImageGenerationItem | (ThreadItemBase & Record<string, unknown>);
271
280
  export interface ThreadSummary {
272
281
  id?: string;
273
282
  threadId?: string;
package/dist/host.js CHANGED
@@ -21,7 +21,7 @@ import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
21
21
  import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
22
22
  import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
23
23
  import { ensureProjectTrusted } from "./claude/trust.js";
24
- import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
24
+ import { claudeAttachmentToken, claudeInputText, codexUserEchoContent, runtimeUserContent, } from "./input-resources.js";
25
25
  import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
26
26
  import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
27
27
  import { buildClaudeHookSettings } from "./claude/native-hooks.js";
@@ -847,6 +847,9 @@ export class LocalAgentHost {
847
847
  };
848
848
  let normalizer = null;
849
849
  let currentResponseId = null;
850
+ /** State for content-only events, keyed by Provider response so consecutive
851
+ * late deltas retain one canonical item id without owning lifecycle. */
852
+ const contentNormalizers = new Map();
850
853
  const startNormalizer = (turnId) => {
851
854
  const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
852
855
  if (normalizer && currentResponseId === responseId)
@@ -892,6 +895,40 @@ export class LocalAgentHost {
892
895
  return;
893
896
  live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== responseId);
894
897
  };
898
+ const turnContentEvents = (turnId, produce) => {
899
+ const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
900
+ let contentNormalizer = contentNormalizers.get(responseId);
901
+ if (!contentNormalizer) {
902
+ contentNormalizer = new SessionNormalizer({
903
+ sessionId: currentSessionId,
904
+ responseId,
905
+ model: live.model || live.runtime,
906
+ });
907
+ contentNormalizers.set(responseId, contentNormalizer);
908
+ }
909
+ for (const event of produce(contentNormalizer)) {
910
+ // Omnigent posts item/transient content under the Turn's response id
911
+ // independently of lifecycle status. Reuse Rynx's canonical item
912
+ // normalization but suppress its synthetic lifecycle edges.
913
+ if (event.type !== "response.created" && event.type !== "session.status") {
914
+ emitCurrent(event);
915
+ }
916
+ }
917
+ };
918
+ const observedUserContent = (content) => {
919
+ const normalizedContent = typeof content === "string"
920
+ ? [{ type: "input_text", text: content }]
921
+ : content;
922
+ const signature = JSON.stringify(normalizedContent);
923
+ const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature || entry.providerEchoSignature === signature);
924
+ if (pending?.state === "optimistic") {
925
+ live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
926
+ return null;
927
+ }
928
+ if (pending)
929
+ pending.observed = true;
930
+ return normalizedContent;
931
+ };
895
932
  const rememberSettledInteraction = (interactionId) => {
896
933
  live.settledInteractions.add(interactionId);
897
934
  if (live.settledInteractions.size <= MAX_CODEX_SETTLED_INTERACTIONS)
@@ -1186,26 +1223,27 @@ export class LocalAgentHost {
1186
1223
  }
1187
1224
  },
1188
1225
  onUserMessage: (content) => {
1189
- const normalizedContent = typeof content === "string"
1190
- ? [{ type: "input_text", text: content }]
1191
- : content;
1192
- const signature = JSON.stringify(normalizedContent);
1193
- const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
1194
- if (pending?.state === "optimistic") {
1195
- live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
1226
+ const normalizedContent = observedUserContent(content);
1227
+ if (!normalizedContent)
1196
1228
  return;
1197
- }
1198
- if (pending)
1199
- pending.observed = true;
1200
1229
  const n = normalizer ?? startNormalizer();
1201
1230
  for (const se of n.userInput(normalizedContent))
1202
1231
  emitCurrent(se);
1203
1232
  },
1233
+ onTurnContentUserMessage: (turnId, content) => {
1234
+ const normalizedContent = observedUserContent(content);
1235
+ if (!normalizedContent)
1236
+ return;
1237
+ turnContentEvents(turnId, (n) => n.userInput(normalizedContent));
1238
+ },
1204
1239
  onEvent: (event) => {
1205
1240
  const n = normalizer ?? startNormalizer();
1206
1241
  for (const se of n.next(event))
1207
1242
  emitCurrent(se);
1208
1243
  },
1244
+ onTurnContentEvent: (turnId, event) => {
1245
+ turnContentEvents(turnId, (n) => n.next(event));
1246
+ },
1209
1247
  onStatus: (note, statusKind) => {
1210
1248
  const responseId = currentResponseId ??
1211
1249
  live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
@@ -1286,6 +1324,7 @@ export class LocalAgentHost {
1286
1324
  currentSessionId = newSessionId;
1287
1325
  normalizer = null;
1288
1326
  currentResponseId = null;
1327
+ contentNormalizers.clear();
1289
1328
  live.pendingInjectedInputs = [];
1290
1329
  pendingRotationEvents = [];
1291
1330
  void this.sessionStore
@@ -1739,9 +1778,16 @@ export class LocalAgentHost {
1739
1778
  return { outcome: "notReady" };
1740
1779
  const nativeInput = buildRuntimeUserInput(runtimeInput);
1741
1780
  const content = runtimeUserContent(runtimeInput);
1781
+ const providerEcho = codexUserEchoContent(nativeInput);
1782
+ const providerEchoContent = typeof providerEcho === "string"
1783
+ ? [{ type: "input_text", text: providerEcho }]
1784
+ : providerEcho;
1742
1785
  const pendingInput = {
1743
1786
  content,
1744
1787
  signature: JSON.stringify(content),
1788
+ ...(providerEchoContent
1789
+ ? { providerEchoSignature: JSON.stringify(providerEchoContent) }
1790
+ : {}),
1745
1791
  state: "awaiting",
1746
1792
  observed: false,
1747
1793
  };
@@ -2315,7 +2361,7 @@ export class LocalAgentHost {
2315
2361
  responseId,
2316
2362
  });
2317
2363
  };
2318
- const settleClaudeTurn = (interrupted, usage) => {
2364
+ const settleClaudeTurn = (interrupted, usage, backgroundTaskCount) => {
2319
2365
  if (!normalizer)
2320
2366
  return;
2321
2367
  const rid = currentResponseId;
@@ -2334,8 +2380,11 @@ export class LocalAgentHost {
2334
2380
  for (const se of normalizer.next({ type: "turn_completed", usage }))
2335
2381
  emitCurrent(se);
2336
2382
  }
2337
- for (const se of normalizer.next({ type: "done" }))
2338
- emitCurrent(se);
2383
+ for (const se of normalizer.next({ type: "done" })) {
2384
+ emitCurrent(se.type === "session.status" && backgroundTaskCount !== undefined
2385
+ ? { ...se, backgroundTaskCount }
2386
+ : se);
2387
+ }
2339
2388
  }
2340
2389
  live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
2341
2390
  normalizer = null;
@@ -2398,30 +2447,42 @@ export class LocalAgentHost {
2398
2447
  ...(blockedOn ? { note: blockedOn } : {}),
2399
2448
  });
2400
2449
  },
2401
- onTurnEnd: (usage) => settleClaudeTurn(false, usage),
2450
+ onTurnEnd: (usage, backgroundTaskCount) => settleClaudeTurn(false, usage, backgroundTaskCount),
2402
2451
  onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
2403
2452
  onTurnInterruptRequested: () => {
2404
2453
  const responseId = currentResponseId;
2405
2454
  if (responseId)
2406
2455
  live.publishInterrupted(responseId);
2407
2456
  },
2408
- onIdle: () => {
2409
- // Surface idle on the current turn WITHOUT finalizing it (see the sink's
2410
- // onIdle doc): a late assistant record still joins this response.
2411
- if (currentResponseId) {
2457
+ onIdle: (backgroundTaskCount) => {
2458
+ // Surface Stop even after its Turn has already closed: a later
2459
+ // authoritative zero is what clears a sticky background-shell tally.
2460
+ // When the Turn is still open, retain its identity without finalizing it
2461
+ // so a late assistant record continues to join the same Response.
2462
+ emitCurrent({
2463
+ type: "session.status",
2464
+ sessionId: currentSessionId,
2465
+ ...(currentResponseId ? { responseId: currentResponseId } : {}),
2466
+ status: "idle",
2467
+ ...(backgroundTaskCount === undefined ? {} : { backgroundTaskCount }),
2468
+ });
2469
+ },
2470
+ onTurnError: (error) => {
2471
+ const message = error.message || "Agent turn failed";
2472
+ if (!normalizer) {
2473
+ // Native pane/forwarder death is Session-level even when Claude is
2474
+ // between Turns. Omnigent publishes the same bare failed edge; the
2475
+ // explicit zero also retires any sticky background-shell tally.
2412
2476
  emitCurrent({
2413
2477
  type: "session.status",
2414
2478
  sessionId: currentSessionId,
2415
- responseId: currentResponseId,
2416
- status: "idle",
2479
+ status: "failed",
2480
+ backgroundTaskCount: 0,
2481
+ note: message,
2417
2482
  });
2418
- }
2419
- },
2420
- onTurnError: (error) => {
2421
- if (!normalizer)
2422
2483
  return;
2484
+ }
2423
2485
  const rid = currentResponseId;
2424
- const message = error.message || "Agent turn failed";
2425
2486
  for (const se of normalizer.fail({
2426
2487
  code: "agent_error",
2427
2488
  message,