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
@@ -5,8 +5,8 @@
5
5
  */
6
6
 
7
7
  import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
8
- import { DynamicCrewBorder } from "./dynamic-border.ts";
9
8
  import { keyOf, matchesKey } from "./key-utils.ts";
9
+ import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts";
10
10
  import type { CrewTheme } from "./theme-adapter.ts";
11
11
  import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts";
12
12
 
@@ -509,19 +509,29 @@ class SelectSubmenu {
509
509
  const end = Math.min(start + this.maxVisible, this.items.length);
510
510
  const needsScroll = this.items.length > this.maxVisible;
511
511
  if (needsScroll && start > 0) {
512
- lines.push(this.theme.fg("dim", ` ▲ ${start} more above`));
512
+ lines.push(` ${overflowHint(start, 0, this.theme)}`);
513
513
  }
514
514
  for (let i = start; i < end; i++) {
515
515
  const isSelected = i === this.selectedIndex;
516
- const prefix = isSelected ? " → " : " ";
516
+ // `›` is the ONLY selection marker (RAIL): `→`-as-cursor is retired.
517
+ const prefix = isSelected ? ` ${CURSOR} ` : " ";
517
518
  const line = `${prefix}${this.items[i]}`;
518
519
  lines.push(isSelected ? (this.theme.inverse?.(line) ?? line) : line);
519
520
  }
520
521
  if (needsScroll && end < this.items.length) {
521
- lines.push(this.theme.fg("dim", ` ▼ ${this.items.length - end} more below`));
522
+ lines.push(` ${overflowHint(0, this.items.length - end, this.theme)}`);
522
523
  }
523
524
  lines.push("");
524
- lines.push(this.theme.fg("dim", "↑↓ navigate · Enter to select · Esc to go back"));
525
+ lines.push(
526
+ this.theme.fg(
527
+ "dim",
528
+ formatHint([
529
+ [["up", "down"], "navigate"],
530
+ ["enter", "to select"],
531
+ ["esc", "to go back"],
532
+ ]),
533
+ ),
534
+ );
525
535
  return lines;
526
536
  }
527
537
 
@@ -593,7 +603,16 @@ class TextinputSubmenu {
593
603
  lines.push("");
594
604
  lines.push(` ${this.buffer}█`);
595
605
  lines.push("");
596
- lines.push(this.theme.fg("dim", "Enter to save · Esc to cancel · Clear to unset"));
606
+ lines.push(
607
+ this.theme.fg(
608
+ "dim",
609
+ formatHint([
610
+ ["enter", "to save"],
611
+ ["esc", "to cancel"],
612
+ ["clear", "to unset"],
613
+ ]),
614
+ ),
615
+ );
597
616
  return lines;
598
617
  }
599
618
 
@@ -682,7 +701,7 @@ class AgentOverridesSubmenu {
682
701
  const thinkingPart = thinking ? `thinking=${thinking}` : "";
683
702
  const valueParts = [modelPart, thinkingPart].filter(Boolean).join(", ");
684
703
  const valueText = valueParts || this.theme.fg("dim", "(default)");
685
- const prefix = isSelected ? " → " : " ";
704
+ const prefix = isSelected ? ` ${CURSOR} ` : " ";
686
705
  const line = `${prefix}${label} ${valueText}`;
687
706
  lines.push(
688
707
  isSelected
@@ -691,19 +710,38 @@ class AgentOverridesSubmenu {
691
710
  );
692
711
  }
693
712
  lines.push("");
694
- lines.push(this.theme.fg("dim", "Enter to edit model · e to edit thinking · Esc to go back"));
713
+ lines.push(
714
+ this.theme.fg(
715
+ "dim",
716
+ formatHint([
717
+ ["enter", "to edit model"],
718
+ ["e", "to edit thinking"],
719
+ ["esc", "to go back"],
720
+ ]),
721
+ ),
722
+ );
695
723
  return lines;
696
724
  }
697
725
 
698
726
  private renderEdit(width: number): string[] {
699
- const agent = this.agents[this.selectedIndex];
727
+ // GUARD (M4 §4): an out-of-range cursor used to print `Edit undefined model`.
728
+ const agent = this.agents[this.selectedIndex] ?? "?";
700
729
  const field = this.editField === "model" ? "model" : "thinking";
701
730
  const lines: string[] = [];
702
731
  lines.push(this.theme.bold(this.theme.fg("accent", `Edit ${agent} ${field}`)));
703
732
  lines.push("");
704
733
  lines.push(` ${this.editBuffer}█`);
705
734
  lines.push("");
706
- lines.push(this.theme.fg("dim", "Enter to save · Esc to cancel · Clear to unset"));
735
+ lines.push(
736
+ this.theme.fg(
737
+ "dim",
738
+ formatHint([
739
+ ["enter", "to save"],
740
+ ["clear", "to unset"],
741
+ ["esc", "to cancel"],
742
+ ]),
743
+ ),
744
+ );
707
745
  return lines;
708
746
  }
