pi-crew 0.6.0 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -72,10 +72,16 @@ export class RetryRunner {
72
72
  private _disposed = false;
73
73
  private _handoffs: HandoffSummary[] = [];
74
74
 
75
+ private taskRunner: TaskRunnerLike;
76
+ private handoffManager: HandoffManager;
77
+
75
78
  constructor(
76
- private taskRunner: TaskRunnerLike,
77
- private handoffManager: HandoffManager,
78
- ) {}
79
+ taskRunner: TaskRunnerLike,
80
+ handoffManager: HandoffManager,
81
+ ) {
82
+ this.taskRunner = taskRunner;
83
+ this.handoffManager = handoffManager;
84
+ }
79
85
 
80
86
  /**
81
87
  * Check if this runner has been disposed.
@@ -208,7 +208,7 @@ export function runDriftDetection(ctx: DriftContext, maxPasses = 2): DriftReport
208
208
  newFindings++;
209
209
  }
210
210
  } catch (error) {
211
- logInternalError("drift-detectors", error, `detector=${detector.name} runId=${ctx.manifest?.runId}`);
211
+ logInternalError("run-drift", error, `detector=${detector.name} runId=${ctx.manifest?.runId}`);
212
212
  }
213
213
  }
214
214
 
@@ -1,21 +1,31 @@
1
- import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
2
1
  import * as fs from "node:fs";
3
2
  import * as path from "node:path";
4
3
  import { loadRunManifestById } from "../state/state-store.ts";
4
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
+ import { projectCrewRoot } from "../utils/paths.ts";
5
6
  import { isFinishedRunStatus } from "./process-status.ts";
6
7
 
7
8
  export interface ActiveRunPromise {
8
9
  promise: Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }>;
9
- resolve: (value: { manifest: TeamRunManifest; tasks: TeamTaskState[] }) => void;
10
+ resolve: (value: {
11
+ manifest: TeamRunManifest;
12
+ tasks: TeamTaskState[];
13
+ }) => void;
10
14
  reject: (reason: unknown) => void;
11
15
  }
12
16
 
13
17
  const activeRunPromises = new Map<string, ActiveRunPromise>();
14
18
 
15
19
  export function registerRunPromise(runId: string): ActiveRunPromise {
16
- let resolve!: (value: { manifest: TeamRunManifest; tasks: TeamTaskState[] }) => void;
20
+ let resolve!: (value: {
21
+ manifest: TeamRunManifest;
22
+ tasks: TeamTaskState[];
23
+ }) => void;
17
24
  let reject!: (reason: unknown) => void;
18
- const promise = new Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }>((res, rej) => {
25
+ const promise = new Promise<{
26
+ manifest: TeamRunManifest;
27
+ tasks: TeamTaskState[];
28
+ }>((res, rej) => {
19
29
  resolve = res;
20
30
  reject = rej;
21
31
  });
@@ -24,7 +34,10 @@ export function registerRunPromise(runId: string): ActiveRunPromise {
24
34
  return entry;
25
35
  }
26
36
 
27
- export function resolveRunPromise(runId: string, result: { manifest: TeamRunManifest; tasks: TeamTaskState[] }): void {
37
+ export function resolveRunPromise(
38
+ runId: string,
39
+ result: { manifest: TeamRunManifest; tasks: TeamTaskState[] },
40
+ ): void {
28
41
  const entry = activeRunPromises.get(runId);
29
42
  if (entry) {
30
43
  entry.resolve(result);
@@ -55,7 +68,7 @@ export async function waitForRun(
55
68
  const deadline = Date.now() + timeoutMs;
56
69
 
57
70
  // Fast path: already terminal on disk
58
- const loaded = loadRunManifestById(cwd, runId);
71
+ const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
59
72
  if (loaded && isFinishedRunStatus(loaded.manifest.status)) {
60
73
  return loaded;
61
74
  }
@@ -65,7 +78,13 @@ export async function waitForRun(
65
78
  if (entry) {
66
79
  let timer: ReturnType<typeof setTimeout> | undefined;
67
80
  const timeoutPromise = new Promise<never>((_, reject) => {
68
- timer = setTimeout(() => reject(new Error(`waitForRun timed out after ${timeoutMs}ms`)), timeoutMs);
81
+ timer = setTimeout(
82
+ () =>
83
+ reject(
84
+ new Error(`waitForRun timed out after ${timeoutMs}ms`),
85
+ ),
86
+ timeoutMs,
87
+ );
69
88
  });
70
89
  try {
71
90
  return await Promise.race([entry.promise, timeoutPromise]);
@@ -78,15 +97,24 @@ export async function waitForRun(
78
97
  let attempt = 0;
79
98
  while (Date.now() < deadline) {
80
99
  if (attempt === 0) {
81
- // Early exit: if the run directory doesn't exist, don't waste time polling
82
- const runDir = path.join(cwd, ".crew", "state", "runs", runId);
100
+ // Early exit: if the run directory doesn't exist, don't waste time polling.
101
+ // Use projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
102
+ // projects (see issue #29). Without this, the hardcoded `.crew/state/runs/`
103
+ // path never resolves in projects that use the `.pi/` layout, the throw
104
+ // escapes via subagent-manager.ts:281, and pi crashes with uncaughtException.
105
+ const runDir = path.join(
106
+ projectCrewRoot(cwd),
107
+ "state",
108
+ "runs",
109
+ runId,
110
+ );
83
111
  if (!fs.existsSync(runDir)) {
84
112
  throw new Error(
85
113
  `Run ${runId} not found. No run directory at ${runDir}`,
86
114
  );
87
115
  }
88
116
  }
89
- const fresh = loadRunManifestById(cwd, runId);
117
+ const fresh = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
90
118
  if (fresh && isFinishedRunStatus(fresh.manifest.status)) {
91
119
  return fresh;
92
120
  }
@@ -21,28 +21,38 @@ const FORBIDDEN_PATTERNS = [
21
21
  // Global escape vectors
22
22
  /\bglobalThis\b/, // globalThis reference
23
23
  /\bglobal\b/, // global reference (Node.js)
24
+ // Block constructor chain escape vectors:
25
+ // - `.constructor` followed by `(`, `.`, `;`, or end-of-line/string
26
+ // - `[constructor]` or `["constructor"]` bracket access on any value
27
+ // The bare `constructor` keyword in a class body is safe and allowed.
28
+ /\.constructor\s*\(/, // Block obj.constructor() chain calls
29
+ /\.constructor\s*(?:\.|$|;|,|\s)/, // Block obj.constructor at end, semicolon, comma, or whitespace
30
+ /\[\s*['"]?constructor['"]?\s*\]/, // Block ["constructor"] or ['constructor'] or [constructor]
24
31
  ] as const;
25
32
 
33
+ Object.freeze(FORBIDDEN_PATTERNS);
34
+
26
35
  /**
27
- * Whitelist of allowed identifiers for strict mode.
28
- * Only these identifiers can be used in sandboxed code.
36
+ * SECURITY (HIGH #3 fix): Normalize source code before forbidden-pattern checks
37
+ * to prevent unicode-escape bypasses.
38
+ *
39
+ * Attackers can write `import\u0028"fs"\u0029` which compiles as
40
+ * `import("fs")` but does not match the regex `/import\s*\(/`.
41
+ *
42
+ * This function:
43
+ * 1. Strips null bytes (used to split keywords across boundaries)
44
+ * 2. Decodes \uXXXX escape sequences so regexes see the actual characters
29
45
  */
