@north-light/crouter 0.3.180 → 0.3.182

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 (137) hide show
  1. package/dist/api/client.d.ts +10 -1
  2. package/dist/api/client.js +13 -0
  3. package/dist/api/dto/broker.d.ts +32 -0
  4. package/dist/api/dto/crons.d.ts +17 -0
  5. package/dist/api/dto/memory.d.ts +17 -0
  6. package/dist/api/dto/memory.js +6 -0
  7. package/dist/api/dto/messages.d.ts +5 -0
  8. package/dist/api/dto/reviews.d.ts +8 -4
  9. package/dist/api/index.d.ts +1 -0
  10. package/dist/api/index.js +1 -0
  11. package/dist/api/routes.d.ts +2 -0
  12. package/dist/api/routes.js +4 -0
  13. package/dist/build-root.d.ts +7 -0
  14. package/dist/build-root.js +21 -0
  15. package/dist/builtin-memory/insights/init.md +48 -3
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/__tests__/insights-active-init.test.ts +98 -0
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +7 -50
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +16 -1
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +34 -0
  20. package/dist/cli.js +1 -2
  21. package/dist/clients/attach/__tests__/context-message.test.js +5 -2
  22. package/dist/clients/attach/assets/README.md +7 -0
  23. package/dist/clients/attach/assets/whip-06.mp3 +0 -0
  24. package/dist/clients/attach/assets/whip-crack.mp3 +0 -0
  25. package/dist/clients/attach/assets/whip-snap.mp3 +0 -0
  26. package/dist/clients/attach/chrome/canvas-panels.d.ts +7 -1
  27. package/dist/clients/attach/chrome/canvas-panels.js +20 -3
  28. package/dist/clients/attach/chrome/review-wait.d.ts +6 -0
  29. package/dist/clients/attach/chrome/review-wait.js +22 -0
  30. package/dist/clients/attach/chrome/roster.js +23 -2
  31. package/dist/clients/attach/chrome/widgets.js +1 -1
  32. package/dist/clients/attach/input/controller.js +4 -3
  33. package/dist/clients/attach/overlays/mcp.js +3 -1
  34. package/dist/clients/attach/render/chat-view.js +1 -1
  35. package/dist/clients/attach/session/whip.d.ts +1 -0
  36. package/dist/clients/attach/session/whip.js +26 -0
  37. package/dist/clients/attach/slash/dispatch.js +2 -0
  38. package/dist/clients/attach/viewer.js +578 -573
  39. package/dist/clients/inbox/review/document-surface.d.ts +1 -1
  40. package/dist/clients/inbox/review/document-surface.js +4 -4
  41. package/dist/clients/inbox/review/launch.js +16 -4
  42. package/dist/clients/inbox/review/review-client.d.ts +9 -4
  43. package/dist/clients/inbox/review/review-client.js +3 -0
  44. package/dist/commands/cron.js +30 -8
  45. package/dist/commands/human/prompts.d.ts +7 -2
  46. package/dist/commands/human/prompts.js +15 -10
  47. package/dist/commands/human.js +1 -2
  48. package/dist/commands/memory/find.js +11 -8
  49. package/dist/commands/memory/read.js +111 -11
  50. package/dist/commands/memory/write.js +1 -1
  51. package/dist/commands/memory.js +1 -1
  52. package/dist/commands/pkg/market-manage.d.ts +13 -0
  53. package/dist/commands/pkg/market-manage.js +39 -33
  54. package/dist/commands/pkg/plugin-inspect.js +4 -3
  55. package/dist/commands/pkg/plugin-manage.js +12 -11
  56. package/dist/commands/surface/node/focus.js +1 -2
  57. package/dist/commands/sys/doctor.js +4 -4
  58. package/dist/commands/sys/setup-core.d.ts +14 -7
  59. package/dist/commands/sys/setup-core.js +66 -11
  60. package/dist/commands/sys/setup-wizard.js +2 -2
  61. package/dist/commands/sys/setup.js +1 -1
  62. package/dist/core/__tests__/cron-held-settlement.test.d.ts +1 -0
  63. package/dist/core/__tests__/cron-held-settlement.test.js +222 -0
  64. package/dist/core/__tests__/helpers/harness.js +1 -2
  65. package/dist/core/__tests__/phase4-review-store.test.js +1 -0
  66. package/dist/core/__tests__/serial/command-plugins.test.js +88 -1
  67. package/dist/core/__tests__/session-model.test.js +5 -3
  68. package/dist/core/bootstrap.d.ts +0 -4
  69. package/dist/core/bootstrap.js +1 -55
  70. package/dist/core/canvas/crons.d.ts +54 -2
  71. package/dist/core/canvas/crons.js +48 -4
  72. package/dist/core/canvas/db.js +23 -0
  73. package/dist/core/command-manifests/manifest.d.ts +11 -0
  74. package/dist/core/command-manifests/manifest.js +45 -4
  75. package/dist/core/command-manifests/schema.d.ts +1 -1
  76. package/dist/core/command-plugins/bundle.d.ts +1 -0
  77. package/dist/core/command-plugins/bundle.js +3 -3
  78. package/dist/core/command-plugins/discovery.d.ts +5 -2
  79. package/dist/core/command-plugins/discovery.js +5 -5
  80. package/dist/core/command-plugins/help-addenda.d.ts +12 -0
  81. package/dist/core/command-plugins/help-addenda.js +30 -0
  82. package/dist/core/command.js +25 -2
  83. package/dist/core/config.js +0 -1
  84. package/dist/core/human/convention.d.ts +0 -1
  85. package/dist/core/human/convention.js +0 -6
  86. package/dist/core/keybindings/inbox.d.ts +6 -8
  87. package/dist/core/keybindings/inbox.js +6 -15
  88. package/dist/core/keybindings/index.d.ts +1 -1
  89. package/dist/core/keybindings/index.js +1 -1
  90. package/dist/core/memory/doc-link-grammar.js +4 -1
  91. package/dist/core/memory-resolver.d.ts +28 -4
  92. package/dist/core/memory-resolver.js +51 -39
  93. package/dist/core/review/stage.js +1 -0
  94. package/dist/core/review/store.d.ts +5 -0
  95. package/dist/core/review/store.js +10 -0
  96. package/dist/core/review/types.d.ts +4 -0
  97. package/dist/core/runtime/broker/event-projection.d.ts +8 -1
  98. package/dist/core/runtime/broker/event-projection.js +25 -1
  99. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  100. package/dist/core/runtime/broker/frame-dispatch.js +50 -8
  101. package/dist/core/runtime/broker/inbox.d.ts +4 -0
  102. package/dist/core/runtime/broker/inbox.js +17 -8
  103. package/dist/core/runtime/broker/message-ledger.d.ts +53 -0
  104. package/dist/core/runtime/broker/message-ledger.js +143 -0
  105. package/dist/core/runtime/broker/rebind.js +14 -0
  106. package/dist/core/runtime/broker-protocol.d.ts +46 -1
  107. package/dist/core/runtime/broker.js +11 -2
  108. package/dist/core/runtime/interactive-deliver.d.ts +5 -2
  109. package/dist/core/runtime/interactive-deliver.js +6 -3
  110. package/dist/core/runtime/shell-expansion.d.ts +32 -0
  111. package/dist/core/runtime/shell-expansion.js +102 -0
  112. package/dist/core/session-model/session-state.d.ts +9 -4
  113. package/dist/core/session-model/session-state.js +5 -1
  114. package/dist/daemon/api/handlers/broker-ops.js +8 -0
  115. package/dist/daemon/api/handlers/crons.js +14 -1
  116. package/dist/daemon/api/handlers/inbox.js +5 -0
  117. package/dist/daemon/api/handlers/memory.d.ts +2 -0
  118. package/dist/daemon/api/handlers/memory.js +48 -0
  119. package/dist/daemon/api/handlers/messages.js +7 -1
  120. package/dist/daemon/api/handlers/reviews.js +7 -5
  121. package/dist/daemon/api/map.js +3 -0
  122. package/dist/daemon/api/server.js +2 -0
  123. package/dist/daemon/cron-run.js +71 -3
  124. package/dist/daemon/crtrd.js +3 -0
  125. package/dist/daemon/reconcilers/pending-review-submit.d.ts +7 -0
  126. package/dist/daemon/reconcilers/pending-review-submit.js +35 -0
  127. package/dist/daemon/review/companion.d.ts +8 -0
  128. package/dist/daemon/review/companion.js +35 -0
  129. package/dist/daemon/review/deliver.js +2 -1
  130. package/dist/daemon/review/finish.d.ts +29 -2
  131. package/dist/daemon/review/finish.js +75 -2
  132. package/dist/pi-extensions/canvas-inbox-watcher.js +34 -1
  133. package/dist/shared/generated-context.d.ts +3 -4
  134. package/dist/shared/generated-context.js +24 -6
  135. package/dist/types.d.ts +0 -1
  136. package/package.json +1 -1
  137. package/runtime.lock.json +2 -2
