@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -0,0 +1,338 @@
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, unlink, writeFile } from 'node:fs/promises';
21
+ import { createInterface } from 'node:readline';
22
+ import { dirname, 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
+ /** Persisted-file schema version. Bump whenever `CacheEntry`'s shape (or
35
+ * `WireSessionInfo`'s) changes: on a version mismatch the whole file is
36
+ * discarded and rebuilt from disk — no per-entry migration, no lenient
37
+ * recovery (the cache is fully derivable). */
38
+ const CACHE_SCHEMA_VERSION = 1;
39
+ /** The full map is tens of MB (~15 MB for one cwd, ~46 MB for `all`); a
40
+ * synchronous `JSON.stringify` of it must never sit on the reply path. Persists
41
+ * are coalesced onto this trailing timer so rapid opens write at most once per
42
+ * window, off the hot path. */
43
+ const PERSIST_DEBOUNCE_MS = 2000;
44
+ /** Shape-check a loaded entry — a well-formed-JSON file with the right version
45
+ * but a stale entry shape (e.g. missing `info`) would otherwise push undefined
46
+ * into the sort and throw. Any failure discards the whole file (rebuild). */
47
+ function isValidEntry(e) {
48
+ if (typeof e !== 'object' || e === null)
49
+ return false;
50
+ const c = e;
51
+ return (typeof c['mtimeMs'] === 'number' &&
52
+ typeof c['size'] === 'number' &&
53
+ typeof c['info'] === 'object' &&
54
+ c['info'] !== null &&
55
+ typeof c['info']['modified'] === 'string');
56
+ }
57
+ // ---- faithful mirror of pi's private buildSessionInfo -----------------------
58
+ function parseLine(line) {
59
+ if (!line.trim())
60
+ return null;
61
+ try {
62
+ return JSON.parse(line);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ function extractTextContent(message) {
69
+ const content = message['content'];
70
+ if (typeof content === 'string')
71
+ return content;
72
+ if (!Array.isArray(content))
73
+ return '';
74
+ return content
75
+ .filter((b) => b.type === 'text')
76
+ .map((b) => b.text)
77
+ .join(' ');
78
+ }
79
+ function messageActivityTime(entry) {
80
+ const message = entry['message'];
81
+ if (!message || !('content' in message))
82
+ return undefined;
83
+ const role = message['role'];
84
+ if (role !== 'user' && role !== 'assistant')
85
+ return undefined;
86
+ const ts = message['timestamp'];
87
+ if (typeof ts === 'number')
88
+ return ts;
89
+ const t = new Date(entry['timestamp']).getTime();
90
+ return Number.isNaN(t) ? undefined : t;
91
+ }
92
+ /** Build one session's wire info by streaming the file. Returns null for a
93
+ * malformed/headerless file (matches pi, which drops those from the list). */
94
+ async function buildWireSessionInfo(filePath) {
95
+ try {
96
+ const stats = await stat(filePath);
97
+ let header = null;
98
+ let messageCount = 0;
99
+ let firstMessage = '';
100
+ const allMessages = [];
101
+ let name;
102
+ let lastActivityTime;
103
+ const rl = createInterface({
104
+ input: createReadStream(filePath, { encoding: 'utf8' }),
105
+ crlfDelay: Infinity,
106
+ });
107
+ for await (const line of rl) {
108
+ const entry = parseLine(line);
109
+ if (!entry)
110
+ continue;
111
+ if (!header) {
112
+ if (entry['type'] !== 'session')
113
+ return null;
114
+ header = entry;
115
+ continue;
116
+ }
117
+ if (entry['type'] === 'session_info') {
118
+ name = entry['name']?.trim() || undefined;
119
+ }
120
+ if (entry['type'] !== 'message')
121
+ continue;
122
+ messageCount++;
123
+ const activityTime = messageActivityTime(entry);
124
+ if (typeof activityTime === 'number') {
125
+ lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
126
+ }
127
+ const message = entry['message'];
128
+ if (!message || !('content' in message))
129
+ continue;
130
+ const role = message['role'];
131
+ if (role !== 'user' && role !== 'assistant')
132
+ continue;
133
+ const textContent = extractTextContent(message);
134
+ if (!textContent)
135
+ continue;
136
+ allMessages.push(textContent);
137
+ if (!firstMessage && role === 'user')
138
+ firstMessage = textContent;
139
+ }
140
+ if (!header)
141
+ return null;
142
+ const cwd = typeof header['cwd'] === 'string' ? header['cwd'] : '';
143
+ const headerTime = typeof header['timestamp'] === 'string' ? new Date(header['timestamp']).getTime() : NaN;
144
+ const modified = typeof lastActivityTime === 'number' && lastActivityTime > 0
145
+ ? new Date(lastActivityTime)
146
+ : !Number.isNaN(headerTime)
147
+ ? new Date(headerTime)
148
+ : stats.mtime;
149
+ return {
150
+ path: filePath,
151
+ id: header['id'],
152
+ cwd,
153
+ name,
154
+ parentSessionPath: header['parentSession'],
155
+ created: new Date(header['timestamp']).toISOString(),
156
+ modified: modified.toISOString(),
157
+ messageCount,
158
+ firstMessage: firstMessage || '(no messages)',
159
+ // Cache the already-bounded wire form (the selector searches this field).
160
+ allMessagesText: compactSessionSearchText(allMessages.join(' ')),
161
+ };
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ // ---- the cache ---------------------------------------------------------------
168
+ /** In-memory + file-backed cache keyed by absolute `.jsonl` path. One instance
169
+ * per broker; the file is shared across brokers (last-writer-wins via atomic
170
+ * rename — a lost update just costs one file's rebuild next open, never
171
+ * corruption). */
172
+ export class SessionListCache {
173
+ cacheFile;
174
+ cache = new Map();
175
+ loaded = false;
176
+ persisting = false;
177
+ persistAgain = false;
178
+ persistTimer = null;
179
+ constructor(cacheFile) {
180
+ this.cacheFile = cacheFile;
181
+ }
182
+ /** Fire-and-forget warm of the cwd scope at broker start, so the first
183
+ * `/resume` open after this broker launches is already warm. The cold scan
184
+ * streams each file via readline (yields per chunk), so it never blocks the
185
+ * broker loop — pre-warming beats worker threads here. Errors are swallowed
186
+ * by the same paths that guard a normal open. */
187
+ prewarm(sessionDir) {
188
+ void this.listDir(sessionDir);
189
+ }
190
+ /** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
191
+ async listDir(sessionDir) {
192
+ return this.build(await jsonlFiles(sessionDir), new Set([sessionDir]));
193
+ }
194
+ /** Sessions across every cwd's dir under the sessions root (`all` scope). */
195
+ async listAll(sessionsRoot) {
196
+ let dirEntries;
197
+ try {
198
+ dirEntries = (await readdir(sessionsRoot, { withFileTypes: true }))
199
+ .filter((e) => e.isDirectory())
200
+ .map((e) => join(sessionsRoot, e.name));
201
+ }
202
+ catch {
203
+ return [];
204
+ }
205
+ const files = [];
206
+ for (const dir of dirEntries)
207
+ files.push(...(await jsonlFiles(dir)));
208
+ return this.build(files, new Set(dirEntries));
209
+ }
210
+ async build(files, scannedDirs) {
211
+ await this.ensureLoaded();
212
+ const present = new Set(files);
213
+ let changed = false;
214
+ // Prune ONLY keys under the dirs we actually scanned. A cwd-scope open must
215
+ // not evict other cwds' cached entries: they weren't rescanned, so their
216
+ // absence from `files` says nothing about whether they still exist on disk.
217
+ // (Pruning the whole map here would self-destruct the global/`all` cache on
218
+ // every cwd open.)
219
+ for (const key of this.cache.keys()) {
220
+ if (!present.has(key) && scannedDirs.has(dirname(key))) {
221
+ this.cache.delete(key);
222
+ changed = true;
223
+ }
224
+ }
225
+ // Positioned by file index so equal-`modified` ties keep readdir order
226
+ // deterministically (workers finish out of order); a stable sort preserves it.
227
+ const infos = new Array(files.length);
228
+ let next = 0;
229
+ const worker = async () => {
230
+ for (;;) {
231
+ const i = next++;
232
+ if (i >= files.length)
233
+ return;
234
+ const file = files[i];
235
+ if (file === undefined)
236
+ return;
237
+ const st = await stat(file).catch(() => null);
238
+ if (!st) {
239
+ if (this.cache.delete(file))
240
+ changed = true;
241
+ continue;
242
+ }
243
+ const cached = this.cache.get(file);
244
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
245
+ infos[i] = cached.info;
246
+ continue;
247
+ }
248
+ const info = await buildWireSessionInfo(file);
249
+ changed = true;
250
+ if (!info) {
251
+ this.cache.delete(file);
252
+ continue;
253
+ }
254
+ this.cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, info });
255
+ infos[i] = info;
256
+ }
257
+ };
258
+ await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_INFO_LOADS, files.length) }, () => worker()));
259
+ if (changed)
260
+ this.schedulePersist();
261
+ const result = infos.filter((x) => x !== undefined);
262
+ result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
263
+ return result;
264
+ }
265
+ async ensureLoaded() {
266
+ if (this.loaded)
267
+ return;
268
+ this.loaded = true;
269
+ try {
270
+ const raw = JSON.parse(await readFile(this.cacheFile, 'utf8'));
271
+ // Version mismatch or shape drift → discard and rebuild. No lenient
272
+ // multi-path recovery: the cache is derivable, so a bad file is just
273
+ // overwritten on the next persist.
274
+ if (raw?.version !== CACHE_SCHEMA_VERSION || typeof raw.entries !== 'object')
275
+ return;
276
+ for (const [path, entry] of Object.entries(raw.entries)) {
277
+ if (!isValidEntry(entry)) {
278
+ this.cache.clear();
279
+ return;
280
+ }
281
+ this.cache.set(path, entry);
282
+ }
283
+ }
284
+ catch {
285
+ // Missing or corrupt cache file → start empty and rebuild.
286
+ this.cache.clear();
287
+ }
288
+ }
289
+ /** Coalesce persists onto a single trailing timer so the tens-of-MB
290
+ * `JSON.stringify` never runs on the reply path and rapid opens write once. */
291
+ schedulePersist() {
292
+ if (this.persistTimer)
293
+ return;
294
+ this.persistTimer = setTimeout(() => {
295
+ this.persistTimer = null;
296
+ void this.persist();
297
+ }, PERSIST_DEBOUNCE_MS);
298
+ this.persistTimer.unref?.();
299
+ }
300
+ /** Atomic write (temp + rename) so a concurrent reader never sees a partial
301
+ * file. Coalesces overlapping writes into one trailing flush. */
302
+ async persist() {
303
+ if (this.persisting) {
304
+ this.persistAgain = true;
305
+ return;
306
+ }
307
+ this.persisting = true;
308
+ const tmp = `${this.cacheFile}.${process.pid}.tmp`;
309
+ try {
310
+ const entries = {};
311
+ for (const [path, entry] of this.cache)
312
+ entries[path] = entry;
313
+ const payload = { version: CACHE_SCHEMA_VERSION, entries };
314
+ await writeFile(tmp, JSON.stringify(payload));
315
+ await rename(tmp, this.cacheFile);
316
+ }
317
+ catch {
318
+ // Best-effort: a failed persist only costs a rebuild next open. Clean up
319
+ // the temp file so a failed write never leaks it.
320
+ await unlink(tmp).catch(() => { });
321
+ }
322
+ finally {
323
+ this.persisting = false;
324
+ if (this.persistAgain) {
325
+ this.persistAgain = false;
326
+ void this.persist();
327
+ }
328
+ }
329
+ }
330
+ }
331
+ async function jsonlFiles(dir) {
332
+ try {
333
+ return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
334
+ }
335
+ catch {
336
+ return [];
337
+ }
338
+ }
@@ -12,7 +12,6 @@
12
12
  import { spawn } from 'node:child_process';
