neurastream 0.1.7 → 0.1.8

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/README.md CHANGED
@@ -51,6 +51,7 @@ The switch is never automatic:
51
51
  - The owner's next plain message then closes the session and starts a fresh one. The fresh session opens with the note verbatim, the generated `main-start` state (including the owner holds), and then the owner's message. The previous session file stays where it is.
52
52
  - While own jobs (subagents included) are still running, the switch waits: a rollover would cancel them, so the owner's message stays in the current session until they finish.
53
53
  - `/main-rollover <message>` does the same by hand. It refuses below the threshold, without a fresh note, or while jobs run.
54
+ - `/main-rollover` shows a notice at once and gathers `main-start` in the background, so omp keeps repainting and reading keys until the new session opens.
54
55
  - `rollover.enabled: false` turns the feature off.
55
56
 
56
57
  ## Changing a script
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neurastream",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Harness-agnostic agent orchestration: queue, guards, blockers, scoring, ledger.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -432,7 +432,8 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
432
432
  c.notify(`main-rollover: own jobs are still running (${c.runningJobs().join(", ")}); a rollover would cancel them.`);
433
433
  return;
434
434
  }
435
- const generated = [await mainStartText([], cfg), holdsBlock(q.ownerHolds())].join("\n");
435
+ c.notify("main-rollover: collecting main-start state for the handoff (this can take a minute)…");
436
+ const generated = [await mainStartText([], cfg, { inheritStdin: false }), holdsBlock(q.ownerHolds())].join("\n");
436
437
  const handoff = buildHandoff({
437
438
  generated,
438
439
  note: readFileSync(path, "utf8"),
@@ -54,17 +54,10 @@ function costOf(m: Record<string, unknown>): number {
54
54
  return c && typeof c.total === "number" ? c.total : 0;
55
55
  }
56
56
 
57
- interface Built {
58
- run: Run;
59
- mtime: number;
60
- }
61
-
62
- function build(file: string, cfg: Config): Built | null {
57
+ function build(file: string, cfg: Config, mtime: number): Run | null {
63
58
  let text: string;
64
- let mtime: number;
65
59
  try {
66
60
  text = readFileSync(file, "utf8");
67
- mtime = statSync(file).mtimeMs / 1000;
68
61
  } catch {
69
62
  return null;
70
63
  }
@@ -202,7 +195,7 @@ function build(file: string, cfg: Config): Built | null {
202
195
  scoreSurvived: done && !scoreErr,
203
196
  scoreEnd: scoreErr || (done ? "done" : "no final message"),
204
197
  };
205
- return { run, mtime };
198
+ return run;
206
199
  }
207
200
 
208
201
  function readMain(file: string): MainSession {
@@ -253,9 +246,15 @@ export function ompRunSource(root: string, cfg: Config): RunSource {
253
246
  const files = [...jsonlAt(root, 1), ...jsonlAt(root, 2)];
254
247
  const runs: Run[] = [];
255
248
  for (const file of files) {
256
- const built = build(file, cfg);
257
- if (!built || built.mtime < since) continue;
258
- runs.push(built.run);
249
+ let mtime: number;
250
+ try {
251
+ mtime = statSync(file).mtimeMs / 1000;
252
+ } catch {
253
+ continue;
254
+ }
255
+ if (mtime < since) continue;
256
+ const run = build(file, cfg, mtime);
257
+ if (run) runs.push(run);
259
258
  }
260
259
  return runs;
261
260
  },
@@ -64,13 +64,21 @@ export function agentLedger(args: string[], cfg: Config, source: RunSource): num
64
64
 
65
65
  if (!args.includes("--cap")) return 0;
66
66
  const live: Record<string, string[]> = {};
67
+ const notLive: string[] = [];
67
68
  for (const r of rows) {
68
69
  if ((r.statusDetail.startsWith("RUNNING") || r.statusDetail.startsWith("WAITING")) && !r.id.startsWith("MAIN")) {
70
+ if ((nowSec() - r.lastWriteAt) / 60 >= cfg.ledger.idle_not_live_min) {
71
+ notLive.push(r.id);
72
+ continue;
73
+ }
69
74
  const provider = r.model.split("/")[0] ?? "";
70
75
  (live[provider] ??= []).push(r.id);
71
76
  }
72
77
  }
73
78
  process.stdout.write(`running per provider: ${pyDictInt(Object.fromEntries(Object.entries(live).map(([k, v]) => [k, v.length])))}\n`);
79
+ if (notLive.length) {
80
+ process.stdout.write(`NOT LIVE (no write for ≥ ${cfg.ledger.idle_not_live_min}m, not counted): ${notLive.join(", ")}\n`);
81
+ }
74
82
  let full = Object.entries(live).filter(([, v]) => v.length >= cfg.ledger.max_running_per_provider).map(([k]) => k);
75
83
  for (const k of full) process.stdout.write(`FULL ${k}: ${live[k]!.join(", ")}\n`);
76
84
  const over = rows.filter(r => r.statusDetail.includes("OVER-BUDGET")).map(r => r.id);
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { Config } from "./config.ts";
@@ -8,39 +8,83 @@ function bin(name: string, cfg: Config): string {
8
8
  return join(cfg.paths.home, ".local/bin", name);
9
9
  }
10
10
 
11
+ interface RunOpts {
12
+ stdin?: "inherit" | "ignore" | "pipe";
13
+ /** Written to the child's stdin when stdin is "pipe". */
14
+ input?: string;
15
+ stderr?: "inherit" | "pipe" | "ignore";
16
+ }
17
+
18
+ interface RunResult {
19
+ stdout: string;
20
+ stderr: string;
21
+ /** Set when the child could not start (e.g. ENOENT). */
22
+ error?: Error;
23
+ }
24
+
25
+ // Runs a child without blocking the caller's event loop. Resolves once the child has closed its
26
+ // streams, or immediately when it fails to start. The handoff runs inside a live agent session,
27
+ // which must keep handling events while Main's state is gathered.
28
+ function run(file: string, args: string[], opts: RunOpts = {}): Promise<RunResult> {
29
+ const { promise, resolve } = Promise.withResolvers<RunResult>();
30
+ const child = spawn(file, args, {
31
+ stdio: [opts.stdin ?? "ignore", "pipe", opts.stderr ?? "pipe"],
32
+ });
33
+ let stdout = "";
34
+ let stderr = "";
35
+ child.stdout?.setEncoding("utf8").on("data", chunk => {
36
+ stdout += chunk;
37
+ });
38
+ child.stderr?.setEncoding("utf8").on("data", chunk => {
39
+ stderr += chunk;
40
+ });
41
+ // A filter such as awk may exit before it has read all its input.
42
+ child.stdin?.on("error", () => {});
43
+ if (opts.stdin === "pipe") child.stdin?.end(opts.input ?? "");
44
+ child.on("error", error => resolve({ stdout, stderr, error }));
45
+ child.on("close", () => resolve({ stdout, stderr }));
46
+ return promise;
47
+ }
48
+
11
49
  // Main's session-start preflight as one text. The CLI prints it; the session rollover handoff
12
50
  // (src/core/rollover.ts) carries the same text into the new session. Children's stdout is
13
51
  // collected in order, so the printed output matches the original script line for line.
14
- export function mainStartText(args: string[], cfg: Config): string {
52
+ // inheritStdin: the CLI passes its terminal to the children that ask the operator something;
53
+ // an agent host passes false, because its stdin is the session's input.
54
+ // stderr: the CLI lets a child's stderr reach the terminal. An agent host captures it and appends it
55
+ // after that child's stdout, because a write to the terminal would draw over the session's UI.
56
+ export async function mainStartText(args: string[], cfg: Config, opts: { inheritStdin: boolean }): Promise<string> {
15
57
  const out: string[] = [];
16
58
  const say = (line: string) => out.push(`${line}\n`);
17
59
  const h = args[0] ?? String(cfg.health.default_hours);
60
+ const childStdin = opts.inheritStdin ? "inherit" : "ignore";
61
+ const childStderr = opts.inheritStdin ? "inherit" : "pipe";
18
62
  say("=== machine");
19
- const mg = spawnSync(bin("machine-guard", cfg), [], { encoding: "utf8", env: process.env });
20
- const mgLines = (mg.stdout ?? "").trim().split("\n");
63
+ const mg = await run(bin("machine-guard", cfg), [], { stderr: "ignore" });
64
+ const mgLines = mg.stdout.trim().split("\n");
21
65
  say(mgLines.at(-1) ?? "");
22
66
  say(readFileSync("/proc/loadavg", "utf8").trim());
23
67
  say("");
24
68
  say(`=== agents (last ${h}h; DIED = check its worktree, relaunch FRESH with its progress file)`);
25
- const ledger = spawnSync(bin("agent-ledger", cfg), ["--hours", h], { encoding: "utf8", env: process.env });
26
- const awk = spawnSync(
69
+ const ledger = await run(bin("agent-ledger", cfg), ["--hours", h], { stderr: "ignore" });
70
+ const awk = await run(
27
71
  "awk",
28
72
  ["NR==1 || /DIED/ {print; next} {ok[++n]=$0} END {for (i=1;i<=n;i++) print ok[i]}"],
29
- { input: ledger.stdout ?? "", encoding: "utf8" },
73
+ { stdin: "pipe", input: ledger.stdout, stderr: "ignore" },
30
74
  );
31
- out.push(awk.stdout ?? "");
32
- const cap = spawnSync(bin("agent-ledger", cfg), ["--cap"], { encoding: "utf8", env: process.env, stdio: ["ignore", "pipe", "ignore"] });
33
- for (const line of (cap.stdout ?? "").split("\n")) {
75
+ out.push(awk.stdout);
76
+ const cap = await run(bin("agent-ledger", cfg), ["--cap"], { stderr: "ignore" });
77
+ for (const line of cap.stdout.split("\n")) {
34
78
  if (/^(PRIORITY IDLE|FULL)/.test(line)) say(line);
35
79
  }
36
80
  say("");
37
81
  say("=== main thread");
38
- const health = spawnSync(bin("main-health", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
39
- out.push(health.stdout ?? "");
82
+ const health = await run(bin("main-health", cfg), [], { stdin: childStdin, stderr: childStderr });
83
+ out.push(health.stdout + health.stderr);
40
84
  say("");
41
85
  say("=== queue");
42
- const check = spawnSync(bin("queue-check", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
43
- out.push(check.stdout ?? "");
86
+ const check = await run(bin("queue-check", cfg), [], { stdin: childStdin, stderr: childStderr });
87
+ out.push(check.stdout + check.stderr);
44
88
  say("");
45
89
  say("=== heavy-run lock");
46
90
  try {
@@ -52,14 +96,14 @@ export function mainStartText(args: string[], cfg: Config): string {
52
96
  // The CLI file itself (shebang `#!/usr/bin/env bun`), never process.execPath: inside a host
53
97
  // harness that is the harness binary, which would start a new agent session instead.
54
98
  const cli = join(dirname(fileURLToPath(import.meta.url)), "../cli/main.ts");
55
- const doctor = spawnSync(cli, ["doctor"], { encoding: "utf8", env: process.env });
56
- const text = `${doctor.stdout ?? ""}${doctor.stderr ?? ""}`;
99
+ const doctor = await run(cli, ["doctor"]);
100
+ const text = `${doctor.stdout}${doctor.stderr}`;
57
101
  out.push(text.endsWith("\n") || text === "" ? text : `${text}\n`);
58
102
  if (doctor.error) say(doctor.error.message);
59
103
  return out.join("");
60
104
  }
61
105
 
62
- export function mainStart(args: string[], cfg: Config): number {
63
- process.stdout.write(mainStartText(args, cfg));
106
+ export async function mainStart(args: string[], cfg: Config): Promise<number> {
107
+ process.stdout.write(await mainStartText(args, cfg, { inheritStdin: true }));
64
108
  return 0;
65
109
  }