workerdeck 2.7.2 → 2.9.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/README.md CHANGED
@@ -7,7 +7,7 @@ session gateway **and** the full dashboard, on one port, with nothing to clone.
7
7
  npx workerdeck
8
8
  ```
9
9
 
10
- That serves `http://127.0.0.1:8787` — dashboard at the root, API under `/v1` — and persists parked
10
+ That serves `http://127.0.0.1:8787` - dashboard at the root, API under `/v1` - and persists parked
11
11
  sessions under `~/.workerdeck` so a restart doesn't drop them.
12
12
 
13
13
  The `@workerdeck/*` packages are the libraries you embed in your own app. This one is the
@@ -22,16 +22,16 @@ npx workerdeck --auth-key "$SECRET" --host 0.0.0.0
22
22
  One secret, two transports:
23
23
 
24
24
  - **Browsers** get a login page, trade the secret for an `HttpOnly` session cookie, and use that
25
- for everything afterwards — including the WebSocket attach.
25
+ for everything afterwards - including the WebSocket attach.
26
26
  - **Services** send the same secret as an `x-workerdeck-key` header (or
27
27
  `Authorization: Bearer`).
28
28
 
29
29
  The cookie is not a convenience. A browser cannot set a header on a WebSocket handshake at all, so
30
- a cookie is the only credential a tab can present when it attaches to a session — and a cookie only
30
+ a cookie is the only credential a tab can present when it attaches to a session - and a cookie only
31
31
  rides requests to the origin that set it. That is why the dashboard and the API share a port.
32
32
 
33
33
  Those logins persist in `<state-dir>/auth-sessions.json` (mode 600), so a restart doesn't sign
34
- every open tab out. Each row is an HMAC of the cookie under the operator secret — so the file is
34
+ every open tab out. Each row is an HMAC of the cookie under the operator secret - so the file is
35
35
  worth nothing on its own, and rotating the secret invalidates every outstanding login.
36
36
 
37
37
  Neither transport authenticates *who* the person is; the secret is a door key. Put an
@@ -50,11 +50,11 @@ port. `--insecure` overrides that, for when something in front is doing the auth
50
50
  | `--auth-key <secret>` | `WORKERDECK_AUTH_KEY` | none (no auth) |
51
51
  | `--cwd-root <path>` (repeatable) | `WORKERDECK_CWD_ROOTS` (`:`-separated) | unrestricted |
52
52
  | `--fs-root <path>` (repeatable) | `WORKERDECK_FS_ROOTS` (`:`-separated) | narrows `/v1/fs`; unset, reading follows `--cwd-root` |
53
- | `--fs-write` | — | off (browse and read only) |
54
- | `--profile <name=dir>` (repeatable) | — | auto-detected from `~/.claude` |
53
+ | `--fs-write` | - | off (browse and read only) |
54
+ | `--profile <name=dir>` (repeatable) | - | auto-detected from `~/.claude` |
55
55
  | `--state-dir <path>` | `WORKERDECK_STATE_DIR` | beside the config file, else `~/.workerdeck` |
56
- | `--no-parking-store` | — | durable parking on |
57
- | `--config <path>` | — | `./workerdeck.config.mjs` |
56
+ | `--no-parking-store` | - | durable parking on |
57
+ | `--config <path>` | - | `./workerdeck.config.mjs` |
58
58
  | `--insecure`, `--open`, `--help`, `--version` | | |
59
59
 
60
60
  Precedence is narrowest-wins: flags > env > config file > defaults.
@@ -75,7 +75,7 @@ export default {
75
75
  }
76
76
  ```
77
77
 
78
- Supplying your own `authenticate` turns the built-in shared-secret auth **off entirely** — one
78
+ Supplying your own `authenticate` turns the built-in shared-secret auth **off entirely** - one
79
79
  hook, one scheme, rather than two paths where only one got audited. If you take it over, you own
80
80
  finding a credential the browser can actually present. There's a fuller example in
81
81
  [`examples/workerdeck.config.mjs`](https://github.com/workerdeck/workerdeck/blob/master/examples/workerdeck.config.mjs).
@@ -87,7 +87,7 @@ npx workerdeck guard --wait 300 --allow-parked && systemctl restart workerdeck
87
87
  ```
88
88
 
89
89
  Exits `0` when a restart is safe, `1` while a session is mid-turn, awaiting an approval, or parked
90
- without durability behind it, and `2` when it couldn't tell — never treating "couldn't tell" as
90
+ without durability behind it, and `2` when it couldn't tell - never treating "couldn't tell" as
91
91
  safe. `--allow-parked` and `--allow-queued` are you asserting that the `SessionStore` and the
92
92
  `QueueAdapter` respectively are durable; they're separate decisions. Point `--url` at any instance
93
93
  and authenticate with `--token` or `--header name=value`.
@@ -95,7 +95,7 @@ and authenticate with `--token` or `--header name=value`.
95
95
  ## Credentials
96
96
 
97
97
  WorkerDeck implements **no Anthropic auth**. The official SDK/CLI resolves credentials from the
98
- operator's environment, per profile — `--auth-key` protects this gateway and nothing else. See the
98
+ operator's environment, per profile - `--auth-key` protects this gateway and nothing else. See the
99
99
  project's
100
100
  [Auth & Anthropic's terms](https://github.com/workerdeck/workerdeck#auth--anthropics-terms).
101
101
 
@@ -113,13 +113,13 @@ MIT
113
113
  ## Rules you cannot infer from the types
114
114
 
115
115
  - **Single origin is load-bearing.** The gateway and the dashboard share one port because a tab
116
- cannot header a WebSocket handshake, so a cookie is the only credential an attach can carry —
116
+ cannot header a WebSocket handshake, so a cookie is the only credential an attach can carry -
117
117
  and cookies are per-origin. This is why there is no "dashboard on another port" mode.
118
118
  - **Loopback runs keyless; off loopback the CLI generates a key rather than serving open.** Only an
119
119
  explicit `--insecure` / `insecureHosts` declaration serves unauthenticated, and those entries
120
120
  double as accepted `Host` headers.
121
121
  - **Browser logins are keyed by `HMAC(secret, token)`.** That is what makes the session file
122
- worthless to a reader — and what makes rotating the auth key invalidate every cookie for free.
122
+ worthless to a reader - and what makes rotating the auth key invalidate every cookie for free.
123
123
  - **A config file supplying its own `authenticate` turns the built-in auth off entirely**, rather
124
124
  than layering on top of it. Two auth systems agreeing is harder to reason about than one.
125
125
 
package/build/cli.mjs CHANGED
@@ -1,15 +1,41 @@
1
1
  #!/usr/bin/env node
2
- import { a as ConfigError, d as parseArgs, f as resolveInstanceConfig, r as startInstance, u as loadConfigFile } from "./instance-DP3af4H-.mjs";
2
+ import { a as ConfigError, d as parseArgs, f as resolveInstanceConfig, r as startInstance, u as loadConfigFile } from "./instance-vQe2VlGq.mjs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { spawn } from "node:child_process";
7
+ //#region src/lib/shutdown.ts
8
+ function installShutdown({ current, alsoClose, log = (text) => void process.stdout.write(`${text}\n`) }) {
9
+ let shuttingDown = false;
10
+ const shutdown = (signal) => {
11
+ if (shuttingDown) {
12
+ process.stdout.write(`\n[workerdeck] ${signal} again - terminating now\n`);
13
+ process.exit(130);
14
+ }
15
+ shuttingDown = true;
16
+ log(`\n[workerdeck] ${signal} - shutting down (press again to stop now)`);
17
+ const instance = current();
18
+ instance.drain({ onProgress: (report) => {
19
+ if (report.working.length > 0) log(`[workerdeck] waiting for ${report.working.length} session(s) to finish the current turn`);
20
+ for (const id of report.awaitingHuman) log(`[workerdeck] session ${id} is waiting on an approval - not waiting for it`);
21
+ if (report.timedOut) log(`[workerdeck] ${report.working.length} session(s) still running - stopping anyway`);
22
+ else if (report.working.length === 0) log("[workerdeck] all turns finished");
23
+ } }).catch(() => void 0).then(() => instance.close()).then(() => {
24
+ for (const runner of alsoClose?.() ?? []) runner.close("server");
25
+ }).then(() => process.exit(0)).catch(() => process.exit(1));
26
+ };
27
+ process.on("SIGINT", () => shutdown("SIGINT"));
28
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
29
+ return shutdown;
30
+ }
31
+ //#endregion
7
32
  //#region src/cli.ts
8
- const HELP = `workerdeck — run a workerdeck instance: session gateway + dashboard, one port.
33
+ const HELP = `workerdeck - run a workerdeck instance: session gateway + dashboard, one port.
9
34
 
10
35
  Usage
11
36
  workerdeck [options]
12
37
  workerdeck guard [options] check whether it is safe to restart an instance
38
+ workerdeck reload [options] hot-reload a gateway started with --hot-reload
13
39
 
14
40
  Options
15
41
  -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)
