@edspencer/paddock 0.56.0 → 0.58.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 (75) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/import-chats.js +6 -1
  3. package/packages/server/dist/cli/paddock.d.ts +29 -1
  4. package/packages/server/dist/cli/paddock.js +136 -44
  5. package/packages/server/dist/config.d.ts +26 -0
  6. package/packages/server/dist/config.js +32 -0
  7. package/packages/server/dist/environment-prompt.d.ts +52 -0
  8. package/packages/server/dist/environment-prompt.js +61 -0
  9. package/packages/server/dist/herdctl.d.ts +51 -0
  10. package/packages/server/dist/herdctl.js +61 -0
  11. package/packages/server/dist/instance-config.d.ts +13 -5
  12. package/packages/server/dist/instance-config.js +40 -0
  13. package/packages/server/package.json +1 -1
  14. package/packages/web/dist/assets/InstanceConfigPage-BKE6j_Wy.js +2 -0
  15. package/packages/web/dist/assets/{MarkdownRenderer-CJZFLj_8.js → MarkdownRenderer-B7_FErIN.js} +1 -1
  16. package/packages/web/dist/assets/{ProjectRedirect-BHwVbbqB.js → ProjectRedirect-DOaB53YM.js} +1 -1
  17. package/packages/web/dist/assets/{ProjectView-BOvuAITg.js → ProjectView-Dz_QUyPG.js} +3 -3
  18. package/packages/web/dist/assets/{ProjectsGrid-BCojZOGY.js → ProjectsGrid-CjfDQ37q.js} +1 -1
  19. package/packages/web/dist/assets/{arc-BVyxvnkl.js → arc-BxAGgBjd.js} +1 -1
  20. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Cd9Puqg1.js → architectureDiagram-3BPJPVTR-CBz2e-c3.js} +1 -1
  21. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B_AQt_Rx.js → blockDiagram-GPEHLZMM-ZqClBxB7.js} +1 -1
  22. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-7e0TQtXB.js → c4Diagram-AAUBKEIU-CvFOBN1E.js} +1 -1
  23. package/packages/web/dist/assets/channel-BHzPWerJ.js +2 -0
  24. package/packages/web/dist/assets/{chunk-2J33WTMH-Dz4WZmUZ.js → chunk-2J33WTMH-C44u7-Xg.js} +1 -1
  25. package/packages/web/dist/assets/{chunk-4BX2VUAB-DVWYldo6.js → chunk-4BX2VUAB-DXG9UM3Z.js} +1 -1
  26. package/packages/web/dist/assets/{chunk-55IACEB6-BIMrizQd.js → chunk-55IACEB6-BLkJFttb.js} +1 -1
  27. package/packages/web/dist/assets/{chunk-727SXJPM-CL2q9fF1.js → chunk-727SXJPM-DZidMKHZ.js} +1 -1
  28. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BSq3HlQ9.js → chunk-AQP2D5EJ-DVowfDYg.js} +1 -1
  29. package/packages/web/dist/assets/{chunk-FMBD7UC4-Bgt64pLE.js → chunk-FMBD7UC4-zeHzmwLI.js} +1 -1
  30. package/packages/web/dist/assets/{chunk-ND2GUHAM-C8Pr2A38.js → chunk-ND2GUHAM-Pcd02Wpz.js} +1 -1
  31. package/packages/web/dist/assets/{chunk-QZHKN3VN-BAOqh-MN.js → chunk-QZHKN3VN-yxlVeu8W.js} +1 -1
  32. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-hNchnAu0.js +2 -0
  33. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-hNchnAu0.js +2 -0
  34. package/packages/web/dist/assets/{core-lJeGACQg.js → core-CSvc61jo.js} +1 -1
  35. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BF4tameA.js → cose-bilkent-S5V4N54A-6MJqrbdr.js} +1 -1
  36. package/packages/web/dist/assets/{dagre-BM42HDAG-DdsLM6ql.js → dagre-BM42HDAG-B3pDB8_y.js} +1 -1
  37. package/packages/web/dist/assets/{diagram-2AECGRRQ-BQ78S_RM.js → diagram-2AECGRRQ-BJA5EERJ.js} +1 -1
  38. package/packages/web/dist/assets/{diagram-5GNKFQAL-CAKE6nHE.js → diagram-5GNKFQAL-CcyfxhVS.js} +1 -1
  39. package/packages/web/dist/assets/{diagram-KO2AKTUF-fEvHRXgr.js → diagram-KO2AKTUF-CPNyNU1j.js} +1 -1
  40. package/packages/web/dist/assets/{diagram-LMA3HP47-CtWsgeSm.js → diagram-LMA3HP47-Q4klJqX8.js} +1 -1
  41. package/packages/web/dist/assets/{diagram-OG6HWLK6-Buog8k7_.js → diagram-OG6HWLK6-D0ZBTRoA.js} +1 -1
  42. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CWvVVPZS.js → erDiagram-TEJ5UH35-DQrCT17u.js} +1 -1
  43. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DsCGXtp4.js → flowDiagram-I6XJVG4X-CK7nM7wz.js} +1 -1
  44. package/packages/web/dist/assets/{format-DamJQ9LO.js → format-GJRFyZIp.js} +1 -1
  45. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CsdwIvvC.js → ganttDiagram-6RSMTGT7-CFsNGWGG.js} +1 -1
  46. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-hZvrX1sD.js → gitGraphDiagram-PVQCEYII-xP0bbv60.js} +1 -1
  47. package/packages/web/dist/assets/{index-CGbZdrm8.js → index-CDiCRIso.js} +2 -2
  48. package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
  49. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nE_Vtz_l.js → infoDiagram-5YYISTIA-BaKZDvMw.js} +1 -1
  50. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-2Nlqs0oH.js → ishikawaDiagram-YF4QCWOH-Dr0LK4iy.js} +1 -1
  51. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-mjU1fMc4.js → journeyDiagram-JHISSGLW-CxusdThR.js} +1 -1
  52. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-C_9xNaa3.js → kanban-definition-UN3LZRKU-CJYlFeaB.js} +1 -1
  53. package/packages/web/dist/assets/{linear-BxKd91ho.js → linear-D0XMlvYu.js} +1 -1
  54. package/packages/web/dist/assets/{mermaid.core-C-CuhD8I.js → mermaid.core-CcPpo_y5.js} +4 -4
  55. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bs6yfcZv.js → mindmap-definition-RKZ34NQL-w064Pb6X.js} +1 -1
  56. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DNdqX7O5.js → pieDiagram-4H26LBE5-D_qwlox1.js} +1 -1
  57. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js → quadrantDiagram-W4KKPZXB-BuMUhsEZ.js} +1 -1
  58. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-RmxMdm2q.js → requirementDiagram-4Y6WPE33-Bp1BqfXY.js} +1 -1
  59. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-OAAhWcfK.js → sankeyDiagram-5OEKKPKP-BQR4HfjD.js} +1 -1
  60. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BWF7rCyN.js → sequenceDiagram-3UESZ5HK-DIAHV11i.js} +1 -1
  61. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-M3b_zqqo.js → stateDiagram-AJRCARHV-BHMJl8rE.js} +1 -1
  62. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CScn8fuQ.js +2 -0
  63. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BpOCh63N.js → timeline-definition-PNZ67QCA-CB5sHevP.js} +1 -1
  64. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-D_iPH45Q.js → vennDiagram-CIIHVFJN-BeS1AXva.js} +1 -1
  65. package/packages/web/dist/assets/{wardley-L42UT6IY-DVD9I2Om.js → wardley-L42UT6IY-CJ9C5W6N.js} +1 -1
  66. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bn1IKwjn.js → wardleyDiagram-YWT4CUSO-Bf75XvJ_.js} +1 -1
  67. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DwtI0bkR.js → xychartDiagram-2RQKCTM6-0Qiya8rY.js} +1 -1
  68. package/packages/web/dist/index.html +2 -2
  69. package/packages/web/dist/sw.js +1 -1
  70. package/packages/web/dist/assets/InstanceConfigPage-4cQGTj9Y.js +0 -2
  71. package/packages/web/dist/assets/channel-C_1aISNn.js +0 -2
  72. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +0 -2
  73. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +0 -2
  74. package/packages/web/dist/assets/index-DJDQ210-.css +0 -1
  75. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -37,6 +37,7 @@
