@crouton-kit/crouter 0.3.70 → 0.3.78

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 (147) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +622 -724
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +361 -90
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +3 -3
  23. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  24. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  25. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  26. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  28. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  29. package/dist/clients/attach/attach-cmd.js +738 -735
  30. package/dist/clients/attach/canvas-panels.js +2 -3
  31. package/dist/clients/attach/chat-view.d.ts +7 -8
  32. package/dist/clients/attach/chat-view.js +119 -79
  33. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  34. package/dist/clients/attach/edit-diff-render.js +263 -0
  35. package/dist/clients/attach/input-controller.d.ts +28 -2
  36. package/dist/clients/attach/input-controller.js +38 -5
  37. package/dist/clients/attach/mermaid-render.js +5 -2
  38. package/dist/clients/attach/pickers.d.ts +8 -7
  39. package/dist/clients/attach/pickers.js +11 -17
  40. package/dist/clients/attach/slash-commands.d.ts +9 -0
  41. package/dist/clients/attach/slash-commands.js +127 -7
  42. package/dist/clients/attach/titled-editor.d.ts +12 -1
  43. package/dist/clients/attach/titled-editor.js +103 -8
  44. package/dist/commands/canvas-browse.js +2 -2
  45. package/dist/commands/memory/lint.js +39 -5
  46. package/dist/commands/memory/write.js +1 -0
  47. package/dist/commands/node.js +9 -2
  48. package/dist/commands/surface-tmux-spread.js +1 -3
  49. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  50. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  51. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  52. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  53. package/dist/commands/sys/config.js +18 -21
  54. package/dist/commands/sys/setup-core.d.ts +12 -1
  55. package/dist/commands/sys/setup-core.js +23 -3
  56. package/dist/commands/sys/setup.js +42 -12
  57. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  58. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  60. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  61. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  62. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  63. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  64. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  65. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  66. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  67. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  68. package/dist/core/canvas/browse/app.js +164 -5
  69. package/dist/core/canvas/browse/model.d.ts +10 -6
  70. package/dist/core/canvas/browse/model.js +20 -12
  71. package/dist/core/canvas/browse/pins.d.ts +4 -0
  72. package/dist/core/canvas/browse/pins.js +29 -0
  73. package/dist/core/canvas/browse/render.d.ts +13 -1
  74. package/dist/core/canvas/browse/render.js +72 -15
  75. package/dist/core/canvas/nav-model.js +5 -11
  76. package/dist/core/canvas/paths.d.ts +5 -0
  77. package/dist/core/canvas/paths.js +7 -0
  78. package/dist/core/config.d.ts +53 -2
  79. package/dist/core/config.js +228 -110
  80. package/dist/core/fault-classifier.js +1 -1
  81. package/dist/core/fs-utils.d.ts +1 -0
  82. package/dist/core/fs-utils.js +15 -1
  83. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  84. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  85. package/dist/core/keybindings/catalog.d.ts +14 -0
  86. package/dist/core/keybindings/catalog.js +256 -0
  87. package/dist/core/keybindings/index.d.ts +5 -0
  88. package/dist/core/keybindings/index.js +4 -0
  89. package/dist/core/keybindings/match.d.ts +29 -0
  90. package/dist/core/keybindings/match.js +99 -0
  91. package/dist/core/keybindings/persistence.d.ts +14 -0
  92. package/dist/core/keybindings/persistence.js +31 -0
  93. package/dist/core/keybindings/resolve.d.ts +14 -0
  94. package/dist/core/keybindings/resolve.js +305 -0
  95. package/dist/core/keybindings/types.d.ts +37 -0
  96. package/dist/core/keybindings/types.js +1 -0
  97. package/dist/core/memory-resolver.d.ts +1 -1
  98. package/dist/core/memory-resolver.js +10 -3
  99. package/dist/core/predicate.d.ts +5 -3
  100. package/dist/core/predicate.js +5 -3
  101. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  102. package/dist/core/runtime/broker.js +163 -14
  103. package/dist/core/runtime/launch.d.ts +39 -6
  104. package/dist/core/runtime/launch.js +78 -20
  105. package/dist/core/runtime/naming.js +3 -3
  106. package/dist/core/runtime/pi-cli.d.ts +6 -0
  107. package/dist/core/runtime/pi-cli.js +16 -2
  108. package/dist/core/runtime/placement.d.ts +2 -2
  109. package/dist/core/runtime/placement.js +4 -1
  110. package/dist/core/runtime/promote.js +4 -0
  111. package/dist/core/runtime/recap.d.ts +3 -3
  112. package/dist/core/runtime/recap.js +47 -44
  113. package/dist/core/runtime/recycle.js +6 -1
  114. package/dist/core/runtime/reset.js +5 -0
  115. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  116. package/dist/core/runtime/session-list-cache.js +270 -0
  117. package/dist/core/runtime/spawn.js +51 -32
  118. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  119. package/dist/core/runtime/stream-watchdog.js +75 -0
  120. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  121. package/dist/core/runtime/tmux-chrome.js +2 -2
  122. package/dist/core/runtime/tmux.d.ts +18 -15
  123. package/dist/core/runtime/tmux.js +216 -110
  124. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  125. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  126. package/dist/core/tui/host.d.ts +30 -1
  127. package/dist/core/tui/host.js +148 -34
  128. package/dist/core/view/contract.d.ts +17 -3
  129. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  130. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  131. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  132. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  133. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  134. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  135. package/dist/pi-extensions/canvas-recap.js +38 -37
  136. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  137. package/dist/pi-extensions/canvas-stophook.js +33 -4
  138. package/dist/types.d.ts +3 -13
  139. package/dist/types.js +2 -26
  140. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  141. package/dist/web-client/index.html +1 -1
  142. package/dist/web-client/sw.js +1 -1
  143. package/package.json +3 -3
  144. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  145. package/dist/pi-extensions/canvas-nav.js +0 -640
  146. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  147. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -48,7 +48,10 @@ import { isPidAlive } from '../canvas/pid.js';