@@ -29,8 +55,8 @@ Options
29
55
  --allowed-host <name> extra Host header accepted when running without auth
30
56
  (repeatable; loopback names are always accepted)
31
57
  --insecure-host <name>
32
- bind host that may serve without auth — no key demanded,
33
- none generated — and, while unauthenticated, also
58
+ bind host that may serve without auth - no key demanded,
59
+ none generated - and, while unauthenticated, also
34
60
  accepted as a Host header (repeatable; config:
35
61
  insecureHosts). Names the host alone, no port.
36
62
  --profile <name=dir> Claude config dir a session may run under (repeatable). Declared
@@ -38,7 +64,7 @@ Options
38
64
  --profile-root <path> directory a profile created at runtime may point its config dir at
39
65
  (repeatable; default: your home directory). Narrow this to pin
40
66
  exactly which credential stores the dashboard may reach for.
41
- --no-profile-store refuse runtime profile management entirely — /v1/profiles stays
67
+ --no-profile-store refuse runtime profile management entirely - /v1/profiles stays
42
68
  read-only and profiles come from --profile and the config file
43
69
  alone. Profiles created at runtime are stored in
44
70
  <state-dir>/profiles.json, so --no-parking-store implies this.
@@ -61,7 +87,7 @@ Options
61
87
  --approval-timeout <d>
62
88
  how long a permission prompt or a question may wait for
63
89
  an answer before the engine denies it (300000, 30s, 5m;
64
- config: approvalTimeoutMs). Default 'none' — a prompt
90
+ config: approvalTimeoutMs). Default 'none' - a prompt
65
91
  waits as long as the session lives. A session may
66
92
  override it per request.
67
93
  --state-dir <path> where parked sessions are persisted
@@ -73,11 +99,11 @@ Options
73
99
  --cors-origin <o> browser origin allowed to call /v1 cross-origin, for a
74
100
  dashboard served elsewhere (repeatable, exact origin, no
75
101
  wildcard; config: corsOrigins). Refused without auth. The
76
- key is still required — this only permits the call.
102
+ key is still required - this only permits the call.
77
103
  --no-keep-awake don't hold the machine awake while a session is waiting
78
104
  on it (config: keepAwake: false). By default a turn in
79
105
  flight, or a session blocked on an approval, blocks idle
80
- sleep — a laptop sleeping mid-turn drops the socket and
106
+ sleep - a laptop sleeping mid-turn drops the socket and
81
107
  burns the turn. The screen is never kept on, and the
82
108
  lock releases the moment nothing is waiting.
83
109
  --no-web don't serve the web dashboard (config: web: false).
@@ -85,12 +111,17 @@ Options
85
111
  404s. For a gateway reached only from the VS Code
86
112
  extension, the phone, or another host's dashboard.
87
113
  --open open the dashboard in a browser once it is up
