@edspencer/paddock 0.70.1 → 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 +2 -2
- package/packages/server/dist/background-live.d.ts +24 -0
- package/packages/server/dist/background-live.js +38 -0
- package/packages/server/dist/chat-dto.d.ts +3 -2
- package/packages/server/dist/chat-dto.js +9 -1
- package/packages/server/dist/cli/args.d.ts +11 -3
- package/packages/server/dist/cli/args.js +33 -7
- 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 +55 -6
- 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 +51 -1
- package/packages/server/dist/herdctl.js +53 -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/chats.js +7 -7
- 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/dist/ws-protocol.d.ts +78 -2
- package/packages/server/dist/ws-protocol.js +9 -0
- package/packages/server/dist/ws.js +38 -0
- 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-DatKu1Pl.js → MarkdownRenderer-DdoNl0Ao.js} +2 -2
- package/packages/web/dist/assets/{ProjectMenu-DJB7uCGt.js → ProjectMenu-BxQu14ze.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-D5Uxki7M.js → ProjectRedirect-QuGeN-qx.js} +1 -1
- package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-CqQTeqdE.js → ProjectsGrid-CUFNVTif.js} +1 -1
- package/packages/web/dist/assets/RootHome-SIxo-xX9.js +2 -0
- package/packages/web/dist/assets/{arc-Bvnrokeb.js → arc-C0DRwCCT.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc5OXCmK.js → architectureDiagram-3BPJPVTR-Wwpf4cvW.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-CFZeUqSH.js → blockDiagram-GPEHLZMM-DKmCbgWk.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BPI5UmpX.js → c4Diagram-AAUBKEIU-CJ8LiLsj.js} +1 -1
- package/packages/web/dist/assets/channel-DEmtNKeB.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CVpthvPT.js → chunk-2J33WTMH-C3P-O4C3.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-cTaBnmzz.js → chunk-4BX2VUAB-Cs9u7x61.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-dkgF9sAS.js → chunk-55IACEB6-C1st284U.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-UiFZch2W.js → chunk-727SXJPM-BjCAWo7B.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-IKsoQNZy.js → chunk-AQP2D5EJ-BP6S9w2Q.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-Tzt_HckZ.js → chunk-FMBD7UC4-C3ySTaPs.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-dbNwUFdT.js → chunk-ND2GUHAM-eNPD4vzc.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-eUsG1OkP.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-Duun4AtS.js → core-ZwDDec0y.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-KF_gNdsd.js → cose-bilkent-S5V4N54A-DiM4eeF5.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-CXFDGeP7.js → dagre-BM42HDAG-DKIEUM4M.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BiefFjo3.js → diagram-2AECGRRQ-CqlX7j8t.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-B1IpANC8.js → diagram-5GNKFQAL-Dph5apqo.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-ChdOA3EK.js → diagram-KO2AKTUF-TW6LyHGt.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BTbLDsXf.js → diagram-LMA3HP47-BxxIIv5a.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-Bqiqt27L.js → diagram-OG6HWLK6-B8M0DeV7.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-KA9WLkV0.js → erDiagram-TEJ5UH35-Bfpy6UQa.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CZKvoeTz.js → flowDiagram-I6XJVG4X-DstRzxek.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-o1QDzlgt.js → ganttDiagram-6RSMTGT7-Bg0oFbAl.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-D5GdOppF.js → gitGraphDiagram-PVQCEYII-CX4IozE-.js} +1 -1
- package/packages/web/dist/assets/{index-B-J2K1w2.js → index-ASW1Jl0Y.js} +11 -11
- package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-21Z9hMO-.js → infoDiagram-5YYISTIA-CG2qCk6u.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-TmbPK40i.js → ishikawaDiagram-YF4QCWOH-CPErvl3a.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DYAWKu-K.js → journeyDiagram-JHISSGLW-Bm6krUsf.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-ZW_ZQ0DQ.js → kanban-definition-UN3LZRKU-B4CC8CAx.js} +1 -1
- package/packages/web/dist/assets/{linear-BDQKnwHn.js → linear-CJ1DbrOX.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BHXnmT7N.js → mermaid.core-Dk2kkhf9.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CHVPYLQs.js → mindmap-definition-RKZ34NQL-CThvf_YD.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-paqmu057.js → pieDiagram-4H26LBE5-DvkHcng6.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-diM_4CGS.js → quadrantDiagram-W4KKPZXB-CqhrTh9q.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DCyNbyIz.js → requirementDiagram-4Y6WPE33-CZykiLym.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DxpEvGOy.js → sankeyDiagram-5OEKKPKP-D-TzCKMx.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BH4-7BdR.js → sequenceDiagram-3UESZ5HK-C5pIItVu.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CVPrwckU.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-B6_yfyHl.js → timeline-definition-PNZ67QCA-C1VK2OfU.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Ic8OogjJ.js → vennDiagram-CIIHVFJN-CtOozEwa.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CuhgMTiv.js → wardley-L42UT6IY-DvHGK-lb.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Djeda9cE.js → wardleyDiagram-YWT4CUSO-DxKwZ3-G.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D3hl8Mdh.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-P4n88XYj.js +0 -2
- package/packages/web/dist/assets/DiscoverPage-B7ap1I1Y.js +0 -2
- package/packages/web/dist/assets/DiscoverView-DxC3mmGp.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-DO87_86Z.js +0 -2
- package/packages/web/dist/assets/ProjectView-D8TqrBW4.js +0 -13
- package/packages/web/dist/assets/RootHome-B0si7ijq.js +0 -2
- package/packages/web/dist/assets/Surfaces-t3XF_my5.js +0 -2
- package/packages/web/dist/assets/channel-BGIf4GoQ.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-6kluxBAR.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-6kluxBAR.js +0 -2
- package/packages/web/dist/assets/index-4ugpmAz4.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CMmy9TKm.js +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edspencer/paddock",
|
|
3
|
-
"version": "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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@fastify/swagger-ui": "4.2.0",
|
|
38
38
|
"@fastify/websocket": "10.0.1",
|
|
39
39
|
"@herdctl/chat": "0.8.0",
|
|
40
|
-
"@herdctl/core": "5.
|
|
40
|
+
"@herdctl/core": "5.33.0",
|
|
41
41
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
42
42
|
"fastify": "4.28.1",
|
|
43
43
|
"jose": "6.2.3",
|
|
@@ -78,6 +78,14 @@ export interface LiveBackgroundTask {
|
|
|
78
78
|
* carried on the wire and filtered at the point of display.
|
|
79
79
|
*/
|
|
80
80
|
skipTranscript?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Can this task be stopped (#848)? Derived from {@link type} alongside
|
|
83
|
+
* {@link role}, at the one boundary that owns SDK task-type knowledge — see
|
|
84
|
+
* {@link UNSTOPPABLE_TASK_TYPES}. Clients gate the stop affordance on this
|
|
85
|
+
* rather than re-deriving it, so the CLI's kill-strategy table has exactly
|
|
86
|
+
* one representation in the codebase.
|
|
87
|
+
*/
|
|
88
|
+
stoppable: boolean;
|
|
81
89
|
}
|
|
82
90
|
export type BackgroundChangeListener = (projectSlug: string, sessionId: string, tasks: LiveBackgroundTask[]) => void;
|
|
83
91
|
/**
|
|
@@ -107,6 +115,22 @@ export declare class BackgroundRegistry {
|
|
|
107
115
|
* stream ends — see the per-process caveat in the module header.
|
|
108
116
|
*/
|
|
109
117
|
clear(sessionId: string): void;
|
|
118
|
+
/**
|
|
119
|
+
* Drop one task the way a terminal edge would, on the word of an out-of-band
|
|
120
|
+
* authority rather than the session's own stream (#848).
|
|
121
|
+
*
|
|
122
|
+
* The single caller is the stop path, for the case where
|
|
123
|
+
* `stopTaskInSession` answers `false` — no live session with that id. There
|
|
124
|
+
* is then no stream left to deliver the `task_notification` this registry
|
|
125
|
+
* normally evicts on, so without this the row would sit at `stopping…`
|
|
126
|
+
* forever describing work that is definitively gone.
|
|
127
|
+
*
|
|
128
|
+
* Safe against being wrong for the same reason {@link evict} is: the level
|
|
129
|
+
* signal remains the sole authority on membership, so a task dropped here
|
|
130
|
+
* that is somehow still running is simply put back by the next
|
|
131
|
+
* `background_tasks_changed`.
|
|
132
|
+
*/
|
|
133
|
+
dropTask(sessionId: string, taskId: string): boolean;
|
|
110
134
|
/**
|
|
111
135
|
* Feed one raw SDK message. Returns true when the live set changed, so the
|
|
112
136
|
* caller can avoid re-broadcasting on the overwhelming majority of messages
|
|
@@ -75,6 +75,25 @@ const TASK_ROLES = {
|
|
|
75
75
|
export function roleForTaskType(type) {
|
|
76
76
|
return TASK_ROLES[type] ?? type;
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Task types the CLI has no kill strategy for, so `stopTask` on one REJECTS
|
|
80
|
+
* with `unsupported_type` rather than being swallowed (#848).
|
|
81
|
+
*
|
|
82
|
+
* Keyed on the RAW discriminant, and {@link TASK_ROLES} directly above is the
|
|
83
|
+
* proof that it has to be: that map sends BOTH `monitor_mcp` and `monitor_ws`
|
|
84
|
+
* to the same `monitor` role, and only the MCP flavour is unkillable. A
|
|
85
|
+
* consumer holding the derived `role` therefore cannot tell the stoppable one
|
|
86
|
+
* from the unstoppable one — so the answer is resolved here, next to the map
|
|
87
|
+
* that erases the distinction, and carried on the wire as `stoppable`.
|
|
88
|
+
*
|
|
89
|
+
* A DENYLIST, not an allowlist, and deliberately so: a task type the SDK adds
|
|
90
|
+
* after this was written gets a stop button, and if the CLI turns out not to
|
|
91
|
+
* support it the rejection surfaces in the row as "can't stop". An allowlist
|
|
92
|
+
* would silently withhold the button from every new stoppable type instead,
|
|
93
|
+
* which is the worse failure — the whole point of the issue is that there was
|
|
94
|
+
* nothing to click.
|
|
95
|
+
*/
|
|
96
|
+
const UNSTOPPABLE_TASK_TYPES = new Set(["monitor_mcp"]);
|
|
78
97
|
const str = (v) => (typeof v === "string" && v ? v : undefined);
|
|
79
98
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
80
99
|
/**
|
|
@@ -134,6 +153,24 @@ export class BackgroundRegistry {
|
|
|
134
153
|
e.tasks.clear();
|
|
135
154
|
this.emit(sessionId, e);
|
|
136
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Drop one task the way a terminal edge would, on the word of an out-of-band
|
|
158
|
+
* authority rather than the session's own stream (#848).
|
|
159
|
+
*
|
|
160
|
+
* The single caller is the stop path, for the case where
|
|
161
|
+
* `stopTaskInSession` answers `false` — no live session with that id. There
|
|
162
|
+
* is then no stream left to deliver the `task_notification` this registry
|
|
163
|
+
* normally evicts on, so without this the row would sit at `stopping…`
|
|
164
|
+
* forever describing work that is definitively gone.
|
|
165
|
+
*
|
|
166
|
+
* Safe against being wrong for the same reason {@link evict} is: the level
|
|
167
|
+
* signal remains the sole authority on membership, so a task dropped here
|
|
168
|
+
* that is somehow still running is simply put back by the next
|
|
169
|
+
* `background_tasks_changed`.
|
|
170
|
+
*/
|
|
171
|
+
dropTask(sessionId, taskId) {
|
|
172
|
+
return this.evict(sessionId, taskId);
|
|
173
|
+
}
|
|
137
174
|
/**
|
|
138
175
|
* Feed one raw SDK message. Returns true when the live set changed, so the
|
|
139
176
|
* caller can avoid re-broadcasting on the overwhelming majority of messages
|
|
@@ -205,6 +242,7 @@ export class BackgroundRegistry {
|
|
|
205
242
|
role: roleForTaskType(type),
|
|
206
243
|
description: str(rt.description) ?? existing?.description ?? "",
|
|
207
244
|
startedAt: existing?.startedAt ?? this.now(),
|
|
245
|
+
stoppable: !UNSTOPPABLE_TASK_TYPES.has(type),
|
|
208
246
|
});
|
|
209
247
|
}
|
|
210
248
|
if (prev && sameSet(prev.tasks, next)) {
|
|
@@ -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
|
|
@@ -71,8 +71,16 @@ export type Command = {
|
|
|
71
71
|
* `paddock service install --port 7299` both work, and the flag grammar is
|
|
72
72
|
* the same one in every position.
|
|
73
73
|
*
|
|
74
|
-
* A verb is only a verb in first position: `paddock --port start` is
|
|
75
|
-
*
|
|
74
|
+
* A verb is only a verb in first position: `paddock --port start` is an error
|
|
75
|
+
* from `--port`, not a `start` invocation, and `paddock start start` is still
|
|
76
|
+
* `unknown option`.
|
|
77
|
+
*
|
|
78
|
+
* That first half used to claim a *missing-value* error, which was wrong (#823):
|
|
79
|
+
* `next()` only rejects `undefined`, so `start` was happily consumed as the port
|
|
80
|
+
* and became `NaN` in `config.ts`. It is now a **value** error — `--port needs a
|
|
81
|
+
* number between 1 and 65535` — which is the behaviour the claim was reaching
|
|
82
|
+
* for. Corrected rather than deleted because the grammar it documents is real
|
|
83
|
+
* and load-bearing; it was only the example's error that was misdescribed.
|
|
76
84
|
*/
|
|
77
85
|
export declare function parseCommand(argv: string[]): Command;
|
|
78
86
|
export declare function parseArgs(argv: string[]): CliOptions;
|
|
@@ -92,4 +100,4 @@ export declare function nodeVersionProblem(nodeVersion: string): string | undefi
|
|
|
92
100
|
*/
|
|
93
101
|
export declare function explainListenError(err: unknown, host: string, port: string): string;
|
|
94
102
|
export declare const USAGE = "paddock \u2014 run a Paddock instance locally\n\nUsage\n npx @edspencer/paddock [options] start the server (the default)\n paddock start [options] the same thing, said out loud\n paddock service <install|uninstall|status>\n run it in the background from login\n (`paddock service --help`)\n\nOptions\n -p, --port <port> HTTP/WS port (default 7233, or $PORT)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)\n -o, --open Open the app in your browser once it is listening\n --verbose Show the server's own logs (quiet by default)\n -v, --version Print the Paddock version and exit\n -h, --help Show this help\n\nOpening your own directories\n Where you run this from does not matter \u2014 the instance is decided by its data\n dir alone. Directories are added inside the app, on the Discover screen: it\n reads your Claude Code history, lists the directories you have worked in, and\n links the ones you tick as projects. A new instance opens on it.\n\n Importing a directory does not write into it. No .paddock/, no .chats/, no\n .gitignore edits, no CLAUDE.md \u2014 the project record and the copied transcripts\n both live in the data dir, and the project just points at the path. Your own\n ~/.claude transcripts are copied, never moved or deleted, so your terminal\n `claude` keeps working exactly as before.\n\nCredentials\n Paddock drives Claude Code, so it needs Claude credentials \u2014 and if you\n already use Claude Code on this machine, it uses the login you already have.\n That is a macOS Keychain entry on a Mac, or your ~/.claude/.credentials.json\n elsewhere (symlinked in, never copied). Reading a login writes nothing.\n\n Otherwise: a CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment,\n or a one-off `CLAUDE_CONFIG_DIR=<data-dir>/claude-home claude login`. With no\n login at all anywhere, run `claude setup-token`. Paddock says at startup when\n it can find none.\n\nSharing your Claude Code state\n Apart from that login, Paddock writes nothing outside its data dir by\n default: transcripts go to each project's .chats/, and your ~/.claude is read\n for config only. Each thing it can share is one key in\n <data-dir>/paddock.config.yaml:\n\n claude:\n transcripts: host # own | host, default own\n credentials: host # own | host, default host\n instructions: host # own | host, default own\n hooks: host # own | host, default own\n mcpServers: host # own | host, default own\n\n transcripts: host makes a chat and a `claude --resume` in the same directory\n the same file, live in both directions; deleting such a chat in Paddock\n releases it rather than removing it, because it is your history rather than\n Paddock's copy. credentials: own is the opt-out from sharing the login above.\n\n instructions: host loads your ~/.claude CLAUDE.md, agents/, commands/ and\n plugins/. Off by default, which is a change: your curated CLAUDE.md does not\n reach Paddock's agents unless you say so. Each project's own CLAUDE.md always\n does.\n\n hooks: host runs the shell commands your ~/.claude/settings.json binds to\n tool use. Off by default \u2014 inheriting someone's hooks is not something to\n discover after the fact. Its other keys (permissions, model, statusline)\n apply either way: under hooks: own Paddock writes its own settings.json\n carrying them with hooks dropped, regenerated at startup.\n\n mcpServers: host attaches the MCP servers declared in your ~/.claude.json \u2014\n the top-level ones, plus a project's own when its directory matches. To give\n this instance a server your machine doesn't have, declare it instead in a\n sibling mcpServers: block of the same file; use env:VAR_NAME anywhere a\n string goes so tokens stay out of it. That keeps a token out of the file, not\n out of `ps`: leave driveMode on its default (session) for a server holding\n one, since batch passes the definition to claude as a command-line argument.\n\nYour data\n Everything lives in one directory \u2014 ~/.paddock unless you pass --data-dir.\n Projects, chat transcripts and settings all persist there between runs. Move\n that directory to move your instance; delete it to start over. Nothing is\n stored anywhere else.\n\nNotes\n Binds loopback with authentication disabled, which is safe for a laptop. To\n expose it on a network, set PADDOCK_AUTH_MODE first \u2014 Paddock refuses to bind\n a routable interface wide open. See AUTH.md.\n\nDocs: https://github.com/edspencer/paddock";
|
|
95
|
-
export declare const SERVICE_USAGE = "paddock service \u2014 keep Paddock running in the background\n\nUsage\n paddock service install [options] register it and start it now\n paddock service uninstall stop it and deregister it\n paddock service status is it registered, is it running, where are the logs\n\nOptions (install only \u2014 recorded in the generated unit)\n -p, --port <port> HTTP/WS port (default 7233)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Only if you want an instance SEPARATE from your\n terminal one.
|
|
103
|
+
export declare const SERVICE_USAGE = "paddock service \u2014 keep Paddock running in the background\n\nUsage\n paddock service install [options] register it and start it now\n paddock service uninstall stop it and deregister it\n paddock service status is it registered, is it running, where are the logs\n\nOptions (install only \u2014 recorded in the generated unit)\n -p, --port <port> HTTP/WS port (default 7233)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Only if you want an instance SEPARATE from your\n terminal one. Left out of the unit when you omit it\n AND `PADDOCK_DATA_DIR` is unset in this shell, so\n `paddock service` and a bare `paddock` are the same\n ~/.paddock instance reached two ways. If\n `PADDOCK_DATA_DIR` IS set here, that path is recorded\n in the unit \u2014 the service would otherwise point\n somewhere your terminal does not.\n --verbose Record the server's own logs, not just warnings\n\nAt login, not at boot\n This registers a per-USER service \u2014 a launchd LaunchAgent on macOS, a\n `systemd --user` unit on Linux \u2014 so it runs as you, with your own Claude\n login. That is not incidental: on macOS your Claude login is a Keychain item\n that only a logged-in user session can read. A boot-time system daemon has no\n such session and could not use it.\n\n So Paddock starts when you LOG IN, not when the machine boots. After a\n restart that nobody logs into, Paddock is not running. That is the design, not\n a fault.\n\n On Linux, a user service is also stopped when you log out. To keep it up:\n\n loginctl enable-linger $USER\n\nWhere it lives\n macOS ~/Library/LaunchAgents/net.edspencer.paddock.plist\n logs in <data-dir>/service/\n Linux ~/.config/systemd/user/paddock.service\n logs via journalctl --user -u paddock.service -f\n\nInstalled from npx?\n `service install` refuses. An npx cache path is hash-keyed and npm may prune\n it, so the unit would work until it silently didn't, at some future login.\n Install properly first:\n\n npm i -g @edspencer/paddock && paddock service install\n\nA note on access\n Paddock binds loopback with authentication off, which is right for a laptop.\n A service is up for as long as you are logged in rather than as long as a\n terminal tab, so that window is longer \u2014 but it is not wider: any local\n process that could reach the port could already read the same Claude login as\n you. Set PADDOCK_AUTH_MODE if you want a credential on it anyway.\n\nDocs: https://github.com/edspencer/paddock";
|
|
@@ -49,8 +49,16 @@ function isServiceAction(token) {
|
|
|
49
49
|
* `paddock service install --port 7299` both work, and the flag grammar is
|
|
50
50
|
* the same one in every position.
|
|
51
51
|
*
|
|
52
|
-
* A verb is only a verb in first position: `paddock --port start` is
|
|
53
|
-
*
|
|
52
|
+
* A verb is only a verb in first position: `paddock --port start` is an error
|
|
53
|
+
* from `--port`, not a `start` invocation, and `paddock start start` is still
|
|
54
|
+
* `unknown option`.
|
|
55
|
+
*
|
|
56
|
+
* That first half used to claim a *missing-value* error, which was wrong (#823):
|
|
57
|
+
* `next()` only rejects `undefined`, so `start` was happily consumed as the port
|
|
58
|
+
* and became `NaN` in `config.ts`. It is now a **value** error — `--port needs a
|
|
59
|
+
* number between 1 and 65535` — which is the behaviour the claim was reaching
|
|
60
|
+
* for. Corrected rather than deleted because the grammar it documents is real
|
|
61
|
+
* and load-bearing; it was only the example's error that was misdescribed.
|
|
54
62
|
*/
|
|
55
63
|
export function parseCommand(argv) {
|
|
56
64
|
const [first, ...rest] = argv;
|
|
@@ -96,9 +104,23 @@ export function parseArgs(argv) {
|
|
|
96
104
|
};
|
|
97
105
|
switch (arg) {
|
|
98
106
|
case "-p":
|
|
99
|
-
case "--port":
|
|
100
|
-
|
|
107
|
+
case "--port": {
|
|
108
|
+
// Validate here rather than letting `Number("start")` become NaN three
|
|
109
|
+
// files later, in `config.ts`, with nothing between the typo and a
|
|
110
|
+
// server asked to listen on NaN (#823).
|
|
111
|
+
//
|
|
112
|
+
// Scoped to NON-EMPTY values on purpose. `paddock --port "$PORT"` with
|
|
113
|
+
// PORT unset passes `""`, which is falsy at the one place that reads it
|
|
114
|
+
// (`paddock.ts`: `if (opts.port)`) and so correctly falls through to the
|
|
115
|
+
// default — a working invocation that a bare `/^\d+$/` guard would turn
|
|
116
|
+
// into a hard error.
|
|
117
|
+
const v = next();
|
|
118
|
+
if (v !== "" && (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 65535)) {
|
|
119
|
+
throw new CliError(`${arg} needs a number between 1 and 65535, got: ${v}`);
|
|
120
|
+
}
|
|
121
|
+
opts.port = v;
|
|
101
122
|
break;
|
|
123
|
+
}
|
|
102
124
|
case "--host":
|
|
103
125
|
opts.host = next();
|
|
104
126
|
break;
|
|
@@ -261,9 +283,13 @@ Options (install only — recorded in the generated unit)
|
|
|
261
283
|
-p, --port <port> HTTP/WS port (default 7233)
|
|
262
284
|
--host <host> Bind address (default 127.0.0.1)
|
|
263
285
|
-d, --data-dir <path> Only if you want an instance SEPARATE from your
|
|
264
|
-
terminal one.
|
|
286
|
+
terminal one. Left out of the unit when you omit it
|
|
287
|
+
AND \`PADDOCK_DATA_DIR\` is unset in this shell, so
|
|
265
288
|
\`paddock service\` and a bare \`paddock\` are the same
|
|
266
|
-
~/.paddock instance reached two ways.
|
|
289
|
+
~/.paddock instance reached two ways. If
|
|
290
|
+
\`PADDOCK_DATA_DIR\` IS set here, that path is recorded
|
|
291
|
+
in the unit — the service would otherwise point
|
|
292
|
+
somewhere your terminal does not.
|
|
267
293
|
--verbose Record the server's own logs, not just warnings
|
|
268
294
|
|
|
269
295
|
At login, not at boot
|
|
@@ -285,7 +311,7 @@ Where it lives
|
|
|
285
311
|
macOS ~/Library/LaunchAgents/net.edspencer.paddock.plist
|
|
286
312
|
logs in <data-dir>/service/
|
|
287
313
|
Linux ~/.config/systemd/user/paddock.service
|
|
288
|
-
logs via journalctl --user -u paddock -f
|
|
314
|
+
logs via journalctl --user -u paddock.service -f
|
|
289
315
|
|
|
290
316
|
Installed from npx?
|
|
291
317
|
\`service install\` refuses. An npx cache path is hash-keyed and npm may prune
|
|
@@ -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),
|
|
@@ -130,7 +168,18 @@ function status(opts, ctx, backend) {
|
|
|
130
168
|
const dataDir = dataFromUnit >= 0 && state.argv[dataFromUnit + 1] !== undefined
|
|
131
169
|
? state.argv[dataFromUnit + 1]
|
|
132
170
|
: defaultDataDir(ctx.homeDir);
|
|
133
|
-
|
|
171
|
+
// Drop `--data-dir` before building the log-path spec (#824). `resolveDataDir`
|
|
172
|
+
// gives `opts.dataDir` precedence over `ctx.envDataDir`, which is right for
|
|
173
|
+
// `install` (see the comment there) and wrong here: this whole block reports
|
|
174
|
+
// the INSTALLED unit, so letting today's flag through printed the unit's data
|
|
175
|
+
// dir beside a `Logs:` path under a directory the service never writes to. On
|
|
176
|
+
// macOS that log path is real and load-bearing, not just cosmetic.
|
|
177
|
+
//
|
|
178
|
+
// `--data-dir` is documented as install-only (SERVICE_USAGE), so ignoring it
|
|
179
|
+
// here matches both the help text and the stated intent of this block.
|
|
180
|
+
const statusOpts = { ...opts };
|
|
181
|
+
delete statusOpts.dataDir;
|
|
182
|
+
const { spec } = specFor(statusOpts, { ...ctx, envDataDir: dataDir });
|
|
134
183
|
const linger = backend.lingerNote();
|
|
135
184
|
console.log([
|
|
136
185
|
"",
|
|
@@ -149,7 +198,7 @@ function status(opts, ctx, backend) {
|
|
|
149
198
|
"",
|
|
150
199
|
].join("\n"));
|
|
151
200
|
}
|
|
152
|
-
export function runService(action, opts, ctx) {
|
|
201
|
+
export async function runService(action, opts, ctx) {
|
|
153
202
|
const backend = backendFor(ctx);
|
|
154
203
|
if (action === "install")
|
|
155
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
|
*
|