@edspencer/paddock 0.70.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/packages/server/dist/background-live.d.ts +40 -8
- package/packages/server/dist/background-live.js +79 -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/service/index.js +12 -1
- package/packages/server/dist/git.d.ts +63 -8
- package/packages/server/dist/git.js +100 -20
- package/packages/server/dist/herdctl-jobs.d.ts +30 -4
- package/packages/server/dist/herdctl-jobs.js +35 -8
- package/packages/server/dist/herdctl.d.ts +49 -0
- package/packages/server/dist/herdctl.js +42 -0
- package/packages/server/dist/project-files.d.ts +18 -7
- package/packages/server/dist/project-files.js +38 -34
- package/packages/server/dist/projects.d.ts +1 -1
- package/packages/server/dist/projects.js +14 -7
- package/packages/server/dist/routes/chats.js +7 -7
- package/packages/server/dist/routes/discover.js +1 -1
- package/packages/server/dist/routes/git.d.ts +2 -2
- package/packages/server/dist/routes/git.js +133 -1
- package/packages/server/dist/ws-protocol.d.ts +89 -3
- 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-C8a-t439.js → Chip-5S5MAayn.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +2 -0
- package/packages/web/dist/assets/DiscoverView-CArICIRO.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DCj_ZwVG.js → MarkdownRenderer-BOrDawoY.js} +1 -1
- package/packages/web/dist/assets/{ProjectMenu-B7dGZSFB.js → ProjectMenu-B_8NYWZr.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-bstvMvM9.js → ProjectRedirect-xFWTlADS.js} +1 -1
- package/packages/web/dist/assets/ProjectView-polknqZw.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-DKvySDUQ.js → ProjectsGrid-BF65wTqN.js} +1 -1
- package/packages/web/dist/assets/{RootHome-CD_37qb-.js → RootHome-32k1iDh4.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-1dBayrsj.js → Surfaces-DCm19ZAD.js} +1 -1
- package/packages/web/dist/assets/{arc-BJPb3ViJ.js → arc-7GXE70CZ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-PkLOC5wV.js → architectureDiagram-3BPJPVTR-CAEj_lAw.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-IPduhNH-.js → blockDiagram-GPEHLZMM-wNLONvdQ.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-dKTYV1Vq.js → c4Diagram-AAUBKEIU-Cf7MUqJz.js} +1 -1
- package/packages/web/dist/assets/channel-U0NZSTs2.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-B5C6geyg.js → chunk-2J33WTMH-W18BVHtE.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-1BRhFRhF.js → chunk-4BX2VUAB-DDJQi9KC.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DNv-sTiI.js → chunk-55IACEB6-L5afa49k.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-CWezuVkx.js → chunk-727SXJPM-Bb0Sw8oK.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-Bt4yEQnu.js → chunk-AQP2D5EJ-DRrKTv1K.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-lb_8q4JY.js → chunk-FMBD7UC4-CIsdDBaT.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DOwgGlQ_.js → chunk-ND2GUHAM-DN5ua8KT.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-BanKpY4q.js → chunk-QZHKN3VN-DE1WNShj.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +2 -0
- package/packages/web/dist/assets/{core-CAsyoNAa.js → core-DadwNpE4.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-sVaVkwMI.js → cose-bilkent-S5V4N54A-2FTPTDA6.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-C9pKHSMo.js → dagre-BM42HDAG-eJ7MdaEX.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CHdrmCfX.js → diagram-2AECGRRQ-D8MZTSkB.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-4UTa2Dfa.js → diagram-5GNKFQAL-DVXF3Z-H.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-NidpfVwW.js → diagram-KO2AKTUF-DoXfbQ0i.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-ictQj03T.js → diagram-LMA3HP47-CRW0fiqv.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-B7dTLMHC.js → diagram-OG6HWLK6-DfhYXDqr.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C2QYYzJ8.js → erDiagram-TEJ5UH35-HG0QTHgA.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D2rcjSZ9.js → flowDiagram-I6XJVG4X-ChVekwUy.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BQcXTYzg.js → ganttDiagram-6RSMTGT7-CeA-6Z-T.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DwB2Vqxa.js → gitGraphDiagram-PVQCEYII-ew2oouNN.js} +1 -1
- package/packages/web/dist/assets/{index-BeuEs7dL.js → index-CbqaEg2j.js} +14 -14
- package/packages/web/dist/assets/index-tvJIh4A9.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BoK4Mawg.js → infoDiagram-5YYISTIA-BY9NYdld.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DPP9svhg.js → ishikawaDiagram-YF4QCWOH-CgbdrGBm.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-TrggodYk.js → journeyDiagram-JHISSGLW-BepLFtbU.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Bh69hRSR.js → kanban-definition-UN3LZRKU-JHMJYGJu.js} +1 -1
- package/packages/web/dist/assets/{linear-C1iYMpBz.js → linear-Ccw8eVgC.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BZDABcuq.js → mermaid.core-lHjASk7x.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DTJON3dS.js → mindmap-definition-RKZ34NQL-Bi5Iq2M4.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BxtFV74I.js → pieDiagram-4H26LBE5-rZuB_P1J.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-D2Ar8kmO.js → quadrantDiagram-W4KKPZXB-BuStI37i.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DiLSfeSQ.js → requirementDiagram-4Y6WPE33-BFElQKEX.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D_CRQc7j.js → sankeyDiagram-5OEKKPKP-tzdQCr91.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DjtfcRNV.js → sequenceDiagram-3UESZ5HK-j1koqb6Q.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CzEtmYLV.js → stateDiagram-AJRCARHV-B-dUkvgq.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DXKspEG3.js → timeline-definition-PNZ67QCA-BWyq6u64.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B_tXRMHH.js → vennDiagram-CIIHVFJN-B0CRl3xN.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DNaNoaJm.js → wardley-L42UT6IY-C45bBEPS.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BuVDpC8s.js → wardleyDiagram-YWT4CUSO-DT50TWrW.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Cqtew2PA.js → xychartDiagram-2RQKCTM6-C8iBuFGD.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/DiscoverPage-C1khyCvD.js +0 -2
- package/packages/web/dist/assets/DiscoverView-DS_Z9xKH.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-B1ei1FDo.js +0 -2
- package/packages/web/dist/assets/ProjectView-iuiUIoIl.js +0 -13
- package/packages/web/dist/assets/channel-BhrgMiqg.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Ho1IMdjg.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Ho1IMdjg.js +0 -2
- package/packages/web/dist/assets/index-BNzTD4hA.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-pM8fIOir.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.0",
|
|
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",
|
|
@@ -35,29 +35,37 @@
|
|
|
35
35
|
* a restart is correct, because Paddock stops the fleet with
|
|
36
36
|
* `waitForJobs: false` and the tasks are genuinely dead.
|
|
37
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* The role for an SDK task type. Identity for anything unrecognised, which also
|
|
40
|
+
* means a future SDK that starts sending friendly names needs no change here.
|
|
41
|
+
*/
|
|
42
|
+
export declare function roleForTaskType(type: string): string;
|
|
38
43
|
/**
|
|
39
44
|
* One live background task, as broadcast to clients.
|
|
40
45
|
*
|
|
41
|
-
* `type` is the SDK
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* `type` is the RAW SDK discriminant (`local_bash`, `local_agent`, …), kept
|
|
47
|
+
* verbatim so an unexpected kind is diagnosable from the wire. `role` is the
|
|
48
|
+
* rendering role derived from it by {@link roleForTaskType}. Both are plain
|
|
49
|
+
* `string`s rather than unions on purpose: a new SDK task type should render as
|
|
50
|
+
* a generic row, not be dropped.
|
|
45
51
|
*/
|
|
46
52
|
export interface LiveBackgroundTask {
|
|
47
53
|
id: string;
|
|
48
54
|
type: string;
|
|
55
|
+
/** Rendering role derived from {@link type} — see {@link roleForTaskType}. */
|
|
56
|
+
role: string;
|
|
49
57
|
description: string;
|
|
50
58
|
/** Epoch-ms this task was first observed. Stamped here; the SDK sends no start time. */
|
|
51
59
|
startedAt: number;
|
|
52
60
|
/** Links the task to its launching tool card, when an edge has told us. */
|
|
53
61
|
toolUseId?: string;
|
|
54
|
-
/** `subagent` only. */
|
|
62
|
+
/** `subagent` role only. */
|
|
55
63
|
agentType?: string;
|
|
56
|
-
/** `shell` only. */
|
|
64
|
+
/** `shell` role only. */
|
|
57
65
|
command?: string;
|
|
58
|
-
/** `workflow` only. */
|
|
66
|
+
/** `workflow` role only. */
|
|
59
67
|
workflowName?: string;
|
|
60
|
-
/** `monitor` / MCP-task only. */
|
|
68
|
+
/** `monitor` / MCP-task role only. */
|
|
61
69
|
server?: string;
|
|
62
70
|
tool?: string;
|
|
63
71
|
/** Latest tool the task ran, from `task_progress`. */
|
|
@@ -70,6 +78,14 @@ export interface LiveBackgroundTask {
|
|
|
70
78
|
* carried on the wire and filtered at the point of display.
|
|
71
79
|
*/
|
|
72
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;
|
|
73
89
|
}
|
|
74
90
|
export type BackgroundChangeListener = (projectSlug: string, sessionId: string, tasks: LiveBackgroundTask[]) => void;
|
|
75
91
|
/**
|
|
@@ -99,6 +115,22 @@ export declare class BackgroundRegistry {
|
|
|
99
115
|
* stream ends — see the per-process caveat in the module header.
|
|
100
116
|
*/
|
|
101
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;
|
|
102
134
|
/**
|
|
103
135
|
* Feed one raw SDK message. Returns true when the live set changed, so the
|
|
104
136
|
* caller can avoid re-broadcasting on the overwhelming majority of messages
|
|
@@ -37,6 +37,63 @@
|
|
|
37
37
|
*/
|
|
38
38
|
/** Terminal task statuses — a task in one of these is no longer live. */
|
|
39
39
|
const TERMINAL_STATUSES = new Set(["completed", "failed", "stopped", "killed"]);
|
|
40
|
+
/**
|
|
41
|
+
* SDK task-type discriminant → the role clients render (issue #846).
|
|
42
|
+
*
|
|
43
|
+
* This module used to document `type` as "the SDK's friendly label (`shell` |
|
|
44
|
+
* `subagent` | `monitor` | `workflow`)" and pass `task_type` straight through.
|
|
45
|
+
* **The CLI does no such mapping.** Its level payload is built by
|
|
46
|
+
*
|
|
47
|
+
* ```js
|
|
48
|
+
* .map((t) => ({ task_id: t.id, task_type: t.type, description: t.description }))
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* — the INTERNAL discriminant verbatim. None of the four friendly names is in
|
|
52
|
+
* the CLI's own id-prefix table, so before this map every row fell through the
|
|
53
|
+
* client's unknown-kind fallback to a raw `local_bash` and a neutral clock.
|
|
54
|
+
* There was no case in which the friendly-name branches fired.
|
|
55
|
+
*
|
|
56
|
+
* The translation lives HERE, at the registry boundary, so one mapping serves
|
|
57
|
+
* every consumer rather than each of them re-deriving it. An unrecognised kind
|
|
58
|
+
* still falls through to its raw string — that fallback was always right, it
|
|
59
|
+
* just should not have been the only path.
|
|
60
|
+
*/
|
|
61
|
+
const TASK_ROLES = {
|
|
62
|
+
local_bash: "shell",
|
|
63
|
+
local_agent: "subagent",
|
|
64
|
+
remote_agent: "subagent",
|
|
65
|
+
in_process_teammate: "subagent",
|
|
66
|
+
local_workflow: "workflow",
|
|
67
|
+
monitor_mcp: "monitor",
|
|
68
|
+
monitor_ws: "monitor",
|
|
69
|
+
mcp_task: "task",
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The role for an SDK task type. Identity for anything unrecognised, which also
|
|
73
|
+
* means a future SDK that starts sending friendly names needs no change here.
|
|
74
|
+
*/
|
|
75
|
+
export function roleForTaskType(type) {
|
|
76
|
+
return TASK_ROLES[type] ?? type;
|
|
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"]);
|
|
40
97
|
const str = (v) => (typeof v === "string" && v ? v : undefined);
|
|
41
98
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
42
99
|
/**
|
|
@@ -96,6 +153,24 @@ export class BackgroundRegistry {
|
|
|
96
153
|
e.tasks.clear();
|
|
97
154
|
this.emit(sessionId, e);
|
|
98
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
|
+
}
|
|
99
174
|
/**
|
|
100
175
|
* Feed one raw SDK message. Returns true when the live set changed, so the
|
|
101
176
|
* caller can avoid re-broadcasting on the overwhelming majority of messages
|
|
@@ -159,12 +234,15 @@ export class BackgroundRegistry {
|
|
|
159
234
|
if (!id)
|
|
160
235
|
continue;
|
|
161
236
|
const existing = prev?.tasks.get(id);
|
|
237
|
+
const type = str(rt.task_type) ?? existing?.type ?? "task";
|
|
162
238
|
next.set(id, {
|
|
163
239
|
...existing,
|
|
164
240
|
id,
|
|
165
|
-
type
|
|
241
|
+
type,
|
|
242
|
+
role: roleForTaskType(type),
|
|
166
243
|
description: str(rt.description) ?? existing?.description ?? "",
|
|
167
244
|
startedAt: existing?.startedAt ?? this.now(),
|
|
245
|
+
stoppable: !UNSTOPPABLE_TASK_TYPES.has(type),
|
|
168
246
|
});
|
|
169
247
|
}
|
|
170
248
|
if (prev && sameSet(prev.tasks, next)) {
|
|
@@ -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
|
|
@@ -130,7 +130,18 @@ function status(opts, ctx, backend) {
|
|
|
130
130
|
const dataDir = dataFromUnit >= 0 && state.argv[dataFromUnit + 1] !== undefined
|
|
131
131
|
? state.argv[dataFromUnit + 1]
|
|
132
132
|
: defaultDataDir(ctx.homeDir);
|
|
133
|
-
|
|
133
|
+
// Drop `--data-dir` before building the log-path spec (#824). `resolveDataDir`
|
|
134
|
+
// gives `opts.dataDir` precedence over `ctx.envDataDir`, which is right for
|
|
135
|
+
// `install` (see the comment there) and wrong here: this whole block reports
|
|
136
|
+
// the INSTALLED unit, so letting today's flag through printed the unit's data
|
|
137
|
+
// dir beside a `Logs:` path under a directory the service never writes to. On
|
|
138
|
+
// macOS that log path is real and load-bearing, not just cosmetic.
|
|
139
|
+
//
|
|
140
|
+
// `--data-dir` is documented as install-only (SERVICE_USAGE), so ignoring it
|
|
141
|
+
// here matches both the help text and the stated intent of this block.
|
|
142
|
+
const statusOpts = { ...opts };
|
|
143
|
+
delete statusOpts.dataDir;
|
|
144
|
+
const { spec } = specFor(statusOpts, { ...ctx, envDataDir: dataDir });
|
|
134
145
|
const linger = backend.lingerNote();
|
|
135
146
|
console.log([
|
|
136
147
|
"",
|
|
@@ -65,10 +65,11 @@ export declare class GitService {
|
|
|
65
65
|
* successful check (repo-ness doesn't change under a running server); a thrown
|
|
66
66
|
* git / missing-binary error resolves to `false`.
|
|
67
67
|
*
|
|
68
|
-
* Store-wide, so it still gates the store-wide
|
|
69
|
-
* {@link
|
|
70
|
-
* {@link isRepoAt} about the
|
|
71
|
-
*
|
|
68
|
+
* Store-wide, so it still gates the one genuinely store-wide operation,
|
|
69
|
+
* {@link dirtyCounts} — a single `git status` over the whole root. Everything
|
|
70
|
+
* else asks {@link isRepoAt} about the directory it is about to act on: the
|
|
71
|
+
* project's own working directory for status/diff/commit (#597, #206) and for
|
|
72
|
+
* remote/push (#710). See {@link isRepoAt} for why the two had to be separated.
|
|
72
73
|
*/
|
|
73
74
|
isRepo(): Promise<boolean>;
|
|
74
75
|
/**
|
|
@@ -101,6 +102,40 @@ export declare class GitService {
|
|
|
101
102
|
* `projectDir`.
|
|
102
103
|
*/
|
|
103
104
|
projectStatus(projectDir: string): Promise<GitProjectStatus>;
|
|
105
|
+
/**
|
|
106
|
+
* Whether `relPath` is a file git currently reports as UNTRACKED in
|
|
107
|
+
* `projectDir` — the read gate for the Changes tab's new-file view (#710).
|
|
108
|
+
*
|
|
109
|
+
* ## Why git's own answer, and not a path guard
|
|
110
|
+
*
|
|
111
|
+
* The Changes tab renders an untracked file's CONTENT (it has no diff), and
|
|
112
|
+
* until #710 it did so through the metadata-dir file surface — which 404'd for
|
|
113
|
+
* every project whose working directory is somewhere else. Serving the file
|
|
114
|
+
* from the working directory instead needs a guard, and the obvious one to
|
|
115
|
+
* reach for is `project-files.ts`'s: refuse any path descending through a
|
|
116
|
+
* dot-prefixed directory. That guard is right for the metadata dir and wrong
|
|
117
|
+
* here, in both directions:
|
|
118
|
+
*
|
|
119
|
+
* - **It under-blocks on a worktree.** A linked worktree's `.git` is a FILE,
|
|
120
|
+
* not a directory, so it is the LEAF — and the leaf is deliberately allowed
|
|
121
|
+
* there (an untracked `.gitignore` has to render). The file is only a
|
|
122
|
+
* `gitdir:` pointer, but serving it discloses the main checkout's path for
|
|
123
|
+
* no reason.
|
|
124
|
+
* - **It over-blocks on a real repo.** A brand-new untracked
|
|
125
|
+
* `.github/workflows/ci.yml` is an ordinary Changes-tab row, and a
|
|
126
|
+
* dot-directory rule refuses to render exactly the file the UI is listing.
|
|
127
|
+
*
|
|
128
|
+
* Asking git is both tighter and looser in the right places. `git status`
|
|
129
|
+
* never reports `.git` (file or directory), never reports an IGNORED path —
|
|
130
|
+
* so `.chats/` and anything else the repo excludes stays unreadable — and does
|
|
131
|
+
* report `.github/…`. The set the route may serve is then, by construction,
|
|
132
|
+
* exactly the set the pane already displays: no third notion of "allowed" to
|
|
133
|
+
* keep in step with the other two.
|
|
134
|
+
*
|
|
135
|
+
* `:(literal)` disables pathspec magic, so a filename containing `*` or `[` is
|
|
136
|
+
* matched as itself rather than as a pattern. False on any git failure.
|
|
137
|
+
*/
|
|
138
|
+
isUntrackedFile(projectDir: string, relPath: string): Promise<boolean>;
|
|
104
139
|
/**
|
|
105
140
|
* `git diff --numstat` for a project's tracked working-tree changes, keyed by
|
|
106
141
|
* PROJECT-relative path (`--relative` makes git emit paths relative to the
|
|
@@ -116,8 +151,23 @@ export declare class GitService {
|
|
|
116
151
|
projectDiff(projectDir: string, file?: string): Promise<string>;
|
|
117
152
|
/** Commit identity (from config, no global git config needed). */
|
|
118
153
|
private identity;
|
|
119
|
-
/**
|
|
120
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Origin remote info + ahead/behind vs upstream (best-effort) for ONE
|
|
156
|
+
* directory's repository, defaulting to the backing store.
|
|
157
|
+
*
|
|
158
|
+
* The `dir` parameter is issue #710. `remote()` and {@link push} were the last
|
|
159
|
+
* two members of the per-project Changes surface still hard-wired to
|
|
160
|
+
* `projectsRoot` after #597/#709 moved status/diff/commit onto the project's
|
|
161
|
+
* `workingDir` — which made the pane's header self-contradictory: it showed the
|
|
162
|
+
* WORKTREE's branch beside an ahead-count, a remote URL and a Push button that
|
|
163
|
+
* all belonged to Paddock's own notes repo. Passing the working directory makes
|
|
164
|
+
* every field in that header describe one repository.
|
|
165
|
+
*
|
|
166
|
+
* For a notebook project (and for the root workspace) the working directory is
|
|
167
|
+
* inside `projectsRoot`, so this resolves to the same repo, same branch, same
|
|
168
|
+
* upstream as before — the generalisation costs those shapes nothing.
|
|
169
|
+
*/
|
|
170
|
+
remote(dir?: string): Promise<{
|
|
121
171
|
repo: boolean;
|
|
122
172
|
configured: boolean;
|
|
123
173
|
url?: string;
|
|
@@ -163,8 +213,13 @@ export declare class GitService {
|
|
|
163
213
|
* Returns 0 for a directory that isn't a repo, matching "nothing to report".
|
|
164
214
|
*/
|
|
165
215
|
dirtyCountAt(dir: string): Promise<number>;
|
|
166
|
-
/**
|
|
167
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Push one directory's current branch to origin (sets upstream on first push),
|
|
218
|
+
* defaulting to the backing store. See {@link remote} for why `dir` exists —
|
|
219
|
+
* in a linked WORKTREE `--abbrev-ref HEAD` is per-worktree, so this pushes the
|
|
220
|
+
* branch the user is actually looking at rather than the store's.
|
|
221
|
+
*/
|
|
222
|
+
push(dir?: string): Promise<{
|
|
168
223
|
pushed: boolean;
|
|
169
224
|
error?: string;
|
|
170
225
|
}>;
|
|
@@ -77,10 +77,11 @@ export class GitService {
|
|
|
77
77
|
* successful check (repo-ness doesn't change under a running server); a thrown
|
|
78
78
|
* git / missing-binary error resolves to `false`.
|
|
79
79
|
*
|
|
80
|
-
* Store-wide, so it still gates the store-wide
|
|
81
|
-
* {@link
|
|
82
|
-
* {@link isRepoAt} about the
|
|
83
|
-
*
|
|
80
|
+
* Store-wide, so it still gates the one genuinely store-wide operation,
|
|
81
|
+
* {@link dirtyCounts} — a single `git status` over the whole root. Everything
|
|
82
|
+
* else asks {@link isRepoAt} about the directory it is about to act on: the
|
|
83
|
+
* project's own working directory for status/diff/commit (#597, #206) and for
|
|
84
|
+
* remote/push (#710). See {@link isRepoAt} for why the two had to be separated.
|
|
84
85
|
*/
|
|
85
86
|
async isRepo() {
|
|
86
87
|
if (this.repoFlag !== null)
|
|
@@ -198,6 +199,70 @@ export class GitService {
|
|
|
198
199
|
}
|
|
199
200
|
return { repo: true, branch, files, clean: files.length === 0 };
|
|
200
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether `relPath` is a file git currently reports as UNTRACKED in
|
|
204
|
+
* `projectDir` — the read gate for the Changes tab's new-file view (#710).
|
|
205
|
+
*
|
|
206
|
+
* ## Why git's own answer, and not a path guard
|
|
207
|
+
*
|
|
208
|
+
* The Changes tab renders an untracked file's CONTENT (it has no diff), and
|
|
209
|
+
* until #710 it did so through the metadata-dir file surface — which 404'd for
|
|
210
|
+
* every project whose working directory is somewhere else. Serving the file
|
|
211
|
+
* from the working directory instead needs a guard, and the obvious one to
|
|
212
|
+
* reach for is `project-files.ts`'s: refuse any path descending through a
|
|
213
|
+
* dot-prefixed directory. That guard is right for the metadata dir and wrong
|
|
214
|
+
* here, in both directions:
|
|
215
|
+
*
|
|
216
|
+
* - **It under-blocks on a worktree.** A linked worktree's `.git` is a FILE,
|
|
217
|
+
* not a directory, so it is the LEAF — and the leaf is deliberately allowed
|
|
218
|
+
* there (an untracked `.gitignore` has to render). The file is only a
|
|
219
|
+
* `gitdir:` pointer, but serving it discloses the main checkout's path for
|
|
220
|
+
* no reason.
|
|
221
|
+
* - **It over-blocks on a real repo.** A brand-new untracked
|
|
222
|
+
* `.github/workflows/ci.yml` is an ordinary Changes-tab row, and a
|
|
223
|
+
* dot-directory rule refuses to render exactly the file the UI is listing.
|
|
224
|
+
*
|
|
225
|
+
* Asking git is both tighter and looser in the right places. `git status`
|
|
226
|
+
* never reports `.git` (file or directory), never reports an IGNORED path —
|
|
227
|
+
* so `.chats/` and anything else the repo excludes stays unreadable — and does
|
|
228
|
+
* report `.github/…`. The set the route may serve is then, by construction,
|
|
229
|
+
* exactly the set the pane already displays: no third notion of "allowed" to
|
|
230
|
+
* keep in step with the other two.
|
|
231
|
+
*
|
|
232
|
+
* `:(literal)` disables pathspec magic, so a filename containing `*` or `[` is
|
|
233
|
+
* matched as itself rather than as a pattern. False on any git failure.
|
|
234
|
+
*/
|
|
235
|
+
async isUntrackedFile(projectDir, relPath) {
|
|
236
|
+
if (!isSafeRelPath(relPath))
|
|
237
|
+
return false;
|
|
238
|
+
if (!(await this.isRepoAt(projectDir)))
|
|
239
|
+
return false;
|
|
240
|
+
// Porcelain paths are REPO-ROOT relative; the caller's path is PROJECT
|
|
241
|
+
// relative. Rebase with the same `--show-prefix` the status route uses, so
|
|
242
|
+
// a project dir below the repo root compares like for like.
|
|
243
|
+
let prefix = "";
|
|
244
|
+
try {
|
|
245
|
+
prefix = (await this.git(projectDir, ["rev-parse", "--show-prefix"])).trim();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* project dir is the repo root */
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const out = await this.git(projectDir, [
|
|
252
|
+
"status",
|
|
253
|
+
"--porcelain=v1",
|
|
254
|
+
"-z",
|
|
255
|
+
"--untracked-files=all",
|
|
256
|
+
"--",
|
|
257
|
+
`:(literal)${relPath}`,
|
|
258
|
+
]);
|
|
259
|
+
return parsePorcelainZ(out).some((f) => f.untracked &&
|
|
260
|
+
(prefix && f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path) === relPath);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
201
266
|
/**
|
|
202
267
|
* `git diff --numstat` for a project's tracked working-tree changes, keyed by
|
|
203
268
|
* PROJECT-relative path (`--relative` makes git emit paths relative to the
|
|
@@ -248,13 +313,28 @@ export class GitService {
|
|
|
248
313
|
identity() {
|
|
249
314
|
return this.author;
|
|
250
315
|
}
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Origin remote info + ahead/behind vs upstream (best-effort) for ONE
|
|
318
|
+
* directory's repository, defaulting to the backing store.
|
|
319
|
+
*
|
|
320
|
+
* The `dir` parameter is issue #710. `remote()` and {@link push} were the last
|
|
321
|
+
* two members of the per-project Changes surface still hard-wired to
|
|
322
|
+
* `projectsRoot` after #597/#709 moved status/diff/commit onto the project's
|
|
323
|
+
* `workingDir` — which made the pane's header self-contradictory: it showed the
|
|
324
|
+
* WORKTREE's branch beside an ahead-count, a remote URL and a Push button that
|
|
325
|
+
* all belonged to Paddock's own notes repo. Passing the working directory makes
|
|
326
|
+
* every field in that header describe one repository.
|
|
327
|
+
*
|
|
328
|
+
* For a notebook project (and for the root workspace) the working directory is
|
|
329
|
+
* inside `projectsRoot`, so this resolves to the same repo, same branch, same
|
|
330
|
+
* upstream as before — the generalisation costs those shapes nothing.
|
|
331
|
+
*/
|
|
332
|
+
async remote(dir = this.projectsRoot) {
|
|
333
|
+
if (!(await this.isRepoAt(dir)))
|
|
254
334
|
return { repo: false, configured: false };
|
|
255
335
|
let branch;
|
|
256
336
|
try {
|
|
257
|
-
branch = (await this.git(
|
|
337
|
+
branch = (await this.git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
258
338
|
if (branch === "HEAD")
|
|
259
339
|
branch = undefined;
|
|
260
340
|
}
|
|
@@ -263,7 +343,7 @@ export class GitService {
|
|
|
263
343
|
}
|
|
264
344
|
let url;
|
|
265
345
|
try {
|
|
266
|
-
url = (await this.git(
|
|
346
|
+
url = (await this.git(dir, ["remote", "get-url", "origin"])).trim();
|
|
267
347
|
}
|
|
268
348
|
catch {
|
|
269
349
|
return { repo: true, configured: false, branch };
|
|
@@ -271,12 +351,7 @@ export class GitService {
|
|
|
271
351
|
let ahead;
|
|
272
352
|
let behind;
|
|
273
353
|
try {
|
|
274
|
-
const counts = (await this.git(
|
|
275
|
-
"rev-list",
|
|
276
|
-
"--left-right",
|
|
277
|
-
"--count",
|
|
278
|
-
"@{upstream}...HEAD",
|
|
279
|
-
])).trim();
|
|
354
|
+
const counts = (await this.git(dir, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])).trim();
|
|
280
355
|
const [b, a] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10));
|
|
281
356
|
behind = Number.isFinite(b) ? b : undefined;
|
|
282
357
|
ahead = Number.isFinite(a) ? a : undefined;
|
|
@@ -393,13 +468,18 @@ export class GitService {
|
|
|
393
468
|
return 0;
|
|
394
469
|
}
|
|
395
470
|
}
|
|
396
|
-
/**
|
|
397
|
-
|
|
398
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Push one directory's current branch to origin (sets upstream on first push),
|
|
473
|
+
* defaulting to the backing store. See {@link remote} for why `dir` exists —
|
|
474
|
+
* in a linked WORKTREE `--abbrev-ref HEAD` is per-worktree, so this pushes the
|
|
475
|
+
* branch the user is actually looking at rather than the store's.
|
|
476
|
+
*/
|
|
477
|
+
async push(dir = this.projectsRoot) {
|
|
478
|
+
if (!(await this.isRepoAt(dir)))
|
|
399
479
|
return { pushed: false, error: "not a repo" };
|
|
400
480
|
try {
|
|
401
|
-
const branch = (await this.git(
|
|
402
|
-
await this.git(
|
|
481
|
+
const branch = (await this.git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
482
|
+
await this.git(dir, ["push", "--set-upstream", "origin", branch]);
|
|
403
483
|
return { pushed: true };
|
|
404
484
|
}
|
|
405
485
|
catch (err) {
|
|
@@ -66,10 +66,36 @@ export declare function listProjectRuns(stateDir: string, project: Project, limi
|
|
|
66
66
|
* the data source for the Triggers tab's per-trigger last-run column. Used to pull
|
|
67
67
|
* one project's keeper AND every scoped `trigger-<slug>-<name>` agent in a single
|
|
68
68
|
* pass so {@link import("./trigger-runtime.js").buildTriggerRuntime} can attribute a
|
|
69
|
-
* scoped trigger's newest run by agent name.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* scoped trigger's newest run by agent name. Order (started_at descending) is
|
|
70
|
+
* preserved. Errors swallow to `[]` so the runtime view degrades to config-only
|
|
71
|
+
* rather than failing to render.
|
|
72
|
+
*
|
|
73
|
+
* ## Why the filter is pushed down (#535)
|
|
74
|
+
*
|
|
75
|
+
* This used to call `listJobs(jobsDir)` with NEITHER filter nor limit, then
|
|
76
|
+
* filter and slice in JS — because `ListJobsFilter` had `agent` (exactly one)
|
|
77
|
+
* and no multi-agent form. That defeats core's job index completely: with
|
|
78
|
+
* `filter.limit === undefined` core sets `retain = matches`, so EVERY record in
|
|
79
|
+
* the shared, never-pruned, fleet-wide jobs dir is read, YAML-parsed and
|
|
80
|
+
* Zod-validated, then thrown away. Measured on a 2,016-record dir:
|
|
81
|
+
*
|
|
82
|
+
* | call | warm |
|
|
83
|
+
* |---|---|
|
|
84
|
+
* | `listJobs(dir)` + JS filter + slice | 1,935–2,095 ms |
|
|
85
|
+
* | `listJobs(dir, { agents, limit })` | 125–146 ms |
|
|
86
|
+
*
|
|
87
|
+
* That ~2 s was essentially the whole cost of `GET /triggers/runtime`, which the
|
|
88
|
+
* Triggers tab polls every 10 s.
|
|
89
|
+
*
|
|
90
|
+
* Both `agents` AND `limit` are load-bearing. `agents` alone still leaves
|
|
91
|
+
* `limit` undefined, so `retain = matches` and every match is hydrated anyway —
|
|
92
|
+
* the win comes from the pair. `limit <= 0` keeps its documented "no cap"
|
|
93
|
+
* meaning and is passed as no `limit`, which costs more but is still filtered.
|
|
94
|
+
*
|
|
95
|
+
* Requires `@herdctl/core` >= 5.28.0 (herdctl#418). On an older core `agents` is
|
|
96
|
+
* an unknown key and would be silently ignored, returning the newest `limit`
|
|
97
|
+
* records across ALL agents — hence the version floor in package.json rather
|
|
98
|
+
* than a defensive re-filter here, which would mask the mismatch.
|
|
73
99
|
*/
|
|
74
100
|
export declare function listRunsForAgents(stateDir: string, agents: string[], limit?: number): Promise<JobMetadata[]>;
|
|
75
101
|
/**
|