@thegitai/cli 1.0.0-beta.16 → 1.0.0-beta.18

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 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
- await mainInteractive({
416
- authConfig,
417
- projectIndex,
418
- serverModels,
419
- serverSessionClient,
420
- session,
421
- usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
422
- initialPrompt,
423
- });
424
- await saveSessionBoth({ session, serverSessionClient });
425
- printSessionExit(session);
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`));
@@ -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,9 @@ 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
+ return buildPlanModeToolBlockedResult(call.name, PLAN_MODE_RUN_COMMAND_ACTION);
123
+ }
120
124
  const command = String(call.args?.command ?? call.args?.cmd ?? '');
121
125
  const reason = planModeRunCommandBlockReason(command);
122
126
  return reason ? buildPlanModeToolBlockedResult(call.name, reason) : null;
@@ -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 rawResult = await executeLocalToolCall({ projectIndex }, session, event.call);
192
- preserveCancelledTurnToolResult(session, input, event, rawResult);
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 requestInput = autoAttach.attachments.length > 0 ? autoAttach.sanitizedInput : input;
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: requestInput,
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, requestInput);
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: requestInput,
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, requestInput);
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, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
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
+ }