@opengeni/react 0.12.0 → 0.13.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5TIXRCSI.js → chunk-NFYVQWIB.js} +150 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +760 -142
  5. package/dist/index.js +5345 -2146
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  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 +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +127 -23
  69. package/src/lib/format.ts +3 -3
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +388 -85
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. 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": {
317
381
  closeStreamingTail();
318
- const authorizationUrl = typeof payload.authorizationUrl === "string" ? payload.authorizationUrl : null;
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;
319
390
  items.push({
320
391
  kind: "notice",
321
392
  id: event.id,
322
393
  tone: "waiting",
323
- text: authNeededNoticeText(payload),
324
- ...(authorizationUrl ? { action: { label: "Connect", url: authorizationUrl } } : {}),
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": {
404
+ closeStreamingTail();
405
+ const reason = typeof payload.reason === "string" ? payload.reason : null;
406
+ items.push({
407
+ kind: "notice",
408
+ id: event.id,
409
+ tone: "waiting",
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) {
@@ -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);
@@ -681,7 +847,10 @@ function stampTurnOutcome(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
681
847
  }
682
848
  }
683
849
 
684
- function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, turnEnd: TurnEndItem): void {
850
+ function applyTurnOutcome(
851
+ group: Extract<TimelineGroup, { kind: "activity" }>,
852
+ turnEnd: TurnEndItem,
853
+ ): void {
685
854
  // A sub-cluster reports ITS OWN outcome, not the turn's. When a turn fails
686
855
  // at step 7, clusters 1–6 completed — painting them all red says "everything
687
856
  // broke" when one thing did. The turn-level fold carries the turn outcome;
@@ -692,7 +861,9 @@ function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, t
692
861
  return;
693
862
  }
694
863
  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");
864
+ const hasInterrupted = group.items.some(
865
+ (item) => "status" in item && item.status === "cancelled",
866
+ );
696
867
  group.outcome = hasFailed ? "failed" : hasInterrupted ? "cancelled" : "complete";
697
868
  if (turnEnd.failureText && hasFailed) {
698
869
  group.failureText = turnEnd.failureText;
@@ -743,7 +914,11 @@ function foldSettledTurn(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
743
914
  turnGroup.failureText = turnEnd.failureText;
744
915
  }
745
916
 
746
- groups.splice(startIndex, collected.length, ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]));
917
+ groups.splice(
918
+ startIndex,
919
+ collected.length,
920
+ ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]),
921
+ );
747
922
  }
748
923
 
749
924
  function isTurnBoundary(group: TimelineGroup | undefined): boolean {
@@ -755,16 +930,31 @@ function belongsToDifferentTurn(group: TimelineGroup | undefined, turnId: string
755
930
  return false;
756
931
  }
757
932
  if (group.kind === "activity") {
758
- return group.items.length > 0 && group.items.every((item) => item.turnId !== null && item.turnId !== turnId);
933
+ return (
934
+ group.items.length > 0 &&
935
+ group.items.every((item) => item.turnId !== null && item.turnId !== turnId)
936
+ );
759
937
  }
760
- return group.kind === "item" && group.item.kind === "agent-message" && group.item.turnId !== null && group.item.turnId !== turnId;
938
+ return (
939
+ group.kind === "item" &&
940
+ group.item.kind === "agent-message" &&
941
+ group.item.turnId !== null &&
942
+ group.item.turnId !== turnId
943
+ );
761
944
  }
762
945
 
763
946
  function isBetweenTurnDivider(group: TimelineGroup | undefined): boolean {
764
- return group?.kind === "item" && group.item.kind === "session-status" && group.item.status !== "running";
947
+ return (
948
+ group?.kind === "item" &&
949
+ group.item.kind === "session-status" &&
950
+ group.item.status !== "running"
951
+ );
765
952
  }
766
953
 
