pi-crew 0.11.1 → 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 (121) hide show
  1. package/CHANGELOG.md +39 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -91462
  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 +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +193 -36
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +23 -0
  20. package/src/extension/help.ts +21 -10
  21. package/src/extension/knowledge-injection.ts +2 -1
  22. package/src/extension/management.ts +8 -3
  23. package/src/extension/notification-sink.ts +17 -0
  24. package/src/extension/registration/command-utils.ts +28 -2
  25. package/src/extension/registration/commands/dashboard.ts +11 -1
  26. package/src/extension/registration/commands/manage.ts +31 -15
  27. package/src/extension/registration/commands/run.ts +24 -2
  28. package/src/extension/registration/commands/shared.ts +23 -0
  29. package/src/extension/registration/commands/status.ts +25 -2
  30. package/src/extension/registration/context-builder.ts +8 -2
  31. package/src/extension/registration/health-notify-policy.ts +100 -0
  32. package/src/extension/registration/lazy-configurers.ts +35 -0
  33. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  34. package/src/extension/registration/lifecycle.ts +75 -10
  35. package/src/extension/registration/observability.ts +98 -35
  36. package/src/extension/registration/registration-types.ts +7 -5
  37. package/src/extension/registration/runtime-cleanup.ts +9 -3
  38. package/src/extension/registration/subagent-helpers.ts +38 -0
  39. package/src/extension/registration/wire-cross-extension.ts +28 -0
  40. package/src/extension/run-compare.ts +220 -0
  41. package/src/extension/run-export.ts +37 -5
  42. package/src/extension/run-maintenance.ts +155 -5
  43. package/src/extension/team-tool/dispatch/index.ts +3 -2
  44. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  45. package/src/extension/team-tool/goal.ts +4 -1
  46. package/src/extension/team-tool/handle-settings.ts +19 -2
  47. package/src/extension/team-tool/health-monitor.ts +21 -7
  48. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  49. package/src/extension/team-tool/plan.ts +10 -0
  50. package/src/extension/team-tool/routing-hint.ts +63 -0
  51. package/src/extension/team-tool/status.ts +4 -0
  52. package/src/extension/team-tool.ts +52 -6
  53. package/src/extension/webhook-notify.ts +382 -0
  54. package/src/observability/metric-sink.ts +12 -2
  55. package/src/prompt/prompt-runtime.ts +82 -31
  56. package/src/prompt/worker-events-channel.ts +12 -0
  57. package/src/runtime/README.md +1 -1
  58. package/src/runtime/async-runner.ts +87 -1
  59. package/src/runtime/background-runner.ts +313 -234
  60. package/src/runtime/broker/crew-broker.ts +17 -11
  61. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  62. package/src/runtime/broker/wait-status-cache.ts +1 -1
  63. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  64. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  65. package/src/runtime/crew-agent-records.ts +337 -45
  66. package/src/runtime/deadletter.ts +43 -1
  67. package/src/runtime/delegate-spawn.ts +5 -1
  68. package/src/runtime/dispatch-batch.ts +72 -5
  69. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  70. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  71. package/src/runtime/model/model-fallback.ts +21 -1
  72. package/src/runtime/model/pi-args.ts +8 -10
  73. package/src/runtime/recovery/crash-recovery.ts +25 -1
  74. package/src/runtime/run-worker.ts +12 -1
  75. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  76. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  77. package/src/runtime/scheduling/scheduler.ts +49 -13
  78. package/src/runtime/scheduling/semaphore.ts +148 -20
  79. package/src/runtime/scratchpad/README.md +1 -1
  80. package/src/runtime/scratchpad/protocol.ts +1 -1
  81. package/src/runtime/settings-store.ts +1 -1
  82. package/src/runtime/skill-instructions.ts +22 -0
  83. package/src/runtime/stale-reconciler.ts +85 -13
  84. package/src/runtime/task-runner/pre-execution.ts +26 -2
  85. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  86. package/src/runtime/task-runner.ts +21 -1
  87. package/src/runtime/team-runner.ts +38 -1
  88. package/src/runtime/workspace-lock.ts +4 -1
  89. package/src/schema/config-schema.ts +14 -0
  90. package/src/schema/team-tool-schema.ts +17 -0
  91. package/src/state/atomic-write.ts +53 -0
  92. package/src/state/contracts.ts +109 -0
  93. package/src/state/coordination/locks.ts +191 -33
  94. package/src/state/coordination/mailbox.ts +140 -15
  95. package/src/state/crew-init.ts +87 -12
  96. package/src/state/event-log/cursor.ts +37 -1
  97. package/src/state/event-log/event-log-rotation.ts +72 -7
  98. package/src/state/stores/active-run-registry.ts +13 -1
  99. package/src/state/stores/state-store.ts +112 -22
  100. package/src/state/types.ts +4 -0
  101. package/src/ui/dashboard-panes/agents-pane.ts +11 -2
  102. package/src/ui/heartbeat-aggregator.ts +34 -0
  103. package/src/ui/keybinding-map.ts +22 -4
  104. package/src/ui/live-conversation-overlay.ts +6 -3
  105. package/src/ui/run-dashboard.ts +98 -5
  106. package/src/ui/run-snapshot-cache.ts +18 -1
  107. package/src/ui/spinner.ts +26 -2
  108. package/src/ui/tool-progress-formatter.ts +2 -1
  109. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  110. package/src/ui/tool-renderers/index.ts +3 -3
  111. package/src/utils/incremental-reader.ts +11 -3
  112. package/src/utils/paths.ts +94 -12
  113. package/src/utils/project-markers.ts +40 -0
  114. package/src/worktree/worktree-manager.ts +206 -26
  115. package/workflows/distill.workflow.md +3 -3
  116. package/workflows/fast-fix.workflow.md +1 -1
  117. package/workflows/plan-execute.workflow.md +1 -1
  118. package/workflows/review.workflow.md +1 -1
  119. package/workflows/strict-fast-fix.workflow.md +1 -1
  120. package/docs/migration-v0.4-v0.5.md +0 -208
  121. package/docs/runtime-flow.md +0 -148