48
48
  // re-exported for the same reason — callers building the seam's expected
49
49
  // bundled-pi command string (e.g. the M1 seam test) need the identical quoting
50
50
  // piCommand uses internally, without importing tmux.ts directly.
51
- export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installMenuBinding, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
51
+ export { splitWindow, breakPane, selectLayout, selectPane, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installTmuxBindings,
52
+ // Compile seam for attach-cmd.ts, which still calls installMenuBinding();
53
+ // deleted when the attach unit imports installTmuxBindings directly.
54
+ installTmuxBindings as installMenuBinding, setPaneOption, getPaneOption, piCommand, shellQuote, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
52
55
  // ---------------------------------------------------------------------------
53
56
  // Viewer registry reads — COMPOSE over the canvas focuses table (§2.3/§4).
54
57
  //
@@ -55,6 +55,10 @@ export function promote(nodeId, opts = {}) {
55
55
  // across the polymorph (the persona default is recomputed fresh for
56
56
  // targetKind).
57
57
  model: targetModel,
58
+ // Resolve kind/ladder config against the NODE's own cwd + profile, not
59
+ // this process's ambient scope.
60
+ cwd: node.cwd,
61
+ profileId: node.profile_id,
58
62
  });
59
63
  // Seed a barebones roadmap scaffold if absent so the file exists for a
60
64
  // refresh. Pre-fill its Goal from the node's goal doc when present (set at
