@hienlh/ppm 0.8.70 → 0.8.72

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 (35) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/web/assets/{browser-tab-sn8vZniz.js → browser-tab-D5GfU4Ja.js} +1 -1
  3. package/dist/web/assets/chat-tab-BJeNwwUM.js +8 -0
  4. package/dist/web/assets/code-editor-CTjgdXh2.js +2 -0
  5. package/dist/web/assets/{database-viewer-BOnawWoi.js → database-viewer-QzEuetE6.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-CYSw0YBG.js → diff-viewer-CvZ06EAH.js} +1 -1
  7. package/dist/web/assets/{git-graph-BNTU6kmo.js → git-graph-BQqdvSjX.js} +1 -1
  8. package/dist/web/assets/index-5a-tMkk5.js +37 -0
  9. package/dist/web/assets/{index-DJ1Bqwo4.css → index-CzwYVupc.css} +1 -1
  10. package/dist/web/assets/keybindings-store-zY8zbJ2c.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-CW2c3h_9.js → markdown-renderer-BVxlq4zO.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-D95__akI.js → postgres-viewer-DP0FOQOa.js} +1 -1
  13. package/dist/web/assets/{settings-tab-CwLkeZaa.js → settings-tab-CcmhnYpw.js} +1 -1
  14. package/dist/web/assets/{sqlite-viewer-J18kIhk2.js → sqlite-viewer-4a4hHLZk.js} +1 -1
  15. package/dist/web/assets/{terminal-tab-BKETi9uD.js → terminal-tab-CKsBIgnq.js} +1 -1
  16. package/dist/web/assets/{use-monaco-theme-Dsn8sLad.js → use-monaco-theme-BwIb9BHq.js} +1 -1
  17. package/dist/web/index.html +2 -2
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/cli/commands/restart.ts +9 -1
  21. package/src/cli/commands/status.ts +19 -0
  22. package/src/index.ts +1 -2
  23. package/src/server/index.ts +17 -154
  24. package/src/server/routes/chat.ts +33 -3
  25. package/src/services/cloud-ws.service.ts +208 -0
  26. package/src/services/db.service.ts +31 -1
  27. package/src/services/supervisor.ts +193 -12
  28. package/src/types/chat.ts +1 -0
  29. package/src/web/components/chat/chat-history-bar.tsx +35 -3
  30. package/src/web/components/chat/session-picker.tsx +78 -31
  31. package/src/web/components/layout/editor-panel.tsx +71 -19
  32. package/dist/web/assets/chat-tab-CVN2falD.js +0 -8
  33. package/dist/web/assets/code-editor-BNAZzdyF.js +0 -2
  34. package/dist/web/assets/index-ButO-DnP.js +0 -37
  35. package/dist/web/assets/keybindings-store-BxDBTcFM.js +0 -1
@@ -8,8 +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 } from "../../services/db.service.ts";
12
- import { getSessionMapping, setSessionTitle } from "../../services/db.service.ts";
11
+ import { getSessionMapping, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession } from "../../services/db.service.ts";
13
12
  import { ok, err } from "../../types/api.ts";
14
13
 
15
14
  type Env = { Variables: { projectPath: string; projectName: string } };
@@ -64,7 +63,16 @@ chatRoutes.get("/sessions", async (c) => {
64
63
  const projectPath = c.get("projectPath");
65
64
  const providerId = c.req.query("providerId");
66
65
  const sessions = await chatService.listSessions(providerId, projectPath);
67
- return c.json(ok(sessions));
66
+ // Enrich with pin status
67
+ const pinnedIds = getPinnedSessionIds();
68
+ const enriched = sessions.map((s) => ({ ...s, pinned: pinnedIds.has(s.id) }));
69
+ // Sort: pinned first (by pinned_at implicit via Set order), then unpinned by createdAt
70
+ enriched.sort((a, b) => {
71
+ if (a.pinned && !b.pinned) return -1;
72
+ if (!a.pinned && b.pinned) return 1;
73
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
74
+ });
75
+ return c.json(ok(enriched));
68
76
  } catch (e) {
69
77
  return c.json(err((e as Error).message), 500);
70
78
  }
@@ -134,6 +142,28 @@ chatRoutes.patch("/sessions/:id", async (c) => {
134
142
  }
135
143
  });
