piher 0.0.0-stage → 0.2.0

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 (60) hide show
  1. package/README.md +60 -2
  2. package/package.json +58 -4
  3. package/schemas/role-manifest.schema.json +62 -0
  4. package/skills/piher-tools/SKILL.md +212 -0
  5. package/src/ansi-text.ts +143 -0
  6. package/src/bg-job-core.ts +412 -0
  7. package/src/bootstrap.ts +102 -0
  8. package/src/config-catalog-core.ts +200 -0
  9. package/src/config-command.ts +324 -0
  10. package/src/cordis-services.ts +38 -0
  11. package/src/core-client.ts +715 -0
  12. package/src/delegation-core.ts +94 -0
  13. package/src/gc-core.ts +150 -0
  14. package/src/guards.ts +6 -0
  15. package/src/hc-templates.ts +134 -0
  16. package/src/index-locks.ts +118 -0
  17. package/src/index-master.ts +92 -0
  18. package/src/index-notices.ts +79 -0
  19. package/src/index-pipe.ts +71 -0
  20. package/src/index-roles.ts +265 -0
  21. package/src/index-runtime.ts +23 -0
  22. package/src/index.ts +460 -0
  23. package/src/ledger.ts +61 -0
  24. package/src/lock-core.ts +200 -0
  25. package/src/manifest-compose.ts +103 -0
  26. package/src/options.ts +78 -0
  27. package/src/pi-surface.ts +140 -0
  28. package/src/pipe-channel.ts +291 -0
  29. package/src/platform-paths.ts +37 -0
  30. package/src/plugins/bg-job.ts +560 -0
  31. package/src/plugins/subagent.ts +1244 -0
  32. package/src/prompt-store.ts +50 -0
  33. package/src/renderers.ts +195 -0
  34. package/src/role-catalog.ts +99 -0
  35. package/src/role-loader.ts +196 -0
  36. package/src/role-manifest.ts +163 -0
  37. package/src/role-state.ts +119 -0
  38. package/src/roles/master.json +23 -0
  39. package/src/roles/worker-default.json +23 -0
  40. package/src/runtime-policy.ts +45 -0
  41. package/src/session-state.ts +19 -0
  42. package/src/session-tail.ts +276 -0
  43. package/src/settle-wake-core.ts +73 -0
  44. package/src/settlement-card.ts +96 -0
  45. package/src/storage-layout.ts +92 -0
  46. package/src/subagent-core.ts +543 -0
  47. package/src/subagent-gc.ts +312 -0
  48. package/src/subagent-poller.ts +484 -0
  49. package/src/subagent-session.ts +548 -0
  50. package/src/subagent-spawn.ts +695 -0
  51. package/src/swallow.ts +59 -0
  52. package/src/task-row.ts +257 -0
  53. package/src/task-store.ts +173 -0
  54. package/src/tool-error.ts +19 -0
  55. package/src/tool-gate.ts +99 -0
  56. package/src/tree-view.ts +58 -0
  57. package/src/vocab.ts +41 -0
  58. package/src/worker-marker.ts +65 -0
  59. package/src/worktree-io.ts +177 -0
  60. package/src/worktree.ts +280 -0
