@hienlh/ppm 0.13.94 → 0.13.97

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 (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +2 -1
  4. package/dist/web/assets/{audio-preview-_926SILu.js → audio-preview-C5XtLYr0.js} +1 -1
  5. package/dist/web/assets/chat-tab-Crh2a5WT.js +16 -0
  6. package/dist/web/assets/{code-editor-CgX34_CM.js → code-editor-D0n5yRzn.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-4d7ifSFr.js → conflict-editor-C7tPFwQu.js} +1 -1
  8. package/dist/web/assets/{database-viewer-BRGf672-.js → database-viewer-CENJQA63.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-C8Dx_mMP.js → diff-viewer-DYskJYPt.js} +1 -1
  10. package/dist/web/assets/{docx-preview-CTC4n52W.js → docx-preview-Cs1Vck_b.js} +1 -1
  11. package/dist/web/assets/{extension-webview-C2-MlEV1.js → extension-webview-DDNsAryv.js} +1 -1
  12. package/dist/web/assets/{git-log-panel-D6XL2Qfe.js → git-log-panel-Bw50iGkP.js} +1 -1
  13. package/dist/web/assets/{glide-data-grid-W196CMwG.js → glide-data-grid-D-kV0skS.js} +1 -1
  14. package/dist/web/assets/{image-preview-B4vAybDG.js → image-preview-ICmsfJXP.js} +1 -1
  15. package/dist/web/assets/index-D7YWNgnj.css +2 -0
  16. package/dist/web/assets/{index-B8jn9Try.js → index-lVDR594A.js} +3 -3
  17. package/dist/web/assets/keybindings-store-zLledTJ_.js +1 -0
  18. package/dist/web/assets/{markdown-renderer-tbhXgrmJ.js → markdown-renderer-VOyp6B1p.js} +1 -1
  19. package/dist/web/assets/notification-store-XwGVhPdW.js +1 -0
  20. package/dist/web/assets/{pdf-preview-CEE9y9ai.js → pdf-preview-DTlEFagS.js} +1 -1
  21. package/dist/web/assets/{port-forwarding-tab-CL03gwO3.js → port-forwarding-tab-Coe4rUGI.js} +1 -1
  22. package/dist/web/assets/{postgres-viewer-Cca5RWLN.js → postgres-viewer-C9G-BZE8.js} +1 -1
  23. package/dist/web/assets/{settings-tab-BIhxSzkH.js → settings-tab-BTEIHM07.js} +1 -1
  24. package/dist/web/assets/{sql-query-editor-CMXFZyid.js → sql-query-editor-COQcgsYM.js} +1 -1
  25. package/dist/web/assets/{sqlite-viewer-C43nch9A.js → sqlite-viewer-D0pkAQQa.js} +1 -1
  26. package/dist/web/assets/{system-monitor-tab-DR3Ny9fs.js → system-monitor-tab-VgYnDn6v.js} +1 -1
  27. package/dist/web/assets/{terminal-tab-BKZgoFBm.js → terminal-tab-D08UOpkI.js} +1 -1
  28. package/dist/web/assets/{video-preview-2xKLGBUs.js → video-preview-D5ufy0_E.js} +1 -1
  29. package/dist/web/index.html +2 -2
  30. package/dist/web/sw.js +1 -1
  31. package/docs/journals/260602-proxy-request-logging-stats.md +86 -0
  32. package/docs/system-architecture.md +1 -1
  33. package/package.json +1 -1
  34. package/src/providers/claude-agent-sdk.ts +94 -3
  35. package/src/server/index.ts +136 -9
  36. package/src/server/routes/proxy.ts +25 -3
  37. package/src/server/routes/upgrade.ts +2 -1
  38. package/src/services/account-selector.service.ts +12 -0
  39. package/src/services/db.service.ts +83 -1
  40. package/src/services/proxy.service.ts +74 -8
  41. package/src/services/supervisor.ts +102 -48
  42. package/src/web/components/chat/chat-tab.tsx +4 -0
  43. package/src/web/components/chat/message-list.tsx +44 -3
  44. package/src/web/hooks/use-chat.ts +16 -0
  45. package/test-tool.mjs +5 -0
  46. package/dist/web/assets/chat-tab-CZ4JB8bF.js +0 -16
  47. package/dist/web/assets/index-CKeYG-TK.css +0 -2
  48. package/dist/web/assets/keybindings-store-D3ajyN3W.js +0 -1
  49. package/dist/web/assets/notification-store-CuF7CL5K.js +0 -1
@@ -1,4 +1,4 @@
1
- import { getConfigValue, setConfigValue } from "./db.service.ts";
1
+ import { getConfigValue, setConfigValue, insertProxyRequest, type ProxyRequestStatus } from "./db.service.ts";
2
2
  import { accountSelector } from "./account-selector.service.ts";
3
3
  import { accountService } from "./account.service.ts";
4
4
  import { forwardViaSdk } from "./proxy-sdk-bridge.ts";
@@ -10,6 +10,20 @@ const PROXY_AUTH_KEY = "proxy_auth_key";
10
10
 
11
11
  const ANTHROPIC_API_BASE = "https://api.anthropic.com";
12
12
 
13
+ export interface ProxyCallerMeta {
14
+ callerIp?: string;
15
+ callerUa?: string;
16
+ }
17
+
18
+ function parseModel(body: string | null): string | undefined {
19
+ if (!body) return undefined;
20
+ try {
21
+ return JSON.parse(body).model as string | undefined;
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ }
26
+
13
27
  class ProxyService {
14
28
  private requestCount = 0;
15
29
 
@@ -51,10 +65,15 @@ class ProxyService {
51
65
  method: string,
52
66
  headers: Record<string, string>,
53
67
  body: string | null,
68
+ caller?: ProxyCallerMeta,
54
69
  ): Promise<Response> {
55
70
  // Pick account via rotation
56
71
  const account = accountSelector.next();
57
72
  if (!account) {
73
+ insertProxyRequest({
74
+ endpoint: path, model: parseModel(body),
75
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status: "error",
76
+ });
58
77
  return new Response(
59
78
  JSON.stringify({ type: "error", error: { type: "authentication_error", message: "No active accounts available" } }),
60
79
  { status: 401, headers: { "Content-Type": "application/json" } },
@@ -70,11 +89,24 @@ class ProxyService {
70
89
 
71
90
  // OAuth tokens: route through SDK query() — direct API doesn't work for Claude Max/Pro
72
91
  if (token.startsWith("sk-ant-oat") && body && path === "/v1/messages") {
92
+ const start = performance.now();
73
93
  try {
74
94
  const parsed = JSON.parse(body);
75
95
  this.requestCount++;
76
- return await forwardViaSdk(parsed, { id: account.id, email: account.email, accessToken: token });
96
+ const response = await forwardViaSdk(parsed, { id: account.id, email: account.email, accessToken: token });
97
+ const durationMs = Math.round(performance.now() - start);
98
+ insertProxyRequest({
99
+ endpoint: path, model: parsed.model, accountId: account.id, accountLabel: account.email ?? account.id,
100
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status: "success", durationMs,
101
+ });
102
+ console.log(`[proxy] ${method} ${path} → ${account.email ?? account.id} (sdk) ${durationMs}ms caller=${caller?.callerIp ?? "unknown"}`);
103
+ return response;
77
104
  } catch (e) {
105
+ insertProxyRequest({
106
+ endpoint: path, model: parseModel(body), accountId: account.id, accountLabel: account.email ?? account.id,
107
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status: "error",
108
+ durationMs: Math.round(performance.now() - start),
109
+ });
78
110
  console.error(`[proxy] SDK bridge error:`, (e as Error).message);
79
111
  return new Response(
80
112
  JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
@@ -84,16 +116,20 @@ class ProxyService {
84
116
  }
85
117
 
86
118
  // API key accounts: direct HTTP forward to Anthropic API
87
- return this.forwardDirect(path, method, headers, body, token, account);
119
+ return this.forwardDirect(path, method, headers, body, token, account, caller);
88
120
  }
89
121
 
90
122
  /**
91
123
  * Forward an OpenAI-format chat completions request via SDK query().
92
124
  * Always uses SDK bridge (works for both OAuth and API key accounts).
93
125
  */
94
- async forwardOpenAi(body: string): Promise<Response> {
126
+ async forwardOpenAi(body: string, caller?: ProxyCallerMeta): Promise<Response> {
95
127
  const account = accountSelector.next();
96
128
  if (!account) {
129
+ insertProxyRequest({
130
+ endpoint: "/v1/chat/completions", model: parseModel(body),
131
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status: "error",
132
+ });
97
133
  return new Response(
98
134
  JSON.stringify({ error: { message: "No active accounts available", type: "server_error" } }),
99
135
  { status: 401, headers: { "Content-Type": "application/json" } },
@@ -106,11 +142,24 @@ class ProxyService {
106
142
  if (fresh) token = fresh.accessToken;
107
143
  }
108
144
 
145
+ const start = performance.now();
109
146
  try {
110
147
  const parsed = JSON.parse(body);
111
148
  this.requestCount++;
112
- return await forwardOpenAiViaSdk(parsed, { id: account.id, email: account.email, accessToken: token });
149
+ const response = await forwardOpenAiViaSdk(parsed, { id: account.id, email: account.email, accessToken: token });
150
+ const durationMs = Math.round(performance.now() - start);
151
+ insertProxyRequest({
152
+ endpoint: "/v1/chat/completions", model: parsed.model, accountId: account.id, accountLabel: account.email ?? account.id,
153
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status: "success", durationMs,
154
+ });
155
+ console.log(`[proxy] POST /v1/chat/completions → ${account.email ?? account.id} (openai) ${durationMs}ms caller=${caller?.callerIp ?? "unknown"}`);
156
+ return response;
113
157
  } catch (e) {
158
+ insertProxyRequest({
159
+ endpoint: "/v1/chat/completions", model: parseModel(body), accountId: account.id, accountLabel: account.email ?? account.id,
160
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status: "error",
161
+ durationMs: Math.round(performance.now() - start),
162
+ });
114
163
  console.error(`[proxy] OpenAI bridge error:`, (e as Error).message);
115
164
  return new Response(
116
165
  JSON.stringify({ error: { message: (e as Error).message, type: "server_error" } }),
@@ -127,6 +176,7 @@ class ProxyService {
127
176
  body: string | null,
128
177
  token: string,
129
178
  account: { id: string; email: string | null },
179
+ caller?: ProxyCallerMeta,
130
180
  ): Promise<Response> {
131
181
  const upstreamHeaders: Record<string, string> = {
132
182
  "Content-Type": "application/json",
@@ -137,7 +187,8 @@ class ProxyService {
137
187
  if (headers["anthropic-beta"]) upstreamHeaders["anthropic-beta"] = headers["anthropic-beta"];
138
188
 
139
189
  const url = `${ANTHROPIC_API_BASE}${path}`;
140
- console.log(`[proxy] ${method} ${path} account ${account.email ?? account.id} (direct)`);
190
+ const accountLabel = account.email ?? account.id;
191
+ const start = performance.now();
141
192
 
142
193
  try {
143
194
  const upstream = await fetch(url, {
@@ -148,17 +199,27 @@ class ProxyService {
148
199
  });
149
200
 
150
201
  this.requestCount++;
202
+ const durationMs = Math.round(performance.now() - start);
151
203
 
204
+ let status: ProxyRequestStatus = "error";
152
205
  if (upstream.status === 429) {
153
206
  accountSelector.onRateLimit(account.id);
154
- console.log(`[proxy] 429 — account ${account.email ?? account.id} rate limited`);
207
+ status = "rate_limited";
208
+ console.log(`[proxy] 429 — account ${accountLabel} rate limited`);
155
209
  } else if (upstream.status === 401) {
156
210
  accountSelector.onAuthError(account.id);
157
- console.log(`[proxy] 401 — account ${account.email ?? account.id} auth error`);
211
+ console.log(`[proxy] 401 — account ${accountLabel} auth error`);
158
212
  } else if (upstream.status >= 200 && upstream.status < 300) {
159
213
  accountSelector.onSuccess(account.id);
214
+ status = "success";
160
215
  }
161
216
 
217
+ insertProxyRequest({
218
+ endpoint: path, model: parseModel(body), accountId: account.id, accountLabel,
219
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status, durationMs,
220
+ });
221
+ console.log(`[proxy] ${method} ${path} → ${accountLabel} (direct) ${durationMs}ms caller=${caller?.callerIp ?? "unknown"}`);
222
+
162
223
  const responseHeaders = new Headers();
163
224
  for (const key of ["content-type", "x-request-id", "request-id"]) {
164
225
  const val = upstream.headers.get(key);
@@ -168,6 +229,11 @@ class ProxyService {
168
229
 
169
230
  return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
170
231
  } catch (e) {
232
+ insertProxyRequest({
233
+ endpoint: path, model: parseModel(body), accountId: account.id, accountLabel,
234
+ callerIp: caller?.callerIp, callerUa: caller?.callerUa, status: "error",
235
+ durationMs: Math.round(performance.now() - start),
236
+ });
171
237
  console.error(`[proxy] Error forwarding:`, (e as Error).message);
172
238
  return new Response(
173
239
  JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
@@ -25,12 +25,11 @@ import { sdNotify } from "./sd-notify.ts";
25
25
  const MAX_RESTARTS = 10;
26
26
  const BACKOFF_BASE_MS = 1000;
27
27
  const BACKOFF_MAX_MS = 60_000;
28
- const TUNNEL_COOLDOWN_MS = 600_000; // 10min cooldown after MAX_RESTARTS before retrying tunnel
29
28
  const STABLE_WINDOW_MS = 300_000; // 5min stable → reset restart counter
30
29
  const SERVER_HEALTH_INTERVAL_MS = 30_000;
31
30
  const SERVER_HEALTH_FAIL_THRESHOLD = 3;
32
31
  const TUNNEL_PROBE_INTERVAL_MS = 30_000; // 30s — adopted tunnels have no `exited` promise
33
- const TUNNEL_PROBE_FAIL_THRESHOLD = 3; // 3 HTTP failures before regenerating (PID check is instant)
32
+ const TUNNEL_ZOMBIE_THRESHOLD = 10; // ~5min @ 30s probe only regenerate a truly-zombied URL (process alive, edge dropped). cloudflared self-heals transient QUIC drops, so don't kill it early.
34
33
  const TUNNEL_URL_REGEX = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/;
35
34
  const UPGRADE_CHECK_INTERVAL_MS = 900_000; // 15min
36
35
  const UPGRADE_SKIP_INITIAL_MS = 300_000; // 5min delay before first check
@@ -38,6 +37,7 @@ const SELF_REPLACE_TIMEOUT_MS = 30_000; // 30s to wait for new supervisor
38
37
 
39
38
  const logFile = () => resolve(getPpmDir(), "ppm.log");
40
39
  const restartingFlag = () => resolve(getPpmDir(), ".restarting");
40
+ const serverShutdownFile = () => resolve(getPpmDir(), ".server-shutdown");
41
41
 
42
42
  // ─── State ─────────────────────────────────────────────────────────────
43
43
  let serverChild: Subprocess | null = null;
@@ -86,6 +86,38 @@ function backoffDelay(restartCount: number): number {
86
86
  return Math.min(BACKOFF_BASE_MS * 2 ** (restartCount - 1), BACKOFF_MAX_MS);
87
87
  }
88
88
 
89
+ // ─── Graceful server shutdown (Windows-safe) ──────────────────────────
90
+ // On Windows, SIGTERM maps to TerminateProcess — graceful handlers never fire.
91
+ // Instead, write a shutdown file that the server child polls for.
92
+ function requestServerShutdown(child: Subprocess, timeoutMs: number = 2000): Promise<void> {
93
+ return new Promise<void>((resolve) => {
94
+ const pid = child.pid;
95
+ if (process.platform === "win32") {
96
+ try { writeFileSync(serverShutdownFile(), String(Date.now())); } catch {}
97
+ const deadline = Date.now() + timeoutMs;
98
+ const poll = setInterval(() => {
99
+ try { process.kill(pid, 0); } catch {
100
+ clearInterval(poll);
101
+ resolve();
102
+ return;
103
+ }
104
+ if (Date.now() > deadline) {
105
+ clearInterval(poll);
106
+ try { process.kill(pid, "SIGKILL"); } catch {}
107
+ resolve();
108
+ }
109
+ }, 100);
110
+ } else {
111
+ try { child.kill("SIGTERM"); } catch {}
112
+ setTimeout(() => {
113
+ try { process.kill(pid, "SIGKILL"); } catch {}
114
+ resolve();
115
+ }, timeoutMs).unref();
116
+ resolve();
117
+ }
118
+ });
119
+ }
120
+
89
121
  // ─── Server management ─────────────────────────────────────────────────
90
122
  export async function spawnServer(
91
123
  serverArgs: string[],
@@ -262,16 +294,9 @@ export async function spawnTunnel(port: number): Promise<void> {
262
294
  lastTunnelCrash = now;
263
295
  tunnelRestarts++;
264
296
 
265
- if (tunnelRestarts > MAX_RESTARTS) {
266
- log("WARN", `Tunnel exceeded ${MAX_RESTARTS} URL extraction failures, cooldown ${TUNNEL_COOLDOWN_MS}ms before retry`);
267
- updateStatus({ shareUrl: null, tunnelPid: null });
268
- await Bun.sleep(TUNNEL_COOLDOWN_MS);
269
- tunnelRestarts = 0;
270
- if (shuttingDown) return;
271
- return spawnTunnel(port);
272
- }
273
-
274
- const delay = backoffDelay(tunnelRestarts);
297
+ // Never give up: cap the counter so backoff plateaus at BACKOFF_MAX_MS (no 10-min dark window).
298
+ if (tunnelRestarts > MAX_RESTARTS) tunnelRestarts = MAX_RESTARTS;
299
+ const delay = backoffDelay(tunnelRestarts) + Math.floor(Math.random() * 1000);
275
300
  log("WARN", `Tunnel failed, retry in ${delay}ms (#${tunnelRestarts})`);
276
301
  await Bun.sleep(delay);
277
302
  return spawnTunnel(port);
@@ -296,16 +321,9 @@ export async function spawnTunnel(port: number): Promise<void> {
296
321
  lastTunnelCrash = now;
297
322
  tunnelRestarts++;
298
323
 
299
- if (tunnelRestarts > MAX_RESTARTS) {
300
- log("WARN", `Tunnel exceeded ${MAX_RESTARTS} restarts, cooldown ${TUNNEL_COOLDOWN_MS}ms before retry`);
301
- updateStatus({ shareUrl: null, tunnelPid: null });
302
- await Bun.sleep(TUNNEL_COOLDOWN_MS);
303
- tunnelRestarts = 0;
304
- if (!shuttingDown) return spawnTunnel(port);
305
- return;
306
- }
307
-
308
- const delay = backoffDelay(tunnelRestarts);
324
+ // Never give up: cap the counter so backoff plateaus at BACKOFF_MAX_MS (no 10-min dark window).
325
+ if (tunnelRestarts > MAX_RESTARTS) tunnelRestarts = MAX_RESTARTS;
326
+ const delay = backoffDelay(tunnelRestarts) + Math.floor(Math.random() * 1000);
309
327
  log("WARN", `Tunnel process exited (code=${exitCode}, signal=${tunnelChild === null ? "killed" : "self"}, url=${deadUrl}), restart in ${delay}ms (#${tunnelRestarts})`);
310
328
  await Bun.sleep(delay);
311
329
 
@@ -316,8 +334,14 @@ export async function spawnTunnel(port: number): Promise<void> {
316
334
  function startServerHealthCheck(port: number) {
317
335
  healthTimer = setInterval(async () => {
318
336
  if (shuttingDown || !serverChild || getState() === "stopped") return;
337
+ // Read actual port from status.json in case server auto-selected a different port
338
+ let checkPort = port;
319
339
  try {
320
- const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
340
+ const status = readStatus();
341
+ if (status.port && typeof status.port === "number") checkPort = status.port;
342
+ } catch {}
343
+ try {
344
+ const res = await fetch(`http://127.0.0.1:${checkPort}/api/health`, {
321
345
  signal: AbortSignal.timeout(5000),
322
346
  });
323
347
  if (res.ok) { healthFailCount = 0; return; }
@@ -325,7 +349,12 @@ function startServerHealthCheck(port: number) {
325
349
  healthFailCount++;
326
350
  if (healthFailCount >= SERVER_HEALTH_FAIL_THRESHOLD && serverChild) {
327
351
  log("WARN", `Server unresponsive (${healthFailCount} failures), killing`);
328
- try { serverChild.kill(); } catch {}
352
+ const pid = serverChild.pid;
353
+ if (process.platform === "win32") {
354
+ try { writeFileSync(serverShutdownFile(), String(Date.now())); } catch {}
355
+ }
356
+ try { serverChild.kill("SIGTERM"); } catch {}
357
+ setTimeout(() => { try { process.kill(pid, "SIGKILL"); } catch {} }, 1000).unref();
329
358
  healthFailCount = 0;
330
359
  // spawnServer loop handles respawn via exited promise
331
360
  }
@@ -363,8 +392,8 @@ function startTunnelProbe(port: number) {
363
392
  }
364
393
  } catch {}
365
394
  tunnelFailCount++;
366
- if (tunnelFailCount >= TUNNEL_PROBE_FAIL_THRESHOLD) {
367
- log("WARN", `Tunnel URL dead (${tunnelFailCount} failures), regenerating`);
395
+ if (tunnelFailCount >= TUNNEL_ZOMBIE_THRESHOLD) {
396
+ log("WARN", `Tunnel URL zombie (${tunnelFailCount} fails ≈ ${tunnelFailCount * (TUNNEL_PROBE_INTERVAL_MS / 1000)}s, process alive but edge dropped), regenerating`);
368
397
  if (tunnelChild) {
369
398
  try { tunnelChild.kill(); } catch {}
370
399
  // spawnTunnel loop handles respawn via exited promise
@@ -461,12 +490,14 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
461
490
  }
462
491
 
463
492
  // Kill server child to free the port; keep tunnel alive for domain continuity
464
- // Use SIGKILL + process group kill to ensure grandchildren (SDK subprocesses) die too
465
493
  log("INFO", "Stopping server before upgrade (tunnel kept alive)");
466
494
  if (serverChild) {
467
495
  const pid = serverChild.pid;
468
- try { process.kill(-pid, "SIGKILL"); } catch {} // kill process group
469
- try { serverChild.kill("SIGKILL"); } catch {} // fallback: kill direct child
496
+ await requestServerShutdown(serverChild, 2000);
497
+ // Process group kill on Unix (catches grandchildren like Claude SDK subprocesses)
498
+ if (process.platform !== "win32") {
499
+ try { process.kill(-pid, "SIGKILL"); } catch {}
500
+ }
470
501
  serverChild = null;
471
502
  }
472
503
  if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
@@ -489,8 +520,11 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
489
520
 
490
521
  // ── Non-systemd path: spawn new supervisor directly (macOS/Windows) ─
491
522
  // Poll until port is actually free (max 10s) — never guess with fixed sleep
523
+ // On Windows, reusePort lets the new server bind over zombie sockets,
524
+ // so we only need a brief wait for the killed process to release.
492
525
  const portFreeStart = Date.now();
493
- while (Date.now() - portFreeStart < 10_000) {
526
+ const portTimeout = process.platform === "win32" ? 3_000 : 10_000;
527
+ while (Date.now() - portFreeStart < portTimeout) {
494
528
  const inUse = await new Promise<boolean>((resolve) => {
495
529
  const net = require("node:net") as typeof import("node:net");
496
530
  const tester = net.createServer()
@@ -505,13 +539,31 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
505
539
 
506
540
  // Spawn new supervisor using saved argv
507
541
  const cmd = originalArgv.slice();
508
- const logFd = openSync(logFile(), "a");
509
- const child = Bun.spawn({
510
- cmd,
511
- stdio: ["ignore", logFd, logFd],
512
- env: process.env,
513
- });
514
- child.unref();
542
+ const newLogFd = openSync(logFile(), "a");
543
+ let killNewChild = () => {};
544
+
545
+ if (process.platform === "win32") {
546
+ // On Windows, Bun.spawn children die when parent exits (same job object).
547
+ // Use node:child_process with detached:true to create a truly independent process.
548
+ const { spawn: nodeSpawn } = require("node:child_process") as typeof import("node:child_process");
549
+ const proc = nodeSpawn(cmd[0]!, cmd.slice(1), {
550
+ detached: true,
551
+ stdio: ["ignore", newLogFd, newLogFd] as any,
552
+ env: process.env as NodeJS.ProcessEnv,
553
+ windowsHide: true,
554
+ });
555
+ killNewChild = () => { try { if (proc.pid) process.kill(proc.pid); } catch {} };
556
+ proc.unref();
557
+ try { closeSync(newLogFd); } catch {} // child inherited fd, parent can close
558
+ } else {
559
+ const proc = Bun.spawn({
560
+ cmd,
561
+ stdio: ["ignore", newLogFd, newLogFd],
562
+ env: process.env,
563
+ });
564
+ killNewChild = () => { try { proc.kill(); } catch {} };
565
+ proc.unref();
566
+ }
515
567
 
516
568
  // Poll status.json for new supervisor PID (up to 30s)
517
569
  const start = Date.now();
@@ -534,7 +586,7 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
534
586
 
535
587
  // Timeout — new supervisor didn't start, restore old supervisor
536
588
  log("ERROR", "Self-replace timeout: new supervisor did not start");
537
- try { child.kill(); } catch {}
589
+ killNewChild();
538
590
  try { unlinkSync(restartingFlag()); } catch {}
539
591
  shuttingDown = false;
540
592
  notifyStateChange("upgrading", "running", "upgrade_failed");
@@ -758,9 +810,8 @@ export async function softStop() {
758
810
  notifyStateChange(getState(), "stopped", "user_stop");
759
811
  setState("stopped");
760
812
 
761
- // Kill server child
762
813
  if (serverChild) {
763
- try { serverChild.kill(); } catch {}
814
+ await requestServerShutdown(serverChild, 1000);
764
815
  serverChild = null;
765
816
  }
766
817
 
@@ -769,9 +820,6 @@ export async function softStop() {
769
820
 
770
821
  // Keep: tunnel, Cloud WS, upgrade checks, tunnel probe
771
822
  updateStatus({ state: "stopped", pid: null, stoppedAt: new Date().toISOString() });
772
-
773
- // Start stopped page on the server port so tunnel URL still works
774
- await Bun.sleep(500); // brief wait for port release
775
823
  startStoppedPage(_opts.port, _opts.host);
776
824
 
777
825
  // Wait for resume signal
@@ -810,11 +858,14 @@ export function shutdown() {
810
858
  if (upgradeDelayTimer) clearTimeout(upgradeDelayTimer);
811
859
  if (cloudMonitorTimer) clearInterval(cloudMonitorTimer);
812
860
 
813
- // Use SIGKILL for children — SIGTERM leaves grandchildren (Claude SDK, etc.)
814
- // alive, causing systemd to wait 90s then SIGKILL the entire cgroup
815
861
  if (serverChild) {
816
862
  log("INFO", `Killing server child (PID: ${serverChild.pid})`);
817
- try { serverChild.kill("SIGKILL"); } catch {}
863
+ if (process.platform === "win32") {
864
+ try { writeFileSync(serverShutdownFile(), String(Date.now())); } catch {}
865
+ }
866
+ const pid = serverChild.pid;
867
+ try { serverChild.kill("SIGTERM"); } catch {}
868
+ setTimeout(() => { try { process.kill(pid, "SIGKILL"); } catch {} }, 2000).unref();
818
869
  }
819
870
  if (tunnelChild) {
820
871
  log("INFO", `Killing tunnel child (PID: ${tunnelChild.pid})`);
@@ -836,8 +887,9 @@ export async function runSupervisor(opts: {
836
887
  const ppmDir = getPpmDir();
837
888
  if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
838
889
 
839
- // Clean up restarting flag from previous upgrade/restart
890
+ // Clean up flags from previous upgrade/restart
840
891
  try { unlinkSync(restartingFlag()); } catch {}
892
+ try { unlinkSync(serverShutdownFile()); } catch {}
841
893
 
842
894
  // Save original argv for self-replace
843
895
  originalArgv = [...process.argv];
@@ -1055,7 +1107,9 @@ if (process.argv.includes("__supervise__")) {
1055
1107
  const host = process.argv[idx + 2] ?? "0.0.0.0";
1056
1108
  const profileRaw = process.argv[idx + 3];
1057
1109
  const profile = profileRaw && profileRaw !== "_" && !profileRaw.startsWith("--") ? profileRaw : undefined;
1058
- const share = process.argv.includes("--share");
1110
+ // Tunnel always enabled — cloudflared shares the server publicly. `--share` is a deprecated no-op
1111
+ // (see src/server/index.ts:227, src/index.ts:27). Supervisor must not gate on it.
1112
+ const share = true;
1059
1113
 
1060
1114
  // Set DB profile for supervisor (needed to read config)
1061
1115
  if (profile) {
@@ -96,6 +96,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
96
96
  renderedMessages,
97
97
  expandCompact,
98
98
  isCompactExpanded,
99
+ dismissMessage,
100
+ clearErrors,
99
101
  messagesLoading,
100
102
  isStreaming,
101
103
  phase,
@@ -441,6 +443,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
441
443
  projectName={projectName}
442
444
  onFork={!isStreaming ? handleFork : undefined}
443
445
  onSelectSession={handleSelectSession}
446
+ onDismissMessage={dismissMessage}
447
+ onClearErrors={clearErrors}
444
448
  bashPartialOutput={bashPartialOutput}
445
449
  />
446
450
 
@@ -55,6 +55,10 @@ interface MessageListProps {
55
55
  onFork?: (userMessage: string, messageId?: string) => void;
56
56
  /** Called when user selects a recent session from the welcome screen */
57
57
  onSelectSession?: (session: import("../../../types/chat").SessionInfo) => void;
58
+ /** Dismiss a single message (removes from local view only — not persisted history) */
59
+ onDismissMessage?: (messageId: string) => void;
60
+ /** Remove all system/error bubbles from the local view */
61
+ onClearErrors?: () => void;
58
62
  /** Partial bash output ref from useChat for real-time streaming */
59
63
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
60
64
  /** Fetches pre-compact transcript and prepends messages. Returns loaded count. */
@@ -79,6 +83,8 @@ export function MessageList({
79
83
  bashPartialOutput,
80
84
  onExpandCompact,
81
85
  isCompactExpanded,
86
+ onDismissMessage,
87
+ onClearErrors,
82
88
  }: MessageListProps) {
83
89
  // Scroll handled by StickToBottom wrapper — no manual scroll logic needed
84
90
 
@@ -110,6 +116,16 @@ export function MessageList({
110
116
  onFork?.(msgContent, msgId);
111
117
  }, [onFork]);
112
118
 
119
+ // Stable dismiss handler — avoids new closure per message (preserves MessageBubble memo)
120
+ const handleDismiss = useCallback((msgId: string) => {
121
+ onDismissMessage?.(msgId);
122
+ }, [onDismissMessage]);
123
+
124
+ const errorCount = useMemo(
125
+ () => filtered.reduce((n, m) => (m.role === "system" ? n + 1 : n), 0),
126
+ [filtered],
127
+ );
128
+
113
129
  // Scroll anchor bridge published from inside StickToBottom (needs the context's scrollRef).
114
130
  const scrollAnchorRef = useRef<ScrollAnchorHandle | null>(null);
115
131
 
@@ -182,6 +198,18 @@ export function MessageList({
182
198
  <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict] [overflow-anchor:auto]" resize="smooth" initial="instant">
183
199
  <StickToBottom.Content className="p-4 space-y-4 select-none [&>*]:[overflow-anchor:auto]">
184
200
  <ScrollAnchorBridge bridgeRef={scrollAnchorRef} />
201
+ {errorCount > 1 && onClearErrors && (
202
+ <div className="sticky top-0 z-10 flex justify-center">
203
+ <button
204
+ type="button"
205
+ onClick={onClearErrors}
206
+ className="flex items-center gap-1.5 rounded-full bg-red-500/15 border border-red-500/25 px-3 py-1 text-xs text-red-400 hover:bg-red-500/25"
207
+ >
208
+ <XCircle className="size-3.5" />
209
+ Clear all errors ({errorCount})
210
+ </button>
211
+ </div>
212
+ )}
185
213
  {hasMore && (
186
214
  <LoadMoreSentinel onLoadMore={loadMore} loading={autoLoadingCompact} />
187
215
  )}
@@ -195,6 +223,7 @@ export function MessageList({
195
223
  isStreaming={isStreaming && msg.id.startsWith("streaming-")}
196
224
  projectName={projectName}
197
225
  onFork={msg.role === "user" && onFork ? handleFork : undefined}
226
+ onDismiss={msg.role === "system" && onDismissMessage ? handleDismiss : undefined}
198
227
  prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
199
228
  bashPartialOutput={bashPartialOutput}
200
229
  />
@@ -385,9 +414,10 @@ function LoadMoreSentinel({ onLoadMore, loading }: { onLoadMore: () => void; loa
385
414
  );
386
415
  }
387
416
 
388
- const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
417
+ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, onDismiss, prevMsgId, bashPartialOutput }: {
389
418
  message: ChatMessage; isStreaming: boolean; projectName?: string;
390
419
  onFork?: (content: string, messageId: string | undefined) => void;
420
+ onDismiss?: (messageId: string) => void;
391
421
  prevMsgId?: string;
392
422
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
393
423
  }) {
@@ -405,9 +435,20 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
405
435
 
406
436
  if (message.role === "system") {
407
437
  return (
408
- <div className="flex items-center gap-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
438
+ <div className="group flex items-center gap-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
409
439
  <AlertCircle className="size-4 shrink-0" />
410
- <p>{message.content}</p>
440
+ <p className="flex-1">{message.content}</p>
441
+ {onDismiss && (
442
+ <button
443
+ type="button"
444
+ onClick={() => onDismiss(message.id)}
445
+ aria-label="Dismiss"
446
+ title="Dismiss"
447
+ className="shrink-0 rounded p-1 text-red-400/70 hover:text-red-400 hover:bg-red-500/15 md:opacity-0 md:group-hover:opacity-100"
448
+ >
449
+ <XCircle className="size-4" />
450
+ </button>
451
+ )}
411
452
  </div>
412
453
  );
413
454
  }
@@ -48,6 +48,10 @@ interface UseChatReturn {
48
48
  expandCompact: (compactMessageId: string, jsonlPath: string) => Promise<number>;
49
49
  /** Whether a given compactMessageId has been expanded. */
50
50
  isCompactExpanded: (compactMessageId: string) => boolean;
51
+ /** Remove a single message from the local view (not persisted history). */
52
+ dismissMessage: (id: string) => void;
53
+ /** Remove all system/error bubbles from the local view. */
54
+ clearErrors: () => void;
51
55
  messagesLoading: boolean;
52
56
  isStreaming: boolean;
53
57
  phase: SessionPhase;
@@ -882,6 +886,16 @@ export function useChat(sessionId: string | null, providerId = "claude", project
882
886
 
883
887
  const isCompactExpanded = useCallback((id: string) => expansions.has(id), [expansions]);
884
888
 
889
+ /** Remove a single message from the local view (e.g. dismiss an error bubble). */
890
+ const dismissMessage = useCallback((id: string) => {
891
+ setMessages((prev) => prev.filter((m) => m.id !== id));
892
+ }, []);
893
+
894
+ /** Remove all system/error bubbles from the local view. */
895
+ const clearErrors = useCallback(() => {
896
+ setMessages((prev) => prev.filter((m) => m.role !== "system"));
897
+ }, []);
898
+
885
899
  /** Flattened view: expansions prepended before their compact cards. */
886
900
  const renderedMessages = useMemo(
887
901
  () => flattenWithExpansions(messages, expansions),
@@ -893,6 +907,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
893
907
  renderedMessages,
894
908
  expandCompact,
895
909
  isCompactExpanded,
910
+ dismissMessage,
911
+ clearErrors,
896
912
  messagesLoading,
897
913
  isStreaming,
898
914
  phase,
package/test-tool.mjs CHANGED
@@ -1,5 +1,10 @@
1
1
  import { tmpdir } from "node:os";
2
2
  import { ClaudeAgentSdkProvider } from "./src/providers/claude-agent-sdk.ts";
3
+ import { configService } from "./src/services/config.service.ts";
4
+
5
+ // Load real config from SQLite (provider reads model/context_1m from here).
6
+ // Without this, configService falls back to DEFAULT_CONFIG (sonnet-4-6, no 1M).
7
+ configService.load();
3
8
 
4
9
  // Remove CLAUDECODE to avoid nested session error
5
10
  delete process.env.CLAUDECODE;