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.
- package/CHANGELOG.md +49 -0
- package/dist/index.mjs +22609 -21373
- package/package.json +1 -1
- package/skills/distill-software/SKILL.md +7 -1
- package/src/agents/discover-agents.ts +6 -0
- package/src/config/config.ts +13 -2
- package/src/config/markers.ts +9 -1
- package/src/extension/action-suggestions.ts +1 -1
- package/src/extension/async-notifier.ts +13 -5
- package/src/extension/registration/context-builder.ts +5 -2
- package/src/extension/registration/crash-recovery-cache.ts +2 -2
- package/src/extension/registration/lazy-configurers.ts +1 -1
- package/src/extension/registration/lifecycle-handlers.ts +25 -10
- package/src/extension/registration/observability.ts +8 -4
- package/src/extension/registration/registration-types.ts +2 -2
- package/src/extension/registration/runtime-cleanup.ts +10 -2
- package/src/extension/registration/subagent-manager-setup.ts +9 -4
- package/src/extension/registration/subagent-tools.ts +15 -3
- package/src/extension/run-import.ts +19 -2
- package/src/extension/run-maintenance.ts +25 -9
- package/src/extension/session-summary.ts +5 -0
- package/src/extension/team-tool/destructive-gate.ts +12 -6
- package/src/extension/team-tool/health-monitor.ts +7 -5
- package/src/extension/team-tool/intent-policy.ts +9 -0
- package/src/extension/team-tool/lifecycle-actions.ts +27 -4
- package/src/extension/team-tool/run-deadline.ts +7 -2
- package/src/extension/team-tool/run.ts +23 -4
- package/src/extension/team-tool/status.ts +2 -2
- package/src/extension/team-tool.ts +126 -51
- package/src/runtime/background-runner.ts +36 -2
- package/src/runtime/delivery-coordinator.ts +24 -3
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/model/model-fallback.ts +3 -1
- package/src/runtime/model/provider-extensions.ts +36 -0
- package/src/runtime/model/runtime-warmup.ts +41 -0
- package/src/runtime/peer-dep.ts +35 -8
- package/src/runtime/recovery/crash-recovery.ts +34 -4
- package/src/runtime/skill-instructions.ts +1 -1
- package/src/runtime/stale-reconciler.ts +11 -19
- package/src/runtime/subagent-manager.ts +15 -6
- package/src/runtime/task-packet.ts +26 -4
- package/src/runtime/task-runner/run-projection.ts +4 -1
- package/src/runtime/team-runner.ts +94 -72
- package/src/runtime/verification/completion-guard.ts +10 -1
- package/src/schema/team-tool-schema.ts +152 -38
- package/src/skills/validate.ts +28 -2
- package/src/state/atomic-write.ts +23 -12
- package/src/state/contracts.ts +1 -0
- package/src/state/coordination/locks.ts +20 -7
- package/src/state/coordination/mailbox.ts +20 -2
- package/src/state/gitignore-manager.ts +5 -1
- package/src/state/stores/artifact-store.ts +5 -5
- package/src/state/stores/run-cache.ts +9 -1
- package/src/state/stores/state-store.ts +64 -10
- package/src/ui/deploy-bundled-themes.ts +11 -0
- package/src/ui/powerbar-publisher.ts +31 -6
- package/src/ui/run-dashboard.ts +7 -1
- package/src/ui/syntax-highlight.ts +31 -12
- package/src/ui/widget/index.ts +9 -1
- package/src/ui/widget/widget-model.ts +1 -1
- package/src/ui/widget/widget-types.ts +3 -0
- package/src/utils/env-filter.ts +25 -11
- package/src/utils/paths.ts +20 -1
- package/src/utils/redaction.ts +22 -1
- package/src/utils/session-utils.ts +42 -19
- 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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
//
|
|
1336
|
-
// so team resume CAN re-queue them. markBlocked maps queued→skipped,
|
|
1337
|
-
// resume never re-queues — work would be lost permanently. Only
|
|
1338
|
-
// tasks that are both in-flight AND still non-terminal (settled
|
|
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
|
-
|
|
1311
|
+
opts.cancelMessage,
|
|
1344
1312
|
(task) => inflightTaskIds.has(task.id) && isNonTerminalTaskStatus(task.status),
|
|
1345
1313
|
);
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
ctx.tasks = markBlocked(ctx.tasks,
|
|
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",
|
|
1352
|
-
return {
|
|
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
|
-
|
|
2102
|
-
|
|
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
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
483
|
+
buildStringEnum(
|
|
370
484
|
allActionLiterals.map((l) => (l as { const: string }).const),
|
|
371
485
|
ACTION_DESCRIPTION,
|
|
372
486
|
),
|
package/src/skills/validate.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|