@@ -0,0 +1,560 @@
1
+ /**
2
+ * `bg_job`: one shell command as its own herdr terminal, reported once when it exits.
3
+ *
4
+ * The execution model is a *program*, not a typed shell line: `terminal.run` starts an argv, so core
5
+ * reports the child's `finished` state and its exit code. Nothing is typed into a shell, no marker is
6
+ * scraped from a screen, and no command has to be a single line at a POSIX prompt.
7
+ *
8
+ * Observation is event-driven with a heartbeat: `watchChildren` wakes this loop when the child
9
+ * changes, and each wake verifies with `getTerminal`, so a lost event costs one heartbeat instead of
10
+ * the result. The notice is delivered through pi's message API — the one channel an `hc` CLI cannot
11
+ * offer — with a card, and a check-in that never wakes an idle agent.
12
+ */
13
+ import { mkdir, open } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { Context } from '@deepseek-ai/cordis';
17
+ import { Type } from 'typebox';
18
+ import { cordisGet } from '../cordis-services.ts';
19
+ import {
20
+ childEnvironment,
21
+ CORE_REQUIRED,
22
+ coreUnavailableHint,
23
+ type CoreClientLike,
24
+ type CoreEnv,
25
+ type TerminalInfo,
26
+ } from '../core-client.ts';
27
+ import { withoutPlacement } from '../subagent-core.ts';
28
+ import { toolError } from '../tool-error.ts';
29
+ import { optionValue } from '../options.ts';
30
+ import { swallow } from '../swallow.ts';
31
+ import {
32
+ DEFAULT_TIMEOUT_S,
33
+ JOB_LABEL_KEY,
34
+ JOB_META_LABEL,
35
+ JOB_NOTICE_CUSTOM_TYPE,
36
+ LOG_READ_BYTES,
37
+ chooseTail,
38
+ clampTimeoutS,
39
+ encodeJobMeta,
40
+ expiredDelivered,
41
+ listedJobs,
42
+ formatJobCheckIn,
43
+ formatJobListLine,
44
+ formatJobNotice,
45
+ formatJobStatus,
46
+ jobCoreName,
47
+ jobNoticeDetails,
48
+ nextJobId,
49
+ planAdoption,
50
+ planJobArgv,
51
+ readLogSlice,
52
+ type JobRecord,
53
+ type JobStatus,
54
+ } from '../bg-job-core.ts';
55
+
56
+ /** Panes a job owns; the index GC must never reclaim a running job's terminal. */
57
+ export interface PaneStateSlot {
58
+ activePaneIds: () => Set<string>;
59
+ }
60
+
61
+ export interface BgJobDeps {
62
+ client: CoreClientLike;
63
+ /** This process's own terminal address; null outside a herdr-core terminal. */
64
+ env: CoreEnv | null;
65
+ state: PaneStateSlot;
66
+ }
67
+
68
+ /** Jobs allowed at once in one session: a runaway loop of jobs is worse than a refused start. */
69
+ const MAX_JOBS = 12;
70
+ /** Wake at least this often to re-check a child, so a missed event is never a missed result. */
71
+ const HEARTBEAT_MS = 2_500;
72
+ /** Lines read from the log for a notice; the model can ask for more with `log`. */
73
+ const NOTICE_TAIL_LINES = 20;
74
+
75
+ type ToolParams = Record<string, unknown> | undefined;
76
+
77
+ const BG_JOB_ACTIONS = ['start', 'status', 'log', 'stop', 'list'] as const;
78
+ type BgJobAction = (typeof BG_JOB_ACTIONS)[number];
79
+
80
+ /** Last `mode` fragment of a log: only one end is read, so a multi-GB log never enters memory. */
81
+ async function readLogEnd(path: string, mode: 'head' | 'tail'): Promise<string | null> {
82
+ let handle;
83
+ try {
84
+ handle = await open(path, 'r');
85
+ } catch {
86
+ return null;
87
+ }
88
+ try {
89
+ const { size } = await handle.stat();
90
+ if (size === 0) return '';
91
+ const length = Math.min(size, LOG_READ_BYTES);
92
+ const position = mode === 'head' ? 0 : size - length;
93
+ const buffer = Buffer.alloc(length);
94
+ await handle.read(buffer, 0, length, position);
95
+ return buffer.toString('utf8');
96
+ } finally {
97
+ await handle.close();
98
+ }
99
+ }
100
+
101
+ async function logSize(path: string): Promise<number | null> {
102
+ try {
103
+ const handle = await open(path, 'r');
104
+ try {
105
+ return (await handle.stat()).size;
106
+ } finally {
107
+ await handle.close();
108
+ }
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ export default function bgJobPlugin(ctx: Context): void {
115
+ const surface = cordisGet(ctx, 'piher.surface');
116
+ const { client, env, state } = cordisGet(ctx, 'piher.bg-job-deps');
117
+ const pi = surface.raw as {
118
+ sendMessage?: (
119
+ message: { customType: string; content: string; display?: boolean; details?: unknown },
120
+ opts?: { deliverAs?: string; triggerTurn?: boolean },
121
+ ) => void;
122
+ };
123
+ const scoped = surface.forModule(import.meta.url);
124
+
125
+ const jobs = new Map<string, JobRecord>();
126
+ /** Live watch loops and check-in timers, so a shutdown or HMR stops them. */
127
+ const watches = new Map<string, { cancel: () => void }>();
128
+ const timers = new Map<string, NodeJS.Timeout>();
129
+ let sequence = 0;
130
+ /** Two base-36 characters per plugin instance: unique ids and core names across a reload. */
131
+ const instanceTag = Math.floor(Math.random() * 36 ** 2).toString(36).padStart(2, '0');
132
+
133
+ const logDir = optionValue('PIHER_JOBS_DIR') ?? join(tmpdir(), 'piher-jobs');
134
+
135
+ state.activePaneIds = () => new Set(
136
+ [...jobs.values()].filter((job) => job.status === 'running').map((job) => job.paneId),
137
+ );
138
+
139
+ const fail = (msg: string): never => toolError(msg);
140
+ const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
141
+
142
+ /** Cancel this job's watch and check-in; both are no-ops when already gone. */
143
+ function stopWatchers(jobId: string): void {
144
+ watches.get(jobId)?.cancel();
145
+ watches.delete(jobId);
146
+ const timer = timers.get(jobId);
147
+ if (timer) clearTimeout(timer);
148
+ timers.delete(jobId);
149
+ }
150
+
151
+ function sendNotice(content: string, details: Record<string, unknown>, wake: boolean): void {
152
+ try {
153
+ // The card is the same content for a human; `details` never repeats the whole text.
154
+ // A settle wakes an idle session; a check-in does not. Both steer, so neither is held behind
155
+ // the end of the turn: a check-in delivered minutes later says "still running" about a job
156
+ // that has since exited, and reads as a contradiction.
157
+ pi.sendMessage?.(
158
+ { customType: JOB_NOTICE_CUSTOM_TYPE, content, display: true, details },
159
+ wake ? { deliverAs: 'steer', triggerTurn: true } : { deliverAs: 'steer' },
160
+ );
161
+ } catch {
162
+ /* A host without custom messages keeps the job; only the notice is lost. */
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Settle one job exactly once: the notice carries the exit code, the last lines and the log path,
168
+ * so the model needs neither `wait` nor a sleep loop. A settle wakes an idle master; a check-in
169
+ * does not.
170
+ */
171
+ async function settleJob(job: JobRecord, status: JobStatus, exitCode: number | null, error?: string): Promise<void> {
172
+ if (job.status !== 'running') return;
173
+ job.status = status;
174
+ job.exitCode = exitCode;
175
+ job.endedAt = Date.now();
176
+ if (error !== undefined) job.error = error;
177
+ stopWatchers(job.id);
178
+ let tail = '';
179
+ try {
180
+ const logText = await readLogEnd(job.logPath, 'tail');
181
+ // A log that never got written falls back to the pane: a shell error is output too.
182
+ const paneText = logText === null || logText.trim() === ''
183
+ ? await client.read(job.paneId, { lines: NOTICE_TAIL_LINES * 2 }).then((r) => r.text).catch(() => '')
184
+ : '';
185
+ tail = chooseTail(logText, paneText, NOTICE_TAIL_LINES);
186
+ } catch (err) {
187
+ swallow('bg-job.notice-tail', err);
188
+ }
189
+ sendNotice(formatJobNotice(job, tail), jobNoticeDetails(job, job.endedAt, false), true);
190
+ job.delivered = true;
191
+ // The labels are the memory a later mount adopts from: a delivered job must not come back as news.
192
+ try {
193
+ await client.setLabels({ [JOB_LABEL_KEY]: null, [JOB_META_LABEL]: null });
194
+ } catch (err) {
195
+ swallow('bg-job.clear-labels', err);
196
+ }
197
+ for (const id of expiredDelivered(jobs.values())) jobs.delete(id);
198
+ }
199
+
200
+ /**
201
+ * Watch one child until it exits. `watchChildren` supplies the fast path; the probe on every wake
202
+ * is authoritative, so a stale snapshot cannot report a job that already ended as running.
203
+ */
204
+ function startWatch(job: JobRecord): void {
205
+ const watch = client.watchChildren();
206
+ const state = { cancelled: false };
207
+ const handle = { cancel: () => { state.cancelled = true; } };
208
+ watches.set(job.id, handle);
209
+ void (async () => {
210
+ try {
211
+ while (!state.cancelled && job.status === 'running') {
212
+ const since = watch.version(job.paneId);
213
+ await watch.changed(job.paneId, since, HEARTBEAT_MS);
214
+ if (state.cancelled || job.status !== 'running') return;
215
+ const info: TerminalInfo | null = await client.getTerminal(job.paneId).catch(() => null);
216
+ if (info === null) {
217
+ // A vanished pane is not evidence of success: report it as a failure to confirm.
218
+ await settleJob(job, 'failed', null, 'the terminal disappeared before reporting an exit');
219
+ return;
220
+ }
221
+ if (info.state === 'finished') {
222
+ await settleJob(job, 'finished', info.exitCode);
223
+ return;
224
+ }
225
+ }
226
+ } catch (err) {
227
+ // A dead socket ends the watch silently; the log and the pane survive for a later look.
228
+ swallow('bg-job.watch', err);
229
+ } finally {
230
+ if (watches.get(job.id) === handle) watches.delete(job.id);
231
+ }
232
+ })();
233
+ }
234
+
235
+ /** One check-in per job, delivered without waking an idle master: it is a glance, not a verdict. */
236
+ function armCheckIn(job: JobRecord, seconds: number): void {
237
+ const timer = setTimeout(() => {
238
+ timers.delete(job.id);
239
+ if (job.status !== 'running' || job.checkInSent) return;
240
+ job.checkInSent = true;
241
+ void (async () => {
242
+ const text = await readLogEnd(job.logPath, 'tail').catch(() => null);
243
+ const pane = text === null || text.trim() === ''
244
+ ? await client.read(job.paneId, { lines: NOTICE_TAIL_LINES * 2 }).then((r) => r.text).catch(() => '')
245
+ : '';
246
+ const tail = chooseTail(text, pane, NOTICE_TAIL_LINES);
247
+ const bytes = await logSize(job.logPath).catch(() => null);
248
+ sendNotice(formatJobCheckIn(job, Date.now(), tail, bytes), jobNoticeDetails(job, Date.now(), true), false);
249
+ })();
250
+ }, seconds * 1000);
251
+ timer.unref?.();
252
+ timers.set(job.id, timer);
253
+ }
254
+
255
+ /**
256
+ * Adopt what a previous mount left running. The registry is in memory, so a reload forgets a live
257
+ * job while core keeps it alive; asking core which panes still carry the job label is what turns
258
+ * that amnesia into "you are told anyway" — including a job that exited while nobody watched.
259
+ */
260
+ async function adoptPreviousJobs(): Promise<void> {
261
+ if (!client.available) return;
262
+ let terminals;
263
+ try {
264
+ terminals = await client.listTerminals();
265
+ } catch (err) {
266
+ swallow('bg-job.adopt', err);
267
+ return;
268
+ }
269
+ const plans = planAdoption(
270
+ terminals.map((terminal) => ({
271
+ id: terminal.id,
272
+ parent: terminal.parent,
273
+ cwd: terminal.cwd,
274
+ labels: terminal.labels,
275
+ state: terminal.state,
276
+ exitCode: terminal.exitCode,
277
+ })),
278
+ new Set(jobs.keys()),
279
+ Date.now(),
280
+ { parent: env?.terminalId ?? null },
281
+ );
282
+ for (const plan of plans) {
283
+ jobs.set(plan.job.id, plan.job);
284
+ if (plan.finished) {
285
+ // Its exit report was owed and never delivered: deliver it now, with core's own exit code.
286
+ await settleJob(plan.job, 'finished', plan.job.exitCode);
287
+ continue;
288
+ }
289
+ startWatch(plan.job);
290
+ if (!plan.job.checkInSent && plan.remainingMs > 0) armCheckIn(plan.job, plan.remainingMs / 1000);
291
+ }
292
+ }
293
+
294
+ function runningCount(): number {
295
+ return [...jobs.values()].filter((job) => job.status === 'running').length;
296
+ }
297
+
298
+ function findJob(id: unknown): JobRecord | null {
299
+ if (typeof id !== 'string' || id === '') return null;
300
+ return jobs.get(id) ?? null;
301
+ }
302
+
303
+ /** Refresh one job from core: a session that lost an event still reports the truth. */
304
+ async function refresh(job: JobRecord): Promise<void> {
305
+ if (job.status !== 'running') return;
306
+ const info = await client.getTerminal(job.paneId).catch(() => null);
307
+ if (info === null) {
308
+ // A probe that says "gone" is an outcome, so it is reported like one: a job that vanishes
309
+ // silently would leave the launcher waiting for a notice that never comes.
310
+ await settleJob(job, 'failed', null, 'the terminal disappeared before reporting an exit');
311
+ return;
312
+ }
313
+ if (info.state === 'finished') await settleJob(job, 'finished', info.exitCode);
314
+ }
315
+
316
+ async function executeStart(params: ToolParams, ctx: unknown) {
317
+ if (!client.available || !env) {
318
+ return fail(`bg_job: ${CORE_REQUIRED}`);
319
+ }
320
+ const command = typeof params?.command === 'string' ? params.command.trim() : '';
321
+ if (command === '') return fail('command is required: the shell command to run in the background');
322
+ if (runningCount() >= MAX_JOBS) {
323
+ return fail(`${MAX_JOBS} background jobs are already running in this session; `
324
+ + 'finish or stop one with bg_job {action: "stop", job_id: "..."} first');
325
+ }
326
+ const cwd = typeof params?.cwd === 'string' && params.cwd
327
+ ? params.cwd
328
+ : (ctx as { cwd?: string }).cwd ?? process.cwd();
329
+ const label = typeof params?.name === 'string' && params.name.trim() !== ''
330
+ ? params.name.trim().slice(0, 80)
331
+ : null;
332
+ const timeoutS = clampTimeoutS(params?.timeout_s);
333
+ const id = nextJobId(++sequence, instanceTag);
334
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
335
+ const logPath = join(logDir, `${id}-${stamp}.log`);
336
+ try {
337
+ await mkdir(logDir, { recursive: true });
338
+ } catch (err) {
339
+ return fail(`cannot create the log directory ${logDir}: ${errText(err)}`);
340
+ }
341
+ const startedAt = Date.now();
342
+ let info: TerminalInfo;
343
+ try {
344
+ // No placement: like a worker, a job's terminal belongs to the agents section of the sidebar,
345
+ // not to a pane beside the master in this tab. Core still runs it, and the labels name it.
346
+ const labels = withoutPlacement(await client.placementLabels());
347
+ info = await client.run({
348
+ argv: [...planJobArgv(command, logPath)],
349
+ cwd,
350
+ env: childEnvironment(process.env, {}),
351
+ labels: {
352
+ ...labels,
353
+ [JOB_LABEL_KEY]: id,
354
+ [JOB_META_LABEL]: encodeJobMeta({
355
+ name: label,
356
+ log: logPath,
357
+ started: startedAt,
358
+ timeout: timeoutS,
359
+ command,
360
+ }),
361
+ },
362
+ name: jobCoreName(id, label),
363
+ // The notice comes through pi's message API, never as a note typed into the master's PTY.
364
+ noNotify: true,
365
+ });
366
+ } catch (err) {
367
+ return fail(coreUnavailableHint(err) ?? `failed to start the background job: ${errText(err)}`);
368
+ }
369
+ const job: JobRecord = {
370
+ id,
371
+ label,
372
+ command,
373
+ cwd,
374
+ paneId: info.id,
375
+ logPath,
376
+ startedAt,
377
+ status: 'running',
378
+ exitCode: null,
379
+ endedAt: null,
380
+ checkInSent: false,
381
+ delivered: false,
382
+ };
383
+ jobs.set(id, job);
384
+ startWatch(job);
385
+ armCheckIn(job, timeoutS);
386
+ const text = [
387
+ `background job ${id}${label ? ` ("${label}")` : ''} started (pane ${info.id}).`,
388
+ `log: ${logPath}`,
389
+ `You will be told once when it exits (exit code, last lines, log path). Keep working or end your turn.`,
390
+ `A check-in arrives after ${timeoutS}s if it is still running; it is not a failure and the job keeps going.`,
391
+ ].join('\n');
392
+ return {
393
+ content: [{ type: 'text' as const, text }],
394
+ details: { jobId: id, paneId: info.id, logPath, status: 'running', timeoutSeconds: timeoutS },
395
+ };
396
+ }
397
+
398
+ async function executeStatus(params: ToolParams) {
399
+ const job = findJob(params?.job_id);
400
+ if (!job) return fail(`unknown job "${String(params?.job_id)}" (see action list)`);
401
+ await refresh(job);
402
+ const logText = await readLogEnd(job.logPath, 'tail').catch(() => null);
403
+ const paneText = logText === null || logText.trim() === ''
404
+ ? await client.read(job.paneId, { lines: NOTICE_TAIL_LINES * 2 }).then((r) => r.text).catch(() => '')
405
+ : '';
406
+ const preview = chooseTail(logText, paneText, NOTICE_TAIL_LINES);
407
+ const text = preview === ''
408
+ ? formatJobStatus(job, Date.now())
409
+ : `${formatJobStatus(job, Date.now())}\nLast output:\n${preview}`;
410
+ return {
411
+ content: [{ type: 'text' as const, text }],
412
+ details: { jobId: job.id, status: job.status, exitCode: job.exitCode, logPath: job.logPath },
413
+ };
414
+ }
415
+
416
+ async function executeLog(params: ToolParams) {
417
+ const job = findJob(params?.job_id);
418
+ if (!job) return fail(`unknown job "${String(params?.job_id)}" (see action list)`);
419
+ const mode: 'head' | 'tail' = params?.mode === 'head' ? 'head' : 'tail';
420
+ const text = await readLogEnd(job.logPath, mode).catch(() => null);
421
+ if (text === null) return fail(`no log for ${job.id} at ${job.logPath} (the job may have failed to start)`);
422
+ const slice = readLogSlice(text, {
423
+ mode,
424
+ lines: typeof params?.lines === 'number' ? params.lines : 100,
425
+ ...(typeof params?.grep === 'string' ? { grep: params.grep } : {}),
426
+ });
427
+ if (!slice.ok) return fail(slice.error);
428
+ if (slice.total === 0) {
429
+ return {
430
+ content: [{ type: 'text' as const, text: `${job.id}: no matching lines in ${mode} of ${job.logPath}` }],
431
+ details: { jobId: job.id, total: 0, shown: 0, logPath: job.logPath },
432
+ };
433
+ }
434
+ const header = `${job.id} · ${mode} ${slice.shown}/${slice.total} matching lines · ${job.logPath}`;
435
+ return {
436
+ content: [{ type: 'text' as const, text: `${header}\n${slice.text}` }],
437
+ details: { jobId: job.id, total: slice.total, shown: slice.shown, logPath: job.logPath },
438
+ };
439
+ }
440
+
441
+ async function executeStop(params: ToolParams) {
442
+ const job = findJob(params?.job_id);
443
+ if (!job) return fail(`unknown job "${String(params?.job_id)}" (see action list)`);
444
+ if (job.status !== 'running') {
445
+ return {
446
+ content: [{ type: 'text' as const, text: `${job.id} was already ${job.status}.` }],
447
+ details: { jobId: job.id, status: job.status },
448
+ };
449
+ }
450
+ // Whoever stopped the job already knows: a stop never notifies, and the job leaves the list.
451
+ job.status = 'stopped';
452
+ job.endedAt = Date.now();
453
+ job.delivered = true;
454
+ stopWatchers(job.id);
455
+ try {
456
+ await client.kill(job.paneId);
457
+ await client.remove(job.paneId);
458
+ } catch (err) {
459
+ return fail(coreUnavailableHint(err) ?? `stop failed (the job may already be gone): ${errText(err)}`);
460
+ }
461
+ return {
462
+ content: [{ type: 'text' as const, text: `stopped ${job.id}; its log stays at ${job.logPath}.` }],
463
+ details: { jobId: job.id, status: 'stopped', logPath: job.logPath },
464
+ };
465
+ }
466
+
467
+ async function executeList(params: ToolParams, toolCtx: unknown) {
468
+ for (const job of jobs.values()) await refresh(job);
469
+ const showAll = params?.all === true;
470
+ const root = typeof params?.cwd === 'string' && params.cwd.trim() !== '' ? params.cwd.trim() : null;
471
+ const rows = listedJobs(jobs.values(), { all: showAll, cwd: root });
472
+ if (rows.length === 0) {
473
+ return {
474
+ content: [{ type: 'text' as const, text: showAll ? 'No background jobs in this session, running or finished.' : 'No background jobs in this session.' }],
475
+ details: { count: 0 },
476
+ };
477
+ }
478
+ const now = Date.now();
479
+ const sessionCwd = (toolCtx as { cwd?: string } | undefined)?.cwd ?? process.cwd();
480
+ return {
481
+ content: [{ type: 'text' as const, text: rows.map((job) => formatJobListLine(job, now, sessionCwd)).join('\n') }],
482
+ details: { count: rows.length, all: showAll },
483
+ };
484
+ }
485
+
486
+ scoped.registerTool({
487
+ name: 'bg_job',
488
+ label: 'Background job',
489
+ description: [
490
+ 'Run one shell command in the background as its own Herdr terminal and be told once when it exits.',
491
+ 'You get the exit code, the last lines and the log path; the whole output stays in the log and is',
492
+ 'read on demand with action "log" (head, tail, lines, grep). Do not poll or sleep: keep working or end your turn.',
493
+ 'A check-in arrives if the job outlives timeout_s; it is a glance, not a failure, and the job keeps running.',
494
+ 'Actions: start (default), status, log, stop, list (this session\'s own jobs; all and cwd filter it).',
495
+ 'The command runs under bash with pipefail, so the exit code is the pipeline\'s last failing command.',
496
+ ].join(' '),
497
+ promptSnippet: 'bg_job: run a command in the background and be told once when it exits (exit code, last lines, log).',
498
+ promptGuidelines: [
499
+ 'Prefer bg_job over bash for anything slow or of unknown duration (builds, test suites, CI, installs, deploys, servers, migrations); keep bash for short commands that finish in seconds.',
500
+ 'After start, continue other work or end the turn: never poll status or log to wait for the end.',
501
+ 'When a check-in arrives the job is still running: look once with log, then keep waiting or stop it — never re-send the command.',
502
+ 'Use bash for anything interactive: bg_job has no way to answer a prompt, and a command that asks for input just hangs.',
503
+ ],
504
+ parameters: Type.Object({
505
+ action: Type.Optional(Type.Union(BG_JOB_ACTIONS.map((name) => Type.Literal(name)), {
506
+ description: 'Default start',
507
+ })),
508
+ command: Type.Optional(Type.String({
509
+ description: '[start] Shell command to run: any multiline script, pipe or heredoc; it must not need input',
510
+ })),
511
+ name: Type.Optional(Type.String({
512
+ description: '[start] Short human-readable label, e.g. "build release"',
513
+ })),
514
+ cwd: Type.Optional(Type.String({
515
+ description: '[start] Working directory (default: the session cwd); [list] keep only jobs running there or below',
516
+ })),
517
+ all: Type.Optional(Type.Boolean({
518
+ description: '[list] Include the finished jobs the session still remembers (default false: only what still owes a notice)',
519
+ })),
520
+ timeout_s: Type.Optional(Type.Number({
521
+ description: `[start] Check in if it is still running after this many seconds (default ${DEFAULT_TIMEOUT_S}, 5-3600)`,
522
+ })),
523
+ job_id: Type.Optional(Type.String({
524
+ description: '[status|log|stop] Job id returned by start, e.g. job-1',
525
+ })),
526
+ mode: Type.Optional(Type.Union([Type.Literal('head'), Type.Literal('tail')], {
527
+ description: '[log] Read from the start or the end (default tail)',
528
+ })),
529
+ lines: Type.Optional(Type.Number({
530
+ description: '[log] How many lines to return (1-400, default 100)',
531
+ })),
532
+ grep: Type.Optional(Type.String({
533
+ description: '[log] Keep only lines matching this regular expression',
534
+ })),
535
+ }),
536
+ async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
537
+ const action = (typeof params?.action === 'string' ? params.action : 'start') as BgJobAction;
538
+ switch (action) {
539
+ case 'start': return executeStart(params, ctx);
540
+ case 'status': return executeStatus(params);
541
+ case 'log': return executeLog(params);
542
+ case 'stop': return executeStop(params);
543
+ case 'list': return executeList(params, ctx);
544
+ default:
545
+ return fail(`unknown action "${action}" (valid: ${BG_JOB_ACTIONS.join(', ')})`);
546
+ }
547
+ },
548
+ });
549
+
550
+ // Jobs end with this terminal (core's default), so shutdown only stops the bookkeeping: a timer
551
+ // that outlived the session would keep reading a log nobody asked about.
552
+ const dispose = (): void => {
553
+ for (const id of [...timers.keys()]) stopWatchers(id);
554
+ for (const id of [...watches.keys()]) stopWatchers(id);
555
+ };
556
+ // A mount that inherited running jobs adopts them before anything else: the notice is owed.
557
+ scoped.on('session_start', async () => { await adoptPreviousJobs(); });
558
+ scoped.on('session_shutdown', async () => { dispose(); });
559
+ ctx.effect(() => dispose, 'bg-job-watchers');
560
+ }