@edspencer/paddock 0.71.0 → 0.71.2

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 (98) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/chat-dto.d.ts +3 -2
  3. package/packages/server/dist/chat-dto.js +9 -1
  4. package/packages/server/dist/cli/args.d.ts +11 -3
  5. package/packages/server/dist/cli/args.js +20 -2
  6. package/packages/server/dist/cli/paddock.js +3 -3
  7. package/packages/server/dist/cli/service/backend.d.ts +33 -0
  8. package/packages/server/dist/cli/service/index.d.ts +10 -1
  9. package/packages/server/dist/cli/service/index.js +167 -7
  10. package/packages/server/dist/cli/service/launchd.d.ts +27 -4
  11. package/packages/server/dist/cli/service/launchd.js +88 -14
  12. package/packages/server/dist/cli/service/ready.d.ts +61 -0
  13. package/packages/server/dist/cli/service/ready.js +71 -0
  14. package/packages/server/dist/cli/service/systemd.d.ts +21 -2
  15. package/packages/server/dist/cli/service/systemd.js +58 -5
  16. package/packages/server/dist/discover.d.ts +37 -0
  17. package/packages/server/dist/discover.js +51 -0
  18. package/packages/server/dist/herdctl.d.ts +2 -1
  19. package/packages/server/dist/herdctl.js +11 -6
  20. package/packages/server/dist/last-activity.d.ts +34 -0
  21. package/packages/server/dist/last-activity.js +178 -0
  22. package/packages/server/dist/management-ops.js +5 -1
  23. package/packages/server/dist/routes/discover.js +10 -3
  24. package/packages/server/dist/self-mcp-types.d.ts +4 -1
  25. package/packages/server/dist/transcripts.js +5 -3
  26. package/packages/server/package.json +1 -1
  27. package/packages/web/dist/assets/Chip-Cp32dDmz.js +2 -0
  28. package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +2 -0
  29. package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +2 -0
  30. package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +2 -0
  31. package/packages/web/dist/assets/{MarkdownRenderer-BOrDawoY.js → MarkdownRenderer-DA5CpdGI.js} +2 -2
  32. package/packages/web/dist/assets/{ProjectMenu-B_8NYWZr.js → ProjectMenu-DXXXtXeh.js} +1 -1
  33. package/packages/web/dist/assets/{ProjectRedirect-xFWTlADS.js → ProjectRedirect-k10uM_63.js} +1 -1
  34. package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +13 -0
  35. package/packages/web/dist/assets/{ProjectsGrid-BF65wTqN.js → ProjectsGrid-DrzZ7R8M.js} +1 -1
  36. package/packages/web/dist/assets/RootHome-rWodzOC8.js +2 -0
  37. package/packages/web/dist/assets/{arc-7GXE70CZ.js → arc-BKAVFR26.js} +1 -1
  38. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CAEj_lAw.js → architectureDiagram-3BPJPVTR-UzIlxaCQ.js} +1 -1
  39. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-wNLONvdQ.js → blockDiagram-GPEHLZMM-Eu4BV7Ea.js} +1 -1
  40. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cf7MUqJz.js → c4Diagram-AAUBKEIU-DR8PZaoa.js} +1 -1
  41. package/packages/web/dist/assets/channel-C-sodtn7.js +2 -0
  42. package/packages/web/dist/assets/{chunk-2J33WTMH-W18BVHtE.js → chunk-2J33WTMH-CgvIIrI1.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-4BX2VUAB-DDJQi9KC.js → chunk-4BX2VUAB-CyJWrZqN.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-55IACEB6-L5afa49k.js → chunk-55IACEB6-DXEnZN1d.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-727SXJPM-Bb0Sw8oK.js → chunk-727SXJPM-Co89QTmp.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DRrKTv1K.js → chunk-AQP2D5EJ-BRxTzfev.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-FMBD7UC4-CIsdDBaT.js → chunk-FMBD7UC4-DUj18CvF.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-ND2GUHAM-DN5ua8KT.js → chunk-ND2GUHAM-BQMMJobN.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-QZHKN3VN-DE1WNShj.js → chunk-QZHKN3VN-COx8LZCe.js} +1 -1
  50. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +2 -0
  51. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +2 -0
  52. package/packages/web/dist/assets/{core-DadwNpE4.js → core-DnpcT0s6.js} +1 -1
  53. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-2FTPTDA6.js → cose-bilkent-S5V4N54A-B-4NadKI.js} +1 -1
  54. package/packages/web/dist/assets/{dagre-BM42HDAG-eJ7MdaEX.js → dagre-BM42HDAG-CuaYJexd.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-2AECGRRQ-D8MZTSkB.js → diagram-2AECGRRQ-C9_anxrA.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-5GNKFQAL-DVXF3Z-H.js → diagram-5GNKFQAL-Ckf3kFlY.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-KO2AKTUF-DoXfbQ0i.js → diagram-KO2AKTUF-BrpUuY5x.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-LMA3HP47-CRW0fiqv.js → diagram-LMA3HP47-B9lyXZXW.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-OG6HWLK6-DfhYXDqr.js → diagram-OG6HWLK6-DT7ro32m.js} +1 -1
  60. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-HG0QTHgA.js → erDiagram-TEJ5UH35-ZZn9bHQh.js} +1 -1
  61. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-ChVekwUy.js → flowDiagram-I6XJVG4X-BxQ82PY_.js} +1 -1
  62. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CeA-6Z-T.js → ganttDiagram-6RSMTGT7-BxliloOH.js} +1 -1
  63. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-ew2oouNN.js → gitGraphDiagram-PVQCEYII-BlwODdH9.js} +1 -1
  64. package/packages/web/dist/assets/{index-CbqaEg2j.js → index-Cx9y6c3f.js} +2 -2
  65. package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
  66. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BY9NYdld.js → infoDiagram-5YYISTIA-nanSBFyG.js} +1 -1
  67. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CgbdrGBm.js → ishikawaDiagram-YF4QCWOH-yZHr_s28.js} +1 -1
  68. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BepLFtbU.js → journeyDiagram-JHISSGLW-BKjDqQEQ.js} +1 -1
  69. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-JHMJYGJu.js → kanban-definition-UN3LZRKU-QusvHqc6.js} +1 -1
  70. package/packages/web/dist/assets/{linear-Ccw8eVgC.js → linear-4xRoD38Z.js} +1 -1
  71. package/packages/web/dist/assets/{mermaid.core-lHjASk7x.js → mermaid.core-CeulYvDv.js} +4 -4
  72. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bi5Iq2M4.js → mindmap-definition-RKZ34NQL-DSjYt2Kp.js} +1 -1
  73. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-rZuB_P1J.js → pieDiagram-4H26LBE5-Ci0gO3CA.js} +1 -1
  74. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BuStI37i.js → quadrantDiagram-W4KKPZXB-y2qHkaHP.js} +1 -1
  75. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BFElQKEX.js → requirementDiagram-4Y6WPE33-D9hiWbad.js} +1 -1
  76. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-tzdQCr91.js → sankeyDiagram-5OEKKPKP-DA3Mvoze.js} +1 -1
  77. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-j1koqb6Q.js → sequenceDiagram-3UESZ5HK-DWACrljw.js} +1 -1
  78. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B-dUkvgq.js → stateDiagram-AJRCARHV-CFGEhoTb.js} +1 -1
  79. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +2 -0
  80. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BWyq6u64.js → timeline-definition-PNZ67QCA-Cjygpd9e.js} +1 -1
  81. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B0CRl3xN.js → vennDiagram-CIIHVFJN-zQnLaxgv.js} +1 -1
  82. package/packages/web/dist/assets/{wardley-L42UT6IY-C45bBEPS.js → wardley-L42UT6IY-DGmt-Lo2.js} +1 -1
  83. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DT50TWrW.js → wardleyDiagram-YWT4CUSO-Dj3vDukN.js} +1 -1
  84. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C8iBuFGD.js → xychartDiagram-2RQKCTM6-C0bGbwbL.js} +1 -1
  85. package/packages/web/dist/index.html +2 -2
  86. package/packages/web/dist/sw.js +1 -1
  87. package/packages/web/dist/assets/Chip-5S5MAayn.js +0 -2
  88. package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +0 -2
  89. package/packages/web/dist/assets/DiscoverView-CArICIRO.js +0 -2
  90. package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +0 -2
  91. package/packages/web/dist/assets/ProjectView-polknqZw.js +0 -13
  92. package/packages/web/dist/assets/RootHome-32k1iDh4.js +0 -2
  93. package/packages/web/dist/assets/Surfaces-DCm19ZAD.js +0 -2
  94. package/packages/web/dist/assets/channel-U0NZSTs2.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +0 -2
  97. package/packages/web/dist/assets/index-tvJIh4A9.css +0 -1
  98. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +0 -2
