pi-crew 0.11.1 → 0.11.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 (154) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +742 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +5 -4
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +206 -41
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +55 -8
  20. package/src/extension/crew-input-router.ts +9 -2
  21. package/src/extension/help.ts +21 -10
  22. package/src/extension/knowledge-injection.ts +2 -1
  23. package/src/extension/management.ts +8 -3
  24. package/src/extension/notification-sink.ts +17 -0
  25. package/src/extension/plan-orchestrate.ts +19 -3
  26. package/src/extension/registration/command-utils.ts +28 -2
  27. package/src/extension/registration/commands/dashboard.ts +11 -1
  28. package/src/extension/registration/commands/manage.ts +31 -15
  29. package/src/extension/registration/commands/run.ts +24 -2
  30. package/src/extension/registration/commands/shared.ts +34 -10
  31. package/src/extension/registration/commands/status.ts +25 -2
  32. package/src/extension/registration/context-builder.ts +8 -2
  33. package/src/extension/registration/foreground-run-controller.ts +2 -2
  34. package/src/extension/registration/health-notify-policy.ts +100 -0
  35. package/src/extension/registration/lazy-configurers.ts +38 -0
  36. package/src/extension/registration/lifecycle-handlers.ts +107 -40
  37. package/src/extension/registration/lifecycle.ts +77 -10
  38. package/src/extension/registration/observability.ts +125 -53
  39. package/src/extension/registration/registration-types.ts +7 -5
  40. package/src/extension/registration/runtime-cleanup.ts +9 -3
  41. package/src/extension/registration/subagent-helpers.ts +38 -0
  42. package/src/extension/registration/team-tool.ts +9 -2
  43. package/src/extension/registration/wire-cross-extension.ts +30 -0
  44. package/src/extension/run-compare.ts +220 -0
  45. package/src/extension/run-export.ts +37 -5
  46. package/src/extension/run-maintenance.ts +155 -5
  47. package/src/extension/team-manager-command.ts +3 -2
  48. package/src/extension/team-tool/dispatch/index.ts +3 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  50. package/src/extension/team-tool/goal-wrap.ts +7 -0
  51. package/src/extension/team-tool/goal.ts +16 -1
  52. package/src/extension/team-tool/handle-settings.ts +19 -2
  53. package/src/extension/team-tool/health-monitor.ts +21 -7
  54. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  55. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  56. package/src/extension/team-tool/plan.ts +10 -0
  57. package/src/extension/team-tool/routing-hint.ts +63 -0
  58. package/src/extension/team-tool/run-intent.ts +1 -1
  59. package/src/extension/team-tool/run.ts +1 -1
  60. package/src/extension/team-tool/status.ts +4 -0
  61. package/src/extension/team-tool.ts +52 -6
  62. package/src/extension/webhook-notify.ts +382 -0
  63. package/src/observability/metric-sink.ts +12 -2
  64. package/src/prompt/prompt-runtime.ts +82 -31
  65. package/src/prompt/worker-events-channel.ts +12 -0
  66. package/src/runtime/README.md +1 -1
  67. package/src/runtime/async-runner.ts +123 -1
  68. package/src/runtime/background-runner.ts +313 -234
  69. package/src/runtime/broker/crew-broker.ts +76 -71
  70. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  73. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  74. package/src/runtime/broker/wait-status-cache.ts +1 -1
  75. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  76. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  77. package/src/runtime/crew-agent-records.ts +337 -45
  78. package/src/runtime/deadletter.ts +43 -1
  79. package/src/runtime/delegate-spawn.ts +21 -3
  80. package/src/runtime/deterministic-ast.ts +14 -2
  81. package/src/runtime/dispatch-batch.ts +72 -5
  82. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  83. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  84. package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
  85. package/src/runtime/group-join.ts +11 -3
  86. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  87. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  88. package/src/runtime/model/model-fallback.ts +21 -1
  89. package/src/runtime/model/pi-args.ts +8 -10
  90. package/src/runtime/model/runtime-warmup.ts +2 -0
  91. package/src/runtime/output/output-validator.ts +10 -5
  92. package/src/runtime/plan-approval.ts +14 -1
  93. package/src/runtime/recovery/crash-recovery.ts +96 -49
  94. package/src/runtime/run-worker.ts +12 -1
  95. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  96. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  97. package/src/runtime/scheduling/scheduler.ts +49 -13
  98. package/src/runtime/scheduling/semaphore.ts +148 -20
  99. package/src/runtime/scratchpad/README.md +1 -1
  100. package/src/runtime/scratchpad/protocol.ts +1 -1
  101. package/src/runtime/settings-store.ts +1 -1
  102. package/src/runtime/skill-instructions.ts +22 -0
  103. package/src/runtime/stale-reconciler.ts +85 -13
  104. package/src/runtime/subagent-manager.ts +43 -13
  105. package/src/runtime/task-runner/child-executor.ts +17 -6
  106. package/src/runtime/task-runner/post-execution.ts +44 -14
  107. package/src/runtime/task-runner/pre-execution.ts +26 -2
  108. package/src/runtime/task-runner/prompt-builder.ts +150 -45
  109. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  110. package/src/runtime/task-runner.ts +28 -1
  111. package/src/runtime/team-runner.ts +42 -2
  112. package/src/runtime/verification/verification-worktree.ts +3 -0
  113. package/src/runtime/workspace-lock.ts +4 -1
  114. package/src/schema/config-schema.ts +14 -0
  115. package/src/schema/team-tool-schema.ts +17 -0
  116. package/src/state/atomic-write.ts +53 -0
  117. package/src/state/contracts.ts +109 -0
  118. package/src/state/coordination/locks.ts +191 -33
  119. package/src/state/coordination/mailbox.ts +147 -17
  120. package/src/state/crew-init.ts +87 -12
  121. package/src/state/event-log/cursor.ts +37 -1
  122. package/src/state/event-log/event-log-rotation.ts +72 -7
  123. package/src/state/stores/active-run-registry.ts +13 -1
  124. package/src/state/stores/state-store.ts +120 -23
  125. package/src/state/types.ts +4 -0
  126. package/src/ui/dashboard-panes/agents-pane.ts +22 -10
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/index.ts +1 -1
  129. package/src/ui/keybinding-map.ts +22 -4
  130. package/src/ui/live-conversation-overlay.ts +6 -3
  131. package/src/ui/render-diff.ts +19 -6
  132. package/src/ui/run-dashboard.ts +109 -6
  133. package/src/ui/run-snapshot-cache.ts +27 -4
  134. package/src/ui/spinner.ts +26 -2
  135. package/src/ui/tool-progress-formatter.ts +2 -1
  136. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  137. package/src/ui/tool-renderers/index.ts +3 -3
  138. package/src/ui/widget/task-list.ts +8 -5
  139. package/src/ui/widget/widget-model.ts +3 -1
  140. package/src/utils/incremental-reader.ts +11 -3
  141. package/src/utils/paths.ts +94 -12
  142. package/src/utils/project-markers.ts +40 -0
  143. package/src/worktree/worktree-manager.ts +206 -26
  144. package/tsconfig.json +2 -0
  145. package/workflows/distill.workflow.md +3 -3
  146. package/workflows/fast-fix.workflow.md +1 -1
  147. package/workflows/plan-execute.workflow.md +1 -1
  148. package/workflows/review.workflow.md +1 -1
  149. package/workflows/strict-fast-fix.workflow.md +1 -1
  150. package/docs/migration-v0.4-v0.5.md +0 -208
  151. package/docs/runtime-flow.md +0 -148
  152. package/src/ui/card-colors.ts +0 -168
  153. package/src/utils/completion-dedupe.ts +0 -63
  154. package/src/utils/resolve-shell.ts +0 -36