136
144
 
145
+ /** PUT /chat/sessions/:id/pin — pin a session */
146
+ chatRoutes.put("/sessions/:id/pin", (c) => {
147
+ try {
148
+ const id = c.req.param("id");
149
+ pinSession(id);
150
+ return c.json(ok({ id, pinned: true }));
151
+ } catch (e) {
152
+ return c.json(err((e as Error).message), 500);
153
+ }
154
+ });
155
+
156
+ /** DELETE /chat/sessions/:id/pin — unpin a session */
157
+ chatRoutes.delete("/sessions/:id/pin", (c) => {
158
+ try {
159
+ const id = c.req.param("id");
160
+ unpinSession(id);
161
+ return c.json(ok({ id, pinned: false }));
162
+ } catch (e) {
163
+ return c.json(err((e as Error).message), 500);
164
+ }
165
+ });
166
+
137
167
  /** POST /chat/sessions/:id/fork — fork session into a new one (for rewind/branch) */
138
168
  chatRoutes.post("/sessions/:id/fork", async (c) => {
139
169
  try {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Cloud WebSocket client — persistent connection from supervisor to PPM Cloud.
3
+ * Auto-reconnects with exponential backoff + jitter. Queues messages when disconnected.
4
+ */
5
+ import { appendFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ // ─── Types (must match Cloud's ws-types.ts) ─────────
10
+ interface WsMessage {
11
+ type: string;
12
+ id?: string;
13
+ timestamp: string;
14
+ }
15
+
16
+ interface HeartbeatMsg extends WsMessage {
17
+ type: "heartbeat";
18
+ tunnelUrl: string | null;
19
+ state: string;
20
+ appVersion: string;
21
+ serverPid: number | null;
22
+ uptime: number;
23
+ }
24
+
25
+ interface StateChangeMsg extends WsMessage {
26
+ type: "state_change";
27
+ from: string;
28
+ to: string;
29
+ reason: string;
30
+ }
31
+
32
+ interface CommandResultMsg extends WsMessage {
33
+ type: "command_result";
34
+ id: string;
35
+ success: boolean;
36
+ error?: string;
37
+ data?: Record<string, unknown>;
38
+ }
39
+
40
+ type OutboundMsg = HeartbeatMsg | StateChangeMsg | CommandResultMsg;
41
+
42
+ interface CommandMsg extends WsMessage {
43
+ type: "command";
44
+ id: string;
45
+ action: string;
46
+ params?: Record<string, unknown>;
47
+ }
48
+
49
+ type CommandHandler = (cmd: CommandMsg) => void;
50
+
51
+ // ─── Constants ──────────────────────────────────────
52
+ const BACKOFF_STEPS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
53
+ const MAX_QUEUE_SIZE = 50;
54
+ const HEARTBEAT_INTERVAL_MS = 60_000; // 60s via WS
55
+
56
+ // ─── State ──────────────────────────────────────────
57
+ let ws: WebSocket | null = null;
58
+ let connected = false;
59
+ let reconnecting = false;
60
+ let reconnectAttempt = 0;
61
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
62
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
63
+ let commandHandler: CommandHandler | null = null;
64
+ let outboundQueue: OutboundMsg[] = [];
65
+ let wsUrl = "";
66
+ let shouldConnect = false;
67
+
68
+ // Credentials for first-message auth
69
+ let deviceId = "";
70
+ let secretKey = "";
71
+
72
+ // For heartbeat payload
73
+ let getHeartbeatData: (() => HeartbeatMsg) | null = null;
74
+
75
+ // ─── Public API ─────────────────────────────────────
76
+
77
+ export function connect(opts: {
78
+ cloudUrl: string;
79
+ deviceId: string;
80
+ secretKey: string;
81
+ heartbeatFn: () => HeartbeatMsg;
82
+ }): void {
83
+ // No secret_key in URL — auth via first message after connect
84
+ wsUrl = `${opts.cloudUrl.replace(/^http/, "ws")}/ws/device`;
85
+ deviceId = opts.deviceId;
86
+ secretKey = opts.secretKey;
87
+ getHeartbeatData = opts.heartbeatFn;
88
+ shouldConnect = true;
89
+ reconnectAttempt = 0;
90
+ doConnect();
91
+ }
92
+
93
+ export function disconnect(): void {
94
+ shouldConnect = false;
95
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
96
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
97
+ if (ws) {
98
+ try { ws.close(1000, "shutdown"); } catch {}
99
+ ws = null;
100
+ }
101
+ connected = false;
102
+ outboundQueue = [];
103
+ }
104
+
105
+ export function send(msg: OutboundMsg): void {
106
+ if (connected && ws?.readyState === WebSocket.OPEN) {
107
+ ws.send(JSON.stringify(msg));
108
+ } else {
109
+ outboundQueue.push(msg);
110
+ if (outboundQueue.length > MAX_QUEUE_SIZE) outboundQueue.shift();
111
+ }
112
+ }
113
+
114
+ export function onCommand(handler: CommandHandler): void {
115
+ commandHandler = handler;
116
+ }
117
+
118
+ export function isConnected(): boolean {
119
+ return connected;
120
+ }
121
+
122
+ // ─── Internal ───────────────────────────────────────
123
+
124
+ function doConnect(): void {
125
+ if (!shouldConnect || reconnecting) return;
126
+ reconnecting = true;
127
+
128
+ try {
129
+ ws = new WebSocket(wsUrl);
130
+ } catch {
131
+ reconnecting = false;
132
+ scheduleReconnect();
133
+ return;
134
+ }
135
+
136
+ ws.onopen = () => {
137
+ reconnecting = false;
138
+ log("INFO", "Cloud WS connected, sending auth");
139
+
140
+ // Send auth as first message (not in URL)
141
+ ws!.send(JSON.stringify({
142
+ type: "auth",
143
+ deviceId,
144
+ secretKey,
145
+ timestamp: new Date().toISOString(),
146
+ version: 1,
147
+ }));
148
+
149
+ connected = true;
150
+ reconnectAttempt = 0;
151
+
152
+ // Flush queued messages
153
+ while (outboundQueue.length > 0 && connected) {
154
+ const msg = outboundQueue.shift()!;
155
+ ws!.send(JSON.stringify(msg));
156
+ }
157
+
158
+ // Send immediate heartbeat
159
+ if (getHeartbeatData) send(getHeartbeatData());
160
+
161
+ // Start periodic heartbeat
162
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
163
+ heartbeatTimer = setInterval(() => {
164
+ if (getHeartbeatData && connected) send(getHeartbeatData());
165
+ }, HEARTBEAT_INTERVAL_MS);
166
+ };
167
+
168
+ ws.onmessage = (event) => {
169
+ try {
170
+ const msg = JSON.parse(String(event.data)) as CommandMsg;
171
+ if (msg.type === "command" && commandHandler) {
172
+ commandHandler(msg);
173
+ }
174
+ } catch {} // ignore malformed
175
+ };
176
+
177
+ ws.onclose = () => {
178
+ connected = false;
179
+ reconnecting = false;
180
+ ws = null;
181
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
182
+ if (shouldConnect) scheduleReconnect();
183
+ };
184
+
185
+ ws.onerror = () => {
186
+ // onclose will fire after onerror — reconnect handled there
187
+ };
188
+ }
189
+
190
+ function scheduleReconnect(): void {
191
+ if (!shouldConnect || reconnectTimer) return;
192
+ const base = BACKOFF_STEPS[Math.min(reconnectAttempt, BACKOFF_STEPS.length - 1)]!;
193
+ // Add ±30% jitter to prevent thundering herd after Cloud deploy
194
+ const jitter = base * (0.7 + Math.random() * 0.6);
195
+ const delay = Math.round(jitter);
196
+ reconnectAttempt++;
197
+ log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt})`);
198
+ reconnectTimer = setTimeout(() => {
199
+ reconnectTimer = null;
200
+ doConnect();
201
+ }, delay);
202
+ }
203
+
204
+ function log(level: string, msg: string): void {
205
+ const ts = new Date().toISOString();
206
+ const logFile = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "ppm.log");
207
+ try { appendFileSync(logFile, `[${ts}] [${level}] [cloud-ws] ${msg}\n`); } catch {}
208
+ }
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
6
  const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
7
- const CURRENT_SCHEMA_VERSION = 8;
7
+ const CURRENT_SCHEMA_VERSION = 9;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -240,6 +240,17 @@ function runMigrations(database: Database): void {
240
240
  PRAGMA user_version = 8;
241
241
  `);
242
242
  }
243
+
244
+ if (current < 9) {
245
+ database.exec(`
246
+ CREATE TABLE IF NOT EXISTS session_pins (
247
+ session_id TEXT PRIMARY KEY,
248
+ pinned_at TEXT DEFAULT (datetime('now'))
249
+ );
250
+
251
+ PRAGMA user_version = 9;
252
+ `);
253
+ }
243
254
  }