767
- function extractFinalAgentMessage(groups: TimelineGroup[], turnEnd: TurnEndItem): Extract<TimelineGroup, { kind: "item" }> | null {
954
+ function extractFinalAgentMessage(
955
+ groups: TimelineGroup[],
956
+ turnEnd: TurnEndItem,
957
+ ): Extract<TimelineGroup, { kind: "item" }> | null {
768
958
  const tail = groups[groups.length - 1];
769
959
  if (tail?.kind !== "item" || tail.item.kind !== "agent-message" || tail.item.streaming) {
770
960
  return null;
@@ -793,10 +983,21 @@ function asRecord(value: unknown): Record<string, unknown> {
793
983
  return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
794
984
  }
795
985
 
796
- const SESSION_STATUSES: readonly SessionStatus[] = ["queued", "running", "idle", "requires_action", "failed", "cancelled"];
986
+ const SESSION_STATUSES: readonly SessionStatus[] = [
987
+ "queued",
988
+ "running",
989
+ "idle",
990
+ "requires_action",
991
+ "failed",
992
+ "cancelled",
993
+ ];
797
994
 
798
995
  /** 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"]);
996
+ const ATTENTION_STATUSES: ReadonlySet<SessionStatus> = new Set([
997
+ "requires_action",
998
+ "failed",
999
+ "cancelled",
1000
+ ]);
800
1001
 
801
1002
  /** Keep only entries that match the wire shapes; user payloads are untyped. */
802
1003
  function resourceRefs(value: unknown): import("@opengeni/sdk").ResourceRef[] {
@@ -822,6 +1023,43 @@ function toolRefs(value: unknown): import("@opengeni/sdk").ToolRef[] {
822
1023
  });
823
1024
  }
824
1025
 
1026
+ function workerCompletionPayload(value: unknown): {
1027
+ childSessionId: string;
1028
+ childStatus: string;
1029
+ goalStatus: string | null;
1030
+ goalText: string | null;
1031
+ evidence: string | null;
1032
+ pausedReason: string | null;
1033
+ } | null {
1034
+ const payload = asRecord(value);
1035
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1036
+ if (
1037
+ typeof payload.childSessionId !== "string" ||
1038
+ !uuidPattern.test(payload.childSessionId) ||
1039
+ typeof payload.status !== "string" ||
1040
+ payload.status.trim() === ""
1041
+ ) {
1042
+ return null;
1043
+ }
1044
+ const goal = asRecord(payload.goal);
1045
+ return {
1046
+ childSessionId: payload.childSessionId,
1047
+ childStatus: payload.status,
1048
+ goalStatus: typeof goal.status === "string" ? goal.status : null,
1049
+ goalText: typeof goal.text === "string" ? goal.text : null,
1050
+ evidence: typeof goal.evidence === "string" ? goal.evidence : null,
1051
+ // Console pauses and several server paths put the human explanation on
1052
+ // `rationale`, not `pausedReason` — same fallback the goal pill uses, so a
1053
+ // paused worker card never shows "Worker paused" with the reason missing.
1054
+ pausedReason:
1055
+ typeof goal.pausedReason === "string"
1056
+ ? goal.pausedReason
1057
+ : typeof goal.rationale === "string"
1058
+ ? goal.rationale
1059
+ : null,
1060
+ };
1061
+ }
1062
+
825
1063
  function isSessionStatus(value: unknown): value is SessionStatus {
826
1064
  return typeof value === "string" && (SESSION_STATUSES as readonly string[]).includes(value);
827
1065
  }
@@ -832,23 +1070,36 @@ function isErrorOutput(payload: Record<string, unknown>): boolean {
832
1070
  return true;
833
1071
  }
834
1072
  const output = payload.output;
835
- return !!output && typeof output === "object" && (output as { isError?: unknown }).isError === true;
1073
+ return (
1074
+ !!output && typeof output === "object" && (output as { isError?: unknown }).isError === true
1075
+ );
836
1076
  }
837
1077
 
838
- function findOpenCall(items: TimelineItem[], callId: string | null): ToolCallItem | WorkerItem | undefined {
1078
+ function findOpenCall(
1079
+ items: TimelineItem[],
1080
+ callId: string | null,
1081
+ ): ToolCallItem | WorkerItem | undefined {
839
1082
  const reversed = [...items].reverse();
840
- const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem => item.kind === "tool-call" || item.kind === "worker";
1083
+ const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem =>
1084
+ item.kind === "tool-call" || item.kind === "worker";
841
1085
  if (callId) {
842
1086
  const byId = reversed.find((item) => isCall(item) && item.callId === callId);
843
1087
  if (byId) {
844
1088
  return byId as ToolCallItem | WorkerItem;
845
1089
  }
846
1090
  }
847
- return reversed.find((item): item is ToolCallItem | WorkerItem => isCall(item) && item.status === "running");
1091
+ return reversed.find(
1092
+ (item): item is ToolCallItem | WorkerItem => isCall(item) && item.status === "running",
1093
+ );
848
1094
  }
849
1095
 
850
1096
  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");
1097
+ return [...items]
1098
+ .reverse()
1099
+ .find(
1100
+ (item): item is SandboxItem =>
1101
+ item.kind === "sandbox" && item.name === name && item.status === "running",
1102
+ );
852
1103
  }
853
1104
 
854
1105
  function failureMessage(payload: Record<string, unknown>): string | null {
@@ -877,20 +1128,61 @@ function goalText(payload: Record<string, unknown>): string | null {
877
1128
  return null;
878
1129
  }
879
1130
 
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.`;
1131
+ /**
1132
+ * Fold a `memory.saved` / `memory.corrected` event into a {@link MemoryItem}.
1133
+ * Reads DEFENSIVELY (the payload is untyped `unknown`, no Zod schema): a missing
1134
+ * memory id means a malformed event, so we return null and the case drops it.
1135
+ */
1136
+ function memoryItem(
1137
+ id: string,
1138
+ type: string,
1139
+ turnId: string | null,
1140
+ payload: Record<string, unknown>,
1141
+ occurredAt: string,
1142
+ ): MemoryItem | null {
1143
+ const memoryId =
1144
+ typeof payload.memoryId === "string" && payload.memoryId ? payload.memoryId : null;
1145
+ if (!memoryId) {
1146
+ return null;
892
1147
  }
893
- return `${provider} needs a connection.`;
1148
+ const replacementPreview =
1149
+ typeof payload.replacementPreview === "string" ? payload.replacementPreview : undefined;
1150
+ const replacementMemoryId =
1151
+ typeof payload.replacementMemoryId === "string" ? payload.replacementMemoryId : undefined;
1152
+ const action = typeof payload.action === "string" ? payload.action : undefined;
1153
+ return {
1154
+ kind: "memory",
1155
+ id,
1156
+ turnId,
1157
+ variant: type === "memory.corrected" ? "corrected" : "saved",
1158
+ memoryKind: typeof payload.kind === "string" ? payload.kind : "",
1159
+ preview: typeof payload.preview === "string" ? payload.preview : "",
1160
+ ...(payload.deduped === true ? { deduped: true } : {}),
1161
+ ...(replacementPreview ? { replacementPreview } : {}),
1162
+ ...(action ? { action } : {}),
1163
+ memoryId,
1164
+ ...(replacementMemoryId ? { replacementMemoryId } : {}),
1165
+ occurredAt,
1166
+ };
1167
+ }
1168
+
1169
+ const AUTH_NEEDED_REASONS: ReadonlySet<string> = new Set([
1170
+ "missing_connection",
1171
+ "expired",
1172
+ "insufficient_scope",
1173
+ "refresh_failed",
1174
+ ]);
1175
+
1176
+ function authNeededReason(value: unknown): AuthNeededItem["reason"] {
1177
+ return typeof value === "string" && AUTH_NEEDED_REASONS.has(value)
1178
+ ? (value as AuthNeededItem["reason"])
1179
+ : null;
1180
+ }
1181
+
1182
+ function stringList(value: unknown): string[] {
1183
+ return Array.isArray(value)
1184
+ ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
1185
+ : [];
894
1186
  }
895
1187
 
896
1188
  function reasoningText(payload: unknown): string {
@@ -922,12 +1214,6 @@ function workerPrompt(args: unknown): string | null {
922
1214
  return null;
923
1215
  }
924
1216
 
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
1217
  /**
932
1218
  * Find a session id in orchestration tool arguments or output. Handles raw
933
1219
  * objects, JSON strings, and MCP tool results (`{ content: [{ type: "text",
@@ -956,7 +1242,11 @@ export function extractSessionRef(value: unknown, depth = 0): string | null {
956
1242
  if (typeof record.sessionId === "string" && looksLikeId(record.sessionId)) {
957
1243
  return record.sessionId;
958
1244
  }
959
- if (typeof record.id === "string" && looksLikeId(record.id) && ("status" in record || "workspaceId" in record || "initialMessage" in record)) {
1245
+ if (
1246
+ typeof record.id === "string" &&
1247
+ looksLikeId(record.id) &&
1248
+ ("status" in record || "workspaceId" in record || "initialMessage" in record)
1249
+ ) {
960
1250
  return record.id;
961
1251
  }
962
1252
  for (const key of ["structuredContent", "session", "result", "content"] as const) {
@@ -977,7 +1267,20 @@ function looksLikeId(value: string): boolean {
977
1267
  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
1268
  }
979
1269
 
980
- /** Readable label for a tool call ("session_create" -> "session create"). */
1270
+ /**
1271
+ * Readable label for a tool call ("session_create" -> "session create").
1272
+ *
1273
+ * MCP tools are namespaced `<serverId>__<toolName>` (see prefixedMcpToolName),
1274
+ * and for catalog-imported servers that serverId is an opaque slug+hash
1275
+ * ("mcp-integrations-sh-supabase-com-34ed9dcf1390-0i6tcf8"). De-slugging the
1276
+ * whole thing leaked that id into the timeline; strip the server prefix and show
1277
+ * just the tool ("list organizations"). Names without the `__` boundary (plain
1278
+ * built-ins like "session_create") are unaffected.
1279
+ */
981
1280
  export function toolDisplayName(name: string): string {
982
- return name.replace(/[_-]+/g, " ").trim();
1281
+ // The prefix is a single LEFT boundary (`registryId__toolName`), so split on
1282
+ // the FIRST `__` — the tool name itself may contain `__` and must survive whole.
1283
+ const boundary = name.indexOf("__");
1284
+ const toolPart = boundary >= 0 ? name.slice(boundary + 2) : name;
1285
+ return toolPart.replace(/[_-]+/g, " ").trim();
983
1286
  }