@@ -0,0 +1,143 @@
1
+ // message-ledger.ts — broker-owned message identity across pi's id-less queues.
2
+ //
3
+ // pi's steering/follow-up queues and user messages carry TEXT only: `steer()`
4
+ // pushes bare strings, `queue_update` relays bare string arrays, and retirement
5
+ // (on user `message_start`) is a first-index-of-text splice. Every downstream
6
+ // consumer was therefore forced into order/text heuristics to match its own
7
+ // optimistic send to the wake that echoed it — wrong under duplicates,
8
+ // cross-surface sends, and reconnect replay.
9
+ //
10
+ // The broker is the ONLY writer to the engine (every prompt/steer/follow_up/
11
+ // deliver funnels through driveEngine) and observes pi's event stream
12
+ // synchronously in-process, so it can own identity at the wire boundary: mint
13
+ // (or adopt the sender's) id per accepted frame, mirror pi's queue state by
14
+ // reconciling pi's own `queue_update` events, and attach the retired entry's id
15
+ // to the relayed user `message_start`. Same deterministic algorithm over the
16
+ // same data in the same process as pi's own bookkeeping — it cannot drift.
17
+ //
18
+ // pi ordering facts this relies on (verified at pi 0.83.0, agent-session.js):
19
+ // - `_queueSteer`/`_queueFollowUp` push the text and emit `queue_update`
20
+ // synchronously inside the steer/followUp call.
21
+ // - On a queued message's user `message_start`, `_handleAgentEvent` splices
22
+ // the queue and emits `queue_update` BEFORE emitting the `message_start`,
23
+ // so a retirement's queue_update always precedes its message and two
24
+ // removals never interleave.
25
+ // - An idle `prompt()` never touches a queue — its user message matches a
26
+ // pending dispatch intent by text instead.
27
+ import { randomUUID } from 'node:crypto';
28
+ /** Dispatch intents not yet observed anywhere: bounded FIFO so an intent whose
29
+ * text pi transformed (and so never matches) cannot accumulate forever. */
30
+ const MAX_PENDING_INTENTS = 32;
31
+ /** Recent id-bearing user-message dispatches carried in `welcome` so a
32
+ * reattaching follower can key its snapshot's trailing user wakes. */
33
+ const MAX_RECENT = 8;
34
+ export class MessageIdLedger {
35
+ steering = [];
36
+ followUp = [];
37
+ pendingIntents = [];
38
+ stagedRetirementId = null;
39
+ recent = [];
40
+ /** Record an accepted client frame's identity before its engine call runs.
41
+ * `id` is the sender's `message_id` when it minted one, else broker-minted. */
42
+ noteDispatch(id, text) {
43
+ this.pendingIntents.push({ id, text });
44
+ if (this.pendingIntents.length > MAX_PENDING_INTENTS)
45
+ this.pendingIntents.shift();
46
+ }
47
+ /** Remove a noted intent whose engine call rejected — the message will never
48
+ * appear in a queue or as a user message. No-op if already adopted. */
49
+ dropIntent(id) {
50
+ const idx = this.pendingIntents.findIndex((e) => e.id === id);
51
+ if (idx !== -1)
52
+ this.pendingIntents.splice(idx, 1);
53
+ }
54
+ /**
55
+ * Fold one pi `queue_update` into the ledger and return the id arrays
56
+ * parallel to its text arrays (total: every position gets an id).
57
+ *
58
+ * Per-queue positional diff against the mirrored entries: a text matching an
59
+ * old entry at-or-after the cursor keeps its id (everything skipped is
60
+ * removed); an unmatched text ADOPTS the first pending intent with equal
61
+ * text, else mints a fresh id. Exactly ONE removal across both queues is
62
+ * pi's per-message retirement (its queue_update precedes the user
63
+ * `message_start` it belongs to) and is staged for `takeUserMessageId`;
64
+ * 0 or >1 removals (an enqueue, clearQueue/dequeue, abort) stage nothing.
65
+ */
66
+ reconcileQueueUpdate(steering, followUp) {
67
+ const removed = [];
68
+ const next = (old, texts) => {
69
+ const out = [];
70
+ let cursor = 0;
71
+ for (const text of texts) {
72
+ let match = -1;
73
+ for (let j = cursor; j < old.length; j++) {
74
+ if (old[j].text === text) {
75
+ match = j;
76
+ break;
77
+ }
78
+ }
79
+ if (match !== -1) {
80
+ for (let j = cursor; j < match; j++)
81
+ removed.push(old[j].id);
82
+ out.push(old[match]);
83
+ cursor = match + 1;
84
+ continue;
85
+ }
86
+ const intentIdx = this.pendingIntents.findIndex((e) => e.text === text);
87
+ const id = intentIdx !== -1 ? this.pendingIntents.splice(intentIdx, 1)[0].id : randomUUID();
88
+ out.push({ id, text });
89
+ }
90
+ for (let j = cursor; j < old.length; j++)
91
+ removed.push(old[j].id);
92
+ return out;
93
+ };
94
+ this.steering = next(this.steering, steering);
95
+ this.followUp = next(this.followUp, followUp);
96
+ this.stagedRetirementId = removed.length === 1 ? removed[0] : null;
97
+ return {
98
+ steeringIds: this.steering.map((e) => e.id),
99
+ followUpIds: this.followUp.map((e) => e.id),
100
+ };
101
+ }
102
+ /**
103
+ * The id for a relayed user `message_start`, or undefined when this message
104
+ * carries none (an engine-command expansion, a text pi transformed, or a
105
+ * pre-ledger replay). A staged retirement wins — pi emitted its queue_update
106
+ * synchronously right before this message; otherwise the first pending
107
+ * intent with equal text (the idle-prompt case, which never touches a
108
+ * queue). Id-bearing results are remembered for `recentUserMessages`.
109
+ */
110
+ takeUserMessageId(messageText) {
111
+ const staged = this.stagedRetirementId;
112
+ if (staged !== null) {
113
+ this.stagedRetirementId = null;
114
+ this.remember(staged, messageText);
115
+ return staged;
116
+ }
117
+ const idx = this.pendingIntents.findIndex((e) => e.text === messageText);
118
+ if (idx === -1)
119
+ return undefined;
120
+ const intent = this.pendingIntents.splice(idx, 1)[0];
121
+ this.remember(intent.id, messageText);
122
+ return intent.id;
123
+ }
124
+ /** The last {@link MAX_RECENT} id-bearing user-message dispatches, oldest
125
+ * first — the `welcome.recentUserMessages` payload. */
126
+ recentUserMessages() {
127
+ return this.recent.map((e) => ({ ...e }));
128
+ }
129
+ /** Session rebind/replacement: the mirrored queues, staged retirement, and
130
+ * recent list all described the OLD session — drop everything. */
131
+ reset() {
132
+ this.steering = [];
133
+ this.followUp = [];
134
+ this.pendingIntents = [];
135
+ this.stagedRetirementId = null;
136
+ this.recent = [];
137
+ }
138
+ remember(id, text) {
139
+ this.recent.push({ id, text });
140
+ if (this.recent.length > MAX_RECENT)
141
+ this.recent.shift();
142
+ }
143
+ }
@@ -58,6 +58,20 @@ export class RebindDriver {
58
58
  this.removeInstalledSubscription();
59
59
  this.sessionValue = candidateSession;
60
60
  this.servicesValue = candidateServices;
61
+ // Queue modes 'all': at each drain point pi injects EVERY queued steering/
62
+ // follow-up message in one drain, instead of the default one-at-a-time
63
+ // (which held a second queued message back a whole assistant response).
64
+ // Set directly on the public `session.agent` fields — exactly what pi's
65
+ // own syncQueueModesFromSettings() does minus the shared settings.json
66
+ // write. Applied here so boot AND every rebind/replacement get it; pi
67
+ // resets the modes from settings only inside reload(), which re-applies
68
+ // them at its call site (frame-dispatch). Structurally guarded: the
69
+ // fake-engine fixture has no `.agent`.
70
+ const agent = candidateSession.agent;
71
+ if (agent) {
72
+ agent.steeringMode = 'all';
73
+ agent.followUpMode = 'all';
74
+ }
61
75
  if (this.deps.cfg.editorName !== undefined && this.deps.cfg.editorName !== '') {
62
76
  try {
63
77
  candidateSession.setSessionName(this.deps.cfg.editorName);
@@ -78,16 +78,27 @@ export interface PromptFrame {
78
78
  images?: ImageContent[];
79
79
  /** Abort the active turn before starting this prompt. Used by the attach viewer's whip action so its command replaces, rather than joins, in-flight work. */
80
80
  interrupt?: true;
81
+ /** Sender-minted message identity (see MessageIdLedger). The broker mirrors
82
+ * it through pi's id-less queues and echoes it back on the relayed user
83
+ * `message_start` (`crtrMessageId`) and enriched `queue_update`
84
+ * (`steeringIds`/`followUpIds`), so an optimistic sender retires its
85
+ * pending entry by exact id instead of text/order guessing. Absent (a
86
+ * legacy client, kickoff, inbox): the broker mints one itself. */
87
+ message_id?: string;
81
88
  }
82
89
  export interface SteerFrame {
83
90
  type: 'steer';
84
91
  text: string;
85
92
  images?: ImageContent[];
93
+ /** Sender-minted message identity — see {@link PromptFrame.message_id}. */
94
+ message_id?: string;
86
95
  }
87
96
  export interface FollowUpFrame {
88
97
  type: 'follow_up';
89
98
  text: string;
90
99
  images?: ImageContent[];
100
+ /** Sender-minted message identity — see {@link PromptFrame.message_id}. */
101
+ message_id?: string;
91
102
  }
92
103
  export interface AbortFrame {
93
104
  type: 'abort';
@@ -111,6 +122,9 @@ export interface DeliverFrame {
111
122
  id: string;
112
123
  text: string;
113
124
  images?: ImageContent[];
125
+ /** Sender-minted message identity — see {@link PromptFrame.message_id}.
126
+ * Distinct from `id`, the one-shot ack-correlation token. */
127
+ message_id?: string;
114
128
  }
115
129
  /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
116
130
  * which runs the command, records a `bashExecution` message in context, and
@@ -351,7 +365,38 @@ export interface WelcomeFrame {
351
365
  * can construct an AuthStorage/ModelRegistry pointing at the SAME auth.json.
352
366
  * crtr surface attach is tmux-local only: broker + viewer share a filesystem. */
353
367
  agentDir?: string;
354
- }
368
+ /** The last few id-bearing user-message dispatches from the broker's message
369
+ * ledger, oldest first — so a reattaching follower (Core's snapshot wake
370
+ * replay) can key the snapshot's trailing user wakes by id instead of
371
+ * discarding them heuristically. Live-process memory only: a broker restart
372
+ * loses it (accepted — optimistic pending sends don't survive that either).
373
+ * Absent on a broker pinned to an older runtime generation. */
374
+ recentUserMessages?: Array<{
375
+ id: string;
376
+ text: string;
377
+ }>;
378
+ }
379
+ /** A relayed pi `queue_update`, enriched by the broker with id arrays PARALLEL
380
+ * to pi's text arrays (`steeringIds[i]` identifies `steering[i]`). The ids are
381
+ * the broker's message ledger mirror of pi's queues — sender-minted when the
382
+ * frame carried `message_id`, broker-minted otherwise. Absent on a broker
383
+ * pinned to an older runtime generation; consumers fall back to bare texts. */
384
+ export type RelayedQueueUpdate = Extract<AgentSessionEvent, {
385
+ type: 'queue_update';
386
+ }> & {
387
+ steeringIds?: string[];
388
+ followUpIds?: string[];
389
+ };
390
+ /** A relayed user `message_start`, enriched by the broker with the message's
391
+ * crouter identity — the id staged by the retirement `queue_update` pi emits
392
+ * synchronously before it, or the matching dispatch intent for an idle
393
+ * prompt. Absent when the message carries none (an engine-command expansion,
394
+ * a pre-ledger replay, an older broker). */
395
+ export type RelayedUserMessageStart = Extract<AgentSessionEvent, {
396
+ type: 'message_start';
397
+ }> & {
398
+ crtrMessageId?: string;
399
+ };
355
400
  /** Broadcast to EVERY client after a successful `set_model`/`cycle_model`. pi
356
401
  * emits no AgentSessionEvent for a model switch, so without this the new model
357
402
  * reaches no viewer at all (the requester gets only a bare ack) and footers
@@ -44,6 +44,7 @@ import { BrokerClientRegistry } from './broker/client-registry.js';
44
44
  import { ToolGroupTracker } from './broker/tool-groups.js';
45
45
  import { FaultRetry } from './broker/fault-retry.js';
46
46
  import { EventProjection } from './broker/event-projection.js';
47
+ import { MessageIdLedger } from './broker/message-ledger.js';
47
48
  import { createFrameDispatchContext, handleFrame } from './broker/frame-dispatch.js';
48
49
  import { RebindDriver } from './broker/rebind.js';
49
50
  import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
@@ -284,6 +285,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
284
285
  let projection;
285
286
  let notifyTurnAccepted = () => { };
286
287
  let resetFrameState = () => { };
288
+ // Broker-owned message identity (see MessageIdLedger): mirrors pi's id-less
289
+ // queues so relayed queue_update/user message_start frames carry ids.
290
+ const messageLedger = new MessageIdLedger();
287
291
  rebind = new RebindDriver({
288
292
  nodeId, cfg, session, services, runtime, registry, toolGroups,
289
293
  uiContext: () => uiContext,
@@ -291,7 +295,10 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
291
295
  projection: () => projection,
292
296
  setActiveSession: (candidate) => { activeSession = candidate; },
293
297
  disposeAndExit: (reason, status) => disposeAndExit(reason, status),
294
- onSessionRebind: () => resetFrameState(),
298
+ onSessionRebind: () => {
299
+ resetFrameState();
300
+ messageLedger.reset();
301
+ },
295
302
  reWelcomeAll: () => reWelcomeAll(),
296
303
  });
297
304
  // Pi's model controls mutate only the live engine. Commit the concrete model
@@ -332,6 +339,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
332
339
  projection = new EventProjection({
333
340
  registry,
334
341
  faultRetry,
342
+ ledger: messageLedger,
335
343
  installedGeneration: () => rebind.installedGeneration(),
336
344
  currentSession: () => rebind.session(),
337
345
  notifyTurnAccepted: () => notifyTurnAccepted(),
@@ -388,6 +396,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
388
396
  role: client.role,
389
397
  pending_dialog: first !== undefined ? first.request : null,
390
398
  agentDir: getAgentDir(),
399
+ recentUserMessages: messageLedger.recentUserMessages(),
391
400
  }, true);
392
401
  // No display_* replay: the retained extension state rides IN the snapshot
393
402
  // (`snapshot.display`), so catch-up is one atomic frame. A viewer attaching
@@ -500,7 +509,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
500
509
  rebind.setRebindSession();
501
510
  await rebind.enqueueRebind(session, services);
502
511
  const frameDispatch = createFrameDispatchContext({
503
- nodeId, cfg, registry, toolGroups, projection, rebind, reviewCompanion,
512
+ nodeId, cfg, registry, toolGroups, projection, ledger: messageLedger, rebind, reviewCompanion,
504
513
  pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit,
505
514
  persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec,
506
515
  formatExactModelSpec, resolveLaunchModel,
@@ -6,8 +6,11 @@ export type LiveDeliverRoute = 'prompt' | 'steer';
6
6
  export type LiveInterruptOutcome = 'turn' | 'bash' | 'idle';
7
7
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
8
8
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
9
- * routed; resolves with the route taken. */
10
- export declare function deliverLive(nodeId: string, text: string): Promise<LiveDeliverRoute>;
9
+ * routed; resolves with the route taken. `messageId` is the sender-minted
10
+ * message identity (`SendMessageRequest.message_id`), carried on the deliver
11
+ * frame so the broker's ledger echoes it back on the relayed user
12
+ * `message_start` — distinct from the one-shot ack-correlation id. */
13
+ export declare function deliverLive(nodeId: string, text: string, messageId?: string): Promise<LiveDeliverRoute>;
11
14
  /** Abort whatever a LIVE node's engine is doing (turn or `!` bash). Resolves
12
15
  * once the broker has PROCESSED the abort — which, on the single frame loop,
13
16
  * also implies every earlier-accepted deliver was already routed (so a
@@ -14,14 +14,17 @@
14
14
  import { oneShotControllerRequest } from './broker-request.js';
15
15
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
16
16
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
17
- * routed; resolves with the route taken. */
18
- export function deliverLive(nodeId, text) {
17
+ * routed; resolves with the route taken. `messageId` is the sender-minted
18
+ * message identity (`SendMessageRequest.message_id`), carried on the deliver
19
+ * frame so the broker's ledger echoes it back on the relayed user
20
+ * `message_start` — distinct from the one-shot ack-correlation id. */
21
+ export function deliverLive(nodeId, text, messageId) {
19
22
  return oneShotControllerRequest({
20
23
  nodeId,
21
24
  what: 'the interactive delivery',
22
25
  ackFor: 'deliver',
23
26
  correlated: true,
24
- frame: (id) => ({ type: 'deliver', id, text }),
27
+ frame: (id) => ({ type: 'deliver', id, text, ...(messageId !== undefined ? { message_id: messageId } : {}) }),
25
28
  result: (detail) => (detail === 'steer' ? 'steer' : 'prompt'),
26
29
  });
27
30
  }
@@ -0,0 +1,32 @@
1
+ export declare const DEFAULT_SHELL_TIMEOUT_MS = 120000;
2
+ /** Runs one command and returns the text that replaces it in the body. A runner
3
+ * never throws: a failure is reported inline so the surrounding document still
4
+ * reaches the model. */
5
+ export type ShellRunner = (cmd: string) => Promise<string>;
6
+ /** True when `body` carries at least one shell construct. Lets a caller skip an
7
+ * async execution pass (and its cwd/timeout plumbing) for the common inert
8
+ * document. */
9
+ export declare function hasShellBlocks(body: string): boolean;
10
+ /** Cap runaway command output before it becomes model context. */
11
+ export declare function truncateShellOutput(content: string): string;
12
+ /** Merge a completed command's streams the way the model should read them. */
13
+ export declare function formatShellResult(result: {
14
+ code: number | null;
15
+ killed?: boolean;
16
+ stdout?: string;
17
+ stderr?: string;
18
+ timeoutMs: number;
19
+ }): string;
20
+ /** The runner for processes with no pi engine — every CLI leaf. */
21
+ export declare function makeNodeShellRunner(opts: {
22
+ cwd: string;
23
+ timeoutMs?: number;
24
+ }): ShellRunner;
25
+ /** Replace every shell construct in `body` with its output.
26
+ *
27
+ * Fenced blocks resolve first behind sentinels so an inline pattern inside a
28
+ * block's own text is never executed a second time; inline forms resolve
29
+ * against the masked text; sentinels are then restored. Commands run in
30
+ * document order, exactly once per call — a document read twice runs its
31
+ * commands twice, which is the intended "once per invocation" contract. */
32
+ export declare function expandShellBlocks(body: string, run: ShellRunner): Promise<string>;
@@ -0,0 +1,102 @@
1
+ import { execFile } from 'node:child_process';
2
+ // ---------------------------------------------------------------------------
3
+ // Shell expansion inside Markdown bodies: `!`cmd`` inline and ```! fenced
4
+ // blocks are replaced by the command's output.
5
+ //
6
+ // The grammar and substitution ORDER live here; the execution STRATEGY is
7
+ // injected. Two callers need different strategies:
8
+ // • Broker-side extensions run through `pi.exec`, so the engine sees the
9
+ // subprocess (claude-plugin-commands, memory-slash-commands).
10
+ // • CLI leaves have no pi engine and must not import one — `crtr memory read`
11
+ // uses `makeNodeShellRunner` over node:child_process.
12
+ //
13
+ // Node built-ins ONLY. This module is reachable from `src/commands/**`, where a
14
+ // `@earendil-works/pi-coding-agent` import would load the whole pi runtime just
15
+ // to read a document.
16
+ // ---------------------------------------------------------------------------
17
+ // Built fresh per use, never shared. A module-level /g regex carries mutable
18
+ // `lastIndex`, and both `.test()` and `matchAll` (which copies it into its
19
+ // internal clone) would make one call silently skip matches another consumed.
20
+ const SHELL_INLINE_SOURCE = '!`([^`\\n]+)`';
21
+ const SHELL_BLOCK_SOURCE = '```!\\n([\\s\\S]*?)\\n```';
22
+ const inlineRe = () => new RegExp(SHELL_INLINE_SOURCE, 'g');
23
+ const blockRe = () => new RegExp(SHELL_BLOCK_SOURCE, 'g');
24
+ export const DEFAULT_SHELL_TIMEOUT_MS = 120_000;
25
+ const MAX_OUTPUT_LINES = 2000;
26
+ const MAX_OUTPUT_BYTES = 50_000;
27
+ /** True when `body` carries at least one shell construct. Lets a caller skip an
28
+ * async execution pass (and its cwd/timeout plumbing) for the common inert
29
+ * document. */
30
+ export function hasShellBlocks(body) {
31
+ return new RegExp(SHELL_INLINE_SOURCE).test(body) || new RegExp(SHELL_BLOCK_SOURCE).test(body);
32
+ }
33
+ /** Cap runaway command output before it becomes model context. */
34
+ export function truncateShellOutput(content) {
35
+ const lines = content.split('\n');
36
+ if (lines.length > MAX_OUTPUT_LINES) {
37
+ return `${lines.slice(0, MAX_OUTPUT_LINES).join('\n')}\n[truncated: hit ${MAX_OUTPUT_LINES} lines]`;
38
+ }
39
+ if (Buffer.byteLength(content, 'utf8') > MAX_OUTPUT_BYTES) {
40
+ return `${Buffer.from(content, 'utf8').subarray(0, MAX_OUTPUT_BYTES).toString('utf8')}\n[truncated: hit ${MAX_OUTPUT_BYTES} bytes]`;
41
+ }
42
+ return content;
43
+ }
44
+ /** Merge a completed command's streams the way the model should read them. */
45
+ export function formatShellResult(result) {
46
+ if (result.killed === true) {
47
+ return `[Shell error: timed out after ${Math.max(1, Math.round(result.timeoutMs / 1000))}s]`;
48
+ }
49
+ if (result.code !== 0) {
50
+ return `[Shell error: exit code ${result.code}]\n${truncateShellOutput(result.stderr ?? '')}`;
51
+ }
52
+ let combined = result.stdout ?? '';
53
+ if (result.stderr !== undefined && result.stderr !== '') {
54
+ const sep = combined.length === 0 || combined.endsWith('\n') ? '' : '\n';
55
+ combined = `${combined}${sep}[stderr]\n${result.stderr}`;
56
+ }
57
+ return truncateShellOutput(combined);
58
+ }
59
+ /** The runner for processes with no pi engine — every CLI leaf. */
60
+ export function makeNodeShellRunner(opts) {
61
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
62
+ const [sh, flag] = process.platform === 'win32' ? ['powershell.exe', '-Command'] : ['sh', '-c'];
63
+ return (cmd) => new Promise((resolve) => {
64
+ execFile(sh, [flag, cmd], { cwd: opts.cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES * 4, encoding: 'utf8' }, (err, stdout, stderr) => {
65
+ // On timeout execFile kills the child and reports `killed: true` with
66
+ // a null `code` and a signal — never a numeric exit status. Check that
67
+ // first, or a timeout is misreported as a generic failure.
68
+ const e = err;
69
+ const killed = e !== null && (e.killed === true || (e.signal !== undefined && e.signal !== null));
70
+ const code = e === null ? 0 : typeof e.code === 'number' ? e.code : 1;
71
+ resolve(formatShellResult({ code, killed, stdout, stderr, timeoutMs }));
72
+ });
73
+ });
74
+ }
75
+ /** Replace every shell construct in `body` with its output.
76
+ *
77
+ * Fenced blocks resolve first behind sentinels so an inline pattern inside a
78
+ * block's own text is never executed a second time; inline forms resolve
79
+ * against the masked text; sentinels are then restored. Commands run in
80
+ * document order, exactly once per call — a document read twice runs its
81
+ * commands twice, which is the intended "once per invocation" contract. */
82
+ export async function expandShellBlocks(body, run) {
83
+ const blockOut = [];
84
+ let masked = '';
85
+ let last = 0;
86
+ for (const m of body.matchAll(blockRe())) {
87
+ const idx = m.index ?? 0;
88
+ masked += body.slice(last, idx) + `\x00B${blockOut.length}\x00`;
89
+ blockOut.push(await run(m[1] ?? ''));
90
+ last = idx + m[0].length;
91
+ }
92
+ masked += body.slice(last);
93
+ let inlined = '';
94
+ last = 0;
95
+ for (const m of masked.matchAll(inlineRe())) {
96
+ const idx = m.index ?? 0;
97
+ inlined += masked.slice(last, idx) + (await run(m[1] ?? ''));
98
+ last = idx + m[0].length;
99
+ }
100
+ inlined += masked.slice(last);
101
+ return inlined.replace(/\x00B(\d+)\x00/g, (_, n) => blockOut[parseInt(n, 10)] ?? '');
102
+ }
@@ -24,10 +24,15 @@ export interface SessionState {
24
24
  /** Live context occupancy in prompt tokens: seeded from the snapshot's
25
25
  * contextUsage, then tracked off each assistant `message_end`. */
26
26
  contextTokens: number | undefined;
27
- /** Queued steering + follow-up message TEXT, from `queue_update`. A fresh
28
- * broker with an empty queue emits no `queue_update`, so every welcome resets
29
- * this rather than leaving a dead broker's rows on screen. */
30
- queued: string[];
27
+ /** Queued steering + follow-up messages, from `queue_update`. `id` is the
28
+ * broker's message-ledger identity for the entry (`steeringIds`/
29
+ * `followUpIds`), absent on a broker pinned to an older runtime generation.
30
+ * A fresh broker with an empty queue emits no `queue_update`, so every
31
+ * welcome resets this rather than leaving a dead broker's rows on screen. */
32
+ queued: Array<{
33
+ id?: string;
34
+ text: string;
35
+ }>;
31
36
  display: DisplayState;
32
37
  /** The blocking dialog awaiting an answer, or null. Only a writable client is
33
38
  * ever sent one, and every writable client is sent the SAME one — the first
@@ -132,7 +132,11 @@ export function reduce(state, frame) {
132
132
  case 'thinking_level_changed':
133
133
  return patchEngine(state, { thinkingLevel: frame.level });
134
134
  case 'queue_update': {
135
- const queued = [...frame.steering, ...frame.followUp];
135
+ // The broker enriches relayed queue_updates with id arrays parallel to
136
+ // pi's text arrays; zip them, tolerating their absence (older broker).
137
+ const { steeringIds, followUpIds } = frame;
138
+ const zip = (texts, ids) => texts.map((text, i) => (ids?.[i] !== undefined ? { id: ids[i], text } : { text }));
139
+ const queued = [...zip(frame.steering, steeringIds), ...zip(frame.followUp, followUpIds)];
136
140
  const next = patchEngine(state, { pendingMessageCount: queued.length });
137
141
  return { ...next, queued };
138
142
  }
@@ -15,6 +15,7 @@ import { handleNewSession } from '../../../core/runtime/reset.js';
15
15
  import { evaluateStop } from '../../../core/runtime/stop-guard.js';
16
16
  import { clearFault } from '../../../core/runtime/fault.js';
17
17
  import { updateModelRecipe } from '../../../core/runtime/model-swap.js';
18
+ import { completeCompanionRequestedSubmit } from '../../review/finish.js';
18
19
  function objectBody(body, operation) {
19
20
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
20
21
  throw usage(`${operation} requires a JSON body`);
@@ -289,6 +290,13 @@ function handleSettle(ctx) {
289
290
  const nodeId = ctx.params['id'];
290
291
  const request = parseSettleBody(ctx.body);
291
292
  const directive = withCanvasWrite(() => settleBroker(nodeId, request));
293
+ // A review companion that just went quiet releases any submit the human left
294
+ // waiting on it. A reprompt is not going quiet, so it waits for the turn that
295
+ // reprompt starts. This never blocks the directive: the completion delivers
296
+ // an approval and retires this very broker, and the broker is owed its answer
297
+ // first.
298
+ if (directive.action !== 'reprompt')
299
+ void completeCompanionRequestedSubmit(nodeId);
292
300
  return { status: 200, body: directive };
293
301
  }
294
302
  /** Commit a watcher cursor only while this broker generation remains current.
@@ -6,6 +6,7 @@
6
6
  // POST /v1/crons/:cronId/pause stop firing, keep config + history
7
7
  // POST /v1/crons/:cronId/resume re-arm a paused cron
8
8
  // POST /v1/crons/:cronId/run fire NOW, out of band (synchronous)
9
+ // POST /v1/crons/poke eligibility poke: re-due every held cron
9
10
  // DELETE /v1/crons/:cronId cancel one cron (idempotent, kills in-flight run)
10
11
  //
11
12
  // The CLI resolves timing client-side (parseWhen/parseCadence) and sends the
@@ -21,7 +22,7 @@
21
22
  // display spec.
22
23
  import { randomUUID } from 'node:crypto';
23
24
  import { ApiError } from '../../../api/errors.js';
24
- import { armCron, cancelCron, getCron, listCrons, listCronRuns, setCronState, } from '../../../core/canvas/crons.js';
25
+ import { armCron, cancelCron, getCron, listCrons, listCronRuns, pokeHeldCrons, setCronState, } from '../../../core/canvas/crons.js';
25
26
  import { getNode } from '../../../core/canvas/canvas.js';
26
27
  import { InputError } from '../../../core/io.js';
27
28
  import { nextSlotAfter } from '../../../core/wake.js';
@@ -329,6 +330,17 @@ async function handleRun(ctx) {
329
330
  }
330
331
  return { status: 200, body: toCronRunDTO(record) };
331
332
  }
333
+ /** `POST /v1/crons/poke` — the bare daemon-level eligibility poke: "something
334
+ * changed; re-check now". Canvas-wide and label-free by design — the caller
335
+ * (a host platform, an operator's curl) asserts only that eligibility
336
+ * changed; every held row re-checks its own gate in bash and re-parks if
337
+ * still blocked. Paused rows are not unparked (pause is user-owned).
338
+ * Idempotent and free when nothing is held. The count is for the caller's
339
+ * log line, nothing else. */
340
+ function handlePoke() {
341
+ const at = new Date().toISOString();
342
+ return { status: 200, body: { unparked: pokeHeldCrons(at), at } };
343
+ }
332
344
  /** `DELETE /v1/crons/:cronId` — cancel one cron. Idempotent: an already-absent
333
345
  * cron is a no-op 204 (`cancelCron` kills any in-flight run process group
334
346
  * before deleting). */
@@ -354,6 +366,7 @@ export const cronRoutes = [
354
366
  { method: 'GET', pattern: '/v1/crons/:cronId', handler: handleShow },
355
367
  { method: 'POST', pattern: '/v1/crons/:cronId/pause', handler: handlePause },
356
368
  { method: 'POST', pattern: '/v1/crons/:cronId/resume', handler: handleResume },
369
+ { method: 'POST', pattern: '/v1/crons/poke', handler: handlePoke },
357
370
  { method: 'POST', pattern: '/v1/crons/:cronId/run', handler: handleRun },
358
371
  { method: 'DELETE', pattern: '/v1/crons/:cronId', handler: handleCancel },
359
372
  ];
@@ -89,6 +89,11 @@ function toDeckSourceDTO(source) {
89
89
  dto.askedBy = source.askedBy;
90
90
  if (source.blockedSince !== undefined)
91
91
  dto.blockedSince = source.blockedSince;
92
+ // The originating canvas node id — machine attribution a consumer uses to
93
+ // relate a ticket to the node that raised it. A node id is opaque canvas
94
+ // identity, not a host path, so it is safe on this wire.
95
+ if (source.nodeId !== undefined)
96
+ dto.nodeId = source.nodeId;
92
97
  return dto;
93
98
  }
94
99
  function toOptionDTO(option) {
@@ -0,0 +1,2 @@
1
+ import type { RouteTable } from '../router.js';
2
+ export declare const memoryRoutes: RouteTable;
@@ -0,0 +1,48 @@
1
+ // Memory-document resolution handler — `GET /v1/memory/resolve?name=&node=`.
2
+ // Turns a `[[canonical/name]]` link written in a node's transcript into the
3
+ // absolute path of the document THAT node would read, so a client can peek it
4
+ // (`GET /v1/files/peek`) and render it.
5
+ //
6
+ // The node is required, not optional: crtrd serves every node from one process,
7
+ // so its own cwd and env name no node, and the same name legitimately resolves
8
+ // to different documents for two nodes (each has its own context store, project
9
+ // stack, and profile). Resolution therefore runs target-addressed —
10
+ // `resolveMemoryDocForTarget` with the node's cwd/profile/id off canvas.
11
+ import { getNode } from '../../../index.js';
12
+ import { notFound, usage } from '../../../core/errors.js';
13
+ import { resolveMemoryDocForTarget } from '../../../core/memory-resolver.js';
14
+ import { isDocLinkName } from '../../../core/memory/doc-link-grammar.js';
15
+ function handleResolve(ctx) {
16
+ const name = ctx.query.get('name');
17
+ if (name === null || name === '') {
18
+ throw usage('a memory resolve requires a `name` query param');
19
+ }
20
+ // The link grammar is the same one that produced the link (browser-safe, one
21
+ // source of truth): anything outside it is not a document name, and rejecting
22
+ // it here keeps a stray `[[…]]` from reaching the resolver's scope parsing.
23
+ if (!isDocLinkName(name)) {
24
+ throw usage('memory document name must be `/`-joined [A-Za-z0-9_-] segments', { received: name });
25
+ }
26
+ const nodeId = ctx.query.get('node');
27
+ if (nodeId === null || nodeId === '') {
28
+ throw usage('a memory resolve requires a `node` query param — resolution is per-node');
29
+ }
30
+ const meta = getNode(nodeId);
31
+ if (meta === null)
32
+ throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
33
+ const doc = resolveMemoryDocForTarget(name, {
34
+ cwd: meta.cwd,
35
+ profileId: meta.profile_id ?? null,
36
+ nodeId: meta.node_id,
37
+ });
38
+ const body = {
39
+ name: doc.name,
40
+ scope: doc.scope,
41
+ path: doc.path,
42
+ ...(doc.plugin === undefined ? {} : { plugin: doc.plugin }),
43
+ };
44
+ return { status: 200, body };
45
+ }
46
+ export const memoryRoutes = [
47
+ { method: 'GET', pattern: '/v1/memory/resolve', handler: handleResolve },
48
+ ];