114
+ --hot-reload dev only: re-evaluate the gateway's own source in place on
115
+ ctrl-r, SIGUSR2 or \`workerdeck reload\`, carrying live
116
+ sessions and their engine child processes across the swap.
117
+ Needs a source checkout; the published CLI is one bundled
118
+ file with nothing to swap.
88
119
  -h, --help show this
89
120
  -v, --version print the version
90
121
 
91
122
  Config file
92
- Options that cannot fit on a command line — \`authenticate\`, \`buildRunnerConfig\`,
93
- \`createEngineRunner\` are functions — live in workerdeck.config.mjs, which
123
+ Options that cannot fit on a command line - \`authenticate\`, \`buildRunnerConfig\`,
124
+ \`createEngineRunner\` are functions - live in workerdeck.config.mjs, which
94
125
  default-exports the createWorkerServer options (or a function returning them).
95
126
  Flags and env override it. Supplying your own \`authenticate\` turns the built-in
96
127
  shared-secret auth off entirely.
@@ -125,8 +156,12 @@ function line(text) {
125
156
  process.stdout.write(`${text}\n`);
126
157
  }
127
158
  async function main(argv) {
159
+ if (argv[0] === "reload") {
160
+ const { runReload } = await import("./reload-command-DAeUzBa1.mjs");
161
+ return await runReload(argv.slice(1));
162
+ }
128
163
  if (argv[0] === "guard") {
129
- const { runGuard } = await import("./guard-CGPVzJ3Q.mjs").then((n) => n.t);
164
+ const { runGuard } = await import("./guard-C4ybpzbt.mjs").then((n) => n.t);
130
165
  return await runGuard(argv.slice(1));
131
166
  }
132
167
  const flags = parseArgs(argv);
@@ -138,27 +173,19 @@ async function main(argv) {
138
173
  process.stdout.write(`${await readVersion()}\n`);
139
174
  return 0;
140
175
  }
176
+ if (flags.hotReload) {
177
+ const { runHotReload } = await import("./hot-reload-CfP94v9Y.mjs");
178
+ const result = await runHotReload(flags);
179
+ if (result !== "unsupported") return result;
180
+ }
141
181
  const loaded = await loadConfigFile(flags.config);
142
182
  const config = resolveInstanceConfig(flags, loaded);
143
183
  const instance = await startInstance(config);
144
184
  if (config.open) openInBrowser(instance.url);
145
- let shuttingDown = false;
146
- const shutdown = (signal) => {
147
- if (shuttingDown) {
148
- process.stdout.write(`\n[workerdeck] ${signal} again — terminating now\n`);
149
- process.exit(130);
150
- }
151
- shuttingDown = true;
152
- line(`\n[workerdeck] ${signal} — shutting down (press again to stop now)`);
153
- instance.drain({ onProgress: (report) => {
154
- if (report.working.length > 0) line(`[workerdeck] waiting for ${report.working.length} session(s) to finish the current turn`);
155
- for (const id of report.awaitingHuman) line(`[workerdeck] session ${id} is waiting on an approval — not waiting for it`);
156
- if (report.timedOut) line(`[workerdeck] ${report.working.length} session(s) still running — stopping anyway`);
157
- else if (report.working.length === 0) line("[workerdeck] all turns finished");
158
- } }).catch(() => void 0).then(() => instance.close()).then(() => process.exit(0)).catch(() => process.exit(1));
159
- };
160
- process.on("SIGINT", () => shutdown("SIGINT"));
161
- process.on("SIGTERM", () => shutdown("SIGTERM"));
185
+ installShutdown({
186
+ current: () => instance,
187
+ log: line
188
+ });
162
189
  await instance.closed;
163
190
  return 0;
164
191
  }
@@ -173,6 +200,6 @@ main(process.argv.slice(2)).then((code) => {
173
200
  process.exit(1);
174
201
  });
175
202
  //#endregion
176
- export {};
203
+ export { installShutdown as t };
177
204
 
178
205
  //# sourceMappingURL=cli.mjs.map
package/build/cli.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport { readFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { ConfigError, loadConfigFile, parseArgs, resolveInstanceConfig } from './config.ts'\nimport { startInstance } from './lib/instance.ts'\n\nconst HELP = `workerdeck — run a workerdeck instance: session gateway + dashboard, one port.\n\nUsage\n workerdeck [options]\n workerdeck guard [options] check whether it is safe to restart an instance\n\nOptions\n -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)\n --host <addr> interface to bind (default 127.0.0.1, WORKERDECK_HOST)\n --auth-key <secret> shared secret, min 12 chars; browsers log in with it,\n services send it as x-workerdeck-key\n (WORKERDECK_AUTH_KEY). Unset = no auth on loopback;\n on any other interface a key is generated instead,\n printed once, and stored in <state-dir>/auth-key for\n later starts to reuse.\n --trust-proxy trust x-forwarded-proto/-host/-for from one reverse\n proxy. Required behind TLS termination, or the session\n cookie loses its Secure flag and the origin check\n computes http:// where the browser says https://.\n --allowed-origin <o> extra origin accepted on browser requests, for when a\n proxy rewrites Host (repeatable)\n --allowed-host <name> extra Host header accepted when running without auth\n (repeatable; loopback names are always accepted)\n --insecure-host <name>\n bind host that may serve without auth — no key demanded,\n none generated — and, while unauthenticated, also\n accepted as a Host header (repeatable; config:\n insecureHosts). Names the host alone, no port.\n --profile <name=dir> Claude config dir a session may run under (repeatable). Declared\n this way a profile is fixed: it cannot be changed over the API.\n --profile-root <path> directory a profile created at runtime may point its config dir at\n (repeatable; default: your home directory). Narrow this to pin\n exactly which credential stores the dashboard may reach for.\n --no-profile-store refuse runtime profile management entirely — /v1/profiles stays\n read-only and profiles come from --profile and the config file\n alone. Profiles created at runtime are stored in\n <state-dir>/profiles.json, so --no-parking-store implies this.\n --cwd-root <path> restrict session cwds to this root (repeatable,\n WORKERDECK_CWD_ROOTS as a ':'-separated list)\n --fs-root <path> narrow which host directories /v1/fs serves\n (repeatable, WORKERDECK_FS_ROOTS as a ':'-separated\n list). Reading otherwise follows --cwd-root: a caller\n who may start a session in a tree can already read it\n through the agent. With neither, the routes 404.\n --fs-write also accept writes over /v1/fs. Its own switch because\n an agent's writes go through the permission flow and a\n PUT does not. Every write is still conditional on the\n hash the client last read.\n --shell let an operator run '!' shell commands on the host from\n the composer, in the session's cwd. Off by default and\n its own switch: unlike an agent's Bash tool, a '!'\n command goes through no permission prompt at all.\n Never offered to a scoped principal.\n --approval-timeout <d>\n how long a permission prompt or a question may wait for\n an answer before the engine denies it (300000, 30s, 5m;\n config: approvalTimeoutMs). Default 'none' — a prompt\n waits as long as the session lives. A session may\n override it per request.\n --state-dir <path> where parked sessions are persisted\n (default: beside the config file, else ~/.workerdeck)\n --no-parking-store keep parked sessions in memory only; a restart drops them\n -c, --config <path> config file (default: ./workerdeck.config.mjs)\n --insecure allow no-auth on a non-loopback address. Only when\n something in front is doing the authenticating.\n --cors-origin <o> browser origin allowed to call /v1 cross-origin, for a\n dashboard served elsewhere (repeatable, exact origin, no\n wildcard; config: corsOrigins). Refused without auth. The\n key is still required — this only permits the call.\n --no-keep-awake don't hold the machine awake while a session is waiting\n on it (config: keepAwake: false). By default a turn in\n flight, or a session blocked on an approval, blocks idle\n sleep — a laptop sleeping mid-turn drops the socket and\n burns the turn. The screen is never kept on, and the\n lock releases the moment nothing is waiting.\n --no-web don't serve the web dashboard (config: web: false).\n /v1 and the auth routes are unchanged; everything else\n 404s. For a gateway reached only from the VS Code\n extension, the phone, or another host's dashboard.\n --open open the dashboard in a browser once it is up\n -h, --help show this\n -v, --version print the version\n\nConfig file\n Options that cannot fit on a command line — \\`authenticate\\`, \\`buildRunnerConfig\\`,\n \\`createEngineRunner\\` are functions — live in workerdeck.config.mjs, which\n default-exports the createWorkerServer options (or a function returning them).\n Flags and env override it. Supplying your own \\`authenticate\\` turns the built-in\n shared-secret auth off entirely.\n\nCredentials\n workerdeck implements no Anthropic auth of its own: the official SDK/CLI\n resolves credentials from the environment, per profile. --auth-key protects this\n gateway, nothing else.\n`\n\nasync function readVersion(): Promise<string> {\n // src/cli.ts and build/cli.mjs are both one level under the package root.\n const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')\n try {\n const raw = await readFile(pkgPath, 'utf8')\n return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'\n } catch {\n return 'unknown'\n }\n}\n\nfunction openInBrowser(url: string): void {\n const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'\n try {\n const child = spawn(command, [url], {\n stdio: 'ignore',\n detached: true,\n shell: process.platform === 'win32',\n })\n child.on('error', () => {})\n child.unref()\n } catch {}\n}\n\nfunction line(text: string): void {\n process.stdout.write(`${text}\\n`)\n}\n\nasync function main(argv: string[]): Promise<number> {\n if (argv[0] === 'guard') {\n const { runGuard } = await import('./lib/guard.ts')\n return await runGuard(argv.slice(1))\n }\n\n const flags = parseArgs(argv)\n if (flags.help) {\n process.stdout.write(HELP)\n return 0\n }\n if (flags.version) {\n process.stdout.write(`${await readVersion()}\\n`)\n return 0\n }\n\n const loaded = await loadConfigFile(flags.config)\n const config = resolveInstanceConfig(flags, loaded)\n const instance = await startInstance(config)\n\n if (config.open) {\n openInBrowser(instance.url)\n }\n\n // A second signal must always be able to kill a shutdown that is taking too long. That used to work only by\n // accident — the second call re-entered `instance.close()` and got an immediate callback out of an already-closed\n // http server — so it evaporated the moment close stopped being idempotent-by-luck. Make it a real path.\n let shuttingDown = false\n const shutdown = (signal: string): void => {\n if (shuttingDown) {\n process.stdout.write(`\\n[workerdeck] ${signal} again — terminating now\\n`)\n process.exit(130)\n }\n shuttingDown = true\n line(`\\n[workerdeck] ${signal} — shutting down (press again to stop now)`)\n instance\n .drain({\n onProgress: (report) => {\n if (report.working.length > 0) {\n line(`[workerdeck] waiting for ${report.working.length} session(s) to finish the current turn`)\n }\n // Named, not waited for: nothing about shutting down answers a permission prompt.\n for (const id of report.awaitingHuman) {\n line(`[workerdeck] session ${id} is waiting on an approval — not waiting for it`)\n }\n if (report.timedOut) {\n line(`[workerdeck] ${report.working.length} session(s) still running — stopping anyway`)\n } else if (report.working.length === 0) {\n line('[workerdeck] all turns finished')\n }\n },\n })\n .catch(() => undefined)\n .then(() => instance.close())\n .then(() => process.exit(0))\n .catch(() => process.exit(1))\n }\n process.on('SIGINT', () => shutdown('SIGINT'))\n process.on('SIGTERM', () => shutdown('SIGTERM'))\n\n // Resolves only on close: until then the process stays up, serving.\n await instance.closed\n return 0\n}\n\nmain(process.argv.slice(2))\n .then((code) => {\n if (code !== 0) {\n process.exit(code)\n }\n })\n .catch((error: unknown) => {\n if (error instanceof ConfigError) {\n process.stderr.write(`[workerdeck] ${error.message}\\n`)\n process.exit(2)\n }\n process.stderr.write(`[workerdeck] ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`)\n process.exit(1)\n })\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.mjs","names":[],"sources":["../src/lib/shutdown.ts","../src/cli.ts"],"sourcesContent":["import type { Instance } from './instance.ts'\n\nexport type ShutdownOptions = {\n // A getter, not an instance: under --hot-reload the instance is replaced on every reload, and a captured one\n // would drain a server that stopped listening several generations ago.\n current: () => Instance\n // Runners this process holds outside any instance, which under --hot-reload is what a failed generation leaves\n // behind: no registry holds them, so `close()` cannot reach them and nothing else ends their engine children.\n alsoClose?: () => { close: (reason?: 'client' | 'server' | 'error') => void }[]\n log?: (text: string) => void\n}\n\nexport function installShutdown({\n current,\n alsoClose,\n log = (text) => void process.stdout.write(`${text}\\n`),\n}: ShutdownOptions): (signal: string) => void {\n // A second signal must always be able to kill a shutdown that is taking too long. That used to work only by\n // accident - the second call re-entered `instance.close()` and got an immediate callback out of an already-closed\n // http server - so it evaporated the moment close stopped being idempotent-by-luck. Make it a real path.\n let shuttingDown = false\n const shutdown = (signal: string): void => {\n if (shuttingDown) {\n process.stdout.write(`\\n[workerdeck] ${signal} again - terminating now\\n`)\n process.exit(130)\n }\n shuttingDown = true\n log(`\\n[workerdeck] ${signal} - shutting down (press again to stop now)`)\n const instance = current()\n instance\n .drain({\n onProgress: (report) => {\n if (report.working.length > 0) {\n log(`[workerdeck] waiting for ${report.working.length} session(s) to finish the current turn`)\n }\n // Named, not waited for: nothing about shutting down answers a permission prompt.\n for (const id of report.awaitingHuman) {\n log(`[workerdeck] session ${id} is waiting on an approval - not waiting for it`)\n }\n if (report.timedOut) {\n log(`[workerdeck] ${report.working.length} session(s) still running - stopping anyway`)\n } else if (report.working.length === 0) {\n log('[workerdeck] all turns finished')\n }\n },\n })\n .catch(() => undefined)\n .then(() => instance.close())\n .then(() => {\n for (const runner of alsoClose?.() ?? []) {\n runner.close('server')\n }\n })\n .then(() => process.exit(0))\n .catch(() => process.exit(1))\n }\n process.on('SIGINT', () => shutdown('SIGINT'))\n process.on('SIGTERM', () => shutdown('SIGTERM'))\n // Returned for the one caller that cannot rely on the signal: a raw-mode TTY delivers ctrl-c as a byte, not SIGINT.\n return shutdown\n}\n","#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport { readFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { ConfigError, loadConfigFile, parseArgs, resolveInstanceConfig } from './config.ts'\nimport { startInstance } from './lib/instance.ts'\nimport { installShutdown } from './lib/shutdown.ts'\n\nconst HELP = `workerdeck - run a workerdeck instance: session gateway + dashboard, one port.\n\nUsage\n workerdeck [options]\n workerdeck guard [options] check whether it is safe to restart an instance\n workerdeck reload [options] hot-reload a gateway started with --hot-reload\n\nOptions\n -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)\n --host <addr> interface to bind (default 127.0.0.1, WORKERDECK_HOST)\n --auth-key <secret> shared secret, min 12 chars; browsers log in with it,\n services send it as x-workerdeck-key\n (WORKERDECK_AUTH_KEY). Unset = no auth on loopback;\n on any other interface a key is generated instead,\n printed once, and stored in <state-dir>/auth-key for\n later starts to reuse.\n --trust-proxy trust x-forwarded-proto/-host/-for from one reverse\n proxy. Required behind TLS termination, or the session\n cookie loses its Secure flag and the origin check\n computes http:// where the browser says https://.\n --allowed-origin <o> extra origin accepted on browser requests, for when a\n proxy rewrites Host (repeatable)\n --allowed-host <name> extra Host header accepted when running without auth\n (repeatable; loopback names are always accepted)\n --insecure-host <name>\n bind host that may serve without auth - no key demanded,\n none generated - and, while unauthenticated, also\n accepted as a Host header (repeatable; config:\n insecureHosts). Names the host alone, no port.\n --profile <name=dir> Claude config dir a session may run under (repeatable). Declared\n this way a profile is fixed: it cannot be changed over the API.\n --profile-root <path> directory a profile created at runtime may point its config dir at\n (repeatable; default: your home directory). Narrow this to pin\n exactly which credential stores the dashboard may reach for.\n --no-profile-store refuse runtime profile management entirely - /v1/profiles stays\n read-only and profiles come from --profile and the config file\n alone. Profiles created at runtime are stored in\n <state-dir>/profiles.json, so --no-parking-store implies this.\n --cwd-root <path> restrict session cwds to this root (repeatable,\n WORKERDECK_CWD_ROOTS as a ':'-separated list)\n --fs-root <path> narrow which host directories /v1/fs serves\n (repeatable, WORKERDECK_FS_ROOTS as a ':'-separated\n list). Reading otherwise follows --cwd-root: a caller\n who may start a session in a tree can already read it\n through the agent. With neither, the routes 404.\n --fs-write also accept writes over /v1/fs. Its own switch because\n an agent's writes go through the permission flow and a\n PUT does not. Every write is still conditional on the\n hash the client last read.\n --shell let an operator run '!' shell commands on the host from\n the composer, in the session's cwd. Off by default and\n its own switch: unlike an agent's Bash tool, a '!'\n command goes through no permission prompt at all.\n Never offered to a scoped principal.\n --approval-timeout <d>\n how long a permission prompt or a question may wait for\n an answer before the engine denies it (300000, 30s, 5m;\n config: approvalTimeoutMs). Default 'none' - a prompt\n waits as long as the session lives. A session may\n override it per request.\n --state-dir <path> where parked sessions are persisted\n (default: beside the config file, else ~/.workerdeck)\n --no-parking-store keep parked sessions in memory only; a restart drops them\n -c, --config <path> config file (default: ./workerdeck.config.mjs)\n --insecure allow no-auth on a non-loopback address. Only when\n something in front is doing the authenticating.\n --cors-origin <o> browser origin allowed to call /v1 cross-origin, for a\n dashboard served elsewhere (repeatable, exact origin, no\n wildcard; config: corsOrigins). Refused without auth. The\n key is still required - this only permits the call.\n --no-keep-awake don't hold the machine awake while a session is waiting\n on it (config: keepAwake: false). By default a turn in\n flight, or a session blocked on an approval, blocks idle\n sleep - a laptop sleeping mid-turn drops the socket and\n burns the turn. The screen is never kept on, and the\n lock releases the moment nothing is waiting.\n --no-web don't serve the web dashboard (config: web: false).\n /v1 and the auth routes are unchanged; everything else\n 404s. For a gateway reached only from the VS Code\n extension, the phone, or another host's dashboard.\n --open open the dashboard in a browser once it is up\n --hot-reload dev only: re-evaluate the gateway's own source in place on\n ctrl-r, SIGUSR2 or \\`workerdeck reload\\`, carrying live\n sessions and their engine child processes across the swap.\n Needs a source checkout; the published CLI is one bundled\n file with nothing to swap.\n -h, --help show this\n -v, --version print the version\n\nConfig file\n Options that cannot fit on a command line - \\`authenticate\\`, \\`buildRunnerConfig\\`,\n \\`createEngineRunner\\` are functions - live in workerdeck.config.mjs, which\n default-exports the createWorkerServer options (or a function returning them).\n Flags and env override it. Supplying your own \\`authenticate\\` turns the built-in\n shared-secret auth off entirely.\n\nCredentials\n workerdeck implements no Anthropic auth of its own: the official SDK/CLI\n resolves credentials from the environment, per profile. --auth-key protects this\n gateway, nothing else.\n`\n\nasync function readVersion(): Promise<string> {\n // src/cli.ts and build/cli.mjs are both one level under the package root.\n const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')\n try {\n const raw = await readFile(pkgPath, 'utf8')\n return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'\n } catch {\n return 'unknown'\n }\n}\n\nfunction openInBrowser(url: string): void {\n const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'\n try {\n const child = spawn(command, [url], {\n stdio: 'ignore',\n detached: true,\n shell: process.platform === 'win32',\n })\n child.on('error', () => {})\n child.unref()\n } catch {}\n}\n\nfunction line(text: string): void {\n process.stdout.write(`${text}\\n`)\n}\n\nasync function main(argv: string[]): Promise<number> {\n if (argv[0] === 'reload') {\n const { runReload } = await import('./dev/reload-command.ts')\n return await runReload(argv.slice(1))\n }\n\n if (argv[0] === 'guard') {\n const { runGuard } = await import('./lib/guard.ts')\n return await runGuard(argv.slice(1))\n }\n\n const flags = parseArgs(argv)\n if (flags.help) {\n process.stdout.write(HELP)\n return 0\n }\n if (flags.version) {\n process.stdout.write(`${await readVersion()}\\n`)\n return 0\n }\n\n if (flags.hotReload) {\n const { runHotReload } = await import('./dev/hot-reload.ts')\n const result = await runHotReload(flags)\n if (result !== 'unsupported') {\n return result\n }\n }\n\n const loaded = await loadConfigFile(flags.config)\n const config = resolveInstanceConfig(flags, loaded)\n const instance = await startInstance(config)\n\n if (config.open) {\n openInBrowser(instance.url)\n }\n\n installShutdown({ current: () => instance, log: line })\n\n // Resolves only on close: until then the process stays up, serving.\n await instance.closed\n return 0\n}\n\nmain(process.argv.slice(2))\n .then((code) => {\n if (code !== 0) {\n process.exit(code)\n }\n })\n .catch((error: unknown) => {\n if (error instanceof ConfigError) {\n process.stderr.write(`[workerdeck] ${error.message}\\n`)\n process.exit(2)\n }\n process.stderr.write(`[workerdeck] ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`)\n process.exit(1)\n })\n"],"mappings":"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"}
@@ -155,7 +155,7 @@ async function runGuard(argv) {
155
155
  const notes = [];
156
156
  for (const session of all.filter((s) => BUSY_STATUSES.has(s.status))) reasons.push(`session ${session.id} is ${session.status}`);
157
157
  const parked = all.filter((s) => s.status === "parked");
158
- if (parked.length > 0 && !values["allow-parked"]) reasons.push(`${parked.length} parked session(s) — pass --allow-parked once the server runs a durable SessionStore, or they are lost on restart`);
158
+ if (parked.length > 0 && !values["allow-parked"]) reasons.push(`${parked.length} parked session(s) - pass --allow-parked once the server runs a durable SessionStore, or they are lost on restart`);
159
159
  const queue = await get("/queue");
160
160
  if (!queue.ok && !("status" in queue) && queue.code !== 404) return {
161
161
  error: `GET ${base}/queue → ${queue.detail}`,
@@ -164,8 +164,8 @@ async function runGuard(argv) {
164
164
  };
165
165
  const stats = queue.ok ? queue.body.stats : void 0;
166
166
  if ((stats?.running ?? 0) > 0) reasons.push(`${stats.running} job(s) running`);
167
- if ((stats?.queued ?? 0) > 0 && !values["allow-queued"]) reasons.push(`${stats.queued} job(s) queued — pass --allow-queued once the server runs a durable QueueAdapter, or they are lost on restart`);
168
- if ((stats?.parked ?? 0) > 0 && values["allow-parked"]) notes.push(`${stats.parked} parked job(s): their queue-side records are the QueueAdapter's, not the SessionStore's — with the in-memory adapter they never finish`);
167
+ if ((stats?.queued ?? 0) > 0 && !values["allow-queued"]) reasons.push(`${stats.queued} job(s) queued - pass --allow-queued once the server runs a durable QueueAdapter, or they are lost on restart`);
168
+ if ((stats?.parked ?? 0) > 0 && values["allow-parked"]) notes.push(`${stats.parked} parked job(s): their queue-side records are the QueueAdapter's, not the SessionStore's - with the in-memory adapter they never finish`);
169
169
  return {
170
170
  reasons,
171
171
  notes,
@@ -203,7 +203,7 @@ async function runGuard(argv) {
203
203
  return 0;
204
204
  }
205
205
  if (Date.now() + intervalMs > deadline) {
206
- report("busy — not safe to restart", result);
206
+ report("busy - not safe to restart", result);
207
207
  return 1;
208
208
  }
209
209
  report(`busy, waiting up to ${Math.round((deadline - Date.now()) / 1e3)}s`, result);
@@ -213,4 +213,4 @@ async function runGuard(argv) {
213
213
  //#endregion
214
214
  export { runGuard as n, guard_exports as t };
215
215
 
216
- //# sourceMappingURL=guard-CGPVzJ3Q.mjs.map
216
+ //# sourceMappingURL=guard-C4ybpzbt.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard-C4ybpzbt.mjs","names":[],"sources":["../src/lib/guard.ts"],"sourcesContent":["import { parseArgs } from 'node:util'\n\nconst BUSY_STATUSES = new Set(['starting', 'running', 'awaiting_approval'])\n\nconst HELP = `usage: workerdeck guard [--url URL] [--token TOKEN] [--header name=value]\n [--wait SECONDS] [--interval SECONDS]\n [--allow-parked] [--allow-queued] [--json]\n\nexit 0 = safe to restart, 1 = busy, 2 = could not tell\n`\n\ntype GetResult =\n | { ok: true; body: Record<string, unknown> }\n | { ok: false; status: 'unreachable' }\n | { ok: false; code?: number; detail: string }\n\ntype Verdict = {\n reasons: string[]\n notes: string[]\n unreachable?: boolean\n error?: string\n sessions?: number\n parked?: number\n}\n\nclass GuardError extends Error {}\n\nexport async function runGuard(argv: string[]): Promise<number> {\n let values: {\n url: string\n token?: string\n header: string[]\n wait: string\n interval: string\n 'allow-parked': boolean\n 'allow-queued': boolean\n json: boolean\n help: boolean\n }\n try {\n values = parseArgs({\n args: argv,\n options: {\n url: {\n type: 'string',\n default: process.env.WORKERDECK_URL ?? 'http://127.0.0.1:8787/v1',\n },\n token: { type: 'string', default: process.env.WORKERDECK_TOKEN },\n header: { type: 'string', multiple: true, default: [] },\n wait: { type: 'string', default: '0' },\n interval: { type: 'string', default: '5' },\n 'allow-parked': { type: 'boolean', default: false },\n 'allow-queued': { type: 'boolean', default: false },\n json: { type: 'boolean', default: false },\n help: { type: 'boolean', default: false },\n },\n }).values as typeof values\n } catch (error) {\n process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\\n`)\n return 2\n }\n\n if (values.help) {\n process.stdout.write(HELP)\n return 0\n }\n\n const base = values.url.replace(/\\/$/, '')\n const headers: Record<string, string> = { accept: 'application/json' }\n if (values.token) {\n headers.authorization = `Bearer ${values.token}`\n }\n\n const seconds = (flag: string, raw: string): number => {\n const value = Number(raw)\n if (!Number.isFinite(value) || value < 0) {\n throw new GuardError(`${flag} must be a non-negative number of seconds`)\n }\n return value\n }\n\n let waitMs: number\n let intervalMs: number\n try {\n for (const entry of values.header) {\n const at = entry.indexOf('=')\n if (at < 1) {\n throw new GuardError(`--header must be name=value, got '${entry}'`)\n }\n headers[entry.slice(0, at).trim()] = entry.slice(at + 1).trim()\n }\n waitMs = seconds('--wait', values.wait) * 1000\n intervalMs = Math.max(seconds('--interval', values.interval), 1) * 1000\n } catch (error) {\n process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\\n`)\n return 2\n }\n\n const get = async (path: string): Promise<GetResult> => {\n let res: Response\n try {\n res = await fetch(base + path, { headers })\n } catch (error) {\n // Nothing listening on the URL we were pointed at, so there is no session to lose.\n const code = (error as { cause?: { code?: string } })?.cause?.code\n if (code === 'ECONNREFUSED') {\n return { ok: false, status: 'unreachable' }\n }\n return { ok: false, detail: String(error instanceof Error ? error.message : error) }\n }\n if (!res.ok) {\n return { ok: false, code: res.status, detail: `HTTP ${res.status}` }\n }\n try {\n return { ok: true, body: (await res.json()) as Record<string, unknown> }\n } catch {\n return { ok: false, code: res.status, detail: 'response was not JSON' }\n }\n }\n\n const inspect = async (): Promise<Verdict> => {\n const sessions = await get('/sessions')\n if (!sessions.ok && 'status' in sessions) {\n return { unreachable: true, reasons: [], notes: [] }\n }\n if (!sessions.ok) {\n return { error: `GET ${base}/sessions → ${sessions.detail}`, reasons: [], notes: [] }\n }\n const listed = sessions.body.sessions\n if (!Array.isArray(listed)) {\n return { error: `GET ${base}/sessions returned no session list`, reasons: [], notes: [] }\n }\n const all = listed as { id: string; status: string }[]\n\n const reasons: string[] = []\n // Worth printing, but never worth blocking a deploy over.\n const notes: string[] = []\n for (const session of all.filter((s) => BUSY_STATUSES.has(s.status))) {\n reasons.push(`session ${session.id} is ${session.status}`)\n }\n const parked = all.filter((s) => s.status === 'parked')\n if (parked.length > 0 && !values['allow-parked']) {\n reasons.push(\n `${parked.length} parked session(s) - pass --allow-parked once the server ` +\n 'runs a durable SessionStore, or they are lost on restart',\n )\n }\n\n // A queue is optional, so a 404 here means this server declares none.\n const queue = await get('/queue')\n if (!queue.ok && !('status' in queue) && queue.code !== 404) {\n return { error: `GET ${base}/queue → ${queue.detail}`, reasons: [], notes: [] }\n }\n const stats = queue.ok ? (queue.body.stats as { running?: number; queued?: number; parked?: number } | undefined) : undefined\n if ((stats?.running ?? 0) > 0) {\n reasons.push(`${stats!.running} job(s) running`)\n }\n if ((stats?.queued ?? 0) > 0 && !values['allow-queued']) {\n reasons.push(\n `${stats!.queued} job(s) queued - pass --allow-queued once the server runs a ` +\n 'durable QueueAdapter, or they are lost on restart',\n )\n }\n if ((stats?.parked ?? 0) > 0 && values['allow-parked']) {\n notes.push(\n `${stats!.parked} parked job(s): their queue-side records are the QueueAdapter's, ` +\n \"not the SessionStore's - with the in-memory adapter they never finish\",\n )\n }\n return { reasons, notes, sessions: all.length, parked: parked.length }\n }\n\n const report = (verdict: string, detail: Verdict): void => {\n if (values.json) {\n process.stdout.write(`${JSON.stringify({ verdict, ...detail })}\\n`)\n return\n }\n const lines = [...detail.reasons.map((reason) => ` - ${reason}`), ...detail.notes.map((note) => ` note: ${note}`)].join('\\n')\n process.stdout.write(`guard: ${verdict}${lines ? `\\n${lines}` : ''}\\n`)\n }\n\n const deadline = Date.now() + waitMs\n for (;;) {\n const result = await inspect()\n if (result.error) {\n report('unknown', { ...result, reasons: [result.error] })\n return 2\n }\n if (result.unreachable) {\n report('safe to restart (nothing listening on the given URL)', result)\n return 0\n }\n if (result.reasons.length === 0) {\n report('safe to restart', result)\n return 0\n }\n if (Date.now() + intervalMs > deadline) {\n report('busy - not safe to restart', result)\n return 1\n }\n report(`busy, waiting up to ${Math.round((deadline - Date.now()) / 1000)}s`, result)\n await new Promise((resolve) => setTimeout(resolve, intervalMs))\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,152 @@
1
+ import { t as installShutdown } from "./cli.mjs";
2
+ import * as nodeModule from "node:module";
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { createFileSessionStore, isDormant, reloadPlan } from "@workerdeck/server";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ //#region src/dev/hot-reload.ts
8
+ const here = fileURLToPath(import.meta.url);
9
+ const packagesDir = resolve(dirname(here), "..", "..", "..");
10
+ const CTRL_C = "";
11
+ const CTRL_R = "";
12
+ function line(text) {
13
+ process.stdout.write(`${text}\n`);
14
+ }
15
+ async function persistProvider(parking, runner) {
16
+ let how = "kept";
17
+ if (!runner.snapshot?.()) {
18
+ await runner.interrupt();
19
+ how = "interrupted";
20
+ }
21
+ const before = Date.now();
22
+ parking.touch(runner);
23
+ await parking.flush(runner.id);
24
+ const record = await parking.get(runner.id);
25
+ return record && !isDormant(record) && record.parkedAt >= before ? how : "lost";
26
+ }
27
+ async function runHotReload(flags) {
28
+ if (!here.endsWith(".ts") || !existsSync(join(packagesDir, "server", "src"))) {
29
+ process.stderr.write("[workerdeck] --hot-reload needs a source checkout; this build is one bundled file with nothing to swap.\n Serving without it. Run it as `pnpm cli --hot-reload` from the repo instead.\n");
30
+ return "unsupported";
31
+ }
32
+ const registerHooks = nodeModule.registerHooks;
33
+ if (!registerHooks) {
34
+ process.stderr.write("[workerdeck] --hot-reload needs `module.registerHooks`, which this runtime does not provide (Node 22.15+).\n Serving without it.\n");
35
+ return "unsupported";
36
+ }
37
+ const packagesUrl = `${pathToFileURL(packagesDir).href}/`;
38
+ let generation = 0;
39
+ registerHooks({ resolve(specifier, context, nextResolve) {
40
+ const result = nextResolve(specifier, context);
41
+ if (generation === 0 || typeof result.url !== "string") return result;
42
+ if (!result.url.startsWith(packagesUrl) || result.url.includes("/node_modules/")) return result;
43
+ return {
44
+ ...result,
45
+ url: `${result.url}${result.url.includes("?") ? "&" : "?"}wdgen=${generation}`
46
+ };
47
+ } });
48
+ let baseStore;
49
+ let stateDir = null;
50
+ let pidPath;
51
+ const start = async (carried) => {
52
+ const config = await import(`${packagesUrl}cli/src/config.ts`);
53
+ const lib = await import(`${packagesUrl}cli/src/lib/instance.ts`);
54
+ const loaded = await config.loadConfigFile(flags.config);
55
+ const resolved = config.resolveInstanceConfig(flags, loaded);
56
+ stateDir = resolved.stateDir;
57
+ if (resolved.stateDir && !resolved.options.parking?.store) baseStore ??= createFileSessionStore({ dir: join(resolved.stateDir, "parked") });
58
+ resolved.options.parking = {
59
+ ...resolved.options.parking,
60
+ ...baseStore ? { store: baseStore } : {},
61
+ persistLive: true
62
+ };
63
+ return await lib.startInstance(resolved, {
64
+ quiet: generation > 0,
65
+ carried
66
+ });
67
+ };
68
+ let instance = await start([]);
69
+ let orphans = [];
70
+ if (stateDir) {
71
+ pidPath = join(stateDir, "gateway.pid");
72
+ mkdirSync(stateDir, { recursive: true });
73
+ writeFileSync(pidPath, `${process.pid}\n`);
74
+ process.on("exit", () => {
75
+ try {
76
+ rmSync(pidPath);
77
+ } catch {}
78
+ });
79
+ }
80
+ let reloading = false;
81
+ const reload = async (trigger) => {
82
+ if (reloading) return;
83
+ reloading = true;
84
+ const started = Date.now();
85
+ const persisted = [];
86
+ const dropped = [];
87
+ try {
88
+ const carry = [];
89
+ const persist = [];
90
+ for (const info of instance.server.registry.list()) {
91
+ const runner = instance.server.registry.get(info.id);
92
+ if (!runner) continue;
93
+ const plan = reloadPlan(runner);
94
+ if (plan === "carry") carry.push(runner.id);
95
+ else if (plan === "persist") persist.push(runner);
96
+ else dropped.push(runner.id);
97
+ }
98
+ for (const runner of persist) persisted.push(`${runner.id} (${await persistProvider(instance.server.parking, runner)})`);
99
+ await instance.server.parking.flush();
100
+ for (const id of carry) {
101
+ const released = instance.server.releaseSession(id);
102
+ if (released) orphans.push(released);
103
+ }
104
+ await instance.close();
105
+ generation += 1;
106
+ instance = await start(orphans);
107
+ const adopted = new Set(instance.adopted);
108
+ const ended = orphans.filter((carried) => !adopted.has(carried.runner.id)).map((carried) => carried.runner.id);
109
+ orphans = [];
110
+ line(`[workerdeck] reloaded on ${trigger}: gen ${generation} in ${Date.now() - started}ms, ${adopted.size} carried${persisted.length ? `, persisted ${persisted.join(", ")}` : ""}${dropped.length ? `, dropped ${dropped.join(", ")}` : ""}${ended.length ? `, ended while held ${ended.join(", ")}` : ""}`);
111
+ } catch (error) {
112
+ line(`[workerdeck] reload failed at gen ${generation}: ${error instanceof Error ? error.message : String(error)}`);
113
+ if (orphans.length > 0) {
114
+ line(`[workerdeck] holding ${orphans.length} session(s): ${orphans.map((carried) => carried.runner.id).join(", ")}`);
115
+ line("[workerdeck] their turns keep running and their transcripts are buffered, but nothing can attach and");
116
+ line("[workerdeck] no push or webhook goes out until a generation starts. Fix the error and reload again,");
117
+ line("[workerdeck] or ctrl-c to stop (the sessions close; their records are kept).");
118
+ }
119
+ } finally {
120
+ reloading = false;
121
+ }
122
+ };
123
+ const shutdown = installShutdown({
124
+ current: () => instance,
125
+ alsoClose: () => orphans.map((carried) => carried.runner),
126
+ log: line
127
+ });
128
+ process.on("SIGUSR2", () => void reload("SIGUSR2"));
129
+ if (process.stdin.isTTY) {
130
+ process.stdin.setRawMode(true);
131
+ process.stdin.resume();
132
+ process.stdin.setEncoding("utf8");
133
+ process.stdin.on("data", (key) => {
134
+ if (key === CTRL_R) {
135
+ reload("ctrl-r");
136
+ return;
137
+ }
138
+ if (key === CTRL_C) shutdown("SIGINT");
139
+ });
140
+ process.on("exit", () => {
141
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
142
+ });
143
+ }
144
+ line(` hot reload: ctrl-r${pidPath ? ", `workerdeck reload`" : ""}, or SIGUSR2 to pid ${process.pid}`);
145
+ line("");
146
+ await new Promise(() => {});
147
+ return 0;
148
+ }
149
+ //#endregion
150
+ export { runHotReload };
151
+
152
+ //# sourceMappingURL=hot-reload-CfP94v9Y.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hot-reload-CfP94v9Y.mjs","names":[],"sources":["../src/dev/hot-reload.ts"],"sourcesContent":["import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'\nimport * as nodeModule from 'node:module'\nimport { dirname, join, resolve } from 'node:path'\nimport { fileURLToPath, pathToFileURL } from 'node:url'\nimport { createFileSessionStore, isDormant, reloadPlan, type CarriedSession, type SessionStore } from '@workerdeck/server'\nimport type { CliFlags } from '../config.ts'\nimport type { Instance } from '../lib/instance.ts'\nimport { installShutdown } from '../lib/shutdown.ts'\n\ntype Registry = Instance['server']['registry']\ntype CarriedRunner = NonNullable<ReturnType<Registry['get']>>\ntype Parking = Instance['server']['parking']\n\nconst here = fileURLToPath(import.meta.url)\nconst packagesDir = resolve(dirname(here), '..', '..', '..')\n\nconst CTRL_C = '\\u0003'\nconst CTRL_R = '\\u0012'\n\nfunction line(text: string): void {\n process.stdout.write(`${text}\\n`)\n}\n\n// A provider session is never carried by identity: its executors close over the generation's bridge hub, so a\n// carried one fails every LATER bridged call with no_client while a client is attached, not merely the one in\n// flight. It takes the ordinary restart path instead, which needs its snapshot on disk before the close.\n// `snapshot()` is side-effect free, so probing it is how we learn whether an interrupt is owed.\nasync function persistProvider(parking: Parking, runner: CarriedRunner): Promise<'kept' | 'interrupted' | 'lost'> {\n let how: 'kept' | 'interrupted' = 'kept'\n if (!runner.snapshot?.()) {\n // The documented way out of every state snapshot refuses: it aborts the model call, settles pending executions\n // as interrupted and emits the failed turn_result whose write-through then runs.\n await runner.interrupt()\n how = 'interrupted'\n }\n const before = Date.now()\n parking.touch(runner)\n await parking.flush(runner.id)\n const record = await parking.get(runner.id)\n return record && !isDormant(record) && record.parkedAt >= before ? how : 'lost'\n}\n\n// Runs the gateway inside a shell that can re-evaluate every module under `packages/` in place, carrying the live\n// runners - and therefore their engine child processes, subagents and shell grandchildren - across the swap.\n// Dev only: the published CLI is a single bundled file with no subgraph to re-evaluate.\n//\n// `unsupported` rather than a throw: the flag reaches this CLI from a VS Code setting and a config file as well as\n// from a keyboard, and a gateway that refuses to start because one dev convenience is unavailable is the worse\n// failure. The caller serves without it and says so.\nexport async function runHotReload(flags: CliFlags): Promise<number | 'unsupported'> {\n if (!here.endsWith('.ts') || !existsSync(join(packagesDir, 'server', 'src'))) {\n process.stderr.write(\n '[workerdeck] --hot-reload needs a source checkout; this build is one bundled file with nothing to swap.\\n' +\n ' Serving without it. Run it as `pnpm cli --hot-reload` from the repo instead.\\n',\n )\n return 'unsupported'\n }\n\n // A namespace lookup, not a named import: `registerHooks` landed in Node 22.15, and a runtime without it (an\n // older Node, or Bun) must reach the unsupported path below rather than fail to load this module at all.\n const registerHooks = (nodeModule as Partial<typeof nodeModule>).registerHooks\n if (!registerHooks) {\n process.stderr.write(\n '[workerdeck] --hot-reload needs `module.registerHooks`, which this runtime does not provide (Node 22.15+).\\n' +\n ' Serving without it.\\n',\n )\n return 'unsupported'\n }\n\n const packagesUrl = `${pathToFileURL(packagesDir).href}/`\n let generation = 0\n\n // Only our own sources are versioned. node_modules stays at one copy per package on purpose: a second Agent SDK,\n // a second `ws`, or a second quickjs would each be a different kind of disaster.\n registerHooks({\n resolve(specifier, context, nextResolve) {\n const result = nextResolve(specifier, context)\n if (generation === 0 || typeof result.url !== 'string') {\n return result\n }\n if (!result.url.startsWith(packagesUrl) || result.url.includes('/node_modules/')) {\n return result\n }\n return { ...result, url: `${result.url}${result.url.includes('?') ? '&' : '?'}wdgen=${generation}` }\n },\n })\n\n let baseStore: SessionStore | undefined\n let stateDir: string | null = null\n let pidPath: string | undefined\n\n const start = async (carried: CarriedSession[]): Promise<Instance> => {\n const config = (await import(`${packagesUrl}cli/src/config.ts`)) as typeof import('../config.ts')\n const lib = (await import(`${packagesUrl}cli/src/lib/instance.ts`)) as typeof import('../lib/instance.ts')\n const loaded = await config.loadConfigFile(flags.config)\n const resolved = config.resolveInstanceConfig(flags, loaded)\n stateDir = resolved.stateDir\n // One store object for the whole process. A file store is single-process by contract, and two generations\n // holding two of them over one directory is exactly the two-servers-one-directory case it refuses to be; an\n // in-memory one is worse, because a reload would drop every record it holds.\n if (resolved.stateDir && !resolved.options.parking?.store) {\n baseStore ??= createFileSessionStore({ dir: join(resolved.stateDir, 'parked') })\n }\n resolved.options.parking = {\n ...resolved.options.parking,\n ...(baseStore ? { store: baseStore } : {}),\n // Forced: the seam persists provider sessions rather than carrying them, and without this their snapshots\n // are never written, so a reload would end them.\n persistLive: true,\n }\n return await lib.startInstance(resolved, { quiet: generation > 0, carried })\n }\n\n let instance = await start([])\n let orphans: CarriedSession[] = []\n\n if (stateDir) {\n pidPath = join(stateDir, 'gateway.pid')\n // The state dir is created lazily by whoever writes into it first, and on a cold one that is nobody yet.\n mkdirSync(stateDir, { recursive: true })\n writeFileSync(pidPath, `${process.pid}\\n`)\n process.on('exit', () => {\n try {\n rmSync(pidPath!)\n } catch {}\n })\n }\n\n let reloading = false\n const reload = async (trigger: string): Promise<void> => {\n if (reloading) {\n return\n }\n reloading = true\n const started = Date.now()\n const persisted: string[] = []\n const dropped: string[] = []\n try {\n // One synchronous pass, because every later step keys off this classification and `info()` is synchronous.\n const carry: string[] = []\n const persist: CarriedRunner[] = []\n for (const info of instance.server.registry.list()) {\n const runner = instance.server.registry.get(info.id)\n if (!runner) {\n continue\n }\n const plan = reloadPlan(runner)\n if (plan === 'carry') {\n carry.push(runner.id)\n } else if (plan === 'persist') {\n persist.push(runner)\n } else {\n dropped.push(runner.id)\n }\n }\n\n // While this generation still owns them: `touch()` writes nothing for a runner its registry does not hold.\n for (const runner of persist) {\n persisted.push(`${runner.id} (${await persistProvider(instance.server.parking, runner)})`)\n }\n // Both halves matter: this generation's queued writes must land before `close()` skips them, and two managers\n // in one process share the file store's `${path}.${pid}.tmp`, so their writes must never overlap.\n await instance.server.parking.flush()\n\n for (const id of carry) {\n const released = instance.server.releaseSession(id)\n if (released) {\n orphans.push(released)\n }\n }\n await instance.close()\n generation += 1\n instance = await start(orphans)\n const adopted = new Set(instance.adopted)\n const ended = orphans.filter((carried) => !adopted.has(carried.runner.id)).map((carried) => carried.runner.id)\n orphans = []\n line(\n `[workerdeck] reloaded on ${trigger}: gen ${generation} in ${Date.now() - started}ms, ` +\n `${adopted.size} carried${persisted.length ? `, persisted ${persisted.join(', ')}` : ''}` +\n `${dropped.length ? `, dropped ${dropped.join(', ')}` : ''}${ended.length ? `, ended while held ${ended.join(', ')}` : ''}`,\n )\n } catch (error) {\n line(`[workerdeck] reload failed at gen ${generation}: ${error instanceof Error ? error.message : String(error)}`)\n if (orphans.length > 0) {\n line(`[workerdeck] holding ${orphans.length} session(s): ${orphans.map((carried) => carried.runner.id).join(', ')}`)\n line('[workerdeck] their turns keep running and their transcripts are buffered, but nothing can attach and')\n line('[workerdeck] no push or webhook goes out until a generation starts. Fix the error and reload again,')\n line('[workerdeck] or ctrl-c to stop (the sessions close; their records are kept).')\n }\n } finally {\n reloading = false\n }\n }\n\n const shutdown = installShutdown({\n current: () => instance,\n // Held across a failed generation, so no registry holds them and `close()` cannot reach them. Nothing else\n // would end their engine children promptly.\n alsoClose: () => orphans.map((carried) => carried.runner),\n log: line,\n })\n\n process.on('SIGUSR2', () => void reload('SIGUSR2'))\n if (process.stdin.isTTY) {\n process.stdin.setRawMode(true)\n process.stdin.resume()\n process.stdin.setEncoding('utf8')\n process.stdin.on('data', (key: string) => {\n if (key === CTRL_R) {\n void reload('ctrl-r')\n return\n }\n // Raw mode swallows SIGINT, so ctrl-c arrives as a byte and has to be re-spelled as the shutdown it was.\n if (key === CTRL_C) {\n shutdown('SIGINT')\n }\n })\n process.on('exit', () => {\n if (process.stdin.isTTY) {\n process.stdin.setRawMode(false)\n }\n })\n }\n\n line(` hot reload: ctrl-r${pidPath ? ', `workerdeck reload`' : ''}, or SIGUSR2 to pid ${process.pid}`)\n line('')\n\n // Never resolves: every exit from here runs through `installShutdown`, which closes the current instance and\n // exits. Awaiting `instance.closed` instead would return the moment a reload closed the generation it captured.\n await new Promise<void>(() => {})\n return 0\n}\n"],"mappings":"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"}
package/build/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { Authenticator, WorkerServer, WorkerServerOptions } from "@workerdeck/server";
1
+ import { Authenticator, CarriedSession, WorkerServer, WorkerServerOptions } from "@workerdeck/server";
2
2
  import { KeyObject } from "node:crypto";
3
3
  import { ProfileInfo, SessionNotification, SessionNotificationType } from "@workerdeck/protocol";
4
4
  import { IncomingMessage, ServerResponse } from "node:http";
@@ -117,6 +117,7 @@ type CliFlags = {
117
117
  keepAwake?: boolean;
118
118
  corsOrigins: string[];
119
119
  open?: boolean;
120
+ hotReload?: boolean;
120
121
  help?: boolean;
121
122
  version?: boolean;
122
123
  };
@@ -153,8 +154,13 @@ declare function isLoopbackHostname(hostname: string): boolean;
153
154
  declare function resolveInstanceConfig(flags: CliFlags, loaded: LoadedConfig, env?: NodeJS.ProcessEnv, cwd?: string): ResolvedConfig;
154
155
  //#endregion
155
156
  //#region src/lib/instance.d.ts
157
+ type StartOptions = {
158
+ quiet?: boolean;
159
+ carried?: CarriedSession[];
160
+ };
156
161
  type Instance = {
157
162
  server: WorkerServer;
163
+ adopted: string[];
158
164
  url: string;
159
165
  port: number;
160
166
  closed: Promise<void>;
@@ -163,9 +169,7 @@ type Instance = {
163
169
  };
164
170
  declare function resolveWebRoot(): string;
165
171
  declare function createHostGuard(allowedHosts: Set<string> | null): (req: IncomingMessage) => boolean;
166
- declare function startInstance(config: ResolvedConfig, options?: {
167
- quiet?: boolean;
168
- }): Promise<Instance>;
172
+ declare function startInstance(config: ResolvedConfig, options?: StartOptions): Promise<Instance>;
169
173
  //#endregion
170
174
  //#region src/lib/guard.d.ts
171
175
  declare function runGuard(argv: string[]): Promise<number>;
package/build/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { n as runGuard } from "./guard-CGPVzJ3Q.mjs";
2
- import { C as createActivityRegistry, S as createDeviceRoute, _ as createApnsForwarder, a as ConfigError, b as createApnsRoute, c as isLoopback, d as parseArgs, f as resolveInstanceConfig, g as buildPush, h as materializeAuthKey, i as renderLoginPage, l as isLoopbackHostname, m as createAuthSessionStore, n as resolveWebRoot, o as defaultStateDir, p as createCliAuth, r as startInstance, s as hostnameOf, t as createHostGuard, u as loadConfigFile, v as createApnsClient, w as createActivityRoute, x as createDeviceRegistry, y as loadApnsKey } from "./instance-DP3af4H-.mjs";
1
+ import { n as runGuard } from "./guard-C4ybpzbt.mjs";
2
+ import { C as createActivityRegistry, S as createDeviceRoute, _ as createApnsForwarder, a as ConfigError, b as createApnsRoute, c as isLoopback, d as parseArgs, f as resolveInstanceConfig, g as buildPush, h as materializeAuthKey, i as renderLoginPage, l as isLoopbackHostname, m as createAuthSessionStore, n as resolveWebRoot, o as defaultStateDir, p as createCliAuth, r as startInstance, s as hostnameOf, t as createHostGuard, u as loadConfigFile, v as createApnsClient, w as createActivityRoute, x as createDeviceRegistry, y as loadApnsKey } from "./instance-vQe2VlGq.mjs";
3
3
  export { ConfigError, buildPush, createActivityRegistry, createActivityRoute, createApnsClient, createApnsForwarder, createApnsRoute, createAuthSessionStore, createCliAuth, createDeviceRegistry, createDeviceRoute, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadApnsKey, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };