@thegitai/cli 1.0.0-beta.2 → 1.0.0-beta.21
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 +37 -2
- package/dist/bin/ai.js +148 -75
- package/dist/parsers/NOTICE +18 -0
- package/dist/src/agent-mode.js +5 -0
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +7 -41
- package/dist/src/api/chat.js +77 -20
- package/dist/src/api/http.js +81 -4
- package/dist/src/api/models.js +26 -18
- package/dist/src/artifact-policy.js +12 -0
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/cli-args.js +60 -0
- package/dist/src/client-environment.js +129 -0
- package/dist/src/colors.js +50 -0
- package/dist/src/core/clipboard.js +75 -0
- package/dist/src/core/image-path-extractor.js +144 -0
- package/dist/src/edit-journal.js +39 -6
- package/dist/src/executor.js +48 -12
- package/dist/src/help-text.js +24 -5
- package/dist/src/markdown-renderer.js +1 -1
- package/dist/src/patcher.js +17 -2
- package/dist/src/scanner.js +58 -17
- package/dist/src/scratch-dir.js +57 -0
- package/dist/src/secret-preview.js +0 -10
- package/dist/src/session-safety.js +64 -31
- package/dist/src/session-store.js +0 -1
- package/dist/src/todo-list.js +106 -0
- package/dist/src/tool-executor.js +164 -18
- package/dist/src/tools/delete-file.js +1 -1
- package/dist/src/tools/index.js +8 -0
- package/dist/src/tools/patch-file.js +16 -2
- package/dist/src/tools/path-suggest.js +139 -0
- package/dist/src/tools/read-document.js +15 -4
- package/dist/src/tools/read-file.js +23 -7
- package/dist/src/tools/replace-document-text.js +234 -0
- 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 +16 -2
- package/dist/src/tools/undo-edit.js +7 -5
- package/dist/src/tools/update-todos.js +27 -0
- package/dist/src/tools/write-file.js +14 -1
- package/dist/src/tree-sitter-runtime.js +8 -1
- package/dist/src/ui/repl.js +315 -24
- package/dist/src/ui/tui/bridge.js +2 -6
- package/dist/src/ui/tui/build-frame.js +225 -25
- package/dist/src/ui/tui/shell-input.js +42 -5
- package/dist/src/version.js +29 -0
- 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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
|
|
2
|
+
export function parseArgs(argv) {
|
|
3
|
+
const args = argv.slice(2);
|
|
4
|
+
const firstArg = args[0];
|
|
5
|
+
const command = firstArg && AUTH_COMMANDS.has(firstArg) ? firstArg : null;
|
|
6
|
+
const commandArgs = command ? args.slice(1) : [];
|
|
7
|
+
let autoYes = false;
|
|
8
|
+
let help = false;
|
|
9
|
+
let version = false;
|
|
10
|
+
let usage = false;
|
|
11
|
+
let session = null;
|
|
12
|
+
let listSessions = false;
|
|
13
|
+
let unknownOption = null;
|
|
14
|
+
const promptParts = [];
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i];
|
|
17
|
+
if (arg === '--yes' || arg === '-y') {
|
|
18
|
+
autoYes = true;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if ((arg === '--session' || arg === '--resume') && i + 1 < args.length) {
|
|
22
|
+
session = args[i + 1] ?? null;
|
|
23
|
+
i += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (arg === '--list-sessions') {
|
|
27
|
+
listSessions = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--help' || arg === '-h') {
|
|
31
|
+
help = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === '--version' || arg === '-v') {
|
|
35
|
+
version = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === '--usage') {
|
|
39
|
+
usage = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (command === null && unknownOption === null && /^-/.test(arg)) {
|
|
43
|
+
unknownOption = arg;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
promptParts.push(arg);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
command,
|
|
50
|
+
commandArgs,
|
|
51
|
+
autoYes,
|
|
52
|
+
help,
|
|
53
|
+
version,
|
|
54
|
+
usage,
|
|
55
|
+
session,
|
|
56
|
+
listSessions,
|
|
57
|
+
unknownOption,
|
|
58
|
+
prompt: promptParts.join(' ').trim(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ensureSessionScratchDir } from './scratch-dir.js';
|
|
5
|
+
const PACKAGE_MANAGER_CANDIDATES = [
|
|
6
|
+
'apt',
|
|
7
|
+
'apt-get',
|
|
8
|
+
'dnf',
|
|
9
|
+
'yum',
|
|
10
|
+
'pacman',
|
|
11
|
+
'zypper',
|
|
12
|
+
'apk',
|
|
13
|
+
'brew',
|
|
14
|
+
'nix',
|
|
15
|
+
'snap',
|
|
16
|
+
'flatpak',
|
|
17
|
+
'winget',
|
|
18
|
+
'choco',
|
|
19
|
+
'scoop',
|
|
20
|
+
];
|
|
21
|
+
function detectShell(platform, env) {
|
|
22
|
+
if (platform === 'win32') {
|
|
23
|
+
const comspec = env.COMSPEC;
|
|
24
|
+
return comspec ? path.win32.basename(comspec) : 'unknown';
|
|
25
|
+
}
|
|
26
|
+
const shell = env.SHELL;
|
|
27
|
+
return shell ? path.basename(shell) : 'unknown';
|
|
28
|
+
}
|
|
29
|
+
function unquoteOsReleaseValue(value) {
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (trimmed.length < 2)
|
|
32
|
+
return trimmed;
|
|
33
|
+
const quote = trimmed[0];
|
|
34
|
+
if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) {
|
|
35
|
+
return trimmed;
|
|
36
|
+
}
|
|
37
|
+
return trimmed
|
|
38
|
+
.slice(1, -1)
|
|
39
|
+
.replace(/\\(["'`$\\])/g, '$1')
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
export function parseLinuxOsRelease(text) {
|
|
43
|
+
const values = {};
|
|
44
|
+
for (const line of text.split(/\r?\n/)) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
47
|
+
continue;
|
|
48
|
+
const index = trimmed.indexOf('=');
|
|
49
|
+
if (index <= 0)
|
|
50
|
+
continue;
|
|
51
|
+
const key = trimmed.slice(0, index);
|
|
52
|
+
const value = unquoteOsReleaseValue(trimmed.slice(index + 1));
|
|
53
|
+
values[key] = value;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
distroId: values.ID,
|
|
57
|
+
distroName: values.PRETTY_NAME ?? values.NAME,
|
|
58
|
+
distroVersion: values.VERSION_ID ?? values.VERSION,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function readLinuxOsReleaseText(options) {
|
|
62
|
+
if ('osReleaseText' in options) {
|
|
63
|
+
return options.osReleaseText ?? '';
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync('/etc/os-release', 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function pathEnv(env) {
|
|
73
|
+
return env.PATH ?? env.Path ?? env.path ?? '';
|
|
74
|
+
}
|
|
75
|
+
function windowsExecutableNames(command, env) {
|
|
76
|
+
if (path.extname(command))
|
|
77
|
+
return [command];
|
|
78
|
+
const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
|
|
79
|
+
.split(';')
|
|
80
|
+
.map((extension) => extension.trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
return [command, ...extensions.map((extension) => `${command}${extension}`)];
|
|
83
|
+
}
|
|
84
|
+
function defaultExecutableExists(command, env, platform) {
|
|
85
|
+
const searchPath = pathEnv(env);
|
|
86
|
+
if (!searchPath)
|
|
87
|
+
return false;
|
|
88
|
+
const names = platform === 'win32' ? windowsExecutableNames(command, env) : [command];
|
|
89
|
+
for (const dir of searchPath.split(path.delimiter)) {
|
|
90
|
+
if (!dir.trim())
|
|
91
|
+
continue;
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
try {
|
|
94
|
+
accessSync(path.join(dir, name), constants.X_OK);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
try {
|
|
99
|
+
accessSync(path.join(dir, name));
|
|
100
|
+
return platform === 'win32';
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
function detectPackageManagers(env, platform, executableExists) {
|
|
111
|
+
return PACKAGE_MANAGER_CANDIDATES.filter((command) => executableExists(command, env, platform));
|
|
112
|
+
}
|
|
113
|
+
export function collectClientEnvironment(options = {}) {
|
|
114
|
+
const platform = options.platform ?? process.platform;
|
|
115
|
+
const env = options.env ?? process.env;
|
|
116
|
+
const executableExists = options.executableExists ?? defaultExecutableExists;
|
|
117
|
+
const linuxDistro = platform === 'linux'
|
|
118
|
+
? parseLinuxOsRelease(readLinuxOsReleaseText(options))
|
|
119
|
+
: {};
|
|
120
|
+
return {
|
|
121
|
+
platform,
|
|
122
|
+
arch: options.arch ?? process.arch,
|
|
123
|
+
release: options.release ?? os.release(),
|
|
124
|
+
shell: detectShell(platform, env),
|
|
125
|
+
...linuxDistro,
|
|
126
|
+
packageManagers: detectPackageManagers(env, platform, executableExists),
|
|
127
|
+
scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
|
|
128
|
+
};
|
|
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;
|