@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.21

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 (44) hide show
  1. package/README.md +32 -4
  2. package/dist/bin/ai.js +57 -291
  3. package/dist/src/agent-mode.js +1 -1
  4. package/dist/src/api/auth.js +2 -2
  5. package/dist/src/api/browser-login.js +72 -3
  6. package/dist/src/api/chat.js +236 -33
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/http.js +16 -3
  9. package/dist/src/api/models.js +9 -4
  10. package/dist/src/core/clipboard.js +7 -13
  11. package/dist/src/core/image-limits.js +56 -0
  12. package/dist/src/core/image-path-extractor.js +70 -3
  13. package/dist/src/core/session-image-store.js +199 -0
  14. package/dist/src/executor.js +1 -1
  15. package/dist/src/help-text.js +51 -11
  16. package/dist/src/permissions.js +243 -0
  17. package/dist/src/project-index.js +13 -1
  18. package/dist/src/session-store.js +119 -20
  19. package/dist/src/session.js +14 -3
  20. package/dist/src/tool-executor.js +2 -2
  21. package/dist/src/tools/delete-file.js +14 -0
  22. package/dist/src/tools/index.js +2 -0
  23. package/dist/src/tools/patch-file.js +12 -16
  24. package/dist/src/tools/read-image-file.js +85 -0
  25. package/dist/src/tools/replace-document-text.js +28 -18
  26. package/dist/src/tools/run-command.js +13 -27
  27. package/dist/src/tools/run-node-script.js +11 -26
  28. package/dist/src/tools/str-replace.js +12 -16
  29. package/dist/src/tools/write-file.js +66 -0
  30. package/dist/src/turn-failure-marker.js +11 -0
  31. package/dist/src/ui/prompt-history-store.js +1 -1
  32. package/dist/src/ui/repl.js +579 -154
  33. package/dist/src/ui/tui/bridge.js +10 -0
  34. package/dist/src/ui/tui/build-frame.js +535 -159
  35. package/dist/src/ui/tui/markdown-render.js +81 -73
  36. package/dist/src/ui/tui/shell-input.js +206 -63
  37. package/dist/src/ui/tui/terminal-theme.js +28 -0
  38. package/dist/src/ui/tui/terminal-title.js +3 -0
  39. package/dist/src/ui/tui/terminal-writes.js +48 -0
  40. package/dist/src/ui/tui/text.js +158 -4
  41. package/dist/src/ui/tui/user-input.js +568 -0
  42. package/dist/src/utils.js +9 -0
  43. package/package.json +18 -6
  44. package/dist/src/markdown-renderer.js +0 -112
@@ -1,11 +1,13 @@
1
1
  import { drainBackgroundJobNotifications } from '../background-jobs.js';
2
2
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
3
- import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
3
+ import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
4
4
  import { executeLocalToolCall } from '../tool-executor.js';
5
+ import { isUserInputQuestionArray } from './contracts.js';
5
6
  import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
6
7
  import { collectClientEnvironment } from '../client-environment.js';
7
8
  import { collectProjectOrientation } from '../project-orientation.js';
8
9
  import { autoAttachImages } from '../core/image-path-extractor.js';
