@lelouchhe/webagent 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +59 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +105 -3
  4. package/dist/fonts/temml/Temml.woff2 +0 -0
  5. package/dist/index.html +64 -41
  6. package/dist/js/app.3OEVQXHK.js +4 -0
  7. package/dist/js/chunk.AJZBJBMO.js +1 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.IJM5DBCO.js +173 -0
  10. package/dist/js/chunk.VZXGXFNN.js +5 -0
  11. package/dist/js/login.PYIK52HN.js +1 -0
  12. package/dist/js/viewer.FCSSTUVY.js +1 -0
  13. package/dist/login.html +49 -0
  14. package/dist/share-viewer.00gubshk.css +114 -0
  15. package/dist/share-viewer.html +54 -0
  16. package/dist/styles.00xfh3e6.css +1848 -0
  17. package/dist/sw.js +79 -27
  18. package/dist/theme-init.js +6 -0
  19. package/lib/agent-detect.js +110 -0
  20. package/lib/atomic-write.js +50 -0
  21. package/lib/attachment-dispatch.js +86 -0
  22. package/lib/attachment-interceptor.js +130 -0
  23. package/lib/attachment-labels.js +139 -0
  24. package/lib/attachments.js +154 -0
  25. package/lib/auth-middleware.js +105 -0
  26. package/lib/auth-store.js +269 -0
  27. package/lib/auth.js +89 -0
  28. package/lib/bootstrap.js +70 -0
  29. package/lib/bridge-event-config.js +29 -0
  30. package/lib/bridge.js +244 -93
  31. package/lib/client-registry.js +149 -0
  32. package/lib/config.js +130 -9
  33. package/lib/daemon.js +175 -41
  34. package/lib/event-handler.js +209 -91
  35. package/lib/image-dimensions.js +64 -0
  36. package/lib/log-fmt.js +67 -0
  37. package/lib/log.js +83 -0
  38. package/lib/message-cleanup.js +48 -0
  39. package/lib/mode-bucket.js +62 -0
  40. package/lib/model-picker.js +17 -0
  41. package/lib/preflight.js +214 -0
  42. package/lib/push-service.js +297 -52
  43. package/lib/routes.js +1315 -145
  44. package/lib/server.js +149 -37
  45. package/lib/session-manager.js +164 -18
  46. package/lib/session-state.js +160 -0
  47. package/lib/sessions-anchor.js +28 -0
  48. package/lib/share/cleanup.js +45 -0
  49. package/lib/share/routes.js +972 -0
  50. package/lib/share/sanitize.js +179 -0
  51. package/lib/sse-manager.js +94 -8
  52. package/lib/sse-ticket.js +45 -0
  53. package/lib/startup-checks.js +95 -0
  54. package/lib/store.js +636 -30
  55. package/lib/title-service.js +26 -9
  56. package/lib/tokens.js +50 -0
  57. package/lib/types.js +23 -0
  58. package/package.json +39 -4
  59. package/dist/js/app.2562YGRO.js +0 -10
  60. package/dist/styles.008ve1hx.css +0 -669
  61. package/lib/shared/constants.js +0 -17
