@crouton-kit/crouter 0.3.36 → 0.3.38
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/dist/build-root.js +3 -6
- package/dist/{builtin-personas/runtime-base.md → builtin-memory/00-runtime-base.md} +8 -1
- package/dist/{builtin-personas/spine/has-manager.md → builtin-memory/01-spine/00-has-manager.md} +8 -0
- package/dist/builtin-memory/01-spine/01-no-manager.md +10 -0
- package/dist/{builtin-personas/lifecycle/terminal.md → builtin-memory/02-lifecycle/00-terminal.md} +8 -0
- package/dist/{builtin-personas/lifecycle/resident.md → builtin-memory/02-lifecycle/01-resident.md} +8 -0
- package/dist/{builtin-personas/waiting.md → builtin-memory/03-waiting.md} +7 -0
- package/dist/{builtin-personas/orchestration-kernel.md → builtin-memory/04-orchestration-kernel.md} +8 -0
- package/dist/{builtin-personas/advisor/PERSONA.md → builtin-memory/05-kinds/advisor/00-base.md} +5 -2
- package/dist/{builtin-personas/design/PERSONA.md → builtin-memory/05-kinds/design/00-base.md} +5 -2
- package/dist/{builtin-personas/design/orchestrator.md → builtin-memory/05-kinds/design/01-orchestrator.md} +5 -4
- package/dist/{builtin-personas/developer/PERSONA.md → builtin-memory/05-kinds/developer/00-base.md} +5 -2
- package/dist/{builtin-personas/developer/orchestrator.md → builtin-memory/05-kinds/developer/01-orchestrator.md} +5 -4
- package/dist/{builtin-personas/explore/PERSONA.md → builtin-memory/05-kinds/explore/00-base.md} +5 -2
- package/dist/{builtin-personas/explore/orchestrator.md → builtin-memory/05-kinds/explore/01-orchestrator.md} +5 -3
- package/dist/{builtin-personas/general/PERSONA.md → builtin-memory/05-kinds/general/00-base.md} +5 -2
- package/dist/{builtin-personas/general/orchestrator.md → builtin-memory/05-kinds/general/01-orchestrator.md} +5 -3
- package/dist/{builtin-personas/plan/PERSONA.md → builtin-memory/05-kinds/plan/00-base.md} +5 -2
- package/dist/{builtin-personas/plan/orchestrator.md → builtin-memory/05-kinds/plan/01-orchestrator.md} +5 -4
- package/dist/{builtin-personas/plan/reviewers/architecture-fit/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/architecture-fit.md} +5 -2
- package/dist/{builtin-personas/plan/reviewers/code-smells/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/code-smells.md} +5 -2
- package/dist/{builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md} +5 -2
- package/dist/{builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md} +5 -2
- package/dist/{builtin-personas/plan/reviewers/security/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/security.md} +5 -2
- package/dist/{builtin-personas/product/PERSONA.md → builtin-memory/05-kinds/product/00-base.md} +5 -2
- package/dist/{builtin-personas/product/orchestrator.md → builtin-memory/05-kinds/product/01-orchestrator.md} +5 -4
- package/dist/{builtin-personas/product/teardown/PERSONA.md → builtin-memory/05-kinds/product/teardown.md} +5 -3
- package/dist/{builtin-personas/review/PERSONA.md → builtin-memory/05-kinds/review/00-base.md} +5 -2
- package/dist/{builtin-personas/review/orchestrator.md → builtin-memory/05-kinds/review/01-orchestrator.md} +5 -3
- package/dist/{builtin-personas/spec/PERSONA.md → builtin-memory/05-kinds/spec/00-base.md} +5 -2
- package/dist/{builtin-personas/spec/orchestrator.md → builtin-memory/05-kinds/spec/01-orchestrator.md} +5 -4
- package/dist/{builtin-personas/spec/requirements/PERSONA.md → builtin-memory/05-kinds/spec/requirements.md} +5 -2
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
- package/dist/builtin-views/canvas/core.mjs +3 -2
- package/dist/builtin-views/prompt-review/core.mjs +6 -134
- package/dist/builtin-views/settings/core.mjs +26 -94
- package/dist/builtin-views/settings/text.mjs +0 -5
- package/dist/builtin-views/settings/tui.mjs +1 -7
- package/dist/builtin-views/settings/web.jsx +3 -49
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
- package/dist/clients/attach/attach-cmd.js +429 -432
- package/dist/clients/web/web-cmd.js +7 -7
- package/dist/commands/__tests__/human.test.js +1 -15
- package/dist/commands/attention.js +9 -6
- package/dist/commands/canvas-history/search.js +2 -2
- package/dist/commands/canvas-history/show.js +3 -3
- package/dist/commands/canvas-history.js +1 -1
- package/dist/commands/canvas-issue.js +1 -0
- package/dist/commands/canvas-snapshot.js +1 -0
- package/dist/commands/canvas.js +10 -11
- package/dist/commands/chord.js +1 -0
- package/dist/commands/human/prompts.js +0 -1
- package/dist/commands/human/queue.js +1 -0
- package/dist/commands/memory/read.js +3 -3
- package/dist/commands/memory/shared.d.ts +13 -8
- package/dist/commands/memory/shared.js +22 -8
- package/dist/commands/memory/write.js +5 -3
- package/dist/commands/node-snapshot.js +1 -0
- package/dist/commands/node.js +52 -22
- package/dist/commands/profile/add-project.d.ts +1 -0
- package/dist/commands/profile/add-project.js +42 -0
- package/dist/commands/profile/delete.d.ts +1 -0
- package/dist/commands/profile/delete.js +39 -0
- package/dist/commands/profile/list.d.ts +1 -0
- package/dist/commands/profile/list.js +35 -0
- package/dist/commands/profile/new.d.ts +1 -0
- package/dist/commands/profile/new.js +50 -0
- package/dist/commands/profile/remove-project.d.ts +1 -0
- package/dist/commands/profile/remove-project.js +42 -0
- package/dist/commands/profile/rename.d.ts +1 -0
- package/dist/commands/profile/rename.js +42 -0
- package/dist/commands/profile/show.d.ts +1 -0
- package/dist/commands/profile/show.js +50 -0
- package/dist/commands/profile.d.ts +2 -0
- package/dist/commands/profile.js +33 -0
- package/dist/commands/push.d.ts +0 -1
- package/dist/commands/push.js +2 -228
- package/dist/commands/revive.js +2 -2
- package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
- package/dist/commands/surface.js +3 -2
- package/dist/commands/sys/config.js +6 -21
- package/dist/commands/sys/daemon.d.ts +2 -0
- package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/commands/sys/prompt-review.js +90 -17
- package/dist/commands/sys.js +4 -3
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-wedge.test.js +87 -0
- package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
- package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
- package/dist/core/__tests__/history-inbox.test.js +105 -0
- package/dist/core/__tests__/kickoff.test.js +3 -3
- package/dist/core/__tests__/revive.test.js +39 -1
- package/dist/core/canvas/canvas.js +10 -9
- package/dist/core/canvas/db.js +11 -0
- package/dist/core/canvas/history.d.ts +1 -1
- package/dist/core/canvas/history.js +91 -3
- package/dist/core/canvas/paths.d.ts +6 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/canvas/types.d.ts +10 -0
- package/dist/core/config.d.ts +44 -1
- package/dist/core/config.js +155 -12
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/feed/inbox.d.ts +7 -12
- package/dist/core/feed/inbox.js +45 -29
- package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
- package/dist/core/memory-resolver.d.ts +26 -15
- package/dist/core/memory-resolver.js +158 -40
- package/dist/core/preview-registry.js +14 -112
- package/dist/core/profiles/manifest.d.ts +33 -0
- package/dist/core/profiles/manifest.js +332 -0
- package/dist/core/profiles/select.d.ts +11 -0
- package/dist/core/profiles/select.js +115 -0
- package/dist/core/render.js +1 -1
- package/dist/core/runtime/bearings.d.ts +9 -0
- package/dist/core/runtime/bearings.js +31 -0
- package/dist/core/runtime/busy.d.ts +15 -0
- package/dist/core/runtime/busy.js +32 -1
- package/dist/core/runtime/close.d.ts +10 -0
- package/dist/core/runtime/close.js +18 -13
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/front-door.js +9 -2
- package/dist/core/runtime/kickoff.js +5 -7
- package/dist/core/runtime/launch.d.ts +25 -7
- package/dist/core/runtime/launch.js +42 -55
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +6 -0
- package/dist/core/runtime/persona.js +18 -32
- package/dist/core/runtime/placement.js +1 -1
- package/dist/core/runtime/revive.js +9 -1
- package/dist/core/runtime/spawn.d.ts +13 -0
- package/dist/core/runtime/spawn.js +25 -0
- package/dist/core/runtime/tmux.d.ts +2 -2
- package/dist/core/runtime/tmux.js +2 -2
- package/dist/core/scope.d.ts +14 -7
- package/dist/core/scope.js +76 -20
- package/dist/core/substrate/index.d.ts +3 -3
- package/dist/core/substrate/index.js +5 -3
- package/dist/core/substrate/on-read.js +3 -2
- package/dist/core/substrate/render.d.ts +16 -11
- package/dist/core/substrate/render.js +249 -62
- package/dist/core/substrate/schema.d.ts +11 -3
- package/dist/core/substrate/schema.js +25 -0
- package/dist/core/substrate/subject.d.ts +15 -2
- package/dist/core/substrate/subject.js +24 -2
- package/dist/daemon/crtrd-cli.js +1 -1
- package/dist/daemon/crtrd.d.ts +24 -0
- package/dist/daemon/crtrd.js +213 -5
- package/dist/daemon/manage.js +2 -2
- package/dist/pi-extensions/canvas-doc-substrate.js +2 -3
- package/dist/pi-extensions/canvas-nav.js +2 -2
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +59 -2
- package/dist/types.d.ts +57 -1
- package/dist/types.js +83 -1
- package/dist/web-client/assets/{index-IAJVtuVe.js → index-CbO8L0mN.js} +4 -4
- package/dist/web-client/assets/index-DwO46Cs5.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +2 -2
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +0 -56
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +0 -57
- package/dist/builtin-memory/crouter-development/personas.md +0 -112
- package/dist/builtin-personas/spine/no-manager.md +0 -2
- package/dist/commands/daemon.d.ts +0 -2
- package/dist/core/personas/index.d.ts +0 -13
- package/dist/core/personas/index.js +0 -11
- package/dist/core/personas/loader.d.ts +0 -159
- package/dist/core/personas/loader.js +0 -327
- package/dist/core/personas/resolve.d.ts +0 -59
- package/dist/core/personas/resolve.js +0 -376
- package/dist/web-client/assets/index-DUThOUzU.css +0 -2
- /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// The profile store: `~/.crouter/profiles/<slug>-<id>/` — a manifest
|
|
2
|
+
// (`profile.json`) naming N project directories, plus the profile's own
|
|
3
|
+
// `memory/` store (spec §2.1). This module is the SOLE place that resolves a
|
|
4
|
+
// profile id/name to a path and writes/mutates a manifest: every join against
|
|
5
|
+
// the canonical profiles root goes through the id-shape + containment checks
|
|
6
|
+
// here, so no caller ever passes a raw CLI operand or `CRTR_PROFILE_ID`
|
|
7
|
+
// straight into `join(profilesRoot(), value)`.
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve as resolvePath, sep } from 'node:path';
|
|
11
|
+
import { SCHEMA_VERSION, PROFILE_DIR } from '../../types.js';
|
|
12
|
+
import { userScopeRoot, resetScopeCache } from '../scope.js';
|
|
13
|
+
import { ensureDir, nowIso } from '../fs-utils.js';
|
|
14
|
+
import { usage, notFound, ambiguous, general } from '../errors.js';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Path safety — the only code in the tree that builds a path under the
|
|
17
|
+
// profiles root. Every other module reaches profile paths through this file.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** `<slug>-<shortid>`: slug is lower-case, non-alphanumeric collapsed to `-`,
|
|
20
|
+
* trimmed to 40 chars (defaulting to `profile`); shortid is 8 lowercase hex
|
|
21
|
+
* chars. The char class alone rules out `/`, `\`, `.`, and `..` segments —
|
|
22
|
+
* this is the "generated id shape" check every exact-id lookup requires. */
|
|
23
|
+
const ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
|
|
24
|
+
export function profilesRoot() {
|
|
25
|
+
return join(userScopeRoot(), PROFILE_DIR);
|
|
26
|
+
}
|
|
27
|
+
function assertSafeProfileId(profileId) {
|
|
28
|
+
if (!ID_SHAPE.test(profileId)) {
|
|
29
|
+
throw usage(`invalid profile id: ${profileId}`, {
|
|
30
|
+
received: profileId,
|
|
31
|
+
next: 'Profile ids look like <slug>-<8-hex-chars> (as printed by `crtr profile new`). Pass an exact id or a profile name, not a raw path.',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Before any read/write/lock/delete: the resolved target must live INSIDE
|
|
36
|
+
* the canonical profiles root, even after symlink resolution. Every caller
|
|
37
|
+
* below reaches this only via `profileRoot`, which already ran
|
|
38
|
+
* `assertSafeProfileId` first — this is defense-in-depth, not the primary
|
|
39
|
+
* guard, and is what makes an unexpected symlink under a profile dir inert. */
|
|
40
|
+
function assertInsideProfilesRoot(path) {
|
|
41
|
+
const root = profilesRoot();
|
|
42
|
+
const resolvedRoot = existsSync(root) ? realpathSync(root) : resolvePath(root);
|
|
43
|
+
const resolvedPath = existsSync(path) ? realpathSync(path) : resolvePath(path);
|
|
44
|
+
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) {
|
|
45
|
+
throw usage(`profile path escapes the profiles root: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function profileRoot(profileId) {
|
|
49
|
+
assertSafeProfileId(profileId);
|
|
50
|
+
const root = join(profilesRoot(), profileId);
|
|
51
|
+
assertInsideProfilesRoot(root);
|
|
52
|
+
return root;
|
|
53
|
+
}
|
|
54
|
+
export function profileManifestPath(profileId) {
|
|
55
|
+
return join(profileRoot(profileId), 'profile.json');
|
|
56
|
+
}
|
|
57
|
+
export function profileMemoryDir(profileId) {
|
|
58
|
+
return join(profileRoot(profileId), 'memory');
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Enumeration + resolution — the ONLY functions that turn a CLI operand into
|
|
62
|
+
// a profile id. Both walk immediate children of the canonical root; neither
|
|
63
|
+
// ever joins an unvalidated operand into a path.
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
function readManifestFile(path) {
|
|
66
|
+
if (!existsSync(path))
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Every profile with a readable manifest, in directory-listing order.
|
|
76
|
+
* Enumerates ONLY immediate children of `profilesRoot()` — never recurses,
|
|
77
|
+
* never trusts a caller-supplied id. A dir whose name doesn't match the
|
|
78
|
+
* generated id shape, or whose `profile.json` is missing/corrupt, is skipped
|
|
79
|
+
* rather than crashing the whole listing. */
|
|
80
|
+
export function listProfiles() {
|
|
81
|
+
const root = profilesRoot();
|
|
82
|
+
if (!existsSync(root))
|
|
83
|
+
return [];
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const dirent of readdirSync(root, { withFileTypes: true })) {
|
|
86
|
+
if (!dirent.isDirectory())
|
|
87
|
+
continue;
|
|
88
|
+
const profileId = dirent.name;
|
|
89
|
+
if (!ID_SHAPE.test(profileId))
|
|
90
|
+
continue;
|
|
91
|
+
const manifest = readManifestFile(join(root, profileId, 'profile.json'));
|
|
92
|
+
if (manifest === null)
|
|
93
|
+
continue;
|
|
94
|
+
out.push({ profileId, manifest });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Resolve a CLI operand to its profile — exact id first (checked against the
|
|
99
|
+
* actually-enumerated dirs, never a blind join), then a unique manifest
|
|
100
|
+
* `name` match. Ambiguous names fail listing every matching id; no match
|
|
101
|
+
* fails naming `profile list`/`profile new` as recovery. This is the ONLY
|
|
102
|
+
* function every command leaf and future runtime consumer (Phase 5/6) should
|
|
103
|
+
* call to turn a `<profile>` operand or `CRTR_PROFILE_ID` into a concrete,
|
|
104
|
+
* safe profile id. */
|
|
105
|
+
export function loadProfileManifest(profileIdOrName) {
|
|
106
|
+
const all = listProfiles();
|
|
107
|
+
if (ID_SHAPE.test(profileIdOrName)) {
|
|
108
|
+
const byId = all.find((p) => p.profileId === profileIdOrName);
|
|
109
|
+
if (byId !== undefined)
|
|
110
|
+
return byId;
|
|
111
|
+
}
|
|
112
|
+
const byName = all.filter((p) => p.manifest.name === profileIdOrName);
|
|
113
|
+
if (byName.length === 1)
|
|
114
|
+
return byName[0];
|
|
115
|
+
if (byName.length > 1) {
|
|
116
|
+
const ids = byName.map((p) => p.profileId);
|
|
117
|
+
throw ambiguous(`profile name "${profileIdOrName}" matches ${ids.length} profiles: ${ids.join(', ')}`, {
|
|
118
|
+
received: profileIdOrName,
|
|
119
|
+
candidates: ids,
|
|
120
|
+
next: `Re-run with one exact profile id: ${ids.join(', ')}.`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
throw notFound(`profile not found: ${profileIdOrName}`, {
|
|
124
|
+
received: profileIdOrName,
|
|
125
|
+
next: 'Run `crtr profile list` to see available profiles, or `crtr profile new --name <name>` to create one.',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Locking — a plain mkdir-based mutex under `profilesRoot()/.locks/`. mkdir is
|
|
130
|
+
// atomic (EEXIST when already held), so this needs no extra dependency. The
|
|
131
|
+
// blocking wait uses Atomics.wait on a throwaway SharedArrayBuffer to stay
|
|
132
|
+
// synchronous, matching every other sync fs call in this module.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
135
|
+
const LOCK_POLL_MS = 25;
|
|
136
|
+
function locksDir() {
|
|
137
|
+
return join(profilesRoot(), '.locks');
|
|
138
|
+
}
|
|
139
|
+
function sleepSync(ms) {
|
|
140
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
141
|
+
}
|
|
142
|
+
/** Hold the per-profile manifest lock for the duration of `fn`. ALL manifest
|
|
143
|
+
* mutations (create/rename/add-project/remove-project/delete/last-used)
|
|
144
|
+
* below run inside this. */
|
|
145
|
+
export function withProfileManifestLock(profileId, fn) {
|
|
146
|
+
assertSafeProfileId(profileId);
|
|
147
|
+
ensureDir(locksDir());
|
|
148
|
+
const lockPath = join(locksDir(), `${profileId}.lock`);
|
|
149
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
150
|
+
for (;;) {
|
|
151
|
+
try {
|
|
152
|
+
mkdirSync(lockPath);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err.code !== 'EEXIST')
|
|
157
|
+
throw err;
|
|
158
|
+
if (Date.now() > deadline) {
|
|
159
|
+
throw general(`timed out waiting for the profile lock: ${profileId}`, {
|
|
160
|
+
next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
sleepSync(LOCK_POLL_MS);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return fn();
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
try {
|
|
171
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* best-effort release */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function writeManifestAtomic(path, manifest) {
|
|
179
|
+
ensureDir(dirname(path));
|
|
180
|
+
const tmp = `${path}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
181
|
+
writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
182
|
+
renameSync(tmp, path);
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Project dirs — resolved to absolute real paths, must exist + be a directory
|
|
186
|
+
// on add/create, deduped by real path, stored in manifest order.
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
function resolveExistingProjectDir(dir) {
|
|
189
|
+
const abs = resolvePath(dir);
|
|
190
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
191
|
+
throw usage(`project directory does not exist or is not a directory: ${dir}`, {
|
|
192
|
+
received: dir,
|
|
193
|
+
next: 'Pass an existing directory path.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return realpathSync(abs);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return abs;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function dedupeOrdered(values) {
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const v of values) {
|
|
207
|
+
if (seen.has(v))
|
|
208
|
+
continue;
|
|
209
|
+
seen.add(v);
|
|
210
|
+
out.push(v);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
function slugify(name) {
|
|
215
|
+
let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
216
|
+
if (slug.length > 40)
|
|
217
|
+
slug = slug.slice(0, 40).replace(/-+$/g, '');
|
|
218
|
+
return slug.length > 0 ? slug : 'profile';
|
|
219
|
+
}
|
|
220
|
+
function generateProfileId(name) {
|
|
221
|
+
const slug = slugify(name);
|
|
222
|
+
const root = profilesRoot();
|
|
223
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
224
|
+
const shortId = randomUUID().replace(/-/g, '').slice(0, 8);
|
|
225
|
+
const candidate = `${slug}-${shortId}`;
|
|
226
|
+
if (!existsSync(join(root, candidate)))
|
|
227
|
+
return candidate;
|
|
228
|
+
}
|
|
229
|
+
throw general(`failed to generate a unique profile id for "${name}" after 20 attempts`);
|
|
230
|
+
}
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Mutations — all under withProfileManifestLock, all atomic writes, all call
|
|
233
|
+
// resetScopeCache() when they can change project dirs or the profile name
|
|
234
|
+
// (never for a bare last-used-at touch).
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
export function createProfile(name, projects = []) {
|
|
237
|
+
const trimmed = name.trim();
|
|
238
|
+
if (trimmed === '')
|
|
239
|
+
throw usage('profile name must not be empty');
|
|
240
|
+
const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
|
|
241
|
+
const profileId = generateProfileId(trimmed);
|
|
242
|
+
return withProfileManifestLock(profileId, () => {
|
|
243
|
+
const root = profileRoot(profileId);
|
|
244
|
+
ensureDir(root);
|
|
245
|
+
ensureDir(join(root, 'memory'));
|
|
246
|
+
const manifest = {
|
|
247
|
+
schema_version: SCHEMA_VERSION,
|
|
248
|
+
name: trimmed,
|
|
249
|
+
projects: resolvedProjects,
|
|
250
|
+
created_at: nowIso(),
|
|
251
|
+
last_used_at: null,
|
|
252
|
+
};
|
|
253
|
+
writeManifestAtomic(profileManifestPath(profileId), manifest);
|
|
254
|
+
resetScopeCache();
|
|
255
|
+
return { profileId, manifest };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
function requireExistingProfile(profileId) {
|
|
259
|
+
assertSafeProfileId(profileId);
|
|
260
|
+
if (!existsSync(profileManifestPath(profileId))) {
|
|
261
|
+
throw notFound(`profile not found: ${profileId}`, {
|
|
262
|
+
received: profileId,
|
|
263
|
+
next: 'Run `crtr profile list` to see available profiles.',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function mutateManifest(profileId, mutate) {
|
|
268
|
+
requireExistingProfile(profileId);
|
|
269
|
+
return withProfileManifestLock(profileId, () => {
|
|
270
|
+
const path = profileManifestPath(profileId);
|
|
271
|
+
const current = readManifestFile(path);
|
|
272
|
+
if (current === null) {
|
|
273
|
+
throw notFound(`profile not found: ${profileId}`, { received: profileId });
|
|
274
|
+
}
|
|
275
|
+
const next = mutate(current);
|
|
276
|
+
writeManifestAtomic(path, next);
|
|
277
|
+
return { profileId, manifest: next };
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
export function updateProfileLastUsed(profileId) {
|
|
281
|
+
return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
|
|
282
|
+
}
|
|
283
|
+
export function renameProfile(profileId, name) {
|
|
284
|
+
const trimmed = name.trim();
|
|
285
|
+
if (trimmed === '')
|
|
286
|
+
throw usage('profile name must not be empty');
|
|
287
|
+
const result = mutateManifest(profileId, (m) => ({ ...m, name: trimmed }));
|
|
288
|
+
resetScopeCache();
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
export function addProfileProject(profileId, dir) {
|
|
292
|
+
const resolved = resolveExistingProjectDir(dir);
|
|
293
|
+
const result = mutateManifest(profileId, (m) => ({
|
|
294
|
+
...m,
|
|
295
|
+
projects: dedupeOrdered([...m.projects, resolved]),
|
|
296
|
+
}));
|
|
297
|
+
resetScopeCache();
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
export function removeProfileProject(profileId, dir) {
|
|
301
|
+
// Lenient resolution: a project dir may have been deleted since it was
|
|
302
|
+
// added, so removal must not require it to still exist on disk. Compare
|
|
303
|
+
// against both its real path (if still resolvable) and its plain resolved
|
|
304
|
+
// path (how it would have been stored had it never been a symlink).
|
|
305
|
+
const abs = resolvePath(dir);
|
|
306
|
+
let real = abs;
|
|
307
|
+
try {
|
|
308
|
+
real = realpathSync(abs);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
/* dir no longer exists — fall back to comparing by its resolved path */
|
|
312
|
+
}
|
|
313
|
+
const result = mutateManifest(profileId, (m) => {
|
|
314
|
+
const filtered = m.projects.filter((p) => p !== real && p !== abs);
|
|
315
|
+
if (filtered.length === m.projects.length) {
|
|
316
|
+
throw notFound(`project not found on profile: ${dir}`, {
|
|
317
|
+
received: dir,
|
|
318
|
+
next: `Current projects: ${m.projects.length > 0 ? m.projects.join(', ') : '(none)'}.`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return { ...m, projects: filtered };
|
|
322
|
+
});
|
|
323
|
+
resetScopeCache();
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
export function deleteProfile(profileId) {
|
|
327
|
+
requireExistingProfile(profileId);
|
|
328
|
+
withProfileManifestLock(profileId, () => {
|
|
329
|
+
rmSync(profileRoot(profileId), { recursive: true, force: true });
|
|
330
|
+
});
|
|
331
|
+
resetScopeCache();
|
|
332
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Select the profile a node about to boot at `cwd` should run under.
|
|
2
|
+
*
|
|
3
|
+
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`,
|
|
4
|
+
* bump `last_used_at`, return the id.
|
|
5
|
+
* 2. Else, among profiles whose project list COVERS `cwd`, pick the
|
|
6
|
+
* most-recently-used (nulls oldest), bump its `last_used_at`, return it.
|
|
7
|
+
* 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
|
|
8
|
+
* create a profile here or proceed as root, BEFORE pi boots. Non-
|
|
9
|
+
* interactive (no TTY): default to root (null) and print the recovery
|
|
10
|
+
* instruction to STDERR — never stdout, which the caller may be piping. */
|
|
11
|
+
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null): Promise<string | null>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Startup profile selection — the CTO-decided order applied at the front door
|
|
2
|
+
// and at any non-inherited `crtr node new`: explicit `--profile` > MRU profile
|
|
3
|
+
// covering cwd > synchronous create-or-root decision. This is the ONLY place
|
|
4
|
+
// that decision runs; front-door.ts/spawn.ts call it, never re-derive it.
|
|
5
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { basename, resolve as resolvePath, sep } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline/promises';
|
|
8
|
+
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, } from './manifest.js';
|
|
9
|
+
/** Resolve cwd to an absolute, realpath'd form so it compares against the
|
|
10
|
+
* realpath'd project dirs `createProfile`/`addProfileProject` already store
|
|
11
|
+
* (manifest.ts resolves every project dir through `realpathSync`). Falls back
|
|
12
|
+
* to the plain resolved path when the dir can't be stat'd (mirrors
|
|
13
|
+
* `resolveExistingProjectDir`'s own fallback). */
|
|
14
|
+
function resolveCwd(cwd) {
|
|
15
|
+
const abs = resolvePath(cwd);
|
|
16
|
+
if (!existsSync(abs))
|
|
17
|
+
return abs;
|
|
18
|
+
try {
|
|
19
|
+
return realpathSync(abs);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return abs;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** A project dir "covers" cwd when cwd sits at or below it, OR — the
|
|
26
|
+
* workspace-root case — the project dir itself sits below cwd (a profile can
|
|
27
|
+
* point at one project nested under a wider workspace cwd). */
|
|
28
|
+
function projectCovers(cwd, project) {
|
|
29
|
+
if (cwd === project)
|
|
30
|
+
return true;
|
|
31
|
+
if (cwd.startsWith(project + sep))
|
|
32
|
+
return true;
|
|
33
|
+
if (project.startsWith(cwd + sep))
|
|
34
|
+
return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function profileCoversCwd(entry, cwd) {
|
|
38
|
+
return entry.manifest.projects.some((p) => projectCovers(cwd, p));
|
|
39
|
+
}
|
|
40
|
+
/** `a` more recent than `b`, treating null as the oldest possible value (never
|
|
41
|
+
* wins over a real timestamp). ISO 8601 strings compare correctly lexically. */
|
|
42
|
+
function isMoreRecent(a, b) {
|
|
43
|
+
if (a === null)
|
|
44
|
+
return false;
|
|
45
|
+
if (b === null)
|
|
46
|
+
return true;
|
|
47
|
+
return a > b;
|
|
48
|
+
}
|
|
49
|
+
function pickMostRecent(entries) {
|
|
50
|
+
return entries.reduce((best, cur) => isMoreRecent(cur.manifest.last_used_at, best.manifest.last_used_at) ? cur : best);
|
|
51
|
+
}
|
|
52
|
+
function isInteractive() {
|
|
53
|
+
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
54
|
+
}
|
|
55
|
+
function recoveryMessage(cwd) {
|
|
56
|
+
return (`crtr: no profile covers ${cwd}, and this session is non-interactive — starting without a profile (root).\n` +
|
|
57
|
+
`Run \`crtr profile new --name <name> --project ${cwd}\` to create one, or pass --profile <id-or-name> next time.\n`);
|
|
58
|
+
}
|
|
59
|
+
/** The one synchronous (pre-boot) decision point: no profile covers cwd, and
|
|
60
|
+
* the session IS interactive. Ask on the controlling terminal — create a
|
|
61
|
+
* profile pointing at cwd, or proceed as root — and block until answered.
|
|
62
|
+
* Deliberately a minimal `readline` prompt, not `crtr human ask`: that bridge
|
|
63
|
+
* is asynchronous (writes a deck, waits for a separate process to answer it)
|
|
64
|
+
* and cannot gate pi's boot; the codebase's other humanloop entry points
|
|
65
|
+
* (`launchReview`, the `human _run` bridge) are built for reviewing an
|
|
66
|
+
* artifact or a queued interaction, not a two-choice gate on the same TTY pi
|
|
67
|
+
* is about to take over — a raw `readline` question is the actual minimal
|
|
68
|
+
* fit here. */
|
|
69
|
+
async function promptCreateOrRoot(cwd) {
|
|
70
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
71
|
+
try {
|
|
72
|
+
process.stdout.write(`\nNo profile covers this directory:\n ${cwd}\n\n`);
|
|
73
|
+
const choice = (await rl.question('Create a profile here, or run as root? [c]reate / [r]oot: '))
|
|
74
|
+
.trim()
|
|
75
|
+
.toLowerCase();
|
|
76
|
+
if (choice !== 'c' && choice !== 'create')
|
|
77
|
+
return null;
|
|
78
|
+
const defaultName = basename(cwd) || 'profile';
|
|
79
|
+
const nameInput = (await rl.question(`Profile name [${defaultName}]: `)).trim();
|
|
80
|
+
const name = nameInput !== '' ? nameInput : defaultName;
|
|
81
|
+
return createProfile(name, [cwd]).profileId;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
rl.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Select the profile a node about to boot at `cwd` should run under.
|
|
88
|
+
*
|
|
89
|
+
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`,
|
|
90
|
+
* bump `last_used_at`, return the id.
|
|
91
|
+
* 2. Else, among profiles whose project list COVERS `cwd`, pick the
|
|
92
|
+
* most-recently-used (nulls oldest), bump its `last_used_at`, return it.
|
|
93
|
+
* 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
|
|
94
|
+
* create a profile here or proceed as root, BEFORE pi boots. Non-
|
|
95
|
+
* interactive (no TTY): default to root (null) and print the recovery
|
|
96
|
+
* instruction to STDERR — never stdout, which the caller may be piping. */
|
|
97
|
+
export async function selectProfileForCwd(cwd, explicitProfile) {
|
|
98
|
+
if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
|
|
99
|
+
const entry = loadProfileManifest(explicitProfile);
|
|
100
|
+
updateProfileLastUsed(entry.profileId);
|
|
101
|
+
return entry.profileId;
|
|
102
|
+
}
|
|
103
|
+
const resolvedCwd = resolveCwd(cwd);
|
|
104
|
+
const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
|
|
105
|
+
if (covering.length > 0) {
|
|
106
|
+
const winner = pickMostRecent(covering);
|
|
107
|
+
updateProfileLastUsed(winner.profileId);
|
|
108
|
+
return winner.profileId;
|
|
109
|
+
}
|
|
110
|
+
if (!isInteractive()) {
|
|
111
|
+
process.stderr.write(recoveryMessage(resolvedCwd));
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return await promptCreateOrRoot(resolvedCwd);
|
|
115
|
+
}
|
package/dist/core/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The raw JSON object is still available behind the `--json` global for tooling.
|
|
7
7
|
//
|
|
8
8
|
// A leaf may hand `defineLeaf` a bespoke `render(result)` for instruction-shaped
|
|
9
|
-
// output that leads with the outcome (see `node new`, `push
|
|
9
|
+
// output that leads with the outcome (see `node new`, `push`).
|
|
10
10
|
// Everything else falls through to the schema-driven generic renderer here, so
|
|
11
11
|
// every command obeys the markdown paradigm without a hand-written renderer.
|
|
12
12
|
// `renderError` is the one sanctioned exception: a failure is a different domain
|
|
@@ -9,6 +9,15 @@ export declare function buildProjectContextBlock(cwd: string): string;
|
|
|
9
9
|
* Present for every node, rendered as the `## Your context directory`
|
|
10
10
|
* subsection of <crtr-bearings>. */
|
|
11
11
|
export declare function contextDirNote(nodeId: string): string;
|
|
12
|
+
/** The profile-purview note — rendered ONLY when `profile_id` resolves to a
|
|
13
|
+
* manifest that names at least one project dir. Phrased as PURVIEW, not a cwd
|
|
14
|
+
* replacement: the node's cwd is still its one working dir (bash/edits run
|
|
15
|
+
* there), the profile just widens what it may ALSO read/act on. Omitted
|
|
16
|
+
* entirely for a root/no-profile node or a deleted/unreadable manifest —
|
|
17
|
+
* never throws (mirrors `assembleNodeSubject`'s profile resolution). This
|
|
18
|
+
* absolute-path disclosure is intentional (spec-approved): the agent must
|
|
19
|
+
* know its full purview, not a redacted count. */
|
|
20
|
+
export declare function profileNote(nodeId: string): string;
|
|
12
21
|
/** Orchestrator-only framing: a resident orchestrator survives refresh cycles,
|
|
13
22
|
* so its context dir is also where a future cycle of itself resumes the work.
|
|
14
23
|
* Used inside the bearings block AND in the promotion guidance dump, so a
|
|
@@ -41,6 +41,7 @@ import { homedir } from 'node:os';
|
|
|
41
41
|
import { join, resolve } from 'node:path';
|
|
42
42
|
import { cadenceDisplay } from '../wake.js';
|
|
43
43
|
import { renderKnowledgeBlock } from '../substrate/index.js';
|
|
44
|
+
import { loadProfileManifest } from '../profiles/manifest.js';
|
|
44
45
|
// ---------------------------------------------------------------------------
|
|
45
46
|
// Project context (<project_context>) — the AGENTS.md/CLAUDE.md files pi would
|
|
46
47
|
// otherwise inject into its SYSTEM PROMPT, rendered here so they ride the
|
|
@@ -206,6 +207,32 @@ export function contextDirNote(nodeId) {
|
|
|
206
207
|
'this directory, so a bare relative `context/...` lands in the repo, not here — and other nodes ' +
|
|
207
208
|
'can only read what is actually here. When you report a file, report its absolute path.');
|
|
208
209
|
}
|
|
210
|
+
/** The profile-purview note — rendered ONLY when `profile_id` resolves to a
|
|
211
|
+
* manifest that names at least one project dir. Phrased as PURVIEW, not a cwd
|
|
212
|
+
* replacement: the node's cwd is still its one working dir (bash/edits run
|
|
213
|
+
* there), the profile just widens what it may ALSO read/act on. Omitted
|
|
214
|
+
* entirely for a root/no-profile node or a deleted/unreadable manifest —
|
|
215
|
+
* never throws (mirrors `assembleNodeSubject`'s profile resolution). This
|
|
216
|
+
* absolute-path disclosure is intentional (spec-approved): the agent must
|
|
217
|
+
* know its full purview, not a redacted count. */
|
|
218
|
+
export function profileNote(nodeId) {
|
|
219
|
+
const meta = getNode(nodeId);
|
|
220
|
+
const profileId = meta?.profile_id;
|
|
221
|
+
if (profileId === undefined || profileId === null || profileId === '')
|
|
222
|
+
return '';
|
|
223
|
+
let entry;
|
|
224
|
+
try {
|
|
225
|
+
entry = loadProfileManifest(profileId);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return '';
|
|
229
|
+
}
|
|
230
|
+
const projects = entry.manifest.projects;
|
|
231
|
+
if (projects.length === 0)
|
|
232
|
+
return '';
|
|
233
|
+
return (`Your working dir is \`${meta.cwd}\`. Your profile **${entry.manifest.name}** also has purview ` +
|
|
234
|
+
`over: ${projects.join(', ')}.`);
|
|
235
|
+
}
|
|
209
236
|
/** Orchestrator-only framing: a resident orchestrator survives refresh cycles,
|
|
210
237
|
* so its context dir is also where a future cycle of itself resumes the work.
|
|
211
238
|
* Used inside the bearings block AND in the promotion guidance dump, so a
|
|
@@ -415,6 +442,10 @@ export function buildContextBearings(nodeId) {
|
|
|
415
442
|
if (map !== '')
|
|
416
443
|
bearings.push('', '## Your place in the canvas', map);
|
|
417
444
|
bearings.push('', '## Your context directory', contextDirNote(nodeId));
|
|
445
|
+
// Profile purview — omitted entirely for a root/no-profile node (see profileNote).
|
|
446
|
+
const profileSection = profileNote(nodeId);
|
|
447
|
+
if (profileSection !== '')
|
|
448
|
+
bearings.push('', profileSection);
|
|
418
449
|
// Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
|
|
419
450
|
if (node?.mode === 'orchestrator')
|
|
420
451
|
bearings.push('', orchestratorContextNote(nodeId));
|
|
@@ -6,3 +6,18 @@ export declare function clearBusy(nodeId: string): void;
|
|
|
6
6
|
* site — a stale marker from a crashed pi is harmless because the dead pid
|
|
7
7
|
* fails the AND. */
|
|
8
8
|
export declare function isBusy(nodeId: string): boolean;
|
|
9
|
+
/** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
|
|
10
|
+
* heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
|
|
11
|
+
* every engine-level sign of life WITHIN a turn (a tool starting/streaming/
|
|
12
|
+
* ending, a streamed assistant token, a turn ending) so the marker's mtime
|
|
13
|
+
* always reflects the last moment the engine visibly did something, not just
|
|
14
|
+
* when the turn began. No-ops when the node isn't currently busy — a touch
|
|
15
|
+
* arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
|
|
16
|
+
* never resurrect a stale marker. Best-effort. */
|
|
17
|
+
export declare function touchBusy(nodeId: string): void;
|
|
18
|
+
/** The mid-turn marker's mtime: the last moment ANY engine-level progress was
|
|
19
|
+
* observed while the node was busy (see `touchBusy`). Null when the node isn't
|
|
20
|
+
* busy or the marker is unreadable. This is the sole heartbeat the daemon's
|
|
21
|
+
* wedge detector reads — it needs no new instrumentation beyond re-touching an
|
|
22
|
+
* already-existing file. */
|
|
23
|
+
export declare function busySince(nodeId: string): number | null;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// read site, so a stale marker (process crashed mid-turn without firing
|
|
14
14
|
// agent_end) is harmless — the dead pid fails the AND and the node is reaped.
|
|
15
15
|
// No db migration, atomic touch/unlink, best-effort (never throws).
|
|
16
|
-
import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { existsSync, writeFileSync, rmSync, mkdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { jobDir } from '../canvas/index.js';
|
|
19
19
|
function busyPath(nodeId) {
|
|
@@ -44,3 +44,34 @@ export function clearBusy(nodeId) {
|
|
|
44
44
|
export function isBusy(nodeId) {
|
|
45
45
|
return existsSync(busyPath(nodeId));
|
|
46
46
|
}
|
|
47
|
+
/** Re-touch the mid-turn marker's mtime — the daemon's wedge-detection
|
|
48
|
+
* heartbeat (crtrd.ts `handleWedgeDetection`). Called from canvas-stophook on
|
|
49
|
+
* every engine-level sign of life WITHIN a turn (a tool starting/streaming/
|
|
50
|
+
* ending, a streamed assistant token, a turn ending) so the marker's mtime
|
|
51
|
+
* always reflects the last moment the engine visibly did something, not just
|
|
52
|
+
* when the turn began. No-ops when the node isn't currently busy — a touch
|
|
53
|
+
* arriving after `clearBusy` (e.g. a late/queued event racing agent_end) must
|
|
54
|
+
* never resurrect a stale marker. Best-effort. */
|
|
55
|
+
export function touchBusy(nodeId) {
|
|
56
|
+
try {
|
|
57
|
+
if (!existsSync(busyPath(nodeId)))
|
|
58
|
+
return;
|
|
59
|
+
writeFileSync(busyPath(nodeId), '');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* best-effort */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The mid-turn marker's mtime: the last moment ANY engine-level progress was
|
|
66
|
+
* observed while the node was busy (see `touchBusy`). Null when the node isn't
|
|
67
|
+
* busy or the marker is unreadable. This is the sole heartbeat the daemon's
|
|
68
|
+
* wedge detector reads — it needs no new instrumentation beyond re-touching an
|
|
69
|
+
* already-existing file. */
|
|
70
|
+
export function busySince(nodeId) {
|
|
71
|
+
try {
|
|
72
|
+
return statSync(busyPath(nodeId)).mtimeMs;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import { type SubscriptionRef } from '../canvas/index.js';
|
|
2
|
+
/** The doctrine wake: fan a system notice to every given subscriber of a node
|
|
3
|
+
* whose lifecycle just ended out from under it (closed, or crashed to dead) —
|
|
4
|
+
* the runtime guarantee that a manager waiting dormant on a child always
|
|
5
|
+
* learns of its terminal outcome instead of hanging forever. Active subscriber
|
|
6
|
+
* → inbox (wakes a dormant manager via its live watcher / the daemon's
|
|
7
|
+
* dormant-revive pass); passive → the passive accumulator (delivered, not
|
|
8
|
+
* woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
|
|
9
|
+
* crash path (revive.ts). */
|
|
10
|
+
export declare function fanDoctrineWake(fromId: string, subscribers: readonly SubscriptionRef[], label: string, data: Record<string, unknown>): void;
|
|
1
11
|
export interface CloseNodeResult {
|
|
2
12
|
/** The focused node that was closed — the cascade root. */
|
|
3
13
|
root: string;
|
|
@@ -36,6 +36,23 @@ import { headlessBrokerHost } from './host.js';
|
|
|
36
36
|
import { tearDownNode, reapIfEmpty } from './placement.js';
|
|
37
37
|
import { appendInbox } from '../feed/inbox.js';
|
|
38
38
|
import { appendPassive } from '../feed/passive.js';
|
|
39
|
+
/** The doctrine wake: fan a system notice to every given subscriber of a node
|
|
40
|
+
* whose lifecycle just ended out from under it (closed, or crashed to dead) —
|
|
41
|
+
* the runtime guarantee that a manager waiting dormant on a child always
|
|
42
|
+
* learns of its terminal outcome instead of hanging forever. Active subscriber
|
|
43
|
+
* → inbox (wakes a dormant manager via its live watcher / the daemon's
|
|
44
|
+
* dormant-revive pass); passive → the passive accumulator (delivered, not
|
|
45
|
+
* woken). Shared by closeNode (step 4, below) and reviveNode's launch-refusal
|
|
46
|
+
* crash path (revive.ts). */
|
|
47
|
+
export function fanDoctrineWake(fromId, subscribers, label, data) {
|
|
48
|
+
for (const sub of subscribers) {
|
|
49
|
+
const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
|
|
50
|
+
if (sub.active)
|
|
51
|
+
appendInbox(sub.node_id, notice);
|
|
52
|
+
else
|
|
53
|
+
appendPassive(sub.node_id, notice);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
39
56
|
/** The set of nodes to close: the root plus every descendant reachable down the
|
|
40
57
|
* subscriptions spine, all of whose managers are themselves in the set. Grown
|
|
41
58
|
* to a fixpoint — a node added this pass can qualify its own children next
|
|
@@ -186,19 +203,7 @@ export function closeNode(rootId, opts = {}) {
|
|
|
186
203
|
// never fires inside a self-contained cascade. Active → inbox (wakes a
|
|
187
204
|
// dormant manager via its live watcher / the daemon's dormant-revive
|
|
188
205
|
// second pass); passive → passive accumulator (delivered, not woken).
|
|
189
|
-
|
|
190
|
-
const notice = {
|
|
191
|
-
from: id,
|
|
192
|
-
tier: 'normal',
|
|
193
|
-
kind: 'message',
|
|
194
|
-
label: `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`,
|
|
195
|
-
data: { reason: 'child-closed', child: id },
|
|
196
|
-
};
|
|
197
|
-
if (sub.active)
|
|
198
|
-
appendInbox(sub.node_id, notice);
|
|
199
|
-
else
|
|
200
|
-
appendPassive(sub.node_id, notice);
|
|
201
|
-
}
|
|
206
|
+
fanDoctrineWake(id, survivors, `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
|
|
202
207
|
closed.push(id);
|
|
203
208
|
}
|
|
204
209
|
catch {
|