@opengeni/react 0.12.0 → 0.15.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.
Files changed (94) hide show
  1. package/README.md +20 -14
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +1040 -363
  5. package/dist/index.js +10938 -6281
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +20 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +41 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +417 -69
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +93 -15
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-health-pill.tsx +68 -0
  27. package/src/components/machine-metrics.tsx +28 -19
  28. package/src/components/machine-status-pill.tsx +26 -5
  29. package/src/components/machines/health.ts +146 -0
  30. package/src/components/machines/machine-detail.tsx +220 -0
  31. package/src/components/machines/metric-history-chart.tsx +298 -0
  32. package/src/components/machines/metric-sparkline.tsx +76 -0
  33. package/src/components/machines/series.ts +113 -0
  34. package/src/components/machines-dashboard.tsx +21 -3
  35. package/src/components/markdown.tsx +39 -9
  36. package/src/components/message-timeline.tsx +633 -109
  37. package/src/components/pierre-diff.tsx +83 -15
  38. package/src/components/pierre-file.tsx +10 -9
  39. package/src/components/queue-surface.tsx +578 -0
  40. package/src/components/sandbox-files.tsx +193 -225
  41. package/src/components/sandbox-terminal.tsx +256 -88
  42. package/src/components/sandbox-workspace.tsx +957 -0
  43. package/src/components/session-status.tsx +22 -2
  44. package/src/components/workbench-changes.tsx +585 -0
  45. package/src/components/workspace-dock.tsx +213 -59
  46. package/src/hooks/internal.ts +5 -2
  47. package/src/hooks/use-available-models.ts +7 -2
  48. package/src/hooks/use-billing-usage.ts +4 -1
  49. package/src/hooks/use-codex-accounts.ts +74 -44
  50. package/src/hooks/use-composer.ts +414 -63
  51. package/src/hooks/use-environments.ts +92 -47
  52. package/src/hooks/use-file-attachments.ts +101 -55
  53. package/src/hooks/use-goal.ts +52 -16
  54. package/src/hooks/use-machine-chip.ts +134 -0
  55. package/src/hooks/use-machines.ts +34 -16
  56. package/src/hooks/use-packs.ts +24 -19
  57. package/src/hooks/use-relay-frame-stream.ts +5 -2
  58. package/src/hooks/use-rigs.ts +335 -0
  59. package/src/hooks/use-sandbox-files.ts +213 -39
  60. package/src/hooks/use-sandbox-git.ts +188 -11
  61. package/src/hooks/use-sandbox-terminal.ts +18 -14
  62. package/src/hooks/use-scheduled-tasks.ts +10 -2
  63. package/src/hooks/use-session-capabilities.ts +77 -20
  64. package/src/hooks/use-session-control.ts +50 -23
  65. package/src/hooks/use-session-events.ts +50 -21
  66. package/src/hooks/use-session-lineage.ts +128 -0
  67. package/src/hooks/use-session.ts +53 -30
  68. package/src/hooks/use-slash-commands.ts +6 -4
  69. package/src/hooks/use-turn-queue.ts +202 -147
  70. package/src/hooks/use-windowed-sections.ts +204 -0
  71. package/src/hooks/use-workspace-capture.ts +233 -0
  72. package/src/hooks/use-workspace-edit.ts +231 -0
  73. package/src/hooks/use-workspace-sessions.ts +48 -5
  74. package/src/hooks/use-workspaces.ts +18 -15
  75. package/src/index.ts +133 -23
  76. package/src/lib/format.ts +3 -3
  77. package/src/lib/xterm-renderer.ts +65 -0
  78. package/src/lib/xterm-theme.ts +224 -18
  79. package/src/machines.ts +29 -3
  80. package/src/provider.tsx +195 -6
  81. package/src/timeline/activity-rail.tsx +213 -54
  82. package/src/timeline/disclosure-context.tsx +12 -2
  83. package/src/timeline/index.ts +21 -2
  84. package/src/timeline/parsers.ts +87 -16
  85. package/src/timeline/projection.ts +412 -87
  86. package/src/timeline/shared.tsx +111 -24
  87. package/src/timeline/tool-diff.tsx +24 -20
  88. package/src/timeline/tool-renderers.tsx +98 -21
  89. package/src/timeline/turn-summary.tsx +76 -23
  90. package/src/timeline/types.ts +96 -12
  91. package/styles/index.css +22 -0
  92. package/styles/tokens.css +2 -2
  93. package/dist/chunk-5TIXRCSI.js +0 -1291
  94. package/dist/chunk-5TIXRCSI.js.map +0 -1
@@ -1,9 +1,16 @@
1
1
  import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
- import { CREDIT_EXHAUSTION_MESSAGE, humanizeFailureReason, isCreditExhaustion, tryParseJson } from "../lib/format";
2
+ import {
3
+ CREDIT_EXHAUSTION_MESSAGE,
4
+ humanizeFailureReason,
5
+ isCreditExhaustion,
6
+ tryParseJson,
7
+ } from "../lib/format";
3
8
  import type {
4
9
  AgentMessageItem,
5
10
  ActivityItem,
11
+ AuthNeededItem,
6
12
  GoalItem,
13
+ MemoryItem,
7
14
  SandboxItem,
8
15
  SessionStatusItem,
9
16
  TimelineGroup,
@@ -30,7 +37,6 @@ import type {
30
37
  /** Tool names on the first-party OpenGeni MCP server that operate on sessions. */
31
38
  const WORKER_SPAWN_TOOL = "session_create";
32
39
  const WORKER_MESSAGE_TOOL = "session_send_message";
33
- const WORKER_INTERRUPT_TOOL = "session_interrupt";
34
40
 
35
41
  export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
36
42
  const items: TimelineItem[] = [];
@@ -47,9 +53,18 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
47
53
  }
48
54
  };
