pi-crew 0.9.57 → 0.9.59

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 (66) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/index.mjs +22609 -21373
  3. package/package.json +1 -1
  4. package/skills/distill-software/SKILL.md +7 -1
  5. package/src/agents/discover-agents.ts +6 -0
  6. package/src/config/config.ts +13 -2
  7. package/src/config/markers.ts +9 -1
  8. package/src/extension/action-suggestions.ts +1 -1
  9. package/src/extension/async-notifier.ts +13 -5
  10. package/src/extension/registration/context-builder.ts +5 -2
  11. package/src/extension/registration/crash-recovery-cache.ts +2 -2
  12. package/src/extension/registration/lazy-configurers.ts +1 -1
  13. package/src/extension/registration/lifecycle-handlers.ts +25 -10
  14. package/src/extension/registration/observability.ts +8 -4
  15. package/src/extension/registration/registration-types.ts +2 -2
  16. package/src/extension/registration/runtime-cleanup.ts +10 -2
  17. package/src/extension/registration/subagent-manager-setup.ts +9 -4
  18. package/src/extension/registration/subagent-tools.ts +15 -3
  19. package/src/extension/run-import.ts +19 -2
  20. package/src/extension/run-maintenance.ts +25 -9
  21. package/src/extension/session-summary.ts +5 -0
  22. package/src/extension/team-tool/destructive-gate.ts +12 -6
  23. package/src/extension/team-tool/health-monitor.ts +7 -5
  24. package/src/extension/team-tool/intent-policy.ts +9 -0
  25. package/src/extension/team-tool/lifecycle-actions.ts +27 -4
  26. package/src/extension/team-tool/run-deadline.ts +7 -2
  27. package/src/extension/team-tool/run.ts +23 -4
  28. package/src/extension/team-tool/status.ts +2 -2
  29. package/src/extension/team-tool.ts +126 -51
  30. package/src/runtime/background-runner.ts +36 -2
  31. package/src/runtime/delivery-coordinator.ts +24 -3
  32. package/src/runtime/foreground-watchdog.ts +2 -2
  33. package/src/runtime/model/model-fallback.ts +3 -1
  34. package/src/runtime/model/provider-extensions.ts +36 -0
  35. package/src/runtime/model/runtime-warmup.ts +41 -0
  36. package/src/runtime/peer-dep.ts +35 -8
  37. package/src/runtime/recovery/crash-recovery.ts +34 -4
  38. package/src/runtime/skill-instructions.ts +1 -1
  39. package/src/runtime/stale-reconciler.ts +11 -19
  40. package/src/runtime/subagent-manager.ts +15 -6
  41. package/src/runtime/task-packet.ts +26 -4
  42. package/src/runtime/task-runner/run-projection.ts +4 -1
  43. package/src/runtime/team-runner.ts +94 -72
  44. package/src/runtime/verification/completion-guard.ts +10 -1
  45. package/src/schema/team-tool-schema.ts +152 -38
  46. package/src/skills/validate.ts +28 -2
  47. package/src/state/atomic-write.ts +23 -12
  48. package/src/state/contracts.ts +1 -0
  49. package/src/state/coordination/locks.ts +20 -7
  50. package/src/state/coordination/mailbox.ts +20 -2
  51. package/src/state/gitignore-manager.ts +5 -1
  52. package/src/state/stores/artifact-store.ts +5 -5
  53. package/src/state/stores/run-cache.ts +9 -1
  54. package/src/state/stores/state-store.ts +64 -10
  55. package/src/ui/deploy-bundled-themes.ts +11 -0
  56. package/src/ui/powerbar-publisher.ts +31 -6
  57. package/src/ui/run-dashboard.ts +7 -1
  58. package/src/ui/syntax-highlight.ts +31 -12
  59. package/src/ui/widget/index.ts +9 -1
  60. package/src/ui/widget/widget-model.ts +1 -1
  61. package/src/ui/widget/widget-types.ts +3 -0
  62. package/src/utils/env-filter.ts +25 -11
  63. package/src/utils/paths.ts +20 -1
  64. package/src/utils/redaction.ts +22 -1
  65. package/src/utils/session-utils.ts +42 -19
  66. package/src/worktree/worktree-manager.ts +2 -2
@@ -1245,59 +1245,27 @@ async function cancelRunFromSignal(ctx: SchedulerContext): Promise<SchedulerDeci
1245
1245
  * @param ctx The scheduler context; `ctx.tasks` and `ctx.manifest` are
1246
1246
  * mutated in-place to reflect the rerun or abort.
1247
1247
  */
