tide-commander 1.149.0 → 1.150.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 (66) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/{BossLogsModal-Cs4kWugD.js → BossLogsModal-DvczRUvP.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-BudMgJqj.js → BossSpawnModal-B2autFKk.js} +1 -1
  4. package/dist/assets/{ControlsModal-Bh6_3qtH.js → ControlsModal-BaREMQxc.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-Nf4wQFfz.js → DockerLogsModal-DZ0udMCo.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-BfRXRX50.js → EmbeddedEditor-CEjr0ZSZ.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-CXUtjA0r.js → GmailOAuthSetup-76BP8J8C.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-D-_rwicD.js → GoogleOAuthSetup-D45GImPI.js} +1 -1
  9. package/dist/assets/{IframeModal-CdaXsQK0.js → IframeModal-De63RfJZ.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-ZsSVxcWr.js → IntegrationsPanel-DYrw1Sqk.js} +2 -2
  11. package/dist/assets/{LogViewerModal-BAf-klB2.js → LogViewerModal-DW-2lpln.js} +1 -1
  12. package/dist/assets/{MonitoringModal-BjRbh5z4.js → MonitoringModal-B0-XXyd3.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-SaywDZVL.js → PM2LogsModal-DOfOhkCY.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-jru2Ez9a.js → RestoreArchivedAreaModal-DNGmIJmM.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-NJHoe2Qg.js → Scene2DCanvas-BW1E76CQ.js} +1 -1
  16. package/dist/assets/{SceneManager-i9wVMa_V.js → SceneManager-DAvc9-Wp.js} +1 -1
  17. package/dist/assets/{SkillsPanel-BHfKLzWe.js → SkillsPanel-Br-gLCLA.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-BfVevCSo.js → SlackMultiInstanceSetup-DG_bIfDu.js} +1 -1
  19. package/dist/assets/{SpawnModal-29x5z53B.js → SpawnModal-lWM8OuZW.js} +1 -1
  20. package/dist/assets/{StatisticsModal-MbIByjWD.js → StatisticsModal-LvA0Fcvn.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-DF0vYaM6.js → SubordinateAssignmentModal-8YxNXcR3.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-EGcKS1Pt.js → TriggerManagerPanel-DftIuKUG.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-BLmejfqe.js → WorkflowEditorPanel-oLwyO_C6.js} +1 -1
  24. package/dist/assets/{area-logos-C4fgpLgO.js → area-logos-mcpQqUMZ.js} +1 -1
  25. package/dist/assets/{index-BiiLWTXJ.js → index-Ay7dKyeR.js} +1 -1
  26. package/dist/assets/{index-DThK5_yS.js → index-BMw7AAAK.js} +3 -3
  27. package/dist/assets/{index-C8CGfmXP.js → index-BQpd34Mc.js} +1 -1
  28. package/dist/assets/{index-BZ4wZwRu.js → index-C6-68CXL.js} +3 -3
  29. package/dist/assets/index-CIon0DLf.js +2 -0
  30. package/dist/assets/{index-C6Kf4ewu.js → index-Cwt-E2td.js} +1 -1
  31. package/dist/assets/{index-B9xpLAcH.js → index-DFVroqA5.js} +1 -1
  32. package/dist/assets/index-DR6_WeF_.js +1 -0
  33. package/dist/assets/{index-B-2H7O_Y.js → index-O0t07OB_.js} +2 -2
  34. package/dist/assets/{main-B0UlZnfk.js → main-D5sqeuwB.js} +151 -125
  35. package/dist/assets/main-JBkwGXfE.css +1 -0
  36. package/dist/assets/{web-CXC8P25z.js → web-BkrshujV.js} +1 -1
  37. package/dist/assets/{web-LGEDa25c.js → web-DPETNqOf.js} +1 -1
  38. package/dist/assets/{web-NM4dUqWg.js → web-vG1Smf_N.js} +1 -1
  39. package/dist/index.html +2 -2
  40. package/dist/locales/de/config.json +3 -1
  41. package/dist/locales/en/config.json +2 -0
  42. package/dist/locales/en/terminal.json +6 -0
  43. package/dist/locales/es/config.json +2 -0
  44. package/dist/locales/fr/config.json +3 -1
  45. package/dist/locales/hi/config.json +3 -1
  46. package/dist/locales/it/config.json +3 -1
  47. package/dist/locales/ja/config.json +3 -1
  48. package/dist/locales/pt/config.json +3 -1
  49. package/dist/locales/ru/config.json +3 -1
  50. package/dist/locales/zh-CN/config.json +3 -1
  51. package/dist/src/packages/server/claude/backend.js +114 -10
  52. package/dist/src/packages/server/claude/runner/process-lifecycle.js +9 -0
  53. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +24 -4
  54. package/dist/src/packages/server/claude/session-loader.js +20 -5
  55. package/dist/src/packages/server/codex/json-event-parser.js +149 -4
  56. package/dist/src/packages/server/grok/backend.js +4 -0
  57. package/dist/src/packages/server/grok/json-event-parser.js +29 -14
  58. package/dist/src/packages/server/grok/session-watcher.js +67 -34
  59. package/dist/src/packages/server/opencode/json-event-parser.js +184 -15
  60. package/dist/src/packages/server/routes/agents.js +12 -9
  61. package/dist/src/packages/server/services/grok-usage-service.js +374 -0
  62. package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +10 -7
  63. package/package.json +1 -1
  64. package/dist/assets/index-DYgVP0G3.js +0 -2
  65. package/dist/assets/index-DhD3m2Qx.js +0 -1
  66. package/dist/assets/main-DeiAIdzy.css +0 -1
