@pentoshi/clai 3.8.46 → 3.8.47

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.
Files changed (96) hide show
  1. package/dist/agent/compaction-summary.js +1 -1
  2. package/dist/agent/compaction-summary.js.map +1 -1
  3. package/dist/agent/plan-tool.js +110 -14
  4. package/dist/agent/plan-tool.js.map +1 -1
  5. package/dist/agent/reliability-policy.d.ts +4 -3
  6. package/dist/agent/reliability-policy.js +4 -3
  7. package/dist/agent/reliability-policy.js.map +1 -1
  8. package/dist/agent/responder-context.d.ts +10 -0
  9. package/dist/agent/responder-context.js +50 -0
  10. package/dist/agent/responder-context.js.map +1 -0
  11. package/dist/agent/runner.js +160 -12
  12. package/dist/agent/runner.js.map +1 -1
  13. package/dist/agent/task-plan.d.ts +4 -0
  14. package/dist/agent/task-plan.js.map +1 -1
  15. package/dist/app/adapters/current-jobs-adapter.js +7 -0
  16. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  17. package/dist/app/controllers/job-controller.d.ts +9 -6
  18. package/dist/app/controllers/job-controller.js +21 -0
  19. package/dist/app/controllers/job-controller.js.map +1 -1
  20. package/dist/app/controllers/session-controller.d.ts +14 -33
  21. package/dist/app/controllers/session-controller.js +71 -132
  22. package/dist/app/controllers/session-controller.js.map +1 -1
  23. package/dist/app/controllers/session-prompt-queue.d.ts +34 -0
  24. package/dist/app/controllers/session-prompt-queue.js +141 -0
  25. package/dist/app/controllers/session-prompt-queue.js.map +1 -0
  26. package/dist/app/controllers/session-responder.d.ts +49 -0
  27. package/dist/app/controllers/session-responder.js +311 -0
  28. package/dist/app/controllers/session-responder.js.map +1 -0
  29. package/dist/app/ports/jobs-port.d.ts +10 -7
  30. package/dist/prompts/embedded.js +1 -1
  31. package/dist/prompts/embedded.js.map +1 -1
  32. package/dist/prompts/system.agent.md +14 -9
  33. package/dist/repl.js +24 -14
  34. package/dist/repl.js.map +1 -1
  35. package/dist/safety/engagement-policy.js +39 -7
  36. package/dist/safety/engagement-policy.js.map +1 -1
  37. package/dist/store/config.js +1 -1
  38. package/dist/store/config.js.map +1 -1
  39. package/dist/store/plan.d.ts +11 -0
  40. package/dist/store/plan.js +66 -3
  41. package/dist/store/plan.js.map +1 -1
  42. package/dist/tools/definitions.js +35 -4
  43. package/dist/tools/definitions.js.map +1 -1
  44. package/dist/tools/jobs.d.ts +72 -3
  45. package/dist/tools/jobs.js +743 -121
  46. package/dist/tools/jobs.js.map +1 -1
  47. package/dist/tools/pentest-workflows.js +80 -4
  48. package/dist/tools/pentest-workflows.js.map +1 -1
  49. package/dist/tools/registry.js +60 -19
  50. package/dist/tools/registry.js.map +1 -1
  51. package/dist/tools/tool-types.d.ts +5 -0
  52. package/dist/tools/wordlists.js +74 -87
  53. package/dist/tools/wordlists.js.map +1 -1
  54. package/dist/tui-v2/actions/action-id.js +1 -1
  55. package/dist/tui-v2/actions/action-id.js.map +1 -1
  56. package/dist/tui-v2/actions/keymap.js +1 -1
  57. package/dist/tui-v2/actions/keymap.js.map +1 -1
  58. package/dist/tui-v2/app/App.js +82 -12
  59. package/dist/tui-v2/app/App.js.map +1 -1
  60. package/dist/tui-v2/app/commands/session-commands.js +0 -5
  61. package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
  62. package/dist/tui-v2/bootstrap/composition-root.js +2 -0
  63. package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
  64. package/dist/tui-v2/components/jobs/jobs-panel.d.ts +12 -0
  65. package/dist/tui-v2/components/jobs/jobs-panel.js +184 -14
  66. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  67. package/dist/tui-v2/components/plan/plan-view.js +7 -3
  68. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  69. package/dist/tui-v2/components/status/status-line.js +2 -3
  70. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  71. package/dist/tui-v2/components/toast/toast-host.js +16 -1
  72. package/dist/tui-v2/components/toast/toast-host.js.map +1 -1
  73. package/dist/tui-v2/components/transcript/tool-card.d.ts +3 -0
  74. package/dist/tui-v2/components/transcript/tool-card.js +12 -2
  75. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  76. package/dist/tui-v2/components/transcript/transcript-row.js +1 -1
  77. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  78. package/dist/tui-v2/composer/composer-editor.d.ts +6 -0
  79. package/dist/tui-v2/composer/composer-editor.js +10 -17
  80. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  81. package/dist/tui-v2/composer/composer-image-paste.d.ts +1 -0
  82. package/dist/tui-v2/composer/composer-image-paste.js +22 -1
  83. package/dist/tui-v2/composer/composer-image-paste.js.map +1 -1
  84. package/dist/tui-v2/state/transcript-store.d.ts +4 -0
  85. package/dist/tui-v2/state/transcript-store.js +13 -0
  86. package/dist/tui-v2/state/transcript-store.js.map +1 -1
  87. package/dist/ui/intro-card.js +3 -3
  88. package/dist/ui/intro-card.js.map +1 -1
  89. package/dist/ui/mentions.d.ts +11 -13
  90. package/dist/ui/mentions.js +67 -26
  91. package/dist/ui/mentions.js.map +1 -1
  92. package/dist/ui/plan-pane.js +6 -2
  93. package/dist/ui/plan-pane.js.map +1 -1
  94. package/dist/version.generated.d.ts +2 -2
  95. package/dist/version.generated.js +2 -2
  96. package/package.json +1 -1
@@ -3,10 +3,24 @@ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, wri
3
3
  import { mkdir, open, stat } from "node:fs/promises";
4
4
  import { basename, join } from "node:path";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
+ import { StringDecoder } from "node:string_decoder";
6
7
  import { redactSecrets } from "../llm/provider.js";
7
8
  import { safeCwd } from "../os/cwd.js";
8
9
  import { getJobsDir } from "../store/paths.js";
9
10
  import { resolveShell } from "./shell.js";
