@thegitai/cli 1.0.0-preview.1 → 1.0.0-preview.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +16 -4
  2. package/dist/bin/ai.js +57 -287
  3. package/dist/src/api/auth.js +2 -2
  4. package/dist/src/api/browser-login.js +72 -3
  5. package/dist/src/api/chat.js +125 -24
  6. package/dist/src/api/http.js +16 -3
  7. package/dist/src/api/models.js +9 -4
  8. package/dist/src/help-text.js +19 -7
  9. package/dist/src/patcher.js +96 -9
  10. package/dist/src/project-index.js +13 -1
  11. package/dist/src/project-orientation.js +99 -0
  12. package/dist/src/scratch-dir.js +51 -33
  13. package/dist/src/session-store.js +52 -20
  14. package/dist/src/session.js +8 -0
  15. package/dist/src/tool-executor.js +38 -6
  16. package/dist/src/tools/delete-file.js +22 -4
  17. package/dist/src/tools/patch-file.js +30 -5
  18. package/dist/src/tools/read-file.js +3 -1
  19. package/dist/src/tools/replace-document-text.js +7 -1
  20. package/dist/src/tools/run-command.js +37 -19
  21. package/dist/src/tools/run-node-script.js +24 -4
  22. package/dist/src/tools/str-replace.js +30 -5
  23. package/dist/src/tools/write-file.js +25 -5
  24. package/dist/src/turn-failure-marker.js +11 -0
  25. package/dist/src/ui/prompt-history-store.js +1 -1
  26. package/dist/src/ui/repl.js +188 -49
  27. package/dist/src/ui/tui/bridge.js +3 -0
  28. package/dist/src/ui/tui/build-frame.js +179 -82
  29. package/dist/src/ui/tui/markdown-render.js +72 -73
  30. package/dist/src/ui/tui/shell-input.js +42 -13
  31. package/dist/src/ui/tui/terminal-title.js +3 -0
  32. package/dist/src/ui/tui/terminal-writes.js +48 -0
  33. package/dist/src/ui/tui/text.js +158 -4
  34. package/dist/src/utils.js +9 -0
  35. package/package.json +18 -6
  36. package/dist/src/markdown-renderer.js +0 -112
@@ -4,7 +4,9 @@ import { applySessionSnapshot, 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.') {
@@ -50,6 +52,7 @@ function parseSseBlock(block) {
50
52
  return { event, data: text };
51
53
  }
52
54
  }
55
+ let toolStateSeqCounter = 0;
53
56
  function toolStateFromSession(session) {
54
57
  return {
55
58
  autoYes: session.autoYes,
@@ -92,16 +95,26 @@ export function preserveCancelledTurnInput(session, input) {
92
95
  return;
93
96
  break;
94
97
  }
95
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
98
+ session.history.push({
99
+ role: 'user',
100
+ parts: [{ text }],
101
+ kind: 'turnStart',
102
+ userInput: text,
103
+ });
96
104
  }
97
105
  function preserveFailedTurnInput(session, input, category) {
98
106
  const text = input.trim();
99
107
  if (!text)
100
108
  return;
101
- session.history.push({ role: 'user', parts: [{ text }], kind: 'turnStart' });
109
+ session.history.push({
110
+ role: 'user',
111
+ parts: [{ text }],
112
+ kind: 'turnStart',
113
+ userInput: text,
114
+ });
102
115
  session.history.push({
103
116
  role: 'model',
104
- parts: [{ text: `Turn failed before completion: ${category}.` }],
117
+ parts: [{ text: formatTurnFailureMarker(category) }],
105
118
  });
106
119
  }
107
120
  function historyHasToolCall(session, callId) {
@@ -143,6 +156,9 @@ function publicStatusMessage(data) {
143
156
  : 'tool';
144
157
  if (event.phase === 'thinking')
145
158
  return 'Thinking...';
159
+ if (event.phase === 'analyzing_image') {
160
+ return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
161
+ }
146
162
  if (event.phase === 'running_tool')
147
163
  return `Running ${toolName}...`;
148
164
  if (event.phase === 'waiting_for_tool')
@@ -183,6 +199,7 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
183
199
  toolCallId: event.call.id,
184
200
  result,
185
201
  toolState: toolStateFromSession(session),
202
+ toolStateSeq: ++toolStateSeqCounter,
186
203
  };
187
204
  const trace = createTraceContext(traceId);
188
205
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
@@ -201,6 +218,29 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
201
218
  throw await readErrorResponse(response, trace.traceId);
202
219
  }
203
220
  }