@@ -167,7 +167,7 @@ export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedTo
167
167
  agent.loadMode === "lean" && agent.defaultTools?.length ? uniqueToolMerge(explicitTools, agent.defaultTools) : explicitTools;
168
168
  // denylist: additive merge of role excludeTools + agent disallowedTools.
169
169
  let excludeTools = uniqueToolMerge(roleConfig.excludeTools, agent.disallowedTools);
170
- // P2 (scratchpad adoption lever, rlm-deep-review-2026-08-12.md §5.1A):
170
+ // P2 (scratchpad adoption lever, docs/archive/rlm-deep-review-2026-08-12.md §5.1A):
171
171
  // when scratchpad is armed for this role AND the operator opted in via
172
172
  // PI_CREW_SCRATCHPAD_DEMOTE_BASH=1, remove `bash` from the tool surface so
173
173
  // the model reaches for `sh()` inside scratchpad cells (structured value
@@ -410,7 +410,7 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
410
410
  // below use logInternalError).
411
411
  if (contextMode === "fork" && !warnedForkAgents.has(filePath)) {
412
412
  console.warn(
413
- "contextMode: 'fork' is only effective in live-session runtime; current default child-process will behave as 'fresh'. See docs/runtime-flow.md.",
413
+ "contextMode: 'fork' is only effective in live-session runtime; current default child-process will behave as 'fresh'. See docs/architecture.md.",
414
414
  );
415
415
  warnedForkAgents.add(filePath);
416
416
  // R5-L5: FIFO eviction — drop the oldest entry past the cap.
@@ -21,6 +21,7 @@ import type {
21
21
  CrewTelemetryConfig,
22
22
  CrewToolsConfig,
23
23
  CrewUiConfig,
24
+ CrewWebhookConfig,
24
25
  CrewWorktreeConfig,
25
26
  GoalWrapWorkflowConfig,
26
27
  PersistenceConfig,
@@ -583,6 +584,19 @@ function parsePolicyConfig(value: unknown): CrewPolicyConfig | undefined {
583
584
  function parseNotificationsConfig(value: unknown): CrewNotificationsConfig | undefined {
584
585
  const obj = asRecord(value);
585
586
  if (!obj) return undefined;
587
+ // US-030: webhook block — field-wise parse mirroring the schema (url must
588
+ // be a non-empty string; an invalid/missing url parses to "" which the
589
+ // notifier treats as disabled — zero network). Sensitive: schema marks the
590
+ // block user-config-only; this parser stays shape-neutral.
591
+ const webhookObj = asRecord(obj.webhook);
592
+ const webhook: CrewWebhookConfig | undefined = webhookObj
593
+ ? {
594
+ url: parseWithSchema(Type.String({ minLength: 1 }), webhookObj.url) ?? "",
595
+ enabled: parseWithSchema(Type.Boolean(), webhookObj.enabled),
596
+ secret: parseWithSchema(Type.String({ minLength: 1 }), webhookObj.secret),
597
+ allowLocalhost: parseWithSchema(Type.Boolean(), webhookObj.allowLocalhost),
598
+ }
599
+ : undefined;
586
600
  const notifications: CrewNotificationsConfig = {
587
601
  enabled: parseWithSchema(Type.Boolean(), obj.enabled),
588
602
  severityFilter: parseWithSchema(
@@ -597,6 +611,7 @@ function parseNotificationsConfig(value: unknown): CrewNotificationsConfig | und
597
611
  batchWindowMs: parseWithSchema(Type.Integer({ minimum: 0, maximum: 60_000 }), obj.batchWindowMs),
598
612
  quietHours: parseWithSchema(Type.String({ pattern: "^\\d{2}:\\d{2}-\\d{2}:\\d{2}$" }), obj.quietHours),
599
613
  sinkRetentionDays: parsePositiveInteger(obj.sinkRetentionDays, 90),
614
+ webhook,
600
615
  };
601
616
  return Object.values(notifications).some((entry) => entry !== undefined) ? notifications : undefined;
602
617
  }
@@ -264,12 +264,47 @@ function unsetPath(record: Record<string, unknown>, dottedPath: string): void {
264
264
  delete target[parts[parts.length - 1]!];
265
265
  }
266
266
 
267
+ // F08 (RR-016): the JSON depth guard below MAX_JSON_DEPTH containers of TRUE
268
+ // nesting — measured by an explicit-stack walk over the parsed value, NOT by a
269
+ // JSON.parse reviver counter. The previous reviver incremented once per VALUE,
270
+ // so a shallow-but-wide config (48 agent model overrides = 101 values) was
271
+ // rejected as "too deep" and the ENTIRE file was discarded — silently losing
272
+ // every resource limit in it, while a genuinely 96-level-deep document with 99
273
+ // values was accepted. Keeping this a real depth limit preserves the guard
274
+ // against pathological nesting without punishing wide configs.
275
+ const MAX_CONFIG_SIZE = 10 * 1024 * 1024;
276
+ const MAX_JSON_DEPTH = 100;
277
+
278
+ /**
279
+ * Deepest container (object/array) nesting level of a parsed JSON value;
280
+ * scalars do not add depth. Iterative (explicit stack) on purpose: a recursive
281
+ * walk would itself stack-overflow on the exact input this guard exists to
282
+ * reject. Cost is O(nodes) and it is only run once per config parse (which the
283
+ * 2s loadConfig cache already amortizes).
284
+ */
285
+ function measureJsonDepth(root: unknown): number {
286
+ let maxDepth = 0;
287
+ const stack: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 1 }];
288
+ while (stack.length > 0) {
289
+ const frame = stack.pop()!;
290
+ if (frame.depth > maxDepth) maxDepth = frame.depth;
291
+ const children: unknown[] = Array.isArray(frame.value)
292
+ ? frame.value
293
+ : frame.value !== null && typeof frame.value === "object"
294
+ ? Object.values(frame.value as Record<string, unknown>)
295
+ : [];
296
+ for (const child of children) {
297
+ if (child !== null && typeof child === "object") stack.push({ value: child, depth: frame.depth + 1 });
298
+ }
299
+ }
300
+ return maxDepth;
301
+ }
302
+
267
303
  function readConfigRecord(filePath: string): Record<string, unknown> {
268
304
  if (!fs.existsSync(filePath)) return {};
269
305
  // Defense-in-depth: reject config files larger than 10 MB before parsing.
270
- // This prevents memory exhaustion and blocks deeply nested JSON that could
271
- // cause stack overflow during parsing.
272
- const MAX_CONFIG_SIZE = 10 * 1024 * 1024;
306
+ // This prevents memory exhaustion from oversized files. (Behaviour kept
307
+ // unchanged by F08/RR-016: the byte-size limit stays a separate, hard cap.)
273
308
  const stat = fs.statSync(filePath);
274
309
  if (stat.size > MAX_CONFIG_SIZE) {
275
310
  logInternalError(
@@ -279,17 +314,16 @@ function readConfigRecord(filePath: string): Record<string, unknown> {
279
314
  );
280
315
  return {};
281
316
  }
282
- // Parse with depth limit to prevent stack overflow from deeply nested JSON.
283
- // Nesting beyond 100 levels is almost certainly an attack or malformed file.
284
- const MAX_JSON_DEPTH = 100;
285
- let depth = 0;
286
- const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"), (_key, value) => {
287
- if (++depth > MAX_JSON_DEPTH) {
288
- throw new Error(`config JSON exceeds max depth ${MAX_JSON_DEPTH}`);
289
- }
290
- return value;
291
- }) as unknown;
317
+ // Plain parse first (V8's JSON.parse is iterative — a 1e6-deep document
318
+ // parses without a stack overflow), then the iterative depth walk above.
319
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
292
320
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
321
+ const depth = measureJsonDepth(raw);
322
+ if (depth > MAX_JSON_DEPTH) {
323
+ throw new Error(
324
+ `config JSON nesting depth ${depth} exceeds max depth ${MAX_JSON_DEPTH} (fix: reduce nesting in ${filePath}, or delete the file to fall back to defaults)`,
325
+ );
326
+ }
293
327
  return raw as Record<string, unknown>;
294
328
  }
295
329
 
@@ -237,6 +237,25 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
237
237
  parser: "boolean",
238
238
  doc: "'1'/'true' allows PI_TEAMS_MOCK_CHILD_PI mock mode (mock-fixtures.ts:40)",
239
239
  },