@@ -10,7 +10,7 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
10
10
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
11
11
  import { getLiveAgentContextPercent } from "../runtime/live-session/live-agent-manager.ts";
12
12
  import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
13
- import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
13
+ import { isActiveRunStatus, isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
14
14
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
15
15
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
16
16
  import { aggregateUsage } from "../state/usage.ts";
@@ -94,6 +94,27 @@ export interface RunDashboardOptions {
94
94
  */
95
95
  let lastActivePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules" = "agents";
96
96
 
97
+ /**
98
+ * US-020 AC-2: the selected run SURVIVES dashboard close/reopen (the shared.ts
99
+ * action loop re-creates RunDashboard after every done() selection — enter on
100
+ * a run opens its detail, escape returns, and the cursor is where it was).
101
+ * Persisted per-process like lastActivePane; restored by runId on construct.
102
+ */
103
+ let lastSelectedRunId: string | undefined;
104
+
105
+ /** @internal — test isolation hook. US-020 AC-2 persists the cursor in
106
+ * module-level state (lastSelectedRunId) so it survives close/reopen cycles,
107
+ * but that state ALSO leaks across RunDashboard instances within one process:
108
+ * an E2E brute-force that constructs a fresh dashboard per iteration inherits
109
+ * the previous iteration's cursor, and with wrap-navigation the driven
110
+ * selection lands somewhere else entirely (measured: count=9 offset=8 restored
111
+ * run-007 → 8 'j' wraps to run-006, not run-008). Tests that need a clean
112
+ * cursor call this before constructing. */
113
+ export function __test__resetDashboardState(): void {
114
+ lastSelectedRunId = undefined;
115
+ lastActivePane = "agents";
116
+ }
117
+
97
118
  export type RunDashboardAction =
98
119
  | "status"
99
120
  | "summary"
@@ -118,7 +139,8 @@ export type RunDashboardAction =
118
139
  | "schedule-disable"
119
140
  | "schedule-run-now"
120
141
  | "schedule-remove"
121
- | "notifications-dismiss";
142
+ | "notifications-dismiss"
143
+ | "cancel";
122
144
  export interface RunDashboardSelection {
123
145
  runId: string;
124
146
  action: RunDashboardAction;
@@ -496,6 +518,11 @@ export class RunDashboard implements DashboardComponent {
496
518
  private scheduleSelected = 0;
497
519
  private scheduleDetails = false;
498
520
  private scheduleDeleteArmed = false;
521
+ /** US-020 (run cancel): 2-step confirm-gate + refusal status line —
522
+ * mirrors the schedules delete gate (first x arms, second x dispatches,
523
+ * any other key disarms; a non-running selection refuses with a line). */
524
+ private runCancelArmed = false;
525
+ private runCancelRefused = false;
499
526
  private scheduleJobsCache: { at: number; jobs: ScheduledJob[]; hiddenCount: number } | undefined;
500
527
  private runs: TeamRunManifest[];
501
528
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
@@ -523,6 +550,15 @@ export class RunDashboard implements DashboardComponent {
523
550
  ? runs.filter((run) => !run.ownerSessionId || run.ownerSessionId === options.workspaceId)
524
551
  : runs;
525
552
  this.runs = filteredRuns;
553
+ // US-020 AC-2: restore the cursor to the previously selected run (if it
554
+ // is still in the list) so escape-return keeps the user's place.
555
+ if (lastSelectedRunId) {
556
+ // groupedRuns rows include GROUP LABELS ("Active"/"Recent") — index
557
+ // into the SELECTABLE rows only (the same projection navigation uses).
558
+ const selectable = groupedRuns(filteredRuns, options.snapshotCache).filter((rowItem) => rowItem.run);
559
+ const idx = selectable.findIndex((rowItem) => rowItem.run?.runId === lastSelectedRunId);
560
+ if (idx >= 0) this.selected = idx;
561
+ }
526
562
  this.done = done;
527
563
  this.theme = asCrewTheme(theme);
528
564
  this.options = options;
@@ -814,16 +850,30 @@ export class RunDashboard implements DashboardComponent {
814
850
  canopyLine({
815
851
  word: "DASHBOARD",
816
852
  subject: formatCount(this.runs.length, "run"),
853
+ // US-020: the cancel hint joins the canopy only when the budget
854
+ // has room — at narrow widths the canopy prefers the run-count
855
+ // subject over hints, and the 5th hint ellipsized it (regression
856
+ // caught by run-dashboard.test.ts's 80-col canonical canopy).
817
857
  right: formatHint([
818
858
  ["1-8", "pane"],
819
859
  [["up", "down"], "move"],
820
860
  ["enter", "select"],
861
+ ...(budget >= 96 ? ([["x", "cancel"]] as const) : []),
821
862
  ["?", "help"],
822
863
  ]),
823
864
  theme: this.theme,
824
865
  budget,
825
866
  }),
826
867
  );
868
+ // US-020: run-cancel confirm gate / refusal status line —
869
+ // rendered right under the canopy so it is visible in EVERY pane
870
+ // (schedule-delete's warning is pane-8-only by design; run cancel
871
+ // is a root action).
872
+ if (this.runCancelArmed) {
873
+ lines.push(row(fg("warning", `⚠ x again to CANCEL '${this.selectedRunId() ?? "(none)"}' · any other key disarms`)));
874
+ } else if (this.runCancelRefused) {
875
+ lines.push(row(fg("dim", "nothing to cancel — the selected run is not active")));
876
+ }
827
877
 
828
878
  if (this.runs.length === 0) {
829
879
  // F-7: actionable empty state instead of a bare "No runs.".
@@ -1076,7 +1126,16 @@ export class RunDashboard implements DashboardComponent {
1076
1126
  this.scheduleDeleteArmed = false;
1077
1127
  this.invalidate();
1078
1128
  }
1129
+ // US-020: the run-cancel confirm gate disarms on any non-cancel key
1130
+ // (schedule-delete precedent), and a stale refusal line clears.
1131
+ if (this.runCancelArmed && action !== "cancel") {
1132
+ this.runCancelArmed = false;
1133
+ this.invalidate();
1134
+ }
1135
+ if (this.runCancelRefused && action !== "cancel") this.runCancelRefused = false;
1079
1136
  const selectedRunId = this.selectedRunId();
1137
+ // US-020 AC-2: keep the cursor across close/reopen cycles.
1138
+ if (selectedRunId) lastSelectedRunId = selectedRunId;
1080
1139
  if (action === "close") {
1081
1140
  this.done(undefined);
1082
1141
  return;
@@ -1085,6 +1144,36 @@ export class RunDashboard implements DashboardComponent {
1085
1144
  this.done(selectedRunId ? { runId: selectedRunId, action: "status" } : undefined);
1086
1145
  return;
1087
1146
  }
1147
+ // US-020: cancel the SELECTED run — dedicated branch (plan-approve
1148
+ // precedent) so a stray keystroke can never close the dashboard via
1149
+ // done(undefined). 2-step confirm gate: first x arms (render shows the
1150
+ // warning line), second x dispatches the existing cancel channel; a
1151
+ // non-running selection refuses with a status line (AC-3) — the guard
1152
+ // mutation target.
1153
+ if (action === "cancel") {
1154
+ const run = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
1155
+ const manifest = run ? (snapshotFor(run, this.options.snapshotCache)?.manifest ?? run) : undefined;
1156
+ if (this.runCancelArmed) {
1157
+ this.runCancelArmed = false;
1158
+ if (manifest && isActiveRunStatus(manifest.status)) {
1159
+ this.done({ runId: manifest.runId, action: "cancel" });
1160
+ } else {
1161
+ this.runCancelRefused = true;
1162
+ this.invalidate();
1163
+ }
1164
+ return;
1165
+ }
1166
+ this.runCancelRefused = manifest ? !isActiveRunStatus(manifest.status) : false;
1167
+ if (this.runCancelRefused) {
1168
+ this.invalidate();
1169
+ return;
1170
+ }
1171
+ if (manifest) {
1172
+ this.runCancelArmed = true;
1173
+ this.invalidate();
1174
+ }
1175
+ return;
1176
+ }
1088
1177
  // WP-3 (H4-subset): plan approval keys. Deliberately a DEDICATED branch
1089
1178
  // (not the generic union block below) because plan actions must gate on
1090
1179
  // the selected run actually being parked on a pending approval — a
@@ -1177,10 +1266,14 @@ export class RunDashboard implements DashboardComponent {
1177
1266
  this.planDiff = !this.planDiff;
1178
1267
  this.invalidate();
1179
1268
  return;
1180
- } else if (action === "up") this.selected = Math.max(0, this.selected - 1);
1181
- else if (action === "down") {
1269
+ } else if (action === "up" || action === "down") {
1270
+ // US-020 AC-1: WRAP at the list edges (was: hard clamp). An empty list
1271
+ // must not divide by zero — stay at 0.
1182
1272
  const selectableCount = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).length;
1183
- this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
1273
+ if (selectableCount > 0) {
1274
+ this.selected =
1275
+ action === "up" ? (this.selected - 1 + selectableCount) % selectableCount : (this.selected + 1) % selectableCount;
1276
+ }
1184
1277
  }
1185
1278
  if (action) {
1186
1279
  lastActivePane = this.activePane;
@@ -27,6 +27,13 @@ export interface RunSnapshotCache extends RunSnapshotCacheBase {
27
27
  preloadAllStale(runIds: string[]): Promise<void>;
28
28
  /** Task 17 (perf/review-2026-08-24): watcher-facing coalesced async refresh. */
29
29
  scheduleRefresh(runId: string): void;
30
+ /**
31
+ * F13 (RR-018): true once dispose() has run. Callers that retain the cache
32
+ * (RegistrationContext.getManifestCache/getRunSnapshotCache) use this to
33
+ * recreate a FRESH instance even when the cwd is unchanged — a disposed
34
+ * instance must never be handed back just because `cacheCwd` still matches.
35
+ */
36
+ isDisposed(): boolean;
30
37
  }
31
38
 
32
39
  /** WP-7 (R7): the plans slice + Plan pane load only when this flag is set —
@@ -840,6 +847,9 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
840
847
  const recentEventsLimit = options.recentEvents ?? DEFAULT_RECENT_EVENTS;
841
848
  const recentOutputLimit = options.recentOutputLines ?? DEFAULT_RECENT_OUTPUT_LINES;
842
849
  const entries = new Map<string, CacheEntry>();
850
+ // F13 (RR-018): liveness flag — dispose() flips it so cache holders can
851
+ // detect a disposed instance and recreate instead of reusing it.
852
+ let disposed = false;
843
853
 
844
854
  function touch(runId: string, entry: CacheEntry): RunUiSnapshot {
845
855
  entry.lastAccessMs = Date.now();
@@ -883,7 +893,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
883
893
  let tasks: TeamTaskState[];
884
894
  let agents: CrewAgentRecord[];
885
895
  try {
886
- // R10-4 (docs/refactor-plan.review.md §ROUND 10): sync/async parity.
896
+ // R10-4 (docs/archive/refactor-plan.review.md §ROUND 10): sync/async parity.
887
897
  // loadRunManifestById already returns tasks validated against the
888
898
  // current tasks.json (mtime+size+generation check in state-store),
889
899
  // so the old readTasks() re-read only doubled tasks.json I/O per
@@ -1143,9 +1153,16 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
1143
1153
  return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
1144
1154
  },
1145
1155
  dispose(): void {
1156
+ // F13: idempotent — a second dispose must be a no-op, never a throw
1157
+ // (cleanup paths can race a lazy cache swap).
1158
+ if (disposed) return;
1159
+ disposed = true;
1146
1160
  unsubscribe();
1147
1161
  inFlightRefreshes.clear();
1148
1162
  entries.clear();
1149
1163
  },
1164
+ isDisposed(): boolean {
1165
+ return disposed;
1166
+ },
1150
1167
  };
1151
1168
  }
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
  }
@@ -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. */
@@ -177,7 +178,7 @@ function taskCounts(tasks: TeamTaskState[] | undefined): string | undefined {
177
178
  * the chat widget overlay does not jitter.
178
179
  */
179
180
  export function formatCompactToolProgress(input: ToolProgressInput): string {
180
- const elapsedSec = Math.max(0, Math.round((Date.now() - input.startedAt) / 1000));
181
+ const elapsedSec = Math.max(0, Math.round((spinnerClockNow() - input.startedAt) / 1000));
181
182
  const head = input.agentId ? `${PROGRESS_FORMAT.agentKey}=${input.agentId}` : PROGRESS_FORMAT.agentKey;
182
183
  // Emitted from PROGRESS_FORMAT so the parser can never drift from the wire format.
183
184
  const lines: string[] = [
@@ -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 ──────────────────────────────────────────────────────────────
@@ -149,7 +150,7 @@ function computeTotalDuration(records: CrewAgentRecord[]): number {
149
150
  for (const r of records) {
150
151
  if (r.startedAt) {
151
152
  const start = new Date(r.startedAt).getTime();
152
- const end = r.completedAt ? new Date(r.completedAt).getTime() : Date.now();
153
+ const end = r.completedAt ? new Date(r.completedAt).getTime() : spinnerClockNow();
153
154
  if (Number.isFinite(start) && Number.isFinite(end)) total += Math.max(0, end - start);
154
155
  }
155
156
  }
@@ -46,7 +46,7 @@ import {
46
46
  statusSlot,
47
47
  truncVisual,
48
48
  } from "../rail.ts";
49
- import { spinnerFrame } from "../spinner.ts";
49
+ import { spinnerClockNow, spinnerFrame } from "../spinner.ts";
50
50
  import type { CrewTheme } from "../theme-adapter.ts";
51
51
  import { parseCompactToolProgress } from "../tool-progress-formatter.ts";
52
52
  import { briefToolResult, isBrief } from "./brief-mode.ts";
@@ -206,7 +206,7 @@ function renderTeamResult(result: Record<string, unknown>, options: unknown, the
206
206
  if (isPartial && !ctx.expanded) {
207
207
  const parsed = parseStreamingProgress(extractContentText(result?.content));
208
208
  if (parsed) {
209
- const spinner = theme.fg("accent", spinnerFrame(String(Date.now())));
209
+ const spinner = theme.fg("accent", spinnerFrame(String(spinnerClockNow())));
210
210
  const elapsed = theme.fg("dim", formatDuration(parsed.elapsedMs));
211
211
  const lines: string[] = [];
212
212
 
@@ -550,7 +550,7 @@ function computeTotalDuration(records: CrewAgentRecord[]): number {
550
550
  function computeRecordDuration(r: CrewAgentRecord): number {
551
551
  if (!r.startedAt) return 0;
552
552
  const start = new Date(r.startedAt).getTime();
553
- const end = r.completedAt ? new Date(r.completedAt).getTime() : Date.now();
553
+ const end = r.completedAt ? new Date(r.completedAt).getTime() : spinnerClockNow();
554
554
  if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
555
555
  return Math.max(0, end - start);
556
556
  }
@@ -157,7 +157,7 @@ export function readLinesSince(filePath: string, state: IncrementalReadState): I
157
157
  totalRead += bytesRead;
158
158
  }
159
159
 
160
- const content = buf.toString("utf-8", 0, totalRead);
160
+ const content = buf.subarray(0, totalRead);
161
161
  const lines: string[] = [];
162
162
  let lineCount = state.lineCount;
163
163
  let committedOffset = state.byteOffset;
@@ -165,8 +165,16 @@ export function readLinesSince(filePath: string, state: IncrementalReadState): I
165
165
  let searchFrom = 0;
166
166
  let newlineIdx: number;
167
167
 
168
- while ((newlineIdx = content.indexOf("\n", searchFrom)) !== -1) {
169
- const lineText = content.slice(searchFrom, newlineIdx);
168
+ // F07 / RR-017: scan the BUFFER, not a decoded string. A string index is a
169
+ // CHARACTER index, so using it as a byte offset silently mis-commits the
170
+ // watermark on any multi-byte UTF-8 line (Vietnamese diacritics, emoji,
171
+ // CJK): the next read then restarts mid-line and produces a corrupt line
172
+ // (measured: a delta read of a 4-line file returned one `raw` garbage
173
+ // event plus the three real ones). Byte offsets are what the API promises,
174
+ // so `Buffer.indexOf(0x0a)` is the correct scan and decoding happens only
175
+ // per complete line.
176
+ while ((newlineIdx = content.indexOf(0x0a, searchFrom)) !== -1) {
177
+ const lineText = content.toString("utf-8", searchFrom, newlineIdx);
170
178
  committedOffset = state.byteOffset + newlineIdx + 1;
171
179
  searchFrom = newlineIdx + 1;
172
180
  if (lineText.length > 0) {
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { getCrewEnv } from "../config/env-vars.ts";
6
+ import { PROJECT_DIR_MARKERS, PROJECT_FILE_MARKERS } from "./project-markers.ts";
6
7
 
7
8
  // NEW-P1/NEW-P2 (perf): packageRoot() and userPiRoot() are invariant for a
8
9
  // process lifetime but each call did statSync/readFileSync/lstatSync (10-15+
@@ -111,17 +112,9 @@ export function userPiRoot(): string {
111
112
  return resolved;
112
113
  }
113
114
 
114
- const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
115
- const PROJECT_FILE_MARKERS = [
116
- "package.json",
117
- "pyproject.toml",
118
- "Cargo.toml",
119
- "go.mod",
120
- "pom.xml",
121
- "composer.json",
122
- "build.gradle",
123
- "build.gradle.kts",
124
- ];
115
+ // Marker lists live in ./project-markers.ts (RR-020 Fix 1) so that
116
+ // src/state/crew-init.ts:findProjectRoot() cannot drift from this resolver
117
+ // again — the drift produced two different project roots for one cwd.
125
118
 
126
119
  // 2.10 — cache findRepoRoot results so repeated lookups during render ticks
127
120
  // (loadConfig, state-store helpers, powerbar, snapshot-cache, ...) skip the
@@ -180,7 +173,14 @@ export function findRepoRoot(cwd: string): string | undefined {
180
173
  // newly-created test directories and shouldn't propagate as a crash.
181
174
  let startKey: string;
182
175
  try {
183
- startKey = fs.realpathSync(cwd);
176
+ // Canonicalize with .native (long-name form on Windows). The home/temp
177
+ // boundaries in computeRepoRoot are canonicalized with realpathSync.native
178
+ // too — a non-native start preserves 8.3 short names (RUNNER~1) on win32,
179
+ // so the walk chain NEVER textually matches the long-name boundary and
180
+ // escapes the sandbox, latching onto an ancestor marker (live CI: runs
181
+ // created under a sandboxed home landed in the REAL home's .pi/teams).
182
+ // On Linux/macOS native and non-native resolve identically.
183
+ startKey = fs.realpathSync.native(cwd);
184
184
  } catch (error) {
185
185
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
186
186
  startKey = path.resolve(cwd);
@@ -252,3 +252,85 @@ export function projectCrewRoot(cwd: string): string {
252
252
  export function userCrewRoot(): string {
253
253
  return path.join(userPiRoot(), "extensions", "pi-crew");
254
254
  }
255
+
256
+ /**
257
+ * Relative path of the run-state directory under a pi-crew root, per layout:
258
+ * - `.crew` → `<root>/.crew/state/runs` (classic layout)
259
+ * - `.pi/teams` → `<root>/.pi/teams/state/runs` (`.pi`-based layout)
260
+ * Mirrors `projectCrewRoot`'s layout choice, so scanners can recognise the
261
+ * SAME two layouts the rest of pi-crew writes.
262
+ */
263
+ export const RUN_STATE_RUNS_SUBPATHS = [path.join(".crew", "state", "runs"), path.join(".pi", "teams", "state", "runs")];
264
+
265
+ /** Layout dirs derived from RUN_STATE_RUNS_SUBPATHS — the single list (a
266
+ * literal copy here is exactly the drift Fix 1 killed for project markers). */
267
+ const RUN_STATE_LAYOUT_DIRS = RUN_STATE_RUNS_SUBPATHS.map((rel) => path.dirname(path.dirname(rel)));
268
+
269
+ /** Layout dirs split into components for the per-component symlink walk
270
+ * (cold-verify F-1: lstat only sees the FINAL path component, so for the
271
+ * `.pi/teams` layout the intermediate `.pi` was never checked). */
272
+ const RUN_STATE_LAYOUT_SEGMENTS = RUN_STATE_LAYOUT_DIRS.map((layout) => layout.split(/[\\/]+/).filter(Boolean));
273
+
274
+ /** True when `p` exists and is NOT a symlink (dir-or-file). A planted symlink
275
+ * never passes: scanners must reject rather than trust it. */
276
+ function existsNoSymlink(p: string): boolean {
277
+ try {
278
+ return !fs.lstatSync(p).isSymbolicLink();
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+
284
+ /** True when EVERY component of `path.join(dir, ...segments)` exists and is
285
+ * not a symlink. lstat rejects only the FINAL component, so intermediate
286
+ * symlinks must be walked component-by-component — cold-verify F-1 reproduced
287
+ * out-of-tree read AND write through `ws/.pi -> /outside` because `.pi` was
288
+ * intermediate in all three lstat calls of the earlier fix. */
289
+ function existsSymlinkFreePath(dir: string, segments: string[]): boolean {
290
+ let current = dir;
291
+ for (const seg of segments) {
292
+ current = path.join(current, seg);
293
+ if (!existsNoSymlink(current)) return false;
294
+ }
295
+ return true;
296
+ }
297
+
298
+ /**
299
+ * True when `dir` holds pi-crew run state under EITHER supported layout
300
+ * (`.crew/` or `.pi/teams/`) — used by /tmp debris scanners so a temp
301
+ * workspace with live `.pi/teams/state/runs` state is not deleted.
302
+ *
303
+ * A SYMLINKED layout dir never counts: scanners must be able to reject a
304
+ * planted symlink instead of treating it as protection.
305
+ */
306
+ export function hasRunStateLayout(dir: string): boolean {
307
+ for (const segments of RUN_STATE_LAYOUT_SEGMENTS) {
308
+ if (existsSymlinkFreePath(dir, segments)) return true;
309
+ }
310
+ return false;
311
+ }
312
+
313
+ /**
314
+ * Resolve the run-state dir (`<layout>/state/runs`) that actually exists under
315
+ * `dir`, checking BOTH supported layouts. Returns undefined when neither
316
+ * layout has a runs dir (i.e. `dir` holds no pi-crew run state).
317
+ *
318
+ * Symlink policy matches `hasRunStateLayout` (cold-verify correction: the
319
+ * first version used existsSync, which FOLLOWS symlinks — a planted
320
+ * `.crew`/`.pi/teams`/`state/runs` symlink let stale-reconciler and
321
+ * health-monitor read — and reconcile — run manifests OUTSIDE the scanned
322
+ * tree). Both the layout dir and the runs dir itself must be symlink-free.
323
+ */
324
+ export function findRunStateDir(dir: string): string | undefined {
325
+ for (const segments of RUN_STATE_LAYOUT_SEGMENTS) {
326
+ // Cold-verify F-1/F-A: EVERY component must be symlink-free — lstat only
327
+ // sees the final path component, so intermediate symlinks (`.pi` of the
328
+ // `.pi/teams` layout, the middle `state`, …) were traversed transparently
329
+ // and let scanners READ and WRITE run manifests OUTSIDE the scanned tree
330
+ // (reproduced: reconcileOrphanedTempWorkspaces flipped an outside manifest
331
+ // running→cancelled through both a `.crew/state` and a `.pi` symlink).
332
+ if (!existsSymlinkFreePath(dir, [...segments, "state", "runs"])) continue;
333
+ return path.join(dir, ...segments, "state", "runs");
334
+ }
335
+ return undefined;
336
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * project-markers.ts — SINGLE source of truth for the project-root marker lists.
3
+ *
4
+ * Why this module exists (RR-020 Fix 1): the marker lists were duplicated and
5
+ * had DRIFTED apart:
6
+ * - `src/utils/paths.ts` (computeRepoRoot) used 15 markers (.pi/.crew/
7
+ * .factory/.omc + 8 build-file markers)
8
+ * - `src/state/crew-init.ts` (findProjectRoot) used 7 markers (.git/.hg/.svn
9
+ * + package.json/pyproject.toml/Cargo.toml/go.mod)
10
+ * For cwd=`parent/subproject` where `parent/.git` and `parent/subproject/.pi`
11
+ * exist, paths.ts resolved the root to `subproject` (→ `subproject/.pi/teams`)
12
+ * while crew-init.ts walked past `.pi` up to `parent/.git` (→ `parent/.crew`),
13
+ * so ONE run could end up with TWO roots (`run-intent.ts` → ensureCrewDirectory).
14
+ * Both resolvers now read the lists from here.
15
+ *
16
+ * CONTRACT — do not break:
17
+ * - NO imports at all (plain string[] literals only). `crew-init.ts` is
18
+ * dynamically `import()`'d from concurrent child Pi subprocesses and under
19
+ * load jiti's ESM/CJS interop can leave namespace bindings `undefined`
20
+ * (issue #28). Importing anything here (even `node:path`) would re-introduce
21
+ * that race for the very module that was hardened against it.
22
+ * - Values must stay IDENTICAL to the historical `paths.ts` lists — they are
23
+ * the widest set, so unifying on them never resolves a root LOWER than
24
+ * before (the direction that would break existing projects).
25
+ */
26
+
27
+ /** Directory names that mark a project root. */
28
+ export const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
29
+
30
+ /** Build-system file names that mark a project root. */
31
+ export const PROJECT_FILE_MARKERS = [
32
+ "package.json",
33
+ "pyproject.toml",
34
+ "Cargo.toml",
35
+ "go.mod",
36
+ "pom.xml",
37
+ "composer.json",
38
+ "build.gradle",
39
+ "build.gradle.kts",
40
+ ];