49
55
 
50
- const finalizeOpen = (turnId?: string | null, disposition: "complete" | "failed" | "cancelled" = "complete"): void => {
56
+ const finalizeOpen = (
57
+ turnId?: string | null,
58
+ disposition: "complete" | "failed" | "cancelled" = "complete",
59
+ ): void => {
51
60
  for (const item of items) {
52
- if (turnId !== undefined && "turnId" in item && item.turnId && turnId && item.turnId !== turnId) {
61
+ if (
62
+ turnId !== undefined &&
63
+ "turnId" in item &&
64
+ item.turnId &&
65
+ turnId &&
66
+ item.turnId !== turnId
67
+ ) {
53
68
  continue;
54
69
  }
55
70
  if ((item.kind === "agent-message" || item.kind === "reasoning") && item.streaming) {
@@ -73,11 +88,27 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
73
88
  // A steering message must not mark in-flight tools complete; it only
74
89
  // ends whatever text was streaming. Turn lifecycle events finalize.
75
90
  closeStreamingTail();
91
+ const childCompletion = workerCompletionPayload(payload.childCompletion);
92
+ if (childCompletion) {
93
+ items.push({
94
+ kind: "worker-completion",
95
+ id: event.id,
96
+ turnId,
97
+ occurredAt: event.occurredAt,
98
+ childSessionId: childCompletion.childSessionId,
99
+ childStatus: childCompletion.childStatus,
100
+ goalStatus: childCompletion.goalStatus,
101
+ goalText: childCompletion.goalText,
102
+ evidence: childCompletion.evidence,
103
+ pausedReason: childCompletion.pausedReason,
104
+ text: typeof payload.text === "string" ? payload.text : "",
105
+ });
106
+ break;
107
+ }
76
108
  items.push({
77
109
  kind: "user-message",
78
110
  id: event.id,
79
111
  text: typeof payload.text === "string" ? payload.text : "",
80
- ...(event.pendingUserMessage ? { pending: true } : {}),
81
112
  resources: resourceRefs(payload.resources),
82
113
  tools: toolRefs(payload.tools),
83
114
  occurredAt: event.occurredAt,
@@ -114,8 +145,14 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
114
145
  // completed text never duplicates the streamed one.
115
146
  const open = [...items]
116
147
  .reverse()
117
- .find((item): item is AgentMessageItem => item.kind === "agent-message" && item.turnId === turnId);
118
- if (open && (open.streaming || !open.text || text === open.text || text.startsWith(open.text))) {
148
+ .find(
149
+ (item): item is AgentMessageItem =>
150
+ item.kind === "agent-message" && item.turnId === turnId,
151
+ );
152
+ if (
153
+ open &&
154
+ (open.streaming || !open.text || text === open.text || text.startsWith(open.text))
155
+ ) {
119
156
  // The completed text is authoritative when it extends what streamed.
120
157
  if (!open.text || (text && text.startsWith(open.text))) {
121
158
  open.text = text || open.text;
@@ -163,23 +200,6 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
163
200
  const callId = typeof payload.id === "string" ? payload.id : null;
164
201
  const args = payload.arguments ?? null;
165
202
  closeStreamingTail();
166
- if (name === WORKER_INTERRUPT_TOOL) {
167
- // stop (default) vs steer — the target keeps its goal on steer and
168
- // picks up its next queued turn (pair with session_send_message).
169
- items.push({
170
- kind: "worker",
171
- id: event.id,
172
- turnId,
173
- callId,
174
- action: "interrupt",
175
- prompt: null,
176
- workerSessionId: extractSessionRef(args),
177
- mode: workerInterruptMode(args),
178
- status: "running",
179
- occurredAt: event.occurredAt,
180
- });
181
- break;
182
- }
183
203
  if (name === WORKER_SPAWN_TOOL || name === WORKER_MESSAGE_TOOL) {
184
204
  items.push({
185
205
  kind: "worker",
@@ -235,10 +255,37 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
235
255
  case "sandbox.operation.completed":
236
256
  case "sandbox.operation.failed": {
237
257
  const name = typeof payload.name === "string" ? payload.name : "sandbox";
238
- const status = event.type.endsWith(".failed") ? "failed" : event.type.endsWith(".completed") ? "complete" : "running";
258
+ const status = event.type.endsWith(".failed")
259
+ ? "failed"
260
+ : event.type.endsWith(".completed")
261
+ ? "complete"
262
+ : "running";
263
+ // Routine per-turn platform plumbing that runs before EVERY turn to
264
+ // guarantee box contents survive a re-warm — NOT the agent redoing work:
265
+ // - repository-clone: idempotent clone check + off-manifest token re-seed;
266
+ // - file-resource-download: idempotent `if [ ! -f ] then curl` (skips
267
+ // when the attached file is already on the box — see
268
+ // sandboxFileDownloadCommand), so an uploaded image is not re-fetched;
269
+ // the operation still emits every turn even when it does nothing.
270
+ // Rendering either every turn reads as churn. Only FAILURES surface, and
271
+ // they surface loudly — the failed event below creates its own item even
272
+ // without a started row.
273
+ if (
274
+ (name === "repository-clone" || name === "file-resource-download") &&
275
+ status !== "failed"
276
+ ) {
277
+ break;
278
+ }
239
279
  const existing = findOpenSandbox(items, name);
240
280
  if (existing && status !== "running") {
241
281
  existing.status = status;
282
+ if (
283
+ payload.origin === "created" ||
284
+ payload.origin === "restored" ||
285
+ payload.origin === "resumed"
286
+ ) {
287
+ existing.origin = payload.origin;
288
+ }
242
289
  const message = failureMessage(payload);
243
290
  if (message) {
244
291
  existing.output = existing.output ? `${existing.output}\n${message}` : message;
@@ -254,6 +301,12 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
254
301
  name,
255
302
  command: typeof payload.command === "string" ? payload.command : null,
256
303
  output: failureMessage(payload) ?? "",
304
+ origin:
305
+ payload.origin === "created" ||
306
+ payload.origin === "restored" ||
307
+ payload.origin === "resumed"
308
+ ? payload.origin
309
+ : null,
257
310
  status,
258
311
  occurredAt: event.occurredAt,
259
312
  });
@@ -263,9 +316,14 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
263
316
 
264
317
  case "sandbox.command.output.delta": {
265
318
  // `chunk` is the canonical wire field; text/output are legacy shapes.
266
- const text = typeof payload.chunk === "string"
267
- ? payload.chunk
268
- : typeof payload.text === "string" ? payload.text : typeof payload.output === "string" ? payload.output : "";
319
+ const text =
320
+ typeof payload.chunk === "string"
321
+ ? payload.chunk
322
+ : typeof payload.text === "string"
323
+ ? payload.text
324
+ : typeof payload.output === "string"
325
+ ? payload.output
326
+ : "";
269
327
  if (!text) {
270
328
  break;
271
329
  }
@@ -273,7 +331,11 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
273
331
  // otherwise the latest running operation is the best available owner.
274
332
  const open =
275
333
  (typeof payload.name === "string" ? findOpenSandbox(items, payload.name) : undefined) ??
276
- [...items].reverse().find((item): item is SandboxItem => item.kind === "sandbox" && item.status === "running");
334
+ [...items]
335
+ .reverse()
336
+ .find(
337
+ (item): item is SandboxItem => item.kind === "sandbox" && item.status === "running",
338
+ );
277
339
  if (open) {
278
340
  open.output += text;
279
341
  }
@@ -293,7 +355,9 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
293
355
  if (!ATTENTION_STATUSES.has(status)) {
294
356
  break;
295
357
  }
296
- const previous = [...items].reverse().find((item): item is SessionStatusItem => item.kind === "session-status");
358
+ const previous = [...items]
359
+ .reverse()
360
+ .find((item): item is SessionStatusItem => item.kind === "session-status");
297
361
  if (previous?.status === status) {
298
362
  break;
299
363
  }
@@ -313,21 +377,94 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
313
377
  break;
314
378
  }
315
379
 
316
- case "tool.auth_needed": {
380
+ case "session.context.compacted": {
381
+ closeStreamingTail();
382
+ const before =
383
+ typeof payload.estimatedTokensBefore === "number"
384
+ ? Math.round(payload.estimatedTokensBefore).toLocaleString("en-US")
385
+ : null;
386
+ const after =
387
+ typeof payload.estimatedTokensAfter === "number"
388
+ ? Math.round(payload.estimatedTokensAfter).toLocaleString("en-US")
389
+ : null;
390
+ items.push({
391
+ kind: "notice",
392
+ id: event.id,
393
+ tone: "waiting",
394
+ text:
395
+ before && after
396
+ ? `Context compacted from approximately ${before} to ${after} tokens.`
397
+ : "Context compacted so the turn could continue.",
398
+ occurredAt: event.occurredAt,
399
+ });
400
+ break;
401
+ }
402
+
403
+ case "session.context.compaction.skipped": {
317
404
  closeStreamingTail();
318
- const authorizationUrl = typeof payload.authorizationUrl === "string" ? payload.authorizationUrl : null;
405
+ const reason = typeof payload.reason === "string" ? payload.reason : null;
319
406
  items.push({
320
407
  kind: "notice",
321
408
  id: event.id,
322
409
  tone: "waiting",
323
- text: authNeededNoticeText(payload),
324
- ...(authorizationUrl ? { action: { label: "Connect", url: authorizationUrl } } : {}),
410
+ text:
411
+ reason === "no_history"
412
+ ? "Context compaction skipped because there is no active history to compact."
413
+ : reason === "replacement_not_smaller"
414
+ ? "Context compaction skipped because the generated checkpoint would not reduce the context."
415
+ : "Context compaction was not needed.",
416
+ occurredAt: event.occurredAt,
417
+ });
418
+ break;
419
+ }
420
+
421
+ case "turn.recovery.requested": {
422
+ // Recovery requests are durable control-plane evidence, not a user
423
+ // message or proof that recovery succeeded. Live state already exposes
424
+ // a genuinely recovering session; keep the raw event in Debug/audit.
425
+ break;
426
+ }
427
+
428
+ case "turn.event.rejected_late": {
429
+ // Attempt-fence rejections prove stale callbacks did not alter current
430
+ // truth. They are useful diagnostics but never actionable chat content,
431
+ // regardless of the rejected event type. Debug/audit retains the event.
432
+ break;
433
+ }
434
+
435
+ case "tool.auth_needed": {
436
+ // Keep the whole structured payload — the renderer turns it into a clean
437
+ // inline reconnect card, and the app starts the recovery flow off the
438
+ // connectionId/resource. Losing it to a plain-text notice was the ugly
439
+ // "linear.app needs to be reconnected." line users complained about.
440
+ closeStreamingTail();
441
+ items.push({
442
+ kind: "auth-needed",
443
+ id: event.id,
444
+ turnId,
445
+ providerDomain: typeof payload.providerDomain === "string" ? payload.providerDomain : "",
446
+ connectionId: typeof payload.connectionId === "string" ? payload.connectionId : null,
447
+ reason: authNeededReason(payload.reason),
448
+ scopes: stringList(payload.scopes),
449
+ resource: typeof payload.resource === "string" ? payload.resource : null,
450
+ toolName: typeof payload.toolName === "string" ? payload.toolName : null,
451
+ authorizationUrl:
452
+ typeof payload.authorizationUrl === "string" ? payload.authorizationUrl : null,
325
453
  occurredAt: event.occurredAt,
326
454
  });
327
455
  break;
328
456
  }
329
457
 
330
458
  case "turn.completed": {
459
+ // A standalone manual compaction uses the turn ledger for fencing and
460
+ // recovery, but it is maintenance rather than a conversational turn.
461
+ // The dedicated session.context.compacted notice is the complete UI
462
+ // truth; adding a generic turn chip would falsely make it look like an
463
+ // extra agent response.
464
+ if (payload.maintenance === "context_compaction") {
465
+ finalizeOpen(turnId);
466
+ break;
467
+ }
331
468
  // Credit exhaustion arrives as a NOMINALLY completed turn (`detail:
332
469
  // "insufficient OpenGeni credits"`, `segmentLimit: "budget_exhausted"`)
333
470
  // — the engine ended the segment early, it did not finish the work.
@@ -356,7 +493,9 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
356
493
  // Credit death can hide behind fields `failureMessage` doesn't read
357
494
  // (detail/segmentLimit), so classify the whole payload before falling
358
495
  // back to the generic error/message extraction.
359
- const failureText = isCreditExhaustionPayload(payload) ? CREDIT_EXHAUSTION_MESSAGE : failureMessage(payload);
496
+ const failureText = isCreditExhaustionPayload(payload)
497
+ ? CREDIT_EXHAUSTION_MESSAGE
498
+ : failureMessage(payload);
360
499
  // The TURN failed — the in-flight items did not. Chip doctrine: red is
361
500
  // spent once, on the turn-level outcome. Items caught mid-flight read
362
501
  // as calm "interrupted" (same as turn.cancelled); an item that itself
@@ -397,11 +536,25 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
397
536
  break;
398
537
  }
399
538
 
539
+ case "memory.saved":
540
+ case "memory.corrected": {
541
+ // A first-party memory write is a discrete step, not streamed text, so it
542
+ // ends whatever was streaming (mirrors tool/sandbox pushes). A payload
543
+ // missing the memory id is malformed and dropped rather than shown blank.
544
+ const memory = memoryItem(event.id, event.type, turnId, payload, event.occurredAt);
545
+ if (memory) {
546
+ closeStreamingTail();
547
+ items.push(memory);
548
+ }
549
+ break;
550
+ }
551
+
400
552
  case "goal.set":
401
553
  case "goal.updated":
402
554
  case "goal.completed":
403
555
  case "goal.paused":
404
556
  case "goal.resumed":
557
+ case "goal.cleared":
405
558
  case "goal.continuation": {
406
559
  items.push({
407
560
  kind: "goal",
@@ -440,7 +593,11 @@ export function creditExhaustedFromEvents(events: SessionEvent[]): boolean {
440
593
  const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
441
594
  for (let index = ordered.length - 1; index >= 0; index -= 1) {
442
595
  const event = ordered[index];
443
- if (event?.type !== "turn.completed" && event?.type !== "turn.failed" && event?.type !== "turn.cancelled") {
596
+ if (
597
+ event?.type !== "turn.completed" &&
598
+ event?.type !== "turn.failed" &&
599
+ event?.type !== "turn.cancelled"
600
+ ) {
444
601
  continue;
445
602
  }
446
603
  return isCreditExhaustionPayload(asRecord(event.payload));
@@ -480,6 +637,7 @@ function isActivityItem(item: TimelineItem): item is ActivityItem {
480
637
  case "tool-call":
481
638
  case "worker":
482
639
  case "sandbox":
640
+ case "memory":
483
641
  return true;
484
642
  default:
485
643
  return false;
@@ -510,8 +668,6 @@ export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
510
668
 
511
669
  /* --- helpers ---------------------------------------------------------------- */
512
670
 
513
- type TimelineEvent = SessionEvent & { pendingUserMessage?: true };
514
-
515
671
  type TurnAnchorPrescan = {
516
672
  queuedTurnByTrigger: Map<string, string>;
517
673
  startSeqByTrigger: Map<string, number>;
@@ -531,7 +687,8 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
531
687
  const payload = asRecord(event.payload);
532
688
  const turnId = event.turnId ?? null;
533
689
  if (event.type === "turn.queued") {
534
- const triggerEventId = typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
690
+ const triggerEventId =
691
+ typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
535
692
  const queuedTurnId = typeof payload.turnId === "string" ? payload.turnId : turnId;
536
693
  if (triggerEventId && queuedTurnId) {
537
694
  queuedTurnByTrigger.set(triggerEventId, queuedTurnId);
@@ -539,8 +696,9 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
539
696
  continue;
540
697
  }
541
698
  if (event.type === "turn.started") {
542
- const triggerEventId = typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
543
- if (triggerEventId) {
699
+ const triggerEventId =
700
+ typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
701
+ if (triggerEventId && !startSeqByTrigger.has(triggerEventId)) {
544
702
  startSeqByTrigger.set(triggerEventId, event.sequence);
545
703
  }
546
704
  if (turnId) {
@@ -550,13 +708,13 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
550
708
  cancelledTurnIds.add(turnId);
551
709
  }
552
710
 
553
- if (turnId && event.type !== "turn.queued" && event.type !== "turn.cancelled") {
711
+ if (turnId && isTurnExecutionEvidence(event.type)) {
554
712
  const previous = fallbackSeqByTurn.get(turnId);
555
713
  if (previous === undefined || event.sequence < previous) {
556
714
  fallbackSeqByTurn.set(turnId, event.sequence);
557
715
  }
558
716
  }
559
- if (turnId && isAgentActivityEvent(event.type)) {
717
+ if (turnId && isTurnExecutionEvidence(event.type)) {
560
718
  startedTurnIds.add(turnId);
561
719
  }
562
720
  }
@@ -572,7 +730,11 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
572
730
 
573
731
  const cancelledBeforeStartTriggers = new Set<string>();
574
732
  for (const [triggerEventId, turnId] of queuedTurnByTrigger) {
575
- if (cancelledTurnIds.has(turnId) && !startSeqByTrigger.has(triggerEventId) && !fallbackSeqByTurn.has(turnId)) {
733
+ if (
734
+ cancelledTurnIds.has(turnId) &&
735
+ !startSeqByTrigger.has(triggerEventId) &&
736
+ !fallbackSeqByTurn.has(turnId)
737
+ ) {
576
738
  cancelledBeforeStartTriggers.add(triggerEventId);
577
739
  }
578
740
  }
@@ -580,10 +742,9 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
580
742
  return { queuedTurnByTrigger, startSeqByTrigger, cancelledBeforeStartTriggers, startedTurnIds };
581
743
  }
582
744
 
583
- function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan): TimelineEvent[] {
745
+ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan): SessionEvent[] {
584
746
  const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
585
- const insertions = new Map<number, TimelineEvent[]>();
586
- const pending: TimelineEvent[] = [];
747
+ const insertions = new Map<number, SessionEvent[]>();
587
748
 
588
749
  for (const event of ordered) {
589
750
  if (event.type !== "user.message") {
@@ -602,10 +763,12 @@ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan)
602
763
  pushInsertion(insertions, startSeq, event);
603
764
  continue;
604
765
  }
605
- pending.push({ ...event, pendingUserMessage: true });
766
+ // A waiting prompt belongs only in the prompt queue. It enters the
767
+ // timeline at turn.started (or the first same-turn activity fallback),
768
+ // never as a second queued representation.
606
769
  }
607
770
 
608
- const projected: TimelineEvent[] = [];
771
+ const projected: SessionEvent[] = [];
609
772
  for (const event of ordered) {
610
773
  const before = insertions.get(event.sequence);
611
774
  if (before) {
@@ -615,11 +778,14 @@ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan)
615
778
  projected.push(event);
616
779
  }
617
780
  }
618
- projected.push(...pending);
619
781
  return projected;
620
782
  }
621
783
 
622
- function pushInsertion(insertions: Map<number, TimelineEvent[]>, sequence: number, event: SessionEvent): void {
784
+ function pushInsertion(
785
+ insertions: Map<number, SessionEvent[]>,
786
+ sequence: number,
787
+ event: SessionEvent,
788
+ ): void {
623
789
  const bucket = insertions.get(sequence);
624
790
  if (bucket) {
625
791
  bucket.push(event);
@@ -632,6 +798,28 @@ function isAgentActivityEvent(type: string): boolean {
632
798
  return type.startsWith("agent.") || type.startsWith("sandbox.");
633
799
  }
634
800
 
801
+ /**
802
+ * Evidence that a queued prompt crossed the execution boundary when an older
803
+ * or partially recovered ledger is missing its canonical `turn.started`.
804
+ * Queue/control bookkeeping intentionally does not qualify: moving, editing,
805
+ * steering, or deleting a waiting row must never make its prompt appear in the
806
+ * transcript as though inference had begun.
807
+ */
808
+ function isTurnExecutionEvidence(type: string): boolean {
809
+ return (
810
+ isAgentActivityEvent(type) ||
811
+ type === "turn.completed" ||
812
+ type === "turn.failed" ||
813
+ type === "turn.recovery.requested" ||
814
+ type === "turn.capacity_waiting" ||
815
+ type === "session.requiresAction" ||
816
+ type === "tool.auth_needed" ||
817
+ type.startsWith("rig.setup.") ||
818
+ type === "codex.capacity.waiting" ||
819
+ type === "codex.capacity.resumed"
820
+ );
821
+ }
822
+
635
823
  function turnEndItem(
636
824
  event: SessionEvent,
637
825
  outcome: TurnEndItem["outcome"],
@@ -681,7 +869,10 @@ function stampTurnOutcome(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
681
869
  }
682
870
  }
683
871
 
684
- function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, turnEnd: TurnEndItem): void {
872
+ function applyTurnOutcome(
873
+ group: Extract<TimelineGroup, { kind: "activity" }>,
874
+ turnEnd: TurnEndItem,
875
+ ): void {
685
876
  // A sub-cluster reports ITS OWN outcome, not the turn's. When a turn fails
686
877
  // at step 7, clusters 1–6 completed — painting them all red says "everything
687
878
  // broke" when one thing did. The turn-level fold carries the turn outcome;
@@ -692,7 +883,9 @@ function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, t
692
883
  return;
693
884
  }
694
885
  const hasFailed = group.items.some((item) => "status" in item && item.status === "failed");
695
- const hasInterrupted = group.items.some((item) => "status" in item && item.status === "cancelled");
886
+ const hasInterrupted = group.items.some(
887
+ (item) => "status" in item && item.status === "cancelled",
888
+ );
696
889
  group.outcome = hasFailed ? "failed" : hasInterrupted ? "cancelled" : "complete";
697
890
  if (turnEnd.failureText && hasFailed) {
698
891
  group.failureText = turnEnd.failureText;
@@ -743,7 +936,11 @@ function foldSettledTurn(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
743
936
  turnGroup.failureText = turnEnd.failureText;
744
937
  }
745
938
 
746
- groups.splice(startIndex, collected.length, ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]));
939
+ groups.splice(
940
+ startIndex,
941
+ collected.length,
942
+ ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]),
943
+ );
747
944
  }
748
945
 
749
946
  function isTurnBoundary(group: TimelineGroup | undefined): boolean {
@@ -755,16 +952,31 @@ function belongsToDifferentTurn(group: TimelineGroup | undefined, turnId: string
755
952
  return false;
756
953
  }
757
954
  if (group.kind === "activity") {
758
- return group.items.length > 0 && group.items.every((item) => item.turnId !== null && item.turnId !== turnId);
955
+ return (
956
+ group.items.length > 0 &&
957
+ group.items.every((item) => item.turnId !== null && item.turnId !== turnId)
958
+ );
759
959
  }
760
- return group.kind === "item" && group.item.kind === "agent-message" && group.item.turnId !== null && group.item.turnId !== turnId;
960
+ return (
961
+ group.kind === "item" &&
962
+ group.item.kind === "agent-message" &&
963
+ group.item.turnId !== null &&
964
+ group.item.turnId !== turnId
965
+ );
761
966
  }
762
967
 
763
968
  function isBetweenTurnDivider(group: TimelineGroup | undefined): boolean {
764
- return group?.kind === "item" && group.item.kind === "session-status" && group.item.status !== "running";
969
+ return (
970
+ group?.kind === "item" &&
971
+ group.item.kind === "session-status" &&
972
+ group.item.status !== "running"
973
+ );
765
974
  }
766
975
 
767
- function extractFinalAgentMessage(groups: TimelineGroup[], turnEnd: TurnEndItem): Extract<TimelineGroup, { kind: "item" }> | null {
976
+ function extractFinalAgentMessage(
977
+ groups: TimelineGroup[],
978
+ turnEnd: TurnEndItem,
979
+ ): Extract<TimelineGroup, { kind: "item" }> | null {
768
980
  const tail = groups[groups.length - 1];
769
981
  if (tail?.kind !== "item" || tail.item.kind !== "agent-message" || tail.item.streaming) {
770
982
  return null;
@@ -793,10 +1005,21 @@ function asRecord(value: unknown): Record<string, unknown> {
793
1005
  return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
794
1006
  }
795
1007
 
796
- const SESSION_STATUSES: readonly SessionStatus[] = ["queued", "running", "idle", "requires_action", "failed", "cancelled"];
1008
+ const SESSION_STATUSES: readonly SessionStatus[] = [
1009
+ "queued",
1010
+ "running",
1011
+ "idle",
1012
+ "requires_action",
1013
+ "failed",
1014
+ "cancelled",
1015
+ ];
797
1016
 
798
1017
  /** Statuses that demand the reader's attention and so earn a timeline divider. */
799
- const ATTENTION_STATUSES: ReadonlySet<SessionStatus> = new Set(["requires_action", "failed", "cancelled"]);
1018
+ const ATTENTION_STATUSES: ReadonlySet<SessionStatus> = new Set([
1019
+ "requires_action",
1020
+ "failed",
1021
+ "cancelled",
1022
+ ]);
800
1023
 
801
1024
  /** Keep only entries that match the wire shapes; user payloads are untyped. */
802
1025
  function resourceRefs(value: unknown): import("@opengeni/sdk").ResourceRef[] {
@@ -822,6 +1045,43 @@ function toolRefs(value: unknown): import("@opengeni/sdk").ToolRef[] {
822
1045
  });
823
1046
  }
824
1047
 
1048
+ function workerCompletionPayload(value: unknown): {
1049
+ childSessionId: string;
1050
+ childStatus: string;
1051
+ goalStatus: string | null;
1052
+ goalText: string | null;
1053
+ evidence: string | null;
1054
+ pausedReason: string | null;
1055
+ } | null {
1056
+ const payload = asRecord(value);
1057
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1058
+ if (
1059
+ typeof payload.childSessionId !== "string" ||
1060
+ !uuidPattern.test(payload.childSessionId) ||
1061
+ typeof payload.status !== "string" ||
1062
+ payload.status.trim() === ""
1063
+ ) {
1064
+ return null;
1065
+ }
1066
+ const goal = asRecord(payload.goal);
1067
+ return {
1068
+ childSessionId: payload.childSessionId,
1069
+ childStatus: payload.status,
1070
+ goalStatus: typeof goal.status === "string" ? goal.status : null,
1071
+ goalText: typeof goal.text === "string" ? goal.text : null,
1072
+ evidence: typeof goal.evidence === "string" ? goal.evidence : null,
1073
+ // Console pauses and several server paths put the human explanation on
1074
+ // `rationale`, not `pausedReason` — same fallback the goal pill uses, so a
1075
+ // paused worker card never shows "Worker paused" with the reason missing.
1076
+ pausedReason:
1077
+ typeof goal.pausedReason === "string"
1078
+ ? goal.pausedReason
1079
+ : typeof goal.rationale === "string"
1080
+ ? goal.rationale
1081
+ : null,
1082
+ };
1083
+ }
1084
+
825
1085
  function isSessionStatus(value: unknown): value is SessionStatus {
826
1086
  return typeof value === "string" && (SESSION_STATUSES as readonly string[]).includes(value);
827
1087
  }
@@ -832,23 +1092,36 @@ function isErrorOutput(payload: Record<string, unknown>): boolean {
832
1092
  return true;
833
1093
  }
834
1094
  const output = payload.output;
835
- return !!output && typeof output === "object" && (output as { isError?: unknown }).isError === true;
1095
+ return (
1096
+ !!output && typeof output === "object" && (output as { isError?: unknown }).isError === true
1097
+ );
836
1098
  }
837
1099
 
838
- function findOpenCall(items: TimelineItem[], callId: string | null): ToolCallItem | WorkerItem | undefined {
1100
+ function findOpenCall(
1101
+ items: TimelineItem[],
1102
+ callId: string | null,
1103
+ ): ToolCallItem | WorkerItem | undefined {
839
1104
  const reversed = [...items].reverse();
840
- const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem => item.kind === "tool-call" || item.kind === "worker";
1105
+ const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem =>
1106
+ item.kind === "tool-call" || item.kind === "worker";
841
1107
  if (callId) {
842
1108
  const byId = reversed.find((item) => isCall(item) && item.callId === callId);
843
1109
  if (byId) {
844
1110
  return byId as ToolCallItem | WorkerItem;
845
1111
  }
846
1112
  }
847
- return reversed.find((item): item is ToolCallItem | WorkerItem => isCall(item) && item.status === "running");
1113
+ return reversed.find(
1114
+ (item): item is ToolCallItem | WorkerItem => isCall(item) && item.status === "running",
1115
+ );
848
1116
  }
849
1117
 
850
1118
  function findOpenSandbox(items: TimelineItem[], name: string): SandboxItem | undefined {
851
- return [...items].reverse().find((item): item is SandboxItem => item.kind === "sandbox" && item.name === name && item.status === "running");
1119
+ return [...items]
1120
+ .reverse()
1121
+ .find(
1122
+ (item): item is SandboxItem =>
1123
+ item.kind === "sandbox" && item.name === name && item.status === "running",
1124
+ );
852
1125
  }
853
1126
 
854
1127
  function failureMessage(payload: Record<string, unknown>): string | null {
@@ -877,20 +1150,61 @@ function goalText(payload: Record<string, unknown>): string | null {
877
1150
  return null;
878
1151
  }
879
1152
 
880
- function authNeededNoticeText(payload: Record<string, unknown>): string {
881
- const provider =
882
- typeof payload.providerDomain === "string" && payload.providerDomain.trim().length > 0 ? payload.providerDomain.trim() : "This service";
883
- const scopes = Array.isArray(payload.scopes)
884
- ? payload.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0)
885
- : [];
886
-
887
- if (payload.reason === "insufficient_scope") {
888
- return scopes.length > 0 ? `${provider} needs additional access (${scopes.join(", ")}).` : `${provider} needs additional access.`;
889
- }
890
- if (payload.reason === "expired" || payload.reason === "refresh_failed") {
891
- return `${provider} needs to be reconnected.`;
1153
+ /**
1154
+ * Fold a `memory.saved` / `memory.corrected` event into a {@link MemoryItem}.
1155
+ * Reads DEFENSIVELY (the payload is untyped `unknown`, no Zod schema): a missing
1156
+ * memory id means a malformed event, so we return null and the case drops it.
1157
+ */
1158
+ function memoryItem(
1159
+ id: string,
1160
+ type: string,
1161
+ turnId: string | null,
1162
+ payload: Record<string, unknown>,
1163
+ occurredAt: string,
1164
+ ): MemoryItem | null {
1165
+ const memoryId =
1166
+ typeof payload.memoryId === "string" && payload.memoryId ? payload.memoryId : null;
1167
+ if (!memoryId) {
1168
+ return null;
892
1169
  }
893
- return `${provider} needs a connection.`;
1170
+ const replacementPreview =
1171
+ typeof payload.replacementPreview === "string" ? payload.replacementPreview : undefined;
1172
+ const replacementMemoryId =
1173
+ typeof payload.replacementMemoryId === "string" ? payload.replacementMemoryId : undefined;
1174
+ const action = typeof payload.action === "string" ? payload.action : undefined;
1175
+ return {
1176
+ kind: "memory",
1177
+ id,
1178
+ turnId,
1179
+ variant: type === "memory.corrected" ? "corrected" : "saved",
1180
+ memoryKind: typeof payload.kind === "string" ? payload.kind : "",
1181
+ preview: typeof payload.preview === "string" ? payload.preview : "",
1182
+ ...(payload.deduped === true ? { deduped: true } : {}),
1183
+ ...(replacementPreview ? { replacementPreview } : {}),
1184
+ ...(action ? { action } : {}),
1185
+ memoryId,
1186
+ ...(replacementMemoryId ? { replacementMemoryId } : {}),
1187
+ occurredAt,
1188
+ };
1189
+ }
1190
+
1191
+ const AUTH_NEEDED_REASONS: ReadonlySet<string> = new Set([
1192
+ "missing_connection",
1193
+ "expired",
1194
+ "insufficient_scope",
1195
+ "refresh_failed",
1196
+ ]);
1197
+
1198
+ function authNeededReason(value: unknown): AuthNeededItem["reason"] {
1199
+ return typeof value === "string" && AUTH_NEEDED_REASONS.has(value)
1200
+ ? (value as AuthNeededItem["reason"])
1201
+ : null;
1202
+ }
1203
+
1204
+ function stringList(value: unknown): string[] {
1205
+ return Array.isArray(value)
1206
+ ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
1207
+ : [];
894
1208
  }
895
1209
 
896
1210
  function reasoningText(payload: unknown): string {
@@ -922,12 +1236,6 @@ function workerPrompt(args: unknown): string | null {
922
1236
  return null;
923
1237
  }
924
1238
 
925
- /** The interrupt mode from `session_interrupt` args; defaults to "stop". */
926
- function workerInterruptMode(args: unknown): "stop" | "steer" {
927
- const record = asRecord(typeof args === "string" ? tryParseJson(args) : args);
928
- return record.mode === "steer" ? "steer" : "stop";
929
- }
930
-
931
1239
  /**
932
1240
  * Find a session id in orchestration tool arguments or output. Handles raw
933
1241
  * objects, JSON strings, and MCP tool results (`{ content: [{ type: "text",
@@ -956,7 +1264,11 @@ export function extractSessionRef(value: unknown, depth = 0): string | null {
956
1264
  if (typeof record.sessionId === "string" && looksLikeId(record.sessionId)) {
957
1265
  return record.sessionId;
958
1266
  }
959
- if (typeof record.id === "string" && looksLikeId(record.id) && ("status" in record || "workspaceId" in record || "initialMessage" in record)) {
1267
+ if (
1268
+ typeof record.id === "string" &&
1269
+ looksLikeId(record.id) &&
1270
+ ("status" in record || "workspaceId" in record || "initialMessage" in record)
1271
+ ) {
960
1272
  return record.id;
961
1273
  }
962
1274
  for (const key of ["structuredContent", "session", "result", "content"] as const) {
@@ -977,7 +1289,20 @@ function looksLikeId(value: string): boolean {
977
1289
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
978
1290
  }
979
1291
 
980
- /** Readable label for a tool call ("session_create" -> "session create"). */
1292
+ /**
1293
+ * Readable label for a tool call ("session_create" -> "session create").
1294
+ *
1295
+ * MCP tools are namespaced `<serverId>__<toolName>` (see prefixedMcpToolName),
1296
+ * and for catalog-imported servers that serverId is an opaque slug+hash
1297
+ * ("mcp-integrations-sh-supabase-com-34ed9dcf1390-0i6tcf8"). De-slugging the
1298
+ * whole thing leaked that id into the timeline; strip the server prefix and show
1299
+ * just the tool ("list organizations"). Names without the `__` boundary (plain
1300
+ * built-ins like "session_create") are unaffected.
1301
+ */
981
1302
  export function toolDisplayName(name: string): string {
982
- return name.replace(/[_-]+/g, " ").trim();
1303
+ // The prefix is a single LEFT boundary (`registryId__toolName`), so split on
1304
+ // the FIRST `__` — the tool name itself may contain `__` and must survive whole.
1305
+ const boundary = name.indexOf("__");
1306
+ const toolPart = boundary >= 0 ? name.slice(boundary + 2) : name;
1307
+ return toolPart.replace(/[_-]+/g, " ").trim();
983
1308
  }