@thegitai/cli 1.0.0-preview.47 → 1.0.0-preview.49

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.
@@ -0,0 +1,36 @@
1
+ import { fetchWhoamiResponse } from './auth.js';
2
+ import { STARTUP_RETRY_BUDGET, isTransientNetworkError, } from './http.js';
3
+ import { fetchServerModels } from './models.js';
4
+ import { abortableDelay, createTurnRecovery, isRetryableTurnFailure, isTurnRecoveryExhausted, } from './turn-recovery.js';
5
+ export async function loadStartupHandshake({ config, canStartOffline, fetchImpl, budget = STARTUP_RETRY_BUDGET, sleep = abortableDelay, onStatus = () => { }, }) {
6
+ let lastFailure;
7
+ const recovery = createTurnRecovery({
8
+ signal: new AbortController().signal,
9
+ sleep,
10
+ onStatus: (status) => onStatus(status, lastFailure),
11
+ });
12
+ const attempt = async () => {
13
+ const [models, whoami] = await Promise.allSettled([
14
+ fetchServerModels({ config, fetchImpl, budget }),
15
+ fetchWhoamiResponse({ config, fetchImpl, budget }),
16
+ ]);
17
+ const unreachable = models.status === 'rejected' &&
18
+ isRetryableTurnFailure(models.reason) &&
19
+ !(canStartOffline && isTransientNetworkError(models.reason));
20
+ if (unreachable) {
21
+ lastFailure = models.reason;
22
+ throw models.reason;
23
+ }
24
+ return { models, whoami };
25
+ };
26
+ try {
27
+ return await recovery.run('loading your models', attempt);
28
+ }
29
+ catch (error) {
30
+ const reason = isTurnRecoveryExhausted(error) ? error.cause : error;
31
+ return {
32
+ models: { status: 'rejected', reason },
33
+ whoami: { status: 'rejected', reason },
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,189 @@
1
+ import { isTransientNetworkError, ServerApiError, transportFailureDetail, } from './http.js';
2
+ export const TURN_RECOVERY_DELAY_MS = 10_000;
3
+ export const TURN_RECOVERY_RETRIES = 3;
4
+ export const TURN_REQUEST_TIMEOUT_MS = 30_000;
5
+ export const TURN_STREAM_IDLE_TIMEOUT_MS = 60_000;
6
+ const RETRYABLE_TURN_STATUSES = new Set([408, 500, 502, 503, 504, 520, 521, 522, 523, 524]);
7
+ export function isRetryableTurnStatus(status) {
8
+ return RETRYABLE_TURN_STATUSES.has(status);
9
+ }
10
+ export function isRetryableTurnFailure(error) {
11
+ if (error instanceof ServerApiError)
12
+ return isRetryableTurnStatus(error.status);
13
+ return isTransientNetworkError(error);
14
+ }
15
+ export function interruptedStream(message = 'The turn stream ended before the turn finished.') {
16
+ return Object.assign(new Error(message), { code: 'ECONNRESET' });
17
+ }
18
+ export function formatTurnRecoveryStatus(status) {
19
+ if (status.phase === 'waiting') {
20
+ return `Connection interrupted · retrying in ${TURN_RECOVERY_DELAY_MS / 1000}s · retry ${status.retry}/${TURN_RECOVERY_RETRIES}`;
21
+ }
22
+ if (status.phase === 'reconnecting') {
23
+ return `Reconnecting · retry ${status.retry}/${TURN_RECOVERY_RETRIES}`;
24
+ }
25
+ if (status.phase === 'reconnected')
26
+ return 'Reconnected';
27
+ if (status.phase === 'continuing')
28
+ return 'Server restarted · continuing from the finished steps';
29
+ if (status.phase === 'continued')
30
+ return 'Server restarted · continued from the finished steps';
31
+ return 'Connection restored · continued the same turn';
32
+ }
33
+ export const TURN_RECOVERY_EXHAUSTED = 'TURN_RECOVERY_EXHAUSTED';
34
+ export function isTurnRecoveryExhausted(error) {
35
+ return error?.code === TURN_RECOVERY_EXHAUSTED;
36
+ }
37
+ function recoveryExhausted(operation, cause) {
38
+ const detail = transportFailureDetail(cause);
39
+ const reason = detail ?? (cause instanceof ServerApiError ? `HTTP ${cause.status}` : 'no response');
40
+ return Object.assign(new Error(`Connection lost while ${operation} (${reason}). Reconnecting failed after ${TURN_RECOVERY_RETRIES} retries. Some actions in this turn may already have completed.`, { cause }), { code: TURN_RECOVERY_EXHAUSTED });
41
+ }
42
+ export function abortableDelay(ms, signal) {
43
+ return new Promise((resolve, reject) => {
44
+ if (signal.aborted) {
45
+ reject(signal.reason);
46
+ return;
47
+ }
48
+ const onAbort = () => {
49
+ clearTimeout(timer);
50
+ reject(signal.reason);
51
+ };
52
+ const timer = setTimeout(() => {
53
+ signal.removeEventListener('abort', onAbort);
54
+ resolve();
55
+ }, ms);
56
+ signal.addEventListener('abort', onAbort, { once: true });
57
+ });
58
+ }
59
+ function untilAborted(promise, signal) {
60
+ if (signal.aborted)
61
+ return Promise.reject(signal.reason);
62
+ return new Promise((resolve, reject) => {
63
+ const onAbort = () => reject(signal.reason);
64
+ signal.addEventListener('abort', onAbort, { once: true });
65
+ promise
66
+ .then(resolve, reject)
67
+ .finally(() => signal.removeEventListener('abort', onAbort));
68
+ });
69
+ }
70
+ export function createTurnRecovery({ signal, onStatus = () => { }, sleep = abortableDelay, }) {
71
+ const affected = new Set();
72
+ let retry = 0;
73
+ let gate = null;
74
+ let exhausted = null;
75
+ async function retryAfter(token, operation, error, caller = signal) {
76
+ signal.throwIfAborted();
77
+ withdrawIfAborted(token, caller);
78
+ if (exhausted)
79
+ throw exhausted;
80
+ if (!isRetryableTurnFailure(error))
81
+ throw error;
82
+ affected.add(token);
83
+ if (!gate) {
84
+ if (retry === TURN_RECOVERY_RETRIES) {
85
+ exhausted = recoveryExhausted(operation, error);
86
+ throw exhausted;
87
+ }
88
+ retry += 1;
89
+ const current = retry;
90
+ onStatus({ phase: 'waiting', retry: current });
91
+ gate = sleep(TURN_RECOVERY_DELAY_MS, signal)
92
+ .then(() => {
93
+ signal.throwIfAborted();
94
+ onStatus({ phase: 'reconnecting', retry: current });
95
+ })
96
+ .finally(() => {
97
+ gate = null;
98
+ });
99
+ }
100
+ if (caller === signal) {
101
+ await gate;
102
+ return;
103
+ }
104
+ await untilAborted(gate, caller).catch((reason) => {
105
+ withdrawIfAborted(token, caller);
106
+ throw reason;
107
+ });
108
+ }
109
+ function withdrawIfAborted(token, caller) {
110
+ if (!caller.aborted)
111
+ return;
112
+ affected.delete(token);
113
+ throw caller.reason;
114
+ }
115
+ function reconnected(token) {
116
+ if (!affected.has(token) || gate || exhausted)
117
+ return;
118
+ onStatus({ phase: 'reconnected' });
119
+ }
120
+ function restored(token) {
121
+ if (!affected.delete(token))
122
+ return;
123
+ if (affected.size > 0 || gate || exhausted)
124
+ return;
125
+ retry = 0;
126
+ onStatus({ phase: 'restored' });
127
+ }
128
+ async function run(operation, attempt, caller = signal) {
129
+ const token = {};
130
+ for (;;) {
131
+ signal.throwIfAborted();
132
+ withdrawIfAborted(token, caller);
133
+ if (exhausted)
134
+ throw exhausted;
135
+ try {
136
+ const value = await attempt();
137
+ restored(token);
138
+ return value;
139
+ }
140
+ catch (error) {
141
+ await retryAfter(token, operation, error, caller);
142
+ }
143
+ }
144
+ }
145
+ return { run, retryAfter, reconnected, restored };
146
+ }
147
+ export async function fetchTurnAttempt(fetchImpl, url, init, signal, { readBody = false } = {}) {
148
+ const controller = new AbortController();
149
+ const onAbort = () => controller.abort(signal.reason);
150
+ signal.addEventListener('abort', onAbort, { once: true });
151
+ if (signal.aborted)
152
+ onAbort();
153
+ const timer = setTimeout(() => controller.abort(new DOMException('The request did not get a response in time.', 'TimeoutError')), TURN_REQUEST_TIMEOUT_MS);
154
+ try {
155
+ const response = await fetchImpl(url, { ...init, signal: controller.signal });
156
+ if (!readBody && response.ok)
157
+ return response;
158
+ return new Response(await response.arrayBuffer(), {
159
+ status: response.status,
160
+ headers: response.headers,
161
+ });
162
+ }
163
+ finally {
164
+ clearTimeout(timer);
165
+ signal.removeEventListener('abort', onAbort);
166
+ }
167
+ }
168
+ export async function readTurnChunk(reader, signal) {
169
+ let timer;
170
+ let onAbort;
171
+ try {
172
+ return await Promise.race([
173
+ reader.read(),
174
+ new Promise((_, reject) => {
175
+ timer = setTimeout(() => reject(interruptedStream(`The turn stream sent nothing for ${TURN_STREAM_IDLE_TIMEOUT_MS / 1000} seconds.`)), TURN_STREAM_IDLE_TIMEOUT_MS);
176
+ onAbort = () => reject(signal.reason);
177
+ signal.addEventListener('abort', onAbort, { once: true });
178
+ if (signal.aborted)
179
+ onAbort();
180
+ }),
181
+ ]);
182
+ }
183
+ finally {
184
+ if (timer)
185
+ clearTimeout(timer);
186
+ if (onAbort)
187
+ signal.removeEventListener('abort', onAbort);
188
+ }
189
+ }
@@ -91,13 +91,12 @@ export function browserAvailability(options) {
91
91
  selected: status.selected,
92
92
  };
93
93
  }
94
- const hint = status.registrationErrors.length
95
- ? `TheGitAI could not register its browser bridge with Chrome: ${status.registrationErrors[0]}`
96
- : status.everConnected
97
- ? 'The browser was connected to this session and dropped. Nothing needs reinstalling — it reconnects on its own, usually within a second or two. If it does not, open the TheGitAI icon in Chrome.'
98
- : chromiumProcessRunning()
99
- ? 'Chrome is running but the TheGitAI extension has not connected. Install or enable the extension, then reload it from chrome://extensions.'
100
- : 'Chrome does not appear to be running. Open Chrome with the TheGitAI extension installed.';
94
+ const hint = connectionHint({
95
+ registrationError: status.registrationErrors[0] ?? null,
96
+ everConnected: status.everConnected,
97
+ chromeRunning: () => chromiumProcessRunning(),
98
+ extensionSeen: () => browserExtensionSeen(options.env),
99
+ });
101
100
  return {
102
101
  connected: false,
103
102
  everConnected: status.everConnected,
@@ -106,6 +105,21 @@ export function browserAvailability(options) {
106
105
  hint,
107
106
  };
108
107
  }
108
+ export function connectionHint(state) {
109
+ if (state.registrationError) {
110
+ return `TheGitAI could not register its browser bridge with Chrome: ${state.registrationError}`;
111
+ }
112
+ if (state.everConnected) {
113
+ return 'The browser was connected to this session but is disconnected now. Check Chrome and the connection status shown by the TheGitAI extension.';
114
+ }
115
+ if (!state.chromeRunning()) {
116
+ return 'Chrome does not appear to be running. Open Chrome with the TheGitAI extension enabled and check its connection status.';
117
+ }
118
+ if (state.extensionSeen()) {
119
+ return 'The TheGitAI extension has connected on this machine before, but has not connected to this session yet. Check the connection status shown by the extension.';
120
+ }
121
+ return 'Chrome is running, but no browser connection reached this session. Check that the TheGitAI extension is installed and enabled, and inspect its connection status.';
122
+ }
109
123
  export function resyncBrowserBridge(options) {
110
124
  try {
111
125
  return ensureBrowserBridge(options).resync();
@@ -194,6 +194,13 @@ const HELP_MARKDOWN = [
194
194
  ' computer" on that page, and paste the code back into the terminal.',
195
195
  '- A local session was used with a different sign-in → sign in with the',
196
196
  ' account you used for that session or start a new session.',
197
+ '- The connection drops mid-turn → nothing to do: the CLI reconnects to the',
198
+ ' same turn up to three times, ten seconds apart, without running anything',
199
+ ' twice. Esc stops it. If it cannot reconnect it says so; some actions may',
200
+ ' already have completed, and a queued message waits in the composer.',
201
+ ' If the service restarted mid-turn, the turn continues from the steps that',
202
+ ' had finished. Starting `ai` while the service is briefly unavailable waits',
203
+ ' the same way.',
197
204
  '- For anything else, re-run the command and file the printed error',
198
205
  ' message with `/report` — there is no client-side debug mode by design.',
199
206
  ].join('\n');
@@ -213,6 +213,7 @@ function recordAssistantEdit(session, call, result, before) {
213
213
  clearEditFailure(session.clientState.safety, filePath);
214
214
  }
215
215
  export async function executeLocalToolCall(session, call) {
216
+ session.turnAbortSignal?.throwIfAborted();
216
217
  session.onStatus(formatToolCallForStatus(call));
217
218
  try {
218
219
  const agentModeBlocked = buildAgentModeToolBlockedResult(session.agentMode, call);
@@ -8,8 +8,7 @@ import { ensurePermission, originForUrl } from '../permissions.js';
8
8
  const READ_TIMEOUT_MS = 20000;
9
9
  const NAVIGATE_TIMEOUT_MS = 45000;
10
10
  const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
11
- const RECONNECT_GRACE_MS = 2500;
12
- const SCAN_GRACE_MS = 1200;
11
+ const DISCOVERY_TIMEOUT_MS = 2500;
13
12
  export const READ_ONLY_BROWSER_TOOLS = new Set([
14
13
  'browser',
15
14
  'browser_tabs',
@@ -35,38 +34,31 @@ function missing(argument, tool) {
35
34
  }
36
35
  async function waitForBrowser(options, timeoutMs) {
37
36
  const deadline = Date.now() + timeoutMs;
38
- let availability = browserAvailability(options);
39
- while (!availability.connected && Date.now() < deadline) {
37
+ const bridge = ensureBrowserBridge(options);
38
+ while (!bridge.status().connected && Date.now() < deadline) {
40
39
  await new Promise((resolve) => setTimeout(resolve, 100));
41
- availability = browserAvailability(options);
42
40
  }
43
- return availability;
41
+ return browserAvailability(options);
44
42
  }
45
43
  async function notConnected(context) {
46
44
  const options = { cwd: context.rootDir, env: context.env };
47
- let availability = browserAvailability(options);
45
+ const status = ensureBrowserBridge(options).status();
46
+ if (status.connected)
47
+ return { ok: true };
48
+ if (status.everConnected)
49
+ resyncBrowserBridge(options);
50
+ const availability = await waitForBrowser(options, DISCOVERY_TIMEOUT_MS);
48
51
  if (availability.connected)
49
52
  return { ok: true };
50
- if (availability.everConnected) {
51
- const grace = resyncBrowserBridge(options) ? RECONNECT_GRACE_MS : SCAN_GRACE_MS;
52
- availability = await waitForBrowser(options, grace);
53
- if (availability.connected)
54
- return { ok: true };
55
- }
56
53
  return {
57
54
  ok: false,
55
+ connected: false,
56
+ browserErrorCode: 'not_connected',
58
57
  error: `TheGitAI is not connected to a browser. ${availability.hint ?? ''}`.trim(),
59
58
  failureCategory: 'external_service',
60
- failureDetails: {
61
- category: 'external_service',
62
- tool: 'browser',
63
- action: availability.everConnected
64
- ? 'The link to a browser that was working dropped and did not come back. Nothing is missing and nothing needs installing, so do not send the user to reinstall anything. Say what happened, and that reopening the TheGitAI icon in Chrome restores it. Do not retry in a loop.'
65
- : 'Report the connection instructions. The extension is a separate installation. Do not retry in a loop or invent a download URL.',
66
- },
67
59
  };
68
60
  }
69
- function bridgeFailure(error, tool) {
61
+ function bridgeFailure(error) {
70
62
  const code = error instanceof BrowserBridgeError ? error.code : 'browser_error';
71
63
  const message = String(error?.message ?? error);
72
64
  return {
@@ -74,15 +66,6 @@ function bridgeFailure(error, tool) {
74
66
  error: message,
75
67
  failureCategory: code === 'not_connected' ? 'external_service' : 'tool_exception',
76
68
  browserErrorCode: code,
77
- failureDetails: {
78
- category: code === 'not_connected' ? 'external_service' : 'tool_exception',
79
- tool,
80
- action: code === 'timeout' || code === 'disconnected'
81
- ? 'The outcome is unknown. Read the page before repeating anything that submits, sends, buys, or deletes.'
82
- : code === 'stale_refs' || code === 'unknown_ref'
83
- ? 'Re-read the page and use the references from that read.'
84
- : 'Report what failed. Do not retry the same call more than once.',
85
- },
86
69
  };
87
70
  }
88
71
  async function call(context, op, args = {}, timeoutMs = READ_TIMEOUT_MS) {
@@ -152,7 +135,7 @@ export async function browserTabs(context, args) {
152
135
  return attachEvents({ ok: true, ...result });
153
136
  }
154
137
  catch (error) {
155
- return bridgeFailure(error, 'browser_tabs');
138
+ return bridgeFailure(error);
156
139
  }
157
140
  }
158
141
  export async function browserTabNew(context) {
@@ -164,7 +147,7 @@ export async function browserTabNew(context) {
164
147
  return attachEvents({ ok: true, ...result });
165
148
  }
166
149
  catch (error) {
167
- return bridgeFailure(error, 'browser_tab_new');
150
+ return bridgeFailure(error);
168
151
  }
169
152
  }
170
153
  export async function browserTabClose(context, args) {
@@ -179,7 +162,7 @@ export async function browserTabClose(context, args) {
179
162
  return attachEvents({ ok: true, ...result });
180
163
  }
181
164
  catch (error) {
182
- return bridgeFailure(error, 'browser_tab_close');
165
+ return bridgeFailure(error);
183
166
  }
184
167
  }
185
168
  export async function browserNavigate(context, args) {
@@ -212,7 +195,7 @@ export async function browserNavigate(context, args) {
212
195
  return attachEvents({ ok: true, ...result });
213
196
  }
214
197
  catch (error) {
215
- return bridgeFailure(error, 'browser_navigate');
198
+ return bridgeFailure(error);
216
199
  }
217
200
  }
218
201
  async function gatedTabCall(context, { tool, op, args, summary, timeoutMs = READ_TIMEOUT_MS, }) {
@@ -249,7 +232,7 @@ async function gatedTabCall(context, { tool, op, args, summary, timeoutMs = READ
249
232
  return attachEvents({ ok: true, tabId, url: tab.url, ...result });
250
233
  }
251
234
  catch (error) {
252
- return bridgeFailure(error, tool);
235
+ return bridgeFailure(error);
253
236
  }
254
237
  }
255
238
  export function browserReadPage(context, args) {
@@ -557,7 +540,7 @@ export async function browserFileUpload(context, args) {
557
540
  return attachEvents({ ok: true, ...result, files: files.map((file) => file.name) });
558
541
  }
559
542
  catch (error) {
560
- return bridgeFailure(error, 'browser_file_upload');
543
+ return bridgeFailure(error);
561
544
  }
562
545
  }
563
546
  function guessMimeType(file) {
@@ -12,7 +12,8 @@ export { getInputCommandToken, getSlashCommandSuggestions, SLASH_COMMANDS, slash
12
12
  import { composerInputWidth, frameContentWidth } from './tui/composer-layout.js';
13
13
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
14
14
  import { chat, models } from '../api/index.js';
15
- import { isTurnCancelledError } from '../api/chat.js';
15
+ import { isTurnCancelledError, isTurnOutcomeKnown } from '../api/chat.js';
16
+ import { formatTurnRecoveryStatus } from '../api/turn-recovery.js';
16
17
  import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
17
18
  import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
18
19
  import { setScratchSession } from '../scratch-dir.js';
@@ -542,15 +543,7 @@ function getToolFilePath(call, result) {
542
543
  '').trim();
543
544
  }
544
545
  function formatToolResultState(result) {
545
- if (result?.skipped)
546
- return 'Skipped';
547
- if (result?.blocked)
548
- return 'Blocked';
549
- if (result?.ok === true)
550
- return 'OK';
551
- if (Number.isInteger(result?.httpStatus))
552
- return String(result.httpStatus);
553
- return 'Failed';
546
+ return result?.skipped ? 'Skipped' : 'OK';
554
547
  }
555
548
  function formatRunCommandResultState(result) {
556
549
  if (result?.timedOut)
@@ -588,12 +581,15 @@ function buildFileChangeEntry(event) {
588
581
  : call.name === 'undo_edit'
589
582
  ? 'Undo'
590
583
  : 'Edit';
591
- return {
592
- body: error || `${call.name} ${filePath}`,
593
- filePath,
594
- kind: skipped ? 'system' : 'error',
595
- title: skipped ? `${verb} skipped: ${filePath}` : `${verb} failed: ${filePath}`,
596
- };
584
+ if (skipped) {
585
+ return {
586
+ body: error || `${call.name} ${filePath}`,
587
+ filePath,
588
+ kind: 'system',
589
+ title: `${verb} skipped: ${filePath}`,
590
+ };
591
+ }
592
+ return { body: '', filePath, kind: 'system', title: `${verb}: ${filePath}` };
597
593
  }
598
594
  if (result?.scratch === true) {
599
595
  const scratchName = truncate(filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath, 72);
@@ -710,22 +706,25 @@ function buildFileChangeEntry(event) {
710
706
  }
711
707
  export function buildWorkingToolEntry(event) {
712
708
  const { call, result } = event;
713
- const error = typeof result?.error === 'string' && result.error.trim()
709
+ const failed = result?.ok !== true && result?.skipped !== true;
710
+ const kind = result?.ok === true ? 'tool' : 'system';
711
+ const state = (text) => (failed ? '' : text);
712
+ const error = !failed && typeof result?.error === 'string' && result.error.trim()
714
713
  ? `\n${truncate(result.error.trim(), 180)}`
715
714
  : '';
716
715
  if (call.name === 'run_command') {
717
716
  const command = String(call.args?.command ?? result?.command ?? '').trim();
718
717
  if (result?.backgrounded === true && result?.jobId) {
719
718
  return {
720
- body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
721
- kind: result?.ok === true ? 'tool' : 'error',
719
+ body: `$ ${truncate(command, 180)}${state(`\nBackground job ${String(result.jobId)} started.`)}${error}`,
720
+ kind,
722
721
  preformatted: true,
723
722
  title: 'Shell',
724
723
  };
725
724
  }
726
725
  return {
727
- body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
728
- kind: result?.ok === true ? 'tool' : 'error',
726
+ body: `$ ${truncate(command, 180)}${state(`\n${formatRunCommandResultState(result)}`)}${error}`,
727
+ kind,
729
728
  title: 'Shell',
730
729
  };
731
730
  }
@@ -737,15 +736,15 @@ export function buildWorkingToolEntry(event) {
737
736
  ? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
738
737
  : formatToolResultState(result);
739
738
  return {
740
- body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
741
- kind: result?.ok === true ? 'tool' : 'error',
739
+ body: `${jobId || '(unknown job)'}${state(` · ${stateText}`)}${error}`,
740
+ kind,
742
741
  title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
743
742
  };
744
743
  }
745
744
  if (call.name === 'run_node_script') {
746
745
  return {
747
- body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
748
- kind: result?.ok === true ? 'tool' : 'error',
746
+ body: `node --input-type=module <script via stdin>${state(`\n${formatRunCommandResultState(result)}`)}${error}`,
747
+ kind,
749
748
  title: 'Node',
750
749
  };
751
750
  }
@@ -754,8 +753,8 @@ export function buildWorkingToolEntry(event) {
754
753
  }
755
754
  const args = formatToolArgs(call, result);
756
755
  return {
757
- body: `${call.name}${args ? ` ${args}` : ''} -> ${formatToolResultState(result)}${error}`,
758
- kind: result?.ok === true ? 'tool' : 'error',
756
+ body: `${call.name}${args ? ` ${args}` : ''}${state(` -> ${formatToolResultState(result)}`)}${error}`,
757
+ kind,
759
758
  title: 'Tool',
760
759
  };
761
760
  }
@@ -873,10 +872,10 @@ export function buildTranscriptFromSessionHistory(history) {
873
872
  }
874
873
  if (!call || !isFileChangeTool(call.name))
875
874
  continue;
876
- entries.push(buildFileChangeEntry({
877
- call,
878
- result: functionResponse.response ?? {},
879
- }));
875
+ const result = (functionResponse.response ?? {});
876
+ if (result.ok !== true && result.skipped !== true)
877
+ continue;
878
+ entries.push(buildFileChangeEntry({ call, result }));
880
879
  }
881
880
  if (entry.role === 'user' && entry.kind === 'turnStart') {
882
881
  const text = displayUserTextFromHistoryEntry(entry);
@@ -1751,6 +1750,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1751
1750
  promptHistoryCursor: queued ? null : current.promptHistoryCursor,
1752
1751
  promptHistoryDraft: queued ? '' : current.promptHistoryDraft,
1753
1752
  queuedMessage: null,
1753
+ recoveryStatus: '',
1754
1754
  status: 'Ready',
1755
1755
  thinkingTitle: '',
1756
1756
  thinkingNotes: [],
@@ -2009,13 +2009,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2009
2009
  pending.resolve(result);
2010
2010
  scheduleLiveFrameRemount();
2011
2011
  };
2012
- const openApprovalPrompt = (request) => new Promise((resolve) => {
2012
+ const openApprovalPrompt = (request, signal) => new Promise((resolve) => {
2013
2013
  const deny = { kind: 'deny' };
2014
- if (exiting) {
2014
+ if (exiting || signal?.aborted) {
2015
2015
  resolve(deny);
2016
2016
  return;
2017
2017
  }
2018
- resolvePermissionDecision = resolve;
2018
+ const onAbort = () => dismissPendingApproval();
2019
+ signal?.addEventListener('abort', onAbort, { once: true });
2020
+ resolvePermissionDecision = (decision) => {
2021
+ signal?.removeEventListener('abort', onAbort);
2022
+ resolve(decision);
2023
+ };
2019
2024
  store.update((current) => ({
2020
2025
  ...pauseBusyClock(current, Date.now()),
2021
2026
  ...closedJobsPickerFields(current),
@@ -2816,6 +2821,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2816
2821
  busyPausedAt: null,
2817
2822
  busySince: turnStartedAt,
2818
2823
  clockNow: turnStartedAt,
2824
+ recoveryStatus: '',
2819
2825
  status: 'Running turn...',
2820
2826
  thinkingTitle: '',
2821
2827
  thinkingNotes: [],
@@ -2897,6 +2903,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2897
2903
  busy: false,
2898
2904
  busyPausedAt: null,
2899
2905
  busySince: null,
2906
+ recoveryStatus: '',
2900
2907
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2901
2908
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2902
2909
  }));
@@ -2913,6 +2920,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2913
2920
  const cancelled = isTurnCancelledError(error);
2914
2921
  if (exitForAuthenticationError(error))
2915
2922
  return;
2923
+ dismissPendingApproval();
2924
+ dismissPendingSudoPassword();
2925
+ dismissPendingUserInput();
2916
2926
  const failedTurnAttachments = activeTurnImageAttachments;
2917
2927
  activeTurnImageAttachments = [];
2918
2928
  const quotaNotice = cancelled ? null : buildQuotaNotice(error);
@@ -2925,6 +2935,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2925
2935
  busySince: null,
2926
2936
  commandLog: [],
2927
2937
  imageAttachments: failedTurnAttachments,
2938
+ recoveryStatus: '',
2928
2939
  status: cancelled
2929
2940
  ? 'Ready'
2930
2941
  : quotaNotice
@@ -2964,8 +2975,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2964
2975
  appendStaticEntries(buildTurnFailureHintEntries(cancelled));
2965
2976
  }
2966
2977
  await remountTui();
2967
- recoverUnacknowledgedMessages(!cancelled);
2968
- if (!cancelled && turnGeneration === activeTurnGeneration) {
2978
+ const autoSubmits = !cancelled && isTurnOutcomeKnown(error);
2979
+ recoverUnacknowledgedMessages(autoSubmits);
2980
+ if (autoSubmits && turnGeneration === activeTurnGeneration) {
2969
2981
  await flushQueuedMessage();
2970
2982
  }
2971
2983
  }
@@ -2988,6 +3000,23 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2988
3000
  generatingImage: activeImageCount > 0 ? false : current.generatingImage,
2989
3001
  }));
2990
3002
  };
3003
+ session.onRecovery = (status) => {
3004
+ if (status.phase === 'restored' || status.phase === 'continued') {
3005
+ store.update((current) => ({ ...current, recoveryStatus: '' }));
3006
+ appendTurnAwareEntry(status.phase === 'restored'
3007
+ ? { body: 'Continued the same turn.', kind: 'system', title: 'Connection restored' }
3008
+ : {
3009
+ body: 'Continued from the steps that had finished.',
3010
+ kind: 'system',
3011
+ title: 'Server restarted',
3012
+ });
3013
+ return;
3014
+ }
3015
+ store.update((current) => ({
3016
+ ...current,
3017
+ recoveryStatus: status.phase === 'reconnected' ? '' : formatTurnRecoveryStatus(status),
3018
+ }));
3019
+ };
2991
3020
  session.onImageGeneration = (active) => {
2992
3021
  store.update((current) => ({
2993
3022
  ...current,
@@ -3026,7 +3055,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
3026
3055
  if (isFileChangeTool(event.call.name)) {
3027
3056
  const entry = buildFileChangeEntry(event);
3028
3057
  store.appendWorkingTool(entry);
3029
- appendTurnAwareEntry(entry);
3058
+ const result = event.result;
3059
+ if (result?.ok === true || result?.skipped === true)
3060
+ appendTurnAwareEntry(entry);
3030
3061
  return;
3031
3062
  }
3032
3063
  store.appendWorkingTool(buildWorkingToolEntry(event));
@@ -3079,7 +3110,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
3079
3110
  return deny;
3080
3111
  if (sessionAutoYes)
3081
3112
  return { kind: 'once' };
3082
- const decision = await openApprovalPrompt(request);
3113
+ const decision = await openApprovalPrompt(request, session.turnAbortSignal);
3083
3114
  if (decision.kind === 'always-bucket') {
3084
3115
  appendTurnAwareEntry({
3085
3116
  body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,