@crouton-kit/crouter 0.3.61 → 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 (113) 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/commands/memory/delete.js +3 -3
  23. package/dist/commands/memory/origin.js +1 -1
  24. package/dist/commands/memory/read.js +3 -3
  25. package/dist/commands/memory/shared.d.ts +8 -6
  26. package/dist/commands/memory/shared.js +23 -9
  27. package/dist/commands/memory/write.js +3 -3
  28. package/dist/commands/node-worktree.js +9 -9
  29. package/dist/commands/node.js +71 -23
  30. package/dist/commands/sys/doctor.js +1 -0
  31. package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
  32. package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
  33. package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
  34. package/dist/core/__tests__/context-intro.test.js +11 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
  36. package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
  37. package/dist/core/__tests__/kickoff.test.js +21 -0
  38. package/dist/core/__tests__/spawn-root.test.js +60 -0
  39. package/dist/core/__tests__/worktree.test.js +24 -1
  40. package/dist/core/canvas/browse/app.d.ts +2 -0
  41. package/dist/core/canvas/browse/app.js +83 -48
  42. package/dist/core/canvas/db.js +3 -2
  43. package/dist/core/canvas/index.d.ts +1 -0
  44. package/dist/core/canvas/index.js +1 -0
  45. package/dist/core/canvas/nav-model.d.ts +5 -3
  46. package/dist/core/canvas/nav-model.js +7 -6
  47. package/dist/core/canvas/paths.d.ts +8 -0
  48. package/dist/core/canvas/paths.js +10 -0
  49. package/dist/core/canvas/render.d.ts +6 -0
  50. package/dist/core/canvas/render.js +117 -20
  51. package/dist/core/canvas/source.d.ts +26 -0
  52. package/dist/core/canvas/source.js +29 -0
  53. package/dist/core/canvas/status-glyph.js +1 -0
  54. package/dist/core/canvas/types.d.ts +4 -3
  55. package/dist/core/config.d.ts +18 -2
  56. package/dist/core/config.js +88 -10
  57. package/dist/core/fault-classifier.d.ts +2 -2
  58. package/dist/core/memory-resolver.d.ts +9 -6
  59. package/dist/core/memory-resolver.js +28 -4
  60. package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
  61. package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
  62. package/dist/core/profiles/manifest.d.ts +2 -0
  63. package/dist/core/profiles/manifest.js +40 -7
  64. package/dist/core/profiles/select.d.ts +4 -4
  65. package/dist/core/profiles/select.js +13 -13
  66. package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
  67. package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
  68. package/dist/core/runtime/bearings.d.ts +5 -4
  69. package/dist/core/runtime/bearings.js +12 -6
  70. package/dist/core/runtime/broker.js +59 -7
  71. package/dist/core/runtime/fault-recovery.js +4 -0
  72. package/dist/core/runtime/front-door.js +1 -1
  73. package/dist/core/runtime/host.js +43 -3
  74. package/dist/core/runtime/kickoff.js +6 -0
  75. package/dist/core/runtime/launch.d.ts +6 -1
  76. package/dist/core/runtime/launch.js +7 -1
  77. package/dist/core/runtime/nodes.d.ts +7 -6
  78. package/dist/core/runtime/nodes.js +3 -3
  79. package/dist/core/runtime/situational-context.d.ts +12 -0
  80. package/dist/core/runtime/situational-context.js +48 -0
  81. package/dist/core/runtime/spawn-env.d.ts +27 -0
  82. package/dist/core/runtime/spawn-env.js +169 -0
  83. package/dist/core/runtime/spawn.d.ts +16 -7
  84. package/dist/core/runtime/spawn.js +8 -2
  85. package/dist/core/substrate/session-cache.js +7 -1
  86. package/dist/core/substrate/subject.d.ts +1 -1
  87. package/dist/core/view/remote-canvas-target.d.ts +3 -0
  88. package/dist/core/view/remote-canvas-target.js +25 -0
  89. package/dist/core/worktree.d.ts +4 -4
  90. package/dist/core/worktree.js +41 -19
  91. package/dist/daemon/crtrd.js +32 -0
  92. package/dist/daemon/predicate-eval.js +12 -1
  93. package/dist/index.d.ts +1 -0
  94. package/dist/index.js +4 -0
  95. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  96. package/dist/pi-extensions/canvas-bash-valve.js +238 -0
  97. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
  98. package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
  99. package/dist/types.d.ts +32 -0
  100. package/dist/types.js +7 -0
  101. package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
  102. package/dist/web-client/assets/{index-BQufdMhB.js → index-BvzxXXGU.js} +18 -18
  103. package/dist/web-client/index.html +2 -2
  104. package/dist/web-client/sw.js +1 -1
  105. package/docs/public-api.md +5 -3
  106. package/package.json +2 -2
  107. package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  108. package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  109. package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  110. package/dist/web-client/assets/index-FjfN-jvN.css +0 -2
  111. package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  112. package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
  113. package/dist/web-client/workbox-98f7a950.js +0 -1