709
747
 
@@ -801,33 +839,30 @@ class SettingsOverlay {
801
839
  }
802
840
 
803
841
  render(width: number): string[] {
804
- // Border wrapper — same style as RunDashboard
805
- const innerWidth = Math.max(30, width - 4);
806
- const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
807
- const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
808
- const borderFill = (count: number) => new DynamicCrewBorder(this.theme).render(Math.max(0, count))[0];
809
- const border = (left: string, right: string) => `${fg("border", left)}${borderFill(borderWidth)}${fg("border", right)}`;
810
- const row = (text: string) => `│ ${padToWidth(truncateToWidth(text, innerWidth - 1), innerWidth - 1)}│`;
842
+ // RAIL frame (M4, 2026-09-16) — the rounded box, the `├ ┤` rule and the
843
+ // `│` tab separator are retired: canopy `┏ SETTINGS ▸ pi-crew`, `┃` body
844
+ // rows (tab bar + content) and a `┗ <hint>` cap. railLine owns the glyph +
845
+ // space and the pad/truncate to `budget`, so no line can exceed `width`.
846
+ const budget = Math.max(0, width - 2);
847
+ const row = (text: string) => railLine(RAIL.body, "border", text, this.theme, budget);
811
848
 
812
849
  const lines: string[] = [];
813
850
 
814
- // ── Title bar ──
815
- lines.push(border("╭", "╮"));
816
- lines.push(row(`${fg("accent", "▐")} ${this.theme.bold("pi-crew Settings")}`));
851
+ // ── Title bar (canopy) ──
852
+ lines.push(canopyLine({ word: "SETTINGS", subject: "pi-crew", theme: this.theme, budget }));
817
853
 
818
- // ── Tab bar ──
819
- const tabLine = this.renderTabBarContent(innerWidth - 2);
820
- lines.push(row(tabLine));
821
- lines.push(border("├", "┤"));
854
+ // ── Tab bar (a rail body row; tabs joined by ` · `, never `│`) ──
855
+ lines.push(row(this.renderTabBarContent(budget)));
822
856
 
823
857
  // ── Content ──
824
- const content = this.submenu ? this.renderSubmenuContent(innerWidth - 4) : this.renderSettingsContent(innerWidth - 4);
858
+ const bodyBudget = Math.max(0, budget - 2);
859
+ const content = this.submenu ? this.renderSubmenuContent(bodyBudget) : this.renderSettingsContent(bodyBudget);
825
860
  for (const line of content) {
826
- lines.push(row(` ${truncateToWidth(line, innerWidth - 2)}`));
861
+ lines.push(row(` ${truncateToWidth(line, Math.max(0, budget - 1))}`));
827
862
  }
828
863
 
829
- // ── Bottom border ──
830
- lines.push(border("╰", "╯"));
864
+ // ── Cap (close action LAST) ──
865
+ lines.push(railLine(RAIL.close, "border", formatHint([["esc", "close"]]), this.theme, budget));
831
866
 
832
867
  return lines;
833
868
  }
@@ -839,7 +874,7 @@ class SettingsOverlay {
839
874
  const text = `${tab.icon} ${tab.label}`;
840
875
  parts.push(isActive ? this.theme.bold(this.theme.fg("accent", text)) : this.theme.fg("dim", text));
841
876
  }
842
- return parts.join(" " + this.theme.fg("border", "│") + " ");
877
+ return truncateToWidth(parts.join(` ${this.theme.fg("dim", "·")} `), innerWidth);
843
878
  }
844
879
 