@@ -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) {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * "Is it actually up?" — the bounded readiness poll the service CLI uses before
3
+ * it claims success (#873, #861).
4
+ *
5
+ * Every service command that starts something has the same failure mode without
6
+ * this: the supervisor accepts the request and returns, so the CLI prints a URL
7
+ * and a cheerful past tense while the server is still binding — or is already
8
+ * crash-looping on a port clash, which `launchctl` and `systemctl` will both
9
+ * happily report as "running" for the ten seconds between restarts. A message
10
+ * that can sit over a crash-loop and still say "running at <url>" is not a
11
+ * status line, it is a guess.
12
+ *
13
+ * So the check is deliberately end-to-end: an HTTP request to the URL a human
14
+ * would open, not a pid lookup. `/api/health` is the probe because it is in
15
+ * `ALWAYS_PUBLIC_PATHS` (`auth.ts`) — it answers whatever `PADDOCK_AUTH_MODE`
16
+ * the installed unit runs with, so this does not quietly stop working for the
17
+ * people who configured a credential.
18
+ *
19
+ * The result is a tri-state on purpose. "I could not confirm it" is a real and
20
+ * common answer — an unusual bind host, a slow first boot — and it is not the
21
+ * same claim as "it failed". Callers print it as a caveat, not an error, and
22
+ * point at the logs.
23
+ */
24
+ /** Where the poll got to when it stopped. */
25
+ export type Readiness = "ready" | "timeout";
26
+ export interface ReadyOptions {
27
+ /** Base URL of the server, e.g. `http://127.0.0.1:7233` — no trailing slash. */
28
+ url: string;
29
+ /** Give up after this long. */
30
+ timeoutMs?: number;
31
+ /** Gap between attempts. */
32
+ intervalMs?: number;
33
+ /** Injected for tests; defaults to the global. */
34
+ fetchImpl?: typeof fetch;
35
+ /** Injected for tests, so a timeout can be proven without waiting for one. */
36
+ now?: () => number;
37
+ sleep?: (ms: number) => Promise<void>;
38
+ }
39
+ /**
40
+ * How long to wait before saying "could not confirm".
41
+ *
42
+ * Generous rather than snappy: this budget is only ever spent on the unhappy
43
+ * path (a ready server answers in well under a second), and the cost of being
44
+ * too short is a false "did not answer" on a cold boot — the single most
45
+ * alarming thing this output could say about a service that is in fact fine.
46
+ */
47
+ export declare const READY_TIMEOUT_MS = 20000;
48
+ /** The same budget as prose, so a message and the clock cannot drift apart. */
49
+ export declare const READY_TIMEOUT_LABEL = "20 seconds";
50
+ /**
51
+ * Poll `<url>/api/health` until it answers, or until `timeoutMs` elapses.
52
+ *
53
+ * Connection errors are the expected case rather than the exceptional one — a
54
+ * server that has not finished binding refuses the connection — so they are
55
+ * swallowed and retried. Only the clock ends the loop.
56
+ *
57
+ * Each request carries its own abort so a hung socket cannot outlive the budget
58
+ * it was given; without it a single connection that opens and never replies
59
+ * would park here well past `timeoutMs`.
60
+ */
61
+ export declare function waitForReady(opts: ReadyOptions): Promise<Readiness>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * "Is it actually up?" — the bounded readiness poll the service CLI uses before
3
+ * it claims success (#873, #861).
4
+ *
5
+ * Every service command that starts something has the same failure mode without
6
+ * this: the supervisor accepts the request and returns, so the CLI prints a URL
7
+ * and a cheerful past tense while the server is still binding — or is already
8
+ * crash-looping on a port clash, which `launchctl` and `systemctl` will both
9
+ * happily report as "running" for the ten seconds between restarts. A message
10
+ * that can sit over a crash-loop and still say "running at <url>" is not a
11
+ * status line, it is a guess.
12
+ *
13
+ * So the check is deliberately end-to-end: an HTTP request to the URL a human
14
+ * would open, not a pid lookup. `/api/health` is the probe because it is in
15
+ * `ALWAYS_PUBLIC_PATHS` (`auth.ts`) — it answers whatever `PADDOCK_AUTH_MODE`
16
+ * the installed unit runs with, so this does not quietly stop working for the
17
+ * people who configured a credential.
18
+ *
19
+ * The result is a tri-state on purpose. "I could not confirm it" is a real and
20
+ * common answer — an unusual bind host, a slow first boot — and it is not the
21
+ * same claim as "it failed". Callers print it as a caveat, not an error, and
22
+ * point at the logs.
23
+ */
24
+ /**
25
+ * How long to wait before saying "could not confirm".
26
+ *
27
+ * Generous rather than snappy: this budget is only ever spent on the unhappy
28
+ * path (a ready server answers in well under a second), and the cost of being
29
+ * too short is a false "did not answer" on a cold boot — the single most
30
+ * alarming thing this output could say about a service that is in fact fine.
31
+ */
32
+ export const READY_TIMEOUT_MS = 20_000;
33
+ /** The same budget as prose, so a message and the clock cannot drift apart. */
34
+ export const READY_TIMEOUT_LABEL = `${READY_TIMEOUT_MS / 1000} seconds`;
35
+ const DEFAULT_INTERVAL_MS = 250;
36
+ /**
37
+ * Poll `<url>/api/health` until it answers, or until `timeoutMs` elapses.
38
+ *
39
+ * Connection errors are the expected case rather than the exceptional one — a
40
+ * server that has not finished binding refuses the connection — so they are
41
+ * swallowed and retried. Only the clock ends the loop.
42
+ *
43
+ * Each request carries its own abort so a hung socket cannot outlive the budget
44
+ * it was given; without it a single connection that opens and never replies
45
+ * would park here well past `timeoutMs`.
46
+ */
47
+ export async function waitForReady(opts) {
48
+ const { url, timeoutMs = READY_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS, fetchImpl = fetch, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = opts;
49
+ const deadline = now() + timeoutMs;
50
+ for (;;) {
51
+ const remaining = deadline - now();
52
+ if (remaining <= 0)
53
+ return "timeout";
54
+ const abort = new AbortController();
55
+ const timer = setTimeout(() => abort.abort(), Math.min(remaining, timeoutMs));
56
+ try {
57
+ const res = await fetchImpl(`${url}/api/health`, { signal: abort.signal });
58
+ if (res.ok)
59
+ return "ready";
60
+ }
61
+ catch {
62
+ // Refused, reset, aborted — all "not yet", all retried until the clock says stop.
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ if (deadline - now() <= 0)
68
+ return "timeout";
69
+ await sleep(intervalMs);
70
+ }
71
+ }
@@ -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() {
@@ -141,6 +141,43 @@ export interface DiscoverContext {
141
141
  }
142
142
  /** The ephemeral roots on THIS machine — the well-known set plus `$TMPDIR`. */
143
143
  export declare function defaultTempRoots(): string[];
144
+ /**
145
+ * Drop the transcript folders paddock planted itself, BEFORE anything is counted
146
+ * (issue #865).
147
+ *
148
+ * Paddock bridges each workspace's `.chats` into its Claude home as a symlink at
149
+ * boot — the root workspace's and the sweeper's exist on an instance nobody has
150
+ * touched yet. They are transcript folders by every structural test, so the scan
151
+ * saw two of them, excluded both as `no-recorded-cwd` (they are empty), and
152
+ * reported `scanned: 2`. Reporting them as *scanned* is the bug: `scanned === 0`
153
+ * is what the UI keys "no Claude Code history on this machine" off, and paddock's
154
+ * own bookkeeping is not the user's history.
155
+ *
156
+ * ## The test, and why it is this one
157
+ *
158
+ * A folder is paddock's own when it resolves INTO paddock's own storage
159
+ * (`ownRoots`: the projects root and the data dir) while resolving OUTSIDE every
160
+ * Claude home. Both halves are load-bearing:
161
+ *
162
+ * - the second half alone would drop everything — a real folder physically
163
+ * lives in `<claudeHome>/projects/`, which is itself inside the data dir;
164
+ * - the first half alone would drop the user's OWN mirrored folders (#620),
165
+ * which resolve into `~/.claude` — inside `legacyClaudeHome`, and exactly the
166
+ * history this feature exists to offer.
167
+ *
168
+ * So: resolves into a claude home ⇒ the user's, keep. Resolves into paddock's
169
+ * storage but no claude home ⇒ a `.chats` bridge paddock made, drop.
170
+ *
171
+ * Note this is deliberately NOT another {@link DiscoverExclusion}. An exclusion
172
+ * is a directory the user has that we are declining to offer, and it is counted
173
+ * and shown as such; these are folders that were never theirs to begin with.
174
+ */
175
+ export declare function withoutOwnTranscriptFolders(folders: TranscriptFolder[], roots: {
176
+ /** Paddock's own storage: the projects root and the data dir. */
177
+ ownRoots: string[];
178
+ /** Both Claude homes — paddock's and the user's. */
179
+ claudeHomes: string[];
180
+ }): TranscriptFolder[];
144
181
  /**
145
182
  * Is this an ephemeral scratch directory?
146
183
  *
@@ -61,6 +61,18 @@
61
61
  *
62
62
  * Surviving directories are ranked by non-noise session count, then by recency.
63
63
  *
64
+ * ## Paddock's own folders never enter the scan at all (#865)
65
+ *
66
+ * {@link withoutOwnTranscriptFolders} runs BEFORE any of this, and it is not one
67
+ * more exclusion rule: a folder it drops is not counted in `scanned` either.
68
+ * That distinction is the whole point. Paddock plants a symlink into its Claude
69
+ * home for each workspace's `.chats` at boot, so a machine that has never run
70
+ * Claude Code reported `scanned: 2, excluded: {"no-recorded-cwd": 2}` — and
71
+ * `scanned === 0`, the one signal that separates "no history here" from "all of
72
+ * it was filtered out", was therefore unreachable in the case it was written
73
+ * for. The UI then told a first-time user their history was "already a project,
74
+ * or was filtered out", on a machine with no history at all.
75
+ *
64
76
  * ## Why counts are reported rather than dropped
65
77
  *
66
78
  * `DiscoverResult.excluded` carries how many directories each rule ate. Without it a
@@ -96,6 +108,45 @@ const TEMP_PATH_ROOTS = [
96
108
  export function defaultTempRoots() {
97
109
  return [...TEMP_PATH_ROOTS, os.tmpdir()];
98
110
  }
111
+ /**
112
+ * Drop the transcript folders paddock planted itself, BEFORE anything is counted
113
+ * (issue #865).
114
+ *
115
+ * Paddock bridges each workspace's `.chats` into its Claude home as a symlink at
116
+ * boot — the root workspace's and the sweeper's exist on an instance nobody has
117
+ * touched yet. They are transcript folders by every structural test, so the scan
118
+ * saw two of them, excluded both as `no-recorded-cwd` (they are empty), and
119
+ * reported `scanned: 2`. Reporting them as *scanned* is the bug: `scanned === 0`
120
+ * is what the UI keys "no Claude Code history on this machine" off, and paddock's
121
+ * own bookkeeping is not the user's history.
122
+ *
123
+ * ## The test, and why it is this one
124
+ *
125
+ * A folder is paddock's own when it resolves INTO paddock's own storage
126
+ * (`ownRoots`: the projects root and the data dir) while resolving OUTSIDE every
127
+ * Claude home. Both halves are load-bearing:
128
+ *
129
+ * - the second half alone would drop everything — a real folder physically
130
+ * lives in `<claudeHome>/projects/`, which is itself inside the data dir;
131
+ * - the first half alone would drop the user's OWN mirrored folders (#620),
132
+ * which resolve into `~/.claude` — inside `legacyClaudeHome`, and exactly the
133
+ * history this feature exists to offer.
134
+ *
135
+ * So: resolves into a claude home ⇒ the user's, keep. Resolves into paddock's
136
+ * storage but no claude home ⇒ a `.chats` bridge paddock made, drop.
137
+ *
138
+ * Note this is deliberately NOT another {@link DiscoverExclusion}. An exclusion
139
+ * is a directory the user has that we are declining to offer, and it is counted
140
+ * and shown as such; these are folders that were never theirs to begin with.
141
+ */
142
+ export function withoutOwnTranscriptFolders(folders, roots) {
143
+ return folders.filter((folder) => {
144
+ const real = folder.realPath;
145
+ if (roots.claudeHomes.some((home) => isPathInside(real, home)))
146
+ return true;
147
+ return !roots.ownRoots.some((root) => isPathInside(real, root));
148
+ });
149
+ }
99
150
  /**
100
151
  * Is this an ephemeral scratch directory?
101
152
  *
@@ -53,6 +53,7 @@ import { type PaddockTrigger } from "./trigger-config.js";
53
53
  import { type McpSources } from "./claude-mcp.js";
54
54
  import { type HostPluginSource } from "./claude-plugins.js";
55
55
  import { type AdoptableSummary, type FilterReason, type TranscriptFolder } from "./adoptable.js";
56
+ import { type SessionWithActivity } from "./last-activity.js";
56
57
  /**
57
58
  * One session an import did NOT bring in, with a reason a UI can show verbatim.
58
59
  *
@@ -869,7 +870,7 @@ export declare class HerdctlService {
869
870
  * zero added cost unless the project actually declares hooks. The extra per-hook
870
871
  * scans only kick in for a project that has hook chats to show.
871
872
  */
872
- listSessions(project: Project): Promise<DiscoveredSession[]>;
873
+ listSessions(project: Project): Promise<SessionWithActivity[]>;
873
874
  /**
874
875
  * The on-disk job-record reads (run history + the unread badge) live in
875
876
  * `./herdctl-jobs.js` as pure functions over `<stateDir>/jobs` (issue #403);
@@ -62,6 +62,7 @@ import * as jobs from "./herdctl-jobs.js";
62
62
  import { JobsDirIndex } from "./herdctl-jobs-index.js";
63
63
  import { AdoptableIndex, } from "./adoptable.js";
64
64
  import { ROOT_KEY } from "./project-paths.js";
65
+ import { withLastActivity } from "./last-activity.js";
65
66
  /**
66
67
  * The name/visibility helpers + tool/model constants are pure (they only read
67
68
  * their arguments, no fleet/cfg/session state), so they live in a sibling module
@@ -1519,9 +1520,9 @@ export class HerdctlService {
1519
1520
  */
1520
1521
  async listSessions(project) {
1521
1522
  const agentNames = visibleProjectAgentNames(project);
1522
- if (agentNames.length === 1)
1523
- return this.manager.getAgentSessions(agentNames[0]);
1524
- const perAgent = await Promise.all(agentNames.map((name) => this.manager.getAgentSessions(name).catch(() => [])));
1523
+ const perAgent = agentNames.length === 1
1524
+ ? [await this.manager.getAgentSessions(agentNames[0])]
1525
+ : await Promise.all(agentNames.map((name) => this.manager.getAgentSessions(name).catch(() => [])));
1525
1526
  const seen = new Set();
1526
1527
  const merged = [];
1527
1528
  for (const list of perAgent) {
@@ -1532,9 +1533,13 @@ export class HerdctlService {
1532
1533
  merged.push(s);
1533
1534
  }
1534
1535
  }
1535
- // ISO-8601 mtimes sort lexicographically in chronological order → descending.
1536
- merged.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : 0));
1537
- return merged;
1536
+ // Recency, from the last real MESSAGE in each transcript rather than from
1537
+ // its file mtime (#863) — and this is also where the single-agent fast path
1538
+ // stopped being free. It used to return herdctl's own mtime-descending
1539
+ // order untouched; that order is the bug, so the common case has to be
1540
+ // re-sorted too. What it costs is a memoised tail read per chat, which is
1541
+ // why `lastMessageAt` seeks to the end rather than parsing.
1542
+ return withLastActivity(project.dir, merged);
1538
1543
  }
1539
1544
  /**
1540
1545
  * The on-disk job-record reads (run history + the unread badge) live in
@@ -0,0 +1,34 @@
1
+ import type { DiscoveredSession } from "@herdctl/core";
2
+ /**
3
+ * A session plus the timestamp of its last real message.
4
+ *
5
+ * A widening rather than a replacement, because `mtime` still has a job:
6
+ * it is the cache key for auto-name, preview, sidechain detection and usage
7
+ * throughout this package and inside herdctl. Overwriting it with the corrected
8
+ * value would fix the display and silently break every one of those caches —
9
+ * they would stop noticing that a file had changed.
10
+ */
11
+ export interface SessionWithActivity extends DiscoveredSession {
12
+ /** ISO 8601 timestamp of the last real message; absent if none was datable. */
13
+ lastMessageAt?: string;
14
+ }
15
+ /**
16
+ * Timestamp of the last real message in a project chat's transcript.
17
+ *
18
+ * `undefined` — a missing file, an unreadable one, a transcript of nothing but
19
+ * control records — is a legitimate answer meaning "nothing better than the
20
+ * mtime", and every caller treats it that way.
21
+ */
22
+ export declare function lastMessageAt(projectDir: string, sessionId: string): Promise<string | undefined>;
23
+ /**
24
+ * Attach `lastMessageAt` to each session and order them newest-first.
25
+ *
26
+ * The sort lives here rather than in the client because the client is not the
27
+ * only reader: the MCP `list_chats` tool and the project-detail payload both
28
+ * take the server's order as given. The comparison falls back to `mtime`
29
+ * per-session rather than for the list as a whole, so a chat with no datable
30
+ * record still sorts sensibly against ones that have.
31
+ */
32
+ export declare function withLastActivity(projectDir: string, sessions: readonly DiscoveredSession[]): Promise<SessionWithActivity[]>;
33
+ /** Epoch ms a session should be ordered by: its last message, else its mtime. */
34
+ export declare function activityMs(s: SessionWithActivity): number;