pi-crew 0.10.1 → 0.10.3

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 (127) hide show
  1. package/CHANGELOG.md +347 -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 +2555 -932
  7. package/package.json +2 -1
  8. package/schema.json +503 -94
  9. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  10. package/skills/real-test-pi-crew/SKILL.md +278 -79
  11. package/src/config/config-merge.ts +11 -1
  12. package/src/config/config-validation.ts +47 -2
  13. package/src/config/config.ts +28 -6
  14. package/src/config/defaults.ts +43 -11
  15. package/src/config/env-vars.ts +27 -2
  16. package/src/config/role-tools.ts +4 -2
  17. package/src/config/types.ts +55 -1
  18. package/src/extension/crew-cleanup.ts +13 -0
  19. package/src/extension/crew-vibes/footer.ts +19 -0
  20. package/src/extension/crew-vibes/index.ts +11 -1
  21. package/src/extension/register.ts +8 -0
  22. package/src/extension/registration/command-registration.ts +1 -0
  23. package/src/extension/registration/commands/run.ts +15 -1
  24. package/src/extension/registration/commands/shared.ts +8 -0
  25. package/src/extension/registration/foreground-run-controller.ts +10 -2
  26. package/src/extension/registration/lifecycle-handlers.ts +92 -11
  27. package/src/extension/registration/runtime-cleanup.ts +23 -5
  28. package/src/extension/registration/team-tool.ts +58 -6
  29. package/src/extension/registration/ui.ts +5 -4
  30. package/src/extension/team-tool/doctor.ts +364 -7
  31. package/src/extension/team-tool/handle-settings.ts +19 -0
  32. package/src/extension/team-tool/inspect.ts +10 -2
  33. package/src/extension/team-tool/run-deadline.ts +20 -3
  34. package/src/extension/team-tool/run.ts +26 -2
  35. package/src/extension/team-tool/status.ts +7 -0
  36. package/src/extension/team-tool.ts +35 -2
  37. package/src/hooks/registry.ts +59 -56
  38. package/src/prompt/inbox-poll.ts +90 -0
  39. package/src/prompt/message-tool.ts +166 -0
  40. package/src/prompt/prompt-runtime.ts +201 -18
  41. package/src/prompt/surface-worker.ts +720 -0
  42. package/src/prompt/worker-events-channel.ts +49 -3
  43. package/src/runtime/async-runner.ts +29 -1
  44. package/src/runtime/background-runner.ts +13 -7
  45. package/src/runtime/broker/broker-issuer.ts +27 -2
  46. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  47. package/src/runtime/broker/crew-broker.ts +261 -41
  48. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  49. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  50. package/src/runtime/child-pi/child-pi-streams.ts +30 -2
  51. package/src/runtime/child-pi/child-pi.ts +353 -5
  52. package/src/runtime/crew-agent-records.ts +13 -1
  53. package/src/runtime/detached-run-results.ts +90 -0
  54. package/src/runtime/dispatch-batch.ts +12 -1
  55. package/src/runtime/event-log-tail-source.ts +374 -0
  56. package/src/runtime/finalize-run.ts +4 -0
  57. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  58. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  59. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  60. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  61. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  62. package/src/runtime/manifest-cache.ts +128 -17
  63. package/src/runtime/merge-gate.ts +25 -9
  64. package/src/runtime/model/model-fallback.ts +7 -3
  65. package/src/runtime/model/pi-args.ts +54 -65
  66. package/src/runtime/output/sidechain-output.ts +61 -6
  67. package/src/runtime/process/proc-stat.ts +46 -0
  68. package/src/runtime/process/zombie-scanner.ts +32 -19
  69. package/src/runtime/process-status.ts +16 -1
  70. package/src/runtime/recovery/crash-recovery.ts +16 -0
  71. package/src/runtime/run-tracker.ts +77 -10
  72. package/src/runtime/spawn-policy.ts +27 -41
  73. package/src/runtime/surface/degrade.ts +776 -0
  74. package/src/runtime/surface/herdr-provider.ts +546 -0
  75. package/src/runtime/surface/launch-script.ts +172 -0
  76. package/src/runtime/surface/resolve-surface.ts +274 -0
  77. package/src/runtime/surface/surface-provider.ts +129 -0
  78. package/src/runtime/surface/surface-spawn.ts +475 -0
  79. package/src/runtime/surface/tmux-provider.ts +400 -0
  80. package/src/runtime/task-runner/child-executor.ts +47 -0
  81. package/src/runtime/task-runner/post-execution.ts +57 -2
  82. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  83. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  84. package/src/runtime/task-runner/state-helpers.ts +54 -30
  85. package/src/runtime/task-runner.ts +4 -2
  86. package/src/runtime/team-runner.ts +104 -2
  87. package/src/schema/config-schema.ts +25 -1
  88. package/src/state/atomic-write.ts +219 -40
  89. package/src/state/coordination/locks.ts +7 -5
  90. package/src/state/coordination/mailbox.ts +56 -10
  91. package/src/state/event-log/cursor.ts +413 -23
  92. package/src/state/event-log/event-log.ts +120 -113
  93. package/src/state/event-log/sequence-cache.ts +21 -3
  94. package/src/state/stores/plan-store.ts +1 -1
  95. package/src/state/stores/state-store.ts +171 -9
  96. package/src/state/types.ts +53 -0
  97. package/src/ui/dock-footer.ts +49 -0
  98. package/src/ui/inline-panel/agent-pane.ts +378 -0
  99. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  100. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  101. package/src/ui/inline-panel/crew-editor.ts +192 -0
  102. package/src/ui/inline-panel/index.ts +290 -0
  103. package/src/ui/inline-panel/panel-rows.ts +37 -0
  104. package/src/ui/inline-panel/panel-selection.ts +157 -0
  105. package/src/ui/inline-panel/panel-store.ts +111 -0
  106. package/src/ui/inline-panel/view-session-store.ts +36 -0
  107. package/src/ui/pi-ui-compat.ts +9 -0
  108. package/src/ui/render-diff.ts +16 -8
  109. package/src/ui/run-dashboard.ts +87 -42
  110. package/src/ui/run-event-bus.ts +10 -1
  111. package/src/ui/run-snapshot-cache.ts +83 -35
  112. package/src/ui/transcript-cache.ts +101 -13
  113. package/src/ui/transcript-viewer.ts +92 -24
  114. package/src/ui/widget/index.ts +203 -25
  115. package/src/ui/widget/task-list.ts +198 -0
  116. package/src/ui/widget/widget-formatters.ts +240 -4
  117. package/src/ui/widget/widget-renderer.ts +234 -37
  118. package/src/ui/widget/widget-types.ts +11 -0
  119. package/src/utils/child-process-shield.ts +106 -0
  120. package/src/utils/redaction.ts +7 -0
  121. package/src/utils/safe-abort.ts +45 -0
  122. package/src/utils/visual.ts +43 -0
  123. package/src/workflows/discover-workflows.ts +1 -0
  124. package/src/workflows/workflow-config.ts +7 -0
  125. package/src/worktree/worktree-manager.ts +65 -4
  126. package/workflows/default.workflow.md +36 -26
  127. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -31,7 +31,8 @@ export function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTea
