pi-crew 0.11.21 → 0.11.23

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 (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/index.mjs +542 -531
  3. package/package.json +2 -1
  4. package/src/errors.ts +14 -1
  5. package/src/extension/async-notifier.ts +42 -17
  6. package/src/extension/cross-extension-rpc.ts +42 -5
  7. package/src/extension/notification-router.ts +15 -2
  8. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  9. package/src/extension/registration/runtime-cleanup.ts +17 -0
  10. package/src/extension/registration/wire-cross-extension.ts +26 -0
  11. package/src/extension/rpc-hmac.ts +53 -2
  12. package/src/extension/team-tool/cancel.ts +34 -21
  13. package/src/extension/team-tool/dispatch/index.ts +6 -5
  14. package/src/extension/team-tool/dispatch/status.ts +42 -2
  15. package/src/extension/team-tool/respond.ts +8 -6
  16. package/src/extension/team-tool/run.ts +4 -4
  17. package/src/extension/team-tool.ts +24 -2
  18. package/src/prompt/inbox-poll.ts +77 -25
  19. package/src/prompt/message-tool.ts +62 -4
  20. package/src/prompt/prompt-runtime.ts +82 -32
  21. package/src/prompt/surface-worker.ts +13 -1
  22. package/src/runtime/broker/crew-broker-client.ts +74 -16
  23. package/src/runtime/broker/crew-broker.ts +120 -97
  24. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  25. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  26. package/src/runtime/broker/wait-status-cache.ts +14 -5
  27. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  28. package/src/runtime/child-pi/child-pi.ts +20 -2
  29. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  30. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  31. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  32. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  33. package/src/runtime/crew-agent-records.ts +32 -14
  34. package/src/runtime/delivery-coordinator.ts +30 -0
  35. package/src/runtime/dispatch-batch.ts +9 -6
  36. package/src/runtime/dwf-state-store.ts +41 -7
  37. package/src/runtime/finalize-run.ts +13 -2
  38. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  39. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  40. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  41. package/src/runtime/live-session/embedded-tier.ts +3 -1
  42. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  43. package/src/runtime/model/model-fallback.ts +23 -1
  44. package/src/runtime/output/pi-json-output.ts +26 -1
  45. package/src/runtime/policy-engine.ts +18 -0
  46. package/src/runtime/process/zombie-scanner.ts +84 -16
  47. package/src/runtime/process-status.ts +6 -1
  48. package/src/runtime/recovery/crash-recovery.ts +170 -28
  49. package/src/runtime/rpc/rpc-worker.ts +44 -1
  50. package/src/runtime/scheduling/run-coalesced-task-group.ts +73 -6
  51. package/src/runtime/scheduling/scheduler.ts +74 -11
  52. package/src/runtime/scratchpad/engine.ts +26 -3
  53. package/src/runtime/settings-store.ts +22 -2
  54. package/src/runtime/stale-reconciler.ts +4 -1
  55. package/src/runtime/surface/tmux-provider.ts +24 -3
  56. package/src/runtime/task-health.ts +16 -1
  57. package/src/runtime/task-output-context.ts +143 -71
  58. package/src/runtime/task-packet.ts +70 -17
  59. package/src/runtime/task-runner/child-executor.ts +27 -0
  60. package/src/runtime/task-runner/output-splitter.ts +79 -16
  61. package/src/runtime/task-runner/post-execution.ts +75 -15
  62. package/src/runtime/task-runner/state-helpers.ts +92 -2
  63. package/src/runtime/usage-tracker.ts +11 -0
  64. package/src/runtime/verification/green-contract.ts +17 -0
  65. package/src/runtime/verification/verification-gates.ts +24 -6
  66. package/src/runtime/workspace-lock.ts +53 -0
  67. package/src/schema/team-tool-schema.ts +10 -4
  68. package/src/state/coordination/mailbox.ts +133 -38
  69. package/src/state/decision-ledger.ts +56 -8
  70. package/src/state/event-log/cursor.ts +14 -2
  71. package/src/state/event-log/event-log-rotation.ts +83 -5
  72. package/src/state/event-log/event-log.ts +181 -12
  73. package/src/state/stores/active-run-registry.ts +10 -3
  74. package/src/state/types.ts +9 -1
  75. package/src/ui/agents-jobs-browser.ts +22 -8
  76. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  77. package/src/ui/events-state-source.ts +67 -28
  78. package/src/ui/inline-panel/agent-pane.ts +13 -6
  79. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  80. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  81. package/src/ui/inline-panel/index.ts +21 -1
  82. package/src/ui/live-run-sidebar.ts +14 -3
  83. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  84. package/src/ui/powerbar-publisher.ts +6 -3
  85. package/src/ui/rail.ts +7 -1
  86. package/src/ui/render-scheduler.ts +53 -1
  87. package/src/ui/run-action-dispatcher.ts +68 -20
  88. package/src/ui/status-colors.ts +9 -0
  89. package/src/utils/env-filter.ts +8 -0
@@ -120,6 +120,19 @@ interface SourceInternals {
120
120
  forceDispose(): void;
121
121
  }
122
122
 
123
+ /** O-7 (0.11.22): a scan result of "empty" only counts as a committed compaction
124
+ * when the path is a genuine regular file. A directory (or any non-regular
125
+ * object) that some readers tolerate into an empty cursor must be treated as a
126
+ * FAILED scan so the guard behaves identically on POSIX and Windows. Returns
127
+ * false when statSync throws (missing file shares the same transient policy). */
128
+ function isRegularFile(eventsPath: string): boolean {
129
+ try {
130
+ return fs.statSync(eventsPath).isFile();
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
123
136
  function createEventsStateSource(
124
137
  runId: string,
125
138
  eventsPath: string,
@@ -153,8 +166,11 @@ function createEventsStateSource(
153
166
  }
154
167
 
155
168
  /** STORAGE SCAN (spec U7): one parse of the (tail-capped) log to the
156
- * current seq. No event replay — the root is rebuilt from storage. */
157
- function rootBuild(): void {
169
+ * current seq. No event replay — the root is rebuilt from storage.
170
+ * O-7 (DR-04 R7): returns whether the scan SUCCEEDED — on failure the
171
+ * committed state, the inode/size stamps AND `resynced`/`version` are all
172
+ * left untouched so the next poll re-detects the rewrite and retries. */
173
+ function rootBuild(): boolean {
158
174
  let scanned: TeamEvent[] | undefined;
159
175
  let scannedSeq = 0;
160
176
  try {
@@ -171,14 +187,23 @@ function createEventsStateSource(
171
187
  scanned.length === 0 &&
172
188
  scannedSeq === 0 &&
173
189
  (seq > 0 || events.length > 0) &&
174
- !fs.existsSync(eventsPath)
190
+ !isRegularFile(eventsPath)
175
191
  ) {
176
192
  // readEventsCursor reports a MISSING log as an empty cursor — but a
177
- // log that carried committed events a beat ago and now does not exist
178
- // is a transient disappearance (run dir moving / the rename→wx-create
179
- // window of rotation), NOT a compaction to empty. Keep the committed
180
- // state — same policy as poll()'s miss-file branch — and keep the inode
181
- // stamp so the next poll re-detects the rewrite and retries the resync.
193
+ // log that carried committed events a beat ago and now is not a
194
+ // readable regular file is a transient disappearance (run dir moving /
195
+ // the rename→wx-create window of rotation) OR an unreadable anomaly
196
+ // (a directory/torn state some readers tolerate into an empty result),
197
+ // NOT a compaction to empty. Keep the committed state — same policy as
198
+ // poll()'s miss-file branch — and keep the inode stamp so the next poll
199
+ // re-detects the rewrite and retries the resync.
200
+ //
201
+ // O-7 platform parity (0.11.22 CI): this guard used `!fs.existsSync`,
202
+ // which is TRUE for a directory — Windows' reader tolerates an EISDIR
203
+ // open into an empty cursor while POSIX throws, so the same replacement
204
+ // committed an empty root on Windows and returned false on Linux.
205
+ // Requiring a regular file makes the failed-scan verdict OS-uniform
206
+ // while a genuine compaction-to-empty (regular empty file) still commits.
182
207
  scanned = undefined;
183
208
  }
184
209
  // Stamp the inode/size the root was built from so the FIRST poll after
@@ -186,30 +211,43 @@ function createEventsStateSource(
186
211
  // instead of learning the inode one poll late. Stamp ONLY on a successful
187
212
  // scan — on a failed scan keep the previous stamp so the next poll
188
213
  // re-detects the rewrite and RETRIES the resync.
189
- if (scanned !== undefined) {
190
- try {
191
- const stat = fs.statSync(eventsPath);
192
- lastIno = stat.ino;
193
- lastSize = stat.size;
194
- } catch {
195
- lastIno = -1;
196
- lastSize = -1;
197
- }
198
- events = scanned.slice(-windowSize);
199
- seq = scannedSeq;
214
+ if (scanned === undefined) {
215
+ // O-7: failed scan — do NOT claim a fresh root: `resynced`/`version`
216
+ // stay untouched and no resync frame is emitted (see resyncAttempt).
217
+ return false;
218
+ }
219
+ try {
220
+ const stat = fs.statSync(eventsPath);
221
+ lastIno = stat.ino;
222
+ lastSize = stat.size;
223
+ } catch {
224
+ lastIno = -1;
225
+ lastSize = -1;
200
226
  }
227
+ events = scanned.slice(-windowSize);
228
+ seq = scannedSeq;
201
229
  resynced = true;
202
230
  version += 1;
231
+ return true;
203
232
  }
204
233
 
205
- function resync(): EventsStateSnapshot {
206
- const fromSeq = seq;
207
- rootBuild();
234
+ /** Root-replace and emit ONE resync frame. Returns false (state untouched,
235
+ * no frame) when the storage scan failed — O-7: a failed resync must not
236
+ * count as a committed frame. `stats.resyncs` counts ATTEMPTS either way
237
+ * (MINOR-1 contract: "the resync attempt still counts"). */
238
+ function resyncAttempt(): boolean {
208
239
  stats.resyncs += 1;
240
+ const fromSeq = seq;
241
+ if (!rootBuild()) return false;
209
242
  stats.frames += 1;
210
243
  const frame: EventsStateFrame = { fromSeq, toSeq: seq, events: [...events], version, resync: true };
211
244
  stats.eventsDelivered += frame.events.length;
212
245
  emit(frame);
246
+ return true;
247
+ }
248
+
249
+ function resync(): EventsStateSnapshot {
250
+ resyncAttempt();
213
251
  return snapshot();
214
252
  }
215
253
 
@@ -224,10 +262,11 @@ function createEventsStateSource(
224
262
  try {
225
263
  const stat = fs.statSync(eventsPath);
226
264
  if (lastIno !== -1 && (stat.ino !== lastIno || stat.size < lastSize)) {
227
- lastIno = stat.ino;
228
- lastSize = stat.size;
229
- resync();
230
- return 1;
265
+ // O-7: do NOT stamp the NEW inode/size before the resync — if the
266
+ // scan inside fails, the OLD stamps are exactly what makes the
267
+ // next poll re-detect the rewrite and retry it. rootBuild()
268
+ // stamps after a successful scan.
269
+ return resyncAttempt() ? 1 : 0;
231
270
  }
232
271
  lastIno = stat.ino;
233
272
  lastSize = stat.size;
@@ -262,8 +301,8 @@ function createEventsStateSource(
262
301
  backlog = false;
263
302
  }
264
303
  if (backlog) {
265
- resync();
266
- return committed + 1;
304
+ // O-7: count the resync frame only when the root scan succeeded.
305
+ return committed + (resyncAttempt() ? 1 : 0);
267
306
  }
268
307
  break;
269
308
  }
@@ -86,6 +86,9 @@ export class CrewAgentPane {
86
86
  private readonly paneOptions: CrewAgentPaneOptions | undefined;
87
87
  /** Current agent target, read fresh from the panel store each render. */
88
88
  private currentTaskId: string | undefined;
89
+ /** Run of {@link currentTaskId} — F3: the pane's switch guard is run-scoped
90
+ * so the same-named task in another run resets the transcript state. */
91
+ private currentRunId: string | undefined;
89
92
  /** Cached manifest for the current run, to avoid re-reading on every tick. */
90
93
  private cachedRunId: string | undefined;
91
94
  private cachedManifest: TeamRunManifest | undefined;
@@ -294,11 +297,15 @@ export class CrewAgentPane {
294
297
  const viewed = getViewedAgent();
295
298
  if (!viewed) return [];
296
299
 
297
- // Detect agent switch: reset the transcript cursor so the pane shows
298
- // the new agent's full history instead of continuing from the old cursor.
299
- if (this.currentTaskId !== viewed.taskId) {
300
- resetAgentTranscriptCursor(viewed.taskId);
300
+ // Detect agent switch (F3: run OR task — task ids are positional per
301
+ // run, so a taskId-only guard let a same-named task in another run
302
+ // reuse the stale cursor/buffer): reset the transcript cursor so the
303
+ // pane shows the new agent's full history instead of continuing from
304
+ // the old cursor.
305
+ if (this.currentTaskId !== viewed.taskId || this.currentRunId !== viewed.runId) {
306
+ resetAgentTranscriptCursor(viewed.runId, viewed.taskId);
301
307
  this.currentTaskId = viewed.taskId;
308
+ this.currentRunId = viewed.runId;
302
309
  this.scrollBack = 0;
303
310
  this.lastItems = [];
304
311
  this.lastTranscriptReadAt = 0;
@@ -371,8 +378,8 @@ export class CrewAgentPane {
371
378
  this.unsubscribePanel();
372
379
  this.cachedManifest = undefined;
373
380
  this.cachedRunId = undefined;
374
- // Free the per-task transcript ring buffer (≤500 items with full
381
+ // Free the per-run::task transcript ring buffer (≤500 items with full
375
382
  // message bodies) instead of retaining it for process lifetime.
376
- if (this.currentTaskId) resetAgentTranscriptCursor(this.currentTaskId);
383
+ if (this.currentRunId && this.currentTaskId) resetAgentTranscriptCursor(this.currentRunId, this.currentTaskId);
377
384
  }
378
385
  }
@@ -13,8 +13,8 @@
13
13
  * detached spawner, so a memory feed would show an empty pane for exactly the
14
14
  * runs users background. Disk works for both with one parser.
15
15
  *
16
- * Per task, a module-level ring buffer accumulates parsed items across reads
17
- * (cursor + persisted parse state), mirroring pi-subtask's `fork.transcript`.
16
+ * Per run::task (F3), a module-level ring buffer accumulates parsed items across
17
+ * reads (cursor + persisted parse state), mirroring pi-subtask's `fork.transcript`.
18
18
  * This matters for two reasons the naive delta-read gets wrong:
19
19
  * - tool `start`/`end` events can straddle a read boundary, so the
20
20
  * unmatched-start map must survive between reads;
@@ -83,13 +83,20 @@ export type CrewTranscriptItem =
83
83
 
84
84
  type PendingTool = CrewTranscriptItem & { type: "tool" };
85
85
 
86
- /** Per-task parse state, persisted across reads. */
86
+ /** Per-run::task parse state, persisted across reads (F3: keyed by run —
87
+ * task ids are positional per run, so a taskId-only key would let the same
88
+ * task name in a LATER run reuse the earlier run's cursor/buffer). */
87
89
  const buffers = new Map<string, CrewTranscriptItem[]>();
88
90
  const pendingByTask = new Map<string, Map<string, PendingTool>>();
89
91
  const cursors = new Map<string, number>();
90
- /** Tasks whose worker prompt has been prepended to the buffer. */
92
+ /** Run::task keys whose worker prompt has been prepended to the buffer. */
91
93
  const promptSeeded = new Set<string>();
92
94
 
95
+ /** F3 (DR-02 wave 2): module-state key — `${runId}::${taskId}`. */
96
+ function transcriptStateKey(runId: string | undefined, taskId: string): string {
97
+ return `${runId ?? ""}::${taskId}`;
98
+ }
99
+
93
100
  /**
94
101
  * The child pi never logs its INITIAL user message (input is not an event),
95
102
  * so the transcript would open on the first assistant message — unlike a
@@ -287,14 +294,15 @@ function matchPending(
287
294
  * cursor skips re-parse and the existing buffer is returned as-is.
288
295
  */
289
296
  export function readAgentTranscript(manifest: TeamRunManifest, taskId: string): CrewTranscriptItem[] {
290
- const sinceSeq = cursors.get(taskId) ?? 0;
297
+ const key = transcriptStateKey(manifest.runId, taskId);
298
+ const sinceSeq = cursors.get(key) ?? 0;
291
299
  const { events, nextSeq } = readCrewAgentEventsCursor(manifest, taskId, { sinceSeq });
292
- if (nextSeq > sinceSeq) cursors.set(taskId, nextSeq);
300
+ if (nextSeq > sinceSeq) cursors.set(key, nextSeq);
293
301
 
294
- let buffer = buffers.get(taskId) ?? [];
302
+ let buffer = buffers.get(key) ?? [];
295
303
  if (events.length > 0) {
296
- const pending = pendingByTask.get(taskId) ?? new Map<string, PendingTool>();
297
- pendingByTask.set(taskId, pending);
304
+ const pending = pendingByTask.get(key) ?? new Map<string, PendingTool>();
305
+ pendingByTask.set(key, pending);
298
306
 
299
307
  for (const record of events) {
300
308
  const parsed = asRecord(record);
@@ -302,26 +310,29 @@ export function readAgentTranscript(manifest: TeamRunManifest, taskId: string):
302
310
  buffer.push(...parseEventRecord(parsed, pending));
303
311
  }
304
312
  }
305
- if (buffer.length > 0 && !promptSeeded.has(taskId)) {
313
+ if (buffer.length > 0 && !promptSeeded.has(key)) {
306
314
  const prompt = readWorkerPrompt(manifest, taskId);
307
315
  if (prompt !== undefined) {
308
- promptSeeded.add(taskId);
316
+ promptSeeded.add(key);
309
317
  buffer.unshift({ type: "user", text: prompt, seq: 0 });
310
318
  }
311
319
  }
312
320
  if (buffer.length > MAX_TRANSCRIPT_ITEMS) {
313
321
  buffer = buffer.slice(buffer.length - MAX_TRANSCRIPT_ITEMS);
314
322
  }
315
- buffers.set(taskId, buffer);
323
+ buffers.set(key, buffer);
316
324
  return buffer;
317
325
  }
318
326
 
319
- /** Drop everything learned about a task (used when the pane switches agents). */
320
- export function resetAgentTranscriptCursor(taskId: string): void {
321
- cursors.delete(taskId);
322
- buffers.delete(taskId);
323
- pendingByTask.delete(taskId);
324
- promptSeeded.delete(taskId);
327
+ /** Drop everything learned about a run's task (used when the pane switches
328
+ * agents or runs — F3: scoped to the run so switching runs never leaves the
329
+ * old run's ring buffer reachable under the new one's key). */
330
+ export function resetAgentTranscriptCursor(runId: string, taskId: string): void {
331
+ const key = transcriptStateKey(runId, taskId);
332
+ cursors.delete(key);
333
+ buffers.delete(key);
334
+ pendingByTask.delete(key);
335
+ promptSeeded.delete(key);
325
336
  }
326
337
 
327
338
  /** Clear all per-task state (session teardown / test isolation). */
@@ -332,7 +343,8 @@ export function resetAllAgentTranscriptCursors(): void {
332
343
  promptSeeded.clear();
333
344
  }
334
345
 
335
- /** Test-only: whether the module currently holds state for a task. */
336
- export function __hasAgentTranscriptState(taskId: string): boolean {
337
- return buffers.has(taskId) || cursors.has(taskId) || pendingByTask.has(taskId);
346
+ /** Test-only: whether the module currently holds state for a run's task. */
347
+ export function __hasAgentTranscriptState(runId: string, taskId: string): boolean {
348
+ const key = transcriptStateKey(runId, taskId);
349
+ return buffers.has(key) || cursors.has(key) || pendingByTask.has(key);
338
350
  }
@@ -37,11 +37,25 @@ export interface CrewAgentOverlayOptions {
37
37
  const INPUT_PROMPT = " ❯ ";
38
38
 
39
39
  /** Printable single keypress or paste chunk: no control bytes, no ESC
40
- * sequences (arrow keys etc. must not land in the steer text). */
41
- function isPrintableInput(data: string): boolean {
40
+ * sequences (arrow keys etc. must not land in the steer text).
41
+ *
42
+ * R7-14 (DR-04): shared with mailbox-compose-overlay — its free-text
43
+ * passthrough previously accepted only single chars, so a multi-char
44
+ * bracketed-paste chunk ("hello world") was dropped. */
45
+ export function isPrintableInput(data: string): boolean {
42
46
  return data.length > 0 && !data.startsWith("\x1b") && !/[\x00-\x1f\x7f]/.test(data);
43
47
  }
44
48
 
49
+ /** O-4 (DR-04): surrogate-safe backspace for editors that edit at
50
+ * end-of-value. When the LAST UTF-16 unit is a low surrogate it completes a
51
+ * pair — remove BOTH units so astral characters (emoji) never split into a
52
+ * stray high surrogate / U+FFFD. Shared with mailbox-compose-overlay. */
53
+ export function backspaceChar(text: string): string {
54
+ const last = text.length > 0 ? text.charCodeAt(text.length - 1) : 0;
55
+ if (last >= 0xdc00 && last <= 0xdfff && text.length >= 2) return text.slice(0, -2);
56
+ return text.slice(0, -1);
57
+ }
58
+
45
59
  export class CrewAgentOverlay {
46
60
  /** The transcript core; exposed so the host can wire scroll/steer keys. */
47
61
  readonly pane: CrewAgentPane;
@@ -136,7 +150,8 @@ export class CrewAgentOverlay {
136
150
  } else if (matchesKey(data, "return")) {
137
151
  this.sendSteer();
138
152
  } else if (matchesKey(data, "backspace")) {
139
- this.inputText = this.inputText.slice(0, -1);
153
+ // O-4: astral-safe backspace (shared helper).
154
+ this.inputText = backspaceChar(this.inputText);
140
155
  } else if (data === "\x03") {
141
156
  // ctrl+c inside steer input cancels the input, not the view.
142
157
  this.inputMode = false;
@@ -47,6 +47,13 @@ const PANE_LIVE_TICK_MS = 700;
47
47
 
48
48
  let livePane: CrewAgentPane | undefined;
49
49
  let liveOverlay: CrewAgentOverlay | undefined;
50
+ /** O-6 (DR-04 R7): double-mount guard — true between the `ctx.ui.custom()`
51
+ * call and the moment its factory actually runs (or the call rejects). A
52
+ * second openPane in exactly that window (key-mash, dock re-entry, rapid
53
+ * `/crew-view`) would otherwise mount a SECOND full-screen overlay on top
54
+ * of the first. Cleared by the factory (overlay live), by rejection (state
55
+ * reset below) and on the custom() promise settling (overlay closed). */
56
+ let paneMounting = false;
50
57
  let paneTickTimer: ReturnType<typeof setInterval> | undefined;
51
58
  let editorInstalled = false;
52
59
  /** Guards the one-time per-process event hooks. */
@@ -124,7 +131,8 @@ function notifyResult(ctx: ExtensionContext, result: PiTeamsToolResult): void {
124
131
  */
125
132
  function openPane(ctx: ExtensionContext, target: PanelTarget): void {
126
133
  setViewedAgent(target);
127
- if (!liveOverlay) {
134
+ if (!liveOverlay && !paneMounting) {
135
+ paneMounting = true;
128
136
  try {
129
137
  void ctx.ui
130
138
  .custom(
@@ -145,6 +153,9 @@ function openPane(ctx: ExtensionContext, target: PanelTarget): void {
145
153
  });
146
154
  liveOverlay = overlay;
147
155
  livePane = overlay.pane;
156
+ // O-6: the factory ran — the mount window is over; from here on
157
+ // the `!liveOverlay` check alone owns re-entry.
158
+ paneMounting = false;
148
159
  return overlay as never;
149
160
  },
150
161
  {
@@ -152,10 +163,17 @@ function openPane(ctx: ExtensionContext, target: PanelTarget): void {
152
163
  overlayOptions: { width: "100%", margin: 0, maxHeight: "100%", anchor: "top-left" },
153
164
  },
154
165
  )
166
+ .then(() => {
167
+ // Resolves when the host tears the overlay down (done()) — the
168
+ // mount window is definitely over (belt and braces for a host
169
+ // that never invoked the factory).
170
+ paneMounting = false;
171
+ })
155
172
  .catch((error) => {
156
173
  // Factory throw or the host tearing the overlay down: drop the
157
174
  // view state so the dock does not point at a view that no
158
175
  // longer exists.
176
+ paneMounting = false;
159
177
  liveOverlay = undefined;
160
178
  livePane = undefined;
161
179
  setViewedAgent(undefined);
@@ -163,6 +181,7 @@ function openPane(ctx: ExtensionContext, target: PanelTarget): void {
163
181
  logInternalError("view.openOverlay", error, target.taskId);
164
182
  });
165
183
  } catch (error) {
184
+ paneMounting = false;
166
185
  // A stale ctx (session replaced between keypress and here) drops the
167
186
  // overlay spawn; the fallback editor path still closes cleanly.
168
187
  logInternalError("view.openPane", error, target.taskId);
@@ -326,6 +345,7 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
326
345
  export function __resetInlinePanelForTest(): void {
327
346
  editorInstalled = false;
328
347
  liveOverlay = undefined;
348
+ paneMounting = false;
329
349
  stopPaneTicker();
330
350
  }
331
351
 
@@ -117,9 +117,10 @@ export class LiveRunSidebar {
117
117
  agents: ReturnType<typeof readCrewAgents>,
118
118
  waitingCount: number,
119
119
  snapshot?: RunUiSnapshot,
120
+ countdownBucket = "",
120
121
  ): string {
121
122
  const animation = agents.some((agent) => agent.status === "running") ? `:spin=${spinnerBucket()}` : "";
122
- if (snapshot) return `${snapshot.signature}:${waitingCount}${animation}`;
123
+ if (snapshot) return `${snapshot.signature}:${waitingCount}${animation}${countdownBucket}`;
123
124
  const taskSig = tasks
124
125
  .map(
125
126
  (task) =>
@@ -143,7 +144,17 @@ export class LiveRunSidebar {
143
144
  ].join(":"),
144
145
  )
145
146
  .join("|");
146
- return `${manifestStatus}|${agents.length}|${waitingCount}|${taskSig}|${agentSig}${animation}`;
147
+ return `${manifestStatus}|${agents.length}|${waitingCount}|${taskSig}|${agentSig}${animation}${countdownBucket}`;
148
+ }
149
+
150
+ /** F5 (DR-02 wave 2): while the terminal auto-close countdown is armed, a
151
+ * coarse per-second bucket joins the render signature — the base signature
152
+ * only changes on state, so the countdown line used to freeze inside
153
+ * cachedLines until some OTHER state changed. The close timer itself is
154
+ * untouched (L4 fixed-deadline semantics stay exactly as they were). */
155
+ private autoCloseSignatureFragment(): string {
156
+ if (this.hasAutoClosed || this.autoCloseDeadline === undefined) return "";
157
+ return `:close=${Math.ceil(Math.max(0, this.autoCloseDeadline - Date.now()) / 1000)}`;
147
158
  }
148
159
 
149
160
  private colorLine(line: string): string {
@@ -231,7 +242,7 @@ export class LiveRunSidebar {
231
242
  const active = agents.filter((agent) => agent.status === "running");
232
243
  const completed = agents.filter((agent) => agent.status !== "running").slice(-5);
233
244
  const waiting = tasks.filter((task) => task.status === "queued");
234
- const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
245
+ const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot, this.autoCloseSignatureFragment());
235
246
  if (signature !== this.cachedSignature || w !== this.cachedWidth) {
236
247
  // RAIL (§2.E): the rounded `╭─╮│╰─╯` box is retired — the surface is a
237
248
  // `┏ LIVE ▸ <run8>` canopy, `┃` body rows, `┣ SECTION` headers and a
@@ -26,6 +26,7 @@
26
26
  import * as piTui from "@earendil-works/pi-tui";
27
27
  import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
28
28
  import { pad, sanitizeLine, truncate } from "../../utils/visual.ts";
29
+ import { backspaceChar, isPrintableInput } from "../inline-panel/agent-view-overlay.ts";
29
30
  import { overlayActionForKey } from "../keybinding-map.ts";
30
31
  import { CURSOR, canopyLine, formatHint, RAIL, railLine, railRaw } from "../rail.ts";
31
32
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
@@ -169,7 +170,9 @@ export class MailboxComposeOverlay {
169
170
  if (field === "direction") return;
170
171
  this.fields = {
171
172
  ...this.fields,
172
- [field]: (this.fields[field] ?? "").slice(0, -1),
173
+ // O-4: astral-safe backspace (shared helper) — a trailing surrogate
174
+ // pair is one code point and must leave together.
175
+ [field]: backspaceChar(this.fields[field] ?? ""),
173
176
  };
174
177
  }
175
178
 
@@ -244,8 +247,11 @@ export class MailboxComposeOverlay {
244
247
  else this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
245
248
  return;
246
249
  }
247
- // Free-text passthrough (unchanged): any other printable char is typed
248
- // into the active field.
249
- if (data.length === 1 && data >= " ") this.appendText(data);
250
+ // Free-text passthrough (R7-14): any other PRINTABLE input is typed into
251
+ // the active field — single chars AND multi-char paste chunks (the old
252
+ // `data.length === 1` guard dropped pasted "hello world" entirely).
253
+ // Shares the agent-view-overlay classifier: no control bytes, no ESC
254
+ // sequences, so arrow keys still never land in the field.
255
+ if (isPrintableInput(data)) this.appendText(data);
250
256
  }
251
257
  }
@@ -12,6 +12,7 @@ import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
12
12
  import { logInternalError } from "../utils/internal-error.ts";
13
13
  import { extractSessionId } from "../utils/session-utils.ts";
14
14
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
15
+ import { truncLine } from "./format-helpers.ts";
15
16
  import { RenderCoalescer } from "./render-coalescer.ts";
16
17
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
17
18
  import { notificationBadge } from "./widget/widget-formatters.ts";
@@ -158,7 +159,9 @@ export function buildStepsPayload(active: ActiveItem[], allTasks: TeamTaskState[
158
159
  const parts = current.phases.map((phase) => {
159
160
  const status = phase.status === "done" ? "completed" : statusOf(phase.itemIds);
160
161
  const icon = status === "completed" ? "✓" : status === "running" ? "→" : "○";
161
- const name = phase.title.length > 10 ? `${phase.title.slice(0, 9)}…` : phase.title;
162
+ // O-5 (DR-04): grapheme-aware truncation — the ad-hoc slice(0, 9)
163
+ // split surrogate pairs (emoji phase titles → stray U+FFFD).
164
+ const name = truncLine(phase.title, 10);
162
165
  return `${icon}${name}`;
163
166
  });
164
167
  if (parts.length) {
@@ -196,8 +199,8 @@ export function buildStepsPayload(active: ActiveItem[], allTasks: TeamTaskState[
196
199
  for (const step of workflow.steps) {
197
200
  const status = stepStatus.get(step.id) ?? "pending";
198
201
  const icon = status === "completed" ? "✓" : status === "running" ? "→" : "○";
199
- // Shorten long step names
200
- const stepName = step.id.length > 10 ? step.id.slice(0, 9) + "…" : step.id;
202
+ // Shorten long step names (O-5: grapheme-aware — slice(0, 9) split emoji).
203
+ const stepName = truncLine(step.id, 10);
201
204
  stepParts.push(`${icon}${stepName}`);
202
205
  }
203
206
  const stepsText = stepParts.join(" › ");
package/src/ui/rail.ts CHANGED
@@ -107,7 +107,12 @@ export function shortId(id: string | undefined): string {
107
107
 
108
108
  export type RailSlot = "border" | "borderAccent" | "success" | "error" | "warning";
109
109
 
110
- /** Status → rail colour. One mapping for every surface (was: per-surface maps). */
110
+ /** Status → rail colour. One mapping for every surface (was: per-surface maps).
111
+ * F4 (DR-02 wave 2): "blocked" joins the warning tier — parity with
112
+ * status-colors.ts (colorForStatus("blocked") === "warning"), so the rail no
113
+ * longer falls to the neutral border while the glyph next to it is painted
114
+ * warning. "planning" stays in the default border tier (informational/dim —
115
+ * same tier status-colors assigns it). */
111
116
  export function statusSlot(status: string | undefined): RailSlot {
112
117
  switch (status) {
113
118
  case "completed":
@@ -128,6 +133,7 @@ export function statusSlot(status: string | undefined): RailSlot {
128
133
  return "borderAccent";
129
134
  case "waiting":
130
135
  case "needs_attention":
136
+ case "blocked":
131
137
  case "stale":
132
138
  case "pending":
133
139
  return "warning";
@@ -28,6 +28,16 @@ export interface RenderSchedulerOptions {
28
28
  * count and re-arms the loop. Defaults to 8.
29
29
  */
30
30
  maxIdleFallbackRenders?: number;
31
+ /**
32
+ * R7-12 (DR-04): maximum time (ms) a PENDING render may be starved by a
33
+ * sustained event stream. While events keep arriving faster than
34
+ * `debounceMs`, every schedule() re-arms the debounce timer and the flush
35
+ * would otherwise be postponed forever. At the cap the pending batch is
36
+ * force-flushed — from schedule() itself and, as a safety net, from the
37
+ * fallback loop. Defaults to `max(3 × debounceMs, 250)`, floored at 250 so
38
+ * an explicit small override can never disable the cap entirely.
39
+ */
40
+ maxDelayMs?: number;
31
41
  }
32
42
 
33
43
  const DEFAULT_EVENTS = [
@@ -61,6 +71,8 @@ export class RenderScheduler {
61
71
  private readonly fallbackMs: number;
62
72
  private readonly invalidateCoalesceMs: number;
63
73
  private readonly maxIdleFallbackRenders: number;
74
+ /** R7-12: pending-starvation cap — see {@link RenderSchedulerOptions.maxDelayMs}. */
75
+ private readonly maxDelayMs: number;
64
76
  private debounceTimer: ReturnType<typeof setTimeout> | undefined;
65
77
  private fallbackTimer: ReturnType<typeof setTimeout> | undefined;
66
78
  private invalidateTimer: ReturnType<typeof setTimeout> | undefined;
@@ -74,6 +86,9 @@ export class RenderScheduler {
74
86
  private idleFallbackRenders = 0;
75
87
  /** R4: consecutive re-entrancy cap-hits — drives exponential backoff. */
76
88
  private consecutiveCapHits = 0;
89
+ /** R7-12: when the CURRENT pending batch was first scheduled (undefined =
90
+ * nothing pending). Reset whenever a flush actually renders. */
91
+ private pendingSince: number | undefined;
77
92
  private readonly unsubs: Array<() => void> = [];
78
93
 
79
94
  constructor(events: RenderSchedulerEventBus | undefined, render: () => void, options: RenderSchedulerOptions = {}) {
@@ -85,6 +100,7 @@ export class RenderScheduler {
85
100
  this.fallbackMs = typeof fallback === "number" ? fallback : 750;
86
101
  this.invalidateCoalesceMs = options.invalidateCoalesceMs ?? 50;
87
102
  this.maxIdleFallbackRenders = options.maxIdleFallbackRenders ?? 8;
103
+ this.maxDelayMs = Math.max(options.maxDelayMs ?? 3 * this.debounceMs, 250);
88
104
  for (const event of options.events ?? DEFAULT_EVENTS) this.subscribe(events, event);
89
105
  this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.currentFallbackMs());
90
106
  this.fallbackTimer.unref();
@@ -118,6 +134,15 @@ export class RenderScheduler {
118
134
  if (Date.now() - this.lastEventAt < fallbackMs) {
119
135
  // A real external event arrived recently — not idle. Keep polling.
120
136
  this.idleFallbackRenders = 0;
137
+ // R7-12: BUT a sustained stream (events faster than debounceMs) keeps
138
+ // re-arming the debounce and the pending render starves. When the
139
+ // pending batch has waited past the max-delay cap, flush it HERE —
140
+ // this loop is the safety net for exactly that starvation (it used
141
+ // to refuse to render while any event was recent).
142
+ if (this.pendingSince !== undefined && Date.now() - this.pendingSince >= this.maxDelayMs) {
143
+ this.cancelDebounce();
144
+ this.flush();
145
+ }
121
146
  this.fallbackTimer = setTimeout(() => this.fallbackLoop(), fallbackMs);
122
147
  this.fallbackTimer.unref();
123
148
  return;
@@ -145,7 +170,17 @@ export class RenderScheduler {
145
170
  this.idleFallbackRenders = 0;
146
171
  this.consecutiveCapHits = 0;
147
172
  this.invalidate(payload);
148
- this.armDebouncedRender();
173
+ // R7-12: mark when the CURRENT pending batch started (first schedule
174
+ // since the last actual render).
175
+ if (this.pendingSince === undefined) this.pendingSince = this.lastEventAt;
176
+ if (this.lastEventAt - this.pendingSince >= this.maxDelayMs) {
177
+ // Force flush NOW, even mid-burst: cancel the pending debounce timer
178
+ // (its render would be redundant) and render synchronously.
179
+ this.cancelDebounce();
180
+ this.flush();
181
+ } else {
182
+ this.armDebouncedRender();
183
+ }
149
184
  // R1: if the fallback loop stopped itself after a sustained idle period,
150
185
  // a real event means activity resumed — re-arm the safety-net loop.
151
186
  if (!this.fallbackTimer) {
@@ -169,6 +204,13 @@ export class RenderScheduler {
169
204
  this.debounceTimer.unref();
170
205
  }
171
206
 
207
+ /** R7-12: cancel a pending debounce flush (its render would be redundant
208
+ * next to a force-flush). Does not touch lastEventAt or pendingSince. */
209
+ private cancelDebounce(): void {
210
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
211
+ this.debounceTimer = undefined;
212
+ }
213
+
172
214
  /**
173
215
  * 1.9: forward `onInvalidate` immediately when the payload has no `runId`
174
216
  * (we cannot dedup it). When it carries a runId, buffer the latest payload
@@ -229,6 +271,10 @@ export class RenderScheduler {
229
271
  }
230
272
  this.rendering = true;
231
273
  this.pendingRender = false;
274
+ // R7-12: this flush satisfies the current pending batch — the next
275
+ // starvation window starts at the NEXT schedule() (set after this, so a
276
+ // render() that synchronously schedules() still marks a fresh batch).
277
+ this.pendingSince = undefined;
232
278
  let iterations = 0;
233
279
  try {
234
280
  do {
@@ -254,6 +300,12 @@ export class RenderScheduler {
254
300
  } else {
255
301
  this.consecutiveCapHits = 0;
256
302
  }
303
+ // R7-12 note: the backoff drain above arms a LONG debounce while
304
+ // pendingRender is still true, but it never sets pendingSince — only
305
+ // real events (schedule()) do. Pathological render() re-entrancy
306
+ // without external events therefore still backs off undisturbed (R4
307
+ // invariant); the max-delay cap only bounds waits caused by real
308
+ // incoming events.
257
309
  }
258
310
  }
259
311