clawdex-mobile 2.0.0 → 3.0.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 (71) hide show
  1. package/.github/workflows/pages.yml +41 -0
  2. package/AGENTS.md +263 -110
  3. package/README.md +11 -0
  4. package/apps/mobile/.env.example +2 -2
  5. package/apps/mobile/App.tsx +175 -14
  6. package/apps/mobile/app.json +27 -9
  7. package/apps/mobile/eas.json +14 -4
  8. package/apps/mobile/package.json +13 -13
  9. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
  10. package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
  11. package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
  12. package/apps/mobile/src/api/account.ts +47 -0
  13. package/apps/mobile/src/api/chatMapping.ts +435 -18
  14. package/apps/mobile/src/api/client.ts +296 -36
  15. package/apps/mobile/src/api/rateLimits.ts +143 -0
  16. package/apps/mobile/src/api/types.ts +106 -0
  17. package/apps/mobile/src/api/ws.ts +10 -1
  18. package/apps/mobile/src/components/ChatHeader.tsx +12 -12
  19. package/apps/mobile/src/components/ChatInput.tsx +154 -88
  20. package/apps/mobile/src/components/ChatMessage.tsx +548 -93
  21. package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
  22. package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
  23. package/apps/mobile/src/components/ToolBlock.tsx +17 -15
  24. package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
  25. package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
  26. package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
  27. package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
  28. package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
  29. package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
  30. package/apps/mobile/src/components/chat-input-layout.ts +59 -0
  31. package/apps/mobile/src/components/chatImageSource.ts +86 -0
  32. package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
  33. package/apps/mobile/src/components/voiceWaveform.ts +46 -0
  34. package/apps/mobile/src/config.ts +9 -2
  35. package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
  36. package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
  37. package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
  38. package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
  39. package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
  40. package/apps/mobile/src/navigation/drawerChats.ts +9 -0
  41. package/apps/mobile/src/screens/GitScreen.tsx +2 -0
  42. package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
  43. package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
  44. package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
  45. package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
  46. package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
  47. package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
  48. package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
  49. package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
  50. package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
  51. package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
  52. package/apps/mobile/src/screens/agentThreads.ts +167 -0
  53. package/apps/mobile/src/screens/planCardState.ts +40 -0
  54. package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
  55. package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
  56. package/apps/mobile/src/theme.ts +6 -12
  57. package/docs/codex-app-server-cli-gap-tracker.md +14 -5
  58. package/docs/privacy-policy.md +54 -0
  59. package/docs/setup-and-operations.md +4 -3
  60. package/docs/terms-of-service.md +33 -0
  61. package/package.json +3 -3
  62. package/services/mac-bridge/package.json +6 -6
  63. package/services/rust-bridge/Cargo.lock +58 -363
  64. package/services/rust-bridge/Cargo.toml +2 -2
  65. package/services/rust-bridge/package.json +1 -1
  66. package/services/rust-bridge/src/main.rs +507 -9
  67. package/site/index.html +54 -0
  68. package/site/privacy/index.html +80 -0
  69. package/site/styles.css +135 -0
  70. package/site/support/index.html +51 -0
  71. package/site/terms/index.html +68 -0
@@ -260,6 +260,33 @@ describe('HostBridgeWsClient', () => {
260
260
  await expect(waitPromise).resolves.toBeUndefined();
261
261
  });
262
262
 
