@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/web/assets/api-settings-Bid0NHuI.js +1 -0
  3. package/dist/web/assets/{browser-tab-DJLH0eDY.js → browser-tab-DNiBGn5p.js} +1 -1
  4. package/dist/web/assets/chat-tab-w7jsIjfo.js +8 -0
  5. package/dist/web/assets/{code-editor-CaGdx-lS.js → code-editor-COwuo1MZ.js} +1 -1
  6. package/dist/web/assets/{database-viewer-i4Ddk6mO.js → database-viewer-CL3kXoYN.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-DQDS7yjv.js → diff-viewer-BCuKcGH5.js} +1 -1
  8. package/dist/web/assets/{git-graph-DUs-TN1u.js → git-graph-CmQb8T0E.js} +1 -1
  9. package/dist/web/assets/{index-Dm6RN1A1.js → index-BlDA3VoN.js} +11 -11
  10. package/dist/web/assets/index-CqhIj4Ko.css +2 -0
  11. package/dist/web/assets/keybindings-store-D44LPqNY.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-L1NgC2Rw.js → markdown-renderer-PdMYiBSA.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-_uDispGW.js → postgres-viewer-9yUy5BZB.js} +1 -1
  14. package/dist/web/assets/{settings-tab-Bp4041i6.js → settings-tab-DSF87yix.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-GW-QCjHn.js → sqlite-viewer-BaloRTBe.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-E4cWujj4.js → terminal-tab-CM6G6XMO.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-zABXAAla.js → use-monaco-theme-C8rXfYU9.js} +1 -1
  18. package/dist/web/index.html +3 -3
  19. package/dist/web/sw.js +1 -1
  20. package/docs/streaming-input-guide.md +267 -0
  21. package/package.json +1 -1
  22. package/snapshot-state.md +1526 -0
  23. package/src/providers/claude-agent-sdk.ts +78 -2
  24. package/src/providers/cli-provider-base.ts +6 -0
  25. package/src/server/routes/chat.ts +31 -10
  26. package/src/server/routes/settings.ts +27 -0
  27. package/src/server/ws/chat.ts +7 -1
  28. package/src/services/account.service.ts +2 -2
  29. package/src/services/claude-usage.service.ts +2 -7
  30. package/src/services/cloud-ws.service.ts +1 -0
  31. package/src/services/cloud.service.ts +1 -0
  32. package/src/services/db.service.ts +8 -0
  33. package/src/services/mcp-config.service.ts +15 -6
  34. package/src/services/supervisor.ts +22 -26
  35. package/src/types/api.ts +1 -0
  36. package/src/types/chat.ts +2 -0
  37. package/src/web/app.tsx +3 -2
  38. package/src/web/components/chat/chat-history-bar.tsx +39 -8
  39. package/src/web/components/chat/chat-tab.tsx +15 -10
  40. package/src/web/components/chat/message-list.tsx +7 -3
  41. package/src/web/components/chat/session-picker.tsx +1 -0
  42. package/src/web/components/chat/usage-badge.tsx +58 -8
  43. package/src/web/components/layout/upgrade-banner.tsx +15 -5
  44. package/src/web/components/settings/change-password-section.tsx +128 -0
  45. package/src/web/components/settings/settings-tab.tsx +4 -0
  46. package/src/web/hooks/use-chat.ts +17 -0
  47. package/test-session-ops.mjs +444 -0
  48. package/test-tokens.mjs +212 -0
  49. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  50. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  51. package/dist/web/assets/api-settings-Dh4oFOpX.js +0 -1
  52. package/dist/web/assets/chat-tab-C8HFXqGS.js +0 -8
  53. package/dist/web/assets/index-DhtLEnPD.css +0 -2
  54. 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
- this.streamingSessions.delete(sessionId);
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
- // Create a new PPM session that will fork from sourceId on first message
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?.setForkSource?.(session.id, sourceId);
196
- return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
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 */
@@ -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}:`, e);
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}:`, e);
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 (excludes expired temporary accounts) */
276
+ /** Get usage for all accounts */
277
277
  export function getAllAccountUsages(): AccountUsageEntry[] {
278
- const nowS = Math.floor(Date.now() / 1000);
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 => {
@@ -21,6 +21,7 @@ interface HeartbeatMsg extends WsMessage {
21
21
  availableVersion: string | null;
22
22
  serverPid: number | null;
23
23
  uptime: number;
24
+ deviceName?: string;
24
25
  }
25
26
 
26
27
  interface StateChangeMsg extends WsMessage {
@@ -354,6 +354,7 @@ export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
354
354
  secret_key: device.secret_key,
355
355
  tunnel_url: tunnelUrl,
356
356
  status: "online",
357
+ name: device.name,
357
358
  }),
358
359
  });
359
360
  return res.ok;
@@ -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
- const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
31
- const result: Record<string, McpServerConfig> = {};
32
- for (const row of rows) {
33
- const parsed = safeParse(row.config, row.name);
34
- if (parsed) result[row.name] = parsed;
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) return false;
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 top-0 left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br">
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
- contextWindowPct != null && (
246
- <span className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${pctColor(contextWindowPct)}`}>
247
- <Activity className="size-3" />
248
- <span>Ctx:{contextWindowPct}%</span>
249
- </span>
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
- // Auto-send pending message for forked sessions (set by handleFork)
138
- const pendingForkMsgRef = useRef(metadata?.pendingMessage as string | undefined);
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 (pendingForkMsgRef.current && isConnected && sessionId) {
141
- const msg = pendingForkMsgRef.current;
142
- pendingForkMsgRef.current = undefined;
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={handleSend}
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}