37
37
  * Applied to the environment BEFORE the config is read.
38
38
  * --json Emit the raw result as JSON instead of prose.
39
39
  */
40
+ import { pathToFileURL } from "node:url";
40
41
  import { loadPaddockConfig } from "../config.js";
41
42
  import { ProjectStore, ROOT_KEY } from "../projects.js";
42
43
  import { HerdctlService } from "../herdctl.js";
@@ -187,7 +188,11 @@ async function main() {
187
188
  }
188
189
  }
189
190
  // Only run when invoked directly, so the arg parser stays unit-testable.
190
- if (process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`) {
191
+ // `pathToFileURL`, not `"file://" + argv[1]`: the naive concatenation leaves any
192
+ // space or non-ASCII character in the path un-encoded, so the comparison fails
193
+ // and the script silently does nothing when run from such a directory — the same
194
+ // percent-encoding trap as #636.
195
+ if (process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url) {
191
196
  main()
192
197
  .then((code) => process.exit(code))
193
198
  .catch((err) => {
@@ -1,2 +1,30 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export interface CliOptions {
3
+ port?: string;
4
+ host?: string;
5
+ dataDir?: string;
6
+ open: boolean;
7
+ verbose: boolean;
8
+ help: boolean;
9
+ version: boolean;
10
+ }
11
+ /** A usage error. Thrown rather than exiting, so `parseArgs` stays testable. */
12
+ export declare class CliError extends Error {
13
+ }
14
+ export declare function parseArgs(argv: string[]): CliOptions;
15
+ /**
16
+ * Why this Node is unusable, or `undefined` if it is fine.
17
+ *
18
+ * Split from the exit so it can be tested. `engines` only warns by default, and
19
+ * the failure it eventually produces is an unexplained syntax error deep inside
20
+ * a dependency — which is nobody's idea of a first-run experience.
21
+ */
22
+ export declare function nodeVersionProblem(nodeVersion: string): string | undefined;
23
+ /**
24
+ * Translate a listen failure into something a human can act on.
25
+ *
26
+ * `EADDRINUSE` on 4000 is the single most likely first-run failure — it is a
27
+ * popular port and Paddock's own default — and Node's raw error names neither
28
+ * the port nor the flag that fixes it.
29
+ */
30
+ export declare function explainListenError(err: unknown, host: string, port: string): string;
@@ -1,34 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `paddock` — the npx / global-install entrypoint (#TBD).
3
+ * `paddock` — the npx / global-install entrypoint (#637, #638).
4
4
  *
5
- * PROTOTYPE. This is the user-facing front door for `npx @edspencer/paddock`:
6
- * someone who has never seen this repo runs one command and gets a working
7
- * instance on localhost. Everything here is preflight and defaults — the server
8
- * itself is unchanged, and `start()` is the same lifecycle `node dist/index.js`
9
- * uses.
5
+ * The user-facing front door for `npx @edspencer/paddock`: someone who has never
6
+ * seen this repo runs one command and gets a working instance on localhost.
7
+ * Everything here is preflight, defaults and presentation — the server itself is
8
+ * untouched, and `start()` is the same lifecycle `node dist/index.js` uses.
10
9
  *
11
- * The design constraint that shapes this file: the server is already
12
- * *tolerant* — every setting has a default, an empty data dir self-initializes,
13
- * and auth defaults to `none` on a loopback bind. So a bare `start()` would in
14
- * fact boot. What it would NOT do is tell a first-time user why their chats
15
- * fail, or where their data went. Hence:
10
+ * The design constraint that shapes this file: the server is already *tolerant*
11
+ * — every setting has a default, an empty data dir self-initializes, and auth
12
+ * defaults to `none` on a loopback bind. So a bare `start()` would in fact boot.
13
+ * What it would NOT do is tell a first-time user why their chats fail, where
14
+ * their data went, or which of the several hundred lines of JSON that just
15
+ * scrolled past contained the URL.
16
16
  *
17
- * 1. Node version — checked explicitly, because the `engines` field only
18
- * warns and the eventual failure is an opaque syntax error.
19
- * 2. Data dir — defaults to `~/.paddock`, NOT the server's `./data`, which is
20
- * relative to cwd and would litter whatever directory you happened to run
21
- * npx from (and silently give you a different instance next time).
22
- * 3. `claude` on PATH — prepended from our own node_modules/.bin. Chats do not
23
- * need it (the SDK runtime resolves its own bundled binary), but the
24
- * sweeper and triggers shell out to `claude` by name.
25
- * 4. Credentials — a warning, never a hard failure: we cannot reliably detect
26
- * a macOS Keychain login, so refusing to start would lock out valid users.
17
+ * Everything below the arg parser is side-effecting by nature, so the pure parts
18
+ * (`parseArgs`, `nodeVersionProblem`, `explainListenError`) are exported and
19
+ * unit-tested; the rest is exercised by running the built binary.
27
20
  */
28
21
  import fs from "node:fs";
29
22
  import os from "node:os";
30
23
  import path from "node:path";
31
- import { fileURLToPath } from "node:url";
24
+ import { spawn } from "node:child_process";
25
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
26
  const MIN_NODE_MAJOR = 22;
33
27
  /** This module's own directory: `<…>/packages/server/dist/cli`. */
34
28
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -43,8 +37,7 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
43
37
  * deps at `<pkg>/node_modules` only — two levels FURTHER up
44
38
  *
45
39
  * A fixed `../..` hop is right for the repo and wrong for the published tarball,
46
- * where it lands on `packages/server` and finds no `node_modules` at all. Walking
47
- * up is correct for both without branching on which one we are in.
40
+ * where it lands on `packages/server` and finds no `node_modules` at all.
48
41
  */
49
42
  function findUp(rel) {
50
43
  let dir = moduleDir;
@@ -59,14 +52,17 @@ function findUp(rel) {
59
52
  }
60
53
  /** Nearest ancestor holding a `package.json` — `packages/server` in both layouts. */
61
54
  const packageRoot = findUp("package.json") ?? path.resolve(moduleDir, "../..");
62
- function parseArgs(argv) {
63
- const opts = { help: false, version: false };
55
+ /** A usage error. Thrown rather than exiting, so `parseArgs` stays testable. */
56
+ export class CliError extends Error {
57
+ }
58
+ export function parseArgs(argv) {
59
+ const opts = { open: false, verbose: false, help: false, version: false };
64
60
  for (let i = 0; i < argv.length; i++) {
65
61
  const arg = argv[i];
66
62
  const next = () => {
67
63
  const v = argv[++i];
68
64
  if (v === undefined)
69
- fail(`${arg} needs a value`);
65
+ throw new CliError(`${arg} needs a value`);
70
66
  return v;
71
67
  };
72
68
  switch (arg) {
@@ -81,6 +77,13 @@ function parseArgs(argv) {
81
77
  case "--data-dir":
82
78
  opts.dataDir = next();
83
79
  break;
80
+ case "-o":
81
+ case "--open":
82
+ opts.open = true;
83
+ break;
84
+ case "--verbose":
85
+ opts.verbose = true;
86
+ break;
84
87
  case "-h":
85
88
  case "--help":
86
89
  opts.help = true;
@@ -90,7 +93,7 @@ function parseArgs(argv) {
90
93
  opts.version = true;
91
94
  break;
92
95
  default:
93
- fail(`unknown option: ${arg}\nRun \`paddock --help\` for usage.`);
96
+ throw new CliError(`unknown option: ${arg}\nRun \`paddock --help\` for usage.`);
94
97
  }
