@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.
- package/README.md +93 -4
- package/dist/bin/ai.js +142 -112
- package/dist/bin/browser-host.js +265 -0
- package/dist/src/agent-mode.js +32 -7
- package/dist/src/api/auth.js +10 -4
- package/dist/src/api/browser-login.js +149 -37
- package/dist/src/api/chat.js +680 -166
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +151 -7
- package/dist/src/api/models.js +29 -10
- package/dist/src/api/startup-handshake.js +36 -0
- package/dist/src/api/turn-recovery.js +189 -0
- package/dist/src/background-jobs.js +4 -2
- package/dist/src/browser/bridge.js +262 -0
- package/dist/src/browser/framing.js +42 -0
- package/dist/src/browser/native-host.js +219 -0
- package/dist/src/browser/protocol.js +46 -0
- package/dist/src/browser/session-bridge.js +130 -0
- package/dist/src/cli-args.js +19 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +246 -0
- package/dist/src/edit-journal.js +33 -2
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +112 -21
- package/dist/src/patcher.js +43 -4
- package/dist/src/permissions.js +288 -0
- package/dist/src/preferences.js +39 -0
- package/dist/src/quota-notice.js +35 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +19 -4
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +13 -46
- package/dist/src/tools/browser.js +641 -0
- package/dist/src/tools/delete-file.js +20 -4
- package/dist/src/tools/index.js +32 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-file.js +5 -0
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +949 -279
- package/dist/src/ui/slash-commands.js +121 -0
- package/dist/src/ui/tui/attention.js +25 -0
- package/dist/src/ui/tui/bridge.js +38 -5
- package/dist/src/ui/tui/build-frame.js +686 -223
- package/dist/src/ui/tui/composer-layout.js +76 -0
- package/dist/src/ui/tui/desktop-notification.js +118 -0
- package/dist/src/ui/tui/markdown-render.js +11 -2
- package/dist/src/ui/tui/shell-input.js +256 -73
- package/dist/src/ui/tui/shortcuts.js +61 -0
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/user-input.js +593 -0
- package/dist/src/update-check.js +205 -0
- package/dist/src/usage.js +5 -2
- package/dist/src/utils.js +13 -0
- package/dist/src/version.js +52 -2
- package/package.json +22 -9
- package/dist/src/project-index.js +0 -233
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- package/dist/src/tools/signature-help.js +0 -118
|
@@ -1 +1,55 @@
|
|
|
1
|
-
|
|
1
|
+
function nonEmptyString(value) {
|
|
2
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
3
|
+
}
|
|
4
|
+
const USER_INPUT_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
5
|
+
export function isUserInputQuestionArray(value) {
|
|
6
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const questionIds = new Set();
|
|
10
|
+
return value.every((question) => {
|
|
11
|
+
if (!question || typeof question !== 'object' || Array.isArray(question)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const candidate = question;
|
|
15
|
+
if (!nonEmptyString(candidate.id) ||
|
|
16
|
+
!USER_INPUT_ID_PATTERN.test(candidate.id) ||
|
|
17
|
+
questionIds.has(candidate.id) ||
|
|
18
|
+
!nonEmptyString(candidate.header) ||
|
|
19
|
+
Array.from(candidate.header).length > 12 ||
|
|
20
|
+
!nonEmptyString(candidate.question) ||
|
|
21
|
+
typeof candidate.multiSelect !== 'boolean' ||
|
|
22
|
+
!Array.isArray(candidate.options) ||
|
|
23
|
+
candidate.options.length < 2 ||
|
|
24
|
+
candidate.options.length > 4) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
questionIds.add(candidate.id);
|
|
28
|
+
const optionIds = new Set();
|
|
29
|
+
const optionsValid = candidate.options.every((option) => {
|
|
30
|
+
if (!option || typeof option !== 'object' || Array.isArray(option)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const item = option;
|
|
34
|
+
if (!nonEmptyString(item.id) ||
|
|
35
|
+
!USER_INPUT_ID_PATTERN.test(item.id) ||
|
|
36
|
+
optionIds.has(item.id) ||
|
|
37
|
+
!nonEmptyString(item.label) ||
|
|
38
|
+
!nonEmptyString(item.description)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
optionIds.add(item.id);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
if (!optionsValid) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (candidate.recommendedOptionId !== undefined &&
|
|
48
|
+
(!nonEmptyString(candidate.recommendedOptionId) ||
|
|
49
|
+
candidate.recommendedOptionId !==
|
|
50
|
+
candidate.options[0].id)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_THEGITAI_HOST = 'https://thegit.ai';
|
package/dist/src/api/http.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
2
|
+
import { DEFAULT_THEGITAI_HOST } from './default-host.js';
|
|
3
3
|
export const TRACE_ID_HEADER = 'x-thegitai-trace-id';
|
|
4
4
|
export const CLIENT_HEADER = 'x-thegitai-client';
|
|
5
5
|
export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
|
|
6
6
|
export class ServerApiError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
traceId;
|
|
9
|
-
|
|
9
|
+
code;
|
|
10
|
+
details;
|
|
11
|
+
constructor(message, status, traceId, code = '', details = {}) {
|
|
10
12
|
super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
|
|
11
13
|
this.name = 'ServerApiError';
|
|
12
14
|
this.status = status;
|
|
13
15
|
this.traceId = traceId;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.details = details;
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
export function createTraceId() {
|
|
@@ -27,6 +31,14 @@ export function createTraceContext(traceId = createTraceId()) {
|
|
|
27
31
|
};
|
|
28
32
|
}
|
|
29
33
|
export const REQUEST_TIMEOUT_MS = 8000;
|
|
34
|
+
export const STARTUP_REQUEST_TIMEOUT_MS = 3000;
|
|
35
|
+
export const STARTUP_DEADLINE_MS = 10_000;
|
|
36
|
+
export const STARTUP_RETRY_BUDGET = {
|
|
37
|
+
retries: 3,
|
|
38
|
+
baseDelayMs: 200,
|
|
39
|
+
deadlineMs: STARTUP_DEADLINE_MS,
|
|
40
|
+
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS,
|
|
41
|
+
};
|
|
30
42
|
const TRANSIENT_NETWORK_CODES = new Set([
|
|
31
43
|
'ECONNRESET',
|
|
32
44
|
'ECONNREFUSED',
|
|
@@ -58,7 +70,9 @@ export function isTransientNetworkError(error) {
|
|
|
58
70
|
}
|
|
59
71
|
return false;
|
|
60
72
|
}
|
|
61
|
-
export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {}) {
|
|
73
|
+
export async function retryTransient(run, { retries = 2, baseDelayMs = 400, deadlineMs, now = () => Date.now(), } = {}) {
|
|
74
|
+
const startedAt = now();
|
|
75
|
+
const remainingMs = () => deadlineMs == null ? Infinity : deadlineMs - (now() - startedAt);
|
|
62
76
|
let attempt = 0;
|
|
63
77
|
for (;;) {
|
|
64
78
|
try {
|
|
@@ -68,13 +82,15 @@ export async function retryTransient(run, { retries = 2, baseDelayMs = 400 } = {
|
|
|
68
82
|
if (attempt >= retries || !isTransientNetworkError(error))
|
|
69
83
|
throw error;
|
|
70
84
|
const delayMs = baseDelayMs * 2 ** attempt;
|
|
85
|
+
if (remainingMs() <= delayMs)
|
|
86
|
+
throw error;
|
|
71
87
|
attempt += 1;
|
|
72
88
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
73
89
|
}
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
92
|
export function normalizeServerUrl(serverUrl) {
|
|
77
|
-
const normalized = String(serverUrl ||
|
|
93
|
+
const normalized = String(serverUrl || DEFAULT_THEGITAI_HOST)
|
|
78
94
|
.trim()
|
|
79
95
|
.replace(/\/+$/, '');
|
|
80
96
|
if (!/^https?:\/\//i.test(normalized)) {
|
|
@@ -82,6 +98,101 @@ export function normalizeServerUrl(serverUrl) {
|
|
|
82
98
|
}
|
|
83
99
|
return normalized;
|
|
84
100
|
}
|
|
101
|
+
const MAX_NON_JSON_BODY_CHARS = 200;
|
|
102
|
+
const GATEWAY_STATUS_MESSAGES = {
|
|
103
|
+
502: 'Could not reach the TheGitAI server (bad gateway).',
|
|
104
|
+
503: 'The TheGitAI server is temporarily unavailable.',
|
|
105
|
+
504: 'The TheGitAI server did not respond in time (gateway timeout).',
|
|
106
|
+
520: 'The connection to the TheGitAI server failed (edge error 520).',
|
|
107
|
+
521: 'The TheGitAI server is not accepting connections (edge error 521).',
|
|
108
|
+
522: 'The connection to the TheGitAI server timed out (edge error 522).',
|
|
109
|
+
523: 'The TheGitAI server is unreachable (edge error 523).',
|
|
110
|
+
524: 'The TheGitAI server did not respond in time (edge error 524).',
|
|
111
|
+
};
|
|
112
|
+
function truncateByCodePoint(text, maxChars) {
|
|
113
|
+
if (text.length <= maxChars)
|
|
114
|
+
return text;
|
|
115
|
+
const points = Array.from(text);
|
|
116
|
+
if (points.length <= maxChars)
|
|
117
|
+
return text;
|
|
118
|
+
return `${points.slice(0, maxChars).join('').trimEnd()}…`;
|
|
119
|
+
}
|
|
120
|
+
export function nonJsonErrorMessage(body, status) {
|
|
121
|
+
const rayId = /Cloudflare Ray ID:\s*(?:<[^>]*>\s*)*([0-9a-f]{8,})/i.exec(body)?.[1];
|
|
122
|
+
const gateway = GATEWAY_STATUS_MESSAGES[status];
|
|
123
|
+
const summary = truncateByCodePoint(body
|
|
124
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
125
|
+
.replace(/<[^>]*>/g, ' ')
|
|
126
|
+
.replace(/\s+/g, ' ')
|
|
127
|
+
.trim(), MAX_NON_JSON_BODY_CHARS);
|
|
128
|
+
const base = gateway ?? (summary || `Request failed with ${status}`);
|
|
129
|
+
return rayId ? `${base} (Cloudflare Ray ID: ${rayId})` : base;
|
|
130
|
+
}
|
|
131
|
+
const TRANSPORT_CODES = new Set([
|
|
132
|
+
'ECONNRESET',
|
|
133
|
+
'ECONNREFUSED',
|
|
134
|
+
'ECONNABORTED',
|
|
135
|
+
'EPIPE',
|
|
136
|
+
'ETIMEDOUT',
|
|
137
|
+
'ENOTFOUND',
|
|
138
|
+
'EAI_AGAIN',
|
|
139
|
+
'EHOSTUNREACH',
|
|
140
|
+
'ENETUNREACH',
|
|
141
|
+
'EPROTO',
|
|
142
|
+
'ERR_STREAM_PREMATURE_CLOSE',
|
|
143
|
+
]);
|
|
144
|
+
const TRANSPORT_PHRASES = [
|
|
145
|
+
[/\bsocket hang up\b/i, 'socket hang up'],
|
|
146
|
+
[/\bother side closed\b/i, 'other side closed'],
|
|
147
|
+
[/\bpremature close\b/i, 'premature close'],
|
|
148
|
+
[/\bterminated\b/i, 'terminated'],
|
|
149
|
+
[/\bfetch failed\b/i, 'fetch failed'],
|
|
150
|
+
[/\bnetwork timeout\b/i, 'network timeout'],
|
|
151
|
+
[/\bnetwork error\b/i, 'network error'],
|
|
152
|
+
[/\bheaders timeout\b/i, 'headers timeout'],
|
|
153
|
+
[/\bbody timeout\b/i, 'body timeout'],
|
|
154
|
+
];
|
|
155
|
+
export function transportFailureDetail(error) {
|
|
156
|
+
if (error instanceof ServerApiError)
|
|
157
|
+
return null;
|
|
158
|
+
const parts = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
const queue = [error];
|
|
161
|
+
while (queue.length && seen.size < 24) {
|
|
162
|
+
const current = queue.shift();
|
|
163
|
+
if (!current || typeof current !== 'object' || seen.has(current))
|
|
164
|
+
continue;
|
|
165
|
+
seen.add(current);
|
|
166
|
+
const code = String(current.code ?? '');
|
|
167
|
+
if (TRANSPORT_CODES.has(code) || /^UND_ERR_[A-Z_]+$/.test(code)) {
|
|
168
|
+
if (!parts.includes(code))
|
|
169
|
+
parts.push(code);
|
|
170
|
+
}
|
|
171
|
+
const message = String(current.message ?? '');
|
|
172
|
+
for (const [pattern, label] of TRANSPORT_PHRASES) {
|
|
173
|
+
if (pattern.test(message) && !parts.includes(label))
|
|
174
|
+
parts.push(label);
|
|
175
|
+
}
|
|
176
|
+
if (current.cause)
|
|
177
|
+
queue.push(current.cause);
|
|
178
|
+
const nested = current.errors;
|
|
179
|
+
if (Array.isArray(nested))
|
|
180
|
+
queue.push(...nested);
|
|
181
|
+
}
|
|
182
|
+
return parts.length ? parts.slice(0, 3).join(' <- ') : null;
|
|
183
|
+
}
|
|
184
|
+
function isTransportFailure(error) {
|
|
185
|
+
return transportFailureDetail(error) !== null;
|
|
186
|
+
}
|
|
187
|
+
export function gatewayFailureCategory(error) {
|
|
188
|
+
if (isTransportFailure(error))
|
|
189
|
+
return 'connection_error';
|
|
190
|
+
if (!(error instanceof ServerApiError))
|
|
191
|
+
return null;
|
|
192
|
+
if (isQuotaExceededError(error))
|
|
193
|
+
return 'quota_exceeded';
|
|
194
|
+
return error.status in GATEWAY_STATUS_MESSAGES ? 'gateway_error' : null;
|
|
195
|
+
}
|
|
85
196
|
export async function readJsonResponse(response) {
|
|
86
197
|
const text = await response.text();
|
|
87
198
|
if (!text.trim())
|
|
@@ -90,15 +201,48 @@ export async function readJsonResponse(response) {
|
|
|
90
201
|
return JSON.parse(text);
|
|
91
202
|
}
|
|
92
203
|
catch {
|
|
93
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
error: { message: nonJsonErrorMessage(text, response.status) },
|
|
206
|
+
};
|
|
94
207
|
}
|
|
95
208
|
}
|
|
96
209
|
export function failureMessage(data, status) {
|
|
97
210
|
return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
|
|
98
211
|
}
|
|
212
|
+
export function failureCode(data) {
|
|
213
|
+
return typeof data?.error?.code === 'string' ? data.error.code : '';
|
|
214
|
+
}
|
|
215
|
+
export function failureDetails(data) {
|
|
216
|
+
const error = data?.error;
|
|
217
|
+
if (!error || typeof error !== 'object')
|
|
218
|
+
return {};
|
|
219
|
+
return {
|
|
220
|
+
...(typeof error.window === 'string' ? { window: error.window } : {}),
|
|
221
|
+
...(typeof error.resetsAt === 'string' ? { resetsAt: error.resetsAt } : {}),
|
|
222
|
+
...(typeof error.percentLeft === 'number'
|
|
223
|
+
? { percentLeft: error.percentLeft }
|
|
224
|
+
: {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export const QUOTA_EXCEEDED_CODE = 'QUOTA_EXCEEDED';
|
|
228
|
+
export function isQuotaExceededError(error) {
|
|
229
|
+
if (!(error instanceof ServerApiError))
|
|
230
|
+
return false;
|
|
231
|
+
if (error.code === QUOTA_EXCEEDED_CODE)
|
|
232
|
+
return true;
|
|
233
|
+
return error.status === 429 && !error.code;
|
|
234
|
+
}
|
|
235
|
+
export function isAuthenticationError(error) {
|
|
236
|
+
return error instanceof ServerApiError && error.status === 401;
|
|
237
|
+
}
|
|
238
|
+
export function authenticationErrorMessage(error) {
|
|
239
|
+
return error.code === 'AUTH_TOKEN_EXPIRED'
|
|
240
|
+
? 'Your login expired after 48 hours of inactivity. Run `ai login` and resume this saved session.'
|
|
241
|
+
: 'Your login is no longer valid. Run `ai login` and resume this saved session.';
|
|
242
|
+
}
|
|
99
243
|
export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
|
|
100
244
|
const data = await readJsonResponse(response);
|
|
101
|
-
return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
|
|
245
|
+
return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data), failureDetails(data));
|
|
102
246
|
}
|
|
103
247
|
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
|
|
104
248
|
const trace = createTraceContext();
|
|
@@ -115,7 +259,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
|
|
|
115
259
|
});
|
|
116
260
|
const data = await readJsonResponse(response);
|
|
117
261
|
if (!response.ok) {
|
|
118
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
262
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data), failureDetails(data));
|
|
119
263
|
}
|
|
120
264
|
return data;
|
|
121
265
|
}
|
package/dist/src/api/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
4
|
+
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
function sanitizeModelInfo(raw) {
|
|
6
6
|
if (!raw || typeof raw !== 'object') {
|
|
7
7
|
return null;
|
|
@@ -58,12 +58,21 @@ export function selectCacheForServer(cached, serverUrl) {
|
|
|
58
58
|
export function writeCachedServerModels(cache, env = process.env) {
|
|
59
59
|
const filePath = getModelsCachePath(env);
|
|
60
60
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(tempPath, `${JSON.stringify(cache, null, 2)}\n`, {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
mode: 0o600,
|
|
66
|
+
});
|
|
67
|
+
renameSync(tempPath, filePath);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
rmSync(tempPath, { force: true });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
65
73
|
}
|
|
66
|
-
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
|
|
74
|
+
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, budget = {}, }) {
|
|
75
|
+
const { timeoutMs = REQUEST_TIMEOUT_MS, ...ladder } = budget;
|
|
67
76
|
return retryTransient(async () => {
|
|
68
77
|
const trace = createTraceContext();
|
|
69
78
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
|
|
@@ -71,11 +80,11 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
71
80
|
authorization: `Bearer ${config.token}`,
|
|
72
81
|
...trace.headers,
|
|
73
82
|
},
|
|
74
|
-
signal: AbortSignal.timeout(
|
|
83
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
75
84
|
});
|
|
76
85
|
const data = (await readJsonResponse(response));
|
|
77
86
|
if (!response.ok) {
|
|
78
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
87
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
79
88
|
}
|
|
80
89
|
const models = Array.isArray(data?.models)
|
|
81
90
|
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
@@ -84,7 +93,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
84
93
|
throw new Error('Server returned an invalid model list.');
|
|
85
94
|
}
|
|
86
95
|
return { models };
|
|
87
|
-
});
|
|
96
|
+
}, ladder);
|
|
88
97
|
}
|
|
89
98
|
export function selectServerModel({ requestedModelId, cached, serverModels, }) {
|
|
90
99
|
const supportedIds = new Set(serverModels.models.map((model) => model.id));
|
|
@@ -113,6 +122,16 @@ export function validateServerModel(modelId, serverModels) {
|
|
|
113
122
|
}
|
|
114
123
|
return requested;
|
|
115
124
|
}
|
|
125
|
+
export function resolveResumedSessionModel(storedModelId, serverModels) {
|
|
126
|
+
if (serverModels.models.some((model) => model.id === storedModelId)) {
|
|
127
|
+
return { modelId: storedModelId, fellBack: false };
|
|
128
|
+
}
|
|
129
|
+
const fallbackId = serverModels.models[0]?.id;
|
|
130
|
+
return {
|
|
131
|
+
modelId: fallbackId ?? storedModelId,
|
|
132
|
+
fellBack: fallbackId != null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
116
135
|
export function updateSelectedModelCache({ config, selectedModelId, serverModels, env = process.env, }) {
|
|
117
136
|
writeCachedServerModels({
|
|
118
137
|
serverUrl: normalizeServerUrl(config.serverUrl),
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
|
-
import { buildCommandEnv, commandUsesSudo, sanitizeCommandText, terminateChild, } from './executor.js';
|
|
3
|
+
import { buildCommandEnv, commandUsesSudo, resolveCommandShell, sanitizeCommandText, terminateChild, } from './executor.js';
|
|
4
4
|
import { isTuiMode } from './runtime-mode.js';
|
|
5
5
|
import { redactConnectionStringCredentials } from './secret-preview.js';
|
|
6
6
|
const MAX_RUNNING_JOBS = 8;
|
|
@@ -196,7 +196,7 @@ export async function startBackgroundJob(command, cwd, { startupWaitMs, sessionI
|
|
|
196
196
|
const id = `bg_${++jobCounter}`;
|
|
197
197
|
const child = spawn(command, {
|
|
198
198
|
cwd,
|
|
199
|
-
shell:
|
|
199
|
+
shell: resolveCommandShell(),
|
|
200
200
|
detached: process.platform !== 'win32',
|
|
201
201
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
202
202
|
env: buildCommandEnv(cwd),
|
|
@@ -265,6 +265,7 @@ export function getJobOutputTail(id, maxLines) {
|
|
|
265
265
|
if (!record || !belongsToSession(record) || maxLines <= 0)
|
|
266
266
|
return [];
|
|
267
267
|
const lines = sanitizeJobText(record, record.buffer)
|
|
268
|
+
.replace(/\r\n?/g, '\n')
|
|
268
269
|
.split('\n')
|
|
269
270
|
.filter((line) => line.trim().length > 0);
|
|
270
271
|
return lines.slice(-maxLines);
|
|
@@ -277,6 +278,7 @@ export function getJobOutputPreview(id, maxTailLines) {
|
|
|
277
278
|
? sanitizeJobText(record, record.firstOutputLine).trim()
|
|
278
279
|
: '';
|
|
279
280
|
return {
|
|
281
|
+
droppedChars: Math.max(record.totalCaptured - record.buffer.length, 0),
|
|
280
282
|
firstLine,
|
|
281
283
|
tailLines: getJobOutputTail(id, maxTailLines),
|
|
282
284
|
};
|