pi-crew 0.11.1 → 0.11.4
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.
- package/CHANGELOG.md +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -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;
|
|
@@ -226,7 +248,17 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?:
|
|
|
226
248
|
if (snapshotCache) return ["Progress: (loading…)"];
|
|
227
249
|
// Legacy fallback: tests/dev paths without a snapshot cache still read the
|
|
228
250
|
// progress artifact directly so existing assertions keep working.
|
|
229
|
-
|
|
251
|
+
// RR-021 WI-4.3e: allocation-free reverse scan — the old
|
|
252
|
+
// [...run.artifacts].reverse().find() copied the whole array per render.
|
|
253
|
+
// (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
|
|
254
|
+
// ambient resolution — see WI-4.3 evidence note.)
|
|
255
|
+
let progress: (typeof run.artifacts)[number] | undefined;
|
|
256
|
+
for (let i = run.artifacts.length - 1; i >= 0; i--) {
|
|
257
|
+
if (run.artifacts[i]?.kind === "progress") {
|
|
258
|
+
progress = run.artifacts[i];
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
230
262
|
if (!progress) return ["Progress: (none)"];
|
|
231
263
|
try {
|
|
232
264
|
const progressPath = resolveRealContainedPath(run.artifactsRoot, progress.path);
|
|
@@ -496,6 +528,11 @@ export class RunDashboard implements DashboardComponent {
|
|
|
496
528
|
private scheduleSelected = 0;
|
|
497
529
|
private scheduleDetails = false;
|
|
498
530
|
private scheduleDeleteArmed = false;
|
|
531
|
+
/** US-020 (run cancel): 2-step confirm-gate + refusal status line —
|
|
532
|
+
* mirrors the schedules delete gate (first x arms, second x dispatches,
|
|
533
|
+
* any other key disarms; a non-running selection refuses with a line). */
|
|
534
|
+
private runCancelArmed = false;
|
|
535
|
+
private runCancelRefused = false;
|
|
499
536
|
private scheduleJobsCache: { at: number; jobs: ScheduledJob[]; hiddenCount: number } | undefined;
|
|
500
537
|
private runs: TeamRunManifest[];
|
|
501
538
|
private readonly done: (selection: RunDashboardSelection | undefined) => void;
|
|
@@ -523,6 +560,15 @@ export class RunDashboard implements DashboardComponent {
|
|
|
523
560
|
? runs.filter((run) => !run.ownerSessionId || run.ownerSessionId === options.workspaceId)
|
|
524
561
|
: runs;
|
|
525
562
|
this.runs = filteredRuns;
|
|
563
|
+
// US-020 AC-2: restore the cursor to the previously selected run (if it
|
|
564
|
+
// is still in the list) so escape-return keeps the user's place.
|
|
565
|
+
if (lastSelectedRunId) {
|
|
566
|
+
// groupedRuns rows include GROUP LABELS ("Active"/"Recent") — index
|
|
567
|
+
// into the SELECTABLE rows only (the same projection navigation uses).
|
|
568
|
+
const selectable = groupedRuns(filteredRuns, options.snapshotCache).filter((rowItem) => rowItem.run);
|
|
569
|
+
const idx = selectable.findIndex((rowItem) => rowItem.run?.runId === lastSelectedRunId);
|
|
570
|
+
if (idx >= 0) this.selected = idx;
|
|
571
|
+
}
|
|
526
572
|
this.done = done;
|
|
527
573
|
this.theme = asCrewTheme(theme);
|
|
528
574
|
this.options = options;
|
|
@@ -814,16 +860,30 @@ export class RunDashboard implements DashboardComponent {
|
|
|
814
860
|
canopyLine({
|
|
815
861
|
word: "DASHBOARD",
|
|
816
862
|
subject: formatCount(this.runs.length, "run"),
|
|
863
|
+
// US-020: the cancel hint joins the canopy only when the budget
|
|
864
|
+
// has room — at narrow widths the canopy prefers the run-count
|
|
865
|
+
// subject over hints, and the 5th hint ellipsized it (regression
|
|
866
|
+
// caught by run-dashboard.test.ts's 80-col canonical canopy).
|
|
817
867
|
right: formatHint([
|
|
818
868
|
["1-8", "pane"],
|
|
819
869
|
[["up", "down"], "move"],
|
|
820
870
|
["enter", "select"],
|
|
871
|
+
...(budget >= 96 ? ([["x", "cancel"]] as const) : []),
|
|
821
872
|
["?", "help"],
|
|
822
873
|
]),
|
|
823
874
|
theme: this.theme,
|
|
824
875
|
budget,
|
|
825
876
|
}),
|
|
826
877
|
);
|
|
878
|
+
// US-020: run-cancel confirm gate / refusal status line —
|
|
879
|
+
// rendered right under the canopy so it is visible in EVERY pane
|
|
880
|
+
// (schedule-delete's warning is pane-8-only by design; run cancel
|
|
881
|
+
// is a root action).
|
|
882
|
+
if (this.runCancelArmed) {
|
|
883
|
+
lines.push(row(fg("warning", `⚠ x again to CANCEL '${this.selectedRunId() ?? "(none)"}' · any other key disarms`)));
|
|
884
|
+
} else if (this.runCancelRefused) {
|
|
885
|
+
lines.push(row(fg("dim", "nothing to cancel — the selected run is not active")));
|
|
886
|
+
}
|
|
827
887
|
|
|
828
888
|
if (this.runs.length === 0) {
|
|
829
889
|
// F-7: actionable empty state instead of a bare "No runs.".
|
|
@@ -1076,7 +1136,16 @@ export class RunDashboard implements DashboardComponent {
|
|
|
1076
1136
|
this.scheduleDeleteArmed = false;
|
|
1077
1137
|
this.invalidate();
|
|
1078
1138
|
}
|
|
1139
|
+
// US-020: the run-cancel confirm gate disarms on any non-cancel key
|
|
1140
|
+
// (schedule-delete precedent), and a stale refusal line clears.
|
|
1141
|
+
if (this.runCancelArmed && action !== "cancel") {
|
|
1142
|
+
this.runCancelArmed = false;
|
|
1143
|
+
this.invalidate();
|
|
1144
|
+
}
|
|
1145
|
+
if (this.runCancelRefused && action !== "cancel") this.runCancelRefused = false;
|
|
1079
1146
|
const selectedRunId = this.selectedRunId();
|
|
1147
|
+
// US-020 AC-2: keep the cursor across close/reopen cycles.
|
|
1148
|
+
if (selectedRunId) lastSelectedRunId = selectedRunId;
|
|
1080
1149
|
if (action === "close") {
|
|
1081
1150
|
this.done(undefined);
|
|
1082
1151
|
return;
|
|
@@ -1085,6 +1154,36 @@ export class RunDashboard implements DashboardComponent {
|
|
|
1085
1154
|
this.done(selectedRunId ? { runId: selectedRunId, action: "status" } : undefined);
|
|
1086
1155
|
return;
|
|
1087
1156
|
}
|
|
1157
|
+
// US-020: cancel the SELECTED run — dedicated branch (plan-approve
|
|
1158
|
+
// precedent) so a stray keystroke can never close the dashboard via
|
|
1159
|
+
// done(undefined). 2-step confirm gate: first x arms (render shows the
|
|
1160
|
+
// warning line), second x dispatches the existing cancel channel; a
|
|
1161
|
+
// non-running selection refuses with a status line (AC-3) — the guard
|
|
1162
|
+
// mutation target.
|
|
1163
|
+
if (action === "cancel") {
|
|
1164
|
+
const run = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
|
|
1165
|
+
const manifest = run ? (snapshotFor(run, this.options.snapshotCache)?.manifest ?? run) : undefined;
|
|
1166
|
+
if (this.runCancelArmed) {
|
|
1167
|
+
this.runCancelArmed = false;
|
|
1168
|
+
if (manifest && isActiveRunStatus(manifest.status)) {
|
|
1169
|
+
this.done({ runId: manifest.runId, action: "cancel" });
|
|
1170
|
+
} else {
|
|
1171
|
+
this.runCancelRefused = true;
|
|
1172
|
+
this.invalidate();
|
|
1173
|
+
}
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
this.runCancelRefused = manifest ? !isActiveRunStatus(manifest.status) : false;
|
|
1177
|
+
if (this.runCancelRefused) {
|
|
1178
|
+
this.invalidate();
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (manifest) {
|
|
1182
|
+
this.runCancelArmed = true;
|
|
1183
|
+
this.invalidate();
|
|
1184
|
+
}
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1088
1187
|
// WP-3 (H4-subset): plan approval keys. Deliberately a DEDICATED branch
|
|
1089
1188
|
// (not the generic union block below) because plan actions must gate on
|
|
1090
1189
|
// the selected run actually being parked on a pending approval — a
|
|
@@ -1177,10 +1276,14 @@ export class RunDashboard implements DashboardComponent {
|
|
|
1177
1276
|
this.planDiff = !this.planDiff;
|
|
1178
1277
|
this.invalidate();
|
|
1179
1278
|
return;
|
|
1180
|
-
} else if (action === "up"
|
|
1181
|
-
|
|
1279
|
+
} else if (action === "up" || action === "down") {
|
|
1280
|
+
// US-020 AC-1: WRAP at the list edges (was: hard clamp). An empty list
|
|
1281
|
+
// must not divide by zero — stay at 0.
|
|
1182
1282
|
const selectableCount = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).length;
|
|
1183
|
-
|
|
1283
|
+
if (selectableCount > 0) {
|
|
1284
|
+
this.selected =
|
|
1285
|
+
action === "up" ? (this.selected - 1 + selectableCount) % selectableCount : (this.selected + 1) % selectableCount;
|
|
1286
|
+
}
|
|
1184
1287
|
}
|
|
1185
1288
|
if (action) {
|
|
1186
1289
|
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 —
|
|
@@ -685,9 +692,15 @@ async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentReco
|
|
|
685
692
|
}
|
|
686
693
|
|
|
687
694
|
function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
695
|
+
// RR-021 WI-4.3e: allocation-free reverse scan — the old
|
|
696
|
+
// [...events].reverse().find() copied the whole array on every call.
|
|
697
|
+
// (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
|
|
698
|
+
// ambient resolution — see WI-4.3 evidence note.)
|
|
699
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
700
|
+
const event = events[i];
|
|
701
|
+
if (event?.type === "run.cancelled" && typeof event.data?.reason === "string") return event.data.reason;
|
|
702
|
+
}
|
|
703
|
+
return undefined;
|
|
691
704
|
}
|
|
692
705
|
|
|
693
706
|
type SliceSignatures = NonNullable<RunUiSnapshot["sliceSignatures"]>;
|
|
@@ -840,6 +853,9 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
840
853
|
const recentEventsLimit = options.recentEvents ?? DEFAULT_RECENT_EVENTS;
|
|
841
854
|
const recentOutputLimit = options.recentOutputLines ?? DEFAULT_RECENT_OUTPUT_LINES;
|
|
842
855
|
const entries = new Map<string, CacheEntry>();
|
|
856
|
+
// F13 (RR-018): liveness flag — dispose() flips it so cache holders can
|
|
857
|
+
// detect a disposed instance and recreate instead of reusing it.
|
|
858
|
+
let disposed = false;
|
|
843
859
|
|
|
844
860
|
function touch(runId: string, entry: CacheEntry): RunUiSnapshot {
|
|
845
861
|
entry.lastAccessMs = Date.now();
|
|
@@ -883,7 +899,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
883
899
|
let tasks: TeamTaskState[];
|
|
884
900
|
let agents: CrewAgentRecord[];
|
|
885
901
|
try {
|
|
886
|
-
// R10-4 (docs/refactor-plan.review.md §ROUND 10): sync/async parity.
|
|
902
|
+
// R10-4 (docs/archive/refactor-plan.review.md §ROUND 10): sync/async parity.
|
|
887
903
|
// loadRunManifestById already returns tasks validated against the
|
|
888
904
|
// current tasks.json (mtime+size+generation check in state-store),
|
|
889
905
|
// so the old readTasks() re-read only doubled tasks.json I/O per
|
|
@@ -1143,9 +1159,16 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
1143
1159
|
return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
|
|
1144
1160
|
},
|
|
1145
1161
|
dispose(): void {
|
|
1162
|
+
// F13: idempotent — a second dispose must be a no-op, never a throw
|
|
1163
|
+
// (cleanup paths can race a lazy cache swap).
|
|
1164
|
+
if (disposed) return;
|
|
1165
|
+
disposed = true;
|
|
1146
1166
|
unsubscribe();
|
|
1147
1167
|
inFlightRefreshes.clear();
|
|
1148
1168
|
entries.clear();
|
|
1149
1169
|
},
|
|
1170
|
+
isDisposed(): boolean {
|
|
1171
|
+
return disposed;
|
|
1172
|
+
},
|
|
1150
1173
|
};
|
|
1151
1174
|
}
|
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
|
-
|
|
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 =
|
|
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((
|
|
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() :
|
|
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(
|
|
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() :
|
|
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
|
}
|
|
@@ -126,10 +126,12 @@ function runningSuffix(task: TeamTaskState): string {
|
|
|
126
126
|
|
|
127
127
|
/** `› blocked by #2, #3` for queued tasks — open (non-completed)
|
|
128
128
|
* dependencies only, named by task number (pi-tasks display rule). */
|
|
129
|
-
function blockedSuffix(task: TeamTaskState,
|
|
129
|
+
function blockedSuffix(task: TeamTaskState, taskById: Map<string, TeamTaskState>, numberById: Map<string, number>): string {
|
|
130
130
|
if (task.status !== "queued") return "";
|
|
131
|
+
// RR-021 WI-4.3b: Map lookup — tasks.find() per dependency was O(deps×tasks)
|
|
132
|
+
// on every queued row render.
|
|
131
133
|
const openDeps = (task.dependsOn ?? [])
|
|
132
|
-
.map((depId) =>
|
|
134
|
+
.map((depId) => taskById.get(depId))
|
|
133
135
|
.filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed");
|
|
134
136
|
if (openDeps.length === 0) return "";
|
|
135
137
|
const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`);
|
|
@@ -139,9 +141,9 @@ function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], num
|
|
|
139
141
|
/** One line per task: `#n title` behind its status glyph — pi-tasks style.
|
|
140
142
|
* Completed titles are dimmed and struck through. Returns the ROW CONTENT:
|
|
141
143
|
* the rail glyph (`┃ `) is owned by `railLine`. */
|
|
142
|
-
function taskRow(task: TeamTaskState, taskNumber: number,
|
|
144
|
+
function taskRow(task: TeamTaskState, taskNumber: number, taskById: Map<string, TeamTaskState>, numberById: Map<string, number>): string {
|
|
143
145
|
const title = taskTitle(task);
|
|
144
|
-
const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task,
|
|
146
|
+
const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, taskById, numberById);
|
|
145
147
|
if (task.status === "completed") {
|
|
146
148
|
const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`;
|
|
147
149
|
return `${taskStatusIcon(task)} ${struck}`;
|
|
@@ -182,6 +184,7 @@ export function buildTaskListLines(runs: readonly WidgetRun[], width: number, th
|
|
|
182
184
|
|
|
183
185
|
const budget = Math.max(8, width - 2);
|
|
184
186
|
const numberById = new Map(tasks.map((task, index) => [task.id, index + 1]));
|
|
187
|
+
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
|
185
188
|
const done = tasks.filter((task) => task.status === "completed").length;
|
|
186
189
|
const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length;
|
|
187
190
|
const inProgress = tasks.filter(
|
|
@@ -211,7 +214,7 @@ export function buildTaskListLines(runs: readonly WidgetRun[], width: number, th
|
|
|
211
214
|
|
|
212
215
|
const visible = visibleTasks(tasks);
|
|
213
216
|
for (const task of visible) {
|
|
214
|
-
lines.push(railLine(RAIL.body, "border", taskRow(task, numberById.get(task.id) ?? 0,
|
|
217
|
+
lines.push(railLine(RAIL.body, "border", taskRow(task, numberById.get(task.id) ?? 0, taskById, numberById), theme, budget));
|
|
215
218
|
}
|
|
216
219
|
const hidden = tasks.length - visible.length;
|
|
217
220
|
if (hidden > 0) {
|
|
@@ -46,7 +46,9 @@ export function activeWidgetRuns(
|
|
|
46
46
|
if (now - lastStaleReconcileAt > STALE_RECONCILE_INTERVAL_MS && manifestCache) {
|
|
47
47
|
lastStaleReconcileAt = now;
|
|
48
48
|
try {
|
|
49
|
-
reconcileAllStaleRuns(cwd, manifestCache, Date.now(), workspaceId)
|
|
49
|
+
void reconcileAllStaleRuns(cwd, manifestCache, Date.now(), workspaceId).catch(() => {
|
|
50
|
+
/* non-critical */
|
|
51
|
+
});
|
|
50
52
|
} catch {
|
|
51
53
|
/* non-critical */
|
|
52
54
|
}
|
|
@@ -157,7 +157,7 @@ export function readLinesSince(filePath: string, state: IncrementalReadState): I
|
|
|
157
157
|
totalRead += bytesRead;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
const content = buf.
|
|
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
|
-
|
|
169
|
-
|
|
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) {
|
package/src/utils/paths.ts
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
+
];
|