@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.
- package/README.md +25 -0
- package/dist/bin/ai.js +21 -11
- package/dist/src/api/chat.js +564 -214
- package/dist/src/api/startup-handshake.js +36 -0
- package/dist/src/api/turn-recovery.js +189 -0
- package/dist/src/browser/session-bridge.js +21 -7
- package/dist/src/help-text.js +7 -0
- package/dist/src/tool-executor.js +1 -0
- package/dist/src/tools/browser.js +19 -36
- package/dist/src/ui/repl.js +69 -38
- package/dist/src/ui/tui/build-frame.js +17 -0
- package/package.json +6 -6
|
@@ -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 =
|
|
95
|
-
|
|
96
|
-
: status.everConnected
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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();
|
package/dist/src/help-text.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
39
|
-
while (!
|
|
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
|
|
41
|
+
return browserAvailability(options);
|
|
44
42
|
}
|
|
45
43
|
async function notConnected(context) {
|
|
46
44
|
const options = { cwd: context.rootDir, env: context.env };
|
|
47
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
543
|
+
return bridgeFailure(error);
|
|
561
544
|
}
|
|
562
545
|
}
|
|
563
546
|
function guessMimeType(file) {
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
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)}
|
|
721
|
-
kind
|
|
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)}
|
|
728
|
-
kind
|
|
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
|
|
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
|
|
748
|
-
kind
|
|
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
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2968
|
-
|
|
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
|
-
|
|
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)}.`,
|