@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 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,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;
@@ -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: _filePath, ...attachment }) => attachment);
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 rawResult = await executeLocalToolCall({ projectIndex }, session, event.call);
190
- 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);
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 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
+ });
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: requestInput,
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, requestInput);
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: requestInput,
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, requestInput);
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, requestInput, error instanceof ChatTurnFailedError ? error.category : 'unknown_error');
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
+ }