pi-crew 0.9.9 → 0.9.10
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 +278 -0
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/child-pi.ts +122 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/role-permission.ts +2 -2
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/task-output-context.ts +161 -27
- package/src/runtime/task-runner.ts +76 -15
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +17 -1
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
|
@@ -763,7 +763,30 @@ export async function runTeamTask(
|
|
|
763
763
|
"",
|
|
764
764
|
);
|
|
765
765
|
if (!error) break;
|
|
766
|
-
|
|
766
|
+
let nextModel = attemptModels[i + 1];
|
|
767
|
+
// FIX 1 (task packet 01_01-agent): when the precomputed attempt
|
|
768
|
+
// chain is exhausted but the failure is retryable, do a one-shot
|
|
769
|
+
// re-resolve via buildConfiguredModelRouting with the failed
|
|
770
|
+
// model as parent. This finds alternative providers/models the
|
|
771
|
+
// original chain missed (e.g. a registry gained new fallbacks
|
|
772
|
+
// after the precompute, or the precompute ran before the parent
|
|
773
|
+
// model was known). If a different candidate is found, use it as
|
|
774
|
+
// nextModel; otherwise fall through to the existing break.
|
|
775
|
+
if (!nextModel && isRetryableModelFailure(error)) {
|
|
776
|
+
const reResolved = buildConfiguredModelRouting({
|
|
777
|
+
overrideModel: undefined,
|
|
778
|
+
stepModel: undefined,
|
|
779
|
+
teamRoleModel: undefined,
|
|
780
|
+
agentModel: undefined,
|
|
781
|
+
fallbackModels: undefined,
|
|
782
|
+
parentModel: attempt.model,
|
|
783
|
+
modelRegistry: input.modelRegistry,
|
|
784
|
+
cwd: task.cwd,
|
|
785
|
+
scopeModelsPatterns: await resolveTaskScopeModelsPatterns(task.cwd),
|
|
786
|
+
});
|
|
787
|
+
const alt = reResolved.candidates.find((c) => c !== attempt.model);
|
|
788
|
+
if (alt) nextModel = alt;
|
|
789
|
+
}
|
|
767
790
|
if (!nextModel || !isRetryableModelFailure(error)) break;
|
|
768
791
|
logs.push(formatModelAttemptNote(attempt, nextModel), "");
|
|
769
792
|
}
|
|
@@ -1368,19 +1391,57 @@ async function resolveTaskScopeModelsPatterns(cwd: string): Promise<string[]> {
|
|
|
1368
1391
|
* or when there are no retryable error messages.
|
|
1369
1392
|
*/
|
|
1370
1393
|
export function detectRetryableModelFailureFromOutput(parsed: ParsedPiJsonOutput): string | undefined {
|
|
1394
|
+
// Primary signal: pre-extracted `errorMessages` (from pi-json-output parser).
|
|
1395
|
+
// The parser already filters to non-empty trimmed strings from message_end
|
|
1396
|
+
// events.
|
|
1371
1397
|
const messages = parsed.errorMessages;
|
|
1372
|
-
if (
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1398
|
+
if (messages && messages.length > 0) {
|
|
1399
|
+
// Find the first retryable model-failure message
|
|
1400
|
+
// (429 / rate-limit / overloaded / 5xx / ...).
|
|
1401
|
+
const retryable = messages.find((m) => isRetryableModelFailure(m));
|
|
1402
|
+
if (retryable) {
|
|
1403
|
+
// Did the run actually produce real output despite the transient errors?
|
|
1404
|
+
// If finalText / textEvents / patches exist, the model recovered and we
|
|
1405
|
+
// should NOT mark the run as failed — only flag it when the worker
|
|
1406
|
+
// yielded nothing (the 429-only case from the bug report).
|
|
1407
|
+
const hasRealOutput =
|
|
1408
|
+
(parsed.finalText?.trim().length ?? 0) > 0 ||
|
|
1409
|
+
parsed.textEvents.some((t) => t.trim().length > 0) ||
|
|
1410
|
+
(parsed.patches?.length ?? 0) > 0;
|
|
1411
|
+
if (hasRealOutput) return undefined;
|
|
1412
|
+
return `Model returned only retryable errors and no output: ${retryable}`;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// Secondary signal (FIX 3, task packet 01_01-agent): inspect a raw
|
|
1416
|
+
// `messageEndEvents` (or `transcript`) array on the parsed output. The
|
|
1417
|
+
// ParsedPiJsonOutput type does not currently declare this field, so we
|
|
1418
|
+
// read it through a local extension cast. Callers that pass it (tests, a
|
|
1419
|
+
// future parser that captures the full event stream) get a second chance
|
|
1420
|
+
// to surface retryable failures. Primary path still wins when it matches.
|
|
1421
|
+
const raw = parsed as ParsedPiJsonOutput & {
|
|
1422
|
+
messageEndEvents?: unknown;
|
|
1423
|
+
transcript?: unknown;
|
|
1424
|
+
};
|
|
1425
|
+
const eventSource = Array.isArray(raw.messageEndEvents)
|
|
1426
|
+
? raw.messageEndEvents
|
|
1427
|
+
: Array.isArray(raw.transcript)
|
|
1428
|
+
? raw.transcript
|
|
1429
|
+
: undefined;
|
|
1430
|
+
if (!eventSource || eventSource.length === 0) return undefined;
|
|
1431
|
+
for (const candidate of eventSource) {
|
|
1432
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
1433
|
+
const event = candidate as { stopReason?: unknown; errorMessage?: unknown };
|
|
1434
|
+
if (event.stopReason !== "error") continue;
|
|
1435
|
+
if (typeof event.errorMessage !== "string" || event.errorMessage.length === 0) continue;
|
|
1436
|
+
if (!isRetryableModelFailure(event.errorMessage)) continue;
|
|
1437
|
+
// Same real-output gate as the primary signal — don't flag runs that
|
|
1438
|
+
// recovered with real final text / patches.
|
|
1439
|
+
const hasRealOutput =
|
|
1440
|
+
(parsed.finalText?.trim().length ?? 0) > 0 ||
|
|
1441
|
+
parsed.textEvents.some((t) => t.trim().length > 0) ||
|
|
1442
|
+
(parsed.patches?.length ?? 0) > 0;
|
|
1443
|
+
if (hasRealOutput) return undefined;
|
|
1444
|
+
return `Model returned only retryable errors and no output: ${event.errorMessage}`;
|
|
1445
|
+
}
|
|
1446
|
+
return undefined;
|
|
1386
1447
|
}
|
package/src/state/locks.ts
CHANGED
|
@@ -292,6 +292,17 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
292
292
|
// append, or even the lock acquisition itself) would race with the lock.
|
|
293
293
|
const lockFile = `${filePath}.lock`;
|
|
294
294
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
295
|
+
// FIX (Round 29): re-entrance guard — mirrors withRunLockSync below.
|
|
296
|
+
// When the same call stack already holds the file lock (e.g.
|
|
297
|
+
// registerWorker -> cleanupOrphanWorkers -> readRegistry), the second
|
|
298
|
+
// acquisition would otherwise read its own freshly-written lock file
|
|
299
|
+
// (same pid, fresh createdAt), fail the steal check, and deadlock for
|
|
300
|
+
// the full staleMs window. Strace-confirmed in
|
|
301
|
+
// .github/issues/pre-existing-2026-06-10/04-orphan-worker-registry-tests.md:75-86.
|
|
302
|
+
const existingToken = fileLockHeldByUs.get(lockFile);
|
|
303
|
+
if (existingToken) {
|
|
304
|
+
return fn();
|
|
305
|
+
}
|
|
295
306
|
// FIX: Validate the parent directory is not a symlink BEFORE calling mkdirSync.
|
|
296
307
|
// Between mkdir and lock acquisition, an attacker could plant a symlink.
|
|
297
308
|
if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
|
|
@@ -322,10 +333,12 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
335
|
if (token === "") throw new Error(`Run '${path.basename(lockFile)}' is locked by another operation.`);
|
|
336
|
+
fileLockHeldByUs.set(lockFile, token);
|
|
325
337
|
try {
|
|
326
338
|
return fn();
|
|
327
339
|
} finally {
|
|
328
340
|
// Token-guarded release: don't rm the lock if it has been stolen.
|
|
341
|
+
fileLockHeldByUs.delete(lockFile);
|
|
329
342
|
releaseLock(lockFile, token);
|
|
330
343
|
}
|
|
331
344
|
}
|
|
@@ -353,6 +366,9 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
|
|
|
353
366
|
// already held by this call stack (handleResume -> executeTeamRun ->
|
|
354
367
|
// executeTeamRunCore), we skip re-acquisition to avoid deadlock.
|
|
355
368
|
const runLockHeldByUs = new Map<string, string>(); // filePath -> token
|
|
369
|
+
// Round 29: parallel map for withFileLockSync re-entrance. See the comment
|
|
370
|
+
// at the top of withFileLockSync for the full deadlock mechanism.
|
|
371
|
+
const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
|
|
356
372
|
|
|
357
373
|
export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
|
|
358
374
|
const filePath = lockPath(manifest);
|
package/src/state/state-store.ts
CHANGED
|
@@ -634,7 +634,10 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
634
634
|
// between the final stat and the read. Callers needing strict consistency
|
|
635
635
|
// MUST use withRunLock() around load+modify+save.
|
|
636
636
|
if (attempts > 0) {
|
|
637
|
-
|
|
637
|
+
// Round 19: downgrade to debug — retry-loop instability is expected under
|
|
638
|
+
// concurrent writes (live team runs constantly append to tasks.json).
|
|
639
|
+
// This is best-effort by design; strict consistency requires withRunLock().
|
|
640
|
+
console.debug(`[state-store] loadRunManifestById: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
|
|
638
641
|
}
|
|
639
642
|
// NOTE: manifest mtime may legitimately be >= tasks mtime because
|
|
640
643
|
// saveManifestAndTasksAtomicSync writes manifest before tasks. However,
|
|
@@ -724,7 +727,10 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
724
727
|
// between the final stat and the read. Callers needing strict consistency
|
|
725
728
|
// MUST use withRunLock() around load+modify+save.
|
|
726
729
|
if (attempts > 0) {
|
|
727
|
-
|
|
730
|
+
// Round 19: downgrade to debug — retry-loop instability is expected under
|
|
731
|
+
// concurrent writes (live team runs constantly append to tasks.json).
|
|
732
|
+
// This is best-effort by design; strict consistency requires withRunLock().
|
|
733
|
+
console.debug(`[state-store] loadRunManifestByIdAsync: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
|
|
728
734
|
}
|
|
729
735
|
// NOTE: manifest mtime may legitimately be >= tasks mtime because
|
|
730
736
|
// saveManifestAndTasksAtomicSync writes manifest before tasks. However,
|
|
@@ -76,7 +76,12 @@ export class LiveRunSidebar {
|
|
|
76
76
|
this.config = input.config ?? {};
|
|
77
77
|
this.snapshotCache = input.snapshotCache;
|
|
78
78
|
this.unsubscribeTheme = subscribeThemeChange(input.theme, () => this.invalidate());
|
|
79
|
-
this.unsubscribeEventBus =
|
|
79
|
+
this.unsubscribeEventBus = (() => {
|
|
80
|
+
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
|
|
81
|
+
const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
|
|
82
|
+
const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
|
|
83
|
+
return () => { unsub1(); unsub2(); unsub3(); };
|
|
84
|
+
})();
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
private buildSignature(manifestStatus: string, tasks: TeamTaskState[], agents: ReturnType<typeof readCrewAgents>, waitingCount: number, snapshot?: RunUiSnapshot): string {
|
package/src/ui/loaders.ts
CHANGED
|
@@ -113,23 +113,43 @@ export class CountdownTimer {
|
|
|
113
113
|
private readonly timeoutMs: number;
|
|
114
114
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
115
115
|
private expired = false;
|
|
116
|
+
private lastEmittedSeconds = -1;
|
|
116
117
|
|
|
117
118
|
constructor(options: CountdownTimerOptions) {
|
|
118
119
|
this.timeoutMs = Math.max(0, options.timeoutMs);
|
|
119
120
|
this.onTick = options.onTick;
|
|
120
121
|
this.onExpire = options.onExpire;
|
|
121
122
|
this.startedAt = Date.now();
|
|
122
|
-
this.
|
|
123
|
+
this.lastEmittedSeconds = this.secondsLeft();
|
|
124
|
+
this.onTick(this.lastEmittedSeconds);
|
|
123
125
|
if (this.timeoutMs === 0) {
|
|
124
126
|
this.emitExpire();
|
|
125
127
|
return;
|
|
126
128
|
}
|
|
127
|
-
this.
|
|
129
|
+
this.scheduleNextTick();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Schedule the next tick via recursive setTimeout. Each tick re-emits the
|
|
134
|
+
* current `secondsLeft()` only if it differs from the last emitted value
|
|
135
|
+
* (lastEmittedSeconds guard). This makes the countdown correct even under
|
|
136
|
+
* event-loop pressure: if the previous tick fired 1.2s late, the next
|
|
137
|
+
* tick still emits the right value for the current second rather than
|
|
138
|
+
* skipping it (the pre-fix `setInterval` could SKIP a second value when
|
|
139
|
+
* the loop was busy, producing [3,2,0] instead of [3,2,1,0] in tests).
|
|
140
|
+
*/
|
|
141
|
+
private scheduleNextTick(): void {
|
|
142
|
+
this.timer = setTimeout(() => {
|
|
128
143
|
const seconds = this.secondsLeft();
|
|
129
|
-
this.
|
|
144
|
+
if (seconds !== this.lastEmittedSeconds) {
|
|
145
|
+
this.lastEmittedSeconds = seconds;
|
|
146
|
+
this.onTick(seconds);
|
|
147
|
+
}
|
|
130
148
|
if (seconds <= 0) {
|
|
131
149
|
this.emitExpire();
|
|
150
|
+
return;
|
|
132
151
|
}
|
|
152
|
+
this.scheduleNextTick();
|
|
133
153
|
}, 1000);
|
|
134
154
|
// Defense-in-depth: never let the countdown timer keep the event loop
|
|
135
155
|
// alive. If dispose() is missed (e.g. UI unmount race), the timer must
|
|
@@ -151,7 +171,7 @@ export class CountdownTimer {
|
|
|
151
171
|
|
|
152
172
|
dispose(): void {
|
|
153
173
|
if (this.timer === undefined) return;
|
|
154
|
-
|
|
174
|
+
clearTimeout(this.timer);
|
|
155
175
|
this.timer = undefined;
|
|
156
176
|
}
|
|
157
177
|
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -294,7 +294,12 @@ export class RunDashboard implements DashboardComponent {
|
|
|
294
294
|
this.theme = asCrewTheme(theme);
|
|
295
295
|
this.options = options;
|
|
296
296
|
this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidateAndRender());
|
|
297
|
-
this.unsubscribeEventBus =
|
|
297
|
+
this.unsubscribeEventBus = (() => {
|
|
298
|
+
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidateAndRender());
|
|
299
|
+
const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidateAndRender());
|
|
300
|
+
const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidateAndRender());
|
|
301
|
+
return () => { unsub1(); unsub2(); unsub3(); };
|
|
302
|
+
})();
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
/**
|
package/src/ui/run-event-bus.ts
CHANGED
|
@@ -40,7 +40,7 @@ const RUN_STATE_TYPES = new Set([
|
|
|
40
40
|
"manifest.saved", "task.claimed", "task.unclaimed", "mailbox_updated",
|
|
41
41
|
]);
|
|
42
42
|
const UI_INVALIDATE_TYPES = new Set([
|
|
43
|
-
"effectiveness_changed", "snapshot_stale",
|
|
43
|
+
"effectiveness_changed", "snapshot_stale", "run.cache_invalidated",
|
|
44
44
|
]);
|
|
45
45
|
|
|
46
46
|
/** Classify an event type string into a typed channel. */
|
|
@@ -787,11 +787,55 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
787
787
|
}
|
|
788
788
|
}
|
|
789
789
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
790
|
+
// Coalesced eager refresh on event-bus signals. Previously every
|
|
791
|
+
// `run:state` / `worker:lifecycle` event deleted the cache entry, leaving
|
|
792
|
+
// a window where `widget-model.ts: snapshotCache.get(runId)` returned
|
|
793
|
+
// `undefined`. The widget then fell back to `agentsFor(run)` (a disk read
|
|
794
|
+
// with no snapshot.tasks) and rendered the "0/1 done" branch of
|
|
795
|
+
// `widget-renderer.ts:39-41` instead of the "Phase 1/1 default: 0% (0/3)"
|
|
796
|
+
// branch — producing the live flicker between those two progressPart
|
|
797
|
+
// values every render tick. Replacing the delete with a coalesced
|
|
798
|
+
// refreshIfStale keeps the cache populated so the widget always sees the
|
|
799
|
+
// same logical snapshot between stamp changes; multiple events for the
|
|
800
|
+
// same runId within INVAL_COALESCE_MS are batched into one refresh.
|
|
801
|
+
function localRefresh(runId: string): RunUiSnapshot {
|
|
802
|
+
const previous = entries.get(runId);
|
|
803
|
+
const entry = build(runId, previous);
|
|
804
|
+
entries.set(runId, entry);
|
|
805
|
+
evictIfNeeded();
|
|
806
|
+
return entry.snapshot;
|
|
807
|
+
}
|
|
808
|
+
function localRefreshIfStale(runId: string): RunUiSnapshot {
|
|
809
|
+
const previous = entries.get(runId);
|
|
810
|
+
if (!previous) return localRefresh(runId);
|
|
811
|
+
const now = Date.now();
|
|
812
|
+
if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
|
|
813
|
+
const stamps = currentStamps(previous);
|
|
814
|
+
if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
|
|
815
|
+
return localRefresh(runId);
|
|
816
|
+
}
|
|
817
|
+
const pendingRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
|
|
818
|
+
const INVAL_COALESCE_MS = 80;
|
|
819
|
+
const scheduleRefresh = (runId: string): void => {
|
|
820
|
+
const existing = pendingRefreshes.get(runId);
|
|
821
|
+
if (existing) clearTimeout(existing);
|
|
822
|
+
pendingRefreshes.set(runId, setTimeout(() => {
|
|
823
|
+
pendingRefreshes.delete(runId);
|
|
824
|
+
try { localRefreshIfStale(runId); } catch { /* best-effort; widget falls back gracefully */ }
|
|
825
|
+
}, INVAL_COALESCE_MS));
|
|
826
|
+
};
|
|
827
|
+
const unsubState = runEventBus.onChannel("run:state", (event) => {
|
|
828
|
+
if (entries.has(event.runId)) scheduleRefresh(event.runId);
|
|
794
829
|
});
|
|
830
|
+
const unsubLifecycle = runEventBus.onChannel("worker:lifecycle", (event) => {
|
|
831
|
+
if (entries.has(event.runId)) scheduleRefresh(event.runId);
|
|
832
|
+
});
|
|
833
|
+
const unsubscribe = () => {
|
|
834
|
+
unsubState();
|
|
835
|
+
unsubLifecycle();
|
|
836
|
+
for (const timer of pendingRefreshes.values()) clearTimeout(timer);
|
|
837
|
+
pendingRefreshes.clear();
|
|
838
|
+
};
|
|
795
839
|
|
|
796
840
|
return {
|
|
797
841
|
get(runId: string): RunUiSnapshot | undefined {
|
|
@@ -799,20 +843,10 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
799
843
|
return entry ? touch(runId, entry) : undefined;
|
|
800
844
|
},
|
|
801
845
|
refresh(runId: string): RunUiSnapshot {
|
|
802
|
-
|
|
803
|
-
const entry = build(runId, previous);
|
|
804
|
-
entries.set(runId, entry);
|
|
805
|
-
evictIfNeeded();
|
|
806
|
-
return entry.snapshot;
|
|
846
|
+
return localRefresh(runId);
|
|
807
847
|
},
|
|
808
848
|
refreshIfStale(runId: string): RunUiSnapshot {
|
|
809
|
-
|
|
810
|
-
if (!previous) return this.refresh(runId);
|
|
811
|
-
const now = Date.now();
|
|
812
|
-
if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
|
|
813
|
-
const stamps = currentStamps(previous);
|
|
814
|
-
if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
|
|
815
|
-
return this.refresh(runId);
|
|
849
|
+
return localRefreshIfStale(runId);
|
|
816
850
|
},
|
|
817
851
|
preloadStale,
|
|
818
852
|
preloadAllStale,
|
package/src/ui/widget/index.ts
CHANGED
|
@@ -18,13 +18,30 @@ import { spinnerBucket, spinnerFrame } from "../spinner.ts";
|
|
|
18
18
|
import { runEventBus } from "../run-event-bus.ts";
|
|
19
19
|
import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
20
20
|
import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
|
|
21
|
-
import { buildWidgetLines, colorWidgetLine, renderLines } from "./widget-renderer.ts";
|
|
21
|
+
import { buildWidgetLines, colorWidgetLine, renderLines, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
|
|
22
22
|
import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
|
|
23
23
|
|
|
24
24
|
// Re-export types and helpers for backward compatibility
|
|
25
25
|
export type { WidgetRun, CrewWidgetModel, CrewWidgetState } from "./widget-types.ts";
|
|
26
26
|
export { activeWidgetRuns, statusSummary } from "./widget-model.ts";
|
|
27
|
-
export { buildWidgetLines as buildCrewWidgetLines, widgetHeader } from "./widget-renderer.ts";
|
|
27
|
+
export { buildWidgetLines as buildCrewWidgetLines, widgetHeader, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the real render width for widget lines, in priority order:
|
|
31
|
+
* 1. explicit `width` argument (e.g. from caller that already knows terminal width)
|
|
32
|
+
* 2. `process.stdout.columns` (works in Node when stdout is a TTY)
|
|
33
|
+
* 3. `DEFAULT_WIDGET_WIDTH` (100) — last-resort fallback so we never paint
|
|
34
|
+
* a line wider than the smallest expected TUI.
|
|
35
|
+
*
|
|
36
|
+
* Callers SHOULD pass the width they already hold (e.g. `WidgetRender.render(width)`
|
|
37
|
+
* in this file already receives one). This helper exists for paths that don't.
|
|
38
|
+
*/
|
|
39
|
+
export function getRenderWidth(width?: number): number {
|
|
40
|
+
if (Number.isFinite(width) && width! > 0) return Math.floor(width!);
|
|
41
|
+
const stdoutCols = (globalThis as { process?: { stdout?: { columns?: number } } }).process?.stdout?.columns;
|
|
42
|
+
if (Number.isFinite(stdoutCols) && stdoutCols! > 0) return Math.floor(stdoutCols!);
|
|
43
|
+
return DEFAULT_WIDGET_WIDTH;
|
|
44
|
+
}
|
|
28
45
|
export { notificationBadge } from "./widget-formatters.ts";
|
|
29
46
|
|
|
30
47
|
// ── Constants ─────────────────────────────────────────────────────────
|
|
@@ -57,7 +74,12 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
57
74
|
this.theme = asCrewTheme(themeLike);
|
|
58
75
|
this.cachedTheme = this.theme;
|
|
59
76
|
this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
|
|
60
|
-
this.unsubscribeEventBus =
|
|
77
|
+
this.unsubscribeEventBus = (() => {
|
|
78
|
+
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
|
|
79
|
+
const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
|
|
80
|
+
const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
|
|
81
|
+
return () => { unsub1(); unsub2(); unsub3(); };
|
|
82
|
+
})();
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
private buildSignature(runs: WidgetRun[]): string {
|
|
@@ -103,7 +125,7 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
103
125
|
const runningGlyph = spinnerFrame("widget-header");
|
|
104
126
|
|
|
105
127
|
if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
|
|
106
|
-
this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0).map((line, index) => {
|
|
128
|
+
this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0, width).map((line, index) => {
|
|
107
129
|
if (index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
|
|
108
130
|
return line;
|
|
109
131
|
});
|
|
@@ -150,7 +172,7 @@ export function updateCrewWidget(
|
|
|
150
172
|
}
|
|
151
173
|
|
|
152
174
|
const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
|
|
153
|
-
const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0);
|
|
175
|
+
const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth());
|
|
154
176
|
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
155
177
|
|
|
156
178
|
ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
|
|
@@ -11,12 +11,21 @@ import { Box, Text } from "../layout-primitives.ts";
|
|
|
11
11
|
import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
|
|
12
12
|
import { computePhaseProgress, formatPhaseProgressLine } from "../../runtime/phase-progress.ts";
|
|
13
13
|
import { spinnerFrame } from "../spinner.ts";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { computeLiveDurationMs } from "../live-duration.ts";
|
|
15
|
+
import { getTaskUsage } from "../../runtime/usage-tracker.ts";
|
|
16
|
+
import { agentActivity, agentStats, elapsed, formatTokensCompact, notificationBadge } from "./widget-formatters.ts";
|
|
17
|
+
import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
|
|
16
18
|
import type { WidgetRun } from "./widget-types.ts";
|
|
17
19
|
|
|
18
20
|
const MAX_AGENTS_DISPLAY = 3;
|
|
19
21
|
const FINISHED_LINGER_MAX_AGE = 1;
|
|
22
|
+
/** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
|
|
23
|
+
* (the same default used elsewhere in pi-crew tool renderers) so we never paint
|
|
24
|
+
* a line wider than the smallest expected TUI. Callers SHOULD pass the real
|
|
25
|
+
* width when known (via ctx.width || process.stdout.columns). */
|
|
26
|
+
export const DEFAULT_WIDGET_WIDTH = 100;
|
|
27
|
+
/** Cap per-component text so a single field cannot blow past width on its own. */
|
|
28
|
+
export const TASK_DESC_MAX = 60;
|
|
20
29
|
const ERROR_LINGER_MAX_AGE = 2;
|
|
21
30
|
const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
|
|
22
31
|
|
|
@@ -37,8 +46,12 @@ export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines =
|
|
|
37
46
|
|
|
38
47
|
// ── Line builder ──────────────────────────────────────────────────────
|
|
39
48
|
|
|
40
|
-
export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0): string[] {
|
|
41
|
-
|
|
49
|
+
export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0, width = DEFAULT_WIDGET_WIDTH): string[] {
|
|
50
|
+
// Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
|
|
51
|
+
// auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
|
|
52
|
+
// only `(cwd, frame)` would render an empty line set (regression vs. the
|
|
53
|
+
// pre-refactor implementation that called activeWidgetRuns here).
|
|
54
|
+
const runs = providedRuns ?? activeWidgetRuns(cwd);
|
|
42
55
|
if (!runs.length) return [];
|
|
43
56
|
|
|
44
57
|
const runningGlyph = spinnerFrame("widget-header");
|
|
@@ -56,9 +69,23 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
56
69
|
});
|
|
57
70
|
const completed = agents.filter((a) => a.status === "completed").length;
|
|
58
71
|
const runGlyph = iconForStatus(run.status, { runningGlyph });
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
// Run progress line. v1–v3 flickered on snapshot.tasks state, v4 was
|
|
73
|
+
// too minimal (`0/1 agents` only), v5 duplicated the worker activity
|
|
74
|
+
// line (tools/tokens/duration already shown one row below). v6 (this)
|
|
75
|
+
// shows only data that is RUN-level (not already in the per-agent
|
|
76
|
+
// activity line) and is GUARANTEED stable across ticks:
|
|
77
|
+
// - agents count — from `agents` array, always populated, never empty.
|
|
78
|
+
// - run elapsed — from `run.createdAt`, always set on manifest.
|
|
79
|
+
// Both come from sources with no race window — `agents` is read from
|
|
80
|
+
// snapshot.agents OR agentsFor(run) (both always return same length
|
|
81
|
+
// for a healthy run), and `run.createdAt` is immutable. The format
|
|
82
|
+
// shape `"X/Y agents · Ns"` is therefore truly invariant: same number
|
|
83
|
+
// of `·`-separated fields, same field meanings, every render tick.
|
|
84
|
+
const agentCountText = `${completed}/${agents.length} agents`;
|
|
85
|
+
const runElapsedMs = Math.max(0, Date.now() - new Date(run.createdAt).getTime());
|
|
86
|
+
const runElapsedText = `${Math.floor(runElapsedMs / 1000)}s`;
|
|
87
|
+
const progressPart = `${agentCountText} · ${runElapsedText}`;
|
|
88
|
+
lines.push(truncate(`├─ ${runGlyph} ${shortRunLabel(run)} · ${progressPart} · ${run.runId.slice(-8)}`, width));
|
|
62
89
|
|
|
63
90
|
const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
|
|
64
91
|
|
|
@@ -67,8 +94,9 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
67
94
|
const name = liveHandle?.agent ?? agent.agent;
|
|
68
95
|
const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
|
|
69
96
|
const stats = agentStats(agent, liveHandle);
|
|
70
|
-
const desc = liveHandle?.description ?? agent.role;
|
|
71
|
-
|
|
97
|
+
const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
|
|
98
|
+
const _finished = truncate(`│ ├─ ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
|
|
99
|
+
lines.push(_finished);
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
|
|
@@ -79,13 +107,15 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
79
107
|
const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
|
|
80
108
|
const stats = agentStats(agent, liveHandle);
|
|
81
109
|
const name = liveHandle?.agent ?? agent.agent;
|
|
82
|
-
const desc = liveHandle?.description ?? agent.role;
|
|
83
|
-
|
|
84
|
-
lines.push(
|
|
110
|
+
const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
|
|
111
|
+
const _activeMain = truncate(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
|
|
112
|
+
lines.push(_activeMain);
|
|
113
|
+
const _activity = truncate(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`, width);
|
|
114
|
+
lines.push(_activity);
|
|
85
115
|
}
|
|
86
116
|
|
|
87
117
|
if (activeAgents.length > MAX_AGENTS_DISPLAY) {
|
|
88
|
-
lines.push(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents
|
|
118
|
+
lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
if (lines.length >= maxLines) break;
|
package/src/utils/redaction.ts
CHANGED
|
@@ -208,6 +208,22 @@ export function redactSecretString(value: string): string {
|
|
|
208
208
|
// i++ (advance 1 char) and re-scanned from i+1, so a long run was rescanned O(n)
|
|
209
209
|
// times = O(n^2). The P1f ReDoS test (300KB no-dot input) surfaced this pre-existing
|
|
210
210
|
// bug. Now advances past the whole run when it isn't a redactable secret -> O(n).
|
|
211
|
+
// FIX (BG2 follow-up): the inner-loop regex test /[a-zA-Z0-9_-]/.test(value[j])
|
|
212
|
+
// is O(1) per call but with measurable regex-engine overhead — for a 100KB
|
|
213
|
+
// underscore run that's 100K regex calls, well over the 200ms budget the
|
|
214
|
+
// P1f regression test allows. Replaced with a charCodeAt check (~5x faster
|
|
215
|
+
// in practice, O(1) per call with no regex allocation/eval cost).
|
|
216
|
+
function isKeyChar(c: string): boolean {
|
|
217
|
+
const code = c.charCodeAt(0);
|
|
218
|
+
return (
|
|
219
|
+
(code >= 48 && code <= 57) || // 0-9
|
|
220
|
+
(code >= 65 && code <= 90) || // A-Z
|
|
221
|
+
(code >= 97 && code <= 122) || // a-z
|
|
222
|
+
code === 95 || // _
|
|
223
|
+
code === 45 // -
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
211
227
|
function redactInlineSecrets(value: string): string {
|
|
212
228
|
const result: string[] = [];
|
|
213
229
|
let i = 0;
|
|
@@ -215,7 +231,7 @@ function redactInlineSecrets(value: string): string {
|
|
|
215
231
|
while (i < value.length) {
|
|
216
232
|
// Collect a run of key characters (alphanumeric, underscore, hyphen).
|
|
217
233
|
let j = i;
|
|
218
|
-
while (j < value.length &&
|
|
234
|
+
while (j < value.length && isKeyChar(value[j])) {
|
|
219
235
|
j++;
|
|
220
236
|
}
|
|
221
237
|
const keyLen = j - i;
|
package/src/utils/visual.ts
CHANGED
|
@@ -29,6 +29,12 @@ const WIDE_RANGES: Array<[number, number]> = [
|
|
|
29
29
|
[0x274C, 0x274C], [0x274E, 0x274E], [0x2753, 0x2755], [0x2757, 0x2757],
|
|
30
30
|
[0x2763, 0x2764], [0x2795, 0x2797], [0x27A1, 0x27A1], [0x27B0, 0x27B0],
|
|
31
31
|
[0x27BF, 0x27BF],
|
|
32
|
+
// Geometric Shapes / Misc Symbols-Arrows emoji that pi-tui upstream counts as
|
|
33
|
+
// width=2 (RGI emoji). Mismatch here caused the "Rendered line N exceeds
|
|
34
|
+
// terminal width (160 > 159)" TUI crash: pi-crew truncated to width 159 by
|
|
35
|
+
// its own (mismatched) measure, then Box padded to 159 chars, but pi-tui
|
|
36
|
+
// re-measured the padded line at 160 because ⬜ counts as 2 upstream.
|
|
37
|
+
[0x2B1B, 0x2B1C], // ⬛ BLACK LARGE SQUARE, ⬜ WHITE LARGE SQUARE
|
|
32
38
|
[0x1F300, 0x1F9FF], // Misc Symbols, Emoticons, Transport, Map, Supplement
|
|
33
39
|
[0x1FA00, 0x1FAFF], // Symbols Extended-A
|
|
34
40
|
[0x1F000, 0x1F02F], // Mahjong, Dominos
|