@edspencer/paddock 0.71.1 → 0.72.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 (113) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +27 -0
  3. package/packages/server/dist/cli/args.d.ts +67 -8
  4. package/packages/server/dist/cli/args.js +213 -33
  5. package/packages/server/dist/cli/config.d.ts +157 -0
  6. package/packages/server/dist/cli/config.js +462 -0
  7. package/packages/server/dist/cli/paddock.js +53 -3
  8. package/packages/server/dist/cli/service/backend.d.ts +33 -0
  9. package/packages/server/dist/cli/service/index.d.ts +3 -3
  10. package/packages/server/dist/cli/service/index.js +124 -2
  11. package/packages/server/dist/cli/service/launchd.d.ts +27 -4
  12. package/packages/server/dist/cli/service/launchd.js +88 -14
  13. package/packages/server/dist/cli/service/systemd.d.ts +21 -2
  14. package/packages/server/dist/cli/service/systemd.js +58 -5
  15. package/packages/server/dist/config.d.ts +36 -1
  16. package/packages/server/dist/config.js +59 -44
  17. package/packages/server/dist/herdctl.d.ts +45 -0
  18. package/packages/server/dist/herdctl.js +58 -4
  19. package/packages/server/dist/instance-config.d.ts +149 -21
  20. package/packages/server/dist/instance-config.js +228 -5
  21. package/packages/server/dist/last-activity.d.ts +21 -0
  22. package/packages/server/dist/last-activity.js +7 -2
  23. package/packages/server/dist/profiles.d.ts +154 -0
  24. package/packages/server/dist/profiles.js +214 -0
  25. package/packages/server/dist/routes/transcripts.d.ts +61 -0
  26. package/packages/server/dist/routes/transcripts.js +676 -0
  27. package/packages/server/dist/routes.d.ts +3 -0
  28. package/packages/server/dist/routes.js +2 -0
  29. package/packages/server/dist/transcript-move.d.ts +62 -0
  30. package/packages/server/dist/transcript-move.js +179 -0
  31. package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
  32. package/packages/server/dist/transcripts-migration-execute.js +630 -0
  33. package/packages/server/dist/transcripts-migration.d.ts +336 -0
  34. package/packages/server/dist/transcripts-migration.js +917 -0
  35. package/packages/server/dist/transcripts.d.ts +46 -2
  36. package/packages/server/dist/transcripts.js +126 -3
  37. package/packages/server/package.json +1 -1
  38. package/packages/web/dist/assets/DiscoverPage-DXI9FjmJ.js +2 -0
  39. package/packages/web/dist/assets/DiscoverView-D9oqVkT4.js +2 -0
  40. package/packages/web/dist/assets/InstanceConfigPage-DyH1j5oR.js +2 -0
  41. package/packages/web/dist/assets/{MarkdownRenderer-DdoNl0Ao.js → MarkdownRenderer-D95ofnQ8.js} +1 -1
  42. package/packages/web/dist/assets/ProjectMenu-BO70tGwy.js +2 -0
  43. package/packages/web/dist/assets/{ProjectRedirect-QuGeN-qx.js → ProjectRedirect-BxX_aADU.js} +1 -1
  44. package/packages/web/dist/assets/ProjectView-BbCyKoh5.js +13 -0
  45. package/packages/web/dist/assets/{ProjectsGrid-CUFNVTif.js → ProjectsGrid-BafOHL43.js} +1 -1
  46. package/packages/web/dist/assets/RootHome-BGkMypUJ.js +2 -0
  47. package/packages/web/dist/assets/Surfaces-B8SyV4r3.js +2 -0
  48. package/packages/web/dist/assets/{arc-C0DRwCCT.js → arc-DGyr--oL.js} +1 -1
  49. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BNczNNmx.js +37 -0
  50. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DKmCbgWk.js → blockDiagram-GPEHLZMM-DiTvGSrV.js} +1 -1
  51. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CJ8LiLsj.js → c4Diagram-AAUBKEIU-CKZ_jy81.js} +1 -1
  52. package/packages/web/dist/assets/channel-CjwDi1A9.js +2 -0
  53. package/packages/web/dist/assets/{chunk-2J33WTMH-C3P-O4C3.js → chunk-2J33WTMH-5o96lthu.js} +1 -1
  54. package/packages/web/dist/assets/{chunk-4BX2VUAB-Cs9u7x61.js → chunk-4BX2VUAB-38_pZfd8.js} +1 -1
  55. package/packages/web/dist/assets/{chunk-55IACEB6-C1st284U.js → chunk-55IACEB6-Db73LvlE.js} +1 -1
  56. package/packages/web/dist/assets/{chunk-727SXJPM-BjCAWo7B.js → chunk-727SXJPM-C-Q1-wUu.js} +1 -1
  57. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BP6S9w2Q.js → chunk-AQP2D5EJ-ClJigITk.js} +1 -1
  58. package/packages/web/dist/assets/{chunk-FMBD7UC4-C3ySTaPs.js → chunk-FMBD7UC4-BVzSX2hw.js} +1 -1
  59. package/packages/web/dist/assets/{chunk-ND2GUHAM-eNPD4vzc.js → chunk-ND2GUHAM-DJlL-ELa.js} +1 -1
  60. package/packages/web/dist/assets/{chunk-QZHKN3VN-BbBpgHkO.js → chunk-QZHKN3VN-BC0QEk6w.js} +1 -1
  61. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CxiNtY3i.js +2 -0
  62. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CxiNtY3i.js +2 -0
  63. package/packages/web/dist/assets/{core-ZwDDec0y.js → core-VUdoTe9w.js} +1 -1
  64. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-DVqArcBy.js +2 -0
  65. package/packages/web/dist/assets/{dagre-BM42HDAG-DKIEUM4M.js → dagre-BM42HDAG-BgFGyUdv.js} +1 -1
  66. package/packages/web/dist/assets/{diagram-2AECGRRQ-CqlX7j8t.js → diagram-2AECGRRQ-BqdZmJt3.js} +1 -1
  67. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dph5apqo.js → diagram-5GNKFQAL-DeLPu8jT.js} +1 -1
  68. package/packages/web/dist/assets/{diagram-KO2AKTUF-TW6LyHGt.js → diagram-KO2AKTUF-BqTiTGay.js} +1 -1
  69. package/packages/web/dist/assets/{diagram-LMA3HP47-BxxIIv5a.js → diagram-LMA3HP47-CbpAvCgl.js} +1 -1
  70. package/packages/web/dist/assets/{diagram-OG6HWLK6-B8M0DeV7.js → diagram-OG6HWLK6-C4h0V97s.js} +1 -1
  71. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Bfpy6UQa.js → erDiagram-TEJ5UH35-Dwz77uRx.js} +1 -1
  72. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DstRzxek.js → flowDiagram-I6XJVG4X-DSikJnZR.js} +1 -1
  73. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-DkAGNZb3.js +293 -0
  74. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CX4IozE-.js → gitGraphDiagram-PVQCEYII-BQtzt9hP.js} +1 -1
  75. package/packages/web/dist/assets/index-BN3rLMpD.js +71 -0
  76. package/packages/web/dist/assets/index-Dfzb1DvL.css +1 -0
  77. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CG2qCk6u.js → infoDiagram-5YYISTIA-YcaBUjeC.js} +1 -1
  78. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CPErvl3a.js → ishikawaDiagram-YF4QCWOH-BfLZB88u.js} +1 -1
  79. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bm6krUsf.js → journeyDiagram-JHISSGLW-DwaFhd6C.js} +1 -1
  80. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-B4CC8CAx.js → kanban-definition-UN3LZRKU-e9P3WxsD.js} +1 -1
  81. package/packages/web/dist/assets/{linear-CJ1DbrOX.js → linear-CsXS5LTz.js} +1 -1
  82. package/packages/web/dist/assets/{mermaid.core-Dk2kkhf9.js → mermaid.core-DsA72Qyi.js} +4 -4
  83. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CThvf_YD.js → mindmap-definition-RKZ34NQL-DyLOITDI.js} +1 -1
  84. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DvkHcng6.js → pieDiagram-4H26LBE5-C36aM3D-.js} +1 -1
  85. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-CqhrTh9q.js → quadrantDiagram-W4KKPZXB-Cq1zTk6a.js} +1 -1
  86. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZykiLym.js → requirementDiagram-4Y6WPE33-B6MctUk7.js} +1 -1
  87. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D-TzCKMx.js → sankeyDiagram-5OEKKPKP-CaNRUoPR.js} +1 -1
  88. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C5pIItVu.js → sequenceDiagram-3UESZ5HK-DDj_tZiQ.js} +1 -1
  89. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DE6G2JfB.js → stateDiagram-AJRCARHV-DDaMlLyK.js} +1 -1
  90. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bu6Swk2T.js +2 -0
  91. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C1VK2OfU.js → timeline-definition-PNZ67QCA-bMP-7Wam.js} +1 -1
  92. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CtOozEwa.js → vennDiagram-CIIHVFJN-9nzKgc0Y.js} +1 -1
  93. package/packages/web/dist/assets/{wardley-L42UT6IY-DvHGK-lb.js → wardley-L42UT6IY-DIWbAFW5.js} +1 -1
  94. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DxKwZ3-G.js → wardleyDiagram-YWT4CUSO-CVL3skbs.js} +1 -1
  95. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-HM4j3l6y.js → xychartDiagram-2RQKCTM6-DHoVPvll.js} +1 -1
  96. package/packages/web/dist/index.html +2 -2
  97. package/packages/web/dist/sw.js +1 -1
  98. package/packages/web/dist/assets/Chip-Db--PJFH.js +0 -2
  99. package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +0 -2
  100. package/packages/web/dist/assets/DiscoverView-C4hOB3nr.js +0 -2
  101. package/packages/web/dist/assets/InstanceConfigPage-U78M95nW.js +0 -2
  102. package/packages/web/dist/assets/ProjectMenu-BxQu14ze.js +0 -2
  103. package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +0 -13
  104. package/packages/web/dist/assets/RootHome-SIxo-xX9.js +0 -2
  105. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Wwpf4cvW.js +0 -37
  106. package/packages/web/dist/assets/channel-DEmtNKeB.js +0 -2
  107. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +0 -2
  108. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +0 -2
  109. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-DiM4eeF5.js +0 -2
  110. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-Bg0oFbAl.js +0 -293
  111. package/packages/web/dist/assets/index-ASW1Jl0Y.js +0 -71
  112. package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
  113. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.js +0 -2
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `paddock service install | uninstall | status` (#796).
2
+ * `paddock service install | uninstall | status | start | stop | restart`
3
+ * (#796; the lifecycle three added in #873).
3
4
  *
4
5
  * Registers Paddock as a **per-user** background service — a launchd
5
6
  * LaunchAgent on macOS, a `systemd --user` unit on Linux — so it is up from
@@ -26,7 +27,7 @@ import os from "node:os";
26
27
  import path from "node:path";
27
28
  import { CliError } from "../args.js";
28
29
  import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, } from "./spec.js";
29
- import { spawnRunner } from "./backend.js";
30
+ import { spawnRunner, } from "./backend.js";
30
31
  import { createLaunchdBackend } from "./launchd.js";
31
32
  import { createSystemdBackend } from "./systemd.js";
32
33
  import { waitForReady, READY_TIMEOUT_LABEL } from "./ready.js";
@@ -77,6 +78,25 @@ function specFor(opts, ctx) {
77
78
  const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots — it runs as\n" +
78
79
  " you, so it can use the Claude login you already have. After a restart\n" +
79
80
  " that nobody logs into, Paddock is not running.";
81
+ /**
82
+ * What `status` says about a unit installed before #872.
83
+ *
84
+ * Upgrading the package leaves the unit file alone, so the people still holding
85
+ * the broken shape are exactly the people who will never notice — the symptom
86
+ * is Paddock being absent one morning, with nothing in the logs, because it was
87
+ * signalled rather than crashed. Naming the fix here is the only path from that
88
+ * symptom back to a working service.
89
+ */
90
+ function staleUnitNote(backend) {
91
+ const key = backend.platform === "darwin"
92
+ ? "KeepAlive is conditional on a non-zero exit"
93
+ : "Restart=on-failure";
94
+ return (` ⚠ This unit predates a fix (#872): ${key}, so a graceful stop —\n` +
95
+ " the kind sleep, logout or a stray `kill` sends — puts Paddock down\n" +
96
+ " until you start it again by hand. Re-run to update the unit:\n" +
97
+ "\n" +
98
+ " paddock service install");
99
+ }
80
100
  /**
81
101
  * Say what is about to happen, before the part that looks like a hang (#861).
82
102
  *
@@ -135,6 +155,7 @@ async function install(opts, ctx, backend) {
135
155
  ...(linger !== undefined ? ["", linger] : []),
136
156
  "",
137
157
  " paddock service status is it up, and where",
158
+ " paddock service restart bounce it after editing config",
138
159
  " paddock service uninstall stop it and remove the unit",
139
160
  "",
140
161
  ].join("\n"));
@@ -194,16 +215,117 @@ function status(opts, ctx, backend) {
194
215
  backend.logsHint(spec),
195
216
  "",
196
217
  AT_LOGIN,
218
+ ...(state.keepsAlive ? [] : ["", staleUnitNote(backend)]),
197
219
  ...(linger !== undefined && !state.running ? ["", linger] : []),
198
220
  "",
199
221
  ].join("\n"));
200
222
  }
223
+ /**
224
+ * The URL an installed unit is actually serving, or undefined if none is.
225
+ *
226
+ * Read out of the unit rather than recomputed from today's flags, for the same
227
+ * reason `status` does it: the port in the file is the port the server is on.
228
+ */
229
+ function installedUrl(state) {
230
+ return `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
231
+ }
232
+ /** "It isn't installed" — the same answer, wherever it is asked. */
233
+ function notInstalled(backend) {
234
+ return [
235
+ "",
236
+ " Paddock is not installed as a service.",
237
+ ` Looked for: ${backend.unitPath}`,
238
+ "",
239
+ " paddock service install register it and start it now",
240
+ "",
241
+ ].join("\n");
242
+ }
243
+ /**
244
+ * `start` / `restart` (#873), which differ only in what they tolerate.
245
+ *
246
+ * `start` on something already running is reported and not acted on — asking
247
+ * launchd to bootstrap a loaded job is an error, and "it is already up" is the
248
+ * outcome the user wanted anyway. `restart` has no such case: bouncing a
249
+ * stopped service is a legitimate way to start it, and the sequence each
250
+ * backend uses handles both states.
251
+ *
252
+ * Both then WAIT. Neither supervisor's "started" means the port answers — it
253
+ * means the fork succeeded — so printing a URL on the strength of it produces a
254
+ * success message that can sit over a crash-loop (a port clash restarts every
255
+ * `ThrottleInterval`, and `launchctl print` says `running` for most of that
256
+ * window). The wait is what makes the last line true.
257
+ */
258
+ async function startOrRestart(action, ctx, backend) {
259
+ const before = backend.status();
260
+ if (!before.registered) {
261
+ console.log(notInstalled(backend));
262
+ return;
263
+ }
264
+ const url = installedUrl(before);
265
+ if (action === "start" && before.running) {
266
+ console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
267
+ return;
268
+ }
269
+ console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
270
+ if (action === "start")
271
+ backend.start();
272
+ else
273
+ backend.restart();
274
+ const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
275
+ const after = backend.status();
276
+ console.log([
277
+ "",
278
+ readiness === "ready"
279
+ ? ` ✓ Paddock is running${pid(after)} at ${url}`
280
+ : ` Paddock was ${action === "start" ? "started" : "restarted"}, but ${url} did not\n` +
281
+ ` answer within ${READY_TIMEOUT_LABEL}. It may still be coming up — check:`,
282
+ ...(readiness === "ready" ? [] : ["", " paddock service status", backend.logsHint(specForLogs(ctx, after))]),
283
+ "",
284
+ ].join("\n"));
285
+ }
286
+ /** `stop` (#873): put it down, leave it installed. */
287
+ function stop(backend) {
288
+ const state = backend.status();
289
+ if (!state.registered) {
290
+ console.log(notInstalled(backend));
291
+ return;
292
+ }
293
+ if (!state.running) {
294
+ console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
295
+ return;
296
+ }
297
+ backend.stop();
298
+ console.log([
299
+ "",
300
+ " Paddock is stopped. The unit is still installed.",
301
+ ` Unit: ${backend.unitPath}`,
302
+ "",
303
+ " It comes back when you log in again, or now with:",
304
+ "",
305
+ " paddock service start",
306
+ "",
307
+ ].join("\n"));
308
+ }
309
+ function pid(state) {
310
+ return state.pid !== undefined ? ` (pid ${state.pid})` : "";
311
+ }
312
+ /** The spec `logsHint` needs, built from the INSTALLED unit's data dir. */
313
+ function specForLogs(ctx, state) {
314
+ const i = state.argv.indexOf("--data-dir");
315
+ const dataDir = i >= 0 && state.argv[i + 1] !== undefined ? state.argv[i + 1] : undefined;
316
+ const { spec } = specFor({ open: false, verbose: false, help: false, version: false }, { ...ctx, ...(dataDir !== undefined ? { envDataDir: dataDir } : {}) });
317
+ return spec;
318
+ }
201
319
  export async function runService(action, opts, ctx) {
202
320
  const backend = backendFor(ctx);
203
321
  if (action === "install")
204
322
  return install(opts, ctx, backend);
205
323
  if (action === "uninstall")
206
324
  return uninstall(ctx, backend);
325
+ if (action === "start" || action === "restart")
326
+ return startOrRestart(action, ctx, backend);
327
+ if (action === "stop")
328
+ return stop(backend);
207
329
  return status(opts, ctx, backend);
208
330
  }
209
331
  /** Exported for the entrypoint's context assembly, and for tests. */
@@ -5,10 +5,22 @@ export declare function plistPath(homeDir?: string): string;
5
5
  /**
6
6
  * Render the plist.
7
7
  *
8
- * `KeepAlive` is a dict rather than `<true/>` on purpose: `SuccessfulExit:
9
- * false` means "restart it if it exits nonzero, leave it alone if it exits
10
- * cleanly". A bare `<true/>` would fight a deliberate `launchctl bootout` and
11
- * make stopping Paddock a wrestling match.
8
+ * `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
9
+ * (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
10
+ * nonzero, leave it alone if it exits cleanly" — which reads like the polite
11
+ * choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
12
+ * down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
13
+ * LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
14
+ * to stop", and Paddock stayed down until the next login. A hard crash was the
15
+ * survivable case; the graceful stop was the terminal one.
16
+ *
17
+ * The worry that motivated the dict — that `<true/>` fights a deliberate stop —
18
+ * does not hold for the stop this CLI actually performs. `launchctl bootout`
19
+ * unloads the job from the domain, and KeepAlive only applies to a job that is
20
+ * still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
21
+ * does fight is `launchctl stop`, which is why nothing here uses it.
22
+ *
23
+ * Intent belongs in an explicit command, not in an exit code.
12
24
  *
13
25
  * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
14
26
  * launchd hands a process a stub PATH that a version manager's `node`, and the
@@ -32,6 +44,17 @@ export declare function renderPlist(spec: ServiceSpec): string;
32
44
  * would be the larger risk.
33
45
  */
34
46
  export declare function parsePlistArgv(xml: string): string[];
47
+ /**
48
+ * Does an installed plist carry the unconditional `KeepAlive` from #872?
49
+ *
50
+ * Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
51
+ * and nothing else. The shape it must NOT accept is the old
52
+ * `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
53
+ * leaves a service dead after a graceful stop — so a `false` here is the signal
54
+ * `status` turns into "re-run install", and defaulting to `true` on anything
55
+ * unrecognised would silence exactly the case this exists to catch.
56
+ */
57
+ export declare function plistKeepsAlive(xml: string): boolean;
35
58
  /**
36
59
  * Read liveness out of `launchctl print gui/<uid>/<label>`.
37
60
  *
@@ -51,10 +51,22 @@ function xmlEscape(s) {
51
51
  /**
52
52
  * Render the plist.
53
53
  *
54
- * `KeepAlive` is a dict rather than `<true/>` on purpose: `SuccessfulExit:
55
- * false` means "restart it if it exits nonzero, leave it alone if it exits
56
- * cleanly". A bare `<true/>` would fight a deliberate `launchctl bootout` and
57
- * make stopping Paddock a wrestling match.
54
+ * `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
55
+ * (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
56
+ * nonzero, leave it alone if it exits cleanly" — which reads like the polite
57
+ * choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
58
+ * down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
59
+ * LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
60
+ * to stop", and Paddock stayed down until the next login. A hard crash was the
61
+ * survivable case; the graceful stop was the terminal one.
62
+ *
63
+ * The worry that motivated the dict — that `<true/>` fights a deliberate stop —
64
+ * does not hold for the stop this CLI actually performs. `launchctl bootout`
65
+ * unloads the job from the domain, and KeepAlive only applies to a job that is
66
+ * still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
67
+ * does fight is `launchctl stop`, which is why nothing here uses it.
68
+ *
69
+ * Intent belongs in an explicit command, not in an exit code.
58
70
  *
59
71
  * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
60
72
  * launchd hands a process a stub PATH that a version manager's `node`, and the
@@ -82,10 +94,7 @@ ${strings}
82
94
  <key>RunAtLoad</key>
83
95
  <true/>
84
96
  <key>KeepAlive</key>
85
- <dict>
86
- <key>SuccessfulExit</key>
87
- <false/>
88
- </dict>
97
+ <true/>
89
98
  <key>ThrottleInterval</key>
90
99
  <integer>10</integer>
91
100
  <key>WorkingDirectory</key>
@@ -123,6 +132,19 @@ export function parsePlistArgv(xml) {
123
132
  .replace(/&lt;/g, "<")
124
133
  .replace(/&amp;/g, "&"));
125
134
  }
135
+ /**
136
+ * Does an installed plist carry the unconditional `KeepAlive` from #872?
137
+ *
138
+ * Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
139
+ * and nothing else. The shape it must NOT accept is the old
140
+ * `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
141
+ * leaves a service dead after a graceful stop — so a `false` here is the signal
142
+ * `status` turns into "re-run install", and defaulting to `true` on anything
143
+ * unrecognised would silence exactly the case this exists to catch.
144
+ */
145
+ export function plistKeepsAlive(xml) {
146
+ return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
147
+ }
126
148
  /**
127
149
  * Read liveness out of `launchctl print gui/<uid>/<label>`.
128
150
  *
@@ -139,6 +161,10 @@ export function parseLaunchctlPrint(out) {
139
161
  ...(pid !== null ? { pid: pid[1] } : {}),
140
162
  };
141
163
  }
164
+ /** One phrasing for "launchctl said no", so every caller reports it the same way. */
165
+ function launchctlFailure(verb, r) {
166
+ return (`launchctl ${verb} failed (exit ${String(r.status)}).\n` + `${r.stderr || r.stdout}`.trim());
167
+ }
142
168
  function target() {
143
169
  return `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`;
144
170
  }
@@ -164,10 +190,8 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
164
190
  // they are shims over this one that report success in cases where it
165
191
  // reports the actual error.
166
192
  const boot = run("launchctl", ["bootstrap", domain(), file]);
167
- if (boot.status !== 0) {
168
- throw new Error(`launchctl bootstrap failed (exit ${String(boot.status)}).\n` +
169
- `${boot.stderr || boot.stdout}`.trim());
170
- }
193
+ if (boot.status !== 0)
194
+ throw new Error(launchctlFailure("bootstrap", boot));
171
195
  // `RunAtLoad` should already have started it; `kickstart -k` makes that
172
196
  // true rather than probable, and is the same command a later restart uses.
173
197
  run("launchctl", ["kickstart", "-k", target()]);
@@ -179,10 +203,59 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
179
203
  fs.rmSync(file, { force: true });
180
204
  return existed;
181
205
  },
206
+ /**
207
+ * Load the job back into the GUI domain and make sure it is running (#873).
208
+ *
209
+ * `bootstrap`, not `launchctl start`: `stop` below BOOTS OUT, so a stopped
210
+ * Paddock is not merely idle, it is absent from the domain — and `start` on
211
+ * a label launchd has never heard of fails. Bootstrapping is also what a
212
+ * login does, so `service start` and the next login take the same path.
213
+ */
214
+ start() {
215
+ const boot = run("launchctl", ["bootstrap", domain(), file]);
216
+ if (boot.status !== 0)
217
+ throw new Error(launchctlFailure("bootstrap", boot));
218
+ run("launchctl", ["kickstart", target()]);
219
+ },
220
+ /**
221
+ * `bootout`, not `launchctl stop`.
222
+ *
223
+ * With #872's unconditional `KeepAlive`, `stop` is a request launchd
224
+ * immediately overrules — it stops the process and then relaunches it,
225
+ * which is exactly the wrestling match the old conditional KeepAlive was
226
+ * meant to avoid. `bootout` unloads the job, so KeepAlive no longer applies
227
+ * and it stays down. The plist is untouched, so `status` still reports it
228
+ * registered, `start` puts it back, and the next LOGIN starts it again via
229
+ * `RunAtLoad` — a stop is for this session, not forever. Uninstall is the
230
+ * one that is forever.
231
+ */
232
+ stop() {
233
+ const out = run("launchctl", ["bootout", target()]);
234
+ // Exit 3 / "No such process" is launchd for "it was not loaded", which is
235
+ // the state the caller asked for. Anything else is worth surfacing.
236
+ if (out.status !== 0 && !/no such process/i.test(`${out.stderr}${out.stdout}`)) {
237
+ throw new Error(launchctlFailure("bootout", out));
238
+ }
239
+ },
240
+ /**
241
+ * Boot out, bootstrap, kick. Not `kickstart -k` alone, which needs the job
242
+ * to be loaded and so fails on the case `restart` most needs to handle: a
243
+ * service that is stopped. This sequence is identical to `install`'s minus
244
+ * writing the plist, which also gives `restart` the property the issue asks
245
+ * for — the unit is re-read on the way up, so an edited plist takes effect.
246
+ */
247
+ restart() {
248
+ run("launchctl", ["bootout", target()]);
249
+ const boot = run("launchctl", ["bootstrap", domain(), file]);
250
+ if (boot.status !== 0)
251
+ throw new Error(launchctlFailure("bootstrap", boot));
252
+ run("launchctl", ["kickstart", "-k", target()]);
253
+ },
182
254
  status() {
183
255
  if (!fs.existsSync(file))
184
- return { registered: false, running: false, argv: [] };
185
- const argv = parsePlistArgv(fs.readFileSync(file, "utf8"));
256
+ return { registered: false, running: false, argv: [], keepsAlive: true };
257
+ const xml = fs.readFileSync(file, "utf8");
258
+ const argv = parsePlistArgv(xml);
186
259
  const printed = run("launchctl", ["print", target()]);
187
260
  const parsed = printed.status === 0
188
261
  ? parseLaunchctlPrint(printed.stdout)
@@ -194,6 +267,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
194
267
  // Strip the interpreter and script: what a reader wants from `status`
195
268
  // is the Paddock invocation, not the absolute path to node twice.
196
269
  argv: argv.slice(2),
270
+ keepsAlive: plistKeepsAlive(xml),
197
271
  };
198
272
  },
199
273
  logsHint(spec) {
@@ -31,8 +31,17 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
31
31
  /**
32
32
  * Render the unit.
33
33
  *
34
- * `Restart=on-failure` rather than `always`, matching launchd's `SuccessfulExit:
35
- * false` — a clean shutdown should stay shut down.
34
+ * `Restart=always`, matching launchd's `KeepAlive=<true/>` (#872). It was
35
+ * `on-failure`, which for a service is the wrong default for the same reason it
36
+ * was wrong there: `start.ts` exits `0` on SIGTERM, so a logout — or anything
37
+ * else that signals the user manager — was read as an intended stop and Paddock
38
+ * stayed down.
39
+ *
40
+ * `always` does not make the unit unstoppable. systemd never restarts a unit it
41
+ * was itself asked to stop, so `systemctl --user stop` (and therefore
42
+ * `uninstall`) remains the clean, explicit way to put it down. That is the
43
+ * difference between an exit code, which has to guess at intent, and a command,
44
+ * which carries it.
36
45
  *
37
46
  * `WantedBy=default.target` is what makes `systemctl --user enable` mean
38
47
  * "start at login" for a user unit; `multi-user.target` is the system-unit
@@ -43,6 +52,16 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
43
52
  * answers to "where are the logs".
44
53
  */
45
54
  export declare function renderUnit(spec: ServiceSpec): string;
55
+ /**
56
+ * Does an installed unit carry `Restart=always` (#872)?
57
+ *
58
+ * The counterpart to `plistKeepsAlive`, and narrow for the same reason: the
59
+ * value it has to reject is the `on-failure` this file used to write, which
60
+ * leaves Paddock down after the graceful exit `start.ts` performs on SIGTERM.
61
+ * Anything unrecognised reads as "not the fixed shape", which at worst tells a
62
+ * hand-edited unit's owner to re-run install.
63
+ */
64
+ export declare function unitKeepsAlive(text: string): boolean;
46
65
  /** Read `ExecStart=` back out of an installed unit, split into an argv. */
47
66
  export declare function parseUnitArgv(text: string): string[];
48
67
  export declare function createSystemdBackend(run: Runner, homeDir?: string, xdgConfigHome?: string): ServiceBackend;
@@ -62,8 +62,17 @@ export function unitPath(homeDir, xdgConfigHome) {
62
62
  /**
63
63
  * Render the unit.
64
64
  *
65
- * `Restart=on-failure` rather than `always`, matching launchd's `SuccessfulExit:
66
- * false` — a clean shutdown should stay shut down.
65
+ * `Restart=always`, matching launchd's `KeepAlive=<true/>` (#872). It was
66
+ * `on-failure`, which for a service is the wrong default for the same reason it
67
+ * was wrong there: `start.ts` exits `0` on SIGTERM, so a logout — or anything
68
+ * else that signals the user manager — was read as an intended stop and Paddock
69
+ * stayed down.
70
+ *
71
+ * `always` does not make the unit unstoppable. systemd never restarts a unit it
72
+ * was itself asked to stop, so `systemctl --user stop` (and therefore
73
+ * `uninstall`) remains the clean, explicit way to put it down. That is the
74
+ * difference between an exit code, which has to guess at intent, and a command,
75
+ * which carries it.
67
76
  *
68
77
  * `WantedBy=default.target` is what makes `systemctl --user enable` mean
69
78
  * "start at login" for a user unit; `multi-user.target` is the system-unit
@@ -84,13 +93,25 @@ Type=simple
84
93
  ExecStart=${exec}
85
94
  WorkingDirectory=${spec.workingDirectory}
86
95
  Environment=PATH=${spec.pathEnv}
87
- Restart=on-failure
96
+ Restart=always
88
97
  RestartSec=10
89
98
 
90
99
  [Install]
91
100
  WantedBy=default.target
92
101
  `;
93
102
  }
103
+ /**
104
+ * Does an installed unit carry `Restart=always` (#872)?
105
+ *
106
+ * The counterpart to `plistKeepsAlive`, and narrow for the same reason: the
107
+ * value it has to reject is the `on-failure` this file used to write, which
108
+ * leaves Paddock down after the graceful exit `start.ts` performs on SIGTERM.
109
+ * Anything unrecognised reads as "not the fixed shape", which at worst tells a
110
+ * hand-edited unit's owner to re-run install.
111
+ */
112
+ export function unitKeepsAlive(text) {
113
+ return /^Restart=always\s*$/m.test(text);
114
+ }
94
115
  /** Read `ExecStart=` back out of an installed unit, split into an argv. */
95
116
  export function parseUnitArgv(text) {
96
117
  const m = /^ExecStart=(.*)$/m.exec(text);
@@ -98,6 +119,14 @@ export function parseUnitArgv(text) {
98
119
  return [];
99
120
  return m[1].trim().split(/\s+/).filter(Boolean);
100
121
  }
122
+ /** Run a `systemctl` and turn a non-zero exit into the manager's own words. */
123
+ function systemctlOrThrow(run, args) {
124
+ const r = run("systemctl", args);
125
+ if (r.status === 0)
126
+ return;
127
+ throw new Error(`systemctl ${args.join(" ")} failed (exit ${String(r.status)}).\n` +
128
+ `${r.stderr || r.stdout}`.trim());
129
+ }
101
130
  export function createSystemdBackend(run, homeDir, xdgConfigHome) {
102
131
  // Both forwarded, neither defaulted here: `unitPath` owns the precedence, and
103
132
  // defaulting `homeDir` to `os.homedir()` at this layer would collapse "the
@@ -129,10 +158,33 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
129
158
  run("systemctl", ["--user", "daemon-reload"]);
130
159
  return existed;
131
160
  },
161
+ /**
162
+ * The lifecycle three (#873), which on systemd are just the verbs.
163
+ *
164
+ * None of them touches `enable`/`disable`: those decide whether Paddock
165
+ * comes back at your NEXT login, which is a different question from whether
166
+ * it is running now, and quietly answering both would make `stop` a
167
+ * surprise the following morning. `uninstall` is where that link is cut.
168
+ *
169
+ * `restart` re-reads the unit itself, but a `daemon-reload` first is what
170
+ * makes an EDITED unit take effect rather than the manager's cached copy —
171
+ * the same reason `install` reloads before enabling.
172
+ */
173
+ start() {
174
+ systemctlOrThrow(run, ["--user", "start", SYSTEMD_UNIT]);
175
+ },
176
+ stop() {
177
+ systemctlOrThrow(run, ["--user", "stop", SYSTEMD_UNIT]);
178
+ },
179
+ restart() {
180
+ run("systemctl", ["--user", "daemon-reload"]);
181
+ systemctlOrThrow(run, ["--user", "restart", SYSTEMD_UNIT]);
182
+ },
132
183
  status() {
133
184
  if (!fs.existsSync(file))
134
- return { registered: false, running: false, argv: [] };
135
- const argv = parseUnitArgv(fs.readFileSync(file, "utf8"));
185
+ return { registered: false, running: false, argv: [], keepsAlive: true };
186
+ const text = fs.readFileSync(file, "utf8");
187
+ const argv = parseUnitArgv(text);
136
188
  const active = run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]);
137
189
  const pid = run("systemctl", ["--user", "show", "-p", "MainPID", "--value", SYSTEMD_UNIT]);
138
190
  const mainPid = pid.stdout.trim();
@@ -141,6 +193,7 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
141
193
  running: active.stdout.trim() === "active",
142
194
  ...(mainPid !== "" && mainPid !== "0" ? { pid: mainPid } : {}),
143
195
  argv: argv.slice(2),
196
+ keepsAlive: unitKeepsAlive(text),
144
197
  };
145
198
  },
146
199
  logsHint() {
@@ -1,4 +1,5 @@
1
1
  import { type DriveMode } from "./models.js";
2
+ import { type ProfileName } from "./profiles.js";
2
3
  import { type TranscriptsMode } from "./transcripts.js";
3
4
  import { type CredentialsMode } from "./claude-credentials.js";
4
5
  import { type InstructionsMode } from "./claude-instructions.js";
@@ -145,6 +146,17 @@ export interface OpenApiConfig {
145
146
  path: string;
146
147
  }
147
148
  export interface PaddockConfig {
149
+ /**
150
+ * The resolved posture profile (#878) — `paranoid` | `balanced` | `yolo`.
151
+ *
152
+ * Reported for display and provenance only; nothing reads it to make a
153
+ * decision. It has already been expanded into the individual posture fields
154
+ * below by the time this object exists, and any of those the operator set
155
+ * explicitly (file key or env var) has overridden the profile's value — so the
156
+ * profile name tells you where the UNSET levers came from, not what every
157
+ * lever is. See `profiles.ts` for the precedence chain.
158
+ */
159
+ profile: ProfileName;
148
160
  /** HTTP/WS port. */
149
161
  port: number;
150
162
  /** Bind host. Defaults to loopback (`127.0.0.1`) — safe by default (#435). */
@@ -485,6 +497,13 @@ export interface PaddockConfigFile {
485
497
  * lenient-parsed. See `schema-version.ts` for when to bump it.
486
498
  */
487
499
  schemaVersion?: number | string;
500
+ /**
501
+ * Posture profile (#878): `paranoid` | `balanced` | `yolo`. Expands to
502
+ * defaults for the `claude:` modes, `maxSpawnDepth` and the capability
503
+ * toggles; any of those set explicitly here or in the env still wins.
504
+ * `PADDOCK_PROFILE` overrides this key. Absent ⇒ `balanced`.
505
+ */
506
+ profile?: string;
488
507
  port?: number | string;
489
508
  host?: string;
490
509
  dataDir?: string;
@@ -680,7 +699,23 @@ export declare function loadConfigFile(dataDir: string): PaddockConfigFile;
680
699
  * installed under an arbitrary user directory (`npx`, global install).
681
700
  */
682
701
  export declare function resolveDefaultWebDist(moduleUrl: string): string;
683
- export declare function loadPaddockConfig(): PaddockConfig;
702
+ /** Options for {@link loadPaddockConfig}. */
703
+ export interface LoadConfigOptions {
704
+ /**
705
+ * Create the data root if it is missing. Default `true` — every caller that
706
+ * goes on to RUN something needs it, and creating it early is what makes the
707
+ * symlink resolution below consistent.
708
+ *
709
+ * `paddock config show` (#878) passes `false`. Inspecting an instance must not
710
+ * bring one into being: otherwise running it on a machine that has never
711
+ * started Paddock leaves an empty `~/.paddock` behind, and the first-run
712
+ * welcome — which keys on that directory's absence — never prints again.
713
+ * Skipping the mkdir costs nothing else, because `canonical()` already handles
714
+ * a path whose tail does not exist yet.
715
+ */
716
+ createDataDir?: boolean;
717
+ }
718
+ export declare function loadPaddockConfig(opts?: LoadConfigOptions): PaddockConfig;
684
719
  /**
685
720
  * The USER's own Claude home — `~/.claude`, always. A read-only source for
686
721
  * adoption and the user-config bridge, never a destination (#620).