1248
- async function handleFailedTask(ctx: SchedulerContext): Promise<SchedulerDecision | null> {
1249
- const failed = ctx.tasks.find((task) => task.status === "failed");
1250
- if (!failed) return null;
1251
-
1252
- // #4 (assessment): honor limits.maxRetriesPerTask — re-queue an eligible
1253
- // failed task for a bounded whole-task rerun instead of immediately
1254
- // aborting the run. Before #4, the recovery ledger recorded `rerun_task`
1255
- // entries with state:"planned" but never executed them (decorative).
1256
- // Default-off: maxRetriesPerTask=0 → original abort behavior preserved.
1257
- const rerun = shouldRerunFailedTask(failed, ctx.input.limits);
1258
- if (rerun.rerun) {
1259
- ctx.tasks = ctx.tasks.map((item) =>
1260
- item.id === failed.id
1261
- ? {
1262
- ...item,
1263
- status: "queued" as const,
1264
- policy: {
1265
- ...(item.policy ?? {}),
1266
- retryCount: rerun.newRetryCount,
1267
- },
1268
- error: undefined,
1269
- finishedAt: undefined,
1270
- }
1271
- : item,
1272
- );
1273
- await saveRunTasksAsync(ctx.manifest, ctx.tasks);
1274
- await appendEventAsync(ctx.manifest.eventsPath, {
1275
- type: "recovery.rerun_task",
1276
- runId: ctx.manifest.runId,
1277
- taskId: failed.id,
1278
- message: `Re-queuing failed task for whole-task rerun: ${rerun.reason}`,
1279
- data: {
1280
- attempt: rerun.newRetryCount,
1281
- maxRetries: ctx.input.limits?.maxRetriesPerTask ?? 0,
1282
- scenario: "task_failed",
1283
- },
1284
- });
1285
- return { kind: "continue" }; // loop re-processes the re-queued task
1286
- }
1287
- // RT-1: drain in-flight units and merge settled results BEFORE terminalising
1288
- // tasks. The streaming-dispatch path only adds to ctx.pendingUnits — it
1289
- // never updates ctx.tasks to "running" — so ctx.tasks is stale ("queued")
1290
- // for in-flight tasks. A direct saveRunTasksAsync would overwrite disk
1291
- // "running" with the stale "queued" status, then markBlocked maps that to
1292
- // "skipped". team resume never re-queues skipped tasks → permanent work
1293
- // loss. Draining first preserves settled results and gives non-settled
1294
- // in-flight tasks a re-queueable terminal status (cancelled, not skipped).
1295
- //
1296
- // #3 refactor: drainPendingUnits now RETURNS the settled outcomes directly,
1297
- // so we collect inflightTaskIds before draining (it clears the map), then
1298
- // consume the returned outcomes — no redundant re-await of the same promises.
1299
- // After drain, ctx.pendingUnits is empty, so the finally-block
1300
- // drainPendingUnits call (~:2419) is a no-op (idempotent — no double-drain).
1248
+ /**
1249
+ * RT-NEW-2: terminalise a run as "failed" while draining in-flight dispatch
1250
+ * units FIRST and merging their settled results under the run lock.
1251
+ *
1252
+ * Extracted verbatim from handleFailedTask (the FIXED reference) so every
1253
+ * abort path behaves identically: drain pendingUnits (abort controller +
1254
+ * await allSettled + clear), merge fulfilled outcomes into manifest/tasks
1255
+ * under withRunLock (flushPendingAtomicWrites + loadRunManifestById +
1256
+ * mergeArtifacts + mergeTaskUpdatesPreservingTerminal + save both), cancel
1257
+ * (not skip) non-settled in-flight tasks so team resume can re-queue them,
1258
+ * then markBlocked the remaining never-dispatched queued tasks.
1259
+ *
1260
+ * Previously enforceRunBudget skipped the drain+merge and called
1261
+ * markBlocked directly — in-flight tasks (still "queued" in ctx.tasks since
1262
+ * streaming dispatch never sets "running") were clobbered to "skipped",
1263
+ * which team resume never re-queues → permanent work loss.
1264
+ */
1265
+ async function terminaliseRunWithDrain(
1266
+ ctx: SchedulerContext,
1267
+ opts: { cancelMessage: string; blockedMessage: string; failedReason: string },
1268
+ ): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
1301
1269
  const inflightTaskIds = new Set<string>();
1302
1270
  for (const unit of ctx.pendingUnits.values()) {
1303
1271
  for (const id of unit.taskIds) inflightTaskIds.add(id);
@@ -1332,24 +1300,74 @@ async function handleFailedTask(ctx: SchedulerContext): Promise<SchedulerDecisio
1332
1300
  ctx.manifest = mergeResult.resultManifest;
1333
1301
  ctx.tasks = mergeResult.resultTasks;
1334
1302
  }
1335
- // RT-1: cancel in-flight tasks that did NOT settle (e.g. rejected promises)
1336
- // so team resume CAN re-queue them. markBlocked maps queued→skipped, which
1337
- // resume never re-queues — work would be lost permanently. Only cancel
1338
- // tasks that are both in-flight AND still non-terminal (settled tasks with
1339
- // a terminal status are preserved).
1303
+ // Cancel in-flight tasks that did NOT settle (e.g. rejected promises)
1304
+ // so team resume CAN re-queue them. markBlocked maps queued→skipped,
1305
+ // which resume never re-queues — work would be lost permanently. Only
1306
+ // cancel tasks that are both in-flight AND still non-terminal (settled
1307
+ // tasks with a terminal status are preserved).
1340
1308
  ctx.tasks = cancelNonTerminalTasks(
1341
1309
  ctx.tasks,
1342
1310
  "cancelled",
1343
- `Cancelled by failed task '${failed.id}'.`,
1311
+ opts.cancelMessage,
1344
1312
  (task) => inflightTaskIds.has(task.id) && isNonTerminalTaskStatus(task.status),
1345
1313
  );
1346
- // RT-1: remaining queued tasks (never dispatched) → skipped (original
1347
- // behavior preserved for downstream tasks not yet in-flight).
1348
- ctx.tasks = markBlocked(ctx.tasks, `Blocked by failed task '${failed.id}'.`);
1314
+ // Remaining queued tasks (never dispatched) → skipped (original behavior
1315
+ // preserved for downstream tasks not yet in-flight).
1316
+ ctx.tasks = markBlocked(ctx.tasks, opts.blockedMessage);
1349
1317
  await saveRunTasksAsync(ctx.manifest, ctx.tasks);
1350
1318
  saveCrewAgents(ctx.manifest, recordsForMaterializedTasks(ctx.manifest, ctx.tasks, ctx.runtimeKind));
1351
- ctx.manifest = updateRunStatus(ctx.manifest, "failed", `Failed at task '${failed.id}'.`);
1352
- return { kind: "return", result: { manifest: ctx.manifest, tasks: ctx.tasks } };
1319
+ ctx.manifest = updateRunStatus(ctx.manifest, "failed", opts.failedReason);
1320
+ return { manifest: ctx.manifest, tasks: ctx.tasks };
1321
+ }
1322
+
1323
+ async function handleFailedTask(ctx: SchedulerContext): Promise<SchedulerDecision | null> {
1324
+ const failed = ctx.tasks.find((task) => task.status === "failed");
1325
+ if (!failed) return null;
1326
+
1327
+ // #4 (assessment): honor limits.maxRetriesPerTask — re-queue an eligible
1328
+ // failed task for a bounded whole-task rerun instead of immediately
1329
+ // aborting the run. Before #4, the recovery ledger recorded `rerun_task`
1330
+ // entries with state:"planned" but never executed them (decorative).
1331
+ // Default-off: maxRetriesPerTask=0 → original abort behavior preserved.
1332
+ const rerun = shouldRerunFailedTask(failed, ctx.input.limits);
1333
+ if (rerun.rerun) {
1334
+ ctx.tasks = ctx.tasks.map((item) =>
1335
+ item.id === failed.id
1336
+ ? {
1337
+ ...item,
1338
+ status: "queued" as const,
1339
+ policy: {
1340
+ ...(item.policy ?? {}),
1341
+ retryCount: rerun.newRetryCount,
1342
+ },
1343
+ error: undefined,
1344
+ finishedAt: undefined,
1345
+ }
1346
+ : item,
1347
+ );
1348
+ await saveRunTasksAsync(ctx.manifest, ctx.tasks);
1349
+ await appendEventAsync(ctx.manifest.eventsPath, {
1350
+ type: "recovery.rerun_task",
1351
+ runId: ctx.manifest.runId,
1352
+ taskId: failed.id,
1353
+ message: `Re-queuing failed task for whole-task rerun: ${rerun.reason}`,
1354
+ data: {
1355
+ attempt: rerun.newRetryCount,
1356
+ maxRetries: ctx.input.limits?.maxRetriesPerTask ?? 0,
1357
+ scenario: "task_failed",
1358
+ },
1359
+ });
1360
+ return { kind: "continue" }; // loop re-processes the re-queued task
1361
+ }
1362
+ // RT-NEW-2: drain in-flight units + merge settled results, then cancel
1363
+ // non-settled in-flight tasks and markBlocked the rest — via the shared
1364
+ // terminaliseRunWithDrain helper (extracted from this function verbatim).
1365
+ const result = await terminaliseRunWithDrain(ctx, {
1366
+ cancelMessage: `Cancelled by failed task '${failed.id}'.`,
1367
+ blockedMessage: `Blocked by failed task '${failed.id}'.`,
1368
+ failedReason: `Failed at task '${failed.id}'.`,
1369
+ });
1370
+ return { kind: "return", result };
1353
1371
  }
