shraga 0.1.64 → 0.1.66

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,511 @@
1
+ // Background shell jobs that OUTLIVE the turn that started them.
2
+ //
3
+ // The bug this exists for: an agent dispatches long work, promises "I'll report back", and the turn
4
+ // is then cut at the engine's wall clock. The work finishes fine — and nobody is left to say so.
5
+ // Any dispatch-and-poll pattern loses that race whenever the work outlasts the remaining budget, so
6
+ // polling is not the fix; something has to WAKE the session when the process exits.
7
+ //
8
+ // Shape:
9
+ // • The registry is owned by the SERVER (module singleton), not by the turn. agentx builds a fresh
10
+ // `Agent` per turn, so a registry created there would die with the turn — the very failure above.
11
+ // Hosts hand each turn a thin per-session VIEW (`sessionJobRegistry`) over this one store; the
12
+ // view is duck-compatible with agentx's `ShellJobRegistry`, so `Shell({background:true})` and the
13
+ // ShellOutput/ShellStatus/ShellKill tools light up with no folklore `nohup`.
14
+ // • On exit we report through wake.ts — the same "close then report" path polls.ts already uses in
15
+ // production, so a job outcome lands wherever the session speaks (Slack thread / web UI).
16
+ // • No double-report: reading a finished job's OUTPUT from inside a turn marks it `observed` — the
17
+ // model had the result in hand and can speak for itself. Only that. `status`/`list` return no
18
+ // output, so seeing "exited" there is not holding the result; marking those observed suppressed
19
+ // the follow-up and lost the outcome entirely. We wake for every job whose output nobody read.
20
+ // (Known narrow gap: a turn that reads the output and is then cut before it speaks gets no
21
+ // follow-up. Much smaller window than the one being fixed.)
22
+ // • No collision with a live turn: the report defers while `isSessionLocked` — with a cap, past
23
+ // which it is delivered as plain text rather than never.
24
+ //
25
+ // Restart survival is PARTIAL and deliberately visible. Children are detached, so they keep running
26
+ // across a server restart and their output keeps landing in the job's log file; boot re-adopts them
27
+ // by pid and reports on exit as usual. What cannot survive is the exit CODE of a job that finished
28
+ // while we were down — nothing was waiting on the process. Those are reported with an explicit
29
+ // "exit code unknown (server restarted)" instead of a fabricated success.
30
+ import { spawn, execFileSync } from 'node:child_process';
31
+ import { mkdirSync, openSync, closeSync, readSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'node:fs';
32
+ import path from 'node:path';
33
+ import { dataPath } from './paths.ts';
34
+ import { isSessionLocked, getSession } from './sessions.ts';
35
+ import { wakeSession, deliverToSession, wakeReady } from './wake.ts';
36
+
37
+ const PREFIX = '[jobs]';
38
+
39
+ // ── Bounds. Every one of these caps a blast radius the incident's `nohup` had none of. ──
40
+ /** Hard cap on a job's log, enforced by `head -c` inside the job's own pipeline (see buildShell).
41
+ * Polling a size and killing late cannot bound anything: at a 5s cadence an 8MB cap measured 830MB
42
+ * on disk. `head` closes the pipe at exactly this many bytes instead — the writer then dies of
43
+ * SIGPIPE, so the bound is the kernel's, not a timer's. */
44
+ const MAX_LOG_BYTES = 8 * 1024 * 1024;
45
+ /** How much of the tail we ever read back into memory (for a tool result or a report). */
46
+ const TAIL_BYTES = 16 * 1024;
47
+ /** How much of that tail goes into the wake prompt (the rest stays readable via ShellOutput). */
48
+ const REPORT_CHARS = 4_000;
49
+ const MAX_RUNNING_PER_SESSION = 4;
50
+ const MAX_RUNNING_GLOBAL = 16;
51
+ /** Grace before reporting: lets a still-live turn poll the just-finished job and own the telling. */
52
+ const GRACE_MS = 8_000;
53
+ /** Re-check cadence while the session is busy, or while an adopted (post-restart) job still runs. */
54
+ const POLL_MS = 5_000;
55
+ /** Give up deferring behind a busy session after this long and deliver the outcome as plain text. */
56
+ const MAX_DEFER_MS = 30 * 60_000;
57
+ /** Delete finished job records + logs after this long. */
58
+ const PRUNE_AFTER_MS = 24 * 60 * 60_000;
59
+
60
+ export type JobStatus = 'running' | 'exited' | 'killed' | 'error';
61
+
62
+ export interface JobOwner {
63
+ sessionId: string;
64
+ uid: string;
65
+ userEmail?: string;
66
+ /** Working directory for the job's shell (the deployment/project root). */
67
+ cwd: string;
68
+ /** Extra env merged over the server's own for the child. */
69
+ env?: Record<string, string>;
70
+ }
71
+
72
+ interface JobRecord {
73
+ id: string;
74
+ sessionId: string;
75
+ uid: string;
76
+ userEmail?: string;
77
+ command: string;
78
+ cwd: string;
79
+ pid?: number;
80
+ startedAt: number;
81
+ status: JobStatus;
82
+ exitCode?: number;
83
+ endedAt?: number;
84
+ /** A turn read this job's terminal state — the model can report it itself, so we must not. */
85
+ observed?: boolean;
86
+ /** Terminal outcome of the follow-up delivery (absent = not attempted yet). */
87
+ reported?: 'woke' | 'raw' | 'skipped-observed' | 'no-session' | 'failed';
88
+ /** The exit code is unknown because the server restarted while this job ran. */
89
+ orphaned?: boolean;
90
+ /** The job hit MAX_LOG_BYTES: its output was cut off and the command was killed by SIGPIPE. */
91
+ truncated?: boolean;
92
+ }
93
+
94
+ // ── Storage ────────────────────────────────────────────────────────────────────
95
+ const dir = (): string => { const d = dataPath('jobs'); mkdirSync(d, { recursive: true }); return d; };
96
+ const recFile = (id: string): string => path.join(dir(), `${id}.json`);
97
+ const logFile = (id: string): string => path.join(dir(), `${id}.log`);
98
+ /** The command's REAL exit status, written by the job's own shell (see buildShell). */
99
+ const statusFile = (id: string): string => path.join(dir(), `${id}.status`);
100
+
101
+ /** The command's exit code as its shell recorded it, or null if it never got that far. */
102
+ function readStatus(id: string): number | null {
103
+ try {
104
+ const n = Number(readFileSync(statusFile(id), 'utf-8').trim());
105
+ return Number.isInteger(n) ? n : null;
106
+ } catch { return null; }
107
+ }
108
+
109
+ /** Current size of a job's log, 0 if it does not exist yet. */
110
+ function logSize(id: string): number {
111
+ try { return statSync(logFile(id)).size; } catch { return 0; }
112
+ }
113
+
114
+ /**
115
+ * Wrap the user's command so the log is bounded and the real exit code survives.
116
+ *
117
+ * `{ ( cmd ); echo $? > status; } 2>&1 | head -c N`, and every piece of that shape is load-bearing:
118
+ * • `head -c N` — the actual disk bound. It closes the pipe at exactly N bytes; the writer then
119
+ * takes SIGPIPE. Chosen over `ulimit -f`, which is also kernel-enforced but caps EVERY file the
120
+ * job writes — that would kill an ordinary build the moment it emitted a >8MB artifact.
121
+ * • the inner `( … )` subshell — a command ending in `exit 3` (agents write those) would otherwise
122
+ * exit the group before the status line ever ran, and we'd lose the code. Verified.
123
+ * • the status file — the pipeline's own exit code is `head`'s (always 0), so without this every
124
+ * job would report success. The shell is not the process writing to the pipe, so it survives the
125
+ * SIGPIPE that kills the command and still records the code.
126
+ * It also outlives us: the whole pipeline is in the job's detached process group, so a job adopted
127
+ * after a server restart can be given its true exit code instead of "unknown".
128
+ */
129
+ function buildShell(id: string, cmd: string): string {
130
+ return `{\n(\n${cmd}\n)\n__st=$?\necho $__st > '${statusFile(id)}'\n} 2>&1 | head -c ${MAX_LOG_BYTES}`;
131
+ }
132
+
133
+ /** In-memory mirror of the on-disk records (disk is the restart-durable copy; this is the hot path). */
134
+ const records = new Map<string, JobRecord>();
135
+ /** Live timers per job, so nothing is left ticking after a job is reported or the record pruned. */
136
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
137
+
138
+ function save(j: JobRecord): void {
139
+ records.set(j.id, j);
140
+ try { writeFileSync(recFile(j.id), JSON.stringify(j, null, 2)); }
141
+ catch (e) { console.error(`${PREFIX} persist ${j.id} failed:`, (e as Error).message); }
142
+ }
143
+
144
+ function setTimer(id: string, ms: number, fn: () => void): void {
145
+ clearTimer(id);
146
+ timers.set(id, setTimeout(() => { timers.delete(id); fn(); }, ms));
147
+ }
148
+ function clearTimer(id: string): void {
149
+ const t = timers.get(id);
150
+ if (t) { clearTimeout(t); timers.delete(id); }
151
+ }
152
+
153
+ /** Last `TAIL_BYTES` of a job's log, decoded as utf-8. Never loads the whole file. */
154
+ function readTail(id: string): string {
155
+ let fd: number | undefined;
156
+ try {
157
+ const size = statSync(logFile(id)).size;
158
+ const start = Math.max(0, size - TAIL_BYTES);
159
+ const len = size - start;
160
+ if (len <= 0) return '';
161
+ const buf = Buffer.allocUnsafe(len);
162
+ fd = openSync(logFile(id), 'r');
163
+ readSync(fd, buf, 0, len, start);
164
+ // A head-truncated read can split a multi-byte char — the replacement char is cosmetic, not corruption.
165
+ return (start > 0 ? `…(truncated, showing last ${TAIL_BYTES >> 10}KB)\n` : '') + buf.toString('utf-8');
166
+ } catch { return ''; }
167
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ } }
168
+ }
169
+
170
+ const runningCount = (sessionId?: string): number =>
171
+ [...records.values()].filter((j) => j.status === 'running' && (!sessionId || j.sessionId === sessionId)).length;
172
+
173
+ /**
174
+ * Seconds this pid has been alive, or null if there is no such process.
175
+ *
176
+ * `etime` (formatted `[[DD-]HH:]MM:SS`), NOT `etimes` (raw seconds): etimes is a procps/Linux
177
+ * extension and BSD ps — macOS, which is what the live box runs — rejects it outright, so the whole
178
+ * adoption check silently degraded to "process gone" and every job that survived a restart was
179
+ * reported as `exit code unknown`. etime is in POSIX and works on both.
180
+ */
181
+ function pidAgeSeconds(pid: number): number | null {
182
+ let out: string;
183
+ try {
184
+ // stderr piped: a missing pid is an expected outcome here, not something to print.
185
+ out = execFileSync('ps', ['-o', 'etime=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
186
+ } catch { return null; /* no such process */ }
187
+ const m = out.match(/^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/);
188
+ if (!m) return null;
189
+ const [, d, h, mi, sec] = m;
190
+ return Number(d ?? 0) * 86400 + Number(h ?? 0) * 3600 + Number(mi) * 60 + Number(sec);
191
+ }
192
+
193
+ /**
194
+ * Env for a job's shell: the server's own, minus anything that looks like a credential.
195
+ *
196
+ * agentx's foreground `Bash` redacts these by default (RealShellOptions.redactEnv). Backgrounding a
197
+ * command must not be a way around that — otherwise `Bash({background:true})` becomes a strictly
198
+ * weaker sandbox than `Bash({})`, and `echo $ANTHROPIC_API_KEY` lands in a job log we then feed back
199
+ * into the model. Mirrors agentx's SECRET_ENV_RE; a job that genuinely needs a credential gets it
200
+ * explicitly via `JobOwner.env`.
201
+ */
202
+ const SECRET_ENV_RE = /(API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$|^SLACK_)/i;
203
+ function childEnv(): Record<string, string | undefined> {
204
+ const out: Record<string, string | undefined> = {};
205
+ for (const [k, v] of Object.entries(process.env)) if (!SECRET_ENV_RE.test(k)) out[k] = v;
206
+ return out;
207
+ }
208
+
209
+ // ── Lifecycle ──────────────────────────────────────────────────────────────────
210
+
211
+ /** Start `command` detached, in its own process group, with stdout+stderr appended to the job log.
212
+ * Detached (setsid) for two reasons: the group is killable as a subtree, and the child is NOT torn
213
+ * down with the server — which is what makes restart adoption possible at all. */
214
+ export async function startJob(owner: JobOwner, command: string): Promise<string> {
215
+ const cmd = command.trim();
216
+ if (!cmd) throw new Error('empty command');
217
+ if (runningCount(owner.sessionId) >= MAX_RUNNING_PER_SESSION)
218
+ throw new Error(`too many background jobs in this session (max ${MAX_RUNNING_PER_SESSION}) — wait for one to finish or ShellKill it`);
219
+ if (runningCount() >= MAX_RUNNING_GLOBAL)
220
+ throw new Error(`too many background jobs on this server (max ${MAX_RUNNING_GLOBAL})`);
221
+
222
+ const id = `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
223
+ const j: JobRecord = {
224
+ id, sessionId: owner.sessionId, uid: owner.uid, userEmail: owner.userEmail,
225
+ command: cmd, cwd: owner.cwd, startedAt: Date.now(), status: 'running',
226
+ };
227
+ let fd: number;
228
+ try { fd = openSync(logFile(id), 'a'); }
229
+ catch (e) { throw new Error(`cannot open job log: ${(e as Error).message}`); }
230
+ try {
231
+ // stdin is /dev/null so a child can never block on (or steal) input; stdout+stderr go straight
232
+ // to the fd — nothing is buffered in this process, so a chatty job costs disk, not memory.
233
+ const proc = spawn('/bin/sh', ['-c', buildShell(id, cmd)], {
234
+ cwd: owner.cwd, env: { ...childEnv(), ...owner.env },
235
+ detached: true, stdio: ['ignore', fd, fd],
236
+ });
237
+ j.pid = proc.pid;
238
+ proc.on('error', (err) => finish(id, 'error', undefined, `spawn error: ${err.message}`));
239
+ // The pipeline's own code is `head`'s; the command's real one is in the status file.
240
+ proc.on('close', (code) => finish(id, 'exited', readStatus(id) ?? code ?? undefined));
241
+ proc.unref(); // never hold the event loop open for a background job
242
+ } catch (e) {
243
+ j.status = 'error';
244
+ save(j);
245
+ closeSync(fd);
246
+ throw new Error(`failed to spawn: ${(e as Error).message}`);
247
+ }
248
+ closeSync(fd); // the child holds its own dup of the fd
249
+ save(j);
250
+ console.log(`${PREFIX} started ${id} pid=${j.pid} session=${j.sessionId.slice(0, 8)} cmd=${cmd.slice(0, 120)}`);
251
+ return id;
252
+ }
253
+
254
+ /** Record a terminal state exactly once, then schedule the follow-up. */
255
+ function finish(id: string, status: JobStatus, exitCode?: number, note?: string): void {
256
+ const j = records.get(id);
257
+ if (!j || j.status !== 'running') return; // already terminal (e.g. killed, then close fires)
258
+ j.status = status;
259
+ j.exitCode = exitCode;
260
+ j.endedAt = Date.now();
261
+ if (logSize(id) >= MAX_LOG_BYTES) {
262
+ // Say so IN the log: every path the model can read the result through goes via readTail, so the
263
+ // notice reaches it whether it polls ShellOutput or gets the wake report.
264
+ j.truncated = true;
265
+ try { writeFileSync(logFile(id), `\n[output limit: ${MAX_LOG_BYTES >> 20}MB reached — output was cut off here and the command was terminated]\n`, { flag: 'a' }); }
266
+ catch { /* nothing more we can do about the log */ }
267
+ console.warn(`${PREFIX} ${id} hit the ${MAX_LOG_BYTES >> 20}MB log cap — output truncated`);
268
+ }
269
+ save(j);
270
+ console.log(`${PREFIX} ${id} ${status}${exitCode != null ? ` exit=${exitCode}` : ''} in ${Math.round((j.endedAt - j.startedAt) / 1000)}s${note ? ` (${note})` : ''}`);
271
+ // Grace: a turn that is still alive gets first refusal on telling the user (see `observed`).
272
+ setTimer(id, GRACE_MS, () => { void report(id, Date.now()); });
273
+ }
274
+
275
+ export function killJob(id: string): boolean {
276
+ const j = records.get(id);
277
+ if (!j) return false;
278
+ if (j.status === 'running') {
279
+ // Group-kill: the child is its own group leader, so signalling `-pid` reaps the whole subtree —
280
+ // `kill(pid)` alone would hit /bin/sh and orphan whatever it forked.
281
+ if (j.pid) { try { process.kill(-j.pid, 'SIGTERM'); } catch { try { process.kill(j.pid, 'SIGTERM'); } catch { /* already gone */ } } }
282
+ finish(id, 'killed');
283
+ }
284
+ return true;
285
+ }
286
+
287
+ /** A job the model explicitly killed inside a turn needs no follow-up — it already knows. */
288
+ function markObservedIfTerminal(j: JobRecord): void {
289
+ if (j.status !== 'running' && !j.observed) { j.observed = true; save(j); }
290
+ }
291
+
292
+ // ── Follow-up delivery ─────────────────────────────────────────────────────────
293
+
294
+ function reportPrompt(j: JobRecord): string {
295
+ const dur = Math.round(((j.endedAt ?? Date.now()) - j.startedAt) / 1000);
296
+ const result = (j.orphaned
297
+ ? 'ended while the server was restarting — exit code unknown'
298
+ : j.status === 'killed' ? 'killed'
299
+ : j.status === 'error' ? 'failed to run'
300
+ : `exit ${j.exitCode ?? 0}`)
301
+ + (j.truncated ? `, cut off at the ${MAX_LOG_BYTES >> 20}MB output limit` : '');
302
+ const tail = readTail(j.id).slice(-REPORT_CHARS).trim();
303
+ return [
304
+ `[Background job finished] A command you started in an earlier turn has completed, after that turn ended.`,
305
+ `Command: \`${j.command}\``,
306
+ `Result: ${result} (after ${dur}s)`,
307
+ ``,
308
+ `Output (tail):`,
309
+ tail || '(no output)',
310
+ ``,
311
+ `Report this outcome to the user now — that turn promised a follow-up and this is it.`,
312
+ `Be brief and concrete. Do NOT re-run the command.`,
313
+ ].join('\n');
314
+ }
315
+
316
+ /** Plain-text form, for when we cannot run a turn (no runner, or the session never went idle). */
317
+ function rawReport(j: JobRecord): string {
318
+ const dur = Math.round(((j.endedAt ?? Date.now()) - j.startedAt) / 1000);
319
+ const result = (j.orphaned ? 'exit code unknown (server restarted)' : j.status === 'exited' ? `exit ${j.exitCode ?? 0}` : j.status)
320
+ + (j.truncated ? `, cut off at the ${MAX_LOG_BYTES >> 20}MB output limit` : '');
321
+ const tail = readTail(j.id).slice(-REPORT_CHARS).trim();
322
+ return `Background job finished — \`${j.command}\`\nResult: ${result} (after ${dur}s)\n\n\`\`\`\n${tail || '(no output)'}\n\`\`\``;
323
+ }
324
+
325
+ /**
326
+ * Deliver a finished job's outcome, once.
327
+ *
328
+ * Order matters: `observed` is checked AFTER the grace window, so a turn that was still alive when
329
+ * the job exited has had its chance to poll and own the telling. `firstAttemptAt` bounds how long
330
+ * we defer behind a busy session.
331
+ */
332
+ const report = (id: string, firstAttemptAt: number): Promise<void> =>
333
+ tryReport(id, firstAttemptAt).catch((e) => console.error(`${PREFIX} ${id} report threw:`, (e as Error).message));
334
+
335
+ async function tryReport(id: string, firstAttemptAt: number): Promise<void> {
336
+ const j = records.get(id);
337
+ if (!j || j.reported) return;
338
+
339
+ if (j.observed) {
340
+ j.reported = 'skipped-observed';
341
+ save(j);
342
+ console.log(`${PREFIX} ${id} observed inside a turn — no follow-up needed`);
343
+ return;
344
+ }
345
+ if (!getSession(j.sessionId)) {
346
+ j.reported = 'no-session';
347
+ save(j);
348
+ console.warn(`${PREFIX} ${id} session ${j.sessionId} is gone — dropping follow-up`);
349
+ return;
350
+ }
351
+ // Never start a turn on top of a live one — they would interleave into one transcript.
352
+ if (isSessionLocked(j.sessionId)) {
353
+ if (Date.now() - firstAttemptAt < MAX_DEFER_MS) {
354
+ setTimer(id, POLL_MS, () => { void report(id, firstAttemptAt); });
355
+ return;
356
+ }
357
+ console.warn(`${PREFIX} ${id} session busy for ${Math.round(MAX_DEFER_MS / 60_000)}min — delivering raw`);
358
+ await deliverToSession({ sessionId: j.sessionId, uid: j.uid, text: rawReport(j), title: 'Background job' })
359
+ .catch((e) => console.error(`${PREFIX} raw deliver failed:`, (e as Error).message));
360
+ j.reported = 'raw';
361
+ save(j);
362
+ return;
363
+ }
364
+ try {
365
+ if (!wakeReady()) {
366
+ await deliverToSession({ sessionId: j.sessionId, uid: j.uid, text: rawReport(j), title: 'Background job' });
367
+ j.reported = 'raw';
368
+ } else {
369
+ const outcome = await wakeSession({
370
+ sessionId: j.sessionId, uid: j.uid, userEmail: j.userEmail,
371
+ prompt: reportPrompt(j), channel: 'job', title: 'Background job',
372
+ });
373
+ if (outcome === 'woke') j.reported = 'woke';
374
+ else {
375
+ // The wake ran but said nothing (or the session vanished mid-flight). Falling back to raw
376
+ // keeps the promise: the user sees the result rather than nothing at all.
377
+ await deliverToSession({ sessionId: j.sessionId, uid: j.uid, text: rawReport(j), title: 'Background job' });
378
+ j.reported = 'raw';
379
+ console.warn(`${PREFIX} ${id} wake returned '${outcome}' — delivered raw instead`);
380
+ }
381
+ }
382
+ console.log(`${PREFIX} ${id} follow-up delivered (${j.reported})`);
383
+ } catch (e) {
384
+ j.reported = 'failed';
385
+ console.error(`${PREFIX} ${id} follow-up FAILED:`, (e as Error).message);
386
+ }
387
+ save(j);
388
+ }
389
+
390
+ // ── Boot: adopt what survived, prune what is stale ─────────────────────────────
391
+
392
+ /**
393
+ * Load persisted records and reconcile them with reality.
394
+ *
395
+ * `running` records are jobs we lost the `close` listener for when the process ended. Their children
396
+ * are detached, so many are genuinely still running — re-adopt those by polling the pid. The rest
397
+ * finished while we were down: we know THAT they ended but not with what code, which is reported
398
+ * honestly rather than guessed.
399
+ */
400
+ export function initBackgroundJobs(): void {
401
+ let files: string[] = [];
402
+ try { files = readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return; }
403
+ let adopted = 0, orphaned = 0;
404
+ for (const f of files) {
405
+ let j: JobRecord;
406
+ try { j = JSON.parse(readFileSync(path.join(dir(), f), 'utf-8')) as JobRecord; } catch { continue; }
407
+ if (!j?.id) continue;
408
+ records.set(j.id, j);
409
+
410
+ if (j.status !== 'running') {
411
+ // A terminal job whose follow-up never went out (we died between finish and report) still owes one.
412
+ if (!j.reported) setTimer(j.id, GRACE_MS, () => { void report(j.id, Date.now()); });
413
+ continue;
414
+ }
415
+ // pid liveness, guarded against pid REUSE. Our process started when the job did, so a pid that
416
+ // is still ours is AT LEAST as old as the job. A recycled pid belongs to a process the OS started
417
+ // later — i.e. YOUNGER than the job — which is exactly what this rejects. (Getting the comparison
418
+ // backwards adopts the stranger: we would report its exit as the job's, and ShellKill would
419
+ // `kill(-pid)` an unrelated process tree.) The 5s slack absorbs `etime`'s 1s granularity.
420
+ const age = j.pid ? pidAgeSeconds(j.pid) : null;
421
+ const jobAgeSec = (Date.now() - j.startedAt) / 1000;
422
+ if (age != null && age >= jobAgeSec - 5) { adoptRunning(j.id); adopted++; }
423
+ else {
424
+ if (age != null) console.warn(`${PREFIX} ${j.id} pid ${j.pid} is ${Math.round(age)}s old but the job is ${Math.round(jobAgeSec)}s old — pid was reused, not adopting`);
425
+ // Its own shell may still have recorded the real code before we went down.
426
+ const st = readStatus(j.id);
427
+ if (st != null) j.exitCode = st; else j.orphaned = true;
428
+ j.status = 'exited';
429
+ j.endedAt = Date.now();
430
+ save(j);
431
+ orphaned++;
432
+ setTimer(j.id, GRACE_MS, () => { void report(j.id, Date.now()); });
433
+ }
434
+ }
435
+ pruneOld();
436
+ setInterval(pruneOld, 60 * 60_000).unref?.();
437
+ if (adopted || orphaned) console.log(`${PREFIX} boot: adopted ${adopted} running job(s), ${orphaned} ended while down`);
438
+ }
439
+
440
+ /** Watch a job we no longer have a child handle for; report when its pid disappears. */
441
+ function adoptRunning(id: string): void {
442
+ const j = records.get(id);
443
+ if (!j || j.status !== 'running' || !j.pid) return;
444
+ if (pidAgeSeconds(j.pid) == null) {
445
+ // `wait` only works for your own child, so the pid vanishing tells us THAT it ended, not how.
446
+ // Its own shell wrote the code down before exiting, though — prefer that over guessing.
447
+ const st = readStatus(id);
448
+ if (st == null) j.orphaned = true;
449
+ finish(id, 'exited', st ?? undefined);
450
+ return;
451
+ }
452
+ setTimer(id, POLL_MS, () => adoptRunning(id));
453
+ }
454
+
455
+ function pruneOld(): void {
456
+ const now = Date.now();
457
+ for (const j of [...records.values()]) {
458
+ if (j.status === 'running' || !j.endedAt || now - j.endedAt < PRUNE_AFTER_MS) continue;
459
+ clearTimer(j.id);
460
+ records.delete(j.id);
461
+ for (const p of [recFile(j.id), logFile(j.id), statusFile(j.id)]) try { rmSync(p); } catch { /* already gone */ }
462
+ }
463
+ }
464
+
465
+ // ── The per-turn view handed to the agent engine ───────────────────────────────
466
+
467
+ /**
468
+ * A session-scoped facade over the process-wide store, duck-compatible with agentx's
469
+ * `ShellJobRegistry` — pass it as `makeRealShellTool({ registry })` and to `makeShellJobTools`.
470
+ *
471
+ * Cheap to build per turn (it holds no state); the jobs it starts belong to the server, so they
472
+ * outlive the turn that made it. Every lookup is session-scoped: one session's agent can neither
473
+ * read nor kill another's jobs.
474
+ */
475
+ export function sessionJobRegistry(owner: JobOwner) {
476
+ const mine = (id: string): JobRecord | undefined => {
477
+ const j = records.get(id);
478
+ return j && j.sessionId === owner.sessionId ? j : undefined;
479
+ };
480
+ return {
481
+ start: (command: string) => startJob(owner, command),
482
+ output(id: string): string | null {
483
+ const j = mine(id);
484
+ if (!j) return null;
485
+ markObservedIfTerminal(j);
486
+ return readTail(id);
487
+ },
488
+ // NB: status() and list() deliberately do NOT mark the job observed — they return no output, so
489
+ // the model has not got the result and still needs the follow-up. Only output() may.
490
+ status(id: string): { status: JobStatus; exitCode?: number; bytes: number } | null {
491
+ const j = mine(id);
492
+ if (!j) return null;
493
+ return { status: j.status, exitCode: j.exitCode, bytes: logSize(id) };
494
+ },
495
+ list(): Array<{ id: string; command: string; status: JobStatus }> {
496
+ return [...records.values()]
497
+ .filter((j) => j.sessionId === owner.sessionId)
498
+ .map((j) => ({ id: j.id, command: j.command, status: j.status }));
499
+ },
500
+ kill(id: string): boolean {
501
+ const j = mine(id);
502
+ if (!j) return false;
503
+ const ok = killJob(id);
504
+ markObservedIfTerminal(j); // the model asked for this end — it does not need telling about it
505
+ return ok;
506
+ },
507
+ };
508
+ }
509
+
510
+ /** Test/introspection seam: the current record for a job (undefined once pruned). */
511
+ export function getJob(id: string): Readonly<JobRecord> | undefined { return records.get(id); }
@@ -30,6 +30,7 @@ import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMes
30
30
  import { setBroadcaster } from './session-bus.ts';
31
31
  import * as scheduler from './scheduler/index.ts';
32
32
  import { initPolls } from './polls.ts';
33
+ import { initBackgroundJobs } from './background-jobs.ts';
33
34
  import { pushEnabled } from './push/push.ts';
34
35
  import { upsertToken, removeToken } from './push/store.ts';
35
36
  import { initPushTriggers, pushTurnDone, pushQuestion } from './push/triggers.ts';
@@ -1138,6 +1139,11 @@ initPolls({
1138
1139
  runTurn: ({ prompt, sessionId, uid, userEmail }) =>
1139
1140
  consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController: new AbortController(), onPermissionRequest: async () => ({ allow: true }) })),