221
+ const turnIdOverrides = new WeakMap();
222
+ function enterServerTurnId(session, serverSessionTurnId) {
223
+ const active = turnIdOverrides.get(session);
224
+ if (active) {
225
+ active.depth += 1;
226
+ return;
227
+ }
228
+ turnIdOverrides.set(session, {
229
+ previousTurnId: session.turnState.id,
230
+ depth: 1,
231
+ });
232
+ session.turnState.id = serverSessionTurnId;
233
+ }
234
+ function exitServerTurnId(session) {
235
+ const active = turnIdOverrides.get(session);
236
+ if (!active)
237
+ return;
238
+ active.depth -= 1;
239
+ if (active.depth === 0) {
240
+ session.turnState.id = active.previousTurnId;
241
+ turnIdOverrides.delete(session);
242
+ }
243
+ }
204
244
  async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
205
245
  const turnId = String(event?.turnId ?? '').trim();
206
246
  if (!turnId || !event?.call?.id || !event.call.name) {
@@ -209,10 +249,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
209
249
  if (signal?.aborted) {
210
250
  throw new TurnCancelledError();
211
251
  }
212
- const previousTurnId = session.turnState.id;
213
252
  const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
214
253
  if (serverSessionTurnId) {
215
- session.turnState.id = serverSessionTurnId;
254
+ enterServerTurnId(session, serverSessionTurnId);
216
255
  if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
217
256
  createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
218
257
  }
@@ -235,7 +274,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
235
274
  });
236
275
  }
237
276
  finally {
238
- session.turnState.id = previousTurnId;
277
+ if (serverSessionTurnId) {
278
+ exitServerTurnId(session);
279
+ }
239
280
  }
240
281
  }
241
282
  async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
@@ -248,8 +289,40 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
248
289
  const finalResult = {
249
290
  current: null,
250
291
  };
