pi-crew 0.11.0 → 0.11.2

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 (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. package/src/ui/overlay-stack.ts +0 -148
@@ -18,6 +18,12 @@
18
18
  * for zero benefit. The input-guard half of L2 is therefore intentionally
19
19
  * skipped; only the DRY/data-driven dispatch refactor landed.
20
20
  *
21
+ * M2-1 (P1-9) adds the sibling `overlay:*` keyspace below. The migrated
22
+ * overlays dispatch through `overlayActionForKey("<name>", data)` — the same
23
+ * data-driven + override pipeline as the dashboard, but a SEPARATE namespace:
24
+ * an overlay never consults `dashboardActionForKey`, so the no-leak argument
25
+ * above is unchanged.
26
+ *
21
27
  * Origin pattern: deer-flow `frontend/src/components/workspace/command-palette.tsx:39-50`
22
28
  * drives shortcuts from a single data array consumed by one loop in
23
29
  * `use-global-shortcuts.ts:38-61`.
@@ -27,6 +33,7 @@ import * as fs from "node:fs";
27
33
  import * as path from "node:path";
28
34
  import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
29
35
  import { getCrewEnv } from "../config/env-vars.ts";
36
+ import { projectCrewRoot } from "../utils/paths.ts";
30
37
  import { keyOf } from "./key-utils.ts";
31
38
 
32
39
  export const DASHBOARD_KEYS = {
@@ -35,6 +42,8 @@ export const DASHBOARD_KEYS = {
35
42
  help: ["?"],
36
43
  root: {
37
44
  summary: ["u"],
45
+ /** US-020: cancel the selected run (2-step confirm in the dashboard). */
46
+ cancel: ["x"],
38
47
  artifacts: ["a"],
39
48
  api: ["i"],
40
49
  agents: ["d"],
@@ -44,7 +53,6 @@ export const DASHBOARD_KEYS = {
44
53
  transcript: ["v"],
45
54
  liveConversation: ["V"],
46
55
  reload: ["r"],
47
- progressToggle: ["p"],
48
56
  browser: ["b"],
49
57
  },
50
58
  pane: {
@@ -106,8 +114,8 @@ export type DashboardKeyAction =
106
114
  | "transcript"
107
115
  | "live-conversation"
108
116
  | "reload"
109
- | "progressToggle"
110
117
  | "browser"
118
+ | "cancel"
111
119
  | "pane-agents"
112
120
  | "pane-progress"
113
121
  | "pane-mailbox"
@@ -152,11 +160,13 @@ export type DashboardKeyAction =
152
160
  *
153
161
  * NOTE: mailbox action keys A/N/C/P/X (ack/nudge/compose/preview/ackAll) are
154
162
  * intentionally NOT dispatched for the mailbox pane by this table. They live
155
- * in `DASHBOARD_KEYS.mailbox` for reservation but are handled by the mailbox
156
- * overlay's own `handleInput`, not by the dashboard dispatch. The `plan`
157
- * group reuses uppercase "A" (approve) pane-scoped to "progress" — it never
158
- * fires while the mailbox pane (or the mailbox-detail overlay) owns input,
159
- * so `mailbox.ack` behavior is unchanged.
163
+ * in `DASHBOARD_KEYS.mailbox` for reservation and are resolved by the mailbox
164
+ * overlays' own input handling — since M2-1 through the sibling `overlay:*`
165
+ * keyspace (`OVERLAY_KEYS["mailbox-detail"]` / `OVERLAY_KEYS["mailbox-compose"]`),
166
+ * never by `dashboardActionForKey`. The `plan` group reuses uppercase "A"
167
+ * (approve) pane-scoped to "progress" — it never fires while the mailbox pane
168
+ * (or the mailbox-detail overlay) owns input, so `mailbox.ack` behavior is
169
+ * unchanged.
160
170
  */
161
171
  const DEFAULT_BINDINGS: readonly KeyBinding[] = [
162
172
  { keys: DASHBOARD_KEYS.close, action: "close" },
@@ -245,12 +255,14 @@ const DEFAULT_BINDINGS: readonly KeyBinding[] = [
245
255
  { keys: DASHBOARD_KEYS.root.transcript, action: "transcript" },
246
256
  { keys: DASHBOARD_KEYS.root.liveConversation, action: "live-conversation" },
247
257
  { keys: DASHBOARD_KEYS.root.reload, action: "reload" },
248
- { keys: DASHBOARD_KEYS.root.progressToggle, action: "progressToggle" },
249
258
  // Agents & Jobs browser (one-keypress overlay, mirrors live-conversation).
250
259
  // Collision analysis: "b" is unbound everywhere else — root-unscoped is
251
260
  // safe; inside the browser overlay itself "p" is free because overlays
252
261
  // are mutually exclusive (see keybinding-map.ts header note).
253
262
  { keys: DASHBOARD_KEYS.root.browser, action: "browser" },
263
+ // US-020: x → cancel the selected run (unscoped; the schedules pane's X
264
+ // delete is pane-scoped uppercase — pass-1 exact match keeps them distinct).
265
+ { keys: DASHBOARD_KEYS.root.cancel, action: "cancel" },
254
266
  { keys: DASHBOARD_KEYS.pane.agents, action: "pane-agents" },
255
267
  { keys: DASHBOARD_KEYS.pane.progress, action: "pane-progress" },
256
268
  { keys: DASHBOARD_KEYS.pane.mailbox, action: "pane-mailbox" },
@@ -289,6 +301,103 @@ const KEY_RESERVED = new Set<string>([
289
301
 
290
302
  export { KEY_RESERVED };
291
303
 
304
+ // ─── Overlay keybindings (P1-9 / M2-1) ─────────────────────────────────────
305
+ //
306
+ // Overlays used to hardcode their own key chains — the SAME
307
+ // "↑/↓/Enter/Esc" concept re-implemented in every overlay file, none of them
308
+ // remappable. M2-1 centralises them into an `overlay:<name>:<action>`
309
+ // keyspace that rides the SAME override pipeline as the dashboard table
310
+ // above: `.crew/config.json` → `keybindings` and `PI_CREW_KEYBINDINGS` both
311
+ // accept `"overlay:<name>:<action>": ["<key>", …]`, a colliding override is
312
+ // reverted to its default, and the revert is reported by
313
+ // `getKeybindingOverrideWarnings()`.
314
+ //
315
+ // Overlay bindings are validated INDEPENDENTLY of dashboard bindings: the two
316
+ // namespaces are never live at the same time (the host hands input to exactly
317
+ // one component — an open overlay or the dashboard), so a shared key such as
318
+ // `q`/`escape`/`A` is not a real ambiguity. Within one overlay a shared key IS
319
+ // ambiguous and is treated as a collision.
320
+
321
+ export const OVERLAY_KEYS = {
322
+ "agent-picker": {
323
+ close: ["escape", "q"],
324
+ up: ["k", "up"],
325
+ down: ["j", "down"],
326
+ select: ["return"],
327
+ },
328
+ confirm: {
329
+ confirm: ["y", "Y"],
330
+ /** Enter is dual-role: the overlay resolves it with `defaultAction`. */
331
+ submit: ["return"],
332
+ cancel: ["n", "N", "escape", "q"],
333
+ },
334
+ "mailbox-detail": {
335
+ close: ["escape", "q"],
336
+ toggleSide: ["tab", "\t"],
337
+ up: ["k", "up"],
338
+ down: ["j", "down"],
339
+ toggleDetail: ["return"],
340
+ ack: ["A"],
341
+ nudge: ["N"],
342
+ compose: ["C"],
343
+ ackAll: ["X"],
344
+ },
345
+ "mailbox-compose": {
346
+ cancel: ["escape"],
347
+ preview: ["P"],
348
+ nextField: ["tab", "\t"],
349
+ space: [" "],
350
+ backspace: ["backspace"],
351
+ submit: ["return"],
352
+ },
353
+ } as const;
354
+
355
+ type OverlayDefs = typeof OVERLAY_KEYS;
356
+
357
+ /** Overlay namespaces that opt into the central keybinding map. */
358
+ export type OverlayName = keyof OverlayDefs;
359
+
360
+ /** Every action across every overlay (union). */
361
+ export type OverlayAction = { [N in OverlayName]: keyof OverlayDefs[N] & string }[OverlayName];
362
+
363
+ /** Actions of ONE overlay — the precise return type of `overlayActionForKey`. */
364
+ export type OverlayActionOf<N extends OverlayName> = keyof OverlayDefs[N] & string;
365
+
366
+ /** Override key as written in config/env: `overlay:<name>:<action>`. */
367
+ export type OverlayBindingKey = { [N in OverlayName]: `overlay:${N & string}:${keyof OverlayDefs[N] & string}` }[OverlayName];
368
+
369
+ /** A resolved overlay binding (one action of one overlay). */
370
+ export interface OverlayBinding {
371
+ readonly overlay: OverlayName;
372
+ readonly action: OverlayAction;
373
+ readonly keys: readonly string[];
374
+ }
375
+
376
+ /** Compose the config/env override key for one overlay action. */
377
+ function overlayBindingKey(overlay: OverlayName, action: OverlayAction): OverlayBindingKey {
378
+ return `overlay:${overlay}:${action}` as OverlayBindingKey;
379
+ }
380
+
381
+ // `Object.entries` widens the const-typed tables; this cast restores the exact
382
+ // shape (no `any`; the `OverlayBinding` annotation below still checks it).
383
+ type OverlayTableEntry = readonly [OverlayName, Readonly<Record<string, readonly string[]>>];
384
+
385
+ const OVERLAY_TABLE = Object.entries(OVERLAY_KEYS) as readonly OverlayTableEntry[];
386
+
387
+ /** Every valid `overlay:<name>:<action>` override key. */
388
+ const OVERLAY_BINDING_KEYS: ReadonlySet<string> = new Set(
389
+ OVERLAY_TABLE.flatMap(([overlay, actions]) =>
390
+ Object.keys(actions).map((action) => overlayBindingKey(overlay, action as OverlayAction)),
391
+ ),
392
+ );
393
+
394
+ const DEFAULT_OVERLAY_BINDINGS: ReadonlyMap<OverlayName, readonly OverlayBinding[]> = new Map<OverlayName, readonly OverlayBinding[]>(
395
+ OVERLAY_TABLE.map(([overlay, actions]) => [
396
+ overlay,
397
+ Object.entries(actions).map(([action, keys]): OverlayBinding => ({ overlay, action: action as OverlayAction, keys })),
398
+ ]),
399
+ );
400
+
292
401
  // ─── Keybinding overrides (UI-2) ───────────────────────────────────────────
293
402
  //
294
403
  // The hardcoded DEFAULT_BINDINGS above can be overridden per-action via two
@@ -309,13 +418,17 @@ export { KEY_RESERVED };
309
418
  // first-match-wins dispatch unambiguous — a shadowed override never silently
310
419
  // changes behaviour.
311
420
 
312
- /** Override map: action → replacement keys. `Partial` ⇒ only listed actions. */
313
- export type KeybindingOverride = Partial<Record<DashboardKeyAction, readonly string[]>>;
421
+ /** Override map: action → replacement keys. `Partial` ⇒ only listed actions.
422
+ * Keys are dashboard actions plus `overlay:<name>:<action>` binding keys. */
423
+ export type KeybindingOverride = Partial<Record<DashboardKeyAction | OverlayBindingKey, readonly string[]>>;
314
424
 
315
425
  const KEYBINDINGS_ENV = "PI_CREW_KEYBINDINGS";
316
426
 
317
- /** Every dispatched action is a valid override target. */
318
- const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set(DEFAULT_BINDINGS.map((b) => b.action));
427
+ /** Every dispatched action + every `overlay:*` binding is a valid override target. */
428
+ export const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set<string>([
429
+ ...DEFAULT_BINDINGS.map((b) => b.action),
430
+ ...OVERLAY_BINDING_KEYS,
431
+ ]);
319
432
 
320
433
  /** Coerce an unknown parsed value into a safe {@link KeybindingOverride}. */
321
434
  function parseKeybindingOverride(raw: unknown): KeybindingOverride {
@@ -355,48 +468,100 @@ function paneScopesCompatible(a: PaneScope | undefined, b: PaneScope | undefined
355
468
 
356
469
  interface EffectiveBindingsResult {
357
470
  readonly bindings: readonly KeyBinding[];
358
- /** Actions whose override was rejected due to a collision. */
359
- readonly reverted: readonly DashboardKeyAction[];
471
+ /** Resolved bindings per overlay (defaults + non-colliding overrides). */
472
+ readonly overlayBindings: ReadonlyMap<OverlayName, readonly OverlayBinding[]>;
473
+ /** Override targets whose override was rejected due to a collision. */
474
+ readonly reverted: readonly string[];
360
475
  }
361
476
 
362
477
  /**
363
- * Apply `overrides` onto {@link DEFAULT_BINDINGS} (replace keys per action,
364
- * preserving each action's pane scope) and detect collisions. A colliding
365
- * override is reverted to its default so the dispatch stays unambiguous.
478
+ * Does `candidate` share a key with any `other` binding that could be live at
479
+ * the same time? Shared by the dashboard and the overlay namespaces so the
480
+ * collision rule (and its revert semantics) has ONE implementation.
481
+ */
482
+ function collidesWithOthers<B extends { readonly keys: readonly string[]; readonly action: string }>(
483
+ candidate: B,
484
+ others: readonly B[],
485
+ compatible: (a: B, b: B) => boolean,
486
+ ): boolean {
487
+ for (const other of others) {
488
+ if (other.action === candidate.action) continue;
489
+ if (!compatible(candidate, other)) continue;
490
+ if (candidate.keys.some((k) => other.keys.includes(k))) return true;
491
+ }
492
+ return false;
493
+ }
494
+
495
+ /**
496
+ * Apply `overrides` onto {@link DEFAULT_BINDINGS} + {@link DEFAULT_OVERLAY_BINDINGS}
497
+ * (replace keys per target, preserving each binding's pane scope / overlay) and
498
+ * detect collisions. A colliding override is reverted to its default so the
499
+ * dispatch stays unambiguous. Dashboard and overlay namespaces are validated
500
+ * separately (they are never live simultaneously).
366
501
  */
367
502
  function computeEffectiveBindings(overrides: KeybindingOverride): EffectiveBindingsResult {
503
+ // ── dashboard namespace (semantics unchanged since UI-2) ──
368
504
  const applied = new Map<DashboardKeyAction, KeyBinding>();
369
505
  for (const def of DEFAULT_BINDINGS) {
370
506
  const ov = overrides[def.action];
371
507
  applied.set(def.action, ov && ov.length > 0 ? { keys: [...ov], action: def.action, pane: def.pane } : def);
372
508
  }
373
509
  const effective = [...applied.values()];
374
- const reverted = new Set<DashboardKeyAction>();
510
+ const reverted = new Set<string>();
375
511
  for (const def of DEFAULT_BINDINGS) {
376
512
  const ov = overrides[def.action];
377
513
  if (!ov || ov.length === 0) continue; // not overridden
378
514
  const ob = applied.get(def.action);
379
515
  if (!ob) continue;
380
- for (const other of effective) {
381
- if (other.action === def.action) continue;
382
- if (!paneScopesCompatible(ob.pane, other.pane)) continue;
383
- if (ob.keys.some((k) => other.keys.includes(k))) {
384
- reverted.add(def.action);
385
- break;
386
- }
387
- }
516
+ if (collidesWithOthers(ob, effective, (a, b) => paneScopesCompatible(a.pane, b.pane))) reverted.add(def.action);
388
517
  }
389
518
  const bindings =
390
519
  reverted.size > 0
391
520
  ? effective.map((b) => (reverted.has(b.action) ? (DEFAULT_BINDINGS.find((d) => d.action === b.action) ?? b) : b))
392
521
  : effective;
393
- return { bindings, reverted: [...reverted] };
522
+
523
+ // ── overlay namespace (M2-1) ──
524
+ const overlayBindings = new Map<OverlayName, readonly OverlayBinding[]>();
525
+ for (const [overlay, defaults] of DEFAULT_OVERLAY_BINDINGS) {
526
+ const perOverlay: OverlayBinding[] = defaults.map((def) => {
527
+ const ov = overrides[overlayBindingKey(overlay, def.action)];
528
+ return ov && ov.length > 0 ? { overlay, action: def.action, keys: [...ov] } : def;
529
+ });
530
+ for (const def of defaults) {
531
+ const key = overlayBindingKey(overlay, def.action);
532
+ const ov = overrides[key];
533
+ if (!ov || ov.length === 0) continue; // not overridden
534
+ const ob = perOverlay.find((b) => b.action === def.action);
535
+ if (!ob) continue;
536
+ // Same overlay ⇒ same input lineage ⇒ any shared key is ambiguous.
537
+ if (collidesWithOthers(ob, perOverlay, () => true)) reverted.add(key);
538
+ }
539
+ if (reverted.size === 0) {
540
+ overlayBindings.set(overlay, perOverlay);
541
+ continue;
542
+ }
543
+ overlayBindings.set(
544
+ overlay,
545
+ perOverlay.map((b) =>
546
+ reverted.has(overlayBindingKey(overlay, b.action)) ? (defaults.find((d) => d.action === b.action) ?? b) : b,
547
+ ),
548
+ );
549
+ }
550
+
551
+ return { bindings, overlayBindings, reverted: [...reverted] };
394
552
  }
395
553
 
396
- /** Read the `keybindings` section from `<cwd>/.crew/config.json`. */
554
+ /**
555
+ * Read the `keybindings` section from the project config.
556
+ *
557
+ * RR-020 Fix 4: resolve via `projectCrewRoot(cwd)` instead of a literal
558
+ * `<cwd>/.crew` — the config lives in whichever layout the project actually
559
+ * uses (`.crew/` or `.pi/teams/`), so a `.pi/teams` project's keybinding
560
+ * overrides are no longer silently ignored.
561
+ */
397
562
  function readConfigKeybindings(cwd: string): KeybindingOverride {
398
563
  try {
399
- const raw: unknown = JSON.parse(fs.readFileSync(path.join(cwd, ".crew", "config.json"), "utf-8"));
564
+ const raw: unknown = JSON.parse(fs.readFileSync(path.join(projectCrewRoot(cwd), "config.json"), "utf-8"));
400
565
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
401
566
  return parseKeybindingOverride((raw as Record<string, unknown>).keybindings);
402
567
  } catch {
@@ -417,7 +582,8 @@ function readEnvKeybindings(): KeybindingOverride {
417
582
 
418
583
  function configKeybindingsMtime(cwd: string): number | undefined {
419
584
  try {
420
- return fs.statSync(path.join(cwd, ".crew", "config.json")).mtimeMs;
585
+ // RR-020 Fix 4: same layout-resolved config path as readConfigKeybindings.
586
+ return fs.statSync(path.join(projectCrewRoot(cwd), "config.json")).mtimeMs;
421
587
  } catch {
422
588
  return undefined;
423
589
  }
@@ -428,15 +594,17 @@ interface EffectiveCache {
428
594
  readonly configMtime: number | undefined;
429
595
  readonly cwd: string;
430
596
  readonly bindings: readonly KeyBinding[];
597
+ readonly overlayBindings: ReadonlyMap<OverlayName, readonly OverlayBinding[]>;
431
598
  }
432
599
 
433
600
  let _effectiveCache: EffectiveCache | null = null;
434
601
  let _overrideWarnings: readonly string[] = [];
435
602
 
436
603
  /**
437
- * Resolve the effective dispatch table: {@link DEFAULT_BINDINGS} with config +
438
- * env overrides applied (env wins per action). Memoised on (env value, config
439
- * mtime, cwd); a single `statSync` per call detects on-disk config changes.
604
+ * Resolve the effective dispatch tables: {@link DEFAULT_BINDINGS} and
605
+ * {@link DEFAULT_OVERLAY_BINDINGS} with config + env overrides applied (env
606
+ * wins per target). Memoised on (env value, config mtime, cwd); a single
607
+ * `statSync` per call detects on-disk config changes.
440
608
  */
441
609
  function getEffectiveBindings(cwd: string = process.cwd()): readonly KeyBinding[] {
442
610
  const envRaw = getCrewEnv(KEYBINDINGS_ENV);
@@ -445,12 +613,18 @@ function getEffectiveBindings(cwd: string = process.cwd()): readonly KeyBinding[
445
613
  return _effectiveCache.bindings;
446
614
  }
447
615
  const merged: KeybindingOverride = { ...readConfigKeybindings(cwd), ...readEnvKeybindings() };
448
- const { bindings, reverted } = computeEffectiveBindings(merged);
616
+ const { bindings, overlayBindings, reverted } = computeEffectiveBindings(merged);
449
617
  _overrideWarnings = reverted.map((a) => `keybinding override for '${a}' collides with another binding — reverting to default`);
450
- _effectiveCache = { env: envRaw, configMtime, cwd, bindings };
618
+ _effectiveCache = { env: envRaw, configMtime, cwd, bindings, overlayBindings };
451
619
  return bindings;
452
620
  }
453
621
 
622
+ /** Resolved overlay bindings for one overlay (defaults + non-colliding overrides). */
623
+ function getEffectiveOverlayBindings(overlay: OverlayName): readonly OverlayBinding[] {
624
+ getEffectiveBindings(); // ensures the memo is warm + warnings are populated
625
+ return _effectiveCache?.overlayBindings.get(overlay) ?? DEFAULT_OVERLAY_BINDINGS.get(overlay) ?? [];
626
+ }
627
+
454
628
  /** Warnings from the most recent override resolution (e.g. collisions). */
455
629
  export function getKeybindingOverrideWarnings(): readonly string[] {
456
630
  // Ensure a resolution has run so warnings are populated.
@@ -514,3 +688,45 @@ export function dashboardActionForKey(data: string, activePane?: ActivePane): Da
514
688
  }
515
689
  return undefined;
516
690
  }
691
+
692
+ /**
693
+ * Resolve a raw input `data` string to an OVERLAY action (M2-1 / P1-9).
694
+ *
695
+ * Mirror image of {@link dashboardActionForKey} over the overlay keyspace:
696
+ * same two-pass dispatch (case-sensitive exact match first, then the
697
+ * terminal-aware `matchesKey` normalization), same override pipeline
698
+ * (`.crew/config.json` → `keybindings["overlay:<name>:<action>"]`, then the
699
+ * `PI_CREW_KEYBINDINGS` env var, collision → revert to default).
700
+ *
701
+ * With no override configured the result is identical to the key chains the
702
+ * overlays used to hardcode (asserted by
703
+ * `test/unit/ui/overlay-keybindings.test.ts`).
704
+ *
705
+ * @param overlay Overlay namespace, e.g. `"confirm"`.
706
+ * @param data Raw key input (single char or escape sequence).
707
+ */
708
+ export function overlayActionForKey<N extends OverlayName>(overlay: N, data: string): OverlayActionOf<N> | undefined {
709
+ const bindings = getEffectiveOverlayBindings(overlay);
710
+ // Pass 1 — exact, case-sensitive match (keeps "y"/"Y" and "A"/"a" distinct).
711
+ for (const binding of bindings) {
712
+ if (binding.keys.includes(data)) return binding.action as OverlayActionOf<N>;
713
+ }
714
+ // Pass 2 — escape-sequence / canonical KeyId normalisation.
715
+ //
716
+ // Single-character candidates are deliberately EXCLUDED here: pi-tui's
717
+ // `matchesKey()` is case-INSENSITIVE for plain ASCII
718
+ // (`matchesKey("a", "A") === true`), which would collapse the deliberate
719
+ // case distinctions this keyspace relies on ("A"=ack vs lowercase "a"=free
720
+ // text in mailbox-compose; "y"/"Y" in confirm). Pass 1 already matched
721
+ // single chars exactly and case-sensitively, mirroring the pre-M2-1 chains
722
+ // (`data === "A"`, `data === "P"`, …).
723
+ const key = keyOf(data);
724
+ for (const binding of bindings) {
725
+ for (const candidate of binding.keys) {
726
+ if (candidate.length === 1) continue;
727
+ if (key === candidate) return binding.action as OverlayActionOf<N>;
728
+ if (matchesKey(data, candidate as KeyId)) return binding.action as OverlayActionOf<N>;
729
+ }
730
+ }
731
+ return undefined;
732
+ }