@thegitai/cli 1.0.0-preview.3 → 1.0.0-preview.31

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 (58) hide show
  1. package/README.md +39 -6
  2. package/dist/bin/ai.js +142 -383
  3. package/dist/src/agent-mode.js +1 -6
  4. package/dist/src/api/auth.js +6 -4
  5. package/dist/src/api/browser-login.js +152 -37
  6. package/dist/src/api/chat.js +258 -38
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/default-host.js +1 -0
  9. package/dist/src/api/http.js +69 -7
  10. package/dist/src/api/models.js +19 -10
  11. package/dist/src/background-jobs.js +2 -2
  12. package/dist/src/cli-args.js +19 -5
  13. package/dist/src/core/clipboard.js +7 -13
  14. package/dist/src/core/image-limits.js +56 -0
  15. package/dist/src/core/image-path-extractor.js +70 -3
  16. package/dist/src/core/session-image-store.js +199 -0
  17. package/dist/src/executor.js +25 -3
  18. package/dist/src/help-text.js +67 -18
  19. package/dist/src/permissions.js +243 -0
  20. package/dist/src/session-safety.js +0 -12
  21. package/dist/src/session-store.js +121 -20
  22. package/dist/src/session.js +14 -3
  23. package/dist/src/signin.js +58 -0
  24. package/dist/src/tool-executor.js +11 -46
  25. package/dist/src/tools/delete-file.js +15 -3
  26. package/dist/src/tools/index.js +13 -10
  27. package/dist/src/tools/patch-file.js +12 -26
  28. package/dist/src/tools/read-image-file.js +85 -0
  29. package/dist/src/tools/replace-document-text.js +28 -18
  30. package/dist/src/tools/restore-checkpoint.js +0 -1
  31. package/dist/src/tools/run-command.js +14 -71
  32. package/dist/src/tools/run-node-script.js +12 -81
  33. package/dist/src/tools/save-generated-image.js +120 -0
  34. package/dist/src/tools/str-replace.js +12 -26
  35. package/dist/src/tools/undo-edit.js +1 -6
  36. package/dist/src/tools/write-file.js +67 -11
  37. package/dist/src/turn-failure-marker.js +11 -0
  38. package/dist/src/ui/prompt-history-store.js +1 -1
  39. package/dist/src/ui/repl.js +649 -164
  40. package/dist/src/ui/tui/bridge.js +10 -0
  41. package/dist/src/ui/tui/build-frame.js +453 -115
  42. package/dist/src/ui/tui/markdown-render.js +81 -73
  43. package/dist/src/ui/tui/shell-input.js +206 -63
  44. package/dist/src/ui/tui/terminal-theme.js +28 -0
  45. package/dist/src/ui/tui/terminal-title.js +3 -0
  46. package/dist/src/ui/tui/terminal-writes.js +48 -0
  47. package/dist/src/ui/tui/text.js +158 -4
  48. package/dist/src/ui/tui/user-input.js +568 -0
  49. package/dist/src/utils.js +9 -0
  50. package/package.json +29 -6
  51. package/dist/src/markdown-renderer.js +0 -112
  52. package/dist/src/project-index.js +0 -221
  53. package/dist/src/tools/code-intel.js +0 -472
  54. package/dist/src/tools/find-symbol.js +0 -70
  55. package/dist/src/tools/hover-symbol.js +0 -95
  56. package/dist/src/tools/list-symbols.js +0 -55
  57. package/dist/src/tools/search-code.js +0 -37
  58. package/dist/src/tools/signature-help.js +0 -118