@@ -1,106 +1,99 @@
1
1
  // view-socket.ts — the socket client half of `crtr surface attach` (Phase 4, T7).
2
2
  //
3
3
  // The §0 ONE-WRITER INVARIANT lives in this directory: a viewer has ONLY a
4
- // socket. ViewSocketClient connects to a node's ALREADY-running headless broker
5
- // over its unix socket (`nodeDir(id)/view.sock`) and speaks the LOCAL
6
- // broker-protocol codec (`node:net` + the bounded `FrameDecoder`). It NEVER
7
- // spawns pi, NEVER opens the session `.jsonl`, NEVER constructs an engine — if
8
- // the broker is not running, connect fails and `attach-cmd` exits (focus/T9 is
4
+ // Transport (`transport.ts`) to an ALREADY-running headless broker — locally
5
+ // over its unix socket (`SocketTransport`, `nodeDir(id)/view.sock`), or
6
+ // remotely over a relayed WebSocket to the Blaxel preview endpoint
7
+ // (`RelayTransport`, `transport-relay.ts`). ViewSocketClient NEVER spawns pi,
8
+ // NEVER opens the session `.jsonl`, NEVER constructs an engine if the
9
+ // broker is not running, connect fails and `attach-cmd` exits (focus/T9 is
9
10
  // what keeps a broker alive, not attach). It also never uses pi's `RpcClient`
10
11
  // (which would spawn its own engine).
11
12
  //
13
+ // Everything below is transport-agnostic: request correlation (the pending
14
+ // map), the reconnect give-up predicate, and close handling are the SAME
15
+ // regardless of which Transport is plugged in at construction — only the
16
+ // wire differs. See `transport.ts` for the seam contract.
17
+ //
12
18
  // Reconnect: a dropped broker emits `close`; the caller's reconnect supervisor
13
- // decides whether to re-dial (a yield/revive keeps the SAME `view.sock` path
14
- // `redial()` re-establishes the socket on it) or give up ("broker gone"). On a
15
- // connect-time ECONNREFUSED/ENOENT a `BrokerUnavailableError` so the FIRST
16
- // connect exits non-zero with a clear message; the same codes during a redial
17
- // reject the redial promise as retryable. Decode is bounded by
18
- // `CLIENT_READ_CAPS`; a broker that somehow sends an oversized frame surfaces as
19
- // a clean error+exit, not a crash.
19
+ // decides whether to re-dial (a yield/revive keeps the SAME target a local
20
+ // `view.sock` path, or a remote preview endpoint `redial()` re-establishes
21
+ // the connection to it) or give up ("broker gone"). A connect-time
22
+ // unavailable-broker signal surfaces as `BrokerUnavailableError` so the FIRST
23
+ // connect exits non-zero with a clear message; the same signal during a
24
+ // redial rejects the redial promise as retryable.
20
25
  import { EventEmitter } from 'node:events';
21
26
  import { randomUUID } from 'node:crypto';
22
- import { createConnection } from 'node:net';
23
- import { viewSocketPath } from '../../core/canvas/paths.js';
24
- import { classify } from '../../core/fault-classifier.js';
25
- import { clearFault, recordFault } from '../../core/runtime/fault.js';
26
- import { CLIENT_READ_CAPS, encodeFrame, FrameDecoder, FrameOverflowError, } from '../../core/runtime/broker-protocol.js';
27
+ import { SocketTransport } from './transport-socket.js';
28
+ export { BrokerUnavailableError } from './transport.js';
29
+ export { SocketTransport } from './transport-socket.js';
30
+ export { RelayTransport } from './transport-relay.js';
27
31
  /** How long {@link ViewSocketClient.request} waits for the correlated reply before
28
32
  * rejecting — bounds a pending picker fetch if the broker drops the request
29
33
  * (it should always reply with `data` or a correlated `error`). */
30
34
  const REQUEST_TIMEOUT_MS = 10_000;
31
- /** Surfaced when the node has no reachable broker at connect time (no socket
32
- * file, or a stale socket with nothing listening). The command catches this to
33
- * exit non-zero with a focus/revive hint. */
34
35
  /** The reconnect supervisor's give-up predicate (extracted pure so it is
35
36
  * testable without a socket or TUI). After a broker close the viewer KEEPS
36
- * re-dialing the same `view.sock` while the node is still alive — a yield
37
+ * re-dialing the same target while the node is still alive — a yield
37
38
  * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
38
39
  * broker on the same path. It gives up only when the node is genuinely gone:
39
40
  * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
40
41
  * terminal — an idle-release node revives on its next inbox wake, so keep
41
42
  * trying (the supervisor waits at a relaxed cadence for as long as the row
42
- * stays live). */
43
+ * stays live). Local canvas rows only — a remote target has no local row, so
44
+ * callers driving a remote attach must not consult this (see attach-cmd.ts). */
43
45
  export function reconnectShouldGiveUp(row) {
44
46
  if (row === null)
45
47
  return true;
46
48
  return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
47
49
  }
