claude-yes 1.147.0 → 1.149.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.
@@ -0,0 +1,8 @@
1
+ import "./ts-9In7C_H_.js";
2
+ import "./logger-CDIsZ-Pp.js";
3
+ import "./versionChecker-xwsWu7p6.js";
4
+ import "./pidStore-fqXqTKkh.js";
5
+ import "./globalPidIndex-DlmmJlO8.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DmEBbACr.js";
7
+
8
+ export { SUPPORTED_CLIS };
@@ -1,8 +1,8 @@
1
- import { t as CLIS_CONFIG } from "./ts-DKH7E-F6.js";
1
+ import { t as CLIS_CONFIG } from "./ts-9In7C_H_.js";
2
2
 
3
3
  //#region ts/SUPPORTED_CLIS.ts
4
4
  const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
5
5
 
6
6
  //#endregion
7
7
  export { SUPPORTED_CLIS as t };
8
- //# sourceMappingURL=SUPPORTED_CLIS-BFZPtEsl.js.map
8
+ //# sourceMappingURL=SUPPORTED_CLIS-DmEBbACr.js.map
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
3
3
  import { n as logger } from "./logger-CDIsZ-Pp.js";
4
- import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-BGsjdYf6.js";
4
+ import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-xwsWu7p6.js";
5
5
  import { argv } from "process";
6
6
  import { execFileSync, spawn } from "child_process";
7
7
  import ms from "ms";
@@ -480,7 +480,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
480
480
  const rawArg = process.argv[2];
481
481
  const managerCommands = !invokedCliName(process.argv);
482
482
  const isHelpFlag = rawArg === "-h" || rawArg === "--help";
483
- const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-CiRgqMjR.js");
483
+ const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DCsphJXW.js");
484
484
  if (isHelpFlag && process.argv.length === 3) {
485
485
  cmdHelp(managerCommands);
486
486
  process.exit(0);
@@ -513,7 +513,7 @@ if (config.useRust) {
513
513
  }
514
514
  }
