@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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 (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -1,12 +1,19 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { drainBackgroundJobNotifications } from '../background-jobs.js';
3
+ import { cancelActiveCommand } from '../executor.js';
2
4
  import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
3
- import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
5
+ import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
4
6
  import { executeLocalToolCall } from '../tool-executor.js';
5
- import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
7
+ import { saveGeneratedImage } from '../tools/save-generated-image.js';
8
+ import { isUserInputQuestionArray } from './contracts.js';
9
+ import { createTraceContext, gatewayFailureCategory, normalizeServerUrl, QUOTA_EXCEEDED_CODE, readErrorResponse, ServerApiError, transportFailureDetail, } from './http.js';
10
+ import { createTurnRecovery, fetchTurnAttempt, interruptedStream, isRetryableTurnStatus, isTurnRecoveryExhausted, readTurnChunk, } from './turn-recovery.js';
6
11
  import { collectClientEnvironment } from '../client-environment.js';
7
12
  import { collectProjectOrientation } from '../project-orientation.js';
13
+ import { describeSessionImageStore } from '../core/session-image-store.js';
8
14
  import { autoAttachImages } from '../core/image-path-extractor.js';
9
15
  import { formatTurnFailureMarker } from '../turn-failure-marker.js';
16
+ import { browserExtensionSeen, releaseBrowserAfterTurn } from '../browser/session-bridge.js';
10
17
  export class TurnCancelledError extends Error {
11
18
  name = 'TurnCancelledError';
12
19
  constructor(message = 'Turn cancelled.') {
@@ -18,11 +25,13 @@ export class ChatTurnFailedError extends Error {
18
25
  category;
19
26
  retryable;
20
27
  traceId;
21
- constructor(message, category = 'unknown_error', retryable = false, traceId = '') {
28
+ partialSnapshot;
29
+ constructor(message, category = 'unknown_error', retryable = false, traceId = '', partialSnapshot) {
22
30
  super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
23
31
  this.category = category;
24
32
  this.retryable = retryable;
25
33
  this.traceId = traceId;
34
+ this.partialSnapshot = partialSnapshot;
26
35
  }
27
36
  }
28
37
  export function isTurnCancelledError(error) {
@@ -32,8 +41,13 @@ export function isTurnCancelledError(error) {
32
41
  }
33
42
  function parseSseBlock(block) {
34
43
  let event = 'message';
44
+ let id = '';
35
45
  const dataLines = [];
36
46
  for (const line of block.split(/\r?\n/)) {
47
+ if (line.startsWith('id:')) {
48
+ id = line.slice(3).trim();
49
+ continue;
50
+ }
37
51
  if (line.startsWith('event:')) {
38
52
  event = line.slice('event:'.length).trim() || event;
39
53
  continue;
@@ -46,10 +60,10 @@ function parseSseBlock(block) {
46
60
  return null;
47
61
  const text = dataLines.join('\n');
48
62
  try {
49
- return { event, data: JSON.parse(text) };
63
+ return { id, event, data: JSON.parse(text) };
50
64
  }
51
65
  catch {
52
- return { event, data: text };
66
+ throw new Error('Server emitted invalid event JSON.');
53
67
  }
54
68
  }
55
69
  let toolStateSeqCounter = 0;
@@ -86,7 +100,7 @@ export function preserveCancelledTurnInput(session, input) {
86
100
  return;
87
101
  for (let i = session.history.length - 1; i >= 0; i--) {
88
102
  const entry = session.history[i];
89
- if (!entry || entry.role !== 'user')
103
+ if (!entry || entry.role !== 'user' || entry.kind === 'userInterjection')
90
104
  continue;
91
105
  const prior = userHistoryText(entry);
92
106
  if (!prior)
@@ -95,24 +109,46 @@ export function preserveCancelledTurnInput(session, input) {
95
109
  return;
96
110
  break;
97
111
  }
98
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
112
+ session.history.push({
113
+ role: 'user',
114
+ parts: [{ text }],
115
+ kind: 'turnStart',
116
+ userInput: text,
117
+ });
118
+ }
119
+ function appendTurnFailureMarker(session, category) {
120
+ session.history.push({
121
+ role: 'model',
122
+ parts: [{ text: formatTurnFailureMarker(category) }],
123
+ });
99
124
  }
100
125
  function preserveFailedTurnInput(session, input, category) {
101
126
  const text = input.trim();
102
127
  if (!text)
103
128
  return;
104
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
105
129
  session.history.push({
106
- role: 'model',
107
- parts: [{ text: formatTurnFailureMarker(category) }],
130
+ role: 'user',
131
+ parts: [{ text }],
132
+ kind: 'turnStart',
133
+ userInput: text,
108
134
  });
135
+ appendTurnFailureMarker(session, category);
109
136
  }
110
- function historyHasToolCall(session, callId) {
111
- return session.history.some((entry) => (entry.parts ?? []).some((part) => String(part?.functionCall?.id ?? '') === callId));
137
+ function tagTurnPhase(phase, run) {
138
+ return run().catch((error) => {
139
+ if (error && typeof error === 'object' && !error.thegitaiPhase) {
140
+ error.thegitaiPhase = phase;
141
+ }
142
+ throw error;
143
+ });
144
+ }
145
+ function turnPhaseOf(error) {
146
+ const phase = error?.thegitaiPhase;
147
+ return typeof phase === 'string' ? phase : null;
112
148
  }
113
149
  function preserveCancelledTurnToolResult(session, input, event, result) {
114
150
  const callId = String(event.call.id ?? '').trim();
115
- if (!callId || historyHasToolCall(session, callId))
151
+ if (!callId)
116
152
  return;
117
153
  preserveCancelledTurnInput(session, input);
118
154
  session.history.push({
@@ -132,6 +168,22 @@ function preserveCancelledTurnToolResult(session, input, event, result) {
132
168
  ],
133
169
  });
134
170
  }
171
+ const turnInterjections = new Map();
172
+ function preserveTurnInterjection(session, input, sent) {
173
+ sent.delivered = true;
174
+ preserveCancelledTurnInput(session, input);
175
+ session.history.push({
176
+ role: 'user',
177
+ parts: [{ text: sent.text }],
178
+ kind: 'userInterjection',
179
+ userInput: sent.text,
180
+ });
181
+ }
182
+ function preserveTurnAnswer(session, input, requestId, questions, result) {
183
+ preserveCancelledTurnInput(session, input);
184
+ session.history.push({ role: 'model', parts: [{ userInputRequest: { requestId, questions } }] });
185
+ session.history.push({ role: 'user', parts: [{ userInputResult: { requestId, result } }] });
186
+ }
135
187
  function publicStatusMessage(data) {
136
188
  if (!data || typeof data !== 'object')
137
189
  return null;
@@ -145,11 +197,19 @@ function publicStatusMessage(data) {
145
197
  ? event.toolName
146
198
  : 'tool';
147
199
  if (event.phase === 'thinking')
148
- return 'Thinking...';
200
+ return 'Exploring options...';
201
+ if (event.phase === 'analyzing_image') {
202
+ return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
203
+ }
204
+ if (event.phase === 'generating_image') {
205
+ return 'Generating image...';
206
+ }
149
207
  if (event.phase === 'running_tool')
150
208
  return `Running ${toolName}...`;
151
209
  if (event.phase === 'waiting_for_tool')
152
210
  return `Running ${toolName} locally...`;
211
+ if (event.phase === 'waiting_for_user_input')
212
+ return 'Waiting for your input...';
153
213
  return null;
154
214
  }
155
215
  function normalizeShellJobToolCall(call) {
@@ -181,15 +241,33 @@ function normalizeShellJobToolCall(call) {
181
241
  }
182
242
  return changed ? { ...call, args } : call;
183
243
  }
184
- async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
244
+ function createTurnRequester({ fetchImpl, recovery, signal, traceId, }) {
245
+ return (operation, url, init) => {
246
+ const caller = init.signal ? AbortSignal.any([signal, init.signal]) : signal;
247
+ return tagTurnPhase(operation, () => recovery.run(operation, async () => {
248
+ const response = await fetchTurnAttempt(fetchImpl, url, init, caller, {
249
+ readBody: true,
250
+ });
251
+ if (isRetryableTurnStatus(response.status)) {
252
+ throw await readErrorResponse(response, traceId);
253
+ }
254
+ return response;
255
+ }, caller));
256
+ };
257
+ }
258
+ function turnUrl(config, turnId, action) {
259
+ return `${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/${action}`;
260
+ }
261
+ async function postToolResult({ config, turnId, event, result, session, request, traceId, delivery, }) {
185
262
  const payload = {
263
+ executionId: event.executionId,
186
264
  toolCallId: event.call.id,
187
265
  result,
188
266
  toolState: toolStateFromSession(session),
189
267
  toolStateSeq: ++toolStateSeqCounter,
190
268
  };
191
269
  const trace = createTraceContext(traceId);
192
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
270
+ const response = await request('posting a tool result', turnUrl(config, turnId, 'tool-result'), {
193
271
  method: 'POST',
194
272
  headers: {
195
273
  authorization: `Bearer ${config.token}`,
@@ -197,14 +275,88 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
197
275
  ...trace.headers,
198
276
  },
199
277
  body: JSON.stringify(payload),
278
+ signal: delivery,
200
279
  });
201
- if (response.status === 410) {
280
+ if (response.status === 410)
202
281
  return;
282
+ if (!response.ok) {
283
+ throw await readErrorResponse(response, trace.traceId);
203
284
  }
285
+ }
286
+ async function postUserInputResult({ config, turnId, requestId, result, request, traceId, delivery, }) {
287
+ const payload = {
288
+ requestId,
289
+ result,
290
+ };
291
+ const trace = createTraceContext(traceId);
292
+ const response = await request('posting your answer', turnUrl(config, turnId, 'user-input-result'), {
293
+ method: 'POST',
294
+ headers: {
295
+ authorization: `Bearer ${config.token}`,
296
+ 'content-type': 'application/json',
297
+ ...trace.headers,
298
+ },
299
+ body: JSON.stringify(payload),
300
+ signal: delivery,
301
+ });
302
+ if (response.status === 410)
303
+ return;
204
304
  if (!response.ok) {
205
305
  throw await readErrorResponse(response, trace.traceId);
206
306
  }
207
307
  }
308
+ const activeTurnRequesters = new Map();
309
+ export async function postInterjection({ config, turnId, text, messageId, imageAttachments = [], fetchImpl = globalThis.fetch, traceId, }) {
310
+ const payload = {
311
+ text,
312
+ messageId,
313
+ ...(imageAttachments.length > 0 ? { imageAttachments } : {}),
314
+ };
315
+ const trace = createTraceContext(traceId);
316
+ const url = turnUrl(config, turnId, 'interject');
317
+ const init = {
318
+ method: 'POST',
319
+ headers: {
320
+ authorization: `Bearer ${config.token}`,
321
+ 'content-type': 'application/json',
322
+ ...trace.headers,
323
+ },
324
+ body: JSON.stringify(payload),
325
+ };
326
+ const request = activeTurnRequesters.get(turnId);
327
+ const sent = turnInterjections.get(turnId);
328
+ const record = {
329
+ messageId,
330
+ text,
331
+ hasImages: imageAttachments.length > 0,
332
+ delivered: false,
333
+ };
334
+ sent?.push(record);
335
+ const forget = () => {
336
+ const index = sent?.indexOf(record) ?? -1;
337
+ if (index !== -1)
338
+ sent.splice(index, 1);
339
+ };
340
+ let response;
341
+ try {
342
+ response = request
343
+ ? await request('posting a message into the turn', url, init)
344
+ : await fetchImpl(url, init);
345
+ }
346
+ catch (error) {
347
+ forget();
348
+ throw error;
349
+ }
350
+ if (response.status === 410) {
351
+ forget();
352
+ return 'stale';
353
+ }
354
+ if (!response.ok) {
355
+ forget();
356
+ throw await readErrorResponse(response, trace.traceId);
357
+ }
358
+ return 'delivered';
359
+ }
208
360
  const turnIdOverrides = new WeakMap();
209
361
  function enterServerTurnId(session, serverSessionTurnId) {
210
362
  const active = turnIdOverrides.get(session);
@@ -228,14 +380,13 @@ function exitServerTurnId(session) {
228
380
  turnIdOverrides.delete(session);
229
381
  }
230
382
  }
231
- async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
383
+ async function executeAndPostToolResult({ config, session, event, input, request, signal, delivery, executed, traceId, }) {
232
384
  const turnId = String(event?.turnId ?? '').trim();
233
- if (!turnId || !event?.call?.id || !event.call.name) {
385
+ if (!turnId || !event?.executionId || !event?.call?.id || !event.call.name) {
234
386
  throw new Error('Server emitted an invalid tool-call event.');
235
387
  }
236
- if (signal?.aborted) {
237
- throw new TurnCancelledError();
238
- }
388
+ signal.throwIfAborted();
389
+ delivery.throwIfAborted();
239
390
  const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
240
391
  if (serverSessionTurnId) {
241
392
  enterServerTurnId(session, serverSessionTurnId);
@@ -245,19 +396,28 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
245
396
  }
246
397
  try {
247
398
  const call = normalizeShellJobToolCall(event.call);
248
- const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
399
+ const rawResult = event.generatedImage
400
+ ? saveGeneratedImage({
401
+ base64Data: event.generatedImage.base64Data,
402
+ mimeType: event.generatedImage.mimeType,
403
+ suggestedFilename: event.generatedImage.suggestedFilename ||
404
+ String(call.args?.filename ?? call.args?.file_name ?? ''),
405
+ })
406
+ : await executeLocalToolCall(session, call);
249
407
  preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
250
- if (signal?.aborted) {
251
- throw new TurnCancelledError();
252
- }
408
+ executed();
409
+ signal.throwIfAborted();
410
+ if (delivery.aborted)
411
+ return;
253
412
  await postToolResult({
254
413
  config,
255
414
  turnId,
256
415
  event,
257
416
  result: rawResult,
258
417
  session,
259
- fetchImpl,
418
+ request,
260
419
  traceId,
420
+ delivery,
261
421
  });
262
422
  }
263
423
  finally {
@@ -266,142 +426,312 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
266
426
  }
267
427
  }
268
428
  }
269
- async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
270
- if (!response.body) {
271
- throw new Error('Server returned an empty chat stream.');
429
+ function createTurnWork() {
430
+ const executions = new Map();
431
+ const inFlight = new Set();
432
+ const running = new Set();
433
+ let lastSerial = Promise.resolve();
434
+ let hasFailed = false;
435
+ let rejectFailed;
436
+ const failed = new Promise((_, reject) => {
437
+ rejectFailed = reject;
438
+ });
439
+ failed.catch(() => { });
440
+ function dispatch(key, parallel, run) {
441
+ if (executions.has(key))
442
+ return;
443
+ const prior = parallel ? lastSerial : Promise.all([lastSerial, ...inFlight]);
444
+ let executed;
445
+ const local = new Promise((resolve) => {
446
+ executed = resolve;
447
+ });
448
+ running.add(local);
449
+ void local.then(() => running.delete(local));
450
+ const execution = prior.then(() => run(executed));
451
+ execution.then(executed, executed);
452
+ executions.set(key, execution);
453
+ inFlight.add(execution);
454
+ if (!parallel)
455
+ lastSerial = execution;
456
+ execution.then(() => inFlight.delete(execution), (error) => {
457
+ inFlight.delete(execution);
458
+ hasFailed = true;
459
+ rejectFailed(error);
460
+ });
272
461
  }
273
- const decoder = new TextDecoder();
274
- const reader = response.body.getReader();
275
- let buffer = '';
276
- const finalResult = {
277
- current: null,
462
+ return {
463
+ dispatch,
464
+ failed,
465
+ get hasFailed() {
466
+ return hasFailed;
467
+ },
468
+ async executed() {
469
+ await Promise.all([...running]);
470
+ },
471
+ async settled() {
472
+ await Promise.allSettled([...inFlight]);
473
+ },
278
474
  };
279
- const pendingParallelTools = [];
280
- let firstParallelFailure = null;
281
- let rejectOnParallelFailure = null;
282
- const parallelToolFailure = new Promise((_, reject) => {
283
- rejectOnParallelFailure = reject;
284
- });
285
- parallelToolFailure.catch(() => { });
286
- function recordParallelFailure(error) {
287
- const failure = error ?? new Error('Local tool execution failed.');
288
- if (firstParallelFailure == null) {
289
- firstParallelFailure = failure;
290
- rejectOnParallelFailure?.(failure);
291
- }
292
- return failure;
475
+ }
476
+ function terminalFailure(event, traceId) {
477
+ const data = (event.data ?? {});
478
+ const message = String(data.message ?? 'The turn failed before completion.');
479
+ const eventTraceId = typeof data.traceId === 'string' ? data.traceId : traceId;
480
+ if (event.event === 'cancelled') {
481
+ return new TurnCancelledError(message);
293
482
  }
294
- async function drainParallelTools() {
295
- if (!pendingParallelTools.length)
296
- return;
297
- const pending = pendingParallelTools.splice(0);
298
- const outcomes = await Promise.all(pending);
299
- for (const outcome of outcomes) {
300
- if (outcome != null)
301
- throw outcome;
302
- }
483
+ if (data.code === QUOTA_EXCEEDED_CODE) {
484
+ return new ServerApiError(message, 429, eventTraceId, QUOTA_EXCEEDED_CODE, data.details ?? {});
303
485
  }
304
- async function handleEvent(event) {
305
- if (event.event === 'status') {
306
- const message = publicStatusMessage(event.data);
307
- if (message)
308
- session.onStatus(message);
486
+ return new ChatTurnFailedError(message, typeof data.category === 'string' ? data.category : 'unknown_error', Boolean(data.retryable), eventTraceId, data.snapshot);
487
+ }
488
+ const TURN_EVENT_ACK_INTERVAL_MS = 5_000;
489
+ const MAX_TURN_EVENT_BYTES = 64 * 1024 * 1024;
490
+ function createEventAcknowledger(send) {
491
+ let latest = '';
492
+ let confirmed = '';
493
+ let inFlight = false;
494
+ let stopped = false;
495
+ let timer = null;
496
+ const schedule = () => {
497
+ if (timer || stopped)
309
498
  return;
310
- }
311
- if (event.event === 'context') {
499
+ timer = setTimeout(flush, TURN_EVENT_ACK_INTERVAL_MS);
500
+ timer.unref?.();
501
+ };
502
+ const flush = () => {
503
+ if (timer)
504
+ clearTimeout(timer);
505
+ timer = null;
506
+ if (stopped || inFlight || !latest || latest === confirmed)
312
507
  return;
508
+ const eventId = latest;
509
+ inFlight = true;
510
+ send(eventId)
511
+ .then(() => {
512
+ confirmed = eventId;
513
+ }, () => { })
514
+ .finally(() => {
515
+ inFlight = false;
516
+ if (latest !== confirmed)
517
+ schedule();
518
+ });
519
+ };
520
+ return {
521
+ note(eventId) {
522
+ latest = eventId;
523
+ schedule();
524
+ },
525
+ flush,
526
+ stop() {
527
+ stopped = true;
528
+ if (timer)
529
+ clearTimeout(timer);
530
+ timer = null;
531
+ },
532
+ };
533
+ }
534
+ async function runTurnStream({ openStream, config, session, input, request, recovery, signal, traceId, acknowledge, stopLocalWork, interjections, onTurnStart, onInterjectionDelivered, }) {
535
+ const seen = new Set();
536
+ const work = createTurnWork();
537
+ const deliveries = new AbortController();
538
+ const delivery = AbortSignal.any([signal, deliveries.signal]);
539
+ const streamToken = {};
540
+ let lastEventId = '';
541
+ let registeredTurnId = '';
542
+ function handleEvent(event) {
543
+ if (!event.id) {
544
+ throw new Error('The server sent a turn event without an id.');
313
545
  }
314
- if (event.event === 'tool-call') {
315
- const data = event.data;
316
- if (data?.parallelSafe === true) {
317
- pendingParallelTools.push(executeAndPostToolResult({
546
+ if (seen.has(event.id))
547
+ return null;
548
+ seen.add(event.id);
549
+ lastEventId = event.id;
550
+ const data = event.data;
551
+ switch (event.event) {
552
+ case 'turn-start': {
553
+ const turnId = String(data?.turnId ?? '').trim();
554
+ if (!turnId)
555
+ throw new Error('The server sent a turn without an id.');
556
+ registeredTurnId = turnId;
557
+ activeTurnRequesters.set(turnId, request);
558
+ turnInterjections.set(turnId, interjections);
559
+ onTurnStart?.(turnId);
560
+ break;
561
+ }
562
+ case 'interjection-delivered': {
563
+ const delivered = data;
564
+ for (const messageId of delivered?.messageIds ?? []) {
565
+ const sent = interjections.find((item) => item.messageId === messageId);
566
+ if (sent && !sent.delivered)
567
+ preserveTurnInterjection(session, input, sent);
568
+ }
569
+ onInterjectionDelivered?.(delivered);
570
+ break;
571
+ }
572
+ case 'status': {
573
+ const status = (data ?? {});
574
+ if (status.phase === 'analyzing_image') {
575
+ session.onImageAnalysis?.(Math.max(1, Number(status.imageCount ?? 1) || 1));
576
+ session.onImageGeneration?.(false);
577
+ }
578
+ else if (status.phase === 'generating_image') {
579
+ session.onImageGeneration?.(true);
580
+ session.onImageAnalysis?.(0);
581
+ }
582
+ else if (status.phase) {
583
+ session.onImageAnalysis?.(0);
584
+ session.onImageGeneration?.(false);
585
+ }
586
+ const message = publicStatusMessage(data);
587
+ if (message)
588
+ session.onStatus(message);
589
+ break;
590
+ }
591
+ case 'tool-call': {
592
+ const toolCall = data;
593
+ if (!toolCall?.executionId || !toolCall.call?.name) {
594
+ throw new Error('Server emitted an invalid tool-call event.');
595
+ }
596
+ work.dispatch(`tool:${toolCall.executionId}`, toolCall.parallelSafe === true, (executed) => executeAndPostToolResult({
318
597
  config,
319
- projectIndex,
320
598
  session,
321
- event: data,
599
+ event: toolCall,
322
600
  input,
323
- fetchImpl,
601
+ request,
324
602
  signal,
603
+ delivery,
604
+ executed,
325
605
  traceId,
326
- }).then(() => null, (error) => recordParallelFailure(error)));
327
- return;
606
+ }));
607
+ break;
328
608
  }
329
- await drainParallelTools();
330
- await executeAndPostToolResult({
331
- config,
332
- projectIndex,
333
- session,
334
- event: data,
335
- input,
336
- fetchImpl,
337
- signal,
338
- traceId,
339
- });
340
- return;
341
- }
342
- if (event.event === 'tool-result') {
343
- const data = event.data;
344
- if (data?.call?.name) {
345
- session.onToolEvent?.({ call: data.call, result: data.result });
609
+ case 'tool-result': {
610
+ const toolResult = data;
611
+ if (toolResult?.call?.name) {
612
+ session.onToolEvent?.({ call: toolResult.call, result: toolResult.result });
613
+ }
614
+ break;
346
615
  }
347
- return;
348
- }
349
- if (event.event === 'result') {
350
- await drainParallelTools();
351
- finalResult.current = event.data;
352
- return;
616
+ case 'user-input-request': {
617
+ const question = data;
618
+ const turnId = String(question?.turnId ?? '').trim();
619
+ const requestId = String(question?.requestId ?? '').trim();
620
+ if (!turnId || !requestId || !isUserInputQuestionArray(question?.questions)) {
621
+ throw new Error('Server emitted an invalid user-input request.');
622
+ }
623
+ work.dispatch(`question:${requestId}`, false, async (executed) => {
624
+ if (!session.requestUserInput) {
625
+ throw new Error('Interactive user input is unavailable in this client.');
626
+ }
627
+ const result = await session.requestUserInput({ questions: question.questions }, signal);
628
+ executed();
629
+ signal.throwIfAborted();
630
+ preserveTurnAnswer(session, input, requestId, question.questions, result);
631
+ if (delivery.aborted)
632
+ return;
633
+ await postUserInputResult({
634
+ config,
635
+ turnId,
636
+ requestId,
637
+ result,
638
+ request,
639
+ traceId,
640
+ delivery,
641
+ });
642
+ });
643
+ break;
644
+ }
645
+ case 'result':
646
+ if (!data?.snapshot)
647
+ throw new Error('Server returned an invalid chat result.');
648
+ return { kind: 'result', result: data };
649
+ case 'error':
650
+ case 'cancelled':
651
+ return { kind: 'failed', error: terminalFailure(event, traceId) };
652
+ default:
653
+ break;
353
654
  }
354
- if (event.event === 'cancelled' || event.event === 'error') {
355
- await drainParallelTools().catch(() => { });
356
- const message = String(event.data?.message ?? 'Server chat failed.');
357
- if (event.event === 'cancelled') {
358
- throw new TurnCancelledError(message);
655
+ acknowledge.note(event.id);
656
+ return null;
657
+ }
658
+ async function readUntilOutcome(response) {
659
+ if (!response.body)
660
+ throw interruptedStream('The server returned an empty turn stream.');
661
+ const reader = response.body.getReader();
662
+ const decoder = new TextDecoder();
663
+ let buffer = '';
664
+ try {
665
+ for (;;) {
666
+ const read = await Promise.race([readTurnChunk(reader, signal), work.failed]);
667
+ if (read.done)
668
+ throw interruptedStream();
669
+ buffer += decoder.decode(read.value, { stream: true });
670
+ for (let match = /\r?\n\r?\n/.exec(buffer); match; match = /\r?\n\r?\n/.exec(buffer)) {
671
+ const block = buffer.slice(0, match.index);
672
+ buffer = buffer.slice(match.index + match[0].length);
673
+ signal.throwIfAborted();
674
+ const event = parseSseBlock(block);
675
+ if (!event)
676
+ continue;
677
+ recovery.restored(streamToken);
678
+ const outcome = handleEvent(event);
679
+ if (outcome)
680
+ return outcome;
681
+ }
682
+ if (buffer.length > MAX_TURN_EVENT_BYTES) {
683
+ throw new Error('The server sent a turn event larger than the protocol allows.');
684
+ }
359
685
  }
360
- 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);
686
+ }
687
+ finally {
688
+ await reader.cancel().catch(() => { });
361
689
  }
362
690
  }
363
691
  try {
364
- while (true) {
365
- if (signal?.aborted) {
366
- await reader.cancel().catch(() => { });
367
- throw new TurnCancelledError();
692
+ for (;;) {
693
+ let outcome;
694
+ try {
695
+ signal.throwIfAborted();
696
+ const response = await openStream(lastEventId);
697
+ recovery.reconnected(streamToken);
698
+ outcome = await readUntilOutcome(response);
368
699
  }
369
- const read = await Promise.race([reader.read(), parallelToolFailure]);
370
- if (read.done)
371
- break;
372
- buffer += decoder.decode(read.value, { stream: true });
373
- let separatorIndex = buffer.indexOf('\n\n');
374
- while (separatorIndex !== -1) {
375
- const block = buffer.slice(0, separatorIndex);
376
- buffer = buffer.slice(separatorIndex + 2);
377
- const event = parseSseBlock(block);
378
- if (event)
379
- await handleEvent(event);
380
- separatorIndex = buffer.indexOf('\n\n');
700
+ catch (error) {
701
+ signal.throwIfAborted();
702
+ if (work.hasFailed)
703
+ throw error;
704
+ await recovery.retryAfter(streamToken, lastEventId ? 'reading the turn stream' : 'opening the turn', error);
705
+ continue;
381
706
  }
707
+ acknowledge.note(lastEventId);
708
+ acknowledge.flush();
709
+ if (outcome.kind === 'result') {
710
+ deliveries.abort(new Error('The turn already finished.'));
711
+ await work.executed();
712
+ signal.throwIfAborted();
713
+ }
714
+ else {
715
+ stopLocalWork();
716
+ await work.settled();
717
+ }
718
+ return outcome;
382
719
  }
383
- await drainParallelTools();
384
720
  }
385
721
  catch (error) {
386
- await reader.cancel().catch(() => { });
387
- throw error;
722
+ const outcome = signal.aborted ? signal.reason : error;
723
+ stopLocalWork();
724
+ await work.settled();
725
+ throw outcome;
388
726
  }
389
727
  finally {
390
- await drainParallelTools().catch(() => { });
391
- }
392
- buffer += decoder.decode();
393
- const tail = buffer.trim();
394
- if (tail) {
395
- const event = parseSseBlock(tail);
396
- if (event)
397
- await handleEvent(event);
398
- }
399
- if (!finalResult.current?.snapshot) {
400
- throw new Error('Server returned an invalid chat result.');
728
+ if (registeredTurnId) {
729
+ activeTurnRequesters.delete(registeredTurnId);
730
+ turnInterjections.delete(registeredTurnId);
731
+ }
401
732
  }
402
- return finalResult.current;
403
733
  }
404
- export async function sendServerUserMessage({ config, projectIndex, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, }) {
734
+ export async function sendServerUserMessage({ config, session, input, imageAttachments = [], fetchImpl = globalThis.fetch, signal, onTurnStart, onInterjectionDelivered, recoverySleep, }) {
405
735
  const autoAttach = autoAttachImages(input, session.rootDir, imageAttachments);
406
736
  const requestImageAttachments = autoAttach.attachments.length > 0
407
737
  ? [...imageAttachments, ...autoAttach.attachments]
@@ -414,57 +744,99 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
414
744
  session.onStatus(`Image: ${err}`);
415
745
  }
416
746
  const request = {
747
+ clientTurnId: randomUUID(),
417
748
  modelId: session.modelId,
418
749
  session: snapshotForServer(session),
419
750
  input: requestInputBase,
420
751
  backgroundJobUpdate: backgroundJobUpdate || undefined,
421
752
  clientEnvironment: collectClientEnvironment({ env: session.env }),
422
753
  projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
754
+ sessionImageStore: describeSessionImageStore(session.env) ?? undefined,
423
755
  imageAttachments: imageAttachmentsForServer(requestImageAttachments),
424
- maxToolSteps: session.maxToolSteps,
756
+ ...(session.maxToolStepsWasExplicit || !browserExtensionSeen(session.env)
757
+ ? { maxToolSteps: session.maxToolSteps }
758
+ : {}),
425
759
  autoYes: session.autoYes,
426
760
  agentMode: session.agentMode,
427
761
  };
428
762
  const trace = createTraceContext();
763
+ let completed = false;
764
+ let turnEnded = false;
765
+ const reportRecovery = (status) => {
766
+ if (!turnEnded)
767
+ session.onRecovery?.(status);
768
+ };
769
+ const startAttempt = (turnRequest, onStart) => startTurnAttempt({
770
+ config,
771
+ session,
772
+ turnRequest,
773
+ input: requestInputBase,
774
+ trace,
775
+ fetchImpl,
776
+ recoverySleep,
777
+ reportRecovery,
778
+ onTurnStart: onStart,
779
+ onInterjectionDelivered,
780
+ });
781
+ let attempt = startAttempt(request, onTurnStart);
782
+ const cancelTurn = () => attempt.cancel();
429
783
  const preTurnHistoryLength = session.history.length;
430
784
  const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInputBase);
431
785
  if (signal?.aborted) {
432
786
  preserveOnAbort();
787
+ cancelTurn();
433
788
  }
434
789
  else {
435
790
  signal?.addEventListener('abort', preserveOnAbort, { once: true });
791
+ signal?.addEventListener('abort', cancelTurn, { once: true });
436
792
  }
437
793
  try {
438
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn`, {
439
- method: 'POST',
440
- headers: {
441
- accept: 'text/event-stream',
442
- authorization: `Bearer ${config.token}`,
443
- 'content-type': 'application/json',
444
- ...trace.headers,
445
- },
446
- body: JSON.stringify(request),
447
- signal,
448
- });
449
- if (!response.ok) {
450
- throw await readErrorResponse(response, trace.traceId);
794
+ let outcome;
795
+ try {
796
+ outcome = await attempt.run();
451
797
  }
452
- const result = await consumeTurnStream({
453
- response,
454
- config,
455
- projectIndex,
456
- session,
457
- input: requestInputBase,
458
- fetchImpl,
459
- signal,
460
- traceId: trace.traceId,
461
- });
462
- applySessionSnapshot(session, result.snapshot, { preserveAgentMode: true });
798
+ catch (error) {
799
+ const sent = attempt.interjections;
800
+ if (!isTurnLostByServer(error) || sent.some((item) => item.hasImages))
801
+ throw error;
802
+ attempt.close(false);
803
+ if (signal?.aborted)
804
+ throw new TurnCancelledError();
805
+ const carried = sent.filter((item) => !item.delivered);
806
+ for (const item of carried)
807
+ preserveTurnInterjection(session, requestInputBase, item);
808
+ reportRecovery({ phase: 'continuing' });
809
+ attempt = startAttempt({
810
+ ...request,
811
+ clientTurnId: randomUUID(),
812
+ session: snapshotForServer(session),
813
+ continuation: {
814
+ clientTurnId: request.clientTurnId,
815
+ historyIndex: preTurnHistoryLength,
816
+ },
817
+ }, (turnId) => {
818
+ reportRecovery({ phase: 'continued' });
819
+ onTurnStart?.(turnId);
820
+ if (carried.length > 0) {
821
+ onInterjectionDelivered?.({
822
+ turnId,
823
+ step: 0,
824
+ deliveredAt: 'tool_round_boundary',
825
+ messageIds: carried.map((item) => item.messageId),
826
+ });
827
+ }
828
+ });
829
+ outcome = await attempt.run();
830
+ }
831
+ if (outcome.kind === 'failed')
832
+ throw outcome.error;
833
+ applySessionSnapshot(session, outcome.result.snapshot, { preserveAgentMode: true });
834
+ completed = true;
463
835
  return {
464
- text: result.text,
465
- waitingForApproval: result.waitingForApproval,
466
- toolBudgetReached: result.toolBudgetReached,
467
- usageSummary: result.usageSummary,
836
+ text: outcome.result.text,
837
+ waitingForApproval: outcome.result.waitingForApproval,
838
+ toolBudgetReached: outcome.result.toolBudgetReached,
839
+ usageSummary: outcome.result.usageSummary,
468
840
  };
469
841
  }
470
842
  catch (error) {
@@ -474,11 +846,153 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
474
846
  ? error
475
847
  : new TurnCancelledError();
476
848
  }
477
- session.history.length = preTurnHistoryLength;
478
- preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
849
+ const exhausted = isTurnRecoveryExhausted(error);
850
+ const carriesTrace = error instanceof ChatTurnFailedError || error instanceof ServerApiError;
851
+ const transportDetail = carriesTrace || exhausted ? null : transportFailureDetail(error);
852
+ if (transportDetail && error instanceof Error) {
853
+ const phase = turnPhaseOf(error) ?? 'reaching the server';
854
+ error.message = `connection lost while ${phase}: ${transportDetail} [trace ${trace.traceId}]`;
855
+ }
856
+ else if (!carriesTrace && error instanceof Error) {
857
+ error.message = `${error.message} [trace ${trace.traceId}]`;
858
+ }
859
+ const category = error instanceof ChatTurnFailedError
860
+ ? error.category
861
+ : exhausted
862
+ ? 'connection_error'
863
+ :
864
+ (gatewayFailureCategory(error) ?? 'unknown_error');
865
+ const partial = error instanceof ChatTurnFailedError ? error.partialSnapshot : undefined;
866
+ if (partial) {
867
+ applySessionSnapshot(session, partial, { preserveAgentMode: true });
868
+ }
869
+ else if (session.history.length === preTurnHistoryLength) {
870
+ preserveFailedTurnInput(session, requestInputBase, category);
871
+ }
872
+ else {
873
+ appendTurnFailureMarker(session, category);
874
+ }
875
+ try {
876
+ saveSessionState(session, session.env);
877
+ }
878
+ catch { }
479
879
  throw error;
480
880
  }
481
881
  finally {
882
+ turnEnded = true;
482
883
  signal?.removeEventListener('abort', preserveOnAbort);
884
+ signal?.removeEventListener('abort', cancelTurn);
885
+ attempt.close(completed);
886
+ releaseBrowserAfterTurn();
483
887
  }
484
888
  }
889
+ function isTurnLostByServer(error) {
890
+ return error instanceof ServerApiError && error.code === 'RECOVERY_UNAVAILABLE';
891
+ }
892
+ function startTurnAttempt({ config, session, turnRequest, input, trace, fetchImpl, recoverySleep, reportRecovery, onTurnStart, onInterjectionDelivered, }) {
893
+ const requestBody = JSON.stringify(turnRequest);
894
+ const interjections = [];
895
+ const turnController = new AbortController();
896
+ const turnSignal = turnController.signal;
897
+ session.turnAbortSignal = turnSignal;
898
+ let serverSettled = false;
899
+ let cancellationSent = false;
900
+ const sendCancellation = () => {
901
+ if (cancellationSent)
902
+ return;
903
+ cancellationSent = true;
904
+ const cancelController = new AbortController();
905
+ void fetchTurnAttempt(fetchImpl, turnUrl(config, turnRequest.clientTurnId, 'cancel'), {
906
+ method: 'POST',
907
+ headers: {
908
+ authorization: `Bearer ${config.token}`,
909
+ 'content-type': 'application/json',
910
+ ...trace.headers,
911
+ },
912
+ body: JSON.stringify({ sessionId: turnRequest.session.id }),
913
+ }, cancelController.signal, { readBody: true }).catch(() => { });
914
+ };
915
+ const stopLocalWork = () => {
916
+ turnController.abort(new TurnCancelledError());
917
+ cancelActiveCommand();
918
+ };
919
+ const recovery = createTurnRecovery({
920
+ signal: turnSignal,
921
+ sleep: recoverySleep,
922
+ onStatus: reportRecovery,
923
+ });
924
+ const requester = createTurnRequester({
925
+ fetchImpl,
926
+ recovery,
927
+ signal: turnSignal,
928
+ traceId: trace.traceId,
929
+ });
930
+ const acknowledge = createEventAcknowledger(async (eventId) => {
931
+ const response = await fetchTurnAttempt(fetchImpl, turnUrl(config, turnRequest.clientTurnId, 'ack'), {
932
+ method: 'POST',
933
+ headers: {
934
+ authorization: `Bearer ${config.token}`,
935
+ 'content-type': 'application/json',
936
+ ...trace.headers,
937
+ },
938
+ body: JSON.stringify({ eventId }),
939
+ }, new AbortController().signal, { readBody: true });
940
+ if (!response.ok)
941
+ throw await readErrorResponse(response, trace.traceId);
942
+ });
943
+ const openStream = async (lastEventId) => {
944
+ const response = await fetchTurnAttempt(fetchImpl, `${normalizeServerUrl(config.serverUrl)}/v1/chat/turn`, {
945
+ method: 'POST',
946
+ headers: {
947
+ accept: 'text/event-stream',
948
+ authorization: `Bearer ${config.token}`,
949
+ 'content-type': 'application/json',
950
+ ...trace.headers,
951
+ ...(lastEventId ? { 'last-event-id': lastEventId } : {}),
952
+ },
953
+ body: requestBody,
954
+ }, turnSignal);
955
+ if (!response.ok) {
956
+ throw await readErrorResponse(response, trace.traceId);
957
+ }
958
+ return response;
959
+ };
960
+ return {
961
+ interjections,
962
+ cancel() {
963
+ stopLocalWork();
964
+ sendCancellation();
965
+ },
966
+ async run() {
967
+ const outcome = await runTurnStream({
968
+ openStream,
969
+ config,
970
+ session,
971
+ input,
972
+ request: requester,
973
+ recovery,
974
+ signal: turnSignal,
975
+ traceId: trace.traceId,
976
+ acknowledge,
977
+ stopLocalWork,
978
+ interjections,
979
+ onTurnStart,
980
+ onInterjectionDelivered,
981
+ });
982
+ serverSettled = true;
983
+ return outcome;
984
+ },
985
+ close(completed) {
986
+ acknowledge.stop();
987
+ if (!serverSettled)
988
+ sendCancellation();
989
+ if (!completed)
990
+ turnController.abort(new TurnCancelledError());
991
+ if (session.turnAbortSignal === turnSignal)
992
+ session.turnAbortSignal = undefined;
993
+ },
994
+ };
995
+ }
996
+ export function isTurnOutcomeKnown(error) {
997
+ return error instanceof ChatTurnFailedError;
998
+ }