@@ -1,8 +1,8 @@
1
- /** Ask pi headlessly for a three-fragment recap of `conversation`, async. Invokes
2
- * `onRecap` with the parsed fragment lines on success, or never (silent) on any
1
+ /** Ask pi headlessly for a concise briefing of `conversation`, async. Invokes
2
+ * `onRecap` with the parsed string on success, or never (silent) on any
3
3
  * failure — non-zero exit, timeout, empty/garbled output. Owns the subprocess
4
4
  * mechanics — crucially it hands pi an immediate stdin EOF: `pi -p` reads stdin,
5
5
  * and execFile's default stdin is an OPEN pipe that never closes, so without
6
6
  * this pi blocks waiting for EOF and the call exits non-zero (the same gotcha
7
7
  * naming.ts documents). */
8
- export declare function generateRecap(conversation: string, onRecap: (lines: string[]) => void): void;
8
+ export declare function generateRecap(conversation: string, onRecap: (briefing: string) => void, liveModel?: string): void;
@@ -1,9 +1,10 @@
1
- // Inactivity recap — turn a dormant node's conversation into a three-fragment
2
- // "where it left off" card (goal / doing-now / next) for the recap widget.
1
+ // Inactivity recap — turn a dormant node's conversation into one concise
2
+ // returning-developer briefing for the recap widget.
3
3
  //
4
4
  // One entry point: generateRecap — async (execFile, non-blocking), mirroring
5
- // naming.ts's headless-`pi -p` pattern exactly. It runs Haiku over the literal
6
- // back-and-forth (the caller concatenates user+assistant text) with a stripped-
5
+ // naming.ts's headless-`pi -p` pattern exactly. It runs the live conversation's
6
+ // working model over the literal back-and-forth (the caller concatenates user and
7
+ // assistant text) with a stripped-
7
8
  // down pi invocation (no tools/session/context/extensions/skills), so it's fast
8
9
  // and side-effect free and can never recurse into another spawn.
9
10
  //
@@ -15,37 +16,48 @@
15
16
  // Plain TS-with-types — no imports from @earendil-works/* so it compiles inside
16
17
  // crouter's own tsc build without a dep on the pi packages.
17
18
  import { execFile } from 'node:child_process';
18
- import { resolveBundledPiCliPath } from './pi-cli.js';
19
+ import { bundledPiSubprocessEnv, resolveBundledPiCliPath } from './pi-cli.js';
19
20
  /** Wall-clock budget for the headless pi call before we give up (no recap). */
20
21
  const RECAP_TIMEOUT_MS = 25_000;
21
- /** Default recap model Haiku. A recap is a one-shot summarization, so we pin
22
- * the small/fast model rather than the node's heavyweight default. Override
23
- * with CRTR_RECAP_MODEL (mirrors CRTR_NAME_MODEL). */
22
+ /** Fallback model when the live session has not resolved one yet. Ordinarily the
23
+ * caller passes the provider/model that just completed the conversation's last
24
+ * turn, avoiding a dead hard-pinned provider. CRTR_RECAP_MODEL overrides both. */
24
25
  const DEFAULT_RECAP_MODEL = 'anthropic/claude-haiku-4-5';