292
+ const pendingParallelTools = [];
293
+ let firstParallelFailure = null;
294
+ let rejectOnParallelFailure = null;
295
+ const parallelToolFailure = new Promise((_, reject) => {
296
+ rejectOnParallelFailure = reject;
297
+ });
298
+ parallelToolFailure.catch(() => { });
299
+ function recordParallelFailure(error) {
300
+ const failure = error ?? new Error('Local tool execution failed.');
301
+ if (firstParallelFailure == null) {
302
+ firstParallelFailure = failure;
303
+ rejectOnParallelFailure?.(failure);
304
+ }
305
+ return failure;
306
+ }
307
+ async function drainParallelTools() {
308
+ if (!pendingParallelTools.length)
309
+ return;
310
+ const pending = pendingParallelTools.splice(0);
311
+ const outcomes = await Promise.all(pending);
312
+ for (const outcome of outcomes) {
313
+ if (outcome != null)
314
+ throw outcome;
315
+ }
316
+ }
251
317
  async function handleEvent(event) {
252
318
  if (event.event === 'status') {
319
+ const data = event.data;
320
+ if (data?.phase === 'analyzing_image') {
321
+ session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
322
+ }
323
+ else if (data?.phase) {
324
+ session.onImageAnalysis?.(0);
325
+ }
253
326
  const message = publicStatusMessage(event.data);
254
327
  if (message)
255
328
  session.onStatus(message);
@@ -259,11 +332,26 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
259
332
  return;
260
333
  }
261
334
  if (event.event === 'tool-call') {
335
+ const data = event.data;
336
+ if (data?.parallelSafe === true) {
337
+ pendingParallelTools.push(executeAndPostToolResult({
338
+ config,
339
+ projectIndex,
340
+ session,
341
+ event: data,
342
+ input,
343
+ fetchImpl,
344
+ signal,
345
+ traceId,
346
+ }).then(() => null, (error) => recordParallelFailure(error)));
347
+ return;
348
+ }
349
+ await drainParallelTools();
262
350
  await executeAndPostToolResult({
263
351
  config,
264
352
  projectIndex,
265
353
  session,
266
- event: event.data,
354
+ event: data,
267
355
  input,
268
356
  fetchImpl,
269
357
  signal,
@@ -279,10 +367,12 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
279
367
  return;
280
368
  }
281
369
  if (event.event === 'result') {
370
+ await drainParallelTools();
282
371
  finalResult.current = event.data;
283
372
  return;
284
373
  }
285
374
  if (event.event === 'cancelled' || event.event === 'error') {
375
+ await drainParallelTools().catch(() => { });
286
376
  const message = String(event.data?.message ?? 'Server chat failed.');
287
377
  if (event.event === 'cancelled') {
288
378
  throw new TurnCancelledError(message);
@@ -290,24 +380,34 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
290
380
  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);
291
381
  }
292
382
  }
293
- while (true) {
294
- if (signal?.aborted) {
295
- await reader.cancel().catch(() => { });
296
- throw new TurnCancelledError();
297
- }
298
- const read = await reader.read();
299
- if (read.done)
300
- break;
301
- buffer += decoder.decode(read.value, { stream: true });
302
- let separatorIndex = buffer.indexOf('\n\n');
303
- while (separatorIndex !== -1) {
304
- const block = buffer.slice(0, separatorIndex);
305
- buffer = buffer.slice(separatorIndex + 2);
306
- const event = parseSseBlock(block);
307
- if (event)
308
- await handleEvent(event);
309
- separatorIndex = buffer.indexOf('\n\n');
383
+ try {
384
+ while (true) {
385
+ if (signal?.aborted) {
386
+ await reader.cancel().catch(() => { });
387
+ throw new TurnCancelledError();
388
+ }
389
+ const read = await Promise.race([reader.read(), parallelToolFailure]);
390
+ if (read.done)
391
+ break;
392
+ buffer += decoder.decode(read.value, { stream: true });
393
+ let separatorIndex = buffer.indexOf('\n\n');
394
+ while (separatorIndex !== -1) {
395
+ const block = buffer.slice(0, separatorIndex);
396
+ buffer = buffer.slice(separatorIndex + 2);
397
+ const event = parseSseBlock(block);
398
+ if (event)
399
+ await handleEvent(event);
400
+ separatorIndex = buffer.indexOf('\n\n');
401
+ }
310
402
  }
403
+ await drainParallelTools();
404
+ }
405
+ catch (error) {
406
+ await reader.cancel().catch(() => { });
407
+ throw error;
408
+ }
409
+ finally {
410
+ await drainParallelTools().catch(() => { });
311
411
  }
312
412
  buffer += decoder.decode();
313
413
  const tail = buffer.trim();
@@ -339,6 +439,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
339
439
  input: requestInputBase,
340
440
  backgroundJobUpdate: backgroundJobUpdate || undefined,
341
441
  clientEnvironment: collectClientEnvironment({ env: session.env }),
442
+ projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
342
443
  imageAttachments: imageAttachmentsForServer(requestImageAttachments),
343
444
  maxToolSteps: session.maxToolSteps,
344
445
  autoYes: session.autoYes,
@@ -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
- constructor(message, status, traceId) {
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
  }
@@ -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
- if (!Number.isInteger(id) || id <= 0 || !label) {
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)
@@ -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 scoped to the current repo. The five',
39
- ' most recent sessions per repo are kept.',
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** quits. These are the same on macOS, Linux, and Windows.',
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
- '- `/clear` — clear the current conversation history',
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
- '- Stuck on the wrong account → `ai logout`, then `ai login` again.',
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+(.*)$/);
@@ -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
- export function resolveProjectPath(rootDir, filePath) {
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('..') || path.isAbsolute(relative)) {
151
- throw new Error(`Refusing to access path outside the project root: ${filePath}`);
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
- mkdirSync(path.dirname(absPath), { recursive: true });
168
- writeFileSync(absPath, content, 'utf-8');
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
- mkdirSync(path.dirname(absPath), { recursive: true });
184
- writeFileSync(absPath, content);
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 Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
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,