shraga 0.1.79 → 0.1.81

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.79",
3
+ "version": "0.1.81",
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",
@@ -231,7 +231,15 @@ export async function startJob(owner: JobOwner, command: string): Promise<string
231
231
  // stdin is /dev/null so a child can never block on (or steal) input; stdout+stderr go straight
232
232
  // to the fd — nothing is buffered in this process, so a chatty job costs disk, not memory.
233
233
  const proc = spawn('/bin/sh', ['-c', buildShell(id, cmd)], {
234
- cwd: owner.cwd, env: { ...childEnv(), ...owner.env },
234
+ cwd: owner.cwd,
235
+ env: {
236
+ ...childEnv(),
237
+ ...owner.env,
238
+ // A long-running launcher can gate on these: only a REGISTERED job gets a completion wake,
239
+ // so one started in the foreground orphans silently when its 60s tool call is killed.
240
+ SHRAGA_JOB_ID: id,
241
+ SHRAGA_BG_JOB: '1',
242
+ },
235
243
  detached: true, stdio: ['ignore', fd, fd],
236
244
  });
237
245
  j.pid = proc.pid;
@@ -251,6 +259,47 @@ export async function startJob(owner: JobOwner, command: string): Promise<string
251
259
  return id;
252
260
  }
253
261
 
262
+ /** The little of a spawned child this module needs to adopt one (node's ChildProcess satisfies it). */
263
+ type AdoptableProcess = {
264
+ pid?: number | null;
265
+ stdout?: { on(ev: 'data', cb: (chunk: unknown) => void): void } | null;
266
+ stderr?: { on(ev: 'data', cb: (chunk: unknown) => void): void } | null;
267
+ on(ev: 'close' | 'error', cb: (arg: never) => void): void;
268
+ unref?: () => void;
269
+ };
270
+
271
+ /**
272
+ * Register an ALREADY-RUNNING child as a background job. The Shell tool hands a foreground command
273
+ * over at its deadline instead of killing it: the work continues, and from here it is an ordinary job
274
+ * — polled with ShellOutput, and reported back to the session when it ends. Without this the deadline
275
+ * stays a kill, which is what abandoned a scheduled run midway.
276
+ */
277
+ export function adoptJob(owner: JobOwner, command: string, proc: AdoptableProcess, seed = ''): string {
278
+ const id = `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
279
+ const j: JobRecord = {
280
+ id, sessionId: owner.sessionId, uid: owner.uid, userEmail: owner.userEmail,
281
+ command: command.trim(), cwd: owner.cwd, startedAt: Date.now(), status: 'running',
282
+ pid: proc.pid ?? undefined,
283
+ };
284
+ // The child was spawned with pipes by the caller, so output is appended here rather than dup'd to
285
+ // an fd the way a job we spawn ourselves does it.
286
+ const append = (chunk: unknown) => {
287
+ const text = typeof chunk === 'string' ? chunk : String(chunk ?? '');
288
+ if (!text) return;
289
+ try { writeFileSync(logFile(id), text, { flag: 'a' }); } catch { /* the job outlives its log */ }
290
+ };
291
+ if (seed) append(seed);
292
+ proc.stdout?.on('data', append);
293
+ proc.stderr?.on('data', append);
294
+ proc.on('error', ((err: Error) => finish(id, 'error', undefined, `adopted job error: ${err?.message ?? err}`)) as never);
295
+ proc.on('close', ((code: number | null) => finish(id, 'exited', code ?? undefined)) as never);
296
+ proc.unref?.();
297
+ records.set(id, j);
298
+ save(j);
299
+ console.log(`${PREFIX} adopted ${id} pid=${j.pid} session=${j.sessionId.slice(0, 8)} cmd=${j.command.slice(0, 120)}`);
300
+ return id;
301
+ }
302
+
254
303
  /** Record a terminal state exactly once, then schedule the follow-up. */
255
304
  function finish(id: string, status: JobStatus, exitCode?: number, note?: string): void {
256
305
  const j = records.get(id);
@@ -479,6 +528,7 @@ export function sessionJobRegistry(owner: JobOwner) {
479
528
  };
480
529
  return {
481
530
  start: (command: string) => startJob(owner, command),
531
+ adopt: (command: string, proc: AdoptableProcess, seed?: string) => adoptJob(owner, command, proc, seed),
482
532
  output(id: string): string | null {
483
533
  const j = mine(id);
484
534
  if (!j) return null;
@@ -208,7 +208,7 @@ export async function runSchedule(
208
208
  // Tell the run how to report its own truthful outcome (scheduler/outcome.ts). Prompt tasks only:
209
209
  // a `bash` task's permission handler allows nothing but the task's own command, so such a run
210
210
  // could not write the file even if it wanted to — its exit code is already the truth there.
211
- if (task.kind === 'prompt') {
211
+ if ((task.kind ?? 'prompt') === 'prompt') {
212
212
  // Cleared on RESUME too: a resume reuses the interrupted run's session id, so a declaration left
213
213
  // by the attempt that crashed would be adopted as this attempt's verdict. The contract is
214
214
  // re-stated for the same reason — the resumed turn must be able to declare for itself.
@@ -401,7 +401,7 @@ export async function runSchedule(
401
401
  // The run's OWN verdict beats "the turn returned" — see scheduler/outcome.ts. Deliberately after
402
402
  // the finally: the session lock is released by now, so a run that declared `pending` can be closed
403
403
  // by a later turn in this session (a background job's wake, a follow-up message) while we wait.
404
- if (status === 'ok' && task.kind === 'prompt') {
404
+ if (status === 'ok' && (task.kind ?? 'prompt') === 'prompt') {
405
405
  const declared = await resolveDeclaredOutcome(sessionId, abortController, schedule.id);
406
406
  if (declared) {
407
407
  status = declared.status;
@@ -27,11 +27,28 @@ export function saveThrottleState(state: Record<string, number>): void {
27
27
  renameSync(tmp, THROTTLE_FILE);
28
28
  }
29
29
 
30
+ /**
31
+ * Schedules persisted before `task.kind` existed carry only the payload fields. Everything
32
+ * downstream branches on the discriminant, so infer it from the shape once, on load — a task
33
+ * missing its kind silently skipped the outcome contract and reported every run as `ok`.
34
+ * Only `prompt` is inferable: `bash` and `job` are both `{ command }`, and guessing between them
35
+ * would hand a run the wrong permission handler, so a kindless command task is left alone.
36
+ */
37
+ function normalizeTask(task: Record<string, unknown>): void {
38
+ if (task.kind) return;
39
+ if (typeof task.prompt === 'string' || typeof task.promptFile === 'string') task.kind = 'prompt';
40
+ }
41
+
42
+ function normalizeSchedule(s: Schedule): Schedule {
43
+ if (s.task && typeof s.task === 'object') normalizeTask(s.task as unknown as Record<string, unknown>);
44
+ return s;
45
+ }
46
+
30
47
  export function loadSchedules(): Schedule[] {
31
48
  if (!existsSync(FILE)) return [];
32
49
  try {
33
50
  const parsed = JSON.parse(readFileSync(FILE, 'utf-8'));
34
- return Array.isArray(parsed) ? parsed : [];
51
+ return Array.isArray(parsed) ? parsed.map((s) => normalizeSchedule(s as Schedule)) : [];
35
52
  } catch (err) {
36
53
  console.error('[scheduler] failed to parse schedules.json:', err);
37
54
  return [];