@@ -0,0 +1,149 @@
1
+ /**
2
+ * ClientRegistry — in-memory tracking of connected clients.
3
+ *
4
+ * Tracks per-client metadata that survives SSE disconnect:
5
+ * - capabilities advertised by the client on /hello.
6
+ * - visible / active / visibleSince: identity-layer visibility state used
7
+ * by TTS dispatch (voice branch) and push suppression (main).
8
+ *
9
+ * Lifecycle: clients call /hello on SSE connect (register) and POST
10
+ * /visibility on visibilitychange + 15s heartbeat. SSE disconnect does
11
+ * not remove a client — it stays in the registry until an explicit
12
+ * /goodbye or TTL eviction (caller's responsibility). This lets visibility
13
+ * state outlive transient drops.
14
+ */
15
+ export class ClientRegistry {
16
+ clients = new Map();
17
+ visibilityTtlMs;
18
+ now;
19
+ constructor(options = {}) {
20
+ this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
21
+ this.now = options.now ?? Date.now;
22
+ }
23
+ register(id, data) {
24
+ const existing = this.clients.get(id);
25
+ if (existing) {
26
+ existing.capabilities = data.capabilities;
27
+ existing.lastSeen = this.now();
28
+ return existing;
29
+ }
30
+ const entry = {
31
+ id,
32
+ capabilities: data.capabilities,
33
+ visible: false,
34
+ active: null,
35
+ visibleSince: 0,
36
+ lastSeen: this.now(),
37
+ };
38
+ this.clients.set(id, entry);
39
+ return entry;
40
+ }
41
+ remove(id) {
42
+ this.clients.delete(id);
43
+ }
44
+ /**
45
+ * Atomic visibility setter. Mirrors PushService.updateClient semantics:
46
+ * - `visible` omitted = preserve; bool = set (and stamp/clear visibleSince).
47
+ * - `active` omitted = preserve; null = clear; string = replace.
48
+ * - Returns `becameVisibleFor=X` only on first transition into
49
+ * (visible:true, active:X) — heartbeat refreshes return null so
50
+ * callers can fire edge-triggered side effects exactly once.
51
+ * - Session-switch while visible (active X→Y) restarts the TTL clock
52
+ * even when the patch doesn't carry an explicit visible:true.
53
+ *
54
+ * No-op on unknown client.
55
+ */
56
+ setVisibility(id, patch) {
57
+ const entry = this.clients.get(id);
58
+ if (!entry)
59
+ return { becameVisibleFor: null };
60
+ const wasVisibleForSession = entry.visible && entry.active != null ? entry.active : null;
61
+ if (patch.visible !== undefined) {
62
+ entry.visible = patch.visible;
63
+ entry.visibleSince = patch.visible ? this.now() : 0;
64
+ }
65
+ if (patch.active !== undefined) {
66
+ entry.active = patch.active;
67
+ }
68
+ const becameVisibleFor = entry.visible &&
69
+ entry.active != null &&
70
+ entry.active !== wasVisibleForSession
71
+ ? entry.active
72
+ : null;
73
+ if (becameVisibleFor) {
74
+ // Any transition into "visible + active=X" restarts TTL — including
75
+ // session-switches that arrive without an explicit visible:true.
76
+ entry.visibleSince = this.now();
77
+ }
78
+ entry.lastSeen = this.now();
79
+ return { becameVisibleFor };
80
+ }
81
+ /** Is this specific client currently visible & viewing `sessionId` & fresh? */
82
+ isVisibleForSession(id, sessionId) {
83
+ const entry = this.clients.get(id);
84
+ if (!entry)
85
+ return false;
86
+ if (!entry.visible)
87
+ return false;
88
+ if (entry.active !== sessionId)
89
+ return false;
90
+ if (this.now() - entry.visibleSince > this.visibilityTtlMs)
91
+ return false;
92
+ return true;
93
+ }
94
+ /** Is at least one fresh visible client viewing `sessionId`? */
95
+ isSessionVisibleToAnyClient(sessionId) {
96
+ const now = this.now();
97
+ for (const e of this.clients.values()) {
98
+ if (!e.visible)
99
+ continue;
100
+ if (e.active !== sessionId)
101
+ continue;
102
+ if (now - e.visibleSince > this.visibilityTtlMs)
103
+ continue;
104
+ return true;
105
+ }
106
+ return false;
107
+ }
108
+ /** Is this specific client currently fresh-visible (any session)? */
109
+ isClientVisible(id) {
110
+ const entry = this.clients.get(id);
111
+ if (!entry)
112
+ return false;
113
+ if (!entry.visible)
114
+ return false;
115
+ if (this.now() - entry.visibleSince > this.visibilityTtlMs)
116
+ return false;
117
+ return true;
118
+ }
119
+ /** Is at least one fresh visible client connected (any session)? */
120
+ hasAnyVisibleClient() {
121
+ const now = this.now();
122
+ for (const e of this.clients.values()) {
123
+ if (!e.visible)
124
+ continue;
125
+ if (now - e.visibleSince > this.visibilityTtlMs)
126
+ continue;
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ updateCapabilities(id, caps) {
132
+ const entry = this.clients.get(id);
133
+ if (!entry)
134
+ return;
135
+ entry.capabilities = caps;
136
+ entry.lastSeen = this.now();
137
+ }
138
+ touch(id) {
139
+ const entry = this.clients.get(id);
140
+ if (entry)
141
+ entry.lastSeen = this.now();
142
+ }
143
+ get(id) {
144
+ return this.clients.get(id);
145
+ }
146
+ list() {
147
+ return Array.from(this.clients.values());
148
+ }
149
+ }
package/lib/config.js CHANGED
@@ -1,30 +1,144 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { parse as parseTOML } from "smol-toml";
3
3
  import { z } from "zod";
4
- const ConfigSchema = z.object({
4
+ export const ConfigSchema = z.object({
5
5
  port: z.number().int().positive().default(6800),
6
+ // Network interface to bind. Default "127.0.0.1" = loopback only
7
+ // (no LAN exposure). Set to "0.0.0.0" to listen on all IPv4
8
+ // interfaces, "::" for IPv6/dual-stack, or a specific NIC IP
9
+ // (e.g. "192.168.1.10") to bind one interface on a multi-homed
10
+ // host. Note: "localhost" works but resolves via DNS and may
11
+ // pick IPv6 (`::1`) over IPv4 — prefer the explicit IP form.
12
+ host: z.string().default("127.0.0.1"),
6
13
  data_dir: z.string().default("data"),
7
14
  default_cwd: z.string().default(process.cwd()),
8
15
  public_dir: z.string().default("dist"),
9
- agent_cmd: z.string().default("copilot --acp"),
10
- limits: z.object({
16
+ agent_cmd: z.string().default("auto"),
17
+ limits: z
18
+ .object({
11
19
  bash_output: z.number().int().positive().default(1_048_576), // 1 MB
12
20
  image_upload: z.number().int().positive().default(10_485_760), // 10 MB
21
+ file_upload: z.number().int().positive().default(52_428_800), // 50 MB — non-image attachments
13
22
  cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
14
23
  recent_paths: z.number().int().nonnegative().default(10), // /new menu display limit; 0 = show all
15
24
  recent_paths_ttl: z.number().int().nonnegative().default(30), // days before auto-cleanup; 0 = keep forever
16
- }).default({
25
+ })
26
+ .default({
17
27
  bash_output: 1_048_576,
18
28
  image_upload: 10_485_760,
29
+ file_upload: 52_428_800,
19
30
  cancel_timeout: 10_000,
20
31
  recent_paths: 10,
21
32
  recent_paths_ttl: 30,
22
33
  }),
23
- push: z.object({
24
- vapid_subject: z.string().default("mailto:webagent@localhost"),
25
- }).default({
26
- vapid_subject: "mailto:webagent@localhost",
34
+ push: z
35
+ .object({
36
+ vapid_subject: z.string().default("mailto:noreply@example.com"),
37
+ global_visibility_suppression: z.boolean().default(true),
38
+ })
39
+ .default({
40
+ vapid_subject: "mailto:noreply@example.com",
41
+ global_visibility_suppression: true,
27
42
  }),
43
+ // [title] — title generation sub-session configuration.
44
+ //
45
+ // `models` is an array of case-insensitive substring patterns. When the
46
+ // title sub-session is created, we look at the model list the agent
47
+ // reports (ACP `availableModels`) and pick the first model whose id
48
+ // matches any pattern in order. Match → call `setConfigOption` with
49
+ // that model id; no match → skip the call and inherit the agent's
50
+ // default model (`currentModelId`).
51
+ //
52
+ // Default list targets the cheap/fast tier across major providers:
53
+ // - "haiku" → Anthropic (claude-haiku-*)
54
+ // - "flash-lite" → Google Gemini (gemini-*-flash-lite) [must precede "flash"]
55
+ // - "nano" → OpenAI (gpt-*-nano), Gemini Nano
56
+ // - "mini" → OpenAI (gpt-*-mini, 4o-mini), Mistral
57
+ // - "flash" → Google Gemini (gemini-*-flash)
58
+ // - "lite" → Cohere, generic
59
+ //
60
+ // Set `models = []` to disable substring matching entirely and always
61
+ // inherit the agent's default model. To pin one specific model, pass a
62
+ // single-element array: `models = ["claude-haiku-4.5"]`.
63
+ title: z
64
+ .object({
65
+ models: z
66
+ .array(z.string())
67
+ .default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
68
+ })
69
+ .default({
70
+ models: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
71
+ }),
72
+ // [debug] — frontend log level.
73
+ // level ∈ off | debug | info | warn | error. Default "off".
74
+ // Users can override per page-load via `?debug=<level>` in the URL,
75
+ // or at runtime via the /log slash command.
76
+ debug: z
77
+ .object({
78
+ level: z.enum(["off", "debug", "info", "warn", "error"]).default("off"),
79
+ })
80
+ .default({ level: "off" }),
81
+ // [messages] — external notifications primitive.
82
+ // `unprocessed_ttl_days` caps how long an unbound message stays in the
83
+ // inbox before TTL cleanup removes it. 0 = keep forever.
84
+ messages: z
85
+ .object({
86
+ unprocessed_ttl_days: z.number().int().nonnegative().default(30),
87
+ })
88
+ .default({ unprocessed_ttl_days: 30 }),
89
+ // [share] — public read-only session share links.
90
+ // Default: disabled. Dogfood manually flips `enabled = true` after
91
+ // CF Access bypass + Rate Limiting are configured. See docs/share.md.
92
+ // enabled — master kill switch; when false, all share routes
93
+ // return 410 and slash commands are hidden.
94
+ // ttl_hours — global default TTL for public share links. 0 =
95
+ // never expire (default). >0 is clamped to 168 (7d).
96
+ // Per-share override via `shares.ttl_hours` column.
97
+ // csp_enforce — true (default) emits Content-Security-Policy on
98
+ // /s/* and /api/v1/shared/* routes. false emits
99
+ // Content-Security-Policy-Report-Only for rollback.
100
+ // viewer_origin — public viewer URL host; empty string = same as
101
+ // webagent host (default). Useful if viewer is
102
+ // behind a different CF Worker route (e.g.
103
+ // "https://share.example.com").
104
+ // internal_hosts — sanitizer internal-domain allowlist; any token
105
+ // matching these substrings gets rewritten to
106
+ // `<internal-host>` before publishing.
107
+ share: z
108
+ .object({
109
+ enabled: z.boolean().default(false),
110
+ ttl_hours: z.number().int().nonnegative().default(0),
111
+ csp_enforce: z.boolean().default(true),
112
+ viewer_origin: z.string().default(""),
113
+ internal_hosts: z.array(z.string()).default([]),
114
+ })
115
+ .default({
116
+ enabled: false,
117
+ ttl_hours: 0,
118
+ csp_enforce: true,
119
+ viewer_origin: "",
120
+ internal_hosts: [],
121
+ }),
122
+ // [auth] — bearer-token auth knobs.
123
+ // first_run_bootstrap controls the zero-config first-run UX:
124
+ // true (default) — when auth.json file does NOT exist AND stdin
125
+ // is a TTY, server auto-mints a one-time admin
126
+ // token and prints it as part of the startup-
127
+ // doctor stream. Operator copies the token from
128
+ // terminal scrollback and pastes it into the
129
+ // /login form.
130
+ // false — refuse to serve, print `--create-token` hint,
131
+ // exit 78. Use this when deploying behind a
132
+ // supervisor that provisions auth.json
133
+ // out-of-band (CI, Ansible, k8s init container).
134
+ // Either way: if auth.json exists but list is empty (deleted/parse-
135
+ // error), server still exits 78 — that's a config anomaly, not a
136
+ // fresh install.
137
+ auth: z
138
+ .object({
139
+ first_run_bootstrap: z.boolean().default(true),
140
+ })
141
+ .default({ first_run_bootstrap: true }),
28
142
  });
29
143
  let _config = null;
30
144
  function parseArgs() {
@@ -35,7 +149,14 @@ function parseArgs() {
35
149
  return null;
36
150
  }
37
151
  export function loadConfig() {
38
- const configPath = parseArgs();
152
+ return loadConfigFromPath(parseArgs());
153
+ }
154
+ /**
155
+ * Load + validate config from an explicit path (or defaults if null).
156
+ * Lets non-CLI callers (daemon parent) load the same effective config
157
+ * the server would, without needing to mutate process.argv.
158
+ */
159
+ export function loadConfigFromPath(configPath) {
39
160
  let raw = {};
40
161
  if (configPath) {
41
162
  try {
package/lib/daemon.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { atomicWriteFileSync } from "./atomic-write.js";
6
+ import { loadConfigFromPath } from "./config.js";
7
+ import { runStartupChecks, STARTUP_CHECKED_ENV } from "./startup-checks.js";
5
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
9
  // ---------------------------------------------------------------------------
7
10
  // Constants
@@ -30,13 +33,15 @@ export function readPidInfo(filePath) {
30
33
  try {
31
34
  unlinkSync(filePath);
32
35
  }
33
- catch { /* ignore */ }
36
+ catch {
37
+ /* ignore */
38
+ }
34
39
  return null;
35
40
  }
36
41
  }
37
42
  /** Write PID info to `filePath`. */
38
43
  export function writePidInfo(filePath, info) {
39
- writeFileSync(filePath, JSON.stringify(info) + "\n");
44
+ atomicWriteFileSync(filePath, JSON.stringify(info) + "\n");
40
45
  }
41
46
  // ---------------------------------------------------------------------------
42
47
  // Arg helpers
@@ -48,35 +53,116 @@ export function isSubcommand(arg) {
48
53
  export function resolveArgs(args) {
49
54
  const result = [...args];
50
55
  for (let i = 0; i < result.length; i++) {
51
- if (result[i] === "--config" && i + 1 < result.length && !isAbsolute(result[i + 1])) {
56
+ if (result[i] === "--config" &&
57
+ i + 1 < result.length &&
58
+ !isAbsolute(result[i + 1])) {
52
59
  result[i + 1] = resolve(result[i + 1]);
53
60
  }
54
61
  }
55
62
  return result;
56
63
  }
57
64
  // ---------------------------------------------------------------------------
65
+ // Config-path extraction for parent-side checks
66
+ // ---------------------------------------------------------------------------
67
+ /**
68
+ * Pull the `--config <path>` value out of the daemon's argv (resolving
69
+ * relative paths against cwd). Used by `cmdStart` to load the same
70
+ * config the server child would, so the parent process can run the
71
+ * unified startup checks (preflight + auth bootstrap) in the operator's
72
+ * TTY before forking. Returns null if no `--config` was passed.
73
+ */
74
+ export function extractConfigPath(args, cwd) {
75
+ const idx = args.indexOf("--config");
76
+ if (idx < 0 || idx + 1 >= args.length)
77
+ return null;
78
+ const v = args[idx + 1];
79
+ return isAbsolute(v) ? v : resolve(cwd, v);
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // Restart decision (pure)
83
+ // ---------------------------------------------------------------------------
84
+ /**
85
+ * Sysexits.h EX_CONFIG. Server exits 78 when configuration is bad
86
+ * (missing auth.json + non-TTY, preflight failures, etc.). Restarting
87
+ * cannot fix configuration — supervisor must surface and stop.
88
+ */
89
+ const EX_CONFIG = 78;
90
+ /**
91
+ * Pure decision: should the supervisor restart the child, and with what
92
+ * delay? Side effects (logging, scheduling) live in `runSupervisor`.
93
+ */
94
+ export function decideRestart(code, _signal, ctx) {
95
+ if (ctx.stopping) {
96
+ return { kind: "stop", reason: "supervisor shutting down" };
97
+ }
98
+ if (code === EX_CONFIG) {
99
+ return {
100
+ kind: "stop",
101
+ reason: `child exited with EX_CONFIG (${EX_CONFIG}) — not restarting`,
102
+ };
103
+ }
104
+ const stable = ctx.now - ctx.lastStart > STABLE_THRESHOLD_MS;
105
+ const delay = stable
106
+ ? RESTART_DELAY_INITIAL
107
+ : Math.min(ctx.currentDelay * 2, RESTART_DELAY_MAX);
108
+ return { kind: "restart", delayMs: delay };
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // PID file location
112
+ // ---------------------------------------------------------------------------
113
+ //
114
+ // PID file lives in `data_dir`, NOT cwd. This lets multiple instances
115
+ // (e.g. one per agent / port) coexist on the same machine launched from
116
+ // the same shell — each `--config` points at its own data_dir, so each
117
+ // gets its own pid file. The log file stays in cwd (operator-facing).
118
+ function loadDaemonContext(args, cwd) {
119
+ const cfgPath = extractConfigPath(args, cwd);
120
+ const config = loadConfigFromPath(cfgPath);
121
+ const dataDir = isAbsolute(config.data_dir)
122
+ ? config.data_dir
123
+ : resolve(cwd, config.data_dir);
124
+ try {
125
+ mkdirSync(dataDir, { recursive: true });
126
+ }
127
+ catch {
128
+ /* best-effort — start will surface real errors via startup checks */
129
+ }
130
+ return { config, pidFile: join(dataDir, PID_FILE) };
131
+ }
132
+ // ---------------------------------------------------------------------------
58
133
  // Command dispatch
59
134
  // ---------------------------------------------------------------------------
60
135
  export async function run(command, args) {
61
- const pidFile = join(process.cwd(), PID_FILE);
136
+ const { config, pidFile } = loadDaemonContext(args, process.cwd());
62
137
  const logFile = join(process.cwd(), LOG_FILE);
63
138
  switch (command) {
64
- case "start": return cmdStart(pidFile, logFile, args);
65
- case "stop": return cmdStop(pidFile);
66
- case "status": return cmdStatus(pidFile, logFile);
67
- case "restart": return cmdRestart(pidFile, logFile);
139
+ case "start":
140
+ return cmdStart(pidFile, logFile, args, config);
141
+ case "stop":
142
+ return cmdStop(pidFile);
143
+ case "status":
144
+ return cmdStatus(pidFile, logFile);
145
+ case "restart":
146
+ return cmdRestart(pidFile, logFile);
68
147
  }
69
148
  }
70
149
  // ---------------------------------------------------------------------------
71
150
  // Commands
72
151
  // ---------------------------------------------------------------------------
73
- async function cmdStart(pidFile, logFile, args) {
152
+ async function cmdStart(pidFile, logFile, args, config) {
74
153
  const existing = readPidInfo(pidFile);
75
154
  if (existing) {
76
155
  console.log(`webagent is already running (pid ${existing.pid})`);
77
156
  process.exitCode = 1;
78
157
  return;
79
158
  }
159
+ // Run the unified startup gate in this (foreground) process before
160
+ // forking. This is the operator's TTY, so first-run mint banners
161
+ // and `[check]` failures land where they can actually be seen and
162
+ // copy-pasted, instead of being entombed in the daemon log file.
163
+ // Pass WEBAGENT_STARTUP_CHECKED=1 to the supervisor child so it (and
164
+ // the server it spawns) skip re-running the gate.
165
+ await runStartupChecks(config);
80
166
  const serverJs = join(__dirname, "server.js");
81
167
  if (!existsSync(serverJs)) {
82
168
  console.error(`server not found: ${serverJs}`);
@@ -94,10 +180,18 @@ async function cmdStart(pidFile, logFile, args) {
94
180
  writeFileSync(logFile, lines.slice(-LOG_MAX_LINES).join("\n"));
95
181
  }
96
182
  }
97
- catch { /* best-effort */ }
183
+ catch {
184
+ /* best-effort */
185
+ }
98
186
  }
99
187
  const log = openSync(logFile, "a");
100
- const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], { detached: true, stdio: ["ignore", log, log], cwd: process.cwd(), windowsHide: true });
188
+ const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], {
189
+ detached: true,
190
+ stdio: ["ignore", log, log],
191
+ cwd: process.cwd(),
192
+ windowsHide: true,
193
+ env: { ...process.env, [STARTUP_CHECKED_ENV]: "1" },
194
+ });
101
195
  child.unref();
102
196
  closeSync(log);
103
197
  // Poll for PID file (supervisor writes it on startup)
@@ -128,7 +222,9 @@ async function cmdStop(pidFile) {
128
222
  try {
129
223
  unlinkSync(pidFile);
130
224
  }
131
- catch { /* ignore */ }
225
+ catch {
226
+ /* ignore */
227
+ }
132
228
  return;
133
229
  }
134
230
  // Wait for exit
@@ -143,7 +239,9 @@ async function cmdStop(pidFile) {
143
239
  try {
144
240
  unlinkSync(pidFile);
145
241
  }
146
- catch { /* ignore */ }
242
+ catch {
243
+ /* ignore */
244
+ }
147
245
  console.log("webagent stopped");
148
246
  return;
149
247
  }
@@ -177,7 +275,8 @@ async function cmdRestart(pidFile, logFile) {
177
275
  if (process.platform === "win32") {
178
276
  // No SIGHUP on Windows — fall back to stop + start (non-atomic)
179
277
  await cmdStop(pidFile);
180
- await cmdStart(pidFile, logFile, info.args);
278
+ const { config } = loadDaemonContext(info.args, process.cwd());
279
+ await cmdStart(pidFile, logFile, info.args, config);
181
280
  return;
182
281
  }
183
282
  // Unix: atomic restart via SIGHUP to supervisor
@@ -206,8 +305,12 @@ async function cmdRestart(pidFile, logFile) {
206
305
  // ---------------------------------------------------------------------------
207
306
  function runSupervisor(serverArgs) {
208
307
  const serverJs = join(__dirname, "server.js");
209
- const pidFile = join(process.cwd(), PID_FILE);
210
- writePidInfo(pidFile, { pid: process.pid, args: serverArgs, started: new Date().toISOString() });
308
+ const { pidFile } = loadDaemonContext(serverArgs, process.cwd());
309
+ writePidInfo(pidFile, {
310
+ pid: process.pid,
311
+ args: serverArgs,
312
+ started: new Date().toISOString(),
313
+ });
211
314
  let child = null;
212
315
  let stopping = false;
213
316
  let lastStart = 0;
@@ -215,19 +318,33 @@ function runSupervisor(serverArgs) {
215
318
  let timer = null;
216
319
  function spawnServer() {
217
320
  lastStart = Date.now();
218
- child = spawn(process.execPath, [serverJs, ...serverArgs], { stdio: "inherit", windowsHide: true });
321
+ child = spawn(process.execPath, [serverJs, ...serverArgs], {
322
+ stdio: "inherit",
323
+ windowsHide: true,
324
+ });
219
325
  child.on("exit", onChildExit);
220
326
  }
221
327
  function onChildExit(code, signal) {
222
328
  child = null;
223
- if (stopping)
224
- return;
225
- if (Date.now() - lastStart > STABLE_THRESHOLD_MS) {
226
- delay = RESTART_DELAY_INITIAL;
227
- }
228
- else {
229
- delay = Math.min(delay * 2, RESTART_DELAY_MAX);
329
+ const action = decideRestart(code, signal, {
330
+ stopping,
331
+ lastStart,
332
+ now: Date.now(),
333
+ currentDelay: delay,
334
+ });
335
+ if (action.kind === "stop") {
336
+ if (stopping)
337
+ return;
338
+ console.error(`[supervisor] ${action.reason} (code=${code} signal=${signal})`);
339
+ try {
340
+ unlinkSync(pidFile);
341
+ }
342
+ catch {
343
+ /* ignore */
344
+ }
345
+ process.exit(code ?? 1);
230
346
  }
347
+ delay = action.delayMs;
231
348
  console.log(`[supervisor] server exited (code=${code} signal=${signal}), restarting in ${delay}ms`);
232
349
  timer = setTimeout(spawnServer, delay);
233
350
  }
@@ -236,18 +353,27 @@ function runSupervisor(serverArgs) {
236
353
  clearTimeout(timer);
237
354
  timer = null;
238
355
  }
239
- return new Promise((resolve) => {
356
+ return new Promise((innerResolve) => {
240
357
  if (!child) {
241
- resolve();
358
+ innerResolve();
242
359
  return;
243
360
  }
244
361
  const c = child;
245
- c.once("exit", () => resolve());
362
+ // Take ownership of this exit don't let the auto-restart listener
363
+ // race with the explicit respawn (SIGHUP path) or shutdown.
364
+ c.removeListener("exit", onChildExit);
365
+ c.once("exit", () => {
366
+ innerResolve();
367
+ });
246
368
  c.kill("SIGTERM");
247
- setTimeout(() => { try {
248
- c.kill("SIGKILL");
249
- }
250
- catch { /* ignore */ } }, KILL_GRACE_MS);
369
+ setTimeout(() => {
370
+ try {
371
+ c.kill("SIGKILL");
372
+ }
373
+ catch {
374
+ /* ignore */
375
+ }
376
+ }, KILL_GRACE_MS);
251
377
  });
252
378
  }
253
379
  async function shutdown() {
@@ -258,18 +384,26 @@ function runSupervisor(serverArgs) {
258
384
  try {
259
385
  unlinkSync(pidFile);
260
386
  }
261
- catch { /* ignore */ }
387
+ catch {
388
+ /* ignore */
389
+ }
262
390
  process.exit(0);
263
391
  }
264
- process.on("SIGTERM", () => { shutdown(); });
265
- process.on("SIGINT", () => { shutdown(); });
392
+ process.on("SIGTERM", () => {
393
+ void shutdown();
394
+ });
395
+ process.on("SIGINT", () => {
396
+ void shutdown();
397
+ });
266
398
  if (process.platform !== "win32") {
267
- process.on("SIGHUP", async () => {
268
- console.log("[supervisor] SIGHUP received, restarting server");
269
- delay = RESTART_DELAY_INITIAL;
270
- await killChild();
271
- if (!stopping)
272
- spawnServer();
399
+ process.on("SIGHUP", () => {
400
+ void (async () => {
401
+ console.log("[supervisor] SIGHUP received, restarting server");
402
+ delay = RESTART_DELAY_INITIAL;
403
+ await killChild();
404
+ if (!stopping)
405
+ spawnServer();
406
+ })();
273
407
  });
274
408
  }
275
409
  console.log(`[supervisor] started (pid ${process.pid})`);