clawdex-mobile 2.0.1 → 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 +1 -1
  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 +56 -47
  64. package/services/rust-bridge/Cargo.toml +1 -1
  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
@@ -6,7 +6,11 @@ import {
6
6
  type RawThread,
7
7
  toRawThread,
8
8
  } from './chatMapping';
9
+ import { readAccountSnapshot } from './account';
10
+ import { readAccountRateLimits as readSelectedAccountRateLimits } from './rateLimits';
9
11
  import type {
12
+ AccountSnapshot,
13
+ AccountRateLimitSnapshot,
10
14
  ApprovalPolicy,
11
15
  ApprovalDecision,
12
16
  CollaborationMode,
@@ -28,6 +32,7 @@ import type {
28
32
  ResolveUserInputRequest,
29
33
  ResolveUserInputResponse,
30
34
  SendChatMessageRequest,
35
+ SteerChatTurnRequest,
31
36
  MentionInput,
32
37
  LocalImageInput,
33
38
  UploadAttachmentRequest,
@@ -37,8 +42,12 @@ import type {
37
42
  ModelOption,
38
43
  ReasoningEffort,
39
44
  ModelReasoningEffortOption,
45
+ ServiceTier,
40
46
  TerminalExecRequest,
41
47
  TerminalExecResponse,
48
+ WorkspaceListResponse,
49
+ FileSystemListRequest,
50
+ FileSystemListResponse,
42
51
  } from './types';
43
52
  import type { HostBridgeWsClient } from './ws';
44
53
 
@@ -56,6 +65,10 @@ interface AppServerListResponse {
56
65
  data?: unknown[];
57
66
  }
58
67
 
68
+ interface AppServerLoadedThreadListResponse {
69
+ data?: unknown[];
70
+ }
71
+
59
72
  interface AppServerReadResponse {
60
73
  thread?: unknown;
61
74
  }
@@ -80,8 +93,18 @@ interface AppServerModelListResponse {
80
93
  data?: unknown[];
81
94
  }
82
95
 
96
+ interface AppServerConfigReadResponse {
97
+ config?: unknown;
98
+ }
99
+
100
+ interface AppServerAccountReadResponse {
101
+ account?: unknown;
102
+ requiresOpenaiAuth?: boolean;
103
+ requires_openai_auth?: boolean;
104
+ }
105
+
83
106
  interface AppServerCollaborationMode {
84
- mode: 'plan';
107
+ mode: 'plan' | 'default';
85
108
  settings: {
86
109
  model: string;
87
110
  reasoning_effort: ReasoningEffort | null;
@@ -89,11 +112,25 @@ interface AppServerCollaborationMode {
89
112
  };
90
113
  }
91
114
 
115
+ interface AppServerThreadRuntimeSettings {
116
+ model: string | null;
117
+ effort: ReasoningEffort | null;
118
+ }
119
+
92
120
  type AppServerThreadSetNameResponse = Record<string, never>;
93
121
 
94
122
  const CHAT_LIST_SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'] as const;
123
+ const CHAT_LIST_SOURCE_KINDS_WITH_SUBAGENTS = [
124
+ ...CHAT_LIST_SOURCE_KINDS,
125
+ 'subAgent',
126
+ 'subAgentReview',
127
+ 'subAgentCompact',
128
+ 'subAgentThreadSpawn',
129
+ 'subAgentOther',
130
+ ] as const;
95
131
  const MOBILE_DEVELOPER_INSTRUCTIONS =
96
132
  'When you need clarification, call request_user_input instead of asking only in plain text. Provide 2-3 concise options whenever possible and use isOther when free-form input is appropriate.';
133
+ const MOBILE_DEFAULT_SANDBOX = 'danger-full-access';
97
134
 
98
135
  interface ChatSnapshot {
99
136
  rawThread: RawThread;
@@ -121,6 +158,10 @@ interface SendChatMessageOptions {
121
158
  onTurnStarted?: (turnId: string) => void;
122
159
  }
123
160
 
161
+ interface ListChatsOptions {
162
+ includeSubAgents?: boolean;
163
+ }
164
+
124
165
  const ACTIVE_TURN_STATUSES = new Set([
125
166
  'inprogress',
126
167
  'in_progress',
@@ -142,13 +183,32 @@ export class HostBridgeApiClient {
142
183
  return this.ws.request<HealthResponse>('bridge/health/read');
143
184
  }
144
185
 
145
- async listChats(): Promise<ChatSummary[]> {
186
+ async readAccountRateLimits(): Promise<AccountRateLimitSnapshot | null> {
187
+ const response = await this.ws.request<Record<string, unknown>>('account/rateLimits/read');
188
+ return readSelectedAccountRateLimits(response);
189
+ }
190
+
191
+ async readAccount(): Promise<AccountSnapshot> {
192
+ const response = await this.ws.request<AppServerAccountReadResponse>('account/read', {
193
+ refreshToken: false,
194
+ });
195
+ return readAccountSnapshot(response);
196
+ }
197
+
198
+ async logoutAccount(): Promise<void> {
199
+ await this.ws.request('account/logout');
200
+ }
201
+
202
+ async listChats(options?: ListChatsOptions): Promise<ChatSummary[]> {
203
+ const includeSubAgents = options?.includeSubAgents === true;
146
204
  const response = await this.ws.request<AppServerListResponse>('thread/list', {
147
205
  cursor: null,
148
206
  limit: 200,
149
207
  sortKey: null,
150
208
  modelProviders: null,
151
- sourceKinds: CHAT_LIST_SOURCE_KINDS,
209
+ sourceKinds: includeSubAgents
210
+ ? CHAT_LIST_SOURCE_KINDS_WITH_SUBAGENTS
211
+ : CHAT_LIST_SOURCE_KINDS,
152
212
  archived: false,
153
213
  cwd: null,
154
214
  });
@@ -178,22 +238,52 @@ export class HostBridgeApiClient {
178
238
  return mapped;
179
239
  })
180
240
  .filter((item): item is ChatSummary => item !== null)
181
- .filter((item) => !isSubAgentSource(item.sourceKind))
241
+ .filter((item) => includeSubAgents || !isSubAgentSource(item.sourceKind))
182
242
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
183
243
  }
184
244
 
245
+ async listLoadedChatIds(): Promise<string[]> {
246
+ const response = await this.ws.request<AppServerLoadedThreadListResponse>(
247
+ 'thread/loaded/list',
248
+ undefined
249
+ );
250
+ const ids = Array.isArray(response.data) ? response.data : [];
251
+ return ids
252
+ .map((value) => readString(value)?.trim() ?? '')
253
+ .filter((value): value is string => value.length > 0);
254
+ }
255
+
256
+ async listWorkspaceRoots(limit = 200): Promise<WorkspaceListResponse> {
257
+ const response = await this.ws.request<Record<string, unknown>>('bridge/workspaces/list', {
258
+ limit,
259
+ });
260
+ return readWorkspaceListResponse(response);
261
+ }
262
+
263
+ async listFilesystemEntries(
264
+ request?: FileSystemListRequest
265
+ ): Promise<FileSystemListResponse> {
266
+ const response = await this.ws.request<Record<string, unknown>>('bridge/fs/list', {
267
+ path: normalizeCwd(request?.path) ?? null,
268
+ includeHidden: request?.includeHidden === true,
269
+ directoriesOnly: request?.directoriesOnly !== false,
270
+ });
271
+ return readFileSystemListResponse(response);
272
+ }
273
+
185
274
  async createChat(body: CreateChatRequest): Promise<Chat> {
186
275
  const requestedCwd = normalizeCwd(body.cwd);
187
276
  const requestedModel = normalizeModel(body.model);
188
277
  const requestedEffort = normalizeEffort(body.effort);
278
+ const requestedServiceTier = normalizeServiceTier(body.serviceTier);
189
279
  const requestedApprovalPolicy = normalizeApprovalPolicy(body.approvalPolicy) ?? 'untrusted';
190
280
  const started = await this.ws.request<AppServerStartResponse>('thread/start', {
191
281
  model: requestedModel ?? null,
192
282
  modelProvider: null,
193
283
  cwd: requestedCwd ?? null,
194
284
  approvalPolicy: requestedApprovalPolicy,
195
- sandbox: 'workspace-write',
196
- config: null,
285
+ sandbox: MOBILE_DEFAULT_SANDBOX,
286
+ config: toThreadConfig(requestedServiceTier),
197
287
  baseInstructions: null,
198
288
  developerInstructions: MOBILE_DEVELOPER_INSTRUCTIONS,
199
289
  personality: null,
@@ -309,7 +399,7 @@ export class HostBridgeApiClient {
309
399
  model?: string | null;
310
400
  approvalPolicy?: ApprovalPolicy | null;
311
401
  }
312
- ): Promise<void> {
402
+ ): Promise<AppServerThreadRuntimeSettings> {
313
403
  const threadId = id.trim();
314
404
  if (!threadId) {
315
405
  throw new Error('thread id is required');
@@ -327,7 +417,7 @@ export class HostBridgeApiClient {
327
417
  modelProvider: null,
328
418
  cwd: normalizeCwd(options?.cwd) ?? null,
329
419
  approvalPolicy: requestedApprovalPolicy,
330
- sandbox: 'workspace-write',
420
+ sandbox: MOBILE_DEFAULT_SANDBOX,
331
421
  config: null,
332
422
  baseInstructions: null,
333
423
  developerInstructions: MOBILE_DEVELOPER_INSTRUCTIONS,
@@ -337,8 +427,11 @@ export class HostBridgeApiClient {
337
427
  };
338
428
 
339
429
  try {
340
- await this.ws.request('thread/resume', primaryRequest);
341
- return;
430
+ const response = await this.ws.request<Record<string, unknown>>(
431
+ 'thread/resume',
432
+ primaryRequest
433
+ );
434
+ return readThreadRuntimeSettings(response);
342
435
  } catch (primaryError) {
343
436
  // First fallback: keep raw-event streaming enabled, but relax approval policy.
344
437
  const compatibilityRequest = {
@@ -346,8 +439,11 @@ export class HostBridgeApiClient {
346
439
  approvalPolicy: fallbackApprovalPolicy,
347
440
  };
348
441
  try {
349
- await this.ws.request('thread/resume', compatibilityRequest);
350
- return;
442
+ const response = await this.ws.request<Record<string, unknown>>(
443
+ 'thread/resume',
444
+ compatibilityRequest
445
+ );
446
+ return readThreadRuntimeSettings(response);
351
447
  } catch (compatibilityError) {
352
448
  // Final compatibility fallback for older app-server builds that reject
353
449
  // experimentalRawEvents/developerInstructions on resume.
@@ -357,8 +453,11 @@ export class HostBridgeApiClient {
357
453
  };
358
454
  delete (legacyRequest as { experimentalRawEvents?: boolean }).experimentalRawEvents;
359
455
  try {
360
- await this.ws.request('thread/resume', legacyRequest);
361
- return;
456
+ const response = await this.ws.request<Record<string, unknown>>(
457
+ 'thread/resume',
458
+ legacyRequest
459
+ );
460
+ return readThreadRuntimeSettings(response);
362
461
  } catch (legacyError) {
363
462
  throw new Error(
364
463
  `thread/resume failed: ${(primaryError as Error).message}; compatibility failed: ${(compatibilityError as Error).message}; legacy fallback failed: ${(legacyError as Error).message}`
@@ -385,14 +484,25 @@ export class HostBridgeApiClient {
385
484
  const normalizedCwd = normalizeCwd(body.cwd);
386
485
  const normalizedModel = normalizeModel(body.model);
387
486
  const normalizedEffort = normalizeEffort(body.effort);
487
+ const normalizedServiceTier = normalizeServiceTier(body.serviceTier);
388
488
  const normalizedApprovalPolicy = normalizeApprovalPolicy(body.approvalPolicy);
389
489
  const normalizedMentions = normalizeMentions(body.mentions);
390
490
  const normalizedLocalImages = normalizeLocalImages(body.localImages);
391
- const requestedPlanMode =
392
- typeof body.collaborationMode === 'string' &&
393
- body.collaborationMode.trim().toLowerCase() === 'plan';
394
- let effectiveModel = normalizedModel;
395
- if (requestedPlanMode && !effectiveModel) {
491
+ const requestedCollaborationMode = normalizeCollaborationMode(body.collaborationMode);
492
+ let resumedThreadSettings: AppServerThreadRuntimeSettings | null = null;
493
+
494
+ try {
495
+ resumedThreadSettings = await this.resumeThread(id, {
496
+ model: normalizedModel,
497
+ cwd: normalizedCwd,
498
+ approvalPolicy: normalizedApprovalPolicy,
499
+ });
500
+ } catch {
501
+ // Best effort: turn/start still works for recently started chats.
502
+ }
503
+
504
+ let effectiveModel = normalizedModel ?? resumedThreadSettings?.model ?? null;
505
+ if (requestedCollaborationMode && !effectiveModel) {
396
506
  try {
397
507
  const models = await this.listModels(false);
398
508
  effectiveModel =
@@ -401,22 +511,16 @@ export class HostBridgeApiClient {
401
511
  // Best effort: fall back to the current thread settings if model lookup fails.
402
512
  }
403
513
  }
514
+ const effectiveEffort =
515
+ requestedCollaborationMode
516
+ ? normalizedEffort ?? resumedThreadSettings?.effort ?? null
517
+ : normalizedEffort;
404
518
  const normalizedCollaborationMode = toTurnCollaborationMode(
405
- body.collaborationMode,
519
+ requestedCollaborationMode,
406
520
  effectiveModel,
407
- normalizedEffort
521
+ effectiveEffort
408
522
  );
409
523
 
410
- try {
411
- await this.resumeThread(id, {
412
- model: effectiveModel,
413
- cwd: normalizedCwd,
414
- approvalPolicy: normalizedApprovalPolicy,
415
- });
416
- } catch {
417
- // Best effort: turn/start still works for recently started chats.
418
- }
419
-
420
524
  const turnStart = await this.ws.request<AppServerTurnResponse>('turn/start', {
421
525
  threadId: id,
422
526
  input: buildTurnInput(content, normalizedMentions, normalizedLocalImages),
@@ -424,7 +528,8 @@ export class HostBridgeApiClient {
424
528
  approvalPolicy: normalizedApprovalPolicy ?? null,
425
529
  sandboxPolicy: null,
426
530
  model: effectiveModel ?? null,
427
- effort: normalizedEffort ?? null,
531
+ effort: effectiveEffort ?? null,
532
+ serviceTier: normalizedServiceTier ?? null,
428
533
  summary: null,
429
534
  personality: null,
430
535
  outputSchema: null,
@@ -439,6 +544,28 @@ export class HostBridgeApiClient {
439
544
  return this.getChatWithUserMessage(id, turnId, content);
440
545
  }
441
546
 
547
+ async steerChatTurn(
548
+ threadId: string,
549
+ expectedTurnId: string,
550
+ body: SteerChatTurnRequest
551
+ ): Promise<void> {
552
+ const normalizedThreadId = threadId.trim();
553
+ const normalizedExpectedTurnId = expectedTurnId.trim();
554
+ const content = body.content.trim();
555
+ if (!normalizedThreadId || !normalizedExpectedTurnId || !content) {
556
+ return;
557
+ }
558
+
559
+ const normalizedMentions = normalizeMentions(body.mentions);
560
+ const normalizedLocalImages = normalizeLocalImages(body.localImages);
561
+
562
+ await this.ws.request<Record<string, never>>('turn/steer', {
563
+ threadId: normalizedThreadId,
564
+ expectedTurnId: normalizedExpectedTurnId,
565
+ input: buildTurnInput(content, normalizedMentions, normalizedLocalImages),
566
+ });
567
+ }
568
+
442
569
  async interruptTurn(threadId: string, turnId: string): Promise<void> {
443
570
  const normalizedThreadId = threadId.trim();
444
571
  const normalizedTurnId = turnId.trim();
@@ -556,11 +683,13 @@ export class HostBridgeApiClient {
556
683
  options?: {
557
684
  cwd?: string;
558
685
  model?: string;
686
+ serviceTier?: ServiceTier;
559
687
  approvalPolicy?: ApprovalPolicy | null;
560
688
  }
561
689
  ): Promise<Chat> {
562
690
  const requestedApprovalPolicy =
563
691
  normalizeApprovalPolicy(options?.approvalPolicy) ?? 'untrusted';
692
+ const requestedServiceTier = normalizeServiceTier(options?.serviceTier);
564
693
  const response = await this.ws.request<AppServerForkResponse>('thread/fork', {
565
694
  threadId: id,
566
695
  path: null,
@@ -568,8 +697,8 @@ export class HostBridgeApiClient {
568
697
  modelProvider: null,
569
698
  cwd: normalizeCwd(options?.cwd) ?? null,
570
699
  approvalPolicy: requestedApprovalPolicy,
571
- sandbox: 'workspace-write',
572
- config: null,
700
+ sandbox: MOBILE_DEFAULT_SANDBOX,
701
+ config: toThreadConfig(requestedServiceTier),
573
702
  baseInstructions: null,
574
703
  developerInstructions: MOBILE_DEVELOPER_INSTRUCTIONS,
575
704
  persistExtendedHistory: true,
@@ -582,6 +711,15 @@ export class HostBridgeApiClient {
582
711
  throw new Error('thread/fork did not return a chat payload');
583
712
  }
584
713
 
714
+ async readServiceTierPreference(): Promise<ServiceTier | null> {
715
+ const response = await this.ws.request<AppServerConfigReadResponse>('config/read', {
716
+ includeLayers: false,
717
+ cwd: null,
718
+ });
719
+ const config = toRecord(response.config);
720
+ return normalizeServiceTier(readString(config?.service_tier));
721
+ }
722
+
585
723
  listApprovals(): Promise<PendingApproval[]> {
586
724
  return this.ws.request<PendingApproval[]>('bridge/approvals/list');
587
725
  }
@@ -817,6 +955,76 @@ function normalizeCwd(cwd: string | null | undefined): string | null {
817
955
  return trimmed.length > 0 ? trimmed : null;
818
956
  }
819
957
 
958
+ function readWorkspaceListResponse(value: unknown): WorkspaceListResponse {
959
+ const record = toRecord(value) ?? {};
960
+ const workspacesRaw = Array.isArray(record.workspaces) ? record.workspaces : [];
961
+
962
+ return {
963
+ bridgeRoot: normalizeCwd(readString(record.bridgeRoot)) ?? '',
964
+ allowOutsideRootCwd: record.allowOutsideRootCwd === true,
965
+ workspaces: workspacesRaw
966
+ .map((entry) => {
967
+ const workspace = toRecord(entry);
968
+ if (!workspace) {
969
+ return null;
970
+ }
971
+
972
+ const path = normalizeCwd(readString(workspace.path));
973
+ if (!path) {
974
+ return null;
975
+ }
976
+
977
+ const rawChatCount = workspace.chatCount;
978
+ const chatCount =
979
+ typeof rawChatCount === 'number'
980
+ ? Math.max(0, Math.trunc(rawChatCount))
981
+ : typeof rawChatCount === 'string'
982
+ ? Math.max(0, Number.parseInt(rawChatCount, 10) || 0)
983
+ : 0;
984
+
985
+ return {
986
+ path,
987
+ chatCount,
988
+ };
989
+ })
990
+ .filter((entry): entry is WorkspaceListResponse['workspaces'][number] => entry !== null),
991
+ };
992
+ }
993
+
994
+ function readFileSystemListResponse(value: unknown): FileSystemListResponse {
995
+ const record = toRecord(value) ?? {};
996
+ const entriesRaw = Array.isArray(record.entries) ? record.entries : [];
997
+
998
+ return {
999
+ bridgeRoot: normalizeCwd(readString(record.bridgeRoot)) ?? '',
1000
+ path: normalizeCwd(readString(record.path)) ?? '',
1001
+ parentPath: normalizeCwd(readString(record.parentPath)) ?? null,
1002
+ entries: entriesRaw
1003
+ .map((entry) => {
1004
+ const item = toRecord(entry);
1005
+ if (!item) {
1006
+ return null;
1007
+ }
1008
+
1009
+ const path = normalizeCwd(readString(item.path));
1010
+ const name = normalizeCwd(readString(item.name));
1011
+ if (!path || !name) {
1012
+ return null;
1013
+ }
1014
+
1015
+ return {
1016
+ name,
1017
+ path,
1018
+ kind: readString(item.kind) ?? 'directory',
1019
+ hidden: item.hidden === true,
1020
+ selectable: item.selectable !== false,
1021
+ isGitRepo: item.isGitRepo === true,
1022
+ };
1023
+ })
1024
+ .filter((entry): entry is FileSystemListResponse['entries'][number] => entry !== null),
1025
+ };
1026
+ }
1027
+
820
1028
  function normalizeModel(model: string | null | undefined): string | null {
821
1029
  if (typeof model !== 'string') {
822
1030
  return null;
@@ -846,6 +1054,33 @@ function normalizeEffort(effort: string | null | undefined): ReasoningEffort | n
846
1054
  return null;
847
1055
  }
848
1056
 
1057
+ function normalizeServiceTier(
1058
+ serviceTier: ServiceTier | string | null | undefined
1059
+ ): ServiceTier | null {
1060
+ if (typeof serviceTier !== 'string') {
1061
+ return null;
1062
+ }
1063
+
1064
+ const normalized = serviceTier.trim().toLowerCase();
1065
+ if (normalized === 'flex' || normalized === 'fast') {
1066
+ return normalized;
1067
+ }
1068
+
1069
+ return null;
1070
+ }
1071
+
1072
+ function toThreadConfig(
1073
+ serviceTier: ServiceTier | null
1074
+ ): Record<string, ServiceTier> | null {
1075
+ if (!serviceTier) {
1076
+ return null;
1077
+ }
1078
+
1079
+ return {
1080
+ service_tier: serviceTier,
1081
+ };
1082
+ }
1083
+
849
1084
  function normalizeApprovalPolicy(
850
1085
  policy: string | null | undefined
851
1086
  ): ApprovalPolicy | null {
@@ -988,7 +1223,7 @@ function toTurnCollaborationMode(
988
1223
  }
989
1224
 
990
1225
  const normalized = value.trim().toLowerCase();
991
- if (normalized !== 'plan') {
1226
+ if (normalized !== 'plan' && normalized !== 'default') {
992
1227
  return null;
993
1228
  }
994
1229
 
@@ -997,7 +1232,7 @@ function toTurnCollaborationMode(
997
1232
  }
998
1233
 
999
1234
  return {
1000
- mode: 'plan',
1235
+ mode: normalized,
1001
1236
  settings: {
1002
1237
  model,
1003
1238
  reasoning_effort: effort,
@@ -1006,6 +1241,31 @@ function toTurnCollaborationMode(
1006
1241
  };
1007
1242
  }
1008
1243
 
1244
+ function normalizeCollaborationMode(
1245
+ value: CollaborationMode | string | null | undefined
1246
+ ): CollaborationMode | null {
1247
+ if (typeof value !== 'string') {
1248
+ return null;
1249
+ }
1250
+
1251
+ const normalized = value.trim().toLowerCase();
1252
+ if (normalized === 'plan' || normalized === 'default') {
1253
+ return normalized;
1254
+ }
1255
+
1256
+ return null;
1257
+ }
1258
+
1259
+ function readThreadRuntimeSettings(value: unknown): AppServerThreadRuntimeSettings {
1260
+ const record = toRecord(value);
1261
+ return {
1262
+ model: normalizeModel(readString(record?.model)),
1263
+ effort: normalizeEffort(
1264
+ readString(record?.reasoningEffort) ?? readString(record?.reasoning_effort)
1265
+ ),
1266
+ };
1267
+ }
1268
+
1009
1269
  function toReasoningEffortOptions(raw: unknown): ModelReasoningEffortOption[] {
1010
1270
  if (!Array.isArray(raw)) {
1011
1271
  return [];
@@ -0,0 +1,143 @@
1
+ import { readString, toRecord } from './chatMapping';
2
+ import type {
3
+ AccountCreditsSnapshot,
4
+ AccountRateLimitSnapshot,
5
+ AccountRateLimitWindow,
6
+ PlanType,
7
+ } from './types';
8
+
9
+ const PLAN_TYPES = new Set<PlanType>([
10
+ 'free',
11
+ 'go',
12
+ 'plus',
13
+ 'pro',
14
+ 'team',
15
+ 'business',
16
+ 'enterprise',
17
+ 'edu',
18
+ 'unknown',
19
+ ]);
20
+
21
+ export function readAccountRateLimits(value: unknown): AccountRateLimitSnapshot | null {
22
+ const record = toRecord(value);
23
+ if (!record) {
24
+ return null;
25
+ }
26
+
27
+ const byLimitId =
28
+ toRecord(record.rateLimitsByLimitId) ?? toRecord(record.rate_limits_by_limit_id);
29
+
30
+ if (byLimitId) {
31
+ const preferred = readAccountRateLimitSnapshot(byLimitId.codex);
32
+ if (preferred) {
33
+ return preferred;
34
+ }
35
+
36
+ for (const candidate of Object.values(byLimitId)) {
37
+ const snapshot = readAccountRateLimitSnapshot(candidate);
38
+ if (snapshot) {
39
+ return snapshot;
40
+ }
41
+ }
42
+ }
43
+
44
+ return readAccountRateLimitSnapshot(record.rateLimits ?? record.rate_limits);
45
+ }
46
+
47
+ export function readAccountRateLimitSnapshot(
48
+ value: unknown
49
+ ): AccountRateLimitSnapshot | null {
50
+ const record = toRecord(value);
51
+ if (!record) {
52
+ return null;
53
+ }
54
+
55
+ const primary = readAccountRateLimitWindow(record.primary);
56
+ const secondary = readAccountRateLimitWindow(record.secondary);
57
+ if (!primary && !secondary) {
58
+ return null;
59
+ }
60
+
61
+ return {
62
+ limitId: readString(record.limitId) ?? readString(record.limit_id),
63
+ limitName: readString(record.limitName) ?? readString(record.limit_name),
64
+ primary,
65
+ secondary,
66
+ credits: readAccountCreditsSnapshot(record.credits),
67
+ planType: readPlanType(record.planType ?? record.plan_type),
68
+ };
69
+ }
70
+
71
+ function readAccountRateLimitWindow(value: unknown): AccountRateLimitWindow | null {
72
+ const record = toRecord(value);
73
+ if (!record) {
74
+ return null;
75
+ }
76
+
77
+ const usedPercent = readNumberLike(record.usedPercent) ?? readNumberLike(record.used_percent);
78
+ if (usedPercent === null) {
79
+ return null;
80
+ }
81
+
82
+ return {
83
+ usedPercent,
84
+ windowDurationMins:
85
+ readIntegerLike(record.windowDurationMins) ??
86
+ readIntegerLike(record.window_duration_mins),
87
+ resetsAt: readIntegerLike(record.resetsAt) ?? readIntegerLike(record.resets_at),
88
+ };
89
+ }
90
+
91
+ function readAccountCreditsSnapshot(value: unknown): AccountCreditsSnapshot | null {
92
+ const record = toRecord(value);
93
+ if (!record) {
94
+ return null;
95
+ }
96
+
97
+ const hasCredits = readBoolean(record.hasCredits) ?? readBoolean(record.has_credits);
98
+ const unlimited = readBoolean(record.unlimited);
99
+ if (hasCredits === null && unlimited === null && !readString(record.balance)) {
100
+ return null;
101
+ }
102
+
103
+ return {
104
+ hasCredits: hasCredits ?? false,
105
+ unlimited: unlimited ?? false,
106
+ balance: readString(record.balance),
107
+ };
108
+ }
109
+
110
+ function readPlanType(value: unknown): PlanType | null {
111
+ if (typeof value !== 'string') {
112
+ return null;
113
+ }
114
+
115
+ return PLAN_TYPES.has(value as PlanType) ? (value as PlanType) : null;
116
+ }
117
+
118
+ function readBoolean(value: unknown): boolean | null {
119
+ return typeof value === 'boolean' ? value : null;
120
+ }
121
+
122
+ function readNumberLike(value: unknown): number | null {
123
+ if (typeof value === 'number' && Number.isFinite(value)) {
124
+ return value;
125
+ }
126
+
127
+ const stringValue = readString(value)?.trim();
128
+ if (!stringValue) {
129
+ return null;
130
+ }
131
+
132
+ const parsed = Number(stringValue);
133
+ return Number.isFinite(parsed) ? parsed : null;
134
+ }
135
+
136
+ function readIntegerLike(value: unknown): number | null {
137
+ const numericValue = readNumberLike(value);
138
+ if (numericValue === null) {
139
+ return null;
140
+ }
141
+
142
+ return Math.max(0, Math.floor(numericValue));
143
+ }