claude-yes 1.155.1 → 1.157.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-CKvvDjEK.js";
2
+ import "./logger-CDIsZ-Pp.js";
3
+ import "./versionChecker-IAATi04_.js";
4
+ import "./pidStore-fqXqTKkh.js";
5
+ import "./globalPidIndex-DlmmJlO8.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-tIYMeICi.js";
7
+
8
+ export { SUPPORTED_CLIS };
@@ -1,8 +1,8 @@
1
- import { t as CLIS_CONFIG } from "./ts-SEpCdKU5.js";
1
+ import { t as CLIS_CONFIG } from "./ts-CKvvDjEK.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-Nwu058MP.js.map
8
+ //# sourceMappingURL=SUPPORTED_CLIS-tIYMeICi.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-B9y2MQF2.js";
4
+ import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-IAATi04_.js";
5
5
  import { argv } from "process";
6
6
  import { execFileSync, spawn } from "child_process";
7
7
  import ms from "ms";
@@ -481,7 +481,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
481
481
  const rawArg = process.argv[2];
482
482
  const managerCommands = !invokedCliName(process.argv);
483
483
  const isHelpFlag = rawArg === "-h" || rawArg === "--help";
484
- const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DcJsS7Uu.js");
484
+ const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-B1EoS0xQ.js");
485
485
  if (isHelpFlag && process.argv.length === 3) {
486
486
  cmdHelp(managerCommands);
487
487
  process.exit(0);
@@ -514,7 +514,7 @@ if (config.useRust) {
514
514
  }
515
515
  }
