@edspencer/paddock 0.71.0 → 0.71.1

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 (91) 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/paddock.js +3 -3
  5. package/packages/server/dist/cli/service/index.d.ts +10 -1
  6. package/packages/server/dist/cli/service/index.js +43 -5
  7. package/packages/server/dist/cli/service/ready.d.ts +61 -0
  8. package/packages/server/dist/cli/service/ready.js +71 -0
  9. package/packages/server/dist/discover.d.ts +37 -0
  10. package/packages/server/dist/discover.js +51 -0
  11. package/packages/server/dist/herdctl.d.ts +2 -1
  12. package/packages/server/dist/herdctl.js +11 -6
  13. package/packages/server/dist/last-activity.d.ts +34 -0
  14. package/packages/server/dist/last-activity.js +178 -0
  15. package/packages/server/dist/management-ops.js +5 -1
  16. package/packages/server/dist/routes/discover.js +10 -3
  17. package/packages/server/dist/self-mcp-types.d.ts +4 -1
  18. package/packages/server/dist/transcripts.js +5 -3
  19. package/packages/server/package.json +1 -1
  20. package/packages/web/dist/assets/Chip-Db--PJFH.js +2 -0
  21. package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +2 -0
  22. package/packages/web/dist/assets/DiscoverView-C4hOB3nr.js +2 -0
  23. package/packages/web/dist/assets/InstanceConfigPage-U78M95nW.js +2 -0
  24. package/packages/web/dist/assets/{MarkdownRenderer-BOrDawoY.js → MarkdownRenderer-DdoNl0Ao.js} +2 -2
  25. package/packages/web/dist/assets/{ProjectMenu-B_8NYWZr.js → ProjectMenu-BxQu14ze.js} +1 -1
  26. package/packages/web/dist/assets/{ProjectRedirect-xFWTlADS.js → ProjectRedirect-QuGeN-qx.js} +1 -1
  27. package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +13 -0
  28. package/packages/web/dist/assets/{ProjectsGrid-BF65wTqN.js → ProjectsGrid-CUFNVTif.js} +1 -1
  29. package/packages/web/dist/assets/RootHome-SIxo-xX9.js +2 -0
  30. package/packages/web/dist/assets/{arc-7GXE70CZ.js → arc-C0DRwCCT.js} +1 -1
  31. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CAEj_lAw.js → architectureDiagram-3BPJPVTR-Wwpf4cvW.js} +1 -1
  32. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-wNLONvdQ.js → blockDiagram-GPEHLZMM-DKmCbgWk.js} +1 -1
  33. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cf7MUqJz.js → c4Diagram-AAUBKEIU-CJ8LiLsj.js} +1 -1
  34. package/packages/web/dist/assets/channel-DEmtNKeB.js +2 -0
  35. package/packages/web/dist/assets/{chunk-2J33WTMH-W18BVHtE.js → chunk-2J33WTMH-C3P-O4C3.js} +1 -1
  36. package/packages/web/dist/assets/{chunk-4BX2VUAB-DDJQi9KC.js → chunk-4BX2VUAB-Cs9u7x61.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-55IACEB6-L5afa49k.js → chunk-55IACEB6-C1st284U.js} +1 -1
  38. package/packages/web/dist/assets/{chunk-727SXJPM-Bb0Sw8oK.js → chunk-727SXJPM-BjCAWo7B.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DRrKTv1K.js → chunk-AQP2D5EJ-BP6S9w2Q.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-FMBD7UC4-CIsdDBaT.js → chunk-FMBD7UC4-C3ySTaPs.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-ND2GUHAM-DN5ua8KT.js → chunk-ND2GUHAM-eNPD4vzc.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-QZHKN3VN-DE1WNShj.js → chunk-QZHKN3VN-BbBpgHkO.js} +1 -1
  43. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +2 -0
  44. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +2 -0
  45. package/packages/web/dist/assets/{core-DadwNpE4.js → core-ZwDDec0y.js} +1 -1
  46. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-2FTPTDA6.js → cose-bilkent-S5V4N54A-DiM4eeF5.js} +1 -1
  47. package/packages/web/dist/assets/{dagre-BM42HDAG-eJ7MdaEX.js → dagre-BM42HDAG-DKIEUM4M.js} +1 -1
  48. package/packages/web/dist/assets/{diagram-2AECGRRQ-D8MZTSkB.js → diagram-2AECGRRQ-CqlX7j8t.js} +1 -1
  49. package/packages/web/dist/assets/{diagram-5GNKFQAL-DVXF3Z-H.js → diagram-5GNKFQAL-Dph5apqo.js} +1 -1
  50. package/packages/web/dist/assets/{diagram-KO2AKTUF-DoXfbQ0i.js → diagram-KO2AKTUF-TW6LyHGt.js} +1 -1
  51. package/packages/web/dist/assets/{diagram-LMA3HP47-CRW0fiqv.js → diagram-LMA3HP47-BxxIIv5a.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-OG6HWLK6-DfhYXDqr.js → diagram-OG6HWLK6-B8M0DeV7.js} +1 -1
  53. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-HG0QTHgA.js → erDiagram-TEJ5UH35-Bfpy6UQa.js} +1 -1
  54. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-ChVekwUy.js → flowDiagram-I6XJVG4X-DstRzxek.js} +1 -1
  55. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CeA-6Z-T.js → ganttDiagram-6RSMTGT7-Bg0oFbAl.js} +1 -1
  56. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-ew2oouNN.js → gitGraphDiagram-PVQCEYII-CX4IozE-.js} +1 -1
  57. package/packages/web/dist/assets/{index-CbqaEg2j.js → index-ASW1Jl0Y.js} +2 -2
  58. package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
  59. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BY9NYdld.js → infoDiagram-5YYISTIA-CG2qCk6u.js} +1 -1
  60. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CgbdrGBm.js → ishikawaDiagram-YF4QCWOH-CPErvl3a.js} +1 -1
  61. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BepLFtbU.js → journeyDiagram-JHISSGLW-Bm6krUsf.js} +1 -1
  62. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-JHMJYGJu.js → kanban-definition-UN3LZRKU-B4CC8CAx.js} +1 -1
  63. package/packages/web/dist/assets/{linear-Ccw8eVgC.js → linear-CJ1DbrOX.js} +1 -1
  64. package/packages/web/dist/assets/{mermaid.core-lHjASk7x.js → mermaid.core-Dk2kkhf9.js} +4 -4
  65. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bi5Iq2M4.js → mindmap-definition-RKZ34NQL-CThvf_YD.js} +1 -1
  66. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-rZuB_P1J.js → pieDiagram-4H26LBE5-DvkHcng6.js} +1 -1
  67. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BuStI37i.js → quadrantDiagram-W4KKPZXB-CqhrTh9q.js} +1 -1
  68. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BFElQKEX.js → requirementDiagram-4Y6WPE33-CZykiLym.js} +1 -1
  69. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-tzdQCr91.js → sankeyDiagram-5OEKKPKP-D-TzCKMx.js} +1 -1
  70. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-j1koqb6Q.js → sequenceDiagram-3UESZ5HK-C5pIItVu.js} +1 -1
  71. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B-dUkvgq.js → stateDiagram-AJRCARHV-DE6G2JfB.js} +1 -1
  72. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.js +2 -0
  73. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BWyq6u64.js → timeline-definition-PNZ67QCA-C1VK2OfU.js} +1 -1
  74. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B0CRl3xN.js → vennDiagram-CIIHVFJN-CtOozEwa.js} +1 -1
  75. package/packages/web/dist/assets/{wardley-L42UT6IY-C45bBEPS.js → wardley-L42UT6IY-DvHGK-lb.js} +1 -1
  76. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DT50TWrW.js → wardleyDiagram-YWT4CUSO-DxKwZ3-G.js} +1 -1
  77. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C8iBuFGD.js → xychartDiagram-2RQKCTM6-HM4j3l6y.js} +1 -1
  78. package/packages/web/dist/index.html +2 -2
  79. package/packages/web/dist/sw.js +1 -1
  80. package/packages/web/dist/assets/Chip-5S5MAayn.js +0 -2
  81. package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +0 -2
  82. package/packages/web/dist/assets/DiscoverView-CArICIRO.js +0 -2
  83. package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +0 -2
  84. package/packages/web/dist/assets/ProjectView-polknqZw.js +0 -13
  85. package/packages/web/dist/assets/RootHome-32k1iDh4.js +0 -2
  86. package/packages/web/dist/assets/Surfaces-DCm19ZAD.js +0 -2
  87. package/packages/web/dist/assets/channel-U0NZSTs2.js +0 -2
  88. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +0 -2
  90. package/packages/web/dist/assets/index-tvJIh4A9.css +0 -1
  91. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.71.0",
