@opengeni/react 0.11.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-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +791 -145
  5. package/dist/index.js +5447 -2213
  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 +128 -23
  69. package/src/lib/format.ts +44 -6
  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 +435 -84
  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-5C7RGAWA.js.map +0 -1
@@ -1,9 +1,16 @@
1
1
  import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
- import { humanizeFailureReason, 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,112 @@ 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
+ }
468
+ // Credit exhaustion arrives as a NOMINALLY completed turn (`detail:
469
+ // "insufficient OpenGeni credits"`, `segmentLimit: "budget_exhausted"`)
470
+ // — the engine ended the segment early, it did not finish the work.
471
+ // Rendering it as a clean "complete" turn is a lie that leaves the
472
+ // session looking healthy while every future turn silently dies, so it
473
+ // projects exactly like a failed turn plus an explicit notice.
474
+ if (isCreditExhaustionPayload(payload)) {
475
+ finalizeOpen(turnId);
476
+ items.push(turnEndItem(event, "failed", CREDIT_EXHAUSTION_MESSAGE));
477
+ items.push({
478
+ kind: "notice",
479
+ id: event.id,
480
+ tone: "failed",
481
+ text: CREDIT_EXHAUSTION_MESSAGE,
482
+ occurredAt: event.occurredAt,
483
+ });
484
+ break;
485
+ }
331
486
  finalizeOpen(turnId);
332
487
  items.push(turnEndItem(event, "complete", null));
333
488
  break;
@@ -335,7 +490,12 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
335
490
 
336
491
  case "turn.failed": {
337
492
  const hadActivity = hasTurnActivity(items, turnId);
338
- const failureText = failureMessage(payload);
493
+ // Credit death can hide behind fields `failureMessage` doesn't read
494
+ // (detail/segmentLimit), so classify the whole payload before falling
495
+ // back to the generic error/message extraction.
496
+ const failureText = isCreditExhaustionPayload(payload)
497
+ ? CREDIT_EXHAUSTION_MESSAGE
498
+ : failureMessage(payload);
339
499
  // The TURN failed — the in-flight items did not. Chip doctrine: red is
340
500
  // spent once, on the turn-level outcome. Items caught mid-flight read
341
501
  // as calm "interrupted" (same as turn.cancelled); an item that itself
@@ -376,11 +536,25 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
376
536
  break;
377
537
  }
378
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
+
379
552
  case "goal.set":
380
553
  case "goal.updated":
381
554
  case "goal.completed":
382
555
  case "goal.paused":
383
556
  case "goal.resumed":