95
98
  }
96
99
  return opts;
@@ -117,6 +120,8 @@ Options
117
120
  -p, --port <port> HTTP/WS port (default 4000, or $PORT)
118
121
  --host <host> Bind address (default 127.0.0.1)
119
122
  -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)
123
+ -o, --open Open the app in your browser once it is listening
124
+ --verbose Show the server's own logs (quiet by default)
120
125
  -v, --version Print the Paddock version and exit
121
126
  -h, --help Show this help
122
127
 
@@ -126,20 +131,31 @@ Credentials
126
131
  billing). If you already use Claude Code on this machine, an existing login
127
132
  under ~/.claude is picked up automatically. Otherwise run \`claude setup-token\`.
128
133
 
134
+ Your data
135
+ Everything lives in one directory — ~/.paddock unless you pass --data-dir.
136
+ Projects, chat transcripts and settings all persist there between runs. Move
137
+ that directory to move your instance; delete it to start over. Nothing is
138
+ stored anywhere else.
139
+
129
140
  Notes
130
141
  Binds loopback with authentication disabled, which is safe for a laptop. To
131
142
  expose it on a network, set PADDOCK_AUTH_MODE first — Paddock refuses to bind
132
143
  a routable interface wide open. See AUTH.md.
133
144
 
134
145
  Docs: https://github.com/edspencer/paddock`;
