@north-light/crouter 0.3.184 → 0.3.186

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 (80) hide show
  1. package/dist/api/dto/broker.d.ts +0 -32
  2. package/dist/api/dto/messages.d.ts +0 -5
  3. package/dist/build-root.js +10 -3
  4. package/dist/builtin-memory/internal/plugins.md +1 -1
  5. package/dist/clients/attach/input/capabilities.js +3 -0
  6. package/dist/clients/attach/input/controller.js +3 -4
  7. package/dist/clients/attach/overlays/mcp.js +1 -3
  8. package/dist/clients/attach/slash/dispatch.js +12 -2
  9. package/dist/clients/attach/viewer.js +349 -349
  10. package/dist/commands/memory/list.js +7 -3
  11. package/dist/commands/pkg/browse/actions.d.ts +7 -0
  12. package/dist/commands/pkg/browse/actions.js +199 -0
  13. package/dist/commands/pkg/browse/catalog.d.ts +9 -0
  14. package/dist/commands/pkg/browse/catalog.js +466 -0
  15. package/dist/commands/pkg/browse/command-view.d.ts +3 -0
  16. package/dist/commands/pkg/browse/command-view.js +191 -0
  17. package/dist/commands/pkg/browse/detail-panel.d.ts +66 -0
  18. package/dist/commands/pkg/browse/detail-panel.js +402 -0
  19. package/dist/commands/pkg/browse/doc-view.d.ts +3 -0
  20. package/dist/commands/pkg/browse/doc-view.js +81 -0
  21. package/dist/commands/pkg/browse/list-panel.d.ts +28 -0
  22. package/dist/commands/pkg/browse/list-panel.js +168 -0
  23. package/dist/commands/pkg/browse/model.d.ts +230 -0
  24. package/dist/commands/pkg/browse/model.js +36 -0
  25. package/dist/commands/pkg/browse/shell.d.ts +93 -0
  26. package/dist/commands/pkg/browse/shell.js +640 -0
  27. package/dist/commands/pkg/browse.d.ts +2 -0
  28. package/dist/commands/pkg/browse.js +27 -0
  29. package/dist/commands/pkg/market-manage.js +13 -4
  30. package/dist/commands/pkg/plugin-manage.js +8 -3
  31. package/dist/commands/pkg.js +2 -1
  32. package/dist/commands/sys/doctor.js +6 -4
  33. package/dist/commands/sys/panels/broker-limits-panel.d.ts +1 -1
  34. package/dist/commands/sys/panels/broker-limits-panel.js +1 -1
  35. package/dist/commands/sys/panels/dependencies-panel.d.ts +1 -1
  36. package/dist/commands/sys/panels/dependencies-panel.js +1 -1
  37. package/dist/commands/sys/panels/keybindings-panel.js +1 -1
  38. package/dist/commands/sys/panels/models-panel.d.ts +1 -1
  39. package/dist/commands/sys/panels/models-panel.js +1 -1
  40. package/dist/commands/sys/panels/plugins-panel.d.ts +1 -1
  41. package/dist/commands/sys/panels/plugins-panel.js +1 -1
  42. package/dist/commands/sys/panels/prefs-panel.d.ts +1 -1
  43. package/dist/commands/sys/panels/prefs-panel.js +1 -1
  44. package/dist/commands/sys/panels/provider-panel.d.ts +1 -1
  45. package/dist/commands/sys/panels/provider-panel.js +1 -1
  46. package/dist/commands/sys/settings-shell.js +1 -1
  47. package/dist/commands/sys/setup-settings-runner.d.ts +7 -18
  48. package/dist/commands/sys/setup-settings-runner.js +3 -52
  49. package/dist/commands/sys/setup-wizard.js +1 -1
  50. package/dist/core/__tests__/serial/command-plugins.test.js +21 -40
  51. package/dist/core/__tests__/session-model.test.js +2 -4
  52. package/dist/core/command-manifests/registry.d.ts +10 -3
  53. package/dist/core/command-manifests/registry.js +36 -22
  54. package/dist/core/command-plugins/compose.js +1 -1
  55. package/dist/core/command-plugins/discovery.js +15 -7
  56. package/dist/core/keybindings/catalog.d.ts +1 -1
  57. package/dist/core/keybindings/catalog.js +1 -0
  58. package/dist/core/resolver.d.ts +2 -2
  59. package/dist/core/runtime/broker/event-projection.d.ts +1 -8
  60. package/dist/core/runtime/broker/event-projection.js +1 -25
  61. package/dist/core/runtime/broker/frame-dispatch.d.ts +0 -2
  62. package/dist/core/runtime/broker/frame-dispatch.js +8 -41
  63. package/dist/core/runtime/broker-protocol.d.ts +1 -46
  64. package/dist/core/runtime/broker.js +1 -8
  65. package/dist/core/runtime/interactive-deliver.d.ts +2 -5
  66. package/dist/core/runtime/interactive-deliver.js +3 -6
  67. package/dist/core/runtime/tmux-bindings.js +2 -0
  68. package/dist/core/session-model/session-state.d.ts +3 -6
  69. package/dist/core/session-model/session-state.js +1 -5
  70. package/dist/core/tui/page-host.d.ts +25 -0
  71. package/dist/core/tui/page-host.js +65 -0
  72. package/dist/daemon/api/handlers/messages.js +1 -7
  73. package/dist/pi-extensions/canvas-bash-valve.js +45 -1
  74. package/dist/types.d.ts +4 -1
  75. package/package.json +1 -1
  76. package/runtime.lock.json +2 -2
  77. package/dist/core/runtime/broker/message-ledger.d.ts +0 -53
  78. package/dist/core/runtime/broker/message-ledger.js +0 -143
  79. /package/dist/{commands/sys/panels → core/tui}/panel.d.ts +0 -0
  80. /package/dist/{commands/sys/panels → core/tui}/panel.js +0 -0
