@north-light/crouter 0.3.180 → 0.3.181

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 (134) 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/message-ledger.d.ts +53 -0
  102. package/dist/core/runtime/broker/message-ledger.js +143 -0
  103. package/dist/core/runtime/broker/rebind.js +14 -0
  104. package/dist/core/runtime/broker-protocol.d.ts +46 -1
  105. package/dist/core/runtime/broker.js +11 -2
  106. package/dist/core/runtime/interactive-deliver.d.ts +5 -2
  107. package/dist/core/runtime/interactive-deliver.js +6 -3
  108. package/dist/core/runtime/shell-expansion.d.ts +32 -0
  109. package/dist/core/runtime/shell-expansion.js +102 -0
  110. package/dist/core/session-model/session-state.d.ts +9 -4
  111. package/dist/core/session-model/session-state.js +5 -1
  112. package/dist/daemon/api/handlers/broker-ops.js +8 -0
  113. package/dist/daemon/api/handlers/crons.js +14 -1
  114. package/dist/daemon/api/handlers/inbox.js +5 -0
  115. package/dist/daemon/api/handlers/memory.d.ts +2 -0
  116. package/dist/daemon/api/handlers/memory.js +48 -0
  117. package/dist/daemon/api/handlers/messages.js +7 -1
  118. package/dist/daemon/api/handlers/reviews.js +7 -5
  119. package/dist/daemon/api/map.js +3 -0
  120. package/dist/daemon/api/server.js +2 -0
  121. package/dist/daemon/cron-run.js +71 -3
  122. package/dist/daemon/crtrd.js +3 -0
  123. package/dist/daemon/reconcilers/pending-review-submit.d.ts +7 -0
  124. package/dist/daemon/reconcilers/pending-review-submit.js +35 -0
  125. package/dist/daemon/review/companion.d.ts +8 -0
  126. package/dist/daemon/review/companion.js +35 -0
  127. package/dist/daemon/review/deliver.js +2 -1
  128. package/dist/daemon/review/finish.d.ts +29 -2
  129. package/dist/daemon/review/finish.js +75 -2
  130. package/dist/shared/generated-context.d.ts +3 -4
  131. package/dist/shared/generated-context.js +24 -6
  132. package/dist/types.d.ts +0 -1
  133. package/package.json +1 -1
  134. package/runtime.lock.json +2 -2
@@ -6,7 +6,7 @@ import { parseFrontmatterGeneric } from './frontmatter.js';
6
6
  import { listInstalledPlugins, listInstalledPluginsInRoot, parseSkillQualifier } from './resolver.js';
7
7
  import { ambiguous, notFound, usage } from './errors.js';
8
8
  import { warn } from './output.js';
9
- import { pluginMemoryDir, projectScopeRoot, projectScopeRoots, scopeMemoryDir } from './scope.js';
9
+ import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from './scope.js';
10
10
  import { effectiveDocKind, normalizeDocName, normalizeNameSegment, resolveDocName } from './substrate/schema.js';
11
11
  import { loadProfileManifest, profileMemoryDir } from './profiles/manifest.js';
12
12
  import { memoryDir as nodeMemoryDir } from './runtime/memory.js';
@@ -14,33 +14,35 @@ import { memoryDir as nodeMemoryDir } from './runtime/memory.js';
14
14
  export function memoryDocId(doc) {
15
15
  return `${doc.scope}/${doc.name}`;
16
16
  }