240
+ PI_CREW_TEST_ASYNC_INLINE: {
241
+ name: "PI_CREW_TEST_ASYNC_INLINE",
242
+ parser: "boolean",
243
+ doc: "'1' runs async team runs IN-PROCESS instead of spawning a detached background-runner (test seam; requires PI_CREW_ALLOW_MOCK=1 — see async-runner.ts spawnBackgroundTeamRun)",
244
+ },
245
+ PI_CREW_BACKGROUND_RUNNER_ENTRY: {
246
+ name: "PI_CREW_BACKGROUND_RUNNER_ENTRY",
247
+ parser: "boolean",
248
+ doc: "'1' marks this process as a directly-invoked background-runner entry (set by async-runner spawn; module import without it skips main() — background-runner.ts entry guard)",
249
+ },
250
+ PI_CREW_PROMPT_BREAKDOWN: {
251
+ name: "PI_CREW_PROMPT_BREAKDOWN",
252
+ parser: "boolean",
253
+ doc: "'1' writes a per-section prompt token breakdown artifact metadata/<task>.prompt-breakdown.json (SR-02 phase 1, prompt-builder.ts)",
254
+ },
255
+ PI_CREW_PROMPT_SKILLS: {
256
+ name: "PI_CREW_PROMPT_SKILLS",
257
+ doc: "'full' inlines complete skill bodies in worker prompts (SR-02 escape hatch); default injects compact index entries (name+description+path, read-on-demand)",
258
+ },
240
259
  PI_CREW_ASYNC_EARLY_EXIT_GUARD: {
241
260
  name: "PI_CREW_ASYNC_EARLY_EXIT_GUARD",
242
261
  doc: "'0' skips the async-run early-exit guard (team-tool/run.ts:103)",
@@ -257,6 +276,10 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
257
276
  name: "PI_CREW_DEBUG_BUDGET",
258
277
  doc: "'1' logs token budget (team-tool/run.ts:498)",
259
278
  },