13
13
  import { readdirSync, existsSync } from 'node:fs';
14
14
  import { isAbsolute, resolve, join } from 'node:path';
15
- import { homedir } from 'node:os';
16
15
  import { spawnNode, currentNodeContext, rootOfSpine, newNodeId } from './nodes.js';
17
16
  import { loadProfileManifest } from '../profiles/manifest.js';
18
17
  import { selectProfileForCwd } from '../profiles/select.js';
@@ -24,10 +23,11 @@ import { writeGoal } from './kickoff.js';
24
23
  import { appendSituationalContext } from './situational-context.js';
25
24
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
26
25
  import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
27
- import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
26
+ import { installTmuxBindings } from './tmux-chrome.js';
28
27
  import { getNode, fullName, recordPid } from '../canvas/index.js';
29
28
  import { registerViewerFocus, openViewerWindow, waitForBrokerViewSocket, viewerSplitEnv, windowOfPane, currentTmux, inTmux, focusWindow, } from './placement.js';
30
29
  import { transition } from './lifecycle.js';
30
+ import { piSessionsRoot } from './pi-vendored.js';
31
31
  import { headlessBrokerHost } from './host.js';
32
32
  import { ensureDaemon } from '../../daemon/manage.js';
33
33
  // ---------------------------------------------------------------------------
