@edspencer/paddock 0.70.1 → 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 +24 -0
- package/packages/server/dist/background-live.js +38 -0
- 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/herdctl.d.ts +49 -0
- package/packages/server/dist/herdctl.js +42 -0
- package/packages/server/dist/routes/chats.js +7 -7
- package/packages/server/dist/routes/discover.js +1 -1
- 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-P4n88XYj.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-DatKu1Pl.js → MarkdownRenderer-BOrDawoY.js} +1 -1
- package/packages/web/dist/assets/{ProjectMenu-DJB7uCGt.js → ProjectMenu-B_8NYWZr.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-D5Uxki7M.js → ProjectRedirect-xFWTlADS.js} +1 -1
- package/packages/web/dist/assets/ProjectView-polknqZw.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-CqQTeqdE.js → ProjectsGrid-BF65wTqN.js} +1 -1
- package/packages/web/dist/assets/{RootHome-B0si7ijq.js → RootHome-32k1iDh4.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-t3XF_my5.js → Surfaces-DCm19ZAD.js} +1 -1
- package/packages/web/dist/assets/{arc-Bvnrokeb.js → arc-7GXE70CZ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc5OXCmK.js → architectureDiagram-3BPJPVTR-CAEj_lAw.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-CFZeUqSH.js → blockDiagram-GPEHLZMM-wNLONvdQ.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BPI5UmpX.js → c4Diagram-AAUBKEIU-Cf7MUqJz.js} +1 -1
- package/packages/web/dist/assets/channel-U0NZSTs2.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CVpthvPT.js → chunk-2J33WTMH-W18BVHtE.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-cTaBnmzz.js → chunk-4BX2VUAB-DDJQi9KC.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-dkgF9sAS.js → chunk-55IACEB6-L5afa49k.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-UiFZch2W.js → chunk-727SXJPM-Bb0Sw8oK.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-IKsoQNZy.js → chunk-AQP2D5EJ-DRrKTv1K.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-Tzt_HckZ.js → chunk-FMBD7UC4-CIsdDBaT.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-dbNwUFdT.js → chunk-ND2GUHAM-DN5ua8KT.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-eUsG1OkP.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-Duun4AtS.js → core-DadwNpE4.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-KF_gNdsd.js → cose-bilkent-S5V4N54A-2FTPTDA6.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-CXFDGeP7.js → dagre-BM42HDAG-eJ7MdaEX.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BiefFjo3.js → diagram-2AECGRRQ-D8MZTSkB.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-B1IpANC8.js → diagram-5GNKFQAL-DVXF3Z-H.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-ChdOA3EK.js → diagram-KO2AKTUF-DoXfbQ0i.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BTbLDsXf.js → diagram-LMA3HP47-CRW0fiqv.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-Bqiqt27L.js → diagram-OG6HWLK6-DfhYXDqr.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-KA9WLkV0.js → erDiagram-TEJ5UH35-HG0QTHgA.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CZKvoeTz.js → flowDiagram-I6XJVG4X-ChVekwUy.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-o1QDzlgt.js → ganttDiagram-6RSMTGT7-CeA-6Z-T.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-D5GdOppF.js → gitGraphDiagram-PVQCEYII-ew2oouNN.js} +1 -1
- package/packages/web/dist/assets/{index-B-J2K1w2.js → index-CbqaEg2j.js} +11 -11
- package/packages/web/dist/assets/index-tvJIh4A9.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-21Z9hMO-.js → infoDiagram-5YYISTIA-BY9NYdld.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-TmbPK40i.js → ishikawaDiagram-YF4QCWOH-CgbdrGBm.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DYAWKu-K.js → journeyDiagram-JHISSGLW-BepLFtbU.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-ZW_ZQ0DQ.js → kanban-definition-UN3LZRKU-JHMJYGJu.js} +1 -1
- package/packages/web/dist/assets/{linear-BDQKnwHn.js → linear-Ccw8eVgC.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BHXnmT7N.js → mermaid.core-lHjASk7x.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CHVPYLQs.js → mindmap-definition-RKZ34NQL-Bi5Iq2M4.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-paqmu057.js → pieDiagram-4H26LBE5-rZuB_P1J.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-diM_4CGS.js → quadrantDiagram-W4KKPZXB-BuStI37i.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DCyNbyIz.js → requirementDiagram-4Y6WPE33-BFElQKEX.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DxpEvGOy.js → sankeyDiagram-5OEKKPKP-tzdQCr91.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BH4-7BdR.js → sequenceDiagram-3UESZ5HK-j1koqb6Q.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CVPrwckU.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-B6_yfyHl.js → timeline-definition-PNZ67QCA-BWyq6u64.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Ic8OogjJ.js → vennDiagram-CIIHVFJN-B0CRl3xN.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CuhgMTiv.js → wardley-L42UT6IY-C45bBEPS.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Djeda9cE.js → wardleyDiagram-YWT4CUSO-DT50TWrW.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D3hl8Mdh.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-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/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.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",
|
|
@@ -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)) {
|
|
@@ -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
|
"",
|
|
@@ -79,6 +79,26 @@ export interface ChatDeletion {
|
|
|
79
79
|
/** The transcript was deliberately left on disk because it is not paddock's. */
|
|
80
80
|
retained: boolean;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* What happened to a request to stop one background task (#848).
|
|
84
|
+
*
|
|
85
|
+
* Three cases, kept apart on purpose — see
|
|
86
|
+
* {@link HerdctlService.stopBackgroundTask} for why only `error` is a failure.
|
|
87
|
+
*/
|
|
88
|
+
export type StopTaskOutcome =
|
|
89
|
+
/** Accepted. The SDK's own terminal notification is what removes the row. */
|
|
90
|
+
{
|
|
91
|
+
outcome: "stopping";
|
|
92
|
+
}
|
|
93
|
+
/** No live session — the work died with it. The caller drops the row. */
|
|
94
|
+
| {
|
|
95
|
+
outcome: "gone";
|
|
96
|
+
}
|
|
97
|
+
/** The stop did not happen and the task is still running. */
|
|
98
|
+
| {
|
|
99
|
+
outcome: "error";
|
|
100
|
+
message: string;
|
|
101
|
+
};
|
|
82
102
|
/** Options passed through to a streamed trigger. */
|
|
83
103
|
export interface ChatTurnOptions {
|
|
84
104
|
prompt: string;
|
|
@@ -768,6 +788,35 @@ export declare class HerdctlService {
|
|
|
768
788
|
* failure, so a broken Stop looked like a no-op).
|
|
769
789
|
*/
|
|
770
790
|
cancel(jobId: string): Promise<boolean>;
|
|
791
|
+
/**
|
|
792
|
+
* Stop ONE background task, by session id and SDK task id (issue #848).
|
|
793
|
+
*
|
|
794
|
+
* Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
|
|
795
|
+
* emptied in a `finally` the moment the primary turn's result lands, while the
|
|
796
|
+
* background tasks it launched keep running — so a `liveSessions`-keyed stop
|
|
797
|
+
* would go inert during exactly the phase the running-work bar exists for.
|
|
798
|
+
* `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
|
|
799
|
+
* session across it.
|
|
800
|
+
*
|
|
801
|
+
* There is deliberately NO liveness pre-check. A reap landing between render
|
|
802
|
+
* and click is a routine race, and `false` already describes it precisely.
|
|
803
|
+
*
|
|
804
|
+
* The three outcomes are kept DISTINCT all the way to the UI, because they
|
|
805
|
+
* mean genuinely different things and only one of them is a failure:
|
|
806
|
+
*
|
|
807
|
+
* - `stopping` — the request was accepted. Nothing is evicted here: the SDK
|
|
808
|
+
* emits the terminal `task_notification{status:"stopped"}` on the session's
|
|
809
|
+
* own stream and {@link BackgroundRegistry} already evicts on it. This is
|
|
810
|
+
* also the idempotent case — the CLI answers `not_found` / `not_running`
|
|
811
|
+
* with a success, so a click racing a natural completion lands here.
|
|
812
|
+
* - `gone` — no live session with that id. NOT a failure: the tasks died with
|
|
813
|
+
* the session, so the user's intent is already satisfied. The caller drops
|
|
814
|
+
* the row itself, because there is no stream left to notify on.
|
|
815
|
+
* - `error` — the stop did NOT happen and the task is still running. Notably
|
|
816
|
+
* a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
|
|
817
|
+
* with `unsupported_type`. Surfaced rather than reported as success.
|
|
818
|
+
*/
|
|
819
|
+
stopBackgroundTask(sessionId: string, taskId: string): Promise<StopTaskOutcome>;
|
|
771
820
|
/**
|
|
772
821
|
* End a managed chat SESSION outright, by session id (issue #731). Where
|
|
773
822
|
* {@link cancel} ends the model turn — and on the session runtime deliberately
|
|
@@ -1397,6 +1397,48 @@ export class HerdctlService {
|
|
|
1397
1397
|
return false;
|
|
1398
1398
|
}
|
|
1399
1399
|
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Stop ONE background task, by session id and SDK task id (issue #848).
|
|
1402
|
+
*
|
|
1403
|
+
* Deliberately keyed on the SESSION, not on {@link liveSessions}. That map is
|
|
1404
|
+
* emptied in a `finally` the moment the primary turn's result lands, while the
|
|
1405
|
+
* background tasks it launched keep running — so a `liveSessions`-keyed stop
|
|
1406
|
+
* would go inert during exactly the phase the running-work bar exists for.
|
|
1407
|
+
* `stopTaskInSession` is herdctl's answer to that gap: the reaper holds the
|
|
1408
|
+
* session across it.
|
|
1409
|
+
*
|
|
1410
|
+
* There is deliberately NO liveness pre-check. A reap landing between render
|
|
1411
|
+
* and click is a routine race, and `false` already describes it precisely.
|
|
1412
|
+
*
|
|
1413
|
+
* The three outcomes are kept DISTINCT all the way to the UI, because they
|
|
1414
|
+
* mean genuinely different things and only one of them is a failure:
|
|
1415
|
+
*
|
|
1416
|
+
* - `stopping` — the request was accepted. Nothing is evicted here: the SDK
|
|
1417
|
+
* emits the terminal `task_notification{status:"stopped"}` on the session's
|
|
1418
|
+
* own stream and {@link BackgroundRegistry} already evicts on it. This is
|
|
1419
|
+
* also the idempotent case — the CLI answers `not_found` / `not_running`
|
|
1420
|
+
* with a success, so a click racing a natural completion lands here.
|
|
1421
|
+
* - `gone` — no live session with that id. NOT a failure: the tasks died with
|
|
1422
|
+
* the session, so the user's intent is already satisfied. The caller drops
|
|
1423
|
+
* the row itself, because there is no stream left to notify on.
|
|
1424
|
+
* - `error` — the stop did NOT happen and the task is still running. Notably
|
|
1425
|
+
* a `monitor_mcp` task, which the CLI has no kill strategy for and rejects
|
|
1426
|
+
* with `unsupported_type`. Surfaced rather than reported as success.
|
|
1427
|
+
*/
|
|
1428
|
+
async stopBackgroundTask(sessionId, taskId) {
|
|
1429
|
+
const fleet = this.fleet;
|
|
1430
|
+
if (!fleet)
|
|
1431
|
+
return { outcome: "gone" };
|
|
1432
|
+
try {
|
|
1433
|
+
const stopped = await fleet.stopTaskInSession(sessionId, taskId);
|
|
1434
|
+
return stopped ? { outcome: "stopping" } : { outcome: "gone" };
|
|
1435
|
+
}
|
|
1436
|
+
catch (err) {
|
|
1437
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1438
|
+
console.warn(`[herdctl] stopTaskInSession failed for ${sessionId}/${taskId}:`, err);
|
|
1439
|
+
return { outcome: "error", message };
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1400
1442
|
/**
|
|
1401
1443
|
* End a managed chat SESSION outright, by session id (issue #731). Where
|
|
1402
1444
|
* {@link cancel} ends the model turn — and on the session runtime deliberately
|
|
@@ -1436,8 +1436,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1436
1436
|
app.get("/adoptable-chats", {
|
|
1437
1437
|
schema: {
|
|
1438
1438
|
tags: ["Chats"],
|
|
1439
|
-
summary: "Count the native Claude Code chats this project could
|
|
1440
|
-
description: "Returns the terminal `claude` sessions that could be
|
|
1439
|
+
summary: "Count the native Claude Code chats this project could adopt",
|
|
1440
|
+
description: "Returns the terminal `claude` sessions that could be adopted into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name when the project has a `repo:` key, by exact path otherwise). Sidechains, already-adopted sessions and sessions belonging to a real Paddock run are excluded by the engine; empty transcripts, slash-command-only transcripts and Paddock's own sweeper curation runs (`too-small` / `slash-command-only` / `sweeper-run`) are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an adoption rather than remembering a dismissal.",
|
|
1441
1441
|
params: {
|
|
1442
1442
|
type: "object",
|
|
1443
1443
|
properties: {
|
|
@@ -1474,8 +1474,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1474
1474
|
app.post("/adopt-chats", {
|
|
1475
1475
|
schema: {
|
|
1476
1476
|
tags: ["Chats"],
|
|
1477
|
-
summary: "
|
|
1478
|
-
description: "
|
|
1477
|
+
summary: "Adopt native Claude Code chats into this project",
|
|
1478
|
+
description: "Adopts the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is adopted; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. `sessionIds` narrows the adoption to a chosen SUBSET of what is on offer (what the confirmation dialog sends); ids that are not on offer are simply not imported, and anything the engine adopts as a side effect of taking a whole source is released again. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
|
|
1479
1479
|
params: {
|
|
1480
1480
|
type: "object",
|
|
1481
1481
|
properties: {
|
|
@@ -1500,7 +1500,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1500
1500
|
},
|
|
1501
1501
|
response: {
|
|
1502
1502
|
200: {
|
|
1503
|
-
description: "Object `{ adopted, skipped }` describing what was
|
|
1503
|
+
description: "Object `{ adopted, skipped }` describing what was adopted.",
|
|
1504
1504
|
type: "object",
|
|
1505
1505
|
additionalProperties: true,
|
|
1506
1506
|
},
|
|
@@ -1549,8 +1549,8 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1549
1549
|
app.post("/unadopt-chats", {
|
|
1550
1550
|
schema: {
|
|
1551
1551
|
tags: ["Chats"],
|
|
1552
|
-
summary: "Undo the most recent native-chat
|
|
1553
|
-
description: "Reverses the
|
|
1552
|
+
summary: "Undo the most recent native-chat adoption into this project",
|
|
1553
|
+
description: "Reverses the adoption this server most recently performed into the workspace: each adoption is released and the copies that adoption placed are deleted, leaving the user's own `~/.claude` history untouched. `sessionIds` undoes part of that adoption; omit it to undo all of it. Deliberately scoped to the LAST adoption and remembered in memory — undo may delete files, so the paths it can touch come from what this process actually did rather than from the request body, and a restart forgets the offer rather than acting on a stale one. Response: `{ released }`, the session ids actually released; an empty array means there was nothing to undo (nothing imported, already undone, or the server restarted since).",
|
|
1554
1554
|
params: {
|
|
1555
1555
|
type: "object",
|
|
1556
1556
|
properties: {
|
|
@@ -73,7 +73,7 @@ export function registerDiscoverRoutes(app, ctx) {
|
|
|
73
73
|
schema: {
|
|
74
74
|
tags: ["System"],
|
|
75
75
|
summary: "Directories on this machine that could become projects",
|
|
76
|
-
description: "Scans `<claudeHome>/projects/*` for directories with existing Claude Code history and returns the ones worth importing, ranked by how many non-noise sessions each holds. Never walks the filesystem: the only directories it can name are ones a transcript already records, and each is put through the same path floor a linked project must clear (no system directories, no temp roots, nothing inside Paddock's own data dir, nothing overlapping an existing project). Two rules are relaxable: `includeNonGit=1` offers directories that are not git checkouts, `includeOutsideHome=1` offers directories outside `$HOME`. `excluded` reports how many
|
|
76
|
+
description: "Scans `<claudeHome>/projects/*` for directories with existing Claude Code history and returns the ones worth importing, ranked by how many non-noise sessions each holds. Never walks the filesystem: the only directories it can name are ones a transcript already records, and each is put through the same path floor a linked project must clear (no system directories, no temp roots, nothing inside Paddock's own data dir, nothing overlapping an existing project). Two rules are relaxable: `includeNonGit=1` offers directories that are not git checkouts, `includeOutsideHome=1` offers directories outside `$HOME`. Note the two counts are in DIFFERENT units and do not reconcile by subtraction: `scanned` counts transcript FOLDERS, while `excluded` reports how many DIRECTORIES each rule ate — one directory routinely has two transcript folders (the user's own and Paddock's copy), so a rule that ate one directory may account for two scanned folders. The single exception is `no-recorded-cwd`, which is necessarily per-folder: a folder with no recorded cwd has no directory to be counted under. A container whose Claude home is empty reports `scanned: 0`, which is what distinguishes 'no history' from 'all of it was filtered'. Response: `{ claudeHome, homeDir, scanned, candidates, excluded }`.",
|
|
77
77
|
querystring: {
|
|
78
78
|
type: "object",
|
|
79
79
|
properties: {
|
|
@@ -159,6 +159,59 @@ export interface ChatCancelMessage {
|
|
|
159
159
|
jobId: string;
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Stop ONE piece of background work, leaving the session and everything else it
|
|
164
|
+
* is running alone (issue #848).
|
|
165
|
+
*
|
|
166
|
+
* The finer-grained sibling of `chat:cancel`: that ends the model turn (or, in
|
|
167
|
+
* the background phase, reaps the whole session), this ends a single background
|
|
168
|
+
* shell / sub-agent / monitor. Keyed on the SESSION id rather than a job or turn
|
|
169
|
+
* id because background work outlives the turn that started it — which is
|
|
170
|
+
* exactly the phase in which the running-work bar matters.
|
|
171
|
+
*
|
|
172
|
+
* `taskId` is the SDK's own `task_id`, carried to the client on
|
|
173
|
+
* `chat:background` as {@link LiveBackgroundTaskWire.id}.
|
|
174
|
+
*
|
|
175
|
+
* Answered by exactly one {@link ChatStopTaskResultMessage}, to the asking
|
|
176
|
+
* socket. It does NOT remove the row: the SDK emits its own terminal
|
|
177
|
+
* `task_notification`, which arrives as an ordinary `chat:background` update.
|
|
178
|
+
*/
|
|
179
|
+
export interface ChatStopTaskMessage {
|
|
180
|
+
type: "chat:stop_task";
|
|
181
|
+
payload: {
|
|
182
|
+
sessionId: string;
|
|
183
|
+
taskId: string;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The answer to one {@link ChatStopTaskMessage} — three outcomes, only one of
|
|
188
|
+
* which is a failure:
|
|
189
|
+
*
|
|
190
|
+
* - `stopping` — accepted. The row stays, held at `stopping…`, until the SDK's
|
|
191
|
+
* terminal notification evicts it. Also the idempotent case: a click that
|
|
192
|
+
* raced a natural completion lands here.
|
|
193
|
+
* - `gone` — no live session. NOT an error; the work died with the session, so
|
|
194
|
+
* the intent is already satisfied. The server has already dropped the task
|
|
195
|
+
* from the registry (nothing is left to notify), so a `chat:background`
|
|
196
|
+
* update without it accompanies this.
|
|
197
|
+
* - `error` — the stop did not happen and the task is STILL RUNNING. Notably a
|
|
198
|
+
* `monitor_mcp` task, which the CLI cannot kill. `message` is for display.
|
|
199
|
+
*
|
|
200
|
+
* Unicast, not broadcast: it answers one socket's request, and a second viewer
|
|
201
|
+
* of the same chat should not be shown an error for a click it did not make.
|
|
202
|
+
* Both viewers still agree on the outcome, because the row's actual removal
|
|
203
|
+
* travels on the broadcast `chat:background` frame.
|
|
204
|
+
*/
|
|
205
|
+
export interface ChatStopTaskResultMessage {
|
|
206
|
+
type: "chat:stop_task_result";
|
|
207
|
+
payload: {
|
|
208
|
+
sessionId: string;
|
|
209
|
+
taskId: string;
|
|
210
|
+
outcome: "stopping" | "gone" | "error";
|
|
211
|
+
/** Present only on `error`. */
|
|
212
|
+
message?: string;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
162
215
|
/**
|
|
163
216
|
* Run a slash command (e.g. `/compact`) in the current chat.
|
|
164
217
|
*
|
|
@@ -276,7 +329,7 @@ export interface ChatContinueMessage {
|
|
|
276
329
|
export interface PingMessage {
|
|
277
330
|
type: "ping";
|
|
278
331
|
}
|
|
279
|
-
export type ClientMessage = ChatSendMessage | ChatCommandMessage | ChatCancelMessage | ChatSubscribeMessage | ChatSetQueueMessage | ChatContinueMessage | PingMessage;
|
|
332
|
+
export type ClientMessage = ChatSendMessage | ChatCommandMessage | ChatCancelMessage | ChatStopTaskMessage | ChatSubscribeMessage | ChatSetQueueMessage | ChatContinueMessage | PingMessage;
|
|
280
333
|
export interface Routing {
|
|
281
334
|
projectSlug: string;
|
|
282
335
|
sessionId: string | null;
|
|
@@ -584,6 +637,29 @@ export interface LiveBackgroundTaskWire {
|
|
|
584
637
|
lastToolName?: string;
|
|
585
638
|
toolUses?: number;
|
|
586
639
|
skipTranscript?: boolean;
|
|
640
|
+
/**
|
|
641
|
+
* May this task be stopped via `chat:stop_task` (#848)? Resolved on the
|
|
642
|
+
* server from the raw {@link type}, and sent rather than left to the client
|
|
643
|
+
* for two reasons — note that neither is "the client could not tell", since
|
|
644
|
+
* #846 does carry the raw discriminant here:
|
|
645
|
+
*
|
|
646
|
+
* 1. It is a fact about the RUNTIME'S capability, not about rendering. The
|
|
647
|
+
* server owns which task types the CLI has a kill strategy for; a second
|
|
648
|
+
* copy of that denylist in the client is a second place to forget when
|
|
649
|
+
* the SDK gains one.
|
|
650
|
+
* 2. #846 made {@link role} the thing clients render and {@link type} a
|
|
651
|
+
* diagnostic. Having the client branch on `type` for this one decision
|
|
652
|
+
* would re-couple its rendering to the discriminant #846 just abstracted
|
|
653
|
+
* away — and `role` alone genuinely is not enough, because `monitor_ws`
|
|
654
|
+
* and `monitor_mcp` share the `monitor` role and only one is killable.
|
|
655
|
+
*
|
|
656
|
+
* REQUIRED on the wire, like {@link role}: the server always resolves it, and
|
|
657
|
+
* leaving it optional here would let a future producer omit it and still
|
|
658
|
+
* typecheck — at which point clients read absent as "stoppable" and hand a
|
|
659
|
+
* `monitor_mcp` row a button that can only ever fail. The tolerance for an
|
|
660
|
+
* older server belongs on the CLIENT's copy of this type, and lives there.
|
|
661
|
+
*/
|
|
662
|
+
stoppable: boolean;
|
|
587
663
|
}
|
|
588
664
|
/**
|
|
589
665
|
* A session's live background work (#604) — the tasks still running after the
|
|
@@ -626,6 +702,6 @@ export interface ChatNoticeMessage {
|
|
|
626
702
|
export interface PongMessage {
|
|
627
703
|
type: "pong";
|
|
628
704
|
}
|
|
629
|
-
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatBackgroundMessage | ChatNoticeMessage | PongMessage;
|
|
705
|
+
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatBackgroundMessage | ChatStopTaskResultMessage | ChatNoticeMessage | PongMessage;
|
|
630
706
|
export declare function isClientMessage(data: unknown): data is ClientMessage;
|
|
631
707
|
export {};
|
|
@@ -198,6 +198,15 @@ export function isClientMessage(data) {
|
|
|
198
198
|
const p = m.payload;
|
|
199
199
|
return !!p && typeof p.jobId === "string";
|
|
200
200
|
}
|
|
201
|
+
if (m.type === "chat:stop_task") {
|
|
202
|
+
const p = m.payload;
|
|
203
|
+
// Both ids must be non-empty: an empty session id would reach the fleet as a
|
|
204
|
+
// lookup miss and report `gone`, quietly telling the user work that is still
|
|
205
|
+
// running has stopped.
|
|
206
|
+
if (!p || typeof p.sessionId !== "string" || p.sessionId.length === 0)
|
|
207
|
+
return false;
|
|
208
|
+
return typeof p.taskId === "string" && p.taskId.length > 0;
|
|
209
|
+
}
|
|
201
210
|
if (m.type === "chat:send") {
|
|
202
211
|
const p = m.payload;
|
|
203
212
|
if (!p || typeof p.message !== "string")
|
|
@@ -1500,6 +1500,44 @@ export function makeChatHandler(deps) {
|
|
|
1500
1500
|
void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
|
|
1501
1501
|
return;
|
|
1502
1502
|
}
|
|
1503
|
+
if (parsed.type === "chat:stop_task") {
|
|
1504
|
+
// Stop ONE background task (#848). Unlike `chat:cancel` there is no turn
|
|
1505
|
+
// to note and no queue to hand back — this leaves the session, and every
|
|
1506
|
+
// other task in it, running. The row is NOT removed here: on `stopping`
|
|
1507
|
+
// the SDK's own terminal notification does it, which is what keeps the
|
|
1508
|
+
// bar honest if the kill turns out not to land.
|
|
1509
|
+
const { sessionId, taskId } = parsed.payload;
|
|
1510
|
+
void deps.herdctl.stopBackgroundTask(sessionId, taskId).then((res) => {
|
|
1511
|
+
// `gone` means no live session, so nothing will ever notify for this
|
|
1512
|
+
// task — drop it here or the row is stranded at `stopping…` forever.
|
|
1513
|
+
// The level signal stays the authority and puts it back if wrong.
|
|
1514
|
+
if (res.outcome === "gone")
|
|
1515
|
+
background.dropTask(sessionId, taskId);
|
|
1516
|
+
send({
|
|
1517
|
+
type: "chat:stop_task_result",
|
|
1518
|
+
payload: {
|
|
1519
|
+
sessionId,
|
|
1520
|
+
taskId,
|
|
1521
|
+
outcome: res.outcome,
|
|
1522
|
+
...(res.outcome === "error" ? { message: res.message } : {}),
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
},
|
|
1526
|
+
// stopBackgroundTask catches its own rejections; this is belt-and-braces
|
|
1527
|
+
// so a future refactor cannot leave the row held with no answer at all.
|
|
1528
|
+
(err) => {
|
|
1529
|
+
send({
|
|
1530
|
+
type: "chat:stop_task_result",
|
|
1531
|
+
payload: {
|
|
1532
|
+
sessionId,
|
|
1533
|
+
taskId,
|
|
1534
|
+
outcome: "error",
|
|
1535
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1536
|
+
},
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1503
1541
|
if (parsed.type === "chat:subscribe") {
|
|
1504
1542
|
onSubscribe(parsed);
|
|
1505
1543
|
return;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{j as r,l as t,r as d}from"./index-
|
|
1
|
+
import{j as r,l as t,r as d}from"./index-CbqaEg2j.js";const u=["w-full rounded-lg border border-edge-strong bg-surface-sunken px-3 py-2 text-sm text-fg","outline-none motion-fast transition-[border-color,box-shadow]","focus:border-accent focus:ring-2 focus:ring-accent/25","disabled:cursor-not-allowed disabled:opacity-60","aria-[invalid=true]:border-danger aria-[invalid=true]:focus:ring-danger/25"].join(" ");function p({className:e,...s}){return r.jsx("input",{className:t(u,e),...s})}function m({className:e,...s}){return r.jsx("textarea",{className:t(u,"resize-y",e),...s})}function h({className:e,...s}){return r.jsx("select",{className:t(u,e),...s})}function j({checked:e,onChange:s,label:a,disabled:n,id:o,className:c}){const i=d.useId(),l=o??i;return r.jsxs("label",{htmlFor:l,className:t("relative inline-block shrink-0 cursor-pointer",c),children:[r.jsx("input",{id:l,type:"checkbox","aria-label":a,className:"peer sr-only",checked:e,disabled:n,onChange:x=>s(x.target.checked)}),r.jsx("span",{className:t("block h-[18px] w-8 rounded-full bg-edge-strong","motion-fast transition-colors","after:absolute after:left-0.5 after:top-0.5 after:h-3.5 after:w-3.5 after:rounded-full","after:bg-surface-raised after:shadow-2xs after:transition-transform after:content-['']","peer-checked:bg-accent-solid peer-checked:after:translate-x-3.5","peer-focus-visible:ring-2 peer-focus-visible:ring-accent/50","peer-disabled:opacity-50")})]})}function w({label:e,description:s,indeterminate:a=!1,className:n,...o}){const c=d.useRef(null);d.useEffect(()=>{c.current&&(c.current.indeterminate=a)},[a]);const i=r.jsx("input",{ref:c,type:"checkbox",...a?{"aria-checked":"mixed"}:{},className:t("h-4 w-4 shrink-0 cursor-pointer rounded border-edge-strong accent-[var(--accent-solid)]","disabled:cursor-not-allowed disabled:opacity-50",e?"mt-0.5":"",n),...o});return e?r.jsxs("label",{className:"flex cursor-pointer items-start gap-2 text-sm text-fg",children:[i,r.jsxs("span",{className:"min-w-0",children:[e,s&&r.jsx("span",{className:"block text-xs leading-snug text-fg-muted",children:s})]})]}):i}const f={neutral:"bg-surface-active text-fg-muted",accent:"bg-accent-soft text-accent",success:"bg-success-soft text-success",warn:"bg-warn-soft text-warn",danger:"bg-danger-soft text-danger",info:"bg-info-soft text-info",lineage:"bg-lineage-soft text-lineage"},g={neutral:"bg-fg-subtle",accent:"bg-accent-solid",success:"bg-success-solid",warn:"bg-warn-solid",danger:"bg-danger-solid",info:"bg-info-solid",lineage:"bg-lineage-solid"};function N({tone:e="neutral",shape:s="tag",size:a="md",dot:n=!1,icon:o,title:c,className:i,children:l}){return r.jsxs("span",{title:c,className:t("inline-flex items-center gap-1 font-medium leading-none",s==="pill"?"rounded-full px-2 py-0.5":"rounded-md px-1.5 py-0.5",a==="sm"?"text-3xs":"text-2xs",f[e],i),children:[n&&r.jsx("span",{className:t("h-1.5 w-1.5 shrink-0 rounded-full",g[e])}),o,l]})}function k({tone:e="neutral",pulse:s=!1,className:a}){return r.jsx("span",{className:t("inline-block h-2 w-2 shrink-0 rounded-full",g[e],s&&"animate-pulse",a)})}function y({tone:e="info",icon:s,className:a,children:n}){const o={neutral:"border-edge",accent:"border-accent-edge",success:"border-success-edge",warn:"border-warn-edge",danger:"border-danger-edge",info:"border-info-edge",lineage:"border-lineage-edge"};return r.jsxs("div",{role:e==="danger"?"alert":void 0,className:t("flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",f[e],o[e],a),children:[s&&r.jsx("span",{className:"mt-0.5 shrink-0",children:s}),r.jsx("div",{className:"min-w-0",children:n})]})}export{N as C,p as I,k as S,j as T,y as a,h as b,m as c,w as d};
|
|
2
2
|
|