pi-crew 0.10.6 → 0.11.0

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 (44) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/index.mjs +34850 -34889
  3. package/package.json +170 -169
  4. package/scripts/watch-agent-transcript.mjs +120 -0
  5. package/src/extension/crew-input-router.ts +2 -0
  6. package/src/extension/crew-vibes/config.ts +1 -56
  7. package/src/extension/crew-vibes/figures.ts +5 -84
  8. package/src/extension/crew-vibes/font-detect.ts +1 -1
  9. package/src/extension/crew-vibes/index.ts +36 -328
  10. package/src/extension/crew-vibes/render.ts +2 -58
  11. package/src/extension/help.ts +1 -0
  12. package/src/extension/registration/command-registration.ts +5 -1
  13. package/src/extension/registration/commands/schedules.ts +212 -0
  14. package/src/extension/registration/commands/shared.ts +56 -1
  15. package/src/extension/registration/lifecycle-handlers.ts +16 -2
  16. package/src/extension/registration/schedule-toast-bridge.ts +109 -0
  17. package/src/extension/registration/viewers.ts +66 -1
  18. package/src/extension/team-tool/handle-schedule.ts +120 -1
  19. package/src/runtime/scheduling/scheduler.ts +120 -6
  20. package/src/runtime/settings-store.ts +28 -2
  21. package/src/state/coordination/locks.ts +17 -2
  22. package/src/ui/agents-jobs-browser.ts +785 -0
  23. package/src/ui/dashboard-panes/agents-pane.ts +7 -4
  24. package/src/ui/dashboard-panes/schedules-pane.ts +154 -0
  25. package/src/ui/inline-panel/crew-editor.ts +19 -6
  26. package/src/ui/inline-panel/index.ts +6 -0
  27. package/src/ui/key-utils.ts +3 -0
  28. package/src/ui/keybinding-map.ts +56 -2
  29. package/src/ui/mascot.ts +2 -1
  30. package/src/ui/overlays/agent-picker-overlay.ts +3 -3
  31. package/src/ui/overlays/confirm-overlay.ts +3 -2
  32. package/src/ui/overlays/mailbox-compose-overlay.ts +4 -3
  33. package/src/ui/overlays/mailbox-detail-overlay.ts +3 -3
  34. package/src/ui/run-dashboard.ts +257 -6
  35. package/src/ui/settings-overlay.ts +3 -3
  36. package/src/ui/transcript-viewer.ts +3 -2
  37. package/src/ui/widget/index.ts +61 -88
  38. package/src/ui/widget/widget-formatters.ts +7 -4
  39. package/src/ui/widget/widget-renderer.ts +134 -174
  40. package/src/ui/widget/widget-types.ts +0 -7
  41. package/src/utils/relative-time.ts +33 -0
  42. package/src/extension/crew-vibes/footer.ts +0 -313
  43. package/src/extension/crew-vibes/speed.ts +0 -286
  44. package/src/ui/dock-footer.ts +0 -49
@@ -1,6 +1,11 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import { humanizeSchedule, nextRunTime, parseSchedule } from "../../runtime/scheduling/scheduler.ts";
3
- import { type CrewSettings, updateCrewSettings } from "../../runtime/settings-store.ts";
3
+ import {
4
+ type CrewSettings,
5
+ getScheduledJobsHiddenCount as computeScheduledJobsHiddenCount,
6
+ loadCrewSettingsTiers,
7
+ updateCrewSettings,
8
+ } from "../../runtime/settings-store.ts";
4
9
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
5
10
  import type { PiTeamsToolResult } from "../tool-result.ts";
6
11
  import { result, type TeamContext } from "./context.ts";
@@ -17,6 +22,8 @@ type SchedulerRef = {
17
22
  id: string,
18
23
  patch: Partial<import("../../runtime/scheduling/scheduler.ts").ScheduledJob>,
19
24
  ): import("../../runtime/scheduling/scheduler.ts").ScheduledJob | undefined;
25
+ /** Trigger a job immediately (force — bypasses the enabled gate). */
26
+ runNow(jobId: string): { ok: true } | { ok: false; error: string };
20
27
  };
21
28
 