279
+ PI_CREW_DEBUG_STALE: {
280
+ name: "PI_CREW_DEBUG_STALE",
281
+ doc: "'1' writes a forensic sidecar log of every STALE verdict from the stale reconciler (stale-reconciler.ts:287) — the reconciler may run in ANY host process, hence a fixed env gate",
282
+ },
260
283
  PI_CREW_DWF_SCRIPT_TIMEOUT_MS: {
261
284
  name: "PI_CREW_DWF_SCRIPT_TIMEOUT_MS",
262
285
  parser: "int",
@@ -415,6 +438,18 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
415
438
  name: "PI_CREW_AUTO_EXIT",
416
439
  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
440
  },
441
+ PI_CREW_AUTO_PRUNE_KEEP: {
442
+ name: "PI_CREW_AUTO_PRUNE_KEEP",
443
+ parser: "int",
444
+ default: "10",
445
+ doc: "DP-01: number of most-recent finished runs the session-start auto-prune retains (was hard-coded 10). Invalid/negative → 10 with a warning (run-maintenance.ts:resolveAutoPruneKeep)",
446
+ },
447
+ PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS: {
448
+ name: "PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS",
449
+ parser: "int",
450
+ default: "24",
451
+ doc: "DP-01: auto-prune never deletes a finished run younger than this many hours, even beyond top-keep — evidence/incident runs survive a session restart (0 disables; run-maintenance.ts:resolveAutoPruneAgeFloorMs)",
452
+ },
418
453
  PI_CREW_SURFACE: {
419
454
  name: "PI_CREW_SURFACE",
420
455
  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)",
