@crouton-kit/crouter 0.3.61 → 0.3.63
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/builtin-views/canvas/core.mjs +2 -1
- package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/context-message.test.js +46 -0
- package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
- package/dist/clients/attach/attach-cmd.js +747 -743
- package/dist/clients/attach/canvas-panels.d.ts +4 -4
- package/dist/clients/attach/canvas-panels.js +108 -37
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/context-message.d.ts +3 -1
- package/dist/clients/attach/context-message.js +30 -21
- package/dist/clients/attach/graph-overlay.d.ts +8 -1
- package/dist/clients/attach/graph-overlay.js +201 -22
- package/dist/clients/attach/transport-relay.d.ts +51 -0
- package/dist/clients/attach/transport-relay.js +196 -0
- package/dist/clients/attach/transport-socket.d.ts +29 -0
- package/dist/clients/attach/transport-socket.js +164 -0
- package/dist/clients/attach/transport.d.ts +32 -0
- package/dist/clients/attach/transport.js +26 -0
- package/dist/clients/attach/view-socket.d.ts +40 -44
- package/dist/clients/attach/view-socket.js +94 -197
- package/dist/commands/memory/delete.js +3 -3
- package/dist/commands/memory/origin.js +1 -1
- package/dist/commands/memory/read.js +3 -3
- package/dist/commands/memory/shared.d.ts +8 -6
- package/dist/commands/memory/shared.js +23 -9
- package/dist/commands/memory/write.js +3 -3
- package/dist/commands/node-worktree.js +9 -9
- package/dist/commands/node.js +71 -23
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
- package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
- package/dist/core/__tests__/context-intro.test.js +11 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
- package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
- package/dist/core/__tests__/full/broker-crash-teardown.test.js +17 -3
- package/dist/core/__tests__/kickoff.test.js +21 -0
- package/dist/core/__tests__/spawn-root.test.js +60 -0
- package/dist/core/__tests__/worktree.test.js +24 -1
- package/dist/core/canvas/browse/app.d.ts +2 -0
- package/dist/core/canvas/browse/app.js +83 -48
- package/dist/core/canvas/db.js +3 -2
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/nav-model.d.ts +5 -3
- package/dist/core/canvas/nav-model.js +7 -6
- package/dist/core/canvas/paths.d.ts +8 -0
- package/dist/core/canvas/paths.js +10 -0
- package/dist/core/canvas/render.d.ts +6 -0
- package/dist/core/canvas/render.js +117 -20
- package/dist/core/canvas/source.d.ts +26 -0
- package/dist/core/canvas/source.js +29 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/canvas/types.d.ts +4 -3
- package/dist/core/config.d.ts +18 -2
- package/dist/core/config.js +88 -10
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/memory-resolver.d.ts +9 -6
- package/dist/core/memory-resolver.js +28 -4
- package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
- package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
- package/dist/core/profiles/manifest.d.ts +2 -0
- package/dist/core/profiles/manifest.js +40 -7
- package/dist/core/profiles/select.d.ts +4 -4
- package/dist/core/profiles/select.js +13 -13
- package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
- package/dist/core/runtime/bearings.d.ts +5 -4
- package/dist/core/runtime/bearings.js +12 -6
- package/dist/core/runtime/broker.js +59 -7
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/host.js +43 -3
- package/dist/core/runtime/kickoff.js +6 -0
- package/dist/core/runtime/launch.d.ts +6 -1
- package/dist/core/runtime/launch.js +7 -1
- package/dist/core/runtime/nodes.d.ts +7 -6
- package/dist/core/runtime/nodes.js +3 -3
- package/dist/core/runtime/situational-context.d.ts +12 -0
- package/dist/core/runtime/situational-context.js +48 -0
- package/dist/core/runtime/spawn-env.d.ts +27 -0
- package/dist/core/runtime/spawn-env.js +169 -0
- package/dist/core/runtime/spawn.d.ts +16 -7
- package/dist/core/runtime/spawn.js +8 -2
- package/dist/core/substrate/session-cache.js +7 -1
- package/dist/core/substrate/subject.d.ts +1 -1
- package/dist/core/view/remote-canvas-target.d.ts +3 -0
- package/dist/core/view/remote-canvas-target.js +25 -0
- package/dist/core/worktree.d.ts +4 -4
- package/dist/core/worktree.js +41 -19
- package/dist/daemon/crtrd.js +32 -0
- package/dist/daemon/predicate-eval.js +12 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +238 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
- package/dist/types.d.ts +32 -0
- package/dist/types.js +7 -0
- package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
- package/dist/web-client/assets/{index-BQufdMhB.js → index-BvzxXXGU.js} +18 -18
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/public-api.md +5 -3
- package/package.json +2 -2
- package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
- package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
- package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
- package/dist/web-client/assets/index-FjfN-jvN.css +0 -2
- package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
- package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
- package/dist/web-client/workbox-98f7a950.js +0 -1
package/dist/core/worktree.js
CHANGED
|
@@ -39,24 +39,37 @@ export function openManagedWorktreeForNode(nodeId) {
|
|
|
39
39
|
function repoRootFor(cwd) {
|
|
40
40
|
return runGit(cwd, ['rev-parse', '--show-toplevel'], 'not_git_repo', 'Pass --cwd pointing inside a git repository.');
|
|
41
41
|
}
|
|
42
|
-
function
|
|
43
|
-
return runGit(repoRoot, ['rev-parse', '
|
|
42
|
+
function requireLocalMain(repoRoot) {
|
|
43
|
+
return runGit(repoRoot, ['rev-parse', '--verify', 'refs/heads/main'], 'missing_local_main', 'Managed worktrees are based on and land onto your local `main` branch. Create a local `main`, then retry.');
|
|
44
|
+
}
|
|
45
|
+
/** Path of the worktree where a local branch is currently checked out, or null
|
|
46
|
+
* if it is not checked out in any worktree. */
|
|
47
|
+
function checkoutPathForBranch(repoRoot, branch) {
|
|
48
|
+
const out = runGit(repoRoot, ['worktree', 'list', '--porcelain'], 'worktree_list_failed', 'Inspect the repository worktrees manually, then retry.');
|
|
49
|
+
let current = null;
|
|
50
|
+
for (const line of out.split('\n')) {
|
|
51
|
+
if (line.startsWith('worktree '))
|
|
52
|
+
current = line.slice('worktree '.length).trim();
|
|
53
|
+
else if (line === `branch refs/heads/${branch}`)
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
44
57
|
}
|
|
45
58
|
export function createManagedWorktree(cwd, nodeId) {
|
|
46
59
|
const repoRoot = repoRootFor(cwd);
|
|
47
|
-
const baseSha =
|
|
60
|
+
const baseSha = requireLocalMain(repoRoot);
|
|
48
61
|
const path = managedWorktreePath(nodeId);
|
|
49
62
|
if (existsSync(path)) {
|
|
50
63
|
throw new WorktreeError('worktree_path_exists', `managed worktree path already exists: ${path}`, 'Remove the stale path or choose a fresh node id.');
|
|
51
64
|
}
|
|
52
65
|
const branch = `crtr/${nodeId}`;
|
|
53
|
-
runGit(repoRoot, ['worktree', 'add', '-b', branch, path, '
|
|
66
|
+
runGit(repoRoot, ['worktree', 'add', '-b', branch, path, 'main'], 'worktree_add_failed', 'Inspect the git error, fix the repository/worktree state, then retry spawning with --worktree.');
|
|
54
67
|
return {
|
|
55
68
|
state: 'open',
|
|
56
69
|
path,
|
|
57
70
|
branch,
|
|
58
71
|
repo_root: repoRoot,
|
|
59
|
-
base_ref: '
|
|
72
|
+
base_ref: 'main',
|
|
60
73
|
base_sha: baseSha,
|
|
61
74
|
created: new Date().toISOString(),
|
|
62
75
|
};
|
|
@@ -97,24 +110,33 @@ export function closeManagedWorktree(nodeId) {
|
|
|
97
110
|
const found = branch === 'HEAD' ? 'detached HEAD' : branch;
|
|
98
111
|
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the human with \`crtr human ask\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`);
|
|
99
112
|
}
|
|
100
|
-
|
|
101
|
-
const rebase = gitSync(['rebase', '
|
|
113
|
+
requireLocalMain(wt.repo_root);
|
|
114
|
+
const rebase = gitSync(['rebase', 'main'], wt.path);
|
|
102
115
|
if (rebase.status !== 0) {
|
|
103
116
|
const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
|
|
104
|
-
throw new WorktreeError('rebase_failed', 'rebase onto
|
|
117
|
+
throw new WorktreeError('rebase_failed', 'rebase onto local main did not complete', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree close\`. If the blocker is not mechanical, ask the human with \`crtr human ask\`.`, detail);
|
|
105
118
|
}
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
const landedSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'landed_sha_failed', 'Inspect the worktree checkout manually, then rerun `crtr node worktree close`.');
|
|
120
|
+
// Fast-forward local `main` to the rebased worktree tip — landing is purely
|
|
121
|
+
// local, nothing is ever pushed to origin. If main is checked out in another
|
|
122
|
+
// worktree, ff-merge inside that checkout so its ref AND working tree advance
|
|
123
|
+
// together; otherwise move the branch ref directly.
|
|
124
|
+
const mainCheckout = checkoutPathForBranch(wt.repo_root, 'main');
|
|
125
|
+
const land = mainCheckout !== null
|
|
126
|
+
? gitSync(['merge', '--ff-only', landedSha], mainCheckout)
|
|
127
|
+
: gitSync(['branch', '-f', 'main', landedSha], wt.repo_root);
|
|
128
|
+
if (land.status !== 0) {
|
|
129
|
+
const detail = (land.stderr.trim() || land.stdout.trim()).trim();
|
|
130
|
+
throw new WorktreeError('land_main_failed', 'could not fast-forward local main to this worktree', mainCheckout !== null
|
|
131
|
+
? `Local main is checked out at ${mainCheckout} in a state that blocks a fast-forward (uncommitted changes, or it has diverged). Reconcile it there, then rerun \`crtr node worktree close\`. If you are unsure, ask the human with \`crtr human ask\`.`
|
|
132
|
+
: 'Ask the human with `crtr human ask` how to land this managed worktree.', detail);
|
|
110
133
|
}
|
|
111
|
-
//
|
|
112
|
-
// IMMEDIATELY, before
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
134
|
+
// Landing on local main IS the point of no return: persist `state: 'closed'`
|
|
135
|
+
// IMMEDIATELY, before attempting any cleanup below. Worktree removal and
|
|
136
|
+
// branch deletion are best-effort from here on — their failure is cosmetic
|
|
137
|
+
// (a stray checkout / local ref) and must never re-strand `state: 'open'`
|
|
138
|
+
// behind a dead end that permanently blocks push-final.
|
|
116
139
|
updateNode(nodeId, { managed_worktree: { ...wt, state: 'closed', closed: new Date().toISOString() } });
|
|
117
|
-
const pushedSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'pushed_sha_failed', 'The branch landed and the node is already marked closed, but crouter could not read the landed commit SHA. Inspect the checkout manually to confirm the landed commit; no further close action is needed.');
|
|
118
140
|
const remove = gitSync(['worktree', 'remove', wt.path], wt.repo_root);
|
|
119
141
|
const worktreeRemoved = remove.status === 0;
|
|
120
142
|
if (worktreeRemoved && existsSync(wt.path)) {
|
|
@@ -149,7 +171,7 @@ export function closeManagedWorktree(nodeId) {
|
|
|
149
171
|
node_id: nodeId,
|
|
150
172
|
branch: wt.branch,
|
|
151
173
|
worktree_path: wt.path,
|
|
152
|
-
|
|
174
|
+
landed_sha: landedSha,
|
|
153
175
|
worktree_removed: worktreeRemoved,
|
|
154
176
|
...(worktreeRemoveError !== undefined ? { worktree_remove_error: worktreeRemoveError } : {}),
|
|
155
177
|
branch_deleted: branchDeleted,
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -833,6 +833,38 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
|
|
|
833
833
|
return;
|
|
834
834
|
}
|
|
835
835
|
unhealthySince.delete(id);
|
|
836
|
+
// A FATAL *launch* fault (M-9 model-not-found — an unregistered/
|
|
837
|
+
// unauthenticated model spec recorded by the broker just before it threw
|
|
838
|
+
// and died) will reproduce IDENTICALLY on every resume: the saved launch
|
|
839
|
+
// recipe is what crashed it, so retrying it is not recovery, it's a crash
|
|
840
|
+
// loop — and unlike the null-pid stranded-relaunch path above, this branch
|
|
841
|
+
// has no attempt cap. Stop here: mark dead and notify once, instead of
|
|
842
|
+
// grace-reviving forever.
|
|
843
|
+
//
|
|
844
|
+
// Scoped narrowly to that launch fault (link + kind, not just
|
|
845
|
+
// disposition:'fatal') — other fatal faults (e.g. link:'pi→provider' auth/
|
|
846
|
+
// protocol/other faults) are intentionally handled live by
|
|
847
|
+
// handleFatalFault() without terminalizing the node, and must stay on the
|
|
848
|
+
// pre-existing liveness/grace-revive/resume path below if their broker pid
|
|
849
|
+
// later dies for an unrelated reason.
|
|
850
|
+
const fault = readFault(id);
|
|
851
|
+
if (fault !== null &&
|
|
852
|
+
fault.retry.disposition === 'fatal' &&
|
|
853
|
+
fault.link === 'crtr→pi' &&
|
|
854
|
+
fault.kind === 'model-not-found') {
|
|
855
|
+
process.stderr.write(`[crtrd] fatal-fault ${id} (${fault.kind}) — not resuming a dead-end launch; marking dead\n`);
|
|
856
|
+
transition(id, 'crash');
|
|
857
|
+
if (meta !== null) {
|
|
858
|
+
try {
|
|
859
|
+
fanDoctrineWake(id, subscribersOf(id), `Child failed to start — ${fullName(meta)} (${id}) hit a fatal launch fault (${fault.kind}: ${fault.message}) ` +
|
|
860
|
+
`and is now dead. It stays dead until you fix the cause and revive it — \`crtr node lifecycle revive ${id}\`.`, { reason: 'child-crashed', child: id });
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
/* best-effort, mirrors every other fan-out in this file */
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
836
868
|
process.stderr.write(`[crtrd] revive ${id} (engine dead, intent=${String(row.intent)})\n`);
|
|
837
869
|
reviveNode(id, { resume: retryResumeMode(meta) });
|
|
838
870
|
revivedThisTick.add(id); // third-pass bare double-spawn guard (Maj-4)
|
|
@@ -17,6 +17,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
17
17
|
import { duePredicateTriggers, duePredicateTimeouts, runningPredicateTriggers, acquirePredicateLease, recordPredicateEvalResult, resetPredicateLease, consumeTrigger, getNode, } from '../core/canvas/index.js';
|
|
18
18
|
import { isPidAlive, killProcessGroup } from '../core/canvas/pid.js';
|
|
19
19
|
import { nextSlotAfter } from '../core/wake.js';
|
|
20
|
+
import { buildOperationalEnvBase } from '../core/runtime/spawn-env.js';
|
|
20
21
|
export const STDOUT_CAP_BYTES = 16 * 1024;
|
|
21
22
|
export const STDERR_TAIL_BYTES = 4 * 1024;
|
|
22
23
|
/** Read fresh each pass (not cached at module load) so a test/operator can
|
|
@@ -85,7 +86,15 @@ export function reapTimedOutPredicates(now, onTimeout) {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
function runOneEvaluation(t, config, onDone) {
|
|
89
|
+
// The TARGET's own cwd/profile — never this (daemon) process's ambient
|
|
90
|
+
// cwd/profile — so `buildOperationalEnvBase`'s source C (`spawnEnv.allow`)
|
|
91
|
+
// resolves from the target's own project-scope config. For `msg` the
|
|
92
|
+
// target node already exists (`t.node_id`). For `spawn_child` it doesn't
|
|
93
|
+
// exist yet, so the child's future scope is the OWNER's current scope
|
|
94
|
+
// (`t.owner_id`) — the same scope a real spawn would inherit (spawn.ts:
|
|
95
|
+
// a managed child's profile defaults to its spawner's).
|
|
88
96
|
let cwd;
|
|
97
|
+
let targetProfileId;
|
|
89
98
|
if (t.action === 'msg') {
|
|
90
99
|
const target = t.node_id != null ? getNode(t.node_id) : null;
|
|
91
100
|
if (target === null) {
|
|
@@ -94,12 +103,14 @@ function runOneEvaluation(t, config, onDone) {
|
|
|
94
103
|
return;
|
|
95
104
|
}
|
|
96
105
|
cwd = target.cwd;
|
|
106
|
+
targetProfileId = target.profile_id ?? null;
|
|
97
107
|
}
|
|
98
108
|
else {
|
|
99
109
|
cwd = t.payload.cwd;
|
|
110
|
+
targetProfileId = getNode(t.owner_id)?.profile_id ?? null;
|
|
100
111
|
}
|
|
101
112
|
const env = {
|
|
102
|
-
...
|
|
113
|
+
...buildOperationalEnvBase({ targetCwd: cwd, targetProfileId }),
|
|
103
114
|
CRTR_TRIGGER_ID: t.trigger_id,
|
|
104
115
|
CRTR_TRIGGER_OWNER_ID: t.owner_id,
|
|
105
116
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { closeNode } from './core/runtime/close.js';
|
|
|
6
6
|
export type { CloseNodeResult } from './core/runtime/close.js';
|
|
7
7
|
export { appendInbox } from './core/feed/inbox.js';
|
|
8
8
|
export type { InboxEntry, InboxTier, InboxKind } from './core/feed/inbox.js';
|
|
9
|
+
export { appendSituationalContext } from './core/runtime/situational-context.js';
|
|
9
10
|
export { getNode, listNodes } from './core/canvas/canvas.js';
|
|
10
11
|
export { nodeDir } from './core/canvas/paths.js';
|
|
11
12
|
export type { NodeMeta, NodeRow, NodeStatus, Lifecycle, Mode, } from './core/canvas/types.js';
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,10 @@ export { spawnChild } from './core/runtime/spawn.js';
|
|
|
16
16
|
export { reviveNode } from './core/runtime/revive.js';
|
|
17
17
|
export { closeNode } from './core/runtime/close.js';
|
|
18
18
|
export { appendInbox } from './core/feed/inbox.js';
|
|
19
|
+
// Hidden ambient "current situation" sidecar (see runtime/situational-context.ts) —
|
|
20
|
+
// upsert-only, narrow surface so an external composer (e.g. Hearth) never needs to
|
|
21
|
+
// learn the storage file itself.
|
|
22
|
+
export { appendSituationalContext } from './core/runtime/situational-context.js';
|
|
19
23
|
// ── Canvas reads ─────────────────────────────────────────────────────────
|
|
20
24
|
export { getNode, listNodes } from './core/canvas/canvas.js';
|
|
21
25
|
export { nodeDir } from './core/canvas/paths.js';
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// canvas-bash-valve.ts — the bash safety valve: auto-background hanging
|
|
2
|
+
// commands, alert via inbox.
|
|
3
|
+
//
|
|
4
|
+
// Loaded into every canvas node's pi process via the node's launch.extensions
|
|
5
|
+
// list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job
|
|
6
|
+
// agent) — registers nothing, so the builtin bash tool stays in place.
|
|
7
|
+
//
|
|
8
|
+
// Rise with the tide: we do NOT reimplement or fork pi's bash tool. We call
|
|
9
|
+
// pi's own `createBashToolDefinition()` (schema, description, rendering,
|
|
10
|
+
// truncation, streaming throttle — ALL of it) and supply only a custom
|
|
11
|
+
// `BashOperations` exec backend. Future pi improvements to the bash tool flow
|
|
12
|
+
// through automatically; if pi ever changes the BashOperations interface, tsc
|
|
13
|
+
// breaks loudly at upgrade instead of silently drifting.
|
|
14
|
+
//
|
|
15
|
+
// Why file-backed, not pipes: the builtin streams from a direct child over
|
|
16
|
+
// pipes. A backgrounded orphan writing to a dead pipe after pi dies gets
|
|
17
|
+
// SIGPIPE, and its exit code is observable by nobody. So every command is
|
|
18
|
+
// file-backed from birth via a tiny detached supervisor (a `bash -c` process
|
|
19
|
+
// group leader) that redirects the real command's stdout+stderr to a log
|
|
20
|
+
// file and records its exit code to a sentinel file — both readable, and
|
|
21
|
+
// tail-able, independent of whether this pi process is even still alive.
|
|
22
|
+
//
|
|
23
|
+
// Deviation from upstream: this backend pins `bash` as the shell (upstream
|
|
24
|
+
// consults user shell config via non-exported helpers). Acceptable — crtr's
|
|
25
|
+
// agents are bash-contract anyway.
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Deadline — 5 minutes by default, overridable via CRTR_BASH_VALVE_MS
|
|
33
|
+
// (undocumented; test-only seam so the E2E valve check doesn't sleep 5m).
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const DEFAULT_VALVE_MS = 5 * 60 * 1000;
|
|
36
|
+
const POLL_MS = 200;
|
|
37
|
+
function resolveValveMs() {
|
|
38
|
+
const raw = process.env['CRTR_BASH_VALVE_MS'];
|
|
39
|
+
if (raw === undefined)
|
|
40
|
+
return DEFAULT_VALVE_MS;
|
|
41
|
+
const n = Number(raw);
|
|
42
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_VALVE_MS;
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// The detached supervisor. Spawned with `detached: true` so it becomes its
|
|
46
|
+
// own process-group leader (pgid === its pid) — killing `-pgid` reaches the
|
|
47
|
+
// real command and anything it forked, even after this pi process exits.
|
|
48
|
+
//
|
|
49
|
+
// Positional argv (never interpolated into the script — the command text
|
|
50
|
+
// lives only in cmd.sh):
|
|
51
|
+
// $1 cmd.sh $2 job.log $3 job.exit $4 job.bg $5 jobId $6 nodeId
|
|
52
|
+
//
|
|
53
|
+
// `--` before the paths is a dummy $0 (a standard `bash -c script -- args`
|
|
54
|
+
// idiom) so the real payload lands at $1 onward.
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
const SUPERVISOR_SCRIPT = [
|
|
57
|
+
'bash "$1" > "$2" 2>&1',
|
|
58
|
+
'ec=$?',
|
|
59
|
+
'echo "$ec" > "$3"',
|
|
60
|
+
'if [ -f "$4" ]; then',
|
|
61
|
+
' crtr node msg "Background bash job $5 finished (exit $ec). Log: $2 Command: $1" --to "$6" --tier normal || true',
|
|
62
|
+
'fi',
|
|
63
|
+
].join('\n');
|
|
64
|
+
function makeJobPaths(contextDir) {
|
|
65
|
+
const jobId = `${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
66
|
+
const dir = join(contextDir, 'jobs', jobId);
|
|
67
|
+
return {
|
|
68
|
+
jobId,
|
|
69
|
+
dir,
|
|
70
|
+
cmdSh: join(dir, 'cmd.sh'),
|
|
71
|
+
jobLog: join(dir, 'job.log'),
|
|
72
|
+
jobExit: join(dir, 'job.exit'),
|
|
73
|
+
jobBg: join(dir, 'job.bg'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function killGroup(pgid) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(-pgid, 'SIGTERM');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* already gone */
|
|
82
|
+
}
|
|
83
|
+
const killer = setTimeout(() => {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(-pgid, 'SIGKILL');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* already gone */
|
|
89
|
+
}
|
|
90
|
+
}, 2000);
|
|
91
|
+
killer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
/** Read bytes appended to `path` since `offset`, feed them to onData, return
|
|
94
|
+
* the new offset. Best-effort — a not-yet-created log file is not an error. */
|
|
95
|
+
function tailOnce(path, offset, onData) {
|
|
96
|
+
let stat;
|
|
97
|
+
try {
|
|
98
|
+
stat = statSync(path);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return offset;
|
|
102
|
+
}
|
|
103
|
+
if (stat.size <= offset)
|
|
104
|
+
return offset;
|
|
105
|
+
const fd = openSync(path, 'r');
|
|
106
|
+
try {
|
|
107
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
108
|
+
readSync(fd, buf, 0, buf.length, offset);
|
|
109
|
+
onData(buf);
|
|
110
|
+
return stat.size;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function cleanupJobDir(dir) {
|
|
117
|
+
try {
|
|
118
|
+
rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* best-effort — worst case a stray dir under context/jobs/ */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function readExitCode(path) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
127
|
+
const n = Number(raw);
|
|
128
|
+
return Number.isFinite(n) ? n : null;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** The valve's BashOperations backend. */
|
|
135
|
+
function createValveOperations(nodeId, contextDir) {
|
|
136
|
+
return {
|
|
137
|
+
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
if (signal?.aborted) {
|
|
140
|
+
reject(new Error('aborted'));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const paths = makeJobPaths(contextDir);
|
|
144
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
145
|
+
writeFileSync(paths.cmdSh, command);
|
|
146
|
+
writeFileSync(paths.jobLog, '');
|
|
147
|
+
let offset = 0;
|
|
148
|
+
let settled = false;
|
|
149
|
+
let pgid;
|
|
150
|
+
const startedAt = Date.now();
|
|
151
|
+
const valveMs = resolveValveMs();
|
|
152
|
+
let interval;
|
|
153
|
+
const teardown = () => {
|
|
154
|
+
if (interval)
|
|
155
|
+
clearInterval(interval);
|
|
156
|
+
if (signal)
|
|
157
|
+
signal.removeEventListener('abort', onAbort);
|
|
158
|
+
};
|
|
159
|
+
const finish = (act) => {
|
|
160
|
+
if (settled)
|
|
161
|
+
return;
|
|
162
|
+
settled = true;
|
|
163
|
+
teardown();
|
|
164
|
+
act();
|
|
165
|
+
};
|
|
166
|
+
function onAbort() {
|
|
167
|
+
tailOnce(paths.jobLog, offset, onData);
|
|
168
|
+
if (pgid)
|
|
169
|
+
killGroup(pgid);
|
|
170
|
+
finish(() => reject(new Error('aborted')));
|
|
171
|
+
cleanupJobDir(paths.dir);
|
|
172
|
+
}
|
|
173
|
+
if (signal)
|
|
174
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
175
|
+
const child = spawn('bash', ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobBg, paths.jobId, nodeId], {
|
|
176
|
+
cwd,
|
|
177
|
+
detached: true,
|
|
178
|
+
stdio: 'ignore',
|
|
179
|
+
env: env ?? process.env,
|
|
180
|
+
});
|
|
181
|
+
child.on('error', (err) => {
|
|
182
|
+
finish(() => reject(err));
|
|
183
|
+
});
|
|
184
|
+
pgid = child.pid;
|
|
185
|
+
child.unref();
|
|
186
|
+
interval = setInterval(() => {
|
|
187
|
+
offset = tailOnce(paths.jobLog, offset, onData);
|
|
188
|
+
if (existsSync(paths.jobExit)) {
|
|
189
|
+
offset = tailOnce(paths.jobLog, offset, onData);
|
|
190
|
+
const exitCode = readExitCode(paths.jobExit);
|
|
191
|
+
finish(() => resolve({ exitCode }));
|
|
192
|
+
cleanupJobDir(paths.dir);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const elapsedMs = Date.now() - startedAt;
|
|
196
|
+
if (timeout !== undefined && timeout > 0) {
|
|
197
|
+
if (elapsedMs >= timeout * 1000) {
|
|
198
|
+
if (pgid)
|
|
199
|
+
killGroup(pgid);
|
|
200
|
+
finish(() => reject(new Error(`timeout:${timeout}`)));
|
|
201
|
+
cleanupJobDir(paths.dir);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (elapsedMs >= valveMs) {
|
|
206
|
+
writeFileSync(paths.jobBg, '');
|
|
207
|
+
const minutes = Math.round(valveMs / 60000);
|
|
208
|
+
const notice = `\n[still running after ${minutes}m — backgrounded as job ${paths.jobId}. ` +
|
|
209
|
+
`You'll get an inbox message when it exits. ` +
|
|
210
|
+
`Watch: tail -f ${paths.jobLog} ; stop: kill -- -${pgid}]`;
|
|
211
|
+
onData(Buffer.from(notice));
|
|
212
|
+
finish(() => resolve({ exitCode: 0 }));
|
|
213
|
+
// Backgrounded — job dir persists (inspectable); no cleanup here.
|
|
214
|
+
}
|
|
215
|
+
}, POLL_MS);
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Extension
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
export default function (pi) {
|
|
224
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
225
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
226
|
+
return; // inert — builtin bash stays
|
|
227
|
+
const contextDir = process.env['CRTR_CONTEXT_DIR'];
|
|
228
|
+
if (contextDir === undefined || contextDir.trim() === '')
|
|
229
|
+
return; // defensive; always set alongside CRTR_NODE_ID
|
|
230
|
+
// Registered on session_start (fires for startup, new, resume, fork, and
|
|
231
|
+
// /reload) so we get ctx.cwd — the same session cwd the builtin bash tool
|
|
232
|
+
// is built against. registerTool replaces the builtin by name; re-firing on
|
|
233
|
+
// every session_start is idempotent (last registration wins).
|
|
234
|
+
pi.on('session_start', (_event, ctx) => {
|
|
235
|
+
const operations = createValveOperations(nodeId, contextDir);
|
|
236
|
+
pi.registerTool(createBashToolDefinition(ctx.cwd, { operations }));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
@@ -4,6 +4,18 @@ interface PiLike {
|
|
|
4
4
|
sendUserMessage: (content: string, options?: {
|
|
5
5
|
deliverAs?: 'steer' | 'followUp';
|
|
6
6
|
}) => void;
|
|
7
|
+
/** Custom-message injection — the hidden-context delivery seam (mirrors
|
|
8
|
+
* canvas-context-intro's session_start use), distinct from sendUserMessage's
|
|
9
|
+
* visible-chat path. */
|
|
10
|
+
sendMessage: (message: {
|
|
11
|
+
customType: string;
|
|
12
|
+
content: string;
|
|
13
|
+
display?: boolean;
|
|
14
|
+
details?: Record<string, unknown>;
|
|
15
|
+
}, options?: {
|
|
16
|
+
deliverAs?: 'steer' | 'followUp' | 'nextTurn';
|
|
17
|
+
triggerTurn?: boolean;
|
|
18
|
+
}) => void;
|
|
7
19
|
}
|
|
8
20
|
/**
|
|
9
21
|
* Register the canvas inbox watcher on `pi`.
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
// crouter's own tsc build without a dep on the pi packages.
|
|
34
34
|
import { readInboxSince, readCursor, writeCursor, coalesce, } from '../core/feed/inbox.js';
|
|
35
35
|
import { getNode } from '../core/canvas/index.js';
|
|
36
|
+
import { readSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../core/runtime/situational-context.js';
|
|
36
37
|
// ---------------------------------------------------------------------------
|
|
37
38
|
// Module-level timer — prevents stacking on /reload (the double-notify bug).
|
|
38
39
|
//
|
|
@@ -137,15 +138,50 @@ export function registerCanvasInboxWatcher(pi) {
|
|
|
137
138
|
return;
|
|
138
139
|
const batch = buffer;
|
|
139
140
|
buffer = [];
|
|
140
|
-
|
|
141
|
+
// Hidden situational-context delivery: an entry with data.situational marks
|
|
142
|
+
// a pending ambient-context update on this node's sidecar. Deliver it as a
|
|
143
|
+
// HIDDEN custom message (never through coalesce(), never as a visible chat
|
|
144
|
+
// turn) ahead of whatever visible digest remains. situationalOnly entries
|
|
145
|
+
// (context alone, no accompanying visible body) are dropped from the
|
|
146
|
+
// visible batch entirely — they exist purely as a wake marker for this
|
|
147
|
+
// delivery and must never surface a line in the digest.
|
|
148
|
+
const hasSituational = batch.some((e) => e.data?.['situational'] === true);
|
|
149
|
+
const visibleBatch = batch.filter((e) => e.data?.['situationalOnly'] !== true);
|
|
150
|
+
if (hasSituational) {
|
|
151
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
152
|
+
const text = nodeId !== undefined ? readSituationalContext(nodeId) : null;
|
|
153
|
+
if (text !== null) {
|
|
154
|
+
try {
|
|
155
|
+
pi.sendMessage({
|
|
156
|
+
customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
|
|
157
|
+
content: `<situational-context>\n${text}\n</situational-context>`,
|
|
158
|
+
display: true,
|
|
159
|
+
details: { nodeId },
|
|
160
|
+
}, { deliverAs: 'followUp', triggerTurn: true });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Re-queue on delivery failure — same durability contract as the visible
|
|
164
|
+
// digest path below. The cursor already advanced past these entries in
|
|
165
|
+
// tick(), so a swallowed failure here would otherwise drop the hidden
|
|
166
|
+
// wake permanently (never re-read from the inbox, never retried). Only
|
|
167
|
+
// the situational-only entries go back; a real re-send re-reads the
|
|
168
|
+
// CURRENT sidecar value (upsert, not append), so retrying is idempotent
|
|
169
|
+
// even if the text changed again in between.
|
|
170
|
+
buffer = batch.filter((e) => e.data?.['situationalOnly'] === true).concat(buffer);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (visibleBatch.length === 0)
|
|
175
|
+
return;
|
|
176
|
+
const digest = coalesce(visibleBatch);
|
|
141
177
|
// Tier (and kind) drive delivery mode. Critical is a TRUE preempt; urgent —
|
|
142
178
|
// and a finished node (kind 'final') — steers at the turn boundary;
|
|
143
179
|
// normal/deferred ride the next turn (followUp). (A purely-deferred idle
|
|
144
180
|
// batch was already held above and never reaches here.) A completion a
|
|
145
181
|
// subscriber is likely blocked on must not drain as a follow-up, so 'final'
|
|
146
182
|
// steers exactly like 'urgent'.
|
|
147
|
-
const anyCritical =
|
|
148
|
-
const steerMidStream = anyCritical ||
|
|
183
|
+
const anyCritical = visibleBatch.some((e) => e.tier === 'critical');
|
|
184
|
+
const steerMidStream = anyCritical || visibleBatch.some((e) => e.tier === 'urgent' || e.kind === 'final');
|
|
149
185
|
try {
|
|
150
186
|
if (isIdle()) {
|
|
151
187
|
// Idle → trigger a new turn immediately (sendUserMessage always triggers).
|
|
@@ -163,7 +199,7 @@ export function registerCanvasInboxWatcher(pi) {
|
|
|
163
199
|
lastCtx?.abort?.();
|
|
164
200
|
}
|
|
165
201
|
catch { /* abort is best-effort */ }
|
|
166
|
-
buffer =
|
|
202
|
+
buffer = visibleBatch.concat(buffer);
|
|
167
203
|
}
|
|
168
204
|
else {
|
|
169
205
|
// Mid-stream → steer on urgency or a finished node, else enqueue for the
|
|
@@ -174,7 +210,7 @@ export function registerCanvasInboxWatcher(pi) {
|
|
|
174
210
|
catch {
|
|
175
211
|
// Re-queue on delivery failure so a transient error doesn't silently drop
|
|
176
212
|
// inbox entries. They will be retried on the next flush.
|
|
177
|
-
buffer =
|
|
213
|
+
buffer = visibleBatch.concat(buffer);
|
|
178
214
|
}
|
|
179
215
|
};
|
|
180
216
|
// ---------------------------------------------------------------------------
|
package/dist/types.d.ts
CHANGED
|
@@ -137,6 +137,35 @@ export interface ScopeConfig {
|
|
|
137
137
|
* `defaultScopeConfig()`; user/project `config.json` adds or shadows
|
|
138
138
|
* entries at the same scope precedence as the rest of `ScopeConfig`. */
|
|
139
139
|
kinds: Record<string, KindConfig>;
|
|
140
|
+
/** Named remote-canvas targets for `crtr surface attach to <id> --canvas
|
|
141
|
+
* <name>` (Phase 1 interim contract — hand-edited in config.json, no CLI
|
|
142
|
+
* verbs yet; Phase 3 owns the real secret-store-backed `crtr canvas
|
|
143
|
+
* config`/`crtr canvas use`). Unset via `sys config set` — not in
|
|
144
|
+
* `TOP_LEVEL_KEYS`. */
|
|
145
|
+
remoteCanvas: RemoteCanvasConfig;
|
|
146
|
+
/** Extra env-var names/globs a scope explicitly admits across the broker
|
|
147
|
+
* spawn-env boundary (`buildBrokerEnv`, `core/runtime/spawn-env.ts`) — the
|
|
148
|
+
* "I accept this crosses as a shell-visible credential" escape hatch for a
|
|
149
|
+
* bespoke provider key, a deliberate `GIT_*` var, or a credentialed proxy.
|
|
150
|
+
* Default empty; merged additively across project > profile > user >
|
|
151
|
+
* builtin scope precedence, same as the rest of `ScopeConfig`. */
|
|
152
|
+
spawnEnv?: {
|
|
153
|
+
allow?: string[];
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** One remote canvas target: where to relay-attach and how to find its
|
|
157
|
+
* bearer token. The token itself is NEVER stored here — only the name of an
|
|
158
|
+
* environment variable holding it, resolved at connect time by
|
|
159
|
+
* `resolveRemoteCanvasTarget` (`src/core/view/remote-canvas-target.ts`). */
|
|
160
|
+
export interface RemoteCanvasTarget {
|
|
161
|
+
previewEndpoint: string;
|
|
162
|
+
/** Name of an environment variable holding the raw relay/bearer token —
|
|
163
|
+
* NEVER the token itself. Resolved from process.env at connect time. */
|
|
164
|
+
relayTokenEnv: string;
|
|
165
|
+
cpOrigin?: string;
|
|
166
|
+
}
|
|
167
|
+
export interface RemoteCanvasConfig {
|
|
168
|
+
targets: Record<string, RemoteCanvasTarget>;
|
|
140
169
|
}
|
|
141
170
|
export interface ScopeState {
|
|
142
171
|
marketplaces: Record<string, {
|
|
@@ -214,6 +243,9 @@ export declare const AGENTS_DIR = "agents";
|
|
|
214
243
|
export declare const PROFILE_DIR = "profiles";
|
|
215
244
|
export declare const DEFAULT_MAX_PANES_PER_WINDOW = 3;
|
|
216
245
|
export declare function defaultScopeConfig(): ScopeConfig;
|
|
246
|
+
/** No remote canvas targets are configured out of the box — every one is
|
|
247
|
+
* hand-added to config.json (Phase 1 interim contract, see `RemoteCanvasConfig`). */
|
|
248
|
+
export declare function defaultRemoteCanvasConfig(): RemoteCanvasConfig;
|
|
217
249
|
/** The builtin kind registry (spec §1.5): the built-in defaults for every
|
|
218
250
|
* top-level kind and its sub-persona kinds. `whenToUse`/`model` are the
|
|
219
251
|
* one-per-kind defaults (not one per mode) that seed a node's spawn menu
|
package/dist/types.js
CHANGED
|
@@ -31,8 +31,15 @@ export function defaultScopeConfig() {
|
|
|
31
31
|
canvasNav: defaultCanvasNavConfig(),
|
|
32
32
|
modelLadders: defaultModelLaddersConfig(),
|
|
33
33
|
kinds: defaultKindsConfig(),
|
|
34
|
+
remoteCanvas: defaultRemoteCanvasConfig(),
|
|
35
|
+
spawnEnv: { allow: [] },
|
|
34
36
|
};
|
|
35
37
|
}
|
|
38
|
+
/** No remote canvas targets are configured out of the box — every one is
|
|
39
|
+
* hand-added to config.json (Phase 1 interim contract, see `RemoteCanvasConfig`). */
|
|
40
|
+
export function defaultRemoteCanvasConfig() {
|
|
41
|
+
return { targets: {} };
|
|
42
|
+
}
|
|
36
43
|
/** The builtin kind registry (spec §1.5): the built-in defaults for every
|
|
37
44
|
* top-level kind and its sub-persona kinds. `whenToUse`/`model` are the
|
|
38
45
|
* one-per-kind defaults (not one per mode) that seed a node's spawn menu
|