@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.
- package/package.json +1 -1
- package/packages/server/dist/chat-dto.d.ts +3 -2
- package/packages/server/dist/chat-dto.js +9 -1
- package/packages/server/dist/cli/paddock.js +3 -3
- package/packages/server/dist/cli/service/index.d.ts +10 -1
- package/packages/server/dist/cli/service/index.js +43 -5
- package/packages/server/dist/cli/service/ready.d.ts +61 -0
- package/packages/server/dist/cli/service/ready.js +71 -0
- package/packages/server/dist/discover.d.ts +37 -0
- package/packages/server/dist/discover.js +51 -0
- package/packages/server/dist/herdctl.d.ts +2 -1
- package/packages/server/dist/herdctl.js +11 -6
- package/packages/server/dist/last-activity.d.ts +34 -0
- package/packages/server/dist/last-activity.js +178 -0
- package/packages/server/dist/management-ops.js +5 -1
- package/packages/server/dist/routes/discover.js +10 -3
- package/packages/server/dist/self-mcp-types.d.ts +4 -1
- package/packages/server/dist/transcripts.js +5 -3
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/Chip-Db--PJFH.js +2 -0
- package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +2 -0
- package/packages/web/dist/assets/DiscoverView-C4hOB3nr.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-U78M95nW.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-BOrDawoY.js → MarkdownRenderer-DdoNl0Ao.js} +2 -2
- package/packages/web/dist/assets/{ProjectMenu-B_8NYWZr.js → ProjectMenu-BxQu14ze.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-xFWTlADS.js → ProjectRedirect-QuGeN-qx.js} +1 -1
- package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BF65wTqN.js → ProjectsGrid-CUFNVTif.js} +1 -1
- package/packages/web/dist/assets/RootHome-SIxo-xX9.js +2 -0
- package/packages/web/dist/assets/{arc-7GXE70CZ.js → arc-C0DRwCCT.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CAEj_lAw.js → architectureDiagram-3BPJPVTR-Wwpf4cvW.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-wNLONvdQ.js → blockDiagram-GPEHLZMM-DKmCbgWk.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cf7MUqJz.js → c4Diagram-AAUBKEIU-CJ8LiLsj.js} +1 -1
- package/packages/web/dist/assets/channel-DEmtNKeB.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-W18BVHtE.js → chunk-2J33WTMH-C3P-O4C3.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DDJQi9KC.js → chunk-4BX2VUAB-Cs9u7x61.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-L5afa49k.js → chunk-55IACEB6-C1st284U.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Bb0Sw8oK.js → chunk-727SXJPM-BjCAWo7B.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DRrKTv1K.js → chunk-AQP2D5EJ-BP6S9w2Q.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-CIsdDBaT.js → chunk-FMBD7UC4-C3ySTaPs.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DN5ua8KT.js → chunk-ND2GUHAM-eNPD4vzc.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DE1WNShj.js → chunk-QZHKN3VN-BbBpgHkO.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +2 -0
- package/packages/web/dist/assets/{core-DadwNpE4.js → core-ZwDDec0y.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-2FTPTDA6.js → cose-bilkent-S5V4N54A-DiM4eeF5.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-eJ7MdaEX.js → dagre-BM42HDAG-DKIEUM4M.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-D8MZTSkB.js → diagram-2AECGRRQ-CqlX7j8t.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-DVXF3Z-H.js → diagram-5GNKFQAL-Dph5apqo.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-DoXfbQ0i.js → diagram-KO2AKTUF-TW6LyHGt.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CRW0fiqv.js → diagram-LMA3HP47-BxxIIv5a.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DfhYXDqr.js → diagram-OG6HWLK6-B8M0DeV7.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-HG0QTHgA.js → erDiagram-TEJ5UH35-Bfpy6UQa.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-ChVekwUy.js → flowDiagram-I6XJVG4X-DstRzxek.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CeA-6Z-T.js → ganttDiagram-6RSMTGT7-Bg0oFbAl.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-ew2oouNN.js → gitGraphDiagram-PVQCEYII-CX4IozE-.js} +1 -1
- package/packages/web/dist/assets/{index-CbqaEg2j.js → index-ASW1Jl0Y.js} +2 -2
- package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BY9NYdld.js → infoDiagram-5YYISTIA-CG2qCk6u.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CgbdrGBm.js → ishikawaDiagram-YF4QCWOH-CPErvl3a.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BepLFtbU.js → journeyDiagram-JHISSGLW-Bm6krUsf.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-JHMJYGJu.js → kanban-definition-UN3LZRKU-B4CC8CAx.js} +1 -1
- package/packages/web/dist/assets/{linear-Ccw8eVgC.js → linear-CJ1DbrOX.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-lHjASk7x.js → mermaid.core-Dk2kkhf9.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bi5Iq2M4.js → mindmap-definition-RKZ34NQL-CThvf_YD.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-rZuB_P1J.js → pieDiagram-4H26LBE5-DvkHcng6.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BuStI37i.js → quadrantDiagram-W4KKPZXB-CqhrTh9q.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BFElQKEX.js → requirementDiagram-4Y6WPE33-CZykiLym.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-tzdQCr91.js → sankeyDiagram-5OEKKPKP-D-TzCKMx.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-j1koqb6Q.js → sequenceDiagram-3UESZ5HK-C5pIItVu.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B-dUkvgq.js → stateDiagram-AJRCARHV-DE6G2JfB.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BWyq6u64.js → timeline-definition-PNZ67QCA-C1VK2OfU.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B0CRl3xN.js → vennDiagram-CIIHVFJN-CtOozEwa.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-C45bBEPS.js → wardley-L42UT6IY-DvHGK-lb.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DT50TWrW.js → wardleyDiagram-YWT4CUSO-DxKwZ3-G.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C8iBuFGD.js → xychartDiagram-2RQKCTM6-HM4j3l6y.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/Chip-5S5MAayn.js +0 -2
- package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +0 -2
- package/packages/web/dist/assets/DiscoverView-CArICIRO.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +0 -2
- package/packages/web/dist/assets/ProjectView-polknqZw.js +0 -13
- package/packages/web/dist/assets/RootHome-32k1iDh4.js +0 -2
- package/packages/web/dist/assets/Surfaces-DCm19ZAD.js +0 -2
- package/packages/web/dist/assets/channel-U0NZSTs2.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +0 -2
- package/packages/web/dist/assets/index-tvJIh4A9.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +0 -2
package/package.json
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
//
|
|
1536
|
-
|
|
1537
|
-
return
|
|
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;
|