1354
1372
 
1355
1373
  /**
@@ -2098,8 +2116,8 @@ async function advanceWorkflowPhases(ctx: SchedulerContext): Promise<void> {
2098
2116
  */
2099
2117
  async function enforceRunBudget(ctx: SchedulerContext): Promise<SchedulerDecision | null> {
2100
2118
  const input = ctx.input;
2101
- let tasks = ctx.tasks;
2102
- let manifest = ctx.manifest;
2119
+ const tasks = ctx.tasks;
2120
+ const manifest = ctx.manifest;
2103
2121
  // Per-task budget enforcement: check cumulative usage after each batch merge.
2104
2122
  // This prevents a single task from consuming 100% of the budget before
2105
2123
  // abort triggers (the goal-loop only checks at turn boundaries).
@@ -2121,12 +2139,16 @@ async function enforceRunBudget(ctx: SchedulerContext): Promise<SchedulerDecisio
2121
2139
  threshold: "abort",
2122
2140
  },
2123
2141
  });
2124
- tasks = markBlocked(tasks, `Budget abort threshold exceeded: ${message}`);
2125
- await saveRunTasksAsync(manifest, tasks);
2126
- manifest = updateRunStatus(manifest, "failed", message);
2127
- ctx.tasks = tasks;
2128
- ctx.manifest = manifest;
2129
- return { kind: "return", result: { manifest, tasks } };
2142
+ // RT-NEW-2: drain in-flight units + merge settled results before
2143
+ // terminalising, so in-flight tasks become completed/cancelled (not
2144
+ // skipped). Same shared helper handleFailedTask uses. Run-failed
2145
+ // reason stays the budget message.
2146
+ const result = await terminaliseRunWithDrain(ctx, {
2147
+ cancelMessage: `Cancelled by budget abort: ${message}`,
2148
+ blockedMessage: `Budget abort threshold exceeded: ${message}`,
2149
+ failedReason: message,
2150
+ });
2151
+ return { kind: "return", result };
2130
2152
  }