@@ -72,9 +72,13 @@ export async function bootRoot(opts) {
72
72
  // covering cwd > a synchronous create-or-root-profile prompt (the stable root
73
73
  // profile, headless).
74
74
  const profileId = await selectProfileForCwd(opts.cwd, opts.profile, opts.pickProfile ?? false);
75
+ // Immediate feedback: the broker + viewer boot take ~1s and the profile
76
+ // selector has just released the terminal — a silent cursor reads as a hang.
77
+ if (process.stdout.isTTY)
78
+ process.stdout.write('\u001b[2m › starting…\u001b[0m\r\n');
75
79
  // A born-resident root starts in base mode; it earns the orchestrator persona
76
80
  // the first time it delegates (or on promotion). Resident lifecycle either way.
77
- const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false });
81
+ const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false, cwd: opts.cwd, profileId });
78
82
  // Born WITHOUT a name. Naming is async + event-driven: the canvas-goal-capture
79
83
  // extension names the node from its FIRST real message (the kickoff prompt or
80
84
  // a human's first line) inside its own pi process, via a headless `pi -p`.
@@ -93,18 +97,9 @@ export async function bootRoot(opts) {
93
97
  // mandate (bare `crtr` has none — writeGoal no-ops on empty).
94
98
  if (opts.prompt !== undefined)
95
99
  writeGoal(meta.node_id, opts.prompt);
96
- // Make the Alt+C action menu + Alt+] / Alt+[ node-nav keys + Alt+V then ]/[
97
- // view-nav chord live on this server (idempotent; guaranteed in tmux above).
100
+ // Reconcile the crouter-owned tmux bindings on this server.
98
101
  try {
99
- installMenuBinding();
100
- }
101
- catch { /* best-effort */ }
102
- try {
103
- installNavBindings();
104
- }
105
- catch { /* best-effort */ }
106
- try {
107
- installViewNavBindings();
102
+ installTmuxBindings();
108
103
  }
109
104
  catch { /* best-effort */ }
110
105
  // This terminal becomes the root's attach VIEWER in the user's CURRENT session,
@@ -140,19 +135,14 @@ export async function bootRoot(opts) {
140
135
  throw new Error(`failed to launch the broker engine for the front-door root ${meta.node_id} — nothing to attach to.`);
141
136
  }
142
137
  recordPid(meta.node_id, placed.pid);
143
- // The terminal has nothing to attach to until view.sock accepts. The root is
144
- // useless without its engine, so a socket timeout IS fatal here (unlike a
145
- // background child's optional viewer, which is skipped on timeout).
146
- if (!(await waitForBrokerViewSocket(meta.node_id, placed.exited))) {
147
- try {
148
- transition(meta.node_id, 'crash');
149
- }
150
- catch {
151
- /* best-effort cleanup */
152
- }
153
- throw new Error(`the front-door root broker ${meta.node_id} never bound its view socket — cannot attach.`);
154
- }
155
- const attachEnv = { ...process.env, ...viewerSplitEnv() };
138
+ // Spawn the attach viewer NOW, in parallel with the broker's boot: the
139
+ // viewer's own `crtr` cold start (~0.4s of module load) then overlaps the
140
+ // broker's ~1s view-socket bind instead of serializing after it — this was
141
+ // the bulk of the post-profile-pick dead air. CRTR_ATTACH_WAIT_MS tells the
142
+ // viewer to poll view.sock until it accepts (preWaitForViewSocket in
143
+ // attach-cmd.ts) instead of failing fast on the not-yet-bound socket; the
144
+ // deadline mirrors placement's BROKER_FOCUS_SOCKET_WAIT_MS.
145
+ const attachEnv = { ...process.env, ...viewerSplitEnv(), CRTR_ATTACH_WAIT_MS: '30000' };
156
146
  // Async, NOT spawnSync: a synchronous spawn would park THIS process's own
157
147
  // event loop for the whole attach session (which legitimately runs for
158
148
  // hours) — starving it of the SIGCHLD delivery it needs to reap its OWN
@@ -165,23 +155,36 @@ export async function bootRoot(opts) {
165
155
  // reap it in real time; `stdio: 'inherit'` still hands the child the TTY
166
156
  // directly, so this is a behavior-neutral swap for the foreground session.
167
157
  const attachChild = spawn('crtr', ['surface', 'attach', 'to', meta.node_id], { stdio: 'inherit', env: attachEnv });
168
- const code = await new Promise((resolveCode) => {
158
+ const attachExit = new Promise((resolveCode) => {
169
159
  attachChild.once('exit', (exitCode) => resolveCode(exitCode ?? 0));
170
160
  attachChild.once('error', () => resolveCode(1));
171
161
  });
172
- process.exit(code);
173
- }
174
- /** pi's sessions root, VENDORED from pi `config.getSessionsDir()` (= `<agentDir>/
175
- * sessions`). pi's package `exports` map is `.`-only, so config.js can't be
176
- * deep-imported, and a ROOT import of `getAgentDir` would eager-load the entire
177
- * heavy pi SDK index on crtr's front-door hot path (the reason broker-sdk.ts
178
- * dynamic-imports the engine). Mirrors pi: `PI_CODING_AGENT_DIR` env, else
179
- * `~/.pi/agent` (APP_NAME defaults to 'pi'). Re-sync on a pi SDK bump that moves
180
- * the sessions dir — same vendoring rationale as pi-vendored.ts. */
181
- function piSessionsRoot() {
182
- const env = process.env['PI_CODING_AGENT_DIR'];
183
- const agentDir = env !== undefined && env !== '' ? env : join(homedir(), '.pi', 'agent');
184
- return join(agentDir, 'sessions');
162
+ // The root is useless without its engine, so a socket timeout IS fatal here
163
+ // (unlike a background child's optional viewer, which is skipped on timeout):
164
+ // await readiness concurrently with the viewer's own pre-wait, mark the node
165
+ // crashed and reap the polling viewer if the broker never binds. Raced
166
+ // against the viewer exiting on its own first (e.g. the user quits during
167
+ // boot), which simply ends the session like any other detach.
168
+ const outcome = await Promise.race([
169
+ waitForBrokerViewSocket(meta.node_id, placed.exited).then((ok) => (ok ? 'ready' : 'never')),
170
+ attachExit.then(() => 'viewer-exit'),
171
+ ]);
172
+ if (outcome === 'never') {
173
+ try {
174
+ attachChild.kill();
175
+ }
176
+ catch {
177
+ /* best-effort */
178
+ }
179
+ try {
180
+ transition(meta.node_id, 'crash');
181
+ }
182
+ catch {
183
+ /* best-effort cleanup */
184
+ }
185
+ throw new Error(`the front-door root broker ${meta.node_id} never bound its view socket — cannot attach.`);
186
+ }
187
+ process.exit(await attachExit);
185
188
  }
186
189
  /** Resolve a bare/partial pi session uuid to its ABSOLUTE `.jsonl` path by
187
190
  * scanning pi's sessions store the way pi's own CLI does (exact id, else a
@@ -302,10 +305,6 @@ export async function spawnChild(opts) {
302
305
  // reaps when done. A child born as an orchestrator is terminal/orchestrator
303
306
  // (delegates + holds a roadmap, but still reports up), NOT resident.
304
307
  const lifecycle = root ? 'resident' : 'terminal';
305
- // Spine: a managed child reports up to its spawner (has a manager); an
306
- // independent root sits top-of-spine with nobody to push to. Mirrors the
307
- // `parent` set below (root ? null : spawner), so hasManager === parent!==null.
308
- const { launch } = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model });
309
308
  // Born WITHOUT a name — the canvas-goal-capture extension names it async from
310
309
  // its first message (the kickoff task) inside its own pi process, so spawn
311
310
  // never blocks on the LLM naming round-trip (the 2-3s freeze it used to cost).
@@ -326,6 +325,14 @@ export async function spawnChild(opts) {
326
325
  managedWorktree = createManagedWorktree(opts.cwd, nodeId);
327
326
  spawnCwd = managedWorktree.path;
328
327
  }
328
+ // Spine: a managed child reports up to its spawner (has a manager); an
329
+ // independent root sits top-of-spine with nobody to push to. Mirrors the
330
+ // `parent` set below (root ? null : spawner), so hasManager === parent!==null.
331
+ // Built HERE — after profile + worktree cwd are resolved — so the kind's
332
+ // model/tools resolve against the CHILD's own profile + project stack, not
333
+ // this process's ambient scope (a front-door shell has no CRTR_PROFILE_ID,
334
+ // which silently hid every profile-scope kind override).
335
+ const { launch } = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId });
329
336
  const meta = spawnNode({
330
337
  kind: opts.kind,
331
338
  mode,
@@ -0,0 +1,26 @@
1
+ export declare function resolveStreamWatchdogMs(): number;
2
+ /**
3
+ * A single-timer dead-man's switch: `poke(token)` (re)arms it, `clear()`
4
+ * disarms it, and `onStall(token)` fires exactly once if `poke()` isn't called
5
+ * again within `ms`. The broker pokes it on relayed engine events while a turn
6
+ * is awaiting the provider and clears it at `agent_end` / tool execution — see
7
+ * broker.ts `relayEvent`.
8
+ *
9
+ * The `token` is an opaque generation stamp: each arm captures the token passed
10
+ * to its `poke()`, and `onStall` receives exactly that token. The broker bumps
11
+ * a session generation on every rebind, so a timer armed against a since-
12
+ * superseded session hands its stale generation to `onStall`, which no-ops it
13
+ * instead of aborting the live replacement session.
14
+ */
15
+ export declare class StreamWatchdog {
16
+ private readonly onStall;
17
+ private timer;
18
+ private readonly ms;
19
+ constructor(onStall: (token: number) => void, ms?: number);
20
+ /** Proof of progress — (re)arm the timer for `token`, canceling any prior
21
+ * countdown. The fired `onStall` receives THIS arm's token. */
22
+ poke(token: number): void;
23
+ /** Turn over (or broker tearing down) — no more relay expected until the
24
+ * next `poke()`. Idempotent. */
25
+ clear(): void;
26
+ }
@@ -0,0 +1,75 @@
1
+ // stream-watchdog.ts — broker-side dead-provider-stream detector.
2
+ //
3
+ // A laptop sleep or Wi-Fi loss can leave the provider's HTTP response stream
4
+ // half-open: the socket never errors and never closes, it just stops
5
+ // delivering bytes. pi's turn loop has no read-timeout on that socket, so the
6
+ // engine wedges in phase='turn' indefinitely — nothing drains (steer/followUp
7
+ // queues included) until the daemon's 20-minute wedge detector SIGTERMs the
8
+ // broker, which is the actual data-loss event a wedge this long causes (see
9
+ // queued-messages-never-delivered-diagnosis.md RC1/RC2). This watchdog aborts
10
+ // the stalled stream itself, well before that backstop: if no engine event has
11
+ // been relayed for STREAM_WATCHDOG_MS while a turn is active, the stream is
12
+ // presumed dead — abort it. The turn ends with stopReason 'aborted' (or the
13
+ // stophook's own error classification if the abort races a real error), the
14
+ // existing connection-fault retry machinery (which arms on agent_end) owns
15
+ // re-driving the turn, and the broker process — and its in-memory queues —
16
+ // survive instead of being killed.
17
+ const DEFAULT_STREAM_WATCHDOG_MS = 5 * 60_000; // 5 minutes.
18
+ // Comfortably under the daemon's 20-minute WEDGE_QUIET_MS hard kick (crtrd.ts),
19
+ // so this is the common-case recovery path and the daemon kick becomes the rare
20
+ // backstop. Generous enough that a live, healthy stream never trips it: a
21
+ // single LLM turn's own network round-trips (message_update deltas,
22
+ // tool_execution_start/update/end) land well inside 5 minutes. The watchdog
23
+ // covers ONLY time awaiting the provider: the broker disarms it while a tool is
24
+ // executing (a legitimately silent tool can run longer than the timeout — see
25
+ // broker.ts relayEvent) and rearms it once the last in-flight tool ends, so a
26
+ // slow-but-live turn is never mistaken for a dead one.
27
+ export function resolveStreamWatchdogMs() {
28
+ const raw = process.env['CRTR_STREAM_WATCHDOG_MS'];
29
+ if (raw === undefined)
30
+ return DEFAULT_STREAM_WATCHDOG_MS;
31
+ const n = Number(raw);
32
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_STREAM_WATCHDOG_MS;
33
+ }
34
+ /**
35
+ * A single-timer dead-man's switch: `poke(token)` (re)arms it, `clear()`
36
+ * disarms it, and `onStall(token)` fires exactly once if `poke()` isn't called
37
+ * again within `ms`. The broker pokes it on relayed engine events while a turn
38
+ * is awaiting the provider and clears it at `agent_end` / tool execution — see
39
+ * broker.ts `relayEvent`.
40
+ *
41
+ * The `token` is an opaque generation stamp: each arm captures the token passed
42
+ * to its `poke()`, and `onStall` receives exactly that token. The broker bumps
43
+ * a session generation on every rebind, so a timer armed against a since-
44
+ * superseded session hands its stale generation to `onStall`, which no-ops it
45
+ * instead of aborting the live replacement session.
46
+ */
47
+ export class StreamWatchdog {
48
+ onStall;
49
+ timer;
50
+ ms;
51
+ constructor(onStall, ms) {
52
+ this.onStall = onStall;
53
+ this.ms = ms ?? resolveStreamWatchdogMs();
54
+ }
55
+ /** Proof of progress — (re)arm the timer for `token`, canceling any prior
56
+ * countdown. The fired `onStall` receives THIS arm's token. */
57
+ poke(token) {
58
+ this.clear();
59
+ this.timer = setTimeout(() => {
60
+ this.timer = undefined;
61
+ this.onStall(token);
62
+ }, this.ms);
63
+ // unref() so a live watchdog never keeps the broker process open on its own.
64
+ if (typeof this.timer.unref === 'function')
65
+ this.timer.unref();
66
+ }
67
+ /** Turn over (or broker tearing down) — no more relay expected until the
68
+ * next `poke()`. Idempotent. */
69
+ clear() {
70
+ if (this.timer === undefined)
71
+ return;
72
+ clearTimeout(this.timer);
73
+ this.timer = undefined;
74
+ }
75
+ }
@@ -1 +1 @@
1
- export { installMenuBinding, installNavBindings, installViewNavBindings, sendKeysEnter, displayMessage, paneCurrentPath } from './tmux.js';
1
+ export { installTmuxBindings, sendKeysEnter, displayMessage, paneCurrentPath, type TmuxInstalledPair, type TmuxInstallDiagnostic, type TmuxInstallResult, } from './tmux.js';
@@ -1,4 +1,4 @@
1
1
  // tmux-chrome.ts — chrome seam (§2.1): stateless keybind/input verbs.
2
2
  // The ONLY non-placement module allowed to import the tmux driver, per the
3
- // §5.1 lint. Re-exports the menu/nav/send-keys verbs callers (spawn, chord) need.
4
- export { installMenuBinding, installNavBindings, installViewNavBindings, sendKeysEnter, displayMessage, paneCurrentPath } from './tmux.js';
3
+ // §5.1 lint. Re-exports the server chrome callers need.
4
+ export { installTmuxBindings, sendKeysEnter, displayMessage, paneCurrentPath, } from './tmux.js';
@@ -1,3 +1,4 @@
1
+ import { type BindingId, type BindingResolution } from '../keybindings/index.js';
1
2
  /** POSIX single-quote escaping for one shell word. */
2
3
  export declare function shellQuote(s: string): string;
3
4
  export declare function inTmux(): boolean;
@@ -202,18 +203,20 @@ export declare function switchClient(session: string): boolean;
202
203
  * (`tmux send-keys -t <pane> '<text>' Enter`). Requires the pane's editor be
203
204
  * empty, same limitation as the menu's `/promote` item. Best-effort. */
204
205
  export declare function sendKeysEnter(pane: string, text: string): boolean;
205
- /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
206
- export declare function installMenuBinding(): boolean;
207
- /** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
208
- * false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
209
- * terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
210
- */
211
- export declare function installNavBindings(): boolean;
212
- /** Bind Alt+V → (], [) to the view-monitor cycle. `M-v` enters the private
213
- * `crtr-view` key table (switch-client -T), then ] cycles next / [ cycles prev.
214
- * Best-effort; false if any of the three binds fail. Deliberately distinct from
215
- * installNavBindings' bare Alt+]/Alt+[ (node cycle): the two cycles coexist on
216
- * the same server — brackets alone walk the node graph, Alt+V-then-bracket flips
217
- * view monitors. The bracket keys are bound in the private table, so they NEVER
218
- * shadow the user's root ]/[ and carry no CSI-introducer ambiguity. */
219
- export declare function installViewNavBindings(): boolean;
206
+ export interface TmuxInstalledPair {
207
+ readonly table: string;
208
+ readonly key: string;
209
+ }
210
+ export interface TmuxInstallDiagnostic {
211
+ readonly code: 'occupied' | 'tmux-error';
212
+ readonly bindingId?: BindingId;
213
+ readonly gesture?: string;
214
+ readonly message: string;
215
+ }
216
+ export interface TmuxInstallResult {
217
+ readonly ok: boolean;
218
+ readonly installed: readonly TmuxInstalledPair[];
219
+ readonly diagnostics: readonly TmuxInstallDiagnostic[];
220
+ }
221
+ /** Reconcile the tmux server with one immutable crouter binding snapshot. */
222
+ export declare function installTmuxBindings(bindings?: BindingResolution<BindingId>): TmuxInstallResult;