@crouton-kit/crouter 0.3.41 → 0.3.43

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.
@@ -16,7 +16,8 @@ export type ReadOpRequest = Omit<ListModelsFrame, 'id'> | Omit<ListSessionsFrame
16
16
  * broker on the same path. It gives up only when the node is genuinely gone:
17
17
  * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
18
18
  * terminal — an idle-release node revives on its next inbox wake, so keep
19
- * trying (the supervisor's own ~30s bound caps an indefinite wait). */
19
+ * trying (the supervisor waits at a relaxed cadence for as long as the row
20
+ * stays live). */
20
21
  export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
21
22
  export declare class BrokerUnavailableError extends Error {
22
23
  readonly nodeId: string;
@@ -45,6 +46,12 @@ export declare class ViewSocketClient extends EventEmitter {
45
46
  * Resolved by the matching `data` frame / rejected by the matching `error`
46
47
  * frame in {@link onData}, the request's timeout, or socket teardown. */
47
48
  private pending;
49
+ /** One redial fault line per reconnect EPISODE, not per attempt: the
50
+ * supervisor redials for as long as the node can come back (a dormant node
51
+ * may be gone for hours), so a per-attempt record would grow fault.log
52
+ * without bound. Set on the first failed redial, reset on any successful
53
+ * connect. */
54
+ private redialFaultRecorded;
48
55
  constructor(nodeId: string);
49
56
  private recordSocketFault;
50
57
  /** Issue a correlated read-op and resolve with the broker's `data` reply (or
@@ -38,7 +38,8 @@ const REQUEST_TIMEOUT_MS = 10_000;
38
38
  * broker on the same path. It gives up only when the node is genuinely gone:
39
39
  * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
40
40
  * terminal — an idle-release node revives on its next inbox wake, so keep
41
- * trying (the supervisor's own ~30s bound caps an indefinite wait). */
41
+ * trying (the supervisor waits at a relaxed cadence for as long as the row
42
+ * stays live). */
42
43
  export function reconnectShouldGiveUp(row) {
43
44
  if (row === null)
44
45
  return true;
@@ -61,11 +62,22 @@ export class ViewSocketClient extends EventEmitter {
61
62
  * Resolved by the matching `data` frame / rejected by the matching `error`
62
63
  * frame in {@link onData}, the request's timeout, or socket teardown. */
63
64
  pending = new Map();
65
+ /** One redial fault line per reconnect EPISODE, not per attempt: the
66
+ * supervisor redials for as long as the node can come back (a dormant node
67
+ * may be gone for hours), so a per-attempt record would grow fault.log
68
+ * without bound. Set on the first failed redial, reset on any successful
69
+ * connect. */
70
+ redialFaultRecorded = false;
64
71
  constructor(nodeId) {
65
72
  super();
66
73
  this.nodeId = nodeId;
67
74
  }
68
75
  recordSocketFault(op, err) {
76
+ if (op === 'redial') {
77
+ if (this.redialFaultRecorded)
78
+ return;
79
+ this.redialFaultRecorded = true;
80
+ }
69
81
  const classified = classify('viewer↔broker', err);
70
82
  recordFault(this.nodeId, {
71
83
  link: 'viewer↔broker',
@@ -122,6 +134,7 @@ export class ViewSocketClient extends EventEmitter {
122
134
  const socket = createConnection(this.socketPath);
123
135
  this.socket = socket;
124
136
  socket.on('connect', () => {
137
+ this.redialFaultRecorded = false;
125
138
  clearFault(this.nodeId, { link: 'viewer↔broker' });
126
139
  this.emit('connect');
127
140
  });
@@ -148,6 +161,7 @@ export class ViewSocketClient extends EventEmitter {
148
161
  if (settled)
149
162
  return;
150
163
  settled = true;
164
+ this.redialFaultRecorded = false;
151
165
  clearFault(this.nodeId, { link: 'viewer↔broker' });
152
166
  this.emit('connect');
153
167
  resolve();
@@ -12,9 +12,13 @@ export declare function scopeRank(scope: MemoryScope): number;
12
12
  * always resolves. `--scope profile` is NEVER a default — it requires a
13
13
  * selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
14
14
  * or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
15
- * `loadProfileManifest` (never a raw path join). Returns the absolute
16
- * `<root>/memory` dir to write under. */
17
- export declare function resolveWriteTarget(scopeArg: string | undefined, profileArg?: string): {
15
+ * `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
16
+ * `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
17
+ * `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
18
+ * — the way a profiled agent writes into any project in its purview, and the
19
+ * only way to target a dir shadowed by an ancestor store. Returns the
20
+ * absolute `<root>/memory` dir to write under. */
21
+ export declare function resolveWriteTarget(scopeArg: string | undefined, profileArg?: string, dirArg?: string): {
18
22
  scope: MemoryScope;
19
23
  memoryDir: string;
20
24
  };
@@ -3,10 +3,12 @@
3
3
  // scope modules and build their documented output objects on top of the small
4
4
  // helpers here. Nothing in this file forks on kind or re-implements the
5
5
  // schema/gate/resolver — it only composes them.
6
- import { join } from 'node:path';
6
+ import { existsSync, realpathSync, statSync } from 'node:fs';
7
+ import { join, resolve as resolvePath } from 'node:path';
7
8
  import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
8
9
  import { usage } from '../../core/errors.js';
9
- import { scopeMemoryDir, projectScopeRoot, ensureProjectScopeRoot, } from '../../core/scope.js';
10
+ import { CRTR_DIR_NAME } from '../../types.js';
11
+ import { scopeMemoryDir, projectScopeRoot, ensureProjectScopeRoot, resetScopeCache, } from '../../core/scope.js';
10
12
  import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
11
13
  // The two memory kinds — knowledge (consult: procedural playbooks + factual
12
14
  // references merged) vs preference (behave: standing directives). Used as the
@@ -29,9 +31,37 @@ export function scopeRank(scope) {
29
31
  * always resolves. `--scope profile` is NEVER a default — it requires a
30
32
  * selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
31
33
  * or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
32
- * `loadProfileManifest` (never a raw path join). Returns the absolute
33
- * `<root>/memory` dir to write under. */
34
- export function resolveWriteTarget(scopeArg, profileArg) {
34
+ * `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
35
+ * `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
36
+ * `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
37
+ * — the way a profiled agent writes into any project in its purview, and the
38
+ * only way to target a dir shadowed by an ancestor store. Returns the
39
+ * absolute `<root>/memory` dir to write under. */
40
+ export function resolveWriteTarget(scopeArg, profileArg, dirArg) {
41
+ if (dirArg !== undefined && dirArg !== '') {
42
+ if (scopeArg !== undefined && scopeArg !== 'project') {
43
+ throw usage(`--dir targets a project store and cannot combine with --scope ${scopeArg}`);
44
+ }
45
+ const abs = resolvePath(dirArg);
46
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
47
+ throw usage(`--dir does not exist or is not a directory: ${dirArg}`, {
48
+ received: dirArg,
49
+ next: 'Pass an existing project directory.',
50
+ });
51
+ }
52
+ let real = abs;
53
+ try {
54
+ real = realpathSync(abs);
55
+ }
56
+ catch {
57
+ /* fall back to the resolved path */
58
+ }
59
+ // The scaffold (mkdir of `.crouter/memory/`) happens at write time via the
60
+ // writer's ensureDir; drop the scope cache so this process's later
61
+ // resolves see the new root.
62
+ resetScopeCache();
63
+ return { scope: 'project', memoryDir: join(real, CRTR_DIR_NAME, 'memory') };
64
+ }
35
65
  let scope;
36
66
  if (scopeArg === 'user' || scopeArg === 'project' || scopeArg === 'profile') {
37
67
  scope = scopeArg;
@@ -13,7 +13,7 @@ export const writeLeaf = defineLeaf({
13
13
  guide: 'The body is the easy part; the craft is routing — every frontmatter flag decides who sees this doc, when, and at what context cost. Each rung up is paid by every future agent at every boot or read, forever, so default each rung down.\n\n' +
14
14
  'Pick the kind. knowledge is consulted for facts or procedures; preference directs behavior. The kind choice is about how the doc is used, not about how long it is.\n\n' +
15
15
  'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
16
- 'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
16
+ 'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
17
17
  'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
18
18
  'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
19
19
  'Find before write. Group related docs with path names (area/topic). Do not store what is already recorded or what only matters to this conversation. Body is for current truth, not history. Provenance is automatic on create and preserved on update. Validate after authoring.',
@@ -27,7 +27,8 @@ export const writeLeaf = defineLeaf({
27
27
  { kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate — YAML/JSON object predicate over node config using the same field/matcher vocabulary described in the guide.' },
28
28
  { kind: 'flag', name: 'applies-to', type: 'string', required: false, constraint: 'Frontmatter applies-to — glob/path scope the document applies to.' },
29
29
  { kind: 'flag', name: 'read-when', type: 'string', required: false, constraint: 'Frontmatter read-when — YAML/JSON object predicate over a read file’s own frontmatter using the same field/matcher vocabulary described in the guide.' },
30
- { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
30
+ { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `project` resolves to the NEAREST ancestor `.crouter/` walking up from cwd — in a nested workspace that can be a parent’s store, not the dir you are standing in; pass --dir to pin the exact project directory. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
31
+ { kind: 'flag', name: 'dir', type: 'string', required: false, constraint: 'Exact project directory to write under — targets `<dir>/.crouter/memory/` regardless of cwd or ancestor stores, scaffolding `.crouter/` there if absent. THE way to place a doc in a specific project’s store (e.g. another project in your profile’s purview) without cd’ing there, and the only way to target a dir shadowed by an ancestor store. Implies --scope project; rejects --scope user/profile.' },
31
32
  { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile id or name to write under, for --scope profile. Default: the process CRTR_PROFILE_ID (the node\u2019s selected profile). Resolved through the same profile lookup as `crtr profile show`; ignored for any other --scope.' },
32
33
  { kind: 'stdin', name: 'body', required: true, constraint: 'Document body (markdown, no frontmatter). Piped on stdin, or passed as the bare positional after <name>.' },
33
34
  ],
@@ -50,7 +51,8 @@ export const writeLeaf = defineLeaf({
50
51
  const scopeArg = input['scope'];
51
52
  const profileArg = input['profile'];
52
53
  const body = input['body'] ?? '';
53
- const { scope, memoryDir } = resolveWriteTarget(scopeArg, profileArg);
54
+ const dirArg = input['dir'];
55
+ const { scope, memoryDir } = resolveWriteTarget(scopeArg, profileArg, dirArg);
54
56
  const path = memoryFilePath(memoryDir, name);
55
57
  const created = !pathExists(path);
56
58
  // CREATE requires the read-routing line that becomes the preview — without
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/pid-zombie.test.ts
2
+ //
3
+ // Regression cover for the zombie-broker revive bug: `kill(pid, 0)` reports a
4
+ // ZOMBIE process (exited, awaiting reap by its parent) as perfectly alive —
5
+ // its pid is still occupied in the process table. A broker's spawning process
6
+ // that never gets to process its SIGCHLD (the real-world trigger: `bootRoot`'s
7
+ // foreground attach session used to block its OWN event loop for the whole
8
+ // session via a synchronous `spawnSync`, so it could never reap its detached
9
+ // broker child in real time — see the async-spawn fix in runtime/spawn.ts)
10
+ // leaves that broker a zombie indefinitely. The daemon's revive guard reads
11
+ // liveness through `isPidAlive(pi_pid)` alone (`revive.ts`), so misreading a
12
+ // zombie as alive wedges the relaunch forever. `isPidAlive` must classify a
13
+ // genuine zombie as dead.
14
+ //
15
+ // Fabricates a REAL zombie: spawn a child with no exit listener, then starve
16
+ // THIS process's own event loop with a synchronous busy-wait spanning the
17
+ // child's exit — mirroring exactly how a blocking spawnSync parks the parent's
18
+ // loop past a child's death, before any assertion ever runs.
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import { spawn } from 'node:child_process';
22
+ import { isPidAlive } from '../canvas/pid.js';
23
+ test('isPidAlive reads a genuine zombie process as dead, not alive', () => {
24
+ const child = spawn('/bin/sh', ['-c', 'exit 0'], { detached: true, stdio: 'ignore' });
25
+ const pid = child.pid;
26
+ assert.ok(pid != null, 'child must have a pid');
27
+ child.unref(); // no exit listener attached — nothing reaps it while the loop is starved below
28
+ // Busy-wait SYNCHRONOUSLY (no `await`/timer — starving the event loop, not
29
+ // just delaying it) long enough for the child to exit and the kernel to
30
+ // mark it a zombie, before this process's SIGCHLD handling ever gets a turn.
31
+ const deadline = Date.now() + 400;
32
+ while (Date.now() < deadline) { /* starve the loop */ }
33
+ assert.equal(isPidAlive(pid), false, 'a zombie pid must read as dead, not alive');
34
+ // Best-effort cleanup: signaling an already-zombied pid is a harmless no-op
35
+ // (it's already dead) — the zombie self-clears once this test process exits
36
+ // and the orphan is reparented to init, same as every other fixture in this
37
+ // suite; nothing here relies on that timing for the assertion above.
38
+ try {
39
+ process.kill(pid, 'SIGKILL');
40
+ }
41
+ catch { /* already gone */ }
42
+ });
@@ -1,6 +1,17 @@
1
- /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
2
- * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
3
- * ours — still alive. A null/undefined pid (legacy / never-booted) reads dead. */
1
+ /** True if a process with `pid` is currently alive (signal-0 probe) AND not a
2
+ * zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
3
+ * it exists but isn't ours — still alive (a foreign process is never OUR
4
+ * zombie, so the `isZombie` check is skipped for that branch — see below). A
5
+ * null/undefined pid (legacy / never-booted) reads dead.
6
+ *
7
+ * The zombie check matters because EVERY broker this module supervises is
8
+ * spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
9
+ * dead broker whose spawning process hasn't reaped it yet is a zombie, and
10
+ * `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
11
+ * (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
12
+ * already-live-looking row, so a refreshed/crashed node whose broker zombied
13
+ * out under a long-lived spawning process (see `isZombie`'s doc) never comes
14
+ * back on its own. */
4
15
  export declare function isPidAlive(pid: number | null | undefined): boolean;
5
16
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
6
17
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
@@ -9,19 +9,56 @@
9
9
  // placement.ts, and crtrd.ts into one (Phase 3 review reuse MINOR-1).
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { readFileSync } from 'node:fs';
12
- /** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
13
- * 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
14
- * ours still alive. A null/undefined pid (legacy / never-booted) reads dead. */
12
+ /** Is `pid` a ZOMBIE (exited, awaiting reap by its parent)? `kill(pid, 0)`
13
+ * reports a zombie as a perfectly normal alive process its PID is still
14
+ * occupied in the process table so callers relying on that signal-0 probe
15
+ * alone (see `isPidAlive`) misread a dead broker as alive forever whenever
16
+ * its spawning process never reaps it (crouton-labs zombie-broker bug: the
17
+ * front door's foreground attach session parks its OWN event loop for the
18
+ * whole session — see `bootRoot` in runtime/spawn.ts — so it can't process
19
+ * the SIGCHLD that would otherwise reap its detached broker child in real
20
+ * time). `ps -o stat=` reports a leading `Z` for a zombie on BOTH BSD ps
21
+ * (macOS) and procps-ng (Linux), so one portable probe covers both platforms
22
+ * without a Linux-only `/proc` special case. Best-effort: any probe failure
23
+ * (spawn error, no matching row, non-zero exit) reads as NOT a zombie —
24
+ * fail-open, matching this module's other guards; a probe failure must never
25
+ * be misread as proof of death (a live pid IS alive; only a positive `Z`
26
+ * read says otherwise). */
27
+ function isZombie(pid) {
28
+ try {
29
+ const r = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
30
+ if (r.status !== 0 || typeof r.stdout !== 'string')
31
+ return false;
32
+ return r.stdout.trim().charAt(0) === 'Z';
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /** True if a process with `pid` is currently alive (signal-0 probe) AND not a
39
+ * zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
40
+ * it exists but isn't ours — still alive (a foreign process is never OUR
41
+ * zombie, so the `isZombie` check is skipped for that branch — see below). A
42
+ * null/undefined pid (legacy / never-booted) reads dead.
43
+ *
44
+ * The zombie check matters because EVERY broker this module supervises is
45
+ * spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
46
+ * dead broker whose spawning process hasn't reaped it yet is a zombie, and
47
+ * `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
48
+ * (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
49
+ * already-live-looking row, so a refreshed/crashed node whose broker zombied
50
+ * out under a long-lived spawning process (see `isZombie`'s doc) never comes
51
+ * back on its own. */
15
52
  export function isPidAlive(pid) {
16
53
  if (pid == null)
17
54
  return false;
18
55
  try {
19
56
  process.kill(pid, 0);
20
- return true;
21
57
  }
22
58
  catch (e) {
23
59
  return e.code === 'EPERM';
24
60
  }
61
+ return !isZombie(pid);
25
62
  }
26
63
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
27
64
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
@@ -1,7 +1,9 @@
1
1
  /** Select the profile a node about to boot at `cwd` should run under.
2
2
  *
3
- * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`,
4
- * bump `last_used_at`, return the id.
3
+ * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
4
+ * its manifest does not already cover `cwd` and the session is interactive,
5
+ * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
6
+ * return the id.
5
7
  * 2. Else, among profiles whose project list COVERS `cwd`, pick the
6
8
  * most-recently-used (nulls oldest), bump its `last_used_at`, return it.
7
9
  * 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
@@ -5,7 +5,7 @@
5
5
  import { existsSync, realpathSync } from 'node:fs';
6
6
  import { basename, resolve as resolvePath, sep } from 'node:path';
7
7
  import { createInterface } from 'node:readline/promises';
8
- import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, } from './manifest.js';
8
+ import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
9
9
  /** Resolve cwd to an absolute, realpath'd form so it compares against the
10
10
  * realpath'd project dirs `createProfile`/`addProfileProject` already store
11
11
  * (manifest.ts resolves every project dir through `realpathSync`). Falls back
@@ -66,6 +66,25 @@ function recoveryMessage(cwd) {
66
66
  * artifact or a queued interaction, not a two-choice gate on the same TTY pi
67
67
  * is about to take over — a raw `readline` question is the actual minimal
68
68
  * fit here. */
69
+ /** Explicit `--profile` names a profile whose manifest does NOT cover cwd.
70
+ * Interactive only: offer to widen the profile's purview to include this
71
+ * directory (default yes), so the next node started here is covered without a
72
+ * separate `crtr profile add-project`. Declining leaves the manifest untouched
73
+ * and runs the node under the profile anyway. Same raw-readline rationale as
74
+ * `promptCreateOrRoot` — this gates pi's boot on the same TTY. */
75
+ async function promptAddDirToProfile(entry, cwd) {
76
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
77
+ try {
78
+ process.stdout.write(`\nProfile "${entry.manifest.name}" does not cover this directory:\n ${cwd}\n\n`);
79
+ const choice = (await rl.question('Add it to the profile? [Y/n]: ')).trim().toLowerCase();
80
+ if (choice === 'n' || choice === 'no')
81
+ return;
82
+ addProfileProject(entry.profileId, cwd);
83
+ }
84
+ finally {
85
+ rl.close();
86
+ }
87
+ }
69
88
  async function promptCreateOrRoot(cwd) {
70
89
  const rl = createInterface({ input: process.stdin, output: process.stdout });
71
90
  try {
@@ -86,8 +105,10 @@ async function promptCreateOrRoot(cwd) {
86
105
  }
87
106
  /** Select the profile a node about to boot at `cwd` should run under.
88
107
  *
89
- * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`,
90
- * bump `last_used_at`, return the id.
108
+ * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
109
+ * its manifest does not already cover `cwd` and the session is interactive,
110
+ * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
111
+ * return the id.
91
112
  * 2. Else, among profiles whose project list COVERS `cwd`, pick the
92
113
  * most-recently-used (nulls oldest), bump its `last_used_at`, return it.
93
114
  * 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
@@ -95,12 +116,17 @@ async function promptCreateOrRoot(cwd) {
95
116
  * interactive (no TTY): default to root (null) and print the recovery
96
117
  * instruction to STDERR — never stdout, which the caller may be piping. */
97
118
  export async function selectProfileForCwd(cwd, explicitProfile) {
119
+ const resolvedCwd = resolveCwd(cwd);
98
120
  if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
99
121
  const entry = loadProfileManifest(explicitProfile);
122
+ // Selecting a profile from a directory it does not yet cover: offer to
123
+ // widen its purview (interactive only, default yes) before pi boots.
124
+ if (!profileCoversCwd(entry, resolvedCwd) && isInteractive()) {
125
+ await promptAddDirToProfile(entry, resolvedCwd);
126
+ }
100
127
  updateProfileLastUsed(entry.profileId);
101
128
  return entry.profileId;
102
129
  }
103
- const resolvedCwd = resolveCwd(cwd);
104
130
  const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
105
131
  if (covering.length > 0) {
106
132
  const winner = pickMostRecent(covering);
@@ -9,7 +9,7 @@
9
9
  // terminal background worker by default; with `root`, an
10
10
  // INDEPENDENT resident root (no subscription back to the spawner,
11
11
  // provenance via spawned_by) brought forefront for direct driving.
12
- import { spawnSync } from 'node:child_process';
12
+ import { spawn } from 'node:child_process';
13
13
  import { readdirSync, existsSync } from 'node:fs';
14
14
  import { isAbsolute, resolve, join } from 'node:path';
15
15
  import { homedir } from 'node:os';
@@ -151,8 +151,23 @@ export async function bootRoot(opts) {
151
151
  throw new Error(`the front-door root broker ${meta.node_id} never bound its view socket — cannot attach.`);
152
152
  }
153
153
  const attachEnv = { ...process.env, ...viewerSplitEnv() };
154
- const r = spawnSync('crtr', ['surface', 'attach', 'to', meta.node_id], { stdio: 'inherit', env: attachEnv });
155
- process.exit(r.status ?? 0);
154
+ // Async, NOT spawnSync: a synchronous spawn would park THIS process's own
155
+ // event loop for the whole attach session (which legitimately runs for
156
+ // hours) — starving it of the SIGCHLD delivery it needs to reap its OWN
157
+ // detached broker child (recorded above via `recordPid`). A refresh/crash
158
+ // mid-session would then leave that broker a zombie for as long as the user
159
+ // stays attached, which `isPidAlive` misread as "alive" and blocked the
160
+ // daemon's relaunch (the exact bug this fixes). An async spawn keeps this
161
+ // process's loop ticking throughout, so the broker's already-registered
162
+ // `exit` listener (`headlessBrokerHost.launch`) actually gets to run and
163
+ // reap it in real time; `stdio: 'inherit'` still hands the child the TTY
164
+ // directly, so this is a behavior-neutral swap for the foreground session.
165
+ const attachChild = spawn('crtr', ['surface', 'attach', 'to', meta.node_id], { stdio: 'inherit', env: attachEnv });
166
+ const code = await new Promise((resolveCode) => {
167
+ attachChild.once('exit', (exitCode) => resolveCode(exitCode ?? 0));
168
+ attachChild.once('error', () => resolveCode(1));
169
+ });
170
+ process.exit(code);
156
171
  }
157
172
  /** pi's sessions root, VENDORED from pi `config.getSessionsDir()` (= `<agentDir>/
158
173
  * sessions`). pi's package `exports` map is `.`-only, so config.js can't be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",