@thegitai/cli 1.0.0-beta.8 → 1.0.0-preview.1

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 (55) hide show
  1. package/README.md +36 -2
  2. package/dist/bin/ai.js +135 -26
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +3 -3
  6. package/dist/src/api/browser-login.js +3 -38
  7. package/dist/src/api/chat.js +57 -11
  8. package/dist/src/api/http.js +49 -1
  9. package/dist/src/api/models.js +26 -20
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +11 -6
  19. package/dist/src/markdown-renderer.js +1 -1
  20. package/dist/src/patcher.js +1 -3
  21. package/dist/src/scanner.js +50 -12
  22. package/dist/src/scratch-dir.js +57 -0
  23. package/dist/src/secret-preview.js +0 -10
  24. package/dist/src/session-safety.js +0 -19
  25. package/dist/src/session-store.js +0 -1
  26. package/dist/src/todo-list.js +106 -0
  27. package/dist/src/tool-executor.js +159 -18
  28. package/dist/src/tools/delete-file.js +1 -1
  29. package/dist/src/tools/index.js +6 -0
  30. package/dist/src/tools/patch-file.js +3 -2
  31. package/dist/src/tools/path-suggest.js +81 -8
  32. package/dist/src/tools/read-document.js +2 -2
  33. package/dist/src/tools/read-file.js +14 -7
  34. package/dist/src/tools/replace-document-text.js +3 -11
  35. package/dist/src/tools/restore-checkpoint.js +1 -1
  36. package/dist/src/tools/run-command.js +83 -16
  37. package/dist/src/tools/run-node-script.js +3 -1
  38. package/dist/src/tools/shell-job-kill.js +48 -0
  39. package/dist/src/tools/shell-job-output.js +51 -0
  40. package/dist/src/tools/str-replace.js +3 -2
  41. package/dist/src/tools/undo-edit.js +1 -1
  42. package/dist/src/tools/update-todos.js +27 -0
  43. package/dist/src/tools/write-file.js +1 -1
  44. package/dist/src/tree-sitter-runtime.js +8 -1
  45. package/dist/src/ui/repl.js +313 -23
  46. package/dist/src/ui/tui/bridge.js +0 -4
  47. package/dist/src/ui/tui/build-frame.js +220 -24
  48. package/dist/src/ui/tui/shell-input.js +33 -4
  49. package/dist/src/ui/tui/terminal-title.js +81 -0
  50. package/dist/src/version.js +0 -6
  51. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  52. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  53. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  55. package/package.json +14 -15
@@ -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 { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
4
+ import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
5
5
  function sanitizeModelInfo(raw) {
6
6
  if (!raw || typeof raw !== 'object') {
7
7
  return null;
@@ -45,6 +45,11 @@ export function readCachedServerModels(env = process.env) {
45
45
  return null;
46
46
  }
47
47
  }
48
+ export function selectCacheForServer(cached, serverUrl) {
49
+ if (!cached)
50
+ return null;
51
+ return cached.serverUrl === normalizeServerUrl(serverUrl) ? cached : null;
52
+ }
48
53
  export function writeCachedServerModels(cache, env = process.env) {
49
54
  const filePath = getModelsCachePath(env);
50
55
  mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -54,26 +59,27 @@ export function writeCachedServerModels(cache, env = process.env) {
54
59
  });
55
60
  }
56
61
  export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
57
- const trace = createTraceContext();
58
- const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
59
- headers: {
60
- authorization: `Bearer ${config.token}`,
61
- ...trace.headers,
62
- },
62
+ return retryTransient(async () => {
63
+ const trace = createTraceContext();
64
+ const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
65
+ headers: {
66
+ authorization: `Bearer ${config.token}`,
67
+ ...trace.headers,
68
+ },
69
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
70
+ });
71
+ const data = (await readJsonResponse(response));
72
+ if (!response.ok) {
73
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
74
+ }
75
+ const models = Array.isArray(data?.models)
76
+ ? data.models.map(sanitizeModelInfo).filter(Boolean)
77
+ : [];
78
+ if (models.length === 0) {
79
+ throw new Error('Server returned an invalid model list.');
80
+ }
81
+ return { models };
63
82
  });