@@ -156,6 +156,15 @@ export class CodexJsonEventParser {
156
156
  enableFileDiffEnrichment;
157
157
  workingDirectory;
158
158
  gitRootCache = new Map();
159
+ /**
160
+ * Progressive agent_message text per item.id (for item.updated / delta streams).
161
+ * Current `codex exec --json` only emits agent_message on item.completed, but
162
+ * app-server / newer CLIs can stream via item.updated or agentMessage deltas.
163
+ */
164
+ streamedAgentTextByItemId = new Map();
165
+ openAgentTextUuids = new Set();
166
+ streamedThinkingByItemId = new Map();
167
+ openThinkingUuids = new Set();
159
168
  constructor(options = {}) {
160
169
  this.enableFileDiffEnrichment = options.enableFileDiffEnrichment === true;
161
170
  this.workingDirectory = options.workingDirectory || process.cwd();
@@ -190,6 +199,9 @@ export class CodexJsonEventParser {
190
199
  if (event.type === 'item.started') {
191
200
  return this.parseItemStarted(event.item);
192
201
  }
202
+ if (event.type === 'item.updated') {
203
+ return this.parseItemUpdated(event.item);
204
+ }
193
205
  if (event.type === 'item.completed') {
194
206
  // Capture agent message text for later use in step_complete
195
207
  if (event.item?.type === 'agent_message' && event.item?.text) {
@@ -197,6 +209,12 @@ export class CodexJsonEventParser {
197
209
  }
198
210
  return this.parseItemCompleted(event.item);
199
211
  }
212
+ // App-server style notification (when present on exec stream): streaming text delta.
213
+ if (event.type === 'item/agentMessage/delta' ||
214
+ event.type === 'item.agentMessage.delta' ||
215
+ event.type === 'agent_message_delta') {
216
+ return this.parseAgentMessageDelta(rawEvent);
217
+ }
200
218
  if (event.type === 'turn.completed') {
201
219
  return this.parseTurnCompleted(event.usage);
202
220
  }
@@ -228,12 +246,27 @@ export class CodexJsonEventParser {
228
246
  };
229
247
  return [];
230
248
  }
231
- // agent_reasoning: Map to thinking event
249
+ // agent_reasoning: Map to thinking event (stable uuid when item id present)
232
250
  if (payloadType === 'agent_reasoning') {
233
251
  const text = asString(payload.text);
234
252
  if (!text)
235
253
  return [];
236
- return [{ type: 'thinking', text, isStreaming: false }];
254
+ const itemId = asString(payload.item_id) || asString(payload.id);
255
+ const uuid = itemId ? `codex-thinking-${itemId}` : undefined;
256
+ return [{ type: 'thinking', text, isStreaming: false, uuid }];
257
+ }
258
+ // Streaming agent message delta (app-server / experimental envelopes)
259
+ if (payloadType === 'agent_message_delta' ||
260
+ payloadType === 'agent_message/delta' ||
261
+ payloadType === 'output_text_delta') {
262
+ const delta = asString(payload.text) ||
263
+ asString(payload.delta);
264
+ if (!delta)
265
+ return [];
266
+ const itemId = asString(payload.item_id) ||
267
+ asString(payload.id) ||
268
+ 'anon';
269
+ return this.appendAgentTextDelta(itemId, delta);
237
270
  }
238
271
  // turn_aborted: Silent. The interruption is already visible from the agent stopping.
239
272
  if (payloadType === 'turn_aborted') {
@@ -391,9 +424,109 @@ export class CodexJsonEventParser {
391
424
  toolOutput,
392
425
  }];
393
426
  }
427
+ /** Stable uuid for a Codex agent_message item so streaming chunks merge. */
428
+ agentTextUuid(itemId) {
429
+ return `codex-text-${itemId}`;
430
+ }
431
+ appendAgentTextDelta(itemId, delta) {
432
+ if (!delta)
433
+ return [];
434
+ const prev = this.streamedAgentTextByItemId.get(itemId) || '';
435
+ const next = prev + delta;
436
+ this.streamedAgentTextByItemId.set(itemId, next);
437
+ this.lastAgentMessageText = next;
438
+ const uuid = this.agentTextUuid(itemId);
439
+ this.openAgentTextUuids.add(uuid);
440
+ return [{ type: 'text', text: delta, isStreaming: true, uuid }];
441
+ }
442
+ /**
443
+ * Progressive snapshot for agent_message / reasoning on item.updated.
444
+ * Emits only the new suffix when text grows (prefix match).
445
+ */
446
+ emitProgressiveItemText(kind, itemId, fullText, isFinal) {
447
+ if (!fullText)
448
+ return [];
449
+ const id = itemId || 'anon';
450
+ const map = kind === 'text' ? this.streamedAgentTextByItemId : this.streamedThinkingByItemId;
451
+ const openSet = kind === 'text' ? this.openAgentTextUuids : this.openThinkingUuids;
452
+ const uuid = kind === 'text' ? this.agentTextUuid(id) : `codex-thinking-${id}`;
453
+ const prev = map.get(id) || '';
454
+ if (kind === 'text')
455
+ this.lastAgentMessageText = fullText;
456
+ if (fullText === prev) {
457
+ if (isFinal && openSet.has(uuid)) {
458
+ openSet.delete(uuid);
459
+ map.delete(id);
460
+ return [{ type: kind === 'text' ? 'text' : 'thinking', text: fullText, isStreaming: false, uuid }];
461
+ }
462
+ return [];
463
+ }
464
+ map.set(id, fullText);
465
+ if (!isFinal && fullText.startsWith(prev)) {
466
+ const delta = fullText.slice(prev.length);
467
+ if (!delta)
468
+ return [];
469
+ openSet.add(uuid);
470
+ return [{ type: kind === 'text' ? 'text' : 'thinking', text: delta, isStreaming: true, uuid }];
471
+ }
472
+ openSet.delete(uuid);
473
+ if (isFinal)
474
+ map.delete(id);
475
+ return [{ type: kind === 'text' ? 'text' : 'thinking', text: fullText, isStreaming: false, uuid }];
476
+ }
477
+ parseItemUpdated(item) {
478
+ if (!item?.type)
479
+ return [];
480
+ // Progressive agent_message text (when CLI emits item.updated for messages).
481
+ if (item.type === 'agent_message' && item.text) {
482
+ const sanitized = sanitizeCodexMessageText(item.text);
483
+ if (!sanitized.text)
484
+ return [];
485
+ return this.emitProgressiveItemText('text', item.id, sanitized.text, false);
486
+ }
487
+ if (item.type === 'reasoning' && item.text) {
488
+ return this.emitProgressiveItemText('thinking', item.id, item.text, false);
489
+ }
490
+ // todo_list updates are already handled elsewhere as non-text progress;
491
+ // leave other item.updated types silent to avoid raw dumps.
492
+ if (item.type === 'todo_list') {
493
+ return [];
494
+ }
495
+ return [];
496
+ }
497
+ parseAgentMessageDelta(rawEvent) {
498
+ if (!isObject(rawEvent))
499
+ return [];
500
+ const item = isObject(rawEvent.item) ? rawEvent.item : undefined;
501
+ const params = isObject(rawEvent.params) ? rawEvent.params : undefined;
502
+ const delta = asString(rawEvent.delta) ||
503
+ asString(rawEvent.text) ||
504
+ asString(item?.text) ||
505
+ (params ? asString(params.delta) || asString(params.text) : undefined);
506
+ if (!delta)
507
+ return [];
508
+ const itemId = asString(item?.id) ||
509
+ asString(rawEvent.item_id) ||
510
+ asString(rawEvent.id) ||
511
+ (params ? asString(params.item_id) || asString(params.id) : undefined) ||
512
+ 'anon';
513
+ return this.appendAgentTextDelta(itemId, delta);
514
+ }
394
515
  parseItemStarted(item) {
395
516
  if (!item?.type)
396
517
  return [];
518
+ // Some Codex builds open agent_message with empty text on item.started;
519
+ // pin the stream uuid so later updates merge into one row.
520
+ if (item.type === 'agent_message') {
521
+ if (item.id) {
522
+ const uuid = this.agentTextUuid(item.id);
523
+ this.openAgentTextUuids.add(uuid);
524
+ if (item.text) {
525
+ return this.emitProgressiveItemText('text', item.id, item.text, false);
526
+ }
527
+ }
528
+ return [];
529
+ }
397
530
  if (item.type === 'web_search') {
398
531
  const toolName = 'web_search';
399
532
  if (item.id) {
@@ -446,7 +579,12 @@ export class CodexJsonEventParser {
446
579
  return [{ type: 'error', errorMessage }];
447
580
  }
448
581
  if (item.type === 'reasoning' && item.text) {
449
- return [{ type: 'thinking', text: item.text, isStreaming: false }];
582
+ const uuid = item.id ? `codex-thinking-${item.id}` : undefined;
583
+ if (item.id) {
584
+ this.streamedThinkingByItemId.delete(item.id);
585
+ this.openThinkingUuids.delete(uuid);
586
+ }
587
+ return [{ type: 'thinking', text: item.text, isStreaming: false, uuid }];
450
588
  }
451
589
  // Handle agent_message regardless of whether `text` is present. An empty
452
590
  // agent_message (text:"" or missing) must be suppressed — NOT allowed to
@@ -467,7 +605,14 @@ export class CodexJsonEventParser {
467
605
  // Empty message → render nothing (no empty bubble, no raw debug line).
468
606
  if (!sanitized.text)
469
607
  return [];
470
- return [{ type: 'text', text: sanitized.text, isStreaming: false }];
608
+ const uuid = item.id ? `codex-text-${item.id}` : undefined;
609
+ if (item.id) {
610
+ this.streamedAgentTextByItemId.delete(item.id);
611
+ if (uuid)
612
+ this.openAgentTextUuids.delete(uuid);
613
+ }
614
+ // isStreaming:false finalizes any prior streaming row with the same uuid.
615
+ return [{ type: 'text', text: sanitized.text, isStreaming: false, uuid }];
471
616
  }
472
617
  if (item.type === 'web_search') {
473
618
  const toolName = item.id ? (this.activeToolByItemId.get(item.id) ?? 'web_search') : 'web_search';
@@ -141,6 +141,10 @@ export class GrokBackend {
141
141
  return null;
142
142
  return events.length === 1 ? events[0] : events;
143
143
  }
144
+ /** See CLIBackend.breakOpenStreams — tool-boundary stream split for Grok. */
145
+ breakOpenStreams() {
146
+ return this.parser.breakOpenStreams();
147
+ }
144
148
  extractSessionId(rawEvent) {
145
149
  const event = rawEvent;
146
150
  if (event?.sessionId && typeof event.sessionId === 'string') {
@@ -85,18 +85,23 @@ export class GrokJsonEventParser {
85
85
  sessionId: event.sessionId,
86
86
  }];
87
87
  }
88
- parseEnd(event) {
88
+ /**
89
+ * Finalize open text/thinking streams and mint new uuids for the next
90
+ * generation — WITHOUT step_complete (agent stays "working").
91
+ *
92
+ * Grok keeps one agentic turn across many tool rounds and does not emit
93
+ * `end` between them. Without this break, every intermediate status line
94
+ * concatenates into a single mashed bubble until the final end.
95
+ */
96
+ breakOpenStreams(sessionId) {
89
97
  const events = [];
90
- // Finalize open streams so the client stops treating the row as "live"
91
- // and stops re-mounting/blinking. Final payload replaces the merged row
92
- // (same uuid, isStreaming=false) with the full accumulated text.
93
98
  if (this.thinkingStreamUuid && this.lastThinkingContent) {
94
99
  events.push({
95
100
  type: 'thinking',
96
101
  text: this.lastThinkingContent,
97
102
  isStreaming: false,
98
103
  uuid: this.thinkingStreamUuid,
99
- sessionId: event.sessionId,
104
+ sessionId,
100
105
  });
101
106
  }
102
107
  if (this.textStreamUuid && this.lastTextContent) {
@@ -105,27 +110,37 @@ export class GrokJsonEventParser {
105
110
  text: this.lastTextContent,
106
111
  isStreaming: false,
107
112
  uuid: this.textStreamUuid,
108
- sessionId: event.sessionId,
113
+ sessionId,
109
114
  });
110
115
  }
116
+ this.lastTextContent = '';
117
+ this.lastThinkingContent = '';
118
+ // Keep textEventEmittedInTurn so a later end/step_complete still knows
119
+ // we produced text this turn (avoids empty-thinking fallback).
120
+ this.textStreamUuid = undefined;
121
+ this.thinkingStreamUuid = undefined;
122
+ return events;
123
+ }
124
+ parseEnd(event) {
125
+ // Finalize open streams (same as a tool-boundary break) then step_complete.
126
+ const events = this.breakOpenStreams(event.sessionId);
127
+ // breakOpenStreams cleared accumulators — re-read is not possible. Capture
128
+ // resultText from the finalize events we just built.
129
+ const finalizedText = events.find((e) => e.type === 'text' && e.text)?.text;
130
+ const finalizedThinking = events.find((e) => e.type === 'thinking' && e.text)?.text;
111
131
  const stepComplete = {
112
132
  type: 'step_complete',
113
133
  sessionId: event.sessionId,
114
134
  };
115
- if (this.lastTextContent) {
116
- stepComplete.resultText = this.lastTextContent;
135
+ if (finalizedText) {
136
+ stepComplete.resultText = finalizedText;
117
137
  }
118
- else if (this.lastThinkingContent && !this.textEventEmittedInTurn) {
138
+ else if (finalizedThinking && !this.textEventEmittedInTurn) {
119
139
  // Thinking-only turn — surface a marker so the terminal isn't blank
120
140
  stepComplete.resultText = `(Empty response: thinking only, stopReason=${event.stopReason || 'EndTurn'})`;
121
141
  }
122
142
  events.push(stepComplete);
123
- // Reset per-turn accumulators / stream ids
124
- this.lastTextContent = '';
125
- this.lastThinkingContent = '';
126
143
  this.textEventEmittedInTurn = false;
127
- this.textStreamUuid = undefined;
128
- this.thinkingStreamUuid = undefined;
129
144
  return events;
130
145
  }
131
146
  parseError(event) {
@@ -186,10 +186,43 @@ export function startGrokSessionWatcher(opts) {
186
186
  let lastSignalsUsageKey = '';
187
187
  const emittedToolStarts = new Set();
188
188
  const emittedToolResults = new Set();
189
- /** raw tool_name → queue of early synthetic ids (events.jsonl, no call id) */
189
+ /**
190
+ * raw tool_name → early synthetic ids still running (events tool_started,
191
+ * no tool_completed yet). chat_history prefers these first.
192
+ */
190
193
  const pendingEarlyByName = new Map();
194
+ /**
195
+ * raw tool_name → early ids that already got tool_completed BEFORE
196
+ * chat_history wrote the real call. Fast tools (TodoWrite) often complete
197
+ * in <100ms; if we only kept them in pending and shifted on complete, the
198
+ * upgrade path was empty and we emitted a second call-* chip — leaving the
199
+ * original early card stuck with empty toolInput (bare "TODOWRITE").
200
+ */
201
+ const completedEarlyByName = new Map();
191
202
  const earlyToReal = new Map();
192
203
  const realToEarly = new Map();
204
+ /** Pop the next early id eligible for upgrade (still running, else completed). */
205
+ const takeEarlyForUpgrade = (rawName) => {
206
+ const pending = pendingEarlyByName.get(rawName);
207
+ if (pending && pending.length > 0) {
208
+ const id = pending.shift();
209
+ if (pending.length === 0)
210
+ pendingEarlyByName.delete(rawName);
211
+ else
212
+ pendingEarlyByName.set(rawName, pending);
213
+ return id;
214
+ }
215
+ const completed = completedEarlyByName.get(rawName);
216
+ if (completed && completed.length > 0) {
217
+ const id = completed.shift();
218
+ if (completed.length === 0)
219
+ completedEarlyByName.delete(rawName);
220
+ else
221
+ completedEarlyByName.set(rawName, completed);
222
+ return id;
223
+ }
224
+ return undefined;
225
+ };
193
226
  const emit = (event) => {
194
227
  if (stopped)
195
228
  return;
@@ -263,24 +296,22 @@ export function startGrokSessionWatcher(opts) {
263
296
  pendingEarlyByName.delete(rawName);
264
297
  else
265
298
  pendingEarlyByName.set(rawName, queue);
266
- // If chat_history already completed the real call, skip empty early result.
267
- if (emittedToolResults.has(synthId))
268
- return;
269
- // If this early was upgraded to a real call id that already has a result, skip.
270
- const realId = earlyToReal.get(synthId);
271
- if (realId && emittedToolResults.has(realId)) {
272
- emittedToolResults.add(synthId);
273
- return;
299
+ // Keep the early id available for chat_history upgrade even after complete.
300
+ // TodoWrite / list_dir often finish before the assistant tool_calls line
301
+ // is flushed — without this, upgrade fails and the UI keeps an empty chip.
302
+ if (!earlyToReal.has(synthId)) {
303
+ const completedQ = completedEarlyByName.get(rawName) || [];
304
+ completedQ.push(synthId);
305
+ completedEarlyByName.set(rawName, completedQ);
274
306
  }
275
- emittedToolResults.add(synthId);
276
- emit({
277
- type: 'tool_result',
278
- toolName: normalizeGrokToolName(rawName),
279
- toolUseId: synthId,
280
- uuid: synthId,
281
- toolOutput: line.outcome === 'success' ? '(ok)' : `(${line.outcome || 'done'})`,
282
- });
283
- // Tools change context fill — re-read signals after completions.
307
+ // Do NOT emit tool_result from events.jsonl.
308
+ // events only has outcome (success/error) — no stdout. Emitting
309
+ // "Bash output:\n(ok)" here races ahead of chat_history, marks the early
310
+ // uuid as done, and blocks the real dual-emit with full stdout. The UI
311
+ // then pairs the Bash chip to "(ok)" forever ("No output available").
312
+ // Real tool_result content always comes from chat_history.jsonl below.
313
+ //
314
+ // Still refresh context usage — tools change fill.
284
315
  pollSignalsUsage();
285
316
  return;
286
317
  }
@@ -301,14 +332,11 @@ export function startGrokSessionWatcher(opts) {
301
332
  const rawName = call.name || 'unknown';
302
333
  const toolName = normalizeGrokToolName(rawName);
303
334
  const toolInput = parseToolInput(call.arguments);
304
- // Link to a pending early card for this tool name (if any)
305
- const earlyQueue = pendingEarlyByName.get(rawName);
306
- if (earlyQueue && earlyQueue.length > 0) {
307
- const earlyId = earlyQueue.shift();
308
- if (earlyQueue.length === 0)
309
- pendingEarlyByName.delete(rawName);
310
- earlyToReal.set(earlyId, callId);
311
- realToEarly.set(callId, earlyId);
335
+ // Link to an early card for this tool name (running or already completed).
336
+ const linkedEarlyId = takeEarlyForUpgrade(rawName);
337
+ if (linkedEarlyId) {
338
+ earlyToReal.set(linkedEarlyId, callId);
339
+ realToEarly.set(callId, linkedEarlyId);
312
340
  }
313
341
  emittedToolStarts.add(callId);
314
342
  // Prefer upgrading the early card (same uuid) with full args so the UI
@@ -340,26 +368,31 @@ export function startGrokSessionWatcher(opts) {
340
368
  const callId = line.tool_call_id;
341
369
  if (emittedToolResults.has(callId))
342
370
  return;
343
- emittedToolResults.add(callId);
344
371
  const content = typeof line.content === 'string'
345
372
  ? line.content
346
373
  : line.content != null
347
374
  ? JSON.stringify(line.content)
348
375
  : '';
349
376
  const out = truncateOutput(content);
377
+ // Prefer the live card uuid (early synth id) so Bash output pairs with the
378
+ // "Using tool: Bash" row the UI already rendered under that uuid.
379
+ const earlyId = realToEarly.get(callId);
380
+ const cardUuid = earlyId || callId;
381
+ emittedToolResults.add(callId);
382
+ if (earlyId)
383
+ emittedToolResults.add(earlyId);
350
384
  emit({
351
385
  type: 'tool_result',
352
- toolUseId: callId,
353
- uuid: callId,
386
+ toolUseId: cardUuid,
387
+ uuid: cardUuid,
354
388
  toolOutput: out,
355
389
  });
356
- const earlyId = realToEarly.get(callId);
357
- if (earlyId && !emittedToolResults.has(earlyId)) {
358
- emittedToolResults.add(earlyId);
390
+ // Also emit under callId when different so any call-id-keyed consumers match.
391
+ if (cardUuid !== callId) {
359
392
  emit({
360
393
  type: 'tool_result',
361
- toolUseId: earlyId,
362
- uuid: earlyId,
394
+ toolUseId: callId,
395
+ uuid: callId,
363
396
  toolOutput: out,
364
397
  });
365
398
  }