supipowers 2.2.1 → 2.2.2

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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Workflow extension for OMP coding agents.",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "bun test --timeout 20000 tests/",
7
+ "test": "bun test --timeout 60000 --parallel tests/",
8
8
  "typecheck": "tsc --noEmit",
9
- "test:watch": "bun test --timeout 20000 --watch tests/",
10
- "test:evals": "bun test --timeout 20000 tests/evals/",
9
+ "test:watch": "bun test --timeout 60000 --parallel --watch tests/",
10
+ "test:evals": "bun test --timeout 60000 --parallel tests/evals/",
11
11
  "build": "tsc -p tsconfig.build.json",
12
12
  "ci": "bun run typecheck && bun run test",
13
13
  "install:visual-server": "npm --prefix src/visual/scripts ci --ignore-scripts --no-audit --no-fund",
@@ -16,7 +16,7 @@
16
16
  "dev-install": "bun run bin/dev-install.ts"
17
17
  },
18
18
  "engines": {
19
- "bun": ">=1.3.10"
19
+ "bun": ">=1.3.13"
20
20
  },
21
21
  "keywords": [
22
22
  "omp",
@@ -65,7 +65,7 @@
65
65
  ]
66
66
  },
67
67
  "dependencies": {
68
- "@clack/prompts": "^0.10.0",
68
+ "@clack/prompts": "^1.4.0",
69
69
  "handlebars": "^4.7.8",
70
70
  "yaml": "^2.8.3",
71
71
  "zod": "^4.3.0"
@@ -90,8 +90,8 @@
90
90
  "@oh-my-pi/pi-ai": "latest",
91
91
  "@oh-my-pi/pi-coding-agent": "latest",
92
92
  "@oh-my-pi/pi-tui": "latest",
93
- "@types/node": "^22.0.0",
93
+ "@types/node": "^25.8.0",
94
94
  "bun-types": "^1.3.11",
95
- "typescript": "^5.9.3"
95
+ "typescript": "^6.0.3"
96
96
  }
97
97
  }
package/src/bootstrap.ts CHANGED
@@ -12,6 +12,7 @@ import { registerAiReviewCommand, handleAiReview } from "./commands/ai-review.js
12
12
  import { registerQaCommand } from "./commands/qa.js";
13
13
  import { registerReleaseCommand, handleRelease } from "./commands/release.js";
14
14
  import { registerUpdateCommand, handleUpdate } from "./commands/update.js";
15
+ import { execCli } from "./utils/exec-cli.js";
15
16
  import { registerDoctorCommand, handleDoctor } from "./commands/doctor.js";
16
17
  import { registerModelCommand, handleModel } from "./commands/model.js";
17
18
  import { registerFixPrCommand } from "./commands/fix-pr.js";