516
516
  if (rustBinary) {
517
- const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-BJkbB_vi.js");
517
+ const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-DLSsJ845.js");
518
518
  const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
519
519
  if (config.verbose) {
520
520
  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-SEpCdKU5.js";
1
+ import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-CKvvDjEK.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-B9y2MQF2.js";
3
+ import "./versionChecker-IAATi04_.js";
4
4
  import "./pidStore-fqXqTKkh.js";
5
5
  import "./globalPidIndex-DlmmJlO8.js";
6
6
 
@@ -1,10 +1,10 @@
1
- import "./ts-SEpCdKU5.js";
1
+ import "./ts-CKvvDjEK.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-B9y2MQF2.js";
3
+ import "./versionChecker-IAATi04_.js";
4
4
  import "./pidStore-fqXqTKkh.js";
5
5
  import "./globalPidIndex-DlmmJlO8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Nwu058MP.js";
7
- import { i as resolveSpawnCwd } from "./workspaceConfig-DT3cCBZK.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-tIYMeICi.js";
7
+ import { o as resolveSpawnCwd } from "./workspaceConfig-B0Q9-q2B.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-CuPyOtGt.js.map
144
+ //# sourceMappingURL=schedule-43wkfctj.js.map
@@ -1,18 +1,18 @@
1
- import "./ts-SEpCdKU5.js";
1
+ import "./ts-CKvvDjEK.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import { r as getInstalledPackage } from "./versionChecker-B9y2MQF2.js";
3
+ import { r as getInstalledPackage } from "./versionChecker-IAATi04_.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-CEyhl0WH.js";
8
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Nwu058MP.js";
8
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-tIYMeICi.js";
9
9
  import "./e2e-Bfw7qL9O.js";
10
10
  import "./webrtcLink-CBZkZ-LT.js";
11
11
  import "./remotes-DBCvpp3B.js";
12
- import { i as resolveSpawnCwd, r as isProvisionAllowed, t as getProvisionRoot } from "./workspaceConfig-DT3cCBZK.js";
13
- import { E as writeToIpc, _ as readPtysize, b as resolveOne, c as extractTaskCounts, g as readNotes, i as controlCodeFromName, m as listRecords, o as deriveLiveStatus, v as renderRawLog, w as snapshotStatus } from "./subcommands-DSTQRbub.js";
12
+ import { a as isProvisionAllowed, i as hasSpawnHook, n as getSpawnHook, o as resolveSpawnCwd, t as getProvisionRoot } from "./workspaceConfig-B0Q9-q2B.js";
13
+ import { E as writeToIpc, _ as readPtysize, b as resolveOne, c as extractTaskCounts, g as readNotes, i as controlCodeFromName, m as listRecords, o as deriveLiveStatus, v as renderRawLog, w as snapshotStatus } from "./subcommands-Bkf8qkP8.js";
14
14
  import yargs from "yargs";
15
- import { mkdir, open, readFile, stat, writeFile } from "fs/promises";
15
+ import { mkdir, open, readFile, stat, unlink, writeFile } from "fs/promises";
16
16
  import { homedir, hostname, userInfo } from "os";
17
17
  import path from "path";
18
18
  import { execFileSync } from "node:child_process";
@@ -789,6 +789,7 @@ Options:
789
789
  return Response.json({ host: user ? `${user}@${host}` : host });
790
790
  }
791
791
  if (req.method === "GET" && p === "/api/version") return Response.json({ version: getInstalledPackage().version });
792
+ if (req.method === "GET" && p === "/api/spawn-config") return Response.json({ hasSpawnHook: hasSpawnHook() });
792
793
  if (req.method === "GET" && p === "/api/notes") {
793
794
  const notes = await readNotes();
794
795
  return Response.json(Object.fromEntries(notes));
@@ -1154,15 +1155,59 @@ Options:
1154
1155
  }
1155
1156
  process.stderr.write(`→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd}${provisioned ? `, ${provisioned.action}` : ""})\n`);
1156
1157
  const ayBin = Bun.which("ay") ?? process.argv[1];
1157
- const ayCmd = process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe") ? [ayBin] : [process.execPath, ayBin];
1158
+ const agentArgv = [
1159
+ ...process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe") ? [ayBin] : [process.execPath, ayBin],
1160
+ cli,
1161
+ ...prompt ? ["--", prompt] : []
1162
+ ];
1163
+ const agentEnv = freshAgentEnv();
1158
1164
  try {
1159
- const child = Bun.spawn([
1160
- ...ayCmd,
1161
- cli,
1162
- ...prompt ? ["--", prompt] : []
1163
- ], {
1165
+ const hook = getSpawnHook();
1166
+ if (hook) {
1167
+ const shell = process.env.AGENT_YES_SPAWN_SHELL?.trim() || "/bin/sh";
1168
+ const script = `set -e\n${hook}\nexec "$@"`;
1169
+ const errPath = path.join(agentYesHome(), `spawn-hook-${process.pid}-${performance.now().toString(36).replace(".", "")}.err`);
1170
+ const child = Bun.spawn([
1171
+ shell,
1172
+ "-c",
1173
+ script,
1174
+ "ay-spawn",
1175
+ ...agentArgv
1176
+ ], {
1177
+ cwd,
1178
+ env: {
1179
+ ...agentEnv,
1180
+ AGENT_YES_CWD: cwd,
1181
+ AGENT_YES_CLI: cli
1182
+ },
1183
+ stdin: "ignore",
1184
+ stdout: "ignore",
1185
+ stderr: Bun.file(errPath)
1186
+ });
1187
+ const windowMs = Number(process.env.AGENT_YES_SPAWN_HOOK_TIMEOUT_MS) || 5e3;
1188
+ const exitCode = await Promise.race([child.exited, new Promise((r) => setTimeout(() => r(null), windowMs))]);
1189
+ if (exitCode !== null && exitCode !== 0) {
1190
+ let detail = "";
1191
+ try {
1192
+ detail = (await Bun.file(errPath).text()).slice(0, 4096);
1193
+ } catch {}
1194
+ await unlink(errPath).catch(() => {});
1195
+ return new Response(`spawn hook failed (exit ${exitCode})${detail ? `:\n${detail.trimEnd()}` : ""}`, { status: 502 });
1196
+ }
1197
+ child.unref();
1198
+ setTimeout(() => void unlink(errPath).catch(() => {}), 6e4).unref?.();
1199
+ return Response.json({
1200
+ ok: true,
1201
+ pid: child.pid,
1202
+ cli,
1203
+ cwd,
1204
+ hook: true,
1205
+ ...provisioned ? { provisioned } : {}
1206
+ });
1207
+ }
1208
+ const child = Bun.spawn(agentArgv, {
1164
1209
  cwd,
1165
- env: freshAgentEnv(),
1210
+ env: agentEnv,
1166
1211
  stdin: "ignore",
1167
1212
  stdout: "ignore",
1168
1213
  stderr: "ignore"
@@ -1303,4 +1348,4 @@ Options:
1303
1348
 
1304
1349
  //#endregion
1305
1350
  export { cmdServe };
1306
- //# sourceMappingURL=serve-KupsUr6A.js.map
1351
+ //# sourceMappingURL=serve-MQwHVctu.js.map
@@ -1,4 +1,4 @@
1
- import { a as setWorkspaceRoot, n as getWorkspaceRoot } from "./workspaceConfig-DT3cCBZK.js";
1
+ import { r as getWorkspaceRoot, s as setWorkspaceRoot } from "./workspaceConfig-B0Q9-q2B.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-KupsUr6A.js");
35
+ const { cmdServe } = await import("./serve-MQwHVctu.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-DkN1MjXp.js.map
45
+ //# sourceMappingURL=setup-DcYBxFku.js.map
@@ -4,6 +4,6 @@ import "./configShared-CEyhl0WH.js";
4
4
  import "./e2e-Bfw7qL9O.js";
5
5
  import "./webrtcLink-CBZkZ-LT.js";
6
6
  import "./remotes-DBCvpp3B.js";
7
- import { C as runSubcommand, E as writeToIpc, S as resolveResumeArgs, T as stopTipForCli, _ as readPtysize, a as cursorAbs, b as resolveOne, 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 renderRawLog, w as snapshotStatus, x as resolveReadWindow, y as renderRawLogLines } from "./subcommands-DSTQRbub.js";
7
+ import { C as runSubcommand, E as writeToIpc, S as resolveResumeArgs, T as stopTipForCli, _ as readPtysize, a as cursorAbs, b as resolveOne, 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 renderRawLog, w as snapshotStatus, x as resolveReadWindow, y as renderRawLogLines } from "./subcommands-Bkf8qkP8.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-KupsUr6A.js");
557
+ const { cmdServe } = await import("./serve-MQwHVctu.js");
558
558
  return cmdServe(rest);
559
559
  }
560
560
  case "setup": {
561
- const { cmdSetup } = await import("./setup-DkN1MjXp.js");
561
+ const { cmdSetup } = await import("./setup-DcYBxFku.js");
562
562
  return cmdSetup(rest);
563
563
  }
564
564
  case "schedule": {
565
- const { cmdSchedule } = await import("./schedule-CuPyOtGt.js");
565
+ const { cmdSchedule } = await import("./schedule-43wkfctj.js");
566
566
  return cmdSchedule(rest);
567
567
  }
568
568
  case "remote": {
@@ -2710,4 +2710,4 @@ async function cmdResultSet(rest) {
2710
2710
 
2711
2711
  //#endregion
2712
2712
  export { runSubcommand as C, writeToIpc as E, resolveResumeArgs as S, stopTipForCli as T, readPtysize as _, cursorAbs as a, resolveOne 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, renderRawLog as v, snapshotStatus as w, resolveReadWindow as x, renderRawLogLines as y };
2713
- //# sourceMappingURL=subcommands-DSTQRbub.js.map
2713
+ //# sourceMappingURL=subcommands-Bkf8qkP8.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-B9y2MQF2.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-IAATi04_.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";
@@ -1802,4 +1802,4 @@ function sleep(ms) {
1802
1802
 
1803
1803
  //#endregion
1804
1804
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1805
- //# sourceMappingURL=ts-SEpCdKU5.js.map
1805
+ //# sourceMappingURL=ts-CKvvDjEK.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.155.1";
10
+ var version = "1.157.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-B9y2MQF2.js.map
218
+ //# sourceMappingURL=versionChecker-IAATi04_.js.map
@@ -1,5 +1,5 @@
1
1
  import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
2
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import path from "path";
5
5
 
@@ -61,6 +61,41 @@ function isProvisionAllowed(owner, repo) {
61
61
  const full = `${owner}/${repo}`.toLowerCase();
62
62
  return list.some((e) => e.replace(/\/\*$/, "") === o || e === full);
63
63
  }
64
+ /**
65
+ * A trusted, HOST-LOCAL shell hook run before each agent spawn. It is a POSIX
66
+ * `sh -c` script that prepares the environment (provisioning, env, cd) and that
67
+ * agent-yes runs as `sh -c "set -e\n<hook>\nexec \"$@\"" ay-spawn <agent argv…>`
68
+ * — so the real agent argv is passed as positional params and the prompt is
69
+ * never shell-parsed. `set -e` aborts the spawn if any hook step fails.
70
+ *
71
+ * This is arbitrary local code that runs on EVERY spawn, so it is deliberately
72
+ * NOT writable over the network. Set it on the machine by editing
73
+ * `~/.agent-yes/config.json` (`"spawnHook"`), or via env `AGENT_YES_SPAWN_HOOK`.
74
+ *
75
+ * Tampering guard (POSIX): a file-backed hook is ignored when the config file is
76
+ * a symlink, is not owned by us, or is group/world-writable — we refuse to run a
77
+ * hook from a file other users can swap or rewrite. The env override is trusted
78
+ * (it comes from the daemon's own environment). Returns null when unset/guarded.
79
+ */
80
+ function getSpawnHook() {
81
+ const env = process.env.AGENT_YES_SPAWN_HOOK;
82
+ if (env && env.trim()) return env;
83
+ const h = readConfig().spawnHook;
84
+ if (!h || !h.trim()) return null;
85
+ if (process.platform !== "win32") try {
86
+ if (lstatSync(configPath()).isSymbolicLink()) return null;
87
+ const st = statSync(configPath());
88
+ if ((st.mode & 18) !== 0) return null;
89
+ if (typeof process.getuid === "function" && st.uid !== process.getuid()) return null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ return h;
94
+ }
95
+ /** Whether a usable {@link getSpawnHook} is configured (for read-only disclosure). */
96
+ function hasSpawnHook() {
97
+ return getSpawnHook() !== null;
98
+ }
64
99
  /** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
65
100
  function setWorkspaceRoot(dir) {
66
101
  const abs = path.resolve(expandTilde(dir));
@@ -87,5 +122,5 @@ function resolveSpawnCwd(input) {
87
122
  }
88
123
 
89
124
  //#endregion
90
- export { setWorkspaceRoot as a, resolveSpawnCwd as i, getWorkspaceRoot as n, isProvisionAllowed as r, getProvisionRoot as t };
91
- //# sourceMappingURL=workspaceConfig-DT3cCBZK.js.map
125
+ export { isProvisionAllowed as a, hasSpawnHook as i, getSpawnHook as n, resolveSpawnCwd as o, getWorkspaceRoot as r, setWorkspaceRoot as s, getProvisionRoot as t };
126
+ //# sourceMappingURL=workspaceConfig-B0Q9-q2B.js.map
package/lab/ui/index.html CHANGED
@@ -832,6 +832,17 @@
832
832
  .dot.idle {
833
833
  background: var(--amber);
834
834
  }
835
+ /* Wedged: the Rust supervisor flagged the agent unresponsive (no PTY output
836
+ after a poke / frozen spinner) — surfaced as `stuck` by /api/ls. Red so a
837
+ stuck agent stands out from a healthy green / quiet-amber one. */
838
+ .dot.stuck {
839
+ background: var(--red);
840
+ }
841
+ /* Waiting on you: alive and quiet, but parked on an unanswered prompt/menu.
842
+ The UI accent (blue) reads as "your turn" — distinct from idle-amber. */
843
+ .dot.needs_input {
844
+ background: var(--accent);
845
+ }
835
846
  .dot.stopped,
836
847
  .dot.exited {
837
848
  background: var(--muted);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-yes",
3
- "version": "1.155.1",
3
+ "version": "1.157.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
@@ -1,4 +1,4 @@
1
- import { mkdir, open, readFile, stat, writeFile } from "fs/promises";
1
+ import { mkdir, open, readFile, stat, unlink, writeFile } from "fs/promises";
2
2
  import { renameSync, watch, writeFileSync } from "node:fs";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
@@ -23,7 +23,13 @@ import { updateGlobalPidStatus } from "./globalPidIndex.ts";
23
23
  import { pgidForWrapper } from "./reaper.ts";
24
24
  import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
25
25
  import { getInstalledPackage } from "./versionChecker.ts";
26
- import { getProvisionRoot, isProvisionAllowed, resolveSpawnCwd } from "./workspaceConfig.ts";
26
+ import {
27
+ getProvisionRoot,
28
+ getSpawnHook,
29
+ hasSpawnHook,
30
+ isProvisionAllowed,
31
+ resolveSpawnCwd,
32
+ } from "./workspaceConfig.ts";
27
33
 
28
34
  const DEFAULT_PORT = 7432;
29
35
 
@@ -1157,6 +1163,15 @@ export async function cmdServe(rest: string[]): Promise<number> {
1157
1163
  return Response.json({ version: getInstalledPackage().version });
1158
1164
  }
1159
1165
 
1166
+ // GET /api/spawn-config — read-only disclosure of WHETHER a host-local spawn
1167
+ // hook is configured. Deliberately returns only a boolean, never the hook
1168
+ // body: the hook is arbitrary local code and console/room clients holding the
1169
+ // share token are not necessarily host admins. Setting it stays host-local
1170
+ // (edit ~/.agent-yes/config.json) — there is no PUT.
1171
+ if (req.method === "GET" && p === "/api/spawn-config") {
1172
+ return Response.json({ hasSpawnHook: hasSpawnHook() });
1173
+ }
1174
+
1160
1175
  // GET /api/notes
1161
1176
  if (req.method === "GET" && p === "/api/notes") {
1162
1177
  const notes = await readNotes();
@@ -1685,10 +1700,75 @@ export async function cmdServe(rest: string[]): Promise<number> {
1685
1700
  process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe")
1686
1701
  ? [ayBin]
1687
1702
  : [process.execPath, ayBin];
1703
+ const agentArgv = [...ayCmd, cli, ...(prompt ? ["--", prompt] : [])];
1704
+ // don't leak our Claude Code session into the agent
1705
+ const agentEnv = freshAgentEnv();
1688
1706
  try {
1689
- const child = Bun.spawn([...ayCmd, cli, ...(prompt ? ["--", prompt] : [])], {
1707
+ const hook = getSpawnHook();
1708
+ if (hook) {
1709
+ // Host-local spawn hook (trusted local code, never network-writable). We
1710
+ // run it via POSIX `sh -c` then `exec "$@"` the real agent — the agent
1711
+ // argv is passed as positional params ($1…), so the prompt is NEVER
1712
+ // shell-parsed (no quoting/injection surface). `set -e` aborts the spawn
1713
+ // if any hook step fails. No `-l`: we don't re-source rc files here (the
1714
+ // env is already the recovered login-shell env via freshAgentEnv). cwd
1715
+ // and cli are exposed as env for the hook to consume.
1716
+ const shell = process.env.AGENT_YES_SPAWN_SHELL?.trim() || "/bin/sh";
1717
+ const script = `set -e\n${hook}\nexec "$@"`;
1718
+ const errPath = path.join(
1719
+ agentYesHome(),
1720
+ `spawn-hook-${process.pid}-${performance.now().toString(36).replace(".", "")}.err`,
1721
+ );
1722
+ // File-backed stderr never blocks the child (a pipe we stop draining
1723
+ // after exec would), and bounds our read to the first few KB.
1724
+ const child = Bun.spawn([shell, "-c", script, "ay-spawn", ...agentArgv], {
1725
+ cwd,
1726
+ env: { ...agentEnv, AGENT_YES_CWD: cwd, AGENT_YES_CLI: cli },
1727
+ stdin: "ignore",
1728
+ stdout: "ignore",
1729
+ stderr: Bun.file(errPath),
1730
+ });
1731
+ // Handshake: surface an EARLY hook/provision failure synchronously. If
1732
+ // the child exits non-zero within the window, the hook failed before the
1733
+ // agent settled — return the captured stderr. If it's still alive after
1734
+ // the window, `exec` succeeded (or the agent is running) → detach and
1735
+ // report success. We can't always attribute a failure to the hook vs the
1736
+ // agent, so we just surface the captured output.
1737
+ const windowMs = Number(process.env.AGENT_YES_SPAWN_HOOK_TIMEOUT_MS) || 5000;
1738
+ const exitCode = await Promise.race([
1739
+ child.exited,
1740
+ new Promise<null>((r) => setTimeout(() => r(null), windowMs)),
1741
+ ]);
1742
+ if (exitCode !== null && exitCode !== 0) {
1743
+ let detail = "";
1744
+ try {
1745
+ detail = (await Bun.file(errPath).text()).slice(0, 4096);
1746
+ } catch {
1747
+ /* no stderr captured */
1748
+ }
1749
+ await unlink(errPath).catch(() => {});
1750
+ return new Response(
1751
+ `spawn hook failed (exit ${exitCode})${detail ? `:\n${detail.trimEnd()}` : ""}`,
1752
+ { status: 502 },
1753
+ );
1754
+ }
1755
+ child.unref();
1756
+ // Clean up the stderr sidecar. On POSIX the running agent may still hold
1757
+ // the fd; unlinking an open file is harmless (the inode lives until
1758
+ // close), so a brief delay is enough to keep early-failure reads valid.
1759
+ setTimeout(() => void unlink(errPath).catch(() => {}), 60_000).unref?.();
1760
+ return Response.json({
1761
+ ok: true,
1762
+ pid: child.pid,
1763
+ cli,
1764
+ cwd,
1765
+ hook: true,
1766
+ ...(provisioned ? { provisioned } : {}),
1767
+ });
1768
+ }
1769
+ const child = Bun.spawn(agentArgv, {
1690
1770
  cwd,
1691
- env: freshAgentEnv(), // don't leak our Claude Code session into the agent
1771
+ env: agentEnv,
1692
1772
  stdin: "ignore",
1693
1773
  stdout: "ignore",
1694
1774
  stderr: "ignore",
@@ -1,12 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
- import { mkdtempSync, rmSync, writeFileSync } from "fs";
2
+ import { chmodSync, 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
7
  getProvisionAllowlist,
8
8
  getProvisionRoot,
9
+ getSpawnHook,
9
10
  getWorkspaceRoot,
11
+ hasSpawnHook,
10
12
  isProvisionAllowed,
11
13
  resolveSpawnCwd,
12
14
  setWorkspaceRoot,
@@ -25,6 +27,7 @@ describe("workspaceConfig", () => {
25
27
  else process.env.AGENT_YES_HOME = original;
26
28
  delete process.env.CODEHOST_WS_ROOT;
27
29
  delete process.env.CODEHOST_PROVISION_ALLOWLIST;
30
+ delete process.env.AGENT_YES_SPAWN_HOOK;
28
31
  rmSync(tmp, { recursive: true, force: true });
29
32
  });
30
33
 
@@ -142,4 +145,42 @@ describe("workspaceConfig", () => {
142
145
  expect(isProvisionAllowed("org", "anything")).toBe(true);
143
146
  });
144
147
  });
148
+
149
+ describe("getSpawnHook / hasSpawnHook", () => {
150
+ const isPosix = process.platform !== "win32";
151
+
152
+ it("is null/false when unset", () => {
153
+ expect(getSpawnHook()).toBeNull();
154
+ expect(hasSpawnHook()).toBe(false);
155
+ });
156
+
157
+ it("returns the configured hook from a private (0600) config", () => {
158
+ writeConfig({ spawnHook: 'echo hi >&2\nexec "$@"' });
159
+ if (isPosix) chmodSync(path.join(tmp, "config.json"), 0o600);
160
+ expect(getSpawnHook()).toBe('echo hi >&2\nexec "$@"');
161
+ expect(hasSpawnHook()).toBe(true);
162
+ });
163
+
164
+ it("ignores a blank hook", () => {
165
+ writeConfig({ spawnHook: " " });
166
+ expect(getSpawnHook()).toBeNull();
167
+ });
168
+
169
+ it("env AGENT_YES_SPAWN_HOOK overrides the config", () => {
170
+ writeConfig({ spawnHook: "from-file" });
171
+ process.env.AGENT_YES_SPAWN_HOOK = "from-env";
172
+ expect(getSpawnHook()).toBe("from-env");
173
+ });
174
+
175
+ it.skipIf(!isPosix)(
176
+ "refuses a file-backed hook when the config is group/world-writable (tampering guard)",
177
+ () => {
178
+ writeConfig({ spawnHook: "echo pwned" });
179
+ chmodSync(path.join(tmp, "config.json"), 0o666);
180
+ expect(getSpawnHook()).toBeNull();
181
+ chmodSync(path.join(tmp, "config.json"), 0o600);
182
+ expect(getSpawnHook()).toBe("echo pwned");
183
+ },
184
+ );
185
+ });
145
186
  });
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
1
+ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import path from "path";
4
4
  import { agentYesHome } from "./agentYesHome.ts";
@@ -19,6 +19,12 @@ interface Config {
19
19
  provisionRoot?: string;
20
20
  /** Owners/repos permitted for `from`-provisioning; empty = deny all. */
21
21
  provisionAllowlist?: string[];
22
+ /**
23
+ * A trusted, HOST-LOCAL shell hook run before each console/CLI spawn. See
24
+ * {@link getSpawnHook}. Intentionally NOT settable over the network — it is
25
+ * arbitrary local code that runs on every spawn.
26
+ */
27
+ spawnHook?: string;
22
28
  }
23
29
 
24
30
  function configPath(): string {
@@ -86,6 +92,45 @@ export function isProvisionAllowed(owner: string, repo: string): boolean {
86
92
  return list.some((e) => e.replace(/\/\*$/, "") === o || e === full);
87
93
  }
88
94
 
95
+ /**
96
+ * A trusted, HOST-LOCAL shell hook run before each agent spawn. It is a POSIX
97
+ * `sh -c` script that prepares the environment (provisioning, env, cd) and that
98
+ * agent-yes runs as `sh -c "set -e\n<hook>\nexec \"$@\"" ay-spawn <agent argv…>`
99
+ * — so the real agent argv is passed as positional params and the prompt is
100
+ * never shell-parsed. `set -e` aborts the spawn if any hook step fails.
101
+ *
102
+ * This is arbitrary local code that runs on EVERY spawn, so it is deliberately
103
+ * NOT writable over the network. Set it on the machine by editing
104
+ * `~/.agent-yes/config.json` (`"spawnHook"`), or via env `AGENT_YES_SPAWN_HOOK`.
105
+ *
106
+ * Tampering guard (POSIX): a file-backed hook is ignored when the config file is
107
+ * a symlink, is not owned by us, or is group/world-writable — we refuse to run a
108
+ * hook from a file other users can swap or rewrite. The env override is trusted
109
+ * (it comes from the daemon's own environment). Returns null when unset/guarded.
110
+ */
111
+ export function getSpawnHook(): string | null {
112
+ const env = process.env.AGENT_YES_SPAWN_HOOK;
113
+ if (env && env.trim()) return env;
114
+ const h = readConfig().spawnHook;
115
+ if (!h || !h.trim()) return null;
116
+ if (process.platform !== "win32") {
117
+ try {
118
+ if (lstatSync(configPath()).isSymbolicLink()) return null;
119
+ const st = statSync(configPath());
120
+ if ((st.mode & 0o022) !== 0) return null; // group/world-writable
121
+ if (typeof process.getuid === "function" && st.uid !== process.getuid()) return null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+ return h;
127
+ }
128
+
129
+ /** Whether a usable {@link getSpawnHook} is configured (for read-only disclosure). */
130
+ export function hasSpawnHook(): boolean {
131
+ return getSpawnHook() !== null;
132
+ }
133
+
89
134
  /** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
90
135
  export function setWorkspaceRoot(dir: string): string {
91
136
  const abs = path.resolve(expandTilde(dir));
@@ -1,8 +0,0 @@
1
- import "./ts-SEpCdKU5.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-B9y2MQF2.js";
4
- import "./pidStore-fqXqTKkh.js";
5
- import "./globalPidIndex-DlmmJlO8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Nwu058MP.js";
7
-
8
- export { SUPPORTED_CLIS };