@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.10

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 (64) hide show
  1. package/README.md +49 -3
  2. package/dist/bin/ai.js +83 -197
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +4 -4
  6. package/dist/src/api/browser-login.js +72 -19
  7. package/dist/src/api/chat.js +182 -35
  8. package/dist/src/api/http.js +65 -4
  9. package/dist/src/api/models.js +33 -22
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +30 -13
  19. package/dist/src/patcher.js +97 -12
  20. package/dist/src/project-index.js +13 -1
  21. package/dist/src/project-orientation.js +99 -0
  22. package/dist/src/scanner.js +50 -12
  23. package/dist/src/scratch-dir.js +75 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +0 -19
  26. package/dist/src/session-store.js +52 -21
  27. package/dist/src/session.js +8 -0
  28. package/dist/src/todo-list.js +106 -0
  29. package/dist/src/tool-executor.js +194 -21
  30. package/dist/src/tools/delete-file.js +23 -5
  31. package/dist/src/tools/index.js +6 -0
  32. package/dist/src/tools/patch-file.js +33 -7
  33. package/dist/src/tools/path-suggest.js +81 -8
  34. package/dist/src/tools/read-document.js +2 -2
  35. package/dist/src/tools/read-file.js +17 -8
  36. package/dist/src/tools/replace-document-text.js +10 -12
  37. package/dist/src/tools/restore-checkpoint.js +1 -1
  38. package/dist/src/tools/run-command.js +109 -24
  39. package/dist/src/tools/run-node-script.js +27 -5
  40. package/dist/src/tools/shell-job-kill.js +48 -0
  41. package/dist/src/tools/shell-job-output.js +51 -0
  42. package/dist/src/tools/str-replace.js +33 -7
  43. package/dist/src/tools/undo-edit.js +1 -1
  44. package/dist/src/tools/update-todos.js +27 -0
  45. package/dist/src/tools/write-file.js +26 -6
  46. package/dist/src/tree-sitter-runtime.js +8 -1
  47. package/dist/src/turn-failure-marker.js +11 -0
  48. package/dist/src/ui/prompt-history-store.js +1 -1
  49. package/dist/src/ui/repl.js +500 -71
  50. package/dist/src/ui/tui/bridge.js +3 -4
  51. package/dist/src/ui/tui/build-frame.js +393 -100
  52. package/dist/src/ui/tui/markdown-render.js +72 -73
  53. package/dist/src/ui/tui/shell-input.js +75 -17
  54. package/dist/src/ui/tui/terminal-title.js +84 -0
  55. package/dist/src/ui/tui/terminal-writes.js +48 -0
  56. package/dist/src/ui/tui/text.js +158 -4
  57. package/dist/src/utils.js +9 -0
  58. package/dist/src/version.js +0 -6
  59. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  60. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  61. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  62. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  63. package/package.json +27 -16
  64. package/dist/src/markdown-renderer.js +0 -112
@@ -1,8 +1,12 @@
1
+ import { drainBackgroundJobNotifications } from '../background-jobs.js';
1
2
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
2
3
  import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
3
4
  import { executeLocalToolCall } from '../tool-executor.js';
4
5
  import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
5
6
  import { collectClientEnvironment } from '../client-environment.js';