10
+ import { formatTurnFailureMarker } from '../turn-failure-marker.js';
9
11
  export class TurnCancelledError extends Error {
10
12
  name = 'TurnCancelledError';
11
13
  constructor(message = 'Turn cancelled.') {
@@ -17,11 +19,13 @@ export class ChatTurnFailedError extends Error {
17
19
  category;
18
20
  retryable;
19
21
  traceId;
20
- constructor(message, category = 'unknown_error', retryable = false, traceId = '') {
22
+ partialSnapshot;
23
+ constructor(message, category = 'unknown_error', retryable = false, traceId = '', partialSnapshot) {
21
24
  super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
22
25
  this.category = category;
23
26
  this.retryable = retryable;
24
27
  this.traceId = traceId;
28
+ this.partialSnapshot = partialSnapshot;
25
29
  }
26
30
  }
27
31
  export function isTurnCancelledError(error) {
@@ -51,6 +55,7 @@ function parseSseBlock(block) {
51
55
  return { event, data: text };
52
56
  }
53
57
  }
58
+ let toolStateSeqCounter = 0;
54
59
  function toolStateFromSession(session) {
55
60
  return {
56
61
  autoYes: session.autoYes,
@@ -93,17 +98,30 @@ export function preserveCancelledTurnInput(session, input) {
93
98
  return;
94
99
  break;
95
100
  }
96
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
101
+ session.history.push({
102
+ role: 'user',
103
+ parts: [{ text }],
104
+ kind: 'turnStart',
105
+ userInput: text,
106
+ });
107
+ }
108
+ function appendTurnFailureMarker(session, category) {
109
+ session.history.push({
110
+ role: 'model',
111
+ parts: [{ text: formatTurnFailureMarker(category) }],
112
+ });
97
113
  }
98
114
  function preserveFailedTurnInput(session, input, category) {
99
115
  const text = input.trim();
100
116
  if (!text)
101
117
  return;
102
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
103
118
  session.history.push({
104
- role: 'model',
105
- parts: [{ text: `Turn failed before completion: ${category}.` }],
119
+ role: 'user',
120
+ parts: [{ text }],
121
+ kind: 'turnStart',
122
+ userInput: text,
106
123
  });
124
+ appendTurnFailureMarker(session, category);
107
125
  }
108
126
  function historyHasToolCall(session, callId) {
109
127
  return session.history.some((entry) => (entry.parts ?? []).some((part) => String(part?.functionCall?.id ?? '') === callId));
@@ -143,11 +161,16 @@ function publicStatusMessage(data) {
143
161
  ? event.toolName
144
162
  : 'tool';
145
163
  if (event.phase === 'thinking')
146
- return 'Thinking...';
164
+ return 'Exploring options...';
165
+ if (event.phase === 'analyzing_image') {
166
+ return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
167
+ }
147
168
  if (event.phase === 'running_tool')
148
169
  return `Running ${toolName}...`;
149
170
  if (event.phase === 'waiting_for_tool')
150
171
  return `Running ${toolName} locally...`;
172
+ if (event.phase === 'waiting_for_user_input')
173
+ return 'Waiting for your input...';
151
174
  return null;
152
175
  }
153
176
  function normalizeShellJobToolCall(call) {
@@ -184,6 +207,7 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
184
207
  toolCallId: event.call.id,
185
208
  result,
186
209
  toolState: toolStateFromSession(session),
210
+ toolStateSeq: ++toolStateSeqCounter,
187
211
  };
188
212
  const trace = createTraceContext(traceId);
189
213
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
@@ -202,6 +226,75 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
202
226
  throw await readErrorResponse(response, trace.traceId);
203
227
  }
204
228
  }
229
+ async function postUserInputResult({ config, turnId, requestId, result, fetchImpl, traceId, }) {
230
+ const payload = {
231
+ requestId,
232
+ result,
233
+ };
234
+ const trace = createTraceContext(traceId);
235
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/user-input-result`, {
236
+ method: 'POST',
237
+ headers: {
238
+ authorization: `Bearer ${config.token}`,
239
+ 'content-type': 'application/json',
240
+ ...trace.headers,
241
+ },
242
+ body: JSON.stringify(payload),
243
+ });
244
+ if (response.status === 410) {
245
+ return;
246
+ }
247
+ if (!response.ok) {
248
+ throw await readErrorResponse(response, trace.traceId);
249
+ }
250
+ }
251
+ export async function postInterjection({ config, turnId, text, messageId, imageAttachments = [], fetchImpl = globalThis.fetch, traceId, }) {
252
+ const payload = {
253
+ text,
254
+ messageId,
255
+ ...(imageAttachments.length > 0 ? { imageAttachments } : {}),
256
+ };
257
+ const trace = createTraceContext(traceId);
258
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
259
+ method: 'POST',
260
+ headers: {
261
+ authorization: `Bearer ${config.token}`,
262
+ 'content-type': 'application/json',
263
+ ...trace.headers,
264
+ },
265
+ body: JSON.stringify(payload),
266
+ });
267
+ if (response.status === 410) {
268
+ return 'stale';
269
+ }
270
+ if (!response.ok) {
271
+ throw await readErrorResponse(response, trace.traceId);
272
+ }
273
+ return 'delivered';
274
+ }
275
+ const turnIdOverrides = new WeakMap();
276
+ function enterServerTurnId(session, serverSessionTurnId) {
277
+ const active = turnIdOverrides.get(session);
278
+ if (active) {
279
+ active.depth += 1;
280
+ return;
281
+ }
282
+ turnIdOverrides.set(session, {
283
+ previousTurnId: session.turnState.id,
284
+ depth: 1,
285
+ });
286
+ session.turnState.id = serverSessionTurnId;
287
+ }
288
+ function exitServerTurnId(session) {
289
+ const active = turnIdOverrides.get(session);
290
+ if (!active)
291
+ return;
292
+ active.depth -= 1;
293
+ if (active.depth === 0) {
294
+ session.turnState.id = active.previousTurnId;
295
+ turnIdOverrides.delete(session);
296
+ }
297
+ }
205
298
  async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
206
299
  const turnId = String(event?.turnId ?? '').trim();
207
300
  if (!turnId || !event?.call?.id || !event.call.name) {
@@ -210,10 +303,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
210
303
  if (signal?.aborted) {
211
304
  throw new TurnCancelledError();
212
305
  }
213
- const previousTurnId = session.turnState.id;
214
306
  const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
215
307
  if (serverSessionTurnId) {
216
- session.turnState.id = serverSessionTurnId;
308
+ enterServerTurnId(session, serverSessionTurnId);
217
309
  if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
218
310
  createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
219
311
  }
@@ -236,10 +328,12 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
236
328
  });
237
329
  }
238
330
  finally {
239
- session.turnState.id = previousTurnId;
331
+ if (serverSessionTurnId) {
332
+ exitServerTurnId(session);
333
+ }
240
334
  }
241
335
  }
242
- async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
336
+ async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, onTurnStart, onInterjectionDelivered, }) {
243
337
  if (!response.body) {
244
338
  throw new Error('Server returned an empty chat stream.');
245
339
  }
@@ -249,8 +343,50 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
249
343
  const finalResult = {
250
344
  current: null,
251
345
  };
346
+ const pendingParallelTools = [];
347
+ let firstParallelFailure = null;
348
+ let rejectOnParallelFailure = null;
349
+ const parallelToolFailure = new Promise((_, reject) => {
350
+ rejectOnParallelFailure = reject;
351
+ });
352
+ parallelToolFailure.catch(() => { });
353
+ function recordParallelFailure(error) {
354
+ const failure = error ?? new Error('Local tool execution failed.');
355
+ if (firstParallelFailure == null) {
356
+ firstParallelFailure = failure;
357
+ rejectOnParallelFailure?.(failure);
358
+ }
359
+ return failure;
360
+ }
361
+ async function drainParallelTools() {
362
+ if (!pendingParallelTools.length)
363
+ return;
364
+ const pending = pendingParallelTools.splice(0);
365
+ const outcomes = await Promise.all(pending);
366
+ for (const outcome of outcomes) {
367
+ if (outcome != null)
368
+ throw outcome;
369
+ }
370
+ }
252
371
  async function handleEvent(event) {
372
+ if (event.event === 'turn-start') {
373
+ const turnId = String(event.data?.turnId ?? '').trim();
374
+ if (turnId)
375
+ onTurnStart?.(turnId);
376
+ return;
377
+ }
378
+ if (event.event === 'interjection-delivered') {
379
+ onInterjectionDelivered?.(event.data);
380
+ return;
381
+ }
253
382
  if (event.event === 'status') {
383
+ const data = event.data;
384
+ if (data?.phase === 'analyzing_image') {
385
+ session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
386
+ }
387
+ else if (data?.phase) {
388
+ session.onImageAnalysis?.(0);
389
+ }
254
390
  const message = publicStatusMessage(event.data);
255
391
  if (message)
256
392
  session.onStatus(message);
@@ -260,11 +396,26 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
260
396
  return;
261
397
  }
262
398
  if (event.event === 'tool-call') {
399
+ const data = event.data;
400
+ if (data?.parallelSafe === true) {
401
+ pendingParallelTools.push(executeAndPostToolResult({
402
+ config,
403
+ projectIndex,
404
+ session,
405
+ event: data,
406
+ input,
407
+ fetchImpl,
408
+ signal,
409
+ traceId,
410
+ }).then(() => null, (error) => recordParallelFailure(error)));
411
+ return;
412
+ }
413
+ await drainParallelTools();
263
414
  await executeAndPostToolResult({
264
415
  config,
265
416
  projectIndex,
266
417
  session,
267
- event: event.data,
418
+ event: data,
268
419
  input,
269
420
  fetchImpl,
270
421
  signal,
@@ -279,36 +430,75 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
279
430
  }
280
431
  return;
281
432
  }
433
+ if (event.event === 'user-input-request') {
434
+ await drainParallelTools();
435
+ const data = event.data;
436
+ const turnId = String(data?.turnId ?? '').trim();
437
+ const requestId = String(data?.requestId ?? '').trim();
438
+ if (!turnId ||
439
+ !requestId ||
440
+ !isUserInputQuestionArray(data?.questions)) {
441
+ throw new Error('Server emitted an invalid user-input request.');
442
+ }
443
+ if (!session.requestUserInput) {
444
+ throw new Error('Interactive user input is unavailable in this client.');
445
+ }
446
+ const result = await session.requestUserInput({ questions: data.questions }, signal);
447
+ if (signal?.aborted) {
448
+ throw new TurnCancelledError();
449
+ }
450
+ await postUserInputResult({
451
+ config,
452
+ turnId,
453
+ requestId,
454
+ result,
455
+ fetchImpl,
456
+ traceId,
457
+ });
458
+ return;
459
+ }
282
460
  if (event.event === 'result') {
461
+ await drainParallelTools();
283
462
  finalResult.current = event.data;
284
463
  return;
285
464
  }
286
465
  if (event.event === 'cancelled' || event.event === 'error') {
466
+ await drainParallelTools().catch(() => { });
287
467
  const message = String(event.data?.message ?? 'Server chat failed.');
288
468
  if (event.event === 'cancelled') {
289
469
  throw new TurnCancelledError(message);
290
470
  }
291
- throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId);
471
+ throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId, event.data?.snapshot);
292
472
  }
293
473
  }
294
- while (true) {
295
- if (signal?.aborted) {
296
- await reader.cancel().catch(() => { });
297
- throw new TurnCancelledError();
298
- }
299
- const read = await reader.read();
300
- if (read.done)
301
- break;
302
- buffer += decoder.decode(read.value, { stream: true });
303
- let separatorIndex = buffer.indexOf('\n\n');
304
- while (separatorIndex !== -1) {
305
- const block = buffer.slice(0, separatorIndex);
306
- buffer = buffer.slice(separatorIndex + 2);
307
- const event = parseSseBlock(block);
308
- if (event)
309
- await handleEvent(event);
310
- separatorIndex = buffer.indexOf('\n\n');
474
+ try {
475
+ while (true) {
476
+ if (signal?.aborted) {
477
+ await reader.cancel().catch(() => { });
478
+ throw new TurnCancelledError();
479
+ }
480
+ const read = await Promise.race([reader.read(), parallelToolFailure]);
481
+ if (read.done)
482
+ break;
483
+ buffer += decoder.decode(read.value, { stream: true });
484
+ let separatorIndex = buffer.indexOf('\n\n');
485
+ while (separatorIndex !== -1) {
486
+ const block = buffer.slice(0, separatorIndex);
487
+ buffer = buffer.slice(separatorIndex + 2);
488
+ const event = parseSseBlock(block);
489
+ if (event)
490
+ await handleEvent(event);
491
+ separatorIndex = buffer.indexOf('\n\n');
492
+ }
311
493
  }
494
+ await drainParallelTools();
495
+ }
496
+ catch (error) {
497
+ await reader.cancel().catch(() => { });
498
+ throw error;
499
+ }
500
+ finally {
501
+ await drainParallelTools().catch(() => { });
312
502
  }
313
503
  buffer += decoder.decode();
314
504
  const tail = buffer.trim();
@@ -322,7 +512,7 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
322
512
  }
323
513
  return finalResult.current;
324
514
  }
325
- export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
515
+ export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, }) {
326
516
  const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
327
517
  const requestImageAttachments = autoAttach.attachments.length > 0
328
518
  ? [...imageAttachments, ...autoAttach.attachments]
@@ -379,6 +569,8 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
379
569
  fetchImpl,
380
570
  signal,
381
571
  traceId: trace.traceId,
572
+ onTurnStart,
573
+ onInterjectionDelivered,
382
574
  });
383
575
  applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
384
576
  return {
@@ -395,8 +587,19 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
395
587
  ? error
396
588
  : new TurnCancelledError();
397
589
  }
398
- session.history.length = preTurnHistoryLength;
399
- preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
590
+ const category = error instanceof ChatTurnFailedError ? error.category : 'unknown_error';
591
+ const partial = error instanceof ChatTurnFailedError ? error.partialSnapshot : undefined;
592
+ if (partial) {
593
+ applySessionSnapshot(session, partial, { preserveAgentMode: true });
594
+ }
595
+ else {
596
+ session.history.length = preTurnHistoryLength;
597
+ preserveFailedTurnInput(session, requestInputBase, category);
598
+ }
599
+ try {
600
+ saveSessionState(session, session.env);
601
+ }
602
+ catch { }
400
603
  throw error;
401
604
  }
402
605
  finally {
@@ -1 +1,55 @@
1
- export {};
1
+ function nonEmptyString(value) {
2
+ return typeof value === 'string' && value.trim().length > 0;
3
+ }
4
+ const USER_INPUT_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
5
+ export function isUserInputQuestionArray(value) {
6
+ if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
7
+ return false;
8
+ }
9
+ const questionIds = new Set();
10
+ return value.every((question) => {
11
+ if (!question || typeof question !== 'object' || Array.isArray(question)) {
12
+ return false;
13
+ }
14
+ const candidate = question;
15
+ if (!nonEmptyString(candidate.id) ||
16
+ !USER_INPUT_ID_PATTERN.test(candidate.id) ||
17
+ questionIds.has(candidate.id) ||
18
+ !nonEmptyString(candidate.header) ||
19
+ Array.from(candidate.header).length > 12 ||
20
+ !nonEmptyString(candidate.question) ||
21
+ typeof candidate.multiSelect !== 'boolean' ||
22
+ !Array.isArray(candidate.options) ||
23
+ candidate.options.length < 2 ||
24
+ candidate.options.length > 4) {
25
+ return false;
26
+ }
27
+ questionIds.add(candidate.id);
28
+ const optionIds = new Set();
29
+ const optionsValid = candidate.options.every((option) => {
30
+ if (!option || typeof option !== 'object' || Array.isArray(option)) {
31
+ return false;
32
+ }
33
+ const item = option;
34
+ if (!nonEmptyString(item.id) ||
35
+ !USER_INPUT_ID_PATTERN.test(item.id) ||
36
+ optionIds.has(item.id) ||
37
+ !nonEmptyString(item.label) ||
38
+ !nonEmptyString(item.description)) {
39
+ return false;
40
+ }
41
+ optionIds.add(item.id);
42
+ return true;
43
+ });
44
+ if (!optionsValid) {
45
+ return false;
46
+ }
47
+ if (candidate.recommendedOptionId !== undefined &&
48
+ (!nonEmptyString(candidate.recommendedOptionId) ||
49
+ candidate.recommendedOptionId !==
50
+ candidate.options[0].id)) {
51
+ return false;
52
+ }
53
+ return true;
54
+ });
55
+ }
@@ -6,11 +6,13 @@ export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
6
6
  export class ServerApiError extends Error {
7
7
  status;
8
8
  traceId;
9
- constructor(message, status, traceId) {
9
+ code;
10
+ constructor(message, status, traceId, code = '') {
10
11
  super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
11
12
  this.name = 'ServerApiError';
12
13
  this.status = status;
13
14
  this.traceId = traceId;
15
+ this.code = code;
14
16
  }
15
17
  }
16
18
  export function createTraceId() {
@@ -96,9 +98,20 @@ export async function readJsonResponse(response) {
96
98
  export function failureMessage(data, status) {
97
99
  return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
98
100
  }
101
+ export function failureCode(data) {
102
+ return typeof data?.error?.code === 'string' ? data.error.code : '';
103
+ }
104
+ export function isAuthenticationError(error) {
105
+ return error instanceof ServerApiError && error.status === 401;
106
+ }
107
+ export function authenticationErrorMessage(error) {
108
+ return error.code === 'AUTH_TOKEN_EXPIRED'
109
+ ? 'Your login expired after 24 hours of inactivity. Run `ai login` and resume this saved session.'
110
+ : 'Your login is no longer valid. Run `ai login` and resume this saved session.';
111
+ }
99
112
  export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
100
113
  const data = await readJsonResponse(response);
101
- return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
114
+ return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
102
115
  }
103
116
  export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
104
117
  const trace = createTraceContext();
@@ -115,7 +128,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
115
128
  });
116
129
  const data = await readJsonResponse(response);
117
130
  if (!response.ok) {
118
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
131
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
119
132
  }
120
133
  return data;
121
134
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
- import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
4
+ import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
5
5
  function sanitizeModelInfo(raw) {
6
6
  if (!raw || typeof raw !== 'object') {
7
7
  return null;
@@ -9,10 +9,15 @@ function sanitizeModelInfo(raw) {
9
9
  const value = raw;
10
10
  const id = Number(value.id);
11
11
  const label = String(value.label ?? '').trim();
12
- if (!Number.isInteger(id) || id <= 0 || !label) {
12
+ const costRating = Number(value.costRating);
13
+ const description = String(value.description ?? '').trim();
14
+ if (!Number.isInteger(id) || id <= 0 || !label || !isCostRating(costRating)) {
13
15
  return null;
14
16
  }
15
- return { id, label };
17
+ return { id, label, costRating, description };
18
+ }
19
+ function isCostRating(value) {
20
+ return Number.isInteger(value) && value >= 1 && value <= 3;
16
21
  }
17
22
  export function getModelsCachePath(env = process.env) {
18
23
  return path.join(getClientStateDir(env), 'models.json');
@@ -70,7 +75,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
70
75
  });
71
76
  const data = (await readJsonResponse(response));
72
77
  if (!response.ok) {
73
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
78
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
74
79
  }
75
80
  const models = Array.isArray(data?.models)
76
81
  ? data.models.map(sanitizeModelInfo).filter(Boolean)
@@ -1,15 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
5
- const MIME_BY_EXT = {
6
- '.png': 'image/png',
7
- '.jpg': 'image/jpeg',
8
- '.jpeg': 'image/jpeg',
9
- '.gif': 'image/gif',
10
- '.webp': 'image/webp',
11
- };
12
- const SUPPORTED_MIME_TYPES = new Set(Object.values(MIME_BY_EXT));
4
+ import { MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_MIME_TYPES as SUPPORTED_MIME_TYPES, sniffImageMimeType, } from './image-limits.js';
13
5
  export class ClipboardError extends Error {
14
6
  code;
15
7
  constructor(message, code) {
@@ -273,11 +265,13 @@ export function loadImageFromFile(filePath) {
273
265
  if (stat.size > MAX_IMAGE_SIZE_BYTES) {
274
266
  throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
275
267
  }
276
- const ext = path.extname(resolved).toLowerCase();
277
- const mimeType = MIME_BY_EXT[ext];
268
+ const buf = readFileSync(resolved);
269
+ const mimeType = sniffImageMimeType(buf);
278
270
  if (!mimeType) {
279
- throw new ClipboardError(`Unsupported image format "${ext}". Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
271
+ const ext = path.extname(resolved).toLowerCase();
272
+ throw new ClipboardError(ext
273
+ ? `"${path.basename(resolved)}" is named ${ext} but its contents are not a supported image. Supported: PNG, JPEG, GIF, WebP.`
274
+ : `Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
280
275
  }
281
- const buf = readFileSync(resolved);
282
276
  return { base64Data: buf.toString('base64'), mimeType };
283
277
  }
@@ -0,0 +1,56 @@
1
+ export const MAX_IMAGES_PER_MESSAGE = 5;
2
+ export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
3
+ export const MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE = 20 * 1024 * 1024;
4
+ export function approximateBase64DecodedBytes(base64) {
5
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
6
+ return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
7
+ }
8
+ export function totalAttachmentBytes(attachments) {
9
+ return attachments.reduce((sum, attachment) => sum + approximateBase64DecodedBytes(attachment.base64Data), 0);
10
+ }
11
+ export const SUPPORTED_IMAGE_MIME_TYPES = new Set([
12
+ 'image/png',
13
+ 'image/jpeg',
14
+ 'image/gif',
15
+ 'image/webp',
16
+ ]);
17
+ export const IMAGE_MIME_BY_EXT = {
18
+ '.png': 'image/png',
19
+ '.jpg': 'image/jpeg',
20
+ '.jpeg': 'image/jpeg',
21
+ '.gif': 'image/gif',
22
+ '.webp': 'image/webp',
23
+ };
24
+ export const IMAGE_EXT_BY_MIME = {
25
+ 'image/png': '.png',
26
+ 'image/jpeg': '.jpg',
27
+ 'image/gif': '.gif',
28
+ 'image/webp': '.webp',
29
+ };
30
+ export function isSupportedImageMimeType(mime) {
31
+ return SUPPORTED_IMAGE_MIME_TYPES.has(mime);
32
+ }
33
+ export function sniffImageMimeType(bytes) {
34
+ if (bytes.length < 12)
35
+ return null;
36
+ if (bytes[0] === 0x89 &&
37
+ bytes[1] === 0x50 &&
38
+ bytes[2] === 0x4e &&
39
+ bytes[3] === 0x47) {
40
+ return 'image/png';
41
+ }
42
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
43
+ return 'image/jpeg';
44
+ }
45
+ if (bytes.subarray(0, 3).toString('latin1') === 'GIF') {
46
+ return 'image/gif';
47
+ }
48
+ if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
49
+ bytes.subarray(8, 12).toString('latin1') === 'WEBP') {
50
+ return 'image/webp';
51
+ }
52
+ return null;
53
+ }
54
+ export function imageExtensionForMime(mime) {
55
+ return IMAGE_EXT_BY_MIME[mime] ?? '.png';
56
+ }