11
+ export function formatJobElapsed(job, now = Date.now()) {
12
+ const startedAt = Date.parse(job.startedAt);
13
+ const endedAt = job.endedAt ? Date.parse(job.endedAt) : now;
14
+ if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt))
15
+ return "unknown";
16
+ const seconds = Math.max(0, Math.floor((endedAt - startedAt) / 1000));
17
+ if (seconds < 1)
18
+ return "<1s";
19
+ if (seconds < 60)
20
+ return `${seconds}s`;
21
+ const minutes = Math.floor(seconds / 60);
22
+ return `${minutes}m${seconds % 60}s`;
23
+ }
10
24
  function displayArg(value) {
11
25
  return /^[A-Za-z0-9_./:=@+-]+$/.test(value)
12
26
  ? value
@@ -18,15 +32,18 @@ function commandDisplay(command) {
18
32
  return command.display ?? [command.command, ...command.argv].map(displayArg).join(" ");
19
33
  }
20
34
  const PER_FILE_BYTES = 1024 * 1024;
21
- const TOTAL_STREAM_BYTES = 16 * 1024 * 1024;
35
+ const MAX_STREAM_BYTES = 16 * 1024 * 1024;
22
36
  const DEFAULT_TAIL_BYTES = 8_000;
23
37
  const REGISTRY_FILE = "registry-v1.json";
38
+ const TRANSIENT_V2_REGISTRY_FILE = "registry-v2.json";
24
39
  /** Cap durable terminal jobs kept on disk/in memory (per process). */
25
40
  const MAX_DURABLE_TERMINAL_JOBS = 80;
26
41
  /** Drop terminal durable jobs older than this on load/list. */
27
42
  const TERMINAL_JOB_MAX_AGE_MS = 48 * 60 * 60 * 1000;
28
43
  /** Max lines shell.jobs returns to the model (running first, then recent). */
29
44
  const LIST_JOBS_MAX_LINES = 40;
45
+ /** Coalesce window for chatty stdout/stderr progress persistence + UI events. */
46
+ const PROGRESS_FLUSH_MS = 250;
30
47
  /**
31
48
  * Number of trailing bytes in `buf` that form an incomplete multi-byte UTF-8
32
49
  * sequence (a lead byte whose continuation bytes were cut off by the read
@@ -64,7 +81,13 @@ function processIdentity(pid) {
64
81
  if (!pid || process.platform === "win32")
65
82
  return undefined;
66
83
  try {
67
- const value = execFileSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="], {
84
+ // Start time only: it is invariant for a pid's lifetime and changes on
85
+ // reuse, so it is a sufficient pid-reuse guard. The command line is
86
+ // deliberately excluded — `sh -c "cmd"` execs into `cmd`, mutating
87
+ // `ps command=` mid-run, which made a live job fail its OWN identity check
88
+ // (stop refused with "process identity no longer matches", liveness falsely
89
+ // marked lost, and double-Esc cancel unable to kill the process).
90
+ const value = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
68
91
  encoding: "utf8",
69
92
  timeout: 2_000,
70
93
  stdio: ["ignore", "pipe", "ignore"],
@@ -96,19 +119,26 @@ function looksLikeEphemeralToolTrack(job) {
96
119
  class RotatingRedactedWriter {
97
120
  receipt;
98
121
  stream;
122
+ streamDone;
123
+ completedStreams = [];
99
124
  index = 0;
100
125
  currentBytes = 0;
101
126
  hash = createHash("sha256");
127
+ decoder = new StringDecoder("utf8");
102
128
  pending = "";
129
+ closePromise;
130
+ writeError;
131
+ closed = false;
132
+ acceptedBytes;
103
133
  static REDACTION_OVERLAP_CHARS = 4096;
104
134
  constructor(receipt) {
105
135
  this.receipt = receipt;
136
+ this.acceptedBytes = receipt.bytes;
106
137
  }
107
138
  append(raw) {
108
- this.pending += Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
109
- // Complete lines are safe to redact as a unit, including secrets split
110
- // across arbitrary process data events. Keep an unterminated tail as the
111
- // overlap window for the next event.
139
+ if (this.closed)
140
+ throw new Error("Cannot append to a closed job artifact");
141
+ this.pending += Buffer.isBuffer(raw) ? this.decoder.write(raw) : raw;
112
142
  const lastNewline = this.pending.lastIndexOf("\n");
113
143
  if (lastNewline >= 0) {
114
144
  this.writeRedacted(this.pending.slice(0, lastNewline + 1));
@@ -121,59 +151,94 @@ class RotatingRedactedWriter {
121
151
  this.pending = this.pending.slice(flushLength);
122
152
  }
123
153
  close() {
154
+ if (this.closePromise)
155
+ return this.closePromise;
156
+ this.pending += this.decoder.end();
124
157
  if (this.pending)
125
158
  this.writeRedacted(this.pending);
126
159
  this.pending = "";
127
- this.stream?.end();
128
- this.stream = undefined;
160
+ this.closed = true;
161
+ this.finishCurrentStream();
162
+ this.closePromise = Promise.all(this.completedStreams).then(() => {
163
+ if (this.writeError)
164
+ throw this.writeError;
165
+ });
166
+ return this.closePromise;
129
167
  }
130
168
  writeRedacted(source) {
131
169
  const safe = redactSecrets(source);
132
170
  if (safe !== source)
133
171
  this.receipt.redacted = true;
134
172
  let data = Buffer.from(safe, "utf8");
135
- if (this.receipt.bytes >= TOTAL_STREAM_BYTES) {
136
- this.receipt.droppedBytes += data.length;
137
- return;
138
- }
139
- if (this.receipt.bytes + data.length > TOTAL_STREAM_BYTES) {
140
- const keep = TOTAL_STREAM_BYTES - this.receipt.bytes;
141
- this.receipt.droppedBytes += data.length - keep;
142
- data = data.subarray(0, keep);
173
+ const remaining = Math.max(0, MAX_STREAM_BYTES - this.acceptedBytes);
174
+ if (data.length > remaining) {
175
+ this.receipt.droppedBytes += data.length - remaining;
176
+ data = data.subarray(0, remaining);
143
177
  }
178
+ this.acceptedBytes += data.length;
144
179
  while (data.length > 0) {
145
180
  if (!this.stream || this.currentBytes >= PER_FILE_BYTES)
146
181
  this.rotate();
147
182
  const room = PER_FILE_BYTES - this.currentBytes;
148
183
  const part = data.subarray(0, room);
149
- this.stream.write(part);
150
- this.hash.update(part);
184
+ const stream = this.stream;
185
+ stream.write(part, (error) => {
186
+ if (error) {
187
+ this.writeError ??= error;
188
+ return;
189
+ }
190
+ this.hash.update(part);
191
+ this.receipt.bytes += part.length;
192
+ this.receipt.sha256 = this.hash.copy().digest("hex");
193
+ });
151
194
  this.currentBytes += part.length;
152
- this.receipt.bytes += part.length;
153
195
  data = data.subarray(part.length);
154
196
  }
155
- this.receipt.sha256 = this.hash.copy().digest("hex");
197
+ }
198
+ finishCurrentStream() {
199
+ if (!this.stream || !this.streamDone)
200
+ return;
201
+ const stream = this.stream;
202
+ const done = this.streamDone;
203
+ this.stream = undefined;
204
+ this.streamDone = undefined;
205
+ stream.end();
206
+ this.completedStreams.push(done);
156
207
  }
157
208
  rotate() {
158
- this.stream?.end();
209
+ this.finishCurrentStream();
159
210
  const path = this.index === 0 ? this.receipt.path : `${this.receipt.path}.${this.index}`;
160
211
  this.index += 1;
161
212
  this.currentBytes = 0;
162
213
  this.receipt.chunks.push(path);
163
- this.stream = createWriteStream(path, { flags: "w", mode: 0o600 });
214
+ const stream = createWriteStream(path, { flags: "w", mode: 0o600 });
215
+ this.stream = stream;
216
+ this.streamDone = new Promise((resolve) => {
217
+ stream.once("finish", resolve);
218
+ stream.once("error", (error) => {
219
+ this.writeError ??= error;
220
+ resolve();
221
+ });
222
+ });
164
223
  }
165
224
  }
166
225
  export class JobManager {
167
226
  jobsDir;
168
227
  jobs = new Map();
228
+ notifications = new Map();
169
229
  processes = new Map();
170
230
  writers = new Map();
171
231
  abortControllers = new Map();
172
232
  deadlineTimers = new Map();
233
+ finalizations = new Map();
234
+ listeners = new Set();
235
+ registryRetryTimer;
173
236
  registryPath;
237
+ transientV2RegistryPath;
174
238
  constructor(jobsDir = getJobsDir()) {
175
239
  this.jobsDir = jobsDir;
176
240
  this.registryPath = join(this.jobsDir, REGISTRY_FILE);
241
+ this.transientV2RegistryPath = join(this.jobsDir, TRANSIENT_V2_REGISTRY_FILE);
177
242
  this.loadAndReconcile();
178
243
  }
179
244
  isDurable(job) {
@@ -184,10 +249,24 @@ export class JobManager {
184
249
  job.status === "starting" ||
185
250
  job.status === "stopping");
186
251
  }
252
+ isTerminalStatus(status) {
253
+ return status === "exited" || status === "failed" || status === "killed" || status === "lost";
254
+ }
255
+ emit(change) {
256
+ for (const listener of this.listeners) {
257
+ try {
258
+ listener(change);
259
+ }
260
+ catch { /* listeners cannot break job persistence */ }
261
+ }
262
+ }
263
+ subscribe(listener) {
264
+ this.listeners.add(listener);
265
+ return () => this.listeners.delete(listener);
266
+ }
187
267
  matchesSession(job, sessionId) {
188
268
  if (!sessionId)
189
269
  return true;
190
- // Strict: only this session's durable jobs (never leak other sessions).
191
270
  return job.ownerSessionId === sessionId;
192
271
  }
