pi-crew 0.10.2 → 0.10.4

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 (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -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;
@@ -676,6 +714,7 @@ export function parseConfig(raw: unknown): PiTeamsConfig {
676
714
  otlp: parseOtlpConfig(obj.otlp),
677
715
  ui: parseUiConfig(obj.ui),
678
716
  broker: parseBrokerConfig(obj.broker),
717
+ persistence: parsePersistenceConfig(obj.persistence),
679
718
  };
680
719
  }
681
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
  }
@@ -194,24 +194,49 @@ export const DEFAULT_BROKER = {
194
194
  maxFrameBytes: 262144,
195
195
  outboundQueueCap: 256,
196
196
  /** WP-2/R2 (ADR-0 docs/decisions/2026-08-17-waiting-producer-ask item 7):
197
- * capability gate for the broker's `wait.*` methods. DEFAULT FALSE —
198
- * 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,
199
203
  * wait.request/wait.resolve are rejected with a policy-disabled error
200
204
  * AND a policy.action event in events.jsonl (never silent). */
201
- waitMethodsEnabled: false,
205
+ waitMethodsEnabled: true,
202
206
  } as const;
203
207
 
204
208
  /**
205
209
  * Governed-nesting defaults (ADR-5 docs/decisions/2026-08-17-governed-nesting.md §10).
206
- * `enabled` is DEFAULT FALSE — fail-closed until WP-5 completes (B3 battery +
207
- * security sign-off), then flipped to true. While false, the `delegate`
208
- * surface rejects with a structured policy message and a `delegate.rejected`
209
- * event in events.jsonl (never silent). `maxSlots` default is computed at the
210
- * 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)).
211
220
  */
212
221
  export const DEFAULT_NESTING = {
213
- enabled: false,
214
- 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,
215
240
  } as const;
216
241
 