135
- /** Node's own version gate. `engines` only warns by default, and the failure it
136
- * eventually produces is an unexplained syntax error deep in a dependency. */
137
- function checkNodeVersion() {
138
- const major = Number(process.versions.node.split(".")[0]);
139
- if (Number.isFinite(major) && major < MIN_NODE_MAJOR) {
140
- fail(`Node ${process.versions.node} is too old — Paddock needs Node ${MIN_NODE_MAJOR}+.\n` +
141
- `Upgrade Node, then re-run. (nodejs.org, or \`nvm install ${MIN_NODE_MAJOR}\`)`);
142
- }
146
+ /**
147
+ * Why this Node is unusable, or `undefined` if it is fine.
148
+ *
149
+ * Split from the exit so it can be tested. `engines` only warns by default, and
150
+ * the failure it eventually produces is an unexplained syntax error deep inside
151
+ * a dependency — which is nobody's idea of a first-run experience.
152
+ */
153
+ export function nodeVersionProblem(nodeVersion) {
154
+ const major = Number(nodeVersion.split(".")[0]);
155
+ if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR)
156
+ return undefined;
157
+ return (`Node ${nodeVersion} is too old — Paddock needs Node ${MIN_NODE_MAJOR}+.\n` +
158
+ `Upgrade Node, then re-run. (nodejs.org, or \`nvm install ${MIN_NODE_MAJOR}\`)`);
143
159
  }
