openmausbot 0.1.82 → 0.1.83

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 (47) hide show
  1. package/dist/assets/{index-BLLsDK2F.js → index-DSvqfBOf.js} +71 -71
  2. package/dist/assets/{index-5HVwp5m2.js → index-nqTFZTxg.js} +1 -1
  3. package/dist/index.html +1 -1
  4. package/dist-server/container-mcp.js +20 -4
  5. package/dist-server/drivers/agents-proxy.js +4 -4
  6. package/dist-server/index.js +1063 -493
  7. package/dist-server/mcp-gate.js +2 -1
  8. package/dist-server/openmausbot.js +187 -55
  9. package/dist-server/pair-cli.js +187 -55
  10. package/dist-server/server/auto-approve.js +15 -1
  11. package/dist-server/server/auto-vm-claims.js +22 -0
  12. package/dist-server/server/browser-bundle-release.js +8 -7
  13. package/dist-server/server/browser-engine.js +1 -1
  14. package/dist-server/server/channel-queue.js +58 -0
  15. package/dist-server/server/chief-of-staff.js +2 -2
  16. package/dist-server/server/config.js +33 -2
  17. package/dist-server/server/contracts.js +0 -7
  18. package/dist-server/server/drivers/acp/gemini.js +11 -1
  19. package/dist-server/server/drivers/acp/qwen.js +16 -1
  20. package/dist-server/server/drivers/agents-proxy.js +4 -4
  21. package/dist-server/server/drivers/claude.js +4 -3
  22. package/dist-server/server/drivers/codex.js +66 -1
  23. package/dist-server/server/drivers/pi.js +2 -1
  24. package/dist-server/server/env-path.js +6 -1
  25. package/dist-server/server/index.js +534 -84
  26. package/dist-server/server/local-routing.js +10 -4
  27. package/dist-server/server/peer-roster.js +37 -1
  28. package/dist-server/server/recent-work.js +4 -1
  29. package/dist-server/server/redact.js +5 -48
  30. package/dist-server/server/room-handoffs.js +79 -16
  31. package/dist-server/server/skill-fetch.js +1 -1
  32. package/dist-server/server/steer-queue.js +29 -0
  33. package/dist-server/server/store.js +26 -3
  34. package/dist-server/server/thread-retention.js +50 -0
  35. package/dist-server/server/turn-context.js +7 -0
  36. package/dist-server/server/turn-resources.js +6 -0
  37. package/dist-server/shared/approval-mode.js +8 -5
  38. package/dist-server/shared/inspector.js +1 -0
  39. package/dist-server/shared/json.js +1 -0
  40. package/dist-server/shared/notification.js +1 -0
  41. package/dist-server/shared/redact.js +61 -0
  42. package/dist-server/shared/routines.js +1 -0
  43. package/dist-server/shared/runtime-events.js +1 -0
  44. package/dist-server/shared/webhooks.js +1 -0
  45. package/dist-server/shared/wire.js +8 -0
  46. package/dist-server/vps-container-mcp.js +20 -4
  47. package/package.json +1 -1
