@edspencer/paddock 0.71.0 → 0.71.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/chat-dto.d.ts +3 -2
  3. package/packages/server/dist/chat-dto.js +9 -1
  4. package/packages/server/dist/cli/args.d.ts +11 -3
  5. package/packages/server/dist/cli/args.js +20 -2
  6. package/packages/server/dist/cli/paddock.js +3 -3
  7. package/packages/server/dist/cli/service/backend.d.ts +33 -0
  8. package/packages/server/dist/cli/service/index.d.ts +10 -1
  9. package/packages/server/dist/cli/service/index.js +167 -7
  10. package/packages/server/dist/cli/service/launchd.d.ts +27 -4
  11. package/packages/server/dist/cli/service/launchd.js +88 -14
  12. package/packages/server/dist/cli/service/ready.d.ts +61 -0
  13. package/packages/server/dist/cli/service/ready.js +71 -0
  14. package/packages/server/dist/cli/service/systemd.d.ts +21 -2
  15. package/packages/server/dist/cli/service/systemd.js +58 -5
  16. package/packages/server/dist/discover.d.ts +37 -0
  17. package/packages/server/dist/discover.js +51 -0
  18. package/packages/server/dist/herdctl.d.ts +2 -1
  19. package/packages/server/dist/herdctl.js +11 -6
  20. package/packages/server/dist/last-activity.d.ts +34 -0
  21. package/packages/server/dist/last-activity.js +178 -0
  22. package/packages/server/dist/management-ops.js +5 -1
  23. package/packages/server/dist/routes/discover.js +10 -3
  24. package/packages/server/dist/self-mcp-types.d.ts +4 -1
  25. package/packages/server/dist/transcripts.js +5 -3
  26. package/packages/server/package.json +1 -1
  27. package/packages/web/dist/assets/Chip-Cp32dDmz.js +2 -0
  28. package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +2 -0
  29. package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +2 -0
  30. package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +2 -0
  31. package/packages/web/dist/assets/{MarkdownRenderer-BOrDawoY.js → MarkdownRenderer-DA5CpdGI.js} +2 -2
  32. package/packages/web/dist/assets/{ProjectMenu-B_8NYWZr.js → ProjectMenu-DXXXtXeh.js} +1 -1
  33. package/packages/web/dist/assets/{ProjectRedirect-xFWTlADS.js → ProjectRedirect-k10uM_63.js} +1 -1
  34. package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +13 -0
  35. package/packages/web/dist/assets/{ProjectsGrid-BF65wTqN.js → ProjectsGrid-DrzZ7R8M.js} +1 -1
  36. package/packages/web/dist/assets/RootHome-rWodzOC8.js +2 -0
  37. package/packages/web/dist/assets/{arc-7GXE70CZ.js → arc-BKAVFR26.js} +1 -1
  38. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CAEj_lAw.js → architectureDiagram-3BPJPVTR-UzIlxaCQ.js} +1 -1
  39. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-wNLONvdQ.js → blockDiagram-GPEHLZMM-Eu4BV7Ea.js} +1 -1
  40. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cf7MUqJz.js → c4Diagram-AAUBKEIU-DR8PZaoa.js} +1 -1
  41. package/packages/web/dist/assets/channel-C-sodtn7.js +2 -0
  42. package/packages/web/dist/assets/{chunk-2J33WTMH-W18BVHtE.js → chunk-2J33WTMH-CgvIIrI1.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-4BX2VUAB-DDJQi9KC.js → chunk-4BX2VUAB-CyJWrZqN.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-55IACEB6-L5afa49k.js → chunk-55IACEB6-DXEnZN1d.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-727SXJPM-Bb0Sw8oK.js → chunk-727SXJPM-Co89QTmp.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DRrKTv1K.js → chunk-AQP2D5EJ-BRxTzfev.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-FMBD7UC4-CIsdDBaT.js → chunk-FMBD7UC4-DUj18CvF.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-ND2GUHAM-DN5ua8KT.js → chunk-ND2GUHAM-BQMMJobN.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-QZHKN3VN-DE1WNShj.js → chunk-QZHKN3VN-COx8LZCe.js} +1 -1
  50. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +2 -0
  51. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +2 -0
  52. package/packages/web/dist/assets/{core-DadwNpE4.js → core-DnpcT0s6.js} +1 -1
  53. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-2FTPTDA6.js → cose-bilkent-S5V4N54A-B-4NadKI.js} +1 -1
  54. package/packages/web/dist/assets/{dagre-BM42HDAG-eJ7MdaEX.js → dagre-BM42HDAG-CuaYJexd.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-2AECGRRQ-D8MZTSkB.js → diagram-2AECGRRQ-C9_anxrA.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-5GNKFQAL-DVXF3Z-H.js → diagram-5GNKFQAL-Ckf3kFlY.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-KO2AKTUF-DoXfbQ0i.js → diagram-KO2AKTUF-BrpUuY5x.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-LMA3HP47-CRW0fiqv.js → diagram-LMA3HP47-B9lyXZXW.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-OG6HWLK6-DfhYXDqr.js → diagram-OG6HWLK6-DT7ro32m.js} +1 -1
  60. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-HG0QTHgA.js → erDiagram-TEJ5UH35-ZZn9bHQh.js} +1 -1
  61. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-ChVekwUy.js → flowDiagram-I6XJVG4X-BxQ82PY_.js} +1 -1
  62. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CeA-6Z-T.js → ganttDiagram-6RSMTGT7-BxliloOH.js} +1 -1
  63. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-ew2oouNN.js → gitGraphDiagram-PVQCEYII-BlwODdH9.js} +1 -1
  64. package/packages/web/dist/assets/{index-CbqaEg2j.js → index-Cx9y6c3f.js} +2 -2
  65. package/packages/web/dist/assets/index-bC5uCujx.css +1 -0
  66. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BY9NYdld.js → infoDiagram-5YYISTIA-nanSBFyG.js} +1 -1
  67. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CgbdrGBm.js → ishikawaDiagram-YF4QCWOH-yZHr_s28.js} +1 -1
  68. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BepLFtbU.js → journeyDiagram-JHISSGLW-BKjDqQEQ.js} +1 -1
  69. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-JHMJYGJu.js → kanban-definition-UN3LZRKU-QusvHqc6.js} +1 -1
  70. package/packages/web/dist/assets/{linear-Ccw8eVgC.js → linear-4xRoD38Z.js} +1 -1
  71. package/packages/web/dist/assets/{mermaid.core-lHjASk7x.js → mermaid.core-CeulYvDv.js} +4 -4
  72. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bi5Iq2M4.js → mindmap-definition-RKZ34NQL-DSjYt2Kp.js} +1 -1
  73. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-rZuB_P1J.js → pieDiagram-4H26LBE5-Ci0gO3CA.js} +1 -1
  74. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BuStI37i.js → quadrantDiagram-W4KKPZXB-y2qHkaHP.js} +1 -1
  75. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BFElQKEX.js → requirementDiagram-4Y6WPE33-D9hiWbad.js} +1 -1
  76. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-tzdQCr91.js → sankeyDiagram-5OEKKPKP-DA3Mvoze.js} +1 -1
  77. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-j1koqb6Q.js → sequenceDiagram-3UESZ5HK-DWACrljw.js} +1 -1
  78. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B-dUkvgq.js → stateDiagram-AJRCARHV-CFGEhoTb.js} +1 -1
  79. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +2 -0
  80. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BWyq6u64.js → timeline-definition-PNZ67QCA-Cjygpd9e.js} +1 -1
  81. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B0CRl3xN.js → vennDiagram-CIIHVFJN-zQnLaxgv.js} +1 -1
  82. package/packages/web/dist/assets/{wardley-L42UT6IY-C45bBEPS.js → wardley-L42UT6IY-DGmt-Lo2.js} +1 -1
  83. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DT50TWrW.js → wardleyDiagram-YWT4CUSO-Dj3vDukN.js} +1 -1
  84. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C8iBuFGD.js → xychartDiagram-2RQKCTM6-C0bGbwbL.js} +1 -1
  85. package/packages/web/dist/index.html +2 -2
  86. package/packages/web/dist/sw.js +1 -1
  87. package/packages/web/dist/assets/Chip-5S5MAayn.js +0 -2
  88. package/packages/web/dist/assets/DiscoverPage-CEzcRO7X.js +0 -2
  89. package/packages/web/dist/assets/DiscoverView-CArICIRO.js +0 -2
  90. package/packages/web/dist/assets/InstanceConfigPage-CKjJsaGZ.js +0 -2
  91. package/packages/web/dist/assets/ProjectView-polknqZw.js +0 -13
  92. package/packages/web/dist/assets/RootHome-32k1iDh4.js +0 -2
  93. package/packages/web/dist/assets/Surfaces-DCm19ZAD.js +0 -2
  94. package/packages/web/dist/assets/channel-U0NZSTs2.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D1QjNKTc.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D1QjNKTc.js +0 -2
  97. package/packages/web/dist/assets/index-tvJIh4A9.css +0 -1
  98. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-b1ksIBrs.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.71.0",
