@thegitai/cli 1.0.0-beta.9 → 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.
- package/README.md +36 -2
- package/dist/bin/ai.js +134 -18
- package/dist/parsers/NOTICE +18 -0
- package/dist/src/agent-mode.js +5 -0
- package/dist/src/api/auth.js +3 -3
- package/dist/src/api/browser-login.js +0 -16
- package/dist/src/api/chat.js +57 -11
- package/dist/src/api/http.js +49 -1
- package/dist/src/api/models.js +26 -20
- package/dist/src/artifact-policy.js +3 -0
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/cli-args.js +0 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/colors.js +50 -0
- package/dist/src/core/clipboard.js +19 -0
- package/dist/src/core/image-path-extractor.js +144 -0
- package/dist/src/executor.js +48 -12
- package/dist/src/help-text.js +11 -6
- package/dist/src/markdown-renderer.js +1 -1
- package/dist/src/patcher.js +1 -3
- package/dist/src/scanner.js +50 -12
- package/dist/src/scratch-dir.js +57 -0
- package/dist/src/secret-preview.js +0 -10
- package/dist/src/session-safety.js +0 -19
- package/dist/src/session-store.js +0 -1
- package/dist/src/todo-list.js +106 -0
- package/dist/src/tool-executor.js +159 -18
- package/dist/src/tools/delete-file.js +1 -1
- package/dist/src/tools/index.js +6 -0
- package/dist/src/tools/patch-file.js +3 -2
- package/dist/src/tools/path-suggest.js +81 -8
- package/dist/src/tools/read-document.js +2 -2
- package/dist/src/tools/read-file.js +14 -7
- package/dist/src/tools/replace-document-text.js +3 -11
- package/dist/src/tools/restore-checkpoint.js +1 -1
- package/dist/src/tools/run-command.js +83 -16
- package/dist/src/tools/run-node-script.js +3 -1
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/tools/str-replace.js +3 -2
- package/dist/src/tools/undo-edit.js +1 -1
- package/dist/src/tools/update-todos.js +27 -0
- package/dist/src/tools/write-file.js +1 -1
- package/dist/src/tree-sitter-runtime.js +8 -1
- package/dist/src/ui/repl.js +313 -23
- package/dist/src/ui/tui/bridge.js +0 -4
- package/dist/src/ui/tui/build-frame.js +220 -24
- package/dist/src/ui/tui/shell-input.js +33 -4
- package/dist/src/ui/tui/terminal-title.js +81 -0
- package/dist/src/version.js +0 -6
- package/dist/vendor/web-tree-sitter/LICENSE +21 -0
- package/dist/vendor/web-tree-sitter/NOTICE +13 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/package.json +14 -15
|
@@ -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
|
+
}
|
package/dist/src/cli-args.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadImageFromFile } from './clipboard.js';
|
|
5
|
+
const EXT = '(?:png|jpe?g|gif|webp)';
|
|
6
|
+
const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
|
|
7
|
+
const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
|
|
8
|
+
const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
|
|
9
|
+
`|'([^']*\\.${EXT})'` +
|
|
10
|
+
`|file://(\\S*\\.${EXT})` +
|
|
11
|
+
`|(${BARE_PATH})`, 'gi');
|
|
12
|
+
const EXTENSION_TOKEN = new RegExp(`^(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+$`);
|
|
13
|
+
const MAX_EXTENSION_TOKENS = 8;
|
|
14
|
+
function isFile(p) {
|
|
15
|
+
try {
|
|
16
|
+
return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function extendBareMatchAcrossSpaces(input, matchStart, raw, cwd) {
|
|
23
|
+
const baseInner = raw.replace(/\\ /g, ' ');
|
|
24
|
+
const baseExists = isFile(path.isAbsolute(baseInner) ? baseInner : path.resolve(cwd, baseInner));
|
|
25
|
+
let best = null;
|
|
26
|
+
let candidate = raw;
|
|
27
|
+
let start = matchStart;
|
|
28
|
+
let addedSeparator = false;
|
|
29
|
+
for (let hops = 0; hops < MAX_EXTENSION_TOKENS; hops++) {
|
|
30
|
+
if (start < 2 || input[start - 1] !== ' ' || /\s/.test(input[start - 2]))
|
|
31
|
+
break;
|
|
32
|
+
let i = start - 2;
|
|
33
|
+
while (i >= 0 && !/\s/.test(input[i]))
|
|
34
|
+
i--;
|
|
35
|
+
const tokenStart = i + 1;
|
|
36
|
+
const token = input.slice(tokenStart, start - 1);
|
|
37
|
+
if (!EXTENSION_TOKEN.test(token))
|
|
38
|
+
break;
|
|
39
|
+
candidate = `${token} ${candidate}`;
|
|
40
|
+
start = tokenStart;
|
|
41
|
+
addedSeparator = addedSeparator || /[\\/]/.test(token);
|
|
42
|
+
if (baseExists && !addedSeparator)
|
|
43
|
+
continue;
|
|
44
|
+
const inner = candidate.replace(/\\ /g, ' ');
|
|
45
|
+
const resolved = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
46
|
+
if (isFile(resolved))
|
|
47
|
+
best = candidate;
|
|
48
|
+
}
|
|
49
|
+
return best ?? raw;
|
|
50
|
+
}
|
|
51
|
+
function detectImagePaths(input, cwd) {
|
|
52
|
+
const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
|
|
53
|
+
const detected = [];
|
|
54
|
+
let match;
|
|
55
|
+
while ((match = regex.exec(input)) !== null) {
|
|
56
|
+
let raw = match[0];
|
|
57
|
+
if (match[4] != null && raw.includes('://'))
|
|
58
|
+
continue;
|
|
59
|
+
let inner;
|
|
60
|
+
if (match[1] != null || match[2] != null) {
|
|
61
|
+
const quoted = (match[1] ?? match[2]);
|
|
62
|
+
if (/^file:\/\//i.test(quoted)) {
|
|
63
|
+
try {
|
|
64
|
+
inner = fileURLToPath(quoted);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
inner = quoted;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (match[3] != null) {
|
|
75
|
+
try {
|
|
76
|
+
inner = fileURLToPath(raw);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
try {
|
|
80
|
+
inner = decodeURIComponent(match[3]);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
raw = extendBareMatchAcrossSpaces(input, match.index, raw, cwd);
|
|
89
|
+
inner = raw.replace(/\\ /g, ' ');
|
|
90
|
+
}
|
|
91
|
+
const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
|
|
92
|
+
const start = match.index - (raw.length - match[0].length);
|
|
93
|
+
while (detected.length > 0) {
|
|
94
|
+
const prev = detected[detected.length - 1];
|
|
95
|
+
if (prev.start + prev.raw.length <= start)
|
|
96
|
+
break;
|
|
97
|
+
detected.pop();
|
|
98
|
+
}
|
|
99
|
+
detected.push({ resolvedPath, raw, start });
|
|
100
|
+
}
|
|
101
|
+
const rawsByPath = new Map();
|
|
102
|
+
for (const { resolvedPath, raw } of detected) {
|
|
103
|
+
const existing = rawsByPath.get(resolvedPath);
|
|
104
|
+
if (existing) {
|
|
105
|
+
existing.push(raw);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
rawsByPath.set(resolvedPath, [raw]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return rawsByPath;
|
|
112
|
+
}
|
|
113
|
+
export function autoAttachImages(input, cwd, existing = []) {
|
|
114
|
+
const max = 2;
|
|
115
|
+
const rawsByPath = detectImagePaths(input, cwd);
|
|
116
|
+
let sanitizedInput = input;
|
|
117
|
+
const attachments = [];
|
|
118
|
+
const errors = [];
|
|
119
|
+
const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
|
|
120
|
+
for (const [resolvedPath, rawForms] of rawsByPath) {
|
|
121
|
+
if (existing.length + attachments.length >= max)
|
|
122
|
+
break;
|
|
123
|
+
if (!existsSync(resolvedPath))
|
|
124
|
+
continue;
|
|
125
|
+
try {
|
|
126
|
+
const loaded = loadImageFromFile(resolvedPath);
|
|
127
|
+
const idx = maxExistingIndex + attachments.length + 1;
|
|
128
|
+
attachments.push({
|
|
129
|
+
index: idx,
|
|
130
|
+
mimeType: loaded.mimeType,
|
|
131
|
+
base64Data: loaded.base64Data,
|
|
132
|
+
source: 'file',
|
|
133
|
+
filePath: resolvedPath,
|
|
134
|
+
});
|
|
135
|
+
for (const raw of rawForms) {
|
|
136
|
+
sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
errors.push(err.message);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { sanitizedInput, attachments, errors };
|
|
144
|
+
}
|