845
880
  private renderSettingsContent(innerWidth: number): string[] {
@@ -864,7 +899,8 @@ class SettingsOverlay {
864
899
  const valueStr = formatValue(effective, def.id);
865
900
  const suffix = isDefault && (effective !== undefined || EFFECTIVE_DEFAULTS[def.id] !== undefined) ? " (default)" : "";
866
901
 
867
- const prefix = isSelected ? " → " : " ";
902
+ // Cursor: `›` is the ONLY selection marker (RAIL).
903
+ const prefix = isSelected ? ` ${CURSOR} ` : " ";
868
904
  const labelPad = padToWidth(def.label, maxLabelWidth);
869
905
  const valueMax = innerWidth - maxLabelWidth - 6 - prefix.length - suffix.length;
870
906
  const valueText = truncateToWidth(valueStr, Math.max(10, valueMax));
@@ -877,15 +913,12 @@ class SettingsOverlay {
877
913
  }
878
914
  }
879
915
 
880
- // Scroll indicator
916
+ // Scroll indicator — RAIL overflow dialect (`▲ n above · ▼ m below`).
881
917
  if (startIdx > 0 || endIdx < settings.length) {
882
918
  const remaining = settings.length - endIdx;
883
- const count = startIdx > 0 ? `↑${startIdx}` : "";
884
- const below = remaining > 0 ? `↓${remaining}` : "";
885
- const parts = [count, below].filter(Boolean);
886
- if (parts.length > 0) {
887
- lines.push(this.theme.fg("dim", ` (${this.selectedIndex + 1}/${settings.length}) ${parts.join(" ")}`));
888
- }
919
+ lines.push(
920
+ `${this.theme.fg("dim", ` (${this.selectedIndex + 1}/${settings.length})`)} ${overflowHint(startIdx, remaining, this.theme)}`,
921
+ );
889
922
  }
890
923
 
891
924
  // Description
@@ -895,9 +928,18 @@ class SettingsOverlay {
895
928
  lines.push(this.theme.fg("muted", ` ${selectedDef.description}`));
896
929
  }
897
930
 
898
- // Hints
931
+ // Hints — one format (`formatHint`); the close action lives in the cap.
899
932
  lines.push("");
900
- lines.push(this.theme.fg("dim", " ↑↓ Navigate · Enter/Space change · Tab switch · Esc close"));
933
+ lines.push(
934
+ this.theme.fg(
935
+ "dim",
936
+ ` ${formatHint([
937
+ [["up", "down"], "Navigate"],
938
+ [["enter", "space"], "Change"],
939
+ ["tab", "switch"],
940
+ ])}`,
941
+ ),
942
+ );
901
943
 
902
944
  return lines;
903
945
  }
package/src/ui/spinner.ts CHANGED
@@ -1,7 +1,31 @@
1
1
  export const SUBAGENT_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
2
2
  export const SUBAGENT_SPINNER_FRAME_MS = 160;
3
3
 