3
+ "version": "0.71.2",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -6,6 +6,7 @@
6
6
  * and the run-history page-size constants + `clampRunsLimit`.
7
7
  */
8
8
  import type { DiscoveredSession } from "@herdctl/core";
9
+ import type { SessionWithActivity } from "./last-activity.js";
9
10
  import type { Project } from "./projects.js";
10
11
  import { type SessionTokenUsage } from "./usage.js";
11
12
  import type { RunProvenance } from "./run-provenance.js";
@@ -138,7 +139,7 @@ export type ChatUsageScope = (typeof CHAT_USAGE_SCOPES)[number];
138
139
  * each class separately (see {@link estimateCostUsd}).
139
140
  */
140
141
  export declare function toChatUsage(u: SessionTokenUsage, model: string): ChatUsage | null;
141
- export declare function toChatDto(s: DiscoveredSession, previewOverride?: string, usage?: ChatUsage | null, archived?: boolean, lastTurnCompletedAt?: string, lastSeen?: number, provenance?: RunProvenance | null, trigger?: ChatTriggerInfo | null, starred?: boolean, unread?: boolean, parent?: ChatParentRef | null): {
142
+ export declare function toChatDto(s: SessionWithActivity, previewOverride?: string, usage?: ChatUsage | null, archived?: boolean, lastTurnCompletedAt?: string, lastSeen?: number, provenance?: RunProvenance | null, trigger?: ChatTriggerInfo | null, starred?: boolean, unread?: boolean, parent?: ChatParentRef | null): {
142
143
  trigger?: ChatTriggerInfo | undefined;
143
144
  parent?: ChatParentRef | undefined;
144
145
  provenance?: RunProvenance | undefined;
@@ -170,7 +171,7 @@ export declare function toChatDto(s: DiscoveredSession, previewOverride?: string
170
171
  * name reflects the user's actual request. Only preload chats trigger the extra
171
172
  * (head-of-file) read; everything else maps straight through.
172
173
  */
173
- export declare function buildProjectChats(projectDir: string, sessions: DiscoveredSession[], usageOf?: (s: DiscoveredSession) => Promise<ChatUsage | null>, archivedOf?: (s: DiscoveredSession) => Promise<boolean>, lastTurnAt?: ReadonlyMap<string, string>, lastSeenOf?: (s: DiscoveredSession) => Promise<number>, provenanceOf?: (s: DiscoveredSession) => Promise<RunProvenance | undefined>, triggerOf?: (s: DiscoveredSession) => Promise<ChatTriggerInfo | undefined>, starredOf?: (s: DiscoveredSession) => Promise<boolean>, unreadOf?: (s: DiscoveredSession) => Promise<boolean>, parentOf?: (s: DiscoveredSession) => Promise<ChatParentRef | null>): Promise<{
174
+ export declare function buildProjectChats(projectDir: string, sessions: SessionWithActivity[], usageOf?: (s: DiscoveredSession) => Promise<ChatUsage | null>, archivedOf?: (s: DiscoveredSession) => Promise<boolean>, lastTurnAt?: ReadonlyMap<string, string>, lastSeenOf?: (s: DiscoveredSession) => Promise<number>, provenanceOf?: (s: DiscoveredSession) => Promise<RunProvenance | undefined>, triggerOf?: (s: DiscoveredSession) => Promise<ChatTriggerInfo | undefined>, starredOf?: (s: DiscoveredSession) => Promise<boolean>, unreadOf?: (s: DiscoveredSession) => Promise<boolean>, parentOf?: (s: DiscoveredSession) => Promise<ChatParentRef | null>): Promise<{
174
175
  trigger?: ChatTriggerInfo | undefined;
175
176
  parent?: ChatParentRef | undefined;
176
177
  provenance?: RunProvenance | undefined;
@@ -185,7 +185,15 @@ export function toChatDto(s, previewOverride, usage, archived = false, lastTurnC
185
185
  sessionId: s.sessionId,
186
186
  workingDirectory: s.workingDirectory,
187
187
  name: s.customName ?? s.autoName ?? preview ?? s.sessionId.slice(0, 8),
188
- updatedAt: s.mtime,
188
+ // The last real MESSAGE in the transcript, not the file's mtime (#863).
189
+ // Paddock touches transcripts for reasons that are not conversation — a
190
+ // discovery re-stat, a resume appending a mode record — and every one of
191
+ // those used to restamp an idle chat as "updated a few minutes ago" and
192
+ // float it to the top of a list sorted by recency. mtime survives as the
193
+ // fallback for a transcript with no datable record, and only as that: it is
194
+ // still the cache key for auto-name, preview and usage, so it is read here
195
+ // rather than overwritten upstream.
196
+ updatedAt: s.lastMessageAt ?? s.mtime,
189
197
  resumable: s.resumable,
190
198
  preview,
191
199
  // Whether this chat is filed away in the Archived section (#95). Always
@@ -36,8 +36,16 @@ export interface CliOptions {
36
36
  export declare class CliError extends Error {
37
37
  }
38
38
  export declare const MIN_NODE_MAJOR = 22;
39
- /** What `paddock service` can be asked to do. */
40
- export declare const SERVICE_ACTIONS: readonly ["install", "uninstall", "status"];
39
+ /**
40
+ * What `paddock service` can be asked to do.
41
+ *
42
+ * `start`/`stop`/`restart` join the original three in #873. Note that `start`
43
+ * is also a top-level VERB — `paddock start` runs a server in this terminal,
44
+ * `paddock service start` asks the supervisor to run one. The overlap is safe
45
+ * because `parseCommand` dispatches on `argv[0]` alone and only then reads an
46
+ * action, so the two never compete for the same token.
47
+ */
48
+ export declare const SERVICE_ACTIONS: readonly ["install", "uninstall", "status", "start", "stop", "restart"];
41
49
  export type ServiceAction = (typeof SERVICE_ACTIONS)[number];
42
50
  /** The leading words {@link parseCommand} recognises. Anything else is a flag. */
43
51
  export declare const VERBS: readonly ["start", "service"];
@@ -100,4 +108,4 @@ export declare function nodeVersionProblem(nodeVersion: string): string | undefi
100
108
  */
101
109
  export declare function explainListenError(err: unknown, host: string, port: string): string;
102
110
  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";
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";
111
+ 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 paddock service start start an installed service that is stopped\n paddock service stop stop it, and leave it installed\n paddock service restart stop it and start it again, re-reading config\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";
@@ -27,8 +27,23 @@
27
27
  export class CliError extends Error {
28
28
  }
29
29
  export const MIN_NODE_MAJOR = 22;
30
- /** What `paddock service` can be asked to do. */
31
- export const SERVICE_ACTIONS = ["install", "uninstall", "status"];
30
+ /**
31
+ * What `paddock service` can be asked to do.
32
+ *
33
+ * `start`/`stop`/`restart` join the original three in #873. Note that `start`
34
+ * is also a top-level VERB — `paddock start` runs a server in this terminal,
35
+ * `paddock service start` asks the supervisor to run one. The overlap is safe
36
+ * because `parseCommand` dispatches on `argv[0]` alone and only then reads an
37
+ * action, so the two never compete for the same token.
38
+ */
39
+ export const SERVICE_ACTIONS = [
40
+ "install",
41
+ "uninstall",
42
+ "status",
43
+ "start",
44
+ "stop",
45
+ "restart",
46
+ ];
32
47
  /** The leading words {@link parseCommand} recognises. Anything else is a flag. */
33
48
  export const VERBS = ["start", "service"];
34
49
  function isServiceAction(token) {
@@ -278,6 +293,9 @@ Usage
278
293
  paddock service install [options] register it and start it now
279
294
  paddock service uninstall stop it and deregister it
280
295
  paddock service status is it registered, is it running, where are the logs
296
+ paddock service start start an installed service that is stopped
297
+ paddock service stop stop it, and leave it installed
298
+ paddock service restart stop it and start it again, re-reading config
281
299
 
282
300
  Options (install only — recorded in the generated unit)
283
301
  -p, --port <port> HTTP/WS port (default 7233)
@@ -155,7 +155,7 @@ function openBrowser(url) {
155
155
  * for npx's cache, because a unit file pointing into a prunable hash-keyed
156
156
  * directory rots at some future login with nobody watching.
157
157
  */
158
- function service(command) {
158
+ async function service(command) {
159
159
  const { action, opts } = command;
160
160
  // Unreachable: `parseCommand` only omits the action when `--help` was given,
161
161
  // which `main` has already handled. Typed rather than asserted.
@@ -164,7 +164,7 @@ function service(command) {
164
164
  return;
165
165
  }
166
166
  try {
167
- runService(action, opts, {
167
+ await runService(action, opts, {
168
168
  platform: process.platform,
169
169
  nodePath: process.execPath,
170
170
  scriptPath: entryScript,
@@ -210,7 +210,7 @@ async function main() {
210
210
  if (problem !== undefined)
211
211
  fail(problem);
212
212
  if (command.verb === "service") {
213
- service(command);
213
+ await service(command);
214
214
  return;
215
215
  }
216
216
  addBundledBinsToPath();
@@ -20,6 +20,21 @@ export interface ServiceState {
20
20
  pid?: string;
21
21
  /** The Paddock arguments recorded in the installed unit, interpreter stripped. */
22
22
  argv: string[];
23
+ /**
24
+ * Does the unit ON DISK come back after a graceful stop (#872)?
25
+ *
26
+ * Read from the installed file rather than from what we would write today,
27
+ * because those differ for exactly the people who need to hear about it.
28
+ * Upgrading the package does not rewrite a unit, so anyone who installed
29
+ * before #872 still has `SuccessfulExit: false` / `Restart=on-failure` — the
30
+ * shape where one sleep or logout stops Paddock for good. They cannot see
31
+ * that from the outside: the service simply is not running one morning.
32
+ * `status` is where they look, so `status` is where it has to say so.
33
+ *
34
+ * Meaningless when `registered` is false, and `true` there — "nothing to warn
35
+ * about" — so no caller has to special-case a unit that does not exist.
36
+ */
37
+ keepsAlive: boolean;
23
38
  }
24
39
  export interface ServiceBackend {
25
40
  platform: "darwin" | "linux";
@@ -31,6 +46,24 @@ export interface ServiceBackend {
31
46
  /** True if something was actually there to remove. */
32
47
  uninstall(): boolean;
33
48
  status(): ServiceState;
49
+ /**
50
+ * Start / stop / restart an already-installed unit (#873).
51
+ *
52
+ * These exist so an intended stop has a channel of its OWN. Before them the
53
+ * only stop was `uninstall`, which also deregisters, so "bounce it after
54
+ * editing config" meant either losing the unit or dropping to raw
55
+ * `launchctl kickstart -k gui/$(id -u)/net.edspencer.paddock` — undiscoverable,
56
+ * and different on each platform. The exit-code heuristic that #872 removed
57
+ * was carrying the same meaning by inference; a command carries it directly.
58
+ *
59
+ * Each throws with the supervisor's own stderr rather than returning a
60
+ * boolean: the interesting failures here (a unit that was booted out, a
61
+ * manager that is not running) have text worth showing, and swallowing it
62
+ * would leave `start` printing a URL for a server that never came up.
63
+ */
64
+ start(): void;
65
+ stop(): void;
66
+ restart(): void;
34
67
  /** How to read the logs, as printed lines. */
35
68
  logsHint(spec: ServiceSpec): string;
36
69
  /** The `enable-linger` warning, or undefined where it does not apply. */
@@ -1,6 +1,7 @@
1
1
  import { type CliOptions, type ServiceAction } from "../args.js";
2
2
  import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
3
3
  import { type Runner } from "./backend.js";
4
+ import { type Readiness } from "./ready.js";
4
5
  export interface ServiceContext {
5
6
  /** `process.platform`. */
6
7
  platform: NodeJS.Platform;
@@ -17,8 +18,16 @@ export interface ServiceContext {
17
18
  xdgConfigHome?: string;
18
19
  pathEnv?: string;
19
20
  run?: Runner;
21
+ /**
22
+ * "Did the URL answer?", injected for the same reason `run` is.
23
+ *
24
+ * Without a seam here every `install` / `start` / `restart` test would really
25
+ * poll 127.0.0.1 for the full timeout and then assert on the disappointed
26
+ * message — twenty seconds per case, and green for the wrong reason.
27
+ */
28
+ checkReady?: (url: string) => Promise<Readiness>;
20
29
  }
21
- export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): void;
30
+ export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): Promise<void>;
22
31
  /** Exported for the entrypoint's context assembly, and for tests. */
23
32
  export { serviceDir, isNpxPath };
24
33
  export type { ServiceSpec };
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `paddock service install | uninstall | status` (#796).
2
+ * `paddock service install | uninstall | status | start | stop | restart`
3
+ * (#796; the lifecycle three added in #873).
3
4
  *
4
5
  * Registers Paddock as a **per-user** background service — a launchd
5
6
  * LaunchAgent on macOS, a `systemd --user` unit on Linux — so it is up from
@@ -26,9 +27,10 @@ import os from "node:os";
26
27
  import path from "node:path";
27
28
  import { CliError } from "../args.js";
28
29
  import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, } from "./spec.js";
29
- import { spawnRunner } from "./backend.js";
30
+ import { spawnRunner, } from "./backend.js";
30
31
  import { createLaunchdBackend } from "./launchd.js";
31
32
  import { createSystemdBackend } from "./systemd.js";
33
+ import { waitForReady, READY_TIMEOUT_LABEL } from "./ready.js";
32
34
  /**
33
35
  * Pick a backend, or explain why there isn't one.
34
36
  *
@@ -76,19 +78,75 @@ function specFor(opts, ctx) {
76
78
  const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots — it runs as\n" +
77
79
  " you, so it can use the Claude login you already have. After a restart\n" +
78
80
  " that nobody logs into, Paddock is not running.";
79
- function install(opts, ctx, backend) {
81
+ /**
82
+ * What `status` says about a unit installed before #872.
83
+ *
84
+ * Upgrading the package leaves the unit file alone, so the people still holding
85
+ * the broken shape are exactly the people who will never notice — the symptom
86
+ * is Paddock being absent one morning, with nothing in the logs, because it was
87
+ * signalled rather than crashed. Naming the fix here is the only path from that
88
+ * symptom back to a working service.
89
+ */
90
+ function staleUnitNote(backend) {
91
+ const key = backend.platform === "darwin"
92
+ ? "KeepAlive is conditional on a non-zero exit"
93
+ : "Restart=on-failure";
94
+ return (` ⚠ This unit predates a fix (#872): ${key}, so a graceful stop —\n` +
95
+ " the kind sleep, logout or a stray `kill` sends — puts Paddock down\n" +
96
+ " until you start it again by hand. Re-run to update the unit:\n" +
97
+ "\n" +
98
+ " paddock service install");
99
+ }
100
+ /**
101
+ * Say what is about to happen, before the part that looks like a hang (#861).
102
+ *
103
+ * `backend.install` runs `launchctl bootout` / `bootstrap` / `kickstart` — or
104
+ * the systemd equivalents — through `spawnSync`, and on macOS the bootstrap is
105
+ * followed by Background-Task-Management registering the new LaunchAgent as a
106
+ * login item (the "… can run in the background" notification fires in this
107
+ * window). Three to five seconds, and every one of them silent.
108
+ *
109
+ * **There is no spinner here, and that is not laziness.** Those subprocess calls
110
+ * are synchronous: they block the event loop, so a `setInterval` ticker would
111
+ * sit frozen for exactly the span it was added to animate — worse than no
112
+ * spinner, because a stopped spinner reads as a crashed process. Animating it
113
+ * would mean making the whole `Runner` seam async, which is a much larger change
114
+ * than this issue is asking for. The defect reported was the SILENCE, and text
115
+ * on the screen fixes the silence.
116
+ */
117
+ function installProgress(backend) {
118
+ const kind = backend.platform === "darwin" ? "the LaunchAgent" : "the user service";
119
+ return backend.platform === "darwin"
120
+ ? `\n Installing ${kind} and starting Paddock…\n` +
121
+ " macOS may take a few seconds to approve the background item."
122
+ : `\n Installing ${kind} and starting Paddock…`;
123
+ }
124
+ async function install(opts, ctx, backend) {
80
125
  if (isNpxPath(ctx.packageRoot))
81
126
  throw new CliError(NPX_REFUSAL);
82
127
  const { spec, dataDir } = specFor(opts, ctx);
83
- backend.install(spec);
84
128
  const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
129
+ console.log(installProgress(backend));
130
+ backend.install(spec);
131
+ // The unit is written and the supervisor has accepted it. That is NOT the
132
+ // same as "Paddock is running", which is what this block used to assert
133
+ // before anything had been checked — so the success message could sit over a
134
+ // crash-loop (a port clash restarts every RestartSec/ThrottleInterval, and
135
+ // both supervisors report `running` for most of that window) and still print
136
+ // a URL and a tick. Waiting is what makes the headline true.
137
+ console.log(` Waiting for ${url} to answer…`);
138
+ const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
85
139
  const kind = backend.platform === "darwin" ? "a launchd LaunchAgent" : "a systemd --user service";
86
140
  const linger = backend.lingerNote();
87
141
  console.log([
88
142
  "",
89
- ` Paddock is installed as ${kind}, and running now.`,
143
+ readiness === "ready"
144
+ ? ` ✓ Paddock is installed as ${kind}, and running at ${url}`
145
+ : ` Paddock is installed as ${kind}, but ${url} did not answer\n` +
146
+ ` within ${READY_TIMEOUT_LABEL}. The unit is in place; it may still be\n` +
147
+ " coming up, or it may be failing to start — the logs below say which.",
90
148
  "",
91
- ` URL: ${url}`,
149
+ ...(readiness === "ready" ? [] : [` URL: ${url}`]),
92
150
  ` Data: ${dataDir}`,
93
151
  ` Unit: ${backend.unitPath}`,
94
152
  backend.logsHint(spec),
@@ -97,6 +155,7 @@ function install(opts, ctx, backend) {
97
155
  ...(linger !== undefined ? ["", linger] : []),
98
156
  "",
99
157
  " paddock service status is it up, and where",
158
+ " paddock service restart bounce it after editing config",
100
159
  " paddock service uninstall stop it and remove the unit",
101
160
  "",
102
161
  ].join("\n"));
@@ -156,16 +215,117 @@ function status(opts, ctx, backend) {
156
215
  backend.logsHint(spec),
157
216
  "",
158
217
  AT_LOGIN,
218
+ ...(state.keepsAlive ? [] : ["", staleUnitNote(backend)]),
159
219
  ...(linger !== undefined && !state.running ? ["", linger] : []),
160
220
  "",
161
221
  ].join("\n"));
162
222
  }
163
- export function runService(action, opts, ctx) {
223
+ /**
224
+ * The URL an installed unit is actually serving, or undefined if none is.
225
+ *
226
+ * Read out of the unit rather than recomputed from today's flags, for the same
227
+ * reason `status` does it: the port in the file is the port the server is on.
228
+ */
229
+ function installedUrl(state) {
230
+ return `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
231
+ }
232
+ /** "It isn't installed" — the same answer, wherever it is asked. */
233
+ function notInstalled(backend) {
234
+ return [
235
+ "",
236
+ " Paddock is not installed as a service.",
237
+ ` Looked for: ${backend.unitPath}`,
238
+ "",
239
+ " paddock service install register it and start it now",
240
+ "",
241
+ ].join("\n");
242
+ }
243
+ /**
244
+ * `start` / `restart` (#873), which differ only in what they tolerate.
245
+ *
246
+ * `start` on something already running is reported and not acted on — asking
247
+ * launchd to bootstrap a loaded job is an error, and "it is already up" is the
248
+ * outcome the user wanted anyway. `restart` has no such case: bouncing a
249
+ * stopped service is a legitimate way to start it, and the sequence each
250
+ * backend uses handles both states.
251
+ *
252
+ * Both then WAIT. Neither supervisor's "started" means the port answers — it
253
+ * means the fork succeeded — so printing a URL on the strength of it produces a
254
+ * success message that can sit over a crash-loop (a port clash restarts every
255
+ * `ThrottleInterval`, and `launchctl print` says `running` for most of that
256
+ * window). The wait is what makes the last line true.
257
+ */
258
+ async function startOrRestart(action, ctx, backend) {
259
+ const before = backend.status();
260
+ if (!before.registered) {
261
+ console.log(notInstalled(backend));
262
+ return;
263
+ }
264
+ const url = installedUrl(before);
265
+ if (action === "start" && before.running) {
266
+ console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
267
+ return;
268
+ }
269
+ console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
270
+ if (action === "start")
271
+ backend.start();
272
+ else
273
+ backend.restart();
274
+ const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
275
+ const after = backend.status();
276
+ console.log([
277
+ "",
278
+ readiness === "ready"
279
+ ? ` ✓ Paddock is running${pid(after)} at ${url}`
280
+ : ` Paddock was ${action === "start" ? "started" : "restarted"}, but ${url} did not\n` +
281
+ ` answer within ${READY_TIMEOUT_LABEL}. It may still be coming up — check:`,
282
+ ...(readiness === "ready" ? [] : ["", " paddock service status", backend.logsHint(specForLogs(ctx, after))]),
283
+ "",
284
+ ].join("\n"));
285
+ }
286
+ /** `stop` (#873): put it down, leave it installed. */
287
+ function stop(backend) {
288
+ const state = backend.status();
289
+ if (!state.registered) {
290
+ console.log(notInstalled(backend));
291
+ return;
292
+ }
293
+ if (!state.running) {
294
+ console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
295
+ return;
296
+ }
297
+ backend.stop();
298
+ console.log([
299
+ "",
300
+ " Paddock is stopped. The unit is still installed.",
301
+ ` Unit: ${backend.unitPath}`,
302
+ "",
303
+ " It comes back when you log in again, or now with:",
304
+ "",
305
+ " paddock service start",
306
+ "",
307
+ ].join("\n"));
308
+ }
309
+ function pid(state) {
310
+ return state.pid !== undefined ? ` (pid ${state.pid})` : "";
311
+ }
312
+ /** The spec `logsHint` needs, built from the INSTALLED unit's data dir. */
313
+ function specForLogs(ctx, state) {
314
+ const i = state.argv.indexOf("--data-dir");
315
+ const dataDir = i >= 0 && state.argv[i + 1] !== undefined ? state.argv[i + 1] : undefined;
316
+ const { spec } = specFor({ open: false, verbose: false, help: false, version: false }, { ...ctx, ...(dataDir !== undefined ? { envDataDir: dataDir } : {}) });
317
+ return spec;
318
+ }
319
+ export async function runService(action, opts, ctx) {
164
320
  const backend = backendFor(ctx);
165
321
  if (action === "install")
166
322
  return install(opts, ctx, backend);
167
323
  if (action === "uninstall")
168
324
  return uninstall(ctx, backend);
325
+ if (action === "start" || action === "restart")
326
+ return startOrRestart(action, ctx, backend);
327
+ if (action === "stop")
328
+ return stop(backend);
169
329
  return status(opts, ctx, backend);
170
330
  }
171
331
  /** Exported for the entrypoint's context assembly, and for tests. */
@@ -5,10 +5,22 @@ export declare function plistPath(homeDir?: string): string;
5
5
  /**
6
6
  * Render the plist.
7
7
  *
8
- * `KeepAlive` is a dict rather than `<true/>` on purpose: `SuccessfulExit:
9
- * false` means "restart it if it exits nonzero, leave it alone if it exits
10
- * cleanly". A bare `<true/>` would fight a deliberate `launchctl bootout` and
11
- * make stopping Paddock a wrestling match.
8
+ * `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
9
+ * (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
10
+ * nonzero, leave it alone if it exits cleanly" — which reads like the polite
11
+ * choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
12
+ * down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
13
+ * LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
14
+ * to stop", and Paddock stayed down until the next login. A hard crash was the
15
+ * survivable case; the graceful stop was the terminal one.
16
+ *
17
+ * The worry that motivated the dict — that `<true/>` fights a deliberate stop —
18
+ * does not hold for the stop this CLI actually performs. `launchctl bootout`
19
+ * unloads the job from the domain, and KeepAlive only applies to a job that is
20
+ * still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
21
+ * does fight is `launchctl stop`, which is why nothing here uses it.
22
+ *
23
+ * Intent belongs in an explicit command, not in an exit code.
12
24
  *
13
25
  * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
14
26
  * launchd hands a process a stub PATH that a version manager's `node`, and the
@@ -32,6 +44,17 @@ export declare function renderPlist(spec: ServiceSpec): string;
32
44
  * would be the larger risk.
33
45
  */
34
46
  export declare function parsePlistArgv(xml: string): string[];
47
+ /**
48
+ * Does an installed plist carry the unconditional `KeepAlive` from #872?
49
+ *
50
+ * Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
51
+ * and nothing else. The shape it must NOT accept is the old
52
+ * `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
53
+ * leaves a service dead after a graceful stop — so a `false` here is the signal
54
+ * `status` turns into "re-run install", and defaulting to `true` on anything
55
+ * unrecognised would silence exactly the case this exists to catch.
56
+ */
57
+ export declare function plistKeepsAlive(xml: string): boolean;
35
58
  /**
36
59
  * Read liveness out of `launchctl print gui/<uid>/<label>`.
37
60
  *