3
+ "version": "0.71.1",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -6,6 +6,7 @@
6
6
  * and the run-history page-size constants + `clampRunsLimit`.
7
7
  */
8
8
  import type { DiscoveredSession } from "@herdctl/core";
9
+ import type { SessionWithActivity } from "./last-activity.js";
9
10
  import type { Project } from "./projects.js";
10
11
  import { type SessionTokenUsage } from "./usage.js";
11
12
  import type { RunProvenance } from "./run-provenance.js";
@@ -138,7 +139,7 @@ export type ChatUsageScope = (typeof CHAT_USAGE_SCOPES)[number];
138
139
  * each class separately (see {@link estimateCostUsd}).
139
140
  */
140
141
  export declare function toChatUsage(u: SessionTokenUsage, model: string): ChatUsage | null;
141
- export declare function toChatDto(s: DiscoveredSession, previewOverride?: string, usage?: ChatUsage | null, archived?: boolean, lastTurnCompletedAt?: string, lastSeen?: number, provenance?: RunProvenance | null, trigger?: ChatTriggerInfo | null, starred?: boolean, unread?: boolean, parent?: ChatParentRef | null): {
142
+ export declare function toChatDto(s: SessionWithActivity, previewOverride?: string, usage?: ChatUsage | null, archived?: boolean, lastTurnCompletedAt?: string, lastSeen?: number, provenance?: RunProvenance | null, trigger?: ChatTriggerInfo | null, starred?: boolean, unread?: boolean, parent?: ChatParentRef | null): {
142
143
  trigger?: ChatTriggerInfo | undefined;
143
144
  parent?: ChatParentRef | undefined;
144
145
  provenance?: RunProvenance | undefined;
@@ -170,7 +171,7 @@ export declare function toChatDto(s: DiscoveredSession, previewOverride?: string
170
171
  * name reflects the user's actual request. Only preload chats trigger the extra
171
172
  * (head-of-file) read; everything else maps straight through.
172
173
  */
173
- export declare function buildProjectChats(projectDir: string, sessions: DiscoveredSession[], usageOf?: (s: DiscoveredSession) => Promise<ChatUsage | null>, archivedOf?: (s: DiscoveredSession) => Promise<boolean>, lastTurnAt?: ReadonlyMap<string, string>, lastSeenOf?: (s: DiscoveredSession) => Promise<number>, provenanceOf?: (s: DiscoveredSession) => Promise<RunProvenance | undefined>, triggerOf?: (s: DiscoveredSession) => Promise<ChatTriggerInfo | undefined>, starredOf?: (s: DiscoveredSession) => Promise<boolean>, unreadOf?: (s: DiscoveredSession) => Promise<boolean>, parentOf?: (s: DiscoveredSession) => Promise<ChatParentRef | null>): Promise<{
174
+ export declare function buildProjectChats(projectDir: string, sessions: SessionWithActivity[], usageOf?: (s: DiscoveredSession) => Promise<ChatUsage | null>, archivedOf?: (s: DiscoveredSession) => Promise<boolean>, lastTurnAt?: ReadonlyMap<string, string>, lastSeenOf?: (s: DiscoveredSession) => Promise<number>, provenanceOf?: (s: DiscoveredSession) => Promise<RunProvenance | undefined>, triggerOf?: (s: DiscoveredSession) => Promise<ChatTriggerInfo | undefined>, starredOf?: (s: DiscoveredSession) => Promise<boolean>, unreadOf?: (s: DiscoveredSession) => Promise<boolean>, parentOf?: (s: DiscoveredSession) => Promise<ChatParentRef | null>): Promise<{
174
175
  trigger?: ChatTriggerInfo | undefined;
175
176
  parent?: ChatParentRef | undefined;
176
177
  provenance?: RunProvenance | undefined;
@@ -185,7 +185,15 @@ export function toChatDto(s, previewOverride, usage, archived = false, lastTurnC
185
185
  sessionId: s.sessionId,
186
186
  workingDirectory: s.workingDirectory,
187
187
  name: s.customName ?? s.autoName ?? preview ?? s.sessionId.slice(0, 8),
188
- updatedAt: s.mtime,
188
+ // The last real MESSAGE in the transcript, not the file's mtime (#863).
189
+ // Paddock touches transcripts for reasons that are not conversation — a
190
+ // discovery re-stat, a resume appending a mode record — and every one of
191
+ // those used to restamp an idle chat as "updated a few minutes ago" and
192
+ // float it to the top of a list sorted by recency. mtime survives as the
193
+ // fallback for a transcript with no datable record, and only as that: it is
194
+ // still the cache key for auto-name, preview and usage, so it is read here
195
+ // rather than overwritten upstream.
196
+ updatedAt: s.lastMessageAt ?? s.mtime,
189
197
  resumable: s.resumable,
190
198
  preview,
191
199
  // Whether this chat is filed away in the Archived section (#95). Always
@@ -155,7 +155,7 @@ function openBrowser(url) {
155
155
  * for npx's cache, because a unit file pointing into a prunable hash-keyed
156
156
  * directory rots at some future login with nobody watching.
157
157
  */
158
- function service(command) {
158
+ async function service(command) {
159
159
  const { action, opts } = command;
160
160
  // Unreachable: `parseCommand` only omits the action when `--help` was given,
161
161
  // which `main` has already handled. Typed rather than asserted.
@@ -164,7 +164,7 @@ function service(command) {
164
164
  return;
165
165
  }
166
166
  try {
167
- runService(action, opts, {
167
+ await runService(action, opts, {
168
168
  platform: process.platform,
169
169
  nodePath: process.execPath,
170
170
  scriptPath: entryScript,
@@ -210,7 +210,7 @@ async function main() {
210
210
  if (problem !== undefined)
211
211
  fail(problem);
212
212
  if (command.verb === "service") {
213
- service(command);
213
+ await service(command);
214
214
  return;
215
215
  }
216
216
  addBundledBinsToPath();
@@ -1,6 +1,7 @@
1
1
  import { type CliOptions, type ServiceAction } from "../args.js";
2
2
  import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
3
3
  import { type Runner } from "./backend.js";
4
+ import { type Readiness } from "./ready.js";
4
5
  export interface ServiceContext {
5
6
  /** `process.platform`. */
6
7
  platform: NodeJS.Platform;
@@ -17,8 +18,16 @@ export interface ServiceContext {
17
18
  xdgConfigHome?: string;
18
19
  pathEnv?: string;
19
20
  run?: Runner;
21
+ /**
22
+ * "Did the URL answer?", injected for the same reason `run` is.
23
+ *
24
+ * Without a seam here every install test would really poll 127.0.0.1 for the
25
+ * full timeout and then assert on the disappointed message — twenty seconds
26
+ * per case, and green for the wrong reason.
27
+ */
28
+ checkReady?: (url: string) => Promise<Readiness>;
20
29
  }
21
- export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): void;
30
+ export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): Promise<void>;
22
31
  /** Exported for the entrypoint's context assembly, and for tests. */
23
32
  export { serviceDir, isNpxPath };
24
33
  export type { ServiceSpec };
@@ -29,6 +29,7 @@ import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFr
29
29
  import { spawnRunner } from "./backend.js";
30
30
  import { createLaunchdBackend } from "./launchd.js";
31
31
  import { createSystemdBackend } from "./systemd.js";
32
+ import { waitForReady, READY_TIMEOUT_LABEL } from "./ready.js";
32
33
  /**
33
34
  * Pick a backend, or explain why there isn't one.
34
35
  *
@@ -76,19 +77,56 @@ function specFor(opts, ctx) {
76
77
  const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots — it runs as\n" +
77
78
  " you, so it can use the Claude login you already have. After a restart\n" +
78
79
  " that nobody logs into, Paddock is not running.";
79
- function install(opts, ctx, backend) {
80
+ /**
81
+ * Say what is about to happen, before the part that looks like a hang (#861).
82
+ *
83
+ * `backend.install` runs `launchctl bootout` / `bootstrap` / `kickstart` — or
84
+ * the systemd equivalents — through `spawnSync`, and on macOS the bootstrap is
85
+ * followed by Background-Task-Management registering the new LaunchAgent as a
86
+ * login item (the "… can run in the background" notification fires in this
87
+ * window). Three to five seconds, and every one of them silent.
88
+ *
89
+ * **There is no spinner here, and that is not laziness.** Those subprocess calls
90
+ * are synchronous: they block the event loop, so a `setInterval` ticker would
91
+ * sit frozen for exactly the span it was added to animate — worse than no
92
+ * spinner, because a stopped spinner reads as a crashed process. Animating it
93
+ * would mean making the whole `Runner` seam async, which is a much larger change
94
+ * than this issue is asking for. The defect reported was the SILENCE, and text
95
+ * on the screen fixes the silence.
96
+ */
97
+ function installProgress(backend) {
98
+ const kind = backend.platform === "darwin" ? "the LaunchAgent" : "the user service";
99
+ return backend.platform === "darwin"
100
+ ? `\n Installing ${kind} and starting Paddock…\n` +
101
+ " macOS may take a few seconds to approve the background item."
102
+ : `\n Installing ${kind} and starting Paddock…`;
103
+ }
104
+ async function install(opts, ctx, backend) {
80
105
  if (isNpxPath(ctx.packageRoot))
81
106
  throw new CliError(NPX_REFUSAL);
82
107
  const { spec, dataDir } = specFor(opts, ctx);
83
- backend.install(spec);
84
108
  const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
109
+ console.log(installProgress(backend));
110
+ backend.install(spec);
111
+ // The unit is written and the supervisor has accepted it. That is NOT the
112
+ // same as "Paddock is running", which is what this block used to assert
113
+ // before anything had been checked — so the success message could sit over a
114
+ // crash-loop (a port clash restarts every RestartSec/ThrottleInterval, and
115
+ // both supervisors report `running` for most of that window) and still print
116
+ // a URL and a tick. Waiting is what makes the headline true.
117
+ console.log(` Waiting for ${url} to answer…`);
118
+ const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
85
119
  const kind = backend.platform === "darwin" ? "a launchd LaunchAgent" : "a systemd --user service";
86
120
  const linger = backend.lingerNote();
87
121
  console.log([
88
122
  "",
89
- ` Paddock is installed as ${kind}, and running now.`,
123
+ readiness === "ready"
124
+ ? ` ✓ Paddock is installed as ${kind}, and running at ${url}`
125
+ : ` Paddock is installed as ${kind}, but ${url} did not answer\n` +
126
+ ` within ${READY_TIMEOUT_LABEL}. The unit is in place; it may still be\n` +
127
+ " coming up, or it may be failing to start — the logs below say which.",
90
128
  "",
91
- ` URL: ${url}`,
129
+ ...(readiness === "ready" ? [] : [` URL: ${url}`]),
92
130
  ` Data: ${dataDir}`,
93
131
  ` Unit: ${backend.unitPath}`,
94
132
  backend.logsHint(spec),
@@ -160,7 +198,7 @@ function status(opts, ctx, backend) {
160
198
  "",
161
199
  ].join("\n"));
162
200
  }
163
- export function runService(action, opts, ctx) {
201
+ export async function runService(action, opts, ctx) {
164
202
  const backend = backendFor(ctx);
165
203
  if (action === "install")
166
204
  return install(opts, ctx, backend);
@@ -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
+ }
@@ -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;