@hienlh/ppm 0.13.95 → 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.
- package/CHANGELOG.md +21 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +2 -1
- package/dist/web/assets/{audio-preview-_926SILu.js → audio-preview-C5XtLYr0.js} +1 -1
- package/dist/web/assets/chat-tab-Crh2a5WT.js +16 -0
- package/dist/web/assets/{code-editor-CgX34_CM.js → code-editor-D0n5yRzn.js} +2 -2
- package/dist/web/assets/{conflict-editor-4d7ifSFr.js → conflict-editor-C7tPFwQu.js} +1 -1
- package/dist/web/assets/{database-viewer-BRGf672-.js → database-viewer-CENJQA63.js} +1 -1
- package/dist/web/assets/{diff-viewer-C8Dx_mMP.js → diff-viewer-DYskJYPt.js} +1 -1
- package/dist/web/assets/{docx-preview-CTC4n52W.js → docx-preview-Cs1Vck_b.js} +1 -1
- package/dist/web/assets/{extension-webview-C2-MlEV1.js → extension-webview-DDNsAryv.js} +1 -1
- package/dist/web/assets/{git-log-panel-D6XL2Qfe.js → git-log-panel-Bw50iGkP.js} +1 -1
- package/dist/web/assets/{glide-data-grid-W196CMwG.js → glide-data-grid-D-kV0skS.js} +1 -1
- package/dist/web/assets/{image-preview-B4vAybDG.js → image-preview-ICmsfJXP.js} +1 -1
- package/dist/web/assets/index-D7YWNgnj.css +2 -0
- package/dist/web/assets/{index-B8jn9Try.js → index-lVDR594A.js} +3 -3
- package/dist/web/assets/keybindings-store-zLledTJ_.js +1 -0
- package/dist/web/assets/{markdown-renderer-tbhXgrmJ.js → markdown-renderer-VOyp6B1p.js} +1 -1
- package/dist/web/assets/notification-store-XwGVhPdW.js +1 -0
- package/dist/web/assets/{pdf-preview-CEE9y9ai.js → pdf-preview-DTlEFagS.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-CL03gwO3.js → port-forwarding-tab-Coe4rUGI.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Cca5RWLN.js → postgres-viewer-C9G-BZE8.js} +1 -1
- package/dist/web/assets/{settings-tab-BIhxSzkH.js → settings-tab-BTEIHM07.js} +1 -1
- package/dist/web/assets/{sql-query-editor-CMXFZyid.js → sql-query-editor-COQcgsYM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-C43nch9A.js → sqlite-viewer-D0pkAQQa.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-DR3Ny9fs.js → system-monitor-tab-VgYnDn6v.js} +1 -1
- package/dist/web/assets/{terminal-tab-BKZgoFBm.js → terminal-tab-D08UOpkI.js} +1 -1
- package/dist/web/assets/{video-preview-2xKLGBUs.js → video-preview-D5ufy0_E.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/journals/260602-proxy-request-logging-stats.md +86 -0
- package/docs/system-architecture.md +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +84 -2
- package/src/server/index.ts +136 -9
- package/src/server/routes/proxy.ts +25 -3
- package/src/server/routes/upgrade.ts +2 -1
- package/src/services/account-selector.service.ts +12 -0
- package/src/services/db.service.ts +83 -1
- package/src/services/proxy.service.ts +74 -8
- package/src/services/supervisor.ts +102 -48
- package/src/web/components/chat/chat-tab.tsx +4 -0
- package/src/web/components/chat/message-list.tsx +44 -3
- package/src/web/hooks/use-chat.ts +16 -0
- package/dist/web/assets/chat-tab-CZ4JB8bF.js +0 -16
- package/dist/web/assets/index-CKeYG-TK.css +0 -2
- package/dist/web/assets/keybindings-store-D3ajyN3W.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
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 >=
|
|
367
|
-
log("WARN", `Tunnel URL
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|