22
29
  // Module-scoped scheduler instance — one per extension load (EXT-9).
@@ -31,9 +38,86 @@ export function registerCrewScheduler(scheduler: SchedulerRef): void {
31
38
  crewSchedulerInstance = scheduler;
32
39
  }
33
40
 
41
+ // Module-scoped hidden-count stash (P2-1, EXT-9 pattern): computed ONCE at
42
+ // session_start registration from the SAME tiers read that drove registration
43
+ // (no extra disk I/O), then served to paint-path consumers — the crew widget
44
+ // line and the dashboard pane render ticks must never hit the settings store
45
+ // (P0-6). undefined = never stashed (pre-registration / cleaned up / unit
46
+ // tests with a fake scheduler) → consumers treat it as 0.
47
+ let stashedScheduledJobsHiddenCount: number | undefined;
48
+
49
+ /** @internal — lifecycle wiring: stash the registration-time hidden count. */
50
+ export function stashScheduledJobsHiddenCount(count: number | undefined): void {
51
+ stashedScheduledJobsHiddenCount = count;
52
+ }
53
+
54
+ /** @internal — test seam: read the raw stash (undefined = never stashed). */
55
+ export function getStashedScheduledJobsHiddenCount(): number | undefined {
56
+ return stashedScheduledJobsHiddenCount;
57
+ }
58
+
34
59
  /** Remove the scheduler singleton. Call during session cleanup. */