@@ -1,11 +1,14 @@
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 { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
5
+ import { saveGeneratedImage } from '../tools/save-generated-image.js';
6
+ import { isUserInputQuestionArray } from './contracts.js';
7
+ import { createTraceContext, gatewayFailureCategory, normalizeServerUrl, readErrorResponse, } from './http.js';
6
8
  import { collectClientEnvironment } from '../client-environment.js';
7
9
  import { collectProjectOrientation } from '../project-orientation.js';
8
10
  import { autoAttachImages } from '../core/image-path-extractor.js';
11
+ import { formatTurnFailureMarker } from '../turn-failure-marker.js';
9
12
  export class TurnCancelledError extends Error {
10
13
  name = 'TurnCancelledError';
11
14
  constructor(message = 'Turn cancelled.') {
@@ -17,11 +20,13 @@ export class ChatTurnFailedError extends Error {
17
20
  category;
18
21
  retryable;
19
22
  traceId;
20
- constructor(message, category = 'unknown_error', retryable = false, traceId = '') {
23
+ partialSnapshot;
24
+ constructor(message, category = 'unknown_error', retryable = false, traceId = '', partialSnapshot) {
21
25
  super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
22
26
  this.category = category;
23
27
  this.retryable = retryable;
24
28
  this.traceId = traceId;
29
+ this.partialSnapshot = partialSnapshot;
25
30
  }
26
31
  }
27
32
  export function isTurnCancelledError(error) {
@@ -51,6 +56,7 @@ function parseSseBlock(block) {
51
56
  return { event, data: text };
52
57
  }
53
58
  }
59
+ let toolStateSeqCounter = 0;
54
60
  function toolStateFromSession(session) {
55
61
  return {
56
62
  autoYes: session.autoYes,
@@ -93,17 +99,30 @@ export function preserveCancelledTurnInput(session, input) {
93
99
  return;
94
100
  break;
95
101
  }
96
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
102
+ session.history.push({
103
+ role: 'user',
104
+ parts: [{ text }],
105
+ kind: 'turnStart',
106
+ userInput: text,
107
+ });
108
+ }
109
+ function appendTurnFailureMarker(session, category) {
110
+ session.history.push({
111
+ role: 'model',
112
+ parts: [{ text: formatTurnFailureMarker(category) }],
113
+ });
97
114
  }
98
115
  function preserveFailedTurnInput(session, input, category) {
99
116
  const text = input.trim();
100
117
  if (!text)
101
118
  return;
102
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
103
119
  session.history.push({
104
- role: 'model',
105
- parts: [{ text: `Turn failed before completion: ${category}.` }],
120
+ role: 'user',
121
+ parts: [{ text }],
122
+ kind: 'turnStart',
123
+ userInput: text,
106
124
  });
125
+ appendTurnFailureMarker(session, category);
107
126
  }
108
127
  function historyHasToolCall(session, callId) {
109
128
  return session.history.some((entry) => (entry.parts ?? []).some((part) => String(part?.functionCall?.id ?? '') === callId));
@@ -143,11 +162,19 @@ function publicStatusMessage(data) {
143
162
  ? event.toolName
144
163
  : 'tool';
145
164
  if (event.phase === 'thinking')
146
- return 'Thinking...';
165
+ return 'Exploring options...';
166
+ if (event.phase === 'analyzing_image') {
167
+ return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
168
+ }
169
+ if (event.phase === 'generating_image') {
170
+ return 'Generating image...';
171
+ }
147
172
  if (event.phase === 'running_tool')
148
173
  return `Running ${toolName}...`;
149
174
  if (event.phase === 'waiting_for_tool')
150
175
  return `Running ${toolName} locally...`;
176
+ if (event.phase === 'waiting_for_user_input')
177
+ return 'Waiting for your input...';
151
178
  return null;
152
179
  }
153
180
  function normalizeShellJobToolCall(call) {
@@ -184,6 +211,7 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
184
211
  toolCallId: event.call.id,
185
212
  result,
186
213
  toolState: toolStateFromSession(session),
214
+ toolStateSeq: ++toolStateSeqCounter,
187
215
  };
188
216
  const trace = createTraceContext(traceId);
189
217
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
@@ -202,7 +230,76 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
202
230
  throw await readErrorResponse(response, trace.traceId);
203
231
  }
204
232
  }
205
- async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
233
+ async function postUserInputResult({ config, turnId, requestId, result, fetchImpl, traceId, }) {
234
+ const payload = {
235
+ requestId,
236
+ result,
237
+ };
238
+ const trace = createTraceContext(traceId);
239
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/user-input-result`, {
240
+ method: 'POST',
241
+ headers: {
242
+ authorization: `Bearer ${config.token}`,
243
+ 'content-type': 'application/json',
244
+ ...trace.headers,
245
+ },
246
+ body: JSON.stringify(payload),
247
+ });
248
+ if (response.status === 410) {
249
+ return;
250
+ }
251
+ if (!response.ok) {
252
+ throw await readErrorResponse(response, trace.traceId);
253
+ }
254
+ }
255
+ export async function postInterjection({ config, turnId, text, messageId, imageAttachments = [], fetchImpl = globalThis.fetch, traceId, }) {
256
+ const payload = {
257
+ text,
258
+ messageId,
259
+ ...(imageAttachments.length > 0 ? { imageAttachments } : {}),
260
+ };
261
+ const trace = createTraceContext(traceId);
262
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/interject`, {
263
+ method: 'POST',
264
+ headers: {
265
+ authorization: `Bearer ${config.token}`,
266
+ 'content-type': 'application/json',
267
+ ...trace.headers,
268
+ },
269
+ body: JSON.stringify(payload),
270
+ });
271
+ if (response.status === 410) {
272
+ return 'stale';
273
+ }
274
+ if (!response.ok) {
275
+ throw await readErrorResponse(response, trace.traceId);
276
+ }
277
+ return 'delivered';
278
+ }
279
+ const turnIdOverrides = new WeakMap();
280
+ function enterServerTurnId(session, serverSessionTurnId) {
281
+ const active = turnIdOverrides.get(session);
282
+ if (active) {
283
+ active.depth += 1;
284
+ return;
285
+ }
286
+ turnIdOverrides.set(session, {
287
+ previousTurnId: session.turnState.id,
288
+ depth: 1,
289
+ });
290
+ session.turnState.id = serverSessionTurnId;
291
+ }
292
+ function exitServerTurnId(session) {
293
+ const active = turnIdOverrides.get(session);
294
+ if (!active)
295
+ return;
296
+ active.depth -= 1;
297
+ if (active.depth === 0) {
298
+ session.turnState.id = active.previousTurnId;
299
+ turnIdOverrides.delete(session);
300
+ }
301
+ }
302
+ async function executeAndPostToolResult({ config, session, event, input, fetchImpl, signal, traceId, }) {
206
303
  const turnId = String(event?.turnId ?? '').trim();
207
304
  if (!turnId || !event?.call?.id || !event.call.name) {
208
305
  throw new Error('Server emitted an invalid tool-call event.');
@@ -210,17 +307,23 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
210
307
  if (signal?.aborted) {
211
308
  throw new TurnCancelledError();
212
309
  }
213
- const previousTurnId = session.turnState.id;
214
310
  const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
215
311
  if (serverSessionTurnId) {
216
- session.turnState.id = serverSessionTurnId;
312
+ enterServerTurnId(session, serverSessionTurnId);
217
313
  if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
218
314
  createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
219
315
  }
220
316
  }
221
317
  try {
222
318
  const call = normalizeShellJobToolCall(event.call);
223
- const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
319
+ const rawResult = event.generatedImage
320
+ ? saveGeneratedImage({
321
+ base64Data: event.generatedImage.base64Data,
322
+ mimeType: event.generatedImage.mimeType,
323
+ suggestedFilename: event.generatedImage.suggestedFilename ||
324
+ String(call.args?.filename ?? call.args?.file_name ?? ''),
325
+ })
326
+ : await executeLocalToolCall(session, call);
224
327
  preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
225
328
  if (signal?.aborted) {
226
329
  throw new TurnCancelledError();
@@ -236,10 +339,12 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
236
339
  });
237
340
  }
238
341
  finally {
239
- session.turnState.id = previousTurnId;
342
+ if (serverSessionTurnId) {
343
+ exitServerTurnId(session);
344
+ }
240
345
  }
241
346
  }
242
- async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
347
+ async function consumeTurnStream({ response, config, session, input, fetchImpl, signal, traceId, onTurnStart, onInterjectionDelivered, }) {
243
348
  if (!response.body) {
244
349
  throw new Error('Server returned an empty chat stream.');
245
350
  }
@@ -249,8 +354,56 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
249
354
  const finalResult = {
250
355
  current: null,
251
356
  };
357
+ const pendingParallelTools = [];
358
+ let firstParallelFailure = null;
359
+ let rejectOnParallelFailure = null;
360
+ const parallelToolFailure = new Promise((_, reject) => {
361
+ rejectOnParallelFailure = reject;
362
+ });
363
+ parallelToolFailure.catch(() => { });
364
+ function recordParallelFailure(error) {
365
+ const failure = error ?? new Error('Local tool execution failed.');
366
+ if (firstParallelFailure == null) {
367
+ firstParallelFailure = failure;
368
+ rejectOnParallelFailure?.(failure);
369
+ }
370
+ return failure;
371
+ }
372
+ async function drainParallelTools() {
373
+ if (!pendingParallelTools.length)
374
+ return;
375
+ const pending = pendingParallelTools.splice(0);
376
+ const outcomes = await Promise.all(pending);
377
+ for (const outcome of outcomes) {
378
+ if (outcome != null)
379
+ throw outcome;
380
+ }
381
+ }
252
382
  async function handleEvent(event) {
383
+ if (event.event === 'turn-start') {
384
+ const turnId = String(event.data?.turnId ?? '').trim();
385
+ if (turnId)
386
+ onTurnStart?.(turnId);
387
+ return;
388
+ }
389
+ if (event.event === 'interjection-delivered') {
390
+ onInterjectionDelivered?.(event.data);
391
+ return;
392
+ }
253
393
  if (event.event === 'status') {
394
+ const data = event.data;
395
+ if (data?.phase === 'analyzing_image') {
396
+ session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
397
+ session.onImageGeneration?.(false);
398
+ }
399
+ else if (data?.phase === 'generating_image') {
400
+ session.onImageGeneration?.(true);
401
+ session.onImageAnalysis?.(0);
402
+ }
403
+ else if (data?.phase) {
404
+ session.onImageAnalysis?.(0);
405
+ session.onImageGeneration?.(false);
406
+ }
254
407
  const message = publicStatusMessage(event.data);
255
408
  if (message)
256
409
  session.onStatus(message);
@@ -260,11 +413,24 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
260
413
  return;
261
414
  }
262
415
  if (event.event === 'tool-call') {
416
+ const data = event.data;
417
+ if (data?.parallelSafe === true) {
418
+ pendingParallelTools.push(executeAndPostToolResult({
419
+ config,
420
+ session,
421
+ event: data,
422
+ input,
423
+ fetchImpl,
424
+ signal,
425
+ traceId,
426
+ }).then(() => null, (error) => recordParallelFailure(error)));
427
+ return;
428
+ }
429
+ await drainParallelTools();
263
430
  await executeAndPostToolResult({
264
431
  config,
265
- projectIndex,
266
432
  session,
267
- event: event.data,
433
+ event: data,
268
434
  input,
269
435
  fetchImpl,
270
436
  signal,
@@ -279,36 +445,75 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
279
445
  }
280
446
  return;
281
447
  }
448
+ if (event.event === 'user-input-request') {
449
+ await drainParallelTools();
450
+ const data = event.data;
451
+ const turnId = String(data?.turnId ?? '').trim();
452
+ const requestId = String(data?.requestId ?? '').trim();
453
+ if (!turnId ||
454
+ !requestId ||
455
+ !isUserInputQuestionArray(data?.questions)) {
456
+ throw new Error('Server emitted an invalid user-input request.');
457
+ }
458
+ if (!session.requestUserInput) {
459
+ throw new Error('Interactive user input is unavailable in this client.');
460
+ }
461
+ const result = await session.requestUserInput({ questions: data.questions }, signal);
462
+ if (signal?.aborted) {
463
+ throw new TurnCancelledError();
464
+ }
465
+ await postUserInputResult({
466
+ config,
467
+ turnId,
468
+ requestId,
469
+ result,
470
+ fetchImpl,
471
+ traceId,
472
+ });
473
+ return;
474
+ }
282
475
  if (event.event === 'result') {
476
+ await drainParallelTools();
283
477
  finalResult.current = event.data;
284
478
  return;
285
479
  }
286
480
  if (event.event === 'cancelled' || event.event === 'error') {
481
+ await drainParallelTools().catch(() => { });
287
482
  const message = String(event.data?.message ?? 'Server chat failed.');
288
483
  if (event.event === 'cancelled') {
289
484
  throw new TurnCancelledError(message);
290
485
  }
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);
486
+ 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
487
  }
293
488
  }
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');
489
+ try {
490
+ while (true) {
491
+ if (signal?.aborted) {
492
+ await reader.cancel().catch(() => { });
493
+ throw new TurnCancelledError();
494
+ }
495
+ const read = await Promise.race([reader.read(), parallelToolFailure]);
496
+ if (read.done)
497
+ break;
498
+ buffer += decoder.decode(read.value, { stream: true });
499
+ let separatorIndex = buffer.indexOf('\n\n');
500
+ while (separatorIndex !== -1) {
501
+ const block = buffer.slice(0, separatorIndex);
502
+ buffer = buffer.slice(separatorIndex + 2);
503
+ const event = parseSseBlock(block);
504
+ if (event)
505
+ await handleEvent(event);
506
+ separatorIndex = buffer.indexOf('\n\n');
507
+ }
311
508
  }
509
+ await drainParallelTools();
510
+ }
511
+ catch (error) {
512
+ await reader.cancel().catch(() => { });
513
+ throw error;
514
+ }
515
+ finally {
516
+ await drainParallelTools().catch(() => { });
312
517
  }
313
518
  buffer += decoder.decode();
314
519
  const tail = buffer.trim();
@@ -322,7 +527,7 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
322
527
  }
323
528
  return finalResult.current;
324
529
  }
325
- export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
530
+ export async function sendServerUserMessage({ config, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, }) {
326
531
  const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
327
532
  const requestImageAttachments = autoAttach.attachments.length > 0
328
533
  ? [...imageAttachments, ...autoAttach.attachments]
@@ -373,12 +578,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
373
578
  const result = await consumeTurnStream({
374
579
  response,
375
580
  config,
376
- projectIndex,
377
581
  session,
378
582
  input: requestInputBase,
379
583
  fetchImpl,
380
584
  signal,
381
585
  traceId: trace.traceId,
586
+ onTurnStart,
587
+ onInterjectionDelivered,
382
588
  });
383
589
  applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
384
590
  return {
@@ -395,8 +601,22 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
395
601
  ? error
396
602
  : new TurnCancelledError();
397
603
  }
398
- session.history.length = preTurnHistoryLength;
399
- preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
604
+ const category = error instanceof ChatTurnFailedError
605
+ ? error.category
606
+ :
607
+ gatewayFailureCategory(error) ?? 'unknown_error';
608
+ const partial = error instanceof ChatTurnFailedError ? error.partialSnapshot : undefined;
609
+ if (partial) {
610
+ applySessionSnapshot(session, partial, { preserveAgentMode: true });
611
+ }
612
+ else {
613
+ session.history.length = preTurnHistoryLength;
614
+ preserveFailedTurnInput(session, requestInputBase, category);
615
+ }
616
+ try {
617
+ saveSessionState(session, session.env);
618
+ }
619
+ catch { }
400
620
  throw error;
401
621
  }
402
622
  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
+ }
@@ -0,0 +1 @@
1
+ export const DEFAULT_THEGITAI_HOST = 'https://thegit.ai';
@@ -1,16 +1,18 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- const DEFAULT_SERVER_URL = 'https://thegit.ai';
2
+ import { DEFAULT_THEGITAI_HOST } from './default-host.js';
3
3
  export const TRACE_ID_HEADER = 'x-thegitai-trace-id';
