pi-crew 0.9.41 → 0.9.44

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 (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -1,16 +1,3 @@
1
- /**
2
- * run-coalesced-task-group.ts — M6 real-dispatch MVP worker spawn.
3
- *
4
- * Spawns ONE child Pi process for an entire coalesced group of N tasks
5
- * (sharing role + cwd per `planCoalescedGroups`). The combined prompt
6
- * instructs the worker to wrap each task's result in `<<<TASK_RESULT:id>>>`
7
- * ... `<<<END_TASK_RESULT>>>` delimiters. Output is split back into N
8
- * per-task results via `splitCoalescedOutput`, each written to its own
9
- * result artifact + state update.
10
- */
11
-
12
- import { writeFile } from "node:fs/promises";
13
- import path from "node:path";
14
1
  import type { AgentConfig } from "../agents/agent-config.ts";
15
2
  import { writeArtifact } from "../state/artifact-store.ts";
16
3
  import { appendEventAsync } from "../state/event-log.ts";
@@ -18,8 +5,10 @@ import { saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
18
5
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
19
6
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
20
7
  import { runChildPi } from "./child-pi.ts";
8
+ import { DEFAULT_RETRY_POLICY, executeWithRetry } from "./retry-executor.ts";
21
9
  import { permissionForRole } from "./role-permission.ts";
22
10
  import type { CrewRuntimeMode } from "./runtime-resolver.ts";
11
+ import { sanitizeTaskText } from "./task-packet.ts";
23
12
  import { splitCoalescedOutput } from "./task-runner/output-splitter.ts";
24
13
  import { mergeArtifacts } from "./team-runner-artifacts.ts";
25
14
  import { createWorkerHeartbeat, touchWorkerHeartbeat } from "./worker-heartbeat.ts";
@@ -114,15 +103,21 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
114
103
  }
115
104
  }, 15_000);
116
105
  try {
117
- const result = await runChildPi({
118
- cwd: firstTask.cwd,
119
- task: combinedPrompt,
120
- agent,
121
- signal,
122
- excludeContextBash: true,
123
- maxTurns: 5,
124
- onJsonEvent: (e) => input.onJsonEvent?.(firstTask.id, manifest.runId, e),
125
- });
106
+ const result = await executeWithRetry(
107
+ async () => {
108
+ return await runChildPi({
109
+ cwd: firstTask.cwd,
110
+ task: combinedPrompt,
111
+ agent,
112
+ signal,
113
+ excludeContextBash: true,
114
+ maxTurns: 5,
115
+ onJsonEvent: (e) => input.onJsonEvent?.(firstTask.id, manifest.runId, e),
116
+ });
117
+ },
118
+ DEFAULT_RETRY_POLICY,
119
+ { signal },
120
+ );
126
121
  rawOutput = result.rawFinalText ?? result.stdout ?? "";
127
122
  success = result.exitStatus?.exitCode === 0;
128
123
  } catch (err) {
@@ -206,7 +201,7 @@ async function buildCoalescedPrompt(
206
201
  `### Task ${idx + 1} of ${groupTasks.length} (id: ${task.id})`,
207
202
  `Step: ${step.id}`,
208
203
  `Role: ${step.role}`,
209
- `Task: ${step.task.replaceAll("{goal}", manifest.goal)}`,
204
+ `Task: ${sanitizeTaskText(step.task.replaceAll("{goal}", manifest.goal))}`,
210
205
  ].join("\n");
211
206
  })
212
207
  .join("\n\n---\n\n");
