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.
Files changed (64) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/executor.md +1 -1
  5. package/agents/test-engineer.md +1 -1
  6. package/dist/index.mjs +2781 -949
  7. package/package.json +1 -1
  8. package/schema.json +492 -94
  9. package/src/config/config-validation.ts +7 -1
  10. package/src/config/defaults.ts +8 -1
  11. package/src/config/role-tools.ts +4 -2
  12. package/src/config/types.ts +19 -1
  13. package/src/extension/crew-cleanup.ts +13 -0
  14. package/src/extension/crew-vibes/footer.ts +19 -0
  15. package/src/extension/crew-vibes/index.ts +11 -1
  16. package/src/extension/register.ts +8 -0
  17. package/src/extension/registration/command-registration.ts +1 -0
  18. package/src/extension/registration/commands/run.ts +15 -1
  19. package/src/extension/registration/commands/shared.ts +8 -0
  20. package/src/extension/registration/foreground-run-controller.ts +10 -2
  21. package/src/extension/registration/lifecycle-handlers.ts +52 -2
  22. package/src/extension/registration/runtime-cleanup.ts +23 -5
  23. package/src/extension/registration/team-tool.ts +5 -1
  24. package/src/extension/registration/ui.ts +5 -4
  25. package/src/extension/team-tool/run-deadline.ts +20 -3
  26. package/src/extension/team-tool/run.ts +26 -2
  27. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  28. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  29. package/src/runtime/detached-run-results.ts +90 -0
  30. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  32. package/src/runtime/merge-gate.ts +25 -9
  33. package/src/runtime/model/model-fallback.ts +7 -3
  34. package/src/runtime/process-status.ts +16 -1
  35. package/src/runtime/recovery/crash-recovery.ts +16 -0
  36. package/src/runtime/run-tracker.ts +77 -10
  37. package/src/runtime/team-runner.ts +3 -2
  38. package/src/schema/config-schema.ts +1 -1
  39. package/src/state/stores/plan-store.ts +1 -1
  40. package/src/state/stores/state-store.ts +73 -3
  41. package/src/state/types.ts +2 -0
  42. package/src/ui/dock-footer.ts +49 -0
  43. package/src/ui/inline-panel/agent-pane.ts +375 -0
  44. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  45. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  46. package/src/ui/inline-panel/crew-editor.ts +192 -0
  47. package/src/ui/inline-panel/index.ts +290 -0
  48. package/src/ui/inline-panel/panel-rows.ts +37 -0
  49. package/src/ui/inline-panel/panel-selection.ts +157 -0
  50. package/src/ui/inline-panel/panel-store.ts +111 -0
  51. package/src/ui/inline-panel/view-session-store.ts +36 -0
  52. package/src/ui/pi-ui-compat.ts +9 -0
  53. package/src/ui/widget/index.ts +176 -22
  54. package/src/ui/widget/task-list.ts +198 -0
  55. package/src/ui/widget/widget-formatters.ts +240 -4
  56. package/src/ui/widget/widget-renderer.ts +234 -37
  57. package/src/ui/widget/widget-types.ts +11 -0
  58. package/src/utils/child-process-shield.ts +106 -0
  59. package/src/utils/redaction.ts +7 -0
  60. package/src/utils/safe-abort.ts +45 -0
  61. package/src/workflows/discover-workflows.ts +1 -0
  62. package/src/workflows/workflow-config.ts +7 -0
  63. package/workflows/default.workflow.md +36 -26
  64. 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(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor")]), obj.widgetPlacement);
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),
@@ -129,8 +129,15 @@ export const DEFAULT_UI = {
129
129
  refreshMs: 1000,
130
130
  notifierIntervalMs: 5000,
131
131
  widgetDefaultFrameMs: 1000,
132
- widgetPlacement: "aboveEditor" as const,
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,
@@ -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,
@@ -143,8 +143,26 @@ export interface GoalWrapWorkflowConfig {
143
143
  }
144
144
 
145
145
  export interface CrewUiConfig {
146
- widgetPlacement?: "aboveEditor" | "belowEditor";
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", () => setFooter(ctx, undefined));
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
- await notifyCommandResult(ctx, commandText(result));
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
- controller.abort();
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
- if (extensionCtx.hasUI) {
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 { requestRender, setExtensionWidget } from "../../ui/pi-ui-compat.ts";
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
- for (const controller of ctx.foregroundTeamRunControllers.values()) controller.abort();
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
- for (const controller of ctx.foregroundControllers.values()) controller.abort();
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
- ctx.subagentManager.abortAll("Session switching — foreground subagents cancelled.");
225
- terminateActiveChildPiProcesses();
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
- const abort = (): void => controller.abort();
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
- setExtensionWidget(ctx, "pi-crew", undefined, { placement: widgetPlacement });
80
- setExtensionWidget(ctx, "pi-crew-active", undefined, { placement: widgetPlacement });
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 = widgetPlacement;
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.abort();
71
+ if (ctx.signal.aborted) safeAbort(controller, "run-deadline.caller-preaborted");
70
72
  else {
71
73
  const callerSignal = ctx.signal;
72
- const onCallerAbort = () => controller.abort();
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.abort(), deadlineMs);
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.abort();
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
- fgAbortListener = () => fgDeadline.controller.abort();
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