217
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)",
@@ -0,0 +1,113 @@
1
+ /**
2
+ * migration-validator.ts — WI-5.6 (M5 spec §5).
3
+ *
4
+ * Validates a parsed config against deprecated/removed env keys and
5
+ * returns an advisory list (not a failure). Per spec acceptance:
6
+ * "Migration validator: test case cũ config + key deprecated →
7
+ * warning không fail."
8
+ *
9
+ * Key principles (additive-only, READ-ONLY — the validator never mutates
10
+ * env or config):
11
+ * - Deprecated: emit warning (severity "deprecated"), keep the value,
12
+ * do not fail.
13
+ * - Removed/dead/reverted: emit warning (severity "removed"), do not
14
+ * fail, do NOT delete the key (cleanup is a separate concern).
15
+ * - Keys not in the registry are not scanned (registry-driven scan).
16
+ *
17
+ * Returns an advisory list; never throws.
18
+ */
19
+
20
+ import { CREW_ENV_VARS } from "./env-vars.ts";
21
+
22
+ export interface ValidationWarning {
23
+ scope: "env-var" | "config-key";
24
+ name: string;
25
+ severity: "deprecated" | "removed";
26
+ message: string;
27
+ /** Optional policy note from the registry entry. */
28
+ policy?: string;
29
+ }
30
+
31
+ export interface EnvValidationResult {
32
+ warnings: ValidationWarning[];
33
+ /** Fast-bool for callers that don't need detail. */
34
+ hasWarnings: boolean;
35
+ }
36
+
37
+ /** Validate `process.env` against the registry. Returns warnings; never
38
+ * throws. */
39
+ export function validateEnv(env: NodeJS.ProcessEnv = process.env): EnvValidationResult {
40
+ const warnings: ValidationWarning[] = [];
41
+ if (!env || typeof env !== "object") return { warnings, hasWarnings: false };
42
+ for (const [name, spec] of Object.entries(CREW_ENV_VARS)) {
43
+ let value: string | undefined;
44
+ try {
45
+ value = (env as Record<string, string | undefined>)[name];
46
+ } catch {
47
+ value = undefined;
48
+ }
49
+ if (value === undefined) continue;
50
+ if (spec.deprecated !== undefined) {
51
+ // Distinguish DEAD/REMOVED vs DEPRECATED-but-working.
52
+ const lower = spec.deprecated.toLowerCase();
53
+ if (lower === "dead" || lower === "removed" || lower.startsWith("reverted")) {
54
+ warnings.push({
55
+ scope: "env-var",
56
+ name,
57
+ severity: "removed",
58
+ message: `${name} is ${spec.deprecated}; setting it has no effect`,
59
+ policy: spec.deprecated,
60
+ });
61
+ // Note: we DO NOT delete env[name] here; that's a separate
62
+ // cleanup concern. The validator is read-only.
63
+ } else {
64
+ warnings.push({
65
+ scope: "env-var",
66
+ name,
67
+ severity: "deprecated",
68
+ message: `${name} is deprecated (${spec.deprecated}); prefer the canonical name`,
69
+ policy: spec.deprecated,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ return { warnings, hasWarnings: warnings.length > 0 };
75
+ }
76
+
77
+ /** Validate a parsed config object against the env-var registry.
78
+ * Useful for: a config file that hardcodes a removed key. */
79
+ export function validateConfigAgainstEnvRegistry(config: Record<string, unknown>): EnvValidationResult {
80
+ const warnings: ValidationWarning[] = [];
81
+ if (!config || typeof config !== "object") return { warnings, hasWarnings: false };
82
+ for (const [name, spec] of Object.entries(CREW_ENV_VARS)) {
83
+ let present = false;
84
+ try {
85
+ present = name in config;
86
+ } catch {
87
+ present = false;
88
+ }
89
+ if (present) {
90
+ if (spec.deprecated !== undefined) {
91
+ const lower = spec.deprecated.toLowerCase();
92
+ if (lower === "dead" || lower === "removed" || lower.startsWith("reverted")) {
93
+ warnings.push({
94
+ scope: "config-key",
95
+ name,
96
+ severity: "removed",
97
+ message: `${name} config key is ${spec.deprecated}`,
98
+ policy: spec.deprecated,
99
+ });
100
+ } else {
101
+ warnings.push({
102
+ scope: "config-key",
103
+ name,
104
+ severity: "deprecated",
105
+ message: `${name} config key is deprecated (${spec.deprecated})`,
106
+ policy: spec.deprecated,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return { warnings, hasWarnings: warnings.length > 0 };
113
+ }
@@ -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
  }
@@ -313,6 +328,27 @@ export interface PiTeamsConfig {
313
328
  * `delegate.rejected` event (never silent).
314
329
  */
315
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;
316
352
  }
317
353
 
318
354
  /** Governed-nesting config (ADR-5). */
@@ -5,17 +5,13 @@ import { withHmacVerification } from "./rpc-hmac.ts";
5
5
  // Lazy-loaded to avoid pulling team-tool.ts (and its entire runtime chain) into module load.
6
6
  import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
7
7
 
8
- let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
9
8
  async function handleTeamTool(
10
9
  params: Parameters<typeof HandleTeamToolFn>[0],
11
10
  ctx: Parameters<typeof HandleTeamToolFn>[1],
12
11
  ): Promise<Awaited<ReturnType<typeof HandleTeamToolFn>>> {
13
- if (!_cachedHandleTeamTool) {
14
- // LAZY: avoid pulling team-tool.ts (and its entire runtime chain) into module load.
15
- const mod = await import("./team-tool.ts");
16
- _cachedHandleTeamTool = mod.handleTeamTool;
17
- }
18
- return _cachedHandleTeamTool(params, ctx);
12
+ // LAZY: avoid pulling team-tool.ts (and its entire runtime chain) into module load.
13
+ const mod = await import("./team-tool.ts");
14
+ return mod.handleTeamTool(params, ctx);
19
15
  }
20
16
 
21
17
  import { parseLiveControlRealtimeMessage, publishLiveControlRealtime } from "../runtime/live-session/live-control-realtime.ts";
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
20
  import { loadConfig } from "../config/config.ts";
21
+ import { validateEnv } from "../config/migration-validator.ts";
21
22
  import { startRuntimeWarmup } from "../runtime/model/runtime-warmup.ts";
22
23
  import { primePeerDep } from "../runtime/peer-dep.ts";
23
24
  import { deployBundledThemes } from "../ui/deploy-bundled-themes.ts";
@@ -60,6 +61,18 @@ export function registerPiTeams(pi: ExtensionAPI): void {
60
61
  // full crash chain — /crew-view mid-run crash).
61
62
  installChildProcessAbortShield();
62
63
 
64
+ // WI-5.6 wiring (review remediation 2026-09-10): surface deprecated /
65
+ // removed / reverted PI_CREW_* env keys as ONE consolidated startup
66
+ // warning — warn, never fail (spec M5 acceptance). validateEnv is a pure
67
+ // scanner over the env-registry (env-vars.ts) and never throws.
68
+ const envWarnings = validateEnv(process.env);
69
+ if (envWarnings.warnings.length > 0) {
70
+ console.warn(
71
+ `[pi-crew] ${envWarnings.warnings.length} deprecated env var(s) in use:\n` +
72
+ envWarnings.warnings.map((w) => ` ${w.name}: ${w.message}`).join("\n"),
73
+ );
74
+ }
75
+
63
76
  startRuntimeWarmup();
64
77
  primePeerDep().catch(() => undefined);
65
78
  deployBundledThemes();
@@ -21,7 +21,7 @@ import { loadConfig } from "../../config/config.ts";
21
21
  import { DEFAULT_UI } from "../../config/defaults.ts";
22
22
  import { getCrewEnv } from "../../config/env-vars.ts";
23
23
  import { pruneFinishedRuns, pruneUserLevelRuns } from "../../extension/run-maintenance.ts";
24
- import { type BrokerSpawnCredentials, setActiveBrokerIssuer } from "../../runtime/broker/broker-issuer.ts";
24
+ import { type BrokerSpawnCredentials, setActiveBrokerIssuer, setActiveBrokerRevoker } 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
27
  import { forgetDetachedRun, hasDetachedRuns, peekFinishedDetachedRunResults } from "../../runtime/detached-run-results.ts";
@@ -681,8 +681,13 @@ function setupRenderLoop(
681
681
  // file just changed on disk, so force a fresh snapshot while keeping
682
682
  // the entry populated — deleting it left a window where the widget's
683
683
  // `get()` returned undefined and dropped the run to "(loading…)".
684
+ // PERF (2026-08-24): route through the coalesced ASYNC refresh —
685
+ // fs.watch can fire many times per second and the sync rebuild
686
+ // blocked the UI event loop. The entry stays populated until the
687
+ // async rebuild re-sets it in place; the render schedule below
688
+ // repaints while it lands.
684
689
  try {
685
- ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).refresh(runId);
690
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
686
691
  } catch (error) {
687
692
  logInternalError("register.runWatcher.refresh", error, runId);
688
693
  }
@@ -930,8 +935,11 @@ function setupRenderLoop(
930
935
  // FLICKER FIX: rebuild-in-place instead of deleting the entry (see
931
936
  // onRunChange above). A hard delete left `get()` returning undefined for
932
937
  // a frame, dropping the run to "(loading…)" and causing visible flicker.
938
+ // PERF (2026-08-24): coalesced ASYNC refresh — fs.watch can fire many
939
+ // times per second and the sync rebuild blocked the UI event loop; the
940
+ // render schedule below repaints while the rebuild lands.
933
941
  try {
934
- ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).refresh(runId);
942
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
935
943
  } catch (error) {
936
944
  logInternalError("register.crewRunWatcher.refresh", error, runId);
937
945
  }
@@ -1077,10 +1085,11 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
1077
1085
  // config.broker.waitMethodsEnabled a dead knob and the ADR-0
1078
1086
  // "then true" flip a silent no-op. Fail-closed when unset.
1079
1087
  waitMethodsEnabled: cfg?.waitMethodsEnabled ?? false,
1080
- // T3/R5 (ADR-5 §10): governed-nesting capability gate — fail-closed
1081
- // default; production threads config.nesting (sensitive: user config
1082
- // only). Nested-slot sizing + admission-time model catalog (ADR-5 §7 —
1083
- // the production wiring MUST supply it) + workspace gate mirror.
1088
+ // T3/R5 (ADR-5 §10): governed-nesting capability gate — default-on
1089
+ // since D8 (loadConfig layers DEFAULT_NESTING.enabled=true; sensitive
1090
+ // flag, so only USER config may flip it). Nested-slot sizing +
1091
+ // admission-time model catalog (ADR-5 §7 — the production wiring
1092
+ // MUST supply it) + workspace gate mirror.
1084
1093
  nestingEnabled: nestingCfg?.nesting?.enabled ?? false,
1085
1094
  ...(nestingCfg?.nesting?.maxSlots !== undefined ? { nestingMaxSlots: nestingCfg.nesting.maxSlots } : {}),
1086
1095
  ...(nestingCfg?.nesting?.maxDepth !== undefined ? { nestingMaxDepth: nestingCfg.nesting.maxDepth } : {}),
@@ -1120,8 +1129,8 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
1120
1129
  if (!effectiveEnabled()) return undefined;
1121
1130
  // ADR-5 §4 (governed nesting): tokens are minted ONLY for children that
1122
1131
  // may themselves delegate — childDepth < resolved maxDepth. At the default
1123
- // maxDepth=2 a delegate-spawned depth-2 grandchild gets NO credentials
1124
- // (env containment: no PI_CREW_BROKER_SOCKET/TOKEN at depth 2; identity
1132
+ // maxDepth=4 a delegate-spawned depth-4 grandchild gets NO credentials
1133
+ // (env containment: no PI_CREW_BROKER_SOCKET/TOKEN at the cap depth; identity
1125
1134
  // routing via PI_CREW_BROKER_RUN_ID/TASK_ID is threaded unconditionally
1126
1135
  // elsewhere). Undefined childDepth = legacy worker spawn (depth 1).
1127
1136
  if (childDepth !== undefined && childDepth >= resolveCrewMaxDepth(undefined)) return undefined;
@@ -1136,6 +1145,27 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
1136
1145
  }
1137
1146
  };
1138
1147
 
1148
+ // MuxSurface A1 (spec §7 D3 step 2): publish the revoker alongside the
1149
+ // issuer — team-runner's degrade controller calls it when a pane is lost.
1150
+ // Best-effort and self-gated: no broker bound (never started / session
1151
+ // switched) means nothing to revoke; re-issue after respawn mints fresh (T10).
1152
+ const revokeForTask = (taskId: string): void => {
1153
+ if (!taskId || typeof taskId !== "string") return;
1154
+ if (!isRootSession(process.env)) return;
1155
+ if (!broker || brokerSessionId !== cachedSessionId) return;
1156
+ try {
1157
+ broker.revokeTaskToken(taskId);
1158
+ } catch (error) {
1159
+ logInternalError(
1160
+ "broker.revoke-for-task",
1161
+ error instanceof Error ? error : new Error(String(error)),
1162
+ `taskId=${taskId}`,
1163
+ "warn",
1164
+ );
1165
+ }
1166
+ };
1167
+ setActiveBrokerRevoker(revokeForTask);
1168
+
1139
1169
  // Publish this issuer as the process-local active issuer so runChildPi can
1140
1170
  // default `brokerIssuer` without the registration context being threaded
1141
1171
  // through every runner call site. The issuer self-gates (root + flag), so
@@ -1146,6 +1176,7 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
1146
1176
  issueForChild,
1147
1177
  stop: async () => {
1148
1178
  setActiveBrokerIssuer(undefined);
1179
+ setActiveBrokerRevoker(undefined);
1149
1180
  if (broker) {
1150
1181
  try {
1151
1182
  await broker.stop();
@@ -75,14 +75,10 @@ export interface ObservabilityDeps {
75
75
  }>;
76
76
  }
77
77
 
78
- let _cachedOTLPExporter: OTLPExporterCtor | undefined;
79
78
  async function importOTLPExporter(): Promise<OTLPExporterCtor> {
80
- if (!_cachedOTLPExporter) {
81
- // LAZY: opt-in OTLP metric export — load only when otlp.enabled=true.
82
- const mod = await import("../../observability/exporters/otlp-exporter.ts");
83
- _cachedOTLPExporter = mod.OTLPExporter as unknown as OTLPExporterCtor;
84
- }
85
- return _cachedOTLPExporter;
79
+ // LAZY: opt-in OTLP metric export — load only when otlp.enabled=true.
80
+ const mod = await import("../../observability/exporters/otlp-exporter.ts");
81
+ return mod.OTLPExporter as unknown as OTLPExporterCtor;
86
82
  }
87
83
 
88
84
  /**
@@ -6,17 +6,13 @@ import { withSessionId } from "../team-tool/context.ts";
6
6
  // Lazy-loaded: team-tool.ts pulls in entire runtime chain.
7
7
  import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts";
8
8
 
9
- let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
10
9
  async function handleTeamTool(
11
10
  params: Parameters<typeof HandleTeamToolFn>[0],
12
11
  ctx: Parameters<typeof HandleTeamToolFn>[1],
13
12
  ): Promise<Awaited<ReturnType<typeof HandleTeamToolFn>>> {
14
- if (!_cachedHandleTeamTool) {
15
- // LAZY: team-tool.ts pulls in entire runtime chain.
16
- const mod = await import("../team-tool.ts");
17
- _cachedHandleTeamTool = mod.handleTeamTool;
18
- }
19
- return _cachedHandleTeamTool(params, ctx);
13
+ // LAZY: team-tool.ts pulls in entire runtime chain.
14
+ const mod = await import("../team-tool.ts");
15
+ return mod.handleTeamTool(params, ctx);
20
16
  }
21
17
 
22
18
  import { Text } from "@earendil-works/pi-tui";