neurastream 0.1.7 → 0.1.9
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 +58 -8
- package/package.json +8 -2
- package/src/adapters/omp/index.ts +2 -1
- package/src/adapters/omp/sessions.ts +11 -12
- package/src/core/agent-ledger.ts +8 -0
- package/src/core/main-start.ts +62 -18
package/README.md
CHANGED
|
@@ -1,19 +1,54 @@
|
|
|
1
1
|
# neurastream
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Guardrails for running many AI coding agents at once.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
One "main" agent plans the work and reviews it; cheaper "helper" agents do the coding. neurastream is the set of checks between them. It keeps a queue of written plans, refuses helper actions a plan forbids, limits how many helpers run per provider, scores each helper by accepted work per dollar, and only lets a plan leave the queue once its branch is really merged.
|
|
6
|
+
|
|
7
|
+
The core does not depend on one agent tool. Adapters translate a tool's events into core calls. The first adapter is [omp](https://github.com/can1357/oh-my-pi).
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
flowchart LR
|
|
11
|
+
O[Owner message] --> P[Plan file in docs/plans]
|
|
12
|
+
P --> Q[queue-check]
|
|
13
|
+
Q --> S[Spawn checks<br/>ledger cap, score gate, brief-lint]
|
|
14
|
+
S --> H[Helper agent<br/>blockers + call budget]
|
|
15
|
+
H --> R[Main review<br/>agent-score accept]
|
|
16
|
+
R --> M[Merge to main]
|
|
17
|
+
M --> C[queue-close<br/>merged proof + done check]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What it does
|
|
6
21
|
|
|
7
|
-
|
|
22
|
+
| Part | What it enforces |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Queue (`queue-check`, `queue-close`, `queue-cancel`) | Every open plan is listed in an index and follows a template. A plan leaves the queue only when its branch is merged into remote main and its done check passes; a failing check reopens it. A cancelled plan's unmerged work is saved under `refs/queue-archive/`. |
|
|
25
|
+
| Blockers | Each plan's phase 0 lists what helpers may not do: push, touch a path, run a command, or write a text. The guard refuses those calls. |
|
|
26
|
+
| Guards | Helpers may only edit their plan's `## Progress`; they get a warning at call 90 and are stopped at call 100. Protected files need an owner unlock (a file the owner touches, valid 30 minutes). |
|
|
27
|
+
| Ledger (`agent-ledger`) | Tokens, cost, state and exit reason per agent; `--cap` refuses a spawn when a provider already runs 2. |
|
|
28
|
+
| Scoring (`agent-score`) | Points only for work Main accepted by commit, times the phase difficulty. Ranked per dollar and per hour. |
|
|
29
|
+
| Brief lint (`brief-lint`) | Refuses a helper brief that misses a required rule (acceptance, stop condition, no push, call budget). |
|
|
30
|
+
| Machine health (`machine-guard`, `main-start`, `heavy-run`) | Load, free memory, disk and `/tmp` limits; one heavy test run at a time. |
|
|
31
|
+
| Unlanded work (`wip-check`, `wip-snapshot`, `wip-backup`) | Lists branches on no remote and dirty worktrees; snapshots them as hidden commits; can push them to a backup repo. |
|
|
32
|
+
| Session rollover | When the main session file passes 10 MB, the next owner message starts a fresh session from a handoff note. |
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- [Bun](https://bun.sh) 1.x
|
|
37
|
+
- git
|
|
38
|
+
- omp, for the guards and rollover (the command-line tools work without it)
|
|
39
|
+
|
|
40
|
+
## Install
|
|
8
41
|
|
|
9
42
|
```sh
|
|
10
|
-
export HOME=/tmp/neurastream-home-dev
|
|
11
43
|
bun add -g neurastream
|
|
12
44
|
neurastream init
|
|
13
45
|
neurastream adapter install omp
|
|
46
|
+
neurastream doctor
|
|
14
47
|
```
|
|
15
48
|
|
|
16
|
-
`init` writes `~/.config/neurastream/config.yml` from the defaults in
|
|
49
|
+
`init` writes `~/.config/neurastream/config.yml` from the defaults in [docs/config.md](docs/config.md), plus an empty index, notes, the queue template, blocker wrappers, and the state directories. A second run changes nothing. `adapter install omp` links the extension (`~/.omp/agent/extensions` and `omp plugin link`). `doctor` exits 0 when the install matches the package.
|
|
50
|
+
|
|
51
|
+
The defaults are the author's setup (paths, the backup repo, provider names). Edit `config.yml` before relying on them. To try it without touching your real home, set `HOME=/tmp/neurastream-home-dev` first.
|
|
17
52
|
|
|
18
53
|
Check the merged result:
|
|
19
54
|
|
|
@@ -21,11 +56,21 @@ Check the merged result:
|
|
|
21
56
|
neurastream config --json
|
|
22
57
|
```
|
|
23
58
|
|
|
24
|
-
|
|
59
|
+
Every tool is one program, selected by the name it was called with: `agent-score`, `agent-ledger`, `brief-lint`, `oc-run`, `wip-check`, `wip-backup`, `wip-snapshot`, `pm-fail-facts`, `main-health`, `queue-check`, `queue-close`, `queue-cancel`, `main-start`, `machine-guard`, `glm-reset`, `priority-watch`, `resource-alerts`, `tmp-snapshot`, `heavy-run`.
|
|
60
|
+
|
|
61
|
+
## Develop
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
bun install
|
|
65
|
+
bun run typecheck
|
|
66
|
+
bun test
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Green is both exiting 0.
|
|
25
70
|
|
|
26
|
-
Scoring
|
|
71
|
+
## Scoring details
|
|
27
72
|
|
|
28
|
-
|
|
73
|
+
`agent-score accept` records accepted work by commit; `agent-score validate <Agent> --plan <plan.md> --phase <P> --evidence <report.md> --section "<heading prefix>"` records a validation phase (checks/tests ran, report section written, no commits) with proof Main cannot fake by typing: the plan line must be ticked `- [x]`, the cited section body must name a heavy-run label run by that agent (from `paths.heavy_log`), and the phase must not be scored already. The ranking board counts it in `Accepted (diag/val)` as `N (d/v)`.
|
|
29
74
|
|
|
30
75
|
## Main reads the code for its plans
|
|
31
76
|
|
|
@@ -51,6 +96,7 @@ The switch is never automatic:
|
|
|
51
96
|
- 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
97
|
- 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
98
|
- `/main-rollover <message>` does the same by hand. It refuses below the threshold, without a fresh note, or while jobs run.
|
|
99
|
+
- `/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
100
|
- `rollover.enabled: false` turns the feature off.
|
|
55
101
|
|
|
56
102
|
## Changing a script
|
|
@@ -63,3 +109,7 @@ neurastream adapter install omp
|
|
|
63
109
|
```
|
|
64
110
|
|
|
65
111
|
Live copies are never hand-edited. `neurastream doctor` names each hand-edited or foreign installed file, and a stale install (the installed version is not `npm view neurastream version`). If the registry cannot be reached, that check is a warning, not a failure.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT. See [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neurastream",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "Guardrails for running many AI coding agents at once: plan queue, blockers, call budgets, provider caps, scoring, merge-verified close.",
|
|
5
|
+
"keywords": ["ai-agents", "coding-agents", "orchestration", "guardrails", "omp", "bun", "cli"],
|
|
6
|
+
"homepage": "https://github.com/umiddey/neurastream#readme",
|
|
7
|
+
"bugs": "https://github.com/umiddey/neurastream/issues",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"repository": {
|
|
8
11
|
"type": "git",
|
|
9
12
|
"url": "git+https://github.com/umiddey/neurastream.git"
|
|
10
13
|
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"bun": ">=1.0"
|
|
16
|
+
},
|
|
11
17
|
"publishConfig": {
|
|
12
18
|
"access": "public"
|
|
13
19
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
},
|
package/src/core/agent-ledger.ts
CHANGED
|
@@ -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);
|
package/src/core/main-start.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
20
|
-
const mgLines =
|
|
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 =
|
|
26
|
-
const awk =
|
|
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
|
|
73
|
+
{ stdin: "pipe", input: ledger.stdout, stderr: "ignore" },
|
|
30
74
|
);
|
|
31
|
-
out.push(awk.stdout
|
|
32
|
-
const cap =
|
|
33
|
-
for (const line of
|
|
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 =
|
|
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 =
|
|
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 =
|
|
56
|
-
const text = `${doctor.stdout
|
|
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
|
}
|