@@ -7,7 +7,8 @@ import { join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
9
9
  import { writeFileAtomic } from "./atomic.js";
10
- import { EFFORT_LEVELS, isModelVariant } from "./contracts.js";
10
+ import { EFFORT_LEVELS } from "../shared/wire.js";
11
+ import { isModelVariant } from "./contracts.js";
11
12
  import { isRemoteMcpServer, parseStoredMcpServer } from "./mcp-registry.js";
12
13
  import { parseJson, schemaIssue } from "./schema.js";
13
14
  const optionalText = z.string().optional();
@@ -17,6 +18,9 @@ const BROWSER_PROFILE_ID = /^[a-z0-9_-]{1,40}$/;
17
18
  export const DEFAULT_ROOM_TURN_TIMEOUT_MINUTES = 5;
18
19
  export const MIN_ROOM_TURN_TIMEOUT_MINUTES = 1;
19
20
  export const MAX_ROOM_TURN_TIMEOUT_MINUTES = 1_440;
21
+ export const DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES = 30;
22
+ export const DEFAULT_ROOM_HANDOFF_MIN_RUNWAY_MINUTES = 10;
23
+ export const DEFAULT_ROOM_HANDOFF_HARD_CAP_MINUTES = 240;
20
24
  export const DEFAULT_MAX_CONCURRENT_BOT_THREADS = 3;
21
25
  export const MAX_CONCURRENT_BOT_THREADS = 10;
22
26
  /** Bounds for threads.eventLogMaxBytes: the floor keeps the kept tail large
@@ -57,7 +61,15 @@ const roomConfigSchema = z.object({
57
61
  .int()
58
62
  .min(MIN_ROOM_TURN_TIMEOUT_MINUTES)
59
63
  .max(MAX_ROOM_TURN_TIMEOUT_MINUTES),
60
- });
64
+ /** Room handoff tree lifetime. Active execution pauses this clock; the
65
+ * hard cap is wall-clock and bounds trees that never stop executing. */
66
+ handoffLifetimeMinutes: z.number().int().min(1).max(MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
67
+ handoffMinRunwayMinutes: z.number().int().min(1).max(MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
68
+ handoffHardCapMinutes: z.number().int().min(1).max(7 * MAX_ROOM_TURN_TIMEOUT_MINUTES).optional(),
69
+ }).refine((rooms) => (rooms.handoffMinRunwayMinutes ?? DEFAULT_ROOM_HANDOFF_MIN_RUNWAY_MINUTES) <=
70
+ (rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) &&
71
+ (rooms.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) <=
72
+ (rooms.handoffHardCapMinutes ?? DEFAULT_ROOM_HANDOFF_HARD_CAP_MINUTES), { message: "rooms handoff bounds must satisfy handoffMinRunwayMinutes <= handoffLifetimeMinutes <= handoffHardCapMinutes" });
61
73
  const localVmConfigSchema = z.object({
62
74
  mode: z.enum(["shared", "per-bot"]).optional(),
63
75
  maxInstances: z
@@ -349,6 +361,9 @@ const appConfigSchema = z.object({
349
361
  /** Cap each per-thread events/ and native/ NDJSON log at this many
350
362
  * bytes; absent (the default) keeps today's unbounded growth (#1280). */
351
363
  eventLogMaxBytes: z.number().int().min(MIN_THREAD_EVENT_LOG_BYTES).max(MAX_THREAD_EVENT_LOG_BYTES).optional(),
364
+ /** Days a closed or archived thread's event logs survive (#1280).
365
+ * Absent keeps them forever. */
366
+ eventLogRetentionDays: z.number().int().min(1).max(3650).optional(),
352
367
  }).strict().optional(),
353
368
  localVm: localVmConfigSchema.optional(),
354
369
  features: featureConfigSchema.optional(),
@@ -447,6 +462,16 @@ export function vpsSshAlias(cfg) {
447
462
  export function roomTurnTimeoutMinutes(cfg) {
448
463
  return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
449
464
  }
465
+ /** Room handoff tree budgets in milliseconds. The tree lifetime pauses
466
+ * while a node is actively executing; the hard cap is wall-clock and bounds
467
+ * trees that never stop. Read when the server starts. */
468
+ export function roomHandoffLimits(cfg) {
469
+ return {
470
+ lifetimeMs: (cfg.rooms?.handoffLifetimeMinutes ?? DEFAULT_ROOM_HANDOFF_LIFETIME_MINUTES) * 60_000,
471
+ minRunwayMs: (cfg.rooms?.handoffMinRunwayMinutes ?? DEFAULT_ROOM_HANDOFF_MIN_RUNWAY_MINUTES) * 60_000,
472
+ hardCapMs: (cfg.rooms?.handoffHardCapMinutes ?? DEFAULT_ROOM_HANDOFF_HARD_CAP_MINUTES) * 60_000,
473
+ };
474
+ }
450
475
  export function maxConcurrentBotThreads(cfg) {
451
476
  return cfg.threads?.maxConcurrentPerBot ?? DEFAULT_MAX_CONCURRENT_BOT_THREADS;
452
477
  }
@@ -456,6 +481,12 @@ export function threadEventLogMaxBytes(cfg) {
456
481
  const cap = cfg.threads?.eventLogMaxBytes;
457
482
  return typeof cap === "number" && Number.isFinite(cap) && cap > 0 ? cap : null;
458
483
  }
484
+ /** Days a closed or archived bot thread's event logs survive before the
485
+ * retention sweep removes them (#1280). Null — the default — keeps them
486
+ * forever. */
487
+ export function threadEventLogRetentionDays(cfg) {
488
+ return cfg.threads?.eventLogRetentionDays ?? null;
489
+ }
459
490
  export function localVmMode(cfg) {
460
491
  return cfg.localVm?.mode ?? DEFAULT_LOCAL_VM_MODE;
461
492
  }
@@ -12,13 +12,6 @@ export class ProviderError extends Error {
12
12
  this.code = code;
13
13
  }
14
14
  }
15
- /** Reasoning-effort levels, ascending. A union of everything any engine
16
- * accepts; each driver declares the subset its CLI will take. */
17
- export const EFFORT_LEVELS = ["none", "low", "medium", "high", "xhigh", "max"];
18
- /** Narrow untrusted API/config input before it becomes a model selection. */
19
- export function isEffortLevel(value) {
20
- return typeof value === "string" && EFFORT_LEVELS.includes(value);
21
- }
22
15
  /** Variants are opaque provider IDs, not the cross-engine effort enum. */
23
16
  export function isModelVariant(value) {
24
17
  return typeof value === "string" && value.length > 0 && value.length <= 256 &&
@@ -64,6 +64,16 @@ export function geminiIsAuthenticated(env) {
64
64
  nonBlank(env.GOOGLE_API_KEY) ||
65
65
  (existsSync(join(homedir(), ".gemini", "oauth_creds.json")) && liveOauthCredential()));
66
66
  }
67
+ /** Gemini CLI's approval modes, passed through: `--approval-mode auto_edit`
68
+ * approves file edits, `--yolo` approves everything; Gemini has no
69
+ * automatic reviewer, so Auto stays Ask. Ask sends nothing. */
70
+ export function geminiApprovalArgs(fullAuto, approvalMode) {
71
+ if (fullAuto)
72
+ return ["--yolo"];
73
+ if (approvalMode === "edits")
74
+ return ["--approval-mode", "auto_edit"];
75
+ return [];
76
+ }
67
77
  const support = {
68
78
  driverKind: "geminiAgent",
69
79
  displayName: "Gemini",
@@ -79,7 +89,7 @@ const support = {
79
89
  loginNote: "Gemini CLI needs a GEMINI_API_KEY, a Vertex AI setup, or an enterprise Code Assist login — consumer Google logins stopped working on 2026-06-18 (use the Antigravity engine for those accounts)",
80
90
  // --acp is the stable Gemini CLI surface. --experimental-acp remains an
81
91
  // alias for older releases, but using it now emits a deprecation warning.
82
- spawnArgs: (_config, turn) => ["--acp", ...(turn.model ? ["-m", turn.model] : [])],
92
+ spawnArgs: (config, turn) => ["--acp", ...geminiApprovalArgs(config.fullAuto, turn.approvalMode), ...(turn.model ? ["-m", turn.model] : [])],
83
93
  credentialEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
84
94
  pickAuthMethod: (methods) => {
85
95
  const ids = methods.map((m) => m.id).filter((id) => typeof id === "string");
@@ -205,6 +205,21 @@ export function resolveQwenTurnModel(model, env) {
205
205
  throw new Error("This Qwen model is no longer configured. Refresh models and select it again.");
206
206
  return matches[0].id;
207
207
  }
208
+ /** Qwen Code's own approval ladder, passed through (qwen --help, 0.24):
209
+ * `--approval-mode default` asks, `auto-edit` approves file edits, `auto`
210
+ * runs Qwen's LLM classifier that approves safe actions and blocks risky
211
+ * ones, and `--yolo` approves everything. Ask sends nothing, so an older
212
+ * CLI without the flag keeps working at the level it always had; the ACP
213
+ * client still answers residual permission asks itself under Full. */
214
+ export function qwenApprovalArgs(fullAuto, approvalMode) {
215
+ if (fullAuto)
216
+ return ["--yolo"];
217
+ if (approvalMode === "auto")
218
+ return ["--approval-mode", "auto"];
219
+ if (approvalMode === "edits")
220
+ return ["--approval-mode", "auto-edit"];
221
+ return [];
222
+ }
208
223
  const support = {
209
224
  driverKind: "qwenAgent",
210
225
  displayName: "Qwen",
@@ -225,7 +240,7 @@ const support = {
225
240
  },
226
241
  // A raw -m only changes the model within the saved provider. ACP switches
227
242
  // the complete route and confirms it before any prompt leaves OMB.
228
- spawnArgs: () => ["--acp"],
243
+ spawnArgs: (config, turn) => ["--acp", ...qwenApprovalArgs(config.fullAuto, turn.approvalMode)],
229
244
  selectModel: { configId: "model" },
230
245
  pickAuthMethod: () => null,
231
246
  authFailure: "continue",
@@ -390,10 +390,10 @@ const TOOLS = [
390
390
  },
391
391
  {
392
392
  name: "coordinate_bots",
393
- description: "Ask existing OpenMausBot teammates for advice or assign concrete work. From normal chat every assignment you send a teammate continues your one standing conversation with that teammate, so they keep the context of what you asked before; from a room it defaults to this room. Use group_id from list_room_targets for a specific room. Name 1-4 bot_ids: they receive only your brief and use their own model, tools and permissions. Busy bots queue. They can consult their specialists; all results return here and resume you automatically. Include exact file paths, constraints and what must be verified. After sending all assignments, END your turn; do not poll or wait. On return, resolve tradeoffs, verify the requested outcome and request concrete corrections if necessary before giving one final answer. Do not send acknowledgements as new work.",
393
+ description: "Ask existing OpenMausBot teammates for advice or assign concrete work. From normal chat every assignment you send a teammate continues your one standing conversation with that teammate, so they keep the context of what you asked before; from a room it defaults to this room. Use group_id from list_room_targets for a specific room. Give 1-4 bot_ids — teammate ids as list_bots or your roster prints them; a unique teammate name also resolves: they receive only your brief and use their own model, tools and permissions. Busy bots queue. They can consult their specialists; all results return here and resume you automatically. Include exact file paths, constraints and what must be verified. After sending all assignments, END your turn; do not poll or wait. On return, resolve tradeoffs, verify the requested outcome and request concrete corrections if necessary before giving one final answer. Do not send acknowledgements as new work.",
394
394
  inputSchema: { type: "object", additionalProperties: false, properties: {
395
395
  group_id: { type: "string", description: "Optional destination room. Omit for this room, or your standing conversation with each teammate when chatting directly." },
396
- bot_ids: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 4, uniqueItems: true },
396
+ bot_ids: { type: "array", items: { type: "string", description: "A teammate's id exactly as list_bots or your roster prints it ([id: …]). A teammate's unique display name also resolves; a name shared by two reachable teammates is refused." }, minItems: 1, maxItems: 4, uniqueItems: true },
397
397
  message: { type: "string", minLength: 1, maxLength: 4000, description: "Self-contained question or task for these teammates. Send separate requests when responsibilities differ." },
398
398
  request_key: { type: "string", description: "A short unique assignment key. Reuse for an identical retry." },
399
399
  rework: { type: "boolean", description: "True only for concrete additional work from someone who already completed a request." },
@@ -416,7 +416,7 @@ const TOOLS = [
416
416
  inputSchema: {
417
417
  type: "object",
418
418
  properties: {
419
- bot_id: { type: "string", description: "The target bot's id (from list_bots)." },
419
+ bot_id: { type: "string", description: "The target bot's id (from list_bots or your roster); a unique teammate name also resolves." },
420
420
  message: { type: "string", description: "What to say / ask the bot." },
421
421
  },
422
422
  required: ["bot_id", "message"],
@@ -428,7 +428,7 @@ const TOOLS = [
428
428
  inputSchema: {
429
429
  type: "object",
430
430
  properties: {
431
- bot_id: { type: "string", description: "The target bot's id (from list_bots)." },
431
+ bot_id: { type: "string", description: "The target bot's id (from list_bots or your roster); a unique teammate name also resolves." },
432
432
  message: { type: "string", description: "What the peer should do / answer." },
433
433
  reason: { type: "string", description: "Optional one-line reason for the delegation (shown to the user as a chip)." },
434
434
  },
@@ -1754,12 +1754,13 @@ export const ClaudeDriver = {
1754
1754
  return { turnId };
1755
1755
  };
1756
1756
  /** A user message into the running turn: the CLI delivers it before its
1757
- * next model call. False when nothing is running here to steer. */
1757
+ * next model call. "refused" when nothing is running here to steer or
1758
+ * the stdin write provably failed; the caller queues those words. */
1758
1759
  const steer = async (threadId, text) => {
1759
1760
  const s = sessions.get(threadId);
1760
1761
  if (!s || !s.turn || s.turn.settled || s.closing || s.child.exitCode !== null)
1761
- return false;
1762
- return writeUser(s, threadId, claudeUserMessage(text, undefined));
1762
+ return "refused";
1763
+ return (await writeUser(s, threadId, claudeUserMessage(text, undefined))) ? "steered" : "refused";
1763
1764
  };
1764
1765
  // Sign in from Settings: the unmodified CLI's own login, driven over pipes
1765
1766
  // (server/drivers/claude-login-auth.ts). Same environment as every turn.
@@ -703,9 +703,33 @@ export const CodexDriver = {
703
703
  return stopped;
704
704
  });
705
705
  let completeStoppedTurn;
706
+ // Stop asks the app-server to end the turn itself before any process
707
+ // signal. Killing first surfaced routine stops as "codex exited null
708
+ // (signal SIGTERM) before turn/completed"; the protocol interrupt keeps
709
+ // the session the authority, and the kill below is only escalation for
710
+ // a server that will not answer. settle() runs with state.settled
711
+ // already true, so ordinary completion still tears down immediately.
712
+ let interruptRequested = false;
706
713
  const stop = async () => {
707
714
  stopRequested = true;
708
715
  stopSignal.abort();
716
+ if (!state.settled && !interruptRequested && codexThreadId && codexTurnId &&
717
+ child.exitCode === null && child.signalCode === null) {
718
+ interruptRequested = true;
719
+ const graceMs = Math.max(1, Number(process.env.FAKE_CODEX_INTERRUPT_GRACE_MS ?? 750) || 750);
720
+ try {
721
+ await request("turn/interrupt", { threadId: codexThreadId, turnId: codexTurnId }, graceMs);
722
+ }
723
+ catch {
724
+ // Old CLI without the method, or a wedged server: escalate below.
725
+ }
726
+ const deadline = Date.now() + graceMs;
727
+ while (!state.settled && Date.now() < deadline) {
728
+ await new Promise((wake) => setTimeout(wake, 15));
729
+ }
730
+ if (state.settled)
731
+ return true;
732
+ }
709
733
  const stopped = await terminate();
710
734
  if (stopped)
711
735
  completeStoppedTurn?.();
@@ -731,6 +755,35 @@ export const CodexDriver = {
731
755
  emit({ ...base(threadId, turnId), type: "runtime.error", message: "codex did not shut down after termination was requested" });
732
756
  }
733
757
  };
758
+ // Live steering folds new input into the running turn without ending
759
+ // it. expectedTurnId is the protocol's precondition: a turn that moved
760
+ // on (or a CLI without turn/steer) answers with an explicit RPC error,
761
+ // which becomes "refused" here so the caller queues for the next turn —
762
+ // the child is never killed to steer. A timeout after delivery, a dead
763
+ // transport, or a turn that settles while the answer is in flight is
764
+ // "indeterminate": the words may already be running, so the caller must
765
+ // not re-queue them.
766
+ const steerActiveTurn = async (text) => {
767
+ if (state.settled || abandoned || stopRequested || !codexThreadId || !codexTurnId)
768
+ return "refused";
769
+ if (child.exitCode !== null || child.signalCode !== null)
770
+ return "refused";
771
+ try {
772
+ const steerTimeoutMs = Math.max(1, Number(process.env.FAKE_CODEX_STEER_TIMEOUT_MS ?? 10_000) || 10_000);
773
+ await request("turn/steer", {
774
+ threadId: codexThreadId,
775
+ input: [{ type: "text", text }],
776
+ expectedTurnId: codexTurnId,
777
+ }, steerTimeoutMs);
778
+ return "steered";
779
+ }
780
+ catch (error) {
781
+ const message = error instanceof Error ? error.message : String(error);
782
+ if (state.settled || message.includes("timed out"))
783
+ return "indeterminate";
784
+ return "refused";
785
+ }
786
+ };
734
787
  // server→client approval request → canonical request.opened
735
788
  // Host-scope tagging mirrors claude.ts: when this turn mounts the real
736
789
  // Mac (not a VM), every card carries approvalScope so the harness's
@@ -1134,6 +1187,13 @@ export const CodexDriver = {
1134
1187
  void stop();
1135
1188
  return;
1136
1189
  }
1190
+ // An intentional stop killed (or outlived) the child before the
1191
+ // turn acknowledged its own end. That is the stop doing its job, not
1192
+ // a crash: settle quietly so Stop never reports the raw signal.
1193
+ if (stopRequested) {
1194
+ void settle(false, "interrupted");
1195
+ return;
1196
+ }
1137
1197
  // The child died before the turn completed. Attribute the exit
1138
1198
  // honestly: name the signal when it was killed, and only quote
1139
1199
  // stderr that arrived after the last protocol message. A stale
@@ -1194,7 +1254,7 @@ export const CodexDriver = {
1194
1254
  });
1195
1255
  void settle(false, "exit_before_result");
1196
1256
  });
1197
- active.set(threadId, { stop, turnId, asks });
1257
+ active.set(threadId, { stop, turnId, asks, steer: steerActiveTurn });
1198
1258
  // Relaunching the app-server is still the same logical turn. Keep the
1199
1259
  // active process current on every attempt, but announce the turn once.
1200
1260
  if (attempt === 0)
@@ -1441,6 +1501,7 @@ export const CodexDriver = {
1441
1501
  provider: DRIVER_KIND,
1442
1502
  capabilities: {
1443
1503
  sessionModelSwitch: "unsupported",
1504
+ queueing: true,
1444
1505
  computerMcp: true,
1445
1506
  localComputerMcp: true,
1446
1507
  composioMcp: true,
@@ -1456,6 +1517,10 @@ export const CodexDriver = {
1456
1517
  interruptTurn: async (threadId) => {
1457
1518
  await active.get(threadId)?.stop();
1458
1519
  },
1520
+ steer: async (threadId, text) => {
1521
+ const turn = active.get(threadId);
1522
+ return turn?.steer ? await turn.steer(text) : "refused";
1523
+ },
1459
1524
  respondToRequest: async (threadId, requestId, decision) => {
1460
1525
  const turn = active.get(threadId);
1461
1526
  const finish = turn?.asks.get(requestId);
@@ -28,7 +28,8 @@ import { augmentedPath } from "../env-path.js";
28
28
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
29
29
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
30
30
  import { commandSummary, toolDetailPreview } from "../tool-summary.js";
31
- import { EFFORT_LEVELS, newEventId, newId } from "../contracts.js";
31
+ import { EFFORT_LEVELS } from "../../shared/wire.js";
32
+ import { newEventId, newId } from "../contracts.js";
32
33
  import { decodeInjectId, encodeInjectId, hostApiKey, localHost, mergeLocalInject, } from "./local-inject.js";
33
34
  import { appendNative } from "./native.js";
34
35
  const DRIVER_KIND = "piAgent";
@@ -252,7 +252,12 @@ function parseCmdShim(shim, env) {
252
252
  return null;
253
253
  }
254
254
  const dir = dirname(shim);
255
- const targets = [...text.matchAll(/"%~?dp0%?\\?([^"]+)"/g)]
255
+ // npm's own npm.cmd / npx.cmd, installed beside node.exe, name their entry
256
+ // in a variable next to a helper script that is not the CLI:
257
+ // SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js". The launcher's
258
+ // switch to a globally upgraded npm is not followed; this node's npm runs.
259
+ const npmEntry = /^SET "NP[MX]_CLI_JS=%~dp0\\([^"]+)"/im.exec(text);
260
+ const targets = [...(npmEntry ? [npmEntry] : []), ...text.matchAll(/"%~?dp0%?\\?([^"]+)"/g)]
256
261
  .map((m) => join(dir, m[1]))
257
262
  .filter((p) => isFile(p) && basename(p).toLowerCase() !== "node.exe");
258
263
  const script = targets.find((p) => /\.[cm]?js$/i.test(p));