@hienlh/ppm 0.8.94 → 0.8.95
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 +48 -0
- package/dist/web/assets/api-settings-Bid0NHuI.js +1 -0
- package/dist/web/assets/{browser-tab-DJLH0eDY.js → browser-tab-DNiBGn5p.js} +1 -1
- package/dist/web/assets/chat-tab-w7jsIjfo.js +8 -0
- package/dist/web/assets/{code-editor-CaGdx-lS.js → code-editor-COwuo1MZ.js} +1 -1
- package/dist/web/assets/{database-viewer-i4Ddk6mO.js → database-viewer-CL3kXoYN.js} +1 -1
- package/dist/web/assets/{diff-viewer-DQDS7yjv.js → diff-viewer-BCuKcGH5.js} +1 -1
- package/dist/web/assets/{git-graph-DUs-TN1u.js → git-graph-CmQb8T0E.js} +1 -1
- package/dist/web/assets/{index-Dm6RN1A1.js → index-BlDA3VoN.js} +11 -11
- package/dist/web/assets/index-CqhIj4Ko.css +2 -0
- package/dist/web/assets/keybindings-store-D44LPqNY.js +1 -0
- package/dist/web/assets/{markdown-renderer-L1NgC2Rw.js → markdown-renderer-PdMYiBSA.js} +1 -1
- package/dist/web/assets/{postgres-viewer-_uDispGW.js → postgres-viewer-9yUy5BZB.js} +1 -1
- package/dist/web/assets/{settings-tab-Bp4041i6.js → settings-tab-DSF87yix.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-GW-QCjHn.js → sqlite-viewer-BaloRTBe.js} +1 -1
- package/dist/web/assets/{terminal-tab-E4cWujj4.js → terminal-tab-CM6G6XMO.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-zABXAAla.js → use-monaco-theme-C8rXfYU9.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/docs/streaming-input-guide.md +267 -0
- package/package.json +1 -1
- package/snapshot-state.md +1526 -0
- package/src/providers/claude-agent-sdk.ts +78 -2
- package/src/providers/cli-provider-base.ts +6 -0
- package/src/server/routes/chat.ts +31 -10
- package/src/server/routes/settings.ts +27 -0
- package/src/server/ws/chat.ts +7 -1
- package/src/services/account.service.ts +2 -2
- package/src/services/claude-usage.service.ts +2 -7
- package/src/services/cloud-ws.service.ts +1 -0
- package/src/services/cloud.service.ts +1 -0
- package/src/services/db.service.ts +8 -0
- package/src/services/mcp-config.service.ts +15 -6
- package/src/services/supervisor.ts +22 -26
- package/src/types/api.ts +1 -0
- package/src/types/chat.ts +2 -0
- package/src/web/app.tsx +3 -2
- package/src/web/components/chat/chat-history-bar.tsx +39 -8
- package/src/web/components/chat/chat-tab.tsx +15 -10
- package/src/web/components/chat/message-list.tsx +7 -3
- package/src/web/components/chat/session-picker.tsx +1 -0
- package/src/web/components/chat/usage-badge.tsx +58 -8
- package/src/web/components/layout/upgrade-banner.tsx +15 -5
- package/src/web/components/settings/change-password-section.tsx +128 -0
- package/src/web/components/settings/settings-tab.tsx +4 -0
- package/src/web/hooks/use-chat.ts +17 -0
- package/test-session-ops.mjs +444 -0
- package/test-tokens.mjs +212 -0
- package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
- package/dist/web/assets/api-settings-Dh4oFOpX.js +0 -1
- package/dist/web/assets/chat-tab-C8HFXqGS.js +0 -8
- package/dist/web/assets/index-DhtLEnPD.css +0 -2
- package/dist/web/assets/keybindings-store-qVLDZz97.js +0 -1
|
@@ -19,9 +19,11 @@ import { getSessionMapping, getSessionProjectPath, setSessionMapping, getSession
|
|
|
19
19
|
import { accountSelector } from "../services/account-selector.service.ts";
|
|
20
20
|
import { accountService } from "../services/account.service.ts";
|
|
21
21
|
import { resolve } from "node:path";
|
|
22
|
-
import { existsSync } from "node:fs";
|
|
22
|
+
import { existsSync, readdirSync, unlinkSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
|
|
25
|
+
const CLAUDE_PROJECTS_DIR = resolve(homedir(), ".claude/projects");
|
|
26
|
+
|
|
25
27
|
function getSdkSessionId(ppmId: string): string {
|
|
26
28
|
return getSessionMapping(ppmId) ?? ppmId;
|
|
27
29
|
}
|
|
@@ -323,6 +325,21 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
323
325
|
this.closeStreamingSession(sessionId);
|
|
324
326
|
this.activeSessions.delete(sessionId);
|
|
325
327
|
this.messageCount.delete(sessionId);
|
|
328
|
+
this.pendingApprovals.delete(sessionId);
|
|
329
|
+
this.forkSources.delete(sessionId);
|
|
330
|
+
|
|
331
|
+
// Best-effort: delete JSONL from ~/.claude/projects/
|
|
332
|
+
const sdkId = getSessionMapping(sessionId) ?? sessionId;
|
|
333
|
+
try {
|
|
334
|
+
if (existsSync(CLAUDE_PROJECTS_DIR)) {
|
|
335
|
+
const projectDirs = readdirSync(CLAUDE_PROJECTS_DIR);
|
|
336
|
+
for (const dir of projectDirs) {
|
|
337
|
+
if (dir.includes("..") || dir.includes("/")) continue; // safety
|
|
338
|
+
const jsonlPath = resolve(CLAUDE_PROJECTS_DIR, dir, `${sdkId}.jsonl`);
|
|
339
|
+
if (existsSync(jsonlPath)) { unlinkSync(jsonlPath); break; }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch { /* best-effort */ }
|
|
326
343
|
}
|
|
327
344
|
|
|
328
345
|
/**
|
|
@@ -341,6 +358,29 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
341
358
|
this.forkSources.set(sessionId, sourceSessionId);
|
|
342
359
|
}
|
|
343
360
|
|
|
361
|
+
/** Fork a session at a specific message using SDK forkSession() */
|
|
362
|
+
async forkAtMessage(
|
|
363
|
+
sessionId: string,
|
|
364
|
+
messageId: string,
|
|
365
|
+
opts?: { title?: string; dir?: string },
|
|
366
|
+
): Promise<{ sessionId: string }> {
|
|
367
|
+
const sdkId = getSessionMapping(sessionId) ?? sessionId;
|
|
368
|
+
// Dynamic import: Bun's ESM linker fails to resolve forkSession as a static named export
|
|
369
|
+
// in certain test configurations. Lazy import avoids the module linking issue.
|
|
370
|
+
const { forkSession } = await import("@anthropic-ai/claude-agent-sdk");
|
|
371
|
+
const result = await forkSession(sdkId, {
|
|
372
|
+
upToMessageId: messageId,
|
|
373
|
+
title: opts?.title,
|
|
374
|
+
dir: opts?.dir,
|
|
375
|
+
});
|
|
376
|
+
return { sessionId: result.sessionId };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Mark session as resumed so next sendMessage uses resume path */
|
|
380
|
+
markAsResumed(sessionId: string): void {
|
|
381
|
+
this.messageCount.set(sessionId, 1);
|
|
382
|
+
}
|
|
383
|
+
|
|
344
384
|
async listModels(): Promise<ModelOption[]> {
|
|
345
385
|
return [
|
|
346
386
|
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
@@ -704,6 +744,24 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
704
744
|
}
|
|
705
745
|
}
|
|
706
746
|
|
|
747
|
+
// Detect compacting status
|
|
748
|
+
if (subtype === "status") {
|
|
749
|
+
const status = (msg as any).status;
|
|
750
|
+
if (status === "compacting") {
|
|
751
|
+
console.log(`[sdk] session=${sessionId} COMPACTING`);
|
|
752
|
+
yield { type: "system" as const, subtype: "compacting" } as ChatEvent;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Detect compact boundary (compact finished, messages replaced in JSONL)
|
|
758
|
+
if (subtype === "compact_boundary") {
|
|
759
|
+
const meta = (msg as any).compact_metadata;
|
|
760
|
+
console.log(`[sdk] session=${sessionId} COMPACT_BOUNDARY trigger=${meta?.trigger} pre_tokens=${meta?.pre_tokens}`);
|
|
761
|
+
yield { type: "system" as const, subtype: "compact_done" } as ChatEvent;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
|
|
707
765
|
// Yield system events so streaming loop can transition phases
|
|
708
766
|
// (e.g. connecting → thinking when hooks/init arrive)
|
|
709
767
|
yield { type: "system" as any, subtype } as any;
|
|
@@ -852,6 +910,18 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
852
910
|
}
|
|
853
911
|
}
|
|
854
912
|
|
|
913
|
+
// Auth failed permanently after retry — cooldown account and break loop.
|
|
914
|
+
// SDK doesn't send a result event after auth errors in streaming mode,
|
|
915
|
+
// so the streaming session would stay alive with broken credentials forever.
|
|
916
|
+
// Breaking here lets the finally block tear down the session, so the next
|
|
917
|
+
// user message creates a fresh session with a different account.
|
|
918
|
+
if (assistantError === "authentication_failed" && account && authRetried) {
|
|
919
|
+
accountSelector.onAuthError(account.id);
|
|
920
|
+
console.warn(`[sdk] session=${sessionId} auth permanently failed — tearing down streaming session`);
|
|
921
|
+
yield { type: "error", message: "API authentication failed. Check your account credentials in Settings → Accounts." };
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
|
|
855
925
|
const errorHints: Record<string, string> = {
|
|
856
926
|
authentication_failed: "API authentication failed. Check your account credentials in Settings → Accounts.",
|
|
857
927
|
billing_error: "Billing error on this account. Check your subscription status.",
|
|
@@ -1100,7 +1170,13 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1100
1170
|
}
|
|
1101
1171
|
} finally {
|
|
1102
1172
|
this.activeQueries.delete(sessionId);
|
|
1103
|
-
|
|
1173
|
+
// Properly close streaming session: terminate subprocess + generator
|
|
1174
|
+
const ss = this.streamingSessions.get(sessionId);
|
|
1175
|
+
if (ss) {
|
|
1176
|
+
ss.controller.done();
|
|
1177
|
+
ss.query.close();
|
|
1178
|
+
this.streamingSessions.delete(sessionId);
|
|
1179
|
+
}
|
|
1104
1180
|
console.log(`[sdk] session=${sessionId} streaming session ended`);
|
|
1105
1181
|
}
|
|
1106
1182
|
|
|
@@ -84,7 +84,13 @@ export abstract class CliProvider implements AIProvider {
|
|
|
84
84
|
}));
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
markAsResumed(sessionId: string): void {
|
|
88
|
+
this.messageCount.set(sessionId, 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
async deleteSession(sessionId: string): Promise<void> {
|
|
92
|
+
const proc = this.activeProcesses.get(sessionId);
|
|
93
|
+
if (proc) { proc.kill(); this.activeProcesses.delete(sessionId); }
|
|
88
94
|
this.sessions.delete(sessionId);
|
|
89
95
|
this.messageCount.delete(sessionId);
|
|
90
96
|
}
|
|
@@ -8,7 +8,7 @@ import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sd
|
|
|
8
8
|
import { listSlashItems } from "../../services/slash-items.service.ts";
|
|
9
9
|
import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
|
|
10
10
|
import { getSessionLog } from "../../services/session-log.service.ts";
|
|
11
|
-
import { getSessionMapping, getSessionProjectPath, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession } from "../../services/db.service.ts";
|
|
11
|
+
import { getSessionMapping, getSessionProjectPath, setSessionMapping, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionTitle } from "../../services/db.service.ts";
|
|
12
12
|
import { ok, err } from "../../types/api.ts";
|
|
13
13
|
|
|
14
14
|
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
@@ -125,7 +125,13 @@ chatRoutes.delete("/sessions/:id", async (c) => {
|
|
|
125
125
|
try {
|
|
126
126
|
const id = c.req.param("id");
|
|
127
127
|
const providerId = c.req.query("providerId") ?? "claude";
|
|
128
|
+
const sdkId = getSessionMapping(id) ?? id;
|
|
129
|
+
// Provider-specific cleanup (JSONL, process, etc.)
|
|
128
130
|
await chatService.deleteSession(providerId, id);
|
|
131
|
+
// Shared DB cleanup
|
|
132
|
+
deleteSessionMapping(id);
|
|
133
|
+
deleteSessionTitle(sdkId);
|
|
134
|
+
unpinSession(sdkId);
|
|
129
135
|
return c.json(ok({ deleted: id }));
|
|
130
136
|
} catch (e) {
|
|
131
137
|
return c.json(err((e as Error).message), 404);
|
|
@@ -184,16 +190,31 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
|
|
|
184
190
|
const projectName = c.get("projectName");
|
|
185
191
|
const projectPath = c.get("projectPath");
|
|
186
192
|
const providerId = c.req.query("providerId") ?? "claude";
|
|
187
|
-
|
|
188
|
-
const session = await chatService.createSession(providerId, {
|
|
189
|
-
projectName,
|
|
190
|
-
projectPath,
|
|
191
|
-
title: "Forked Chat",
|
|
192
|
-
});
|
|
193
|
-
// Store fork source so WS handler knows to use forkSession on first message
|
|
193
|
+
const body = await c.req.json<{ messageId?: string }>().catch(() => ({} as { messageId?: string }));
|
|
194
194
|
const provider = providerRegistry.get(providerId);
|
|
195
|
-
provider
|
|
196
|
-
|
|
195
|
+
if (!provider) return c.json(err("Provider not found"), 404);
|
|
196
|
+
|
|
197
|
+
if (body.messageId) {
|
|
198
|
+
// Mid-fork at a specific message
|
|
199
|
+
if (!provider.forkAtMessage) {
|
|
200
|
+
return c.json(err("Provider does not support forking"), 400);
|
|
201
|
+
}
|
|
202
|
+
const result = await provider.forkAtMessage(sourceId, body.messageId, {
|
|
203
|
+
title: "Forked Chat", dir: projectPath,
|
|
204
|
+
});
|
|
205
|
+
const session = await chatService.createSession(providerId, {
|
|
206
|
+
projectName, projectPath, title: "Forked Chat",
|
|
207
|
+
});
|
|
208
|
+
setSessionMapping(session.id, result.sessionId);
|
|
209
|
+
provider.markAsResumed?.(session.id);
|
|
210
|
+
return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
|
|
211
|
+
} else {
|
|
212
|
+
// No messageId (fork at first message) — create a fresh empty session
|
|
213
|
+
const session = await chatService.createSession(providerId, {
|
|
214
|
+
projectName, projectPath, title: "Forked Chat",
|
|
215
|
+
});
|
|
216
|
+
return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
|
|
217
|
+
}
|
|
197
218
|
} catch (e) {
|
|
198
219
|
return c.json(err((e as Error).message), 500);
|
|
199
220
|
}
|
|
@@ -252,6 +252,33 @@ settingsRoutes.post("/telegram/test", async (c) => {
|
|
|
252
252
|
}
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
+
// ── Auth / Password ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/** PUT /settings/auth/password — change the access password (token) */
|
|
258
|
+
settingsRoutes.put("/auth/password", async (c) => {
|
|
259
|
+
try {
|
|
260
|
+
const { password, confirm } = await c.req.json<{ password: string; confirm: string }>();
|
|
261
|
+
if (typeof password !== "string" || !password.trim()) {
|
|
262
|
+
return c.json(err("Password is required"), 400);
|
|
263
|
+
}
|
|
264
|
+
if (password !== confirm) {
|
|
265
|
+
return c.json(err("Passwords do not match"), 400);
|
|
266
|
+
}
|
|
267
|
+
const trimmed = password.trim();
|
|
268
|
+
if (trimmed.length < 4) {
|
|
269
|
+
return c.json(err("Password must be at least 4 characters"), 400);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const auth = configService.get("auth");
|
|
273
|
+
configService.set("auth", { ...auth, token: trimmed });
|
|
274
|
+
configService.save();
|
|
275
|
+
|
|
276
|
+
return c.json(ok({ token: trimmed }));
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return c.json(err((e as Error).message), 400);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
255
282
|
// ── Proxy ────────────────────────────────────────────────────────────
|
|
256
283
|
|
|
257
284
|
/** GET /settings/proxy — proxy status */
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -218,8 +218,14 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
218
218
|
continue;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
// System events → transition connecting → thinking
|
|
221
|
+
// System events → transition connecting → thinking, forward compact events
|
|
222
222
|
if (evType === "system") {
|
|
223
|
+
const sub = (ev as any).subtype;
|
|
224
|
+
if (sub === "compacting") {
|
|
225
|
+
broadcast(sessionId, { type: "compact_status", status: "compacting" });
|
|
226
|
+
} else if (sub === "compact_done") {
|
|
227
|
+
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
228
|
+
}
|
|
223
229
|
if (!firstEventReceived) {
|
|
224
230
|
if (heartbeat) clearInterval(heartbeat);
|
|
225
231
|
setPhase(sessionId, "thinking");
|
|
@@ -139,7 +139,7 @@ class AccountService {
|
|
|
139
139
|
await this.refreshAccessToken(id, false);
|
|
140
140
|
return this.getWithTokens(id);
|
|
141
141
|
} catch (e) {
|
|
142
|
-
console.error(`[accounts] Pre-flight refresh failed for ${id}
|
|
142
|
+
console.error(`[accounts] Pre-flight refresh failed for ${id}: ${(e as Error).message ?? e}`);
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
145
|
}
|
|
@@ -709,7 +709,7 @@ class AccountService {
|
|
|
709
709
|
try {
|
|
710
710
|
await this.refreshAccessToken(acc.id, false);
|
|
711
711
|
} catch (e) {
|
|
712
|
-
console.error(`[accounts] Auto-refresh failed for ${acc.id}
|
|
712
|
+
console.error(`[accounts] Auto-refresh failed for ${acc.id}: ${(e as Error).message ?? e}`);
|
|
713
713
|
}
|
|
714
714
|
}
|
|
715
715
|
};
|
|
@@ -273,14 +273,9 @@ export function getUsageForAccount(accountId: string): ClaudeUsage {
|
|
|
273
273
|
return row ? snapshotToUsage(row) : {};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/** Get usage for all accounts
|
|
276
|
+
/** Get usage for all accounts */
|
|
277
277
|
export function getAllAccountUsages(): AccountUsageEntry[] {
|
|
278
|
-
const
|
|
279
|
-
const accounts = accountService.list().filter(acc => {
|
|
280
|
-
// Exclude expired accounts without refresh token (temporary/invalid)
|
|
281
|
-
if (!accountService.hasRefreshToken(acc.id) && acc.expiresAt && acc.expiresAt < nowS) return false;
|
|
282
|
-
return true;
|
|
283
|
-
});
|
|
278
|
+
const accounts = accountService.list();
|
|
284
279
|
const snapshots = getAllLatestSnapshots();
|
|
285
280
|
const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
|
|
286
281
|
return accounts.map(acc => {
|
|
@@ -444,6 +444,14 @@ export function getPinnedSessionIds(): Set<string> {
|
|
|
444
444
|
return new Set(rows.map((r) => r.session_id));
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
export function deleteSessionMapping(ppmId: string): void {
|
|
448
|
+
getDb().query("DELETE FROM session_map WHERE ppm_id = ?").run(ppmId);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function deleteSessionTitle(sessionId: string): void {
|
|
452
|
+
getDb().query("DELETE FROM session_titles WHERE session_id = ?").run(sessionId);
|
|
453
|
+
}
|
|
454
|
+
|
|
447
455
|
// ---------------------------------------------------------------------------
|
|
448
456
|
// Push subscription helpers
|
|
449
457
|
// ---------------------------------------------------------------------------
|
|
@@ -27,13 +27,22 @@ export class McpConfigService {
|
|
|
27
27
|
|
|
28
28
|
/** List all MCP servers as Record (SDK-compatible format) */
|
|
29
29
|
list(): Record<string, McpServerConfig> {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
30
|
+
try {
|
|
31
|
+
const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
|
|
32
|
+
const result: Record<string, McpServerConfig> = {};
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const parsed = safeParse(row.config, row.name);
|
|
35
|
+
if (parsed) result[row.name] = parsed;
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
const msg = (e as Error).message ?? String(e);
|
|
40
|
+
if (msg.includes("no such table")) {
|
|
41
|
+
console.warn("[mcp] mcp_servers table not found — returning empty list");
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
35
45
|
}
|
|
36
|
-
return result;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/** List as array with metadata (for UI) */
|
|
@@ -378,17 +378,33 @@ function adoptTunnel(): boolean {
|
|
|
378
378
|
const status = readStatus();
|
|
379
379
|
const pid = status.tunnelPid as number;
|
|
380
380
|
const url = status.shareUrl as string;
|
|
381
|
-
if (!pid || !url)
|
|
381
|
+
if (!pid || !url) {
|
|
382
|
+
log("DEBUG", `adoptTunnel: missing tunnelPid(${pid}) or shareUrl(${url}) in status`);
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
382
385
|
process.kill(pid, 0); // throws if process is dead
|
|
383
386
|
adoptedTunnelPid = pid;
|
|
384
387
|
tunnelUrl = url;
|
|
385
388
|
log("INFO", `Adopted existing tunnel (PID: ${pid}, URL: ${url})`);
|
|
386
389
|
return true;
|
|
387
|
-
} catch {
|
|
390
|
+
} catch (e) {
|
|
391
|
+
log("WARN", `adoptTunnel: tunnel PID ${(readStatus().tunnelPid)} unreachable: ${e}`);
|
|
388
392
|
return false;
|
|
389
393
|
}
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
/** Kill stale tunnel PID from status.json (cleanup after failed adoption) */
|
|
397
|
+
function killStaleTunnel() {
|
|
398
|
+
try {
|
|
399
|
+
const status = readStatus();
|
|
400
|
+
const pid = status.tunnelPid as number;
|
|
401
|
+
if (!pid) return;
|
|
402
|
+
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
403
|
+
log("INFO", `Killed stale tunnel (PID: ${pid})`);
|
|
404
|
+
} catch {}
|
|
405
|
+
updateStatus({ tunnelPid: null, shareUrl: null });
|
|
406
|
+
}
|
|
407
|
+
|
|
392
408
|
/** Spawn new supervisor from updated code, wait for it to be healthy, then exit */
|
|
393
409
|
async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
394
410
|
log("INFO", "Starting self-replace for upgrade");
|
|
@@ -490,6 +506,8 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
|
|
|
490
506
|
secretKey: device.secret_key,
|
|
491
507
|
heartbeatFn: () => {
|
|
492
508
|
const status = readStatus();
|
|
509
|
+
// Re-read device file each heartbeat to pick up name changes
|
|
510
|
+
const currentDevice = getCloudDevice();
|
|
493
511
|
return {
|
|
494
512
|
type: "heartbeat" as const,
|
|
495
513
|
tunnelUrl,
|
|
@@ -499,6 +517,7 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
|
|
|
499
517
|
availableVersion: (status.availableVersion as string) || null,
|
|
500
518
|
serverPid: serverChild?.pid ?? null,
|
|
501
519
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
520
|
+
deviceName: currentDevice?.name ?? device.name,
|
|
502
521
|
timestamp: new Date().toISOString(),
|
|
503
522
|
};
|
|
504
523
|
},
|
|
@@ -559,30 +578,6 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
|
|
|
559
578
|
}, 500);
|
|
560
579
|
break;
|
|
561
580
|
|
|
562
|
-
case "upgrade": {
|
|
563
|
-
// Install new version FIRST (same as CLI / HTTP route)
|
|
564
|
-
const { applyUpgrade } = await import("./upgrade.service.ts");
|
|
565
|
-
sendResult(true, undefined, { status: "installing" });
|
|
566
|
-
await new Promise(r => setTimeout(r, 300));
|
|
567
|
-
const installResult = await applyUpgrade();
|
|
568
|
-
if (!installResult.success) {
|
|
569
|
-
sendResult(false, installResult.error);
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
// New version installed — self-replace to pick it up
|
|
573
|
-
sendResult(true, undefined, { status: "upgrading", newVersion: installResult.newVersion });
|
|
574
|
-
await new Promise(r => setTimeout(r, 300));
|
|
575
|
-
const result = await selfReplace();
|
|
576
|
-
// Only reaches here on failure — selfReplace exits on success
|
|
577
|
-
if (!result.success) {
|
|
578
|
-
sendResult(false, result.error);
|
|
579
|
-
if (!serverChild && !shuttingDown) {
|
|
580
|
-
spawnServer(serverArgs, logFd);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
break;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
581
|
case "status":
|
|
587
582
|
sendResult(true, undefined, {
|
|
588
583
|
state: supervisorState,
|
|
@@ -706,6 +701,7 @@ export async function runSupervisor(opts: {
|
|
|
706
701
|
startTunnelProbe(opts.port);
|
|
707
702
|
// Try adopting tunnel kept alive from previous upgrade; spawn new if dead
|
|
708
703
|
if (!adoptTunnel()) {
|
|
704
|
+
killStaleTunnel(); // kill orphaned tunnel before spawning new one
|
|
709
705
|
promises.push(spawnTunnel(opts.port));
|
|
710
706
|
}
|
|
711
707
|
}
|
package/src/types/api.ts
CHANGED
|
@@ -44,4 +44,5 @@ export type ChatWsServerMessage =
|
|
|
44
44
|
| { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null }
|
|
45
45
|
| { type: "turn_events"; events: unknown[] }
|
|
46
46
|
| { type: "title_updated"; title: string }
|
|
47
|
+
| { type: "compact_status"; status: "compacting" | "done" }
|
|
47
48
|
| { type: "ping" };
|
package/src/types/chat.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface AIProvider {
|
|
|
29
29
|
listSessionsByDir?(dir: string): Promise<SessionInfo[]>;
|
|
30
30
|
ensureProjectPath?(sessionId: string, path: string): void;
|
|
31
31
|
setForkSource?(sessionId: string, sourceSessionId: string): void;
|
|
32
|
+
forkAtMessage?(sessionId: string, messageId: string, opts?: { title?: string; dir?: string }): Promise<{ sessionId: string }>;
|
|
33
|
+
markAsResumed?(sessionId: string): void;
|
|
32
34
|
isAvailable?(): Promise<boolean>;
|
|
33
35
|
listModels?(): Promise<ModelOption[]>;
|
|
34
36
|
}
|
package/src/web/app.tsx
CHANGED
|
@@ -37,6 +37,7 @@ type AuthState = "checking" | "authenticated" | "unauthenticated";
|
|
|
37
37
|
|
|
38
38
|
export function App() {
|
|
39
39
|
const [authState, setAuthState] = useState<AuthState>("checking");
|
|
40
|
+
const [upgradeBannerVisible, setUpgradeBannerVisible] = useState(false);
|
|
40
41
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
41
42
|
const [drawerTab, setDrawerTab] = useState<"explorer" | "git" | "settings" | undefined>();
|
|
42
43
|
const [projectSheetOpen, setProjectSheetOpen] = useState(false);
|
|
@@ -229,11 +230,11 @@ export function App() {
|
|
|
229
230
|
<TooltipProvider>
|
|
230
231
|
<div className="h-dvh flex flex-col bg-background text-foreground overflow-hidden relative">
|
|
231
232
|
{/* Upgrade banner — shown when new version available */}
|
|
232
|
-
<UpgradeBanner />
|
|
233
|
+
<UpgradeBanner onVisibilityChange={setUpgradeBannerVisible} />
|
|
233
234
|
|
|
234
235
|
{/* Mobile device name badge — floating top-left */}
|
|
235
236
|
{deviceName && (
|
|
236
|
-
<div className="md:hidden fixed
|
|
237
|
+
<div className={cn("md:hidden fixed left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br transition-[top]", upgradeBannerVisible ? "top-7" : "top-0")}>
|
|
237
238
|
{deviceName}
|
|
238
239
|
</div>
|
|
239
240
|
)}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
-
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff } from "lucide-react";
|
|
2
|
+
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2 } from "lucide-react";
|
|
3
3
|
import { Activity } from "lucide-react";
|
|
4
4
|
import { api, projectUrl } from "@/lib/api-client";
|
|
5
5
|
import { useTabStore } from "@/stores/tab-store";
|
|
@@ -16,6 +16,7 @@ interface ChatHistoryBarProps {
|
|
|
16
16
|
projectName: string;
|
|
17
17
|
usageInfo: UsageInfo;
|
|
18
18
|
contextWindowPct?: number | null;
|
|
19
|
+
compactStatus?: "compacting" | null;
|
|
19
20
|
usageLoading?: boolean;
|
|
20
21
|
refreshUsage?: () => void;
|
|
21
22
|
lastFetchedAt?: string | null;
|
|
@@ -79,7 +80,7 @@ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projec
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
export function ChatHistoryBar({
|
|
82
|
-
projectName, usageInfo, contextWindowPct, usageLoading, refreshUsage, lastFetchedAt,
|
|
83
|
+
projectName, usageInfo, contextWindowPct, compactStatus, usageLoading, refreshUsage, lastFetchedAt,
|
|
83
84
|
sessionId, providerId, onSelectSession, onBugReport, isConnected, onReconnect,
|
|
84
85
|
}: ChatHistoryBarProps) {
|
|
85
86
|
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
@@ -172,6 +173,16 @@ export function ChatHistoryBar({
|
|
|
172
173
|
} catch { /* silent */ }
|
|
173
174
|
}, [projectName]);
|
|
174
175
|
|
|
176
|
+
const deleteSession = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
|
|
177
|
+
e.stopPropagation();
|
|
178
|
+
if (!projectName) return;
|
|
179
|
+
if (!window.confirm("Delete this session? This cannot be undone.")) return;
|
|
180
|
+
try {
|
|
181
|
+
await api.del(`${projectUrl(projectName)}/chat/sessions/${session.id}?providerId=${session.providerId}`);
|
|
182
|
+
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
183
|
+
} catch { /* silent */ }
|
|
184
|
+
}, [projectName]);
|
|
185
|
+
|
|
175
186
|
// Filter sessions by search query
|
|
176
187
|
const filteredSessions = searchQuery.trim()
|
|
177
188
|
? sessions.filter((s) => (s.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
|
|
@@ -240,14 +251,27 @@ export function ChatHistoryBar({
|
|
|
240
251
|
<span className={pctColor(contextWindowPct)}>Ctx:{contextWindowPct}%</span>
|
|
241
252
|
</>
|
|
242
253
|
)}
|
|
254
|
+
{compactStatus === "compacting" && (
|
|
255
|
+
<>
|
|
256
|
+
<span className="text-text-subtle">·</span>
|
|
257
|
+
<span className="text-blue-400 animate-pulse">compacting...</span>
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
243
260
|
</button>
|
|
244
261
|
) : (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
262
|
+
<>
|
|
263
|
+
{contextWindowPct != null && (
|
|
264
|
+
<span className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${pctColor(contextWindowPct)}`}>
|
|
265
|
+
<Activity className="size-3" />
|
|
266
|
+
<span>Ctx:{contextWindowPct}%</span>
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
269
|
+
{compactStatus === "compacting" && (
|
|
270
|
+
<span className="text-[11px] px-1.5 py-0.5 text-blue-400 animate-pulse">
|
|
271
|
+
compacting...
|
|
272
|
+
</span>
|
|
273
|
+
)}
|
|
274
|
+
</>
|
|
251
275
|
)}
|
|
252
276
|
|
|
253
277
|
{/* Spacer */}
|
|
@@ -369,6 +393,13 @@ export function ChatHistoryBar({
|
|
|
369
393
|
>
|
|
370
394
|
<Pencil className="size-3" />
|
|
371
395
|
</button>
|
|
396
|
+
<button
|
|
397
|
+
onClick={(e) => deleteSession(e, session)}
|
|
398
|
+
className="p-0.5 rounded text-text-subtle hover:text-red-400 hover:bg-red-500/20 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
|
|
399
|
+
title="Delete session"
|
|
400
|
+
>
|
|
401
|
+
<Trash2 className="size-3" />
|
|
402
|
+
</button>
|
|
372
403
|
</>
|
|
373
404
|
)}
|
|
374
405
|
{editingId !== session.id && session.updatedAt && (
|
|
@@ -89,6 +89,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
89
89
|
connectingElapsed,
|
|
90
90
|
pendingApproval,
|
|
91
91
|
contextWindowPct,
|
|
92
|
+
compactStatus,
|
|
92
93
|
sessionTitle,
|
|
93
94
|
migratedSessionId,
|
|
94
95
|
sendMessage,
|
|
@@ -134,14 +135,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
134
135
|
}
|
|
135
136
|
}, [sessionTitle]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
136
137
|
|
|
137
|
-
//
|
|
138
|
-
const
|
|
138
|
+
// Pending fork message — show in input for user to edit, not auto-send
|
|
139
|
+
const [forkDraft, setForkDraft] = useState<string | undefined>(metadata?.pendingMessage as string | undefined);
|
|
139
140
|
useEffect(() => {
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (tabId) updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
|
|
144
|
-
setTimeout(() => sendMessage(msg, { permissionMode }), 100);
|
|
141
|
+
if (forkDraft && isConnected && sessionId && tabId) {
|
|
142
|
+
// Clear from tab metadata once consumed
|
|
143
|
+
updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
|
|
145
144
|
}
|
|
146
145
|
}, [isConnected, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
147
146
|
|
|
@@ -162,12 +161,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
162
161
|
}, [tabId, updateTab]);
|
|
163
162
|
|
|
164
163
|
/** Fork current session and open new tab with the forked session, resending userMessage */
|
|
165
|
-
const handleFork = useCallback(async (userMessage: string) => {
|
|
164
|
+
const handleFork = useCallback(async (userMessage: string, messageId?: string) => {
|
|
166
165
|
if (!sessionId || !projectName) return;
|
|
167
166
|
try {
|
|
168
167
|
const { api, projectUrl } = await import("@/lib/api-client");
|
|
169
168
|
const forked = await api.post<{ id: string; forkedFrom: string }>(
|
|
170
169
|
`${projectUrl(projectName)}/chat/sessions/${sessionId}/fork?providerId=${providerId}`,
|
|
170
|
+
{ messageId },
|
|
171
171
|
);
|
|
172
172
|
// Open new chat tab with forked session — it will send userMessage on connect
|
|
173
173
|
useTabStore.getState().openTab({
|
|
@@ -350,6 +350,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
350
350
|
projectName={projectName}
|
|
351
351
|
usageInfo={usageInfo}
|
|
352
352
|
contextWindowPct={contextWindowPct}
|
|
353
|
+
compactStatus={compactStatus}
|
|
353
354
|
usageLoading={usageLoading}
|
|
354
355
|
refreshUsage={refreshUsage}
|
|
355
356
|
lastFetchedAt={lastFetchedAt}
|
|
@@ -382,10 +383,14 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
382
383
|
|
|
383
384
|
{/* Input */}
|
|
384
385
|
<MessageInput
|
|
385
|
-
onSend={
|
|
386
|
+
onSend={(content, attachments, priority) => {
|
|
387
|
+
if (forkDraft) setForkDraft(undefined);
|
|
388
|
+
handleSend(content, attachments, priority);
|
|
389
|
+
}}
|
|
386
390
|
isStreaming={isStreaming}
|
|
387
391
|
onCancel={cancelStreaming}
|
|
388
|
-
autoFocus={!(metadata?.sessionId)}
|
|
392
|
+
autoFocus={!(metadata?.sessionId) || !!forkDraft}
|
|
393
|
+
initialValue={forkDraft}
|
|
389
394
|
projectName={projectName}
|
|
390
395
|
onSlashStateChange={handleSlashStateChange}
|
|
391
396
|
onSlashItemsLoaded={setSlashItems}
|