@@ -219,6 +219,23 @@ export interface CrewPolicyConfig {
219
219
 
220
220
  export type CrewNotificationSeverity = "info" | "warning" | "error" | "critical";
221
221
 
222
+ /**
223
+ * US-030: opt-in outbound webhook on run terminal transitions.
224
+ * SENSITIVE (user config only — the schema marks this block `sensitive`):
225
+ * a project-level webhook URL would let an untrusted repo exfiltrate run
226
+ * metadata (incl. the goal first line) to an attacker-controlled endpoint.
227
+ */
228
+ export interface CrewWebhookConfig {
229
+ /** Target URL. Must be http(s); localhost/loopback/link-local refused unless `allowLocalhost`. */
230
+ url: string;
231
+ /** Master switch. Absent + url set = enabled; `false` disables (zero network). */
232
+ enabled?: boolean;
233
+ /** Shared secret → `x-pi-crew-signature: sha256=<hmac-sha256(body, secret)>` header. */
234
+ secret?: string;
235
+ /** Explicit SSRF-guard bypass for localhost/127.0.0.0/8/[::1]/169.254.0.0/16 targets. */
236
+ allowLocalhost?: boolean;
237
+ }
238
+
222
239
  export interface CrewNotificationsConfig {
223
240
  enabled?: boolean;
224
241
  severityFilter?: CrewNotificationSeverity[];
@@ -226,6 +243,8 @@ export interface CrewNotificationsConfig {
226
243
  batchWindowMs?: number;
227
244
  quietHours?: string;
228
245
  sinkRetentionDays?: number;
246
+ /** US-030: outbound webhook on run terminal transitions. Opt-in only — no URL configured means disabled (zero network calls). */
247
+ webhook?: CrewWebhookConfig;
229
248
  }
230
249
 
231
250
  export interface CrewObservabilityConfig {
package/src/errors.ts CHANGED
@@ -1,4 +1,4 @@
1
- // pi-crew structured error module — taxonomy mapping E001–E006.
1
+ // pi-crew structured error module — taxonomy mapping E001–E013.
2
2
  /**
3
3
  * @fileoverview Error types and structured error handling for pi-crew.
4
4
  *
@@ -6,7 +6,7 @@
6
6
  * matching fallow's E001-E004 pattern. It exports three main constructs:
7
7
  *
8
8
  * - {@link ErrorCode} — a `const` object and string-literal union type alias
9
- * enumerating machine-readable error codes (E001–E006). Implemented as a
9
+ * enumerating machine-readable error codes (E001–E013). Implemented as a
10
10
  * `const` object rather than a TypeScript `enum` so that Node's
11
11
  * `--experimental-strip-types` can load this module (enum syntax is not
12
12
  * supported in strip-only mode).
@@ -1,13 +1,14 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readCrewAgents, saveCrewAgents } from "../runtime/crew-agent-records.ts";
3
3
  import { checkProcessLiveness, isActiveRunStatus } from "../runtime/process-status.ts";
4
- import { withRunLockSync } from "../state/coordination/locks.ts";
4
+ import { withRunLock } from "../state/coordination/locks.ts";
5
5
  import { appendEvent, readEventsCursor, type TeamEvent } from "../state/event-log/event-log.ts";
6
6
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
7
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
8
  import { logInternalError } from "../utils/internal-error.ts";
9
9
  import { extractSessionId } from "../utils/session-utils.ts";
10
- import { listRuns } from "./run-index.ts";
10
+ import { listRecentRuns, listRuns } from "./run-index.ts";
11
+ import type { WebhookNotifier } from "./webhook-notify.ts";
11
12
 
12
13
  export interface AsyncNotifierState {
13
14
  seenFinishedRunIds: Set<string>;
@@ -20,6 +21,13 @@ export interface AsyncNotifierState {
20
21
  export interface AsyncNotifierOptions {
21
22
  generation?: number;
22
23
  isCurrent?: (generation: number) => boolean;
24
+ /**
25
+ * US-030 (docs/specs/US-030.md): outbound webhook sink, invoked ONCE per
26
+ * observed terminal transition — the same point (and dedupe memory) as the
27
+ * completion toast below. The sink handles quiet-hours, SSRF, HMAC and
28
+ * retry internally and NEVER throws. Optional — absent = disabled.
29
+ */
30
+ webhookNotifier?: WebhookNotifier;
23
31
  }
24
32
 
25
33
  function isFinished(status: string): boolean {
@@ -99,7 +107,11 @@ function markActiveTasksAndAgentsFailed(run: TeamRunManifest, message: string):
99
107
  }
100
108
  }
101
109
 
102
- export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(), quietMs = 30_000): TeamRunManifest | undefined {
110
+ export async function markDeadAsyncRunIfNeeded(
111
+ run: TeamRunManifest,
112
+ now = Date.now(),
113
+ quietMs = 30_000,
114
+ ): Promise<TeamRunManifest | undefined> {
103
115
  if (!run.async || !isActiveRunStatus(run.status)) return undefined;
104
116
  const liveness = checkProcessLiveness(run.async.pid);
105
117
  if (liveness.alive) return undefined;
@@ -108,8 +120,13 @@ export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(),
108
120
  if (latestEventAgeMs(events, now) < quietMs) return undefined;
109
121
  const asyncPid = run.async.pid;
110
122
  const message = `Background runner died unexpectedly; check background.log (${liveness.detail}).`;
111
- return withRunLockSync(run, () => {
112
- const fresh = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
123
+ // RR-021 WI-1.4: rerouted to the async run-lock (same v0.9.26 lock family as
124
+ // the sync helper — sync and async acquisitions interoperate via live-token
125
+ // registration, RR-011 F02). The notifier tick is async, so we must not hold
126
+ // the event loop with a blocking sync acquisition while other async contenders
127
+ // (background runner, stale reconciler) wait.
128
+ return withRunLock(run, async () => {
129
+ const fresh = loadRunManifestById(run.cwd, run.runId); // NOTE: best-effort only inside the lock; concurrent writes may cause inconsistency;
113
130
  if (!fresh || !isActiveRunStatus(fresh.manifest.status)) return undefined;
114
131
  const failed = updateRunStatus(fresh.manifest, "failed", message);
115
132
  markActiveTasksAndAgentsFailed(failed, message);
@@ -155,16 +172,25 @@ export function startAsyncRunNotifier(
155
172
  if (isFinished(run.status) && updatedAtMs < staleBeforeMs) addSeenFinishedRunId(state, run.runId);
156
173
  }
157
174
  let cachedRuns: TeamRunManifest[] | undefined;
158
- state.interval = setInterval(() => {
175
+ // RR-021 WI-1.4: the tick is async now (markDeadAsyncRunIfNeeded awaits the
176
+ // run-lock). `ticking` preserves the old no-overlap semantics — setInterval
177
+ // must not re-enter a tick that is still awaiting a lock.
178
+ let ticking = false;
179
+ const tick = async (): Promise<void> => {
180
+ if (ticking) return;
181
+ ticking = true;
159
182
  try {
160
183
  if (options.isCurrent && !options.isCurrent(generation)) return;
161
184
  const nowMs = Date.now();
162
185
  if (cachedRuns === undefined || nowMs - (state.lastListRunsMs ?? 0) > LIST_RUNS_DEBOUNCE_MS) {
163
- cachedRuns = listRuns(ctx.cwd).filter(ownsRun).slice(0, 20);
186
+ // RR-021 WI-4.1: bounded recent-runs read (40 = 2x the 20 we keep) instead
187
+ // of a full listRuns() index scan on every debounce window — the 2x bound
188
+ // survives the ownsRun filter still yielding 20 owned runs.
189
+ cachedRuns = listRecentRuns(ctx.cwd, 40).filter(ownsRun).slice(0, 20);
164
190
  state.lastListRunsMs = nowMs;
165
191
  }
166
192
  for (const run of cachedRuns) {
167
- const current = markDeadAsyncRunIfNeeded(run) ?? run;
193
+ const current = (await markDeadAsyncRunIfNeeded(run)) ?? run;
168
194
  if (!isFinished(current.status) || state.seenFinishedRunIds.has(current.runId)) continue;
169
195
  addSeenFinishedRunId(state, current.runId);
170
196
  // Suppress notifications for INTERNAL goal-loop sub-runs.
@@ -177,6 +203,21 @@ export function startAsyncRunNotifier(
177
203
  // alarming 'Error: pi-crew run failed' toast for an internal sub-run
178
204
  // the user never started directly.
179
205
  if (current.workflow === "goal-turn" && current.team.startsWith("goal-")) continue;
206
+ // US-030: outbound webhook on the terminal transition. Only the three
207
+ // spec statuses (completed/failed/cancelled) — "blocked" runs do not
208
+ // notify. Fire-and-forget: the sink is contractually non-throwing, the
209
+ // try/catch is belt-only so a webhook failure can NEVER suppress the
210
+ // local toast (or reach the run lifecycle path).
211
+ if (
212
+ options.webhookNotifier &&
213
+ (current.status === "completed" || current.status === "failed" || current.status === "cancelled")
214
+ ) {
215
+ try {
216
+ options.webhookNotifier.notifyTerminalRun(current);
217
+ } catch (error) {
218
+ logInternalError("async-notifier.webhook", error, current.runId);
219
+ }
220
+ }
180
221
  const level = current.status === "completed" ? "info" : current.status === "cancelled" ? "warning" : "error";
181
222
  ctx.ui.notify(`pi-crew run ${current.status}: ${current.runId} (${current.team}/${current.workflow ?? "none"})`, level);
182
223
  }
@@ -190,7 +231,13 @@ export function startAsyncRunNotifier(
190
231
  return;
191
232
  }
192
233
  logInternalError("async-notifier", error, `interval=${intervalMs}`);
234
+ } finally {
235
+ ticking = false;
193
236
  }
237
+ };
238
+ state.interval = setInterval(() => {
239
+ // tick() never rejects (it catches internally) — void is safe.
240
+ void tick();
194
241
  }, intervalMs);
195
242
  // Defense-in-depth: never let the notifier timer keep the event loop alive.
196
243
  // If stopAsyncRunNotifier is missed (session switch race), the next run of
@@ -55,6 +55,13 @@ function phraseToRegex(phrase: string): RegExp {
55
55
  return new RegExp(`^${escaped}\\b`, "i");
56
56
  }
57
57
 
58
+ // RR-021 WI-4.3k: precompile the per-phrase regexes ONCE — the router used to
59
+ // rebuild every phrase regex on every routed input line.
60
+ const CREW_PHRASE_MATCHERS: ReadonlyArray<{ command: string; regex: RegExp }> = CREW_PHRASES.map((entry) => ({
61
+ command: entry.command,
62
+ regex: phraseToRegex(entry.phrase),
63
+ }));
64
+
58
65
  /**
59
66
  * Try to rewrite a natural-language crew phrase into a slash command.
60
67
  * Returns the rewritten command string, or `null` if no rule matches.
@@ -67,8 +74,8 @@ export function rewriteCrewInput(text: string): string | null {
67
74
  // Never transform explicit slash commands or inputs that don't start with
68
75
  // a crew/team keyword phrase.
69
76
  if (trimmed.startsWith("/")) return null;
70
- for (const entry of CREW_PHRASES) {
71
- const match = trimmed.match(phraseToRegex(entry.phrase));
77
+ for (const entry of CREW_PHRASE_MATCHERS) {
78
+ const match = trimmed.match(entry.regex);
72
79
  if (!match) continue;
73
80
  // Carry any remaining args after the matched phrase forward.
74
81
  const rest = trimmed.slice(match[0].length).trim();
@@ -4,7 +4,6 @@ export function piTeamsHelp(): string {
4
4
  "",
5
5
  "Core:",
6
6
  "- Agent can use the `team` tool autonomously; slash commands are manual controls.",
7
- "- Tool action `recommend` suggests the best team/workflow for a goal.",
8
7
  "- /teams — list teams, workflows, agents, recent runs",
9
8
  "- /team-run [--team=name] [--workflow=name] [--async] [--worktree] <goal>",
10
9
  "- /team-status <runId>",
@@ -12,36 +11,48 @@ export function piTeamsHelp(): string {
12
11
  "- /team-resume <runId>",
13
12
  "- /team-cancel <runId>",
14
13
  "- /team-retry <runId> [taskId]",
14
+ "- /team-respond <runId> <taskId|--all> <message>",
15
+ "- /team-follow-up <runId> <taskId> <prompt>",
16
+ "- /team-goal — autonomous goal loop (sub-actions: start/status/pause/resume/stop/step/clear)",
17
+ "- /workflows — list static + dynamic workflows",
15
18
  "",
16
19
  "Inspection:",
17
20
  "- /team-events <runId>",
18
21
  "- /team-artifacts <runId>",
22
+ "- /team-result <runId> [taskId]",
23
+ "- /team-transcript <runId> [taskId]",
19
24
  "- /team-worktrees <runId>",
20
- "- /team-api <runId> <operation> [taskId=<taskId>] [body=<message>]",
25
+ "- /team-api <runId> <operation> [key=value]",
26
+ "- /team-metrics [filter]",
21
27
  "- /team-dashboard",
22
- "- /schedules [log <jobId-or-name>] — list scheduled jobs / tail latest run output",
23
28
  "- /team-mascot",
24
- "- /team-transcript <runId> [taskId]",
25
- "- /team-result <runId> [taskId]",
26
- "- /team-manager — interactive menu (alias: /team-cleanup-menu)",
27
29
  "",
28
30
  "Maintenance:",
31
+ "- /team-manager — interactive menu (alias: /team-cleanup-menu)",
29
32
  "- /team-forget <runId> --confirm [--force]",
30
33
  "- /team-prune --keep=20 --confirm",
34
+ "- /team-invalidate <runId>",
35
+ "",
36
+ "Skills:",
37
+ "- /skill-list [--json] — list builtin skill templates",
38
+ "- /skill-create <template-id> [--var key=value...] [--project]",
31
39
  "",
32
40
  "Portability:",
33
41
  "- /team-export <runId>",
34
- "- /team-import <path-to-run-export.json> [--user]",
42
+ "- /team-import <path-to-run-export.json>",
35
43
  "- /team-imports",
36
44
  "",
37
- "Diagnostics:",
45
+ "Diagnostics & config:",
38
46
  "- /team-doctor",
47
+ "- /team-validate",
39
48
  "- /team-init [--copy-builtins] [--overwrite]",
40
49
  "- /team-config [key=value] [--unset=key.path] [--project]",
41
- "- /team-autonomy [status|on|off|manual|suggested|assisted|aggressive] [--prefer-async] [--no-worktree-suggest]",
42
- "- /team-validate",
50
+ "- /team-settings [list|get <key>|set <key> <value>|unset <key>|path|scope]",
51
+ "- /team-autonomy [status|on|off|manual|suggested|assisted|aggressive]",
43
52
  "- /team-help",
44
53
  "",
54
+ "Non-team commands: /schedules [log <jobId-or-name>], /crew-view <runId> <taskId>, /crew-back, /team-vibes [on|off], /crew-brief [on|off|status]",
55
+ "",
45
56
  "Goal loops (P0/P1 — autonomous goal loop):",
46
57
  "- team action='goal' config.subAction='start' config.objective='...' config.evaluatorModel='...' [config.maxTurns=20] [budgetTotal=N]",
47
58
  "- team action='goal' config.subAction='status' [config.goalId=<id>]",
@@ -23,6 +23,7 @@
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
25
  import { sanitizeAgentSystemPrompt } from "../agents/discover-agents.ts";
26
+ import { getCrewEnv } from "../config/env-vars.ts";
26
27
  import { logInternalError } from "../utils/internal-error.ts";
27
28
  import { projectCrewRoot } from "../utils/paths.ts";
28
29
  import type { BeforeAgentStartEvent, ExtensionAPI } from "./pi-api.ts";
@@ -463,7 +464,7 @@ export function registerKnowledgeInjection(pi: ExtensionAPI): void {
463
464
  // ARCH-2: never fire in child worker processes — knowledge reaches
464
465
  // workers via prompt-builder's stablePrefix fragment; firing here too
465
466
  // would double-inject.
466
- if (process.env.PI_CREW_KIND === "subagent") return;
467
+ if (getCrewEnv("PI_CREW_KIND") === "subagent") return;
467
468
  const options =
468
469
  (
469
470
  event as BeforeAgentStartEvent & {
@@ -175,22 +175,27 @@ function findResource(ctx: ManagementContext, resource: "agent" | "team" | "work
175
175
  const sourceMatches = (item: { name: string; source: ResourceSource }) =>
176
176
  (scope === "user" || scope === "project" ? item.source === scope : item.source !== "builtin") && item.name === normalized;
177
177
  // Search in the correct scope array directly to avoid allAgents shadowing issue.
178
+ // Tier 9e (2026-09-21): the DEFAULT pool must include PROJECT resources —
179
+ // the error message already promises "mutable user/project scopes", but the
180
+ // pool was `[...builtin, ...user]` and `sourceMatches` then drops every
181
+ // builtin entry, degenerating the default to USER ONLY. A project resource
182
+ // was invisible unless the caller guessed scope:'project'.
178
183
  if (resource === "agent") {
179
184
  const discovery = discoverAgents(ctx.cwd);
180
185
  const pool =
181
- scope === "user" ? discovery.user : scope === "project" ? discovery.project : [...discovery.builtin, ...discovery.user];
186
+ scope === "user" ? discovery.user : scope === "project" ? discovery.project : [...discovery.user, ...discovery.project];
182
187
  return pool.filter(sourceMatches);
183
188
  }
184
189
  if (resource === "team") {
185
190
  const discovery = discoverTeams(ctx.cwd);
186
191
  const pool =
187
- scope === "user" ? discovery.user : scope === "project" ? discovery.project : [...discovery.builtin, ...discovery.user];
192
+ scope === "user" ? discovery.user : scope === "project" ? discovery.project : [...discovery.user, ...discovery.project];
188
193
  return pool.filter(sourceMatches);
189
194
  }
190
195
  {
191
196
  const discovery = discoverWorkflows(ctx.cwd);
192
197
  const pool =
193
- scope === "user" ? discovery.user : scope === "project" ? discovery.project : [...discovery.builtin, ...discovery.user];
198
+ scope === "user" ? discovery.user : scope === "project" ? discovery.project : [...discovery.user, ...discovery.project];
194
199
  return pool.filter(sourceMatches);
195
200
  }
196
201
  }
@@ -23,12 +23,29 @@ function rotateOldFiles(dir: string, retentionDays: number, now = Date.now()): v
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * JSONL notification sink.
28
+ *
29
+ * RR-020 Fix 2: writes are gated on the crew root ALREADY existing — this sink
30
+ * is attached at session start (lifecycle.ts) and the router pushes `info`
31
+ * notices through it before any severity filter, which used to create an empty
32
+ * `<crewRoot>/state/notifications/` tree (and with it the crew root) on every
33
+ * project. A project that never ran a team now stays untouched; once the crew
34
+ * root exists the sink behaves exactly as before.
35
+ */
26
36
  export function createJsonlSink(crewRoot: string, retentionDays = 7): NotificationSink {
27
37
  const dir = path.join(crewRoot, "state", "notifications");
28
38
  let lastRotateDate = "";
29
39
  return {
30
40
  write(notification: NotificationDescriptor): void {
31
41
  try {
42
+ // RR-020 Fix 2: never materialise the project crew root. The router
43
+ // calls this sink BEFORE its severity filter (notification-router.ts),
44
+ // so an `info` notice on session start used to mkdir `<crewRoot>/`
45
+ // (→ `<crewRoot>/state/notifications/`) for a project that never ran
46
+ // a team. Persist only when the crew root already exists — a project
47
+ // that has real crew state keeps every notification exactly as before.
48
+ if (!fs.existsSync(crewRoot)) return;
32
49
  const timestamp = notification.timestamp ?? Date.now();
33
50
  const date = new Date(timestamp).toISOString().slice(0, 10);
34
51
  if (date !== lastRotateDate) {
@@ -183,11 +183,27 @@ export function parsePlanDocumentSimple(planPath: string): OrchestratedStep[] {
183
183
  /**
184
184
  * Detect implicit tag from content keywords when no explicit tag is present.
185
185
  */
186
+ // RR-021 WI-4.3k: precompiled word-boundary regexes — hasWord used to compile
187
+ // a fresh RegExp for every word on every call.
188
+ const WORD_MATCHERS: Readonly<Record<string, RegExp>> = {
189
+ design: /\bdesign\b/,
190
+ architecture: /\barchitecture\b/,
191
+ implement: /\bimplement\b/,
192
+ coding: /\bcoding\b/,
193
+ security: /\bsecurity\b/,
194
+ audit: /\baudit\b/,
195
+ build: /\bbuild\b/,
196
+ compile: /\bcompile\b/,
197
+ test: /\btest\b/,
198
+ verify: /\bverify\b/,
199
+ review: /\breview\b/,
200
+ feedback: /\bfeedback\b/,
201
+ };
186
202
  function detectImplicitTag(content: string): string | undefined {
187
203
  const lowerContent = content.toLowerCase();
188
- // Use word-boundary regex to avoid substring false matches.
189
- // E.g., "implementation" must NOT trigger "impl" — only the word "implement" should.
190
- const hasWord = (word: string): boolean => new RegExp(`\\b${word}\\b`).test(lowerContent);
204
+ // Word-boundary match avoids substring false matches: "implementation"
205
+ // must NOT trigger "impl" — only the word "implement" should.
206
+ const hasWord = (word: keyof typeof WORD_MATCHERS): boolean => WORD_MATCHERS[word].test(lowerContent);
191
207
 
192
208
  if (hasWord("design") || hasWord("architecture")) return "design";
193
209
  if (hasWord("implement") || hasWord("coding")) return "impl";
@@ -23,8 +23,34 @@ export function commandText(result: { content?: Array<{ type: string; text?: str
23
23
  return result.content?.map((item) => item.text ?? "").join("\n") ?? "";
24
24
  }
25
25
 
26
- export async function notifyCommandResult(ctx: ExtensionCommandContext, text: string): Promise<void> {
27
- ctx.ui.notify(text.length > 800 ? `${text.slice(0, 797)}...` : text, "info");
26
+ /** Hard cap for command-result notifications (spec W5: cap value stays 800). */
27
+ export const NOTIFY_TEXT_CAP = 800;
28
+
29
+ /** Explicit marker appended whenever a command-result notification is clipped. */
30
+ export const TRUNCATION_MARKER = "\n… [truncated]";
31
+
32
+ export interface NotifyCommandResultOptions {
33
+ /**
34
+ * Pointer appended AFTER the truncation marker when clipping occurs (e.g.
35
+ * the on-disk log path so the full output stays reachable). Included
36
+ * INSIDE the cap — the body shrinks to make room. Ignored when the text
37
+ * fits without clipping.
38
+ */
39
+ truncatedFooter?: string;
40
+ }
41
+
42
+ export async function notifyCommandResult(
43
+ ctx: ExtensionCommandContext,
44
+ text: string,
45
+ options: NotifyCommandResultOptions = {},
46
+ ): Promise<void> {
47
+ if (text.length <= NOTIFY_TEXT_CAP) {
48
+ ctx.ui.notify(text, "info");
49
+ return;
50
+ }
51
+ const tail = `${TRUNCATION_MARKER}${options.truncatedFooter ?? ""}`;
52
+ const bodyLength = Math.max(0, NOTIFY_TEXT_CAP - tail.length);
53
+ ctx.ui.notify(`${text.slice(0, bodyLength)}${tail}`, "info");
28
54
  }
29
55
 
30
56
  export function parseScalar(raw: string): unknown {
@@ -105,12 +105,22 @@ export function registerDashboardCommands(pi: ExtensionAPI, deps: RegisterTeamCo
105
105
  pi.registerCommand("team-dashboard", {
106
106
  description: "Open a pi-crew run dashboard overlay",
107
107
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
108
+ // W4 (slash-commands fix spec): headless invocation previously fell
109
+ // through to openTeamDashboard's silent `if (!ctx.hasUI) return;`
110
+ // no-op — notify + return here instead so headless users get an
111
+ // actionable pointer instead of silence.
112
+ if (!ctx.hasUI) {
113
+ ctx.ui.notify("team-dashboard needs a UI session — headless runs can use /team-status instead.", "info");
114
+ return;
115
+ }
108
116
  await openTeamDashboard(ctx);
109
117
  },
110
118
  });
111
119
 
112
120
  pi.registerCommand("team-mascot", {
113
- description: "Show an animated mascot splash",
121
+ // W4: cosmetic command is a documented silent no-op without a UI
122
+ // session (handler's `if (!ctx.hasUI) return;` stays).
123
+ description: "Show an animated mascot splash (UI session only)",
114
124
  handler: async (args: string, ctx: ExtensionCommandContext) => {
115
125
  if (!ctx.hasUI) return;
116
126
  const tokens = args.trim().split(/\s+/).filter(Boolean);