515
515
  if (rustBinary) {
516
- const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-BUNhV-WM.js");
516
+ const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-CFaRUz70.js");
517
517
  const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
518
518
  if (config.verbose) {
519
519
  console.log(`[rust] Using binary: ${rustBinary}`);
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-DKH7E-F6.js";
1
+ import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-9In7C_H_.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-BGsjdYf6.js";
3
+ import "./versionChecker-xwsWu7p6.js";
4
4
  import "./pidStore-fqXqTKkh.js";
5
5
  import "./globalPidIndex-DlmmJlO8.js";
6
6
 
@@ -1,10 +1,10 @@
1
- import "./ts-DKH7E-F6.js";
1
+ import "./ts-9In7C_H_.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-BGsjdYf6.js";
3
+ import "./versionChecker-xwsWu7p6.js";
4
4
  import "./pidStore-fqXqTKkh.js";
5
5
  import "./globalPidIndex-DlmmJlO8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BFZPtEsl.js";
7
- import { n as resolveSpawnCwd } from "./workspaceConfig-B3ylOZAO.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DmEBbACr.js";
7
+ import { i as resolveSpawnCwd } from "./workspaceConfig-B9VX8tVO.js";
8
8
  import { createHash } from "node:crypto";
9
9
 
10
10
  //#region ts/oxmgrService.ts
@@ -141,4 +141,4 @@ async function cmdSchedule(rest) {
141
141
 
142
142
  //#endregion
143
143
  export { cmdSchedule };
144
- //# sourceMappingURL=schedule-Dq0rsd5g.js.map
144
+ //# sourceMappingURL=schedule-CdJjOPt7.js.map
@@ -1,15 +1,16 @@
1
- import "./ts-DKH7E-F6.js";
1
+ import "./ts-9In7C_H_.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import { r as getInstalledPackage } from "./versionChecker-BGsjdYf6.js";
3
+ import { r as getInstalledPackage } from "./versionChecker-xwsWu7p6.js";
4
4
  import "./pidStore-fqXqTKkh.js";
5
5
  import { a as updateGlobalPidStatus } from "./globalPidIndex-DlmmJlO8.js";
6
6
  import { t as pgidForWrapper } from "./reaper-C-eWAxIj.js";
7
7
  import "./configShared-C1C04bbq.js";
8
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BFZPtEsl.js";
8
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DmEBbACr.js";
9
9
  import "./e2e-ClOI_aqV.js";
10
10
  import "./webrtcLink-BWhuA74k.js";
11
11
  import "./remotes-qK6uozO4.js";
12
- import { S as snapshotStatus, _ as renderRawLog, c as extractTaskCounts, g as readNotes, i as controlCodeFromName, m as listRecords, o as deriveLiveStatus, w as writeToIpc, y as resolveOne } from "./subcommands-DAv18AHb.js";
12
+ import { i as resolveSpawnCwd, r as isProvisionAllowed, t as getProvisionRoot } from "./workspaceConfig-B9VX8tVO.js";
13
+ import { S as snapshotStatus, _ as renderRawLog, c as extractTaskCounts, g as readNotes, i as controlCodeFromName, m as listRecords, o as deriveLiveStatus, w as writeToIpc, y as resolveOne } from "./subcommands-PCUd7vJf.js";
13
14
  import yargs from "yargs";
14
15
  import { mkdir, open, readFile, stat, writeFile } from "fs/promises";
15
16
  import { homedir, hostname, userInfo } from "os";
@@ -20,6 +21,22 @@ import { randomBytes, timingSafeEqual } from "crypto";
20
21
 
21
22
  //#region ts/serve.ts
22
23
  const DEFAULT_PORT = 7432;
24
+ /**
25
+ * Normalize a user-supplied GitHub-ish source into the standard
26
+ * `<owner>/<repo>/tree/<branch>` path that codehost/provision's `parseSpec`
27
+ * understands. Interim fallback used only when the linked codehost build
28
+ * predates `parseSource` (the canonical normalizer in the standard):
29
+ * https://github.com/o/r/tree/b · github.com/o/r/tree/b · o/r/tree/b
30
+ * o/r@branch · o/r (→ default branch main)
31
+ */
32
+ function normalizeGithubSource(s) {
33
+ let v = s.trim().replace(/^https?:\/\//, "").replace(/^github\.com\//, "");
34
+ v = v.replace(/[?#].*$/, "").replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
35
+ const at = v.match(/^([^/]+)\/([^/@]+)@(.+)$/);
36
+ if (at) return `${at[1]}/${at[2]}/tree/${at[3]}`;
37
+ if (/^[^/]+\/[^/]+$/.test(v)) return `${v}/tree/main`;
38
+ return v;
39
+ }
23
40
  function agentYesHome() {
24
41
  return process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
25
42
  }
@@ -1031,9 +1048,47 @@ Options:
1031
1048
  }
1032
1049
  const cli = String(body.cli ?? "claude");
1033
1050
  if (!SUPPORTED_CLIS.includes(cli)) return new Response(`unsupported cli: ${cli}`, { status: 400 });
1034
- const cwd = typeof body.cwd === "string" && body.cwd ? body.cwd : process.cwd();
1035
1051
  const prompt = String(body.prompt ?? "");
1036
- process.stderr.write(`→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`);
1052
+ let cwd;
1053
+ let provisioned = null;
1054
+ const from = typeof body.from === "string" ? body.from.trim() : "";
1055
+ if (from) {
1056
+ let prov;
1057
+ try {
1058
+ prov = await import("codehost/provision");
1059
+ } catch (e) {
1060
+ return new Response(`spawn-from needs the 'codehost' package (codehost/provision) — install it (npm i -g codehost) or 'bun link' it for local dev: ${e.message}`, { status: 501 });
1061
+ }
1062
+ let spec;
1063
+ try {
1064
+ spec = typeof prov.parseSource === "function" ? prov.parseSource(from) : prov.parseSpec(normalizeGithubSource(from));
1065
+ } catch {
1066
+ spec = null;
1067
+ }
1068
+ if (!spec) return new Response(`unrecognized spawn source: ${from}`, { status: 400 });
1069
+ if (!isProvisionAllowed(spec.owner, spec.repo)) return new Response(`provisioning '${spec.owner}/${spec.repo}' is not allowed — add the owner to provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all)`, { status: 403 });
1070
+ let result;
1071
+ try {
1072
+ const wsRoot = getProvisionRoot();
1073
+ result = await prov.provision(spec, wsRoot ? { wsRoot } : void 0);
1074
+ } catch (e) {
1075
+ return new Response(`provision failed: ${e.message}`, { status: 502 });
1076
+ }
1077
+ if (!result?.ok) return new Response(result?.error ?? "provision failed", { status: 502 });
1078
+ cwd = result.folder;
1079
+ provisioned = {
1080
+ action: result.action,
1081
+ folder: result.folder
1082
+ };
1083
+ } else {
1084
+ cwd = resolveSpawnCwd(body.cwd);
1085
+ try {
1086
+ await mkdir(cwd, { recursive: true });
1087
+ } catch (e) {
1088
+ return new Response(`cannot create cwd ${cwd}: ${e.message}`, { status: 500 });
1089
+ }
1090
+ }
1091
+ process.stderr.write(`→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd}${provisioned ? `, ${provisioned.action} from ${from}` : ""})\n`);
1037
1092
  const ayBin = Bun.which("ay") ?? process.argv[1];
1038
1093
  const ayCmd = process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe") ? [ayBin] : [process.execPath, ayBin];
1039
1094
  try {
@@ -1053,7 +1108,8 @@ Options:
1053
1108
  ok: true,
1054
1109
  pid: child.pid,
1055
1110
  cli,
1056
- cwd
1111
+ cwd,
1112
+ ...provisioned ? { provisioned } : {}
1057
1113
  });
1058
1114
  } catch (e) {
1059
1115
  return new Response(e.message, { status: 500 });
@@ -1179,4 +1235,4 @@ Options:
1179
1235
 
1180
1236
  //#endregion
1181
1237
  export { cmdServe };
1182
- //# sourceMappingURL=serve-C5nlXrQt.js.map
1238
+ //# sourceMappingURL=serve-j3Jwmk5O.js.map
@@ -1,4 +1,4 @@
1
- import { r as setWorkspaceRoot, t as getWorkspaceRoot } from "./workspaceConfig-B3ylOZAO.js";
1
+ import { a as setWorkspaceRoot, n as getWorkspaceRoot } from "./workspaceConfig-B9VX8tVO.js";
2
2
  import { existsSync } from "node:fs";
3
3
  import { stdin, stdout } from "node:process";
4
4
  import { createInterface } from "node:readline/promises";
@@ -32,7 +32,7 @@ async function cmdSetup(rest) {
32
32
  if (!existsSync(abs)) process.stderr.write(` note: that directory doesn't exist yet — create it, or agents spawned there will fail\n`);
33
33
  if (noShare) return 0;
34
34
  process.stdout.write(`\nsharing this machine to agent-yes.com…\n`);
35
- const { cmdServe } = await import("./serve-C5nlXrQt.js");
35
+ const { cmdServe } = await import("./serve-j3Jwmk5O.js");
36
36
  return cmdServe([
37
37
  "install",
38
38
  "--share",
@@ -42,4 +42,4 @@ async function cmdSetup(rest) {
42
42
 
43
43
  //#endregion
44
44
  export { cmdSetup };
45
- //# sourceMappingURL=setup-CYnFh4ZX.js.map
45
+ //# sourceMappingURL=setup-BsVxgyr7.js.map
@@ -4,6 +4,6 @@ import "./configShared-C1C04bbq.js";
4
4
  import "./e2e-ClOI_aqV.js";
5
5
  import "./webrtcLink-BWhuA74k.js";
6
6
  import "./remotes-qK6uozO4.js";
7
- import { C as stopTipForCli, S as snapshotStatus, _ as renderRawLog, a as cursorAbs, b as resolveReadWindow, c as extractTaskCounts, d as isExitRequest, f as isPidAlive, g as readNotes, h as matchKeyword, i as controlCodeFromName, l as finalizedLines, m as listRecords, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isSubcommand, r as cmdHelp, s as extractNeedsInput, t as GRACEFUL_EXIT_COMMANDS, u as isAgentStuck, v as renderRawLogLines, w as writeToIpc, x as runSubcommand, y as resolveOne } from "./subcommands-DAv18AHb.js";
7
+ import { C as stopTipForCli, S as snapshotStatus, _ as renderRawLog, a as cursorAbs, b as resolveReadWindow, c as extractTaskCounts, d as isExitRequest, f as isPidAlive, g as readNotes, h as matchKeyword, i as controlCodeFromName, l as finalizedLines, m as listRecords, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isSubcommand, r as cmdHelp, s as extractNeedsInput, t as GRACEFUL_EXIT_COMMANDS, u as isAgentStuck, v as renderRawLogLines, w as writeToIpc, x as runSubcommand, y as resolveOne } from "./subcommands-PCUd7vJf.js";
8
8
 
9
9
  export { cmdHelp, isSubcommand, runSubcommand };
@@ -554,15 +554,15 @@ async function runSubcommand(argv) {
554
554
  case "restart": return await cmdRestart(rest);
555
555
  case "note": return await cmdNote(rest);
556
556
  case "serve": {
557
- const { cmdServe } = await import("./serve-C5nlXrQt.js");
557
+ const { cmdServe } = await import("./serve-j3Jwmk5O.js");
558
558
  return cmdServe(rest);
559
559
  }
560
560
  case "setup": {
561
- const { cmdSetup } = await import("./setup-CYnFh4ZX.js");
561
+ const { cmdSetup } = await import("./setup-BsVxgyr7.js");
562
562
  return cmdSetup(rest);
563
563
  }
564
564
  case "schedule": {
565
- const { cmdSchedule } = await import("./schedule-Dq0rsd5g.js");
565
+ const { cmdSchedule } = await import("./schedule-CdJjOPt7.js");
566
566
  return cmdSchedule(rest);
567
567
  }
568
568
  case "remote": {
@@ -2593,4 +2593,4 @@ async function cmdResultSet(rest) {
2593
2593
 
2594
2594
  //#endregion
2595
2595
  export { stopTipForCli as C, snapshotStatus as S, renderRawLog as _, cursorAbs as a, resolveReadWindow as b, extractTaskCounts as c, isExitRequest as d, isPidAlive as f, readNotes as g, matchKeyword as h, controlCodeFromName as i, finalizedLines as l, listRecords as m, READ_PAGE_DEFAULT as n, deriveLiveStatus as o, isSubcommand as p, cmdHelp as r, extractNeedsInput as s, GRACEFUL_EXIT_COMMANDS as t, isAgentStuck as u, renderRawLogLines as v, writeToIpc as w, runSubcommand as x, resolveOne as y };
2596
- //# sourceMappingURL=subcommands-DAv18AHb.js.map
2596
+ //# sourceMappingURL=subcommands-PCUd7vJf.js.map
@@ -1,5 +1,5 @@
1
1
  import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
2
- import { r as getInstalledPackage } from "./versionChecker-BGsjdYf6.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-xwsWu7p6.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-V4qvXgAw.js";
5
5
  import { t as PidStore } from "./pidStore-fqXqTKkh.js";
@@ -1800,4 +1800,4 @@ function sleep(ms) {
1800
1800
 
1801
1801
  //#endregion
1802
1802
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1803
- //# sourceMappingURL=ts-DKH7E-F6.js.map
1803
+ //# sourceMappingURL=ts-9In7C_H_.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "claude-yes";
10
- var version = "1.147.0";
10
+ var version = "1.149.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -215,4 +215,4 @@ async function displayVersion() {
215
215
 
216
216
  //#endregion
217
217
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
218
- //# sourceMappingURL=versionChecker-BGsjdYf6.js.map
218
+ //# sourceMappingURL=versionChecker-xwsWu7p6.js.map
@@ -0,0 +1,91 @@
1
+ import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import path from "path";
5
+
6
+ //#region ts/workspaceConfig.ts
7
+ function configPath() {
8
+ return path.join(agentYesHome(), "config.json");
9
+ }
10
+ function readConfig() {
11
+ try {
12
+ return JSON.parse(readFileSync(configPath(), "utf-8"));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+ /** Expand a leading `~` (`~` or `~/x`) to an absolute home-based path. */
18
+ function expandTilde(p) {
19
+ const s = p.trim();
20
+ if (s === "~") return homedir();
21
+ if (s.startsWith("~/") || s.startsWith("~\\")) return path.join(homedir(), s.slice(2));
22
+ return s;
23
+ }
24
+ /** The configured workspace root (absolute), or the home dir if unset. */
25
+ function getWorkspaceRoot() {
26
+ const w = readConfig().workspace;
27
+ return w && w.trim() ? w : homedir();
28
+ }
29
+ /**
30
+ * Root for `from`-provisioned worktrees, handed to codehost/provision so they
31
+ * land in `<root>/<owner>/<repo>/tree/<branch>`. Resolution order: env
32
+ * `CODEHOST_WS_ROOT` wins (ops override), then the configured `provisionRoot`,
33
+ * else undefined — letting codehost/provision fall back to its own `~/ws`
34
+ * default. Kept separate from `workspace` (the plain-cwd default), which may be a
35
+ * specific project dir rather than a root.
36
+ */
37
+ function getProvisionRoot() {
38
+ const env = process.env.CODEHOST_WS_ROOT?.trim();
39
+ if (env) return path.resolve(expandTilde(env));
40
+ const r = readConfig().provisionRoot;
41
+ return r && r.trim() ? path.resolve(expandTilde(r)) : void 0;
42
+ }
43
+ /**
44
+ * Owner/repo allowlist for `from`-provisioning. Provisioning clones a repo and
45
+ * runs its setup script (dependency installs + package lifecycle hooks = code
46
+ * execution on the host), so an **empty allowlist means DENY ALL** — a secure
47
+ * default the host opts out of by listing owners it trusts. Entries match
48
+ * `<owner>` (any repo of that owner), `<owner>/<repo>` (exact), or `*` (allow
49
+ * all — an explicit opt-in to the wide-open behavior). Env
50
+ * `CODEHOST_PROVISION_ALLOWLIST` (comma-separated) overrides the config.
51
+ */
52
+ function getProvisionAllowlist() {
53
+ const env = process.env.CODEHOST_PROVISION_ALLOWLIST?.trim();
54
+ return (env ? env.split(",") : readConfig().provisionAllowlist ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
55
+ }
56
+ /** Whether `<owner>/<repo>` may be `from`-provisioned, per {@link getProvisionAllowlist}. */
57
+ function isProvisionAllowed(owner, repo) {
58
+ const list = getProvisionAllowlist();
59
+ if (list.includes("*")) return true;
60
+ const o = owner.toLowerCase();
61
+ const full = `${owner}/${repo}`.toLowerCase();
62
+ return list.some((e) => e.replace(/\/\*$/, "") === o || e === full);
63
+ }
64
+ /** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
65
+ function setWorkspaceRoot(dir) {
66
+ const abs = path.resolve(expandTilde(dir));
67
+ const cfg = readConfig();
68
+ cfg.workspace = abs;
69
+ mkdirSync(agentYesHome(), { recursive: true });
70
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
71
+ return abs;
72
+ }
73
+ /**
74
+ * Resolve a user-supplied spawn location to an absolute cwd:
75
+ * - empty → the workspace root
76
+ * - a bare name → `<workspace>/<name>` (so "myproject" lands under the root)
77
+ * - `~`-prefixed → home-based absolute
78
+ * - anything with a path separator → resolved as-is
79
+ */
80
+ function resolveSpawnCwd(input) {
81
+ const root = getWorkspaceRoot();
82
+ const v = (input ?? "").trim();
83
+ if (!v) return root;
84
+ if (v.startsWith("~")) return path.resolve(expandTilde(v));
85
+ if (v.includes("/") || v.includes("\\") || path.isAbsolute(v)) return path.resolve(v);
86
+ return path.join(root, v);
87
+ }
88
+
89
+ //#endregion
90
+ export { setWorkspaceRoot as a, resolveSpawnCwd as i, getWorkspaceRoot as n, isProvisionAllowed as r, getProvisionRoot as t };
91
+ //# sourceMappingURL=workspaceConfig-B9VX8tVO.js.map
package/lab/ui/index.html CHANGED
@@ -550,7 +550,10 @@
550
550
  position: fixed;
551
551
  inset: 0;
552
552
  z-index: 30;
553
- background: rgba(2, 6, 12, 0.6);
553
+ /* Light scrim only: the omnibox live-previews the highlighted agent in the
554
+ terminal behind it, so the backdrop must stay see-through. The omnibox's
555
+ own panel bg + shadow keep it legible against the brighter terminal. */
556
+ background: rgba(2, 6, 12, 0.32);
554
557
  display: flex;
555
558
  align-items: flex-start;
556
559
  justify-content: center;
@@ -1490,8 +1493,8 @@
1490
1493
  <div class="omni-results" id="omni-results"></div>
1491
1494
  <div class="omni-foot">
1492
1495
  <span
1493
- ><b>↑↓</b> move &nbsp; <b>⏎</b> open &nbsp; <b>⌘⏎</b> spawn here &nbsp;
1494
- <b>esc</b> close</span
1496
+ ><b>↑↓</b> preview &nbsp; <b>⏎</b> open &nbsp; <b>⌘⏎</b> spawn here &nbsp;
1497
+ <b>esc</b> cancel</span
1495
1498
  >
1496
1499
  </div>
1497
1500
  </div>
@@ -3326,7 +3329,12 @@
3326
3329
  document.title = docTitle(name, e && e.status); // pure formatter (tested)
3327
3330
  }
3328
3331
 
3329
- function select(keyOrPid) {
3332
+ // opts.preview — a transient "peek" used by the Cmd+K omnibox to swap the
3333
+ // background terminal to the highlighted candidate WITHOUT committing: it
3334
+ // renders + live-tails the agent exactly like a real open, but skips the
3335
+ // persistence (localStorage/URL hash) and skips stealing keyboard focus from
3336
+ // the omnibox input. Esc later restores the original via a full select().
3337
+ function select(keyOrPid, opts = {}) {
3330
3338
  const e =
3331
3339
  entries.find((x) => x._key === keyOrPid) ||
3332
3340
  entries.find((x) => String(x.pid) === String(keyOrPid));
@@ -3343,19 +3351,22 @@
3343
3351
  renderPeerSelections(); // drop any prior agent's peer-selection overlay now
3344
3352
  sendPresence(); // announce we're now watching this agent (host TTLs the prior one)
3345
3353
  paintHeaderBadge();
3346
- // Remember the selection so a refresh re-opens this agent (see boot/autoPid).
3347
- try {
3348
- localStorage.setItem("ay.sel", sel);
3349
- } catch {}
3350
- // Reflect it in the URL hash as #room:pid. The fragment is never sent in
3351
- // any request, so unlike the old ?pid= query that landed in the server's
3352
- // access logs — the agent id stays client-side, while still surviving a
3353
- // refresh and being copy-pasteable as a deep link.
3354
- try {
3355
- const want = "#" + e._room + ":" + e.pid;
3356
- if (location.hash !== want)
3357
- history.replaceState(null, document.title, location.pathname + want);
3358
- } catch {}
3354
+ // Persist the selection (refresh re-opens this agent) and reflect it in the
3355
+ // URL hash as #room:pid — but ONLY for a real open. A preview is transient,
3356
+ // so it must not clobber the remembered selection or the deep-link hash;
3357
+ // those still point at the agent we'll restore to on Esc. The fragment is
3358
+ // never sent in any request, so the agent id stays client-side while
3359
+ // surviving a refresh and being copy-pasteable as a deep link.
3360
+ if (!opts.preview) {
3361
+ try {
3362
+ localStorage.setItem("ay.sel", sel);
3363
+ } catch {}
3364
+ try {
3365
+ const want = "#" + e._room + ":" + e.pid;
3366
+ if (location.hash !== want)
3367
+ history.replaceState(null, document.title, location.pathname + want);
3368
+ } catch {}
3369
+ }
3359
3370
  // pid + tx are how we talk to the agent's own host; sel (composite) is
3360
3371
  // only for UI identity/highlight, since pids can collide across rooms.
3361
3372
  const pid = e.pid;
@@ -3427,7 +3438,9 @@
3427
3438
  // On a phone, auto-focusing yanks up the soft keyboard the moment you open
3428
3439
  // an agent — even when you only meant to read the tail. Skip it there; a tap
3429
3440
  // on the terminal still focuses to type. Desktop keeps instant keyboard input.
3430
- if (window.innerWidth > 720) term.focus();
3441
+ // During a preview the omnibox input owns the keyboard (so ↑↓ keep moving the
3442
+ // selection), so never pull focus into the terminal.
3443
+ if (!opts.preview && window.innerWidth > 720) term.focus();
3431
3444
  // An agent can rename itself by emitting an OSC 0/2 title sequence
3432
3445
  // (\x1b]2;my-name\x07); xterm parses it out of the raw PTY stream we already
3433
3446
  // feed it, so we just surface the latest title as the header name. Falls
@@ -3537,7 +3550,12 @@
3537
3550
  const close = tx.subscribe(
3538
3551
  "/api/tail/" + encodeURIComponent(pid) + "?raw=1",
3539
3552
  (raw) => {
3540
- if (term) {
3553
+ // Guard on selKey: a late event from a just-closed subscription (the
3554
+ // close is async, so one buffered message can still arrive) must NOT
3555
+ // be written into the NEW terminal — otherwise the previous agent's
3556
+ // raw bleeds into the one we just switched to. Preview makes switches
3557
+ // frequent, so this race is no longer theoretical.
3558
+ if (term && sel === selKey) {
3541
3559
  term.write(raw);
3542
3560
  perfNote("out", raw?.length ?? 0);
3543
3561
  }
@@ -3689,32 +3707,93 @@
3689
3707
  let omniIdx = 0;
3690
3708
  let omniTailTimer = null;
3691
3709
  let omniTailSeq = 0;
3710
+ // Live-preview state. While the omnibox is open it swaps the BACKGROUND
3711
+ // terminal to whatever candidate is highlighted, so you can read it through
3712
+ // the (lightened) backdrop. omniReturnSel is the agent that was open when the
3713
+ // omnibox launched — Esc/cancel restores it, commit (Enter/click) keeps the
3714
+ // previewed one. omniWasDetail remembers the mobile list/detail pane so a
3715
+ // cancel lands back where you were. omniPreviewKey dedupes redundant swaps;
3716
+ // omniPreviewTimer debounces them (rebuilding xterm + the tail SSE on every
3717
+ // arrow keypress would thrash the host).
3718
+ let omniReturnSel = null;
3719
+ let omniWasDetail = false;
3720
+ let omniPreviewing = false;
3721
+ let omniPreviewKey = null;
3722
+ let omniPreviewTimer = null;
3692
3723
  const omniOpen = () => $("omni").style.display !== "none";
3693
3724
 
3694
3725
  function openOmni() {
3726
+ omniReturnSel = sel;
3727
+ omniWasDetail = document.querySelector(".app").classList.contains("show-detail");
3728
+ omniPreviewing = false;
3729
+ omniPreviewKey = sel; // start deduped against the already-open agent
3695
3730
  $("omni").style.display = "flex";
3696
3731
  const inp = $("omni-input");
3697
3732
  inp.value = "";
3698
3733
  inp.focus();
3699
- runOmni();
3700
- }
3701
- function closeOmni() {
3734
+ runOmni(); // no preview yet — the bg stays on the original until you move/type
3735
+ }
3736
+ // restore=true (default — Esc / backdrop click / Cmd+K toggle) reverts the
3737
+ // terminal to the agent that was open before the omnibox, via a full select()
3738
+ // so the original is re-persisted, and undoes any mobile pane flip the preview
3739
+ // caused. restore=false (commit) leaves the previewed agent in place for the
3740
+ // caller (omniActivate) to finalize with its own select().
3741
+ function closeOmni(restore = true) {
3702
3742
  if (omniTailTimer) clearTimeout(omniTailTimer);
3743
+ if (omniPreviewTimer) clearTimeout(omniPreviewTimer);
3744
+ omniPreviewTimer = null;
3703
3745
  omniTailSeq++; // cancel any in-flight tail search
3704
3746
  $("omni").style.display = "none";
3747
+ if (restore && omniPreviewing) {
3748
+ // Re-open + re-persist the original (only if we actually moved away from
3749
+ // it — when previewing the original itself, sel already matches and the
3750
+ // saved selection was never clobbered, so a rebuild would just flicker).
3751
+ if (omniReturnSel && sel !== omniReturnSel) select(omniReturnSel);
3752
+ // Either way restore the mobile list/detail state the preview flipped.
3753
+ document.querySelector(".app").classList.toggle("show-detail", omniWasDetail);
3754
+ }
3755
+ omniPreviewing = false;
3705
3756
  }
3706
3757
 
3707
- // The agent the spawn action is anchored to. PINNED to the agent currently
3708
- // open in the console (its cwd/cli), so the spawn target stays put as you
3709
- // arrow through search results. Falls back to the top result when nothing is
3710
- // selected yet.
3758
+ // The agent the spawn action is anchored to. PINNED to the agent that was open
3759
+ // when the omnibox launched (omniReturnSel) NOT the live `sel`, which now
3760
+ // moves as you arrow-preview candidates so the spawn cwd/cli stays put as you
3761
+ // browse. Falls back to the top result when nothing was open.
3711
3762
  function omniAnchor() {
3712
- const cur = sel ? entries.find((x) => x._key === sel) : null;
3763
+ const cur = omniReturnSel ? entries.find((x) => x._key === omniReturnSel) : null;
3713
3764
  if (cur) return cur;
3714
3765
  const first = omniRows.find((r) => r.kind === "agent");
3715
3766
  return first ? first.entry : null;
3716
3767
  }
3717
3768
 
3769
+ // Which agent the highlighted candidate should preview in the background: an
3770
+ // agent row → that agent; the spawn row / an empty list → the launch-time
3771
+ // agent (so the backdrop shows the spawn target's cwd).
3772
+ function omniPreviewTarget() {
3773
+ const row = omniRows[omniIdx];
3774
+ if (row && row.kind === "agent") return row.entry._key;
3775
+ return omniReturnSel;
3776
+ }
3777
+ // Swap the background terminal to the highlighted candidate. Debounced so a
3778
+ // held arrow key doesn't rebuild xterm + reconnect the tail on every repeat,
3779
+ // and deduped by key so re-renders (filtering, tail-search inserts) don't
3780
+ // re-peek the agent already shown. Called only on explicit user moves (arrow /
3781
+ // type), never on open, so the bg stays on the original until you act.
3782
+ function omniPreview() {
3783
+ const key = omniPreviewTarget();
3784
+ if (key === omniPreviewKey) return; // already targeting this agent
3785
+ omniPreviewKey = key;
3786
+ if (omniPreviewTimer) clearTimeout(omniPreviewTimer);
3787
+ omniPreviewTimer = setTimeout(() => {
3788
+ omniPreviewTimer = null;
3789
+ if (!omniOpen()) return; // closed during the debounce window
3790
+ if (!key || key === sel) return; // nothing to peek / already showing it
3791
+ omniPreviewing = true;
3792
+ select(key, { preview: true });
3793
+ $("omni-input").focus(); // select() must not steal the keyboard
3794
+ }, 120);
3795
+ }
3796
+
3718
3797
  function renderOmni() {
3719
3798
  const q = $("omni-input").value.trim();
3720
3799
  if (omniIdx >= omniRows.length) omniIdx = Math.max(0, omniRows.length - 1);
@@ -3803,8 +3882,12 @@
3803
3882
  : esc(raw);
3804
3883
  const at = omniRows.findIndex((r) => r.kind === "spawn");
3805
3884
  const row = { kind: "agent", entry: e, snippet };
3806
- if (at >= 0) omniRows.splice(at, 0, row);
3807
- else omniRows.push(row);
3885
+ if (at >= 0) {
3886
+ omniRows.splice(at, 0, row);
3887
+ // Keep the highlight (and thus the preview) anchored to the SAME row:
3888
+ // an insert at/above omniIdx shifts our row down one, so follow it.
3889
+ if (at <= omniIdx) omniIdx++;
3890
+ } else omniRows.push(row);
3808
3891
  }),
3809
3892
  );
3810
3893
  if (seq === omniTailSeq) renderOmni();
@@ -3814,13 +3897,14 @@
3814
3897
  if (!omniRows.length) return;
3815
3898
  omniIdx = (omniIdx + d + omniRows.length) % omniRows.length;
3816
3899
  renderOmni();
3900
+ omniPreview();
3817
3901
  }
3818
3902
  async function omniActivate(forceSpawn) {
3819
3903
  const q = $("omni-input").value.trim();
3820
3904
  const row = omniRows[omniIdx];
3821
3905
  if (forceSpawn || (row && row.kind === "spawn")) {
3822
3906
  const anchor = omniAnchor();
3823
- closeOmni();
3907
+ closeOmni(false); // committing — keep the bg as-is, spawnAndSelect takes over
3824
3908
  await spawnAndSelect(
3825
3909
  { cli: anchor?.cli || "claude", cwd: anchor?.cwd || undefined, prompt: q || undefined },
3826
3910
  anchor?._room,
@@ -3828,12 +3912,17 @@
3828
3912
  return;
3829
3913
  }
3830
3914
  if (row && row.kind === "agent") {
3831
- closeOmni();
3915
+ closeOmni(false); // committing the previewed agent — don't restore the original
3832
3916
  select(row.entry._key);
3833
3917
  }
3834
3918
  }
3835
3919
 
3836
- $("omni-input").addEventListener("input", runOmni);
3920
+ // Typing re-filters AND re-previews the new top match (user-initiated, so a
3921
+ // bg swap here is expected); the open's own runOmni() call is silent.
3922
+ $("omni-input").addEventListener("input", () => {
3923
+ runOmni();
3924
+ omniPreview();
3925
+ });
3837
3926
  $("omni-results").addEventListener("click", (ev) => {
3838
3927
  const r = ev.target.closest(".omni-row");
3839
3928
  if (!r) return;
@@ -4245,6 +4334,7 @@
4245
4334
  const before = new Set(entries.filter((e) => e._room === target.id).map((e) => e.pid));
4246
4335
  const res = await target.tx.post("/api/spawn", {
4247
4336
  cli: spec.cli || "claude",
4337
+ from: spec.from || undefined,
4248
4338
  cwd: spec.cwd || undefined,
4249
4339
  prompt: spec.prompt || undefined,
4250
4340
  });
@@ -4296,7 +4386,8 @@
4296
4386
  $("newform").innerHTML = `<div class="lcard">
4297
4387
  <div class="ltitle">New agent · ${esc(where)}</div>
4298
4388
  <div class="nfield"><label>CLI</label><input id="nf-cli" value="claude" spellcheck="false" autocapitalize="off" /></div>
4299
- <div class="nfield"><label>Working dir</label><input id="nf-cwd" value="${esc(cwd)}" placeholder="(host default)" spellcheck="false" autocapitalize="off" /></div>
4389
+ <div class="nfield"><label>Spawn from — optional</label><input id="nf-from" placeholder="github URL / owner/repo@branch — provisions a worktree" spellcheck="false" autocapitalize="off" /></div>
4390
+ <div class="nfield"><label>Working dir</label><input id="nf-cwd" value="${esc(cwd)}" placeholder="(host default — ignored when Spawn from is set)" spellcheck="false" autocapitalize="off" /></div>
4300
4391
  <div class="nfield"><label>Prompt — optional</label><textarea id="nf-prompt" rows="3" placeholder="initial message to send the agent…"></textarea></div>
4301
4392
  <div class="lrow">
4302
4393
  <button class="lfleet" id="nf-go">▷ Launch</button>
@@ -4312,6 +4403,7 @@
4312
4403
  if (!go || go.disabled) return;
4313
4404
  const spec = {
4314
4405
  cli: ($("nf-cli").value || "claude").trim(),
4406
+ from: $("nf-from")?.value.trim() || "",
4315
4407
  cwd: $("nf-cwd").value.trim(),
4316
4408
  prompt: $("nf-prompt").value,
4317
4409
  };
@@ -4320,7 +4412,7 @@
4320
4412
  // target fleet. A 2nd `claude -c` in the same repo fights over the same
4321
4413
  // session/files and usually exits on startup — which looks like "the new
4322
4414
  // agent never appears". Let the user pick a different dir or proceed.
4323
- if (spec.cwd) {
4415
+ if (spec.cwd && !spec.from) {
4324
4416
  await loadList();
4325
4417
  const norm = (p) => (p || "").replace(/\/+$/, "");
4326
4418
  const busy = entries.some(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-yes",
3
- "version": "1.147.0",
3
+ "version": "1.149.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
package/ts/serve.ts CHANGED
@@ -21,9 +21,33 @@ import { updateGlobalPidStatus } from "./globalPidIndex.ts";
21
21
  import { pgidForWrapper } from "./reaper.ts";
22
22
  import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
23
23
  import { getInstalledPackage } from "./versionChecker.ts";
24
+ import { getProvisionRoot, isProvisionAllowed, resolveSpawnCwd } from "./workspaceConfig.ts";
24
25
 
25
26
  const DEFAULT_PORT = 7432;
26
27
 
28
+ /**
29
+ * Normalize a user-supplied GitHub-ish source into the standard
30
+ * `<owner>/<repo>/tree/<branch>` path that codehost/provision's `parseSpec`
31
+ * understands. Interim fallback used only when the linked codehost build
32
+ * predates `parseSource` (the canonical normalizer in the standard):
33
+ * https://github.com/o/r/tree/b · github.com/o/r/tree/b · o/r/tree/b
34
+ * o/r@branch · o/r (→ default branch main)
35
+ */
36
+ function normalizeGithubSource(s: string): string {
37
+ let v = s
38
+ .trim()
39
+ .replace(/^https?:\/\//, "")
40
+ .replace(/^github\.com\//, "");
41
+ v = v
42
+ .replace(/[?#].*$/, "")
43
+ .replace(/\.git$/, "")
44
+ .replace(/^\/+|\/+$/g, "");
45
+ const at = v.match(/^([^/]+)\/([^/@]+)@(.+)$/);
46
+ if (at) return `${at[1]}/${at[2]}/tree/${at[3]}`;
47
+ if (/^[^/]+\/[^/]+$/.test(v)) return `${v}/tree/main`;
48
+ return v;
49
+ }
50
+
27
51
  function agentYesHome(): string {
28
52
  return process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
29
53
  }
@@ -1448,9 +1472,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
1448
1472
  return Response.json(live);
1449
1473
  }
1450
1474
 
1451
- // POST /api/spawn body {cli, cwd, prompt} — launch a new agent
1475
+ // POST /api/spawn body {cli, cwd?, from?, prompt?} — launch a new agent.
1476
+ // `from` (a GitHub URL / owner/repo@branch / owner/repo/tree/branch) is
1477
+ // resolved to a ready worktree via codehost/provision (the shared workspace
1478
+ // standard); otherwise `cwd` is resolved against the workspace root and
1479
+ // created if missing (Layer-0 plain-dir provisioning — no more ENOENT 500).
1452
1480
  if (req.method === "POST" && p === "/api/spawn") {
1453
- let body: { cli?: string; cwd?: string; prompt?: string };
1481
+ let body: { cli?: string; cwd?: string; from?: string; prompt?: string };
1454
1482
  try {
1455
1483
  body = (await req.json()) as typeof body;
1456
1484
  } catch {
@@ -1459,10 +1487,73 @@ export async function cmdServe(rest: string[]): Promise<number> {
1459
1487
  const cli = String(body.cli ?? "claude");
1460
1488
  if (!SUPPORTED_CLIS.includes(cli as never))
1461
1489
  return new Response(`unsupported cli: ${cli}`, { status: 400 });
1462
- const cwd = typeof body.cwd === "string" && body.cwd ? body.cwd : process.cwd();
1463
1490
  const prompt = String(body.prompt ?? "");
1491
+
1492
+ // Resolve the working directory. A `from` source is provisioned (clone /
1493
+ // worktree) through codehost/provision; a plain `cwd` is resolved to the
1494
+ // workspace root and mkdir-p'd so a missing dir no longer ENOENTs.
1495
+ let cwd: string;
1496
+ let provisioned: { action: string; folder: string } | null = null;
1497
+ const from = typeof body.from === "string" ? body.from.trim() : "";
1498
+ if (from) {
1499
+ type Spec = { owner: string; repo: string; branch: string };
1500
+ let prov: {
1501
+ parseSource?: (s: string) => Spec | null;
1502
+ parseSpec: (s: string) => Spec | null;
1503
+ provision: (
1504
+ spec: Spec,
1505
+ opts?: { wsRoot?: string },
1506
+ ) => Promise<{ ok: boolean; folder: string; action: string; error?: string }>;
1507
+ };
1508
+ try {
1509
+ prov = (await import("codehost/provision")) as typeof prov;
1510
+ } catch (e) {
1511
+ return new Response(
1512
+ `spawn-from needs the 'codehost' package (codehost/provision) — install it ` +
1513
+ `(npm i -g codehost) or 'bun link' it for local dev: ${(e as Error).message}`,
1514
+ { status: 501 },
1515
+ );
1516
+ }
1517
+ // Malformed input (e.g. bad %-encoding) must surface as 400, not a 500.
1518
+ let spec: Spec | null;
1519
+ try {
1520
+ spec =
1521
+ typeof prov.parseSource === "function"
1522
+ ? prov.parseSource(from)
1523
+ : prov.parseSpec(normalizeGithubSource(from));
1524
+ } catch {
1525
+ spec = null;
1526
+ }
1527
+ if (!spec) return new Response(`unrecognized spawn source: ${from}`, { status: 400 });
1528
+ // Provisioning clones the repo and runs its setup script (dependency
1529
+ // installs + package lifecycle hooks = code execution on the host), so
1530
+ // gate it behind an owner/repo allowlist — empty allowlist = deny all.
1531
+ if (!isProvisionAllowed(spec.owner, spec.repo))
1532
+ return new Response(
1533
+ `provisioning '${spec.owner}/${spec.repo}' is not allowed — add the owner to ` +
1534
+ `provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all)`,
1535
+ { status: 403 },
1536
+ );
1537
+ let result: { ok: boolean; folder: string; action: string; error?: string };
1538
+ try {
1539
+ const wsRoot = getProvisionRoot();
1540
+ result = await prov.provision(spec, wsRoot ? { wsRoot } : undefined);
1541
+ } catch (e) {
1542
+ return new Response(`provision failed: ${(e as Error).message}`, { status: 502 });
1543
+ }
1544
+ if (!result?.ok) return new Response(result?.error ?? "provision failed", { status: 502 });
1545
+ cwd = result.folder;
1546
+ provisioned = { action: result.action, folder: result.folder };
1547
+ } else {
1548
+ cwd = resolveSpawnCwd(body.cwd);
1549
+ try {
1550
+ await mkdir(cwd, { recursive: true });
1551
+ } catch (e) {
1552
+ return new Response(`cannot create cwd ${cwd}: ${(e as Error).message}`, { status: 500 });
1553
+ }
1554
+ }
1464
1555
  process.stderr.write(
1465
- `→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`,
1556
+ `→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd}${provisioned ? `, ${provisioned.action} from ${from}` : ""})\n`,
1466
1557
  );
1467
1558
  // Resolve `ay` to an absolute command. The detached daemon (oxmgr/launchd/
1468
1559
  // pm2) usually has a PATH WITHOUT ~/.bun/bin, so a bare "ay" fails with
@@ -1483,7 +1574,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
1483
1574
  stderr: "ignore",
1484
1575
  });
1485
1576
  child.unref();
1486
- return Response.json({ ok: true, pid: child.pid, cli, cwd });
1577
+ return Response.json({
1578
+ ok: true,
1579
+ pid: child.pid,
1580
+ cli,
1581
+ cwd,
1582
+ ...(provisioned ? { provisioned } : {}),
1583
+ });
1487
1584
  } catch (e) {
1488
1585
  return new Response((e as Error).message, { status: 500 });
1489
1586
  }
@@ -1,10 +1,13 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
- import { mkdtempSync, rmSync } from "fs";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { homedir, tmpdir } from "os";
4
4
  import path from "path";
5
5
  import {
6
6
  expandTilde,
7
+ getProvisionAllowlist,
8
+ getProvisionRoot,
7
9
  getWorkspaceRoot,
10
+ isProvisionAllowed,
8
11
  resolveSpawnCwd,
9
12
  setWorkspaceRoot,
10
13
  } from "./workspaceConfig.ts";
@@ -20,6 +23,8 @@ describe("workspaceConfig", () => {
20
23
  afterEach(() => {
21
24
  if (original === undefined) delete process.env.AGENT_YES_HOME;
22
25
  else process.env.AGENT_YES_HOME = original;
26
+ delete process.env.CODEHOST_WS_ROOT;
27
+ delete process.env.CODEHOST_PROVISION_ALLOWLIST;
23
28
  rmSync(tmp, { recursive: true, force: true });
24
29
  });
25
30
 
@@ -71,4 +76,70 @@ describe("workspaceConfig", () => {
71
76
  expect(resolveSpawnCwd("a/b")).toBe(path.resolve("a/b"));
72
77
  });
73
78
  });
79
+
80
+ const writeConfig = (c: Record<string, unknown>) =>
81
+ writeFileSync(path.join(tmp, "config.json"), JSON.stringify(c));
82
+
83
+ describe("getProvisionRoot", () => {
84
+ it("is undefined when neither env nor config is set", () => {
85
+ expect(getProvisionRoot()).toBeUndefined();
86
+ });
87
+
88
+ it("returns the configured provisionRoot, resolved", () => {
89
+ writeConfig({ provisionRoot: "/code" });
90
+ expect(getProvisionRoot()).toBe(path.resolve("/code"));
91
+ });
92
+
93
+ it("env CODEHOST_WS_ROOT overrides the config and expands ~", () => {
94
+ writeConfig({ provisionRoot: "/code" });
95
+ process.env.CODEHOST_WS_ROOT = "~/ws";
96
+ expect(getProvisionRoot()).toBe(path.join(homedir(), "ws"));
97
+ });
98
+
99
+ it("ignores a blank configured value", () => {
100
+ writeConfig({ provisionRoot: " " });
101
+ expect(getProvisionRoot()).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe("getProvisionAllowlist", () => {
106
+ it("is empty when unset", () => {
107
+ expect(getProvisionAllowlist()).toEqual([]);
108
+ });
109
+
110
+ it("reads and normalizes the configured list (trim/lowercase/drop empties)", () => {
111
+ writeConfig({ provisionAllowlist: [" Snomiao ", "Acme/Repo", ""] });
112
+ expect(getProvisionAllowlist()).toEqual(["snomiao", "acme/repo"]);
113
+ });
114
+
115
+ it("env CODEHOST_PROVISION_ALLOWLIST (comma-separated) overrides config", () => {
116
+ writeConfig({ provisionAllowlist: ["snomiao"] });
117
+ process.env.CODEHOST_PROVISION_ALLOWLIST = "Foo, bar/baz ,";
118
+ expect(getProvisionAllowlist()).toEqual(["foo", "bar/baz"]);
119
+ });
120
+ });
121
+
122
+ describe("isProvisionAllowed", () => {
123
+ it("denies everything when the allowlist is empty (secure default)", () => {
124
+ expect(isProvisionAllowed("snomiao", "agent-yes")).toBe(false);
125
+ });
126
+
127
+ it("'*' allows any owner/repo", () => {
128
+ writeConfig({ provisionAllowlist: ["*"] });
129
+ expect(isProvisionAllowed("anyone", "anything")).toBe(true);
130
+ });
131
+
132
+ it("matches by owner case-insensitively and rejects others", () => {
133
+ writeConfig({ provisionAllowlist: ["snomiao"] });
134
+ expect(isProvisionAllowed("SNOMIAO", "x")).toBe(true);
135
+ expect(isProvisionAllowed("evil", "x")).toBe(false);
136
+ });
137
+
138
+ it("matches an exact owner/repo and an owner/* glob", () => {
139
+ writeConfig({ provisionAllowlist: ["acme/widget", "org/*"] });
140
+ expect(isProvisionAllowed("acme", "widget")).toBe(true);
141
+ expect(isProvisionAllowed("acme", "other")).toBe(false);
142
+ expect(isProvisionAllowed("org", "anything")).toBe(true);
143
+ });
144
+ });
74
145
  });
@@ -15,6 +15,10 @@ import { agentYesHome } from "./agentYesHome.ts";
15
15
 
16
16
  interface Config {
17
17
  workspace?: string;
18
+ /** Root for `from`-provisioned worktrees (`<root>/<owner>/<repo>/tree/<branch>`). */
19
+ provisionRoot?: string;
20
+ /** Owners/repos permitted for `from`-provisioning; empty = deny all. */
21
+ provisionAllowlist?: string[];
18
22
  }
19
23
 
20
24
  function configPath(): string {
@@ -43,6 +47,45 @@ export function getWorkspaceRoot(): string {
43
47
  return w && w.trim() ? w : homedir();
44
48
  }
45
49
 
50
+ /**
51
+ * Root for `from`-provisioned worktrees, handed to codehost/provision so they
52
+ * land in `<root>/<owner>/<repo>/tree/<branch>`. Resolution order: env
53
+ * `CODEHOST_WS_ROOT` wins (ops override), then the configured `provisionRoot`,
54
+ * else undefined — letting codehost/provision fall back to its own `~/ws`
55
+ * default. Kept separate from `workspace` (the plain-cwd default), which may be a
56
+ * specific project dir rather than a root.
57
+ */
58
+ export function getProvisionRoot(): string | undefined {
59
+ const env = process.env.CODEHOST_WS_ROOT?.trim();
60
+ if (env) return path.resolve(expandTilde(env));
61
+ const r = readConfig().provisionRoot;
62
+ return r && r.trim() ? path.resolve(expandTilde(r)) : undefined;
63
+ }
64
+
65
+ /**
66
+ * Owner/repo allowlist for `from`-provisioning. Provisioning clones a repo and
67
+ * runs its setup script (dependency installs + package lifecycle hooks = code
68
+ * execution on the host), so an **empty allowlist means DENY ALL** — a secure
69
+ * default the host opts out of by listing owners it trusts. Entries match
70
+ * `<owner>` (any repo of that owner), `<owner>/<repo>` (exact), or `*` (allow
71
+ * all — an explicit opt-in to the wide-open behavior). Env
72
+ * `CODEHOST_PROVISION_ALLOWLIST` (comma-separated) overrides the config.
73
+ */
74
+ export function getProvisionAllowlist(): string[] {
75
+ const env = process.env.CODEHOST_PROVISION_ALLOWLIST?.trim();
76
+ const raw = env ? env.split(",") : (readConfig().provisionAllowlist ?? []);
77
+ return raw.map((s) => s.trim().toLowerCase()).filter(Boolean);
78
+ }
79
+
80
+ /** Whether `<owner>/<repo>` may be `from`-provisioned, per {@link getProvisionAllowlist}. */
81
+ export function isProvisionAllowed(owner: string, repo: string): boolean {
82
+ const list = getProvisionAllowlist();
83
+ if (list.includes("*")) return true;
84
+ const o = owner.toLowerCase();
85
+ const full = `${owner}/${repo}`.toLowerCase();
86
+ return list.some((e) => e.replace(/\/\*$/, "") === o || e === full);
87
+ }
88
+
46
89
  /** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
47
90
  export function setWorkspaceRoot(dir: string): string {
48
91
  const abs = path.resolve(expandTilde(dir));
@@ -1,8 +0,0 @@
1
- import "./ts-DKH7E-F6.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-BGsjdYf6.js";
4
- import "./pidStore-fqXqTKkh.js";
5
- import "./globalPidIndex-DlmmJlO8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BFZPtEsl.js";
7
-
8
- export { SUPPORTED_CLIS };
@@ -1,56 +0,0 @@
1
- import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
2
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { homedir } from "os";
4
- import path from "path";
5
-
6
- //#region ts/workspaceConfig.ts
7
- function configPath() {
8
- return path.join(agentYesHome(), "config.json");
9
- }
10
- function readConfig() {
11
- try {
12
- return JSON.parse(readFileSync(configPath(), "utf-8"));
13
- } catch {
14
- return {};
15
- }
16
- }
17
- /** Expand a leading `~` (`~` or `~/x`) to an absolute home-based path. */
18
- function expandTilde(p) {
19
- const s = p.trim();
20
- if (s === "~") return homedir();
21
- if (s.startsWith("~/") || s.startsWith("~\\")) return path.join(homedir(), s.slice(2));
22
- return s;
23
- }
24
- /** The configured workspace root (absolute), or the home dir if unset. */
25
- function getWorkspaceRoot() {
26
- const w = readConfig().workspace;
27
- return w && w.trim() ? w : homedir();
28
- }
29
- /** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
30
- function setWorkspaceRoot(dir) {
31
- const abs = path.resolve(expandTilde(dir));
32
- const cfg = readConfig();
33
- cfg.workspace = abs;
34
- mkdirSync(agentYesHome(), { recursive: true });
35
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
36
- return abs;
37
- }
38
- /**
39
- * Resolve a user-supplied spawn location to an absolute cwd:
40
- * - empty → the workspace root
41
- * - a bare name → `<workspace>/<name>` (so "myproject" lands under the root)
42
- * - `~`-prefixed → home-based absolute
43
- * - anything with a path separator → resolved as-is
44
- */
45
- function resolveSpawnCwd(input) {
46
- const root = getWorkspaceRoot();
47
- const v = (input ?? "").trim();
48
- if (!v) return root;
49
- if (v.startsWith("~")) return path.resolve(expandTilde(v));
50
- if (v.includes("/") || v.includes("\\") || path.isAbsolute(v)) return path.resolve(v);
51
- return path.join(root, v);
52
- }
53
-
54
- //#endregion
55
- export { resolveSpawnCwd as n, setWorkspaceRoot as r, getWorkspaceRoot as t };
56
- //# sourceMappingURL=workspaceConfig-B3ylOZAO.js.map