17
- /** The selected profile id from the process env, or '' when none. The single
18
- * seam every profile-aware memory/config path reads through — see the plan's
19
- * synthesis decision on why this is process env, not a threaded parameter. */
20
- function selectedProfileId() {
21
- return process.env['CRTR_PROFILE_ID'] || '';
17
+ /** The target the ambient process describes: cwd + the two env seams every
18
+ * profile/node-aware memory path reads through (`CRTR_PROFILE_ID`,
19
+ * `CRTR_NODE_ID`). */
20
+ function ambientTarget() {
21
+ return {
22
+ cwd: process.cwd(),
23
+ profileId: process.env['CRTR_PROFILE_ID'] || null,
24
+ nodeId: process.env['CRTR_NODE_ID'] || null,
25
+ };
22
26
  }
23
- /** The current node id from the process env, or '' when not running inside a
24
- * node. The seam the `node` scope resolves through — its memory dir is
25
- * `nodeMemoryDir(id)` (`nodes/<id>/context/memory/`). */
26
- function selectedNodeId() {
27
- return process.env['CRTR_NODE_ID'] || '';
27
+ /** The selected profile id from the process env, or '' when none. */
28
+ function selectedProfileId() {
29
+ return ambientTarget().profileId ?? '';
28
30
  }
29
31
  /** The memory scopes in resolution precedence: node > project stack > profile >
30
- * user > builtin. Node is included only inside a running node (`CRTR_NODE_ID`)
31
- * and ranks NEAREST — a node doc overrides any wider scope. Project is included
32
- * only when a project scope exists for the cwd (or the selected profile's
33
- * project stack); profile is included only when a profile is selected. A single
34
- * `scope` narrows to just that scope. */
35
- function scopesInPrecedence(scope) {
32
+ * user > builtin. Node is included only when the target names one and ranks
33
+ * NEAREST — a node doc overrides any wider scope. Project is included only when
34
+ * a project scope exists for the target's cwd (or its profile's project stack);
35
+ * profile is included only when the target has one. A single `scope` narrows to
36
+ * just that scope. */
37
+ function scopesInPrecedence(target, scope) {
36
38
  if (scope)
37
39
  return [scope];
38
40
  const out = [];
39
- if (selectedNodeId() !== '')
41
+ if (target.nodeId)
40
42
  out.push('node');
41
- if (projectScopeRoot())
43
+ if (projectScopeRoots(target.cwd, target.profileId).length > 0)
42
44
  out.push('project');
43
- if (selectedProfileId() !== '')
45
+ if (target.profileId)
44
46
  out.push('profile');
45
47
  out.push('user');
46
48
  out.push('builtin');
@@ -53,11 +55,11 @@ function scopesInPrecedence(scope) {
53
55
  * join — and drops out silently (never throws the resolver) when the
54
56
  * selected profile id no longer resolves to a manifest. User and builtin
55
57
  * remain singleton scopes after that. */
56
- function memorySourcesInPrecedence(scope) {
58
+ function memorySourcesInPrecedence(target, scope) {
57
59
  const out = [];
58
- for (const s of scopesInPrecedence(scope)) {
60
+ for (const s of scopesInPrecedence(target, scope)) {
59
61
  if (s === 'project') {
60
- for (const root of projectScopeRoots()) {
62
+ for (const root of projectScopeRoots(target.cwd, target.profileId)) {
61
63
  out.push({
62
64
  scope: 'project',
63
65
  memoryDir: join(root, 'memory'),
@@ -66,7 +68,7 @@ function memorySourcesInPrecedence(scope) {
66
68
  }
67
69
  }
68
70
  else if (s === 'profile') {
69
- const profileId = selectedProfileId();
71
+ const profileId = target.profileId ?? '';
70
72
  if (profileId !== '') {
71
73
  try {
72
74
  const { profileId: resolvedId } = loadProfileManifest(profileId);
@@ -79,8 +81,8 @@ function memorySourcesInPrecedence(scope) {
79
81
  }
80
82
  else if (s === 'node') {
81
83
  // The this-node store — a singleton dir in the node's context, no plugins.
82
- // Silently absent when not running inside a node (CRTR_NODE_ID unset).
83
- const nodeId = selectedNodeId();
84
+ // Silently absent when the target names no node.
85
+ const nodeId = target.nodeId ?? '';
84
86
  if (nodeId !== '')
85
87
  out.push({ scope: 'node', memoryDir: nodeMemoryDir(nodeId), plugins: [] });
86
88
  }
@@ -157,11 +159,12 @@ function listMemoryDocsInDir(scope, dir, quiet = false) {
157
159
  * stack); profile is the selected profile's own singleton store, resolved
158
160
  * through `loadProfileManifest`; user and builtin are singleton stores. */
159
161
  export function listMemoryDocs(scope, quiet = false) {
162
+ const target = ambientTarget();
160
163
  if (scope === 'project') {
161
- return listProjectMemoryDocs(process.cwd(), selectedProfileId() || null, quiet);
164
+ return listProjectMemoryDocs(target.cwd, target.profileId, quiet);
162
165
  }
163
166
  if (scope === 'profile') {
164
- const profileId = selectedProfileId();
167
+ const profileId = target.profileId ?? '';
165
168
  if (profileId === '')
166
169
  return [];
167
170
  try {
@@ -173,7 +176,7 @@ export function listMemoryDocs(scope, quiet = false) {
173
176
  }
174
177
  }
175
178
  if (scope === 'node') {
176
- const nodeId = selectedNodeId();
179
+ const nodeId = target.nodeId ?? '';
177
180
  if (nodeId === '')
178
181
  return [];
179
182
  return listMemoryDocsInDir('node', nodeMemoryDir(nodeId), quiet);
@@ -215,10 +218,10 @@ function sourceMemoryDocs(source, quiet = false) {
215
218
  ...source.plugins.flatMap((p) => listPluginMemoryDocs(p, source.scope, quiet)),
216
219
  ];
217
220
  }
218
- /** All project-scoped docs visible from an explicit node workspace/profile.
219
- * This target-addressed form is used when one process serves several nodes
220
- * (notably warm-spare claims), where ambient cwd/profile belong to the host
221
- * process rather than the node whose first-message context is being built. */
221
+ /** All project-scoped docs visible from an explicit node workspace/profile — the
222
+ * project-only slice of a `MemoryTarget` view, used where only workspace docs
223
+ * are wanted (a workspace-open render). For a full-precedence target-addressed
224
+ * lookup, use `resolveMemoryDocForTarget`. */
222
225
  export function listProjectMemoryDocs(startDir = process.cwd(), profileId = selectedProfileId() || null, quiet = false) {
223
226
  return projectScopeRoots(startDir, profileId).flatMap((root) => sourceMemoryDocs({
224
227
  scope: 'project',
@@ -232,7 +235,7 @@ export function listProjectMemoryDocs(startDir = process.cwd(), profileId = sele
232
235
  * native docs are emitted before enabled-plugin docs, so native wins on the
233
236
  * caller's first-wins dedup. */
234
237
  export function listAllMemoryDocs(scope, quiet = false) {
235
- return memorySourcesInPrecedence(scope).flatMap((source) => sourceMemoryDocs(source, quiet));
238
+ return memorySourcesInPrecedence(ambientTarget(), scope).flatMap((source) => sourceMemoryDocs(source, quiet));
236
239
  }
237
240
  /** Find the direct child of `dir` — a `.md` file (matched on name minus
238
241
  * extension) or a directory — whose NORMALIZED display name equals
@@ -356,21 +359,22 @@ function formatLeafAmbiguous(leaf, matches) {
356
359
  const ids = matches.map(memoryDocId).join(', ');
357
360
  return `ambiguous memory document: ${leaf} matches multiple documents: ${ids}`;
358
361
  }
359
- function loadMemorySources(scope) {
362
+ function loadMemorySources(target, scope) {
360
363
  // Quiet: a targeted read must not spew other docs' frontmatter warnings
361
364
  // before its own result (esp. a not_found) — corpus health is `lint`'s job.
362
- return memorySourcesInPrecedence(scope).map((source) => ({
365
+ return memorySourcesInPrecedence(target, scope).map((source) => ({
363
366
  source,
364
367
  docs: sourceMemoryDocs(source, true),
365
368
  }));
366
369
  }
367
370
  export function createMemoryDocSnapshot() {
368
- const defaultSources = loadMemorySources();
371
+ const target = ambientTarget();
372
+ const defaultSources = loadMemorySources(target);
369
373
  const sourcesByScope = new Map([[undefined, defaultSources]]);
370
374
  const sourceFor = (scope) => {
371
375
  let sources = sourcesByScope.get(scope);
372
376
  if (sources === undefined) {
373
- sources = loadMemorySources(scope);
377
+ sources = loadMemorySources(target, scope);
374
378
  sourcesByScope.set(scope, sources);
375
379
  }
376
380
  return sources;
@@ -448,10 +452,18 @@ export function resolveMemoryDocs(names) {
448
452
  return createMemoryDocSnapshot().resolve(names);
449
453
  }
450
454
  export function resolveMemoryDoc(rawName, opts = {}) {
455
+ return resolveMemoryDocForTarget(rawName, ambientTarget(), opts);
456
+ }
457
+ /** Resolve a memory document as ANOTHER node would see it — the same precedence
458
+ * chain (node-local > project stack > profile > user > builtin), read from the
459
+ * target's cwd/profile/node rather than the host process's. This is what crtrd
460
+ * resolves a `[[name]]` link through: the daemon's own cwd and env name no
461
+ * node, and the same name can be a different document for two nodes. */
462
+ export function resolveMemoryDocForTarget(rawName, target, opts = {}) {
451
463
  const parsed = parseSkillQualifier(rawName);
452
464
  if (parsed.scope && opts.scope && parsed.scope !== opts.scope) {
453
465
  throw usage(`scope conflict: identifier "${rawName}" uses scope "${parsed.scope}" but --scope is "${opts.scope}"`);
454
466
  }
455
467
  const effectiveScope = opts.scope ?? parsed.scope;
456
- return resolveMemoryDocFromSources(rawName, opts, loadMemorySources(effectiveScope));
468
+ return resolveMemoryDocFromSources(rawName, opts, loadMemorySources(target, effectiveScope));
457
469
  }
@@ -157,6 +157,7 @@ export async function stageReview(args) {
157
157
  created,
158
158
  captured_at: capturedAt,
159
159
  opened_at: null,
160
+ submit_requested_at: null,
160
161
  output_path: args.output_path ?? null,
161
162
  subtitle: args.subtitle ?? null,
162
163
  approved_at: null,
@@ -29,9 +29,14 @@ export declare function listReviews(filter: {
29
29
  file?: string;
30
30
  }): ReviewRecord[];
31
31
  export declare function listBindingReviews(): ReviewRecord[];
32
+ /** Open reviews the human has already submitted, still waiting on their companion. */
33
+ export declare function listSubmitRequestedReviews(): ReviewRecord[];
32
34
  export declare function listUnprojectedTerminalReviews(): ReviewRecord[];
33
35
  export declare function bindBridgeNode(reviewId: string, bridgeNodeId: string): boolean;
34
36
  export declare function openReviewRow(reviewId: string, openedAt: string): boolean;
37
+ /** Record the human's submit intent on a review that stays open meanwhile.
38
+ * False when the review already left `open` or already carries an intent. */
39
+ export declare function requestReviewSubmit(reviewId: string, at: string): boolean;
35
40
  export declare function approveReviewLocked(db: DatabaseSync, args: ApproveReviewArgs): boolean;
36
41
  export declare function cancelReviewRow(reviewId: string, args: {
37
42
  at: string;
@@ -24,6 +24,7 @@ function recordFromRow(raw) {
24
24
  created: raw['created'],
25
25
  captured_at: raw['captured_at'],
26
26
  opened_at: raw['opened_at'] ?? null,
27
+ submit_requested_at: raw['submit_requested_at'] ?? null,
27
28
  output_path: raw['output_path'] ?? null,
28
29
  subtitle: raw['subtitle'] ?? null,
29
30
  approved_at: raw['approved_at'] ?? null,
@@ -118,6 +119,10 @@ export function listReviews(filter) {
118
119
  export function listBindingReviews() {
119
120
  return readRecords(openDb(), "state = 'binding'");
120
121
  }
122
+ /** Open reviews the human has already submitted, still waiting on their companion. */
123
+ export function listSubmitRequestedReviews() {
124
+ return readRecords(openDb(), "state = 'open' AND submit_requested_at IS NOT NULL");
125
+ }
121
126
  export function listUnprojectedTerminalReviews() {
122
127
  return readRecords(openDb(), "state IN ('approved', 'canceled') AND projected_at IS NULL");
123
128
  }
@@ -127,6 +132,11 @@ export function bindBridgeNode(reviewId, bridgeNodeId) {
127
132
  export function openReviewRow(reviewId, openedAt) {
128
133
  return withCanvasWrite((db) => db.prepare("UPDATE reviews SET state = 'open', opened_at = ? WHERE review_id = ? AND state = 'binding'").run(openedAt, reviewId).changes > 0);
129
134
  }
135
+ /** Record the human's submit intent on a review that stays open meanwhile.
136
+ * False when the review already left `open` or already carries an intent. */
137
+ export function requestReviewSubmit(reviewId, at) {
138
+ return withCanvasWrite((db) => db.prepare("UPDATE reviews SET submit_requested_at = ? WHERE review_id = ? AND state = 'open' AND submit_requested_at IS NULL").run(at, reviewId).changes > 0);
139
+ }
130
140
  export function approveReviewLocked(db, args) {
131
141
  return db.prepare(`
132
142
  UPDATE reviews SET
@@ -47,6 +47,10 @@ export interface ReviewRow {
47
47
  created: string;
48
48
  captured_at: string;
49
49
  opened_at: string | null;
50
+ /** When the human pressed submit on a review whose companion was still
51
+ * working. The review stays `open` — comments still land and still remap —
52
+ * until the companion goes quiet and the daemon completes the approval. */
53
+ submit_requested_at: string | null;
50
54
  output_path: string | null;
51
55
  subtitle: string | null;
52
56
  approved_at: string | null;
@@ -1,11 +1,13 @@
1
1
  import type { BrokerToClient } from '../broker-protocol.js';
2
2
  import type { BrokerSession } from './read-ops.js';
3
3
  import type { BrokerClientRegistry } from './client-registry.js';
4
+ import type { MessageIdLedger } from './message-ledger.js';
4
5
  import type { SubscriptionGeneration } from './rebind.js';
5
6
  import type { FaultRetry } from './fault-retry.js';
6
7
  type EventProjectionDeps = {
7
8
  registry: BrokerClientRegistry;
8
9
  faultRetry: FaultRetry;
10
+ ledger: MessageIdLedger;
9
11
  installedGeneration: () => SubscriptionGeneration | undefined;
10
12
  currentSession: () => BrokerSession;
11
13
  notifyTurnAccepted: () => void;
@@ -31,7 +33,12 @@ export declare class EventProjection {
31
33
  reset(): void;
32
34
  dispose(): void;
33
35
  knownStreamWait(deadlineMs: number): () => void;
34
- relayEvent(event: BrokerToClient, generation: SubscriptionGeneration): void;
36
+ /** Message-identity enrichment (see MessageIdLedger): fold pi's queue state
37
+ * into the ledger and relay id arrays parallel to the texts; attach the
38
+ * retired/dispatched id to the user message that carries it. Both are
39
+ * additive fields on the verbatim pi event — consumers read or ignore them. */
40
+ private withMessageIdentity;
41
+ relayEvent(rawEvent: BrokerToClient, generation: SubscriptionGeneration): void;
35
42
  private clearPendingUpdate;
36
43
  private flushPendingUpdate;
37
44
  private invalidatePendingBroadcasts;
@@ -1,3 +1,4 @@
1
+ import { contentText } from '@earendil-works/pi-ai';
1
2
  import { readConfig } from '../../config.js';
2
3
  import { emitEvent } from '../../events/emit.js';
3
4
  import { releaseKnownStreamWait, resolveStreamWatchdogMs, StreamWatchdog } from '../stream-watchdog.js';
@@ -64,7 +65,30 @@ export class EventProjection {
64
65
  releaseKnownStreamWait(this.streamWatchdog, generation.id, waitingSession.isStreaming, generation.executingTools.size > 0);
65
66
  };
66
67
  }
67
- relayEvent(event, generation) {
68
+ /** Message-identity enrichment (see MessageIdLedger): fold pi's queue state
69
+ * into the ledger and relay id arrays parallel to the texts; attach the
70
+ * retired/dispatched id to the user message that carries it. Both are
71
+ * additive fields on the verbatim pi event — consumers read or ignore them. */
72
+ withMessageIdentity(event) {
73
+ if (event.type === 'queue_update') {
74
+ const ids = this.deps.ledger.reconcileQueueUpdate(event.steering, event.followUp);
75
+ const enriched = { ...event, steeringIds: ids.steeringIds, followUpIds: ids.followUpIds };
76
+ return enriched;
77
+ }
78
+ if (event.type === 'message_start' && event.message.role === 'user') {
79
+ // contentText(content, '') is pi's OWN retirement text extraction
80
+ // (agent-session.js _handleAgentEvent) — the same function, so the
81
+ // ledger's text matching cannot diverge from pi's queue splice.
82
+ const crtrMessageId = this.deps.ledger.takeUserMessageId(contentText(event.message.content, ''));
83
+ if (crtrMessageId === undefined)
84
+ return event;
85
+ const enriched = { ...event, crtrMessageId };
86
+ return enriched;
87
+ }
88
+ return event;
89
+ }
90
+ relayEvent(rawEvent, generation) {
91
+ const event = this.withMessageIdentity(rawEvent);
68
92
  this.deps.faultRetry.onEvent(event);
69
93
  const type = event.type;
70
94
  if (type === 'message_start' && event.message.role === 'user') {
@@ -6,6 +6,7 @@ import type { BrokerClient, BrokerClientRegistry } from './client-registry.js';
6
6
  import { type BrokerSession } from './read-ops.js';
7
7
  import type { ToolGroupTracker } from './tool-groups.js';
8
8
  import type { EventProjection } from './event-projection.js';
9
+ import type { MessageIdLedger } from './message-ledger.js';
9
10
  import type { RebindDriver } from './rebind.js';
10
11
  export declare const REVIEW_BOUNDARY_NAVIGATION_CODE = "review_boundary_navigation";
11
12
  export declare class ReviewBoundaryNavigationError extends Error {
@@ -180,6 +181,7 @@ export type FrameDispatchDeps = {
180
181
  registry: BrokerClientRegistry;
181
182
  toolGroups: ToolGroupTracker;
182
183
  projection: EventProjection;
184
+ ledger: MessageIdLedger;
183
185
  rebind: RebindDriver;
184
186
  reviewCompanion: boolean;
185
187
  pendingDialogs: Map<string, PendingDialog>;
@@ -279,7 +279,7 @@ export function stripHiddenEntriesFromJsonlExport(filePath) {
279
279
  }
280
280
  /** Owns frame-local input state while dispatching through explicit broker collaborators. */
281
281
  export function createFrameDispatchContext(deps) {
282
- const { nodeId, cfg, registry, toolGroups, projection, rebind, reviewCompanion, pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit, persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec, formatExactModelSpec, resolveLaunchModel, } = deps;
282
+ const { nodeId, cfg, registry, toolGroups, projection, ledger, rebind, reviewCompanion, pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit, persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec, formatExactModelSpec, resolveLaunchModel, } = deps;
283
283
  const currentSession = () => rebind.session();
284
284
  const currentServices = () => rebind.services();
285
285
  let kickoffPending;
@@ -301,9 +301,14 @@ export function createFrameDispatchContext(deps) {
301
301
  const prompt = kickoffPending;
302
302
  kickoffPending = undefined;
303
303
  kickoffInFlight = true;
304
+ // The kickoff prompt is a broker-originated dispatch — mint its identity
305
+ // here so its relayed user message_start carries an id like any other.
306
+ const kickoffMessageId = randomUUID();
307
+ ledger.noteDispatch(kickoffMessageId, prompt);
304
308
  trackPromptDispatch(operationIdContext.fresh(() => currentSession()
305
309
  .prompt(prompt)
306
310
  .catch((err) => {
311
+ ledger.dropIntent(kickoffMessageId);
307
312
  emitEvent({
308
313
  level: 'error',
309
314
  event: 'broker.prompt.first_failed',
@@ -392,6 +397,7 @@ export function createFrameDispatchContext(deps) {
392
397
  // is producing now, then start this as a new turn. A normal mid-stream
393
398
  // prompt deliberately steers into pi's queue; the whip must not.
394
399
  if (frame.type === 'prompt' && frame.interrupt === true) {
400
+ const whipMessageId = frame.message_id;
395
401
  return (async () => {
396
402
  if (currentSession().isBashRunning) {
397
403
  currentSession().abortBash();
@@ -414,6 +420,7 @@ export function createFrameDispatchContext(deps) {
414
420
  type: 'prompt',
415
421
  text: frame.text,
416
422
  ...(frame.images !== undefined ? { images: frame.images } : {}),
423
+ ...(whipMessageId !== undefined ? { message_id: whipMessageId } : {}),
417
424
  });
418
425
  })().catch((err) => {
419
426
  relayError(err);
@@ -444,6 +451,19 @@ export function createFrameDispatchContext(deps) {
444
451
  const dispatch = () => {
445
452
  const route = resolveEngineRoute(frame, currentSession().isStreaming);
446
453
  const guidance = resolveFrameGuidance(frame.text);
454
+ // Message identity (see MessageIdLedger): note the dispatch intent
455
+ // before the engine call so pi's synchronous queue_update/message_start
456
+ // can adopt it. A leading engine command never produces a matching
457
+ // user message — no intent. On rejection the intent is dropped.
458
+ let noteFailure = relayError;
459
+ if (!isLeadingEngineCommand(frame.text, currentSession())) {
460
+ const messageId = frame.message_id ?? randomUUID();
461
+ ledger.noteDispatch(messageId, frame.text);
462
+ noteFailure = (err) => {
463
+ ledger.dropIntent(messageId);
464
+ relayError(err);
465
+ };
466
+ }
447
467
  if (guidance === null) {
448
468
  if (route.call === 'prompt') {
449
469
  // A routed prompt STARTS a turn — including an idle follow_up
@@ -452,12 +472,12 @@ export function createFrameDispatchContext(deps) {
452
472
  // steer.
453
473
  return settle(trackPromptDispatch(operationIdContext.fresh(() => currentSession()
454
474
  .prompt(frame.text, route.options)
455
- .catch(relayError))));
475
+ .catch(noteFailure))));
456
476
  }
457
477
  // C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
458
478
  return settle(currentSession()
459
479
  .followUp(frame.text, frame.images)
460
- .catch(relayError));
480
+ .catch(noteFailure));
461
481
  }
462
482
  const deliverAs = chooseGuidanceDeliveryMode(frame.type, route.call, currentSession().isStreaming);
463
483
  if (route.call === 'prompt') {
@@ -469,18 +489,18 @@ export function createFrameDispatchContext(deps) {
469
489
  // ATOMICALLY clears its own pending entry if prompt() settles
470
490
  // without ever reaching that drain (an early throw/return leaves
471
491
  // nothing stranded to leak into a later, unrelated turn).
472
- return settle(trackPromptDispatch(promptWithNextTurnGuidance(currentSession(), guidance, () => operationIdContext.fresh(() => currentSession().prompt(frame.text, route.options))).catch(relayError)));
492
+ return settle(trackPromptDispatch(promptWithNextTurnGuidance(currentSession(), guidance, () => operationIdContext.fresh(() => currentSession().prompt(frame.text, route.options))).catch(noteFailure)));
473
493
  }
474
494
  return settle(trackPromptDispatch(operationIdContext.fresh(() => currentSession()
475
495
  .prompt(frame.text, route.options)
476
- .catch(relayError)
496
+ .catch(noteFailure)
477
497
  .then(() => sendGuidance(guidance, deliverAs)))));
478
498
  }
479
499
  // deliverAs is always 'followUp' here per chooseGuidanceDeliveryMode's
480
500
  // contract (a followUp routeCall never yields 'nextTurn').
481
501
  return settle(currentSession()
482
502
  .followUp(frame.text, frame.images)
483
- .catch(relayError)
503
+ .catch(noteFailure)
484
504
  .then(() => sendGuidance(guidance, deliverAs)));
485
505
  };
486
506
  // /login acknowledges before its asynchronous local refresh completes.
@@ -510,6 +530,8 @@ export function createFrameDispatchContext(deps) {
510
530
  // and executes it immediately even while streaming. It also expands a
511
531
  // matching skill/template before applying the same steer behavior.
512
532
  if (resolveSteerCall(frame.text, currentSession()) === 'prompt') {
533
+ // The steer→prompt leading-command path never yields a matching user
534
+ // message — no ledger intent (see MessageIdLedger).
513
535
  void currentSession()
514
536
  .prompt(frame.text, {
515
537
  images: frame.images,
@@ -518,17 +540,25 @@ export function createFrameDispatchContext(deps) {
518
540
  .catch(relayError);
519
541
  break;
520
542
  }
543
+ // Message identity: note the intent before steer() so pi's synchronous
544
+ // enqueue queue_update can adopt it; drop it if the call rejects.
545
+ const steerMessageId = frame.message_id ?? randomUUID();
546
+ ledger.noteDispatch(steerMessageId, frame.text);
547
+ const steerFailure = (err) => {
548
+ ledger.dropIntent(steerMessageId);
549
+ relayError(err);
550
+ };
521
551
  const guidance = resolveFrameGuidance(frame.text);
522
552
  // C1: pi's steer() takes images as a POSITIONAL 2nd arg (`steer(text, images?)`).
523
553
  if (guidance === null) {
524
554
  void currentSession()
525
555
  .steer(frame.text, frame.images)
526
- .catch(relayError);
556
+ .catch(steerFailure);
527
557
  break;
528
558
  }
529
559
  void currentSession()
530
560
  .steer(frame.text, frame.images)
531
- .catch(relayError)
561
+ .catch(steerFailure)
532
562
  .then(() => sendGuidance(guidance, 'steer'));
533
563
  break;
534
564
  }
@@ -806,6 +836,9 @@ export function createFrameDispatchContext(deps) {
806
836
  type: 'prompt',
807
837
  text: frame.text,
808
838
  ...(frame.images !== undefined ? { images: frame.images } : {}),
839
+ // Carry the sender-minted message identity onto the synthesized prompt
840
+ // frame so a gateway/Core deliver retires its optimistic entry by id.
841
+ ...(frame.message_id !== undefined ? { message_id: frame.message_id } : {}),
809
842
  });
810
843
  if (via === 'steer' || outcome === undefined) {
811
844
  turnAcceptedWaiters.delete(ackAccepted);
@@ -1203,6 +1236,15 @@ export function createFrameDispatchContext(deps) {
1203
1236
  void currentSession()
1204
1237
  .reload()
1205
1238
  .then(() => {
1239
+ // pi's reload() re-syncs queue modes from the shared settings.json
1240
+ // (its ONE post-bind syncQueueModesFromSettings call site) —
1241
+ // re-apply the broker's 'all' modes so a reload cannot silently
1242
+ // restore one-at-a-time draining (see rebind.enqueueRebind).
1243
+ const agent = currentSession().agent;
1244
+ if (agent) {
1245
+ agent.steeringMode = 'all';
1246
+ agent.followUpMode = 'all';
1247
+ }
1206
1248
  memoizedRefInventory = null;
1207
1249
  clearSessionCache();
1208
1250
  ackTo(client, 'reload');
@@ -0,0 +1,53 @@
1
+ /** One id-bearing message the ledger tracks. */
2
+ export interface LedgerEntry {
3
+ id: string;
4
+ text: string;
5
+ }
6
+ export declare class MessageIdLedger {
7
+ private steering;
8
+ private followUp;
9
+ private pendingIntents;
10
+ private stagedRetirementId;
11
+ private recent;
12
+ /** Record an accepted client frame's identity before its engine call runs.
13
+ * `id` is the sender's `message_id` when it minted one, else broker-minted. */
14
+ noteDispatch(id: string, text: string): void;
15
+ /** Remove a noted intent whose engine call rejected — the message will never
16
+ * appear in a queue or as a user message. No-op if already adopted. */
17
+ dropIntent(id: string): void;
18
+ /**
19
+ * Fold one pi `queue_update` into the ledger and return the id arrays
20
+ * parallel to its text arrays (total: every position gets an id).
21
+ *
22
+ * Per-queue positional diff against the mirrored entries: a text matching an
23
+ * old entry at-or-after the cursor keeps its id (everything skipped is
24
+ * removed); an unmatched text ADOPTS the first pending intent with equal
25
+ * text, else mints a fresh id. Exactly ONE removal across both queues is
26
+ * pi's per-message retirement (its queue_update precedes the user
27
+ * `message_start` it belongs to) and is staged for `takeUserMessageId`;
28
+ * 0 or >1 removals (an enqueue, clearQueue/dequeue, abort) stage nothing.
29
+ */
30
+ reconcileQueueUpdate(steering: readonly string[], followUp: readonly string[]): {
31
+ steeringIds: string[];
32
+ followUpIds: string[];
33
+ };
34
+ /**
35
+ * The id for a relayed user `message_start`, or undefined when this message
36
+ * carries none (an engine-command expansion, a text pi transformed, or a
37
+ * pre-ledger replay). A staged retirement wins — pi emitted its queue_update
38
+ * synchronously right before this message; otherwise the first pending
39
+ * intent with equal text (the idle-prompt case, which never touches a
40
+ * queue). Id-bearing results are remembered for `recentUserMessages`.
41
+ */
42
+ takeUserMessageId(messageText: string): string | undefined;
43
+ /** The last {@link MAX_RECENT} id-bearing user-message dispatches, oldest
44
+ * first — the `welcome.recentUserMessages` payload. */
45
+ recentUserMessages(): Array<{
46
+ id: string;
47
+ text: string;
48
+ }>;
49
+ /** Session rebind/replacement: the mirrored queues, staged retirement, and
50
+ * recent list all described the OLD session — drop everything. */
51
+ reset(): void;
52
+ private remember;
53
+ }