@thegitai/cli 1.0.0-beta.15 → 1.0.0-beta.17
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/dist/bin/ai.js +92 -11
- package/dist/src/agent-mode.js +6 -0
- package/dist/src/api/chat.js +46 -9
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/image-path-extractor.js +55 -4
- package/dist/src/executor.js +27 -7
- package/dist/src/help-text.js +10 -0
- package/dist/src/scratch-dir.js +57 -0
- package/dist/src/tool-executor.js +132 -17
- package/dist/src/tools/index.js +4 -0
- package/dist/src/tools/run-command.js +81 -13
- package/dist/src/tools/run-node-script.js +2 -0
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/ui/repl.js +239 -3
- package/dist/src/ui/tui/build-frame.js +92 -5
- package/dist/src/ui/tui/shell-input.js +31 -0
- package/package.json +5 -5
package/dist/bin/ai.js
CHANGED
|
@@ -16,6 +16,8 @@ import { formatSessionExitNotice } from '../src/session-exit.js';
|
|
|
16
16
|
import { formatUsageText } from '../src/usage.js';
|
|
17
17
|
import { formatVersionLine } from '../src/version.js';
|
|
18
18
|
import { parseArgs } from '../src/cli-args.js';
|
|
19
|
+
import { getJobBufferedOutput, killBackgroundJob, killAllBackgroundJobs, listBackgroundJobs, setBackgroundJobSession, } from '../src/background-jobs.js';
|
|
20
|
+
import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../src/tool-executor.js';
|
|
19
21
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
20
22
|
const { auth, chat, models, sessions } = ServerApi;
|
|
21
23
|
function printUsage() {
|
|
@@ -126,6 +128,15 @@ function modelLabel(serverModels, modelId) {
|
|
|
126
128
|
return (serverModels.models.find((model) => model.id === modelId)?.label ??
|
|
127
129
|
'Unknown model');
|
|
128
130
|
}
|
|
131
|
+
function formatJobElapsedMs(ms) {
|
|
132
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
133
|
+
if (totalSeconds < 60)
|
|
134
|
+
return `${totalSeconds}s`;
|
|
135
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
136
|
+
if (minutes < 60)
|
|
137
|
+
return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
|
|
138
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
139
|
+
}
|
|
129
140
|
function makeConfirmCommand(session) {
|
|
130
141
|
return async (command) => {
|
|
131
142
|
console.log(chalk.bold(`\nCommand approval needed:\n${command}\n`));
|
|
@@ -237,6 +248,68 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
237
248
|
console.log(chalk.dim('Conversation cleared.\n'));
|
|
238
249
|
continue;
|
|
239
250
|
}
|
|
251
|
+
if (trimmed === '/jobs' || trimmed.startsWith('/jobs ')) {
|
|
252
|
+
const jobsArgs = trimmed.slice('/jobs'.length).trim();
|
|
253
|
+
const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
|
|
254
|
+
if (killMatch) {
|
|
255
|
+
const jobId = killMatch[1];
|
|
256
|
+
const killed = await killBackgroundJob(jobId);
|
|
257
|
+
await collectBackgroundJobUiKillMutations({
|
|
258
|
+
session,
|
|
259
|
+
projectIndex,
|
|
260
|
+
jobId,
|
|
261
|
+
result: killed,
|
|
262
|
+
});
|
|
263
|
+
if (!killed.ok) {
|
|
264
|
+
console.log(chalk.red(killed.error ?? 'Background job kill failed.'));
|
|
265
|
+
}
|
|
266
|
+
else if (killed.alreadyFinished) {
|
|
267
|
+
console.log(chalk.dim(`${jobId} had already finished.`));
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
|
|
272
|
+
if (outputMatch) {
|
|
273
|
+
const jobId = outputMatch[1];
|
|
274
|
+
await collectBackgroundJobUiOutputMutations({
|
|
275
|
+
session,
|
|
276
|
+
projectIndex,
|
|
277
|
+
jobId,
|
|
278
|
+
});
|
|
279
|
+
const job = listBackgroundJobs().find((candidate) => candidate.id === jobId);
|
|
280
|
+
const buffered = getJobBufferedOutput(jobId);
|
|
281
|
+
if (!job || !buffered) {
|
|
282
|
+
console.log(chalk.red(`Unknown background job id: ${jobId}`));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (buffered.droppedChars > 0) {
|
|
286
|
+
console.log(chalk.dim(`... (${buffered.droppedChars} chars of older output dropped) ...`));
|
|
287
|
+
}
|
|
288
|
+
console.log(buffered.output || chalk.dim('(no output captured)'));
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (jobsArgs) {
|
|
292
|
+
console.log(chalk.dim('Usage: /jobs — list · /jobs output <id> — full output · /jobs kill <id> — kill'));
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const jobsList = listBackgroundJobs();
|
|
296
|
+
if (!jobsList.length) {
|
|
297
|
+
console.log(chalk.dim('No background jobs in this session.'));
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
for (const job of jobsList) {
|
|
301
|
+
const elapsed = formatJobElapsedMs((job.endedAt ?? Date.now()) - job.startedAt);
|
|
302
|
+
const stateText = job.status === 'running'
|
|
303
|
+
? `running · ${elapsed}`
|
|
304
|
+
: job.status === 'killed'
|
|
305
|
+
? `killed · ran ${elapsed}`
|
|
306
|
+
: job.status === 'error'
|
|
307
|
+
? 'failed to start'
|
|
308
|
+
: `exited (code ${job.exitCode ?? 1}) · ran ${elapsed}`;
|
|
309
|
+
console.log(`${job.id} · ${stateText}\n $ ${job.command}`);
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
240
313
|
if (trimmed === '/resume') {
|
|
241
314
|
const snapshot = await promptForResumeSession(session.rootDir, serverModels);
|
|
242
315
|
if (!snapshot) {
|
|
@@ -244,6 +317,7 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
244
317
|
continue;
|
|
245
318
|
}
|
|
246
319
|
applySessionSnapshot(session, snapshot);
|
|
320
|
+
setBackgroundJobSession(session.sessionId);
|
|
247
321
|
await saveSessionBoth({ session, serverSessionClient });
|
|
248
322
|
console.log(chalk.dim(`Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})\n`));
|
|
249
323
|
continue;
|
|
@@ -412,17 +486,24 @@ export async function main() {
|
|
|
412
486
|
}
|
|
413
487
|
printStartupBanner(rootDir, modelLabel(serverModels, session.modelId), session.autoYes);
|
|
414
488
|
printSessionStartup(session);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
489
|
+
setBackgroundJobSession(session.sessionId);
|
|
490
|
+
try {
|
|
491
|
+
await mainInteractive({
|
|
492
|
+
authConfig,
|
|
493
|
+
projectIndex,
|
|
494
|
+
serverModels,
|
|
495
|
+
serverSessionClient,
|
|
496
|
+
session,
|
|
497
|
+
usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
|
|
498
|
+
initialPrompt,
|
|
499
|
+
});
|
|
500
|
+
await saveSessionBoth({ session, serverSessionClient });
|
|
501
|
+
printSessionExit(session);
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
killAllBackgroundJobs({ sessionId: session.sessionId, remove: true });
|
|
505
|
+
setBackgroundJobSession(null);
|
|
506
|
+
}
|
|
426
507
|
}
|
|
427
508
|
main().catch((error) => {
|
|
428
509
|
console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
|
package/dist/src/agent-mode.js
CHANGED
|
@@ -12,6 +12,7 @@ const PLAN_MODE_TOOL_NAMES = new Set([
|
|
|
12
12
|
'read_document',
|
|
13
13
|
'analyze_image',
|
|
14
14
|
'run_command',
|
|
15
|
+
'shell_job_output',
|
|
15
16
|
]);
|
|
16
17
|
const PLAN_MODE_RUN_COMMAND_NAMES = new Set([
|
|
17
18
|
'pwd',
|
|
@@ -117,6 +118,11 @@ export function buildAgentModeToolBlockedResult(mode, call) {
|
|
|
117
118
|
}
|
|
118
119
|
if (call.name !== 'run_command')
|
|
119
120
|
return null;
|
|
121
|
+
if (call.args?.background === true ||
|
|
122
|
+
call.args?.run_in_background === true ||
|
|
123
|
+
call.args?.runInBackground === true) {
|
|
124
|
+
return buildPlanModeToolBlockedResult(call.name, PLAN_MODE_RUN_COMMAND_ACTION);
|
|
125
|
+
}
|
|
120
126
|
const command = String(call.args?.command ?? call.args?.cmd ?? '');
|
|
121
127
|
const reason = planModeRunCommandBlockReason(command);
|
|
122
128
|
return reason ? buildPlanModeToolBlockedResult(call.name, reason) : null;
|
package/dist/src/api/chat.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { drainBackgroundJobNotifications } from '../background-jobs.js';
|
|
1
2
|
import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../session-safety.js';
|
|
2
3
|
import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
|
|
3
4
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
@@ -65,7 +66,9 @@ function snapshotForServer(session) {
|
|
|
65
66
|
return snapshot;
|
|
66
67
|
}
|
|
67
68
|
function imageAttachmentsForServer(attachments) {
|
|
68
|
-
return (attachments ?? []).map(({ filePath
|
|
69
|
+
return (attachments ?? []).map(({ filePath, ...attachment }) => attachment.source === 'file' && filePath
|
|
70
|
+
? { ...attachment, filePath }
|
|
71
|
+
: attachment);
|
|
69
72
|
}
|
|
70
73
|
function userHistoryText(entry) {
|
|
71
74
|
return (entry.parts ?? [])
|
|
@@ -146,6 +149,35 @@ function publicStatusMessage(data) {
|
|
|
146
149
|
return `Running ${toolName} locally...`;
|
|
147
150
|
return null;
|
|
148
151
|
}
|
|
152
|
+
function normalizeShellJobToolCall(call) {
|
|
153
|
+
if (call.name !== 'shell_job_output' && call.name !== 'shell_job_kill') {
|
|
154
|
+
return call;
|
|
155
|
+
}
|
|
156
|
+
const args = call.args && typeof call.args === 'object' && !Array.isArray(call.args)
|
|
157
|
+
? { ...call.args }
|
|
158
|
+
: {};
|
|
159
|
+
let changed = false;
|
|
160
|
+
if (args.job_id === undefined) {
|
|
161
|
+
const alias = args.jobId ?? args.id;
|
|
162
|
+
if (alias !== undefined) {
|
|
163
|
+
args.job_id = alias;
|
|
164
|
+
delete args.jobId;
|
|
165
|
+
delete args.id;
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (call.name === 'shell_job_output' && args.wait_ms === undefined) {
|
|
170
|
+
const alias = args.waitMs ?? args.wait ?? args.wait_millis;
|
|
171
|
+
if (alias !== undefined) {
|
|
172
|
+
args.wait_ms = alias;
|
|
173
|
+
delete args.waitMs;
|
|
174
|
+
delete args.wait;
|
|
175
|
+
delete args.wait_millis;
|
|
176
|
+
changed = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return changed ? { ...call, args } : call;
|
|
180
|
+
}
|
|
149
181
|
async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
|
|
150
182
|
const payload = {
|
|
151
183
|
toolCallId: event.call.id,
|
|
@@ -186,8 +218,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
186
218
|
}
|
|
187
219
|
}
|
|
188
220
|
try {
|
|
189
|
-
const
|
|
190
|
-
|
|
221
|
+
const call = normalizeShellJobToolCall(event.call);
|
|
222
|
+
const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
|
|
223
|
+
preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
|
|
191
224
|
if (signal?.aborted) {
|
|
192
225
|
throw new TurnCancelledError();
|
|
193
226
|
}
|
|
@@ -293,14 +326,18 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
293
326
|
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
294
327
|
? [...imageAttachments, ...autoAttach.attachments]
|
|
295
328
|
: imageAttachments;
|
|
296
|
-
const
|
|
329
|
+
const requestInputBase = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
|
|
330
|
+
const backgroundJobUpdate = drainBackgroundJobNotifications({
|
|
331
|
+
sessionId: session.sessionId,
|
|
332
|
+
});
|
|
297
333
|
for (const err of autoAttach.errors) {
|
|
298
334
|
session.onStatus(`Image: ${err}`);
|
|
299
335
|
}
|
|
300
336
|
const request = {
|
|
301
337
|
modelId: session.modelId,
|
|
302
338
|
session: snapshotForServer(session),
|
|
303
|
-
input:
|
|
339
|
+
input: requestInputBase,
|
|
340
|
+
backgroundJobUpdate: backgroundJobUpdate || undefined,
|
|
304
341
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
305
342
|
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
306
343
|
maxToolSteps: session.maxToolSteps,
|
|
@@ -309,7 +346,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
309
346
|
};
|
|
310
347
|
const trace = createTraceContext();
|
|
311
348
|
const preTurnHistoryLength = session.history.length;
|
|
312
|
-
const preserveOnAbort = () => preserveCancelledTurnInput(session,
|
|
349
|
+
const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInputBase);
|
|
313
350
|
if (signal?.aborted) {
|
|
314
351
|
preserveOnAbort();
|
|
315
352
|
}
|
|
@@ -336,7 +373,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
336
373
|
config,
|
|
337
374
|
projectIndex,
|
|
338
375
|
session,
|
|
339
|
-
input:
|
|
376
|
+
input: requestInputBase,
|
|
340
377
|
fetchImpl,
|
|
341
378
|
signal,
|
|
342
379
|
traceId: trace.traceId,
|
|
@@ -351,13 +388,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
351
388
|
}
|
|
352
389
|
catch (error) {
|
|
353
390
|
if (isTurnCancelledError(error)) {
|
|
354
|
-
preserveCancelledTurnInput(session,
|
|
391
|
+
preserveCancelledTurnInput(session, requestInputBase);
|
|
355
392
|
throw error instanceof TurnCancelledError
|
|
356
393
|
? error
|
|
357
394
|
: new TurnCancelledError();
|
|
358
395
|
}
|
|
359
396
|
session.history.length = preTurnHistoryLength;
|
|
360
|
-
preserveFailedTurnInput(session,
|
|
397
|
+
preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
361
398
|
throw error;
|
|
362
399
|
}
|
|
363
400
|
finally {
|
|
@@ -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
|
+
}
|