@thegitai/cli 1.0.0-preview.1 → 1.0.0-preview.11
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 +16 -4
- package/dist/bin/ai.js +57 -287
- package/dist/src/api/auth.js +2 -2
- package/dist/src/api/browser-login.js +72 -3
- package/dist/src/api/chat.js +147 -30
- package/dist/src/api/http.js +16 -3
- package/dist/src/api/models.js +9 -4
- package/dist/src/help-text.js +19 -7
- package/dist/src/patcher.js +96 -9
- package/dist/src/project-index.js +13 -1
- package/dist/src/project-orientation.js +99 -0
- package/dist/src/scratch-dir.js +51 -33
- package/dist/src/session-store.js +52 -20
- package/dist/src/session.js +8 -0
- package/dist/src/tool-executor.js +38 -6
- package/dist/src/tools/delete-file.js +22 -4
- package/dist/src/tools/patch-file.js +30 -5
- package/dist/src/tools/read-file.js +3 -1
- package/dist/src/tools/replace-document-text.js +7 -1
- package/dist/src/tools/run-command.js +37 -19
- package/dist/src/tools/run-node-script.js +24 -4
- package/dist/src/tools/str-replace.js +30 -5
- package/dist/src/tools/write-file.js +25 -5
- package/dist/src/turn-failure-marker.js +11 -0
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +197 -51
- package/dist/src/ui/tui/bridge.js +3 -0
- package/dist/src/ui/tui/build-frame.js +179 -82
- package/dist/src/ui/tui/markdown-render.js +72 -73
- package/dist/src/ui/tui/shell-input.js +42 -13
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/utils.js +9 -0
- package/package.json +18 -6
- package/dist/src/markdown-renderer.js +0 -112
package/dist/src/api/chat.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { drainBackgroundJobNotifications } from '../background-jobs.js';
|
|
2
2
|
import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
|
|
3
|
-
import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
|
|
3
|
+
import { applySessionSnapshot, saveSessionState, snapshotFromSession, } from '../session-store.js';
|
|
4
4
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
5
5
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
6
6
|
import { collectClientEnvironment } from '../client-environment.js';
|
|
7
|
+
import { collectProjectOrientation } from '../project-orientation.js';
|
|
7
8
|
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
9
|
+
import { formatTurnFailureMarker } from '../turn-failure-marker.js';
|
|
8
10
|
export class TurnCancelledError extends Error {
|
|
9
11
|
name = 'TurnCancelledError';
|
|
10
12
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -16,11 +18,13 @@ export class ChatTurnFailedError extends Error {
|
|
|
16
18
|
category;
|
|
17
19
|
retryable;
|
|
18
20
|
traceId;
|
|
19
|
-
|
|
21
|
+
partialSnapshot;
|
|
22
|
+
constructor(message, category = 'unknown_error', retryable = false, traceId = '', partialSnapshot) {
|
|
20
23
|
super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
|
|
21
24
|
this.category = category;
|
|
22
25
|
this.retryable = retryable;
|
|
23
26
|
this.traceId = traceId;
|
|
27
|
+
this.partialSnapshot = partialSnapshot;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
export function isTurnCancelledError(error) {
|
|
@@ -50,6 +54,7 @@ function parseSseBlock(block) {
|
|
|
50
54
|
return { event, data: text };
|
|
51
55
|
}
|
|
52
56
|
}
|
|
57
|
+
let toolStateSeqCounter = 0;
|
|
53
58
|
function toolStateFromSession(session) {
|
|
54
59
|
return {
|
|
55
60
|
autoYes: session.autoYes,
|
|
@@ -92,17 +97,30 @@ export function preserveCancelledTurnInput(session, input) {
|
|
|
92
97
|
return;
|
|
93
98
|
break;
|
|
94
99
|
}
|
|
95
|
-
session.history.push({
|
|
100
|
+
session.history.push({
|
|
101
|
+
role: 'user',
|
|
102
|
+
parts: [{ text }],
|
|
103
|
+
kind: 'turnStart',
|
|
104
|
+
userInput: text,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function appendTurnFailureMarker(session, category) {
|
|
108
|
+
session.history.push({
|
|
109
|
+
role: 'model',
|
|
110
|
+
parts: [{ text: formatTurnFailureMarker(category) }],
|
|
111
|
+
});
|
|
96
112
|
}
|
|
97
113
|
function preserveFailedTurnInput(session, input, category) {
|
|
98
114
|
const text = input.trim();
|
|
99
115
|
if (!text)
|
|
100
116
|
return;
|
|
101
|
-
session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
|
|
102
117
|
session.history.push({
|
|
103
|
-
role: '
|
|
104
|
-
parts: [{ text
|
|
118
|
+
role: 'user',
|
|
119
|
+
parts: [{ text }],
|
|
120
|
+
kind: 'turnStart',
|
|
121
|
+
userInput: text,
|
|
105
122
|
});
|
|
123
|
+
appendTurnFailureMarker(session, category);
|
|
106
124
|
}
|
|
107
125
|
function historyHasToolCall(session, callId) {
|
|
108
126
|
return session.history.some((entry) => (entry.parts ?? []).some((part) => String(part?.functionCall?.id ?? '') === callId));
|
|
@@ -143,6 +161,9 @@ function publicStatusMessage(data) {
|
|
|
143
161
|
: 'tool';
|
|
144
162
|
if (event.phase === 'thinking')
|
|
145
163
|
return 'Thinking...';
|
|
164
|
+
if (event.phase === 'analyzing_image') {
|
|
165
|
+
return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
|
|
166
|
+
}
|
|
146
167
|
if (event.phase === 'running_tool')
|
|
147
168
|
return `Running ${toolName}...`;
|
|
148
169
|
if (event.phase === 'waiting_for_tool')
|
|
@@ -183,6 +204,7 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
|
|
|
183
204
|
toolCallId: event.call.id,
|
|
184
205
|
result,
|
|
185
206
|
toolState: toolStateFromSession(session),
|
|
207
|
+
toolStateSeq: ++toolStateSeqCounter,
|
|
186
208
|
};
|
|
187
209
|
const trace = createTraceContext(traceId);
|
|
188
210
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
|
|
@@ -201,6 +223,29 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
|
|
|
201
223
|
throw await readErrorResponse(response, trace.traceId);
|
|
202
224
|
}
|
|
203
225
|
}
|
|
226
|
+
const turnIdOverrides = new WeakMap();
|
|
227
|
+
function enterServerTurnId(session, serverSessionTurnId) {
|
|
228
|
+
const active = turnIdOverrides.get(session);
|
|
229
|
+
if (active) {
|
|
230
|
+
active.depth += 1;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
turnIdOverrides.set(session, {
|
|
234
|
+
previousTurnId: session.turnState.id,
|
|
235
|
+
depth: 1,
|
|
236
|
+
});
|
|
237
|
+
session.turnState.id = serverSessionTurnId;
|
|
238
|
+
}
|
|
239
|
+
function exitServerTurnId(session) {
|
|
240
|
+
const active = turnIdOverrides.get(session);
|
|
241
|
+
if (!active)
|
|
242
|
+
return;
|
|
243
|
+
active.depth -= 1;
|
|
244
|
+
if (active.depth === 0) {
|
|
245
|
+
session.turnState.id = active.previousTurnId;
|
|
246
|
+
turnIdOverrides.delete(session);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
204
249
|
async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
|
|
205
250
|
const turnId = String(event?.turnId ?? '').trim();
|
|
206
251
|
if (!turnId || !event?.call?.id || !event.call.name) {
|
|
@@ -209,10 +254,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
209
254
|
if (signal?.aborted) {
|
|
210
255
|
throw new TurnCancelledError();
|
|
211
256
|
}
|
|
212
|
-
const previousTurnId = session.turnState.id;
|
|
213
257
|
const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
|
|
214
258
|
if (serverSessionTurnId) {
|
|
215
|
-
session
|
|
259
|
+
enterServerTurnId(session, serverSessionTurnId);
|
|
216
260
|
if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
|
|
217
261
|
createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
|
|
218
262
|
}
|
|
@@ -235,7 +279,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
235
279
|
});
|
|
236
280
|
}
|
|
237
281
|
finally {
|
|
238
|
-
|
|
282
|
+
if (serverSessionTurnId) {
|
|
283
|
+
exitServerTurnId(session);
|
|
284
|
+
}
|
|
239
285
|
}
|
|
240
286
|
}
|
|
241
287
|
async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
|
|
@@ -248,8 +294,40 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
248
294
|
const finalResult = {
|
|
249
295
|
current: null,
|
|
250
296
|
};
|
|
297
|
+
const pendingParallelTools = [];
|
|
298
|
+
let firstParallelFailure = null;
|
|
299
|
+
let rejectOnParallelFailure = null;
|
|
300
|
+
const parallelToolFailure = new Promise((_, reject) => {
|
|
301
|
+
rejectOnParallelFailure = reject;
|
|
302
|
+
});
|
|
303
|
+
parallelToolFailure.catch(() => { });
|
|
304
|
+
function recordParallelFailure(error) {
|
|
305
|
+
const failure = error ?? new Error('Local tool execution failed.');
|
|
306
|
+
if (firstParallelFailure == null) {
|
|
307
|
+
firstParallelFailure = failure;
|
|
308
|
+
rejectOnParallelFailure?.(failure);
|
|
309
|
+
}
|
|
310
|
+
return failure;
|
|
311
|
+
}
|
|
312
|
+
async function drainParallelTools() {
|
|
313
|
+
if (!pendingParallelTools.length)
|
|
314
|
+
return;
|
|
315
|
+
const pending = pendingParallelTools.splice(0);
|
|
316
|
+
const outcomes = await Promise.all(pending);
|
|
317
|
+
for (const outcome of outcomes) {
|
|
318
|
+
if (outcome != null)
|
|
319
|
+
throw outcome;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
251
322
|
async function handleEvent(event) {
|
|
252
323
|
if (event.event === 'status') {
|
|
324
|
+
const data = event.data;
|
|
325
|
+
if (data?.phase === 'analyzing_image') {
|
|
326
|
+
session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
|
|
327
|
+
}
|
|
328
|
+
else if (data?.phase) {
|
|
329
|
+
session.onImageAnalysis?.(0);
|
|
330
|
+
}
|
|
253
331
|
const message = publicStatusMessage(event.data);
|
|
254
332
|
if (message)
|
|
255
333
|
session.onStatus(message);
|
|
@@ -259,11 +337,26 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
259
337
|
return;
|
|
260
338
|
}
|
|
261
339
|
if (event.event === 'tool-call') {
|
|
340
|
+
const data = event.data;
|
|
341
|
+
if (data?.parallelSafe === true) {
|
|
342
|
+
pendingParallelTools.push(executeAndPostToolResult({
|
|
343
|
+
config,
|
|
344
|
+
projectIndex,
|
|
345
|
+
session,
|
|
346
|
+
event: data,
|
|
347
|
+
input,
|
|
348
|
+
fetchImpl,
|
|
349
|
+
signal,
|
|
350
|
+
traceId,
|
|
351
|
+
}).then(() => null, (error) => recordParallelFailure(error)));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await drainParallelTools();
|
|
262
355
|
await executeAndPostToolResult({
|
|
263
356
|
config,
|
|
264
357
|
projectIndex,
|
|
265
358
|
session,
|
|
266
|
-
event:
|
|
359
|
+
event: data,
|
|
267
360
|
input,
|
|
268
361
|
fetchImpl,
|
|
269
362
|
signal,
|
|
@@ -279,35 +372,47 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
|
|
|
279
372
|
return;
|
|
280
373
|
}
|
|
281
374
|
if (event.event === 'result') {
|
|
375
|
+
await drainParallelTools();
|
|
282
376
|
finalResult.current = event.data;
|
|
283
377
|
return;
|
|
284
378
|
}
|
|
285
379
|
if (event.event === 'cancelled' || event.event === 'error') {
|
|
380
|
+
await drainParallelTools().catch(() => { });
|
|
286
381
|
const message = String(event.data?.message ?? 'Server chat failed.');
|
|
287
382
|
if (event.event === 'cancelled') {
|
|
288
383
|
throw new TurnCancelledError(message);
|
|
289
384
|
}
|
|
290
|
-
throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId);
|
|
385
|
+
throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId, event.data?.snapshot);
|
|
291
386
|
}
|
|
292
387
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
388
|
+
try {
|
|
389
|
+
while (true) {
|
|
390
|
+
if (signal?.aborted) {
|
|
391
|
+
await reader.cancel().catch(() => { });
|
|
392
|
+
throw new TurnCancelledError();
|
|
393
|
+
}
|
|
394
|
+
const read = await Promise.race([reader.read(), parallelToolFailure]);
|
|
395
|
+
if (read.done)
|
|
396
|
+
break;
|
|
397
|
+
buffer += decoder.decode(read.value, { stream: true });
|
|
398
|
+
let separatorIndex = buffer.indexOf('\n\n');
|
|
399
|
+
while (separatorIndex !== -1) {
|
|
400
|
+
const block = buffer.slice(0, separatorIndex);
|
|
401
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
402
|
+
const event = parseSseBlock(block);
|
|
403
|
+
if (event)
|
|
404
|
+
await handleEvent(event);
|
|
405
|
+
separatorIndex = buffer.indexOf('\n\n');
|
|
406
|
+
}
|
|
310
407
|
}
|
|
408
|
+
await drainParallelTools();
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
await reader.cancel().catch(() => { });
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
await drainParallelTools().catch(() => { });
|
|
311
416
|
}
|
|
312
417
|
buffer += decoder.decode();
|
|
313
418
|
const tail = buffer.trim();
|
|
@@ -339,6 +444,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
339
444
|
input: requestInputBase,
|
|
340
445
|
backgroundJobUpdate: backgroundJobUpdate || undefined,
|
|
341
446
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
447
|
+
projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
|
|
342
448
|
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
343
449
|
maxToolSteps: session.maxToolSteps,
|
|
344
450
|
autoYes: session.autoYes,
|
|
@@ -393,8 +499,19 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
393
499
|
? error
|
|
394
500
|
: new TurnCancelledError();
|
|
395
501
|
}
|
|
396
|
-
|
|
397
|
-
|
|
502
|
+
const category = error instanceof ChatTurnFailedError ? error.category : 'unknown_error';
|
|
503
|
+
const partial = error instanceof ChatTurnFailedError ? error.partialSnapshot : undefined;
|
|
504
|
+
if (partial) {
|
|
505
|
+
applySessionSnapshot(session, partial, { preserveAgentMode: true });
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
session.history.length = preTurnHistoryLength;
|
|
509
|
+
preserveFailedTurnInput(session, requestInputBase, category);
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
saveSessionState(session, session.env);
|
|
513
|
+
}
|
|
514
|
+
catch { }
|
|
398
515
|
throw error;
|
|
399
516
|
}
|
|
400
517
|
finally {
|
package/dist/src/api/http.js
CHANGED
|
@@ -6,11 +6,13 @@ export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
|
|
|
6
6
|
export class ServerApiError extends Error {
|
|
7
7
|
status;
|
|
8
8
|
traceId;
|
|
9
|
-
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, status, traceId, code = '') {
|
|
10
11
|
super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
|
|
11
12
|
this.name = 'ServerApiError';
|
|
12
13
|
this.status = status;
|
|
13
14
|
this.traceId = traceId;
|
|
15
|
+
this.code = code;
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
export function createTraceId() {
|
|
@@ -96,9 +98,20 @@ export async function readJsonResponse(response) {
|
|
|
96
98
|
export function failureMessage(data, status) {
|
|
97
99
|
return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
|
|
98
100
|
}
|
|
101
|
+
export function failureCode(data) {
|
|
102
|
+
return typeof data?.error?.code === 'string' ? data.error.code : '';
|
|
103
|
+
}
|
|
104
|
+
export function isAuthenticationError(error) {
|
|
105
|
+
return error instanceof ServerApiError && error.status === 401;
|
|
106
|
+
}
|
|
107
|
+
export function authenticationErrorMessage(error) {
|
|
108
|
+
return error.code === 'AUTH_TOKEN_EXPIRED'
|
|
109
|
+
? 'Your login expired after 24 hours of inactivity. Run `ai login` and resume this saved session.'
|
|
110
|
+
: 'Your login is no longer valid. Run `ai login` and resume this saved session.';
|
|
111
|
+
}
|
|
99
112
|
export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
|
|
100
113
|
const data = await readJsonResponse(response);
|
|
101
|
-
return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
|
|
114
|
+
return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
|
|
102
115
|
}
|
|
103
116
|
export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
|
|
104
117
|
const trace = createTraceContext();
|
|
@@ -115,7 +128,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
|
|
|
115
128
|
});
|
|
116
129
|
const data = await readJsonResponse(response);
|
|
117
130
|
if (!response.ok) {
|
|
118
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
131
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
119
132
|
}
|
|
120
133
|
return data;
|
|
121
134
|
}
|
package/dist/src/api/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { 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;
|
|
@@ -9,10 +9,15 @@ function sanitizeModelInfo(raw) {
|
|
|
9
9
|
const value = raw;
|
|
10
10
|
const id = Number(value.id);
|
|
11
11
|
const label = String(value.label ?? '').trim();
|
|
12
|
-
|
|
12
|
+
const costRating = Number(value.costRating);
|
|
13
|
+
const description = String(value.description ?? '').trim();
|
|
14
|
+
if (!Number.isInteger(id) || id <= 0 || !label || !isCostRating(costRating)) {
|
|
13
15
|
return null;
|
|
14
16
|
}
|
|
15
|
-
return { id, label };
|
|
17
|
+
return { id, label, costRating, description };
|
|
18
|
+
}
|
|
19
|
+
function isCostRating(value) {
|
|
20
|
+
return Number.isInteger(value) && value >= 1 && value <= 3;
|
|
16
21
|
}
|
|
17
22
|
export function getModelsCachePath(env = process.env) {
|
|
18
23
|
return path.join(getClientStateDir(env), 'models.json');
|
|
@@ -70,7 +75,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
70
75
|
});
|
|
71
76
|
const data = (await readJsonResponse(response));
|
|
72
77
|
if (!response.ok) {
|
|
73
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
78
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
74
79
|
}
|
|
75
80
|
const models = Array.isArray(data?.models)
|
|
76
81
|
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
package/dist/src/help-text.js
CHANGED
|
@@ -22,6 +22,7 @@ const HELP_MARKDOWN = [
|
|
|
22
22
|
'',
|
|
23
23
|
'- `ai` — start an interactive chat session in the current repo',
|
|
24
24
|
'- `ai "<request>"` — start an interactive session with `<request>` as the first message',
|
|
25
|
+
'- Coding sessions require terminal stdin and stdout; piped prompts are not supported.',
|
|
25
26
|
'',
|
|
26
27
|
'## Auth',
|
|
27
28
|
'',
|
|
@@ -35,8 +36,8 @@ const HELP_MARKDOWN = [
|
|
|
35
36
|
'',
|
|
36
37
|
'- `ai --list-sessions` — list saved sessions for this repo',
|
|
37
38
|
'- `ai --session <id|name>` — resume a saved session by id or name',
|
|
38
|
-
'- Sessions are stored locally and
|
|
39
|
-
'
|
|
39
|
+
'- Sessions are stored locally and can be listed or resumed in the same repo.',
|
|
40
|
+
' Continuing one requires the TheGitAI account used for that session.',
|
|
40
41
|
'',
|
|
41
42
|
'## Options',
|
|
42
43
|
'',
|
|
@@ -56,7 +57,8 @@ const HELP_MARKDOWN = [
|
|
|
56
57
|
'## Keys & clipboard',
|
|
57
58
|
'',
|
|
58
59
|
'- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
|
|
59
|
-
' **Ctrl+C**
|
|
60
|
+
' **Ctrl+C** clears the composer or the queued message, and quits once there',
|
|
61
|
+
' is nothing left to clear. These are the same on macOS, Linux, and Windows.',
|
|
60
62
|
`- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
|
|
61
63
|
' on this system) or by right-clicking the composer.',
|
|
62
64
|
'- **Copy** from the transcript by dragging to select; double-click copies a',
|
|
@@ -77,7 +79,7 @@ const HELP_MARKDOWN = [
|
|
|
77
79
|
' browse them, press Enter to expand one and read its output, k to stop it',
|
|
78
80
|
'- `/jobs output <id>` — print one job\'s full captured output',
|
|
79
81
|
'- `/jobs kill <id>` — stop one background job',
|
|
80
|
-
'- `/
|
|
82
|
+
'- `/new` — start a new conversation; this session remains saved',
|
|
81
83
|
'- `/exit` — quit the session',
|
|
82
84
|
'',
|
|
83
85
|
'## Safety & approvals',
|
|
@@ -105,7 +107,10 @@ const HELP_MARKDOWN = [
|
|
|
105
107
|
'- Auth or permission errors → run `ai whoami` to confirm the signed-in',
|
|
106
108
|
' account.',
|
|
107
109
|
'- Usage or quota errors → run `ai --usage`.',
|
|
108
|
-
'-
|
|
110
|
+
'- Signed in with the wrong credentials → `ai logout`, then `ai login` with',
|
|
111
|
+
' the account you intended to use.',
|
|
112
|
+
'- A local session was used with a different sign-in → sign in with the',
|
|
113
|
+
' account you used for that session or start a new session.',
|
|
109
114
|
'- For anything else, re-run the command and report the printed error',
|
|
110
115
|
' message — there is no client-side debug mode by design.',
|
|
111
116
|
].join('\n');
|
|
@@ -126,8 +131,15 @@ export function formatInteractiveHelpText() {
|
|
|
126
131
|
return HELP_MARKDOWN;
|
|
127
132
|
}
|
|
128
133
|
export function formatCliHelpText({ color = false } = {}) {
|
|
129
|
-
if (!color)
|
|
130
|
-
return HELP_MARKDOWN
|
|
134
|
+
if (!color) {
|
|
135
|
+
return HELP_MARKDOWN.split('\n')
|
|
136
|
+
.map((line) => line
|
|
137
|
+
.replace(/^#{1,6}\s+/, '')
|
|
138
|
+
.replace(/^-\s+/, ' ')
|
|
139
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
140
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1'))
|
|
141
|
+
.join('\n');
|
|
142
|
+
}
|
|
131
143
|
return HELP_MARKDOWN.split('\n')
|
|
132
144
|
.map((line) => {
|
|
133
145
|
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
package/dist/src/patcher.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
|
|
2
|
+
import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { runCommand } from './executor.js';
|
|
6
|
+
import { ensureSessionScratchDir, isInsideTheGitAiScratch, isWithinSessionScratchDir, } from './scratch-dir.js';
|
|
6
7
|
import { isTuiMode } from './runtime-mode.js';
|
|
7
8
|
import { truncate } from './utils.js';
|
|
8
9
|
function parseUnifiedDiff(patchText) {
|
|
@@ -143,17 +144,92 @@ export function renderDiffPreview(filePath, patchText) {
|
|
|
143
144
|
function normalizeRoot(rootDir) {
|
|
144
145
|
return path.resolve(rootDir);
|
|
145
146
|
}
|
|
146
|
-
|
|
147
|
+
function expandScratchPath(filePath) {
|
|
148
|
+
const match = filePath.match(/^(?:\$THEGITAI_SCRATCH_DIR|\$\{THEGITAI_SCRATCH_DIR\})(?:[\\/](.*))?$/);
|
|
149
|
+
if (!match)
|
|
150
|
+
return filePath;
|
|
151
|
+
const root = ensureSessionScratchDir();
|
|
152
|
+
return match[1] ? path.join(root, match[1]) : root;
|
|
153
|
+
}
|
|
154
|
+
export function classifyProjectPath(rootDir, filePath) {
|
|
147
155
|
const absRoot = normalizeRoot(rootDir);
|
|
148
|
-
const absPath = path.resolve(absRoot, filePath);
|
|
156
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
157
|
+
if (isWithinSessionScratchDir(absPath)) {
|
|
158
|
+
return absPath !== path.resolve(ensureSessionScratchDir()) &&
|
|
159
|
+
isInsideTheGitAiScratch(absPath)
|
|
160
|
+
? 'scratch'
|
|
161
|
+
: 'outside';
|
|
162
|
+
}
|
|
149
163
|
const relative = path.relative(absRoot, absPath);
|
|
150
|
-
if (relative.startsWith('..')
|
|
151
|
-
|
|
164
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
165
|
+
return 'project';
|
|
166
|
+
}
|
|
167
|
+
return 'outside';
|
|
168
|
+
}
|
|
169
|
+
export function resolveProjectPath(rootDir, filePath) {
|
|
170
|
+
const absRoot = normalizeRoot(rootDir);
|
|
171
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
172
|
+
if (classifyProjectPath(rootDir, filePath) === 'outside') {
|
|
173
|
+
throw new Error(`Refusing to access path outside the project root: ${filePath}. Allowed locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`);
|
|
152
174
|
}
|
|
153
175
|
return absPath;
|
|
154
176
|
}
|
|
177
|
+
function mkdirForWrite(absPath, scratchPath) {
|
|
178
|
+
const parent = path.dirname(absPath);
|
|
179
|
+
if (!scratchPath) {
|
|
180
|
+
mkdirSync(parent, { recursive: true });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const scratchRoot = path.resolve(ensureSessionScratchDir());
|
|
184
|
+
const relative = path.relative(scratchRoot, parent);
|
|
185
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
186
|
+
throw new Error(`Refusing to create a directory outside the session scratch root: ${parent}`);
|
|
187
|
+
}
|
|
188
|
+
let current = scratchRoot;
|
|
189
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
190
|
+
current = path.join(current, segment);
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(current, { mode: 0o700 });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error?.code !== 'EEXIST')
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const stat = lstatSync(current);
|
|
199
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
200
|
+
throw new Error(`Refusing to traverse unsafe scratch directory: ${current}`);
|
|
201
|
+
}
|
|
202
|
+
if (process.platform !== 'win32')
|
|
203
|
+
chmodSync(current, 0o700);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function writeScratchFile(absPath, content) {
|
|
207
|
+
const scratchRoot = realpathSync(ensureSessionScratchDir());
|
|
208
|
+
const parent = realpathSync(path.dirname(absPath));
|
|
209
|
+
const relativeParent = path.relative(scratchRoot, parent);
|
|
210
|
+
if (relativeParent.startsWith('..') || path.isAbsolute(relativeParent)) {
|
|
211
|
+
throw new Error(`Refusing to write through an unsafe scratch directory: ${absPath}`);
|
|
212
|
+
}
|
|
213
|
+
const verifiedPath = path.join(parent, path.basename(absPath));
|
|
214
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
215
|
+
const fd = openSync(verifiedPath, constants.O_WRONLY | constants.O_CREAT | noFollow, 0o600);
|
|
216
|
+
try {
|
|
217
|
+
const stat = fstatSync(fd);
|
|
218
|
+
if (!stat.isFile() || stat.nlink > 1) {
|
|
219
|
+
throw new Error(`Refusing to write an unsafe scratch file: ${absPath}`);
|
|
220
|
+
}
|
|
221
|
+
ftruncateSync(fd, 0);
|
|
222
|
+
if (process.platform !== 'win32')
|
|
223
|
+
fchmodSync(fd, 0o600);
|
|
224
|
+
writeFileSync(fd, content);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
closeSync(fd);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
155
230
|
export function writeProjectFile(rootDir, filePath, content) {
|
|
156
231
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
232
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
157
233
|
if (existsSync(absPath)) {
|
|
158
234
|
try {
|
|
159
235
|
const existingContent = readFileSync(absPath, 'utf-8');
|
|
@@ -164,12 +240,18 @@ export function writeProjectFile(rootDir, filePath, content) {
|
|
|
164
240
|
catch {
|
|
165
241
|
}
|
|
166
242
|
}
|
|
167
|
-
|
|
168
|
-
|
|
243
|
+
mkdirForWrite(absPath, scratchPath);
|
|
244
|
+
if (scratchPath) {
|
|
245
|
+
writeScratchFile(absPath, content);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
writeFileSync(absPath, content, 'utf-8');
|
|
249
|
+
}
|
|
169
250
|
return { absPath, changed: true };
|
|
170
251
|
}
|
|
171
252
|
export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
172
253
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
254
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
173
255
|
if (existsSync(absPath)) {
|
|
174
256
|
try {
|
|
175
257
|
const existingContent = readFileSync(absPath);
|
|
@@ -180,8 +262,13 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
|
180
262
|
catch {
|
|
181
263
|
}
|
|
182
264
|
}
|
|
183
|
-
|
|
184
|
-
|
|
265
|
+
mkdirForWrite(absPath, scratchPath);
|
|
266
|
+
if (scratchPath) {
|
|
267
|
+
writeScratchFile(absPath, content);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
writeFileSync(absPath, content);
|
|
271
|
+
}
|
|
185
272
|
return { absPath, changed: true };
|
|
186
273
|
}
|
|
187
274
|
export function deleteProjectFile(rootDir, filePath) {
|
|
@@ -39,10 +39,21 @@ function removeFile(index, relPath) {
|
|
|
39
39
|
index.chunksByFile.delete(relPath);
|
|
40
40
|
index.fileSignatures.delete(relPath);
|
|
41
41
|
}
|
|
42
|
+
function countIndexedChunks(index) {
|
|
43
|
+
return Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
|
|
44
|
+
}
|
|
42
45
|
async function initializeIndex(index) {
|
|
43
46
|
if (index.initialized) {
|
|
44
|
-
return
|
|
47
|
+
return countIndexedChunks(index);
|
|
48
|
+
}
|
|
49
|
+
if (!index._initializing) {
|
|
50
|
+
index._initializing = scanProjectIntoIndex(index).finally(() => {
|
|
51
|
+
index._initializing = null;
|
|
52
|
+
});
|
|
45
53
|
}
|
|
54
|
+
return index._initializing;
|
|
55
|
+
}
|
|
56
|
+
async function scanProjectIntoIndex(index) {
|
|
46
57
|
const files = listProjectFiles(index.rootDir);
|
|
47
58
|
const chunks = await scanFiles(index.rootDir, files);
|
|
48
59
|
index.fileSignatures.clear();
|
|
@@ -106,6 +117,7 @@ export function createIndex({ rootDir, onStatus = null, onContextLog = null, })
|
|
|
106
117
|
return {
|
|
107
118
|
rootDir: path.resolve(rootDir),
|
|
108
119
|
initialized: false,
|
|
120
|
+
_initializing: null,
|
|
109
121
|
fileSignatures: new Map(),
|
|
110
122
|
chunksByFile: new Map(),
|
|
111
123
|
onStatus,
|