244
255
 
245
256
  // ---------------------------------------------------------------------------
@@ -350,6 +361,25 @@ export function getSessionTitles(sessionIds: string[]): Record<string, string> {
350
361
  return result;
351
362
  }
352
363
 
364
+ // ---------------------------------------------------------------------------
365
+ // Session pin helpers
366
+ // ---------------------------------------------------------------------------
367
+
368
+ export function pinSession(sessionId: string): void {
369
+ getDb().query(
370
+ "INSERT INTO session_pins (session_id, pinned_at) VALUES (?, datetime('now')) ON CONFLICT(session_id) DO UPDATE SET pinned_at = datetime('now')",
371
+ ).run(sessionId);
372
+ }
373
+
374
+ export function unpinSession(sessionId: string): void {
375
+ getDb().query("DELETE FROM session_pins WHERE session_id = ?").run(sessionId);
376
+ }
377
+
378
+ export function getPinnedSessionIds(): Set<string> {
379
+ const rows = getDb().query("SELECT session_id FROM session_pins ORDER BY pinned_at DESC").all() as { session_id: string }[];
380
+ return new Set(rows.map((r) => r.session_id));
381
+ }
382
+
353
383
  // ---------------------------------------------------------------------------
354
384
  // Push subscription helpers
355
385
  // ---------------------------------------------------------------------------
