@4pm/cli 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +419 -239
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -32753,7 +32753,7 @@ async function checkAndUpdate(serverUrl, autoUpdate) {
32753
32753
 
32754
32754
  // src/core/ws-client.ts
32755
32755
  init_wrapper();
32756
- import { randomUUID as randomUUID3 } from "crypto";
32756
+ import { randomUUID as randomUUID4 } from "crypto";
32757
32757
  import { basename as basename7, join as join32 } from "path";
32758
32758
  import { existsSync as existsSync18, mkdirSync as mkdirSync9 } from "fs";
32759
32759
 
@@ -37564,116 +37564,12 @@ var userCardsQuerySchema = external_exports.object({
37564
37564
  // src/core/scaffold.ts
37565
37565
  import { cp, mkdir, mkdtemp, rm, writeFile } from "fs/promises";
37566
37566
  import { existsSync as existsSync4 } from "fs";
37567
- import { execFile as execFile2 } from "child_process";
37567
+ import { execFile } from "child_process";
37568
37568
  import { tmpdir as tmpdir2 } from "os";
37569
- import { dirname as dirname3, join as join6, resolve as resolve2 } from "path";
37569
+ import { dirname as dirname3, join as join6, resolve } from "path";
37570
37570
  import { fileURLToPath as fileURLToPath3 } from "url";
37571
- import { promisify as promisify2 } from "util";
37572
-
37573
- // src/core/ai-assist.ts
37574
- import { execFile } from "child_process";
37575
37571
  import { promisify } from "util";
37576
-
37577
- // src/utils/agent-deny.ts
37578
- import { isAbsolute, resolve } from "path";
37579
- var HOME_SECRET_PATTERNS = [
37580
- "~/.4pm/**",
37581
- "~/.ssh/**",
37582
- "~/.config/gh/**",
37583
- "~/.git-credentials",
37584
- "~/.claude*",
37585
- "~/.claude*/**"
37586
- ];
37587
- function absDirPattern(dir2) {
37588
- const abs = (isAbsolute(dir2) ? dir2 : resolve(dir2)).replace(/\\/g, "/").replace(/\/+$/, "");
37589
- return `/${abs.startsWith("/") ? abs : `/${abs}`}/**`;
37590
- }
37591
- function agentDenyRules(profileDirs2 = []) {
37592
- const patterns = new Set(HOME_SECRET_PATTERNS);
37593
- for (const dir2 of profileDirs2) {
37594
- if (dir2 && dir2.trim()) patterns.add(absDirPattern(dir2.trim()));
37595
- }
37596
- const rules = [];
37597
- for (const p2 of patterns) rules.push(`Read(${p2})`, `Edit(${p2})`);
37598
- return rules;
37599
- }
37600
- function denySettingsArgs(cmd, profileDirs2 = []) {
37601
- if (!cmd.includes("claude")) return [];
37602
- return ["--settings", JSON.stringify({ permissions: { deny: agentDenyRules(profileDirs2) } })];
37603
- }
37604
-
37605
- // src/core/ai-assist.ts
37606
37572
  var run = promisify(execFile);
37607
- function aiCli() {
37608
- return process.env.AI_CLI || "claude";
37609
- }
37610
- function aiArgs(cli, prompt) {
37611
- if (cli === "claude") return ["-p", prompt, "--output-format", "json", ...denySettingsArgs(cli, [process.env.CLAUDE_CONFIG_DIR])];
37612
- if (cli === "codex") return ["exec", "--json", prompt];
37613
- return ["-p", prompt];
37614
- }
37615
- function estimateTokens(prompt, output) {
37616
- return Math.max(1, Math.ceil((prompt.length + output.length) / 4));
37617
- }
37618
- function parseAi(cli, stdout, prompt) {
37619
- if (cli === "claude") {
37620
- try {
37621
- const j2 = JSON.parse(stdout);
37622
- const text2 = (j2.result ?? "").trim();
37623
- const tokens = (j2.usage?.input_tokens ?? 0) + (j2.usage?.output_tokens ?? 0);
37624
- if (text2 || tokens) return { text: text2, tokens: tokens || estimateTokens(prompt, text2) };
37625
- } catch {
37626
- }
37627
- }
37628
- if (cli === "codex") {
37629
- try {
37630
- let text2 = "";
37631
- let tokens = 0;
37632
- for (const line of stdout.split("\n").filter(Boolean)) {
37633
- const obj = JSON.parse(line);
37634
- if (obj.text) text2 = obj.text;
37635
- else if (obj.message) text2 = obj.message;
37636
- if (obj.usage) {
37637
- tokens = obj.usage.total_tokens ?? (obj.usage.input_tokens ?? 0) + (obj.usage.output_tokens ?? 0);
37638
- }
37639
- }
37640
- if (text2 || tokens) return { text: text2.trim(), tokens: tokens || estimateTokens(prompt, text2) };
37641
- } catch {
37642
- }
37643
- }
37644
- const text = stdout.trim();
37645
- return { text, tokens: estimateTokens(prompt, text) };
37646
- }
37647
- async function runAi(prompt) {
37648
- const cli = aiCli();
37649
- const { stdout } = await run(cli, aiArgs(cli, prompt), {
37650
- timeout: 12e4,
37651
- maxBuffer: 8 * 1024 * 1024
37652
- });
37653
- return parseAi(cli, stdout, prompt);
37654
- }
37655
- async function aiGenerate(prompt) {
37656
- const { text } = await runAi(prompt);
37657
- return text;
37658
- }
37659
- async function aiOutboundReview(input, allowedRepos, projectPath) {
37660
- const prompt = `You are a security reviewer for an autonomous coding agent. Decide if the following INPUT is SAFE to run. It is UNSAFE if it: (1) asks the AI using secret VALUES (api keys, env values, passwords); (2) would affect other environments (only the sandbox project is allowed, no ssh/deploy/prod); (3) edits outside the project folder "${projectPath}"; (4) commits/pushes to a repo not in this allowlist: ${allowedRepos.length ? allowedRepos.join(", ") : "(none declared)"}. Reply with ONLY JSON: {"ok": <true|false>, "reasons": ["secret"|"environment"|"path"|"repo", ...]}. INPUT:
37661
- ${input}`;
37662
- const { text, tokens } = await runAi(prompt);
37663
- const start = text.indexOf("{");
37664
- const end = text.lastIndexOf("}");
37665
- if (start < 0 || end <= start) return { ok: false, reasons: ["ai_parse"], tokens };
37666
- try {
37667
- const j2 = JSON.parse(text.slice(start, end + 1));
37668
- const reasons = Array.isArray(j2.reasons) ? j2.reasons.filter((r) => typeof r === "string") : [];
37669
- return { ok: j2.ok === true && reasons.length === 0, reasons, tokens };
37670
- } catch {
37671
- return { ok: false, reasons: ["ai_parse"], tokens };
37672
- }
37673
- }
37674
-
37675
- // src/core/scaffold.ts
37676
- var run2 = promisify2(execFile2);
37677
37573
  function reposOf(spec) {
37678
37574
  const repos = spec?.repos;
37679
37575
  return Array.isArray(repos) ? repos : [];
@@ -37695,7 +37591,7 @@ async function attachSubmodules(root, submodules, emit, createMissingBranch = fa
37695
37591
  if (existsSync4(join6(root, dir2, ".git")) || existsSync4(join6(root, ".git", "modules", dir2))) {
37696
37592
  emit("submodule", `Submodule ${dir2} already present \u2014 updating\u2026`);
37697
37593
  try {
37698
- await run2("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37594
+ await run("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37699
37595
  } catch {
37700
37596
  }
37701
37597
  continue;
@@ -37709,11 +37605,11 @@ async function attachSubmodules(root, submodules, emit, createMissingBranch = fa
37709
37605
  const b2 = (sub.branch ?? "").trim();
37710
37606
  if (b2 && createMissingBranch && !await ensureRemoteBranch(sub.url, b2, emit)) {
37711
37607
  try {
37712
- await run2("git", ["submodule", "add", sub.url, dir2], { cwd: root, timeout: 12e4 });
37713
- await run2("git", ["config", "-f", ".gitmodules", `submodule.${dir2}.branch`, b2], { cwd: root, timeout: 3e4 });
37608
+ await run("git", ["submodule", "add", sub.url, dir2], { cwd: root, timeout: 12e4 });
37609
+ await run("git", ["config", "-f", ".gitmodules", `submodule.${dir2}.branch`, b2], { cwd: root, timeout: 3e4 });
37714
37610
  try {
37715
- await run2("git", ["checkout", "-b", b2], { cwd: join6(root, dir2), timeout: 3e4 });
37716
- await run2("git", ["push", "-u", "origin", b2], { cwd: join6(root, dir2), timeout: 12e4 });
37611
+ await run("git", ["checkout", "-b", b2], { cwd: join6(root, dir2), timeout: 3e4 });
37612
+ await run("git", ["push", "-u", "origin", b2], { cwd: join6(root, dir2), timeout: 12e4 });
37717
37613
  } catch (pushErr) {
37718
37614
  const pmsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
37719
37615
  emit("git-branch-push-failed", `Submodule ${dir2}: branch "${b2}" created locally but the push failed: ${pmsg}`);
@@ -37728,13 +37624,13 @@ async function attachSubmodules(root, submodules, emit, createMissingBranch = fa
37728
37624
  }
37729
37625
  }
37730
37626
  try {
37731
- await run2("git", ["submodule", "add", ...b2 ? ["-b", b2] : [], sub.url, dir2], { cwd: root, timeout: 12e4 });
37627
+ await run("git", ["submodule", "add", ...b2 ? ["-b", b2] : [], sub.url, dir2], { cwd: root, timeout: 12e4 });
37732
37628
  added++;
37733
37629
  } catch (err) {
37734
37630
  const msg = err instanceof Error ? err.message : String(err);
37735
37631
  if (/already exists|already registered|in the index/i.test(msg)) {
37736
37632
  try {
37737
- await run2("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37633
+ await run("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37738
37634
  added++;
37739
37635
  } catch {
37740
37636
  }
@@ -37753,18 +37649,18 @@ async function commitAndPushSubmodules(root, submodules, emit) {
37753
37649
  ];
37754
37650
  if (addPaths.length === 0) return;
37755
37651
  emit("submodule", "Committing .gitmodules\u2026");
37756
- await run2("git", ["add", ...addPaths], { cwd: root, timeout: 6e4 });
37757
- const staged = (await run2("git", ["diff", "--cached", "--name-only"], { cwd: root, timeout: 3e4 })).stdout.trim();
37652
+ await run("git", ["add", ...addPaths], { cwd: root, timeout: 6e4 });
37653
+ const staged = (await run("git", ["diff", "--cached", "--name-only"], { cwd: root, timeout: 3e4 })).stdout.trim();
37758
37654
  if (!staged) return;
37759
37655
  const commitArgs = ["commit", "-m", "chore: add git submodules (4PM)"];
37760
37656
  try {
37761
- await run2("git", commitArgs, { cwd: root, timeout: 6e4 });
37657
+ await run("git", commitArgs, { cwd: root, timeout: 6e4 });
37762
37658
  } catch {
37763
- await run2("git", ["-c", "user.name=4PM", "-c", "user.email=noreply@4pm.app", ...commitArgs], { cwd: root, timeout: 6e4 });
37659
+ await run("git", ["-c", "user.name=4PM", "-c", "user.email=noreply@4pm.app", ...commitArgs], { cwd: root, timeout: 6e4 });
37764
37660
  }
37765
37661
  emit("submodule", "Pushing .gitmodules to the primary remote\u2026");
37766
37662
  try {
37767
- await run2("git", ["push"], { cwd: root, timeout: 12e4 });
37663
+ await run("git", ["push"], { cwd: root, timeout: 12e4 });
37768
37664
  } catch (err) {
37769
37665
  const msg = err instanceof Error ? err.message : String(err);
37770
37666
  emit("submodule-push-failed", `Submodules committed locally but the push failed: ${msg}`);
@@ -37777,22 +37673,22 @@ function providerSlug(url) {
37777
37673
  }
37778
37674
  async function commitAndOpenPr(root, repo, emit) {
37779
37675
  emit("commit", "Committing the scaffolded project\u2026");
37780
- await run2("git", ["add", "-A"], { cwd: root, timeout: 6e4 });
37781
- const staged = (await run2("git", ["diff", "--cached", "--name-only"], { cwd: root, timeout: 3e4 })).stdout.trim();
37676
+ await run("git", ["add", "-A"], { cwd: root, timeout: 6e4 });
37677
+ const staged = (await run("git", ["diff", "--cached", "--name-only"], { cwd: root, timeout: 3e4 })).stdout.trim();
37782
37678
  if (!staged) {
37783
37679
  emit("commit", "Nothing to commit \u2014 skipping the pull request.");
37784
37680
  return;
37785
37681
  }
37786
37682
  const commitArgs = ["commit", "-m", "chore: scaffold project (4PM)"];
37787
37683
  try {
37788
- await run2("git", commitArgs, { cwd: root, timeout: 6e4 });
37684
+ await run("git", commitArgs, { cwd: root, timeout: 6e4 });
37789
37685
  } catch {
37790
- await run2("git", ["-c", "user.name=4PM", "-c", "user.email=noreply@4pm.app", ...commitArgs], { cwd: root, timeout: 6e4 });
37686
+ await run("git", ["-c", "user.name=4PM", "-c", "user.email=noreply@4pm.app", ...commitArgs], { cwd: root, timeout: 6e4 });
37791
37687
  }
37792
37688
  const branch = (repo.branch ?? "").trim();
37793
37689
  emit("push", "Pushing the scaffold branch\u2026");
37794
37690
  try {
37795
- await run2("git", ["push", "-u", "origin", ...branch ? [branch] : ["HEAD"]], { cwd: root, timeout: 12e4 });
37691
+ await run("git", ["push", "-u", "origin", ...branch ? [branch] : ["HEAD"]], { cwd: root, timeout: 12e4 });
37796
37692
  } catch (err) {
37797
37693
  const msg = err instanceof Error ? err.message : String(err);
37798
37694
  emit("push-failed", `Scaffold committed locally but the push failed: ${msg} \u2014 push it from the Git tab when credentials are available.`);
@@ -37806,7 +37702,7 @@ async function commitAndOpenPr(root, repo, emit) {
37806
37702
  emit("pr", `Opening a pull request via ${provider}\u2026`);
37807
37703
  try {
37808
37704
  const args = provider === "glab" ? ["mr", "create", "--fill", ...slug ? ["-R", slug] : []] : ["pr", "create", "--fill", ...slug ? ["-R", slug] : []];
37809
- const { stdout } = await run2(provider, args, { cwd: root, timeout: 12e4 });
37705
+ const { stdout } = await run(provider, args, { cwd: root, timeout: 12e4 });
37810
37706
  const link2 = stdout.trim().split(/\s+/).find((s3) => /^https?:\/\//.test(s3)) ?? stdout.trim().split("\n").pop() ?? "";
37811
37707
  emit("pr", `Pull request opened${link2 ? `: ${link2}` : "."}`);
37812
37708
  } catch (err) {
@@ -37820,7 +37716,7 @@ function cloneArgs(url, dest, branch) {
37820
37716
  }
37821
37717
  async function remoteHasBranch(url, branch) {
37822
37718
  try {
37823
- const { stdout } = await run2("git", ["ls-remote", "--heads", url, branch], { timeout: 3e4 });
37719
+ const { stdout } = await run("git", ["ls-remote", "--heads", url, branch], { timeout: 3e4 });
37824
37720
  return stdout.trim().length > 0;
37825
37721
  } catch {
37826
37722
  return false;
@@ -37831,9 +37727,9 @@ async function ensureRemoteBranch(url, branch, emit) {
37831
37727
  const tmp = await mkdtemp(join6(tmpdir2(), "4pm-branch-"));
37832
37728
  try {
37833
37729
  emit("git", `Branch "${branch}" not found on ${url} \u2014 creating it from the default branch\u2026`);
37834
- await run2("git", ["clone", "--depth", "1", url, tmp], { timeout: 12e4 });
37835
- await run2("git", ["checkout", "-b", branch], { cwd: tmp, timeout: 3e4 });
37836
- await run2("git", ["push", "-u", "origin", branch], { cwd: tmp, timeout: 12e4 });
37730
+ await run("git", ["clone", "--depth", "1", url, tmp], { timeout: 12e4 });
37731
+ await run("git", ["checkout", "-b", branch], { cwd: tmp, timeout: 3e4 });
37732
+ await run("git", ["push", "-u", "origin", branch], { cwd: tmp, timeout: 12e4 });
37837
37733
  return true;
37838
37734
  } catch (err) {
37839
37735
  const msg = err instanceof Error ? err.message : String(err);
@@ -37846,9 +37742,9 @@ async function ensureRemoteBranch(url, branch, emit) {
37846
37742
  async function updateRepo(dir2, branch, emit) {
37847
37743
  const b2 = (branch ?? "").trim();
37848
37744
  emit("git", `Updating repository (fetch + ${b2 ? `checkout ${b2} + ` : ""}fast-forward pull)\u2026`);
37849
- await run2("git", ["fetch", "origin", "--prune"], { cwd: dir2, timeout: 12e4 });
37850
- if (b2) await run2("git", ["checkout", b2], { cwd: dir2, timeout: 6e4 });
37851
- await run2("git", ["pull", "--ff-only"], { cwd: dir2, timeout: 12e4 });
37745
+ await run("git", ["fetch", "origin", "--prune"], { cwd: dir2, timeout: 12e4 });
37746
+ if (b2) await run("git", ["checkout", b2], { cwd: dir2, timeout: 6e4 });
37747
+ await run("git", ["pull", "--ff-only"], { cwd: dir2, timeout: 12e4 });
37852
37748
  }
37853
37749
  async function provisionRepo(target, repo, emit, opts = {}) {
37854
37750
  const mode = opts.mode ?? "clone";
@@ -37856,7 +37752,7 @@ async function provisionRepo(target, repo, emit, opts = {}) {
37856
37752
  if (!existsSync4(join6(target, ".git"))) {
37857
37753
  emit("git", "Initializing repository\u2026");
37858
37754
  await mkdir(target, { recursive: true });
37859
- await run2("git", ["init"], { cwd: target, timeout: 2e4 });
37755
+ await run("git", ["init"], { cwd: target, timeout: 2e4 });
37860
37756
  }
37861
37757
  return;
37862
37758
  }
@@ -37866,7 +37762,7 @@ async function provisionRepo(target, repo, emit, opts = {}) {
37866
37762
  } else if (mode === "force") {
37867
37763
  emit("git", `Re-cloning ${repo.url} (force)\u2026`);
37868
37764
  await rm(target, { recursive: true, force: true });
37869
- await run2("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37765
+ await run("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37870
37766
  } else {
37871
37767
  emit("git", "Repository already present \u2014 skipping clone.");
37872
37768
  }
@@ -37875,10 +37771,10 @@ async function provisionRepo(target, repo, emit, opts = {}) {
37875
37771
  const b2 = (repo.branch ?? "").trim();
37876
37772
  if (b2 && opts.createMissingBranch && !await remoteHasBranch(repo.url, b2)) {
37877
37773
  emit("git", `Cloning ${repo.url} (default branch) to create "${b2}"\u2026`);
37878
- await run2("git", ["clone", repo.url, target], { timeout: 12e4 });
37879
- await run2("git", ["checkout", "-b", b2], { cwd: target, timeout: 3e4 });
37774
+ await run("git", ["clone", repo.url, target], { timeout: 12e4 });
37775
+ await run("git", ["checkout", "-b", b2], { cwd: target, timeout: 3e4 });
37880
37776
  try {
37881
- await run2("git", ["push", "-u", "origin", b2], { cwd: target, timeout: 12e4 });
37777
+ await run("git", ["push", "-u", "origin", b2], { cwd: target, timeout: 12e4 });
37882
37778
  emit("git", `Created and pushed branch "${b2}".`);
37883
37779
  } catch (err) {
37884
37780
  const msg = err instanceof Error ? err.message : String(err);
@@ -37887,7 +37783,7 @@ async function provisionRepo(target, repo, emit, opts = {}) {
37887
37783
  return;
37888
37784
  }
37889
37785
  emit("git", `Cloning ${repo.url}\u2026`);
37890
- await run2("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37786
+ await run("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37891
37787
  }
37892
37788
  async function ensureReposCloned(physicRoot, repos, emit) {
37893
37789
  const list = repos;
@@ -37900,27 +37796,27 @@ async function ensureReposCloned(physicRoot, repos, emit) {
37900
37796
  function sampleDir() {
37901
37797
  if (process.env.SCAFFOLD_SAMPLE_DIR) return process.env.SCAFFOLD_SAMPLE_DIR;
37902
37798
  const here = dirname3(fileURLToPath3(import.meta.url));
37903
- const bundled = resolve2(here, "project-sample");
37904
- for (const candidate of [bundled, resolve2(here, "../project-sample"), resolve2(here, "../../project-sample")]) {
37799
+ const bundled = resolve(here, "project-sample");
37800
+ for (const candidate of [bundled, resolve(here, "../project-sample"), resolve(here, "../../project-sample")]) {
37905
37801
  if (existsSync4(candidate)) return candidate;
37906
37802
  }
37907
37803
  return bundled;
37908
37804
  }
37909
- async function scaffoldProject(payload, profileDir2, onProgress) {
37805
+ async function scaffoldProject(payload, profileDir2, onProgress, ai2) {
37910
37806
  let lastStep = "start";
37911
37807
  const emit = (step, message) => {
37912
37808
  lastStep = step;
37913
37809
  onProgress?.({ projectId: payload.projectId, step, message });
37914
37810
  };
37915
37811
  try {
37916
- const root = process.env.SCAFFOLD_ROOT ? resolve2(process.env.SCAFFOLD_ROOT) : resolve2(profileDir2);
37812
+ const root = process.env.SCAFFOLD_ROOT ? resolve(process.env.SCAFFOLD_ROOT) : resolve(profileDir2);
37917
37813
  const target = join6(root, payload.projectName);
37918
37814
  await mkdir(target, { recursive: true });
37919
37815
  const repo = singleRepo(reposOf(payload.spec));
37920
37816
  if (!repo) throw new Error("A project must declare one repo (ADR-0314).");
37921
37817
  emit("git", "Cloning repository\u2026");
37922
37818
  await provisionRepo(target, repo, emit, { createMissingBranch: true });
37923
- await scaffoldRepo(target, payload.projectName, payload.spec, emit, { resetClaude: true });
37819
+ await scaffoldRepo(target, payload.projectName, payload.spec, emit, { resetClaude: true, ai: ai2 });
37924
37820
  await attachSubmodules(target, submodulesOf(reposOf(payload.spec)), emit, true);
37925
37821
  emit("version", "Writing .4pm/.4pm.json\u2026");
37926
37822
  await writeTemplateMarker(target);
@@ -37939,7 +37835,7 @@ async function scaffoldRepo(dir2, label, spec, emit, opts = {}) {
37939
37835
  if (spec) {
37940
37836
  emit("spec", `Writing project.spec.json into ${label}\u2026`);
37941
37837
  await writeFile(join6(dir2, "project.spec.json"), JSON.stringify(spec, null, 2), "utf8");
37942
- await aiInit(dir2, spec, emit);
37838
+ await aiInit(dir2, spec, emit, opts.ai);
37943
37839
  }
37944
37840
  }
37945
37841
  async function resetTemplateManagedClaude(dir2, label, emit) {
@@ -37965,7 +37861,7 @@ function readSpecField(spec, key) {
37965
37861
  }
37966
37862
  return typeof spec[key] === "string" ? spec[key] : "";
37967
37863
  }
37968
- async function aiInit(target, spec, emit) {
37864
+ async function aiInit(target, spec, emit, ai2) {
37969
37865
  const guideFile = readSpecField(spec, "ai_guide_file") === "AGENT.md" ? "AGENT.md" : "CLAUDE.md";
37970
37866
  const guideInstructions = readSpecField(spec, "ai_guide_instructions").trim();
37971
37867
  emit("ai-init", `AI init: subagents, README, ${guideFile}\u2026`);
@@ -37985,25 +37881,47 @@ ${(sa.description || "").trim()}
37985
37881
  }
37986
37882
  }
37987
37883
  const specJson = JSON.stringify(spec);
37988
- await generateFile(
37989
- join6(target, "README.md"),
37990
- `Write a concise README.md (Markdown only, no preamble) for this project from its spec JSON:
37884
+ let generate3 = null;
37885
+ let releaseSlot = () => void 0;
37886
+ if (ai2) {
37887
+ const slot = await ai2.acquireSlot(
37888
+ (q2) => emit("ai-queued", `Waiting for an AI run slot \u2014 ${q2.position} ahead (${q2.running}/${q2.limit} parallel runs in use)\u2026`)
37889
+ );
37890
+ if (slot.kind === "granted") {
37891
+ releaseSlot = () => slot.handle.release();
37892
+ generate3 = (prompt, label) => ai2.generate(prompt, target, label);
37893
+ if (slot.queued) emit("ai-init", `AI init: subagents, README, ${guideFile}\u2026`);
37894
+ } else {
37895
+ emit("ai-skipped", "No AI run slot freed up in time \u2014 AI init skipped; fallback content written.");
37896
+ }
37897
+ }
37898
+ try {
37899
+ await generateFile(
37900
+ generate3,
37901
+ join6(target, "README.md"),
37902
+ "README.md",
37903
+ `Write a concise README.md (Markdown only, no preamble) for this project from its spec JSON:
37991
37904
  ${specJson}`
37992
- );
37993
- await generateFile(
37994
- join6(target, guideFile),
37995
- `Write a ${guideFile} (Markdown only, no preamble) with guidance/conventions for AI agents working in this project, derived from its spec JSON:
37905
+ );
37906
+ await generateFile(
37907
+ generate3,
37908
+ join6(target, guideFile),
37909
+ guideFile,
37910
+ `Write a ${guideFile} (Markdown only, no preamble) with guidance/conventions for AI agents working in this project, derived from its spec JSON:
37996
37911
  ${specJson}` + (guideInstructions ? `
37997
37912
  Also incorporate these additional instructions/content:
37998
37913
  ${guideInstructions}
37999
37914
  ` : ""),
38000
- guideInstructions
38001
- );
37915
+ guideInstructions
37916
+ );
37917
+ } finally {
37918
+ releaseSlot();
37919
+ }
38002
37920
  }
38003
- async function generateFile(path, prompt, fallback) {
37921
+ async function generateFile(generate3, path, label, prompt, fallback) {
38004
37922
  try {
38005
- const text = await aiGenerate(prompt);
38006
- if (text.trim()) {
37923
+ const text = generate3 ? await generate3(prompt, label) : null;
37924
+ if (text && text.trim()) {
38007
37925
  await writeFile(path, text.trim() + "\n", "utf8");
38008
37926
  return;
38009
37927
  }
@@ -38011,21 +37929,21 @@ async function generateFile(path, prompt, fallback) {
38011
37929
  }
38012
37930
  if (fallback && fallback.trim()) await writeFile(path, fallback.trim() + "\n", "utf8");
38013
37931
  }
38014
- async function addProject(payload, profileDir2, onProgress) {
37932
+ async function addProject(payload, profileDir2, onProgress, ai2) {
38015
37933
  let lastStep = "start";
38016
37934
  const emit = (step, message) => {
38017
37935
  lastStep = step;
38018
37936
  onProgress?.({ projectId: payload.projectId, step, message });
38019
37937
  };
38020
37938
  try {
38021
- const root = process.env.SCAFFOLD_ROOT ? resolve2(process.env.SCAFFOLD_ROOT) : resolve2(profileDir2);
37939
+ const root = process.env.SCAFFOLD_ROOT ? resolve(process.env.SCAFFOLD_ROOT) : resolve(profileDir2);
38022
37940
  const target = join6(root, payload.projectName);
38023
37941
  await mkdir(target, { recursive: true });
38024
37942
  const repo = singleRepo(payload.repos ?? []);
38025
37943
  const mode = payload.mode ?? "clone";
38026
37944
  if (repo) await provisionRepo(target, repo, emit, { mode });
38027
37945
  if (payload.scaffoldRepos && payload.scaffoldRepos.length > 0) {
38028
- await scaffoldRepo(target, payload.projectName, payload.spec, emit);
37946
+ await scaffoldRepo(target, payload.projectName, payload.spec, emit, { ai: ai2 });
38029
37947
  }
38030
37948
  await attachSubmodules(target, submodulesOf(payload.repos ?? []), emit);
38031
37949
  onProgress?.({ projectId: payload.projectId, step: "done", message: "Project added.", done: true });
@@ -38432,6 +38350,9 @@ var WsChannels = {
38432
38350
  CLI_UPDATE_RESULT: "cli.update-result",
38433
38351
  // command lifecycle
38434
38352
  COMMAND_ANNOUNCE: "command.announce",
38353
+ // server → cli (reply): stop an AI run (ADR-0362) — queued ⇒ leave the run-slot queue, running ⇒ kill
38354
+ // the AI process with no failover; the cli then ends the stream with `command.output {done, cancelled}`.
38355
+ COMMAND_CANCEL: "command.cancel",
38435
38356
  // console transcript sync (ADR-0150): the cli streams its authoritative SessionBus
38436
38357
  // transcript so the web Console renders it verbatim; gated by console.watch (server → cli:
38437
38358
  // whether a web viewer is attached) so an unwatched cli syncs nothing.
@@ -38551,6 +38472,7 @@ function decryptPayload(key, payloadBase64, nonceBase64) {
38551
38472
  var RUN_SLOT_RENEW_MS = 45e3;
38552
38473
  var RUN_SLOT_RETRY_MS = 3e3;
38553
38474
  var RUN_SLOT_MAX_WAIT_MS = 30 * 6e4;
38475
+ var COMMAND_CANCELLED_EXIT_CODE = 130;
38554
38476
 
38555
38477
  // src/core/update-scheduler.ts
38556
38478
  import { spawn as spawn2 } from "child_process";
@@ -38696,7 +38618,7 @@ function classifyToolFailure(code, out) {
38696
38618
  }
38697
38619
  return { reason: "other", retryable: false };
38698
38620
  }
38699
- function run3(cmd, args, onLine, timeoutMs = 18e4) {
38621
+ function run2(cmd, args, onLine, timeoutMs = 18e4) {
38700
38622
  return new Promise((resolve12) => {
38701
38623
  let out = "";
38702
38624
  let buf = "";
@@ -38736,13 +38658,13 @@ function run3(cmd, args, onLine, timeoutMs = 18e4) {
38736
38658
  });
38737
38659
  }
38738
38660
  async function detectOne(cmd, versionArg) {
38739
- const { code, out } = await run3(cmd, [versionArg], void 0, 15e3);
38661
+ const { code, out } = await run2(cmd, [versionArg], void 0, 15e3);
38740
38662
  if (code !== 0) return null;
38741
38663
  const first = out.split("\n").map((l) => l.trim()).find(Boolean);
38742
38664
  return first ?? "";
38743
38665
  }
38744
38666
  async function listExtras(autoUpdateSet = /* @__PURE__ */ new Set()) {
38745
- const { out } = await run3("npm", ["ls", "-g", "--depth=0", "--json"], void 0, 2e4);
38667
+ const { out } = await run2("npm", ["ls", "-g", "--depth=0", "--json"], void 0, 2e4);
38746
38668
  if (!out.trim()) return [];
38747
38669
  let deps = {};
38748
38670
  try {
@@ -38818,7 +38740,7 @@ async function runWorkerToolOp(op, name, manager, onLine, timeoutMs = TOOL_INSTA
38818
38740
  return { ok: false, exitCode: 1, error: resolved.error ?? "Invalid package." };
38819
38741
  }
38820
38742
  onLine(`$ ${manager} ${opArgs(op, manager, resolved.pkg).join(" ")}`);
38821
- const { code, out } = await run3(manager, opArgs(op, manager, resolved.pkg), onLine, timeoutMs);
38743
+ const { code, out } = await run2(manager, opArgs(op, manager, resolved.pkg), onLine, timeoutMs);
38822
38744
  if (code === 0) return { ok: true, exitCode: code };
38823
38745
  const detail = extractToolError(out);
38824
38746
  return { ok: false, exitCode: code, error: detail ? `${manager} exited ${code}: ${detail}` : `${manager} exited ${code}` };
@@ -38851,7 +38773,7 @@ async function installPinned(name, version, manager, onLine, timeoutMs = TOOL_IN
38851
38773
  for (let attempt = 1; attempt <= TOOL_INSTALL_MAX_ATTEMPTS; attempt++) {
38852
38774
  const suffix = attempt > 1 ? ` (attempt ${attempt}/${TOOL_INSTALL_MAX_ATTEMPTS})` : "";
38853
38775
  onLine(`$ ${manager} ${args.join(" ")}${suffix}`);
38854
- const { code, out } = await run3(manager, args, onLine, timeoutMs);
38776
+ const { code, out } = await run2(manager, args, onLine, timeoutMs);
38855
38777
  if (code === 0) return { ok: true };
38856
38778
  const { reason, retryable } = classifyToolFailure(code, out);
38857
38779
  if (!retryable || attempt === TOOL_INSTALL_MAX_ATTEMPTS) {
@@ -39135,6 +39057,36 @@ function clearPinnedCredential(profileDir2) {
39135
39057
  import { readFileSync as readFileSync8 } from "fs";
39136
39058
  import { homedir as homedir3 } from "os";
39137
39059
  import { basename, isAbsolute as isAbsolute2, join as join9 } from "path";
39060
+
39061
+ // src/utils/agent-deny.ts
39062
+ import { isAbsolute, resolve as resolve2 } from "path";
39063
+ var HOME_SECRET_PATTERNS = [
39064
+ "~/.4pm/**",
39065
+ "~/.ssh/**",
39066
+ "~/.config/gh/**",
39067
+ "~/.git-credentials",
39068
+ "~/.claude*",
39069
+ "~/.claude*/**"
39070
+ ];
39071
+ function absDirPattern(dir2) {
39072
+ const abs = (isAbsolute(dir2) ? dir2 : resolve2(dir2)).replace(/\\/g, "/").replace(/\/+$/, "");
39073
+ return `/${abs.startsWith("/") ? abs : `/${abs}`}/**`;
39074
+ }
39075
+ function agentDenyRules(profileDirs2 = []) {
39076
+ const patterns = new Set(HOME_SECRET_PATTERNS);
39077
+ for (const dir2 of profileDirs2) {
39078
+ if (dir2 && dir2.trim()) patterns.add(absDirPattern(dir2.trim()));
39079
+ }
39080
+ const rules = [];
39081
+ for (const p2 of patterns) rules.push(`Read(${p2})`, `Edit(${p2})`);
39082
+ return rules;
39083
+ }
39084
+ function denySettingsArgs(cmd, profileDirs2 = []) {
39085
+ if (!cmd.includes("claude")) return [];
39086
+ return ["--settings", JSON.stringify({ permissions: { deny: agentDenyRules(profileDirs2) } })];
39087
+ }
39088
+
39089
+ // src/utils/ai-cli.ts
39138
39090
  var REQUIRED_AI_ARGS = {
39139
39091
  claude: ["-p", "--output-format", "stream-json", "--verbose"],
39140
39092
  codex: ["exec", "--json"],
@@ -39811,12 +39763,12 @@ function pruneCommandOutputByAge(days) {
39811
39763
 
39812
39764
  // src/core/support-answer.ts
39813
39765
  import { spawn as spawn4 } from "child_process";
39814
- import { execFile as execFile3 } from "child_process";
39766
+ import { execFile as execFile2 } from "child_process";
39815
39767
  import { createHash as createHash3 } from "crypto";
39816
39768
  import { existsSync as existsSync10, mkdirSync as mkdirSync5, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync12, rmSync as rmSync5, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
39817
39769
  import { join as join14 } from "path";
39818
39770
  import { tmpdir as tmpdir3 } from "os";
39819
- import { promisify as promisify3 } from "util";
39771
+ import { promisify as promisify2 } from "util";
39820
39772
 
39821
39773
  // src/core/ai-stream.ts
39822
39774
  function assistantText(ev) {
@@ -39921,7 +39873,7 @@ function aiProviderOf(cmd) {
39921
39873
  if (cmd.includes("antigravity")) return "antigravity";
39922
39874
  return "claude";
39923
39875
  }
39924
- function estimateTokens2(text) {
39876
+ function estimateTokens(text) {
39925
39877
  return Math.max(1, Math.ceil(text.length / 4));
39926
39878
  }
39927
39879
 
@@ -39977,7 +39929,7 @@ function gitAuthArgs(user, password) {
39977
39929
 
39978
39930
  // src/core/support-answer.ts
39979
39931
  var NO_USAGE = { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
39980
- var execFileAsync = promisify3(execFile3);
39932
+ var execFileAsync = promisify2(execFile2);
39981
39933
  var MAX_CONTEXT_CHARS = 12e4;
39982
39934
  var ANSWER_TIMEOUT_MS = 12e4;
39983
39935
  var LEGAL_DRAFT_TIMEOUT_MS = 54e4;
@@ -40274,7 +40226,7 @@ async function runSupportAnswer(req, ai2, profileDir2) {
40274
40226
  if (!text) return { body: "", error: "empty answer" };
40275
40227
  const { body, moderation } = req.task ? { body: text.trim(), moderation: void 0 } : parseModeratedAnswer(text);
40276
40228
  if (!body) return { body: "", error: "empty answer" };
40277
- const u2 = usage.tokens > 0 ? usage : { ...NO_USAGE, tokens: estimateTokens2(body) };
40229
+ const u2 = usage.tokens > 0 ? usage : { ...NO_USAGE, tokens: estimateTokens(body) };
40278
40230
  return {
40279
40231
  body,
40280
40232
  ...moderation ? { moderation } : {},
@@ -40323,9 +40275,9 @@ function reportToolResult(tool, ok, message) {
40323
40275
  // src/core/autonomous.ts
40324
40276
  import { readFile as readFile4, writeFile as writeFile4, chmod } from "fs/promises";
40325
40277
  import { readFileSync as readFileSync14 } from "fs";
40326
- import { execFile as execFile4, spawn as spawn5 } from "child_process";
40278
+ import { execFile as execFile3, spawn as spawn5 } from "child_process";
40327
40279
  import { join as join17 } from "path";
40328
- import { promisify as promisify4 } from "util";
40280
+ import { promisify as promisify3 } from "util";
40329
40281
 
40330
40282
  // src/core/autonomous-config.ts
40331
40283
  import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
@@ -40436,7 +40388,7 @@ function pushRecord(h, rec) {
40436
40388
  }
40437
40389
 
40438
40390
  // src/core/autonomous.ts
40439
- var run4 = promisify4(execFile4);
40391
+ var run3 = promisify3(execFile3);
40440
40392
  var APPROVALS_REL = ".claude/.autonomous.approvals.json";
40441
40393
  var AUTHORS_REL = ".claude/.autonomous.authors.json";
40442
40394
  var TICK_REL = ".claude/hooks/autonomous-tick.sh";
@@ -40500,7 +40452,7 @@ function tickScript(root) {
40500
40452
  }
40501
40453
  async function crontabList() {
40502
40454
  try {
40503
- const { stdout } = await run4("crontab", ["-l"], { timeout: 1e4 });
40455
+ const { stdout } = await run3("crontab", ["-l"], { timeout: 1e4 });
40504
40456
  return stdout;
40505
40457
  } catch {
40506
40458
  return "";
@@ -40988,9 +40940,20 @@ async function runCommand(dispatch, emitRaw, opts) {
40988
40940
  hardKillTimer.unref?.();
40989
40941
  }, timeoutMs) : null;
40990
40942
  timeoutTimer?.unref?.();
40943
+ let cancelled = false;
40944
+ const onAbort = () => {
40945
+ if (cancelled) return;
40946
+ cancelled = true;
40947
+ child.kill("SIGTERM");
40948
+ hardKillTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
40949
+ hardKillTimer.unref?.();
40950
+ };
40951
+ if (opts?.signal?.aborted) onAbort();
40952
+ else opts?.signal?.addEventListener("abort", onAbort, { once: true });
40991
40953
  const clearTimers = () => {
40992
40954
  if (timeoutTimer) clearTimeout(timeoutTimer);
40993
40955
  if (hardKillTimer) clearTimeout(hardKillTimer);
40956
+ opts?.signal?.removeEventListener("abort", onAbort);
40994
40957
  };
40995
40958
  const batcher = new OutputBatcher({
40996
40959
  onFlush: (chunk, truncated) => {
@@ -41017,9 +40980,10 @@ async function runCommand(dispatch, emitRaw, opts) {
41017
40980
  seq: seq++,
41018
40981
  chunk: "",
41019
40982
  done: true,
41020
- // 124 (the conventional timeout code) when we killed it for exceeding the limit, so the
41021
- // server settles the command as failed and the web surfaces the note instead of spinning.
41022
- exitCode: timedOut ? 124 : exitCode ?? -1
40983
+ // 130 when the run was stopped (ADR-0362); 124 (the conventional timeout code) when we killed it
40984
+ // for exceeding the limit, so the server settles the command as failed and the web surfaces the
40985
+ // note instead of spinning.
40986
+ exitCode: cancelled ? COMMAND_CANCELLED_EXIT_CODE : timedOut ? 124 : exitCode ?? -1
41023
40987
  });
41024
40988
  resolve12();
41025
40989
  });
@@ -41055,7 +41019,7 @@ function isOutOfCredits(text) {
41055
41019
  text
41056
41020
  );
41057
41021
  }
41058
- function runAttempt(commandId, cmd, args, stdin, cwd2, env3, onChunk, timeoutMs) {
41022
+ function runAttempt(commandId, cmd, args, stdin, cwd2, env3, onChunk, timeoutMs, signal) {
41059
41023
  return new Promise((resolve12) => {
41060
41024
  runCommand(
41061
41025
  { commandId, cmd, args, path: cwd2, env: env3 },
@@ -41063,16 +41027,17 @@ function runAttempt(commandId, cmd, args, stdin, cwd2, env3, onChunk, timeoutMs)
41063
41027
  if (out.chunk) onChunk(out.chunk);
41064
41028
  if (out.done) resolve12(out.exitCode ?? -1);
41065
41029
  },
41066
- { stdin, ...timeoutMs > 0 ? { timeoutMs } : {} }
41030
+ { stdin, ...timeoutMs > 0 ? { timeoutMs } : {}, ...signal ? { signal } : {} }
41067
41031
  ).catch(() => resolve12(-1));
41068
41032
  });
41069
41033
  }
41070
- async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
41034
+ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0, signal) {
41071
41035
  const total = plan.attempts.length;
41072
41036
  let finalExit = -1;
41073
41037
  let lastCmd = plan.attempts[0]?.cmd ?? "claude";
41074
41038
  let lastCaptured = "";
41075
41039
  for (let i = 0; i < total; i++) {
41040
+ if (signal?.aborted) return cancelledResult();
41076
41041
  const attempt = plan.attempts[i];
41077
41042
  lastCmd = attempt.cmd;
41078
41043
  if (total > 1) handlers.onAttemptStart(attempt.label, i, total, attempt.cmd);
@@ -41085,17 +41050,18 @@ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
41085
41050
  handlers.onChunk(text);
41086
41051
  }
41087
41052
  };
41088
- finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, attempt.stdin, cwd2, attempt.env, emit, timeoutMs);
41053
+ finalExit = await runAttempt(commandId, attempt.cmd, attempt.args, attempt.stdin, cwd2, attempt.env, emit, timeoutMs, signal);
41089
41054
  const tail = parser.flush();
41090
41055
  if (tail) {
41091
41056
  captured += tail;
41092
41057
  handlers.onChunk(tail);
41093
41058
  }
41059
+ if (signal?.aborted) return cancelledResult();
41094
41060
  lastCaptured = captured;
41095
41061
  const apiErr = parser.apiError();
41096
41062
  if (finalExit === 0 && !apiErr.isError) {
41097
41063
  const usage = parser.usage();
41098
- if (usage.tokens === 0) usage.tokens = estimateTokens2(captured);
41064
+ if (usage.tokens === 0) usage.tokens = estimateTokens(captured);
41099
41065
  reportToolResult(attempt.cmd, true);
41100
41066
  return {
41101
41067
  exitCode: 0,
@@ -41114,6 +41080,17 @@ async function runAiFailover(plan, commandId, cwd2, handlers, timeoutMs = 0) {
41114
41080
  reportToolResult(lastCmd, false, summarizeAiFailure(lastCaptured, finalExit));
41115
41081
  return { exitCode: finalExit, workedDir: null, workedKey: null, workedCmd: null, usage: { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 }, sessionId: "" };
41116
41082
  }
41083
+ function cancelledResult() {
41084
+ return {
41085
+ exitCode: COMMAND_CANCELLED_EXIT_CODE,
41086
+ workedDir: null,
41087
+ workedKey: null,
41088
+ workedCmd: null,
41089
+ usage: { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 },
41090
+ sessionId: "",
41091
+ cancelled: true
41092
+ };
41093
+ }
41117
41094
  function summarizeAiFailure(captured, exitCode) {
41118
41095
  if (isAuthFailure(captured)) return "Not logged in";
41119
41096
  if (isOutOfCredits(captured)) return "Out of usage credits";
@@ -41642,21 +41619,30 @@ async function runMemoryCompaction(ai2, cwd2, input) {
41642
41619
  }
41643
41620
 
41644
41621
  // src/core/git-commit-identity.ts
41645
- import { execFile as execFile5 } from "child_process";
41646
- import { promisify as promisify5 } from "util";
41647
- var run5 = promisify5(execFile5);
41622
+ import { execFile as execFile4 } from "child_process";
41623
+ import { promisify as promisify4 } from "util";
41624
+ var run4 = promisify4(execFile4);
41648
41625
  async function setCommitAuthor(cwd2, machineUsername) {
41649
41626
  if (!machineUsername) return;
41650
41627
  try {
41651
- await run5("git", ["config", "--local", "user.name", machineUsername], { cwd: cwd2, timeout: 8e3 });
41628
+ await run4("git", ["config", "--local", "user.name", machineUsername], { cwd: cwd2, timeout: 8e3 });
41652
41629
  } catch {
41653
41630
  }
41654
41631
  }
41655
41632
 
41656
41633
  // src/core/ws-client/run-slot.ts
41657
41634
  var NO_SLOT = { release: () => void 0 };
41658
- function sleep2(ms2) {
41659
- return new Promise((resolve12) => setTimeout(resolve12, ms2));
41635
+ function sleep2(ms2, signal) {
41636
+ return new Promise((resolve12) => {
41637
+ if (signal?.aborted) return resolve12();
41638
+ const timer = setTimeout(done, ms2);
41639
+ function done() {
41640
+ clearTimeout(timer);
41641
+ signal?.removeEventListener("abort", done);
41642
+ resolve12();
41643
+ }
41644
+ signal?.addEventListener("abort", done, { once: true });
41645
+ });
41660
41646
  }
41661
41647
  function holdLease(ctx, lease) {
41662
41648
  const timer = setInterval(() => {
@@ -41673,22 +41659,28 @@ function holdLease(ctx, lease) {
41673
41659
  }
41674
41660
  };
41675
41661
  }
41676
- async function acquireRunSlot(ctx, onQueued) {
41662
+ async function acquireRunSlot(ctx, onQueued, signal) {
41677
41663
  const startedAt = Date.now();
41678
41664
  let ticket;
41679
41665
  let lastPosition = -1;
41680
41666
  let limit = 0;
41681
41667
  for (; ; ) {
41668
+ if (signal?.aborted) return leaveQueue(ctx, ticket);
41682
41669
  let reply;
41683
41670
  try {
41684
41671
  reply = await ctx.request(WsChannels.RUN_SLOT, { op: "acquire", ...ticket ? { ticket } : {} });
41685
41672
  } catch (err) {
41686
41673
  logger.warn("run.slot.unavailable", { error: err.message });
41687
- return { kind: "granted", handle: NO_SLOT };
41674
+ return { kind: "granted", handle: NO_SLOT, queued: ticket !== void 0 };
41688
41675
  }
41689
- if (!reply || !("granted" in reply)) return { kind: "granted", handle: NO_SLOT };
41676
+ if (!reply || !("granted" in reply)) return { kind: "granted", handle: NO_SLOT, queued: ticket !== void 0 };
41690
41677
  if (reply.granted) {
41691
- return { kind: "granted", handle: reply.lease ? holdLease(ctx, reply.lease) : NO_SLOT };
41678
+ const handle = reply.lease ? holdLease(ctx, reply.lease) : NO_SLOT;
41679
+ if (signal?.aborted) {
41680
+ handle.release();
41681
+ return { kind: "cancelled" };
41682
+ }
41683
+ return { kind: "granted", handle, queued: ticket !== void 0 };
41692
41684
  }
41693
41685
  ticket = reply.ticket;
41694
41686
  limit = reply.limit;
@@ -41697,13 +41689,31 @@ async function acquireRunSlot(ctx, onQueued) {
41697
41689
  onQueued({ position: reply.position, limit: reply.limit, running: reply.running });
41698
41690
  }
41699
41691
  if (Date.now() - startedAt >= RUN_SLOT_MAX_WAIT_MS) return { kind: "timeout", limit };
41700
- await sleep2(reply.retryAfterMs > 0 ? reply.retryAfterMs : RUN_SLOT_RETRY_MS);
41692
+ await sleep2(reply.retryAfterMs > 0 ? reply.retryAfterMs : RUN_SLOT_RETRY_MS, signal);
41701
41693
  }
41702
41694
  }
41695
+ function leaveQueue(ctx, ticket) {
41696
+ if (ticket) ctx.request(WsChannels.RUN_SLOT, { op: "leave", ticket }).catch(() => void 0);
41697
+ return { kind: "cancelled" };
41698
+ }
41703
41699
 
41704
41700
  // src/core/ws-client/command-dispatch.ts
41705
41701
  var MEMORY_SEED_HEADER = "CONTEXT MEMORY from earlier in this conversation (may span prior sessions/accounts). Use it as background; do not repeat it back unless relevant:";
41702
+ var activeRuns = /* @__PURE__ */ new Map();
41703
+ function cancelActiveRun(commandId) {
41704
+ const run11 = activeRuns.get(commandId);
41705
+ if (!run11) return { ok: false, state: "unknown" };
41706
+ run11.controller.abort();
41707
+ return { ok: true, state: run11.state };
41708
+ }
41706
41709
  function handleCommandChannels(ctx, message, payload) {
41710
+ if (message.channel === WsChannels.COMMAND_CANCEL) {
41711
+ const commandId = typeof payload.commandId === "string" ? payload.commandId : "";
41712
+ const reply = cancelActiveRun(commandId);
41713
+ logger.info("command.cancel", { commandId, ...reply });
41714
+ ctx.send(WsChannels.COMMAND_CANCEL, reply, message.id);
41715
+ return true;
41716
+ }
41707
41717
  if (message.channel !== WsChannels.COMMAND_DISPATCH) return false;
41708
41718
  const dispatch = payload;
41709
41719
  if (ctx.hasHandledCommand(dispatch.commandId)) {
@@ -41790,6 +41800,15 @@ async function runLocalCommand(ctx, input) {
41790
41800
  await runAiPrompt(ctx, prompt, commandId, "local");
41791
41801
  }
41792
41802
  async function runAiPrompt(ctx, prompt, commandId, origin, oneShot = false, images, aiConfig, readOnly = false, bypass = false) {
41803
+ const run11 = { controller: new AbortController(), state: "queued" };
41804
+ activeRuns.set(commandId, run11);
41805
+ try {
41806
+ await runAiPromptInner(ctx, prompt, commandId, origin, run11, oneShot, images, aiConfig, readOnly, bypass);
41807
+ } finally {
41808
+ activeRuns.delete(commandId);
41809
+ }
41810
+ }
41811
+ async function runAiPromptInner(ctx, prompt, commandId, origin, run11, oneShot = false, images, aiConfig, readOnly = false, bypass = false) {
41793
41812
  const config = readProfileConfig(ctx.profileDir);
41794
41813
  const projectTimeoutSec = config.projectAiRunTimeoutSec ?? 0;
41795
41814
  const machineTimeoutSec = config.aiRunTimeoutSec ?? 0;
@@ -41797,7 +41816,7 @@ async function runAiPrompt(ctx, prompt, commandId, origin, oneShot = false, imag
41797
41816
  const aiRunTimeoutMs = timeoutSec > 0 ? timeoutSec * 1e3 : 0;
41798
41817
  const perPromptLimit = config.perPromptTokenLimit ?? 0;
41799
41818
  if (perPromptLimit > 0) {
41800
- const estimated = estimateTokens2(prompt);
41819
+ const estimated = estimateTokens(prompt);
41801
41820
  if (estimated > perPromptLimit) {
41802
41821
  rejectPromptOverLimit(ctx, commandId, origin, prompt, estimated, perPromptLimit);
41803
41822
  return;
@@ -41954,24 +41973,39 @@ ${guardedPrompt}`;
41954
41973
  }
41955
41974
  };
41956
41975
  let result;
41957
- let queueTimedOut = false;
41976
+ let neverStarted = false;
41958
41977
  try {
41959
- const slot = await acquireRunSlot(ctx, ({ position, limit, running: running2 }) => {
41960
- const text = `\u23F3 queued \u2014 ${position} ahead, ${running2}/${limit} parallel runs in use (plan limit)`;
41961
- ctx.bus.push({ source: origin, kind: "log", text });
41962
- ctx.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
41963
- `, log: true });
41964
- });
41978
+ const slot = await acquireRunSlot(
41979
+ ctx,
41980
+ ({ position, limit, running: running2 }) => {
41981
+ const text = `\u23F3 queued \u2014 ${position} ahead, ${running2}/${limit} parallel runs in use (plan limit)`;
41982
+ ctx.bus.push({ source: origin, kind: "log", text });
41983
+ ctx.send(WsChannels.COMMAND_OUTPUT, {
41984
+ commandId,
41985
+ seq: seq++,
41986
+ chunk: `${text}
41987
+ `,
41988
+ log: true,
41989
+ queue: { position, limit, running: running2 }
41990
+ });
41991
+ },
41992
+ run11.controller.signal
41993
+ );
41965
41994
  if (slot.kind === "timeout") {
41966
- queueTimedOut = true;
41995
+ neverStarted = true;
41967
41996
  const text = `RUN_QUEUE_TIMEOUT: waited 30 min for a free run slot (plan limit ${slot.limit} parallel runs) \u2014 not run`;
41968
41997
  ctx.bus.push({ source: origin, kind: "log", text, level: "warn" });
41969
41998
  ctx.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
41970
41999
  `, log: true });
41971
42000
  result = { exitCode: 1, workedDir: null, workedKey: null, workedCmd: null, usage: { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 }, sessionId: "" };
42001
+ } else if (slot.kind === "cancelled") {
42002
+ neverStarted = true;
42003
+ result = { exitCode: COMMAND_CANCELLED_EXIT_CODE, workedDir: null, workedKey: null, workedCmd: null, usage: { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 }, sessionId: "", cancelled: true };
41972
42004
  } else {
42005
+ if (slot.queued) ctx.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: "", log: true, queue: null });
42006
+ run11.state = "running";
41973
42007
  try {
41974
- result = await runAiFailover(plan, commandId, cwd2, handlers, aiRunTimeoutMs);
42008
+ result = await runAiFailover(plan, commandId, cwd2, handlers, aiRunTimeoutMs, run11.controller.signal);
41975
42009
  } finally {
41976
42010
  slot.handle.release();
41977
42011
  }
@@ -41979,6 +42013,12 @@ ${guardedPrompt}`;
41979
42013
  } finally {
41980
42014
  ctx.bus.endBusy(plan.cmd);
41981
42015
  }
42016
+ if (result.cancelled) {
42017
+ const text = "\u25A0 stopped";
42018
+ ctx.bus.push({ source: origin, kind: "log", text, level: "warn" });
42019
+ ctx.send(WsChannels.COMMAND_OUTPUT, { commandId, seq: seq++, chunk: `${text}
42020
+ `, log: true });
42021
+ }
41982
42022
  const profileLabel = result.workedDir ? profileDisplayLabel(result.workedDir) : null;
41983
42023
  if (result.workedDir) {
41984
42024
  if (unified && result.workedKey) {
@@ -41993,14 +42033,16 @@ ${guardedPrompt}`;
41993
42033
  seq: seq++,
41994
42034
  chunk: "",
41995
42035
  done: true,
41996
- exitCode: result.exitCode
42036
+ exitCode: result.exitCode,
42037
+ // A stopped run (ADR-0362): the server settles it `cancelled` and the web applies nothing.
42038
+ ...result.cancelled ? { cancelled: true } : {}
41997
42039
  });
41998
42040
  finishCommand(commandId, result.exitCode, result.usage);
41999
42041
  ctx.send(WsChannels.COMMAND_HISTORY, {
42000
42042
  commandId,
42001
42043
  cmd: plan.cmd,
42002
42044
  args: markerArgs,
42003
- status: result.exitCode === 0 ? "done" : "failed",
42045
+ status: result.cancelled ? "cancelled" : result.exitCode === 0 ? "done" : "failed",
42004
42046
  exitCode: result.exitCode,
42005
42047
  tokens: result.usage.tokens,
42006
42048
  // The ai_tokens split for this run (ADR-0145); attached only when we metered real tokens.
@@ -42036,13 +42078,13 @@ ${guardedPrompt}`;
42036
42078
  });
42037
42079
  ctx.bus.addTokens?.(result.usage.tokens);
42038
42080
  }
42039
- if (!queueTimedOut) ctx.reportUsage(events, profileLabel);
42081
+ if (!neverStarted) ctx.reportUsage(events, profileLabel);
42040
42082
  void ctx.pollUsage();
42041
42083
  ctx.bus.push({
42042
42084
  source: origin,
42043
42085
  kind: "exit",
42044
- text: result.exitCode === 0 ? "\u2713 ready \u2014 enter your next prompt" : `\u2717 ${plan.cmd} failed (exit ${result.exitCode}) \u2014 see the error above`,
42045
- level: result.exitCode === 0 ? "info" : "error",
42086
+ text: result.exitCode === 0 ? "\u2713 ready \u2014 enter your next prompt" : result.cancelled ? "\u25A0 stopped \u2014 enter your next prompt" : `\u2717 ${plan.cmd} failed (exit ${result.exitCode}) \u2014 see the error above`,
42087
+ level: result.exitCode === 0 ? "info" : result.cancelled ? "warn" : "error",
42046
42088
  durationMs: Date.now() - aiStartedMs
42047
42089
  });
42048
42090
  if (memCfg.enabled && result.exitCode === 0) {
@@ -42788,10 +42830,10 @@ function handleFsChannels(ctx, message, payload) {
42788
42830
 
42789
42831
  // src/core/git-diff.ts
42790
42832
  import { readFile as readFile8 } from "fs/promises";
42791
- import { execFile as execFile6 } from "child_process";
42833
+ import { execFile as execFile5 } from "child_process";
42792
42834
  import { basename as basename5, dirname as dirname8, resolve as resolve9, sep as sep7 } from "path";
42793
- import { promisify as promisify6 } from "util";
42794
- var run6 = promisify6(execFile6);
42835
+ import { promisify as promisify5 } from "util";
42836
+ var run5 = promisify5(execFile5);
42795
42837
  function resolveInRoot5(root, relPath) {
42796
42838
  if (!root) return null;
42797
42839
  const base = resolve9(root);
@@ -42819,7 +42861,7 @@ async function gitDiff(root, path) {
42819
42861
  }
42820
42862
  let oldBuf = null;
42821
42863
  try {
42822
- const { stdout } = await run6("git", ["show", `HEAD:./${base}`], {
42864
+ const { stdout } = await run5("git", ["show", `HEAD:./${base}`], {
42823
42865
  cwd: dir2,
42824
42866
  timeout: 15e3,
42825
42867
  maxBuffer: FS_TRANSFER_MAX_BYTES + 1024,
@@ -42849,12 +42891,12 @@ async function gitDiff(root, path) {
42849
42891
  }
42850
42892
 
42851
42893
  // src/core/git-env.ts
42852
- import { execFile as execFile7 } from "child_process";
42853
- import { promisify as promisify7 } from "util";
42854
- var run7 = promisify7(execFile7);
42894
+ import { execFile as execFile6 } from "child_process";
42895
+ import { promisify as promisify6 } from "util";
42896
+ var run6 = promisify6(execFile6);
42855
42897
  async function hasBinary(bin, args = ["--version"]) {
42856
42898
  try {
42857
- await run7(bin, args, { timeout: 5e3 });
42899
+ await run6(bin, args, { timeout: 5e3 });
42858
42900
  return true;
42859
42901
  } catch {
42860
42902
  return false;
@@ -42867,7 +42909,7 @@ async function checkGitEnv(provider) {
42867
42909
  let account = null;
42868
42910
  if (installed) {
42869
42911
  try {
42870
- const { stdout, stderr } = await run7(bin, ["auth", "status"], { timeout: 8e3 });
42912
+ const { stdout, stderr } = await run6(bin, ["auth", "status"], { timeout: 8e3 });
42871
42913
  const out = `${stdout}
42872
42914
  ${stderr}`;
42873
42915
  authenticated = /logged in|logged into|✓/i.test(out);
@@ -42880,11 +42922,11 @@ ${stderr}`;
42880
42922
  }
42881
42923
 
42882
42924
  // src/core/git-ssh-key.ts
42883
- import { execFile as execFile8 } from "child_process";
42925
+ import { execFile as execFile7 } from "child_process";
42884
42926
  import { readFile as readFile9, rm as rm4, mkdir as mkdir7 } from "fs/promises";
42885
42927
  import { join as join21 } from "path";
42886
- import { promisify as promisify8 } from "util";
42887
- var run8 = promisify8(execFile8);
42928
+ import { promisify as promisify7 } from "util";
42929
+ var run7 = promisify7(execFile7);
42888
42930
  function keyPaths(profileDir2) {
42889
42931
  const dir2 = join21(profileDir2, ".ssh");
42890
42932
  const key = join21(dir2, "id_4pm");
@@ -42899,14 +42941,14 @@ async function readPublicKey(pub) {
42899
42941
  }
42900
42942
  async function fingerprintOf(pub) {
42901
42943
  try {
42902
- const { stdout } = await run8("ssh-keygen", ["-lf", pub], { timeout: 5e3 });
42944
+ const { stdout } = await run7("ssh-keygen", ["-lf", pub], { timeout: 5e3 });
42903
42945
  return stdout.match(/SHA256:[A-Za-z0-9+/=]+/)?.[0] ?? null;
42904
42946
  } catch {
42905
42947
  return null;
42906
42948
  }
42907
42949
  }
42908
42950
  async function configureGit(key) {
42909
- await run8(
42951
+ await run7(
42910
42952
  "git",
42911
42953
  ["config", "--global", "core.sshCommand", `ssh -i ${key} -o IdentitiesOnly=yes`],
42912
42954
  { timeout: 5e3 }
@@ -42917,7 +42959,7 @@ async function generate(profileDir2) {
42917
42959
  await mkdir7(dir2, { recursive: true });
42918
42960
  await rm4(key, { force: true });
42919
42961
  await rm4(pub, { force: true });
42920
- await run8("ssh-keygen", ["-t", "ed25519", "-f", key, "-N", "", "-C", "4pm-deploy-key"], {
42962
+ await run7("ssh-keygen", ["-t", "ed25519", "-f", key, "-N", "", "-C", "4pm-deploy-key"], {
42921
42963
  timeout: 15e3
42922
42964
  });
42923
42965
  await configureGit(key);
@@ -42927,7 +42969,7 @@ async function remove(profileDir2) {
42927
42969
  const { key, pub } = keyPaths(profileDir2);
42928
42970
  await rm4(key, { force: true });
42929
42971
  await rm4(pub, { force: true });
42930
- await run8("git", ["config", "--global", "--unset", "core.sshCommand"], { timeout: 5e3 }).catch(
42972
+ await run7("git", ["config", "--global", "--unset", "core.sshCommand"], { timeout: 5e3 }).catch(
42931
42973
  () => void 0
42932
42974
  );
42933
42975
  return { publicKey: null, fingerprint: null };
@@ -42948,10 +42990,10 @@ async function manageSshKey(op, profileDir2) {
42948
42990
  }
42949
42991
 
42950
42992
  // src/core/git-history.ts
42951
- import { execFile as execFile9 } from "child_process";
42993
+ import { execFile as execFile8 } from "child_process";
42952
42994
  import { resolve as resolve10, sep as sep8 } from "path";
42953
- import { promisify as promisify9 } from "util";
42954
- var run9 = promisify9(execFile9);
42995
+ import { promisify as promisify8 } from "util";
42996
+ var run8 = promisify8(execFile8);
42955
42997
  var FS = "";
42956
42998
  var RS = "";
42957
42999
  function resolveRepo(root, repo) {
@@ -42962,7 +43004,7 @@ function resolveRepo(root, repo) {
42962
43004
  }
42963
43005
  async function git(cwd2, args) {
42964
43006
  try {
42965
- const { stdout } = await run9("git", args, {
43007
+ const { stdout } = await run8("git", args, {
42966
43008
  cwd: cwd2,
42967
43009
  timeout: 15e3,
42968
43010
  maxBuffer: 8 * 1024 * 1024
@@ -43104,6 +43146,74 @@ function handleGitChannels(ctx, message, payload) {
43104
43146
  // src/core/ws-client/handlers/misc.ts
43105
43147
  import { join as join22 } from "path";
43106
43148
 
43149
+ // src/core/ai-assist.ts
43150
+ import { execFile as execFile9 } from "child_process";
43151
+ import { promisify as promisify9 } from "util";
43152
+ var run9 = promisify9(execFile9);
43153
+ function aiCli() {
43154
+ return process.env.AI_CLI || "claude";
43155
+ }
43156
+ function aiArgs(cli, prompt) {
43157
+ if (cli === "claude") return ["-p", prompt, "--output-format", "json", ...denySettingsArgs(cli, [process.env.CLAUDE_CONFIG_DIR])];
43158
+ if (cli === "codex") return ["exec", "--json", prompt];
43159
+ return ["-p", prompt];
43160
+ }
43161
+ function estimateTokens2(prompt, output) {
43162
+ return Math.max(1, Math.ceil((prompt.length + output.length) / 4));
43163
+ }
43164
+ function parseAi(cli, stdout, prompt) {
43165
+ if (cli === "claude") {
43166
+ try {
43167
+ const j2 = JSON.parse(stdout);
43168
+ const text2 = (j2.result ?? "").trim();
43169
+ const tokens = (j2.usage?.input_tokens ?? 0) + (j2.usage?.output_tokens ?? 0);
43170
+ if (text2 || tokens) return { text: text2, tokens: tokens || estimateTokens2(prompt, text2) };
43171
+ } catch {
43172
+ }
43173
+ }
43174
+ if (cli === "codex") {
43175
+ try {
43176
+ let text2 = "";
43177
+ let tokens = 0;
43178
+ for (const line of stdout.split("\n").filter(Boolean)) {
43179
+ const obj = JSON.parse(line);
43180
+ if (obj.text) text2 = obj.text;
43181
+ else if (obj.message) text2 = obj.message;
43182
+ if (obj.usage) {
43183
+ tokens = obj.usage.total_tokens ?? (obj.usage.input_tokens ?? 0) + (obj.usage.output_tokens ?? 0);
43184
+ }
43185
+ }
43186
+ if (text2 || tokens) return { text: text2.trim(), tokens: tokens || estimateTokens2(prompt, text2) };
43187
+ } catch {
43188
+ }
43189
+ }
43190
+ const text = stdout.trim();
43191
+ return { text, tokens: estimateTokens2(prompt, text) };
43192
+ }
43193
+ async function runAi(prompt) {
43194
+ const cli = aiCli();
43195
+ const { stdout } = await run9(cli, aiArgs(cli, prompt), {
43196
+ timeout: 12e4,
43197
+ maxBuffer: 8 * 1024 * 1024
43198
+ });
43199
+ return parseAi(cli, stdout, prompt);
43200
+ }
43201
+ async function aiOutboundReview(input, allowedRepos, projectPath) {
43202
+ const prompt = `You are a security reviewer for an autonomous coding agent. Decide if the following INPUT is SAFE to run. It is UNSAFE if it: (1) asks the AI using secret VALUES (api keys, env values, passwords); (2) would affect other environments (only the sandbox project is allowed, no ssh/deploy/prod); (3) edits outside the project folder "${projectPath}"; (4) commits/pushes to a repo not in this allowlist: ${allowedRepos.length ? allowedRepos.join(", ") : "(none declared)"}. Reply with ONLY JSON: {"ok": <true|false>, "reasons": ["secret"|"environment"|"path"|"repo", ...]}. INPUT:
43203
+ ${input}`;
43204
+ const { text, tokens } = await runAi(prompt);
43205
+ const start = text.indexOf("{");
43206
+ const end = text.lastIndexOf("}");
43207
+ if (start < 0 || end <= start) return { ok: false, reasons: ["ai_parse"], tokens };
43208
+ try {
43209
+ const j2 = JSON.parse(text.slice(start, end + 1));
43210
+ const reasons = Array.isArray(j2.reasons) ? j2.reasons.filter((r) => typeof r === "string") : [];
43211
+ return { ok: j2.ok === true && reasons.length === 0, reasons, tokens };
43212
+ } catch {
43213
+ return { ok: false, reasons: ["ai_parse"], tokens };
43214
+ }
43215
+ }
43216
+
43107
43217
  // src/core/outbound-review.ts
43108
43218
  function compilePatterns(sources, fallback) {
43109
43219
  const list = sources ?? fallback;
@@ -43217,6 +43327,72 @@ function handleMiscChannels(ctx, message, payload) {
43217
43327
  // src/core/ws-client/handlers/project.ts
43218
43328
  import { existsSync as existsSync14, mkdirSync as mkdirSync7, renameSync, rmSync as rmSync7 } from "fs";
43219
43329
  import { join as join23 } from "path";
43330
+
43331
+ // src/core/ai-task.ts
43332
+ import { randomUUID as randomUUID2 } from "crypto";
43333
+ function createAiTaskRunner(ctx) {
43334
+ return {
43335
+ acquireSlot: (onQueued) => acquireRunSlot(ctx, onQueued),
43336
+ generate: (prompt, cwd2, label) => generate2(ctx, prompt, cwd2, label)
43337
+ };
43338
+ }
43339
+ async function generate2(ctx, prompt, cwd2, label) {
43340
+ const config = readProfileConfig(ctx.profileDir);
43341
+ const cmd = config.aiCli || "claude";
43342
+ const unified = isUnifiedConfig(config);
43343
+ const hint = unified ? {
43344
+ credential: getPinnedCredential(ctx.profileDir) ?? ((config.aiFailoverMode ?? "remember") === "remember" ? getWorkingCredential(ctx.profileDir) : null)
43345
+ } : { dir: getWorkingProfile(ctx.profileDir, cmd) };
43346
+ const plan = planAiRun(prompt, config, hint, /* @__PURE__ */ new Map(), true);
43347
+ const timeoutSec = config.projectAiRunTimeoutSec || config.aiRunTimeoutSec || 0;
43348
+ ctx.bus.push({ source: "system", kind: "aireq", text: `${plan.cmd} \u2039 ${label}` });
43349
+ let text = "";
43350
+ const handlers = {
43351
+ onChunk: (chunk) => {
43352
+ text += chunk;
43353
+ },
43354
+ onAttemptStart: (profile, index, total, attemptCmd) => {
43355
+ ctx.bus.push({ source: "system", kind: "log", text: `\u2192 trying ${attemptCmd} profile "${profile}" (${index + 1}/${total})\u2026` });
43356
+ if (index > 0) text = "";
43357
+ },
43358
+ onAttemptFail: (profile) => {
43359
+ ctx.bus.push({ source: "system", kind: "log", text: `profile "${profile}" failed \u2014 trying next`, level: "warn" });
43360
+ }
43361
+ };
43362
+ ctx.bus.startBusy(plan.cmd);
43363
+ let result;
43364
+ try {
43365
+ result = await runAiFailover(plan, `ai-task-${randomUUID2()}`, cwd2, handlers, timeoutSec * 1e3);
43366
+ } finally {
43367
+ ctx.bus.endBusy(plan.cmd);
43368
+ }
43369
+ const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
43370
+ const events = [{ metric: UsageMetric.COMMANDS, amount: 1, occurredAt }];
43371
+ if (result.exitCode === 0 && result.usage.tokens > 0) {
43372
+ events.push({
43373
+ metric: UsageMetric.AI_TOKENS,
43374
+ amount: result.usage.tokens,
43375
+ occurredAt,
43376
+ inputTokens: result.usage.input,
43377
+ outputTokens: result.usage.output,
43378
+ cacheReadTokens: result.usage.cacheRead,
43379
+ cacheCreationTokens: result.usage.cacheCreation,
43380
+ provider: aiProviderOf(result.workedCmd ?? plan.cmd),
43381
+ authMode: resolveClaudeAuthMode(config)
43382
+ });
43383
+ }
43384
+ ctx.reportUsage(events, result.workedDir ? profileDisplayLabel(result.workedDir) : null);
43385
+ const ok = result.exitCode === 0 && text.trim().length > 0;
43386
+ ctx.bus.push({
43387
+ source: "system",
43388
+ kind: ok ? "aires" : "log",
43389
+ text: ok ? `${plan.cmd} \u203A ${label} \u2713` : `${label}: AI run failed (exit ${result.exitCode}) \u2014 using the fallback`,
43390
+ ...ok ? {} : { level: "warn" }
43391
+ });
43392
+ return ok ? text.trim() : null;
43393
+ }
43394
+
43395
+ // src/core/ws-client/handlers/project.ts
43220
43396
  function handleProjectChannels(ctx, message, payload) {
43221
43397
  switch (message.channel) {
43222
43398
  case WsChannels.PHYSIC_SYNC: {
@@ -43274,14 +43450,18 @@ function handleProjectChannels(ctx, message, payload) {
43274
43450
  void scaffoldProject(
43275
43451
  payload,
43276
43452
  ctx.profileDir,
43277
- (p2) => ctx.send(WsChannels.PROJECT_PROGRESS, p2)
43453
+ (p2) => ctx.send(WsChannels.PROJECT_PROGRESS, p2),
43454
+ // AI init on the standard AI path + one org run slot (ADR-0362).
43455
+ createAiTaskRunner(ctx)
43278
43456
  ).then((reply) => ctx.send(WsChannels.PROJECT_CREATE, reply, message.id));
43279
43457
  return true;
43280
43458
  case WsChannels.PROJECT_ADD:
43281
43459
  void addProject(
43282
43460
  payload,
43283
43461
  ctx.profileDir,
43284
- (p2) => ctx.send(WsChannels.PROJECT_PROGRESS, p2)
43462
+ (p2) => ctx.send(WsChannels.PROJECT_PROGRESS, p2),
43463
+ // An add-with-scaffold runs AI init too (ADR-0362).
43464
+ createAiTaskRunner(ctx)
43285
43465
  ).then((reply) => ctx.send(WsChannels.PROJECT_ADD, reply, message.id));
43286
43466
  return true;
43287
43467
  default:
@@ -43364,7 +43544,7 @@ async function runKnowledgeCompose(req, ai2, physicRoot) {
43364
43544
  // src/core/faq-compose.ts
43365
43545
  import { spawn as spawn9, execFile as execFile10 } from "child_process";
43366
43546
  import { existsSync as existsSync15, mkdirSync as mkdirSync8, rmSync as rmSync8 } from "fs";
43367
- import { randomUUID as randomUUID2 } from "crypto";
43547
+ import { randomUUID as randomUUID3 } from "crypto";
43368
43548
  import { join as join24 } from "path";
43369
43549
  import { tmpdir as tmpdir4 } from "os";
43370
43550
  import { promisify as promisify10 } from "util";
@@ -43486,7 +43666,7 @@ async function runClaudeWithFailover2(prompt, cwd2, ai2) {
43486
43666
  var MAX_OUTPUT_CHARS = 8e3;
43487
43667
  async function runFaqCompose(req, ai2) {
43488
43668
  if (!req.repo.token) return { prUrl: "", error: "no write token" };
43489
- const workDir = join24(tmpdir4(), "4pm-faq-sync", randomUUID2());
43669
+ const workDir = join24(tmpdir4(), "4pm-faq-sync", randomUUID3());
43490
43670
  const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
43491
43671
  const auth = gitAuthArgs("x-access-token", req.repo.token);
43492
43672
  try {
@@ -43501,7 +43681,7 @@ async function runFaqCompose(req, ai2) {
43501
43681
  await execFileAsync2("git", ["-C", workDir, "add", "-A"], { env: gitEnv });
43502
43682
  const staged = await execFileAsync2("git", ["-C", workDir, "diff", "--cached", "--name-only"], { env: gitEnv });
43503
43683
  if (!staged.stdout.trim()) {
43504
- return { prUrl: "", output, perTicket, tokens: usage.tokens || estimateTokens2(text), finishedAt: (/* @__PURE__ */ new Date()).toISOString() };
43684
+ return { prUrl: "", output, perTicket, tokens: usage.tokens || estimateTokens(text), finishedAt: (/* @__PURE__ */ new Date()).toISOString() };
43505
43685
  }
43506
43686
  await execFileAsync2("git", ["-C", workDir, "commit", "-m", "docs(faq): synthesize from support tickets (4PM)"], { env: gitEnv });
43507
43687
  await execFileAsync2("git", [...auth, "-C", workDir, "push", "-u", "origin", req.headBranch], { env: gitEnv });
@@ -45290,7 +45470,7 @@ var WsClient = class {
45290
45470
  }
45291
45471
  const { payload, nonce } = encryptPayload(this.sessionKey, data);
45292
45472
  const envelope = {
45293
- id: randomUUID3(),
45473
+ id: randomUUID4(),
45294
45474
  replyTo,
45295
45475
  channel,
45296
45476
  wsToken: this.wsToken,
@@ -45311,7 +45491,7 @@ var WsClient = class {
45311
45491
  }
45312
45492
  const { payload, nonce } = encryptPayload(this.sessionKey, data);
45313
45493
  const envelope = {
45314
- id: randomUUID3(),
45494
+ id: randomUUID4(),
45315
45495
  replyTo: null,
45316
45496
  channel,
45317
45497
  wsToken: this.wsToken,
@@ -45703,7 +45883,7 @@ function releaseInstanceLock(profileDir2) {
45703
45883
 
45704
45884
  // src/core/session-bus.ts
45705
45885
  import { EventEmitter } from "events";
45706
- import { randomUUID as randomUUID4 } from "crypto";
45886
+ import { randomUUID as randomUUID5 } from "crypto";
45707
45887
  var REPLAY_CAP = 500;
45708
45888
  var SessionBus = class {
45709
45889
  emitter = new EventEmitter();
@@ -45734,7 +45914,7 @@ var SessionBus = class {
45734
45914
  /** Producer → consumers: push one transcript entry. Returns its id (for `updateEntry`). */
45735
45915
  push(input) {
45736
45916
  const entry = {
45737
- id: randomUUID4(),
45917
+ id: randomUUID5(),
45738
45918
  level: "info",
45739
45919
  ...input,
45740
45920
  ts: Date.now()
@@ -48127,7 +48307,7 @@ var src_default = Yoga;
48127
48307
 
48128
48308
  // ../../node_modules/.pnpm/ink@5.2.1_@types+react@18.3.31_react@18.3.1/node_modules/ink/build/reconciler.js
48129
48309
  var import_react_reconciler = __toESM(require_react_reconciler(), 1);
48130
- var import_constants11 = __toESM(require_constants2(), 1);
48310
+ var import_constants12 = __toESM(require_constants2(), 1);
48131
48311
  import process3 from "process";
48132
48312
 
48133
48313
  // ../../node_modules/.pnpm/ansi-regex@6.2.2/node_modules/ansi-regex/index.js
@@ -49369,7 +49549,7 @@ var reconciler_default = (0, import_react_reconciler.default)({
49369
49549
  scheduleTimeout: setTimeout,
49370
49550
  cancelTimeout: clearTimeout,
49371
49551
  noTimeout: -1,
49372
- getCurrentEventPriority: () => import_constants11.DefaultEventPriority,
49552
+ getCurrentEventPriority: () => import_constants12.DefaultEventPriority,
49373
49553
  beforeActiveInstanceBlur() {
49374
49554
  },
49375
49555
  afterActiveInstanceBlur() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "private": false,
5
5
  "description": "4PM CLI — drives Claude/Codex CLIs on AI worker machines (Node + TypeScript)",
6
6
  "license": "LicenseRef-4PM-Source-Available",