557
+ case "goal.cleared":
384
558
  case "goal.continuation": {
385
559
  items.push({
386
560
  kind: "goal",
@@ -400,6 +574,37 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
400
574
  return items;
401
575
  }
402
576
 
577
+ /** The turn-end payload shape, as `isCreditExhaustion` wants it. */
578
+ function isCreditExhaustionPayload(payload: Record<string, unknown>): boolean {
579
+ return isCreditExhaustion({
580
+ error: typeof payload.error === "string" ? payload.error : null,
581
+ detail: typeof payload.detail === "string" ? payload.detail : null,
582
+ segmentLimit: typeof payload.segmentLimit === "string" ? payload.segmentLimit : null,
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Whether the session's most recent turn ended in credit exhaustion — the
588
+ * terminal credit state apps key their "add credits" affordances on. Derived
589
+ * from the LAST turn-end event (completed/failed/cancelled): a later turn that
590
+ * settles any other way (someone topped up and kept working) clears it.
591
+ */
592
+ export function creditExhaustedFromEvents(events: SessionEvent[]): boolean {
593
+ const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
594
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
595
+ const event = ordered[index];
596
+ if (
597
+ event?.type !== "turn.completed" &&
598
+ event?.type !== "turn.failed" &&
599
+ event?.type !== "turn.cancelled"
600
+ ) {
601
+ continue;
602
+ }
603
+ return isCreditExhaustionPayload(asRecord(event.payload));
604
+ }
605
+ return false;
606
+ }
607
+
403
608
  /** The latest session status carried in the event log, if any. */
404
609
  export function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus | null {
405
610
  for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -432,6 +637,7 @@ function isActivityItem(item: TimelineItem): item is ActivityItem {
432
637
  case "tool-call":
433
638
  case "worker":
434
639
  case "sandbox":
640
+ case "memory":
435
641
  return true;
436
642
  default:
437
643
  return false;
@@ -462,8 +668,6 @@ export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
462
668
 
463
669
  /* --- helpers ---------------------------------------------------------------- */
464
670
 
465
- type TimelineEvent = SessionEvent & { pendingUserMessage?: true };
466
-
467
671
  type TurnAnchorPrescan = {
468
672
  queuedTurnByTrigger: Map<string, string>;
469
673
  startSeqByTrigger: Map<string, number>;
@@ -483,7 +687,8 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
483
687
  const payload = asRecord(event.payload);
484
688
  const turnId = event.turnId ?? null;
485
689
  if (event.type === "turn.queued") {
486
- const triggerEventId = typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
690
+ const triggerEventId =
691
+ typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
487
692
  const queuedTurnId = typeof payload.turnId === "string" ? payload.turnId : turnId;
488
693
  if (triggerEventId && queuedTurnId) {
489
694
  queuedTurnByTrigger.set(triggerEventId, queuedTurnId);
@@ -491,8 +696,9 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
491
696
  continue;
492
697
  }
493
698
  if (event.type === "turn.started") {
494
- const triggerEventId = typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
495
- if (triggerEventId) {
699
+ const triggerEventId =
700
+ typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
701
+ if (triggerEventId && !startSeqByTrigger.has(triggerEventId)) {
496
702
  startSeqByTrigger.set(triggerEventId, event.sequence);
497
703
  }
498
704
  if (turnId) {
@@ -524,7 +730,11 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
524
730
 
525
731
  const cancelledBeforeStartTriggers = new Set<string>();
526
732
  for (const [triggerEventId, turnId] of queuedTurnByTrigger) {
527
- 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
+ ) {
528
738
  cancelledBeforeStartTriggers.add(triggerEventId);
529
739
  }
530
740
  }
@@ -532,10 +742,9 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
532
742
  return { queuedTurnByTrigger, startSeqByTrigger, cancelledBeforeStartTriggers, startedTurnIds };
533
743
  }
534
744
 
535
- function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan): TimelineEvent[] {
745
+ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan): SessionEvent[] {
536
746
  const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
537
- const insertions = new Map<number, TimelineEvent[]>();
538
- const pending: TimelineEvent[] = [];
747
+ const insertions = new Map<number, SessionEvent[]>();
539
748
 
540
749
  for (const event of ordered) {
541
750
  if (event.type !== "user.message") {
@@ -554,10 +763,12 @@ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan)
554
763
  pushInsertion(insertions, startSeq, event);
555
764
  continue;
556
765
  }
557
- 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.
558
769
  }
559
770
 
560
- const projected: TimelineEvent[] = [];
771
+ const projected: SessionEvent[] = [];
561
772
  for (const event of ordered) {
562
773
  const before = insertions.get(event.sequence);
563
774
  if (before) {
@@ -567,11 +778,14 @@ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan)
567
778
  projected.push(event);
568
779
  }
569
780
  }
570
- projected.push(...pending);
571
781
  return projected;
572
782
  }
573
783
 
574
- 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 {
575
789
  const bucket = insertions.get(sequence);
576
790
  if (bucket) {
577
791
  bucket.push(event);
@@ -633,7 +847,10 @@ function stampTurnOutcome(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
633
847
  }
634
848
  }
635
849
 
636
- function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, turnEnd: TurnEndItem): void {
850
+ function applyTurnOutcome(
851
+ group: Extract<TimelineGroup, { kind: "activity" }>,
852
+ turnEnd: TurnEndItem,
853
+ ): void {
637
854
  // A sub-cluster reports ITS OWN outcome, not the turn's. When a turn fails
638
855
  // at step 7, clusters 1–6 completed — painting them all red says "everything
639
856
  // broke" when one thing did. The turn-level fold carries the turn outcome;
@@ -644,7 +861,9 @@ function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, t
644
861
  return;
645
862
  }
646
863
  const hasFailed = group.items.some((item) => "status" in item && item.status === "failed");
647
- 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
+ );
648
867
  group.outcome = hasFailed ? "failed" : hasInterrupted ? "cancelled" : "complete";
649
868
  if (turnEnd.failureText && hasFailed) {
650
869
  group.failureText = turnEnd.failureText;
@@ -695,7 +914,11 @@ function foldSettledTurn(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
695
914
  turnGroup.failureText = turnEnd.failureText;
696
915
  }
697
916
 
698
- groups.splice(startIndex, collected.length, ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]));
917
+ groups.splice(
918
+ startIndex,
919
+ collected.length,
920
+ ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]),
921
+ );
699
922
  }
700
923
 
701
924
  function isTurnBoundary(group: TimelineGroup | undefined): boolean {
@@ -707,16 +930,31 @@ function belongsToDifferentTurn(group: TimelineGroup | undefined, turnId: string
707
930
  return false;
708
931
  }
709
932
  if (group.kind === "activity") {
710
- 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
+ );
711
937
  }
712
- 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
+ );
713
944
  }
714
945
 