@@ -37,6 +37,24 @@ let tunnelChild: Subprocess | null = null;
37
37
  let tunnelUrl: string | null = null;
38
38
  let shuttingDown = false;
39
39
 
40
+ type SupervisorState = "running" | "paused" | "upgrading";
41
+ let supervisorState: SupervisorState = "running";
42
+
43
+ let resumeResolve: (() => void) | null = null;
44
+
45
+ function waitForResume(): Promise<void> {
46
+ return new Promise((resolve) => {
47
+ resumeResolve = resolve;
48
+ });
49
+ }
50
+
51
+ function triggerResume(): void {
52
+ if (resumeResolve) {
53
+ resumeResolve();
54
+ resumeResolve = null;
55
+ }
56
+ }
57
+
40
58
  let serverRestarts = 0;
41
59
  let lastServerCrash = 0;
42
60
  let tunnelRestarts = 0;
@@ -129,8 +147,25 @@ export async function spawnServer(
129
147
  serverRestarts++;
130
148
 
131
149
  if (serverRestarts > MAX_RESTARTS) {
132
- log("FATAL", `Server exceeded ${MAX_RESTARTS} restarts, giving up`);
133
- shutdown();
150
+ log("WARN", `Server exceeded ${MAX_RESTARTS} restarts, pausing`);
151
+ notifyStateChange("running", "paused", "max_restarts_exceeded");
152
+ supervisorState = "paused";
153
+ updateStatus({
154
+ state: "paused",
155
+ pid: null,
156
+ pausedAt: new Date().toISOString(),
157
+ pauseReason: "max_restarts",
158
+ lastCrashError: `exit ${exitCode}`,
159
+ });
160
+ // Wait for resume signal — supervisor stays alive
161
+ await waitForResume();
162
+ // Resumed — reset and respawn
163
+ notifyStateChange("paused", "running", "user_resume");
164
+ supervisorState = "running";
165
+ serverRestarts = 0;
166
+ updateStatus({ state: "running", pausedAt: null, pauseReason: null });
167
+ log("INFO", "Resuming server after pause");
168
+ if (!shuttingDown) return spawnServer(serverArgs, logFd);
134
169
  return;
135
170
  }
136
171
 
@@ -189,12 +224,7 @@ async function syncUrlToCloud(url: string) {
189
224
  } catch {}
190
225
  }