48
- export class BrokerUnavailableError extends Error {
49
- nodeId;
50
- constructor(nodeId) {
51
- super(`node ${nodeId} has no running broker — focus or revive it first`);
52
- this.nodeId = nodeId;
53
- this.name = 'BrokerUnavailableError';
54
- }
55
- }
56
50
  export class ViewSocketClient extends EventEmitter {
57
51
  nodeId;
58
- socket;
59
- decoder = new FrameDecoder(CLIENT_READ_CAPS);
52
+ transport;
60
53
  closeEmitted = false;
54
+ /** Tracks live connect↔close state so {@link request} can fail fast on a
55
+ * dead/absent connection rather than parking a promise the reply will
56
+ * never reach (transport-agnostic mirror of the old socket.destroyed
57
+ * check). A post-close request lands here too — {@link rejectAllPending}
58
+ * already ran by the time this flips false. */
59
+ connected = false;
60
+ /** The last transport-level error message, so a close can reject any
61
+ * in-flight requests with the precise reason (e.g. an oversized-frame
62
+ * overflow) instead of a generic one. Cleared on every fresh connect. */
63
+ lastTransportError;
61
64
  /** In-flight correlated read-ops, keyed by the `id` minted in {@link request}.
62
65
  * Resolved by the matching `data` frame / rejected by the matching `error`
63
- * frame in {@link onData}, the request's timeout, or socket teardown. */
66
+ * frame in {@link onFrame}, the request's timeout, or transport teardown. */
64
67
  pending = new Map();
65
- /** One redial fault line per reconnect EPISODE, not per attempt: the
66
- * supervisor redials for as long as the node can come back (a dormant node
67
- * may be gone for hours), so a per-attempt record would grow fault.log
68
- * without bound. Set on the first failed redial, reset on any successful
69
- * connect. */
70
- redialFaultRecorded = false;
71
- constructor(nodeId) {
68
+ /** `transport` defaults to a {@link SocketTransport} against this node's
69
+ * local `view.sock` today's behavior, unchanged. Pass a
70
+ * {@link RelayTransport} to attach a remote node instead; every method
71
+ * below behaves identically either way. */
72
+ constructor(nodeId, transport) {
72
73
  super();
73
74
  this.nodeId = nodeId;
74
- }
75
- recordSocketFault(op, err) {
76
- if (op === 'redial') {
77
- if (this.redialFaultRecorded)
78
- return;
79
- this.redialFaultRecorded = true;
80
- }
81
- const classified = classify('viewer↔broker', err);
82
- recordFault(this.nodeId, {
83
- link: 'viewer↔broker',
84
- op,
85
- kind: classified.kind,
86
- retry: op === 'redial'
87
- ? { disposition: 'auto', by: 'client' }
88
- : { disposition: 'manual' },
89
- message: err instanceof Error ? err.message : String(err),
75
+ this.transport = transport ?? new SocketTransport(nodeId);
76
+ this.transport.on('connect', () => {
77
+ this.closeEmitted = false;
78
+ this.connected = true;
79
+ this.lastTransportError = undefined;
80
+ this.emit('connect');
81
+ });
82
+ this.transport.on('frame', (frame) => this.onFrame(frame));
83
+ this.transport.on('error', (err) => {
84
+ this.lastTransportError = err.message;
85
+ this.emitError(err);
90
86
  });
87
+ this.transport.on('close', () => this.onClose());
91
88
  }
92
89
  /** Issue a correlated read-op and resolve with the broker's `data` reply (or
93
- * reject on the correlated `error`, a timeout, or socket teardown). Mints the
94
- * `id`, sends `{...frame, id}`, and parks a resolver consumed by {@link onData}.
95
- * The reply is narrowed by the caller on its `kind`. */
90
+ * reject on the correlated `error`, a timeout, or transport teardown). Mints
91
+ * the `id`, sends `{...frame, id}`, and parks a resolver consumed by
92
+ * {@link onFrame}. The reply is narrowed by the caller on its `kind`. */
96
93
  request(frame) {
97
94
  const id = randomUUID();
98
95
  return new Promise((resolve, reject) => {
99
- // Fail fast on a dead/absent socket rather than parking a promise that the
100
- // reply will never reach (it would otherwise hang the full timeout). A
101
- // post-`close` request lands here too — `rejectAllPending` already ran.
102
- const sock = this.socket;
103
- if (sock === undefined || sock.destroyed) {
96
+ if (!this.connected) {
104
97
  reject(new Error(`cannot issue '${frame.type}': no live broker connection`));
105
98
  return;
106
99
  }
@@ -114,8 +107,8 @@ export class ViewSocketClient extends EventEmitter {
114
107
  this.send({ ...frame, id });
115
108
  });
116
109
  }
117
- /** Reject + clear every in-flight request (socket gone / decode error) so a
118
- * picker fetch never hangs past the connection it rode on. */
110
+ /** Reject + clear every in-flight request (connection gone / decode error)
111
+ * so a picker fetch never hangs past the connection it rode on. */
119
112
  rejectAllPending(reason) {
120
113
  for (const { reject, timer } of this.pending.values()) {
121
114
  clearTimeout(timer);
@@ -123,157 +116,61 @@ export class ViewSocketClient extends EventEmitter {
123
116
  }
124
117
  this.pending.clear();
125
118
  }
126
- /** The broker binds `viewSocketPath(id)` resolve it the same way. */
127
- get socketPath() {
128
- return viewSocketPath(this.nodeId);
129
- }
130
- /** Open the connection. Wire up listeners; emits `connect` on success or
131
- * `error` (a {@link BrokerUnavailableError} for ECONNREFUSED/ENOENT) on
132
- * failure. Idempotent guard is the caller's job — call once. */
119
+ /** Open the connection. Emits `connect` on success or `error` (a
120
+ * {@link BrokerUnavailableError} when the target has no reachable broker)
121
+ * on failure. Idempotent guard is the caller's job — call once. */
133
122
  connect() {
134
- const socket = createConnection(this.socketPath);
135
- this.socket = socket;
136
- socket.on('connect', () => {
137
- this.redialFaultRecorded = false;
138
- clearFault(this.nodeId, { link: 'viewer↔broker' });
139
- this.emit('connect');
140
- });
141
- socket.on('data', (chunk) => this.onData(chunk));
142
- socket.on('error', (err) => this.onError(err, 'connect'));
143
- socket.on('close', () => this.onClose());
123
+ this.transport.connect();
144
124
  }
145
- /** Re-establish the socket after a broker exit (a yield→revive cycle), on the
146
- * SAME stable `view.sock` path. Installs a FRESH FrameDecoder (a half-frame
147
- * from the dead stream must not corrupt the new one) and resets the close
148
- * guard, then re-dials. Resolves on `connect` (caller re-sends `hello`);
149
- * rejects on the dial's `error` (ECONNREFUSED while the new broker is
150
- * mid-boot, or ENOENT before it re-binds the socket) — both retryable. A
151
- * post-connect error flows through the normal `onError`→`close` path. */
125
+ /** Re-establish the connection after a broker exit (a yield→revive cycle),
126
+ * to the SAME target. Resolves on `connect` (caller re-sends `hello`);
127
+ * rejects on a retryable dial failure. */
152
128
  redial() {
153
- this.destroy();
154
- this.decoder = new FrameDecoder(CLIENT_READ_CAPS);
155
- this.closeEmitted = false;
156
- return new Promise((resolve, reject) => {
157
- const socket = createConnection(this.socketPath);
158
- this.socket = socket;
159
- let settled = false;
160
- socket.on('connect', () => {
161
- if (settled)
162
- return;
163
- settled = true;
164
- this.redialFaultRecorded = false;
165
- clearFault(this.nodeId, { link: 'viewer↔broker' });
166
- this.emit('connect');
167
- resolve();
168
- });
169
- socket.on('data', (chunk) => this.onData(chunk));
170
- socket.on('error', (err) => {
171
- if (settled) {
172
- this.onError(err, 'redial');
173
- return;
174
- }
175
- settled = true;
176
- this.recordSocketFault('redial', err);
177
- try {
178
- socket.destroy();
179
- }
180
- catch {
181
- /* ignore */
182
- }
183
- reject(err);
184
- });
185
- socket.on('close', () => this.onClose());
186
- });
129
+ return this.transport.redial();
187
130
  }
188
- /** Encode + write one client→broker frame. No-op on a dead/absent socket
189
- * (a `close` event drives teardown); never throws. */
131
+ /** Encode + send one client→broker frame. No-op on a dead/absent
132
+ * connection; never throws. */
190
133
  send(frame) {
191
- const sock = this.socket;
192
- if (sock === undefined || sock.destroyed)
193
- return;
194
- try {
195
- sock.write(encodeFrame(frame));
196
- }
197
- catch {
198
- /* dead socket — 'close' will fire and drive teardown */
199
- }
134
+ this.transport.send(frame);
200
135
  }
201
- /** Detach: destroy the socket. `close` fires → the caller tears down. */
136
+ /** Detach: tear down the transport. `close` fires → the caller tears down. */
202
137
  close() {
203
- this.destroy();
138
+ this.transport.destroy();
204
139
  }
205
- onData(chunk) {
206
- let frames;
207
- try {
208
- frames = this.decoder.push(chunk);
209
- }
210
- catch (err) {
211
- // A broker that somehow emits an oversized frame (or a corrupt stream):
212
- // clean error + drop, never a crash.
213
- const msg = err instanceof FrameOverflowError
214
- ? `broker sent an oversized frame (${err.message}) disconnecting`
215
- : `failed to decode a broker frame: ${String(err)}`;
216
- this.rejectAllPending(msg);
217
- this.emitError(new Error(msg));
218
- this.destroy();
219
- return;
220
- }
221
- for (const raw of frames) {
222
- const frame = raw;
223
- // Correlated replies (read-ops + dequeue) are consumed by the pending-by-id
224
- // resolver, NOT re-emitted as a generic 'frame' (the attach frame router
225
- // would otherwise treat a `data` frame as an AgentSessionEvent). A `data`
226
- // frame is ALWAYS a reply, so it is swallowed unconditionally — even a late
227
- // post-timeout one (no pending entry) is dropped, never leaked to the
228
- // router. An `error` is correlated only when its `id` matches an in-flight
229
- // request; an uncorrelated error still flows through.
230
- if (frame.type === 'data') {
231
- const entry = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined;
232
- if (entry) {
233
- this.pending.delete(frame.id);
234
- clearTimeout(entry.timer);
235
- entry.resolve(frame);
236
- }
237
- continue;
238
- }
239
- if (frame.type === 'error' && typeof frame.id === 'string' && this.pending.has(frame.id)) {
240
- const entry = this.pending.get(frame.id);
140
+ onFrame(frame) {
141
+ // Correlated replies (read-ops + dequeue) are consumed by the pending-by-id
142
+ // resolver, NOT re-emitted as a generic 'frame' (the attach frame router
143
+ // would otherwise treat a `data` frame as an AgentSessionEvent). A `data`
144
+ // frame is ALWAYS a reply, so it is swallowed unconditionally — even a late
145
+ // post-timeout one (no pending entry) is dropped, never leaked to the
146
+ // router. An `error` is correlated only when its `id` matches an in-flight
147
+ // request; an uncorrelated error still flows through.
148
+ if (frame.type === 'data') {
149
+ const entry = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined;
150
+ if (entry) {
241
151
  this.pending.delete(frame.id);
242
152
  clearTimeout(entry.timer);
243
- entry.reject(new Error(frame.message || `request failed: ${frame.code}`));
244
- continue;
153
+ entry.resolve(frame);
245
154
  }
246
- this.emit('frame', frame);
247
- }
248
- }
249
- onError(err, op) {
250
- this.recordSocketFault(op, err);
251
- if (err.code === 'ECONNREFUSED' || err.code === 'ENOENT') {
252
- this.emitError(new BrokerUnavailableError(this.nodeId));
155
+ return;
253
156
  }
254
- else {
255
- this.emitError(new Error(`view socket error: ${err.message}`));
157
+ if (frame.type === 'error' && typeof frame.id === 'string' && this.pending.has(frame.id)) {
158
+ const entry = this.pending.get(frame.id);
159
+ this.pending.delete(frame.id);
160
+ clearTimeout(entry.timer);
161
+ entry.reject(new Error(frame.message || `request failed: ${frame.code}`));
162
+ return;
256
163
  }
257
- // 'close' typically follows 'error'; teardown converges there.
164
+ this.emit('frame', frame);
258
165
  }
259
166
  onClose() {
260
167
  if (this.closeEmitted)
261
168
  return;
262
169
  this.closeEmitted = true;
263
- this.rejectAllPending('broker connection closed');
170
+ this.connected = false;
171
+ this.rejectAllPending(this.lastTransportError ?? 'broker connection closed');
264
172
  this.emit('close');
265
173
  }
266
- destroy() {
267
- const sock = this.socket;
268
- if (sock !== undefined && !sock.destroyed) {
269
- try {
270
- sock.destroy();
271
- }
272
- catch {
273
- /* ignore */
274
- }
275
- }
276
- }
277
174
  /** Emit `error` only when a listener exists — a bare EventEmitter `error`
278
175
  * with no listener throws, and this client must never throw uncaught. */
279
176
  emitError(err) {
@@ -22,17 +22,17 @@ function pruneEmptyParents(filePath, nameSegments) {
22
22
  export const deleteLeaf = defineLeaf({
23
23
  name: 'delete',
24
24
  description: 'delete a memory document by name',
25
- whenToUse: 'a stored document is wrong, obsolete, or superseded and should be removed — the sanctioned way to delete one, so you never rm the markdown off disk. Resolves the name exactly as `read` does (project stack > profile > user precedence, leaf-name fallback) and removes that file: if you can read it, you can delete it. Refuses docs you do not own — builtin docs (shipped with the package) and installed-plugin docs (managed by `crtr pkg`) — and refuses an ambiguous leaf name rather than guessing which doc to destroy; pass the full `area/topic` name or `--scope` to pin one.',
25
+ whenToUse: 'a stored document is wrong, obsolete, or superseded and should be removed — the sanctioned way to delete one, so you never rm the markdown off disk. Resolves the name exactly as `read` does (node > project stack > profile > user precedence, leaf-name fallback) and removes that file: if you can read it, you can delete it. Refuses docs you do not own — builtin docs (shipped with the package) and installed-plugin docs (managed by `crtr pkg`) — and refuses an ambiguous leaf name rather than guessing which doc to destroy; pass the full `area/topic` name or `--scope` to pin one.',
26
26
  help: {
27
27
  name: 'memory delete',
28
28
  summary: 'resolve a path-derived name and remove that document from its scope store',
29
29
  params: [
30
- { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`), resolved as `read` resolves it: precedence project stack > profile > user, with leaf-name fallback. A leaf that maps to genuinely different docs across scopes is rejected as ambiguous — pass the full name or --scope to disambiguate. May be scope-prefixed (`user/topic`) to pin the store directly.' },
30
+ { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`), resolved as `read` resolves it: precedence node > project stack > profile > user, with leaf-name fallback. A leaf that maps to genuinely different docs across scopes is rejected as ambiguous — pass the full name or --scope to disambiguate. May be scope-prefixed (`user/topic`) to pin the store directly.' },
31
31
  { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Restrict resolution to this scope before deleting. Use it to disambiguate a leaf name present at multiple scopes, or to assert which store you mean. builtin is not a choice — builtin docs ship with the package and cannot be deleted.' },
32
32
  ],
33
33
  output: [
34
34
  { name: 'name', type: 'string', required: true, constraint: 'Path-derived name of the deleted document.' },
35
- { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was deleted from: user, project, or profile.' },
35
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was deleted from: node, user, project, or profile.' },
36
36
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path of the file that was removed.' },
37
37
  { name: 'deleted', type: 'boolean', required: true, constraint: 'Always true on success — the file was removed. A missing document fails with not_found instead.' },
38
38
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command — browse what remains.' },
@@ -11,7 +11,7 @@ export const originLeaf = defineLeaf({
11
11
  name: 'memory origin',
12
12
  summary: 'resolve a doc name to its creation provenance and deref the originating node',
13
13
  params: [
14
- { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`). Resolved across scopes: project > user > builtin.' },
14
+ { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`). Resolved across scopes: node > project > profile > user > builtin.' },
15
15
  ],
16
16
  output: [
17
17
  { name: 'name', type: 'string', required: true, constraint: 'Resolved document name.' },
@@ -8,19 +8,19 @@ import { MEMORY_KINDS } from './shared.js';
8
8
  export const readLeaf = defineLeaf({
9
9
  name: 'read',
10
10
  description: 'load a memory document body by name',
11
- whenToUse: 'a task in front of you matches a stored document and you already know its name — read it before improvising. Resolves the path-derived name across scopes by precedence (project stack > profile > user > builtin), with leaf-name fallback. You name the document by its crtr identifier, never a file path — do not cat or find the markdown off disk. Reach for `crtr memory find` first when you do not yet know which document applies.',
11
+ whenToUse: 'a task in front of you matches a stored document and you already know its name — read it before improvising. Resolves the path-derived name across scopes by precedence (node > project stack > profile > user > builtin), with leaf-name fallback. You name the document by its crtr identifier, never a file path — do not cat or find the markdown off disk. Reach for `crtr memory find` first when you do not yet know which document applies.',
12
12
  help: {
13
13
  name: 'memory read',
14
14
  summary: 'resolve a path-derived name to its document body, frontmatter stripped unless --frontmatter',
15
15
  params: [
16
- { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`). Resolved across scopes by precedence: project stack > profile > user > builtin, with leaf-name fallback.' },
16
+ { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`). Resolved across scopes by precedence: node > project stack > profile > user > builtin, with leaf-name fallback.' },
17
17
  { kind: 'flag', name: 'kind', type: 'enum', choices: [...MEMORY_KINDS], required: false, constraint: 'Narrows resolution when the name is ambiguous across kinds.' },
18
18
  { kind: 'flag', name: 'frontmatter', type: 'bool', required: false, constraint: 'When present, includes the YAML frontmatter in the returned body. Off by default — only the body is returned.' },
19
19
  ],
20
20
  output: [
21
21
  { name: 'name', type: 'string', required: true, constraint: 'Resolved document name.' },
22
22
  { name: 'kind', type: 'string', required: true, constraint: 'Resolved kind: knowledge or preference.' },
23
- { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was resolved from: project, profile, user, or builtin.' },
23
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was resolved from: node, project, profile, user, or builtin.' },
24
24
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the document on disk — edit this file directly to tweak the doc in place.' },
25
25
  { name: 'content', type: 'string', required: true, constraint: 'Document body. Frontmatter stripped unless --frontmatter is set.' },
26
26
  { name: 'follow_up', type: 'string', required: true, constraint: 'Hints at variant flags or next commands.' },
@@ -1,10 +1,10 @@
1
1
  import type { MemoryScope } from '../../core/memory-resolver.js';
2
2
  export declare const MEMORY_KINDS: readonly ["knowledge", "preference"];
3
3
  export declare const VISIBILITY_RUNGS: readonly ["none", "name", "preview", "content"];
4
- export declare const MEMORY_SCOPES: readonly ["user", "project", "profile"];
5
- /** Scope sort weight matching resolution precedence (project stack > profile >
6
- * user > builtin). Used by `list` for its "scope then kind then name"
7
- * ordering. */
4
+ export declare const MEMORY_SCOPES: readonly ["user", "project", "profile", "node"];
5
+ /** Scope sort weight matching resolution precedence (node > project stack >
6
+ * profile > user > builtin). Used by `list` for its "scope then kind then
7
+ * name" ordering. */
8
8
  export declare function scopeRank(scope: MemoryScope): number;
9
9
  /** Resolve the write target scope + its memory dir. Default: project when a
10
10
  * project scope exists for the cwd, else user. An explicit `--scope project`
@@ -16,8 +16,10 @@ export declare function scopeRank(scope: MemoryScope): number;
16
16
  * `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
17
17
  * `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
18
18
  * — the way a profiled agent writes into any project in its purview, and the
19
- * only way to target a dir shadowed by an ancestor store. Returns the
20
- * absolute `<root>/memory` dir to write under. */
19
+ * only way to target a dir shadowed by an ancestor store. `--scope node`
20
+ * targets the this-node store (`nodes/<CRTR_NODE_ID>/context/memory/`) and
21
+ * requires a running node. Returns the absolute `<root>/memory` dir to write
22
+ * under. */
21
23
  export declare function resolveWriteTarget(scopeArg: string | undefined, profileArg?: string, dirArg?: string): {
22
24
  scope: MemoryScope;
23
25
  memoryDir: string;
@@ -10,6 +10,7 @@ import { usage } from '../../core/errors.js';
10
10
  import { CRTR_DIR_NAME } from '../../types.js';
11
11
  import { scopeMemoryDir, projectScopeRoot, ensureProjectScopeRoot, resetScopeCache, } from '../../core/scope.js';
12
12
  import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
13
+ import { memoryDir as nodeMemoryDir } from '../../core/runtime/memory.js';
13
14
  // The two memory kinds — knowledge (consult: procedural playbooks + factual
14
15
  // references merged) vs preference (behave: standing directives). Used as the
15
16
  // `--kind` enum choices everywhere.
@@ -18,12 +19,13 @@ export const MEMORY_KINDS = ['knowledge', 'preference'];
18
19
  // content). Shared by --system-prompt-visibility and --file-read-visibility.
19
20
  export const VISIBILITY_RUNGS = ['none', 'name', 'preview', 'content'];
20
21
  // Scope choices for filtering / targeting (builtin is read-only, not writable).
21
- export const MEMORY_SCOPES = ['user', 'project', 'profile'];
22
- /** Scope sort weight matching resolution precedence (project stack > profile >
23
- * user > builtin). Used by `list` for its "scope then kind then name"
24
- * ordering. */
22
+ // `node` is the this-node store, writable only inside a running node.
23
+ export const MEMORY_SCOPES = ['user', 'project', 'profile', 'node'];
24
+ /** Scope sort weight matching resolution precedence (node > project stack >
25
+ * profile > user > builtin). Used by `list` for its "scope then kind then
26
+ * name" ordering. */
25
27
  export function scopeRank(scope) {
26
- return scope === 'project' ? 0 : scope === 'profile' ? 1 : scope === 'user' ? 2 : 3;
28
+ return scope === 'node' ? -1 : scope === 'project' ? 0 : scope === 'profile' ? 1 : scope === 'user' ? 2 : 3;
27
29
  }
28
30
  /** Resolve the write target scope + its memory dir. Default: project when a
29
31
  * project scope exists for the cwd, else user. An explicit `--scope project`
@@ -35,8 +37,10 @@ export function scopeRank(scope) {
35
37
  * `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
36
38
  * `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
37
39
  * — the way a profiled agent writes into any project in its purview, and the
38
- * only way to target a dir shadowed by an ancestor store. Returns the
39
- * absolute `<root>/memory` dir to write under. */
40
+ * only way to target a dir shadowed by an ancestor store. `--scope node`
41
+ * targets the this-node store (`nodes/<CRTR_NODE_ID>/context/memory/`) and
42
+ * requires a running node. Returns the absolute `<root>/memory` dir to write
43
+ * under. */
40
44
  export function resolveWriteTarget(scopeArg, profileArg, dirArg) {
41
45
  if (dirArg !== undefined && dirArg !== '') {
42
46
  if (scopeArg !== undefined && scopeArg !== 'project') {
@@ -63,15 +67,25 @@ export function resolveWriteTarget(scopeArg, profileArg, dirArg) {
63
67
  return { scope: 'project', memoryDir: join(real, CRTR_DIR_NAME, 'memory') };
64
68
  }
65
69
  let scope;
66
- if (scopeArg === 'user' || scopeArg === 'project' || scopeArg === 'profile') {
70
+ if (scopeArg === 'user' || scopeArg === 'project' || scopeArg === 'profile' || scopeArg === 'node') {
67
71
  scope = scopeArg;
68
72
  }
69
73
  else if (scopeArg !== undefined) {
70
- throw usage(`invalid --scope: ${scopeArg} (expected user|project|profile)`);
74
+ throw usage(`invalid --scope: ${scopeArg} (expected user|project|profile|node)`);
71
75
  }
72
76
  else {
73
77
  scope = projectScopeRoot() !== null ? 'project' : 'user';
74
78
  }
79
+ if (scope === 'node') {
80
+ // The this-node store: `nodes/<CRTR_NODE_ID>/context/memory/`. Available
81
+ // only inside a running node; the same dir render.ts's nodeLocalDocs loads
82
+ // at boot, so a doc written here rides into this node's knowledge block.
83
+ const nodeId = process.env['CRTR_NODE_ID'] || '';
84
+ if (nodeId === '') {
85
+ throw usage('node scope requires a running node (CRTR_NODE_ID unset); rerun inside a node');
86
+ }
87
+ return { scope: 'node', memoryDir: nodeMemoryDir(nodeId) };
88
+ }
75
89
  if (scope === 'profile') {
76
90
  const profileIdOrName = profileArg && profileArg !== '' ? profileArg : process.env['CRTR_PROFILE_ID'] || '';
77
91
  if (profileIdOrName === '') {
@@ -14,7 +14,7 @@ export const writeLeaf = defineLeaf({
14
14
  'Pick the kind. knowledge is consulted for facts or procedures; preference directs behavior. The kind choice is about how the doc is used, not about how long it is.\n\n' +
15
15
  'Store reusable current truth, not session notes. Useful memories are non-obvious procedures, gotchas, durable preferences, cross-repo conventions, and amendments to plans/specs that future agents must honor. Put plan/spec amendments under path names like `projects/<topic>/...`; add `projects/<topic>/INDEX.md` with `name` visibility so the topic is discoverable without loading the whole body. Do not store chat summaries, implementation history, or facts already recorded in the repo.\n\n' +
16
16
  'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
17
- 'Choose the scope. `project` is for facts any agent in one repo needs. `user` is for person-wide facts and preferences that should follow the user everywhere. `profile` is for the profile’s bundle of dirs: cross-repo conventions, how the pieces relate, or the user’s stance toward that body of work. When unsure, choose the narrowest scope that will still reach the next agent who needs it.\n\n' +
17
+ 'Choose the scope. `project` is for facts any agent in one repo needs. `user` is for person-wide facts and preferences that should follow the user everywhere. `profile` is for the profile’s bundle of dirs: cross-repo conventions, how the pieces relate, or the user’s stance toward that body of work. `node` is the narrowest — the this-node store (`nodes/<CRTR_NODE_ID>/context/memory/`), scratch memory only this running node sees; it rides straight into this node’s knowledge block and dies with the node. When unsure, choose the narrowest scope that will still reach the next agent who needs it.\n\n' +
18
18
  'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
19
19
  'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
20
20
  'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
@@ -32,7 +32,7 @@ export const writeLeaf = defineLeaf({
32
32
  { kind: 'flag', name: 'read-when', type: 'string', required: false, constraint: 'Frontmatter read-when — YAML/JSON object predicate over a read file’s own frontmatter using the same field/matcher vocabulary described in the guide.' },
33
33
  { kind: 'flag', name: 'slash', type: 'bool', required: false, default: false, constraint: 'Presence flags this doc invocable as a pi slash command (`/<name>`, `/` in a nested name rendered as `:`) — the doc body becomes the command’s injected prompt. Default false: most docs are consulted, not invoked.' },
34
34
  { kind: 'flag', name: 'rationale', type: 'string', required: false, constraint: 'Frontmatter rationale — the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only in the raw file or `memory read --frontmatter`. Omitting this flag on an update PRESERVES an existing rationale unchanged.' },
35
- { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `project` resolves to the NEAREST ancestor `.crouter/` walking up from cwd — in a nested workspace that can be a parent’s store, not the dir you are standing in; pass --dir to pin the exact project directory. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
35
+ { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `project` resolves to the NEAREST ancestor `.crouter/` walking up from cwd — in a nested workspace that can be a parent’s store, not the dir you are standing in; pass --dir to pin the exact project directory. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile. `node` writes to the this-node store (`nodes/<CRTR_NODE_ID>/context/memory/`) — the nearest scope, seen only by this running node, requires a node context.' },
36
36
  { kind: 'flag', name: 'dir', type: 'string', required: false, constraint: 'Exact project directory to write under — targets `<dir>/.crouter/memory/` regardless of cwd or ancestor stores, scaffolding `.crouter/` there if absent. THE way to place a doc in a specific project’s store (e.g. another project in your profile’s purview) without cd’ing there, and the only way to target a dir shadowed by an ancestor store. Implies --scope project; rejects --scope user/profile.' },
37
37
  { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile id or name to write under, for --scope profile. Default: the process CRTR_PROFILE_ID (the node\u2019s selected profile). Resolved through the same profile lookup as `crtr profile show`; ignored for any other --scope.' },
38
38
  { kind: 'stdin', name: 'body', required: true, constraint: 'Document body (markdown, no frontmatter). Piped on stdin, or passed as the bare positional after <name>.' },
@@ -40,7 +40,7 @@ export const writeLeaf = defineLeaf({
40
40
  output: [
41
41
  { name: 'name', type: 'string', required: true, constraint: 'The path-derived document name written.' },
42
42
  { name: 'kind', type: 'string', required: true, constraint: 'Kind recorded in frontmatter.' },
43
- { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was written to: user, project, or profile.' },
43
+ { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was written to: user, project, profile, or node.' },
44
44
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the written document — edit this file directly for later body tweaks instead of re-running write.' },
45
45
  { name: 'created', type: 'boolean', required: true, constraint: 'true when a new document was created, false when an existing one was updated in place.' },
46
46
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands — read it back or list the inventory.' },