715
946
  function isBetweenTurnDivider(group: TimelineGroup | undefined): boolean {
716
- 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
+ );
717
952
  }
718
953
 
719
- 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 {
720
958
  const tail = groups[groups.length - 1];
721
959
  if (tail?.kind !== "item" || tail.item.kind !== "agent-message" || tail.item.streaming) {
722
960
  return null;
@@ -745,10 +983,21 @@ function asRecord(value: unknown): Record<string, unknown> {
745
983
  return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
746
984
  }
747
985
 
748
- 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
+ ];
749
994
 
750
995
  /** Statuses that demand the reader's attention and so earn a timeline divider. */
751
- 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
+ ]);
752
1001
 
753
1002
  /** Keep only entries that match the wire shapes; user payloads are untyped. */
754
1003
  function resourceRefs(value: unknown): import("@opengeni/sdk").ResourceRef[] {
@@ -774,6 +1023,43 @@ function toolRefs(value: unknown): import("@opengeni/sdk").ToolRef[] {
774
1023
  });
775
1024
  }
776
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
+
777
1063
  function isSessionStatus(value: unknown): value is SessionStatus {
778
1064
  return typeof value === "string" && (SESSION_STATUSES as readonly string[]).includes(value);
779
1065
  }
@@ -784,23 +1070,36 @@ function isErrorOutput(payload: Record<string, unknown>): boolean {
784
1070
  return true;
785
1071
  }
786
1072
  const output = payload.output;
787
- 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
+ );
788
1076
  }
789
1077
 
790
- function findOpenCall(items: TimelineItem[], callId: string | null): ToolCallItem | WorkerItem | undefined {
1078
+ function findOpenCall(
1079
+ items: TimelineItem[],
1080
+ callId: string | null,
1081
+ ): ToolCallItem | WorkerItem | undefined {
791
1082
  const reversed = [...items].reverse();
792
- 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";
793
1085
  if (callId) {
794
1086
  const byId = reversed.find((item) => isCall(item) && item.callId === callId);
795
1087
  if (byId) {
796
1088
  return byId as ToolCallItem | WorkerItem;
797
1089
  }
798
1090
  }
799
- 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
+ );
800
1094
  }
801
1095
 
802
1096
  function findOpenSandbox(items: TimelineItem[], name: string): SandboxItem | undefined {
803
- 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
+ );
804
1103
  }
805
1104
 
806
1105
  function failureMessage(payload: Record<string, unknown>): string | null {
@@ -829,20 +1128,61 @@ function goalText(payload: Record<string, unknown>): string | null {
829
1128
  return null;
830
1129
  }
831
1130
 
832
- function authNeededNoticeText(payload: Record<string, unknown>): string {
833
- const provider =
834
- typeof payload.providerDomain === "string" && payload.providerDomain.trim().length > 0 ? payload.providerDomain.trim() : "This service";
835
- const scopes = Array.isArray(payload.scopes)
836
- ? payload.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0)
837
- : [];
838
-
839
- if (payload.reason === "insufficient_scope") {
840
- return scopes.length > 0 ? `${provider} needs additional access (${scopes.join(", ")}).` : `${provider} needs additional access.`;
841
- }
842
- if (payload.reason === "expired" || payload.reason === "refresh_failed") {
843
- 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;
844
1147
  }
845
- 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
+ : [];
846
1186
  }
847
1187
 
848
1188
  function reasoningText(payload: unknown): string {
@@ -874,12 +1214,6 @@ function workerPrompt(args: unknown): string | null {
874
1214
  return null;
875
1215
  }
876
1216
 
877
- /** The interrupt mode from `session_interrupt` args; defaults to "stop". */
878
- function workerInterruptMode(args: unknown): "stop" | "steer" {
879
- const record = asRecord(typeof args === "string" ? tryParseJson(args) : args);
880
- return record.mode === "steer" ? "steer" : "stop";
881
- }
882
-
883
1217
  /**
884
1218
  * Find a session id in orchestration tool arguments or output. Handles raw
885
1219
  * objects, JSON strings, and MCP tool results (`{ content: [{ type: "text",
@@ -908,7 +1242,11 @@ export function extractSessionRef(value: unknown, depth = 0): string | null {
908
1242
  if (typeof record.sessionId === "string" && looksLikeId(record.sessionId)) {
909
1243
  return record.sessionId;
910
1244
  }
911
- 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
+ ) {
912
1250
  return record.id;
913
1251
  }
914
1252
  for (const key of ["structuredContent", "session", "result", "content"] as const) {
@@ -929,7 +1267,20 @@ function looksLikeId(value: string): boolean {
929
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);
930
1268
  }
931
1269
 
932
- /** 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
+ */
933
1280
  export function toolDisplayName(name: string): string {
934
- 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();
935
1286
  }