191
226
 
192
- function startCloudHeartbeat(url: string) {
193
- if (heartbeatTimer) clearInterval(heartbeatTimer);
194
- heartbeatTimer = setInterval(() => {
195
- if (tunnelUrl) syncUrlToCloud(tunnelUrl);
196
- }, 5 * 60 * 1000);
197
- }
227
+ // HTTP heartbeat removed — WS is the sole heartbeat mechanism (Phase 4)
198
228
 
199
229
  export async function spawnTunnel(port: number): Promise<void> {
200
230
  let bin: string;
@@ -230,9 +260,8 @@ export async function spawnTunnel(port: number): Promise<void> {
230
260
  updateStatus({ shareUrl: tunnelUrl, tunnelPid: tunnelChild.pid });
231
261
  log("INFO", `Tunnel ready: ${tunnelUrl} (PID: ${tunnelChild.pid})`);
232
262
 
233
- // Sync new URL to cloud immediately + start periodic heartbeat
263
+ // One-time sync of tunnel URL to cloud (WS handles periodic heartbeat)
234
264
  await syncUrlToCloud(tunnelUrl);
235
- startCloudHeartbeat(tunnelUrl);
236
265
 
237
266
  const exitCode = await tunnelChild.exited;
238
267
  tunnelChild = null;
@@ -330,6 +359,9 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
330
359
  try {
331
360
  // Prevent spawnServer crash-restart loop from respawning killed children
332
361
  shuttingDown = true;
362
+ notifyStateChange(supervisorState, "upgrading", "self_replace");
363
+ supervisorState = "upgrading";
364
+ updateStatus({ state: "upgrading" });
333
365
 
334
366
  // Kill server + tunnel children FIRST to free the port for the new supervisor
335
367
  log("INFO", "Stopping server and tunnel before spawning new supervisor");
@@ -372,20 +404,158 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
372
404
  log("ERROR", "Self-replace timeout: new supervisor did not start");
373
405
  try { child.kill(); } catch {}
374
406
  shuttingDown = false;
407
+ notifyStateChange("upgrading", "running", "upgrade_failed");
408
+ supervisorState = "running";
409
+ updateStatus({ state: "running" });
375
410
  return { success: false, error: "New supervisor failed to start within 30s" };
376
411
  } catch (e) {
377
412
  log("ERROR", `Self-replace error: ${e}`);
378
413
  shuttingDown = false;
414
+ notifyStateChange("upgrading", "running", "upgrade_failed");
415
+ supervisorState = "running";
416
+ updateStatus({ state: "running" });
379
417
  return { success: false, error: (e as Error).message };
380
418
  }
381
419
  }
382
420
 
421
+ // ─── Cloud WS integration ─────────────────────────────────────────────
422
+
423
+ /** Notify Cloud of supervisor state change via WS */
424
+ async function notifyStateChange(from: string, to: string, reason: string) {
425
+ try {
426
+ const { send, isConnected } = await import("./cloud-ws.service.ts");
427
+ if (isConnected()) {
428
+ send({
429
+ type: "state_change",
430
+ from,
431
+ to,
432
+ reason,
433
+ timestamp: new Date().toISOString(),
434
+ });
435
+ }
436
+ } catch {}
437
+ }
438
+
439
+ /** Connect supervisor to Cloud via WebSocket (if device is linked) */
440
+ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd: number) {
441
+ try {
442
+ const { getCloudDevice } = await import("./cloud.service.ts");
443
+ const device = getCloudDevice();
444
+ if (!device) return; // not linked to cloud
445
+
446
+ const { connect, onCommand } = await import("./cloud-ws.service.ts");
447
+ const { VERSION } = await import("../version.ts");
448
+ const startTime = Date.now();
449
+
450
+ connect({
451
+ cloudUrl: device.cloud_url,
452
+ deviceId: device.device_id,
453
+ secretKey: device.secret_key,
454
+ heartbeatFn: () => ({
455
+ type: "heartbeat" as const,
456
+ tunnelUrl,
457
+ state: supervisorState,
458
+ appVersion: VERSION,
459
+ serverPid: serverChild?.pid ?? null,
460
+ uptime: Math.floor((Date.now() - startTime) / 1000),
461
+ timestamp: new Date().toISOString(),
462
+ }),
463
+ });
464
+
465
+ // Handle commands from Cloud
466
+ onCommand(async (cmd) => {
467
+ const { send } = await import("./cloud-ws.service.ts");
468
+ const sendResult = (success: boolean, error?: string, data?: Record<string, unknown>) => {
469
+ send({
470
+ type: "command_result",
471
+ id: cmd.id,
472
+ success,
473
+ error,
474
+ data,
475
+ timestamp: new Date().toISOString(),
476
+ });
477
+ };
478
+
479
+ log("INFO", `Cloud command received: ${cmd.action}`);
480
+
481
+ switch (cmd.action) {
482
+ case "restart":
483
+ if (serverChild) {
484
+ serverRestartRequested = true;
485
+ try { serverChild.kill(); } catch {}
486
+ sendResult(true);
487
+ } else if (supervisorState === "paused") {
488
+ triggerResume();
489
+ sendResult(true);
490
+ } else {
491
+ sendResult(false, "No server child to restart");
492
+ }
493
+ break;
494
+
495
+ case "resume":
496
+ if (supervisorState === "paused") {
497
+ triggerResume();
498
+ sendResult(true);
499
+ } else {
500
+ sendResult(false, "Not in paused state");
501
+ }
502
+ break;
503
+
504
+ case "stop":
505
+ sendResult(true);
506
+ // Delay exit to allow WS buffer to flush
507
+ setTimeout(() => {
508
+ shutdown();
509
+ process.exit(0);
510
+ }, 500);
511
+ break;
512
+
513
+ case "upgrade":
514
+ // Send result BEFORE selfReplace (which exits on success)
515
+ sendResult(true, undefined, { status: "upgrading" });
516
+ await new Promise(r => setTimeout(r, 300));
517
+ const result = await selfReplace();
518
+ // Only reaches here on failure — selfReplace exits on success
519
+ if (!result.success) {
520
+ sendResult(false, result.error);
521
+ if (!serverChild && !shuttingDown) {
522
+ spawnServer(serverArgs, logFd);
523
+ }
524
+ }
525
+ break;
526
+
527
+ case "status":
528
+ sendResult(true, undefined, {
529
+ state: supervisorState,
530
+ serverPid: serverChild?.pid ?? null,
531
+ tunnelUrl,
532
+ serverRestarts,
533
+ });
534
+ break;
535
+
536
+ default:
537
+ sendResult(false, `Unknown action: ${cmd.action}`);
538
+ }
539
+ });
540
+ } catch (e) {
541
+ log("WARN", `Cloud WS setup failed: ${e}`);
542
+ }
543
+ }
544
+
383
545
  // ─── Shutdown ──────────────────────────────────────────────────────────