1140
1141
  });
1142
+ // Background jobs outlive the turn that started them, so their follow-up must too. Must run AFTER
1143
+ // initPolls (which wires wake.ts's turn runner) — boot adoption can report a job that finished
1144
+ // while we were down, and that report runs a turn. Skipped when passive: a standby twin must not
1145
+ // adopt the active instance's children or double-report them.
1146
+ if (!PASSIVE) initBackgroundJobs();
1141
1147
  // Remote-push triggers: subscribe to schedule.finished and expose turn-done/question
1142
1148
  // hooks. isForeground reuses the existing presence tracking (see isUserViewingSession).
1143
1149
  initPushTriggers({
@@ -225,6 +225,15 @@ export class ClaudeCodeEngine implements AgentEngine {
225
225
  sdkEnv.SHRAGA_USER_UID = sdkEnv.UNCLAW_USER_UID = opts.uid;
226
226
  if (opts.userEmail) sdkEnv.SHRAGA_USER_EMAIL = sdkEnv.UNCLAW_USER_EMAIL = opts.userEmail;
227
227
  sdkEnv.SHRAGA_SESSION_ID = sdkEnv.UNCLAW_SESSION_ID = opts.sessionId ?? '';
228
+ // Per-command wall-clock cap for the SDK's Bash tool. Without it a single unbounded command
229
+ // (e.g. `curl` with no `-m` against an SSE endpoint that streams nothing) eats the whole turn and
230
+ // the agent answers with "reads were interrupted". The model still sees `[exit 124]` + partial
231
+ // output and can adapt, and may ask for up to BASH_MAX_TIMEOUT_MS on a legitimately slow command.
232
+ // `??=` is not enough: the SDK treats a set-but-empty var as absent and silently falls back to
233
+ // its own 120s, so a blank host value must take our default too — only a real value wins.
234
+ const setIfBlank = (key: string, value: string) => { if (!sdkEnv[key]?.trim()) sdkEnv[key] = value; };
235
+ setIfBlank('BASH_DEFAULT_TIMEOUT_MS', process.env.AGENT_SHELL_TIMEOUT_MS?.trim() || '60000');
236
+ setIfBlank('BASH_MAX_TIMEOUT_MS', process.env.AGENT_SHELL_MAX_TIMEOUT_MS?.trim() || '600000');
228
237
  sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
229
238
 
230
239
  const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
@@ -7,10 +7,9 @@
7
7
  import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { dataPath } from './paths.ts';
10
- import { appendMessage, getSession, type ConvBlock } from './sessions.ts';
11
- import { addUnread } from './unread.ts';
12
- import { postMessage, slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
13
- import { findSlackSessionBySessionId } from './slack/sessions.ts';
10
+ import { getSession } from './sessions.ts';
11
+ import { slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
12
+ import { initWake, wakeSession, type TurnRunner } from './wake.ts';
14
13
 
15
14
  const PREFIX = '[polls]';
16
15
 
@@ -28,14 +27,10 @@ export interface PollRecord extends PollSpec {
28
27
  createdAt: number;
29
28
  }
30
29
 
31
- // ── IoC: injected by index.ts at startup to avoid a claude.ts <-> polls.ts cycle ──
32
- type TurnRunner = (args: { prompt: string; sessionId: string; uid: string; userEmail?: string }) => Promise<ConvBlock[]>;
33
- let runTurn: TurnRunner | null = null;
34
- let broadcastFn: ((ev: object) => void) | null = null;
35
-
30
+ // ── IoC: injected by boot.ts at startup to avoid a claude.ts <-> polls.ts cycle. The runners live
31
+ // in wake.ts now every subsystem that reports back after its turn ended shares them. ──
36
32
  export function initPolls(deps: { runTurn: TurnRunner; broadcast: (ev: object) => void }): void {
37
- runTurn = deps.runTurn;
38
- broadcastFn = deps.broadcast;
33
+ initWake(deps);
39
34
  setInterval(sweep, 60_000);
40
35
  console.log(`${PREFIX} sweeper started`);
41
36
  }
@@ -140,7 +135,6 @@ async function closePoll(pollId: string, reason: 'deadline' | 'quorum' | 'manual
140
135
 
141
136
  // ── Close then report: wake the originating session once with the tally ─────────
142
137
  async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
143
- if (!runTurn) { console.warn(`${PREFIX} no turn runner; skipping wake for ${p.pollId}`); return; }
144
138
  if (!getSession(p.sessionId)) { console.warn(`${PREFIX} session ${p.sessionId} gone; skipping wake`); return; }
145
139
 
146
140
  // Per-option voters, with Slack user ids resolved to display names (cached).
@@ -161,15 +155,5 @@ async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
161
155
  const headline = p.kind === 'question' ? 'Your question was answered' : `Your poll closed (${reason})`;
162
156
  const prompt = `[Poll result] ${headline}. Title: "${p.title}". ${voterCount(p)} participant(s).\n${lines}\n\nFollow up appropriately (summarize, take the next action, or notify the relevant people). Do not re-post the poll.`;
163
157
 
164
- appendMessage(p.sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'poll' });
165
- broadcastFn?.({ type: 'session_messages_changed', sessionId: p.sessionId });
166
-
167
- const blocks = await runTurn({ prompt, sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail });
168
- if (!blocks.length) return;
169
- appendMessage(p.sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
170
- broadcastFn?.({ type: 'session_messages_changed', sessionId: p.sessionId });
171
- const text = blocks.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map((b) => b.text).join('\n\n').trim();
172
- addUnread(p.uid, p.sessionId, text.slice(0, 120) || 'Poll closed', 'proactive', p.title);
173
- const slack = findSlackSessionBySessionId(p.sessionId);
174
- if (slack && text) await postMessage(slack.channel, text, slack.threadTs, slack.useUserToken).catch((e) => console.error(`${PREFIX} slack deliver failed:`, (e as Error)?.message));
158
+ await wakeSession({ sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail, prompt, channel: 'poll', title: p.title, unreadFallback: 'Poll closed' });
175
159
  }
@@ -0,0 +1,95 @@
1
+ // Waking a session from out-of-band work — "something finished while nobody was in the turn,
2
+ // tell the user, wherever that session speaks".
3
+ //
4
+ // Every async subsystem that resolves after its turn is over needs the SAME tail: persist the
5
+ // trigger as a user-channel message, run one agent turn on it, persist + broadcast the reply,
6
+ // mark it unread, and relay to the Slack thread when the session has one. polls.ts grew it first
7
+ // ("close then report"); background-jobs.ts needs it verbatim, so it lives here instead of twice.
8
+ //
9
+ // Decoupled from claude.ts via injected runners (see initWake) — same IoC polls.ts used, for the
10
+ // same reason (claude.ts ← → subsystem import cycle).
11
+ import { appendMessage, getSession, type ConvBlock } from './sessions.ts';
12
+ import { addUnread } from './unread.ts';
13
+ import { postMessage } from './slack/api.ts';
14
+ import { findSlackSessionBySessionId } from './slack/sessions.ts';
15
+
16
+ const PREFIX = '[wake]';
17
+
18
+ export type TurnRunner = (args: { prompt: string; sessionId: string; uid: string; userEmail?: string }) => Promise<ConvBlock[]>;
19
+
20
+ let runTurn: TurnRunner | null = null;
21
+ let broadcastFn: ((ev: object) => void) | null = null;
22
+
23
+ export function initWake(deps: { runTurn: TurnRunner; broadcast: (ev: object) => void }): void {
24
+ runTurn = deps.runTurn;
25
+ broadcastFn = deps.broadcast;
26
+ }
27
+
28
+ /** True once boot has wired a turn runner. Callers that can degrade (deliver raw text instead of
29
+ * running a turn) check this rather than silently dropping their report. */
30
+ export function wakeReady(): boolean { return !!runTurn; }
31
+
32
+ export function broadcastSessionChanged(sessionId: string): void {
33
+ broadcastFn?.({ type: 'session_messages_changed', sessionId });
34
+ }
35
+
36
+ /**
37
+ * Put `text` into the session as an assistant message and push it to every surface that session
38
+ * speaks on (web stream + unread badge, and the Slack thread when one is mapped).
39
+ *
40
+ * This is the delivery half on its own: used directly when we must report WITHOUT running a turn
41
+ * (no runner wired, or the session stayed busy past the defer cap) so the outcome is still visible
42
+ * rather than silently dropped.
43
+ */
44
+ export async function deliverToSession(i: { sessionId: string; uid: string; text: string; title?: string }): Promise<void> {
45
+ const text = i.text.trim();
46
+ if (!text) return;
47
+ appendMessage(i.sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks: [{ type: 'text', text }] });
48
+ broadcastSessionChanged(i.sessionId);
49
+ addUnread(i.uid, i.sessionId, text.slice(0, 120), 'proactive', i.title);
50
+ const slack = findSlackSessionBySessionId(i.sessionId);
51
+ if (slack) await postMessage(slack.channel, text, slack.threadTs, slack.useUserToken)
52
+ .catch((e) => console.error(`${PREFIX} slack deliver failed:`, (e as Error)?.message));
53
+ }
54
+
55
+ export type WakeOutcome = 'woke' | 'no-session' | 'not-ready' | 'no-output';
56
+
57
+ /**
58
+ * Run one agent turn on `prompt` in an existing session and deliver whatever it says.
59
+ *
60
+ * The caller is responsible for NOT calling this while a turn is already streaming in that
61
+ * session (see isSessionLocked) — two concurrent turns would interleave into one transcript.
62
+ */
63
+ export async function wakeSession(i: {
64
+ sessionId: string; uid: string; userEmail?: string;
65
+ prompt: string;
66
+ /** Message channel tag for the injected trigger (e.g. 'poll', 'job') — shows provenance in the thread. */
67
+ channel: string;
68
+ /** Unread-badge title. */
69
+ title?: string;
70
+ /** Badge text to use when the turn ran but produced no TEXT block. Without it such a turn is
71
+ * reported as 'no-output' and raises no badge — but a turn whose only block is `{type:'error'}`
72
+ * is precisely when the user most needs telling. Callers that can degrade some other way (jobs
73
+ * fall back to a raw report) leave this unset and handle 'no-output' themselves. */
74
+ unreadFallback?: string;
75
+ }): Promise<WakeOutcome> {
76
+ if (!runTurn) { console.warn(`${PREFIX} no turn runner; cannot wake ${i.sessionId}`); return 'not-ready'; }
77
+ if (!getSession(i.sessionId)) { console.warn(`${PREFIX} session ${i.sessionId} gone; skipping wake`); return 'no-session'; }
78
+
79
+ appendMessage(i.sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: i.prompt }], channel: i.channel });
80
+ broadcastSessionChanged(i.sessionId);
81
+
82
+ const blocks = await runTurn({ prompt: i.prompt, sessionId: i.sessionId, uid: i.uid, userEmail: i.userEmail });
83
+ if (!blocks.length) return 'no-output';
84
+ appendMessage(i.sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
85
+ broadcastSessionChanged(i.sessionId);
86
+
87
+ const text = blocks.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map((b) => b.text).join('\n\n').trim();
88
+ if (!text && !i.unreadFallback) return 'no-output';
89
+ addUnread(i.uid, i.sessionId, text.slice(0, 120) || i.unreadFallback!, 'proactive', i.title);
90
+ const slack = findSlackSessionBySessionId(i.sessionId);
91
+ // Nothing to say out loud when there is no text — but the badge above still went up.
92
+ if (slack && text) await postMessage(slack.channel, text, slack.threadTs, slack.useUserToken)
93
+ .catch((e) => console.error(`${PREFIX} slack deliver failed:`, (e as Error)?.message));
94
+ return text ? 'woke' : 'no-output';
95
+ }