@@ -111,6 +111,7 @@ export class CrewScheduler {
111
111
  if (this.timers.has(job.id)) return;
112
112
  if (job.scheduleType === "interval" && job.intervalMs) {
113
113
  const t = setInterval(() => this.fire(job.id), job.intervalMs);
114
+ t.unref();
114
115
  this.timers.set(job.id, t);
115
116
  } else if (job.scheduleType === "once") {
116
117
  const target = new Date(job.schedule).getTime();
@@ -120,6 +121,7 @@ export class CrewScheduler {
120
121
  this.fire(job.id);
121
122
  this.update(job.id, { enabled: false });
122
123
  }, delay);
124
+ t.unref();
123
125
  this.timers.set(job.id, t);
124
126
  } else {
125
127
  this.update(job.id, { enabled: false, lastStatus: "error" });
@@ -9,7 +9,7 @@ import { logInternalError } from "../utils/internal-error.ts";
9
9
  */
10
10
  export function tryRegisterSessionCleanup(pi: ExtensionAPI, cleanup: () => void): (() => void) | undefined {
11
11
  const api = pi as unknown as Record<string, unknown>;
12
- const registerFn = api["registerSessionResourceCleanup"];
12
+ const registerFn = api.registerSessionResourceCleanup;
13
13
  if (typeof registerFn === "function") {
14
14
  try {
15
15
  const unregister = (registerFn as (fn: () => void) => (() => void) | void)(cleanup);
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import * as path from "node:path";
4
+ import { atomicWriteJson } from "../state/atomic-write.ts";
5
+ import { withFileLockSync } from "../state/locks.ts";
4
6
  import { logInternalError } from "../utils/internal-error.ts";
5
7
  import type { JoinMode } from "./group-join.ts";
6
8
 
@@ -98,13 +100,41 @@ export function saveCrewSettings(s: CrewSettings, cwd: string = process.cwd()):
98
100
  const p = projectPath(cwd);
99
101
  try {
100
102
  fs.mkdirSync(path.dirname(p), { recursive: true });
101
- fs.writeFileSync(p, JSON.stringify(s, null, 2), "utf-8");
103
+ // Atomic (temp + rename + fsync) instead of raw writeFileSync: a crash/SIGKILL
104
+ // mid-write would otherwise truncate <cwd>/.pi/crew-settings.json, and the
105
+ // reader silently resets to {} on parse failure — losing ALL crew settings +
106
+ // scheduledJobs.
107
+ atomicWriteJson(p, s);
102
108
  return true;
103
109
  } catch {
104
110
  return false;
105
111
  }
106
112
  }
107
113
 
114
+ /**
115
+ * Atomically read-modify-write crew settings under a file lock.
116
+ *
117
+ * Fixes two issues vs a plain loadCrewSettings → saveCrewSettings sequence:
118
+ * 1. Cross-session lost-update race: <cwd>/.pi/crew-settings.json is project-scoped,
119
+ * so two Pi sessions on the same project concurrently scheduling jobs would each
120
+ * load-modify-save and the second save clobbers the first (lost job). The lock
121
+ * serializes the whole transaction.
122
+ * 2. Crash truncation: the write uses atomicWriteJson (temp + rename + fsync).
123
+ *
124
+ * `mutator` receives the freshly-loaded (merged) settings and returns the new value
125
+ * to persist. Returns the persisted settings.
126
+ */
127
+ export function updateCrewSettings(cwd: string, mutator: (settings: CrewSettings) => CrewSettings): CrewSettings {
128
+ const p = projectPath(cwd);
129
+ fs.mkdirSync(path.dirname(p), { recursive: true });
130
+ return withFileLockSync(p, () => {
131
+ const fresh = loadCrewSettings(cwd); // re-read inside the lock (TOCTOU-safe vs other updaters)
132
+ const next = mutator(fresh);
133
+ atomicWriteJson(p, next);
134
+ return next;
135
+ });
136
+ }
137
+
108
138
  export function applyCrewSettingsToConfig(
109
139
  config: {
110
140
  limits?: { maxConcurrentWorkers?: number };
@@ -10,8 +10,8 @@
10
10
  * @module skill-effectiveness
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
14
- import { dirname, join } from "path";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
15
  import { projectCrewRoot } from "../utils/paths.ts";
16
16
  import { crewHooks } from "./crew-hooks.ts";
17
17
 
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { errors } from "../errors.ts";
5
- import { atomicWriteJson } from "../state/atomic-write.ts";
5
+ import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
6
6
  import { saveRunManifest } from "../state/state-store.ts";
7
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
8
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
@@ -152,29 +152,32 @@ function checkPidLiveness(
152
152
  }
153
153
  // If process is alive per kill(0), we're done.
154
154
  if (liveness.alive) return { alive: true, detail: liveness.detail };
155
- // Process is dead per kill(0). Check heartbeat as corroborating evidence.
155
+ // Process is dead per kill(0). ESRCH is AUTHORITATIVE — the PID does not exist.
156
+ // A frozen/zombie process still has a PID (kill(0) returns 0, or EPERM if we lack
157
+ // permission to signal it), so reaching here with alive=false means the process
158
+ // is genuinely gone. PID recycling is already handled above via the startTime
159
+ // TOCTOU check, so the heartbeat file must NOT override the dead verdict.
160
+ //
161
+ // HISTORY: a <5min-old heartbeat.json previously OVERRRODE this verdict (returned
162
+ // alive:true "process dead but heartbeat Xs old"), delaying repair by ~5min.
163
+ // Combined with the 60s reconcile cadence this produced a ~300-360s detection lag
164
+ // for dead workers (observed 353s in production). The heartbeat is now used only
165
+ // to enrich the detail string, never to override the authoritative kill(0) verdict.
166
+ let heartbeatNote = "";
156
167
  if (stateRoot) {
157
- const heartbeatPath = path.join(stateRoot, "heartbeat.json");
158
168
  try {
169
+ const heartbeatPath = path.join(stateRoot, "heartbeat.json");
159
170
  if (fs.existsSync(heartbeatPath)) {
160
171
  const hb = JSON.parse(fs.readFileSync(heartbeatPath, "utf-8")) as { pid?: number; at?: number };
161
172
  if (hb?.pid === pid && hb?.at) {
162
- const ageMs = Date.now() - hb.at;
163
- // Heartbeat written < 5 min ago → process was alive recently.
164
- // Don't repair yet; let the next reconciliation cycle catch it.
165
- if (ageMs < 5 * 60_000) {
166
- return {
167
- alive: true,
168
- detail: `process dead but heartbeat ${Math.round(ageMs / 1000)}s old`,
169
- };
170
- }
173
+ heartbeatNote = ` (heartbeat was ${Math.round((Date.now() - hb.at) / 1000)}s old)`;
171
174
  }
172
175
  }
173
176
  } catch {
174
177
  /* ignore — best-effort */
175
178
  }
176
179
  }
177
- return { alive: liveness.alive, detail: liveness.detail };
180
+ return { alive: false, detail: `${liveness.detail}${heartbeatNote}` };
178
181
  }
179
182
 
180
183
  /**
@@ -267,11 +270,6 @@ function buildStaleReconcileError(task: TeamTaskState, reason: string): Error {
267
270
  return errors.runStale(reason, heartbeatAgeSeconds);
268
271
  }
269
272
 
270
- /** @deprecated use buildStaleReconcileError (returns a structured CrewError). Kept for any external callers. */
271
- function formatStaleReconcileError(task: TeamTaskState, reason: string): string {
272
- return buildStaleReconcileError(task, reason).message;
273
- }
274
-
275
273
  function repairStaleRun(manifest: TeamRunManifest, tasks: TeamTaskState[], reason: string): TeamTaskState[] {
276
274
  const now = new Date().toISOString();
277
275
  const repairedTasks = tasks.map((task) => {
@@ -592,9 +590,13 @@ export function reconcileOrphanedTempWorkspaces(
592
590
  if (!fs.existsSync(workspaceDir)) {
593
591
  fs.mkdirSync(workspaceDir, { recursive: true });
594
592
  }
595
- fs.writeFileSync(sentinelPath, JSON.stringify({ startedAt: now }), {
596
- flag: "wx",
597
- });
593
+ // Atomic exclusive-create: reserve the sentinel (fails with EEXIST if
594
+ // another cleanup is in progress), then fill it atomically. The brief
595
+ // empty-file window is safe — the sentinel "exists" (correctly
596
+ // signalling in-progress cleanup).
597
+ const sentinelFd = fs.openSync(sentinelPath, "wx");
598
+ fs.closeSync(sentinelFd);
599
+ atomicWriteFile(sentinelPath, JSON.stringify({ startedAt: now }));
598
600
  } catch {
599
601
  // Sentinel already exists (another cleanup in progress) — skip
600
602
  canCleanup = false;
@@ -661,38 +663,6 @@ export function reconcileOrphanedTempWorkspaces(
661
663
  } catch {
662
664
  /* ignore if already gone */
663
665
  }
664
- } else if (canCleanup) {
665
- // Sentinel already existed — another cleanup attempt is in progress.
666
- // Perform a simplified TOCTOU check: if any manifest is running,
667
- // do NOT remove the sentinel (let the first attempt finish its scan).
668
- let hasRunningManifest = false;
669
- if (fs.existsSync(stateRunsDir)) {
670
- try {
671
- for (const runDir of fs.readdirSync(stateRunsDir)) {
672
- const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
673
- if (!fs.existsSync(manifestPath)) continue;
674
- try {
675
- const m = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as TeamRunManifest;
676
- if (m.status === "running") {
677
- hasRunningManifest = true;
678
- break;
679
- }
680
- } catch {
681
- /* skip on parse error */
682
- }
683
- }
684
- } catch {
685
- /* skip if runs dir unreadable */
686
- }
687
- }
688
- // Only clean up sentinel if no running manifests exist
689
- if (!hasRunningManifest) {
690
- try {
691
- fs.unlinkSync(sentinelPath);
692
- } catch {
693
- /* ignore if already gone */
694
- }
695
- }
696
666
  }
697
667
  }
698
668
  } catch {
@@ -439,13 +439,29 @@ export class SubagentManager {
439
439
  private drainQueue(): void {
440
440
  while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
441
441
  const next = this.queue.shift();
442
- if (!next || next.record.status !== "queued") continue;
442
+ if (next?.record.status !== "queued") continue;
443
443
  this.start(next.record, next.options, next.runner, next.signal);
444
444
  }
445
445
  }
446
446
 
447
447
  private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
448
+ // Safety: max 30 minutes (1800 polls at 1s interval) to prevent infinite
449
+ // polling if manifest file is deleted or run state becomes unrecoverable.
450
+ const MAX_POLL_COUNT = 1800;
451
+ let pollCount = 0;
448
452
  while (record.runId && (record.status === "running" || record.status === "blocked")) {
453
+ if (++pollCount > MAX_POLL_COUNT) {
454
+ logInternalError(
455
+ "subagent-manager.poll-timeout",
456
+ new Error(`pollRunToTerminal exceeded ${MAX_POLL_COUNT} polls for runId=${record.runId}`),
457
+ `id=${record.id}`,
458
+ );
459
+ record.status = "error";
460
+ record.error = `Poll timeout: run did not reach terminal state after ${MAX_POLL_COUNT} seconds`;
461
+ record.completedAt = Date.now();
462
+ savePersistedSubagentRecord(cwd, record);
463
+ return;
464
+ }
449
465
  const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
450
466
  if (!loaded) {
451
467
  await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
@@ -488,7 +504,7 @@ export class SubagentManager {
488
504
  private scheduleBlockedTerminalPoll(cwd: string, record: SubagentRecord): void {
489
505
  const poll = (): void => {
490
506
  const current = this.records.get(record.id);
491
- if (!current || current.status !== "blocked" || !current.runId) return;
507
+ if (current?.status !== "blocked" || !current.runId) return;
492
508
  const loaded = loadRunManifestById(cwd, current.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
493
509
  if (
494
510
  !loaded ||
@@ -524,7 +540,7 @@ export class SubagentManager {
524
540
  const threshold = DEFAULT_SUBAGENT.stuckBlockedNotifyMs;
525
541
  const fire = (): void => {
526
542
  const current = this.records.get(record.id);
527
- if (!current || current.status !== "blocked" || !current.blockedAt || current.stuckNotified) return;
543
+ if (current?.status !== "blocked" || !current.blockedAt || current.stuckNotified) return;
528
544
  current.stuckNotified = true;
529
545
  this.onEvent?.("subagent.stuck-blocked", {
530
546
  event: "subagent.stuck-blocked",
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DEFAULT_OUTPUT_CONTEXT } from "../config/defaults.ts";
4
4
  import { writeArtifact } from "../state/artifact-store.ts";
5
+ import { atomicWriteFile } from "../state/atomic-write.ts";
5
6
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
7
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
7
8
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
@@ -155,7 +156,7 @@ export function teePathForArtifact(artifactsRoot: string, taskId: string, artifa
155
156
  function writeTeeFile(fullOutputPath: string, content: string): boolean {
156
157
  try {
157
158
  fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true });
158
- fs.writeFileSync(fullOutputPath, content, "utf-8");
159
+ atomicWriteFile(fullOutputPath, content);
159
160
  return true;
160
161
  } catch {
161
162
  return false;
@@ -4,7 +4,7 @@ import type { TaskOutputSchema, TeamRunManifest, TeamTaskState } from "../../sta
4
4
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
5
5
  import { buildMemoryBlock } from "../agent-memory.ts";
6
6
  import { permissionForRole } from "../role-permission.ts";
7
- import { HANDOFF_TEMPLATE, renderTaskPacket } from "../task-packet.ts";
7
+ import { HANDOFF_TEMPLATE, renderTaskPacket, sanitizeTaskText } from "../task-packet.ts";
8
8
  import { buildWorkspaceTree } from "../workspace-tree.ts";
9
9
  import { renderSuggestedFilesSection, runRetrievalCycle } from "./retrieval-orchestrator.ts";
10
10
 
@@ -14,7 +14,7 @@ import { renderSuggestedFilesSection, runRetrievalCycle } from "./retrieval-orch
14
14
  * filtering at the Pi level is a future optimisation (Phase 3.2+).
15
15
  */
16
16
  export function toolGuidanceBlock(agent?: AgentConfig): string {
17
- if (!agent || agent.loadMode !== "lean" || !agent.defaultTools?.length) return "";
17
+ if (agent?.loadMode !== "lean" || !agent.defaultTools?.length) return "";
18
18
  return [
19
19
  "# Tool Guidance",
20
20
  `This role uses a focused tool set. Preferred tools: ${agent.defaultTools.join(", ")}.`,
@@ -267,7 +267,7 @@ export async function renderTaskPrompt(
267
267
  memoryBlock,
268
268
  task.taskPacket?.outputSchema ? renderOutputSchemaBlock(task.taskPacket.outputSchema) : "",
269
269
  "Task:",
270
- step.task.replaceAll("{goal}", manifest.goal),
270
+ sanitizeTaskText(step.task.replaceAll("{goal}", manifest.goal)),
271
271
  "",
272
272
  "When your task is complete, structure your final output using this handoff template:",
273
273
  HANDOFF_TEMPLATE,
@@ -1,5 +1,6 @@
1
1
  import type { MailboxMessage } from "../../state/mailbox.ts";
2
2
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
3
+ import { sanitizeTaskText } from "../task-packet.ts";
3
4
 
4
5
  export interface RunProjectionSource {
5
6
  kind: "events" | "mailbox" | "artifacts" | "ui_metadata" | "runtime_metadata";
@@ -64,7 +65,8 @@ export function transformRunContextBeforeWorkerStart(input: {
64
65
  });
65
66
  lines.push(`Pending messages (${messages.length}/${input.pendingMailbox.length}):`);
66
67
  for (const msg of messages) {
67
- lines.push(`- ${msg.kind ?? "message"}: ${msg.body.slice(0, 100)}`);
68
+ const safeBody = sanitizeTaskText(msg.body.slice(0, 100));
69
+ lines.push(`- ${msg.kind ?? "message"}: ${safeBody}`);
68
70
  }
69
71
  }
70
72
 
@@ -24,6 +24,14 @@ export function updateTask(tasks: TeamTaskState[], updated: TeamTaskState): Team
24
24
  * committed first; retry with the fresh state. This is O(retry) under contention but
25
25
  * converges in the normal single-writer case.
26
26
  *
27
+ * ST-7: pass `skipCoalesce: true` when persisting a TERMINAL task status
28
+ * (completed/failed/cancelled/needs_attention/skipped). Without this the
29
+ * underlying `saveRunTasksCoalesced` would buffer the write for 50ms; a
30
+ * SIGKILL in that window loses the terminal update and crash recovery
31
+ * would see "running" in tasks.json while events.jsonl already shows
32
+ * "completed". For non-terminal transitions (heartbeat, progress) the
33
+ * default buffered write is fine and matches prior behavior.
34
+ *
27
35
  * @param checkpointPhase - Optional checkpoint phase to include in the task state alongside the update.
28
36
  */
29
37
  export function persistSingleTaskUpdate(
@@ -31,6 +39,7 @@ export function persistSingleTaskUpdate(
31
39
  fallbackTasks: TeamTaskState[],
32
40
  updated: TeamTaskState,
33
41
  checkpointPhase?: TaskCheckpointState["phase"],
42
+ skipCoalesce: boolean = false,
34
43
  ): TeamTaskState[] {
35
44
  const MAX_CAS_ATTEMPTS = 100;
36
45
  let baseMtime = 0;
@@ -126,7 +135,10 @@ export function persistSingleTaskUpdate(
126
135
  // non-coalesced writers; the flushPendingAtomicWrites() guard at
127
136
  // the top of the retry loop ensures reads see any other coalesced
128
137
  // writer's flushed-before-this-call state.
129
- saveRunTasksCoalesced(manifest, merged);
138
+ // ST-7: terminal transitions (skipCoalesce=true) bypass the 50ms
139
+ // coalesce window so a SIGKILL after the persist completes cannot
140
+ // leave tasks.json stale with a non-terminal status.
141
+ saveRunTasksCoalesced(manifest, merged, skipCoalesce);
130
142
  } catch (err) {
131
143
  logInternalError("persistSingleTaskUpdate", err, undefined, "error");
132
144
  throw err;
@@ -15,11 +15,10 @@ import type {
15
15
  OperationTerminalEvidence,
16
16
  TeamRunManifest,
17
17
  TeamTaskState,
18
- UsageState,
19
18
  VerificationEvidence,
20
19
  } from "../state/types.ts";
21
20
  import { logInternalError } from "../utils/internal-error.ts";
22
- import { resolveRealContainedPath } from "../utils/safe-paths.ts";
21
+ import { resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
23
22
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
24
23
  import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync, prepareTaskWorkspaceAsync } from "../worktree/worktree-manager.ts";
25
24
  import { reserveControlChannel } from "./agent-control.ts";
@@ -35,7 +34,7 @@ import {
35
34
  recordFromTask,
36
35
  upsertCrewAgent,
37
36
  } from "./crew-agent-records.ts";
38
- import type { CrewAgentProgress, CrewRuntimeKind } from "./crew-agent-runtime.ts";
37
+ import type { CrewRuntimeKind } from "./crew-agent-runtime.ts";
39
38
  import { crewHooks } from "./crew-hooks.ts";
40
39
  import { bridgeEventFromJsonEvent, registerStreamBridge } from "./event-stream-bridge.ts";
41
40
  import { createVerificationEvidence } from "./green-contract.ts";
@@ -87,7 +86,7 @@ registerYieldTool();
87
86
  async function appendSteeringAsync(steeringDir: string, taskId: string, steers: string[]): Promise<void> {
88
87
  try {
89
88
  await fs.promises.mkdir(steeringDir, { recursive: true });
90
- const steeringPath = `${steeringDir}/${taskId}.jsonl`;
89
+ const steeringPath = resolveContainedPath(steeringDir, `${taskId}.jsonl`);
91
90
  const lines = steers
92
91
  .map(
93
92
  (msg) =>
@@ -254,10 +253,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
254
253
  if (input.step.preStepScript) {
255
254
  const scriptTimeout = input.step.preStepTimeout ?? 30_000;
256
255
  const scriptArgs = input.step.preStepArgs ?? [];
256
+ appendEventFireAndForget(manifest.eventsPath, {
257
+ type: "hook.pre_step_started",
258
+ runId: manifest.runId,
259
+ taskId: task.id,
260
+ data: { script: input.step.preStepScript, argCount: scriptArgs.length, timeoutMs: scriptTimeout },
261
+ });
257
262
  // SECURITY (M-1 fix, code-review 2026-06-23): use the project's safe-path
258
263
  // primitive instead of a hand-rolled path.resolve + startsWith check.
259
264
  // The lexical check passed a symlinked ancestor, letting execFileSync
260
265
  // follow it and execute a script outside cwd. Throws on escape.
266
+ // Keep validation outside the optional-execution catch: preStepOptional
267
+ // must never bypass a path-containment failure.
261
268
  resolveRealContainedPath(manifest.cwd, input.step.preStepScript);
262
269
  try {
263
270
  // LAZY: defer dynamic import of node:child_process to its call site.
@@ -268,9 +275,22 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
268
275
  cwd: manifest.cwd,
269
276
  maxBuffer: 1024 * 1024, // 1MB cap
270
277
  });
278
+ appendEventFireAndForget(manifest.eventsPath, {
279
+ type: "hook.pre_step_completed",
280
+ runId: manifest.runId,
281
+ taskId: task.id,
282
+ data: { script: input.step.preStepScript, outputBytes: Buffer.byteLength(preStepOutput, "utf8") },
283
+ });
271
284
  } catch (err) {
272
285
  const msg = err instanceof Error ? err.message : String(err);
273
286
  const exitCode = (err as NodeJS.ErrnoException & { status?: number }).status;
287
+ appendEventFireAndForget(manifest.eventsPath, {
288
+ type: "hook.pre_step_failed",
289
+ runId: manifest.runId,
290
+ taskId: task.id,
291
+ message: `pre-step hook failed (exit ${exitCode ?? "?"})`,
292
+ data: { script: input.step.preStepScript, exitCode: exitCode ?? null, optional: input.step.preStepOptional === true },
293
+ });
274
294
  // E1 (Round 15): structured CrewError with code E009 + help hint,
275
295
  // instead of a raw Error. Surfaces the script path, exit code, and stderr.
276
296
  // Round 21 (E4): if preStepOptional is set, a failing hook is NON-FATAL.
@@ -484,7 +504,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
484
504
  runId: manifest.runId,
485
505
  agentId: task.id,
486
506
  artifactsRoot: manifest.artifactsRoot,
487
- steeringFile: `${manifest.artifactsRoot}/steering/${task.id}.jsonl`,
507
+ steeringFile: resolveContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
488
508
  onSpawn: (pid) => {
489
509
  try {
490
510
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
@@ -1222,9 +1242,14 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
1222
1242
  // stale manifest and overwrite this task's freshly-written artifacts, silently
1223
1243
  // losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard),
1224
1244
  // so nesting it inside this lock is a no-op re-acquire, not a deadlock.
1245
+ // ST-7: this is a terminal transition (task.status is completed/failed/needs_attention),
1246
+ // pass skipCoalesce=true so the terminal update is durable — a SIGKILL in the
1247
+ // 50ms coalesce window must NOT leave tasks.json showing the prior non-terminal
1248
+ // status, otherwise crash recovery would see inconsistency between tasks.json
1249
+ // (running) and events.jsonl (terminal).
1225
1250
  tasks = await withRunLock(manifest, async () => {
1226
1251
  await saveRunManifestAsync(manifest);
1227
- return persistSingleTaskUpdate(manifest, tasks, task);
1252
+ return persistSingleTaskUpdate(manifest, tasks, task, undefined, true);
1228
1253
  });
1229
1254
  upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
1230
1255
  // Execute task_result hook before emitting terminal event
@@ -9,6 +9,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
9
9
  import { PluginRegistry } from "../plugins/plugin-registry.ts";
10
10
  import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
11
11
  import { hashArtifactContent as hashContent, writeArtifact } from "../state/artifact-store.ts";
12
+ import { atomicWriteFile, flushPendingAtomicWrites } from "../state/atomic-write.ts";
12
13
  import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
13
14
  import { HealthStore } from "../state/health-store.ts";
14
15
  import { withRunLock } from "../state/locks.ts";
@@ -84,7 +85,7 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
84
85
  // captured manifest.updatedAt once at startup, making the value
85
86
  // permanently stale throughout the run.
86
87
  const now = new Date().toISOString();
87
- fs.writeFileSync(
88
+ atomicWriteFile(
88
89
  heartbeatPath,
89
90
  JSON.stringify({
90
91
  pid: process.pid,
@@ -93,7 +94,7 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
93
94
  kind: "team-runner",
94
95
  lastTaskUpdateAt: now,
95
96
  }),
96
- { encoding: "utf-8", mode: 0o600 },
97
+ { mode: 0o600 },
97
98
  );
98
99
  } catch {
99
100
  // best-effort
@@ -1614,6 +1615,11 @@ async function executeTeamRunCore(
1614
1615
  // Read committed manifest from disk inside the lock so artifact merge is based
1615
1616
  // on committed state, not in-memory state that may differ from disk.
1616
1617
  const mergeResult = await withRunLock(manifest, async () => {
1618
+ // NEW-D1: flush any pending coalesced atomic writes before reading from
1619
+ // disk. Without this, a worker's async manifest save (coalesced by
1620
+ // atomic-write) may not be committed yet, causing a lost-update on the
1621
+ // merge read. flushPendingAtomicWrites forces all queued writes to disk.
1622
+ flushPendingAtomicWrites();
1617
1623
  const disk = loadRunManifestById(manifest.cwd, manifest.runId);
1618
1624
  const diskManifest = disk?.manifest ?? manifest;
1619
1625
  const diskArtifacts = diskManifest.artifacts;
@@ -13,7 +13,7 @@ import { spawn } from "node:child_process";
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import { writeArtifact } from "../state/artifact-store.ts";
16
- import type { ArtifactDescriptor, GreenLevel, VerificationCommandResult, VerificationContract } from "../state/types.ts";
16
+ import type { GreenLevel, VerificationCommandResult, VerificationContract } from "../state/types.ts";
17
17
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
18
18
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
19
19
  import { redactSecretString } from "../utils/redaction.ts";
@@ -154,13 +154,61 @@ export const CARGO_RUST_GATES: Array<{
154
154
  // `npm test > ~/.ssh/authorized_keys`) and used char-class semantics instead of
155
155
  // a lookahead. Now `[<>](?![&\d])` blocks BOTH `<` and `>` file redirects while
156
156
  // still permitting fd-duplication forms (`2>&1`, `1>&2`, `>&2`, `<&3`) via the
157
- // negative lookahead (a `>`/`<` followed by `&` or a digit is allowed).
158
- const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\$\w|\b(eval|exec)\b|[<>](?![&\d])|[\r\n])/;
157
+ // negative lookahead (a `>`/`<` followed by `&` or a digit is allowed). A bare
158
+ // `&` is also blocked so an allowed gate cannot background itself and start a
159
+ // second command.
160
+ const DANGEROUS_SHELL_PATTERNS = /(?:;|&&|\|\||\$\(|`|\$\{|\$\w|\b(eval|exec)\b|[<>](?![&\d])|(?<![<>])&|[\r\n])/;
161
+
162
+ /**
163
+ * Verification gates intentionally execute project-controlled code, but they
164
+ * must enter through a known build/test runner. Interpreter eval modes and
165
+ * arbitrary utilities are excluded: `node -e`, `python -c`, `curl`, etc. can
166
+ * otherwise execute attacker-provided code without any shell metacharacters.
167
+ *
168
+ * Prefixes are token-bound and each side of a pipeline is checked separately,
169
+ * preventing an allowed prefix such as `npm test | node -e ...` from bypassing
170
+ * the restriction. Keep this list focused on common verification commands.
171
+ */
172
+ const ALLOWED_GATE_COMMAND_PATTERNS: readonly RegExp[] = [
173
+ /^npm\s+(?:test(?:\s|$)|run(?:-script)?\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
174
+ /^pnpm\s+(?:test(?:\s|$)|run\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
175
+ /^yarn\s+(?:test(?:\s|$)|run\s+[A-Za-z0-9_.:-]+(?:\s|$))/,
176
+ /^npx\s+(?:--no-install\s+)?(?:biome|eslint|tsc)(?:\s|$)/,
177
+ /^(?:biome|eslint|tsc)(?:\s|$)/,
178
+ /^node\s+(?:(?:--experimental-strip-types|--no-warnings)\s+)*--test(?:\s|$)/,
179
+ /^python(?:3(?:\.\d+)*)?\s+-m\s+(?:pytest|unittest)(?:\s|$)/,
180
+ /^pytest(?:\s|$)/,
181
+ /^cargo\s+(?:build|check|clippy|fmt|test)(?:\s|$)/,
182
+ /^go\s+(?:build|test|vet)(?:\s|$)/,
183
+ /^bun\s+test(?:\s|$)/,
184
+ /^deno\s+(?:check|lint|test)(?:\s|$)/,
185
+ /^dotnet\s+(?:build|test)(?:\s|$)/,
186
+ /^(?:gradle|\.\/gradlew)\s+(?:build|check|test)(?:\s|$)/,
187
+ /^(?:mvn|\.\/mvnw)\s+(?:package|test|verify)(?:\s|$)/,
188
+ /^make\s+(?:build|check|lint|test)(?:\s|$)/,
189
+ /^(?:echo|false|printenv|printf|true)(?:\s|$)/,
190
+ /^(?:grep|head|rg|tail)(?:\s|$)/,
191
+ ];
192
+
193
+ const NODE_EVAL_MODE_PATTERN = /(?:^|\s)["']?(?:(?:-e|-p)(?!-)|--(?:eval|print)(?:=|\s|["']|$))/;
194
+
195
+ function validateAllowedGateCommands(normalized: string, original: string): void {
196
+ const pipelineCommands = normalized.split("|").map((part) => part.trim());
197
+ for (const pipelineCommand of pipelineCommands) {
198
+ if (/^node(?:\s|$)/.test(pipelineCommand) && NODE_EVAL_MODE_PATTERN.test(pipelineCommand)) {
199
+ throw new Error(`Security: verification gate command rejected (Node eval/print mode is not allowed): ${original}`);
200
+ }
201
+ if (!pipelineCommand || !ALLOWED_GATE_COMMAND_PATTERNS.some((pattern) => pattern.test(pipelineCommand))) {
202
+ throw new Error(`Security: verification gate command rejected (command is not allowlisted): ${original}`);
203
+ }
204
+ }
205
+ }
159
206
 
160
207
  /**
161
208
  * Validate a verification gate command is safe to execute.
162
- * Rejects commands with shell metacharacters that could enable injection.
163
- * Allows: pipes (|), redirection of stderr (2>&1), and basic npm/cargo/npx commands.
209
+ * Rejects commands with shell metacharacters that could enable injection and
210
+ * commands outside the verification-runner allowlist. Allows safe pipelines
211
+ * and stderr fd duplication (`2>&1`).
164
212
  */
165
213
  /** @internal — exported for injection-guard unit testing (Round 25). */
166
214
  export function __test__validateGateCommand(command: string): void {
@@ -187,6 +235,7 @@ function validateGateCommand(command: string): void {
187
235
  if (DANGEROUS_SHELL_PATTERNS.test(normalized)) {
188
236
  throw new Error(`Security: verification gate command rejected (dangerous shell pattern): ${command}`);
189
237
  }
238
+ validateAllowedGateCommands(normalized, command);
190
239
  }
191
240
 
192
241
  async function executeCommand(