@crouton-kit/crouter 0.3.60 → 0.3.62

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.
Files changed (120) hide show
  1. package/dist/builtin-views/canvas/core.mjs +2 -1
  2. package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/context-message.test.js +46 -0
  4. package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
  6. package/dist/clients/attach/attach-cmd.js +747 -743
  7. package/dist/clients/attach/canvas-panels.d.ts +4 -4
  8. package/dist/clients/attach/canvas-panels.js +108 -37
  9. package/dist/clients/attach/chat-view.js +11 -0
  10. package/dist/clients/attach/context-message.d.ts +3 -1
  11. package/dist/clients/attach/context-message.js +30 -21
  12. package/dist/clients/attach/graph-overlay.d.ts +8 -1
  13. package/dist/clients/attach/graph-overlay.js +201 -22
  14. package/dist/clients/attach/transport-relay.d.ts +51 -0
  15. package/dist/clients/attach/transport-relay.js +196 -0
  16. package/dist/clients/attach/transport-socket.d.ts +29 -0
  17. package/dist/clients/attach/transport-socket.js +164 -0
  18. package/dist/clients/attach/transport.d.ts +32 -0
  19. package/dist/clients/attach/transport.js +26 -0
  20. package/dist/clients/attach/view-socket.d.ts +40 -44
  21. package/dist/clients/attach/view-socket.js +94 -197
  22. package/dist/clients/web/server.js +1 -0
  23. package/dist/commands/memory/delete.js +3 -3
  24. package/dist/commands/memory/origin.js +1 -1
  25. package/dist/commands/memory/read.js +3 -3
  26. package/dist/commands/memory/shared.d.ts +8 -6
  27. package/dist/commands/memory/shared.js +23 -9
  28. package/dist/commands/memory/write.js +3 -3
  29. package/dist/commands/node-worktree.js +9 -9
  30. package/dist/commands/node.js +71 -23
  31. package/dist/commands/sys/doctor.js +1 -0
  32. package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
  33. package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
  35. package/dist/core/__tests__/context-intro.test.js +11 -0
  36. package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
  37. package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
  38. package/dist/core/__tests__/kickoff.test.js +21 -0
  39. package/dist/core/__tests__/spawn-root.test.js +60 -0
  40. package/dist/core/__tests__/worktree.test.js +24 -1
  41. package/dist/core/canvas/browse/app.d.ts +2 -0
  42. package/dist/core/canvas/browse/app.js +83 -48
  43. package/dist/core/canvas/db.js +3 -2
  44. package/dist/core/canvas/index.d.ts +1 -0
  45. package/dist/core/canvas/index.js +1 -0
  46. package/dist/core/canvas/nav-model.d.ts +5 -3
  47. package/dist/core/canvas/nav-model.js +7 -6
  48. package/dist/core/canvas/paths.d.ts +8 -0
  49. package/dist/core/canvas/paths.js +10 -0
  50. package/dist/core/canvas/render.d.ts +6 -0
  51. package/dist/core/canvas/render.js +117 -20
  52. package/dist/core/canvas/source.d.ts +26 -0
  53. package/dist/core/canvas/source.js +29 -0
  54. package/dist/core/canvas/status-glyph.js +1 -0
  55. package/dist/core/canvas/types.d.ts +4 -3
  56. package/dist/core/config.d.ts +18 -2
  57. package/dist/core/config.js +88 -10
  58. package/dist/core/fault-classifier.d.ts +2 -2
  59. package/dist/core/memory-resolver.d.ts +9 -6
  60. package/dist/core/memory-resolver.js +28 -4
  61. package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
  62. package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
  63. package/dist/core/profiles/manifest.d.ts +2 -0
  64. package/dist/core/profiles/manifest.js +40 -7
  65. package/dist/core/profiles/select.d.ts +4 -4
  66. package/dist/core/profiles/select.js +13 -13
  67. package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
  69. package/dist/core/runtime/bearings.d.ts +5 -4
  70. package/dist/core/runtime/bearings.js +12 -6
  71. package/dist/core/runtime/broker.js +59 -7
  72. package/dist/core/runtime/fault-recovery.js +4 -0
  73. package/dist/core/runtime/front-door.js +1 -1
  74. package/dist/core/runtime/host.js +43 -3
  75. package/dist/core/runtime/kickoff.js +6 -0
  76. package/dist/core/runtime/launch.d.ts +6 -1
  77. package/dist/core/runtime/launch.js +7 -1
  78. package/dist/core/runtime/nodes.d.ts +7 -6
  79. package/dist/core/runtime/nodes.js +3 -3
  80. package/dist/core/runtime/situational-context.d.ts +12 -0
  81. package/dist/core/runtime/situational-context.js +48 -0
  82. package/dist/core/runtime/spawn-env.d.ts +27 -0
  83. package/dist/core/runtime/spawn-env.js +169 -0
  84. package/dist/core/runtime/spawn.d.ts +16 -7
  85. package/dist/core/runtime/spawn.js +8 -2
  86. package/dist/core/substrate/session-cache.js +7 -1
  87. package/dist/core/substrate/subject.d.ts +1 -1
  88. package/dist/core/view/remote-canvas-target.d.ts +3 -0
  89. package/dist/core/view/remote-canvas-target.js +25 -0
  90. package/dist/core/worktree.d.ts +4 -4
  91. package/dist/core/worktree.js +41 -19
  92. package/dist/daemon/crtrd.js +32 -0
  93. package/dist/daemon/predicate-eval.js +12 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +4 -0
  96. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  97. package/dist/pi-extensions/canvas-bash-valve.js +238 -0
  98. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
  99. package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
  100. package/dist/types.d.ts +32 -0
  101. package/dist/types.js +7 -0
  102. package/dist/web-client/apple-touch-icon.png +0 -0
  103. package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
  104. package/dist/web-client/assets/index-BvzxXXGU.js +77 -0
  105. package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  106. package/dist/web-client/icon-192.png +0 -0
  107. package/dist/web-client/icon-512.png +0 -0
  108. package/dist/web-client/icon-maskable-512.png +0 -0
  109. package/dist/web-client/index.html +6 -3
  110. package/dist/web-client/manifest.webmanifest +1 -0
  111. package/dist/web-client/sw.js +1 -0
  112. package/docs/public-api.md +5 -3
  113. package/package.json +4 -3
  114. package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  115. package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  116. package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  117. package/dist/web-client/assets/index-72YQdji_.js +0 -77
  118. package/dist/web-client/assets/index-bDhImjJy.css +0 -2
  119. package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  120. package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