31
31
  }
32
32
  if (base.nesting || override.nesting) {
33
33
  // ADR-5: per-key user-wins deep merge — a partial user-side nesting block
34
- // (e.g. just maxSlots) must not erase DEFAULT_NESTING.enabled=false.
34
+ // (e.g. just maxSlots) must not erase DEFAULT_NESTING.enabled (true since
35
+ // the D8 flip; explicit user false still wins as the kill switch).
35
36
  merged.nesting = {
36
37
  ...(base.nesting ?? {}),
37
38
  ...withoutUndefined((override.nesting ?? {}) as Record<string, unknown>),
@@ -59,6 +60,15 @@ export function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTea
59
60
  ...withoutUndefined((override.runtime?.modelFallback ?? {}) as Record<string, unknown>),
60
61
  };
61
62
  }
63
+ // Spec v0.7 §8.1 (Task 6): same per-key treatment for the surface
64
+ // block — a user surface with only visibleAgents must not wholesale-
65
+ // erase a project surface.mode (the WP-2/R2 bug class).
66
+ if (base.runtime?.surface || override.runtime?.surface) {
67
+ merged.runtime.surface = {
68
+ ...(base.runtime?.surface ?? {}),
69
+ ...withoutUndefined((override.runtime?.surface ?? {}) as Record<string, unknown>),
70
+ };
71
+ }
62
72
  }
