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.
- package/CHANGELOG.md +39 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -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 +1 -3
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +193 -36
- 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 +23 -0
- 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/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 +23 -0
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- 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/wire-cross-extension.ts +28 -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-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -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/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- 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 +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -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 +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- 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/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- 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 +140 -15
- 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 +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +11 -2
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/run-dashboard.ts +98 -5
- package/src/ui/run-snapshot-cache.ts +18 -1
- 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/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/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/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;
|
|
@@ -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"
|
|
1181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
];
|