7
+ import { collectProjectOrientation } from '../project-orientation.js';
8
+ import { autoAttachImages } from '../core/image-path-extractor.js';
9
+ import { formatTurnFailureMarker } from '../turn-failure-marker.js';
6
10
  export class TurnCancelledError extends Error {
7
11
  name = 'TurnCancelledError';
8
12
  constructor(message = 'Turn cancelled.') {
@@ -48,6 +52,7 @@ function parseSseBlock(block) {
48
52
  return { event, data: text };
49
53
  }
50
54
  }
55
+ let toolStateSeqCounter = 0;
51
56
  function toolStateFromSession(session) {
52
57
  return {
53
58
  autoYes: session.autoYes,
@@ -63,6 +68,11 @@ function snapshotForServer(session) {
63
68
  snapshot.clientState.safety = sanitizeSessionSafetyForServer(snapshot.clientState.safety);
64
69
  return snapshot;
65
70
  }
71
+ function imageAttachmentsForServer(attachments) {
72
+ return (attachments ?? []).map(({ filePath, ...attachment }) => attachment.source === 'file' && filePath
73
+ ? { ...attachment, filePath }
74
+ : attachment);
75
+ }
66
76
  function userHistoryText(entry) {
67
77
  return (entry.parts ?? [])
68
78
  .map((part) => (typeof part?.text === 'string' ? part.text : ''))
@@ -85,16 +95,26 @@ export function preserveCancelledTurnInput(session, input) {
85
95
  return;
86
96
  break;
87
97
  }
88
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
98
+ session.history.push({
99
+ role: 'user',
100
+ parts: [{ text }],
101
+ kind: 'turnStart',
102
+ userInput: text,
103
+ });
89
104
  }
90
105
  function preserveFailedTurnInput(session, input, category) {
91
106
  const text = input.trim();
92
107
  if (!text)
93
108
  return;
94
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
109
+ session.history.push({
110
+ role: 'user',
111
+ parts: [{ text }],
112
+ kind: 'turnStart',
113
+ userInput: text,
114
+ });
95
115
  session.history.push({
96
116
  role: 'model',
97
- parts: [{ text: `Turn failed before completion: ${category}.` }],
117
+ parts: [{ text: formatTurnFailureMarker(category) }],
98
118
  });
99
119
  }
100
120
  function historyHasToolCall(session, callId) {
@@ -136,17 +156,50 @@ function publicStatusMessage(data) {
136
156
  : 'tool';
137
157
  if (event.phase === 'thinking')
138
158
  return 'Thinking...';
159
+ if (event.phase === 'analyzing_image') {
160
+ return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
161
+ }
139
162
  if (event.phase === 'running_tool')
140
163
  return `Running ${toolName}...`;
141
164
  if (event.phase === 'waiting_for_tool')
142
165
  return `Running ${toolName} locally...`;
143
166
  return null;
144
167
  }
168
+ function normalizeShellJobToolCall(call) {
169
+ if (call.name !== 'shell_job_output' && call.name !== 'shell_job_kill') {
170
+ return call;
171
+ }
172
+ const args = call.args && typeof call.args === 'object' && !Array.isArray(call.args)
173
+ ? { ...call.args }
174
+ : {};
175
+ let changed = false;
176
+ if (args.job_id === undefined) {
177
+ const alias = args.jobId ?? args.id;
178
+ if (alias !== undefined) {
179
+ args.job_id = alias;
180
+ delete args.jobId;
181
+ delete args.id;
182
+ changed = true;
183
+ }
184
+ }
185
+ if (call.name === 'shell_job_output' && args.wait_ms === undefined) {
186
+ const alias = args.waitMs ?? args.wait ?? args.wait_millis;
187
+ if (alias !== undefined) {
188
+ args.wait_ms = alias;
189
+ delete args.waitMs;
190
+ delete args.wait;
191
+ delete args.wait_millis;
192
+ changed = true;
193
+ }
194
+ }
195
+ return changed ? { ...call, args } : call;
196
+ }
145
197
  async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
146
198
  const payload = {
147
199
  toolCallId: event.call.id,
148
200
  result,
149
201
  toolState: toolStateFromSession(session),
202
+ toolStateSeq: ++toolStateSeqCounter,
150
203
  };
151
204
  const trace = createTraceContext(traceId);
152
205
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
@@ -165,6 +218,29 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
165
218
  throw await readErrorResponse(response, trace.traceId);
166
219
  }
167
220
  }
221
+ const turnIdOverrides = new WeakMap();
222
+ function enterServerTurnId(session, serverSessionTurnId) {
223
+ const active = turnIdOverrides.get(session);
224
+ if (active) {
225
+ active.depth += 1;
226
+ return;
227
+ }
228
+ turnIdOverrides.set(session, {
229
+ previousTurnId: session.turnState.id,
230
+ depth: 1,
231
+ });
232
+ session.turnState.id = serverSessionTurnId;
233
+ }
234
+ function exitServerTurnId(session) {
235
+ const active = turnIdOverrides.get(session);
236
+ if (!active)
237
+ return;
238
+ active.depth -= 1;
239
+ if (active.depth === 0) {
240
+ session.turnState.id = active.previousTurnId;
241
+ turnIdOverrides.delete(session);
242
+ }
243
+ }
168
244
  async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
169
245
  const turnId = String(event?.turnId ?? '').trim();
170
246
  if (!turnId || !event?.call?.id || !event.call.name) {
@@ -173,17 +249,17 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
173
249
  if (signal?.aborted) {
174
250
  throw new TurnCancelledError();
175
251
  }
176
- const previousTurnId = session.turnState.id;
177
252
  const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
178
253
  if (serverSessionTurnId) {
179
- session.turnState.id = serverSessionTurnId;
254
+ enterServerTurnId(session, serverSessionTurnId);
180
255
  if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
181
256
  createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
182
257
  }
183
258
  }
184
259
  try {
185
- const rawResult = await executeLocalToolCall({ projectIndex }, session, event.call);
186
- preserveCancelledTurnToolResult(session, input, event, rawResult);
260
+ const call = normalizeShellJobToolCall(event.call);
261
+ const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
262
+ preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
187
263
  if (signal?.aborted) {
188
264
  throw new TurnCancelledError();
189
265
  }
@@ -198,7 +274,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
198
274
  });
199
275
  }