144
160
  /**
145
161
  * Put our bundled `claude` on PATH for child processes.
@@ -209,8 +225,54 @@ function noteFirstRun(dataDir) {
209
225
  "",
210
226
  ].join("\n"));
211
227
  }
228
+ /** Open `url` in the default browser. Best-effort: failing is never fatal. */
229
+ function openBrowser(url) {
230
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
231
+ try {
232
+ const child = spawn(cmd, [url], {
233
+ stdio: "ignore",
234
+ detached: true,
235
+ shell: process.platform === "win32", // `start` is a cmd builtin, not a binary
236
+ });
237
+ // A headless box has no xdg-open. The URL is printed either way, so an
238
+ // unopenable browser is a non-event rather than a failed run.
239
+ child.on("error", () => { });
240
+ child.unref();
241
+ }
242
+ catch {
243
+ /* as above */
244
+ }
245
+ }
246
+ /**
247
+ * Translate a listen failure into something a human can act on.
248
+ *
249
+ * `EADDRINUSE` on 4000 is the single most likely first-run failure — it is a
250
+ * popular port and Paddock's own default — and Node's raw error names neither
251
+ * the port nor the flag that fixes it.
252
+ */
253
+ export function explainListenError(err, host, port) {
254
+ const code = err?.code;
255
+ if (code === "EADDRINUSE") {
256
+ return (`port ${port} is already in use.\n` +
257
+ `Something else is listening on ${host}:${port} — possibly another Paddock.\n` +
258
+ `Pick a different port: paddock --port ${Number(port) + 1}`);
259
+ }
260
+ if (code === "EACCES") {
261
+ return (`not allowed to bind ${host}:${port}.\n` +
262
+ `Ports below 1024 need elevated privileges — use a higher one: paddock --port 4000`);
263
+ }
264
+ return String(err?.message ?? err);
265
+ }
212
266
  async function main() {
213
- const opts = parseArgs(process.argv.slice(2));
267
+ let opts;
268
+ try {
269
+ opts = parseArgs(process.argv.slice(2));
270
+ }
271
+ catch (err) {
272
+ if (err instanceof CliError)
273
+ fail(err.message);
274
+ throw err;
275
+ }
214
276
  if (opts.help) {
215
277
  console.log(USAGE);
216
278
  return;
@@ -219,7 +281,9 @@ async function main() {
219
281
  console.log(readVersion());
220
282
  return;
221
283
  }
222
- checkNodeVersion();
284
+ const problem = nodeVersionProblem(process.versions.node);
285
+ if (problem !== undefined)
286
+ fail(problem);
223
287
  addBundledBinsToPath();
224
288
  // Apply CLI defaults as env vars. This is the whole integration surface: the
225
289
  // server resolves config inside `buildApp()`, so anything set before the
@@ -232,16 +296,44 @@ async function main() {
232
296
  process.env.PORT = opts.port;
233
297
  if (opts.host)
234
298
  process.env.HOST = opts.host;
299
+ // Quiet by default. The server emits a few hundred lines on the way up, which
300
+ // scrolls the one line the user actually needs — the URL — off the top of the
301
+ // terminal. TWO loggers have to be told, and they are unrelated:
302
+ // LOG_LEVEL Paddock's own pino logger (config.ts).
303
+ // HERDCTL_LOG_LEVEL @herdctl/core's `createLogger`, which writes the
304
+ // `[fleet-manager] …` lines via console.info — pino's
305
+ // level cannot reach those.
306
+ // An explicit value for either is left alone, and `--verbose` skips both.
307
+ if (!opts.verbose) {
308
+ if (process.env.LOG_LEVEL === undefined)
309
+ process.env.LOG_LEVEL = "warn";
310
+ if (process.env.HERDCTL_LOG_LEVEL === undefined)
311
+ process.env.HERDCTL_LOG_LEVEL = "warn";
312
+ }
235
313
  warnIfNoCredentials(process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude"));
236
314
  const port = process.env.PORT ?? "4000";
237
315
  const host = process.env.HOST ?? "127.0.0.1";
238
316
  // Imported dynamically, AFTER the env above is set: a static import would
239
317
  // pull in app.ts -> config.ts and resolve config against the wrong data dir.
240
318
  const { start } = await import("../start.js");
241
- await start();
242
- console.log(`\n Paddock is running at http://${host}:${port}\n Press Ctrl-C to stop.\n`);
319
+ try {
320
+ await start();
321
+ }
322
+ catch (err) {
323
+ fail(explainListenError(err, host, port));
324
+ }
325
+ const url = `http://${host}:${port}`;
326
+ console.log(`\n Paddock is running at ${url}\n Data: ${dataDir}\n Press Ctrl-C to stop.\n`);
327
+ if (opts.open)
328
+ openBrowser(url);
329
+ }
330
+ // Run only when executed directly, so the unit tests can import the pure parts.
331
+ // `pathToFileURL`, not `"file://" + argv[1]`: the naive concatenation breaks on
332
+ // any path containing a space or non-ASCII character — the same percent-encoding
333
+ // trap as #636.
334
+ if (process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url) {
335
+ main().catch((err) => {
336
+ console.error("fatal:", err);
337
+ process.exit(1);
338
+ });
243
339
  }
244
- main().catch((err) => {
245
- console.error("fatal:", err);
246
- process.exit(1);
247
- });
@@ -168,6 +168,25 @@ export interface PaddockConfig {
168
168
  * instance CLAUDE.md up by walk-up. That closes #512.
169
169
  */
170
170
  nativeSystemPrompt: boolean;
171
+ /**
172
+ * The **environment** prompt appended to every keeper turn's system prompt
173
+ * (issue #635) — the short note telling the agent it renders into a browser as
174
+ * GFM, not a terminal. Resolved from `PADDOCK_ENVIRONMENT_PROMPT` over the
175
+ * `environmentPrompt:` file key over
176
+ * {@link import("./environment-prompt.js").DEFAULT_ENVIRONMENT_PROMPT}.
177
+ *
178
+ * This is the RESOLVED, effective text, so it has only two meaningful states
179
+ * at the point of use: non-empty (append it) and `""` (append nothing — the
180
+ * instance opted out). The three *authoring* states — unset, a string, an
181
+ * empty string — collapse here, which is deliberate: every consumer just asks
182
+ * "is there anything to append?" and the Settings screen shows the text that
183
+ * is actually in force rather than a tri-state it would have to explain.
184
+ *
185
+ * Orthogonal to {@link nativeSystemPrompt}: that switch chooses the agent's
186
+ * *role* prompt (native preset vs Paddock's terse replace text); this states
187
+ * environmental fact about the deployment and applies on top of either.
188
+ */
189
+ environmentPrompt: string;
171
190
  /**
172
191
  * Whether keeper turns are handed the read-only self-management MCP server
173
192
  * (issue #214 Phase 1) — the `mcp__paddock_manage__*` tools that let a keeper
@@ -378,6 +397,13 @@ export interface PaddockConfigFile {
378
397
  models?: string[] | string;
379
398
  driveMode?: string;
380
399
  nativeSystemPrompt?: boolean | string;
400
+ /**
401
+ * Environment system prompt override (issue #635). Absent ⇒ Paddock's built-in
402
+ * text; a non-empty string ⇒ that text instead; an EMPTY string ⇒ append
403
+ * nothing. Unlike almost every other key here, blank is meaningful and is NOT
404
+ * folded to the default — see {@link loadEnvironmentPrompt}.
405
+ */
406
+ environmentPrompt?: string;
381
407
  selfMcpEnabled?: boolean | string;
382
408
  selfMcpWriteEnabled?: boolean | string;
383
409
  selfMcpProjectsEnabled?: boolean | string;
@@ -24,6 +24,7 @@ import { fileURLToPath } from "node:url";
24
24
  import YAML from "yaml";
25
25
  import { DEFAULT_DRIVE_MODE, isKnownDriveMode, isKnownModel, } from "./models.js";
26
26
  import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
27
+ import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
27
28
  import { DEFAULT_RECOVERY } from "./recovery-config.js";
28
29
  import { DEFAULT_CURATION } from "./curation-config.js";
29
30
  import { resolveManagementApiConfig, } from "./management-config.js";
@@ -290,6 +291,7 @@ export function loadPaddockConfig() {
290
291
  models: loadModels(file.models),
291
292
  driveMode: loadDriveMode(file.driveMode),
292
293
  nativeSystemPrompt: loadNativeSystemPrompt(file.nativeSystemPrompt),
294
+ environmentPrompt: loadEnvironmentPrompt(file.environmentPrompt),
293
295
  selfMcpEnabled: loadSelfMcpEnabled(file.selfMcpEnabled),
294
296
  selfMcpWriteEnabled: loadSelfMcpEnabled(file.selfMcpEnabled) && loadSelfMcpWriteEnabled(file.selfMcpWriteEnabled),
295
297
  // Projects tool implies write implies read — the tool is appended in the write
@@ -534,6 +536,36 @@ function loadNativeSystemPrompt(file) {
534
536
  const raw = envOr("PADDOCK_NATIVE_PROMPT", fileOr(file, "true")).toLowerCase();
535
537
  return !(raw === "0" || raw === "false" || raw === "no");
536
538
  }
539
+ /**
540
+ * Resolve the environment system prompt (issue #635): env
541
+ * `PADDOCK_ENVIRONMENT_PROMPT` over the `environmentPrompt:` file key over the
542
+ * built-in {@link DEFAULT_ENVIRONMENT_PROMPT}.
543
+ *
544
+ * Deliberately does NOT use `envOr`/`fileOr`. Those fold a blank value to the
545
+ * fallback, which would make opting out impossible — blank is the opt-out here,
546
+ * so precedence is decided on *definedness*, not on emptiness:
547
+ *
548
+ * - `PADDOCK_ENVIRONMENT_PROMPT` **defined at all** (even empty) wins outright.
549
+ * That is why the field is declared `envShadowWhenDefined` in
550
+ * instance-config.ts — same rule as `PADDOCK_BROWSER_MCP` — so the Settings
551
+ * screen renders it read-only in exactly the cases where it really is.
552
+ * - Otherwise a `string` file value wins, empty included. A non-string (a YAML
553
+ * number, a mapping, `null` from a bare `environmentPrompt:`) is ignored
554
+ * rather than stringified — `"null"` is not a prompt anyone meant to write.
555
+ * - Otherwise the built-in default.
556
+ *
557
+ * The value is used verbatim: no trimming, no normalization. An operator's
558
+ * trailing newline or leading indentation survives the round-trip, because the
559
+ * text is going into a prompt where whitespace is content.
560
+ */
561
+ function loadEnvironmentPrompt(file) {
562
+ const env = process.env.PADDOCK_ENVIRONMENT_PROMPT;
563
+ if (env !== undefined)
564
+ return env;
565
+ if (typeof file === "string")
566
+ return file;
567
+ return DEFAULT_ENVIRONMENT_PROMPT;
568
+ }
537
569
  /**
538
570
  * Resolve the instance model allow-list (issue #457 Step 2), precedence
539
571
  * env `PADDOCK_MODELS` (comma-separated ids) over YAML `models:` (a string array,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Paddock's built-in **environment** system prompt (issue #635).
3
+ *
4
+ * Paddock injects no system prompt of its own: on a default instance the keeper
5
+ * runs on Claude Code's stock `claude_code` preset, which is written for a
6
+ * terminal. Nothing anywhere tells the model that its replies are rendered as
7
+ * GitHub-Flavored Markdown in a browser (`MarkdownRenderer.tsx` = react-markdown
8
+ * + remark-gfm), or that `mcp__paddock__send_file` renders a file inline.
9
+ *
10
+ * This text is a small **append** to whatever system prompt the agent already
11
+ * has — it states environmental fact about the deployment, and deliberately does
12
+ * not instruct on role, tone or workflow (that is the `CLAUDE.md` hierarchy's
13
+ * layer, and it stays the user's).
14
+ *
15
+ * ## Why exactly two rules
16
+ *
17
+ * The text is not written from first principles — an earlier draft was, and the
18
+ * audit in issue #635 refuted most of it. It comes from auditing the 100 most
19
+ * recent chats on the dogfood instance (3,944 assistant text blocks, 2.1 MB of
20
+ * user-facing prose). Two findings carried ~90% of the measured value:
21
+ *
22
+ * - **`#123` is dead text.** 4,440 bare refs vs 155 markdown links. Bare URLs
23
+ * autolink under GFM and came out fine (only 4 inert, all backticked), so the
24
+ * defect is specifically issue/PR refs — and it is not inability: single
25
+ * messages link some refs and leave others bare.
26
+ * - **Visual work is described, never shown.** 194 image reads, 138
27
+ * screenshots, and **zero** images ever sent to a user. `send_file` was used
28
+ * 10 times, none of them an image. In one chat this cost a full re-work round
29
+ * — the agent read 17 QC frames, showed none, misread one, and shipped a
30
+ * regression the user then had to screenshot themselves.
31
+ *
32
+ * Things measured and deliberately **cut**, so they don't get re-added: "no ANSI
33
+ * colour" (0 escapes in 2.1 MB), "don't paste long content" (0 fences ≥40 lines;
34
+ * median fence is 3), "use markdown structure" (580 headings, 892 bullets, 101
35
+ * well-formed tables — already excellent), `file.ts:123` guidance (real but
36
+ * low-stakes; 72 of 75 sampled sit in backticks beside the symbol name), and
37
+ * mermaid (0 genuine missed opportunities; an instruction would over-trigger).
38
+ *
39
+ * ## Keep in sync
40
+ *
41
+ * The `send_file` tool description (`send-file-mcp.ts`) is the only other
42
+ * Paddock-aware signal in a normal turn. These two are the same message told
43
+ * twice; change one and re-read the other.
44
+ */
45
+ /**
46
+ * The default environment prompt appended to every keeper turn unless the
47
+ * instance overrides or opts out (see `environmentPrompt` in config.ts).
48
+ *
49
+ * Kept verbatim from issue #635's evidence pass. Resist adding a third rule
50
+ * without measurement — several plausible candidates were cut above.
51
+ */
52
+ export declare const DEFAULT_ENVIRONMENT_PROMPT = "You are running in Paddock, a web app \u2014 your replies render as GitHub-Flavored\nMarkdown in a browser, not as terminal output.\n\n- Show, don't describe. A screenshot, rendering, chart or file the user is meant\n to look at should be sent with `mcp__paddock__send_file`, which renders it\n inline. A filesystem path, or \"it's up on port 5026\", leaves them unable to see\n your evidence \u2014 or to catch you misreading it.\n- Make clickable things clickable. Bare URLs autolink; `#123` does not \u2014 write\n `[#123](https://github.com/owner/repo/issues/123)`. Prefer a full URL to a bare\n port number, and link a chat or project rather than quoting its id.";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Paddock's built-in **environment** system prompt (issue #635).
3
+ *
4
+ * Paddock injects no system prompt of its own: on a default instance the keeper
5
+ * runs on Claude Code's stock `claude_code` preset, which is written for a
6
+ * terminal. Nothing anywhere tells the model that its replies are rendered as
7
+ * GitHub-Flavored Markdown in a browser (`MarkdownRenderer.tsx` = react-markdown
8
+ * + remark-gfm), or that `mcp__paddock__send_file` renders a file inline.
9
+ *
10
+ * This text is a small **append** to whatever system prompt the agent already
11
+ * has — it states environmental fact about the deployment, and deliberately does
12
+ * not instruct on role, tone or workflow (that is the `CLAUDE.md` hierarchy's
13
+ * layer, and it stays the user's).
14
+ *
15
+ * ## Why exactly two rules
16
+ *
17
+ * The text is not written from first principles — an earlier draft was, and the
18
+ * audit in issue #635 refuted most of it. It comes from auditing the 100 most
19
+ * recent chats on the dogfood instance (3,944 assistant text blocks, 2.1 MB of
20
+ * user-facing prose). Two findings carried ~90% of the measured value:
21
+ *
22
+ * - **`#123` is dead text.** 4,440 bare refs vs 155 markdown links. Bare URLs
23
+ * autolink under GFM and came out fine (only 4 inert, all backticked), so the
24
+ * defect is specifically issue/PR refs — and it is not inability: single
25
+ * messages link some refs and leave others bare.
26
+ * - **Visual work is described, never shown.** 194 image reads, 138
27
+ * screenshots, and **zero** images ever sent to a user. `send_file` was used
28
+ * 10 times, none of them an image. In one chat this cost a full re-work round
29
+ * — the agent read 17 QC frames, showed none, misread one, and shipped a
30
+ * regression the user then had to screenshot themselves.
31
+ *
32
+ * Things measured and deliberately **cut**, so they don't get re-added: "no ANSI
33
+ * colour" (0 escapes in 2.1 MB), "don't paste long content" (0 fences ≥40 lines;
34
+ * median fence is 3), "use markdown structure" (580 headings, 892 bullets, 101
35
+ * well-formed tables — already excellent), `file.ts:123` guidance (real but
36
+ * low-stakes; 72 of 75 sampled sit in backticks beside the symbol name), and
37
+ * mermaid (0 genuine missed opportunities; an instruction would over-trigger).
38
+ *
39
+ * ## Keep in sync
40
+ *
41
+ * The `send_file` tool description (`send-file-mcp.ts`) is the only other
42
+ * Paddock-aware signal in a normal turn. These two are the same message told
43
+ * twice; change one and re-read the other.
44
+ */
45
+ /**
46
+ * The default environment prompt appended to every keeper turn unless the
47
+ * instance overrides or opts out (see `environmentPrompt` in config.ts).
48
+ *
49
+ * Kept verbatim from issue #635's evidence pass. Resist adding a third rule
50
+ * without measurement — several plausible candidates were cut above.
51
+ */
52
+ export const DEFAULT_ENVIRONMENT_PROMPT = `You are running in Paddock, a web app — your replies render as GitHub-Flavored
53
+ Markdown in a browser, not as terminal output.
54
+
55
+ - Show, don't describe. A screenshot, rendering, chart or file the user is meant
56
+ to look at should be sent with \`mcp__paddock__send_file\`, which renders it
57
+ inline. A filesystem path, or "it's up on port 5026", leaves them unable to see
58
+ your evidence — or to catch you misreading it.
59
+ - Make clickable things clickable. Bare URLs autolink; \`#123\` does not — write
60
+ \`[#123](https://github.com/owner/repo/issues/123)\`. Prefer a full URL to a bare
61
+ port number, and link a chat or project rather than quoting its id.`;
@@ -147,6 +147,57 @@ export declare class HerdctlService {
147
147
  private jobsIndexOrNull;
148
148
  private get jobsIndex();
149
149
  constructor(cfg: PaddockConfig);
150
+ /**
151
+ * The environment system prompt to append to a keeper turn (issue #635), or
152
+ * `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
153
+ * treats a falsy `systemPromptAppend` as "don't touch the system prompt", so
154
+ * an opted-out instance is byte-identical to pre-#635 behaviour.
155
+ *
156
+ * Read from `this.cfg` here rather than taken as a per-turn option **on
157
+ * purpose**. Every user-visible turn shape — a human send, a slash command, a
158
+ * scheduled wake, a spawned sub-chat, a `driveMode: batch` trigger — funnels
159
+ * through {@link chat}, {@link chatSession} or {@link runCommand}, so applying
160
+ * it at that choke point makes coverage structural. Threading it through
161
+ * `ChatTurnOptions` instead would make it one more thing five call sites have
162
+ * to remember, which is exactly how `isSidechainMessage` ended up wired into
163
+ * 1 of 5 turn paths.
164
+ *
165
+ * It is orthogonal to `nativeSystemPrompt`: herdctl appends to whatever prompt
166
+ * the agent has, so this rides on top of the native preset AND of Paddock's
167
+ * terse replace prompt (see `buildSystemPrompt` in core's sdk-runtime, which
168
+ * folds an append into a preset's `append` field or concatenates onto a
169
+ * literal string).
170
+ */
171
+ private get environmentPromptAppend();
172
+ /**
173
+ * The same value for the **CLI** runtime (`driveMode: batch`), where it is NOT
174
+ * always safe to send.
175
+ *
176
+ * herdctl's CLI runtime has no `--append-system-prompt`; it folds
177
+ * `systemPromptAppend` into the single `--system-prompt` flag
178
+ * (`cli-runtime.ts`), and `--system-prompt` REPLACES Claude Code's preset:
179
+ *
180
+ * agent.system_prompt && append → --system-prompt "<agent>\n\n<append>" ✅ appends
181
+ * append only → --system-prompt "<append>" ❌ REPLACES
182
+ *
183
+ * Paddock sets `agent.system_prompt` only when `nativeSystemPrompt` is false
184
+ * (herdctl-agent-config.ts), so on a DEFAULT batch instance the second branch
185
+ * fires and a two-rule environment note would silently become the agent's
186
+ * entire system prompt — the whole coding preset gone. That is the same
187
+ * replace-vs-append trap #635 warns about, one layer down.
188
+ *
189
+ * So: on the CLI path we send the append only when it will genuinely be
190
+ * appended. A default (native) batch instance is left byte-identical to
191
+ * pre-#635 and simply doesn't get the environment prompt — a missing hint is
192
+ * survivable; a missing coding prompt is not. The SDK runtime has no such
193
+ * problem (it folds the text into the preset's `append` field), and the SDK
194
+ * runtime is what every chat actually uses: `openChatSession` hard-codes it,
195
+ * and `session` is the default drive mode. Batch is the legacy path.
196
+ *
197
+ * Lifting this needs `--append-system-prompt` support in core's CLI runtime;
198
+ * tracked upstream in edspencer/herdctl.
199
+ */
200
+ private get environmentPromptAppendForCli();
150
201
  /**
151
202
  * Construct + initialize the FleetManager against a minimal zero-agent
152
203
  * config (fleet + defaults only). Agents are then registered programmatically