35
60
  export function unregisterCrewScheduler(): void {
36
61
  crewSchedulerInstance = undefined;
62
+ // P2-1: the hidden-count stash is registration-time state — clear it with
63
+ // the singleton so a later session can never paint a stale gate hint.
64
+ stashedScheduledJobsHiddenCount = undefined;
65
+ }
66
+
67
+ /**
68
+ * SINGLE SOURCE OF TRUTH for scheduled-job READS (G17). Every consumer —
69
+ * dashboard pane, /schedules command, crew widget — must read jobs through
70
+ * this provider; no module may keep its own defaults/settings copy.
71
+ *
72
+ * Reads the module-scoped scheduler singleton when registered (the live view
73
+ * the mutation handlers target); otherwise falls back to the SAME gated tiers
74
+ * view the session-start registration loop consumes (`effectiveScheduledJobs`:
75
+ * user-tier jobs always, project-tier only on explicit opt-in) so the fallback
76
+ * can never disagree with what would register on next session start.
77
+ */
78
+ export function getScheduledJobs(
79
+ cwd: string = process.cwd(),
80
+ globalFile?: string,
81
+ ): import("../../runtime/scheduling/scheduler.ts").ScheduledJob[] {
82
+ const scheduler = getCrewScheduler();
83
+ if (scheduler) return scheduler.list();
84
+ try {
85
+ const tiers = loadCrewSettingsTiers(cwd, globalFile);
86
+ return tiers.effectiveScheduledJobs.filter(isScheduledJobLike);
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ /** Shape guard for the settings fallback — mirrors settings-store's private
93
+ * `validateScheduledJob` (id + scheduleType + enabled) so invalid persisted
94
+ * entries are skipped instead of reaching renderers. */
95
+ function isScheduledJobLike(job: unknown): job is import("../../runtime/scheduling/scheduler.ts").ScheduledJob {
96
+ if (!job || typeof job !== "object") return false;
97
+ const obj = job as Record<string, unknown>;
98
+ return typeof obj.id === "string" && obj.id.length > 0 && typeof obj.scheduleType === "string" && typeof obj.enabled === "boolean";
99
+ }
100
+
101
+ /**
102
+ * P2-1 companion read to getScheduledJobs(): how many project-tier
103
+ * scheduledJobs the B2 gate is hiding right now (for the "N project-tier jobs
104
+ * hidden" hint).
105
+ *
106
+ * With the scheduler singleton registered, serves the REGISTRATION-TIME stash
107
+ * (in-memory — paint-path safe, P0-6) computed from the exact tiers view the
108
+ * registration loop gated. Without a scheduler (pre-registration, headless
109
+ * command usage), computes from the same gated tiers view the provider
110
+ * fallback uses — user-initiated surfaces only (pane/command); the widget's
111
+ * default reader never reaches the compute branch because it gates on the
112
+ * scheduler singleton exactly like defaultScheduledJobsReader.
113
+ */
114
+ export function getScheduledJobsHiddenCountView(cwd: string = process.cwd(), globalFile?: string): number {
115
+ if (getCrewScheduler()) return stashedScheduledJobsHiddenCount ?? 0;
116
+ try {
117
+ return computeScheduledJobsHiddenCount(cwd, globalFile);
118
+ } catch {
119
+ return 0;
120
+ }
37
121
  }
38
122
 
39
123
  interface ScheduleParams {
@@ -108,6 +192,9 @@ export function handleSchedule(params: TeamToolParamsValue, ctx: TeamContext): P
108
192
  if (subAction === "disable" || subAction === "enable" || subAction === "update") {
109
193
  return handleUpdateScheduled(params, ctx);
110
194
  }
195
+ if (subAction === "run-now") {
196
+ return handleRunNowScheduled(params);
197
+ }
111
198
 
112
199
  const team = params.team ?? "default";
113
200
  const goal = params.goal ?? params.task ?? "";
@@ -348,3 +435,35 @@ export function handleUpdateScheduled(params: TeamToolParamsValue, ctx: TeamCont
348
435
  { action: "schedule", status: "ok", data: { jobId, enabled: updated.enabled, schedule: updated.schedule } },
349
436
  );
350
437
  }
438
+
439
+ /**
440
+ * Trigger a scheduled job immediately — the UI "run now" channel. Runs even
441
+ * when the job is disabled (explicit user action); completion tracking stays
442
+ * on the scheduler's async finalization path.
443
+ *
444
+ * Usage:
445
+ * team action='schedule' subAction='run-now' jobId='<uuid>'
446
+ */
447
+ export function handleRunNowScheduled(params: TeamToolParamsValue): PiTeamsToolResult {
448
+ const jobId = getJobIdParam(params);
449
+ if (!jobId) {
450
+ return result(
451
+ "subAction=run-now requires jobId. Usage: team action='schedule' subAction='run-now' jobId='<uuid>'",
452
+ { action: "schedule", status: "error" },
453
+ true,
454
+ );
455
+ }
456
+ const scheduler = getCrewScheduler();
457
+ if (!scheduler) {
458
+ return result("Scheduler not running.", { action: "schedule", status: "error" }, true);
459
+ }
460
+ const outcome = scheduler.runNow(jobId);
461
+ if (!outcome.ok) {
462
+ return result(outcome.error, { action: "schedule", status: "error" }, true);
463
+ }
464
+ return result([`Scheduled job triggered.`, ` Job ID: ${jobId}`].join("\n"), {
465
+ action: "schedule",
466
+ status: "ok",
467
+ data: { jobId, triggered: true },
468
+ });
469
+ }
@@ -26,6 +26,15 @@ export type ScheduleChangeEvent =
26
26
  | { type: "fired"; jobId: string; agentId: string; name: string }
27
27
  | { type: "error"; jobId: string; error: string };
28
28
 
29
+ export interface CrewSchedulerOptions {
30
+ /** Injectable clock for tests. Defaults to real time (`new Date()`). */
31
+ now?: () => Date;
32
+ }
33
+
34
+ /** Node clamps setTimeout delays > 2^31-1 ms to fire ~immediately; longer
35
+ * waits (e.g. a yearly cron) must be chained in hops below this ceiling. */
36
+ const MAX_TIMER_DELAY_MS = 2_147_483_000;
37
+
29
38
  export class CrewScheduler {
30
39
  private jobs = new Map<string, ScheduledJob>();
31
40
  private timers = new Map<string, ReturnType<typeof setInterval | typeof setTimeout>>();
@@ -33,6 +42,16 @@ export class CrewScheduler {
33
42
  private executor?: (job: ScheduledJob) => string;
34
43
  private finalizer?: (jobId: string, agentId: string) => void;
35
44
  private runCancelFn?: (runId: string) => void;
45
+ private nowFn: () => Date;
46
+
47
+ constructor(options: CrewSchedulerOptions = {}) {
48
+ this.nowFn = options.now ?? (() => new Date());
49
+ }
50
+
51
+ /** Scheduler clock. Injected in tests; real time in production. */
52
+ private now(): Date {
53
+ return this.nowFn();
54
+ }
36
55
 
37
56
  start(options: {
38
57
  emit: (event: ScheduleChangeEvent) => void;
@@ -107,6 +126,29 @@ export class CrewScheduler {
107
126
  this.jobs.set(jobId, { ...job, spawnedRunIds });
108
127
  }
109
128
 
129
+ /**
130
+ * Trigger a job immediately (dashboard "run now" / subAction='run-now').
131
+ * Bypasses the enabled gate — an explicit user action — but otherwise reuses
132
+ * fire()'s exact path: marks lastStatus=running, invokes the SAME executor
133
+ * callback, emits fired/error events, and calls the finalizer. Completion
134
+ * (runCount/lastRun/lastStatus=persisted success) is handled by the same
135
+ * async finalization the timer-driven path uses.
136
+ *
137
+ * Returns ok:false (no throw) when the job or the executor is missing.
138
+ */
139
+ runNow(jobId: string): { ok: true } | { ok: false; error: string } {
140
+ const job = this.jobs.get(jobId);
141
+ if (!job) return { ok: false, error: `No scheduled job with id '${jobId}'.` };
142
+ if (!this.executor) return { ok: false, error: "Scheduler is not running." };
143
+ this.fire(jobId, true);
144
+ // A run-now on a scheduled ONCE job CONSUMES it: the timer-driven path
145
+ // self-disables after firing (arm()'s setTimeout callback), and without
146
+ // the same step here the still-armed timer would fire the job a SECOND
147
+ // time at its scheduled time — a one-shot executing twice.
148
+ if (job.scheduleType === "once") this.update(jobId, { enabled: false });
149
+ return { ok: true };
150
+ }
151
+
110
152
  private arm(job: ScheduledJob): void {
111
153
  if (this.timers.has(job.id)) return;
112
154
  if (job.scheduleType === "interval" && job.intervalMs) {
@@ -115,7 +157,7 @@ export class CrewScheduler {
115
157
  this.timers.set(job.id, t);
116
158
  } else if (job.scheduleType === "once") {
117
159
  const target = new Date(job.schedule).getTime();
118
- const delay = target - Date.now();
160
+ const delay = target - this.now().getTime();
119
161
  if (delay > 0) {
120
162
  const t = setTimeout(() => {
121
163
  this.fire(job.id);
@@ -131,7 +173,77 @@ export class CrewScheduler {
131
173
  error: `Scheduled time ${job.schedule} is in the past`,
132
174
  });
133
175
  }
176
+ } else if (job.scheduleType === "cron") {
177
+ this.armCron(job);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Arm a cron job: schedule a chained setTimeout at the next occurrence
183
+ * (hops are clamped below the 2^31-1 ms setTimeout ceiling so yearly crons
184
+ * cannot mis-fire), fire once on arrival, then let the existing
185
+ * update() → disarm+arm lifecycle (triggered by fire()'s
186
+ * lastStatus='running' update) re-arm the NEXT occurrence. fire() itself
187
+ * stays persistence-blind; the post-fire nextRun advance happens in
188
+ * advanceCronNextRun and persists via the usual `updated` event flow.
189
+ */
190
+ private armCron(job: ScheduledJob): void {
191
+ const now = this.now();
192
+ const next = nextRunTime({ kind: "cron", spec: job.schedule }, now);
193
+ if (!(next instanceof Date) || next.getTime() <= now.getTime()) {
194
+ this.disableCronForUncomputableNext(job.id, next);
195
+ return;
196
+ }
197
+ this.setCronTimeout(job.id, next.getTime());
198
+ }
199
+
200
+ /** Chain a clamped timeout hop toward the target cron occurrence. */
201
+ private setCronTimeout(jobId: string, targetMs: number): void {
202
+ const delay = Math.max(targetMs - this.now().getTime(), 0);
203
+ const t = setTimeout(() => this.cronTick(jobId, targetMs), Math.min(delay, MAX_TIMER_DELAY_MS));
204
+ t.unref();
205
+ this.timers.set(jobId, t);
206
+ }
207
+
208
+ /** Timer callback for a cron hop/arrival. Fires at most once per occurrence. */
209
+ private cronTick(jobId: string, targetMs: number): void {
210
+ const job = this.jobs.get(jobId);
211
+ if (!job?.enabled) return; // disabled/removed mid-flight: hop must die
212
+ if (this.now().getTime() < targetMs) {
213
+ // Clamped hop landed early — chain again for the remaining time.
214
+ this.setCronTimeout(jobId, targetMs);
215
+ return;
216
+ }
217
+ // Occurrence reached. fire()'s internal update({lastStatus:'running'})
218
+ // runs disarm→arm, which re-arms the NEXT occurrence exactly once; the
219
+ // hop that just fired is dead, so no timer doubles up.
220
+ this.fire(jobId);
221
+ this.advanceCronNextRun(jobId);
222
+ }
223
+
224
+ /** After a cron fire, advance the persisted nextRun to the next occurrence
225
+ * (or self-disable when no further occurrence is computable). */
226
+ private advanceCronNextRun(jobId: string): void {
227
+ const job = this.jobs.get(jobId);
228
+ if (!job?.enabled || job.scheduleType !== "cron") return;
229
+ const next = nextRunTime({ kind: "cron", spec: job.schedule }, this.now());
230
+ if (!(next instanceof Date)) {
231
+ this.disableCronForUncomputableNext(jobId, next);
232
+ return;
134
233
  }
234
+ this.update(jobId, { nextRun: next.toISOString() });
235
+ }
236
+
237
+ /** No computable next occurrence (e.g. Feb-29 beyond the 366-day search
238
+ * window): disable the job and record why. */
239
+ private disableCronForUncomputableNext(jobId: string, next: Date | { error: string }): void {
240
+ const reason = next && typeof next === "object" && "error" in next ? next.error : "next occurrence is not in the future";
241
+ this.update(jobId, { enabled: false, lastStatus: "error" });
242
+ this.emit?.({
243
+ type: "error",
244
+ jobId,
245
+ error: `Cron schedule cannot compute a next occurrence (${reason}); job disabled`,
246
+ });
135
247
  }
136
248
 
137
249
  private disarm(id: string): void {
@@ -139,18 +251,20 @@ export class CrewScheduler {
139
251
  if (t) {
140
252
  // Branch on timer type to use correct clear function
141
253
  const job = this.jobs.get(id);
142
- if (job?.scheduleType === "once") {
143
- clearTimeout(t as ReturnType<typeof setTimeout>);
144
- } else {
254
+ if (job?.scheduleType === "interval") {
145
255
  clearInterval(t as ReturnType<typeof setInterval>);
256
+ } else {
257
+ // `once` and `cron` both arm setTimeout handles (cron chains hops).
258
+ clearTimeout(t as ReturnType<typeof setTimeout>);
146
259
  }
147
260
  this.timers.delete(id);
148
261
  }
149
262
  }
150
263
 
151
- private fire(id: string): void {
264
+ private fire(id: string, force = false): void {
152
265
  const job = this.jobs.get(id);
153
- if (!job?.enabled || !this.executor) return;
266
+ if (!job || !this.executor) return;
267
+ if (!job.enabled && !force) return;
154
268
  this.update(id, { lastStatus: "running" });
155
269
  let agentId: string;
156
270
  try {
@@ -114,9 +114,9 @@ function readSettingsFile(filePath: string): CrewSettings {
114
114
  * fixed P1 bypass (see docs/decisions/2026-08-15-schema-driven-sanitize.md
115
115
  * and the Wave 2B execution log in docs/refactor-plan.md).
116
116
  */
117
- export function loadCrewSettings(cwd: string = process.cwd()): CrewSettings {
117
+ export function loadCrewSettings(cwd: string = process.cwd(), globalFile: string = globalPath()): CrewSettings {
118
118
  return {
119
- ...readSettingsFile(globalPath()),
119
+ ...readSettingsFile(globalFile),
120
120
  ...readSettingsFile(projectPath(cwd)),
121
121
  };
122
122
  }
@@ -249,6 +249,32 @@ export function loadCrewSettingsTiers(cwd: string = process.cwd(), globalFile: s
249
249
  return { user, project, merged: { ...user, ...project }, effectiveScheduledJobs, projectPath: projectFilePath };
250
250
  }
251
251
 
252
+ /** P2-1 (B2 gate visibility): how many project-tier scheduledJobs the Wave B2
253
+ * opt-in gate is currently hiding, derived from an ALREADY-LOADED tiers view
254
+ * (no disk read — the lifecycle session_start stash reuses its single tiers
255
+ * read through this helper). Mirrors what `effectiveScheduledJobs` would add
256
+ * after opt-in: only shape-valid entries count (the same id + scheduleType +
257
+ * enabled predicate the provider fallback and the scheduler apply), so the
258
+ * hint never counts junk that would be skipped anyway. 0 whenever the user
259
+ * tier opted in (nothing is hidden). */
260
+ export function scheduledJobsHiddenCountOf(tiers: CrewSettingsTiers): number {
261
+ if (projectScheduledJobsOptIn(tiers.user)) return 0;
262
+ const jobs = tiers.project.scheduledJobs;
263
+ if (!Array.isArray(jobs)) return 0;
264
+ return jobs.filter(validateScheduledJob).length;
265
+ }
266
+
267
+ /** P2-1 companion getter: how many project-tier scheduledJobs the B2 gate is
268
+ * filtering out of `effectiveScheduledJobs` (and therefore out of every
269
+ * read surface). `getScheduledJobs()`'s signature is intentionally unchanged
270
+ * — consumers that want the hidden count call this next to their existing
271
+ * provider read. Reads the tiers (one disk read per call): user-initiated
272
+ * surfaces only (pane render/command handlers); paint-path consumers use the
273
+ * registration-time stash in handle-schedule.ts (P0-6). */
274
+ export function getScheduledJobsHiddenCount(cwd: string = process.cwd(), globalFile: string = globalPath()): number {
275
+ return scheduledJobsHiddenCountOf(loadCrewSettingsTiers(cwd, globalFile));
276
+ }
277
+
252
278
  /**
253
279
  * Apply crew settings to a config with correct trust tiering. Returns the
254
280
  * list of drop warnings (standard project-override format).
@@ -307,6 +307,21 @@ function releaseLock(filePath: string, token: string): void {
307
307
  // (probably stale and overtaken). Do not touch it — the new holder owns it.
308
308
  }
309
309
 
310
+ /**
311
+ * Error codes that mean "the lock file exists / is momentarily un-creatable
312
+ * because another process holds it" rather than "something is broken".
313
+ *
314
+ * EEXIST is the POSIX O_CREAT|O_EXCL loser signal. On Windows (libuv),
315
+ * CREATE_NEW against a file that another process has open mid-create can
316
+ * surface as EPERM (ERROR_ACCESS_DENIED) or EBUSY instead of EEXIST —
317
+ * observed on the windows-latest CI runner (ST-3 cross-process flock test).
318
+ * Retrying these is correct: the holder always closes/releases within the
319
+ * stale-deadline budget, and the deadline still bounds the wait.
320
+ */
321
+ function isLockContention(code: string | undefined): boolean {
322
+ return code === "EEXIST" || code === "EPERM" || code === "EBUSY";
323
+ }
324
+
310
325
  function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind = "file"): string {
311
326
  let attempt = 0;
312
327
  const deadline = Date.now() + staleMs * 2;
@@ -317,7 +332,7 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
317
332
  return token;
318
333
  } catch (error) {
319
334
  const code = (error as NodeJS.ErrnoException).code;
320
- if (code !== "EEXIST") throw error;
335
+ if (!isLockContention(code)) throw error;
321
336
  if (Date.now() > deadline) {
322
337
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
323
338
  }
@@ -355,7 +370,7 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
355
370
  return token;
356
371
  } catch (error) {
357
372
  const code = (error as NodeJS.ErrnoException).code;
358
- if (code !== "EEXIST") throw error;
373
+ if (!isLockContention(code)) throw error;
359
374
  if (Date.now() > deadline) {
360
375
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
361
376
  }