63
73
  if (base.control || override.control) {
64
74
  merged.control = {
@@ -23,6 +23,7 @@ import type {
23
23
  CrewUiConfig,
24
24
  CrewWorktreeConfig,
25
25
  GoalWrapWorkflowConfig,
26
+ PersistenceConfig,
26
27
  PiTeamsAutonomousConfig,
27
28
  PiTeamsAutonomyProfile,
28
29
  PiTeamsConfig,
@@ -155,7 +156,13 @@ function parseProfile(value: unknown): PiTeamsAutonomyProfile | undefined {
155
156
 
156
157
  function parseStringList(value: unknown): string[] | undefined {
157
158
  const items = parseWithSchema(Type.Array(Type.String()), value);
158
- if (!items || items.length === 0) return undefined;
159
+ if (!items) return undefined;
160
+ // F3 (real-test 2026-08-30-post-tab-layout-live, Finding 3): an explicit
161
+ // empty array is a VALUE ("nobody"/"none"), not "unset" — dropping it here
162
+ // made `team-settings set <key> []` a no-op that silently kept the previous
163
+ // list on disk. Whitespace-only entries below still collapse to undefined
164
+ // (unchanged legacy behavior).
165
+ if (items.length === 0) return [];
159
166
  const normalized = items.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
160
167
  return normalized.length > 0 ? normalized : undefined;
161
168
  }
@@ -281,6 +288,24 @@ function parseIsolationPolicy(value: unknown): CrewRuntimeConfig["isolationPolic
281
288
  };
282
289
  }
283
290
 
291
+ /** Mux-surface policy (spec v0.7 §8.1) — mirrors the isolationPolicy parse
292
+ * shape: unknown literals are dropped field-wise (never thrown), and an
293
+ * all-invalid block collapses to undefined. */
294
+ function parseSurfacePolicy(value: unknown): CrewRuntimeConfig["surface"] | undefined {
295
+ const obj = asRecord(value);
296
+ if (!obj) return undefined;
297
+ const mode = parseWithSchema(
298
+ Type.Union([Type.Literal("auto"), Type.Literal("tmux"), Type.Literal("herdr"), Type.Literal("off")]),
299
+ obj.mode,
300
+ );
301
+ const visibleAgents = parseStringList(obj.visibleAgents);
302
+ if (mode === undefined && visibleAgents === undefined) return undefined;
303
+ return {
304
+ ...(mode !== undefined ? { mode } : {}),
305
+ ...(visibleAgents !== undefined ? { visibleAgents } : {}),
306
+ };
307
+ }
308
+
284
309
  /**
285
310
  * F19-1 (Round 19 parity): runtime.modelFallback was declared in types.ts and the
286
311
  * schema (PiTeamsModelFallbackConfigSchema) but parseRuntimeConfig never emitted
@@ -338,6 +363,7 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
338
363
  excludeContextBash: parseWithSchema(Type.Boolean(), obj.excludeContextBash),
339
364
  agentExtensions: parseStringList(obj.agentExtensions),
340
365
  isolationPolicy: parseIsolationPolicy(obj.isolationPolicy),
366
+ surface: parseSurfacePolicy(obj.surface),
341
367
  modelFallback: parseModelFallbackConfig(obj.modelFallback),
342
368
  };
343
369
  return Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
@@ -384,6 +410,18 @@ function parseBrokerConfig(value: unknown): CrewBrokerConfig | undefined {
384
410
  return Object.values(broker).some((entry) => entry !== undefined) ? broker : undefined;
385
411
  }
386
412
 
413
+ /** PERF round 2, Task 3: state-layer persistence parser. Opt-in only;
414
+ * the default (false) is layered in loadConfig via DEFAULT_PERSISTENCE,
415
+ * so a missing block here means "not set", never "false" by default. */
416
+ function parsePersistenceConfig(value: unknown): PersistenceConfig | undefined {
417
+ const obj = asRecord(value);
418
+ if (!obj) return undefined;
419
+ const persistence: PersistenceConfig = {
420
+ skipTasksFsync: parseWithSchema(Type.Boolean(), obj.skipTasksFsync),
421
+ };
422
+ return Object.values(persistence).some((entry) => entry !== undefined) ? persistence : undefined;
423
+ }
424
+
387
425
  function parseWorktreeConfig(value: unknown): CrewWorktreeConfig | undefined {
388
426
  const obj = asRecord(value);
389
427
  if (!obj) return undefined;
@@ -456,11 +494,17 @@ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
456
494
  function parseUiConfig(value: unknown): CrewUiConfig | undefined {
457
495
  const obj = asRecord(value);
458
496
  if (!obj) return undefined;
459
- const rawWidgetPlacement = parseWithSchema(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor")]), obj.widgetPlacement);
497
+ const rawWidgetPlacement = parseWithSchema(
498
+ Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor"), Type.Literal("bottom")]),
499
+ obj.widgetPlacement,
500
+ );
460
501
  const rawDashboardPlacement = parseWithSchema(Type.Union([Type.Literal("center"), Type.Literal("right")]), obj.dashboardPlacement);
502
+ const rawRowStyle = parseWithSchema(Type.Union([Type.Literal("compact"), Type.Literal("detailed")]), obj.widgetRowStyle);
461
503
  const ui: CrewUiConfig = {
462
504
  widgetPlacement: rawWidgetPlacement,
463
505
  widgetMaxLines: parsePositiveInteger(obj.widgetMaxLines, 50),
506
+ widgetRowStyle: rawRowStyle,
507
+ inlinePanel: parseWithSchema(Type.Boolean(), obj.inlinePanel),
464
508
  powerbar: parseWithSchema(Type.Boolean(), obj.powerbar),
465
509
  dashboardPlacement: rawDashboardPlacement,
466
510
  dashboardWidth: parseIntegerInRange(obj.dashboardWidth, 32, 120),
@@ -670,6 +714,7 @@ export function parseConfig(raw: unknown): PiTeamsConfig {
670
714
  otlp: parseOtlpConfig(obj.otlp),
671
715
  ui: parseUiConfig(obj.ui),
672
716
  broker: parseBrokerConfig(obj.broker),
717
+ persistence: parsePersistenceConfig(obj.persistence),
673
718
  };
674
719
  }
675
720
 
@@ -7,8 +7,8 @@ import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
8
8
  import { mergeConfig } from "./config-merge.ts";
9
9
  import { parseConfig, parseConfigWithWarnings } from "./config-validation.ts";
10
- import { DEFAULT_BROKER, DEFAULT_NESTING, resolveBrokerEnvOverride } from "./defaults.ts";
11
- import { getCrewEnv } from "./env-vars.ts";
10
+ import { DEFAULT_BROKER, DEFAULT_NESTING, DEFAULT_PERSISTENCE, resolveBrokerEnvOverride } from "./defaults.ts";
11
+ import { getCrewEnv, getCrewEnvBool } from "./env-vars.ts";
12
12
  import { sanitizeProjectConfig } from "./sanitize-project-config.ts";
13
13
 
14
14
  // 2.9: interface types extracted to ./types.ts; re-export for back-compat.
@@ -45,6 +45,7 @@ import type {
45
45
  CrewBrokerConfig,
46
46
  CrewNestingConfig,
47
47
  LoadedPiTeamsConfig,
48
+ PersistenceConfig,
48
49
  PiTeamsAutonomousConfig,
49
50
  PiTeamsConfig,
50
51
  SavedPiTeamsConfig,
@@ -225,8 +226,9 @@ export function projectPiCrewJsonPath(cwd: string): string {
225
226
  * config block, or default.
226
227
  */
227
228
 
228
- /** ADR-5 §10: layer DEFAULT_NESTING under any user-set keys — the
229
- * fail-closed enabled=false default must hold when no nesting block exists. */
229
+ /** ADR-5 §10: layer DEFAULT_NESTING under any user-set keys — the default-on
230
+ * enabled=true (D8 flip) must hold when no nesting block exists, and an
231
+ * explicit user `enabled: false` still wins as the kill switch. */
230
232
  function applyNestingDefaults(parsed: CrewNestingConfig | undefined): CrewNestingConfig {
231
233
  return { ...DEFAULT_NESTING, ...parsed };
232
234
  }
@@ -235,6 +237,21 @@ function applyBrokerEnvOverrideAndDefaults(parsed: CrewBrokerConfig | undefined)
235
237
  return { ...DEFAULT_BROKER, ...envOverridden };
236
238
  }
237
239
 
240
+ /**
241
+ * Resolve the persistence section (perf round 2, Task 3).
242
+ * Precedence: env `PI_CREW_PERSISTENCE_SKIP_TASKS_FSYNC` ("1"/"true") BEATS a
243
+ * config `persistence.skipTasksFsync`; config beats the DEFAULT_PERSISTENCE
244
+ * default (false). Mirrors resolveBrokerEnvOverride's env-beats-config shape.
245
+ */
246
+ function resolvePersistenceEnvOverrideAndDefaults(parsed: PersistenceConfig | undefined): PersistenceConfig {
247
+ const envOverride = getCrewEnvBool("PI_CREW_PERSISTENCE_SKIP_TASKS_FSYNC");
248
+ return {
249
+ ...DEFAULT_PERSISTENCE,
250
+ ...parsed,
251
+ ...(envOverride === undefined ? {} : { skipTasksFsync: envOverride }),
252
+ };
253
+ }
254
+
238
255
  function unsetPath(record: Record<string, unknown>, dottedPath: string): void {
239
256
  const parts = dottedPath.split(".").filter(Boolean);
240
257
  if (parts.length === 0) return;
@@ -377,15 +394,20 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
377
394
  // the enabled flag even when no broker block is configured.
378
395
  broker: applyBrokerEnvOverrideAndDefaults(config.broker),
379
396
  nesting: applyNestingDefaults(config.nesting),
397
+ // PERF round 2, Task 3: env beats config, config beats the default.
398
+ persistence: resolvePersistenceEnvOverrideAndDefaults(config.persistence),
380
399
  },
381
400
  warnings: warnings.length > 0 ? warnings : undefined,
382
401
  };
402
+ // PERF (2026-08-24): readCacheMtimes stats up to 4 files per call and ran
403
+ // twice back-to-back here (guard + store). Compute once.
404
+ const storeMtimes = readCacheMtimes(cacheParts);
383
405
  // Only cache when at least one of the watched paths exists — this avoids
384
406
  // pinning stale empty results when a user later creates one of these files
385
407
  // in the cache window. mtime stat below picks up the new file (it appears
386
408
  // in currentMtimes but not in cached.mtimes) and triggers a re-parse.
387
- if (Object.keys(readCacheMtimes(cacheParts)).length > 0) {
388
- setConfigCache(cacheKey, result, readCacheMtimes(cacheParts));
409
+ if (Object.keys(storeMtimes).length > 0) {
410
+ setConfigCache(cacheKey, result, storeMtimes);
389
411
  }
390
412
  return result;
391
413
  }
@@ -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,
@@ -187,24 +194,49 @@ export const DEFAULT_BROKER = {
187
194
  maxFrameBytes: 262144,
188
195
  outboundQueueCap: 256,
189
196
  /** WP-2/R2 (ADR-0 docs/decisions/2026-08-17-waiting-producer-ask item 7):
190
- * capability gate for the broker's `wait.*` methods. DEFAULT FALSE —
191
- * fail-closed until WP-2 completes, then flipped to true. While false,
197
+ * capability gate for the broker's `wait.*` methods. FLIPPED TO TRUE
198
+ * (2026-08-26): WP-2 completed and its battery landed, so the ADR-0
199
+ * "then flipped to true" step is now done — ask works out of the box.
200
+ * (Before this flip, every production ask was rejected policy-disabled
201
+ * whenever no user config existed — the feature slept silently.) Set
202
+ * `broker.waitMethodsEnabled: false` to re-close. While false,
192
203
  * wait.request/wait.resolve are rejected with a policy-disabled error
193
204
  * AND a policy.action event in events.jsonl (never silent). */
194
- waitMethodsEnabled: false,
205
+ waitMethodsEnabled: true,
195
206
  } as const;
196
207
 
197
208
  /**
198
209
  * Governed-nesting defaults (ADR-5 docs/decisions/2026-08-17-governed-nesting.md §10).
199
- * `enabled` is DEFAULT FALSE — fail-closed until WP-5 completes (B3 battery +
200
- * security sign-off), then flipped to true. While false, the `delegate`
201
- * surface rejects with a structured policy message and a `delegate.rejected`
202
- * event in events.jsonl (never silent). `maxSlots` default is computed at the
203
- * spawn policy from the global worker semaphore: max(1, floor(globalSem/2)).
210
+ * `enabled` is DEFAULT TRUE since D8 (spec v0.7, 2026-08-26): the WP-5
211
+ * completion gate (B3 battery + security sign-off) passed and Task 3 opened
212
+ * the `delegate` role gate for every role, so nested spawning — child creates
213
+ * child — is on out of the box. The security border moved to the depth cap
214
+ * (maxDepth 4) + the nested-slot budget; `nesting.enabled: false` in USER
215
+ * config (sensitive — project config cannot flip it) closes the surface, and
216
+ * while closed `delegate` rejects with a structured policy message plus a
217
+ * `delegate.rejected` event in events.jsonl (never silent). `maxSlots` default
218
+ * is computed at the spawn policy from the global worker semaphore:
219
+ * max(1, floor(globalSem/2)).
204
220
  */
205
221
  export const DEFAULT_NESTING = {
206
- enabled: false,
207
- maxDepth: 2,
222
+ enabled: true,
223
+ // D8 (spec v0.7): nested spawning open — child creates child. Kept in
224
+ // lockstep with DEFAULT_MAX_CREW_DEPTH (pi-args) so the broker-admission
225
+ // depth gate and the spawn-side cap can never disagree.
226
+ maxDepth: 4,
227
+ } as const;
228
+
229
+ /**
230
+ * State-layer persistence defaults (perf round 2, Task 3).
231
+ * `skipTasksFsync` is DEFAULT FALSE — opt-in only. Because the flag is
232
+ * consumed at the save site in the state layer, `loadConfig` must READ it
233
+ * (not default-on-borrow): the flag affects the cost of every non-terminal
234
+ * tasks checkpoint, so it must never silently flip from "fsync" to "no
235
+ * fsync" due to a config default alone. Env `PI_CREW_PERSISTENCE_SKIP_TASKS_FSYNC`
236
+ * ("1"/"true") beats config; config beats this default.
237
+ */
238
+ export const DEFAULT_PERSISTENCE = {
239
+ skipTasksFsync: false,
208
240
  } as const;
209
241
 
210
242
  /**
@@ -270,8 +270,8 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
270
270
  PI_CREW_INTERRUPT_GUARD_INTERVAL_MS: {
271
271
  name: "PI_CREW_INTERRUPT_GUARD_INTERVAL_MS",
272
272
  parser: "int",
273
- default: 250,
274
- doc: "interrupt-guard poll interval; default 250ms (background-runner.ts:217)",
273
+ default: 1000,
274
+ doc: "interrupt-guard poll interval; default 1000ms (background-runner.ts:217)",
275
275
  },
276
276
  PI_CREW_MAX_RUN_MS: {
277
277
  name: "PI_CREW_MAX_RUN_MS",
@@ -323,6 +323,11 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
323
323
  name: "PI_CREW_PLAN_UI",
324
324
  doc: "'1' enables the Plan dashboard pane (7) + plans snapshot slice (WP-7/R7)",
325
325
  },
326
+ PI_CREW_PERSISTENCE_SKIP_TASKS_FSYNC: {
327
+ name: "PI_CREW_PERSISTENCE_SKIP_TASKS_FSYNC",
328
+ parser: "boolean",
329
+ doc: "'1'/'true' writes non-terminal tasks checkpoints best-effort (no fsync; 50ms coalesce kept, only durability skipped); terminal transitions stay full (defaults.ts, config.ts persistence.skipTasksFsync)",
330
+ },
326
331
  PI_CREW_TRUST_PROJECT_DWF: {
327
332
  name: "PI_CREW_TRUST_PROJECT_DWF",
328
333
  doc: "'1' allows project-sourced .dwf.ts workflows (dynamic-workflow-runner.ts:154)",
@@ -390,6 +395,10 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
390
395
  name: "PI_CREW_ASK_ENABLED",
391
396
  doc: "'1' enables the worker-side ask tool — dormant-until-env gate (written UNCONDITIONALLY by child-pi-spawn.ts, read by prompt-runtime.ts per ADR-0 WP-2 item 2)",
392
397
  },
398
+ PI_CREW_MSG_ENABLED: {
399
+ name: "PI_CREW_MSG_ENABLED",
400
+ doc: "'1' enables the worker-side message tool (D9/§15.2) — dormant-until-env gate (written UNCONDITIONALLY by child-pi-spawn.ts, read by prompt-runtime.ts)",
401
+ },
393
402
  PI_CREW_STATE_ROOT: {
394
403
  name: "PI_CREW_STATE_ROOT",
395
404
  doc: "run stateRoot for the ask-tool mailbox poll (<stateRoot>/mailbox; written by child-pi-spawn.ts, read by prompt-runtime.ts per ADR-0 WP-2 item 2)",
@@ -398,6 +407,22 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
398
407
  name: "PI_CREW_BROKER_RUN_ID",
399
408
  doc: "broker run id; aliases PI_CREW_RUN_ID (scratchpad-lifecycle.ts:81, crew-broker-child.ts:51)",
400
409
  },
410
+ PI_CREW_AGENT_EVENTS_PATH: {
411
+ name: "PI_CREW_AGENT_EVENTS_PATH",
412
+ doc: "per-agent events log (<stateRoot>/agents/<taskId>/events.jsonl) written by the surface worker recorder (surface-worker.ts; derived by prepareSurfaceSpawn)",
413
+ },
414
+ PI_CREW_AUTO_EXIT: {
415
+ name: "PI_CREW_AUTO_EXIT",
416
+ doc: "'1' → the worker shuts its session down after the final settled turn — spec §5.2 D7 (written by prepareSurfaceSpawn, read by surface-worker.ts)",
417
+ },
418
+ PI_CREW_SURFACE: {
419
+ name: "PI_CREW_SURFACE",
420
+ doc: "surface provider kind for this worker ('tmux'|'herdr') — arms the worker-side recorder/parent-guard (written by prepareSurfaceSpawn.ts:214, read by surface-worker.ts)",
421
+ },
422
+ PI_CREW_PARENT_START_TIME: {
423
+ name: "PI_CREW_PARENT_START_TIME",
424
+ doc: "parent starttime ticks (/proc/<pid>/stat field 22) captured at spawn — pid-reuse-safe parent-guard comparison (written by prepareSurfaceSpawn.ts:223, read by surface-worker.ts)",
425
+ },
401
426
  PI_CREW_ARTIFACTS_ROOT: {
402
427
  name: "PI_CREW_ARTIFACTS_ROOT",
403
428
  doc: "artifacts root for scratchpad containment (scratchpad-lifecycle.ts:228/281)",
@@ -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,
@@ -85,6 +85,21 @@ export interface CrewRuntimeConfig {
85
85
  };
86
86
  /** Mark certain bash commands as excludeFromContext to reduce context tokens. Default: false */
87
87
  excludeContextBash?: boolean;
88
+ /**
89
+ * Mux-surface policy (mux-surface spec v0.7 §8.1): WHERE worker processes
90
+ * live — a pane in tmux/herdr or headless child processes. Surface only
91
+ * picks the process home; scheduler, broker, and state-on-disk are
92
+ * unaffected. Not sensitive — any config tier may set it.
93
+ */
94
+ surface?: {
95
+ /** auto = detect tmux/herdr and use panes when present, else headless.
96
+ * "tmux"/"herdr" force a backend (detect fail → headless + warning
97
+ * event, never a throw). "off" disables panes entirely. Default: "auto". */
98
+ mode?: "auto" | "tmux" | "herdr" | "off";
99
+ /** Exact-match agent/role names that get a surface pane; ["*"] = all.
100
+ * Default: [] in A1 (surface visible to nobody until opted in). */
101
+ visibleAgents?: string[];
102
+ };
88
103
  /** Subagent model fallback policy: auto-tail ordering, cap, credential filtering, default model. */
89
104
  modelFallback?: CrewModelFallbackConfig;
90
105
  }
@@ -143,8 +158,26 @@ export interface GoalWrapWorkflowConfig {
143
158
  }
144
159
 
145
160
  export interface CrewUiConfig {
146
- widgetPlacement?: "aboveEditor" | "belowEditor";
161
+ /**
162
+ * Where the crew dock (agents list) renders:
163
+ * - `aboveEditor` / `belowEditor`: pi's widget slots around the prompt;
164
+ * - `bottom`: inside the crew-vibes footer, BELOW the quota/meter lines
165
+ * (the very bottom of the screen). Falls back to `belowEditor` when
166
+ * crew-vibes is not active (no footer sink).
167
+ */
168
+ widgetPlacement?: "aboveEditor" | "belowEditor" | "bottom";
147
169
  widgetMaxLines?: number;
170
+ /**
171
+ * Per-agent row layout in the widget. `compact` is one width-budgeted line
172
+ * per agent; `detailed` keeps the two-line tree (name row + activity row).
173
+ */
174
+ widgetRowStyle?: "compact" | "detailed";
175
+ /**
176
+ * Keyboard-navigable agent rows under the prompt (`↓` from an empty prompt).
177
+ * Requires owning pi's editor component, so it yields to any other extension
178
+ * that already installed a custom editor.
179
+ */
180
+ inlinePanel?: boolean;
148
181
  powerbar?: boolean;
149
182
  dashboardPlacement?: "center" | "right";
150
183
  dashboardWidth?: number;
@@ -295,6 +328,27 @@ export interface PiTeamsConfig {
295
328
  * `delegate.rejected` event (never silent).
296
329
  */
297
330
  nesting?: CrewNestingConfig;
331
+ /** State-layer persistence knobs (perf round 2, Task 3). Opt-in only. */
332
+ persistence?: PersistenceConfig;
333
+ }
334
+
335
+ /** State-layer persistence knobs (perf round 2, Task 3). */
336
+ export interface PersistenceConfig {
337
+ /**
338
+ * Opt-in (default `false`): write non-terminal tasks checkpoints with
339
+ * `durability:"best-effort"` (no fsync) while KEEPING the 50ms coalesced
340
+ * write grouping — only the durability of the flush changes, not its
341
+ * timing. tasks.json is fully reconstructible from the fsync'd event log,
342
+ * so a crash loses at most the tail of an in-flight checkpoint and
343
+ * recovers from events.jsonl. Terminal task transitions (completed/failed/
344
+ * cancelled/needs_attention/skipped) ALWAYS stay full-durability
345
+ * regardless of this flag.
346
+ * Set via env `PI_CREW_PERSISTENCE_SKIP_TASKS_FSYNC` (`"1"`/`"true"`) or
347
+ * config `persistence.skipTasksFsync`. Env beats config; default false.
348
+ * @see src/state/stores/state-store.ts saveRunTasksCoalesced
349
+ * @see src/runtime/task-runner/state-helpers.ts persistSingleTaskUpdate
350
+ */
351
+ skipTasksFsync?: boolean;
298
352
  }
299
353
 
300
354
  /** Governed-nesting config (ADR-5). */
@@ -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();