@thegitai/cli 1.0.0-beta.16 → 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 +43 -8
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/executor.js +4 -4
- package/dist/src/help-text.js +10 -0
- package/dist/src/tool-executor.js +132 -17
- package/dist/src/tools/index.js +4 -0
- package/dist/src/tools/run-command.js +80 -12
- 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';
|
|
@@ -148,6 +149,35 @@ function publicStatusMessage(data) {
|
|
|
148
149
|
return `Running ${toolName} locally...`;
|
|
149
150
|
return null;
|
|
150
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
|
+
}
|
|
151
181
|
async function postToolResult({ config, turnId, event, result, session, fetchImpl, traceId, }) {
|
|
152
182
|
const payload = {
|
|
153
183
|
toolCallId: event.call.id,
|
|
@@ -188,8 +218,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
|
|
|
188
218
|
}
|
|
189
219
|
}
|
|
190
220
|
try {
|
|
191
|
-
const
|
|
192
|
-
|
|
221
|
+
const call = normalizeShellJobToolCall(event.call);
|
|
222
|
+
const rawResult = await executeLocalToolCall({ projectIndex }, session, call);
|
|
223
|
+
preserveCancelledTurnToolResult(session, input, { ...event, call }, rawResult);
|
|
193
224
|
if (signal?.aborted) {
|
|
194
225
|
throw new TurnCancelledError();
|
|
195
226
|
}
|
|
@@ -295,14 +326,18 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
295
326
|
const requestImageAttachments = autoAttach.attachments.length > 0
|
|
296
327
|
? [...imageAttachments, ...autoAttach.attachments]
|
|
297
328
|
: imageAttachments;
|
|
298
|
-
const
|
|
329
|
+
const requestInputBase = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
|
|
330
|
+
const backgroundJobUpdate = drainBackgroundJobNotifications({
|
|
331
|
+
sessionId: session.sessionId,
|
|
332
|
+
});
|
|
299
333
|
for (const err of autoAttach.errors) {
|
|
300
334
|
session.onStatus(`Image: ${err}`);
|
|
301
335
|
}
|
|
302
336
|
const request = {
|
|
303
337
|
modelId: session.modelId,
|
|
304
338
|
session: snapshotForServer(session),
|
|
305
|
-
input:
|
|
339
|
+
input: requestInputBase,
|
|
340
|
+
backgroundJobUpdate: backgroundJobUpdate || undefined,
|
|
306
341
|
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
307
342
|
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
308
343
|
maxToolSteps: session.maxToolSteps,
|
|
@@ -311,7 +346,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
311
346
|
};
|
|
312
347
|
const trace = createTraceContext();
|
|
313
348
|
const preTurnHistoryLength = session.history.length;
|
|
314
|
-
const preserveOnAbort = () => preserveCancelledTurnInput(session,
|
|
349
|
+
const preserveOnAbort = () => preserveCancelledTurnInput(session, requestInputBase);
|
|
315
350
|
if (signal?.aborted) {
|
|
316
351
|
preserveOnAbort();
|
|
317
352
|
}
|
|
@@ -338,7 +373,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
338
373
|
config,
|
|
339
374
|
projectIndex,
|
|
340
375
|
session,
|
|
341
|
-
input:
|
|
376
|
+
input: requestInputBase,
|
|
342
377
|
fetchImpl,
|
|
343
378
|
signal,
|
|
344
379
|
traceId: trace.traceId,
|
|
@@ -353,13 +388,13 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
353
388
|
}
|
|
354
389
|
catch (error) {
|
|
355
390
|
if (isTurnCancelledError(error)) {
|
|
356
|
-
preserveCancelledTurnInput(session,
|
|
391
|
+
preserveCancelledTurnInput(session, requestInputBase);
|
|
357
392
|
throw error instanceof TurnCancelledError
|
|
358
393
|
? error
|
|
359
394
|
: new TurnCancelledError();
|
|
360
395
|
}
|
|
361
396
|
session.history.length = preTurnHistoryLength;
|
|
362
|
-
preserveFailedTurnInput(session,
|
|
397
|
+
preserveFailedTurnInput(session, requestInputBase, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
|
|
363
398
|
throw error;
|
|
364
399
|
}
|
|
365
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
|
+
}
|