neurastream 0.1.4 → 0.1.5

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": "neurastream",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Harness-agnostic agent orchestration: queue, guards, blockers, scoring, ledger.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,13 @@ export function adapterLiveFiles(home: string, root: string = PACKAGE_ROOT): Liv
36
36
  for (const bin of packageBins(root)) {
37
37
  files.push({ live: join(home, ".local/bin", bin.name), source: bin.source });
38
38
  }
39
+ // P9: the extension symlink is gone (ONE load route — the plugin below);
40
+ // what the adapter installs under the agent dir is the omp plugin link,
41
+ // whose realpath is an installed neurastream package root. `omp plugin
42
+ // link` owns this file; install must never ensureLink it.
39
43
  files.push({
40
- live: join(home, ".omp/agent/extensions/neurastream.ts"),
41
- source: join(root, "src/adapters/omp/index.ts"),
44
+ live: join(home, ".omp/plugins/node_modules/neurastream"),
45
+ source: root,
42
46
  });
43
47
  files.push({
44
48
  live: join(home, ".omp/agent/queue/lib.ts"),
@@ -68,8 +72,10 @@ function bunLiveFiles(home: string, root: string): LiveFile[] {
68
72
  }
69
73
 
70
74
  function packageRootOf(file: string): string | null {
71
- let dir = dirname(file);
72
- for (let i = 0; i < 8; i++) {
75
+ // `file` itself may be the package root (the omp plugin link's realpath is
76
+ // an installed neurastream root), so the self dir is checked first.
77
+ let dir = file;
78
+ for (let i = 0; i < 9; i++) {
73
79
  const manifest = join(dir, "package.json");
74
80
  if (existsSync(manifest)) {
75
81
  try {
@@ -20,11 +20,13 @@ import { appendFileSync, mkdirSync, openSync, readFileSync, rmSync, statSync, wr
20
20
  import { join } from "node:path";
21
21
  import type { Decision } from "../../core/adapter.ts";
22
22
  import { loadConfig, type Config } from "../../core/config.ts";
23
- import { checkInject, checkPlanEdit, checkStop, checkTool, recordMainResult } from "../../core/guards.ts";
23
+ import { checkInject, checkPlanEdit, checkStop, checkTool, MAIN_PLAN_FILE, recordMainResult } from "../../core/guards.ts";
24
24
  import { applyQueueEnv } from "../../core/queue/env.ts";
25
25
  import { mainStartText } from "../../core/main-start.ts";
26
26
  import { buildHandoff, holdsBlock, notePath, noteRequest, type RolloverState, rolloverState } from "../../core/rollover.ts";
27
27
  import * as q from "../../core/queue/lib.ts";
28
+ // Registers the plugin-command matcher into core's writeTargets (side effect).
29
+ import "./package-ops.ts";
28
30
 
29
31
  /** Subset of the harness extension host the adapter needs (pi.on; herdr also uses pi.events). */
30
32
  export interface ExtensionHost {
@@ -86,11 +88,14 @@ function adapterLog(cfg: Config, line: string): void {
86
88
  // Background queue-close for every plan Main marked complete (owner 2026-10-15). Ported from the
87
89
  // original autoClose: same filter, same "--- auto-close attempt" log line, detached child with
88
90
  // stdio on the per-plan close log. QUEUE_CLOSE_BIN overrides the binary (tests); without it the
89
- // package's own CLI runs via the current bun binary, so no PATH lookup is needed.
91
+ // package's own executable bin/queue-close runs (its `#!/usr/bin/env bun` shebang picks bun).
92
+ // Never process.execPath: inside the harness that is the harness binary itself, so the child was
93
+ // a new agent session that loaded this plugin and auto-closed again — a process fork bomb
94
+ // (2026-10-21 OOMs, ~175 processes).
90
95
  function closeChildArgs(plan: string): [string, string[]] {
91
96
  const bin = process.env.QUEUE_CLOSE_BIN;
92
97
  if (bin) return [bin, [plan]];
93
- return [process.execPath, [join(import.meta.dir, "..", "..", "cli", "main.ts"), "queue-close", plan]];
98
+ return [join(import.meta.dir, "..", "..", "..", "bin", "queue-close"), [plan]];
94
99
  }
95
100
 
96
101
  export function autoClose(cfg: Config): string[] {
@@ -134,7 +139,6 @@ function ledgerText(cfg: Config): string {
134
139
 
135
140
  // Plan files a subagent write/edit may touch: snapshot before the call so tool_result can verify
136
141
  // and restore (the original snapshotted inside checkCall; core returns pure decisions now).
137
- const MAIN_PLAN = /\/docs\/plans\/(?!archive\/)[^/]+\.md$/;
138
142
  function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string, snap: Map<string, string>, main: boolean): void {
139
143
  if (tool !== "write" && tool !== "edit") return;
140
144
  const plans = q.activePlans();
@@ -142,7 +146,7 @@ function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string
142
146
  if (snap.has(p)) continue;
143
147
  if (main) {
144
148
  // Main's own plan text is checked too; a plan that did not exist yet snapshots as "".
145
- if (!MAIN_PLAN.test(p)) continue;
149
+ if (!MAIN_PLAN_FILE.test(p)) continue;
146
150
  try {
147
151
  snap.set(p, readFileSync(p, "utf8"));
148
152
  } catch {
@@ -2,13 +2,15 @@
2
2
  // Does not read or write the real user's agent directory: every path is under `home`.
3
3
 
4
4
  import { spawnSync } from "node:child_process";
5
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { configFile } from "../../core/config.ts";
10
10
 
11
11
  import { adapterLiveFiles } from "./doctor.ts";
12
+ // Registers the plugin-command matcher into core's writeTargets (side effect).
13
+ import "./package-ops.ts";
12
14
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
13
15
 
14
16
  const OVERLAY = `adapter:
@@ -18,11 +20,14 @@ const OVERLAY = `adapter:
18
20
  extensions: "~/.omp/agent/extensions"
19
21
  `;
20
22
 
21
- const PROTECTED = ["~/.omp/agent/agents/", "~/.omp/agent/extensions/"];
23
+ // P8: the plugin dir is adapter surface and lands in the config's
24
+ // guards.protected, so core's protected check covers it without naming a
25
+ // harness path. installOmp writes it via the same overlay pass.
26
+ const PROTECTED = ["~/.omp/agent/agents/", "~/.omp/agent/extensions/", "~/.omp/plugins/"];
22
27
 
23
28
  export interface InstallResult {
24
- extension: string;
25
29
  plugin: string;
30
+ removedStaleLink: string | null;
26
31
  pinsCopied: string[];
27
32
  pinsKept: string[];
28
33
  overlay: "written" | "unchanged";
@@ -46,7 +51,9 @@ function ensureLink(link: string, target: string): void {
46
51
 
47
52
  function linkInstalled(home: string): void {
48
53
  for (const file of adapterLiveFiles(home)) {
49
- if (file.live.includes("/.omp/agent/agents/")) continue;
54
+ // agents/ are copied, not linked; the plugin link is `omp plugin link`'s
55
+ // own output (linkPlugin below), never ours to ensureLink.
56
+ if (file.live.includes("/.omp/agent/agents/") || file.live.includes("/.omp/plugins/")) continue;
50
57
  ensureLink(file.live, file.source);
51
58
  }
52
59
  }
@@ -109,24 +116,37 @@ function linkPlugin(home: string): string {
109
116
  return join(home, ".omp/plugins/node_modules/neurastream");
110
117
  }
111
118
 
112
- /** Link the extension, copy missing pins, write the overlay. HOME is the sandbox or the caller's home. */
119
+ /** Link the plugin, copy missing pins, write the overlay. HOME is the sandbox or the caller's home. */
113
120
  export function installOmp(home: string): InstallResult {
114
121
  const file = configFile(home);
115
122
  if (!existsSync(file)) throw new Error(`no config at ${file}; run neurastream init first`);
123
+ // P9: ONE load route. omp loads extensions-dir files AND a plugin package's
124
+ // package.json#omp.extensions, so an extensions/neurastream.ts link beside
125
+ // `omp plugin link` runs every hook twice. Only the plugin route remains —
126
+ // the one guard parity locks (~/.omp/plugins/ + the package root). A stale
127
+ // link an older install made is removed, but only when it is a symlink: a
128
+ // real file there is never ours to delete.
116
129
  const extension = join(home, ".omp/agent/extensions/neurastream.ts");
117
- const target = join(PACKAGE_ROOT, "src/adapters/omp/index.ts");
118
- ensureLink(extension, target);
130
+ let removedStaleLink: string | null = null;
131
+ try {
132
+ if (lstatSync(extension).isSymbolicLink()) {
133
+ unlinkSync(extension);
134
+ removedStaleLink = extension;
135
+ }
136
+ } catch {
137
+ // absent: nothing stale to remove
138
+ }
119
139
  mkdirSync(join(home, ".omp/agent/sessions"), { recursive: true });
120
140
  const pins = copyPins(join(home, ".omp/agent/agents"));
121
141
  linkInstalled(home);
122
142
  const plugin = linkPlugin(home);
123
143
  const overlay = writeOverlay(file);
124
- return { extension, plugin, pinsCopied: pins.copied, pinsKept: pins.kept, overlay };
144
+ return { plugin, removedStaleLink, pinsCopied: pins.copied, pinsKept: pins.kept, overlay };
125
145
  }
126
146
 
127
147
  export function printInstall(result: InstallResult): void {
128
- console.log(`neurastream adapter install omp: linked ${result.extension}`);
129
148
  console.log(`neurastream adapter install omp: plugin ${result.plugin}`);
149
+ if (result.removedStaleLink) console.log(`neurastream adapter install omp: removed stale extension link ${result.removedStaleLink}`);
130
150
  console.log(
131
151
  `neurastream adapter install omp: pins copied ${result.pinsCopied.length}, kept ${result.pinsKept.length}; overlay ${result.overlay}`,
132
152
  );
@@ -0,0 +1,28 @@
1
+ // Adapter-owned package-operation shapes for the P8 guard parity: core's
2
+ // writeTargets never names a harness, so the plugin-command matcher lives here
3
+ // and registers itself into core at module load. Every plugin command writes
4
+ // under the plugin directory, which src/adapters/omp/install.ts lists in the
5
+ // config's guards.protected — so mapping the commands onto that directory puts
6
+ // them behind the owner's unlock like every other guard file.
7
+
8
+ import { homedir } from "node:os";
9
+ import { registerPackageOpTarget, type PackageOpMatcher } from "../../core/queue/lib.ts";
10
+
11
+ const PLUGIN_ACTIONS = ["install", "uninstall", "link", "unlink", "update"];
12
+
13
+ /** Target for `<harness> plugin <action>` segments; null when none matches. */
14
+ export const pluginCommandTarget: PackageOpMatcher = (code: string): string | null => {
15
+ for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
16
+ const toks = seg.trim().split(/\s+/).filter(Boolean);
17
+ let i = 0;
18
+ while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i] ?? "")) i++;
19
+ const cmd = (toks[i] ?? "").replace(/^["']|["']$/g, "").replace(/^.*\//, "");
20
+ if (cmd !== "omp") continue;
21
+ const sub = (toks[i + 1] ?? "").replace(/^["']+|["']+$/g, "");
22
+ if (sub === "plugin" && PLUGIN_ACTIONS.includes((toks[i + 2] ?? "").replace(/^["']+|["']+$/g, "")))
23
+ return `${homedir()}/.omp/plugins/`;
24
+ }
25
+ return null;
26
+ };
27
+
28
+ registerPackageOpTarget(pluginCommandTarget);
@@ -338,6 +338,9 @@ function mainTool(e: ToolEvent, input: Record<string, unknown>, cfg: Config): De
338
338
  return editWindow(e, input, cfg);
339
339
  }
340
340
 
341
+ /** A live plan file (archived plans excluded); Main's plan-text checks run on these. */
342
+ export const MAIN_PLAN_FILE = /\/docs\/plans\/(?!archive\/)[^/]+\.md$/;
343
+
341
344
  /**
342
345
  * pre-tool: plan-guard checkCall for subs (protected files, budget, plan-edit rules, blockers)
343
346
  * plus, for Main, the spawn gates and the project-file edit window.
@@ -358,6 +361,13 @@ export function checkTool(e: ToolEvent, cfg: Config): Decision {
358
361
  );
359
362
  }
360
363
  if (e.agent === "main") {
364
+ // The plan-text checks (checkPlanEdit) see only edit/write results; a shell redirect,
365
+ // sed -i or heredoc into a plan would skip them, so plans change through edit/write only.
366
+ const shellPlan = e.tool !== "write" && e.tool !== "edit" ? writes.find(p => MAIN_PLAN_FILE.test(p)) : undefined;
367
+ if (shellPlan) {
368
+ log(cfg.paths.plan_guard_log, `BLOCK main tool=${e.tool} shell write to plan ${shellPlan}`);
369
+ return deny(`plan-guard: ${shellPlan} is a plan file: change it with the edit or write tool (close or cancel it with queue-close / queue-cancel).`);
370
+ }
361
371
  const d = mainTool(e, input, cfg);
362
372
  // A task brief names paths it does not read; only the other tools' paths count as reads.
363
373
  if (d.allow && e.tool !== "task") recordMainReads(paths.filter(p => !writes.includes(p)));
@@ -462,23 +472,28 @@ export function flagCancel(text: string, cfg: Config): string {
462
472
 
463
473
  /**
464
474
  * prompt/owner message: inject the queue summary, flag cancel wording, reset Main's edit window
465
- * and the stop counters (the original resets them at every user message).
475
+ * and the stop counters (the original resets them at every user message). A turn the harness
476
+ * starts from this guard's own stop continuation ("main-guard: …") is not an owner message: it
477
+ * keeps the counters and the edit window, else every continuation reset the idle streak and a
478
+ * model that only answers text was nudged again forever (2026-10-21 OOM: ~205 agent starts in one
479
+ * non-interactive session).
466
480
  */
467
481
  export function checkInject(e: InjectEvent, cfg: Config): Decision {
468
482
  if (e.agent !== "main") return ALLOW;
469
- writeWindow({ used: [], windowStart: 0 });
483
+ const continuation = e.inject.trimStart().startsWith("main-guard:");
484
+ if (!continuation) writeWindow({ used: [], windowStart: 0 });
470
485
  let summary: string;
471
486
  try {
472
487
  summary = q.queueSummary();
473
488
  } catch (err) {
474
489
  summary = `Queue unreadable (${q.INDEX}): ${err}`;
475
490
  }
476
- const cancel = flagCancel(e.inject, cfg);
491
+ const cancel = continuation ? "" : flagCancel(e.inject, cfg);
477
492
  return {
478
493
  allow: true,
479
494
  reason: "",
480
495
  inject: `${summary}\n(main-guard: read ${q.INDEX} and the listed repo INDEX.md files are summarised above; work the open plans, close finished ones with queue-close.)${cancel}`,
481
- state: { callsSinceStop: 0, idleStops: 0 },
496
+ ...(continuation ? {} : { state: { callsSinceStop: 0, idleStops: 0 } }),
482
497
  };
483
498
  }
484
499
 
@@ -49,8 +49,10 @@ export function mainStartText(args: string[], cfg: Config): string {
49
49
  if (lines.length) say(lines.join("\n"));
50
50
  } catch {}
51
51
  say("=== doctor");
52
+ // The CLI file itself (shebang `#!/usr/bin/env bun`), never process.execPath: inside a host
53
+ // harness that is the harness binary, which would start a new agent session instead.
52
54
  const cli = join(dirname(fileURLToPath(import.meta.url)), "../cli/main.ts");
53
- const doctor = spawnSync(process.execPath, [cli, "doctor"], { encoding: "utf8", env: process.env });
55
+ const doctor = spawnSync(cli, ["doctor"], { encoding: "utf8", env: process.env });
54
56
  const text = `${doctor.stdout ?? ""}${doctor.stderr ?? ""}`;
55
57
  out.push(text.endsWith("\n") || text === "" ? text : `${text}\n`);
56
58
  if (doctor.error) say(doctor.error.message);
@@ -1,7 +1,7 @@
1
1
  // Queue model: main index, repo INDEX.md, plan shape (templates/queue/TEMPLATE.md).
2
2
 
3
3
  import { createHash } from "node:crypto";
4
- import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
4
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
 
@@ -306,6 +306,60 @@ export function changesFiles(toolName: string, input: Record<string, unknown>):
306
306
  return SHELL_TOOLS.has(toolName) && MUTATING.test(String(input?.command ?? input?.code ?? ""));
307
307
  }
308
308
 
309
+ // P8: package-manager and cutover commands change the running guard package.
310
+ // Their write target is the protected root they touch, so the ordinary
311
+ // protected-path check refuses them without the owner's unlock. Matched per
312
+ // shell segment, with leading KEY=VALUE assignments stripped; the cutover
313
+ // scripts stay free when the same segment pins HOME to /tmp (how every sandbox
314
+ // test and the P9 session test run them). Harness-owned command shapes are NOT
315
+ // matched here: the adapter registers them through registerPackageOpTarget,
316
+ // because core never names a harness.
317
+ function isNeurastreamSpec(t: string): boolean {
318
+ const base = t.replace(/^["']|["']$/g, "").replace(/^\.?\//, "").replace(/^.*\//, "");
319
+ return base === "neurastream" || base.startsWith("neurastream@") || /^neurastream-[^/]*\.tgz$/.test(base);
320
+ }
321
+
322
+ // Adapter-registered matcher for harness package operations (plugin install,
323
+ // link, ...). Returns the protected root the command writes, or null.
324
+ export type PackageOpMatcher = (code: string) => string | null;
325
+ export let packageOpMatcher: PackageOpMatcher | null = null;
326
+
327
+ export function registerPackageOpTarget(fn: PackageOpMatcher): void {
328
+ packageOpMatcher = fn;
329
+ }
330
+
331
+ function packageOpTarget(code: string): string | null {
332
+ for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
333
+ const toks = seg.trim().split(/\s+/).filter(Boolean);
334
+ let i = 0;
335
+ while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i] ?? "")) i++;
336
+ const cmd = (toks[i] ?? "").replace(/^["']|["']$/g, "").replace(/^.*\//, "");
337
+ const rest = toks.slice(i + 1).map(t => t.replace(/^["']+|["']+$/g, ""));
338
+ const sub = rest[0] ?? "";
339
+ const hasGlobal = rest.some(t => t === "-g" || t === "--global");
340
+ const names = rest.slice(1).filter(t => !t.startsWith("-"));
341
+ if (cmd === "bun" && ["add", "install", "remove", "rm", "update", "link", "unlink"].includes(sub) && hasGlobal) {
342
+ if (!names.length || names.some(isNeurastreamSpec)) return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
343
+ continue;
344
+ }
345
+ if (cmd === "npm" && ["install", "i", "uninstall", "update", "link"].includes(sub) && hasGlobal) {
346
+ if (!names.length || names.some(isNeurastreamSpec)) return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
347
+ continue;
348
+ }
349
+ if (cmd === "neurastream" && sub === "adapter" && ["install", "uninstall"].includes(rest[1] ?? ""))
350
+ return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
351
+ if ((cmd === "bash" || cmd === "sh") && rest.some(t => {
352
+ const base = t.replace(/^.*\//, "");
353
+ return base === "cutover.sh" || base === "rollback.sh";
354
+ })) {
355
+ const envHome = toks.slice(0, i).find(t => t.startsWith("HOME="));
356
+ if (envHome && envHome.slice("HOME=".length).replace(/^["']|["']$/g, "").startsWith("/tmp/")) continue;
357
+ return `${agentDir()}/extensions/`;
358
+ }
359
+ }
360
+ return packageOpMatcher?.(code) ?? null;
361
+ }
362
+
309
363
  export function writeTargets(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
310
364
  const out = new Set<string>();
311
365
  const add = (raw: string, from: string) => {
@@ -334,7 +388,10 @@ export function writeTargets(toolName: string, input: Record<string, unknown>, c
334
388
  }
335
389
  if (!SHELL_TOOLS.has(toolName)) return [];
336
390
  const code = String(input?.command ?? input?.code ?? "");
337
- if (!MUTATING.test(code)) return [];
391
+ // A package/plugin/cutover command is a write even though none of the
392
+ // generic mutating heuristics fire on its text.
393
+ const pkgOp = packageOpTarget(code);
394
+ if (!MUTATING.test(code) && !pkgOp) return [];
338
395
  let dir = typeof input?.cwd === "string" ? path.resolve(cwd, expandHome(input.cwd)) : cwd;
339
396
  for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
340
397
  const s = seg.trim();
@@ -364,6 +421,7 @@ export function writeTargets(toolName: string, input: Record<string, unknown>, c
364
421
  /\bshutil\.\w+\(\s*["'][^"']+["']\s*,\s*(["'][^"']+["'])/g,
365
422
  ])
366
423
  for (const m of code.matchAll(re)) add((m[1] ?? "").replace(/`/g, ""), dir);
424
+ if (pkgOp) out.add(pkgOp);
367
425
  return [...out];
368
426
  }
369
427
 
@@ -372,9 +430,29 @@ export function unlockPath(): string {
372
430
  }
373
431
  export let UNLOCK = unlockPath();
374
432
  export const UNLOCK_MINUTES = 30;
433
+ // P8 (2026-10-21): after the npm cutover the running guards load from the
434
+ // installed package, so the package itself is guard surface. The package root
435
+ // is only claimed when this file really is part of a published neurastream
436
+ // package (package.json name check): the pre-cutover standalone copy under
437
+ // <agent>/queue/ has no package.json above it and must not protect the home.
438
+ function packageManifest(): { root: string; bins: string[] } | null {
439
+ try {
440
+ const root = realpathSync(path.resolve(path.dirname(import.meta.path), "..", "..", ".."));
441
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { name?: string; bin?: Record<string, string> };
442
+ if (pkg.name !== "neurastream") return null;
443
+ return { root, bins: Object.keys(pkg.bin ?? {}) };
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+
449
+ function bunGlobalRoot(): string {
450
+ return process.env.BUN_INSTALL ? path.resolve(expandHome(process.env.BUN_INSTALL)) : `${HOME}/.bun`;
451
+ }
452
+
375
453
  function protectedPaths(): string[] {
376
454
  const agent = agentDir();
377
- return [
455
+ const paths = [
378
456
  `${agent}/extensions/`,
379
457
  `${agent}/queue/`,
380
458
  `${agent}/blockers/`,
@@ -395,7 +473,18 @@ function protectedPaths(): string[] {
395
473
  `${HOME}/.local/bin/git`,
396
474
  `${HOME}/.local/bin/git-secret-gate`,
397
475
  `${HOME}/.config/git-secret-gate/`,
476
+ // Where `bun add -g neurastream` lands; writeTargets points package
477
+ // operations here so they need the owner's unlock like any guard edit.
478
+ // The plugin dir is adapter surface: the installer lists it in the
479
+ // config's guards.protected (core never names a harness path).
480
+ `${bunGlobalRoot()}/install/global/node_modules/neurastream/`,
398
481
  ];
482
+ const pkg = packageManifest();
483
+ if (pkg) {
484
+ paths.push(`${pkg.root}/`);
485
+ for (const name of pkg.bins) paths.push(`${HOME}/.bun/bin/${name}`);
486
+ }
487
+ return paths;
399
488
  }
400
489
  export let PROTECTED = protectedPaths();
401
490