263
+ it('waitForTurnCompletion prefers the direct child thread id over parent_thread_id', async () => {
264
+ const client = new HostBridgeWsClient('http://localhost:8787');
265
+ client.connect();
266
+
267
+ const waitPromise = client.waitForTurnCompletion('thr_child', 'turn_child', 100);
268
+ latestMockSocket().simulateMessage(
269
+ JSON.stringify({
270
+ method: 'codex/event/task_complete',
271
+ params: {
272
+ msg: {
273
+ type: 'task_complete',
274
+ thread_id: 'thr_child',
275
+ source: {
276
+ subagent: {
277
+ thread_spawn: {
278
+ parent_thread_id: 'thr_parent',
279
+ },
280
+ },
281
+ },
282
+ },
283
+ },
284
+ })
285
+ );
286
+
287
+ await expect(waitPromise).resolves.toBeUndefined();
288
+ });
289
+
263
290
  it('deduplicates notifications by eventId', () => {
264
291
  const client = new HostBridgeWsClient('http://localhost:8787');
265
292
  const listener = jest.fn();
@@ -0,0 +1,47 @@
1
+ import { readString, toRecord } from './chatMapping';
2
+ import type { AccountSnapshot, PlanType } from './types';
3
+
4
+ const PLAN_TYPES = new Set<PlanType>([
5
+ 'free',
6
+ 'go',
7
+ 'plus',
8
+ 'pro',
9
+ 'team',
10
+ 'business',
11
+ 'enterprise',
12
+ 'edu',
13
+ 'unknown',
14
+ ]);
15
+
16
+ export function readAccountSnapshot(value: unknown): AccountSnapshot {
17
+ const record = toRecord(value);
18
+ const accountRecord = toRecord(record?.account);
19
+ const accountType = readAccountType(accountRecord?.type);
20
+
21
+ return {
22
+ type: accountType,
23
+ email: accountType === 'chatgpt' ? readString(accountRecord?.email) : null,
24
+ planType:
25
+ accountType === 'chatgpt'
26
+ ? readPlanType(accountRecord?.planType ?? accountRecord?.plan_type)
27
+ : null,
28
+ requiresOpenaiAuth:
29
+ record?.requiresOpenaiAuth === true || record?.requires_openai_auth === true,
30
+ };
31
+ }
32
+
33
+ function readAccountType(value: unknown): AccountSnapshot['type'] {
34
+ if (value === 'apiKey' || value === 'chatgpt') {
35
+ return value;
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ function readPlanType(value: unknown): PlanType | null {
42
+ if (typeof value !== 'string') {
43
+ return null;
44
+ }
45
+
46
+ return PLAN_TYPES.has(value as PlanType) ? (value as PlanType) : null;
47
+ }
@@ -1,8 +1,11 @@
1
1
  import type {
2
2
  Chat,
3
3
  ChatMessage,
4
+ ChatMessageSubAgentMeta,
5
+ ChatPlanSnapshot,
4
6
  ChatStatus,
5
7
  ChatSummary,
8
+ TurnPlanStep,
6
9
  } from './types';
7
10
 
8
11
  export type RawThreadStatus =
@@ -43,6 +46,8 @@ export interface RawThread {
43
46
  title?: string;
44
47
  preview?: string;
45
48
  modelProvider?: string;
49
+ agentNickname?: string;
50
+ agentRole?: string;
46
51
  createdAt?: number;
47
52
  updatedAt?: number;
48
53
  status?: RawThreadStatus;
@@ -51,6 +56,12 @@ export interface RawThread {
51
56
  turns?: RawTurn[];
52
57
  }
53
58
 
59
+ interface ThreadSourceMetadata {
60
+ kind?: string;
61
+ parentThreadId?: string;
62
+ subAgentDepth?: number;
63
+ }
64
+
54
65
  export function toRecord(value: unknown): Record<string, unknown> | null {
55
66
  return typeof value === 'object' && value !== null
56
67
  ? (value as Record<string, unknown>)
@@ -61,6 +72,16 @@ export function readString(value: unknown): string | null {
61
72
  return typeof value === 'string' ? value : null;
62
73
  }
63
74
 
75
+ function readStringArray(value: unknown): string[] {
76
+ if (!Array.isArray(value)) {
77
+ return [];
78
+ }
79
+
80
+ return value
81
+ .map((entry) => readString(entry)?.trim() ?? '')
82
+ .filter((entry): entry is string => entry.length > 0);
83
+ }
84
+
64
85
  function readNumber(value: unknown): number | null {
65
86
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
66
87
  }
@@ -197,6 +218,14 @@ export function toRawThread(value: unknown): RawThread {
197
218
  title: threadName,
198
219
  preview: readString(record.preview) ?? undefined,
199
220
  modelProvider: readString(record.modelProvider) ?? undefined,
221
+ agentNickname:
222
+ readString(record.agentNickname) ??
223
+ readString(record.agent_nickname) ??
224
+ undefined,
225
+ agentRole:
226
+ readString(record.agentRole) ??
227
+ readString(record.agent_role) ??
228
+ undefined,
200
229
  createdAt: readNumber(record.createdAt) ?? undefined,
201
230
  updatedAt: readNumber(record.updatedAt) ?? undefined,
202
231
  status: (record.status as RawThreadStatus) ?? undefined,
@@ -236,6 +265,7 @@ export function mapChatSummary(raw: RawThread): ChatSummary | null {
236
265
  const createdAt = unixSecondsToIso(raw.createdAt);
237
266
  const updatedAt = unixSecondsToIso(raw.updatedAt);
238
267
  const turns = Array.isArray(raw.turns) ? raw.turns : [];
268
+ const sourceMetadata = readThreadSourceMetadata(raw.source);
239
269
 
240
270
  const lastError = extractLastError(turns);
241
271
  const displayTitle = raw.name ?? raw.preview;
@@ -250,59 +280,125 @@ export function mapChatSummary(raw: RawThread): ChatSummary | null {
250
280
  lastMessagePreview: toPreview(raw.preview || ''),
251
281
  cwd: readString(raw.cwd) ?? undefined,
252
282
  modelProvider: readString(raw.modelProvider) ?? undefined,
253
- sourceKind: mapSourceKind(raw.source),
283
+ agentNickname: readString(raw.agentNickname) ?? undefined,
284
+ agentRole: readString(raw.agentRole) ?? undefined,
285
+ sourceKind: sourceMetadata.kind,
286
+ parentThreadId: sourceMetadata.parentThreadId,
287
+ subAgentDepth: sourceMetadata.subAgentDepth,
254
288
  lastError: lastError ?? undefined,
255
289
  };
256
290
  }
257
291
 
258
- function mapSourceKind(source: unknown): string | undefined {
292
+ function readThreadSourceMetadata(source: unknown): ThreadSourceMetadata {
259
293
  if (typeof source === 'string') {
260
- return source;
294
+ return {
295
+ kind: source,
296
+ };
261
297
  }
262
298
 
263
299
  const sourceRecord = toRecord(source);
264
300
  if (!sourceRecord) {
265
- return undefined;
301
+ return {};
266
302
  }
267
303
 
268
304
  // Legacy shape used by older adapters.
269
305
  const legacyKind = readString(sourceRecord.kind);
270
306
  if (legacyKind) {
271
- return legacyKind;
307
+ return {
308
+ kind: legacyKind,
309
+ parentThreadId:
310
+ readString(sourceRecord.parentThreadId) ??
311
+ readString(sourceRecord.parent_thread_id) ??
312
+ undefined,
313
+ subAgentDepth:
314
+ readNumber(sourceRecord.depth) ??
315
+ readNumber(sourceRecord.agentDepth) ??
316
+ readNumber(sourceRecord.agent_depth) ??
317
+ undefined,
318
+ };
272
319
  }
273
320
 
274
321
  // Current app-server shape: { subAgent: ... } tagged union.
275
- if ('subAgent' in sourceRecord) {
276
- const subAgent = sourceRecord.subAgent;
322
+ const subAgentValue =
323
+ sourceRecord.subAgent ??
324
+ sourceRecord.subagent;
325
+
326
+ if (subAgentValue !== undefined) {
327
+ const subAgent = subAgentValue;
277
328
  if (typeof subAgent === 'string') {
278
- if (subAgent === 'review') return 'subAgentReview';
279
- if (subAgent === 'compact') return 'subAgentCompact';
280
- if (subAgent === 'memory_consolidation') return 'subAgentOther';
281
- return 'subAgent';
329
+ const kind =
330
+ subAgent === 'review'
331
+ ? 'subAgentReview'
332
+ : subAgent === 'compact'
333
+ ? 'subAgentCompact'
334
+ : subAgent === 'memory_consolidation'
335
+ ? 'subAgentOther'
336
+ : 'subAgent';
337
+ return {
338
+ kind,
339
+ };
282
340
  }
283
341
 
284
342
  const subAgentRecord = toRecord(subAgent);
285
343
  if (!subAgentRecord) {
286
- return 'subAgent';
344
+ return {
345
+ kind: 'subAgent',
346
+ };
287
347
  }
288
348
 
289
- if (toRecord(subAgentRecord.thread_spawn)) {
290
- return 'subAgentThreadSpawn';
349
+ const threadSpawn = toRecord(subAgentRecord.thread_spawn);
350
+ if (threadSpawn) {
351
+ return {
352
+ kind: 'subAgentThreadSpawn',
353
+ parentThreadId:
354
+ readString(threadSpawn.parentThreadId) ??
355
+ readString(threadSpawn.parent_thread_id) ??
356
+ undefined,
357
+ subAgentDepth:
358
+ readNumber(threadSpawn.depth) ??
359
+ readNumber(threadSpawn.agentDepth) ??
360
+ readNumber(threadSpawn.agent_depth) ??
361
+ undefined,
362
+ };
291
363
  }
292
364
 
293
365
  if (readString(subAgentRecord.other)) {
294
- return 'subAgentOther';
366
+ return {
367
+ kind: 'subAgentOther',
368
+ };
295
369
  }
296
370
 
297
- return 'subAgent';
371
+ return {
372
+ kind: 'subAgent',
373
+ parentThreadId:
374
+ readString(subAgentRecord.parentThreadId) ??
375
+ readString(subAgentRecord.parent_thread_id) ??
376
+ undefined,
377
+ subAgentDepth:
378
+ readNumber(subAgentRecord.depth) ??
379
+ readNumber(subAgentRecord.agentDepth) ??
380
+ readNumber(subAgentRecord.agent_depth) ??
381
+ undefined,
382
+ };
298
383
  }
299
384
 
300
385
  const typeKind = readString(sourceRecord.type);
301
386
  if (typeKind && typeKind.startsWith('subAgent')) {
302
- return typeKind;
387
+ return {
388
+ kind: typeKind,
389
+ parentThreadId:
390
+ readString(sourceRecord.parentThreadId) ??
391
+ readString(sourceRecord.parent_thread_id) ??
392
+ undefined,
393
+ subAgentDepth:
394
+ readNumber(sourceRecord.depth) ??
395
+ readNumber(sourceRecord.agentDepth) ??
396
+ readNumber(sourceRecord.agent_depth) ??
397
+ undefined,
398
+ };
303
399
  }
304
400
 
305
- return undefined;
401
+ return {};
306
402
  }
307
403
 
308
404
  export function mapChat(raw: RawThread): Chat {
@@ -312,6 +408,7 @@ export function mapChat(raw: RawThread): Chat {
312
408
  }
313
409
 
314
410
  const messages = mapMessages(raw, summary.createdAt);
411
+ const plans = extractChatPlans(raw);
315
412
 
316
413
  const lastPreview =
317
414
  messages.length > 0
@@ -322,6 +419,67 @@ export function mapChat(raw: RawThread): Chat {
322
419
  ...summary,
323
420
  lastMessagePreview: lastPreview,
324
421
  messages,
422
+ latestPlan: plans.latestPlan,
423
+ latestTurnPlan: plans.latestTurnPlan,
424
+ latestTurnStatus: plans.latestTurnStatus,
425
+ };
426
+ }
427
+
428
+ function extractChatPlans(raw: RawThread): {
429
+ latestPlan: ChatPlanSnapshot | null;
430
+ latestTurnPlan: ChatPlanSnapshot | null;
431
+ latestTurnStatus: string | null;
432
+ } {
433
+ const threadId = raw.id?.trim();
434
+ const turns = Array.isArray(raw.turns) ? raw.turns : [];
435
+ const latestTurn = turns.length > 0 ? turns[turns.length - 1] : null;
436
+ const latestTurnStatus = readString(latestTurn?.status);
437
+
438
+ if (!threadId || turns.length === 0) {
439
+ return {
440
+ latestPlan: null,
441
+ latestTurnPlan: null,
442
+ latestTurnStatus,
443
+ };
444
+ }
445
+
446
+ let latestPlan: ChatPlanSnapshot | null = null;
447
+ let latestTurnPlan: ChatPlanSnapshot | null = null;
448
+
449
+ for (const turn of turns) {
450
+ const turnId = readString(turn.id);
451
+ const items = Array.isArray(turn.items) ? turn.items : [];
452
+ let latestPlanInTurn: ChatPlanSnapshot | null = null;
453
+
454
+ for (const item of items) {
455
+ const itemRecord = toRecord(item);
456
+ if (!itemRecord) {
457
+ continue;
458
+ }
459
+
460
+ const itemType = normalizeType(readString(itemRecord.type) ?? '');
461
+ if (itemType !== 'plan') {
462
+ continue;
463
+ }
464
+
465
+ const plan = toPlanSnapshot(itemRecord, threadId, turnId);
466
+ if (!plan) {
467
+ continue;
468
+ }
469
+
470
+ latestPlan = plan;
471
+ latestPlanInTurn = plan;
472
+ }
473
+
474
+ if (turn === latestTurn) {
475
+ latestTurnPlan = latestPlanInTurn;
476
+ }
477
+ }
478
+
479
+ return {
480
+ latestPlan,
481
+ latestTurnPlan,
482
+ latestTurnStatus,
325
483
  };
326
484
  }
327
485
 
@@ -406,10 +564,13 @@ function mapMessages(raw: RawThread, fallbackCreatedAt: string): ChatMessage[] {
406
564
 
407
565
  const toolLikeMessage = toToolLikeMessage(itemRecord);
408
566
  if (toolLikeMessage) {
567
+ const systemKind = itemType === 'collabToolCall' ? 'subAgent' : 'tool';
409
568
  messages.push({
410
569
  id: readString(itemRecord.id) ?? generateLocalId(),
411
570
  role: 'system',
412
571
  content: toolLikeMessage,
572
+ systemKind,
573
+ subAgentMeta: systemKind === 'subAgent' ? toSubAgentMeta(itemRecord) : undefined,
413
574
  createdAt: new Date(baseTs + messages.length * 1000).toISOString(),
414
575
  });
415
576
  }
@@ -423,6 +584,147 @@ function generateLocalId(): string {
423
584
  return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
424
585
  }
425
586
 
587
+ function toPlanSnapshot(
588
+ item: Record<string, unknown>,
589
+ threadId: string,
590
+ fallbackTurnId?: string | null
591
+ ): ChatPlanSnapshot | null {
592
+ const turnId =
593
+ readString(item.turnId) ??
594
+ readString(item.turn_id) ??
595
+ fallbackTurnId ??
596
+ readString(item.id);
597
+ if (!turnId) {
598
+ return null;
599
+ }
600
+
601
+ const rawSteps = Array.isArray(item.plan)
602
+ ? item.plan
603
+ : Array.isArray(item.steps)
604
+ ? item.steps
605
+ : [];
606
+ const steps: TurnPlanStep[] = rawSteps
607
+ .map((entry) => {
608
+ const entryRecord = toRecord(entry);
609
+ if (!entryRecord) {
610
+ return null;
611
+ }
612
+
613
+ const step = readString(entryRecord.step);
614
+ const status = normalizePlanStepStatus(readString(entryRecord.status));
615
+ if (!step || !status) {
616
+ return null;
617
+ }
618
+
619
+ return {
620
+ step,
621
+ status,
622
+ } satisfies TurnPlanStep;
623
+ })
624
+ .filter((entry): entry is TurnPlanStep => entry !== null);
625
+ const explanation = readString(item.explanation);
626
+
627
+ if (steps.length === 0 && !explanation?.trim()) {
628
+ return parsePlanTextSnapshot(readString(item.text), threadId, turnId);
629
+ }
630
+
631
+ return {
632
+ threadId,
633
+ turnId,
634
+ explanation,
635
+ steps,
636
+ };
637
+ }
638
+
639
+ function parsePlanTextSnapshot(
640
+ text: string | null | undefined,
641
+ threadId: string,
642
+ turnId: string
643
+ ): ChatPlanSnapshot | null {
644
+ const trimmed = text?.trim();
645
+ if (!trimmed) {
646
+ return null;
647
+ }
648
+
649
+ const lines = trimmed
650
+ .split(/\r?\n/)
651
+ .map((line) => line.trim())
652
+ .filter((line) => line.length > 0);
653
+ if (lines.length === 0) {
654
+ return null;
655
+ }
656
+
657
+ const hasSummaryHeader = lines.some((line) => /^summary$/i.test(line));
658
+ const steps: TurnPlanStep[] = [];
659
+ for (const line of lines) {
660
+ const match = line.match(/^\d+[.)]\s+(.+)$/);
661
+ if (!match?.[1]) {
662
+ continue;
663
+ }
664
+
665
+ steps.push({
666
+ step: match[1].trim(),
667
+ status: 'pending',
668
+ });
669
+ }
670
+
671
+ if (!hasSummaryHeader && steps.length === 0) {
672
+ return null;
673
+ }
674
+
675
+ let startIndex = 0;
676
+ if (lines.length > 1 && /plan$/i.test(lines[0])) {
677
+ startIndex = 1;
678
+ }
679
+ if (lines[startIndex] && /^summary$/i.test(lines[startIndex])) {
680
+ startIndex += 1;
681
+ }
682
+
683
+ const explanationLines: string[] = [];
684
+ for (let index = startIndex; index < lines.length; index += 1) {
685
+ const line = lines[index];
686
+ if (/^\d+[.)]\s+/.test(line)) {
687
+ break;
688
+ }
689
+ if (/^(summary|implementation plan|proposed plan)$/i.test(line)) {
690
+ continue;
691
+ }
692
+ explanationLines.push(line);
693
+ }
694
+
695
+ const explanation =
696
+ explanationLines.length > 0 ? explanationLines.join(' ').trim() : null;
697
+
698
+ if (steps.length === 0 && !explanation) {
699
+ return null;
700
+ }
701
+
702
+ return {
703
+ threadId,
704
+ turnId,
705
+ explanation,
706
+ steps,
707
+ };
708
+ }
709
+
710
+ function normalizePlanStepStatus(value: string | null | undefined): TurnPlanStep['status'] | null {
711
+ if (!value) {
712
+ return null;
713
+ }
714
+
715
+ const normalized = value.trim().toLowerCase().replace(/[^a-z]/g, '');
716
+ if (normalized === 'pending') {
717
+ return 'pending';
718
+ }
719
+ if (normalized === 'inprogress') {
720
+ return 'inProgress';
721
+ }
722
+ if (normalized === 'completed' || normalized === 'complete') {
723
+ return 'completed';
724
+ }
725
+ return null;
726
+ }
727
+
426
728
  function toToolLikeMessage(item: Record<string, unknown>): string | null {
427
729
  const rawType = readString(item.type);
428
730
  if (!rawType) {
@@ -473,6 +775,72 @@ function toToolLikeMessage(item: Record<string, unknown>): string | null {
473
775
  return withNestedDetail(title, detail);
474
776
  }
475
777
 
778
+ if (type === 'collabtoolcall') {
779
+ const tool = normalizeType(readString(item.tool) ?? '');
780
+ const status = normalizeType(readString(item.status) ?? '');
781
+ const prompt = normalizeInline(readString(item.prompt), 220);
782
+ const receiverThreadIds = readReceiverThreadIds(item);
783
+ const primaryReceiverThreadId = normalizeInline(receiverThreadIds[0], 120);
784
+ const newThreadId = normalizeInline(
785
+ readString(item.newThreadId) ??
786
+ readString(item.new_thread_id) ??
787
+ primaryReceiverThreadId,
788
+ 120
789
+ );
790
+ const senderThreadId = normalizeInline(
791
+ readString(item.senderThreadId) ?? readString(item.sender_thread_id),
792
+ 120
793
+ );
794
+ const agentStatus = normalizeInline(
795
+ readString(item.agentStatus) ?? readString(item.agent_status),
796
+ 120
797
+ );
798
+
799
+ const title = (() => {
800
+ if (tool === 'spawnagent') {
801
+ if (status === 'failed' || status === 'error') {
802
+ return '• Sub-agent spawn failed';
803
+ }
804
+ if (status === 'completed' || status === 'complete' || status === 'succeeded') {
805
+ return '• Spawned sub-agent';
806
+ }
807
+ return '• Spawning sub-agent';
808
+ }
809
+
810
+ if (tool === 'sendinput') {
811
+ return status === 'failed' || status === 'error'
812
+ ? '• Sub-agent update failed'
813
+ : '• Sent follow-up to sub-agent';
814
+ }
815
+
816
+ if (tool === 'wait') {
817
+ return status === 'failed' || status === 'error'
818
+ ? '• Waiting on sub-agent failed'
819
+ : '• Waiting on sub-agent';
820
+ }
821
+
822
+ if (tool === 'closeagent') {
823
+ return status === 'failed' || status === 'error'
824
+ ? '• Closing sub-agent failed'
825
+ : '• Closed sub-agent thread';
826
+ }
827
+
828
+ return status === 'failed' || status === 'error'
829
+ ? '• Sub-agent action failed'
830
+ : '• Updated sub-agent thread';
831
+ })();
832
+
833
+ const detailParts = [
834
+ prompt ? `Prompt: ${prompt}` : null,
835
+ newThreadId ? `Thread: ${newThreadId}` : null,
836
+ primaryReceiverThreadId ? `Target: ${primaryReceiverThreadId}` : null,
837
+ senderThreadId ? `From: ${senderThreadId}` : null,
838
+ agentStatus ? `Status: ${agentStatus}` : null,
839
+ ].filter(Boolean);
840
+
841
+ return withNestedDetail(title, detailParts.join('\n') || null);
842
+ }
843
+
476
844
  if (type === 'websearch') {
477
845
  const query = normalizeInline(readString(item.query), 180);
478
846
  const actionRecord = toRecord(item.action);
@@ -528,6 +896,55 @@ function toToolLikeMessage(item: Record<string, unknown>): string | null {
528
896
  return null;
529
897
  }
530
898
 
899
+ function toSubAgentMeta(item: Record<string, unknown>): ChatMessageSubAgentMeta | undefined {
900
+ const tool = readString(item.tool) ?? undefined;
901
+ const prompt = normalizeInline(readString(item.prompt), 4000) ?? undefined;
902
+ const senderThreadId =
903
+ normalizeInline(
904
+ readString(item.senderThreadId) ?? readString(item.sender_thread_id),
905
+ 200
906
+ ) ?? undefined;
907
+ const agentStatus =
908
+ normalizeInline(
909
+ readString(item.agentStatus) ?? readString(item.agent_status),
910
+ 200
911
+ ) ?? undefined;
912
+ const receiverThreadIds = readReceiverThreadIds(item);
913
+
914
+ if (!tool && !prompt && !senderThreadId && receiverThreadIds.length === 0 && !agentStatus) {
915
+ return undefined;
916
+ }
917
+
918
+ return {
919
+ tool,
920
+ prompt,
921
+ senderThreadId,
922
+ receiverThreadIds,
923
+ agentStatus,
924
+ };
925
+ }
926
+
927
+ function readReceiverThreadIds(item: Record<string, unknown>): string[] {
928
+ const pluralIds = [
929
+ ...readStringArray(item.receiverThreadIds),
930
+ ...readStringArray(item.receiver_thread_ids),
931
+ ];
932
+ if (pluralIds.length > 0) {
933
+ return Array.from(new Set(pluralIds));
934
+ }
935
+
936
+ const singularIds = [
937
+ readString(item.newThreadId),
938
+ readString(item.new_thread_id),
939
+ readString(item.receiverThreadId),
940
+ readString(item.receiver_thread_id),
941
+ ]
942
+ .map((value) => value?.trim() ?? '')
943
+ .filter((value): value is string => value.length > 0);
944
+
945
+ return singularIds;
946
+ }
947
+
531
948
  function normalizeType(value: string): string {
532
949
  return value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
533
950
  }