25
- const RECAP_SYSTEM_PROMPT = 'You write a terse "where we left off" recap of a coding-agent work session, ' +
26
- 'read at a glance by someone returning to a paused conversation. You are given ' +
27
- 'the literal back-and-forth between the user and the agent. Output EXACTLY three ' +
28
- 'lines, nothing else no labels, no numbering, no bullets, no punctuation at the ' +
29
- 'end of a line, no prose:\n' +
30
- ' Line 1: the GOAL — what this session is ultimately trying to achieve.\n' +
31
- ' Line 2: DOING NOW what was just happening / the active piece of work.\n' +
32
- ' Line 3: NEXT the immediate next step.\n' +
33
- 'Each line is a short FRAGMENT, not a sentence (e.g. `wire up recap widget above editor`, ' +
34
- 'not `We are wiring up the recap widget.`). Keep each under ~60 characters. ' +
35
- 'If the session has barely started or you cannot tell, still give your best three ' +
36
- 'fragments from whatever text exists. Output JUST the three lines.';
26
+ const RECAP_SYSTEM_PROMPT = 'You brief a developer returning to a paused coding-agent conversation. They run ' +
27
+ 'many agent sessions in parallel and have lost this one\'s thread; the brief is ' +
28
+ 'pinned above their input box and must re-orient them in seconds, without ' +
29
+ 're-reading the transcript. You are given the literal user/agent back-and-forth ' +
30
+ '(tool output omitted, most recent last).\n' +
31
+ '\n' +
32
+ 'Output EXACTLY one concise plain-text briefing: no labels, numbering, bullets, ' +
33
+ 'or line breaks. In one or two sentences, explain the overall goal, what has ' +
34
+ 'actually happened, and the current next move or blocker. If the agent is ' +
35
+ 'waiting on the developer, make the needed answer clear. Be concrete: use the ' +
36
+ 'actual file names, commands, branches, option labels, and error messages from ' +
37
+ 'the conversation a developer re-orients on specifics, never abstractions. ' +
38
+ 'Weight the end of the conversation, never narrate ("the user asked…"), hedge, ' +
39
+ 'or pad. If the conversation has barely started, still emit your best briefing. ' +
40
+ 'Output JUST the briefing.';
37
41
  /** Put the conversation FIRST in a delimited block, then the instruction, so the
38
42
  * model reads the content before being told what to do. */
39
43
  function recapUserPrompt(conversation) {
40
- return `<conversation>\n${conversation}\n</conversation>\n\nWrite the three-line recap of the session above (goal / doing-now / next). Output JUST the three lines.`;
44
+ return `<conversation>\n${conversation}\n</conversation>\n\nWrite the concise returning-developer briefing for the conversation above. Output JUST the briefing.`;
41
45
  }
