@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
@@ -0,0 +1,25 @@
1
+ import { ProcessTerminal, TUI, type Component, type Focusable } from "@earendil-works/pi-tui";
2
+ import { readRawScopeConfig } from "../config.js";
3
+ import { type BindingId, type BindingResolution } from "../keybindings/index.js";
4
+ /** A full-screen page: a focusable TUI component that cleans up after itself. */
5
+ export type TuiPageSurface = Component & Focusable & {
6
+ dispose(): void;
7
+ };
8
+ export interface TuiPageContext {
9
+ tui: TUI;
10
+ terminal: ProcessTerminal;
11
+ bindings: BindingResolution<BindingId>;
12
+ rawUserConfig: ReturnType<typeof readRawScopeConfig>;
13
+ }
14
+ export interface TuiPageControl<Result> {
15
+ /** Close the page with a result. */
16
+ submit(result: Result): void;
17
+ /** Close the page with no result. */
18
+ cancel(): void;
19
+ }
20
+ /** Mount one page as the TUI's only child and resolve when it closes.
21
+ *
22
+ * `interceptCancel` is a process-level escape hatch for surfaces that must be
23
+ * abandonable while a nested component holds the keyboard; it runs before the
24
+ * page sees the input and closes the page when it returns true. */
25
+ export declare function runTuiPage<Result>(createSurface: (context: TuiPageContext, control: TuiPageControl<Result>) => TuiPageSurface, interceptCancel?: (data: string, bindings: BindingResolution<BindingId>) => boolean): Promise<Result | undefined>;
@@ -0,0 +1,65 @@
1
+ // The process-level terminal host for crouter's full-screen TUI pages —
2
+ // `crtr sys settings`, the setup wizard, and `crtr pkg browse`.
3
+ //
4
+ // It owns exactly the concerns every page shares: theme, resolved keybindings,
5
+ // the raw user config those pages edit, the ProcessTerminal/TUI pair, and a
6
+ // restore path that survives an exception, a signal, and process exit. Anything
7
+ // a single page needs beyond that — pi's model runtime, a canvas client — the
8
+ // page's own runner builds and closes over before calling in here.
9
+ import { ProcessTerminal, TUI, } from "@earendil-works/pi-tui";
10
+ import { applyTheme } from "../../clients/attach/config.js";
11
+ import { readRawScopeConfig } from "../config.js";
12
+ import { resolveKeybindings, } from "../keybindings/index.js";
13
+ /** Mount one page as the TUI's only child and resolve when it closes.
14
+ *
15
+ * `interceptCancel` is a process-level escape hatch for surfaces that must be
16
+ * abandonable while a nested component holds the keyboard; it runs before the
17
+ * page sees the input and closes the page when it returns true. */
18
+ export async function runTuiPage(createSurface, interceptCancel) {
19
+ applyTheme({ cwd: process.cwd() });
20
+ const rawUserConfig = readRawScopeConfig("user");
21
+ const bindings = resolveKeybindings(rawUserConfig?.keybindings);
22
+ const terminal = new ProcessTerminal();
23
+ const tui = new TUI(terminal, true);
24
+ return new Promise((resolve, reject) => {
25
+ let surface;
26
+ let restored = false;
27
+ const restore = () => {
28
+ if (restored)
29
+ return;
30
+ restored = true;
31
+ surface?.dispose();
32
+ try {
33
+ tui.stop();
34
+ }
35
+ catch {
36
+ /* terminal restore */
37
+ }
38
+ };
39
+ const finish = (result) => {
40
+ restore();
41
+ resolve(result);
42
+ };
43
+ try {
44
+ surface = createSurface({ tui, terminal, bindings, rawUserConfig }, { submit: finish, cancel: () => finish(undefined) });
45
+ tui.addChild(surface);
46
+ tui.setFocus(surface);
47
+ process.once("exit", restore);
48
+ for (const signal of ["SIGTERM", "SIGHUP", "SIGINT"])
49
+ process.once(signal, () => finish(undefined));
50
+ if (interceptCancel !== undefined) {
51
+ tui.addInputListener((data) => {
52
+ if (!interceptCancel(data, bindings))
53
+ return undefined;
54
+ finish(undefined);
55
+ return { consume: true };
56
+ });
57
+ }
58
+ tui.start();
59
+ }
60
+ catch (error) {
61
+ restore();
62
+ reject(error);
63
+ }
64
+ });
65
+ }
@@ -59,12 +59,6 @@ function parseSendBody(body) {
59
59
  if (delivery !== undefined && delivery !== 'interactive') {
60
60
  throw usage(`invalid delivery: ${String(delivery)} (expected interactive)`);
61
61
  }
62
- const messageId = b['message_id'];
63
- if (messageId !== undefined && typeof messageId !== 'string') {
64
- throw usage('message_id must be a string');
65
- }
66
- if (typeof messageId === 'string' && messageId.trim() !== '')
67
- out.message_id = messageId;
68
62
  if (delivery === 'interactive') {
69
63
  // Interactive delivery is a live-conversation send: a plain immediate body,
70
64
  // nothing that only makes sense on the durable path.
@@ -164,7 +158,7 @@ async function handleMessage(ctx) {
164
158
  // straight there (append + revive, watcher delivers post-boot). ---
165
159
  if (req.delivery === 'interactive' && isBrokerLive(meta)) {
166
160
  try {
167
- await deliverLive(id, req.body, req.message_id);
161
+ await deliverLive(id, req.body);
168
162
  const body = {
169
163
  node_id: id,
170
164
  delivered: true,
@@ -28,6 +28,7 @@ import { spawn } from 'node:child_process';
28
28
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
29
29
  import { homedir } from 'node:os';
30
30
  import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId } from '../core/bash-jobs.js';
31
+ import { Type } from 'typebox';
31
32
  import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
32
33
  // ---------------------------------------------------------------------------
33
34
  // Deadline — 5 minutes by default, overridable via CRTR_BASH_VALVE_MS
@@ -342,6 +343,49 @@ export function createValveOperations(nodeId, contextDir) {
342
343
  // ---------------------------------------------------------------------------
343
344
  // Extension
344
345
  // ---------------------------------------------------------------------------
346
+ // ---------------------------------------------------------------------------
347
+ // `purpose` — the one line a person reads while a command runs
348
+ //
349
+ // A deliberate deviation from taking pi's definition verbatim, and the only one
350
+ // here. Everywhere crtr's output reaches a non-technical audience, the host has
351
+ // to render SOMETHING per step, and without this field its only material is the
352
+ // command — which yields "Running command" for an entire turn. The model is the
353
+ // only party that knows why it is running this, so it says so, in one clause,
354
+ // before the command streams.
355
+ //
356
+ // Declared FIRST so it arrives ahead of `command` in the argument stream: a host
357
+ // parsing partial JSON can show the label while the command is still being
358
+ // written, rather than after the call is complete and about to finish.
359
+ //
360
+ // The execute path ignores it entirely — this is a labelling channel, not an
361
+ // input. Optional, so a model that omits it behaves exactly as before.
362
+ //
363
+ // Upstream ask: pi's own bash tool should carry this (Claude Code's bash tool
364
+ // has shipped an equivalent `description` field for years). Until it does, the
365
+ // field is re-declared here on top of pi's schema rather than forked into it.
366
+ // ---------------------------------------------------------------------------
367
+ const PURPOSE_DESCRIPTION = [
368
+ 'One short clause naming what this step is FOR, in the words of the person you',
369
+ 'are working for — "Reading the Acme thread", not "Fetching emails" and never',
370
+ '"Running GMAIL_FETCH_EMAILS". A person reads this while the command runs; it',
371
+ 'is the only thing they see of this step. Under about eight words. Omit it for',
372
+ 'steps that only touch your own scratch work.',
373
+ ].join(' ');
374
+ /**
375
+ * Prepend an optional `purpose` to pi's bash schema, carrying pi's own property
376
+ * definitions through verbatim — so a future upstream change to the `command`
377
+ * or `timeout` wording still reaches the model, and this file owns exactly one
378
+ * field.
379
+ */
380
+ function withPurpose(definition) {
381
+ return {
382
+ ...definition,
383
+ parameters: Type.Object({
384
+ purpose: Type.Optional(Type.String({ description: PURPOSE_DESCRIPTION })),
385
+ ...definition.parameters.properties,
386
+ }),
387
+ };
388
+ }
345
389
  export default function (pi) {
346
390
  const nodeId = process.env['CRTR_NODE_ID'];
347
391
  if (nodeId === undefined || nodeId.trim() === '')
@@ -356,6 +400,6 @@ export default function (pi) {
356
400
  // every session_start is idempotent (last registration wins).
357
401
  pi.on('session_start', (_event, ctx) => {
358
402
  const operations = createValveOperations(nodeId, contextDir);
359
- pi.registerTool(createBashToolDefinition(ctx.cwd, { operations }));
403
+ pi.registerTool(withPurpose(createBashToolDefinition(ctx.cwd, { operations })));
360
404
  });
361
405
  }
package/dist/types.d.ts CHANGED
@@ -301,7 +301,10 @@ export interface InstalledPlugin {
301
301
  }
302
302
  export interface InstalledMarketplace {
303
303
  name: string;
304
- scope: Scope;
304
+ /** Never `builtin`: marketplaces are registered by the user into the user or
305
+ * project scope, and `listInstalledMarketplaces` has no builtin directory to
306
+ * read. `builtin` describes plugins that ship with crtr, not their source. */
307
+ scope: Exclude<Scope, 'builtin'>;
305
308
  root: string;
306
309
  manifest: MarketplaceManifest;
307
310
  url: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.184",
3
+ "version": "0.3.186",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.184",
3
+ "version": "0.3.186",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.184",
9
+ "version": "0.3.186",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -1,53 +0,0 @@
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
- }
@@ -1,143 +0,0 @@
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
- }
File without changes