193
272
  clearJobDeadline(id) {
@@ -196,6 +275,131 @@ export class JobManager {
196
275
  clearTimeout(timer);
197
276
  this.deadlineTimers.delete(id);
198
277
  }
278
+ cloneReceipt(receipt) {
279
+ return { ...receipt, chunks: [...receipt.chunks] };
280
+ }
281
+ notificationForJob(jobId) {
282
+ return [...this.notifications.values()].find((notification) => notification.jobId === jobId);
283
+ }
284
+ ensureCompletionNotification(job) {
285
+ if (!this.isDurable(job) ||
286
+ !this.isTerminalStatus(job.status) ||
287
+ !job.responder) {
288
+ return { created: false, updated: false };
289
+ }
290
+ const existing = this.notificationForJob(job.id);
291
+ const endedAt = job.endedAt ?? new Date().toISOString();
292
+ const stdoutArtifact = this.cloneReceipt(job.artifacts.stdout);
293
+ const stderrArtifact = this.cloneReceipt(job.artifacts.stderr);
294
+ const completionChanged = Boolean(existing &&
295
+ (existing.status !== job.status ||
296
+ existing.endedAt !== endedAt ||
297
+ existing.exitCode !== job.exitCode ||
298
+ existing.signal !== job.signal ||
299
+ JSON.stringify(existing.stdoutArtifact) !==
300
+ JSON.stringify(stdoutArtifact) ||
301
+ JSON.stringify(existing.stderrArtifact) !==
302
+ JSON.stringify(stderrArtifact)));
303
+ const reopenSettlement = Boolean(existing?.acknowledgedAt && completionChanged);
304
+ const notification = {
305
+ id: existing?.id ??
306
+ `completion:${createHash("sha256").update(`${job.ownerSessionId}\0${job.id}\0${job.startedAt}`).digest("hex").slice(0, 24)}`,
307
+ ownerSessionId: job.ownerSessionId,
308
+ jobId: job.id,
309
+ status: job.status,
310
+ createdAt: existing?.createdAt ?? endedAt,
311
+ startedAt: job.startedAt,
312
+ endedAt,
313
+ stdoutArtifact,
314
+ stderrArtifact,
315
+ commandDisplay: job.commandDisplay,
316
+ wakeOnCompletion: job.wakeOnCompletion ?? false,
317
+ responder: job.responder ?? false,
318
+ ...(job.taskId ? { taskId: job.taskId } : {}),
319
+ ...(job.parentTaskId ? { parentTaskId: job.parentTaskId } : {}),
320
+ ...(job.exitCode !== undefined ? { exitCode: job.exitCode } : {}),
321
+ ...(job.signal !== undefined ? { signal: job.signal } : {}),
322
+ ...(job.monitor !== undefined ? { monitor: job.monitor } : {}),
323
+ ...(existing?.deliveredAt ? { deliveredAt: existing.deliveredAt } : {}),
324
+ ...((existing?.analyzedAt ?? existing?.acknowledgedAt)
325
+ ? { analyzedAt: existing?.analyzedAt ?? existing?.acknowledgedAt }
326
+ : {}),
327
+ ...(!reopenSettlement && existing?.acknowledgedAt
328
+ ? { acknowledgedAt: existing.acknowledgedAt }
329
+ : {}),
330
+ };
331
+ const updated = Boolean(existing && JSON.stringify(existing) !== JSON.stringify(notification));
332
+ this.notifications.set(notification.id, notification);
333
+ return { notification, created: !existing, updated };
334
+ }
335
+ async closeWriters(id) {
336
+ const writers = this.writers.get(id);
337
+ if (!writers)
338
+ return true;
339
+ const results = await Promise.allSettled([writers.stdout.close(), writers.stderr.close()]);
340
+ this.writers.delete(id);
341
+ return results.every((result) => result.status === "fulfilled");
342
+ }
343
+ async finalizeJob(job, status, details = {}) {
344
+ const pending = this.finalizations.get(job.id);
345
+ if (pending)
346
+ return pending;
347
+ const correctsLost = job.status === "lost" && status !== "lost";
348
+ if (this.isTerminalStatus(job.status) && !correctsLost) {
349
+ const completion = this.ensureCompletionNotification(job);
350
+ if ((!completion.created && !completion.updated) ||
351
+ !completion.notification) {
352
+ return true;
353
+ }
354
+ const persisted = this.persistSync();
355
+ if (!persisted)
356
+ this.scheduleRegistryRetry();
357
+ this.emit({ type: "job", jobId: job.id });
358
+ this.emit({
359
+ type: "notification",
360
+ jobId: job.id,
361
+ notificationId: completion.notification.id,
362
+ });
363
+ return persisted;
364
+ }
365
+ const finalization = (async () => {
366
+ this.clearJobDeadline(job.id);
367
+ const streamsFlushed = await this.closeWriters(job.id);
368
+ job.status = streamsFlushed ? status : "failed";
369
+ if (details.exitCode !== undefined)
370
+ job.exitCode = details.exitCode;
371
+ if (!streamsFlushed && (job.exitCode === undefined || job.exitCode === 0)) {
372
+ job.exitCode = 1;
373
+ }
374
+ if (details.signal !== undefined)
375
+ job.signal = details.signal;
376
+ job.endedAt = details.endedAt ?? new Date().toISOString();
377
+ this.abortControllers.delete(job.id);
378
+ this.processes.delete(job.id);
379
+ const completion = this.ensureCompletionNotification(job);
380
+ this.pruneTerminalJobs();
381
+ const persisted = this.persistSync();
382
+ if (!persisted)
383
+ this.scheduleRegistryRetry();
384
+ this.emit({ type: "job", jobId: job.id });
385
+ if ((completion.created || completion.updated) &&
386
+ completion.notification) {
387
+ this.emit({
388
+ type: "notification",
389
+ jobId: job.id,
390
+ notificationId: completion.notification.id,
391
+ });
392
+ }
393
+ return persisted;
394
+ })();
395
+ this.finalizations.set(job.id, finalization);
396
+ try {
397
+ return await finalization;
398
+ }
399
+ finally {
400
+ this.finalizations.delete(job.id);
401
+ }
402
+ }
199
403
  /** Reconcile a restored process that no longer has a ChildProcess close event. */
200
404
  refreshJobLiveness(job) {
201
405
  if (!this.isLive(job) || this.processes.has(job.id))
@@ -210,7 +414,19 @@ export class JobManager {
210
414
  job.status = "lost";
211
415
  job.endedAt = new Date().toISOString();
212
416
  this.clearJobDeadline(job.id);
213
- void this.persist();
417
+ const completion = this.ensureCompletionNotification(job);
418
+ this.pruneTerminalJobs();
419
+ if (!this.persistSync())
420
+ this.scheduleRegistryRetry();
421
+ this.emit({ type: "job", jobId: job.id });
422
+ if ((completion.created || completion.updated) &&
423
+ completion.notification) {
424
+ this.emit({
425
+ type: "notification",
426
+ jobId: job.id,
427
+ notificationId: completion.notification.id,
428
+ });
429
+ }
214
430
  }
215
431
  /** Arm or restore a persisted finite-job deadline. */
216
432
  scheduleJobDeadline(job) {
@@ -240,34 +456,37 @@ export class JobManager {
240
456
  this.abortControllers.set(id, ac);
241
457
  if (child)
242
458
  this.processes.set(id, child);
243
- // Ephemeral tool-track rows must not bloat registry-v1.json (was the
244
- // root cause of 1000+ fake "jobs" and multi-GB RAM).
245
459
  if (this.isDurable(tracked))
246
- void this.persist();
460
+ this.persistSync();
461
+ this.emit({ type: "job", jobId: id });
247
462
  }
248
463
  updateJobStatus(id, status, exitCode) {
249
464
  const job = this.jobs.get(id);
250
465
  if (!job)
251
466
  return;
467
+ if (this.isDurable(job) && this.isTerminalStatus(status)) {
468
+ void this.finalizeJob(job, status, { ...(exitCode !== undefined ? { exitCode } : {}) });
469
+ return;
470
+ }
252
471
  job.status = status;
253
472
  if (exitCode !== undefined)
254
473
  job.exitCode = exitCode;
255
- if (!["running", "starting", "stopping"].includes(status)) {
474
+ if (!this.isLive(job))
256
475
  job.endedAt = new Date().toISOString();
257
- }
258
476
  this.abortControllers.delete(id);
259
477
  this.processes.delete(id);
260
478
  if (!this.isLive(job))
261
479
  this.clearJobDeadline(id);
262
- // Drop finished tool-track rows immediately — they are not background jobs.
263
480
  if (!this.isDurable(job) && !this.isLive(job)) {
264
481
  this.jobs.delete(id);
482
+ this.emit({ type: "job", jobId: id });
265
483
  return;
266
484
  }
267
485
  if (this.isDurable(job)) {
268
486
  this.pruneTerminalJobs();
269
- void this.persist();
487
+ this.persistSync();
270
488
  }
489
+ this.emit({ type: "job", jobId: id });
271
490
  }
272
491
  async startJob(command, options) {
273
492
  if (options?.authorization?.expiresAt) {
@@ -312,6 +531,13 @@ export class JobManager {
312
531
  const stderrArtifact = join(this.jobsDir, `${prefix}.stderr.log`);
313
532
  const makeReceipt = (path) => ({ path, chunks: [], bytes: 0, droppedBytes: 0, redacted: false, sha256: "" });
314
533
  const safeDisplay = redactSecrets(commandDisplay(command));
534
+ const monitor = options?.monitor !== undefined || options?.profile !== undefined || options?.estimatedSeconds !== undefined
535
+ ? {
536
+ ...(options?.monitor ?? {}),
537
+ ...(options?.profile !== undefined ? { profile: options.profile } : {}),
538
+ ...(options?.estimatedSeconds !== undefined ? { estimatedSeconds: options.estimatedSeconds } : {}),
539
+ }
540
+ : undefined;
315
541
  const job = {
316
542
  id,
317
543
  command: safeDisplay,
@@ -327,6 +553,11 @@ export class JobManager {
327
553
  ownerSessionId: options?.ownerSessionId ?? "unknown",
328
554
  kind: "durable",
329
555
  ...(options?.name ? { name: options.name } : {}),
556
+ ...(options?.taskId ? { taskId: options.taskId } : {}),
557
+ ...(options?.parentTaskId ? { parentTaskId: options.parentTaskId } : {}),
558
+ ...(options?.wakeOnCompletion !== undefined ? { wakeOnCompletion: options.wakeOnCompletion } : {}),
559
+ ...(options?.responder !== undefined ? { responder: options.responder } : {}),
560
+ ...(monitor !== undefined ? { monitor } : {}),
330
561
  ...(options?.timeoutMs !== undefined
331
562
  ? {
332
563
  timeoutAt: new Date(Date.now() + Math.max(1_000, Math.floor(options.timeoutMs))).toISOString(),
@@ -336,7 +567,18 @@ export class JobManager {
336
567
  };
337
568
  this.jobs.set(id, job);
338
569
  this.pruneTerminalJobs();
339
- await this.persist();
570
+ try {
571
+ await this.persist();
572
+ }
573
+ catch {
574
+ this.jobs.delete(id);
575
+ return {
576
+ ok: false,
577
+ output: "Background command launch error [PERSIST_FAILED]: could not persist the job registry; the command was not started.",
578
+ exitCode: 1,
579
+ };
580
+ }
581
+ this.emit({ type: "job", jobId: id });
340
582
  let launchConfirmed = false;
341
583
  try {
342
584
  const detached = process.platform !== "win32";
@@ -356,6 +598,7 @@ export class JobManager {
356
598
  let child = spawnChild();
357
599
  const stdout = new RotatingRedactedWriter(job.artifacts.stdout);
358
600
  const stderr = new RotatingRedactedWriter(job.artifacts.stderr);
601
+ this.writers.set(id, { stdout, stderr });
359
602
  stdout.append(`$ ${job.commandDisplay}\n\n`);
360
603
  // A ChildProcess object is returned before the OS confirms launch. Wait
361
604
  // for `spawn` (or `error`) so shell.start never reports a phantom running
@@ -399,12 +642,7 @@ export class JobManager {
399
642
  `${fields.join("; ")}\n` +
400
643
  "The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd.";
401
644
  stderr.append(`${detail}\n`);
402
- stdout.close();
403
- stderr.close();
404
- job.status = "failed";
405
- job.exitCode = 127;
406
- job.endedAt = new Date().toISOString();
407
- await this.persist();
645
+ await this.finalizeJob(job, "failed", { exitCode: 127 });
408
646
  return {
409
647
  ok: false,
410
648
  output: detail,
@@ -438,46 +676,88 @@ export class JobManager {
438
676
  : undefined;
439
677
  expiryTimer?.unref?.();
440
678
  this.scheduleJobDeadline(job);
441
- child.stdout?.on("data", (chunk) => { stdout.append(chunk); job.heartbeatAt = new Date().toISOString(); void this.persist(); });
442
- child.stderr?.on("data", (chunk) => { stderr.append(chunk); job.heartbeatAt = new Date().toISOString(); void this.persist(); });
679
+ let lastProgressFlush = 0;
680
+ let progressDirty = false;
681
+ let progressTimer;
682
+ const flushProgress = () => {
683
+ progressDirty = false;
684
+ lastProgressFlush = Date.now();
685
+ this.persistSync();
686
+ this.emit({ type: "job", jobId: id });
687
+ };
688
+ // Coalesce high-frequency stdout/stderr chunks: a chatty job (scanner,
689
+ // dev server) must not force a registry write + UI rerender per data
690
+ // event. Flush at most every PROGRESS_FLUSH_MS, with a trailing flush so
691
+ // the last chunk before a quiet gap is never dropped. Terminal
692
+ // finalize/close always persists authoritatively and clears the timer.
693
+ const scheduleProgressFlush = () => {
694
+ const elapsed = Date.now() - lastProgressFlush;
695
+ if (elapsed >= PROGRESS_FLUSH_MS) {
696
+ if (progressTimer) {
697
+ clearTimeout(progressTimer);
698
+ progressTimer = undefined;
699
+ }
700
+ flushProgress();
701
+ return;
702
+ }
703
+ progressDirty = true;
704
+ if (progressTimer)
705
+ return;
706
+ progressTimer = setTimeout(() => {
707
+ progressTimer = undefined;
708
+ if (progressDirty)
709
+ flushProgress();
710
+ }, PROGRESS_FLUSH_MS - elapsed);
711
+ progressTimer.unref?.();
712
+ };
713
+ const stopProgressFlush = () => {
714
+ if (progressTimer) {
715
+ clearTimeout(progressTimer);
716
+ progressTimer = undefined;
717
+ }
718
+ progressDirty = false;
719
+ };
720
+ const recordOutput = (writer, chunk) => {
721
+ writer.append(chunk);
722
+ job.heartbeatAt = new Date().toISOString();
723
+ scheduleProgressFlush();
724
+ };
725
+ child.stdout?.on("data", (chunk) => recordOutput(stdout, chunk));
726
+ child.stderr?.on("data", (chunk) => recordOutput(stderr, chunk));
443
727
  child.on("close", (code, signal) => {
444
728
  if (expiryTimer)
445
729
  clearTimeout(expiryTimer);
446
- this.clearJobDeadline(id);
447
- job.exitCode = code ?? undefined;
448
- job.signal = signal ?? undefined;
449
- job.status = signal ? "killed" : code === 0 ? "exited" : "failed";
450
- job.endedAt = new Date().toISOString();
451
- stdout.close();
452
- stderr.close();
453
- this.processes.delete(id);
454
- this.writers.delete(id);
455
- void this.persist();
730
+ stopProgressFlush();
731
+ const status = signal ? "killed" : code === 0 ? "exited" : "failed";
732
+ void this.finalizeJob(job, status, {
733
+ ...(code !== null ? { exitCode: code } : {}),
734
+ ...(signal !== null ? { signal } : {}),
735
+ });
456
736
  });
457
737
  child.on("error", (error) => {
458
738
  if (expiryTimer)
459
739
  clearTimeout(expiryTimer);
460
- this.clearJobDeadline(id);
740
+ stopProgressFlush();
461
741
  const code = error.code ?? "UNKNOWN";
462
- stderr.append(`Background process error [${code}]: ${error.message}\n`);
463
- job.status = "failed";
464
- job.exitCode = 127;
465
- job.endedAt = new Date().toISOString();
466
- stdout.close();
467
- stderr.close();
468
- this.processes.delete(id);
469
- this.writers.delete(id);
470
- void this.persist();
742
+ try {
743
+ stderr.append(`Background process error [${code}]: ${error.message}\n`);
744
+ }
745
+ catch { /* finalization already owns the writers */ }
746
+ void this.finalizeJob(job, "failed", { exitCode: 127 });
471
747
  });
748
+ this.persistSync();
749
+ this.emit({ type: "job", jobId: id });
472
750
  // Best-effort detection for shell-level failures such as command-not-found
473
751
  // immediately after the command shell launches. This bounded window is
474
752
  // not an application readiness guarantee; callers must tail/probe.
753
+ let closedEarly = false;
475
754
  await new Promise((resolve) => {
476
755
  if (!this.isLive(job)) {
477
756
  resolve();
478
757
  return;
479
758
  }
480
759
  const onClose = () => {
760
+ closedEarly = true;
481
761
  clearTimeout(timer);
482
762
  resolve();
483
763
  };
@@ -487,6 +767,11 @@ export class JobManager {
487
767
  }, 30);
488
768
  child.once("close", onClose);
489
769
  });
770
+ if (closedEarly) {
771
+ const finalization = this.finalizations.get(id);
772
+ if (finalization)
773
+ await finalization;
774
+ }
490
775
  const earlyStatus = this.getJob(id)?.status;
491
776
  if (earlyStatus === "failed") {
492
777
  await this.persist();
@@ -528,12 +813,14 @@ export class JobManager {
528
813
  const detail = error instanceof Error ? error.message : String(error);
529
814
  if (launchConfirmed) {
530
815
  const alive = processAlive(job.pid);
531
- job.status = alive ? "running" : "failed";
532
- if (!alive) {
533
- job.exitCode ??= 1;
534
- job.endedAt ??= new Date().toISOString();
816
+ if (!alive && this.isLive(job)) {
817
+ await this.finalizeJob(job, "failed", { exitCode: job.exitCode ?? 1 });
818
+ }
819
+ else if (alive) {
820
+ job.status = "running";
821
+ this.persistSync();
822
+ this.emit({ type: "job", jobId: id });
535
823
  }
536
- await this.persist().catch(() => undefined);
537
824
  return {
538
825
  ok: alive,
539
826
  output: `Background command launched as pid=${job.pid ?? "?"}, but job setup/persistence failed: ${detail}\n` +
@@ -549,10 +836,7 @@ export class JobManager {
549
836
  },
550
837
  };
551
838
  }
552
- job.status = "failed";
553
- job.exitCode = 127;
554
- job.endedAt = new Date().toISOString();
555
- await this.persist().catch(() => undefined);
839
+ await this.finalizeJob(job, "failed", { exitCode: 127 });
556
840
  return {
557
841
  ok: false,
558
842
  output: `Background command launch error [UNKNOWN]: ${detail}\n` +
@@ -588,16 +872,12 @@ export class JobManager {
588
872
  const shown = ordered.slice(0, LIST_JOBS_MAX_LINES);
589
873
  const omitted = ordered.length - shown.length;
590
874
  const format = (job) => {
591
- const elapsedEnd = job.endedAt
592
- ? new Date(job.endedAt).getTime()
593
- : Date.now();
594
- const elapsed = Math.max(0, Math.round((elapsedEnd - new Date(job.startedAt).getTime()) / 1000));
595
875
  const health = this.isLive(job)
596
876
  ? processAlive(job.pid)
597
877
  ? "alive"
598
878
  : "unresponsive"
599
879
  : "terminal";
600
- return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"} ${elapsed}s ${job.commandDisplay.slice(0, 80)}`;
880
+ return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"} ${formatJobElapsed(job)} ${job.commandDisplay.slice(0, 80)}`;
601
881
  };
602
882
  const header = sessionId
603
883
  ? `Session background jobs (${durable.length} total, session ${sessionId}):`
@@ -637,6 +917,133 @@ export class JobManager {
637
917
  this.refreshJobLiveness(job);
638
918
  return job;
639
919
  }
920
+ getPendingNotifications(sessionId) {
921
+ return [...this.notifications.values()]
922
+ .filter((notification) => !notification.acknowledgedAt &&
923
+ (!sessionId || notification.ownerSessionId === sessionId))
924
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
925
+ }
926
+ pendingNotifications(sessionId) {
927
+ return this.getPendingNotifications(sessionId);
928
+ }
929
+ markDelivered(notificationId) {
930
+ const notification = this.notifications.get(notificationId);
931
+ if (!notification)
932
+ return false;
933
+ if (!notification.deliveredAt) {
934
+ const deliveredAt = new Date().toISOString();
935
+ notification.deliveredAt = deliveredAt;
936
+ if (!this.persistSync()) {
937
+ notification.deliveredAt = undefined;
938
+ return false;
939
+ }
940
+ this.emit({ type: "notification", jobId: notification.jobId, notificationId });
941
+ }
942
+ return true;
943
+ }
944
+ markAnalyzed(notificationId) {
945
+ const notification = this.notifications.get(notificationId);
946
+ if (!notification?.deliveredAt)
947
+ return false;
948
+ if (!notification.analyzedAt) {
949
+ notification.analyzedAt = new Date().toISOString();
950
+ if (!this.persistSync()) {
951
+ notification.analyzedAt = undefined;
952
+ return false;
953
+ }
954
+ this.emit({
955
+ type: "notification",
956
+ jobId: notification.jobId,
957
+ notificationId,
958
+ });
959
+ }
960
+ return true;
961
+ }
962
+ acknowledge(notificationId) {
963
+ const notification = this.notifications.get(notificationId);
964
+ if (!notification?.analyzedAt)
965
+ return false;
966
+ if (!notification.acknowledgedAt) {
967
+ notification.acknowledgedAt = new Date().toISOString();
968
+ if (!this.persistSync()) {
969
+ notification.acknowledgedAt = undefined;
970
+ return false;
971
+ }
972
+ this.pruneTerminalJobs();
973
+ this.persistSync();
974
+ this.emit({ type: "notification", jobId: notification.jobId, notificationId });
975
+ }
976
+ return true;
977
+ }
978
+ linkJob(jobId, metadata) {
979
+ const resolved = this.resolveJobId(jobId);
980
+ const job = resolved ? this.jobs.get(resolved) : undefined;
981
+ if (!job || !this.isDurable(job))
982
+ return undefined;
983
+ const previousJob = {
984
+ taskId: job.taskId,
985
+ parentTaskId: job.parentTaskId,
986
+ wakeOnCompletion: job.wakeOnCompletion,
987
+ monitor: job.monitor,
988
+ responder: job.responder,
989
+ };
990
+ const existingNotification = this.notificationForJob(job.id);
991
+ const previousNotification = existingNotification
992
+ ? {
993
+ taskId: existingNotification.taskId,
994
+ parentTaskId: existingNotification.parentTaskId,
995
+ wakeOnCompletion: existingNotification.wakeOnCompletion,
996
+ monitor: existingNotification.monitor,
997
+ responder: existingNotification.responder,
998
+ }
999
+ : undefined;
1000
+ if (metadata.taskId !== undefined)
1001
+ job.taskId = metadata.taskId;
1002
+ if (metadata.parentTaskId !== undefined)
1003
+ job.parentTaskId = metadata.parentTaskId;
1004
+ if (metadata.wakeOnCompletion !== undefined)
1005
+ job.wakeOnCompletion = metadata.wakeOnCompletion;
1006
+ if (metadata.monitor !== undefined)
1007
+ job.monitor = metadata.monitor;
1008
+ if (metadata.responder !== undefined)
1009
+ job.responder = metadata.responder;
1010
+ const completion = this.ensureCompletionNotification(job);
1011
+ if (completion.notification) {
1012
+ if (metadata.taskId !== undefined)
1013
+ completion.notification.taskId = metadata.taskId;
1014
+ if (metadata.parentTaskId !== undefined)
1015
+ completion.notification.parentTaskId = metadata.parentTaskId;
1016
+ if (metadata.wakeOnCompletion !== undefined)
1017
+ completion.notification.wakeOnCompletion = metadata.wakeOnCompletion;
1018
+ if (metadata.monitor !== undefined)
1019
+ completion.notification.monitor = metadata.monitor;
1020
+ if (metadata.responder !== undefined)
1021
+ completion.notification.responder = metadata.responder;
1022
+ }
1023
+ if (!this.persistSync()) {
1024
+ job.taskId = previousJob.taskId;
1025
+ job.parentTaskId = previousJob.parentTaskId;
1026
+ job.wakeOnCompletion = previousJob.wakeOnCompletion;
1027
+ job.monitor = previousJob.monitor;
1028
+ job.responder = previousJob.responder;
1029
+ if (completion.created && completion.notification) {
1030
+ this.notifications.delete(completion.notification.id);
1031
+ }
1032
+ else if (completion.notification && previousNotification) {
1033
+ completion.notification.taskId = previousNotification.taskId;
1034
+ completion.notification.parentTaskId = previousNotification.parentTaskId;
1035
+ completion.notification.wakeOnCompletion = previousNotification.wakeOnCompletion;
1036
+ completion.notification.monitor = previousNotification.monitor;
1037
+ completion.notification.responder = previousNotification.responder;
1038
+ }
1039
+ return undefined;
1040
+ }
1041
+ this.emit({ type: "job", jobId: job.id });
1042
+ if (completion.notification) {
1043
+ this.emit({ type: "notification", jobId: job.id, notificationId: completion.notification.id });
1044
+ }
1045
+ return job;
1046
+ }
640
1047
  async tailJob(id, bytesOrCursor) {
641
1048
  const resolved = this.resolveJobId(id);
642
1049
  const job = resolved ? this.jobs.get(resolved) : undefined;
@@ -720,66 +1127,235 @@ export class JobManager {
720
1127
  if (!job || !resolved)
721
1128
  return { ok: false, output: `Job "${id}" not found.`, exitCode: 1 };
722
1129
  id = resolved;
723
- if (job.status !== "running" && job.status !== "starting")
1130
+ const inFlightFinalization = this.finalizations.get(id);
1131
+ if (inFlightFinalization) {
1132
+ const persisted = await inFlightFinalization;
1133
+ return persisted
1134
+ ? { ok: true, output: `Job "${id}" completed while stop was requested.` }
1135
+ : { ok: false, output: `Job "${id}" completed, but its terminal state could not be persisted.`, exitCode: 1 };
1136
+ }
1137
+ if (!this.isLive(job))
724
1138
  return { ok: false, output: `Job "${id}" is already ${job.status}.`, exitCode: 1 };
1139
+ const pid = job.pid;
1140
+ if (!pid)
1141
+ return { ok: false, output: `Job "${id}" has no process id to stop.`, exitCode: 1 };
1142
+ const processGroupId = process.platform !== "win32" ? job.processGroupId : undefined;
1143
+ const targetAlive = () => {
1144
+ if (!processGroupId)
1145
+ return processAlive(pid);
1146
+ try {
1147
+ process.kill(-processGroupId, 0);
1148
+ return true;
1149
+ }
1150
+ catch (error) {
1151
+ return error.code === "EPERM";
1152
+ }
1153
+ };
1154
+ const identityMatches = () => {
1155
+ if (process.platform === "win32")
1156
+ return processAlive(pid);
1157
+ const current = processIdentity(pid);
1158
+ return Boolean(current && job.processIdentity && current === job.processIdentity);
1159
+ };
1160
+ if (targetAlive() && (!processAlive(pid) || !identityMatches())) {
1161
+ return {
1162
+ ok: false,
1163
+ output: `Refused to signal job "${id}": its persisted process identity no longer matches pid ${pid}.`,
1164
+ exitCode: 1,
1165
+ };
1166
+ }
1167
+ const child = this.processes.get(id);
1168
+ const childClose = child
1169
+ ? new Promise((resolve) => child.once("close", () => resolve()))
1170
+ : undefined;
725
1171
  this.clearJobDeadline(id);
1172
+ const previousStatus = job.status;
1173
+ const previousWakeOnCompletion = job.wakeOnCompletion;
1174
+ if (options?.suppressWake)
1175
+ job.wakeOnCompletion = false;
726
1176
  job.status = "stopping";
727
- await this.persist();
1177
+ if (!this.persistSync()) {
1178
+ job.status = previousStatus;
1179
+ job.wakeOnCompletion = previousWakeOnCompletion;
1180
+ if (!job.timeoutAt || Date.parse(job.timeoutAt) > Date.now())
1181
+ this.scheduleJobDeadline(job);
1182
+ return { ok: false, output: `Failed to persist stop state for job "${id}"; no signal was sent.`, exitCode: 1 };
1183
+ }
1184
+ this.emit({ type: "job", jobId: id });
728
1185
  this.abortControllers.get(id)?.abort();
729
- const pid = job.pid;
730
- const send = (signal) => {
731
- if (!pid)
732
- return false;
1186
+ let processGroupVerified = false;
1187
+ const send = (signal, allowVerifiedGroup) => {
1188
+ if (!targetAlive())
1189
+ return "gone";
1190
+ if (processAlive(pid) && identityMatches()) {
1191
+ processGroupVerified = true;
1192
+ }
1193
+ else if (!(processGroupId && processGroupVerified && allowVerifiedGroup)) {
1194
+ return "identity-mismatch";
1195
+ }
733
1196
  try {
734
- process.platform !== "win32" && job.processGroupId ? process.kill(-job.processGroupId, signal) : process.kill(pid, signal);
735
- return true;
1197
+ processGroupId ? process.kill(-processGroupId, signal) : process.kill(pid, signal);
1198
+ return "sent";
736
1199
  }
737
1200
  catch {
738
- return !processAlive(pid);
1201
+ return targetAlive() ? "failed" : "gone";
739
1202
  }
740
1203
  };
741
- const graceful = options?.signal ?? "SIGTERM";
742
- if (!send(graceful) && processAlive(pid)) {
1204
+ const restoreRunning = () => {
743
1205
  job.status = "running";
744
1206
  if (!job.timeoutAt || Date.parse(job.timeoutAt) > Date.now())
745
1207
  this.scheduleJobDeadline(job);
746
- await this.persist();
747
- return { ok: false, output: `Failed to signal job "${id}".`, exitCode: 1 };
1208
+ this.persistSync();
1209
+ this.emit({ type: "job", jobId: id });
1210
+ };
1211
+ const graceful = options?.signal ?? "SIGTERM";
1212
+ const firstSignal = send(graceful, false);
1213
+ if (firstSignal === "identity-mismatch" || firstSignal === "failed") {
1214
+ restoreRunning();
1215
+ return {
1216
+ ok: false,
1217
+ output: firstSignal === "identity-mismatch"
1218
+ ? `Refused to signal job "${id}": process identity changed immediately before stop.`
1219
+ : `Failed to signal job "${id}".`,
1220
+ exitCode: 1,
1221
+ };
748
1222
  }
749
1223
  const waitForExit = async (ms) => {
750
1224
  const deadline = Date.now() + ms;
751
1225
  while (Date.now() < deadline) {
752
- if (!processAlive(pid))
1226
+ if (!targetAlive())
753
1227
  return true;
754
1228
  await new Promise((resolve) => setTimeout(resolve, 25));
755
1229
  }
756
- return !processAlive(pid);
1230
+ return !targetAlive();
757
1231
  };
758
- let stopped = await waitForExit(options?.graceMs ?? 2_000);
1232
+ let stopped = firstSignal === "gone" || await waitForExit(options?.graceMs ?? 2_000);
759
1233
  let actualSignal = graceful;
760
1234
  if (!stopped && options?.escalate !== false) {
761
1235
  actualSignal = "SIGKILL";
762
- send(actualSignal);
763
- stopped = await waitForExit(1_000);
1236
+ const escalated = send(actualSignal, true);
1237
+ if (escalated === "identity-mismatch" || escalated === "failed") {
1238
+ restoreRunning();
1239
+ return {
1240
+ ok: false,
1241
+ output: escalated === "identity-mismatch"
1242
+ ? `Refused to escalate job "${id}": process identity changed before SIGKILL.`
1243
+ : `Failed to escalate job "${id}" with SIGKILL.`,
1244
+ exitCode: 1,
1245
+ };
1246
+ }
1247
+ stopped = escalated === "gone" || await waitForExit(1_000);
764
1248
  }
765
1249
  if (!stopped) {
766
- job.status = "running";
767
- if (!job.timeoutAt || Date.parse(job.timeoutAt) > Date.now())
768
- this.scheduleJobDeadline(job);
769
- await this.persist();
1250
+ restoreRunning();
770
1251
  return { ok: false, output: `Job "${id}" remains alive after ${actualSignal}.`, exitCode: 1 };
771
1252
  }
772
- job.status = "killed";
773
- job.signal = actualSignal;
774
- job.endedAt = new Date().toISOString();
775
- this.abortControllers.delete(id);
776
- this.processes.delete(id);
777
- this.writers.get(id)?.stdout.close();
778
- this.writers.get(id)?.stderr.close();
779
- this.writers.delete(id);
780
- await this.persist();
1253
+ if (childClose)
1254
+ await childClose;
1255
+ let terminalPersisted = true;
1256
+ const eventFinalization = this.finalizations.get(id);
1257
+ if (eventFinalization)
1258
+ terminalPersisted = await eventFinalization;
1259
+ if (this.isLive(job)) {
1260
+ terminalPersisted = await this.finalizeJob(job, "killed", { signal: actualSignal });
1261
+ }
1262
+ if (!terminalPersisted) {
1263
+ return {
1264
+ ok: false,
1265
+ output: `Job "${id}" stopped, but its terminal state and completion notification could not be persisted.`,
1266
+ exitCode: 1,
1267
+ };
1268
+ }
781
1269
  return { ok: true, output: `Job "${id}" stopped and termination verified (${actualSignal}).` };
782
1270
  }
1271
+ async cancelAll(sessionId) {
1272
+ if (!sessionId) {
1273
+ return { ok: false, output: "cancelAll requires a non-empty session id.", exitCode: 1 };
1274
+ }
1275
+ const targets = [...this.jobs.values()].filter((job) => this.isDurable(job) && job.ownerSessionId === sessionId && this.isLive(job));
1276
+ const priorWake = new Map(targets.map((job) => [job.id, job.wakeOnCompletion]));
1277
+ for (const job of targets) {
1278
+ job.wakeOnCompletion = false;
1279
+ const notification = this.notificationForJob(job.id);
1280
+ if (notification)
1281
+ notification.wakeOnCompletion = false;
1282
+ }
1283
+ if (!this.persistSync()) {
1284
+ for (const job of targets)
1285
+ job.wakeOnCompletion = priorWake.get(job.id);
1286
+ return {
1287
+ ok: false,
1288
+ output: `Failed to persist wake suppression for session ${sessionId}; no jobs were cancelled.`,
1289
+ exitCode: 1,
1290
+ };
1291
+ }
1292
+ for (const job of targets)
1293
+ this.emit({ type: "job", jobId: job.id });
1294
+ const outcomes = await Promise.all(targets.map(async (job) => {
1295
+ const current = this.jobs.get(job.id);
1296
+ if (!current || !this.isLive(current))
1297
+ return { id: job.id, ok: true, output: "already terminal" };
1298
+ try {
1299
+ const result = await this.stopJob(job.id, { suppressWake: true });
1300
+ return { id: job.id, ok: result.ok, output: result.output };
1301
+ }
1302
+ catch (error) {
1303
+ return { id: job.id, ok: false, output: error instanceof Error ? error.message : String(error) };
1304
+ }
1305
+ }));
1306
+ const acknowledgedAt = new Date().toISOString();
1307
+ const notificationSnapshots = [];
1308
+ for (const notification of this.notifications.values()) {
1309
+ if (notification.ownerSessionId !== sessionId || notification.acknowledgedAt)
1310
+ continue;
1311
+ notificationSnapshots.push({
1312
+ notification,
1313
+ wakeOnCompletion: notification.wakeOnCompletion,
1314
+ deliveredAt: notification.deliveredAt,
1315
+ acknowledgedAt: notification.acknowledgedAt,
1316
+ });
1317
+ notification.wakeOnCompletion = false;
1318
+ notification.deliveredAt ??= acknowledgedAt;
1319
+ notification.acknowledgedAt = acknowledgedAt;
1320
+ }
1321
+ const acknowledgementsPersisted = this.persistSync();
1322
+ if (!acknowledgementsPersisted) {
1323
+ for (const snapshot of notificationSnapshots) {
1324
+ snapshot.notification.wakeOnCompletion = snapshot.wakeOnCompletion;
1325
+ snapshot.notification.deliveredAt = snapshot.deliveredAt;
1326
+ snapshot.notification.acknowledgedAt = snapshot.acknowledgedAt;
1327
+ }
1328
+ }
1329
+ else {
1330
+ for (const snapshot of notificationSnapshots) {
1331
+ this.emit({
1332
+ type: "notification",
1333
+ jobId: snapshot.notification.jobId,
1334
+ notificationId: snapshot.notification.id,
1335
+ });
1336
+ }
1337
+ this.pruneTerminalJobs();
1338
+ this.persistSync();
1339
+ }
1340
+ const failures = outcomes.filter((outcome) => !outcome.ok);
1341
+ if (failures.length > 0 || !acknowledgementsPersisted) {
1342
+ const details = failures.map((failure) => `[${failure.id}] ${failure.output}`);
1343
+ if (!acknowledgementsPersisted)
1344
+ details.push("Completion notifications could not be durably acknowledged.");
1345
+ return {
1346
+ ok: false,
1347
+ output: `Cancelled ${outcomes.length - failures.length}/${outcomes.length} background job(s) for session ${sessionId}; ` +
1348
+ `${details.length} failure(s):\n${details.join("\n")}`,
1349
+ exitCode: 1,
1350
+ };
1351
+ }
1352
+ return {
1353
+ ok: true,
1354
+ output: targets.length === 0
1355
+ ? `No running background jobs for session ${sessionId}; pending notifications were acknowledged.`
1356
+ : `Cancelled ${targets.length} background job(s) for session ${sessionId}; wake notifications were suppressed and acknowledged.`,
1357
+ };
1358
+ }
783
1359
  getRunningJobs(sessionId) {
784
1360
  for (const job of this.jobs.values())
785
1361
  this.refreshJobLiveness(job);
@@ -798,6 +1374,13 @@ export class JobManager {
798
1374
  /** Drop stale terminal durable jobs and all leftover ephemeral rows. */
799
1375
  pruneTerminalJobs() {
800
1376
  const now = Date.now();
1377
+ for (const [id, notification] of this.notifications) {
1378
+ const job = this.jobs.get(notification.jobId);
1379
+ if (!job ||
1380
+ (notification.acknowledgedAt && job.status !== "lost")) {
1381
+ this.notifications.delete(id);
1382
+ }
1383
+ }
801
1384
  const durableTerminal = [];
802
1385
  for (const [id, job] of this.jobs) {
803
1386
  if (!this.isDurable(job)) {
@@ -808,6 +1391,9 @@ export class JobManager {
808
1391
  }
809
1392
  if (this.isLive(job))
810
1393
  continue;
1394
+ const notification = this.notificationForJob(job.id);
1395
+ if (notification && !notification.acknowledgedAt)
1396
+ continue;
811
1397
  const ended = job.endedAt ? Date.parse(job.endedAt) : Date.parse(job.startedAt);
812
1398
  if (Number.isFinite(ended) && now - ended > TERMINAL_JOB_MAX_AGE_MS) {
813
1399
  this.jobs.delete(id);
@@ -824,17 +1410,34 @@ export class JobManager {
824
1410
  }
825
1411
  loadAndReconcile() {
826
1412
  try {
827
- if (!existsSync(this.registryPath))
1413
+ let parsed;
1414
+ const sourcePath = existsSync(this.registryPath)
1415
+ ? this.registryPath
1416
+ : this.transientV2RegistryPath;
1417
+ if (!existsSync(sourcePath))
828
1418
  return;
829
- const parsed = JSON.parse(readFileSync(this.registryPath, "utf8"));
830
- if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.jobs))
1419
+ try {
1420
+ const candidate = JSON.parse(readFileSync(sourcePath, "utf8"));
1421
+ if ((candidate.schemaVersion === 1 || candidate.schemaVersion === 2) && Array.isArray(candidate.jobs)) {
1422
+ parsed = candidate;
1423
+ }
1424
+ }
1425
+ catch { /* a corrupt current registry must not replay stale v1 state */ }
1426
+ if (!parsed)
831
1427
  return;
1428
+ if (parsed.schemaVersion === 2 && Array.isArray(parsed.notifications)) {
1429
+ const notifiedJobs = new Set();
1430
+ for (const notification of parsed.notifications) {
1431
+ if (!notification || typeof notification.id !== "string" || typeof notification.jobId !== "string")
1432
+ continue;
1433
+ if (notifiedJobs.has(notification.jobId))
1434
+ continue;
1435
+ notifiedJobs.add(notification.jobId);
1436
+ this.notifications.set(notification.id, notification);
1437
+ }
1438
+ }
832
1439
  for (const job of parsed.jobs) {
833
- // Historical pollution: tool-stall rows were persisted as if they were
834
- // background jobs (command like "fs.list /path"). Drop them on load.
835
- if (job.kind === "ephemeral")
836
- continue;
837
- if (looksLikeEphemeralToolTrack(job))
1440
+ if (job.kind === "ephemeral" || looksLikeEphemeralToolTrack(job))
838
1441
  continue;
839
1442
  job.kind = "durable";
840
1443
  if (["starting", "running", "stopping"].includes(job.status)) {
@@ -844,39 +1447,58 @@ export class JobManager {
844
1447
  job.endedAt = new Date().toISOString();
845
1448
  }
846
1449
  else {
847
- // The process identity is still verified. Restore any persisted
848
- // deadline after insertion; an already-expired deadline schedules
849
- // immediate stopJob(), which verifies termination before marking it.
850
1450
  job.status = "running";
851
1451
  job.heartbeatAt = new Date().toISOString();
852
1452
  }
853
1453
  }
854
1454
  this.jobs.set(job.id, job);
1455
+ this.ensureCompletionNotification(job);
855
1456
  this.scheduleJobDeadline(job);
856
1457
  }
1458
+ for (const [id, notification] of this.notifications) {
1459
+ const job = this.jobs.get(notification.jobId);
1460
+ if (!job?.responder)
1461
+ this.notifications.delete(id);
1462
+ }
857
1463
  this.pruneTerminalJobs();
858
1464
  this.persistSync();
859
1465
  }
860
1466
  catch { /* Corrupt registries are ignored, never trusted as running. */ }
861
1467
  }
862
- /** Only durable jobs are written to disk. */
1468
+ /** Only durable jobs and responder notifications are written to disk. */
863
1469
  registry() {
864
1470
  return {
865
- schemaVersion: 1,
866
- jobs: [...this.jobs.values()].filter((j) => this.isDurable(j)),
1471
+ schemaVersion: 2,
1472
+ jobs: [...this.jobs.values()].filter((job) => this.isDurable(job)),
1473
+ notifications: [...this.notifications.values()],
867
1474
  };
868
1475
  }
1476
+ scheduleRegistryRetry() {
1477
+ if (this.registryRetryTimer)
1478
+ return;
1479
+ const timer = setTimeout(() => {
1480
+ this.registryRetryTimer = undefined;
1481
+ if (!this.persistSync())
1482
+ this.scheduleRegistryRetry();
1483
+ }, 250);
1484
+ timer.unref?.();
1485
+ this.registryRetryTimer = timer;
1486
+ }
869
1487
  persistSync() {
870
1488
  try {
871
1489
  mkdirSync(this.jobsDir, { recursive: true });
872
1490
  const temp = `${this.registryPath}.${process.pid}.${randomUUID()}.tmp`;
873
1491
  writeFileSync(temp, `${JSON.stringify(this.registry(), null, 2)}\n`, { mode: 0o600 });
874
1492
  renameSync(temp, this.registryPath);
1493
+ return true;
1494
+ }
1495
+ catch {
1496
+ return false;
875
1497
  }
876
- catch { /* best effort during constructor reconciliation */ }
877
1498
  }
878
1499
  async persist() {
879
- this.persistSync();
1500
+ if (!this.persistSync())
1501
+ throw new Error("Failed to persist the background-job registry.");
880
1502
  }
881
1503
  }
882
1504
  export const jobManager = new JobManager();