42
46
  /** The pi argv for a headless recap request. Stripped down (no tools, session,
43
47
  * context files, extensions, skills, templates, themes) so it's fast and
44
- * side-effect free. Pinned to Haiku with thinking off. Override the model with
45
- * CRTR_RECAP_MODEL. */
46
- function recapArgs(conversation) {
48
+ * side-effect free. Thinking stays off; model precedence is explicit env
49
+ * override → the conversation's live working model → the small fallback. */
50
+ function recapArgs(conversation, liveModel) {
47
51
  const override = process.env['CRTR_RECAP_MODEL'];
48
- const model = override !== undefined && override.trim() !== '' ? override.trim() : DEFAULT_RECAP_MODEL;
52
+ // An explicit operator override wins. Otherwise prefer the live conversation's
53
+ // current model: it just completed a successful turn, while a hard-pinned
54
+ // provider (the old Haiku-only path) may be rate-limited and silently produce
55
+ // no brief. The small default remains for sessions with no resolved model yet.
56
+ const model = override !== undefined && override.trim() !== ''
57
+ ? override.trim()
58
+ : liveModel !== undefined && liveModel.trim() !== ''
59
+ ? liveModel.trim()
60
+ : DEFAULT_RECAP_MODEL;
49
61
  const argv = [
50
62
  '-p',
51
63
  '--no-session',
@@ -63,40 +75,31 @@ function recapArgs(conversation) {
63
75
  ];
64
76
  return argv;
65
77
  }
66
- /** Parse the model's raw stdout into up to three short fragment lines. Strips any
67
- * stray leading labels/bullets the model may have added despite instructions,
68
- * drops empties, and hard-caps line length so a runaway line can't blow up the
69
- * widget. Returns [] when nothing usable survives (→ no recap). */
78
+ /** Normalize model stdout to one display string. Newlines are whitespace rather
79
+ * than separate fields; the viewer wraps this string to its available width. */
70
80
  function parseRecap(stdout) {
71
- const LINE_CAP = 72;
72
- const lines = stdout
73
- .split('\n')
74
- .map((l) => l.trim())
75
- // Strip a leading `goal:` / `now:` / `next:` / `- ` / `1.` style prefix.
76
- .map((l) => l.replace(/^(?:[-*•]\s*)?(?:\d+[.)]\s*)?(?:goal|now|doing(?:[\s-]now)?|next)\s*[:\-]\s*/i, '').trim())
77
- .filter((l) => l !== '');
78
- return lines.slice(0, 3).map((l) => (l.length > LINE_CAP ? l.slice(0, LINE_CAP) : l));
81
+ return stdout.replace(/\s+/g, ' ').trim();
79
82
  }
80
- /** Ask pi headlessly for a three-fragment recap of `conversation`, async. Invokes
81
- * `onRecap` with the parsed fragment lines on success, or never (silent) on any
83
+ /** Ask pi headlessly for a concise briefing of `conversation`, async. Invokes
84
+ * `onRecap` with the parsed string on success, or never (silent) on any
82
85
  * failure — non-zero exit, timeout, empty/garbled output. Owns the subprocess
83
86
  * mechanics — crucially it hands pi an immediate stdin EOF: `pi -p` reads stdin,
84
87
  * and execFile's default stdin is an OPEN pipe that never closes, so without
85
88
  * this pi blocks waiting for EOF and the call exits non-zero (the same gotcha
86
89
  * naming.ts documents). */
87
- export function generateRecap(conversation, onRecap) {
90
+ export function generateRecap(conversation, onRecap, liveModel) {
88
91
  const body = (conversation ?? '').trim();
89
92
  if (body === '')
90
93
  return;
91
94
  try {
92
- const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS }, (err, stdout) => {
95
+ const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body, liveModel)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
93
96
  if (err || typeof stdout !== 'string')
94
97
  return; // silent — no recap
95
- const lines = parseRecap(stdout);
96
- if (lines.length === 0)
98
+ const briefing = parseRecap(stdout);
99
+ if (briefing === '')
97
100
  return;
98
101
  try {
99
- onRecap(lines);
102
+ onRecap(briefing);
100
103
  }
101
104
  catch { /* best-effort */ }
102
105
  });
@@ -97,7 +97,11 @@ export async function recycleNode(nodeId, callerPane) {
97
97
  ensureDaemon();
98
98
  }
99
99
  catch { /* daemon is best-effort */ }
100
- const { launch } = buildLaunchSpec('general', 'base', { lifecycle: 'resident', hasManager: false });
100
+ // Resolve against the RECYCLED node's cwd + profile (not this process's
101
+ // ambient scope) and carry the profile onto the fresh root — it is the same
102
+ // person's seat, so profile-scope defaults (e.g. a persisted /model default)
103
+ // must keep applying.
104
+ const { launch } = buildLaunchSpec('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
101
105
  const root = spawnNode({
102
106
  kind: 'general',
103
107
  mode: 'base',
@@ -105,6 +109,7 @@ export async function recycleNode(nodeId, callerPane) {
105
109
  cwd: meta.cwd,
106
110
  name: 'general',
107
111
  parent: null,
112
+ profile_id: meta.profile_id,
108
113
  launch,
109
114
  });
110
115
  // Hand the viewport to the fresh root: reuse M's focus row over the SAME pane
@@ -86,6 +86,10 @@ export async function relaunchRoot(oldId, deps = {}) {
86
86
  lifecycle: 'resident',
87
87
  hasManager: false,
88
88
  model: old.model_override ?? undefined,
89
+ // The OLD root's cwd + profile, not this process's ambient scope — the
90
+ // relaunch is the same seat, so profile-scope kind defaults keep applying.
91
+ cwd: old.cwd,
92
+ profileId: old.profile_id,
89
93
  });
90
94
  const newMeta = spawnNode({
91
95
  kind: old.kind,
@@ -94,6 +98,7 @@ export async function relaunchRoot(oldId, deps = {}) {
94
98
  cwd: old.cwd,
95
99
  name: old.kind,
96
100
  parent: null,
101
+ profile_id: old.profile_id,
97
102
  launch,
98
103
  modelOverride: old.model_override ?? undefined,
99
104
  });
@@ -0,0 +1,23 @@
1
+ import type { WireSessionInfo } from './broker-protocol.js';
2
+ export declare function compactSessionSearchText(text: string): string;
3
+ /** In-memory + file-backed cache keyed by absolute `.jsonl` path. One instance
4
+ * per broker; the file is shared across brokers (last-writer-wins via atomic
5
+ * rename — a lost update just costs one file's rebuild next open, never
6
+ * corruption). */
7
+ export declare class SessionListCache {
8
+ private readonly cacheFile;
9
+ private cache;
10
+ private loaded;
11
+ private persisting;
12
+ private persistAgain;
13
+ constructor(cacheFile: string);
14
+ /** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
15
+ listDir(sessionDir: string): Promise<WireSessionInfo[]>;
16
+ /** Sessions across every cwd's dir under the sessions root (`all` scope). */
17
+ listAll(sessionsRoot: string): Promise<WireSessionInfo[]>;
18
+ private build;
19
+ private ensureLoaded;
20
+ /** Atomic write (temp + rename) so a concurrent reader never sees a partial
21
+ * file. Coalesces overlapping writes into one trailing flush. */
22
+ private persist;
23
+ }
@@ -0,0 +1,270 @@
1
+ // Broker-side cache for the `/resume` session picker's `list_sessions` reply.
2
+ //
3
+ // pi's `SessionManager.list()` reads EVERY `.jsonl` in a cwd's session dir in
4
+ // full, line-by-line, to build each session's `allMessagesText` search field.
5
+ // On a large corpus (3.3k files / 1.1 GiB here) that is ~3.6s of CPU-bound
6
+ // JSON.parse on EVERY picker open — pi exposes no per-file entry point and no
7
+ // cache. This module keeps a `(mtimeMs, size)`-keyed cache of the already-wire-
8
+ // shaped, search-text-bounded `WireSessionInfo`: on each open it stats every
9
+ // file (cheap — ~260ms for 3.3k) and rebuilds info ONLY for new/changed files,
10
+ // reusing cached entries for the rest and dropping deleted ones. The cache is
11
+ // persisted to one global JSON file so the first open after a broker restart is
12
+ // also fast.
13
+ //
14
+ // `buildWireSessionInfo` below is a faithful mirror of pi's private
15
+ // `buildSessionInfo` (node_modules/@earendil-works/pi-coding-agent/dist/core/
16
+ // session-manager.js) — it is not exported, so the only way to rebuild a single
17
+ // file's info without re-reading the whole corpus is to reproduce its parse.
18
+ // Keep it in sync with pi's session-file format (CURRENT_SESSION_VERSION).
19
+ import { createReadStream } from 'node:fs';
20
+ import { readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
21
+ import { createInterface } from 'node:readline';
22
+ import { join } from 'node:path';
23
+ /** Search-text bound: `allMessagesText` is searched on every query edit; pi's
24
+ * unbounded value retains gigabytes in the viewer and turns key-repeat into GC
25
+ * jitter. The opening and latest text keep search useful. */
26
+ const SESSION_SEARCH_TEXT_CHARS = 1024;
27
+ export function compactSessionSearchText(text) {
28
+ if (text.length <= SESSION_SEARCH_TEXT_CHARS)
29
+ return text;
30
+ const half = SESSION_SEARCH_TEXT_CHARS / 2;
31
+ return `${text.slice(0, half)} … ${text.slice(-half)}`;
32
+ }
33
+ const MAX_CONCURRENT_INFO_LOADS = 10;
34
+ // ---- faithful mirror of pi's private buildSessionInfo -----------------------
35
+ function parseLine(line) {
36
+ if (!line.trim())
37
+ return null;
38
+ try {
39
+ return JSON.parse(line);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function extractTextContent(message) {
46
+ const content = message['content'];
47
+ if (typeof content === 'string')
48
+ return content;
49
+ if (!Array.isArray(content))
50
+ return '';
51
+ return content
52
+ .filter((b) => b.type === 'text')
53
+ .map((b) => b.text)
54
+ .join(' ');
55
+ }
56
+ function messageActivityTime(entry) {
57
+ const message = entry['message'];
58
+ if (!message || !('content' in message))
59
+ return undefined;
60
+ const role = message['role'];
61
+ if (role !== 'user' && role !== 'assistant')
62
+ return undefined;
63
+ const ts = message['timestamp'];
64
+ if (typeof ts === 'number')
65
+ return ts;
66
+ const t = new Date(entry['timestamp']).getTime();
67
+ return Number.isNaN(t) ? undefined : t;
68
+ }
69
+ /** Build one session's wire info by streaming the file. Returns null for a
70
+ * malformed/headerless file (matches pi, which drops those from the list). */
71
+ async function buildWireSessionInfo(filePath) {
72
+ try {
73
+ const stats = await stat(filePath);
74
+ let header = null;
75
+ let messageCount = 0;
76
+ let firstMessage = '';
77
+ const allMessages = [];
78
+ let name;
79
+ let lastActivityTime;
80
+ const rl = createInterface({
81
+ input: createReadStream(filePath, { encoding: 'utf8' }),
82
+ crlfDelay: Infinity,
83
+ });
84
+ for await (const line of rl) {
85
+ const entry = parseLine(line);
86
+ if (!entry)
87
+ continue;
88
+ if (!header) {
89
+ if (entry['type'] !== 'session')
90
+ return null;
91
+ header = entry;
92
+ continue;
93
+ }
94
+ if (entry['type'] === 'session_info') {
95
+ name = entry['name']?.trim() || undefined;
96
+ }
97
+ if (entry['type'] !== 'message')
98
+ continue;
99
+ messageCount++;
100
+ const activityTime = messageActivityTime(entry);
101
+ if (typeof activityTime === 'number') {
102
+ lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
103
+ }
104
+ const message = entry['message'];
105
+ if (!message || !('content' in message))
106
+ continue;
107
+ const role = message['role'];
108
+ if (role !== 'user' && role !== 'assistant')
109
+ continue;
110
+ const textContent = extractTextContent(message);
111
+ if (!textContent)
112
+ continue;
113
+ allMessages.push(textContent);
114
+ if (!firstMessage && role === 'user')
115
+ firstMessage = textContent;
116
+ }
117
+ if (!header)
118
+ return null;
119
+ const cwd = typeof header['cwd'] === 'string' ? header['cwd'] : '';
120
+ const headerTime = typeof header['timestamp'] === 'string' ? new Date(header['timestamp']).getTime() : NaN;
121
+ const modified = typeof lastActivityTime === 'number' && lastActivityTime > 0
122
+ ? new Date(lastActivityTime)
123
+ : !Number.isNaN(headerTime)
124
+ ? new Date(headerTime)
125
+ : stats.mtime;
126
+ return {
127
+ path: filePath,
128
+ id: header['id'],
129
+ cwd,
130
+ name,
131
+ parentSessionPath: header['parentSession'],
132
+ created: new Date(header['timestamp']).toISOString(),
133
+ modified: modified.toISOString(),
134
+ messageCount,
135
+ firstMessage: firstMessage || '(no messages)',
136
+ // Cache the already-bounded wire form (the selector searches this field).
137
+ allMessagesText: compactSessionSearchText(allMessages.join(' ')),
138
+ };
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ // ---- the cache ---------------------------------------------------------------
145
+ /** In-memory + file-backed cache keyed by absolute `.jsonl` path. One instance
146
+ * per broker; the file is shared across brokers (last-writer-wins via atomic
147
+ * rename — a lost update just costs one file's rebuild next open, never
148
+ * corruption). */
149
+ export class SessionListCache {
150
+ cacheFile;
151
+ cache = new Map();
152
+ loaded = false;
153
+ persisting = false;
154
+ persistAgain = false;
155
+ constructor(cacheFile) {
156
+ this.cacheFile = cacheFile;
157
+ }
158
+ /** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
159
+ async listDir(sessionDir) {
160
+ return this.build(await jsonlFiles(sessionDir));
161
+ }
162
+ /** Sessions across every cwd's dir under the sessions root (`all` scope). */
163
+ async listAll(sessionsRoot) {
164
+ let dirEntries;
165
+ try {
166
+ dirEntries = (await readdir(sessionsRoot, { withFileTypes: true }))
167
+ .filter((e) => e.isDirectory())
168
+ .map((e) => join(sessionsRoot, e.name));
169
+ }
170
+ catch {
171
+ return [];
172
+ }
173
+ const files = [];
174
+ for (const dir of dirEntries)
175
+ files.push(...(await jsonlFiles(dir)));
176
+ return this.build(files);
177
+ }
178
+ async build(files) {
179
+ await this.ensureLoaded();
180
+ const present = new Set(files);
181
+ for (const key of this.cache.keys()) {
182
+ if (!present.has(key))
183
+ this.cache.delete(key);
184
+ }
185
+ const infos = [];
186
+ let changed = false;
187
+ let next = 0;
188
+ const worker = async () => {
189
+ while (next < files.length) {
190
+ const file = files[next++];
191
+ if (file === undefined)
192
+ return;
193
+ const st = await stat(file).catch(() => null);
194
+ if (!st) {
195
+ this.cache.delete(file);
196
+ changed = true;
197
+ continue;
198
+ }
199
+ const cached = this.cache.get(file);
200
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
201
+ infos.push(cached.info);
202
+ continue;
203
+ }
204
+ const info = await buildWireSessionInfo(file);
205
+ changed = true;
206
+ if (!info) {
207
+ this.cache.delete(file);
208
+ continue;
209
+ }
210
+ this.cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, info });
211
+ infos.push(info);
212
+ }
213
+ };
214
+ await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_INFO_LOADS, files.length) }, () => worker()));
215
+ if (changed)
216
+ void this.persist();
217
+ infos.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
218
+ return infos;
219
+ }
220
+ async ensureLoaded() {
221
+ if (this.loaded)
222
+ return;
223
+ this.loaded = true;
224
+ try {
225
+ const raw = JSON.parse(await readFile(this.cacheFile, 'utf8'));
226
+ for (const [path, entry] of Object.entries(raw))
227
+ this.cache.set(path, entry);
228
+ }
229
+ catch {
230
+ // Missing or corrupt cache file → start empty and rebuild. No lenient
231
+ // multi-path recovery: the cache is derivable, so a bad file is just
232
+ // overwritten on the next persist.
233
+ }
234
+ }
235
+ /** Atomic write (temp + rename) so a concurrent reader never sees a partial
236
+ * file. Coalesces overlapping writes into one trailing flush. */
237
+ async persist() {
238
+ if (this.persisting) {
239
+ this.persistAgain = true;
240
+ return;
241
+ }
242
+ this.persisting = true;
243
+ try {
244
+ const obj = {};
245
+ for (const [path, entry] of this.cache)
246
+ obj[path] = entry;
247
+ const tmp = `${this.cacheFile}.${process.pid}.tmp`;
248
+ await writeFile(tmp, JSON.stringify(obj));
249
+ await rename(tmp, this.cacheFile);
250
+ }
251
+ catch {
252
+ // Best-effort: a failed persist only costs a rebuild next open.
253
+ }
254
+ finally {
255
+ this.persisting = false;
256
+ if (this.persistAgain) {
257
+ this.persistAgain = false;
258
+ void this.persist();
259
+ }
260
+ }
261
+ }
262
+ }
263
+ async function jsonlFiles(dir) {
264
+ try {
265
+ return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
266
+ }
267
+ catch {
268
+ return [];
269
+ }
270
+ }