@@ -175,7 +176,7 @@ export function bootstrap(platform: Platform): void {
175
176
  const currentVersion = getInstalledVersion(platform);
176
177
  if (!currentVersion) return;
177
178
 
178
- platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
179
+ execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
179
180
  .then((result) => {
180
181
  if (result.code !== 0) return;
181
182
  const latest = result.stdout.trim();
@@ -11,6 +11,7 @@ import { formatReliabilitySection, loadReliabilitySummaries } from "../storage/r
11
11
  import { getMetricsStore, getSessionId } from "../context-mode/hooks.js";
12
12
  import { getProjectStatePath, getProjectStateDir } from "../workspace/state-paths.js";
13
13
  import { basename } from "node:path";
14
+ import { execCli } from "../utils/exec-cli.js";
14
15
 
15
16
  export interface CheckResult {
16
17
  name: string;
@@ -435,14 +436,14 @@ export function checkMetrics(
435
436
 
436
437
  export async function checkNpm(platform: Platform): Promise<CheckResult> {
437
438
  try {
438
- const vResult = await platform.exec("npm", ["--version"]);
439
+ const vResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["--version"]);
439
440
  if (vResult.code !== 0) {
440
441
  return { name: "npm", presence: { ok: false, detail: "npm not found" } };
441
442
  }
442
443
  const version = vResult.stdout.trim();
443
444
  const presence = { ok: true, detail: `v${version}` };
444
445
 
445
- const pingResult = await platform.exec("npm", ["ping"]);
446
+ const pingResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["ping"]);
446
447
  if (pingResult.code === 0) {
447
448
  return { name: "npm", presence, functional: { ok: true, detail: "Registry reachable" } };
448
449
  }
@@ -19,6 +19,7 @@ import { loadModelConfig } from "../config/model-config.js";
19
19
  import { getProjectStatePath } from "../workspace/state-paths.js";
20
20
  import { cancelPlanTracking, startPlanTracking } from "../planning/approval-flow.js";
21
21
  import { stopVisualServer } from "../visual/stop-server.js";
22
+ import { execCli } from "../utils/exec-cli.js";
22
23
 
23
24
  modelRegistry.register({
24
25
  id: "plan",
@@ -111,7 +112,7 @@ export function registerPlanCommand(platform: Platform): void {
111
112
  const nodeModules = path.join(scriptsDir, "node_modules");
112
113
  if (!fs.existsSync(nodeModules)) {
113
114
  notifyInfo(ctx, "Installing visual companion dependencies...");
114
- const installResult = await platform.exec("npm", ["install", "--production"], { cwd: scriptsDir });
115
+ const installResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--production"], { cwd: scriptsDir });
115
116
  if (installResult.code !== 0) {
116
117
  notifyError(ctx, "Failed to install visual companion dependencies", installResult.stderr);
117
118
  debugLogger.log("visual_companion_dependency_install_failed", {
@@ -11,6 +11,7 @@ import {
11
11
  steerMempalaceInitialization,
12
12
  } from "../mempalace/installer-helper.js";
13
13
  import { loadConfig } from "../config/loader.js";
14
+ import { execCli, wrapExecForCli } from "../utils/exec-cli.js";
14
15
 
15
16
  // ── Options builder ──────────────────────────────────────
16
17
 
@@ -59,7 +60,7 @@ async function updateSupipowers(
59
60
  ctx.ui.notify(`Current version: v${currentVersion}`, "info");
60
61
 
61
62
  // Check latest version on npm
62
- const checkResult = await platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() });
63
+ const checkResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() });
63
64
  if (checkResult.code !== 0) {
64
65
  ctx.ui.notify("Failed to check for updates — npm view failed", "error");
65
66
  return null;
@@ -78,7 +79,8 @@ async function updateSupipowers(
78
79
  mkdirSync(tempDir, { recursive: true });
79
80
 
80
81
  try {
81
- const installResult = await platform.exec(
82
+ const installResult = await execCli(
83
+ (cmd, args, opts) => platform.exec(cmd, args, opts),
82
84
  "npm", ["install", "--prefix", tempDir, `supipowers@${latestVersion}`],
83
85
  { cwd: tempDir },
84
86
  );
@@ -130,10 +132,10 @@ async function updateSupipowers(
130
132
  // Install runtime dependencies (handlebars, etc.)
131
133
  // Without this, the extension fails to load because node_modules/ was deleted above.
132
134
  ctx.ui.notify("Installing dependencies...", "info");
133
- const bunInstall = await platform.exec("bun", ["install", "--production"], { cwd: extDir });
135
+ const bunInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "bun", ["install", "--production"], { cwd: extDir });
134
136
  if (bunInstall.code !== 0) {
135
137
  // Fallback to npm if bun is not available (e.g. Windows without global bun)
136
- const npmInstall = await platform.exec("npm", ["install", "--omit=dev"], { cwd: extDir });
138
+ const npmInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--omit=dev"], { cwd: extDir });
137
139
  if (npmInstall.code !== 0) {
138
140
  ctx.ui.notify(
139
141
  "Could not install extension dependencies.\n" +
@@ -177,7 +179,7 @@ async function updateSupipowers(
177
179
 
178
180
  export function handleUpdate(platform: Platform, ctx: PlatformContext): void {
179
181
  void (async () => {
180
- const exec = (cmd: string, args: string[]) => platform.exec(cmd, args);
182
+ const exec = wrapExecForCli((cmd: string, args: string[]) => platform.exec(cmd, args));
181
183
 
182
184
  // 1. Scan all dependencies
183
185
  const allStatuses = await scanAll(exec);
@@ -25,6 +25,7 @@ import {
25
25
  type SlopBackendResult,
26
26
  type SlopFinding,
27
27
  } from "./backend.js";
28
+ import { execCli } from "../../utils/exec-cli.js";
28
29
 
29
30
  const DEFAULT_TIMEOUT_MS = 60_000;
30
31
 
@@ -149,7 +150,7 @@ async function resolveInvocation(
149
150
  }
150
151
 
151
152
  try {
152
- const probe = await platform.exec("npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
153
+ const probe = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
153
154
  if (probe.code === 0) {
154
155
  availabilityCache = { ok: true, via: "npx" };
155
156
  return { ok: true, cmd: "npx", baseArgs: ["--no-install", "fallow"], via: "npx" };
@@ -187,7 +188,7 @@ async function runFallow(
187
188
  const startedAt = Date.now();
188
189
  let result;
189
190
  try {
190
- result = await platform.exec(invocation.cmd, args, {
191
+ result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
191
192
  cwd: opts.cwd,
192
193
  timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
193
194
  });
@@ -269,7 +270,7 @@ export class FallowAdapter implements SlopBackend {
269
270
  if (opts.subtree) args.push("--path", opts.subtree);
270
271
 
271
272
  try {
272
- const result = await platform.exec(invocation.cmd, args, {
273
+ const result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
273
274
  cwd: opts.cwd,
274
275
  timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
275
276
  });
@@ -45,7 +45,6 @@ import {
45
45
  import { computeScore } from "./anti_slop/score.js";
46
46
  import {
47
47
  type BuildRunnerInput,
48
- type HarnessPipelineProgressEvent,
49
48
  type PipelineRunOutcome,
50
49
  HARNESS_STAGE_ORDER,
51
50
  runHarnessPipelineUntilGate,
@@ -58,7 +57,7 @@ import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
58
57
  import { handlePrComment } from "./pr-comment/handler.js";
59
58
  import { runGitVerificationQa } from "./git-verify-qa.js";
60
59
  import { getHarnessSessionDir } from "./project-paths.js";
61
- import type { HarnessDesignSpec, HarnessGateMode, HarnessSession, HarnessStage } from "../types.js";
60
+ import type { HarnessDesignSpec, HarnessGateMode, HarnessPipelineProgressEvent, HarnessSession, HarnessStage } from "../types.js";
62
61
 
63
62
  modelRegistry.register({
64
63
  id: "harness",
@@ -133,9 +132,11 @@ function createHarnessProgress(ctx: HarnessCommandContext) {
133
132
  let done = 0;
134
133
  let cur: HarnessStage | null = null;
135
134
  const completed: string[] = [];
135
+ let liveDetail: string | null = null;
136
136
 
137
137
  function refresh() {
138
- const label = cur ? HARNESS_STAGE_LABELS[cur] : "Complete";
138
+ const baseLabel = cur ? HARNESS_STAGE_LABELS[cur] : "Complete";
139
+ const label = cur && liveDetail ? `${baseLabel} — ${liveDetail}` : baseLabel;
139
140
  const spinner = cur ? "\u25cc" : "\u2713";
140
141
  (ctx.ui as any).setStatus?.("supi-harness", ` ${spinner} harness: ${label} (${done}/${SO.length})`);
141
142
  }
@@ -147,22 +148,26 @@ function createHarnessProgress(ctx: HarnessCommandContext) {
147
148
  case "stage-started":
148
149
  cur = event.stage;
149
150
  break;
151
+ case "stage-progress":
152
+ cur = event.stage;
153
+ liveDetail = event.detail;
154
+ break;
150
155
  case "stage-completed": {
151
- done += 1; cur = null;
156
+ done += 1; cur = null; liveDetail = null;
152
157
  const mark = "\u2713";
153
158
  completed.push(`${mark} ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "done"}`);
154
159
  break;
155
160
  }
156
161
  case "stage-skipped":
157
- done += 1; cur = null;
162
+ done += 1; cur = null; liveDetail = null;
158
163
  completed.push(`\u2013 ${HARNESS_STAGE_LABELS[event.stage]}: skipped`);
159
164
  break;
160
165
  case "awaiting-user":
161
- done += 1; cur = null;
166
+ done += 1; cur = null; liveDetail = null;
162
167
  completed.push(`\u25cb ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "awaiting review"}`);
163
168
  break;
164
169
  case "stage-failed": case "stage-blocked":
165
- cur = null;
170
+ cur = null; liveDetail = null;
166
171
  completed.push(`\u2717 ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "failed"}`);
167
172
  break;
168
173
  }
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import type { Platform, PlatformPaths } from "../platform/types.js";
15
15
  import type {
16
16
  HarnessGateMode,
17
+ HarnessPipelineProgressEvent,
17
18
  HarnessStage,
18
19
  ModelConfig,
19
20
  } from "../types.js";
@@ -38,14 +39,6 @@ import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
38
39
  import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
39
40
  import { getProjectStatePath } from "../workspace/state-paths.js";
40
41
 
41
- /** Progress event emitted by the pipeline driver for UI feedback. */
42
- export type HarnessPipelineProgressEvent =
43
- | { type: "stage-started"; stage: HarnessStage }
44
- | { type: "stage-skipped"; stage: HarnessStage }
45
- | { type: "stage-completed"; stage: HarnessStage; detail?: string }
46
- | { type: "stage-blocked"; stage: HarnessStage; detail: string }
47
- | { type: "stage-failed"; stage: HarnessStage; detail: string }
48
- | { type: "awaiting-user"; stage: HarnessStage; detail?: string };
49
42
 
50
43
  const STAGE_ORDER: readonly HarnessStage[] = [
51
44
  "discover",
@@ -301,6 +294,7 @@ export async function runHarnessPipelineUntilGate(
301
294
  sessionId: input.sessionId,
302
295
  modelConfig: input.modelConfig,
303
296
  gateMode: input.gates,
297
+ onProgress: (event) => input.onProgress?.(event),
304
298
  };
305
299
 
306
300
  const result = await runner.run(ctx);
@@ -21,6 +21,7 @@
21
21
  import type { Platform, PlatformPaths } from "../platform/types.js";
22
22
  import type {
23
23
  HarnessGateMode,
24
+ HarnessPipelineProgressEvent,
24
25
  HarnessStage,
25
26
  HarnessStageStatus,
26
27
  ModelConfig,
@@ -46,6 +47,8 @@ export interface HarnessStageRunnerContext {
46
47
  now?: () => string;
47
48
  /** Optional override for the agent session model. Tests use this to bypass resolution. */
48
49
  modelOverride?: { model: string; thinkingLevel: string | null };
50
+ /** Live progress sink for long-running stage internals such as subagent turns. */
51
+ onProgress?: (event: HarnessPipelineProgressEvent) => void;
49
52
  }
50
53
 
51
54
  export type HarnessStageRunStatus =
@@ -90,6 +90,7 @@ export interface DocsStageInput {
90
90
  }
91
91
 
92
92
  interface AgentSessionLike {
93
+ subscribe?: (handler: (event: unknown) => void) => () => void;
93
94
  prompt(text: string, opts?: { expandPromptTemplates?: boolean }): Promise<void>;
94
95
  dispose(): Promise<void>;
95
96
  }
@@ -536,6 +537,60 @@ async function orchestrateLayerSubagent(input: OrchestrateLayerInput): Promise<O
536
537
  }
537
538
  }
538
539
 
540
+ function isRecord(value: unknown): value is Record<string, unknown> {
541
+ return typeof value === "object" && value !== null && !Array.isArray(value);
542
+ }
543
+
544
+ function stringField(record: Record<string, unknown>, key: string): string | null {
545
+ const value = record[key];
546
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
547
+ }
548
+
549
+ function nestedStringField(record: Record<string, unknown>, objectKey: string, fieldKey: string): string | null {
550
+ const nested = record[objectKey];
551
+ if (!isRecord(nested)) return null;
552
+ return stringField(nested, fieldKey);
553
+ }
554
+
555
+ function compactDetail(value: string, maxChars = 180): string {
556
+ const compact = value.replace(/\s+/g, " ").trim();
557
+ if (compact.length <= maxChars) return compact;
558
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
559
+ }
560
+
561
+ function summarizeAgentSessionEvent(event: unknown): string | null {
562
+ if (!isRecord(event)) return null;
563
+ const type = stringField(event, "type") ?? stringField(event, "kind") ?? stringField(event, "event");
564
+ const lowerType = type?.toLowerCase() ?? "";
565
+ const toolName =
566
+ stringField(event, "toolName") ??
567
+ stringField(event, "tool") ??
568
+ stringField(event, "name") ??
569
+ nestedStringField(event, "toolCall", "name") ??
570
+ nestedStringField(event, "tool_call", "name");
571
+ if (toolName && (lowerType.includes("tool") || lowerType.includes("call"))) {
572
+ return `tool ${compactDetail(toolName, 80)}`;
573
+ }
574
+
575
+ const text =
576
+ stringField(event, "text") ??
577
+ stringField(event, "delta") ??
578
+ stringField(event, "thought") ??
579
+ stringField(event, "content") ??
580
+ nestedStringField(event, "message", "text") ??
581
+ nestedStringField(event, "message", "content");
582
+ if (text) {
583
+ const prefix = lowerType.includes("thought") || lowerType.includes("reason")
584
+ ? "thought"
585
+ : lowerType.includes("tool")
586
+ ? "tool"
587
+ : "agent";
588
+ return `${prefix} ${compactDetail(text)}`;
589
+ }
590
+
591
+ return type ? compactDetail(type, 80) : null;
592
+ }
593
+
539
594
  async function dispatchSubagent(input: {
540
595
  platform: Platform;
541
596
  ctx: HarnessStageRunnerContext;
@@ -548,7 +603,13 @@ async function dispatchSubagent(input: {
548
603
  const agentDisplayName = buildHarnessAgentDisplayName("docs", input.entry.layer.layer);
549
604
 
550
605
  let session: AgentSessionLike | null = null;
606
+ let unsubscribe: (() => void) | null = null;
551
607
  try {
608
+ input.ctx.onProgress?.({
609
+ type: "stage-progress",
610
+ stage: "docs",
611
+ detail: `${agentDisplayName}: starting attempt ${input.attempt}`,
612
+ });
552
613
  if (input.factory) {
553
614
  session = await input.factory(input.platform, {
554
615
  cwd: input.ctx.cwd,
@@ -562,7 +623,21 @@ async function dispatchSubagent(input: {
562
623
  agentDisplayName,
563
624
  });
564
625
  }
626
+ unsubscribe = session.subscribe?.((event) => {
627
+ const summary = summarizeAgentSessionEvent(event);
628
+ if (!summary) return;
629
+ input.ctx.onProgress?.({
630
+ type: "stage-progress",
631
+ stage: "docs",
632
+ detail: `${agentDisplayName}: ${summary}`,
633
+ });
634
+ }) ?? null;
565
635
  await session.prompt(input.assignment, { expandPromptTemplates: false });
636
+ input.ctx.onProgress?.({
637
+ type: "stage-progress",
638
+ stage: "docs",
639
+ detail: `${agentDisplayName}: attempt ${input.attempt} complete`,
640
+ });
566
641
  } catch (error) {
567
642
  return {
568
643
  ok: false,
@@ -571,6 +646,13 @@ async function dispatchSubagent(input: {
571
646
  ],
572
647
  };
573
648
  } finally {
649
+ if (unsubscribe) {
650
+ try {
651
+ unsubscribe();
652
+ } catch {
653
+ /* best-effort */
654
+ }
655
+ }
574
656
  if (session) {
575
657
  try {
576
658
  await session.dispose();
@@ -49,17 +49,22 @@ export function uvTargetFor(uvPlatform: UvPlatform): UvTarget {
49
49
  case "linux-arm64":
50
50
  return target("aarch64-unknown-linux-gnu", ".tar.gz", "uv");
51
51
  case "win32-x64":
52
- return target("x86_64-pc-windows-msvc", ".zip", "uv.exe");
52
+ return target("x86_64-pc-windows-msvc", ".zip", "uv.exe", "uv.exe");
53
53
  }
54
54
  }
55
55
 
56
- function target(triple: string, archiveSuffix: string, binary: string): UvTarget {
56
+ function target(
57
+ triple: string,
58
+ archiveSuffix: string,
59
+ binary: string,
60
+ archiveBinaryRelativePath = path.join(`uv-${triple}`, binary),
61
+ ): UvTarget {
57
62
  const archive = `uv-${triple}${archiveSuffix}`;
58
63
  return {
59
64
  triple,
60
65
  archive,
61
66
  binary,
62
- archiveBinaryRelativePath: path.join(`uv-${triple}`, binary),
67
+ archiveBinaryRelativePath,
63
68
  };
64
69
  }
65
70
 
@@ -226,11 +231,14 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
226
231
  };
227
232
  }
228
233
 
229
- // Replace any pre-existing managed binary atomically-ish.
230
- if (fs.existsSync(managedPath)) {
231
- try { fs.unlinkSync(managedPath); } catch { /* best effort */ }
234
+ // Replace any pre-existing managed binary atomically-ish. Windows uv zips place
235
+ // uv.exe at the archive root, which is already `managedPath` after extraction.
236
+ if (path.resolve(extractedBinary) !== path.resolve(managedPath)) {
237
+ if (fs.existsSync(managedPath)) {
238
+ try { fs.unlinkSync(managedPath); } catch { /* best effort */ }
239
+ }
240
+ fs.renameSync(extractedBinary, managedPath);
232
241
  }
233
- fs.renameSync(extractedBinary, managedPath);
234
242
  if (process.platform !== "win32") {
235
243
  fs.chmodSync(managedPath, 0o755);
236
244
  }
@@ -278,7 +278,7 @@ async function executeApproveFlow(
278
278
  debugLogger?.log("execution_handoff_new_session_cancelled", {
279
279
  planPath,
280
280
  });
281
- ctx.ui.notify("Session start cancelled. Plan saved; run /supi:plan again to execute.");
281
+ ctx.ui?.notify?.("Session start cancelled. Plan saved; run /supi:plan again to execute.");
282
282
  return;
283
283
  }
284
284
  platform.sendUserMessage(prompt);
@@ -298,7 +298,7 @@ async function executeApproveFlow(
298
298
  debugLogger?.log("execution_handoff_same_session_steer_sent", {
299
299
  planPath,
300
300
  });
301
- ctx.ui.notify("Plan approved — starting execution");
301
+ ctx.ui?.notify?.("Plan approved — starting execution");
302
302
  }
303
303
  }
304
304
 
@@ -408,25 +408,23 @@ export function registerPlanApprovalHook(platform: Platform): void {
408
408
  });
409
409
  } catch {}
410
410
  if (!ctx?.hasUI) {
411
- const message = [
412
- `Plan saved to \`${planPath}\`.`,
413
- "Interactive approval is unavailable in this runtime, so no execution was started.",
414
- `To continue manually, explicitly send: \`Execute the saved plan at ${planPath} step by step; verify each step before proceeding.\``,
415
- ].join("\n");
416
- debugLogger?.log("approval_flow_no_ui", {
411
+ debugLogger?.log("approval_flow_no_ui_auto_execute", {
417
412
  planName,
418
413
  planPath,
419
414
  });
420
- ctx?.ui?.notify?.("Plan saved; interactive approval is required before execution.", "warning");
421
- platform.sendMessage(
422
- {
423
- customType: "supi-plan-awaiting-interactive-approval",
424
- content: [{ type: "text", text: message }],
425
- display: true,
426
- },
427
- { deliverAs: "steer", triggerTurn: false },
428
- );
415
+ const executionNewSession = capturedNewSession;
416
+ const executionModel = capturedResolvedModel;
429
417
  cancelPlanTracking();
418
+ await executeApproveFlow(
419
+ platform,
420
+ ctx,
421
+ canonicalContent,
422
+ planPath,
423
+ executionNewSession,
424
+ executionModel,
425
+ debugLogger,
426
+ parsedPlan,
427
+ );
430
428
  return;
431
429
  }
432
430
 
package/src/types.ts CHANGED
@@ -1550,6 +1550,16 @@ export type HarnessStage =
1550
1550
  | "docs"
1551
1551
  | "validate";
1552
1552
 
1553
+ /** Progress event emitted by harness pipeline drivers and long-running stages. */
1554
+ export type HarnessPipelineProgressEvent =
1555
+ | { type: "stage-started"; stage: HarnessStage }
1556
+ | { type: "stage-progress"; stage: HarnessStage; detail: string }
1557
+ | { type: "stage-skipped"; stage: HarnessStage }
1558
+ | { type: "stage-completed"; stage: HarnessStage; detail?: string }
1559
+ | { type: "stage-blocked"; stage: HarnessStage; detail: string }
1560
+ | { type: "stage-failed"; stage: HarnessStage; detail: string }
1561
+ | { type: "awaiting-user"; stage: HarnessStage; detail?: string };
1562
+
1553
1563
  /** Operational status of a harness stage. Mirrors UltraPlanAuthoringStageStatus. */
1554
1564
  export type HarnessStageStatus =
1555
1565
  | "pending"
@@ -0,0 +1,106 @@
1
+ import { dirname, join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+
4
+ import type { ExecOptions, ExecResult } from "../platform/types.js";
5
+ import { findExecutable } from "./executable.js";
6
+
7
+ /**
8
+ * Cross-platform invocation for npm/npx that survives Windows `.cmd` shims.
9
+ *
10
+ * OMP's `platform.exec` is a thin wrapper over libuv's `uv_spawn`. On Windows
11
+ * that exposes two distinct bugs when the target is an npm-shipped CLI:
12
+ *
13
+ * 1. libuv does not consult `PATHEXT`, so spawning `"npm"` fails with
14
+ * `ENOENT: uv_spawn 'npm'` because the on-disk file is `npm.cmd`.
15
+ * 2. Even when callers resolve the absolute path, Node ≥18.20.2 hard-rejects
16
+ * spawning `.cmd`/`.bat` shims without `shell: true` (CVE-2024-27980).
17
+ * `ExecOptions` does not expose `shell`.
18
+ *
19
+ * Wrapping in `cmd.exe /d /s /c` is the canonical workaround, but only safe
20
+ * when the spawner sets `windowsVerbatimArguments: true` — Node's default
21
+ * CRT escaping double-quotes the command line and cmd's `/s` only strips one
22
+ * pair. We don't control the spawner, so we sidestep the whole problem by
23
+ * resolving the shim to the real `node <cli.js>` invocation, which is exactly
24
+ * what `npm.cmd` does internally. `node.exe` is a plain binary that libuv
25
+ * spawns without ceremony.
26
+ *
27
+ * Non-shim binaries (`bun`, `git`, `node`, `gh`, `rustup`, `go`, `pip`, …
28
+ * all ship as `.exe` on Windows) pass through untouched; POSIX always passes
29
+ * through.
30
+ */
31
+
32
+ export type ExecFn = (
33
+ cmd: string,
34
+ args: string[],
35
+ opts?: ExecOptions,
36
+ ) => Promise<ExecResult>;
37
+
38
+ interface ResolvedInvocation {
39
+ cmd: string;
40
+ prefixArgs: string[];
41
+ }
42
+
43
+ const NODE_SHIMS = new Set<string>(["npm", "npx"]);
44
+ const resolutionCache = new Map<string, ResolvedInvocation>();
45
+
46
+ function resolveNodeShim(command: string): ResolvedInvocation | null {
47
+ if (!NODE_SHIMS.has(command)) return null;
48
+
49
+ // node.exe is the executor; npm-cli.js / npx-cli.js sits next to it under
50
+ // node_modules/npm/bin/. We deliberately key off node's location (not the
51
+ // shim's) because `npm.cmd` can live in a user-global dir (e.g. nvm,
52
+ // %AppData%\npm) while the actual CLI bundle stays alongside node.
53
+ const nodeBin = findExecutable("node");
54
+ if (!nodeBin) return null;
55
+
56
+ const cliJs = join(
57
+ dirname(nodeBin),
58
+ "node_modules",
59
+ "npm",
60
+ "bin",
61
+ `${command}-cli.js`,
62
+ );
63
+ if (!existsSync(cliJs)) return null;
64
+
65
+ return { cmd: nodeBin, prefixArgs: [cliJs] };
66
+ }
67
+
68
+ function resolveInvocation(command: string): ResolvedInvocation {
69
+ if (process.platform !== "win32") {
70
+ return { cmd: command, prefixArgs: [] };
71
+ }
72
+ const cached = resolutionCache.get(command);
73
+ if (cached) return cached;
74
+
75
+ const resolved = resolveNodeShim(command) ?? { cmd: command, prefixArgs: [] };
76
+ resolutionCache.set(command, resolved);
77
+ return resolved;
78
+ }
79
+
80
+ /**
81
+ * Drop-in replacement for `platform.exec` callers that invoke npm/npx by name.
82
+ * Other commands pass through unchanged.
83
+ */
84
+ export function execCli(
85
+ exec: ExecFn,
86
+ command: string,
87
+ args: string[],
88
+ opts?: ExecOptions,
89
+ ): Promise<ExecResult> {
90
+ const resolved = resolveInvocation(command);
91
+ return exec(resolved.cmd, [...resolved.prefixArgs, ...args], opts);
92
+ }
93
+
94
+ /**
95
+ * Wrap an `ExecFn` so every call routes through `execCli`. Use when threading
96
+ * an exec callback into helpers that dispatch arbitrary tools by string name
97
+ * (e.g. the deps installer which splits install-command strings).
98
+ */
99
+ export function wrapExecForCli(exec: ExecFn): ExecFn {
100
+ return (cmd, args, opts) => execCli(exec, cmd, args, opts);
101
+ }
102
+
103
+ /** Clears cached CLI resolutions after PATH or tooling changes. */
104
+ export function clearExecCliResolutionCache(): void {
105
+ resolutionCache.clear();
106
+ }