64
- const data = (await readJsonResponse(response));
65
- if (!response.ok) {
66
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
67
- }
68
- const models = Array.isArray(data?.models)
69
- ? data.models.map(sanitizeModelInfo).filter(Boolean)
70
- : [];
71
- if (models.length === 0) {
72
- throw new Error('Server returned an invalid model list.');
73
- }
74
- return {
75
- models,
76
- };
77
83
  }
78
84
  export function selectServerModel({ requestedModelId, cached, serverModels, }) {
79
85
  const supportedIds = new Set(serverModels.models.map((model) => model.id));
@@ -154,6 +154,8 @@ const SENSITIVE_BASENAME_PATTERNS = [
154
154
  /^\.?pypirc$/i,
155
155
  /^credentials(?:\..*)?$/i,
156
156
  /^secrets?(?:\..*)?$/i,
157
+ /^service[-_]?account(?:\..*)?\.json$/i,
158
+ /^.*credentials.*\.json$/i,
157
159
  ];
158
160
  const SENSITIVE_PATH_PATTERNS = [
159
161
  /(^|[/\\])\.aws[/\\]credentials$/i,
@@ -161,6 +163,7 @@ const SENSITIVE_PATH_PATTERNS = [
161
163
  /(^|[/\\])credentials?([._-]|$)/i,
162
164
  /(^|[/\\])secrets?([._-]|$)/i,
163
165
  /(^|[/\\])private[-_]?key([._-]|$)/i,
166
+ /(^|[/\\])service[-_]?account/i,
164
167
  /\.(?:pem|key|p12|pfx)$/i,
165
168
  ];
166
169
  export function normalizeArtifactPath(relPath) {
@@ -0,0 +1,410 @@
1
+ import chalk from './colors.js';
2
+ import { spawn } from 'child_process';
3
+ import { buildCommandEnv, commandUsesSudo, sanitizeCommandText, terminateChild, } from './executor.js';
4
+ import { isTuiMode } from './runtime-mode.js';
5
+ import { redactConnectionStringCredentials } from './secret-preview.js';
6
+ const MAX_RUNNING_JOBS = 8;
7
+ const MAX_FINISHED_JOBS = 20;
8
+ const MAX_JOB_BUFFER_CHARS = 200 * 1024;
9
+ export const DEFAULT_STARTUP_WAIT_MS = 5000;
10
+ export const MAX_JOB_WAIT_MS = 30_000;
11
+ const KILL_ESCALATION_MS = 2000;
12
+ const jobs = new Map();
13
+ let jobCounter = 0;
14
+ let activeSessionId = null;
15
+ let updateHook = null;
16
+ let exitCleanupRegistered = false;
17
+ let pendingModelNotifications = [];
18
+ export function setBackgroundJobUpdateHook(hook) {
19
+ updateHook = hook;
20
+ }
21
+ function normalizeSessionId(sessionId) {
22
+ return String(sessionId ?? activeSessionId ?? 'default').trim() || 'default';
23
+ }
24
+ function sessionFilter(sessionId) {
25
+ const value = String(sessionId ?? activeSessionId ?? '').trim();
26
+ return value || null;
27
+ }
28
+ function belongsToSession(record, sessionId) {
29
+ const filter = sessionFilter(sessionId);
30
+ return !filter || record.sessionId === filter;
31
+ }
32
+ export function setBackgroundJobSession(sessionId) {
33
+ const next = String(sessionId ?? '').trim() || null;
34
+ if (activeSessionId && activeSessionId !== next) {
35
+ killAllBackgroundJobs({ sessionId: activeSessionId, remove: true });
36
+ pendingModelNotifications = pendingModelNotifications.filter((snapshot) => snapshot.sessionId === next);
37
+ }
38
+ activeSessionId = next;
39
+ }
40
+ function snapshotOf(record) {
41
+ return {
42
+ id: record.id,
43
+ sessionId: record.sessionId,
44
+ command: record.command,
45
+ status: record.status,
46
+ pid: record.child.pid ?? null,
47
+ exitCode: record.exitCode,
48
+ signal: record.signal,
49
+ startedAt: record.startedAt,
50
+ endedAt: record.endedAt,
51
+ };
52
+ }
53
+ function notifyUpdate(record) {
54
+ if (!belongsToSession(record))
55
+ return;
56
+ try {
57
+ updateHook?.(snapshotOf(record));
58
+ }
59
+ catch { }
60
+ }
61
+ function logJobStatus(record) {
62
+ if (isTuiMode())
63
+ return;
64
+ if (record.status === 'running') {
65
+ console.log(chalk.cyan(`\n ⚙ Background job ${record.id} started: ${record.command}`));
66
+ return;
67
+ }
68
+ if (record.status === 'killed') {
69
+ console.log(chalk.dim(`\n ■ Background job ${record.id} killed.`));
70
+ return;
71
+ }
72
+ if (record.status === 'error') {
73
+ console.log(chalk.red(`\n ✖ Background job ${record.id} failed to run.`));
74
+ return;
75
+ }
76
+ const color = record.exitCode === 0 ? chalk.green : chalk.red;
77
+ console.log(color(`\n ${record.exitCode === 0 ? '✓' : '✖'} Background job ${record.id} exited with code ${record.exitCode ?? 1}`));
78
+ }
79
+ function appendJobOutput(record, chunk) {
80
+ if (!chunk)
81
+ return;
82
+ if (record.firstOutputLine == null) {
83
+ record.firstOutputFragment = `${record.firstOutputFragment}${chunk}`.slice(0, 8000);
84
+ const firstLine = record.firstOutputFragment
85
+ .split(/\r?\n/)
86
+ .find((line) => line.trim().length > 0);
87
+ if (firstLine) {
88
+ record.firstOutputLine = firstLine;
89
+ }
90
+ }
91
+ record.totalCaptured += chunk.length;
92
+ record.buffer += chunk;
93
+ if (record.buffer.length > MAX_JOB_BUFFER_CHARS) {
94
+ record.buffer = record.buffer.slice(record.buffer.length - MAX_JOB_BUFFER_CHARS);
95
+ }
96
+ const waiters = record.outputWaiters;
97
+ record.outputWaiters = [];
98
+ for (const waiter of waiters)
99
+ waiter();
100
+ }
101
+ function settleJob(record, status, exitCode, signal) {
102
+ if (record.status !== 'running')
103
+ return;
104
+ record.status = status;
105
+ record.exitCode = exitCode;
106
+ record.signal = signal;
107
+ record.endedAt = Date.now();
108
+ const waiters = [...record.exitWaiters, ...record.outputWaiters];
109
+ record.exitWaiters = [];
110
+ record.outputWaiters = [];
111
+ for (const waiter of waiters)
112
+ waiter();
113
+ if (jobs.has(record.id) && belongsToSession(record)) {
114
+ pendingModelNotifications.push(snapshotOf(record));
115
+ }
116
+ logJobStatus(record);
117
+ if (status === 'killed' || record.removeOnSettle) {
118
+ jobs.delete(record.id);
119
+ }
120
+ else {
121
+ pruneFinishedJobs();
122
+ }
123
+ notifyUpdate(record);
124
+ }
125
+ function pruneFinishedJobs() {
126
+ const finished = [...jobs.values()].filter((record) => record.status !== 'running');
127
+ if (finished.length <= MAX_FINISHED_JOBS)
128
+ return;
129
+ finished.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
130
+ for (const record of finished.slice(0, finished.length - MAX_FINISHED_JOBS)) {
131
+ jobs.delete(record.id);
132
+ }
133
+ }
134
+ function registerExitCleanup() {
135
+ if (exitCleanupRegistered)
136
+ return;
137
+ exitCleanupRegistered = true;
138
+ process.on('exit', () => {
139
+ for (const record of jobs.values()) {
140
+ if (record.status !== 'running')
141
+ continue;
142
+ terminateChild(record.child, 'SIGKILL');
143
+ }
144
+ });
145
+ }
146
+ function sanitizeJobText(record, raw) {
147
+ return redactConnectionStringCredentials(sanitizeCommandText(record.command, raw, record.cwd));
148
+ }
149
+ function readNewOutput(record) {
150
+ const dropped = record.totalCaptured - record.buffer.length;
151
+ const droppedUnread = Math.max(dropped - record.readOffset, 0);
152
+ const start = Math.max(record.readOffset - dropped, 0);
153
+ const raw = record.buffer.slice(start);
154
+ record.readOffset = record.totalCaptured;
155
+ return {
156
+ newOutput: sanitizeJobText(record, raw),
157
+ droppedChars: droppedUnread,
158
+ };
159
+ }
160
+ function waitForJobEvent(record, waitMs, kind) {
161
+ if (record.status !== 'running' || waitMs <= 0)
162
+ return Promise.resolve();
163
+ return new Promise((resolve) => {
164
+ const waiters = kind === 'exit' ? record.exitWaiters : record.outputWaiters;
165
+ let settled = false;
166
+ const finish = () => {
167
+ if (settled)
168
+ return;
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ const index = waiters.indexOf(finish);
172
+ if (index !== -1)
173
+ waiters.splice(index, 1);
174
+ resolve();
175
+ };
176
+ const timer = setTimeout(finish, Math.min(waitMs, MAX_JOB_WAIT_MS));
177
+ timer.unref?.();
178
+ waiters.push(finish);
179
+ });
180
+ }
181
+ export async function startBackgroundJob(command, cwd, { startupWaitMs, sessionId, } = {}) {
182
+ if (commandUsesSudo(command)) {
183
+ return {
184
+ ok: false,
185
+ error: 'sudo commands cannot run as background jobs because the password prompt is interactive. Run it in the foreground instead.',
186
+ };
187
+ }
188
+ const running = [...jobs.values()].filter((record) => record.status === 'running' && belongsToSession(record, sessionId));
189
+ if (running.length >= MAX_RUNNING_JOBS) {
190
+ return {
191
+ ok: false,
192
+ error: `Too many background jobs are already running (${running.length}). Kill one with shell_job_kill first.`,
193
+ };
194
+ }
195
+ registerExitCleanup();
196
+ const id = `bg_${++jobCounter}`;
197
+ const child = spawn(command, {
198
+ cwd,
199
+ shell: true,
200
+ detached: process.platform !== 'win32',
201
+ stdio: ['pipe', 'pipe', 'pipe'],
202
+ env: buildCommandEnv(cwd),
203
+ });
204
+ child.stdin?.end();
205
+ const record = {
206
+ id,
207
+ sessionId: normalizeSessionId(sessionId),
208
+ command,
209
+ cwd,
210
+ child,
211
+ status: 'running',
212
+ exitCode: null,
213
+ signal: null,
214
+ startedAt: Date.now(),
215
+ endedAt: null,
216
+ buffer: '',
217
+ totalCaptured: 0,
218
+ readOffset: 0,
219
+ firstOutputLine: null,
220
+ firstOutputFragment: '',
221
+ killRequested: false,
222
+ removeOnSettle: false,
223
+ exitWaiters: [],
224
+ outputWaiters: [],
225
+ };
226
+ jobs.set(id, record);
227
+ child.stdout?.on('data', (chunk) => {
228
+ appendJobOutput(record, chunk.toString('utf-8'));
229
+ });
230
+ child.stderr?.on('data', (chunk) => {
231
+ appendJobOutput(record, chunk.toString('utf-8'));
232
+ });
233
+ child.on('error', (error) => {
234
+ appendJobOutput(record, error.message ? `${error.message}\n` : '');
235
+ terminateChild(child, 'SIGKILL');
236
+ settleJob(record, 'error', 1, null);
237
+ });
238
+ child.on('close', (code, signal) => {
239
+ settleJob(record, record.killRequested ? 'killed' : 'exited', code ?? (signal ? 1 : 0), signal);
240
+ });
241
+ logJobStatus(record);
242
+ notifyUpdate(record);
243
+ const waitMs = Math.min(Math.max(startupWaitMs ?? DEFAULT_STARTUP_WAIT_MS, 0), MAX_JOB_WAIT_MS);
244
+ await waitForJobEvent(record, waitMs, 'exit');
245
+ const { newOutput, droppedChars } = readNewOutput(record);
246
+ return {
247
+ ok: true,
248
+ snapshot: snapshotOf(record),
249
+ startupOutput: newOutput,
250
+ droppedChars,
251
+ };
252
+ }
253
+ export function getBackgroundJob(id, { sessionId } = {}) {
254
+ const record = jobs.get(String(id ?? '').trim());
255
+ return record && belongsToSession(record, sessionId) ? snapshotOf(record) : null;
256
+ }
257
+ export function listBackgroundJobs({ sessionId, } = {}) {
258
+ return [...jobs.values()]
259
+ .filter((record) => belongsToSession(record, sessionId))
260
+ .sort((a, b) => a.startedAt - b.startedAt)
261
+ .map(snapshotOf);
262
+ }
263
+ export function getJobOutputTail(id, maxLines) {
264
+ const record = jobs.get(String(id ?? '').trim());
265
+ if (!record || !belongsToSession(record) || maxLines <= 0)
266
+ return [];
267
+ const lines = sanitizeJobText(record, record.buffer)
268
+ .split('\n')
269
+ .filter((line) => line.trim().length > 0);
270
+ return lines.slice(-maxLines);
271
+ }
272
+ export function getJobOutputPreview(id, maxTailLines) {
273
+ const record = jobs.get(String(id ?? '').trim());
274
+ if (!record || !belongsToSession(record))
275
+ return null;
276
+ const firstLine = record.firstOutputLine
277
+ ? sanitizeJobText(record, record.firstOutputLine).trim()
278
+ : '';
279
+ return {
280
+ firstLine,
281
+ tailLines: getJobOutputTail(id, maxTailLines),
282
+ };
283
+ }
284
+ export function getJobBufferedOutput(id) {
285
+ const record = jobs.get(String(id ?? '').trim());
286
+ if (!record || !belongsToSession(record))
287
+ return null;
288
+ return {
289
+ output: sanitizeJobText(record, record.buffer),
290
+ droppedChars: Math.max(record.totalCaptured - record.buffer.length, 0),
291
+ };
292
+ }
293
+ export async function readBackgroundJobOutput(id, { waitMs, sessionId } = {}) {
294
+ const record = jobs.get(String(id ?? '').trim());
295
+ if (!record || !belongsToSession(record, sessionId)) {
296
+ return { ok: false, error: unknownJobError(id) };
297
+ }
298
+ if (waitMs && waitMs > 0 && record.status === 'running') {
299
+ const hasUnread = record.totalCaptured > record.readOffset;
300
+ if (!hasUnread)
301
+ await waitForJobEvent(record, waitMs, 'output');
302
+ }
303
+ const { newOutput, droppedChars } = readNewOutput(record);
304
+ return { ok: true, snapshot: snapshotOf(record), newOutput, droppedChars };
305
+ }
306
+ export async function killBackgroundJob(id, { waitMs = 5000, sessionId, } = {}) {
307
+ const record = jobs.get(String(id ?? '').trim());
308
+ if (!record || !belongsToSession(record, sessionId)) {
309
+ return { ok: false, error: unknownJobError(id) };
310
+ }
311
+ if (record.status !== 'running') {
312
+ const { newOutput, droppedChars } = readNewOutput(record);
313
+ const snapshot = snapshotOf(record);
314
+ if (record.status === 'killed') {
315
+ jobs.delete(record.id);
316
+ }
317
+ return {
318
+ ok: true,
319
+ alreadyFinished: true,
320
+ snapshot,
321
+ finalOutput: newOutput,
322
+ droppedChars,
323
+ };
324
+ }
325
+ record.killRequested = true;
326
+ terminateChild(record.child, 'SIGTERM');
327
+ const killTimer = setTimeout(() => {
328
+ if (record.status === 'running') {
329
+ terminateChild(record.child, 'SIGKILL');
330
+ }
331
+ }, KILL_ESCALATION_MS);
332
+ killTimer.unref?.();
333
+ await waitForJobEvent(record, Math.max(waitMs, KILL_ESCALATION_MS + 1000), 'exit');
334
+ clearTimeout(killTimer);
335
+ const { newOutput, droppedChars } = readNewOutput(record);
336
+ return {
337
+ ok: true,
338
+ snapshot: snapshotOf(record),
339
+ finalOutput: newOutput,
340
+ droppedChars,
341
+ };
342
+ }
343
+ export function killAllBackgroundJobs({ sessionId, remove = false, } = {}) {
344
+ for (const record of jobs.values()) {
345
+ if (!belongsToSession(record, sessionId))
346
+ continue;
347
+ if (record.status !== 'running')
348
+ continue;
349
+ record.killRequested = true;
350
+ record.removeOnSettle = record.removeOnSettle || remove;
351
+ terminateChild(record.child, 'SIGTERM');
352
+ const killTimer = setTimeout(() => {
353
+ if (record.status === 'running') {
354
+ terminateChild(record.child, 'SIGKILL');
355
+ }
356
+ }, KILL_ESCALATION_MS);
357
+ killTimer.unref?.();
358
+ }
359
+ }
360
+ export function hasRunningBackgroundJobs({ sessionId, } = {}) {
361
+ for (const record of jobs.values()) {
362
+ if (!belongsToSession(record, sessionId))
363
+ continue;
364
+ if (record.status === 'running')
365
+ return true;
366
+ }
367
+ return false;
368
+ }
369
+ function formatRunTime(snapshot) {
370
+ const ms = (snapshot.endedAt ?? Date.now()) - snapshot.startedAt;
371
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
372
+ if (totalSeconds < 60)
373
+ return `${totalSeconds}s`;
374
+ const minutes = Math.floor(totalSeconds / 60);
375
+ if (minutes < 60)
376
+ return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
377
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
378
+ }
379
+ export function drainBackgroundJobNotifications({ sessionId } = {}) {
380
+ if (pendingModelNotifications.length === 0)
381
+ return null;
382
+ const filter = sessionFilter(sessionId);
383
+ const pending = filter
384
+ ? pendingModelNotifications.filter((snapshot) => snapshot.sessionId === filter)
385
+ : pendingModelNotifications;
386
+ pendingModelNotifications = filter
387
+ ? pendingModelNotifications.filter((snapshot) => snapshot.sessionId !== filter)
388
+ : [];
389
+ if (pending.length === 0)
390
+ return null;
391
+ const lines = pending.map((snapshot) => {
392
+ const ran = formatRunTime(snapshot);
393
+ if (snapshot.status === 'error') {
394
+ return `Background job ${snapshot.id} (${snapshot.command}) failed to start.`;
395
+ }
396
+ if (snapshot.status === 'killed') {
397
+ return `Background job ${snapshot.id} (${snapshot.command}) was killed after ${ran}.`;
398
+ }
399
+ return `Background job ${snapshot.id} (${snapshot.command}) exited with code ${snapshot.exitCode ?? 1} after ${ran}.`;
400
+ });
401
+ const hasReadableFinalOutput = pending.some((snapshot) => snapshot.status !== 'killed');
402
+ return `${lines.join(' ')}${hasReadableFinalOutput ? ' Use shell_job_output to read any final output.' : ''}`;
403
+ }
404
+ function unknownJobError(id) {
405
+ const known = listBackgroundJobs().map((job) => job.id);
406
+ const hint = known.length
407
+ ? ` Known jobs: ${known.join(', ')}.`
408
+ : ' No background jobs have been started this session.';
409
+ return `Unknown background job id: ${String(id ?? '').trim() || '(empty)'}.${hint}`;
410
+ }
@@ -39,11 +39,6 @@ export function parseArgs(argv) {
39
39
  usage = true;
40
40
  continue;
41
41
  }
42
- // An unrecognized dashed token is a mistyped flag, not prompt text. Without
43
- // an auth subcommand (whose flags are parsed separately) it would otherwise
44
- // be swept into the prompt and silently start a billable session. Flag the
45
- // first one so the caller can fail fast instead. Quoted prompts are a single
46
- // argv entry with spaces, so they never look like a bare option here.
47
42
  if (command === null && unknownOption === null && /^-/.test(arg)) {
48
43
  unknownOption = arg;
49
44
  continue;
@@ -1,6 +1,7 @@
1
1
  import { accessSync, constants, readFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { ensureSessionScratchDir } from './scratch-dir.js';
4
5
  const PACKAGE_MANAGER_CANDIDATES = [
5
6
  'apt',
6
7
  'apt-get',
@@ -123,5 +124,6 @@ export function collectClientEnvironment(options = {}) {
123
124
  shell: detectShell(platform, env),
124
125
  ...linuxDistro,
125
126
  packageManagers: detectPackageManagers(env, platform, executableExists),
127
+ scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
126
128
  };
127
129
  }
@@ -0,0 +1,50 @@
1
+ const STYLE_NAMES = ['bold', 'dim', 'red', 'green', 'yellow', 'cyan'];
2
+ const OPEN = {
3
+ bold: '\x1b[1m',
4
+ dim: '\x1b[2m',
5
+ red: '\x1b[31m',
6
+ green: '\x1b[32m',
7
+ yellow: '\x1b[33m',
8
+ cyan: '\x1b[36m',
9
+ };
10
+ const CLOSE = {
11
+ bold: '\x1b[22m',
12
+ dim: '\x1b[22m',
13
+ red: '\x1b[39m',
14
+ green: '\x1b[39m',
15
+ yellow: '\x1b[39m',
16
+ cyan: '\x1b[39m',
17
+ };
18
+ function colorEnabled() {
19
+ const force = process.env.FORCE_COLOR;
20
+ if (force !== undefined)
21
+ return force !== '0' && force !== 'false';
22
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') {
23
+ return false;
24
+ }
25
+ return Boolean(process.stdout.isTTY);
26
+ }
27
+ function applyStyle(name, text) {
28
+ const open = OPEN[name];
29
+ const close = CLOSE[name];
30
+ const body = text.includes(close) ? text.split(close).join(close + open) : text;
31
+ return open + body + close;
32
+ }
33
+ function createStyler(styles) {
34
+ const fn = ((text) => {
35
+ const value = String(text);
36
+ if (!colorEnabled() || styles.length === 0)
37
+ return value;
38
+ return styles.reduceRight((acc, name) => applyStyle(name, acc), value);
39
+ });
40
+ for (const name of STYLE_NAMES) {
41
+ Object.defineProperty(fn, name, {
42
+ configurable: true,
43
+ enumerable: false,
44
+ get: () => createStyler([...styles, name]),
45
+ });
46
+ }
47
+ return fn;
48
+ }
49
+ const colors = createStyler([]);
50
+ export default colors;
@@ -1,4 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
2
4
  const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
3
5
  const MIME_BY_EXT = {
4
6
  '.png': 'image/png',
@@ -262,3 +264,20 @@ export function writeClipboardText(text, platform = process.platform) {
262
264
  }
263
265
  throw new ClipboardError(`Clipboard text copy is not supported on ${platform}.`, 'NO_TOOL');
264
266
  }
267
+ export function loadImageFromFile(filePath) {
268
+ const resolved = path.resolve(filePath);
269
+ if (!existsSync(resolved)) {
270
+ throw new ClipboardError(`Image file not found: ${resolved}`, 'READ_FAILED');
271
+ }
272
+ const stat = statSync(resolved);
273
+ if (stat.size > MAX_IMAGE_SIZE_BYTES) {
274
+ throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
275
+ }
276
+ const ext = path.extname(resolved).toLowerCase();
277
+ const mimeType = MIME_BY_EXT[ext];
278
+ if (!mimeType) {
279
+ throw new ClipboardError(`Unsupported image format "${ext}". Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
280
+ }
281
+ const buf = readFileSync(resolved);
282
+ return { base64Data: buf.toString('base64'), mimeType };
283
+ }