200
276
  finally {
201
- session.turnState.id = previousTurnId;
277
+ if (serverSessionTurnId) {
278
+ exitServerTurnId(session);
279
+ }
202
280
  }
203
281
  }
204
282
  async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
@@ -211,8 +289,40 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
211
289
  const finalResult = {
212
290
  current: null,
213
291
  };
292
+ const pendingParallelTools = [];
293
+ let firstParallelFailure = null;
294
+ let rejectOnParallelFailure = null;
295
+ const parallelToolFailure = new Promise((_, reject) => {
296
+ rejectOnParallelFailure = reject;
297
+ });
298
+ parallelToolFailure.catch(() => { });
299
+ function recordParallelFailure(error) {
300
+ const failure = error ?? new Error('Local tool execution failed.');
301
+ if (firstParallelFailure == null) {
302
+ firstParallelFailure = failure;
303
+ rejectOnParallelFailure?.(failure);
304
+ }
305
+ return failure;
306
+ }
307
+ async function drainParallelTools() {
308
+ if (!pendingParallelTools.length)
309
+ return;
310
+ const pending = pendingParallelTools.splice(0);
311
+ const outcomes = await Promise.all(pending);
312
+ for (const outcome of outcomes) {
313
+ if (outcome != null)
314
+ throw outcome;
315
+ }
316
+ }
214
317
  async function handleEvent(event) {
215
318
  if (event.event === 'status') {
319
+ const data = event.data;
320
+ if (data?.phase === 'analyzing_image') {
321
+ session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
322
+ }
323
+ else if (data?.phase) {
324
+ session.onImageAnalysis?.(0);
325
+ }
216
326
  const message = publicStatusMessage(event.data);
217
327
  if (message)
218
328
  session.onStatus(message);
@@ -222,11 +332,26 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
222
332
  return;
223
333
  }
224
334
  if (event.event === 'tool-call') {
335
+ const data = event.data;
336
+ if (data?.parallelSafe === true) {
337
+ pendingParallelTools.push(executeAndPostToolResult({
338
+ config,
339
+ projectIndex,
340
+ session,
341
+ event: data,
342
+ input,
343
+ fetchImpl,
344
+ signal,
345
+ traceId,
346
+ }).then(() => null, (error) => recordParallelFailure(error)));
347
+ return;
348
+ }
349
+ await drainParallelTools();
225
350
  await executeAndPostToolResult({
226
351
  config,
227
352
  projectIndex,
228
353
  session,
229
- event: event.data,
354
+ event: data,
230
355
  input,
231
356
  fetchImpl,
232
357
  signal,
@@ -242,10 +367,12 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
242
367
  return;
243
368
  }
244
369
  if (event.event === 'result') {
370
+ await drainParallelTools();
245
371
  finalResult.current = event.data;
246
372
  return;
247
373
  }
248
374
  if (event.event === 'cancelled' || event.event === 'error') {
375
+ await drainParallelTools().catch(() => { });
249
376
  const message = String(event.data?.message ?? 'Server chat failed.');
250
377
  if (event.event === 'cancelled') {
251
378
  throw new TurnCancelledError(message);
@@ -253,24 +380,34 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
253
380
  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);
254
381
  }
255
382
  }
256
- while (true) {
257
- if (signal?.aborted) {
258
- await reader.cancel().catch(() => { });
259
- throw new TurnCancelledError();
260
- }
261
- const read = await reader.read();
262
- if (read.done)
263
- break;
264
- buffer += decoder.decode(read.value, { stream: true });
265
- let separatorIndex = buffer.indexOf('\n\n');
266
- while (separatorIndex !== -1) {
267
- const block = buffer.slice(0, separatorIndex);
268
- buffer = buffer.slice(separatorIndex + 2);
269
- const event = parseSseBlock(block);
270
- if (event)
271
- await handleEvent(event);
272
- separatorIndex = buffer.indexOf('\n\n');
383
+ try {
384
+ while (true) {
385
+ if (signal?.aborted) {
386
+ await reader.cancel().catch(() => { });
387
+ throw new TurnCancelledError();
388
+ }
389
+ const read = await Promise.race([reader.read(), parallelToolFailure]);
390
+ if (read.done)
391
+ break;
392
+ buffer += decoder.decode(read.value, { stream: true });
393
+ let separatorIndex = buffer.indexOf('\n\n');
394
+ while (separatorIndex !== -1) {
395
+ const block = buffer.slice(0, separatorIndex);
396
+ buffer = buffer.slice(separatorIndex + 2);
397
+ const event = parseSseBlock(block);
398
+ if (event)
399
+ await handleEvent(event);
400
+ separatorIndex = buffer.indexOf('\n\n');
401
+ }
273
402
  }
403
+ await drainParallelTools();
404
+ }
405
+ catch (error) {
406
+ await reader.cancel().catch(() => { });
407
+ throw error;
408
+ }
409
+ finally {
410
+ await drainParallelTools().catch(() => { });
274
411
  }
275
412
  buffer += decoder.decode();
276
413
  const tail = buffer.trim();
@@ -285,19 +422,32 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
285
422
  return finalResult.current;
286
423
  }
287
424
  export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
425
+ const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
426
+ const requestImageAttachments = autoAttach.attachments.length > 0
427
+ ? [...imageAttachments, ...autoAttach.attachments]
428
+ : imageAttachments;
429
+ const requestInputBase = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
430
+ const backgroundJobUpdate = drainBackgroundJobNotifications({
431
+ sessionId: session.sessionId,
432
+ });
433
+ for (const err of autoAttach.errors) {
434
+ session.onStatus(`Image: ${err}`);
435
+ }
288
436
  const request = {
289
437
  modelId: session.modelId,
290
438
  session: snapshotForServer(session),
291
- input,
439
+ input: requestInputBase,
440
+ backgroundJobUpdate: backgroundJobUpdate || undefined,
292
441
  clientEnvironment: collectClientEnvironment({ env: session.env }),
293
- imageAttachments,
442
+ projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
443
+ imageAttachments: imageAttachmentsForServer(requestImageAttachments),
294
444
  maxToolSteps: session.maxToolSteps,
295
445
  autoYes: session.autoYes,
296
446
  agentMode: session.agentMode,
297
447
  };
298
448
  const trace = createTraceContext();
299
449
  const preTurnHistoryLength = session.history.length;
300
- const preserveOnAbort = () => preserveCancelledTurnInput(session, input);
450
+ const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInputBase);
301
451
  if (signal?.aborted) {
302
452
  preserveOnAbort();
303
453
  }
@@ -324,7 +474,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
324
474
  config,
325
475
  projectIndex,
326
476
  session,
327
- input,
477
+ input: requestInputBase,
328
478
  fetchImpl,
329
479
  signal,
330
480
  traceId: trace.traceId,
@@ -339,16 +489,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
339
489
  }
340
490
  catch (error) {
341
491
  if (isTurnCancelledError(error)) {
342
- preserveCancelledTurnInput(session, input);
492
+ preserveCancelledTurnInput(session, requestInputBase);
343
493
  throw error instanceof TurnCancelledError
344
494
  ? error
345
495
  : new TurnCancelledError();
346
496
  }
347
- // Non-cancel failures (e.g. upstream connection errors) must not leave
348
- // speculative cancelled-turn entries in history — otherwise the next
349
- // request replays a malformed transcript to the server.
350
497
  session.history.length = preTurnHistoryLength;
351
- preserveFailedTurnInput(session, input, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
498
+ preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
352
499
  throw error;
353
500
  }
354
501
  finally {
@@ -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() {
@@ -26,6 +28,53 @@ export function createTraceContext(traceId = createTraceId()) {
26
28
  },
27
29
  };
28
30
  }
31
+ export const REQUEST_TIMEOUT_MS = 8000;
32
+ const TRANSIENT_NETWORK_CODES = new Set([
33
+ 'ECONNRESET',
34
+ 'ECONNREFUSED',
35
+ 'ETIMEDOUT',
36
+ 'EAI_AGAIN',
37
+ 'ENOTFOUND',
38
+ 'ENETUNREACH',
39
+ 'EHOSTUNREACH',
40
+ 'EPIPE',
41
+ 'UND_ERR_CONNECT_TIMEOUT',
42
+ 'UND_ERR_SOCKET',
43
+ 'UND_ERR_HEADERS_TIMEOUT',
44
+ 'UND_ERR_BODY_TIMEOUT',
45
+ ]);
46
+ export function isTransientNetworkError(error) {
47
+ if (error instanceof ServerApiError)
48
+ return false;
49
+ if (!error || typeof error !== 'object')
50
+ return false;
51
+ const err = error;
52
+ if (err.name === 'AbortError' || err.name === 'TimeoutError')
53
+ return true;
54
+ const code = err.code ?? err.cause?.code;
55
+ if (code) {
56
+ return TRANSIENT_NETWORK_CODES.has(code);
57
+ }
58
+ if (error instanceof TypeError && /fetch failed/i.test(err.message ?? '')) {
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+ export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {}) {
64
+ let attempt = 0;
65
+ for (;;) {
66
+ try {
67
+ return await run();
68
+ }
69
+ catch (error) {
70
+ if (attempt >= retries || !isTransientNetworkError(error))
71
+ throw error;
72
+ const delayMs = baseDelayMs * 2 ** attempt;
73
+ attempt += 1;
74
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
75
+ }
76
+ }
77
+ }
29
78
  export function normalizeServerUrl(serverUrl) {
30
79
  const normalized = String(serverUrl || DEFAULT_SERVER_URL)
31
80
  .trim()
@@ -49,11 +98,22 @@ export async function readJsonResponse(response) {
49
98
  export function failureMessage(data, status) {
50
99
  return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
51
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
+ }
52
112
  export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
53
113
  const data = await readJsonResponse(response);
54
- return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
114
+ return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
55
115
  }
56
- export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, }) {
116
+ export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
57
117
  const trace = createTraceContext();
58
118
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}${path}`, {
59
119
  method,
@@ -64,10 +124,11 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
64
124
  ...(body === null ? {} : { 'content-type': 'application/json' }),
65
125
  },
66
126
  body: body === null ? undefined : JSON.stringify(body),
127
+ signal: AbortSignal.timeout(timeoutMs),
67
128
  });
68
129
  const data = await readJsonResponse(response);
69
130
  if (!response.ok) {
70
- 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));
71
132
  }
72
133
  return data;
73
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 { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } 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');
@@ -45,6 +50,11 @@ export function readCachedServerModels(env = process.env) {
45
50
  return null;
46
51
  }
47
52
  }
53
+ export function selectCacheForServer(cached, serverUrl) {
54
+ if (!cached)
55
+ return null;
56
+ return cached.serverUrl === normalizeServerUrl(serverUrl) ? cached : null;
57
+ }
48
58
  export function writeCachedServerModels(cache, env = process.env) {
49
59
  const filePath = getModelsCachePath(env);
50
60
  mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -54,26 +64,27 @@ export function writeCachedServerModels(cache, env = process.env) {
54
64
  });
55
65
  }
56
66
  export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
57
- const trace = createTraceContext();
58
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
59
- headers: {
60
- authorization: `Bearer ${config.token}`,
61
- ...trace.headers,
62
- },
67
+ return retryTransient(async () => {
68
+ const trace = createTraceContext();
69
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
70
+ headers: {
71
+ authorization: `Bearer ${config.token}`,
72
+ ...trace.headers,
73
+ },
74
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
75
+ });
76
+ const data = (await readJsonResponse(response));
77
+ if (!response.ok) {
78
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
79
+ }
80
+ const models = Array.isArray(data?.models)
81
+ ? data.models.map(sanitizeModelInfo).filter(Boolean)
82
+ : [];
83
+ if (models.length === 0) {
84
+ throw new Error('Server returned an invalid model list.');
85
+ }
86
+ return { models };
63
87
  });
64
- const data = (await readJsonResponse(response));
65
- if (!response.ok) {
66
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
67
- }
68
- const models = Array.isArray(data?.models)
69
- ? data.models.map(sanitizeModelInfo).filter(Boolean)
70
- : [];
71
- if (models.length === 0) {
72
- throw new Error('Server returned an invalid model list.');
73
- }
74
- return {
75
- models,
76
- };
77
88
  }
78
89
  export function selectServerModel({ requestedModelId, cached, serverModels, }) {
79
90
  const supportedIds = new Set(serverModels.models.map((model) => model.id));
@@ -154,6 +154,8 @@ const SENSITIVE_BASENAME_PATTERNS = [
154
154
  /^\.?pypirc$/i,
155
155
  /^credentials(?:\..*)?$/i,
156
156
  /^secrets?(?:\..*)?$/i,
157
+ /^service[-_]?account(?:\..*)?\.json$/i,
158
+ /^.*credentials.*\.json$/i,
157
159
  ];
158
160
  const SENSITIVE_PATH_PATTERNS = [
159
161
  /(^|[/\\])\.aws[/\\]credentials$/i,
@@ -161,6 +163,7 @@ const SENSITIVE_PATH_PATTERNS = [
161
163
  /(^|[/\\])credentials?([._-]|$)/i,
162
164
  /(^|[/\\])secrets?([._-]|$)/i,
163
165
  /(^|[/\\])private[-_]?key([._-]|$)/i,
166
+ /(^|[/\\])service[-_]?account/i,
164
167
  /\.(?:pem|key|p12|pfx)$/i,
165
168
  ];
166
169
  export function normalizeArtifactPath(relPath) {