pi-crew 0.9.37 → 0.9.39
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 +34 -0
- package/dist/build-meta.json +28 -56
- package/dist/index.mjs +58 -291
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
- package/src/config/config.ts +2 -2
- package/src/extension/crew-vibes/figures.ts +13 -6
- package/src/extension/crew-vibes/index.ts +36 -13
- package/src/extension/register.ts +66 -7
- package/src/extension/registration/subagent-tools.ts +1 -18
- package/src/extension/team-tool/context.ts +1 -0
- package/src/extension/team-tool/run.ts +1 -0
- package/src/runtime/progress-tracker.ts +0 -140
- package/src/state/locks.ts +23 -0
- package/src/state/state-store.ts +3 -0
- package/src/ui/tool-renderers/index.ts +20 -3
- package/src/ui/widget/widget-formatters.ts +10 -0
package/package.json
CHANGED
package/src/config/config.ts
CHANGED
|
@@ -694,7 +694,7 @@ function parseIsolationPolicy(value: unknown): CrewRuntimeConfig["isolationPolic
|
|
|
694
694
|
|
|
695
695
|
function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
|
|
696
696
|
const obj = asRecord(value);
|
|
697
|
-
if (!obj) return
|
|
697
|
+
if (!obj) return { inheritContext: true } as CrewRuntimeConfig;
|
|
698
698
|
const runtime: CrewRuntimeConfig = {
|
|
699
699
|
mode: parseWithSchema(
|
|
700
700
|
Type.Union([Type.Literal("auto"), Type.Literal("scaffold"), Type.Literal("child-process"), Type.Literal("live-session")]),
|
|
@@ -704,7 +704,7 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
|
|
|
704
704
|
allowChildProcessFallback: parseWithSchema(Type.Boolean(), obj.allowChildProcessFallback),
|
|
705
705
|
maxTurns: parsePositiveInteger(obj.maxTurns, LIMIT_CEILINGS.runtimeMaxTurns),
|
|
706
706
|
graceTurns: parsePositiveInteger(obj.graceTurns, LIMIT_CEILINGS.runtimeGraceTurns),
|
|
707
|
-
inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext),
|
|
707
|
+
inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext) ?? true,
|
|
708
708
|
promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
|
|
709
709
|
groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
|
|
710
710
|
groupJoinAckTimeoutMs: parsePositiveInteger(obj.groupJoinAckTimeoutMs, 86_400_000),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SpeedConfig } from "./config.ts";
|
|
2
|
-
import { isWebTerminal } from "./font-detect.ts";
|
|
2
|
+
import { hasCrewFontFile, isWebTerminal } from "./font-detect.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Crew figures replacing the original cat glyphs from pi-speeed / pi-chonk.
|
|
@@ -57,12 +57,19 @@ const PUA_CREW_FRAMES: readonly string[] = [
|
|
|
57
57
|
/**
|
|
58
58
|
* Return the indicator frames for the given style.
|
|
59
59
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* Default is `"braille"` — standard Unicode braille spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏)
|
|
61
|
+
* that renders correctly on every terminal, every platform, with zero
|
|
62
|
+
* font dependency. PUA runner-glyphs require crew-vibes.ttf installed AND
|
|
63
|
+
* a terminal that does font fallback for Private Use Area codepoints —
|
|
64
|
+
* Windows Terminal does NOT do this reliably, showing garbage chars even
|
|
65
|
+
* with the font installed.
|
|
66
|
+
*
|
|
67
|
+
* To opt into the PUA runner look, pass `style: "pua"` explicitly.
|
|
62
68
|
*/
|
|
63
|
-
export function crewFrames(style: "braille" | "pua" = "
|
|
64
|
-
//
|
|
65
|
-
|
|
69
|
+
export function crewFrames(style: "braille" | "pua" = "braille"): readonly string[] {
|
|
70
|
+
// Even when explicitly requested, fall back to braille if the font is
|
|
71
|
+
// missing or we're on a web terminal (which can't render PUA at all).
|
|
72
|
+
if (style === "pua" && (isWebTerminal() || !hasCrewFontFile())) return BRAILLE_FRAMES;
|
|
66
73
|
return style === "pua" ? PUA_CREW_FRAMES : BRAILLE_FRAMES;
|
|
67
74
|
}
|
|
68
75
|
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { requestRender, setFooter } from "../../ui/pi-ui-compat.ts";
|
|
3
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a pi UI call in try/catch. If the call throws (e.g. terminal doesn't
|
|
7
|
+
* support PUA glyphs, theme helper returns undefined, or pi's API signature
|
|
8
|
+
* changed), log it and silently fall back to pi's default behavior — which
|
|
9
|
+
* is simply "do nothing", letting pi show its own built-in indicator.
|
|
10
|
+
*
|
|
11
|
+
* Crew-vibes must NEVER break the user's session. A broken spinner is not
|
|
12
|
+
* worth a crashed pi.
|
|
13
|
+
*/
|
|
14
|
+
function safeUiCall(scope: string, fn: () => void): void {
|
|
15
|
+
try {
|
|
16
|
+
fn();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
logInternalError(`crew-vibes.${scope}`, error, undefined, "warn");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
import { type CrewVibesConfig, loadConfig, PROVIDER_STATUS_ID, saveConfig } from "./config.ts";
|
|
4
23
|
import { intervalForSpeed } from "./figures.ts";
|
|
5
24
|
import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
|
|
@@ -72,44 +91,48 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
72
91
|
function installFooter(ctx: ExtensionContext): void {
|
|
73
92
|
if (!ctx?.hasUI) return;
|
|
74
93
|
if (!metersActive()) {
|
|
75
|
-
setFooter(ctx, undefined);
|
|
94
|
+
safeUiCall("clear-footer", () => setFooter(ctx, undefined));
|
|
76
95
|
return;
|
|
77
96
|
}
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
safeUiCall("install-footer", () => {
|
|
98
|
+
setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
|
|
99
|
+
requestRender(ctx);
|
|
100
|
+
});
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
/** Trigger a footer repaint; the footer recomputes capacity/quota on render. */
|
|
83
104
|
function refreshFooter(ctx: ExtensionContext): void {
|
|
84
|
-
if (ctx?.hasUI) requestRender(ctx);
|
|
105
|
+
if (ctx?.hasUI) safeUiCall("refresh-footer", () => requestRender(ctx));
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
|
|
88
109
|
if (!config.enabled || !config.speed.enabled || !config.speed.footer) {
|
|
89
|
-
setSpeedStatus(ctx, config, undefined);
|
|
110
|
+
safeUiCall("clear-speed-status", () => setSpeedStatus(ctx, config, undefined));
|
|
90
111
|
return;
|
|
91
112
|
}
|
|
92
|
-
setSpeedStatus(ctx, config, renderSpeedFooter(themeOf(ctx), config.speed, speed));
|
|
113
|
+
safeUiCall("publish-speed-status", () => setSpeedStatus(ctx, config, renderSpeedFooter(themeOf(ctx), config.speed, speed)));
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
function applyIndicator(ctx: ExtensionContext, speed: number | null, force = false): void {
|
|
96
117
|
if (!ctx.hasUI || !ctx.ui.setWorkingIndicator) return;
|
|
97
118
|
if (!config.enabled || !config.speed.enabled || !config.speed.indicator) {
|
|
98
|
-
ctx.ui.setWorkingIndicator();
|
|
119
|
+
safeUiCall("reset-indicator", () => ctx.ui.setWorkingIndicator?.());
|
|
99
120
|
return;
|
|
100
121
|
}
|
|
101
122
|
const next = intervalForSpeed(config.speed, speed);
|
|
102
123
|
if (!force && Math.abs(next - currentIntervalMs) < 10) return;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
safeUiCall("set-indicator", () =>
|
|
125
|
+
ctx.ui.setWorkingIndicator?.({
|
|
126
|
+
frames: crewIndicatorFrames(themeOf(ctx)),
|
|
127
|
+
intervalMs: next,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
107
130
|
currentIntervalMs = next;
|
|
108
131
|
}
|
|
109
132
|
|
|
110
133
|
function renderWorking(ctx: ExtensionContext, speed: number | null): void {
|
|
111
134
|
if (!config.enabled || !config.speed.enabled || !ctx.hasUI) return;
|
|
112
|
-
ctx.ui.setWorkingMessage(renderWorkingMessage(themeOf(ctx), config.speed, speed));
|
|
135
|
+
safeUiCall("set-working-message", () => ctx.ui.setWorkingMessage?.(renderWorkingMessage(themeOf(ctx), config.speed, speed)));
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
function stopLiveTimer(): void {
|
|
@@ -315,7 +338,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
315
338
|
stopLiveTimer();
|
|
316
339
|
if (ctx && config.enabled && ctx.hasUI) {
|
|
317
340
|
applyIndicator(ctx, speedTracker.lastTokS);
|
|
318
|
-
ctx.ui.setWorkingMessage();
|
|
341
|
+
safeUiCall("clear-working-message", () => ctx.ui.setWorkingMessage?.());
|
|
319
342
|
}
|
|
320
343
|
});
|
|
321
344
|
|
|
@@ -130,7 +130,6 @@ function purgeStaleActiveRunIndexSyncIfLoaded(): void {
|
|
|
130
130
|
|
|
131
131
|
import { pruneFinishedRuns, pruneUserLevelRuns } from "../extension/run-maintenance.ts";
|
|
132
132
|
import { initI18n } from "../i18n.ts";
|
|
133
|
-
import { globalProgressTracker } from "../runtime/progress-tracker.ts";
|
|
134
133
|
// H3-L2 split: DeliveryCoordinator + OverflowRecoveryTracker moved to registration/lifecycle.ts.
|
|
135
134
|
import { tryRegisterSessionCleanup } from "../runtime/session-resources.ts";
|
|
136
135
|
import { createSessionSnapshot } from "../runtime/session-snapshot.ts";
|
|
@@ -703,6 +702,58 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
703
702
|
};
|
|
704
703
|
});
|
|
705
704
|
|
|
705
|
+
/**
|
|
706
|
+
* Cleanup session resources WITHOUT aborting foreground team runs.
|
|
707
|
+
* Used during session switch (reason="resume"/"new"/"fork") to let foreground
|
|
708
|
+
* runs complete naturally when the session context is torn down.
|
|
709
|
+
*/
|
|
710
|
+
const cleanupSessionResourcesOnly = (): void => {
|
|
711
|
+
if (cleanedUp) return;
|
|
712
|
+
cleanedUp = true;
|
|
713
|
+
if (preloadTimer) {
|
|
714
|
+
clearTimeout(preloadTimer);
|
|
715
|
+
preloadTimer = undefined;
|
|
716
|
+
}
|
|
717
|
+
crewRunWatchers?.closeAll();
|
|
718
|
+
crewRunWatchers = undefined;
|
|
719
|
+
userCrewWatchers?.closeAll();
|
|
720
|
+
userCrewWatchers = undefined;
|
|
721
|
+
stopSessionBoundSubagents();
|
|
722
|
+
// P0 fix: Do NOT abort foreground team runs on session switch.
|
|
723
|
+
// Foreground team runs run in the same process as the session; they naturally clean up
|
|
724
|
+
// when the session context is torn down. Only subagents need explicit abort on switch.
|
|
725
|
+
// Foreground runs will be aborted by cleanupRuntime() during actual session shutdown.
|
|
726
|
+
crewScheduler?.stop();
|
|
727
|
+
stopAsyncRunNotifier(notifierState);
|
|
728
|
+
|
|
729
|
+
// P0: Purge all stale active-run-index entries on session cleanup.
|
|
730
|
+
purgeStaleActiveRunIndexSyncIfLoaded();
|
|
731
|
+
|
|
732
|
+
stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
|
|
733
|
+
clearPiCrewPowerbar(pi.events);
|
|
734
|
+
disposePowerbarCoalescer();
|
|
735
|
+
disposeObservability(observabilityState, cleanedUp);
|
|
736
|
+
lifecycleState.deliveryCoordinator?.dispose();
|
|
737
|
+
clearHooksScoped();
|
|
738
|
+
uninstallCrewGlobalRegistry();
|
|
739
|
+
lifecycleState.overflowTracker?.dispose();
|
|
740
|
+
lifecycleState.deliveryCoordinator = undefined;
|
|
741
|
+
lifecycleState.overflowTracker = undefined;
|
|
742
|
+
manifestCache.dispose();
|
|
743
|
+
runSnapshotCache.dispose?.();
|
|
744
|
+
clearProjectRootCache();
|
|
745
|
+
renderScheduler?.dispose();
|
|
746
|
+
renderScheduler = undefined;
|
|
747
|
+
autoRecoveryLast.clear();
|
|
748
|
+
disposeNotifications(lifecycleState);
|
|
749
|
+
rpcHandle?.unsubscribe();
|
|
750
|
+
rpcHandle = undefined;
|
|
751
|
+
disposeI18n();
|
|
752
|
+
sessionGeneration += 1;
|
|
753
|
+
currentCtx = undefined;
|
|
754
|
+
if (globalStore[runtimeCleanupStoreKey] === cleanupSessionResourcesOnly) delete globalStore[runtimeCleanupStoreKey];
|
|
755
|
+
};
|
|
756
|
+
|
|
706
757
|
const cleanupRuntime = (): void => {
|
|
707
758
|
if (cleanedUp) return;
|
|
708
759
|
cleanedUp = true;
|
|
@@ -775,6 +826,20 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
775
826
|
};
|
|
776
827
|
globalStore[runtimeCleanupStoreKey] = cleanupRuntime;
|
|
777
828
|
|
|
829
|
+
// P0 fix: Check session_shutdown reason to determine whether to abort foreground runs.
|
|
830
|
+
// - reason="quit" or "reload": Actual shutdown — abort foreground runs.
|
|
831
|
+
// - reason="resume"/"new"/"fork": Session switch — let foreground runs complete.
|
|
832
|
+
pi.on("session_shutdown", (event) => {
|
|
833
|
+
const reason = typeof event === "object" && event !== null && "reason" in event ? (event as { reason: string }).reason : undefined;
|
|
834
|
+
if (reason === "quit" || reason === "reload") {
|
|
835
|
+
// Actual shutdown — abort foreground runs and cleanup everything
|
|
836
|
+
cleanupRuntime();
|
|
837
|
+
} else {
|
|
838
|
+
// Session switch (resume/new/fork) — cleanup resources but preserve foreground runs
|
|
839
|
+
cleanupSessionResourcesOnly();
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
778
843
|
pi.on("session_start", (_event, ctx) => {
|
|
779
844
|
runArtifactCleanup(ctx.cwd);
|
|
780
845
|
|
|
@@ -1386,7 +1451,6 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1386
1451
|
stopAsyncRunNotifier(notifierState);
|
|
1387
1452
|
stopSessionBoundSubagents();
|
|
1388
1453
|
});
|
|
1389
|
-
pi.on("session_shutdown", () => cleanupRuntime());
|
|
1390
1454
|
|
|
1391
1455
|
// Phase 11a: Dynamic resource discovery — inject pi-crew skill paths.
|
|
1392
1456
|
try {
|
|
@@ -1475,11 +1539,6 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1475
1539
|
const record = event as Record<string, unknown>;
|
|
1476
1540
|
const eventType = typeof record.type === "string" ? record.type : undefined;
|
|
1477
1541
|
if (eventType) lifecycleState.overflowTracker?.feedEvent(taskId, runId, eventType);
|
|
1478
|
-
// Phase 2: forward worker events to progress tracker for real-time
|
|
1479
|
-
// widget display (tool calls, assistant text) on child-process runtime.
|
|
1480
|
-
if (record && typeof record === "object") {
|
|
1481
|
-
globalProgressTracker.handleWorkerEvent(taskId, runId, record);
|
|
1482
|
-
}
|
|
1483
1542
|
},
|
|
1484
1543
|
});
|
|
1485
1544
|
registerSubagentTools(pi, subagentManager, {
|
|
@@ -23,7 +23,6 @@ import { loadConfig } from "../../config/config.ts";
|
|
|
23
23
|
import { t } from "../../i18n.ts";
|
|
24
24
|
import type { BatchBarrier } from "../../runtime/batch-barrier.ts";
|
|
25
25
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
26
|
-
import { globalProgressTracker } from "../../runtime/progress-tracker.ts";
|
|
27
26
|
import { checkSubagentSpawnPermission, currentCrewRole } from "../../runtime/role-permission.ts";
|
|
28
27
|
import { loadRunManifestById } from "../../state/state-store.ts";
|
|
29
28
|
import {
|
|
@@ -445,23 +444,7 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
|
|
|
445
444
|
agents,
|
|
446
445
|
error: record.error,
|
|
447
446
|
});
|
|
448
|
-
|
|
449
|
-
// from globalProgressTracker. For foreground agents, find the task ID from
|
|
450
|
-
// the manifest and look up streaming progress.
|
|
451
|
-
let progressLine = text;
|
|
452
|
-
if (tasks && tasks.length > 0) {
|
|
453
|
-
const wp = globalProgressTracker.getWorkerProgress(tasks[0].id);
|
|
454
|
-
if (wp) {
|
|
455
|
-
const toolLine = wp.currentTool ? `\n ⚡ tool: ${wp.currentTool}` : "";
|
|
456
|
-
const tokenLine =
|
|
457
|
-
wp.tokens.input + wp.tokens.output > 0
|
|
458
|
-
? `\n 📊 tokens: ${(wp.tokens.input / 1000).toFixed(1)}K in, ${(wp.tokens.output / 1000).toFixed(1)}K out`
|
|
459
|
-
: "";
|
|
460
|
-
const textLine = wp.partialText ? `\n 💬 ${wp.partialText.slice(-150).replace(/\n/g, " ")}` : "";
|
|
461
|
-
progressLine = `${text}${toolLine}${tokenLine}${textLine}`;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
onUpdate({ content: [{ type: "text", text: progressLine }] });
|
|
447
|
+
onUpdate({ content: [{ type: "text", text }] });
|
|
465
448
|
} catch (error) {
|
|
466
449
|
logInternalError("subagent-tools.progress", error, `agentId=${agentRecordId}`);
|
|
467
450
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
2
3
|
import type { PiTeamsConfig } from "../../config/config.ts";
|
|
3
4
|
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
4
5
|
import type { RunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
|
|
@@ -832,6 +832,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
832
832
|
runtime,
|
|
833
833
|
runtimeConfig: executedConfig.runtime,
|
|
834
834
|
parentContext: buildParentContext(ctx),
|
|
835
|
+
|
|
835
836
|
parentModel: ctx.model,
|
|
836
837
|
modelRegistry: ctx.modelRegistry,
|
|
837
838
|
modelOverride: params.model,
|
|
@@ -9,8 +9,6 @@ export interface AgentProgress {
|
|
|
9
9
|
turns: number;
|
|
10
10
|
tokens: { input: number; output: number };
|
|
11
11
|
status: "idle" | "running" | "completed" | "error";
|
|
12
|
-
/** Most recent partial assistant text (for live streaming display). */
|
|
13
|
-
partialText?: string;
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
export class ProgressTracker {
|
|
@@ -120,145 +118,7 @@ export class ProgressTracker {
|
|
|
120
118
|
getProgress(agentId: string): AgentProgress | undefined {
|
|
121
119
|
return this.sessions.get(agentId)?.progress;
|
|
122
120
|
}
|
|
123
|
-
|
|
124
|
-
// ── Child-process worker event bridge ──────────────────────────────────
|
|
125
|
-
//
|
|
126
|
-
// For child-process runtime, events arrive as raw JSON from the child pi
|
|
127
|
-
// process's stdout (via onJsonEvent). These methods bridge those events into
|
|
128
|
-
// the same crewEventBus stream that live-session uses, so the widget shows
|
|
129
|
-
// real-time tool calls and assistant text for BOTH runtimes.
|
|
130
|
-
|
|
131
|
-
private workerProgress = new Map<string, AgentProgress>();
|
|
132
|
-
|
|
133
|
-
/** Throttle: don't emit more than 1 progress event per 500ms per worker. */
|
|
134
|
-
private lastEmitTs = new Map<string, number>();
|
|
135
|
-
private static readonly EMIT_THROTTLE_MS = 500;
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Handle a raw child-process JSON event (from onJsonEvent callback).
|
|
139
|
-
* Processes tool_execution_start/end, agent_start/end, and assistant text.
|
|
140
|
-
*/
|
|
141
|
-
handleWorkerEvent(taskId: string, runId: string, event: Record<string, unknown>): void {
|
|
142
|
-
let progress = this.workerProgress.get(taskId);
|
|
143
|
-
if (!progress) {
|
|
144
|
-
progress = {
|
|
145
|
-
toolCalls: 0,
|
|
146
|
-
currentTool: null,
|
|
147
|
-
toolStartTime: null,
|
|
148
|
-
errors: [],
|
|
149
|
-
turns: 0,
|
|
150
|
-
tokens: { input: 0, output: 0 },
|
|
151
|
-
status: "running",
|
|
152
|
-
};
|
|
153
|
-
this.workerProgress.set(taskId, progress);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const eventType = typeof event.type === "string" ? event.type : undefined;
|
|
157
|
-
|
|
158
|
-
switch (eventType) {
|
|
159
|
-
case "tool_execution_start": {
|
|
160
|
-
progress.toolCalls++;
|
|
161
|
-
progress.currentTool = typeof event.toolName === "string" ? event.toolName : "unknown";
|
|
162
|
-
progress.toolStartTime = Date.now();
|
|
163
|
-
this.emitThrottled(taskId, runId, progress);
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
case "tool_execution_end": {
|
|
167
|
-
progress.currentTool = null;
|
|
168
|
-
progress.toolStartTime = null;
|
|
169
|
-
if (event.isError) {
|
|
170
|
-
progress.errors.push(String(event.result ?? "Unknown error"));
|
|
171
|
-
crewEventBus.emit({
|
|
172
|
-
type: "agent:error",
|
|
173
|
-
runId,
|
|
174
|
-
agentId: taskId,
|
|
175
|
-
payload: String(event.result ?? "Unknown error"),
|
|
176
|
-
timestamp: Date.now(),
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
this.emitThrottled(taskId, runId, progress);
|
|
180
|
-
break;
|
|
181
|
-
}
|
|
182
|
-
case "turn_start":
|
|
183
|
-
case "turn_end":
|
|
184
|
-
progress.turns++;
|
|
185
|
-
break;
|
|
186
|
-
case "agent_start":
|
|
187
|
-
progress.status = "running";
|
|
188
|
-
break;
|
|
189
|
-
case "agent_end":
|
|
190
|
-
case "agent_settled":
|
|
191
|
-
progress.status = "completed";
|
|
192
|
-
this.emitThrottled(taskId, runId, progress);
|
|
193
|
-
break;
|
|
194
|
-
case "message":
|
|
195
|
-
case "message_end": {
|
|
196
|
-
// Extract assistant text for streaming display.
|
|
197
|
-
const message = event.message as { role?: string; content?: unknown } | undefined;
|
|
198
|
-
if (message?.role === "assistant") {
|
|
199
|
-
const text = extractWorkerText(message.content);
|
|
200
|
-
if (text) {
|
|
201
|
-
progress.partialText = text.slice(-2000); // keep last 2K chars
|
|
202
|
-
this.emitThrottled(taskId, runId, progress);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
// Track usage from message_end events.
|
|
206
|
-
if (eventType === "message_end" && event.usage && typeof event.usage === "object") {
|
|
207
|
-
const usage = event.usage as { input?: number; output?: number };
|
|
208
|
-
if (typeof usage.input === "number") progress.tokens.input += usage.input;
|
|
209
|
-
if (typeof usage.output === "number") progress.tokens.output += usage.output;
|
|
210
|
-
}
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Get the accumulated progress for a child-process worker task. */
|
|
217
|
-
getWorkerProgress(taskId: string): AgentProgress | undefined {
|
|
218
|
-
return this.workerProgress.get(taskId);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Remove a worker from tracking after completion. */
|
|
222
|
-
untrackWorker(taskId: string): void {
|
|
223
|
-
this.workerProgress.delete(taskId);
|
|
224
|
-
this.lastEmitTs.delete(taskId);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Emit a progress event to crewEventBus, throttled to avoid flooding
|
|
229
|
-
* the widget with re-renders (max 1 event per EMIT_THROTTLE_MS per worker).
|
|
230
|
-
*/
|
|
231
|
-
private emitThrottled(taskId: string, runId: string, progress: AgentProgress): void {
|
|
232
|
-
const now = Date.now();
|
|
233
|
-
const last = this.lastEmitTs.get(taskId) ?? 0;
|
|
234
|
-
if (now - last < ProgressTracker.EMIT_THROTTLE_MS) return;
|
|
235
|
-
this.lastEmitTs.set(taskId, now);
|
|
236
|
-
crewEventBus.emit({
|
|
237
|
-
type: "agent:progress",
|
|
238
|
-
runId,
|
|
239
|
-
agentId: taskId,
|
|
240
|
-
payload: { ...progress },
|
|
241
|
-
timestamp: now,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
121
|
}
|
|
245
122
|
|
|
246
123
|
// Export singleton instance
|
|
247
124
|
export const globalProgressTracker = new ProgressTracker();
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Extract text content from a worker message's content field.
|
|
251
|
-
* Handles string content and array-of-parts (like extractTextContent in context.ts).
|
|
252
|
-
*/
|
|
253
|
-
function extractWorkerText(content: unknown): string {
|
|
254
|
-
if (typeof content === "string") return content;
|
|
255
|
-
if (!Array.isArray(content)) return "";
|
|
256
|
-
return content
|
|
257
|
-
.map((part) =>
|
|
258
|
-
part && typeof part === "object" && !Array.isArray(part) && typeof (part as { text?: unknown }).text === "string"
|
|
259
|
-
? (part as { text: string }).text
|
|
260
|
-
: "",
|
|
261
|
-
)
|
|
262
|
-
.filter(Boolean)
|
|
263
|
-
.join("\n");
|
|
264
|
-
}
|
package/src/state/locks.ts
CHANGED
|
@@ -79,6 +79,29 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
79
79
|
* Returns `{ canSteal: true }` if the lock is stale OR the holder is dead
|
|
80
80
|
* (safe to forcibly remove); `{ canSteal: false }` if it is fresh AND held by
|
|
81
81
|
* a live process (must keep waiting).
|
|
82
|
+
*
|
|
83
|
+
* ## EPERM Handling (Accepted Risk)
|
|
84
|
+
*
|
|
85
|
+
* When `process.kill(pid, 0)` returns EPERM, it means the holder process
|
|
86
|
+
* EXISTS but we lack permission to signal it. We treat this as "not alive"
|
|
87
|
+
* (stealable) because:
|
|
88
|
+
*
|
|
89
|
+
* 1. **Blocking indefinitely is worse** — on shared systems, a holder running
|
|
90
|
+
* under a different user/permission context would block us forever.
|
|
91
|
+
* 2. **EPERM requires elevated privileges** — on single-user workstations
|
|
92
|
+
* (the typical pi-crew environment), EPERM is rare.
|
|
93
|
+
* 3. **Defense in depth** — the lock staleness check provides a secondary
|
|
94
|
+
* safeguard; we only steal if the lock is also stale.
|
|
95
|
+
*
|
|
96
|
+
* **Trade-off:** On multi-user systems, this could theoretically allow two
|
|
97
|
+
* processes in the critical section simultaneously if: (a) holder is alive
|
|
98
|
+
* under different permissions, AND (b) our steal attempt races with holder's
|
|
99
|
+
* release. The practical risk is low because:
|
|
100
|
+
* - Lock holders typically complete quickly
|
|
101
|
+
* - Stale timeout provides a safety window
|
|
102
|
+
* - Critical sections are designed to tolerate rare races
|
|
103
|
+
*
|
|
104
|
+
* See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
|
|
82
105
|
*/
|
|
83
106
|
function readLockSnapshot(filePath: string, staleMs: number): { canSteal: boolean } {
|
|
84
107
|
let stat: fs.Stats | undefined;
|
package/src/state/state-store.ts
CHANGED
|
@@ -203,6 +203,9 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
|
|
206
|
+
if (!cwd || typeof cwd !== "string") {
|
|
207
|
+
throw new Error(`Invalid cwd: ${cwd}`);
|
|
208
|
+
}
|
|
206
209
|
assertSafePathId("runId", runId);
|
|
207
210
|
const baseRoot = scopeBaseRoot(cwd);
|
|
208
211
|
const stateRoot = resolveContainedRelativePath(path.join(baseRoot, DEFAULT_PATHS.state.runsSubdir), runId, "runId");
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { type Container, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
11
11
|
import { truncateToWidth } from "../../utils/visual.ts";
|
|
12
|
+
import { spinnerFrame } from "../spinner.ts";
|
|
12
13
|
import type { CrewTheme } from "../theme-adapter.ts";
|
|
13
14
|
import { formatDuration, formatTokens, truncLine } from "../tool-render.ts";
|
|
14
15
|
import { briefToolResult, isBrief } from "./brief-mode.ts";
|
|
@@ -373,9 +374,25 @@ function renderAgentResult(result: Record<string, unknown>, options: unknown, th
|
|
|
373
374
|
// isPartial = agent still running
|
|
374
375
|
const isPartial = (options as Record<string, unknown>)?.isPartial === true;
|
|
375
376
|
if (isPartial && !ctx.expanded) {
|
|
376
|
-
const spinner = theme.fg("
|
|
377
|
-
const
|
|
378
|
-
|
|
377
|
+
const spinner = theme.fg("accent", spinnerFrame(d.agentId as string));
|
|
378
|
+
const parts: string[] = [theme.fg("muted", "working")];
|
|
379
|
+
|
|
380
|
+
// Extract progress from onUpdate content (formatCompactToolProgress output)
|
|
381
|
+
const progressText = extractContentText(result?.content);
|
|
382
|
+
if (progressText) {
|
|
383
|
+
const elapsedMatch = progressText.match(/elapsed=(\d+)s/);
|
|
384
|
+
const tokenMatch = progressText.match(/tokens=(\d+)/);
|
|
385
|
+
const toolMatch = progressText.match(/tool:\s+(\S+)/);
|
|
386
|
+
const elapsedSec = elapsedMatch ? parseInt(elapsedMatch[1]!, 10) : 0;
|
|
387
|
+
const tokens = tokenMatch ? parseInt(tokenMatch[1]!, 10) : 0;
|
|
388
|
+
const tps = elapsedSec > 0 ? Math.round(tokens / elapsedSec) : 0;
|
|
389
|
+
|
|
390
|
+
if (elapsedSec > 0) parts.push(theme.fg("dim", formatDuration(elapsedSec * 1000)));
|
|
391
|
+
if (tps > 0) parts.push(theme.fg("dim", `${formatTokens(tps)} tok/s`));
|
|
392
|
+
if (toolMatch) parts.push(theme.fg("dim", `· ${toolMatch[1]}`));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
contentLines.push(padVisual(` ${spinner} ${parts.join(" ")}`, innerW));
|
|
379
396
|
return buildFrameBottom(contentLines, w, theme, "borderAccent", "borderAccent");
|
|
380
397
|
}
|
|
381
398
|
|
|
@@ -18,6 +18,7 @@ import { computeLiveDurationMs } from "../live-duration.ts";
|
|
|
18
18
|
// verbatim (no truncation/crash) — these are rare one-off states, not jitter.
|
|
19
19
|
const TOOLS_METRIC_WIDTH = 8; // "127 tools"
|
|
20
20
|
const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
|
|
21
|
+
const TPS_METRIC_WIDTH = 9; // "411 tok/s"
|
|
21
22
|
const CTX_METRIC_WIDTH = 7; // "100% ctx"
|
|
22
23
|
const DURATION_METRIC_WIDTH = 6; // "120.0s"
|
|
23
24
|
|
|
@@ -139,10 +140,19 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
|
|
|
139
140
|
/* ignore */
|
|
140
141
|
}
|
|
141
142
|
const ms = computeLiveDurationMs(act);
|
|
143
|
+
if (total > 0 && ms > 1000) {
|
|
144
|
+
const tps = Math.round(total / (ms / 1000));
|
|
145
|
+
if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
|
|
146
|
+
}
|
|
142
147
|
parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
|
|
143
148
|
} else {
|
|
144
149
|
if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
|
|
145
150
|
if (agent.progress?.tokens) parts.push(alignMetric(formatTokensCompact(agent.progress.tokens), TOKENS_METRIC_WIDTH));
|
|
151
|
+
const ageMs = agent.startedAt ? Math.max(0, Date.now() - new Date(agent.startedAt).getTime()) : 0;
|
|
152
|
+
if (agent.progress?.tokens && ageMs > 1000) {
|
|
153
|
+
const tps = Math.round(agent.progress.tokens / (ageMs / 1000));
|
|
154
|
+
if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
|
|
155
|
+
}
|
|
146
156
|
const age = elapsed(agent.completedAt ?? agent.startedAt);
|
|
147
157
|
if (age) parts.push(alignMetric(age, DURATION_METRIC_WIDTH));
|
|
148
158
|
}
|