384
546
  export function shutdown() {
385
547
  if (shuttingDown) return;
386
548
  shuttingDown = true;
387
549
  log("INFO", "Supervisor shutting down");
388
550
 
551
+ // Unblock if paused
552
+ triggerResume();
553
+
554
+ // Disconnect Cloud WS
555
+ import("./cloud-ws.service.ts")
556
+ .then(({ disconnect }) => disconnect())
557
+ .catch(() => {});
558
+
389
559
  if (healthTimer) clearInterval(healthTimer);
390
560
  if (tunnelProbeTimer) clearInterval(tunnelProbeTimer);
391
561
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -414,7 +584,10 @@ export async function runSupervisor(opts: {
414
584
 
415
585
  // Write supervisor PID + clear stale availableVersion from previous run
416
586
  writeFileSync(PID_FILE, String(process.pid));
417
- updateStatus({ supervisorPid: process.pid, port: opts.port, host: opts.host, availableVersion: null });
587
+ updateStatus({
588
+ supervisorPid: process.pid, port: opts.port, host: opts.host, availableVersion: null,
589
+ state: "running", pausedAt: null, pauseReason: null, lastCrashError: null,
590
+ });
418
591
 
419
592
  // Build __serve__ args
420
593
  const serverArgs = [
@@ -428,8 +601,13 @@ export async function runSupervisor(opts: {
428
601
  process.on("SIGTERM", () => { shutdown(); process.exit(0); });
429
602
  process.on("SIGINT", () => { shutdown(); process.exit(0); });
430
603
 
431
- // SIGUSR2 = graceful server restart (tunnel stays alive)
604
+ // SIGUSR2 = graceful server restart (tunnel stays alive) or resume from paused
432
605
  process.on("SIGUSR2", () => {
606
+ if (supervisorState === "paused") {
607
+ log("INFO", "SIGUSR2 received while paused, resuming server");
608
+ triggerResume();
609
+ return;
610
+ }
433
611
  log("INFO", "SIGUSR2 received, restarting server only");
434
612
  if (serverChild) {
435
613
  serverRestartRequested = true; // flag so spawnServer skips backoff
@@ -458,6 +636,9 @@ export async function runSupervisor(opts: {
458
636
  upgradeCheckTimer = setInterval(checkAvailableVersion, UPGRADE_CHECK_INTERVAL_MS);
459
637
  }, UPGRADE_SKIP_INITIAL_MS);
460
638
 
639
+ // Connect to Cloud via WebSocket (if device is linked)
640
+ connectCloud(opts, serverArgs, logFd);
641
+
461
642
  // Spawn server + tunnel in parallel
462
643
  const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];
463
644
 
package/src/types/chat.ts CHANGED
@@ -42,6 +42,7 @@ export interface SessionInfo {
42
42
  projectName?: string;
43
43
  createdAt: string;
44
44
  updatedAt?: string;
45
+ pinned?: boolean;
45
46
  }
46
47
 
47
48
  export interface LimitBucket {
@@ -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 } from "lucide-react";
2
+ import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff } 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";
@@ -149,6 +149,27 @@ export function ChatHistoryBar({
149
149
 
150
150
  const cancelEditing = useCallback(() => setEditingId(null), []);
151
151
 
152
+ const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
153
+ e.stopPropagation();
154
+ if (!projectName) return;
155
+ const url = `${projectUrl(projectName)}/chat/sessions/${session.id}/pin`;
156
+ try {
157
+ if (session.pinned) {
158
+ await api.del(url);
159
+ } else {
160
+ await api.put(url);
161
+ }
162
+ setSessions((prev) => {
163
+ const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
164
+ return updated.sort((a, b) => {
165
+ if (a.pinned && !b.pinned) return -1;
166
+ if (!a.pinned && b.pinned) return 1;
167
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
168
+ });
169
+ });
170
+ } catch { /* silent */ }
171
+ }, [projectName]);
172
+
152
173
  // Filter sessions by search query
153
174
  const filteredSessions = searchQuery.trim()
154
175
  ? sessions.filter((s) => (s.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
@@ -310,9 +331,20 @@ export function ChatHistoryBar({
310
331
  >
311
332
  {session.title || "Untitled"}
312
333
  </button>
334
+ <button
335
+ onClick={(e) => togglePin(e, session)}
336
+ className={`p-0.5 rounded transition-all ${
337
+ session.pinned
338
+ ? "text-primary hover:text-primary/70"
339
+ : "text-text-subtle hover:text-text-secondary md:opacity-0 md:group-hover:opacity-100"
340
+ }`}
341
+ title={session.pinned ? "Unpin session" : "Pin session"}
342
+ >
343
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
344
+ </button>
313
345
  <button
314
346
  onClick={(e) => startEditing(session, e)}
315
- className="p-0.5 rounded text-text-subtle hover:text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity"
347
+ className="p-0.5 rounded text-text-subtle hover:text-text-secondary md:opacity-0 md:group-hover:opacity-100 transition-opacity"
316
348
  title="Rename session"
317
349
  >
318
350
  <Pencil className="size-3" />
@@ -320,7 +352,7 @@ export function ChatHistoryBar({
320
352
  </>
321
353
  )}
322
354
  {editingId !== session.id && session.updatedAt && (
323
- <span className="text-[10px] text-text-subtle shrink-0">{formatDate(session.updatedAt)}</span>
355
+ <span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
324
356
  )}
325
357
  </div>
326
358
  ))