pi-crew 0.10.1 → 0.10.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 +98 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/dist/index.mjs +2781 -949
- package/package.json +1 -1
- package/schema.json +492 -94
- package/src/config/config-validation.ts +7 -1
- package/src/config/defaults.ts +8 -1
- package/src/config/role-tools.ts +4 -2
- package/src/config/types.ts +19 -1
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/run.ts +15 -1
- package/src/extension/registration/commands/shared.ts +8 -0
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +52 -2
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/team-tool/run-deadline.ts +20 -3
- package/src/extension/team-tool/run.ts +26 -2
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
- package/src/runtime/merge-gate.ts +25 -9
- package/src/runtime/model/model-fallback.ts +7 -3
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/crash-recovery.ts +16 -0
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/team-runner.ts +3 -2
- package/src/schema/config-schema.ts +1 -1
- package/src/state/stores/plan-store.ts +1 -1
- package/src/state/stores/state-store.ts +73 -3
- package/src/state/types.ts +2 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +234 -37
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/workflows/discover-workflows.ts +1 -0
- package/src/workflows/workflow-config.ts +7 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
|
@@ -456,11 +456,17 @@ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
|
|
|
456
456
|
function parseUiConfig(value: unknown): CrewUiConfig | undefined {
|
|
457
457
|
const obj = asRecord(value);
|
|
458
458
|
if (!obj) return undefined;
|
|
459
|
-
const rawWidgetPlacement = parseWithSchema(
|
|
459
|
+
const rawWidgetPlacement = parseWithSchema(
|
|
460
|
+
Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor"), Type.Literal("bottom")]),
|
|
461
|
+
obj.widgetPlacement,
|
|
462
|
+
);
|
|
460
463
|
const rawDashboardPlacement = parseWithSchema(Type.Union([Type.Literal("center"), Type.Literal("right")]), obj.dashboardPlacement);
|
|
464
|
+
const rawRowStyle = parseWithSchema(Type.Union([Type.Literal("compact"), Type.Literal("detailed")]), obj.widgetRowStyle);
|
|
461
465
|
const ui: CrewUiConfig = {
|
|
462
466
|
widgetPlacement: rawWidgetPlacement,
|
|
463
467
|
widgetMaxLines: parsePositiveInteger(obj.widgetMaxLines, 50),
|
|
468
|
+
widgetRowStyle: rawRowStyle,
|
|
469
|
+
inlinePanel: parseWithSchema(Type.Boolean(), obj.inlinePanel),
|
|
464
470
|
powerbar: parseWithSchema(Type.Boolean(), obj.powerbar),
|
|
465
471
|
dashboardPlacement: rawDashboardPlacement,
|
|
466
472
|
dashboardWidth: parseIntegerInRange(obj.dashboardWidth, 32, 120),
|
package/src/config/defaults.ts
CHANGED
|
@@ -129,8 +129,15 @@ export const DEFAULT_UI = {
|
|
|
129
129
|
refreshMs: 1000,
|
|
130
130
|
notifierIntervalMs: 5000,
|
|
131
131
|
widgetDefaultFrameMs: 1000,
|
|
132
|
-
|
|
132
|
+
// The crew widget (agent rows + hint + main row) docks BELOW the editor,
|
|
133
|
+
// matching pi-subtask's subtask panel — the transcript pane separately
|
|
134
|
+
// mounts above the editor, exactly like pi-subtask's fork view.
|
|
135
|
+
// The dock renders at the very bottom of the screen, below the crew-vibes
|
|
136
|
+
// quota/meter footer (falls back to belowEditor when no footer sink exists).
|
|
137
|
+
widgetPlacement: "bottom" as const,
|
|
133
138
|
widgetMaxLines: 8,
|
|
139
|
+
widgetRowStyle: "compact" as const,
|
|
140
|
+
inlinePanel: true,
|
|
134
141
|
powerbar: true,
|
|
135
142
|
dashboardPlacement: "center" as const,
|
|
136
143
|
dashboardWidth: 72,
|
package/src/config/role-tools.ts
CHANGED
|
@@ -90,9 +90,11 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
|
90
90
|
scratchpad: true,
|
|
91
91
|
},
|
|
92
92
|
|
|
93
|
-
// Test Engineer - Can write tests (F1: hyphenated key)
|
|
93
|
+
// Test Engineer - Can write tests (F1: hyphenated key). Tool list mirrors
|
|
94
|
+
// agents/test-engineer.md frontmatter (T3 binding, commit c25dfbe2):
|
|
95
|
+
// search tools + delegate for spawning depth-1 helpers.
|
|
94
96
|
"test-engineer": {
|
|
95
|
-
tools: ["read", "edit", "write", "bash", "ls", "scratchpad", "ask"],
|
|
97
|
+
tools: ["read", "edit", "write", "bash", "ls", "glob", "grep", "find", "scratchpad", "ask", "delegate"],
|
|
96
98
|
excludeTools: ["web"],
|
|
97
99
|
// Phase 1 scratchpad: build/run test suites with state across cells.
|
|
98
100
|
scratchpad: true,
|
package/src/config/types.ts
CHANGED
|
@@ -143,8 +143,26 @@ export interface GoalWrapWorkflowConfig {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
export interface CrewUiConfig {
|
|
146
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Where the crew dock (agents list) renders:
|
|
148
|
+
* - `aboveEditor` / `belowEditor`: pi's widget slots around the prompt;
|
|
149
|
+
* - `bottom`: inside the crew-vibes footer, BELOW the quota/meter lines
|
|
150
|
+
* (the very bottom of the screen). Falls back to `belowEditor` when
|
|
151
|
+
* crew-vibes is not active (no footer sink).
|
|
152
|
+
*/
|
|
153
|
+
widgetPlacement?: "aboveEditor" | "belowEditor" | "bottom";
|
|
147
154
|
widgetMaxLines?: number;
|
|
155
|
+
/**
|
|
156
|
+
* Per-agent row layout in the widget. `compact` is one width-budgeted line
|
|
157
|
+
* per agent; `detailed` keeps the two-line tree (name row + activity row).
|
|
158
|
+
*/
|
|
159
|
+
widgetRowStyle?: "compact" | "detailed";
|
|
160
|
+
/**
|
|
161
|
+
* Keyboard-navigable agent rows under the prompt (`↓` from an empty prompt).
|
|
162
|
+
* Requires owning pi's editor component, so it yields to any other extension
|
|
163
|
+
* that already installed a custom editor.
|
|
164
|
+
*/
|
|
165
|
+
inlinePanel?: boolean;
|
|
148
166
|
powerbar?: boolean;
|
|
149
167
|
dashboardPlacement?: "center" | "right";
|
|
150
168
|
dashboardWidth?: number;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { cleanupAllTrackedTempDirs } from "../runtime/model/pi-args.ts";
|
|
3
|
+
import { isSessionSwitchInFlight } from "../ui/inline-panel/view-session-store.ts";
|
|
3
4
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
4
5
|
|
|
5
6
|
// NOTE: globalProgressTracker import kept for documentation but not directly used
|
|
@@ -67,6 +68,18 @@ export function registerCleanupHandler(pi: ExtensionAPI, opts?: { disposeTermina
|
|
|
67
68
|
console.log("[pi-crew] Session shutdown - cleaning up resources");
|
|
68
69
|
|
|
69
70
|
try {
|
|
71
|
+
// Navigational switches (pi's own resume/new/fork) tear the session
|
|
72
|
+
// down on purpose — the user is only LOOKING at a different pi
|
|
73
|
+
// session; foreground team runs + their workers share this process
|
|
74
|
+
// and must keep running (P0: "jumping into any session stops the
|
|
75
|
+
// run" — exit 143 signature). Killing them here bypasses child-pi's
|
|
76
|
+
// abort flags, so the worker exit reads as an "unexpected
|
|
77
|
+
// code=143 signal=null" and the run is cancelled. Same contract as
|
|
78
|
+
// stopSessionBoundSubagents and the run-deadline caller-abort
|
|
79
|
+
// suppression; on REAL shutdowns (quit/reload) the flag is unset
|
|
80
|
+
// and this cleanup still drains the registry, as does the
|
|
81
|
+
// process-exit hook (tryRegisterSessionCleanup) when pi dies.
|
|
82
|
+
if (isSessionSwitchInFlight()) return;
|
|
70
83
|
// Kill all child-pi processes
|
|
71
84
|
await cleanupChildProcesses();
|
|
72
85
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { getFooterDockProvider } from "../../ui/dock-footer.ts";
|
|
3
4
|
import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
|
|
4
5
|
import { asCrewTheme, type CrewTheme } from "../../ui/theme-adapter.ts";
|
|
6
|
+
import { colorWidgetLine, renderLines } from "../../ui/widget/widget-renderer.ts";
|
|
5
7
|
import { truncateToWidth, visibleWidth } from "../../utils/visual.ts";
|
|
6
8
|
import type { CrewVibesConfig } from "./config.ts";
|
|
7
9
|
import type { ProviderUsage } from "./provider-usage.ts";
|
|
@@ -280,11 +282,28 @@ class CrewVibesFooter implements FooterComponent {
|
|
|
280
282
|
return [truncateToWidth(cap, width, "…"), this.rightAlign(quota, width)];
|
|
281
283
|
}
|
|
282
284
|
|
|
285
|
+
/** Dock lines registered by the crew widget (`widgetPlacement: "bottom"`):
|
|
286
|
+
* painted as the very last block of the footer, below the quota/meters.
|
|
287
|
+
* The provider returns RAW lines; we color them with this footer's theme
|
|
288
|
+
* (same per-line palette the widget would have used) and truncate to the
|
|
289
|
+
* real render width. */
|
|
290
|
+
private buildDockLines(width: number): string[] {
|
|
291
|
+
const provider = getFooterDockProvider();
|
|
292
|
+
if (!provider) return [];
|
|
293
|
+
const raw = provider(width);
|
|
294
|
+
if (raw.length === 0) return [];
|
|
295
|
+
return renderLines(
|
|
296
|
+
raw.map((line, index) => colorWidgetLine(line, index, this.theme)),
|
|
297
|
+
width,
|
|
298
|
+
).map((line) => truncateToWidth(line, width, "…"));
|
|
299
|
+
}
|
|
300
|
+
|
|
283
301
|
render(width: number): string[] {
|
|
284
302
|
const lines = [this.buildPwdLine(width), this.buildStatsLine(width)];
|
|
285
303
|
const statusLine = this.buildStatusLine(width);
|
|
286
304
|
if (statusLine) lines.push(statusLine);
|
|
287
305
|
lines.push(...this.buildMeterLines(width));
|
|
306
|
+
lines.push(...this.buildDockLines(width));
|
|
288
307
|
return lines;
|
|
289
308
|
}
|
|
290
309
|
}
|
|
@@ -19,6 +19,7 @@ function safeUiCall(scope: string, fn: () => void): void {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
import { setFooterDockSinkActive } from "../../ui/dock-footer.ts";
|
|
22
23
|
import { type CrewVibesConfig, loadConfig, saveConfig } from "./config.ts";
|
|
23
24
|
import { intervalForSpeed } from "./figures.ts";
|
|
24
25
|
import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
|
|
@@ -91,11 +92,18 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
91
92
|
function installFooter(ctx: ExtensionContext): void {
|
|
92
93
|
if (!ctx?.hasUI) return;
|
|
93
94
|
if (!metersActive()) {
|
|
94
|
-
safeUiCall("clear-footer", () =>
|
|
95
|
+
safeUiCall("clear-footer", () => {
|
|
96
|
+
// The crew dock can no longer render at the very bottom — the
|
|
97
|
+
// widget falls back to pi's belowEditor slot automatically.
|
|
98
|
+
setFooterDockSinkActive(false);
|
|
99
|
+
setFooter(ctx, undefined);
|
|
100
|
+
});
|
|
95
101
|
return;
|
|
96
102
|
}
|
|
97
103
|
safeUiCall("install-footer", () => {
|
|
98
104
|
setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
|
|
105
|
+
// The footer can now host the crew dock below the quota lines.
|
|
106
|
+
setFooterDockSinkActive(true);
|
|
99
107
|
requestRender(ctx);
|
|
100
108
|
});
|
|
101
109
|
}
|
|
@@ -245,6 +253,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
245
253
|
stopFooterTimer();
|
|
246
254
|
stopCapacityTimer();
|
|
247
255
|
stopProviderTimer();
|
|
256
|
+
setFooterDockSinkActive(false);
|
|
248
257
|
setFooter(ctx, undefined);
|
|
249
258
|
clearVibesStatus(ctx);
|
|
250
259
|
return;
|
|
@@ -370,6 +379,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
370
379
|
stopFooterTimer();
|
|
371
380
|
stopCapacityTimer();
|
|
372
381
|
stopProviderTimer();
|
|
382
|
+
setFooterDockSinkActive(false);
|
|
373
383
|
setFooter(ctx, undefined);
|
|
374
384
|
clearVibesStatus(ctx);
|
|
375
385
|
});
|
|
@@ -21,6 +21,7 @@ import { loadConfig } from "../config/config.ts";
|
|
|
21
21
|
import { startRuntimeWarmup } from "../runtime/model/runtime-warmup.ts";
|
|
22
22
|
import { primePeerDep } from "../runtime/peer-dep.ts";
|
|
23
23
|
import { deployBundledThemes } from "../ui/deploy-bundled-themes.ts";
|
|
24
|
+
import { installChildProcessAbortShield } from "../utils/child-process-shield.ts";
|
|
24
25
|
import { resetTimings, time } from "../utils/timings.ts";
|
|
25
26
|
import { registerAutonomousPolicy } from "./autonomous-policy.ts";
|
|
26
27
|
import { registerContextStatusInjection } from "./context-status-injection.ts";
|
|
@@ -52,6 +53,13 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
52
53
|
resetTimings();
|
|
53
54
|
time("register:start");
|
|
54
55
|
|
|
56
|
+
// MUST run before any team run can start: guarantees every signal-spawned
|
|
57
|
+
// child in the process has an 'error' listener so Node's abort-after-exit
|
|
58
|
+
// AbortError dispatches instead of being rethrown on the next tick as an
|
|
59
|
+
// uncaughtException that kills pi (see child-process-shield.ts for the
|
|
60
|
+
// full crash chain — /crew-view mid-run crash).
|
|
61
|
+
installChildProcessAbortShield();
|
|
62
|
+
|
|
55
63
|
startRuntimeWarmup();
|
|
56
64
|
primePeerDep().catch(() => undefined);
|
|
57
65
|
deployBundledThemes();
|
|
@@ -31,6 +31,7 @@ export function registerPiCommands(pi: ExtensionAPI, ctx: RegistrationContext):
|
|
|
31
31
|
getManifestCache: ctx.getManifestCache,
|
|
32
32
|
getRunSnapshotCache: ctx.getRunSnapshotCache,
|
|
33
33
|
getMetricRegistry: () => ctx.observabilityState.metricRegistry,
|
|
34
|
+
getCurrentSessionFile: () => ctx.currentCtx?.sessionManager?.getSessionFile?.(),
|
|
34
35
|
uiState: ctx.uiState,
|
|
35
36
|
dismissNotifications: () => {
|
|
36
37
|
ctx.widgetState.notificationCount = 0;
|
|
@@ -17,7 +17,21 @@ export function registerRunCommands(pi: ExtensionAPI, deps: RegisterTeamCommands
|
|
|
17
17
|
abortForegroundRun: deps.abortForegroundRun,
|
|
18
18
|
onRunStarted: undefined,
|
|
19
19
|
});
|
|
20
|
-
|
|
20
|
+
// A foreground run can span a session switch (e.g. the user opens
|
|
21
|
+
// an agent view mid-run and the run finishes there). The captured
|
|
22
|
+
// ctx is stale after the switch: ANY access to it (even reading a
|
|
23
|
+
// field) throws pi's "This extension ctx is stale ..." error, which
|
|
24
|
+
// pi then prints into whichever session is active. Probe
|
|
25
|
+
// defensively and skip the notification when the session moved on
|
|
26
|
+
// (the result is still visible via /team-status).
|
|
27
|
+
try {
|
|
28
|
+
const capturedFile = ctx.sessionManager?.getSessionFile?.();
|
|
29
|
+
const currentFile = deps.getCurrentSessionFile?.();
|
|
30
|
+
if (capturedFile && currentFile && capturedFile !== currentFile) return;
|
|
31
|
+
await notifyCommandResult(ctx, commandText(result));
|
|
32
|
+
} catch {
|
|
33
|
+
// Stale ctx during the probe or notify — nothing to do here.
|
|
34
|
+
}
|
|
21
35
|
},
|
|
22
36
|
});
|
|
23
37
|
|
|
@@ -80,6 +80,14 @@ export interface RegisterTeamCommandsDeps {
|
|
|
80
80
|
getMetricRegistry?: () => MetricRegistry | undefined;
|
|
81
81
|
uiState?: UiState;
|
|
82
82
|
dismissNotifications?: () => void;
|
|
83
|
+
/**
|
|
84
|
+
* The session file of the CURRENT session (RegistrationContext.currentCtx).
|
|
85
|
+
* Commands whose handler can span a session switch — team-run awaits the
|
|
86
|
+
* whole foreground run — use this after the await to detect that the
|
|
87
|
+
* captured ctx is stale and skip notifications that pi would drop with a
|
|
88
|
+
* "ctx is stale" error.
|
|
89
|
+
*/
|
|
90
|
+
getCurrentSessionFile?: () => string | undefined;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
// Lazy-loaded UI module cache — avoids importing 900ms+ of UI at Pi startup.
|
|
@@ -20,6 +20,7 @@ import { setWorkingIndicator } from "../../ui/pi-ui-compat.ts";
|
|
|
20
20
|
import { requestPowerbarUpdate } from "../../ui/powerbar-publisher.ts";
|
|
21
21
|
import { updateCrewWidget } from "../../ui/widget/index.ts";
|
|
22
22
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
23
|
+
import { safeAbort } from "../../utils/safe-abort.ts";
|
|
23
24
|
import type { RegistrationContext } from "./registration-types.ts";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -36,7 +37,10 @@ export function installForegroundRunController(pi: ExtensionAPI, ctx: Registrati
|
|
|
36
37
|
ctx.abortForegroundRun = (runId) => {
|
|
37
38
|
const controller = ctx.foregroundTeamRunControllers.get(runId);
|
|
38
39
|
if (!controller) return false;
|
|
39
|
-
|
|
40
|
+
// safe-abort: cancel can race a completed run whose spawned children
|
|
41
|
+
// already exited — Node's child_process signal listener would throw
|
|
42
|
+
// AbortError out of abort() and kill the host (uncaughtException).
|
|
43
|
+
safeAbort(controller, "fg-run.abort-by-run-id");
|
|
40
44
|
return true;
|
|
41
45
|
};
|
|
42
46
|
}
|
|
@@ -142,7 +146,11 @@ function startForegroundRunImpl(
|
|
|
142
146
|
.catch((error) => logInternalError("register.foreground-watchdog", error, `runId=${runId}`));
|
|
143
147
|
}
|
|
144
148
|
const ownerCurrent = ctx.isContextCurrent(extensionCtx, ownerGeneration);
|
|
145
|
-
|
|
149
|
+
// The owner ctx can go stale while the run is in flight (e.g. the
|
|
150
|
+
// user opened an agent session view via /crew-view and the run
|
|
151
|
+
// finishes there): the `hasUI` getter THROWS on a stale ctx, so
|
|
152
|
+
// it must only be touched for the still-current owner.
|
|
153
|
+
if (ownerCurrent && extensionCtx.hasUI) {
|
|
146
154
|
try {
|
|
147
155
|
setWorkingIndicator(extensionCtx);
|
|
148
156
|
extensionCtx.ui.setWorkingMessage();
|
|
@@ -24,6 +24,7 @@ import { pruneFinishedRuns, pruneUserLevelRuns } from "../../extension/run-maint
|
|
|
24
24
|
import { type BrokerSpawnCredentials, setActiveBrokerIssuer } from "../../runtime/broker/broker-issuer.ts";
|
|
25
25
|
import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
|
|
26
26
|
import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
|
|
27
|
+
import { forgetDetachedRun, hasDetachedRuns, peekFinishedDetachedRunResults } from "../../runtime/detached-run-results.ts";
|
|
27
28
|
import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
|
|
28
29
|
import type { createManifestCache } from "../../runtime/manifest-cache.ts";
|
|
29
30
|
import { configuredModelInfosFromPiConfig } from "../../runtime/model/model-fallback.ts";
|
|
@@ -39,7 +40,9 @@ import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers } from "../../run
|
|
|
39
40
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
40
41
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
41
42
|
import { summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
|
|
42
|
-
import {
|
|
43
|
+
import { installInlinePanel } from "../../ui/inline-panel/index.ts";
|
|
44
|
+
import { clearSessionSwitchInFlight, markSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
|
|
45
|
+
import { requestRender, setExtensionWidget, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
|
|
43
46
|
import {
|
|
44
47
|
registerPiCrewPowerbarSegments,
|
|
45
48
|
requestPowerbarUpdate,
|
|
@@ -75,6 +78,36 @@ export function installSessionLifecycleHandlers(pi: ExtensionAPI, ctx: Registrat
|
|
|
75
78
|
installModelTrackingHandlers(pi);
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Hand over the outcome of runs detached by an agent-view switch.
|
|
83
|
+
*
|
|
84
|
+
* Delivered as a DISPLAYED session entry (not a follow-up queue item): an idle
|
|
85
|
+
* session never flushes a follow-up, so the queued variant left the result
|
|
86
|
+
* invisible. Each result is dropped from the registry only after its send
|
|
87
|
+
* succeeded, and only while the owning session is current — a worker's view
|
|
88
|
+
* session must never receive the parent run's report.
|
|
89
|
+
*/
|
|
90
|
+
function deliverDetachedRunResults(pi: ExtensionAPI, extensionCtx: ExtensionContext): void {
|
|
91
|
+
if (!hasDetachedRuns()) return;
|
|
92
|
+
try {
|
|
93
|
+
// Agent views are in-document panes now, never sessions: the current
|
|
94
|
+
// session is always the main one, so detached results always deliver.
|
|
95
|
+
const inViewSession = false;
|
|
96
|
+
for (const { runId, text } of peekFinishedDetachedRunResults({ inViewSession })) {
|
|
97
|
+
pi.sendMessage({ customType: "pi-crew-run-result", content: text, display: true });
|
|
98
|
+
forgetDetachedRun(runId);
|
|
99
|
+
try {
|
|
100
|
+
extensionCtx.ui.notify(text.split("\n")[0] ?? `pi-crew run finished: ${runId}`, "info");
|
|
101
|
+
} catch {
|
|
102
|
+
/* toast is secondary to the session entry */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Keep the entry: the next tick retries rather than losing the outcome.
|
|
107
|
+
logInternalError("register.detachedRunResults", error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
78
111
|
/**
|
|
79
112
|
* model_select / thinking_level_select:
|
|
80
113
|
* Track what the MAIN session is *actually* running so subagents that
|
|
@@ -125,6 +158,12 @@ function installSessionShutdownHandler(pi: ExtensionAPI, ctx: RegistrationContex
|
|
|
125
158
|
*/
|
|
126
159
|
function installSessionBeforeSwitchHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
127
160
|
pi.on("session_before_switch", () => {
|
|
161
|
+
// The switch tears the current turn down right after this handler
|
|
162
|
+
// (teardownCurrent → session.abort()). That turn-abort must NOT cancel
|
|
163
|
+
// a foreground team run that is still forming (see run-deadline.ts):
|
|
164
|
+
// foreground runs survive session switches (P0). Cleared on the next
|
|
165
|
+
// session_start.
|
|
166
|
+
markSessionSwitchInFlight();
|
|
128
167
|
ctx.sessionGeneration++;
|
|
129
168
|
const pendingCount = ctx.lifecycleState.deliveryCoordinator?.getPendingCount() ?? 0;
|
|
130
169
|
try {
|
|
@@ -167,6 +206,9 @@ function installSessionBeforeSwitchHandler(pi: ExtensionAPI, ctx: RegistrationCo
|
|
|
167
206
|
*/
|
|
168
207
|
function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
169
208
|
pi.on("session_start", (_event, extensionCtx) => {
|
|
209
|
+
// Any session start means a pending switch landed — the turn-abort
|
|
210
|
+
// suppression window for that switch is over.
|
|
211
|
+
clearSessionSwitchInFlight();
|
|
170
212
|
runArtifactCleanup(extensionCtx.cwd);
|
|
171
213
|
|
|
172
214
|
// Restore brief mode state from session entries (best-effort).
|
|
@@ -273,6 +315,13 @@ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext):
|
|
|
273
315
|
});
|
|
274
316
|
const cache = ctx.getManifestCache(extensionCtx.cwd);
|
|
275
317
|
updateCrewWidget(extensionCtx, ctx.widgetState, loadedConfig.config.ui, cache, ctx.getRunSnapshotCache(extensionCtx.cwd));
|
|
318
|
+
// Inline agent panel: keyboard-navigable rows under the prompt + per-agent
|
|
319
|
+
// transcript pane. Installed after the widget so the row projection sees
|
|
320
|
+
// the same caches the widget paint uses.
|
|
321
|
+
installInlinePanel(pi, extensionCtx, loadedConfig.config.ui);
|
|
322
|
+
// Returning from an agent view lands here: report any detached run that
|
|
323
|
+
// finished while the view was open, without waiting for a render tick.
|
|
324
|
+
deliverDetachedRunResults(pi, extensionCtx);
|
|
276
325
|
updatePiCrewPowerbar(
|
|
277
326
|
pi.events,
|
|
278
327
|
extensionCtx.cwd,
|
|
@@ -681,13 +730,14 @@ function setupRenderLoop(
|
|
|
681
730
|
|
|
682
731
|
const renderTick = (): void => {
|
|
683
732
|
if (!ctx.currentCtx) return;
|
|
733
|
+
deliverDetachedRunResults(pi, ctx.currentCtx);
|
|
684
734
|
const config = lastPreloadedConfig?.config.ui;
|
|
685
735
|
const activeCache = lastFrameManifestCache ?? ctx.getManifestCache(ctx.currentCtx.cwd);
|
|
686
736
|
const snapshotCache = lastFrameSnapshotCache ?? ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
|
|
687
737
|
const manifests = lastPreloadedManifests;
|
|
688
738
|
if (!lastPreloadedConfig) backgroundPreload();
|
|
689
739
|
if (ctx.uiState.liveSidebarRunId || ctx.uiState.dashboardOpen) {
|
|
690
|
-
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
740
|
+
const placement = toPiWidgetPlacement(config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement);
|
|
691
741
|
if (ctx.widgetState.lastVisibility !== "hidden" || ctx.widgetState.lastPlacement !== placement) {
|
|
692
742
|
setExtensionWidget(ctx.currentCtx, "pi-crew", undefined, { placement });
|
|
693
743
|
setExtensionWidget(ctx.currentCtx, "pi-crew-active", undefined, { placement });
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { loadConfig } from "../../config/config.ts";
|
|
22
22
|
import { clearHooksScoped } from "../../hooks/registry.ts";
|
|
23
|
-
import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
|
|
24
23
|
import { stopAllWatchdogs } from "../../runtime/foreground-watchdog.ts";
|
|
25
24
|
import { clearPiCrewPowerbar, disposePowerbarCoalescer } from "../../ui/powerbar-publisher.ts";
|
|
26
25
|
import { stopCrewWidget } from "../../ui/widget/index.ts";
|
|
27
26
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
28
27
|
import { clearProjectRootCache } from "../../utils/paths.ts";
|
|
28
|
+
import { safeAbortAll } from "../../utils/safe-abort.ts";
|
|
29
29
|
import { extractSessionId } from "../../utils/session-utils.ts";
|
|
30
30
|
import { stopAsyncRunNotifier } from "../async-notifier.ts";
|
|
31
31
|
import { uninstallCrewGlobalRegistry } from "../team-tool.ts";
|
|
@@ -135,7 +135,9 @@ function buildCleanupRuntime(ctx: RegistrationContext): () => void {
|
|
|
135
135
|
ctx.stopSessionBoundSubagents();
|
|
136
136
|
// P0 fix: also abort foreground team runs on session shutdown (not on session switch).
|
|
137
137
|
// This is the only place where foreground team run controllers should be aborted.
|
|
138
|
-
|
|
138
|
+
// safe-abort: shutdown races completed runs whose spawned children already
|
|
139
|
+
// exited — abort() would throw AbortError from Node's child_process listener.
|
|
140
|
+
safeAbortAll(ctx.foregroundTeamRunControllers.values(), "cleanup-runtime.foreground-team");
|
|
139
141
|
ctx.foregroundTeamRunControllers.clear();
|
|
140
142
|
// RC-01: stop the foreground-run watchdog timers on full shutdown — they were
|
|
141
143
|
// never cleared, so an active (non-terminal) foreground run left its watchdog
|
|
@@ -219,10 +221,26 @@ function buildStopSessionBoundSubagents(ctx: RegistrationContext): () => void {
|
|
|
219
221
|
// Only abort subagent controllers — NOT foreground team runs.
|
|
220
222
|
// Foreground team runs are bound to the session lifecycle; they will be aborted
|
|
221
223
|
// by cleanupRuntime during session_shutdown.
|
|
222
|
-
|
|
224
|
+
// safe-abort: these signals may be linked to spawned children (pipelines pass
|
|
225
|
+
// them into spawn options.signal); aborting after a child exited makes Node's
|
|
226
|
+
// child_process listener throw AbortError synchronously — inside a session
|
|
227
|
+
// event handler that would surface as uncaughtException and kill pi.
|
|
228
|
+
safeAbortAll(ctx.foregroundControllers.values(), "stop-session-bound.foreground");
|
|
223
229
|
ctx.foregroundControllers.clear();
|
|
224
|
-
|
|
225
|
-
|
|
230
|
+
try {
|
|
231
|
+
ctx.subagentManager.abortAll("Session switching — foreground subagents cancelled.");
|
|
232
|
+
} catch (error) {
|
|
233
|
+
logInternalError("stop-session-bound.abortAll", error);
|
|
234
|
+
}
|
|
235
|
+
// Deliberately NOT terminateActiveChildPiProcesses() here: the active
|
|
236
|
+
// child registry holds EVERY child pi spawned in this process —
|
|
237
|
+
// including foreground team-run workers. Killing them on a session
|
|
238
|
+
// switch (resume/new/fork) is exactly the regression "jumping into any
|
|
239
|
+
// session stops the run" (exit 143). Session-bound subagents are
|
|
240
|
+
// cancelled above via their own controllers; foreground runs must
|
|
241
|
+
// survive switches (P0) and are aborted only by cleanupRuntime on real
|
|
242
|
+
// shutdown; the process-exit hook (tryRegisterSessionCleanup) drains
|
|
243
|
+
// the registry when pi actually exits.
|
|
226
244
|
ctx.disposeRenderSchedulerSubscriptions();
|
|
227
245
|
ctx.renderScheduler?.dispose();
|
|
228
246
|
ctx.renderScheduler = undefined;
|
|
@@ -13,6 +13,7 @@ import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
|
|
|
13
13
|
import { statusIcon, type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts";
|
|
14
14
|
import type { CrewWidgetState } from "../../ui/widget/index.ts";
|
|
15
15
|
import { updateCrewWidget } from "../../ui/widget/index.ts";
|
|
16
|
+
import { safeAbort } from "../../utils/safe-abort.ts";
|
|
16
17
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
17
18
|
// Team tool handler — lazy-loaded because team-tool.ts imports many modules
|
|
18
19
|
import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts";
|
|
@@ -174,7 +175,10 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
|
|
|
174
175
|
const controller = new AbortController();
|
|
175
176
|
const toolKey = Symbol();
|
|
176
177
|
deps.foregroundControllers.set(toolKey, controller);
|
|
177
|
-
|
|
178
|
+
// safe-abort: pi's tool-call abort can race a completed run whose
|
|
179
|
+
// spawned children already exited — Node's child_process signal
|
|
180
|
+
// listener throws AbortError out of abort() and kills the host.
|
|
181
|
+
const abort = (): void => safeAbort(controller, "team-tool.caller-abort");
|
|
178
182
|
signal?.addEventListener("abort", abort, { once: true });
|
|
179
183
|
const stopProgress = startTeamToolProgressBinder(onUpdate as OnUpdate | undefined);
|
|
180
184
|
try {
|
|
@@ -15,7 +15,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
15
15
|
import { loadConfig } from "../../config/config.ts";
|
|
16
16
|
import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
17
17
|
import type { LiveRunSidebar as LiveRunSidebarType } from "../../ui/live-run-sidebar.ts";
|
|
18
|
-
import { requestRender, setExtensionWidget, showCustom } from "../../ui/pi-ui-compat.ts";
|
|
18
|
+
import { requestRender, setExtensionWidget, showCustom, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
|
|
19
19
|
import {
|
|
20
20
|
clearPiCrewPowerbar,
|
|
21
21
|
registerPiCrewPowerbarSegments,
|
|
@@ -76,10 +76,11 @@ export async function installLiveSidebar(ctx: ExtensionContext, runId: string, s
|
|
|
76
76
|
state.liveSidebarRunId = runId;
|
|
77
77
|
state.dashboardOpened = true;
|
|
78
78
|
const widgetPlacement = uiConfig?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
79
|
-
|
|
80
|
-
setExtensionWidget(ctx, "pi-crew
|
|
79
|
+
const placement = toPiWidgetPlacement(widgetPlacement);
|
|
80
|
+
setExtensionWidget(ctx, "pi-crew", undefined, { placement });
|
|
81
|
+
setExtensionWidget(ctx, "pi-crew-active", undefined, { placement });
|
|
81
82
|
deps.widgetState.lastVisibility = "hidden";
|
|
82
|
-
deps.widgetState.lastPlacement =
|
|
83
|
+
deps.widgetState.lastPlacement = placement;
|
|
83
84
|
deps.widgetState.lastKey = "pi-crew-active";
|
|
84
85
|
deps.widgetState.model = undefined;
|
|
85
86
|
const width = Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? DEFAULT_UI.dashboardWidth));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { PiTeamsConfig } from "../../config/config.ts";
|
|
2
2
|
import { loadConfig } from "../../config/config.ts";
|
|
3
|
+
import { isSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
|
|
4
|
+
import { safeAbort } from "../../utils/safe-abort.ts";
|
|
3
5
|
import type { TeamContext } from "./context.ts";
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -66,10 +68,23 @@ export function resolveRunDeadline<T extends object>(
|
|
|
66
68
|
// signal fired afterwards, the listener would only call abort() on an
|
|
67
69
|
// already-aborted controller — a no-op.
|
|
68
70
|
if (ctx.signal) {
|
|
69
|
-
if (ctx.signal.aborted) controller.
|
|
71
|
+
if (ctx.signal.aborted) safeAbort(controller, "run-deadline.caller-preaborted");
|
|
70
72
|
else {
|
|
71
73
|
const callerSignal = ctx.signal;
|
|
72
|
-
|
|
74
|
+
// The deadline signal is passed down to run executors that may spawn
|
|
75
|
+
// children with it; aborting after a child exited throws AbortError
|
|
76
|
+
// from Node's child_process listener — never let that escape a
|
|
77
|
+
// pi event handler (safe-abort).
|
|
78
|
+
const onCallerAbort = () => {
|
|
79
|
+
// Regression: switching sessions (resume/new/fork — including
|
|
80
|
+
// /crew-view) tears the current turn down via session.abort(),
|
|
81
|
+
// firing the tool-call abort while the run is still forming. A
|
|
82
|
+
// foreground run must survive session switches (P0 — it shares
|
|
83
|
+
// the process), so a teardown abort must NOT cancel it. Only a
|
|
84
|
+
// genuine caller abort OUTSIDE a session switch propagates.
|
|
85
|
+
if (isSessionSwitchInFlight()) return;
|
|
86
|
+
safeAbort(controller, "run-deadline.caller-abort");
|
|
87
|
+
};
|
|
73
88
|
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
|
|
74
89
|
controller.signal.addEventListener("abort", () => callerSignal.removeEventListener("abort", onCallerAbort), { once: true });
|
|
75
90
|
}
|
|
@@ -77,9 +92,11 @@ export function resolveRunDeadline<T extends object>(
|
|
|
77
92
|
// Arm the deadline timer (unref'd so it never blocks process exit).
|
|
78
93
|
// RC-02: expose the timer so callers can clearTimeout on normal completion —
|
|
79
94
|
// otherwise every run leaves a dangling 1h timer retaining ctx/params in closure.
|
|
95
|
+
// Same abort-after-exit guard as above: the timer may fire after workers
|
|
96
|
+
// already exited and their spawn-signal listeners are gone — Node can throw.
|
|
80
97
|
let timer: NodeJS.Timeout | undefined;
|
|
81
98
|
if (deadlineMs > 0) {
|
|
82
|
-
timer = setTimeout(() => controller.
|
|
99
|
+
timer = setTimeout(() => safeAbort(controller, "run-deadline.timer"), deadlineMs);
|
|
83
100
|
timer.unref?.();
|
|
84
101
|
}
|
|
85
102
|
return { signal: controller.signal, deadlineMs, controller, timer };
|
|
@@ -15,6 +15,7 @@ const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamR
|
|
|
15
15
|
import { fsFailureLabel } from "../../utils/fs-errno.ts";
|
|
16
16
|
import { errorMessage } from "../../utils/guards.ts";
|
|
17
17
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
18
|
+
import { safeAbort } from "../../utils/safe-abort.ts";
|
|
18
19
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
19
20
|
|
|
20
21
|
let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
|
|
@@ -722,12 +723,14 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
722
723
|
// so cancel-via-abortForegroundRun propagates to executeTeamRun.
|
|
723
724
|
fgCallbackSignal = signal;
|
|
724
725
|
if (signal && signal !== fgSignal) {
|
|
725
|
-
if (signal.aborted) fgDeadline.controller.
|
|
726
|
+
if (signal.aborted) safeAbort(fgDeadline.controller, "fg-run.caller-preaborted");
|
|
726
727
|
else {
|
|
727
728
|
// RC-03: keep the ref so we can removeEventListener on completion (the
|
|
728
729
|
// {once:true} alone leaks on the success path — the listener stays
|
|
729
730
|
// attached to the long-lived callback signal if the deadline never fires).
|
|
730
|
-
|
|
731
|
+
// safe-abort: the deadline signal may be linked to spawned children;
|
|
732
|
+
// aborting after they exited throws AbortError from Node's listener.
|
|
733
|
+
fgAbortListener = () => safeAbort(fgDeadline.controller, "fg-run.caller-abort");
|
|
731
734
|
signal.addEventListener("abort", fgAbortListener, { once: true });
|
|
732
735
|
}
|
|
733
736
|
}
|
|
@@ -771,6 +774,27 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
771
774
|
// Wait for the foreground run to complete and return actual results.
|
|
772
775
|
try {
|
|
773
776
|
const completed = await waitForRun(updatedManifest.runId, resolvedCtx.cwd, { timeoutMs: fgDeadline.deadlineMs });
|
|
777
|
+
if (completed.detached) {
|
|
778
|
+
// The waiter was released so this turn can settle (agent view
|
|
779
|
+
// switch); the run keeps executing and reports on completion.
|
|
780
|
+
return result(
|
|
781
|
+
[
|
|
782
|
+
`pi-crew run detached to background: ${updatedManifest.runId}`,
|
|
783
|
+
`Team: ${team.name}`,
|
|
784
|
+
`Workflow: ${workflow.name}`,
|
|
785
|
+
`Goal: ${goal}`,
|
|
786
|
+
"",
|
|
787
|
+
"The run keeps executing — you will be notified when it finishes.",
|
|
788
|
+
`Check status with: team status runId=${updatedManifest.runId}`,
|
|
789
|
+
].join("\n"),
|
|
790
|
+
{
|
|
791
|
+
action: "run",
|
|
792
|
+
status: "ok",
|
|
793
|
+
runId: updatedManifest.runId,
|
|
794
|
+
artifactsRoot: updatedManifest.artifactsRoot,
|
|
795
|
+
},
|
|
796
|
+
);
|
|
797
|
+
}
|
|
774
798
|
return formatRunResult(completed.manifest, {
|
|
775
799
|
tasks: completed.tasks,
|
|
776
800
|
metrics: collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId),
|
|
@@ -32,6 +32,14 @@ export const MAX_LINE_BUFFER_BYTES = 1024 * 1024; // 1 MB
|
|
|
32
32
|
/** Maximum characters for assistant text fragments in compacted events. */
|
|
33
33
|
export const MAX_ASSISTANT_TEXT_CHARS = DEFAULT_CHILD_PI.maxAssistantTextChars;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Maximum characters for reasoning (`thinking`) fragments in compacted events,
|
|
37
|
+
* and the size cap for the thinking parts in the agent view pane / view
|
|
38
|
+
* session. Reasoning is display-only (never folded into results or artifacts),
|
|
39
|
+
* so it gets a tighter cap than full assistant text.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_THINKING_CHARS = 8 * 1024;
|
|
42
|
+
|
|
35
43
|
/** Maximum characters for tool-result fragments in compacted events. */
|
|
36
44
|
export const MAX_TOOL_RESULT_CHARS = DEFAULT_CHILD_PI.maxToolResultChars;
|
|
37
45
|
|