2131
2153
 
2132
2154
  if (budgetCheck.warning) {
@@ -94,7 +94,16 @@ export function collectToolCallsFromEvent(event: unknown): Array<{ tool: string;
94
94
  }
95
95
 
96
96
  function transcriptText(input: CompletionMutationGuardInput): string {
97
- if (input.transcriptPath && fs.existsSync(input.transcriptPath)) return fs.readFileSync(input.transcriptPath, "utf-8");
97
+ if (input.transcriptPath) {
98
+ try {
99
+ return fs.readFileSync(input.transcriptPath, "utf-8");
100
+ } catch (error) {
101
+ // NEW-P4: TOCTOU fix — only ENOENT (transcript deleted between check and read)
102
+ // falls back to stdout; any other read error propagates as before.
103
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return input.stdout ?? "";
104
+ throw error;
105
+ }
106
+ }
98
107
  return input.stdout ?? "";
99
108
  }
100
109
 
@@ -8,17 +8,69 @@ import { type TSchema, Type, TypeRegistry } from "@sinclair/typebox";
8
8
  // LLM tool definition) see the compact `{ type: "string", enum: [...] }`
9
9
  // (~600 chars vs ~1890 for anyOf+const), and Value.Check validates via
10
10
  // the registered predicate.
11
+ //
12
+ // GUARD (v0.9.58 — load-crash fix): TypeRegistry was added in
13
+ // @sinclair/typebox@0.34.50. Pi installs ALL extensions into ONE shared
14
+ // npm store with hoisted deps, and on update it only checks the extension's
15
+ // own package.json version — if pi-crew is already latest, it does NOT re-
16
+ // resolve transitive deps. So an install could land pi-crew@0.9.57 (which
17
+ // needs TypeRegistry) over a stale hoisted @sinclair/typebox@0.34.49 (which
18
+ // lacks it). Under ESM↔CJS interop, `import { TypeRegistry }` then resolves
19
+ // to `undefined`, and the unguarded top-level `TypeRegistry.Set(...)` crashed
20
+ // extension load: "Cannot read properties of undefined (reading 'Set')".
21
+ //
22
+ // We now feature-detect TypeRegistry and skip registration when it is absent;
23
+ // buildStringEnum() falls back to a verbose-but-validating anyOf-of-literals
24
+ // in that case, so the extension ALWAYS loads regardless of the store's
25
+ // typebox. buildStringEnum() is exported so unit tests cover BOTH branches.
11
26
  // ─────────────────────────────────────────────────────────────────────────
12
- TypeRegistry.Set("StringEnum", (schema, value) => {
13
- const s = schema as { enum?: unknown[] };
14
- return typeof value === "string" && Array.isArray(s.enum) && s.enum.includes(value);
15
- });
16
27
  const KIND = Symbol.for("TypeBox.Kind");
17
- function stringEnum(values: readonly string[], description: string): TSchema {
18
- // Empty string accepted: calling models emit "" for unset action; the handler
19
- // treats it as omitted (defaults to "list"). Must be a plain enum member so
20
- // JSON-Schema consumers (pi-ai validation) accept it without coercion.
21
- return Type.Unsafe({ [KIND]: "StringEnum", type: "string", enum: ["", ...values], description });
28
+
29
+ // Feature-detect TypeRegistry.Set (typebox >= 0.34.50). Guarded so a stale
30
+ // hoisted typebox (e.g. 0.34.49 in a shared npm store) cannot crash load.
31
+ export const HAS_TYPE_REGISTRY =
32
+ typeof TypeRegistry === "object" && TypeRegistry !== null && typeof (TypeRegistry as { Set?: unknown }).Set === "function";
33
+
34
+ /** Numeric-string regex: optional sign, integer part, optional decimal.
35
+ * Accepts "0", "0.8", "5000", "-1", "42". Used by the optional numeric
36
+ * fields below to accept the stringified forms pi-ai's tool-argument
37
+ * coercion emits when a Union has a string-literal branch (Literal("")).
38
+ * handleTeamTool coerces the string form back to a number before handlers
39
+ * run, so the TeamToolParamsValue interface (which types these as number)
40
+ * still holds at runtime. */
41
+ const NUMERIC_STRING_RE = "^-?\\d+(\\.\\d+)?$";
42
+
43
+ if (HAS_TYPE_REGISTRY) {
44
+ TypeRegistry.Set("StringEnum", (schema, value) => {
45
+ const s = schema as { enum?: unknown[] };
46
+ return typeof value === "string" && Array.isArray(s.enum) && s.enum.includes(value);
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Build a compact `{ type: "string", enum: [...] }` action-enum schema that
52
+ * Value.Check can validate.
53
+ *
54
+ * - Registry branch (typebox >= 0.34.50): registered "StringEnum" kind →
55
+ * compact enum (EXT-7 optimization, ~600 chars).
56
+ * - Fallback branch (stale hoisted typebox < 0.34.50): `anyOf` of
57
+ * `Type.Literal`s → ~3x larger JSON but natively validating with no custom
58
+ * kind, so Value.Check still works. This is what keeps pi-crew loadable on
59
+ * installs whose shared store still carries an older @sinclair/typebox.
60
+ *
61
+ * `hasRegistry` is injectable so unit tests exercise BOTH branches against the
62
+ * real typebox Type/Value (test/unit/schema/stringenum-typebox-guard.test.ts).
63
+ *
64
+ * Empty string is the unset marker (calling models emit "" for an omitted
65
+ * action; the handler treats it as "list"). It must be a plain member so
66
+ * JSON-Schema consumers (pi-ai validation) accept it without coercion.
67
+ */
68
+ export function buildStringEnum(values: readonly string[], description: string, opts: { hasRegistry?: boolean } = {}): TSchema {
69
+ const hasRegistry = opts.hasRegistry ?? HAS_TYPE_REGISTRY;
70
+ if (hasRegistry) {
71
+ return Type.Unsafe({ [KIND]: "StringEnum", type: "string", enum: ["", ...values], description });
72
+ }
73
+ return Type.Union([Type.Literal(""), ...values.map((v) => Type.Literal(v))], { description });
22
74
  }
23
75
 
24
76
  // ───────────────────────────────────────────────────────────────────────────
@@ -160,7 +212,24 @@ const sharedFields = {
160
212
  }),
161
213
  ),
162
214
  replyFrom: Type.Optional(Type.String({ description: "Task ID sending the reply." })),
163
- replyDeadline: Type.Optional(Type.Integer({ description: "Ms epoch deadline for a reply." })),
215
+ replyDeadline: Type.Optional(
216
+ // Allow the empty-string unset marker (models emit "" when no deadline is
217
+ // set; pi-ai validateToolArguments runs BEFORE the handler and rejects a
218
+ // bare Integer for ""). Mirror the other falsy-allowances (budgetTotal:0).
219
+ // Also accept stringified numbers ("123456") — pi-ai coercion stringifies
220
+ // numeric values when a Union has a string-literal branch. handleTeamTool
221
+ // coerces strings back to numbers before handlers run.
222
+ Type.Union(
223
+ [
224
+ Type.Literal(""),
225
+ Type.Integer({ description: "Ms epoch deadline for a reply." }),
226
+ Type.String({ pattern: NUMERIC_STRING_RE }),
227
+ ],
228
+ {
229
+ description: "Ms epoch deadline for a reply.",
230
+ },
231
+ ),
232
+ ),
164
233
  planPath: Type.Optional(
165
234
  Type.String({
166
235
  description: "Path to a markdown plan document for orchestration.",
@@ -174,9 +243,18 @@ const sharedFields = {
174
243
  }),
175
244
  ),
176
245
  interval: Type.Optional(
177
- Type.Number({
178
- description: "Interval in milliseconds between recurring scheduled runs.",
179
- }),
246
+ // Empty-string unset marker accepted (models emit "" when no interval; pi-ai
247
+ // validateToolArguments rejects bare Number for "" before the handler runs).
248
+ // Also accept stringified numbers (pi-ai coercion); handleTeamTool coerces
249
+ // back to number.
250
+ Type.Union(
251
+ [
252
+ Type.Literal(""),
253
+ Type.Number({ description: "Interval in milliseconds between recurring scheduled runs." }),
254
+ Type.String({ pattern: NUMERIC_STRING_RE }),
255
+ ],
256
+ { description: "Interval in milliseconds between recurring scheduled runs." },
257
+ ),
180
258
  ),
181
259
  once: Type.Optional(
182
260
  // Boolean false accepted: calling models emit false for unset; treated as omitted.
@@ -212,20 +290,44 @@ const sharedFields = {
212
290
  }),
213
291
  ),
214
292
  budgetWarning: Type.Optional(
215
- Type.Number({
216
- description:
217
- "Budget warning threshold as a fraction (0-1). Default: 0.8 (80%). Emits warning event when this threshold is crossed.",
218
- minimum: 0,
219
- maximum: 1,
220
- }),
293
+ // Empty-string unset marker accepted (Tier-9: models emit "" when unset).
294
+ // Also accept stringified numbers (pi-ai coercion); handleTeamTool coerces.
295
+ Type.Union(
296
+ [
297
+ Type.Literal(""),
298
+ Type.Number({
299
+ description:
300
+ "Budget warning threshold as a fraction (0-1). Default: 0.8 (80%). Emits warning event when this threshold is crossed.",
301
+ minimum: 0,
302
+ maximum: 1,
303
+ }),
304
+ Type.String({ pattern: NUMERIC_STRING_RE }),
305
+ ],
306
+ {
307
+ description:
308
+ "Budget warning threshold as a fraction (0-1). Default: 0.8 (80%). Emits warning event when this threshold is crossed.",
309
+ },
310
+ ),
221
311
  ),
222
312
  budgetAbort: Type.Optional(
223
- Type.Number({
224
- description:
225
- "Budget abort threshold as a fraction (0-1). Default: 0.95 (95%). Aborts further execution when this threshold is crossed.",
226
- minimum: 0,
227
- maximum: 1,
228
- }),
313
+ // Empty-string unset marker accepted (Tier-9: models emit "" when unset).
314
+ // Also accept stringified numbers (pi-ai coercion); handleTeamTool coerces.
315
+ Type.Union(
316
+ [
317
+ Type.Literal(""),
318
+ Type.Number({
319
+ description:
320
+ "Budget abort threshold as a fraction (0-1). Default: 0.95 (95%). Aborts further execution when this threshold is crossed.",
321
+ minimum: 0,
322
+ maximum: 1,
323
+ }),
324
+ Type.String({ pattern: NUMERIC_STRING_RE }),
325
+ ],
326
+ {
327
+ description:
328
+ "Budget abort threshold as a fraction (0-1). Default: 0.95 (95%). Aborts further execution when this threshold is crossed.",
329
+ },
330
+ ),
229
331
  ),
230
332
  runKind: Type.Optional(
231
333
  Type.Union([Type.Literal(""), Type.Literal("team-run"), Type.Literal("goal-loop"), Type.Literal("dynamic-workflow")], {
@@ -234,11 +336,23 @@ const sharedFields = {
234
336
  }),
235
337
  ),
236
338
  tokenBudget: Type.Optional(
237
- Type.Number({
238
- description:
239
- "Per-workflow token budget for dynamic-workflow runs. When set, ctx.agent() auto-rejects with ok:false once exhausted. Accumulated from each agent run's reported usage. Overrides workflow.maxTokenBudget.",
240
- minimum: 0,
241
- }),
339
+ // Empty-string unset marker accepted (Tier-9: models emit "" when unset).
340
+ // Also accept stringified numbers (pi-ai coercion); handleTeamTool coerces.
341
+ Type.Union(
342
+ [
343
+ Type.Literal(""),
344
+ Type.Number({
345
+ description:
346
+ "Per-workflow token budget for dynamic-workflow runs. When set, ctx.agent() auto-rejects with ok:false once exhausted. Accumulated from each agent run's reported usage. Overrides workflow.maxTokenBudget.",
347
+ minimum: 0,
348
+ }),
349
+ Type.String({ pattern: NUMERIC_STRING_RE }),
350
+ ],
351
+ {
352
+ description:
353
+ "Per-workflow token budget for dynamic-workflow runs. When set, ctx.agent() auto-rejects with ok:false once exhausted. Accumulated from each agent run's reported usage. Overrides workflow.maxTokenBudget.",
354
+ },
355
+ ),
242
356
  ),
243
357
  args: Type.Optional(Type.Any()),
244
358
  analysis: Type.Optional(
@@ -267,7 +381,7 @@ const sharedFields = {
267
381
  const ACTION_DESCRIPTION = "Team action. Defaults to 'list' when omitted.";
268
382
 
269
383
  const RUN_ACTIONS = ["run", "parallel", "plan", "orchestrate", "resume", "retry", "wait", "steer", "goal"] as const;
270
- const runActions = Type.Optional(stringEnum(RUN_ACTIONS, ACTION_DESCRIPTION));
384
+ const runActions = Type.Optional(buildStringEnum(RUN_ACTIONS, ACTION_DESCRIPTION));
271
385
 
272
386
  const STATUS_ACTIONS = [
273
387
  "status",
@@ -287,10 +401,10 @@ const STATUS_ACTIONS = [
287
401
  "recommend",
288
402
  "help",
289
403
  ] as const;
290
- const statusActions = Type.Optional(stringEnum(STATUS_ACTIONS, ACTION_DESCRIPTION));
404
+ const statusActions = Type.Optional(buildStringEnum(STATUS_ACTIONS, ACTION_DESCRIPTION));
291
405
 
292
406
  const CONTROL_ACTIONS = ["cancel", "invalidate", "respond", "cleanup", "prune", "forget", "doctor"] as const;
293
- const controlActions = Type.Optional(stringEnum(CONTROL_ACTIONS, ACTION_DESCRIPTION));
407
+ const controlActions = Type.Optional(buildStringEnum(CONTROL_ACTIONS, ACTION_DESCRIPTION));
294
408
 
295
409
  const MANAGE_ACTIONS = [
296
410
  "create",
@@ -310,10 +424,10 @@ const MANAGE_ACTIONS = [
310
424
  "imports",
311
425
  "export",
312
426
  ] as const;
313
- const manageActions = Type.Optional(stringEnum(MANAGE_ACTIONS, ACTION_DESCRIPTION));
427
+ const manageActions = Type.Optional(buildStringEnum(MANAGE_ACTIONS, ACTION_DESCRIPTION));
314
428
 
315
429
  const AUTOMATE_ACTIONS = ["schedule", "scheduled", "anchor", "auto-summarize", "auto_boomerang", "api"] as const;
316
- const automateActions = Type.Optional(stringEnum(AUTOMATE_ACTIONS, ACTION_DESCRIPTION));
430
+ const automateActions = Type.Optional(buildStringEnum(AUTOMATE_ACTIONS, ACTION_DESCRIPTION));
317
431
 
318
432
  // ─── Domain schemas (additionalProperties: true — Phase 1, not tightened) ────
319
433
 
@@ -343,8 +457,8 @@ export const allActionLiterals = ([runActions, statusActions, controlActions, ma
343
457
  (set.anyOf as { const: string }[] | undefined) ??
344
458
  (Array.isArray(set.enum) ? set.enum.map((v: unknown) => ({ const: v })) : []) ??
345
459
  [];
346
- // stringEnum accepts "" as an unset marker for model callers; it is NOT an
347
- // action, so exclude it from the literal/type/suggestion derivations.
460
+ // buildStringEnum accepts "" as an unset marker for model callers; it is NOT
461
+ // an action, so exclude it from the literal/type/suggestion derivations.
348
462
  return literals.filter((l) => l.const !== "");
349
463
  },
350
464
  );
@@ -366,7 +480,7 @@ export type TeamAction =
366
480
  export const TeamToolParams = Type.Object(
367
481
  {
368
482
  action: Type.Optional(
369
- stringEnum(
483
+ buildStringEnum(
370
484
  allActionLiterals.map((l) => (l as { const: string }).const),
371
485
  ACTION_DESCRIPTION,
372
486
  ),
@@ -20,11 +20,16 @@
20
20
  * The validator runs once per skill at discovery. Discovery is already cached
21
21
  * (`CACHE_TTL_MS = 30_000` in discover-skills.ts) so the validation cost is
22
22
  * bounded regardless of how many skills exist.
23
+ *
24
+ * NEW-P5: the `yaml` package is now LAZY-LOADED on first parse (see getYaml)
25
+ * instead of imported at module load, so its ~280KB CJS graph only enters the
26
+ * process when a skill frontmatter is actually validated. The public sync API
27
+ * is unchanged.
23
28
  */
24
29
 
25
30
  import * as fs from "node:fs";
31
+ import { createRequire } from "node:module";
26
32
  import * as path from "node:path";
27
- import yaml from "yaml";
28
33
 
29
34
  /**
30
35
  * Properties allowed in SKILL.md frontmatter.
@@ -57,6 +62,27 @@ const NAME_MAX_LEN = 64;
57
62
  const DESCRIPTION_MAX_LEN = 1024;
58
63
  const VERSION_REGEX = /^\d+\.\d+(\.\d+)?(-[A-Za-z0-9.-]+)?$/;
59
64
 
65
+ // NEW-P5: lazy-load the `yaml` package on FIRST USE instead of at module load.
66
+ // Skill validation is rare (once per skill at discovery, and discovery is
67
+ // cached for 30s), but the `yaml` parser is ~280KB — eagerly importing it at
68
+ // module load would pull its whole CJS graph (stringify/schema/parse) into
69
+ // every cold start even when no skill frontmatter is ever validated. The
70
+ // synchronous lazy pattern via createRequire keeps the public (sync) API of
71
+ // parseSkillFrontmatter/validateSkillFrontmatter unchanged. In the esbuild
72
+ // bundle, yaml is a bundled CJS module resolved through the injected
73
+ // createRequire shim (same mechanism as utils/git.ts's hosted-git-info).
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ let yamlModule: typeof import("yaml") | undefined;
76
+ function getYaml(): typeof import("yaml") {
77
+ if (!yamlModule) {
78
+ // LAZY: defer dynamic import of node:module to its call site.
79
+ const require = createRequire(import.meta.url);
80
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
81
+ yamlModule = require("yaml") as typeof import("yaml");
82
+ }
83
+ return yamlModule;
84
+ }
85
+
60
86
  /**
61
87
  * Structured error so callers (capability inventory, logs) can present
62
88
  * actionable diagnostics instead of silently dropping malformed skills.
@@ -104,7 +130,7 @@ export function parseSkillFrontmatter(content: string): { ok: true; data: Record
104
130
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
105
131
  if (!match) return { ok: true, data: {} };
106
132
  try {
107
- const parsed = yaml.parse(match[1]);
133
+ const parsed = getYaml().parse(match[1]);
108
134
  if (parsed === null || parsed === undefined) return { ok: true, data: {} };
109
135
  if (typeof parsed !== "object" || Array.isArray(parsed)) {
110
136
  return {
@@ -537,16 +537,16 @@ export type WriteDurability = "full" | "best-effort";
537
537
  /** Options accepted by atomicWriteFile (forward-compatible string | object form). */
538
538
  export type AtomicWriteOptions =
539
539
  | string // legacy: expectedHash
540
- | { expectedHash?: string; durability?: WriteDurability; mode?: number };
540
+ | { expectedHash?: string; durability?: WriteDurability; mode?: number; compact?: boolean };
541
541
 
542
- function normalizeOptions(arg: unknown): { expectedHash?: string; durability: WriteDurability; mode?: number } {
543
- if (typeof arg === "string") return { expectedHash: arg, durability: "full", mode: undefined };
542
+ function normalizeOptions(arg: unknown): { expectedHash?: string; durability: WriteDurability; mode?: number; compact?: boolean } {
543
+ if (typeof arg === "string") return { expectedHash: arg, durability: "full", mode: undefined, compact: undefined };
544
544
  if (arg && typeof arg === "object") {
545
- const o = arg as { expectedHash?: string; durability?: WriteDurability; mode?: number };
545
+ const o = arg as { expectedHash?: string; durability?: WriteDurability; mode?: number; compact?: boolean };
546
546
  const durability: WriteDurability = o.durability === "best-effort" ? "best-effort" : "full";
547
- return { expectedHash: o.expectedHash, durability, mode: o.mode };
547
+ return { expectedHash: o.expectedHash, durability, mode: o.mode, compact: o.compact };
548
548
  }
549
- return { durability: "full", mode: undefined };
549
+ return { durability: "full", mode: undefined, compact: undefined };
550
550
  }
551
551
 
552
552
  export function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): void {
@@ -720,7 +720,7 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
720
720
 
721
721
  export async function atomicWriteFileAsync(filePath: string, content: string, options?: AtomicWriteOptions): Promise<void> {
722
722
  cancelPendingCoalescedWrite(filePath);
723
- const { durability } = normalizeOptions(options);
723
+ const { durability, mode } = normalizeOptions(options);
724
724
  // Phase 1.5 (RFC 15): when the worker-thread atomic writer is enabled
725
725
  // (PI_CREW_WORKER_ATOMIC_WRITER=1), dispatch to a dedicated worker thread
726
726
  // that performs SYNC fs ops with no internal yields. Mitigates the
@@ -736,7 +736,11 @@ export async function atomicWriteFileAsync(filePath: string, content: string, op
736
736
  let fd: fs.promises.FileHandle | undefined;
737
737
  try {
738
738
  const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
739
- fd = await fs.promises.open(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
739
+ fd = await fs.promises.open(
740
+ tempPath,
741
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW,
742
+ mode ?? 0o600,
743
+ );
740
744
  // Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
741
745
  const openedStat = await fd.stat();
742
746
  if (!openedStat.isFile()) {
@@ -802,11 +806,16 @@ export async function atomicWriteFileAsync(filePath: string, content: string, op
802
806
  }
803
807
 
804
808
  export function atomicWriteJson<T>(filePath: string, value: T, options?: AtomicWriteOptions): void {
805
- atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`, options);
809
+ // PERF-6: compact (no indentation) for machine-only state files — pretty-printing
810
+ // inflates them ~30-40%. Default stays pretty for human-debuggable files.
811
+ const { compact } = normalizeOptions(options);
812
+ atomicWriteFile(filePath, `${compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}\n`, options);
806
813
  }
807
814
 
808
815
  export async function atomicWriteJsonAsync<T>(filePath: string, value: T, options?: AtomicWriteOptions): Promise<void> {
809
- await atomicWriteFileAsync(filePath, `${JSON.stringify(value, null, 2)}\n`, options);
816
+ // PERF-6: mirror the sync variant's compact knob.
817
+ const { compact } = normalizeOptions(options);
818
+ await atomicWriteFileAsync(filePath, `${compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}\n`, options);
810
819
  }
811
820
 
812
821
  // 2.1 — atomic-write coalescer. Buffer the latest payload per filePath and
@@ -871,14 +880,16 @@ export function atomicWriteJsonCoalesced<T>(
871
880
  atomicWriteJson(filePath, value, options);
872
881
  return;
873
882
  }
874
- const content = `${JSON.stringify(value, null, 2)}\n`;
883
+ // PERF-6: honor compact — normalize BEFORE serializing so the buffered content
884
+ // uses the caller's preferred formatting.
885
+ const normalized = normalizeOptions(options);
886
+ const content = `${normalized.compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}\n`;
875
887
  const previous = pendingAtomicWrites.get(filePath);
876
888
  if (previous) clearTimeout(previous.timer);
877
889
  const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), coalesceMs);
878
890
  timer.unref();
879
891
  // Issue 2 fix: increment generation for each new entry
880
892
  const generation = ++writeGeneration;
881
- const normalized = normalizeOptions(options);
882
893
  pendingAtomicWrites.set(filePath, {
883
894
  content,
884
895
  timer,
@@ -81,6 +81,7 @@ export const TEAM_EVENT_TYPES = [
81
81
  "worktree.dirty",
82
82
  "async.spawned",
83
83
  "async.started",
84
+ "async.signal",
84
85
  "async.completed",
85
86
  "async.failed",
86
87
  "async.stale",