@crouton-kit/crouter 0.3.42 → 0.3.44
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/bin/crouter +12 -1
- package/bin/crtr +12 -1
- package/bin/crtrd +12 -1
- package/dist/builtin-memory/05-kinds/advisor/00-base.md +5 -0
- package/dist/builtin-memory/05-kinds/design/00-base.md +8 -1
- package/dist/builtin-memory/05-kinds/developer/00-base.md +7 -0
- package/dist/builtin-memory/05-kinds/explore/00-base.md +7 -0
- package/dist/builtin-memory/05-kinds/general/00-base.md +5 -1
- package/dist/builtin-memory/05-kinds/plan/00-base.md +8 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +8 -4
- package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +5 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +6 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +5 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +6 -1
- package/dist/builtin-memory/05-kinds/product/00-base.md +4 -0
- package/dist/builtin-memory/05-kinds/review/00-base.md +6 -0
- package/dist/builtin-memory/05-kinds/spec/00-base.md +3 -0
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +34 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/memory-slash-commands.test.ts +184 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +166 -0
- package/dist/clients/attach/attach-cmd.js +306 -306
- package/dist/commands/memory/delete.d.ts +1 -0
- package/dist/commands/memory/delete.js +77 -0
- package/dist/commands/memory/lint.js +10 -0
- package/dist/commands/memory/shared.js +1 -0
- package/dist/commands/memory/write.js +7 -1
- package/dist/commands/memory.js +3 -2
- package/dist/commands/node.js +35 -9
- package/dist/core/__tests__/pid-zombie.test.d.ts +1 -0
- package/dist/core/__tests__/pid-zombie.test.js +42 -0
- package/dist/core/canvas/pid.d.ts +14 -3
- package/dist/core/canvas/pid.js +41 -4
- package/dist/core/memory-resolver.d.ts +6 -0
- package/dist/core/memory-resolver.js +1 -1
- package/dist/core/runtime/broker.js +13 -5
- package/dist/core/runtime/launch.d.ts +1 -0
- package/dist/core/runtime/launch.js +4 -3
- package/dist/core/runtime/spawn.js +18 -3
- package/dist/core/runtime/stop-guard.js +19 -0
- package/dist/core/runtime/structured-output.d.ts +12 -0
- package/dist/core/runtime/structured-output.js +90 -0
- package/dist/core/substrate/schema.d.ts +15 -0
- package/dist/core/substrate/schema.js +9 -0
- package/dist/pi-extensions/canvas-structured-output.d.ts +9 -0
- package/dist/pi-extensions/canvas-structured-output.js +150 -0
- package/dist/prompts/review.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const deleteLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { defineLeaf } from '../../core/command.js';
|
|
4
|
+
import { CrtrError, notFound, usage } from '../../core/errors.js';
|
|
5
|
+
import { resolveMemoryDoc } from '../../core/memory-resolver.js';
|
|
6
|
+
import { MEMORY_SCOPES } from './shared.js';
|
|
7
|
+
/** Remove the file's now-empty parent directories up to (never including) the
|
|
8
|
+
* scope's `memory/` root, so deleting the last doc under an `area/` prefix
|
|
9
|
+
* does not orphan an empty directory. Stops at the first non-empty ancestor.
|
|
10
|
+
* `memoryRoot` is derived by walking up one dirname per name segment: a doc
|
|
11
|
+
* named `a/b` sits at `<root>/a/b.md`, so its root is two dirnames above. */
|
|
12
|
+
function pruneEmptyParents(filePath, nameSegments) {
|
|
13
|
+
let memoryRoot = filePath;
|
|
14
|
+
for (let i = 0; i < nameSegments; i += 1)
|
|
15
|
+
memoryRoot = dirname(memoryRoot);
|
|
16
|
+
let dir = dirname(filePath);
|
|
17
|
+
while (dir !== memoryRoot && dir.startsWith(memoryRoot) && existsSync(dir) && readdirSync(dir).length === 0) {
|
|
18
|
+
rmdirSync(dir);
|
|
19
|
+
dir = dirname(dir);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const deleteLeaf = defineLeaf({
|
|
23
|
+
name: 'delete',
|
|
24
|
+
description: 'delete a memory document by name',
|
|
25
|
+
whenToUse: 'a stored document is wrong, obsolete, or superseded and should be removed — the sanctioned way to delete one, so you never rm the markdown off disk. Resolves the name exactly as `read` does (project stack > profile > user precedence, leaf-name fallback) and removes that file: if you can read it, you can delete it. Refuses docs you do not own — builtin docs (shipped with the package) and installed-plugin docs (managed by `crtr pkg`) — and refuses an ambiguous leaf name rather than guessing which doc to destroy; pass the full `area/topic` name or `--scope` to pin one.',
|
|
26
|
+
help: {
|
|
27
|
+
name: 'memory delete',
|
|
28
|
+
summary: 'resolve a path-derived name and remove that document from its scope store',
|
|
29
|
+
params: [
|
|
30
|
+
{ kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`), resolved as `read` resolves it: precedence project stack > profile > user, with leaf-name fallback. A leaf that maps to genuinely different docs across scopes is rejected as ambiguous — pass the full name or --scope to disambiguate. May be scope-prefixed (`user/topic`) to pin the store directly.' },
|
|
31
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Restrict resolution to this scope before deleting. Use it to disambiguate a leaf name present at multiple scopes, or to assert which store you mean. builtin is not a choice — builtin docs ship with the package and cannot be deleted.' },
|
|
32
|
+
],
|
|
33
|
+
output: [
|
|
34
|
+
{ name: 'name', type: 'string', required: true, constraint: 'Path-derived name of the deleted document.' },
|
|
35
|
+
{ name: 'scope', type: 'string', required: true, constraint: 'Scope the document was deleted from: user, project, or profile.' },
|
|
36
|
+
{ name: 'path', type: 'string', required: true, constraint: 'Absolute path of the file that was removed.' },
|
|
37
|
+
{ name: 'deleted', type: 'boolean', required: true, constraint: 'Always true on success — the file was removed. A missing document fails with not_found instead.' },
|
|
38
|
+
{ name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command — browse what remains.' },
|
|
39
|
+
],
|
|
40
|
+
outputKind: 'object',
|
|
41
|
+
effects: [
|
|
42
|
+
'Permanently removes memory/<name>.md from the resolved scope store, and prunes any parent directories it leaves empty. Irreversible.',
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
run: async (input) => {
|
|
46
|
+
const nameRaw = input['name'];
|
|
47
|
+
const scopeArg = input['scope'];
|
|
48
|
+
let doc;
|
|
49
|
+
try {
|
|
50
|
+
doc = resolveMemoryDoc(nameRaw, scopeArg !== undefined ? { scope: scopeArg } : {});
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e instanceof CrtrError && e.code === 'not_found') {
|
|
54
|
+
throw notFound(`memory document not found: ${nameRaw}`, {
|
|
55
|
+
memory: nameRaw,
|
|
56
|
+
next: 'Run `crtr memory find <query>` to locate it, or `crtr memory list` to browse the inventory. If it exists at another scope, pass --scope.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
throw e; // ambiguous / usage propagate with their own recovery guidance
|
|
60
|
+
}
|
|
61
|
+
if (doc.plugin !== undefined) {
|
|
62
|
+
throw usage(`${doc.name} belongs to the installed plugin "${doc.plugin}" — it is not a scope doc and cannot be deleted here. Manage plugin docs with \`crtr pkg\`. To override just this doc, write a same-named doc at a writable scope (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, plugin: doc.plugin });
|
|
63
|
+
}
|
|
64
|
+
if (doc.scope === 'builtin') {
|
|
65
|
+
throw usage(`${doc.name} is a builtin document shipped with the package (read-only) — it cannot be deleted. Override it with a same-named doc at a writable scope instead (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, scope: 'builtin' });
|
|
66
|
+
}
|
|
67
|
+
rmSync(doc.path);
|
|
68
|
+
pruneEmptyParents(resolvePath(doc.path), doc.name.split('/').length);
|
|
69
|
+
return {
|
|
70
|
+
name: doc.name,
|
|
71
|
+
scope: doc.scope,
|
|
72
|
+
path: doc.path,
|
|
73
|
+
deleted: true,
|
|
74
|
+
follow_up: `Removed. Browse what remains with \`crtr memory list\`.`,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -68,6 +68,16 @@ export function lintSubstrateSchema(fm) {
|
|
|
68
68
|
if (fm['file-read-visibility'] === 'none' && (appliesTo !== undefined || readWhen !== undefined)) {
|
|
69
69
|
return 'dead on-read trigger: applies-to/read-when is set but file-read-visibility is `none` — raise the rung or drop the trigger';
|
|
70
70
|
}
|
|
71
|
+
const slash = fm.slash;
|
|
72
|
+
if (slash !== undefined && typeof slash !== 'boolean') {
|
|
73
|
+
return `invalid slash: ${JSON.stringify(slash)} (expected a boolean)`;
|
|
74
|
+
}
|
|
75
|
+
// rationale (maintainer-facing gap this doc closes) is optional everywhere —
|
|
76
|
+
// no nagging when absent, just a type check when present.
|
|
77
|
+
const rationale = fm.rationale;
|
|
78
|
+
if (rationale !== undefined && typeof rationale !== 'string') {
|
|
79
|
+
return `invalid rationale: ${JSON.stringify(rationale)} (expected a string)`;
|
|
80
|
+
}
|
|
71
81
|
return null;
|
|
72
82
|
}
|
|
73
83
|
/** Strict-parse one file; push a finding on a YAML error, then run the
|
|
@@ -16,7 +16,8 @@ export const writeLeaf = defineLeaf({
|
|
|
16
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
|
-
'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
|
|
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.\n\n' +
|
|
20
|
+
'--rationale is the gap this doc exists to close — the observed agent failure that prompted it, captured from user signal (a correction, a mistake you watched happen) rather than inferred from the doc’s own content. The bar: if the rationale is guessable from reading the doc, it is not the real one — a guessable gap is one agents do not actually fall into, so a merely-plausible-sounding reason is not worth recording. It is maintainer-facing only and NEVER ships in a delivered surface (boot render, on-read injection, `memory read` content) — it lives in frontmatter, visible only in the raw file or `memory read --frontmatter`. Omit it when you have no such gap to record; omitting the flag on an update always preserves whatever rationale already exists.',
|
|
20
21
|
params: [
|
|
21
22
|
{ kind: 'positional', name: 'name', required: true, constraint: 'Path-derived identity: one segment, or several joined with `/` to nest the document under a directory and group it with related docs → memory/<name>.md at the resolved scope. Updated in place if it already exists, otherwise created.' },
|
|
22
23
|
{ kind: 'flag', name: 'kind', type: 'enum', choices: [...MEMORY_KINDS], required: true, constraint: 'Document kind.' },
|
|
@@ -27,6 +28,8 @@ export const writeLeaf = defineLeaf({
|
|
|
27
28
|
{ 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
29
|
{ kind: 'flag', name: 'applies-to', type: 'string', required: false, constraint: 'Frontmatter applies-to — glob/path scope the document applies to.' },
|
|
29
30
|
{ 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.' },
|
|
31
|
+
{ kind: 'flag', name: 'slash', type: 'bool', required: false, default: false, constraint: 'Presence flags this doc invocable as a pi slash command (`/<name>`, `/` in a nested name rendered as `:`) — the doc body becomes the command’s injected prompt. Default false: most docs are consulted, not invoked.' },
|
|
32
|
+
{ kind: 'flag', name: 'rationale', type: 'string', required: false, constraint: 'Frontmatter rationale — the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only in the raw file or `memory read --frontmatter`. Omitting this flag on an update PRESERVES an existing rationale unchanged.' },
|
|
30
33
|
{ 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
34
|
{ 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.' },
|
|
32
35
|
{ 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.' },
|
|
@@ -99,6 +102,9 @@ export const writeLeaf = defineLeaf({
|
|
|
99
102
|
if (input['readWhen'] !== undefined) {
|
|
100
103
|
frontmatter['read-when'] = coerceReadWhen(input['readWhen']);
|
|
101
104
|
}
|
|
105
|
+
if (input['slash'] === true)
|
|
106
|
+
frontmatter['slash'] = true;
|
|
107
|
+
setIf('rationale', input['rationale']);
|
|
102
108
|
writeText(path, serializeMemoryDoc(frontmatter, body));
|
|
103
109
|
return {
|
|
104
110
|
name,
|
package/dist/commands/memory.js
CHANGED
|
@@ -5,6 +5,7 @@ import { listLeaf } from './memory/list.js';
|
|
|
5
5
|
import { readLeaf } from './memory/read.js';
|
|
6
6
|
import { findLeaf } from './memory/find.js';
|
|
7
7
|
import { writeLeaf } from './memory/write.js';
|
|
8
|
+
import { deleteLeaf } from './memory/delete.js';
|
|
8
9
|
import { originLeaf } from './memory/origin.js';
|
|
9
10
|
import { lintLeaf } from './memory/lint.js';
|
|
10
11
|
export function registerMemory() {
|
|
@@ -18,8 +19,8 @@ export function registerMemory() {
|
|
|
18
19
|
help: {
|
|
19
20
|
name: 'memory',
|
|
20
21
|
summary: 'list, read, search, and write memory documents — knowledge and preferences',
|
|
21
|
-
model: '`list` for a human inventory of what is stored — one line per document, the only surface that shows short-form. `read` (leaf) loads one document body by name, resolved project > user > builtin with leaf-name fallback; --frontmatter keeps the YAML header. `find` when you do not yet know which document applies — it ranks by relevance over name/when/why/short-form, --body to also weigh bodies, --grep for an exact regex over bodies. `write` creates or updates memory/<name>.md at a scope from frontmatter flags + a body piped on stdin — use it for new docs and frontmatter changes; for a quick body tweak, edit the `path` every leaf emits directly. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. `origin` derefs a doc back to its provenance — the node, conversation, and project that created it (the runtime stamps an `origin` block at write time; this reads it and resolves the node pointer to its live session + context dir). A directory may carry an `INDEX.md` with the same frontmatter schema as any doc; the dir then renders as one entry at the INDEX\'s rung, and that rung is a ceiling for its whole subtree (`none` hides the dir) — when a doc mysteriously is not surfacing, check its ancestors\' INDEX rungs and its gate. Append `-h` at any leaf for its full schema, and `crtr memory write -h` for the authoring guide.',
|
|
22
|
+
model: '`list` for a human inventory of what is stored — one line per document, the only surface that shows short-form. `read` (leaf) loads one document body by name, resolved project > user > builtin with leaf-name fallback; --frontmatter keeps the YAML header. `find` when you do not yet know which document applies — it ranks by relevance over name/when/why/short-form, --body to also weigh bodies, --grep for an exact regex over bodies. `write` creates or updates memory/<name>.md at a scope from frontmatter flags + a body piped on stdin — use it for new docs and frontmatter changes; for a quick body tweak, edit the `path` every leaf emits directly. `delete` removes a document by name (resolved as `read` resolves it) — the sanctioned way to retire one instead of rm-ing the markdown off disk. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. `origin` derefs a doc back to its provenance — the node, conversation, and project that created it (the runtime stamps an `origin` block at write time; this reads it and resolves the node pointer to its live session + context dir). A directory may carry an `INDEX.md` with the same frontmatter schema as any doc; the dir then renders as one entry at the INDEX\'s rung, and that rung is a ceiling for its whole subtree (`none` hides the dir) — when a doc mysteriously is not surfacing, check its ancestors\' INDEX rungs and its gate. Append `-h` at any leaf for its full schema, and `crtr memory write -h` for the authoring guide.',
|
|
22
23
|
},
|
|
23
|
-
children: [listLeaf, readLeaf, findLeaf, writeLeaf, originLeaf, lintLeaf],
|
|
24
|
+
children: [listLeaf, readLeaf, findLeaf, writeLeaf, deleteLeaf, originLeaf, lintLeaf],
|
|
24
25
|
});
|
|
25
26
|
}
|
package/dist/commands/node.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawnChild } from '../core/runtime/spawn.js';
|
|
|
13
13
|
import { promote, requestYield } from '../core/runtime/promote.js';
|
|
14
14
|
import { writeYieldMessage, readGoal } from '../core/runtime/kickoff.js';
|
|
15
15
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
16
|
+
import { parseOutputSchemaValue, writeOutputSchema } from '../core/runtime/structured-output.js';
|
|
16
17
|
import { newNodeId } from '../core/runtime/nodes.js';
|
|
17
18
|
import { readRoadmap, hasRoadmap, seedRoadmap } from '../core/runtime/roadmap.js';
|
|
18
19
|
import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
|
|
@@ -131,6 +132,7 @@ const nodeNew = defineLeaf({
|
|
|
131
132
|
{ kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
|
|
132
133
|
{ kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong', 'medium', 'light'], required: false, constraint: 'Override the model the node runs on, by capability TIER: ultra (frontier), strong (opus), medium (sonnet), light (haiku). Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
|
|
133
134
|
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (root has none, so an uninherited child has none either). --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
|
|
135
|
+
{ kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The spawned node gets a `submit` tool shaped by this schema, must call it to finish, and the submitted JSON is written to the final report and context/result.json. Rejected with trigger flags in this version.' },
|
|
134
136
|
{ kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'DEFER the birth to a one-shot clock trigger at this future time instead of spawning now — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local or in --tz). Required unless --every is given (the cadence then sets the first fire). Mutually exclusive with --when.' },
|
|
135
137
|
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'SPAWN-CRON: re-birth a fresh node on this cadence — a duration ("6h","30m") or a 5-field cron / @alias ("0 9 * * *","@daily"). Combine with --at to anchor the first fire, then recur normally. Min cadence 60s. With --when, this instead sets the PREDICATE EVALUATION cadence (required), not a repeating birth.' },
|
|
136
138
|
{ kind: 'flag', name: 'when', type: 'string', required: false, constraint: 'Arm a daemon-evaluated bash predicate instead of a clock trigger: exit 0 births the node once, non-zero leaves it armed for the next evaluation. Requires --every (eval cadence) and --timeout (wall-clock bound). Mutually exclusive with --at.' },
|
|
@@ -155,7 +157,7 @@ const nodeNew = defineLeaf({
|
|
|
155
157
|
dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
|
|
156
158
|
outputKind: 'object',
|
|
157
159
|
effects: [
|
|
158
|
-
'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr worktree close` before finishing. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
|
|
160
|
+
'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr worktree close` before finishing. With --output-schema: writes nodes/<id>/output-schema.json in terminal mode; the node must call submit, which writes context/result.json and pushes final. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
|
|
159
161
|
'Armed (--at/--every/--when): inserts one detached `new`/`node_birth` triggers row (node_id NULL, owner=you, parent/cwd resolved now); NO node and NO window exist until fire time. The daemon spawns from the stored recipe at fire time, re-deriving the launch spec live.',
|
|
160
162
|
],
|
|
161
163
|
},
|
|
@@ -174,6 +176,7 @@ const nodeNew = defineLeaf({
|
|
|
174
176
|
const forkFrom = input['forkFrom'];
|
|
175
177
|
const model = input['model'];
|
|
176
178
|
const profile = input['profile'];
|
|
179
|
+
const outputSchema = parseOutputSchemaValue(input['outputSchema']);
|
|
177
180
|
const at = input['at']?.trim();
|
|
178
181
|
const every = input['every']?.trim();
|
|
179
182
|
const whenRaw = input['when'];
|
|
@@ -194,9 +197,14 @@ const nodeNew = defineLeaf({
|
|
|
194
197
|
if (root && worktree) {
|
|
195
198
|
throw new InputError({ error: 'worktree_root_conflict', message: '--worktree is mutually exclusive with --root', field: 'worktree', next: 'Drop either --root or --worktree.' });
|
|
196
199
|
}
|
|
200
|
+
if (outputSchema !== null && triggered) {
|
|
201
|
+
throw new InputError({ error: 'output_schema_trigger_unsupported', message: '--output-schema is not supported with --at, --every, or --when in this version', field: 'output-schema', next: 'Spawn immediately with --output-schema, or drop --output-schema for the triggered spawn.' });
|
|
202
|
+
}
|
|
197
203
|
if (!triggered) {
|
|
198
204
|
try {
|
|
199
205
|
const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, worktree, forkFrom, model, profile });
|
|
206
|
+
if (outputSchema !== null)
|
|
207
|
+
writeOutputSchema(res.node.node_id, 'terminal', outputSchema);
|
|
200
208
|
return {
|
|
201
209
|
node_id: res.node.node_id,
|
|
202
210
|
name: res.node.name,
|
|
@@ -712,7 +720,12 @@ const nodeConfig = defineLeaf({
|
|
|
712
720
|
throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
713
721
|
if (isBrokerLive(meta)) {
|
|
714
722
|
try {
|
|
715
|
-
|
|
723
|
+
// Normalize tiers/aliases (opus, strong, anthropic/strong, …) through the
|
|
724
|
+
// config ladder before the broker sees them — a bare alias sent raw falls
|
|
725
|
+
// into the broker's registry substring search, where `opus` resolves to the
|
|
726
|
+
// deprecated claude-3-opus-20240229 on catalog order. Concrete `provider/id`
|
|
727
|
+
// specs and free-text substrings pass through unchanged.
|
|
728
|
+
resolved = await setModelLive(nodeId, normalizeModel(modelSpec));
|
|
716
729
|
}
|
|
717
730
|
catch (err) {
|
|
718
731
|
throw new InputError({ error: 'switch_failed', message: `live model switch failed: ${err instanceof Error ? err.message : String(err)}`, next: 'Check the spec resolves (`provider/id`, tier, alias, or a substring of a registered model).' });
|
|
@@ -1016,11 +1029,12 @@ const nodeMsg = defineLeaf({
|
|
|
1016
1029
|
name: 'node msg',
|
|
1017
1030
|
summary: 'message a node now, or arm a trigger (clock or predicate) that messages/revives it later. No trigger flag → immediate inbox delivery (or immediate fresh revive with --fresh). --at/--every → clock trigger. --self --until → self-only deadline: fires whichever comes first, an inbox message or this time, and that outcome is what should change your next action. --when → daemon-evaluated predicate, fires once on exit 0',
|
|
1018
1031
|
params: [
|
|
1019
|
-
{ kind: 'stdin', name: 'body', required: false, constraint: 'Message body. Positional or stdin. Required for a plain or clock-triggered message and for --until. Forbidden with --fresh. Optional with --when — if omitted, the predicate\'s captured stdout becomes the body when it fires.' },
|
|
1032
|
+
{ kind: 'stdin', name: 'body', required: false, constraint: 'Message body. Positional or stdin. Required for a plain or clock-triggered message and for --until, unless --output-schema is present (then a default one-off structured-output request body is generated). Forbidden with --fresh. Optional with --when — if omitted, the predicate\'s captured stdout becomes the body when it fires.' },
|
|
1020
1033
|
{ kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to/--self is required.' },
|
|
1021
1034
|
{ kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node (CRTR_NODE_ID). Exactly one of --to/--self is required. Required for --until.' },
|
|
1022
|
-
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, constraint: 'Inbox delivery urgency: critical = interrupt + new turn; urgent = steer mid-turn; normal = follow-up; deferred = read on next cycle. Defaults to normal when omitted. Valid for a plain or --when inbox message (immediate or clock-triggered); forbidden with --fresh or --until (--until is always urgent).' },
|
|
1023
|
-
{ kind: 'flag', name: 'fresh', type: 'bool', required: false, constraint: 'Fresh-revive the target instead of delivering a message: no inbox entry, just a clean revive (resume:false). Valid alone (revives now) or with --at/--every (arms a clock-triggered fresh revive). Forbidden with a body, --tier, --until, or --when. Rejected if the target has no goal/roadmap on disk (would resume amnesiac).' },
|
|
1035
|
+
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, constraint: 'Inbox delivery urgency: critical = interrupt + new turn; urgent = steer mid-turn; normal = follow-up; deferred = read on next cycle. Defaults to normal when omitted. Valid for a plain or --when inbox message (immediate or clock-triggered); forbidden with --fresh or --until (--until is always urgent). With --output-schema, deferred is rejected because the target must wake to answer the one-off request.' },
|
|
1036
|
+
{ kind: 'flag', name: 'fresh', type: 'bool', required: false, constraint: 'Fresh-revive the target instead of delivering a message: no inbox entry, just a clean revive (resume:false). Valid alone (revives now) or with --at/--every (arms a clock-triggered fresh revive). Forbidden with a body, --tier, --until, --output-schema, or --when. Rejected if the target has no goal/roadmap on disk (would resume amnesiac).' },
|
|
1037
|
+
{ kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. Grants or replaces a one-off `submit` tool on the target; the target must submit before it can stop, then the result is written to context/result.json, pushed as an update report, and the tool is retired. Immediate messages only; rejected with trigger flags, --fresh, and --until.' },
|
|
1024
1038
|
{ kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'Arm a one-shot clock trigger at this future time instead of acting now — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local or in --tz). Required unless --every is given (the cadence then sets the first fire). Mutually exclusive with --when.' },
|
|
1025
1039
|
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Arm a recurring clock trigger on this cadence — a duration ("6h","30m") or a 5-field cron / @alias ("0 9 * * *","@daily"). Combine with --at to anchor the first fire, then recur normally. Min cadence 60s. With --when, this instead sets the PREDICATE EVALUATION cadence (required), not a repeating action.' },
|
|
1026
1040
|
{ kind: 'flag', name: 'when', type: 'string', required: false, constraint: 'Arm a daemon-evaluated bash predicate instead of a clock trigger: exit 0 fires the message once, non-zero leaves it armed for the next evaluation. Requires --every (eval cadence) and --timeout (wall-clock bound). Mutually exclusive with --at, --fresh, and --until.' },
|
|
@@ -1044,7 +1058,7 @@ const nodeMsg = defineLeaf({
|
|
|
1044
1058
|
],
|
|
1045
1059
|
outputKind: 'object',
|
|
1046
1060
|
effects: [
|
|
1047
|
-
'Immediate inbox_message: appends a message entry to the target inbox.jsonl and best-effort revives the target if dormant.',
|
|
1061
|
+
'Immediate inbox_message: appends a message entry to the target inbox.jsonl and best-effort revives the target if dormant. With --output-schema: also writes nodes/<target>/output-schema.json in oneoff mode before delivery.',
|
|
1048
1062
|
'Immediate fresh_revive: revives the target now with no inbox entry (resume:false).',
|
|
1049
1063
|
'Armed (--at/--every/--when/--until): inserts one triggers row; nothing fires/evaluates until the daemon\'s next pass. No pi spawn, no transition — end your turn separately to go dormant.',
|
|
1050
1064
|
'--until upserts the node\'s single deadline trigger, replacing any prior.',
|
|
@@ -1069,6 +1083,7 @@ const nodeMsg = defineLeaf({
|
|
|
1069
1083
|
const tz = input['tz'];
|
|
1070
1084
|
const hasAt = at !== undefined && at !== '';
|
|
1071
1085
|
const hasEvery = every !== undefined && every !== '';
|
|
1086
|
+
const outputSchema = parseOutputSchemaValue(input['outputSchema']);
|
|
1072
1087
|
// --- Validation per the approved flag matrix ---
|
|
1073
1088
|
if (hasUntil && !isSelf) {
|
|
1074
1089
|
throw new InputError({ error: 'until_self_only', message: '--until is self-only', field: 'until', next: 'Use --self, or drop --until.' });
|
|
@@ -1098,6 +1113,12 @@ const nodeMsg = defineLeaf({
|
|
|
1098
1113
|
if (hasTz && !hasAt && !hasEvery && !hasUntil) {
|
|
1099
1114
|
throw new InputError({ error: 'tz_needs_trigger', message: '--tz has no effect without --at, --every, or --until to parse it against', field: 'tz', next: 'Drop --tz, or pass --at/--every/--until.' });
|
|
1100
1115
|
}
|
|
1116
|
+
if (outputSchema !== null && (hasAt || hasEvery || hasWhen || hasUntil || fresh)) {
|
|
1117
|
+
throw new InputError({ error: 'output_schema_msg_immediate_only', message: '--output-schema on node msg grants an immediate one-off request and cannot combine with --at, --every, --when, --until, or --fresh', field: 'output-schema', next: 'Deliver the one-off request immediately, or drop --output-schema for the trigger/fresh revive.' });
|
|
1118
|
+
}
|
|
1119
|
+
if (outputSchema !== null && tierRaw === 'deferred') {
|
|
1120
|
+
throw new InputError({ error: 'output_schema_deferred_tier', message: '--output-schema cannot combine with --tier deferred because the target must wake and answer the one-off request', field: 'tier', next: 'Use normal, urgent, or critical tier, or drop --output-schema.' });
|
|
1121
|
+
}
|
|
1101
1122
|
const target = isSelf ? 'self' : targetId;
|
|
1102
1123
|
// --- Deadline (--until): self-only, one-shot, urgent ---
|
|
1103
1124
|
if (hasUntil) {
|
|
@@ -1265,19 +1286,24 @@ const nodeMsg = defineLeaf({
|
|
|
1265
1286
|
guidance: `Fresh-revived ${isSelf ? 'yourself' : targetId} now — it resumes a clean window re-reading its roadmap/disk.`,
|
|
1266
1287
|
};
|
|
1267
1288
|
}
|
|
1268
|
-
if (!hasBody) {
|
|
1269
|
-
throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when)', field: 'body', next: 'Pass the message after the target flags or on stdin.' });
|
|
1289
|
+
if (!hasBody && outputSchema === null) {
|
|
1290
|
+
throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when / --output-schema)', field: 'body', next: 'Pass the message after the target flags or on stdin, or pass --output-schema to grant a one-off structured-output request.' });
|
|
1270
1291
|
}
|
|
1271
1292
|
const targetMeta = getNode(targetId);
|
|
1272
1293
|
if (targetMeta === null) {
|
|
1273
1294
|
throw new InputError({ error: 'not_found', message: `no node: ${targetId}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
1274
1295
|
}
|
|
1296
|
+
if (outputSchema !== null)
|
|
1297
|
+
writeOutputSchema(targetId, 'oneoff', outputSchema);
|
|
1275
1298
|
const tier = (hasTier ? tierRaw : 'normal');
|
|
1276
1299
|
const from = process.env['CRTR_NODE_ID'] ?? 'human';
|
|
1300
|
+
const messageBody = hasBody
|
|
1301
|
+
? body
|
|
1302
|
+
: 'A one-off structured-output request has been granted. Call the `submit` tool with a result matching the requested schema before you stop; after submit succeeds, continue normally.';
|
|
1277
1303
|
// A body over the preview bound spills to a ref file; appendInbox clips the
|
|
1278
1304
|
// inline preview and the receiver dereferences the ref for the full text,
|
|
1279
1305
|
// same as any other report.
|
|
1280
|
-
appendInbox(targetId, { from, tier, kind: 'message', label:
|
|
1306
|
+
appendInbox(targetId, { from, tier, kind: 'message', label: messageBody.split('\n')[0].slice(0, 120), data: { body: messageBody } });
|
|
1281
1307
|
// A direct message wakes any node: if the target has no live window
|
|
1282
1308
|
// (done/dead/idle-released), revive it so its inbox-watcher delivers this.
|
|
1283
1309
|
let woke = false;
|
|
@@ -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)
|
|
2
|
-
* 0)` throws ESRCH when the process is gone; EPERM means
|
|
3
|
-
* ours — still alive
|
|
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
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
@@ -22,6 +22,12 @@ export interface MemoryDoc {
|
|
|
22
22
|
frontmatter: Record<string, unknown> | null;
|
|
23
23
|
/** Document body, with the frontmatter block stripped. */
|
|
24
24
|
body: string;
|
|
25
|
+
/** Set to the owning plugin's name when this doc is mounted from an installed
|
|
26
|
+
* plugin (under `<pluginName>/`), undefined for a native scope doc. Plugin
|
|
27
|
+
* docs surface within a source scope but live under the plugins dir and are
|
|
28
|
+
* managed by `crtr pkg`, not writable/deletable as scope docs — consumers
|
|
29
|
+
* that mutate the store gate on this. */
|
|
30
|
+
plugin?: string;
|
|
25
31
|
}
|
|
26
32
|
/** The memory-only scope union: the global `Scope` (`user|project|builtin`)
|
|
27
33
|
* plus `profile`. Confined to the memory resolver/list/read/write/render
|
|
@@ -164,7 +164,7 @@ export function listPluginMemoryDocs(plugin, scope, quiet = false) {
|
|
|
164
164
|
continue;
|
|
165
165
|
const name = `${plugin.name}/${derived}`;
|
|
166
166
|
try {
|
|
167
|
-
docs.push(loadMemoryDoc(name, scope, file));
|
|
167
|
+
docs.push({ ...loadMemoryDoc(name, scope, file), plugin: plugin.name });
|
|
168
168
|
}
|
|
169
169
|
catch (e) {
|
|
170
170
|
const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
@@ -1212,10 +1212,16 @@ export async function runBroker(nodeId) {
|
|
|
1212
1212
|
break;
|
|
1213
1213
|
const requested = parseModelSpec(frame.model);
|
|
1214
1214
|
let model;
|
|
1215
|
+
let normalized;
|
|
1215
1216
|
try {
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1217
|
+
// Tier/alias specs (`opus`, `strong`, `anthropic/strong`, …) MUST resolve
|
|
1218
|
+
// through the config ladder before touching the registry: the fuzzy
|
|
1219
|
+
// registry search rank-ties `opus` between claude-3-opus-20240229 and
|
|
1220
|
+
// claude-opus-4-8, and the deprecated 2024 model wins on catalog order.
|
|
1221
|
+
// Concrete `provider/id` specs and genuine substrings (`fable`) pass
|
|
1222
|
+
// through normalizeModel unchanged and take the exact/search path below.
|
|
1223
|
+
normalized = parseModelSpec(normalizeModel(requested.modelSpec));
|
|
1224
|
+
model = resolveModelQuery(normalized.modelSpec);
|
|
1219
1225
|
}
|
|
1220
1226
|
catch (err) {
|
|
1221
1227
|
// N2: registry.find should never throw on the real SDK, but a degenerate
|
|
@@ -1234,8 +1240,10 @@ export async function runBroker(nodeId) {
|
|
|
1234
1240
|
void session
|
|
1235
1241
|
.setModel(model)
|
|
1236
1242
|
.then(() => {
|
|
1237
|
-
|
|
1238
|
-
|
|
1243
|
+
// The caller's explicit `:thinking` wins; else the ladder cell's.
|
|
1244
|
+
const thinking = requested.thinkingLevel ?? normalized.thinkingLevel;
|
|
1245
|
+
if (thinking !== undefined)
|
|
1246
|
+
session.setThinkingLevel(thinking);
|
|
1239
1247
|
ackTo(client, 'set_model');
|
|
1240
1248
|
broadcastModelChanged();
|
|
1241
1249
|
})
|
|
@@ -8,6 +8,7 @@ export declare const CANVAS_GOAL_CAPTURE_PATH: string;
|
|
|
8
8
|
export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
|
|
9
9
|
export declare const CANVAS_CONTEXT_INTRO_PATH: string;
|
|
10
10
|
export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
|
|
11
|
+
export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
|
|
11
12
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
12
13
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
13
14
|
* graph chrome), recap (the per-node inactivity recap card — Haiku over the
|
|
@@ -16,9 +16,8 @@ import { nodeEnv } from './nodes.js';
|
|
|
16
16
|
import { editorLabel } from '../canvas/index.js';
|
|
17
17
|
import { nodeDir } from '../canvas/paths.js';
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// with no respawn.
|
|
19
|
+
// Canvas pi-extensions every node loads. They self-gate on node/runtime state,
|
|
20
|
+
// so persona changes and per-node feature grants take effect without respawn.
|
|
22
21
|
// ---------------------------------------------------------------------------
|
|
23
22
|
function resolveExtension(name) {
|
|
24
23
|
const here = dirname(fileURLToPath(import.meta.url)); // dist/core/runtime or src/core/runtime
|
|
@@ -36,6 +35,7 @@ export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
|
|
|
36
35
|
export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
|
|
37
36
|
export const CANVAS_CONTEXT_INTRO_PATH = resolveExtension('canvas-context-intro');
|
|
38
37
|
export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate');
|
|
38
|
+
export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
|
|
39
39
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
40
40
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
41
41
|
* graph chrome), recap (the per-node inactivity recap card — Haiku over the
|
|
@@ -57,6 +57,7 @@ export const CANVAS_EXTENSIONS = [
|
|
|
57
57
|
CANVAS_PASSIVE_CONTEXT_PATH,
|
|
58
58
|
CANVAS_CONTEXT_INTRO_PATH,
|
|
59
59
|
CANVAS_DOC_SUBSTRATE_PATH,
|
|
60
|
+
CANVAS_STRUCTURED_OUTPUT_PATH,
|
|
60
61
|
];
|
|
61
62
|
// ---------------------------------------------------------------------------
|
|
62
63
|
// Two-axis model resolution: (provider × strength) → concrete `model:thinking`.
|
|
@@ -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 {
|
|
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
|
-
|
|
155
|
-
|
|
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
|