4
4
  export const CLIENT_HEADER = 'x-thegitai-client';
5
5
  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() {
@@ -27,6 +29,14 @@ export function createTraceContext(traceId = createTraceId()) {
27
29
  };
28
30
  }
29
31
  export const REQUEST_TIMEOUT_MS = 8000;
32
+ export const STARTUP_REQUEST_TIMEOUT_MS = 3000;
33
+ export const STARTUP_DEADLINE_MS = 10_000;
34
+ export const STARTUP_RETRY_BUDGET = {
35
+ retries: 3,
36
+ baseDelayMs: 200,
37
+ deadlineMs: STARTUP_DEADLINE_MS,
38
+ timeoutMs: STARTUP_REQUEST_TIMEOUT_MS,
39
+ };
30
40
  const TRANSIENT_NETWORK_CODES = new Set([
31
41
  'ECONNRESET',
32
42
  'ECONNREFUSED',
@@ -58,7 +68,9 @@ export function isTransientNetworkError(error) {
58
68
  }
59
69
  return false;
60
70
  }
61
- export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {}) {
71
+ export async function retryTransient(run, { retries = 2, baseDelayMs = 400, deadlineMs, now = () => Date.now(), } = {}) {
72
+ const startedAt = now();
73
+ const remainingMs = () => deadlineMs == null ? Infinity : deadlineMs - (now() - startedAt);
62
74
  let attempt = 0;
63
75
  for (;;) {
64
76
  try {
@@ -68,13 +80,15 @@ export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {
68
80
  if (attempt >= retries || !isTransientNetworkError(error))
69
81
  throw error;
70
82
  const delayMs = baseDelayMs * 2 ** attempt;
83
+ if (remainingMs() <= delayMs)
84
+ throw error;
71
85
  attempt += 1;
72
86
  await new Promise((resolve) => setTimeout(resolve, delayMs));
73
87
  }
74
88
  }
75
89
  }
76
90
  export function normalizeServerUrl(serverUrl) {
77
- const normalized = String(serverUrl || DEFAULT_SERVER_URL)
91
+ const normalized = String(serverUrl || DEFAULT_THEGITAI_HOST)
78
92
  .trim()
79
93
  .replace(/\/+$/, '');
80
94
  if (!/^https?:\/\//i.test(normalized)) {
@@ -82,6 +96,41 @@ export function normalizeServerUrl(serverUrl) {
82
96
  }
83
97
  return normalized;
84
98
  }
99
+ const MAX_NON_JSON_BODY_CHARS = 200;
100
+ const GATEWAY_STATUS_MESSAGES = {
101
+ 502: 'Could not reach the TheGitAI server (bad gateway).',
102
+ 503: 'The TheGitAI server is temporarily unavailable.',
103
+ 504: 'The TheGitAI server did not respond in time (gateway timeout).',
104
+ 520: 'The connection to the TheGitAI server failed (edge error 520).',
105
+ 521: 'The TheGitAI server is not accepting connections (edge error 521).',
106
+ 522: 'The connection to the TheGitAI server timed out (edge error 522).',
107
+ 523: 'The TheGitAI server is unreachable (edge error 523).',
108
+ 524: 'The TheGitAI server did not respond in time (edge error 524).',
109
+ };
110
+ function truncateByCodePoint(text, maxChars) {
111
+ if (text.length <= maxChars)
112
+ return text;
113
+ const points = Array.from(text);
114
+ if (points.length <= maxChars)
115
+ return text;
116
+ return `${points.slice(0, maxChars).join('').trimEnd()}…`;
117
+ }
118
+ export function nonJsonErrorMessage(body, status) {
119
+ const rayId = /Cloudflare Ray ID:\s*(?:<[^>]*>\s*)*([0-9a-f]{8,})/i.exec(body)?.[1];
120
+ const gateway = GATEWAY_STATUS_MESSAGES[status];
121
+ const summary = truncateByCodePoint(body
122
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
123
+ .replace(/<[^>]*>/g, ' ')
124
+ .replace(/\s+/g, ' ')
125
+ .trim(), MAX_NON_JSON_BODY_CHARS);
126
+ const base = gateway ?? (summary || `Request failed with ${status}`);
127
+ return rayId ? `${base} (Cloudflare Ray ID: ${rayId})` : base;
128
+ }
129
+ export function gatewayFailureCategory(error) {
130
+ if (!(error instanceof ServerApiError))
131
+ return null;
132
+ return error.status in GATEWAY_STATUS_MESSAGES ? 'gateway_error' : null;
133
+ }
85
134
  export async function readJsonResponse(response) {
86
135
  const text = await response.text();
87
136
  if (!text.trim())
@@ -90,15 +139,28 @@ export async function readJsonResponse(response) {
90
139
  return JSON.parse(text);
91
140
  }
92
141
  catch {
93
- return { error: { message: text } };
142
+ return {
143
+ error: { message: nonJsonErrorMessage(text, response.status) },
144
+ };
94
145
  }
95
146
  }
96
147
  export function failureMessage(data, status) {
97
148
  return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
98
149
  }
150
+ export function failureCode(data) {
151
+ return typeof data?.error?.code === 'string' ? data.error.code : '';
152
+ }
153
+ export function isAuthenticationError(error) {
154
+ return error instanceof ServerApiError && error.status === 401;
155
+ }
156
+ export function authenticationErrorMessage(error) {
157
+ return error.code === 'AUTH_TOKEN_EXPIRED'
158
+ ? 'Your login expired after 48 hours of inactivity. Run `ai login` and resume this saved session.'
159
+ : 'Your login is no longer valid. Run `ai login` and resume this saved session.';
160
+ }
99
161
  export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
100
162
  const data = await readJsonResponse(response);
101
- return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
163
+ return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
102
164
  }
103
165
  export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
104
166
  const trace = createTraceContext();
@@ -115,7 +177,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
115
177
  });
116
178
  const data = await readJsonResponse(response);
117
179
  if (!response.ok) {
118
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
180
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
119
181
  }
120
182
  return data;
121
183
  }