@@ -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 requireOriginMain(repoRoot) {
43
- return runGit(repoRoot, ['rev-parse', 'origin/main'], 'missing_origin_main', 'Managed worktrees land by rebasing onto origin/main. Fetch/create origin/main, then retry.');
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 = requireOriginMain(repoRoot);
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, 'origin/main'], 'worktree_add_failed', 'Inspect the git error, fix the repository/worktree state, then retry spawning with --worktree.');
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: 'origin/main',
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
- runGit(wt.path, ['fetch', 'origin', 'main'], 'fetch_failed', 'Network/remote state blocked landing. Ask the human with `crtr human ask` if this is not a transient git/network issue.');
101
- const rebase = gitSync(['rebase', 'origin/main'], wt.path);
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 origin/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);
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 push = gitSync(['push', 'origin', 'HEAD:main'], wt.path);
107
- if (push.status !== 0) {
108
- const detail = (push.stderr.trim() || push.stdout.trim()).trim();
109
- throw new WorktreeError('push_main_failed', 'could not fast-forward/push origin/main from this worktree', 'Do not force-push main. Ask the human with `crtr human ask` how to land this managed worktree.', detail);
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
- // The push landing IS the point of no return: persist `state: 'closed'`
112
- // IMMEDIATELY, before reading the landed sha or attempting any cleanup below.
113
- // Worktree removal and branch deletion are best-effort from here on — their
114
- // failure is cosmetic (a stray checkout / local ref) and must never re-strand
115
- // `state: 'open'` behind a dead end that permanently blocks push-final.
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
- pushed_sha: pushedSha,
174
+ landed_sha: landedSha,
153
175
  worktree_removed: worktreeRemoved,
154
176
  ...(worktreeRemoveError !== undefined ? { worktree_remove_error: worktreeRemoveError } : {}),
155
177
  branch_deleted: branchDeleted,
@@ -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
- ...process.env,
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,2 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ export default function (pi: ExtensionAPI): void;
@@ -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
- const digest = coalesce(batch);
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 = batch.some((e) => e.tier === 'critical');
148
- const steerMidStream = anyCritical || batch.some((e) => e.tier === 'urgent' || e.kind === 'final');
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 = batch.concat(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 = batch.concat(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