@@ -2,26 +2,35 @@
2
2
  // contributor attribution. This module takes raw candidates (from plugins,
3
3
  // HTTP-transport plugins, or any external source) and runs one unified pass that:
4
4
  //
5
- // 1. Detects collisions by resolved path (shortest collision wins).
6
- // 2. Drops all claimants at each collided path.
7
- // 3. Records issues on every claimant at each collision.
8
- // 4. Gives core absolute precedence.
5
+ // 1. Detects top-level collisions between plugins.
6
+ // 2. Keeps every plugin claimant by mounting it at <origin>:<command>.
7
+ // 3. Records an informational issue on every qualified claimant.
8
+ // 4. Gives core absolute precedence and drops core-name claimants.
9
9
  // 5. Returns a deterministically ordered, collision-resolved snapshot.
10
10
  //
11
11
  // The registry itself is transport-neutral: it knows nothing about plugin
12
12
  // executables or HTTP invokers. Each contribution carries its own adapter
13
13
  // callback, which the tree composer invokes to turn a leaf into a real crtr
14
14
  // LeafDef.
15
+ /** The stable source name used to qualify a colliding plugin command. */
16
+ export function commandContributorOrigin(contributor) {
17
+ return contributor.sourceMarketplace ?? contributor.name;
18
+ }
19
+ /** The top-level name assigned to a plugin command that collides with another
20
+ * plugin command. */
21
+ export function qualifiedTopLevelCommandName(contributor, commandName) {
22
+ return `${commandContributorOrigin(contributor)}:${commandName}`;
23
+ }
15
24
  /** Resolve a flat list of candidate contributions into a collision-resolved
16
- * snapshot. Every contribution at a collided path is dropped (all claimants),
17
- * an issue is recorded on each claimant, core names take precedence, and
18
- * results are deterministically ordered by contributor name. */
25
+ * snapshot. Plugin claimants at a collided path are qualified by origin and
26
+ * kept; core names take precedence and are dropped. An issue is recorded on
27
+ * every claimant, and results are deterministically ordered by contributor
28
+ * name. */
19
29
  export function resolveCommandRegistry(candidates, reservedCoreNames) {
20
30
  const collisions = new Map();
21
31
  const contributionsByPath = new Map();
22
- // Phase 1: Detect collisions. Collect every top-level path (and implicitly,
23
- // every ancestor path that the top-level node reaches), detect duplicates.
24
- // A collision drops ALL claimants at that path.
32
+ // Phase 1: Detect collisions. A core collision drops the plugin claimant;
33
+ // a cross-plugin collision qualifies every claimant in phase 2.
25
34
  for (const contrib of candidates) {
26
35
  const { node, contributor } = contrib;
27
36
  const path = [node.name];
@@ -54,8 +63,8 @@ export function resolveCommandRegistry(candidates, reservedCoreNames) {
54
63
  contributionsByPath.set(pathKey, contrib);
55
64
  }
56
65
  }
57
- // Phase 2: Build the final snapshot. Include only non-collided contributions
58
- // and record issues on all collided claimants.
66
+ // Phase 2: Build the final snapshot. Core collisions are dropped; plugin
67
+ // collisions keep every claimant under its origin-qualified command name.
59
68
  const accepted = [];
60
69
  const issues = [];
61
70
  for (const contrib of candidates) {
@@ -63,11 +72,11 @@ export function resolveCommandRegistry(candidates, reservedCoreNames) {
63
72
  if (collisions.has(pathKey)) {
64
73
  const collision = collisions.get(pathKey);
65
74
  const claimantNames = collision.claimants.map((c) => c.contributor.name).join(', ');
66
- const claimantKinds = collision.claimants.map((c) => c.contributor.name).join(', ');
67
- // Record an issue on this claimant. A core collision has exactly one
68
- // claimant contesting a reserved built-in name, not "multiple contributors".
69
- issues.push(collision.isCoreCollision
70
- ? {
75
+ // A core collision has exactly one claimant contesting a reserved
76
+ // built-in name. Plugin collisions are informative: every claimant is
77
+ // retained under the source-qualified name reported here.
78
+ if (collision.isCoreCollision) {
79
+ issues.push({
71
80
  code: 'command_collision',
72
81
  contributor: contrib.contributor,
73
82
  path: contrib.node.name,
@@ -75,16 +84,21 @@ export function resolveCommandRegistry(candidates, reservedCoreNames) {
75
84
  received: claimantNames,
76
85
  expected: 'no top-level command name matching a reserved core command',
77
86
  next: `Rename "${contrib.node.name}" to a name that does not collide with core.`,
78
- }
79
- : {
87
+ });
88
+ }
89
+ else {
90
+ const qualifiedName = qualifiedTopLevelCommandName(contrib.contributor, contrib.node.name);
91
+ accepted.push({ ...contrib, node: { ...contrib.node, name: qualifiedName } });
92
+ issues.push({
80
93
  code: 'command_collision',
81
94
  contributor: contrib.contributor,
82
95
  path: contrib.node.name,
83
- message: `top-level command "${contrib.node.name}" is claimed by multiple contributors: ${claimantKinds}`,
96
+ message: `top-level command "${contrib.node.name}" is claimed by ${claimantNames} — mounted as "${qualifiedName}"`,
84
97
  received: claimantNames,
85
- expected: 'exactly one contributor per top-level command',
86
- next: `Remove or disable all but one of: ${claimantNames}.`,
98
+ expected: 'an origin-qualified top-level command for each claimant',
99
+ next: `Invoke "${qualifiedName}" to use this plugin command.`,
87
100
  });
101
+ }
88
102
  }
89
103
  else {
90
104
  // Not collided — accept it.
@@ -4,7 +4,7 @@ import { invokeHttpLeaf } from './transport/http-invoke.js';
4
4
  export function adaptPluginContributions(contributions) {
5
5
  return {
6
6
  contributions: contributions.map((contribution) => ({
7
- contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope },
7
+ contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope, sourceMarketplace: contribution.plugin.sourceMarketplace },
8
8
  node: contribution.node,
9
9
  adaptLeaf: (leaf, commandPath) => {
10
10
  const transport = contribution.transport;
@@ -3,7 +3,7 @@ import { resolve, sep } from 'node:path';
3
3
  import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
4
4
  import { projectScopeRoots } from '../scope.js';
5
5
  import { validateCommandManifest } from '../command-manifests/manifest.js';
6
- import { resolveCommandRegistry } from '../command-manifests/registry.js';
6
+ import { qualifiedTopLevelCommandName, resolveCommandRegistry } from '../command-manifests/registry.js';
7
7
  import { adaptPluginContributions } from './compose.js';
8
8
  import { validateHttpPluginTransport } from './endpoint.js';
9
9
  export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
@@ -139,18 +139,26 @@ export function validateEffectiveCommandPlugins(reservedNames, startDir = proces
139
139
  const validations = effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin, reservedNames, coreCommandPaths));
140
140
  const claims = new Map();
141
141
  for (const validation of validations)
142
- for (const contribution of validation.contributions)
143
- (claims.get(contribution.node.name) ?? (claims.set(contribution.node.name, []), claims.get(contribution.node.name))).push(validation);
142
+ for (const contribution of validation.contributions) {
143
+ const claimants = claims.get(contribution.node.name);
144
+ if (claimants === undefined)
145
+ claims.set(contribution.node.name, [contribution]);
146
+ else
147
+ claimants.push(contribution);
148
+ }
144
149
  for (const [name, claimants] of claims)
145
150
  if (claimants.length > 1)
146
- for (const validation of claimants) {
147
- validation.contributions = validation.contributions.filter((contribution) => contribution.node.name !== name);
148
- validation.issues.push({ code: 'command_collision', plugin: validation.plugin.name, path: name, message: `top-level command "${name}" is claimed by multiple plugins`, received: claimants.map((claimant) => claimant.plugin.name).join(', '), expected: 'exactly one plugin per top-level command name', next: 'Disable or remove all but one claimant.' });
151
+ for (const claimant of claimants) {
152
+ const { plugin } = claimant;
153
+ const qualifiedName = qualifiedTopLevelCommandName({ name: plugin.name, sourceMarketplace: plugin.sourceMarketplace }, name);
154
+ const validation = validations.find((candidate) => candidate.plugin.root === plugin.root);
155
+ validation.contributions = validation.contributions.map((contribution) => contribution === claimant ? { ...contribution, node: { ...contribution.node, name: qualifiedName } } : contribution);
156
+ validation.issues.push({ code: 'command_collision', plugin: plugin.name, path: name, message: `top-level command "${name}" is claimed by ${claimants.map((entry) => entry.plugin.name).join(', ')} — mounted as "${qualifiedName}"`, received: claimants.map((entry) => entry.plugin.name).join(', '), expected: 'an origin-qualified top-level command for each claimant', next: `Invoke "${qualifiedName}" to use this plugin command.` });
149
157
  }
150
158
  return validations;
151
159
  }
152
160
  function contributorIssue(plugin, issue) {
153
- return { ...issue, contributor: { name: plugin.name, scope: plugin.scope } };
161
+ return { ...issue, contributor: { name: plugin.name, scope: plugin.scope, sourceMarketplace: plugin.sourceMarketplace } };
154
162
  }
155
163
  export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId) {
156
164
  const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
@@ -1,6 +1,6 @@
1
1
  import type { BindingDefinition, SafetyRequirement } from './types.js';
2
2
  import type { AttachBindingId, AttachMenuBindingId } from './attach-control.js';
3
- type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'resume' | 'fork' | 'demote' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'providers' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
3
+ type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'resume' | 'fork' | 'demote' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'providers' | 'plugins' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
4
4
  type TmuxMenuEntryAction = Exclude<TmuxMenuAction, 'open' | 'attach'> | `issue.${'idea' | 'bug'}`;
5
5
  type GraphAction = 'down' | 'up' | 'first' | 'last' | 'collapse-or-manager' | 'expand-or-child' | 'focus' | 'revive' | 'focus-manager' | 'close-subtree' | 'dismiss';
6
6
  type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child' | 'collapse-or-parent' | 'toggle-fold' | 'tab-next' | 'tab-previous' | `tab.${1 | 2 | 3 | 4 | 5}` | 'close-subtree' | 'confirm-close' | 'sort' | 'scope' | 'profile' | 'residents' | 'preview' | 'search' | 'revive-now' | 'focus' | 'focus-background';
@@ -53,6 +53,7 @@ export const BINDING_CATALOG = Object.freeze([
53
53
  ...menuEntry('metadata', 'Show metadata', 'Show the focused node’s metadata dossier.', ['m'], ['tmux.menu.show']),
54
54
  ...menuEntry('context', 'Browse context', 'Browse the focused node context.', ['c'], ['tmux.menu.show']),
55
55
  ...menuEntry('providers', 'Open settings', 'Open the crouter settings panel.', [','], ['tmux.menu']),
56
+ ...menuEntry('plugins', 'Browse plugins', 'Open the crouter plugin browser.', ['k'], ['tmux.menu']),
56
57
  ...menuEntry('graph', 'Canvas graph', 'Open the canvas graph.', ['g'], ['tmux.menu']),
57
58
  ...menuEntry('focus-manager', 'Go to manager', 'Focus this node’s manager.', ['h'], ['tmux.menu']),
58
59
  ...menuEntry('copy-id', 'Copy node id', 'Copy the focused node id to the system clipboard.', ['y'], ['tmux.menu']),
@@ -8,7 +8,7 @@ export interface ParsedSkillQualifier {
8
8
  segments: string[];
9
9
  }
10
10
  export declare function parseSkillQualifier(raw: string): ParsedSkillQualifier;
11
- export declare function listInstalledMarketplaces(scope: Scope): InstalledMarketplace[];
11
+ export declare function listInstalledMarketplaces(scope: Exclude<Scope, 'builtin'>): InstalledMarketplace[];
12
12
  export declare function listAllMarketplaces(): InstalledMarketplace[];
13
- export declare function findMarketplaceByName(name: string, scope?: Scope): InstalledMarketplace | null;
13
+ export declare function findMarketplaceByName(name: string, scope?: Exclude<Scope, 'builtin'>): InstalledMarketplace | null;
14
14
  export declare function scopeRootsLabel(): string;
@@ -1,13 +1,11 @@
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';
5
4
  import type { SubscriptionGeneration } from './rebind.js';
6
5
  import type { FaultRetry } from './fault-retry.js';
7
6
  type EventProjectionDeps = {
8
7
  registry: BrokerClientRegistry;
9
8
  faultRetry: FaultRetry;
10
- ledger: MessageIdLedger;
11
9
  installedGeneration: () => SubscriptionGeneration | undefined;
12
10
  currentSession: () => BrokerSession;
13
11
  notifyTurnAccepted: () => void;
@@ -33,12 +31,7 @@ export declare class EventProjection {
33
31
  reset(): void;
34
32
  dispose(): void;
35
33
  knownStreamWait(deadlineMs: number): () => 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;
34
+ relayEvent(event: BrokerToClient, generation: SubscriptionGeneration): void;
42
35
  private clearPendingUpdate;
43
36
  private flushPendingUpdate;
44
37
  private invalidatePendingBroadcasts;
@@ -1,4 +1,3 @@
1
- import { contentText } from '@earendil-works/pi-ai';
2
1
  import { readConfig } from '../../config.js';
3
2
  import { emitEvent } from '../../events/emit.js';
4
3
  import { releaseKnownStreamWait, resolveStreamWatchdogMs, StreamWatchdog } from '../stream-watchdog.js';
@@ -65,30 +64,7 @@ export class EventProjection {
65
64
  releaseKnownStreamWait(this.streamWatchdog, generation.id, waitingSession.isStreaming, generation.executingTools.size > 0);
66
65
  };
67
66
  }
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);
67
+ relayEvent(event, generation) {
92
68
  this.deps.faultRetry.onEvent(event);
93
69
  const type = event.type;
94
70
  if (type === 'message_start' && event.message.role === 'user') {
@@ -6,7 +6,6 @@ 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';
10
9
  import type { RebindDriver } from './rebind.js';
11
10
  export declare const REVIEW_BOUNDARY_NAVIGATION_CODE = "review_boundary_navigation";
12
11
  export declare class ReviewBoundaryNavigationError extends Error {
@@ -181,7 +180,6 @@ export type FrameDispatchDeps = {
181
180
  registry: BrokerClientRegistry;
182
181
  toolGroups: ToolGroupTracker;
183
182
  projection: EventProjection;
184
- ledger: MessageIdLedger;
185
183
  rebind: RebindDriver;
186
184
  reviewCompanion: boolean;
187
185
  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, ledger, rebind, reviewCompanion, pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit, persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec, formatExactModelSpec, resolveLaunchModel, } = deps;
282
+ const { nodeId, cfg, registry, toolGroups, projection, 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,14 +301,9 @@ 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);
308
304
  trackPromptDispatch(operationIdContext.fresh(() => currentSession()
309
305
  .prompt(prompt)
310
306
  .catch((err) => {
311
- ledger.dropIntent(kickoffMessageId);
312
307
  emitEvent({
313
308
  level: 'error',
314
309
  event: 'broker.prompt.first_failed',
@@ -397,7 +392,6 @@ export function createFrameDispatchContext(deps) {
397
392
  // is producing now, then start this as a new turn. A normal mid-stream
398
393
  // prompt deliberately steers into pi's queue; the whip must not.
399
394
  if (frame.type === 'prompt' && frame.interrupt === true) {
400
- const whipMessageId = frame.message_id;
401
395
  return (async () => {
402
396
  if (currentSession().isBashRunning) {
403
397
  currentSession().abortBash();
@@ -420,7 +414,6 @@ export function createFrameDispatchContext(deps) {
420
414
  type: 'prompt',
421
415
  text: frame.text,
422
416
  ...(frame.images !== undefined ? { images: frame.images } : {}),
423
- ...(whipMessageId !== undefined ? { message_id: whipMessageId } : {}),
424
417
  });
425
418
  })().catch((err) => {
426
419
  relayError(err);
@@ -451,19 +444,6 @@ export function createFrameDispatchContext(deps) {
451
444
  const dispatch = () => {
452
445
  const route = resolveEngineRoute(frame, currentSession().isStreaming);
453
446
  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
- }
467
447
  if (guidance === null) {
468
448
  if (route.call === 'prompt') {
469
449
  // A routed prompt STARTS a turn — including an idle follow_up
@@ -472,12 +452,12 @@ export function createFrameDispatchContext(deps) {
472
452
  // steer.
473
453
  return settle(trackPromptDispatch(operationIdContext.fresh(() => currentSession()
474
454
  .prompt(frame.text, route.options)
475
- .catch(noteFailure))));
455
+ .catch(relayError))));
476
456
  }
477
457
  // C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
478
458
  return settle(currentSession()
479
459
  .followUp(frame.text, frame.images)
480
- .catch(noteFailure));
460
+ .catch(relayError));
481
461
  }
482
462
  const deliverAs = chooseGuidanceDeliveryMode(frame.type, route.call, currentSession().isStreaming);
483
463
  if (route.call === 'prompt') {
@@ -489,18 +469,18 @@ export function createFrameDispatchContext(deps) {
489
469
  // ATOMICALLY clears its own pending entry if prompt() settles
490
470
  // without ever reaching that drain (an early throw/return leaves
491
471
  // nothing stranded to leak into a later, unrelated turn).
492
- return settle(trackPromptDispatch(promptWithNextTurnGuidance(currentSession(), guidance, () => operationIdContext.fresh(() => currentSession().prompt(frame.text, route.options))).catch(noteFailure)));
472
+ return settle(trackPromptDispatch(promptWithNextTurnGuidance(currentSession(), guidance, () => operationIdContext.fresh(() => currentSession().prompt(frame.text, route.options))).catch(relayError)));
493
473
  }
494
474
  return settle(trackPromptDispatch(operationIdContext.fresh(() => currentSession()
495
475
  .prompt(frame.text, route.options)
496
- .catch(noteFailure)
476
+ .catch(relayError)
497
477
  .then(() => sendGuidance(guidance, deliverAs)))));
498
478
  }
499
479
  // deliverAs is always 'followUp' here per chooseGuidanceDeliveryMode's
500
480
  // contract (a followUp routeCall never yields 'nextTurn').
501
481
  return settle(currentSession()
502
482
  .followUp(frame.text, frame.images)
503
- .catch(noteFailure)
483
+ .catch(relayError)
504
484
  .then(() => sendGuidance(guidance, deliverAs)));
505
485
  };
506
486
  // /login acknowledges before its asynchronous local refresh completes.
@@ -530,8 +510,6 @@ export function createFrameDispatchContext(deps) {
530
510
  // and executes it immediately even while streaming. It also expands a
531
511
  // matching skill/template before applying the same steer behavior.
532
512
  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).
535
513
  void currentSession()
536
514
  .prompt(frame.text, {
537
515
  images: frame.images,
@@ -540,25 +518,17 @@ export function createFrameDispatchContext(deps) {
540
518
  .catch(relayError);
541
519
  break;
542
520
  }
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
- };
551
521
  const guidance = resolveFrameGuidance(frame.text);
552
522
  // C1: pi's steer() takes images as a POSITIONAL 2nd arg (`steer(text, images?)`).
553
523
  if (guidance === null) {
554
524
  void currentSession()
555
525
  .steer(frame.text, frame.images)
556
- .catch(steerFailure);
526
+ .catch(relayError);
557
527
  break;
558
528
  }
559
529
  void currentSession()
560
530
  .steer(frame.text, frame.images)
561
- .catch(steerFailure)
531
+ .catch(relayError)
562
532
  .then(() => sendGuidance(guidance, 'steer'));
563
533
  break;
564
534
  }
@@ -836,9 +806,6 @@ export function createFrameDispatchContext(deps) {
836
806
  type: 'prompt',
837
807
  text: frame.text,
838
808
  ...(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 } : {}),
842
809
  });
843
810
  if (via === 'steer' || outcome === undefined) {
844
811
  turnAcceptedWaiters.delete(ackAccepted);
@@ -78,27 +78,16 @@ 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;
88
81
  }
89
82
  export interface SteerFrame {
90
83
  type: 'steer';
91
84
  text: string;
92
85
  images?: ImageContent[];
93
- /** Sender-minted message identity — see {@link PromptFrame.message_id}. */
94
- message_id?: string;
95
86
  }
96
87
  export interface FollowUpFrame {
97
88
  type: 'follow_up';
98
89
  text: string;
99
90
  images?: ImageContent[];
100
- /** Sender-minted message identity — see {@link PromptFrame.message_id}. */
101
- message_id?: string;
102
91
  }
103
92
  export interface AbortFrame {
104
93
  type: 'abort';
@@ -122,9 +111,6 @@ export interface DeliverFrame {
122
111
  id: string;
123
112
  text: string;
124
113
  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;
128
114
  }
129
115
  /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
130
116
  * which runs the command, records a `bashExecution` message in context, and
@@ -365,38 +351,7 @@ export interface WelcomeFrame {
365
351
  * can construct an AuthStorage/ModelRegistry pointing at the SAME auth.json.
366
352
  * crtr surface attach is tmux-local only: broker + viewer share a filesystem. */
367
353
  agentDir?: string;
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
- };
354
+ }
400
355
  /** Broadcast to EVERY client after a successful `set_model`/`cycle_model`. pi
401
356
  * emits no AgentSessionEvent for a model switch, so without this the new model
402
357
  * reaches no viewer at all (the requester gets only a bare ack) and footers
@@ -44,7 +44,6 @@ 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';
48
47
  import { createFrameDispatchContext, handleFrame } from './broker/frame-dispatch.js';
49
48
  import { RebindDriver } from './broker/rebind.js';
50
49
  import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
@@ -285,9 +284,6 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
285
284
  let projection;
286
285
  let notifyTurnAccepted = () => { };
287
286
  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();
291
287
  rebind = new RebindDriver({
292
288
  nodeId, cfg, session, services, runtime, registry, toolGroups,
293
289
  uiContext: () => uiContext,
@@ -297,7 +293,6 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
297
293
  disposeAndExit: (reason, status) => disposeAndExit(reason, status),
298
294
  onSessionRebind: () => {
299
295
  resetFrameState();
300
- messageLedger.reset();
301
296
  },
302
297
  reWelcomeAll: () => reWelcomeAll(),
303
298
  });
@@ -339,7 +334,6 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
339
334
  projection = new EventProjection({
340
335
  registry,
341
336
  faultRetry,
342
- ledger: messageLedger,
343
337
  installedGeneration: () => rebind.installedGeneration(),
344
338
  currentSession: () => rebind.session(),
345
339
  notifyTurnAccepted: () => notifyTurnAccepted(),
@@ -396,7 +390,6 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
396
390
  role: client.role,
397
391
  pending_dialog: first !== undefined ? first.request : null,
398
392
  agentDir: getAgentDir(),
399
- recentUserMessages: messageLedger.recentUserMessages(),
400
393
  }, true);
401
394
  // No display_* replay: the retained extension state rides IN the snapshot
402
395
  // (`snapshot.display`), so catch-up is one atomic frame. A viewer attaching
@@ -509,7 +502,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
509
502
  rebind.setRebindSession();
510
503
  await rebind.enqueueRebind(session, services);
511
504
  const frameDispatch = createFrameDispatchContext({
512
- nodeId, cfg, registry, toolGroups, projection, ledger: messageLedger, rebind, reviewCompanion,
505
+ nodeId, cfg, registry, toolGroups, projection, rebind, reviewCompanion,
513
506
  pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit,
514
507
  persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec,
515
508
  formatExactModelSpec, resolveLaunchModel,
@@ -6,11 +6,8 @@ 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. `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>;
9
+ * routed; resolves with the route taken. */
10
+ export declare function deliverLive(nodeId: string, text: string): Promise<LiveDeliverRoute>;
14
11
  /** Abort whatever a LIVE node's engine is doing (turn or `!` bash). Resolves
15
12
  * once the broker has PROCESSED the abort — which, on the single frame loop,
16
13
  * also implies every earlier-accepted deliver was already routed (so a
@@ -14,17 +14,14 @@
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. `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) {
17
+ * routed; resolves with the route taken. */
18
+ export function deliverLive(nodeId, text) {
22
19
  return oneShotControllerRequest({
23
20
  nodeId,
24
21
  what: 'the interactive delivery',
25
22
  ackFor: 'deliver',
26
23
  correlated: true,
27
- frame: (id) => ({ type: 'deliver', id, text, ...(messageId !== undefined ? { message_id: messageId } : {}) }),
24
+ frame: (id) => ({ type: 'deliver', id, text }),
28
25
  result: (detail) => (detail === 'steer' ? 'steer' : 'prompt'),
29
26
  });
30
27
  }
@@ -33,6 +33,7 @@ const LEAF_MENU_ACTIONS = {
33
33
  // The settings page lays out a grouped rail beside a content panel; the
34
34
  // default dialog-sized popup clipped both the rail and the models matrix.
35
35
  'crtr.tmux.menu.providers': { description: 'settings', action: { kind: 'popup', run: 'sys settings', size: { w: '90%', h: '85%' } } },
36
+ 'crtr.tmux.menu.plugins': { description: 'plugins', action: { kind: 'popup', run: 'pkg browse', size: { w: '90%', h: '85%' } } },
36
37
  'crtr.tmux.menu.graph': { description: 'canvas graph', action: { kind: 'keys', keys: '/graph' } },
37
38
  'crtr.tmux.menu.focus-manager': { description: 'go to manager', action: { kind: 'command', run: 'surface node focus {manager}' } },
38
39
  'crtr.tmux.menu.copy-id': { description: 'copy node id', action: { kind: 'command', run: 'surface node id --pane {pane}' } },
@@ -301,6 +302,7 @@ function menuItems(bindings, entries) {
301
302
  ]));
302
303
  addGroup('System', boundItems([
303
304
  'crtr.tmux.menu.providers',
305
+ 'crtr.tmux.menu.plugins',
304
306
  'crtr.tmux.menu.issues',
305
307
  ]));
306
308
  const prefixGestures = bindings.gestures('crtr.tmux.menu.open');
@@ -24,13 +24,10 @@ 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 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. */
27
+ /** Queued steering + follow-up messages, from `queue_update`. A fresh broker
28
+ * with an empty queue emits no `queue_update`, so every welcome resets this
29
+ * rather than leaving a dead broker's rows on screen. */
32
30
  queued: Array<{
33
- id?: string;
34
31
  text: string;
35
32
  }>;
36
33
  display: DisplayState;
@@ -132,11 +132,7 @@ 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
- // 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)];
135
+ const queued = [...frame.steering, ...frame.followUp].map((text) => ({ text }));
140
136
  const next = patchEngine(state, { pendingMessageCount: queued.length });
141
137
  return { ...next, queued };
142
138
  }