30
- const ALLOWED_IDENTIFIERS = new Set([
31
- // Built-in constructors
32
- "Array", "Boolean", "Date", "Error", "Function", "JSON", "Map", "Number", "Object", "Promise", "RegExp", "Set", "String", "Symbol",
33
- // Static methods
34
- "ArrayBuffer", "Uint8Array", "parseInt", "parseFloat", "isNaN", "isFinite",
35
- // URI encoding
36
- "encodeURI", "decodeURI", "encodeURIComponent", "decodeURIComponent",
37
- // Math (read-only)
38
- "Math",
39
- // Console (safe methods only)
40
- "console",
41
- // Process (limited)
42
- "process",
43
- ]);
44
-
45
- Object.freeze(FORBIDDEN_PATTERNS);
46
+ export function normalizeCodeForValidation(code: string): string {
47
+ // Strip null bytes
48
+ let normalized = code.replace(/\0/g, "");
49
+ // Decode common unicode escapes: \u0028 → (
50
+ normalized = normalized.replace(
51
+ /\\u([0-9a-fA-F]{4})/g,
52
+ (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)),
53
+ );
54
+ return normalized;
55
+ }
46
56
 
47
57
  export interface SandboxOptions {
48
58
  timeout?: number;
@@ -76,7 +86,16 @@ export class WorkflowSandbox {
76
86
  const safeEnv = Object.freeze(sanitizeEnvSecrets(process.env, {
77
87
  allowList: [
78
88
  "NODE_ENV",
79
- "PI_CREW_*",
89
+ // Note: PI_CREW_* globs are not used here because isDangerousGlob
90
+ // flags them as potentially matching secret env vars (PI_CREW_token,
91
+ // PI_CREW_api_key, etc.). Instead, list the specific PI_CREW env vars
92
+ // that sandboxed code legitimately needs.
93
+ "PI_CREW_DEPTH",
94
+ "PI_CREW_INHERIT_PROJECT_CONTEXT",
95
+ "PI_CREW_INHERIT_SKILLS",
96
+ "PI_CREW_MOCK_LIVE_SESSION",
97
+ "PI_CREW_SKIP_HOME_CHECK",
98
+ "PI_CREW_WARM_POOL_SIZE",
80
99
  "PATH",
81
100
  "PATH_SEPARATOR",
82
101
  "USERPROFILE",
@@ -204,15 +223,17 @@ export class WorkflowSandbox {
204
223
  * ensure compilation is safe.
205
224
  */
206
225
  private validateScript(code: string): void {
226
+ // SECURITY (HIGH #3 fix): Normalize unicode escapes before pattern matching
227
+ const normalized = normalizeCodeForValidation(code);
207
228
  // Check for ESM/module patterns
208
229
  for (const pattern of FORBIDDEN_PATTERNS) {
209
- if (pattern.test(code)) {
230
+ if (pattern.test(normalized)) {
210
231
  throw new Error(`Forbidden pattern detected: ${pattern.source}`);
211
232
  }
212
233
  }
213
234
 
214
235
  // Check for import.meta specifically (C4)
215
- if (/import\.meta/.test(code)) {
236
+ if (/import\.meta/.test(normalized)) {
216
237
  throw new Error("import.meta is not allowed in sandboxed code");
217
238
  }
218
239
 
@@ -15,11 +15,13 @@ export interface ScheduledJob {
15
15
  lastStatus?: "success" | "error" | "running";
16
16
  nextRun?: string;
17
17
  runCount: number;
18
+ /** Run IDs spawned by this job. Used to cancel runs when job is removed. */
19
+ spawnedRunIds?: string[];
18
20
  }
19
21
 
20
22
  export type ScheduleChangeEvent =
21
23
  | { type: "added"; job: ScheduledJob }
22
- | { type: "removed"; jobId: string }
24
+ | { type: "removed"; jobId: string; spawnedRunIds?: string[] }
23
25
  | { type: "updated"; job: ScheduledJob }
24
26
  | { type: "fired"; jobId: string; agentId: string; name: string }
25
27
  | { type: "error"; jobId: string; error: string };
@@ -30,17 +32,21 @@ export class CrewScheduler {
30
32
  private emit?: (event: ScheduleChangeEvent) => void;
31
33
  private executor?: (job: ScheduledJob) => string;
32
34
  private finalizer?: (jobId: string, agentId: string) => void;
35
+ private runCancelFn?: (runId: string) => void;
33
36
 
34
37
  start(
35
38
  options: {
36
39
  emit: (event: ScheduleChangeEvent) => void;
37
40
  executor: (job: ScheduledJob) => string;
38
41
  finalizer: (jobId: string, agentId: string) => void;
42
+ /** Optional callback to cancel a spawned run by runId. */
43
+ runCancelFn?: (runId: string) => void;
39
44
  },
40
45
  ): void {
41
46
  this.emit = options.emit;
42
47
  this.executor = options.executor;
43
48
  this.finalizer = options.finalizer;
49
+ this.runCancelFn = options.runCancelFn;
44
50
  }
45
51
 
46
52
  stop(): void {
@@ -52,6 +58,7 @@ export class CrewScheduler {
52
58
  this.emit = undefined;
53
59
  this.executor = undefined;
54
60
  this.finalizer = undefined;
61
+ this.runCancelFn = undefined;
55
62
  }
56
63
 
57
64
  add(job: ScheduledJob): void {
@@ -61,9 +68,17 @@ export class CrewScheduler {
61
68
  }
62
69
 
63
70
  remove(id: string): boolean {
71
+ const job = this.jobs.get(id);
72
+ const spawnedRunIds = job?.spawnedRunIds;
64
73
  this.disarm(id);
74
+ // Cancel all spawned runs that are still active
75
+ if (spawnedRunIds && this.runCancelFn) {
76
+ for (const runId of spawnedRunIds) {
77
+ try { this.runCancelFn(runId); } catch { /* best-effort */ }
78
+ }
79
+ }
65
80
  const ok = this.jobs.delete(id);
66
- if (ok) this.emit?.({ type: "removed", jobId: id });
81
+ if (ok) this.emit?.({ type: "removed", jobId: id, spawnedRunIds });
67
82
  return ok;
68
83
  }
69
84
 
@@ -82,6 +97,14 @@ export class CrewScheduler {
82
97
  return [...this.jobs.values()];
83
98
  }
84
99
 
100
+ /** Record a runId spawned by a job. Call this after executor fires. */
101
+ recordSpawnedRun(jobId: string, runId: string): void {
102
+ const job = this.jobs.get(jobId);
103
+ if (!job) return;
104
+ const spawnedRunIds = [...(job.spawnedRunIds ?? []), runId];
105
+ this.jobs.set(jobId, { ...job, spawnedRunIds });
106
+ }
107
+
85
108
  private arm(job: ScheduledJob): void {
86
109
  if (this.timers.has(job.id)) return;
87
110
  if (job.scheduleType === "interval" && job.intervalMs) {
@@ -88,7 +88,8 @@ export interface ParallelResult<R> {
88
88
  *
89
89
  * Adapted from oh-my-pi's `mapWithConcurrencyLimit`.
90
90
  */
91
- export async function mapWithFailFast<T, R>(
91
+ /** @internal */
92
+ async function mapWithFailFast<T, R>(
92
93
  items: T[],
93
94
  concurrency: number,
94
95
  fn: (item: T, index: number, signal: AbortSignal) => Promise<R>,
@@ -20,6 +20,18 @@ const MAX_TURNS_CEILING = 10_000;
20
20
  const GRACE_TURNS_CEILING = 1_000;
21
21
  const VALID_JOIN_MODES = new Set<JoinMode>(["async", "group", "smart"]);
22
22
 
23
+ /**
24
+ * M2: Validate that a scheduled job object has required fields before passing to scheduler.
25
+ * Prevents opaque unknown[] from reaching CrewScheduler.add() without validation.
26
+ */
27
+ function validateScheduledJob(job: unknown): boolean {
28
+ if (!job || typeof job !== "object") return false;
29
+ const obj = job as Record<string, unknown>;
30
+ return typeof obj.id === "string" && obj.id.length > 0
31
+ && typeof obj.scheduleType === "string"
32
+ && typeof obj.enabled === "boolean";
33
+ }
34
+
23
35
  function sanitizeSettings(raw: unknown): CrewSettings {
24
36
  if (!raw || typeof raw !== "object") return {};
25
37
  const r = raw as Record<string, unknown>;
@@ -57,9 +69,9 @@ function sanitizeSettings(raw: unknown): CrewSettings {
57
69
  if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
58
70
  out.notifierIntervalMs = r.notifierIntervalMs;
59
71
  }
60
- // Pass through scheduledJobs as opaque array (validated by crewScheduler.add)
72
+ // Pass through scheduledJobs after basic validation
61
73
  if (Array.isArray(r.scheduledJobs)) {
62
- out.scheduledJobs = r.scheduledJobs;
74
+ out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob);
63
75
  }
64
76
  return out;
65
77
  }