4
- export function spinnerBucket(now = Date.now(), frameMs = SUBAGENT_SPINNER_FRAME_MS): number {
4
+ // Clock source indirection (2026-09-22): spinner phase is derived from wall
5
+ // time at ~30 call sites that don't thread an injectable `now` through their
6
+ // render signatures. Deterministic consumers (docs/ui-samples/capture.ts,
7
+ // tests) swap the source once at startup instead of refactoring every
8
+ // signature; production keeps the real clock. Same pattern as the `__test__`
9
+ // hooks in state-store.
10
+ let spinnerClockSource: () => number = Date.now;
11
+
12
+ /** @internal — swap the spinner clock source. Returns the previous source
13
+ * (pass it back to restore). Production default is Date.now. */
14
+ export function __setSpinnerClockSource(source: () => number): () => number {
15
+ const previous = spinnerClockSource;
16
+ spinnerClockSource = source;
17
+ return previous;
18
+ }
19
+
20
+ /** The spinner clock as a shared read: elapsed-time fallbacks that have no
21
+ * injected `now` (tool-progress, record durations) read it here so deterministic
22
+ * captures pin BOTH spinner phase and elapsed text with one hook. Production
23
+ * default stays Date.now(). */
24
+ export function spinnerClockNow(): number {
25
+ return spinnerClockSource();
26
+ }
27
+
28
+ export function spinnerBucket(now = spinnerClockSource(), frameMs = SUBAGENT_SPINNER_FRAME_MS): number {
5
29
  return Math.floor(now / Math.max(1, frameMs));
6
30
  }
7
31
 
@@ -11,7 +35,7 @@ function hashKey(key: string): number {
11
35
  return hash;
12
36
  }
13
37
 
14
- export function spinnerFrame(key = "", now = Date.now()): string {
38
+ export function spinnerFrame(key = "", now = spinnerClockSource()): string {
15
39
  const offset = key ? hashKey(key) % SUBAGENT_SPINNER_FRAMES.length : 0;
16
40
  return SUBAGENT_SPINNER_FRAMES[(spinnerBucket(now) + offset) % SUBAGENT_SPINNER_FRAMES.length] ?? SUBAGENT_SPINNER_FRAMES[0];
17
41
  }
@@ -26,6 +26,12 @@
26
26
  * - run just completed → Ghostty state 1/100 (green flash), schedule clear.
27
27
  * - idle (no active runs) → restore pi's natural title, Ghostty state 0.
28
28
  *
29
+ * M3-1 (2026-09-15): the wiring now exists — see
30
+ * `src/extension/registration/terminal-status-wiring.ts` (installed from
31
+ * `register.ts`). From v0.8.3 (819d71ad) until this change the controller was
32
+ * constructed only by its unit tests: `ctx.terminalStatus` was never assigned,
33
+ * so neither channel ever ran (UI-AUDIT P0-2/P1-1).
34
+ *
29
35
  * @module terminal-status
30
36
  */
31
37
 
@@ -61,7 +67,7 @@ const MAX_TITLE_ROLES = 3;
61
67
  const IDLE_REASSERT_START_MS = 200;
62
68
  const IDLE_REASSERT_MAX_MS = 5000;
63
69
  /** How long the green completion flash stays before clearing. */
64
- const COMPLETE_FLASH_MS = 1500;
70
+ export const COMPLETE_FLASH_MS = 1500;
65
71
 
66
72
  /** Injected for tests (defaults write to the real /dev/tty). */
67
73
  export type GhosttyWriter = (seq: string) => void;
@@ -49,51 +49,6 @@ export type CrewThemeColor =
49
49
 
50
50
  export type CrewThemeBg = "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
51
51
 
52
- /** ANSI fallback values for theme color slots when the active theme doesn't define them. */
53
- export const THEME_COLOR_FALLBACKS: Record<CrewThemeColor, string> = {
54
- accent: "\x1b[36m",
55
- border: "\x1b[38;5;240m",
56
- borderAccent: "\x1b[35m",
57
- borderMuted: "\x1b[38;5;236m",
58
- success: "\x1b[32m",
59
- error: "\x1b[31m",
60
- warning: "\x1b[33m",
61
- muted: "\x1b[38;5;245m",
62
- dim: "\x1b[38;5;240m",
63
- text: "\x1b[39m",
64
- thinkingText: "\x1b[38;5;245m",
65
- toolTitle: "\x1b[36m",
66
- toolOutput: "\x1b[38;5;245m",
67
- toolDiffAdded: "\x1b[32m",
68
- toolDiffRemoved: "\x1b[31m",
69
- toolDiffContext: "\x1b[38;5;245m",
70
- mdHeading: "\x1b[33m",
71
- mdLink: "\x1b[35m",
72
- mdCode: "\x1b[32m",
73
- mdCodeBlock: "\x1b[39m",
74
- mdQuote: "\x1b[38;5;245m",
75
- mdHr: "\x1b[38;5;240m",
76
- mdListBullet: "\x1b[36m",
77
- syntaxKeyword: "\x1b[35m",
78
- syntaxString: "\x1b[32m",
79
- syntaxNumber: "\x1b[33m",
80
- syntaxComment: "\x1b[38;5;245m",
81
- syntaxFunction: "\x1b[36m",
82
- syntaxVariable: "\x1b[39m",
83
- syntaxType: "\x1b[35m",
84
- syntaxOperator: "\x1b[35m",
85
- syntaxPunctuation: "\x1b[35m",
86
- userMessageText: "\x1b[39m",
87
- customMessageLabel: "\x1b[35m",
88
- thinkingOff: "\x1b[38;5;236m",
89
- thinkingMinimal: "\x1b[38;5;245m",
90
- thinkingLow: "\x1b[35m",
91
- thinkingMedium: "\x1b[35m",
92
- thinkingHigh: "\x1b[36m",
93
- thinkingXhigh: "\x1b[35m",
94
- bashMode: "\x1b[32m",
95
- };
96
-
97
52
  export interface CrewTheme {
98
53
  fg(color: CrewThemeColor, text: string): string;
99
54
  bg?(color: CrewThemeBg, text: string): string;
@@ -7,7 +7,19 @@
7
7
  * - setPiTheme() to persist a choice in ~/.pi/agent/settings.json
8
8
  *
9
9
  * Wired into the `team-settings themes` / `theme` subcommands.
10
+ *
11
+ * P0-1 (2026-09-15): this module used bare `require("node:fs")` in three
12
+ * places. It is an ESM module, so `require` is undefined there and every
13
+ * filesystem probe threw a ReferenceError that the surrounding try/catch
14
+ * swallowed: `discoverPiThemes()` collapsed to the 2 builtins, and
15
+ * `getActivePiTheme()` always returned undefined (so no crew theme was
16
+ * selectable and the active theme was misreported). The fs access is now a
17
+ * static top-level import. `node:fs` is a builtin and this module is already
18
+ * eagerly reachable from handle-settings.ts / settings-overlay.ts, so this
19
+ * does not change lazy-load behaviour (check:lazy-imports only polices
20
+ * `await import(` without a `// LAZY:` marker).
10
21
  */
22
+ import * as fs from "node:fs";
11
23
 
12
24
  export interface PiThemeInfo {
13
25
  /** Theme name (filename stem or builtin id). */
@@ -72,8 +84,6 @@ export function discoverPiThemes(): PiThemeInfo[] {
72
84
  // Custom themes from ~/.pi/agent/themes/
73
85
  const dir = customThemesDir();
74
86
  try {
75
- // eslint-disable-next-line @typescript-eslint/no-require-imports
76
- const fs = require("node:fs");
77
87
  if (dir && fs.existsSync(dir)) {
78
88
  for (const file of fs.readdirSync(dir) as string[]) {
79
89
  if (!file.endsWith(".json")) continue;
@@ -109,8 +119,6 @@ export function discoverPiThemes(): PiThemeInfo[] {
109
119
  /** Read the currently active Pi theme from ~/.pi/agent/settings.json. */
110
120
  export function getActivePiTheme(): string | undefined {
111
121
  try {
112
- // eslint-disable-next-line @typescript-eslint/no-require-imports
113
- const fs = require("node:fs");
114
122
  const p = settingsPath();
115
123
  if (!p || !fs.existsSync(p)) return undefined;
116
124
  const json = JSON.parse(fs.readFileSync(p, "utf8"));
@@ -122,8 +130,6 @@ export function getActivePiTheme(): string | undefined {
122
130
 
123
131
  /** Persist a Pi theme choice in ~/.pi/agent/settings.json. Returns the path or throws. */
124
132
  export function setPiTheme(name: string): string {
125
- // eslint-disable-next-line @typescript-eslint/no-require-imports
126
- const fs = require("node:fs");
127
133
  const p = settingsPath();
128
134
  if (!p) throw new Error("Could not determine settings path (no HOME).");
129
135
  let settings: Record<string, unknown> = {};
@@ -1,5 +1,6 @@
1
1
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
2
2
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
3
+ import { spinnerClockNow } from "./spinner.ts";
3
4
 
4
5
  export interface ToolProgressInput {
5
6
  /** Subagent record id or synthetic agent label shown in the header. */
@@ -22,6 +23,119 @@ export interface ToolProgressInput {
22
23
 
23
24
  const MAX_OUTPUT_LINE = 80;
24
25
 
26
+ // ── Producer↔consumer contract (audit P1-13) ───────────────────────────
27
+
28
+ /**
29
+ * Every token that `formatCompactToolProgress` (producer) emits and that the
30
+ * `team`/`agent` tool renderers (consumer, `parseCompactToolProgress` below)
31
+ * look for. The two sides used to be coupled only by hand-written regexes, so a
32
+ * one-character format change made the streaming progress block silently empty.
33
+ * Both sides now read the tokens from here.
34
+ */
35
+ export const PROGRESS_FORMAT = {
36
+ /** Header: `agent=<id> status=<status> elapsed=<n>s` (`agent` with no id). */
37
+ agentKey: "agent",
38
+ statusKey: "status",
39
+ elapsedKey: "elapsed",
40
+ /** Unit suffix for `elapsed=` (seconds). */
41
+ elapsedUnit: "s",
42
+ /** Task tally: `tasks <completed>/<total> done <status>=<n> …`. */
43
+ tasksKey: "tasks",
44
+ tallySeparator: "/",
45
+ doneWord: "done",
46
+ /** Active worker row: `<role>-><agent> turn=<n> tokens=<n>`. */
47
+ roleSeparator: "->",
48
+ turnKey: "turn",
49
+ tokensKey: "tokens",
50
+ /** Current tool row: `tool: <name> (#<n>)`. */
51
+ toolKey: "tool",
52
+ toolSeparator: ":",
53
+ /** Pending-run row: `run=<id> (starting)`. */
54
+ runKey: "run",
55
+ /** Failure row: `error: <text>`. */
56
+ errorKey: "error",
57
+ /** Fallback row before the run materializes. */
58
+ waitingLine: "waiting for run to start",
59
+ } as const;
60
+
61
+ /**
62
+ * Structured image of a compact progress block. Consumed by the tool renderers;
63
+ * `null` from the parser means "nothing progress-like in this text".
64
+ */
65
+ export interface ParsedToolProgress {
66
+ /** `elapsed=<n>s` in milliseconds (0 when absent). */
67
+ elapsedMs: number;
68
+ /** `status=<x>` value (note: the renderer only surfaces it when no task counts exist). */
69
+ status: string | null;
70
+ /** Completed task count from the tally row (null when absent). */
71
+ completed: number | null;
72
+ /** Total task count from the tally row (completed + remaining for the legacy bucket form). */
73
+ total: number | null;
74
+ /** `<role>/<agent>` of the active worker row, if present. */
75
+ activeAgent: string | null;
76
+ /** Current tool name from `tool: <name>`, if present. */
77
+ currentTool: string | null;
78
+ /** Turn count of the active worker row, if present. */
79
+ turns: number | null;
80
+ /** Token count of the active worker row, if present. */
81
+ tokens: number | null;
82
+ }
83
+
84
+ const ELAPSED_RE = new RegExp(`${PROGRESS_FORMAT.elapsedKey}=(\\d+)${PROGRESS_FORMAT.elapsedUnit}`);
85
+ const STATUS_RE = new RegExp(`${PROGRESS_FORMAT.statusKey}=(\\w+)`);
86
+ const TALLY_RE = new RegExp(
87
+ `${PROGRESS_FORMAT.tasksKey}\\s+(\\d+)${PROGRESS_FORMAT.tallySeparator}(\\d+)\\s+${PROGRESS_FORMAT.doneWord}\\b`,
88
+ );
89
+ // Legacy bucket form (`tasks completed=2 running=1`) — kept for older/foreign
90
+ // producers; the tally form above wins when both are present.
91
+ const BUCKETS_RE = new RegExp(
92
+ `${PROGRESS_FORMAT.tasksKey}[^\\n]*(?:completed|${PROGRESS_FORMAT.doneWord})=(\\d+)[^\\n]*(?:running|waiting|queued)=(\\d+)`,
93
+ );
94
+ const AGENT_ROW_RE = new RegExp(`\\s+(\\w+)${PROGRESS_FORMAT.roleSeparator}(\\w+)\\s+${PROGRESS_FORMAT.turnKey}=`);
95
+ const TURN_RE = new RegExp(`${PROGRESS_FORMAT.turnKey}=(\\d+)`);
96
+ const TOKENS_RE = new RegExp(`${PROGRESS_FORMAT.tokensKey}=(\\d+)`);
97
+ const TOOL_ROW_RE = new RegExp(`${PROGRESS_FORMAT.toolKey}${PROGRESS_FORMAT.toolSeparator}\\s+(\\S+)`);
98
+
99
+ /** Parse a compact progress block back into structured fields (see PROGRESS_FORMAT). */
100
+ export function parseCompactToolProgress(text: string): ParsedToolProgress | null {
101
+ if (!text) return null;
102
+
103
+ const elapsedMatch = ELAPSED_RE.exec(text);
104
+ const elapsedMs = elapsedMatch ? Number.parseInt(elapsedMatch[1]!, 10) * 1000 : 0;
105
+
106
+ let completed: number | null = null;
107
+ let total: number | null = null;
108
+ const tally = TALLY_RE.exec(text);
109
+ if (tally) {
110
+ completed = Number.parseInt(tally[1]!, 10);
111
+ total = Number.parseInt(tally[2]!, 10);
112
+ } else {
113
+ const buckets = BUCKETS_RE.exec(text);
114
+ if (buckets) {
115
+ completed = Number.parseInt(buckets[1]!, 10);
116
+ total = completed + Number.parseInt(buckets[2]!, 10);
117
+ }
118
+ }
119
+
120
+ // Nothing progress-like: mirrors the historical "no elapsed/task signal → null".
121
+ if (elapsedMs === 0 && completed === null) return null;
122
+
123
+ const agentRow = AGENT_ROW_RE.exec(text);
124
+ const toolRow = TOOL_ROW_RE.exec(text);
125
+ const turns = TURN_RE.exec(text);
126
+ const tokens = TOKENS_RE.exec(text);
127
+ return {
128
+ elapsedMs,
129
+ status: STATUS_RE.exec(text)?.[1] ?? null,
130
+ completed,
131
+ total,
132
+ activeAgent: agentRow ? `${agentRow[1]}/${agentRow[2]}` : null,
133
+ currentTool: toolRow ? toolRow[1]! : null,
134
+ turns: turns ? Number.parseInt(turns[1]!, 10) : null,
135
+ tokens: tokens ? Number.parseInt(tokens[1]!, 10) : null,
136
+ };
137
+ }
138
+
25
139
  function pickActiveAgent(agents: CrewAgentRecord[] | undefined): CrewAgentRecord | undefined {
26
140
  if (!agents || agents.length === 0) return undefined;
27
141
  return (
@@ -55,7 +169,7 @@ function taskCounts(tasks: TeamTaskState[] | undefined): string | undefined {
55
169
  const buckets = new Map<string, number>();
56
170
  for (const task of tasks) buckets.set(task.status, (buckets.get(task.status) ?? 0) + 1);
57
171
  const summary = [...buckets.entries()].map(([status, count]) => `${status}=${count}`).join(" ");
58
- return `tasks ${completed}/${total} done ${summary}`;
172
+ return `${PROGRESS_FORMAT.tasksKey} ${completed}${PROGRESS_FORMAT.tallySeparator}${total} ${PROGRESS_FORMAT.doneWord} ${summary}`;
59
173
  }
60
174
 
61
175
  /**
@@ -64,9 +178,12 @@ function taskCounts(tasks: TeamTaskState[] | undefined): string | undefined {
64
178
  * the chat widget overlay does not jitter.
65
179
  */
66
180
  export function formatCompactToolProgress(input: ToolProgressInput): string {
67
- const elapsedSec = Math.max(0, Math.round((Date.now() - input.startedAt) / 1000));
68
- const head = input.agentId ? `agent=${input.agentId}` : "agent";
69
- const lines: string[] = [`${head} status=${input.status} elapsed=${elapsedSec}s`];
181
+ const elapsedSec = Math.max(0, Math.round((spinnerClockNow() - input.startedAt) / 1000));
182
+ const head = input.agentId ? `${PROGRESS_FORMAT.agentKey}=${input.agentId}` : PROGRESS_FORMAT.agentKey;
183
+ // Emitted from PROGRESS_FORMAT so the parser can never drift from the wire format.
184
+ const lines: string[] = [
185
+ `${head} ${PROGRESS_FORMAT.statusKey}=${input.status} ${PROGRESS_FORMAT.elapsedKey}=${elapsedSec}${PROGRESS_FORMAT.elapsedUnit}`,
186
+ ];
70
187
 
71
188
  const counts = taskCounts(input.tasks);
72
189
  if (counts) lines.push(` ${counts}`);
@@ -75,19 +192,21 @@ export function formatCompactToolProgress(input: ToolProgressInput): string {
75
192
  if (active) {
76
193
  const turns = active.progress?.turns ?? 0;
77
194
  const tokens = totalTokens(active);
78
- lines.push(` ${active.role}->${active.agent} turn=${turns} tokens=${tokens}`);
195
+ lines.push(
196
+ ` ${active.role}${PROGRESS_FORMAT.roleSeparator}${active.agent} ${PROGRESS_FORMAT.turnKey}=${turns} ${PROGRESS_FORMAT.tokensKey}=${tokens}`,
197
+ );
79
198
  if (active.progress?.currentTool) {
80
199
  const count = active.progress.toolCount ? ` (#${active.progress.toolCount})` : "";
81
- lines.push(` tool: ${active.progress.currentTool}${count}`);
200
+ lines.push(` ${PROGRESS_FORMAT.toolKey}${PROGRESS_FORMAT.toolSeparator} ${active.progress.currentTool}${count}`);
82
201
  }
83
202
  const recent = active.progress?.recentOutput?.at(-1);
84
203
  if (recent?.trim()) lines.push(` ${trimLine(recent)}`);
85
204
  } else if (input.runId && !counts) {
86
- lines.push(` run=${input.runId} (starting)`);
205
+ lines.push(` ${PROGRESS_FORMAT.runKey}=${input.runId} (starting)`);
87
206
  } else if (input.error) {
88
- lines.push(` error: ${trimLine(input.error)}`);
207
+ lines.push(` ${PROGRESS_FORMAT.errorKey}: ${trimLine(input.error)}`);
89
208
  } else if (!counts) {
90
- lines.push(" waiting for run to start");
209
+ lines.push(` ${PROGRESS_FORMAT.waitingLine}`);
91
210
  }
92
211
  return lines.join("\n");
93
212
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
10
10
  import { formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
11
+ import { spinnerClockNow } from "../spinner.ts";
11
12
  import type { CrewTheme } from "../theme-adapter.ts";
12
13
 
13
14
  // ── State ──────────────────────────────────────────────────────────────
@@ -57,24 +58,17 @@ export { BRIEF_ENTRY_TYPE };
57
58
 
58
59
  // ── Brief renderers ────────────────────────────────────────────────────
59
60
 
60
- /** Brief summary for a single tool result. */
61
+ /** Brief summary for a single tool result.
62
+ *
63
+ * SCOPE (audit P1-12): this is only ever called with the hard-coded names
64
+ * "team" (`tool-renderers/index.ts:303`) and "agent" (`:414`). The 7
65
+ * native-tool arms (read/bash/edit/write/find/grep/ls) were unreachable and
66
+ * were REMOVED in M3-3 — turning /crew-brief on cannot change native tool
67
+ * output; only team/agent results and the default fallback render brief.
68
+ */
61
69
  export function briefToolResult(toolName: string, result: { content?: unknown[] }, theme: CrewTheme): string {
62
70
  const text = extractText(result?.content);
63
71
  switch (toolName) {
64
- case "read":
65
- return briefRead(text, theme);
66
- case "bash":
67
- return briefBash(text, theme);
68
- case "edit":
69
- return briefEdit(text, theme);
70
- case "write":
71
- return briefWrite(text, theme);
72
- case "find":
73
- return briefFind(text, theme);
74
- case "grep":
75
- return briefGrep(text, theme);
76
- case "ls":
77
- return briefLs(text, theme);
78
72
  case "team":
79
73
  return briefTeam(result, theme);
80
74
  case "agent":
@@ -84,57 +78,6 @@ export function briefToolResult(toolName: string, result: { content?: unknown[]
84
78
  }
85
79
  }
86
80
 
87
- function briefRead(text: string, theme: CrewTheme): string {
88
- if (!text) return theme.fg("dim", "→ empty");
89
- const count = text.trim().split("\n").filter(Boolean).length;
90
- return theme.fg("muted", `→ ${count} lines`);
91
- }
92
-
93
- function briefBash(text: string, theme: CrewTheme): string {
94
- if (!text?.trim()) return theme.fg("dim", "→ done");
95
- const lines = text.trim().split("\n");
96
- if (lines.length === 1 && lines[0]!.length < 40) {
97
- return theme.fg("muted", `→ ${lines[0]}`);
98
- }
99
- return theme.fg("muted", `→ ${lines.length} lines`);
100
- }
101
-
102
- function briefEdit(text: string, theme: CrewTheme): string {
103
- if (!text) return theme.fg("dim", "→ edited");
104
- if (text.includes("Error") || text.includes("error")) {
105
- return theme.fg("error", "→ failed");
106
- }
107
- const added = (text.match(/^\+ /gm) ?? []).length;
108
- const removed = (text.match(/^- /gm) ?? []).length;
109
- if (added === 0 && removed === 0) {
110
- return theme.fg("success", "→ edited");
111
- }
112
- return theme.fg("success", "→ edited ") + theme.fg("toolDiffAdded", `+${added} `) + theme.fg("toolDiffRemoved", `-${removed}`);
113
- }
114
-
115
- function briefWrite(text: string, theme: CrewTheme): string {
116
- if (text) return theme.fg("error", `→ ${text}`);
117
- return theme.fg("success", "→ written");
118
- }
119
-
120
- function briefFind(text: string, theme: CrewTheme): string {
121
- if (!text) return theme.fg("dim", "→ none");
122
- const count = text.trim().split("\n").filter(Boolean).length;
123
- return theme.fg("muted", `→ ${count} files`);
124
- }
125
-
126
- function briefGrep(text: string, theme: CrewTheme): string {
127
- if (!text) return theme.fg("dim", "→ none");
128
- const count = text.trim().split("\n").filter(Boolean).length;
129
- return theme.fg("muted", `→ ${count} matches`);
130
- }
131
-
132
- function briefLs(text: string, theme: CrewTheme): string {
133
- if (!text) return theme.fg("dim", "→ empty");
134
- const count = text.trim().split("\n").filter(Boolean).length;
135
- return theme.fg("muted", `→ ${count} entries`);
136
- }
137
-
138
81
  function briefTeam(result: { content?: unknown[] }, theme: CrewTheme): string {
139
82
  // Try to extract structured details
140
83
  const details = (result as Record<string, unknown>).details ?? result;
@@ -207,7 +150,7 @@ function computeTotalDuration(records: CrewAgentRecord[]): number {
207
150
  for (const r of records) {
208
151
  if (r.startedAt) {
209
152
  const start = new Date(r.startedAt).getTime();
210
- const end = r.completedAt ? new Date(r.completedAt).getTime() : Date.now();
153
+ const end = r.completedAt ? new Date(r.completedAt).getTime() : spinnerClockNow();
211
154
  if (Number.isFinite(start) && Number.isFinite(end)) total += Math.max(0, end - start);
212
155
  }
213
156
  }