@hienlh/ppm 0.9.0-beta.8 → 0.9.1

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 (159) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/bun.lock +17 -0
  3. package/dist/web/assets/api-settings-BUvk6Saw.js +1 -0
  4. package/dist/web/assets/arrow-up-BYhx9ckd.js +1 -0
  5. package/dist/web/assets/browser-tab-CrkhFCaw.js +1 -0
  6. package/dist/web/assets/chat-tab-C6jpiwh7.js +8 -0
  7. package/dist/web/assets/chevron-right-5HgK6l7K.js +1 -0
  8. package/dist/web/assets/code-editor-CBIPzlP2.js +2 -0
  9. package/dist/web/assets/columns-2-cEVJHYd7.js +1 -0
  10. package/dist/web/assets/createLucideIcon-PuMiQgHl.js +1 -0
  11. package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-ncSOnJSC.js} +2 -2
  12. package/dist/web/assets/database-viewer-BqOJR_zi.js +1 -0
  13. package/dist/web/assets/diff-viewer-CcLyp4eY.js +4 -0
  14. package/dist/web/assets/{dist-CALwEtco.js → dist-DIV6WgAG.js} +1 -1
  15. package/dist/web/assets/{dist-DGDPTxs1.js → dist-ovWkrgO-.js} +1 -1
  16. package/dist/web/assets/extension-webview-NiZ7Ybvv.js +3 -0
  17. package/dist/web/assets/git-graph-CoTvMrIo.js +1 -0
  18. package/dist/web/assets/index-C8byznLO.js +37 -0
  19. package/dist/web/assets/index-KwC2YrG4.css +2 -0
  20. package/dist/web/assets/jsx-runtime-kMwlnEGE.js +1 -0
  21. package/dist/web/assets/keybindings-store-DPYzBe_M.js +1 -0
  22. package/dist/web/assets/{markdown-renderer-DklUd_Gv.js → markdown-renderer-DPLdR9xc.js} +4 -4
  23. package/dist/web/assets/postgres-viewer-BeiK4lCa.js +1 -0
  24. package/dist/web/assets/settings-tab-D3AvU4lu.js +1 -0
  25. package/dist/web/assets/sqlite-viewer-nA2sD4Yv.js +1 -0
  26. package/dist/web/assets/tab-store-BOgTrqRr.js +1 -0
  27. package/dist/web/assets/table-DFevCOMd.js +1 -0
  28. package/dist/web/assets/tag-CXMT0QB6.js +1 -0
  29. package/dist/web/assets/{terminal-tab-CqRuiIFn.js → terminal-tab-BBi0pEji.js} +2 -2
  30. package/dist/web/assets/{use-monaco-theme-Dcz3aLAE.js → use-monaco-theme-B5pG2d1w.js} +1 -1
  31. package/dist/web/index.html +8 -8
  32. package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
  33. package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
  34. package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
  35. package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
  36. package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
  37. package/dist/web/sw.js +1 -1
  38. package/docs/code-standards.md +128 -1
  39. package/docs/codebase-summary.md +79 -12
  40. package/docs/extension-development-guide.md +532 -0
  41. package/docs/project-changelog.md +51 -1
  42. package/docs/project-roadmap.md +9 -3
  43. package/docs/streaming-input-guide.md +267 -0
  44. package/docs/system-architecture.md +432 -3
  45. package/package.json +6 -3
  46. package/packages/ext-database/package.json +41 -0
  47. package/packages/ext-database/src/connection-tree.ts +142 -0
  48. package/packages/ext-database/src/extension.ts +346 -0
  49. package/packages/ext-database/src/query-panel.ts +120 -0
  50. package/packages/ext-database/src/table-viewer-panel.ts +410 -0
  51. package/packages/ext-database/tsconfig.json +8 -0
  52. package/packages/vscode-compat/package.json +16 -0
  53. package/packages/vscode-compat/src/commands.ts +39 -0
  54. package/packages/vscode-compat/src/context.ts +65 -0
  55. package/packages/vscode-compat/src/disposable.ts +21 -0
  56. package/packages/vscode-compat/src/env.ts +20 -0
  57. package/packages/vscode-compat/src/event-emitter.ts +28 -0
  58. package/packages/vscode-compat/src/index.ts +93 -0
  59. package/packages/vscode-compat/src/not-supported.ts +15 -0
  60. package/packages/vscode-compat/src/types.ts +167 -0
  61. package/packages/vscode-compat/src/uri.ts +65 -0
  62. package/packages/vscode-compat/src/window.ts +229 -0
  63. package/packages/vscode-compat/src/workspace.ts +76 -0
  64. package/packages/vscode-compat/tsconfig.json +10 -0
  65. package/snapshot-state.md +1526 -0
  66. package/src/cli/commands/autostart.ts +1 -1
  67. package/src/cli/commands/ext-cmd.ts +121 -0
  68. package/src/cli/commands/restart.ts +9 -1
  69. package/src/cli/commands/status.ts +19 -0
  70. package/src/index.ts +5 -3
  71. package/src/providers/claude-agent-sdk.ts +221 -17
  72. package/src/providers/cli-provider-base.ts +6 -0
  73. package/src/server/index.ts +55 -155
  74. package/src/server/routes/chat.ts +81 -11
  75. package/src/server/routes/extensions.ts +81 -0
  76. package/src/server/routes/project-scoped.ts +2 -0
  77. package/src/server/routes/settings.ts +27 -0
  78. package/src/server/routes/workspace.ts +35 -0
  79. package/src/server/ws/chat.ts +9 -3
  80. package/src/server/ws/extensions.ts +175 -0
  81. package/src/services/account-selector.service.ts +14 -5
  82. package/src/services/account.service.ts +20 -15
  83. package/src/services/claude-usage.service.ts +29 -24
  84. package/src/services/cloud-ws.service.ts +228 -0
  85. package/src/services/cloud.service.ts +11 -6
  86. package/src/services/contribution-registry.ts +110 -0
  87. package/src/services/db.service.ts +181 -4
  88. package/src/services/extension-host-worker.ts +160 -0
  89. package/src/services/extension-installer.ts +112 -0
  90. package/src/services/extension-manifest.ts +65 -0
  91. package/src/services/extension-rpc-handlers.ts +235 -0
  92. package/src/services/extension-rpc.ts +105 -0
  93. package/src/services/extension.service.ts +228 -0
  94. package/src/services/mcp-config.service.ts +15 -6
  95. package/src/services/supervisor.ts +271 -25
  96. package/src/types/api.ts +1 -0
  97. package/src/types/chat.ts +4 -0
  98. package/src/types/extension-messages.ts +64 -0
  99. package/src/types/extension.ts +131 -0
  100. package/src/web/app.tsx +69 -48
  101. package/src/web/components/chat/account-rotation-settings.tsx +163 -0
  102. package/src/web/components/chat/chat-history-bar.tsx +106 -10
  103. package/src/web/components/chat/chat-tab.tsx +15 -10
  104. package/src/web/components/chat/chat-welcome.tsx +148 -0
  105. package/src/web/components/chat/message-list.tsx +19 -6
  106. package/src/web/components/chat/session-picker.tsx +80 -32
  107. package/src/web/components/chat/usage-badge.tsx +68 -8
  108. package/src/web/components/editor/editor-breadcrumb.tsx +20 -29
  109. package/src/web/components/extensions/extension-inputbox.tsx +92 -0
  110. package/src/web/components/extensions/extension-quickpick.tsx +194 -0
  111. package/src/web/components/extensions/extension-tree-view.tsx +240 -0
  112. package/src/web/components/extensions/extension-webview.tsx +83 -0
  113. package/src/web/components/layout/command-palette.tsx +22 -2
  114. package/src/web/components/layout/editor-panel.tsx +163 -18
  115. package/src/web/components/layout/mobile-nav.tsx +2 -1
  116. package/src/web/components/layout/sidebar.tsx +21 -3
  117. package/src/web/components/layout/status-bar.tsx +64 -0
  118. package/src/web/components/layout/tab-bar.tsx +2 -0
  119. package/src/web/components/layout/tab-content.tsx +5 -0
  120. package/src/web/components/layout/upgrade-banner.tsx +15 -5
  121. package/src/web/components/settings/change-password-section.tsx +128 -0
  122. package/src/web/components/settings/extension-manager-section.tsx +214 -0
  123. package/src/web/components/settings/settings-tab.tsx +9 -2
  124. package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
  125. package/src/web/hooks/use-chat.ts +28 -0
  126. package/src/web/hooks/use-extension-ws.ts +181 -0
  127. package/src/web/hooks/use-global-keybindings.ts +18 -2
  128. package/src/web/hooks/use-server-reload.ts +9 -0
  129. package/src/web/hooks/use-url-sync.ts +173 -21
  130. package/src/web/stores/connection-store.ts +39 -0
  131. package/src/web/stores/extension-store.ts +204 -0
  132. package/src/web/stores/panel-store.ts +63 -9
  133. package/src/web/stores/panel-utils.ts +145 -3
  134. package/src/web/stores/settings-store.ts +7 -2
  135. package/src/web/stores/tab-store.ts +2 -1
  136. package/test-session-ops.mjs +444 -0
  137. package/test-tokens.mjs +212 -0
  138. package/tsconfig.json +3 -1
  139. package/dist/web/assets/api-settings-D21InCnR.js +0 -1
  140. package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
  141. package/dist/web/assets/browser-tab-BEe89aSD.js +0 -1
  142. package/dist/web/assets/chat-tab-9lqvWozA.js +0 -7
  143. package/dist/web/assets/chevron-right-CHnjJt4E.js +0 -1
  144. package/dist/web/assets/code-editor-COAIZx-B.js +0 -2
  145. package/dist/web/assets/columns-2-DbesTfa7.js +0 -1
  146. package/dist/web/assets/database-viewer-aRR9n_Ui.js +0 -1
  147. package/dist/web/assets/diff-viewer-C4KMvpHr.js +0 -4
  148. package/dist/web/assets/dist-CVTST7Gc.js +0 -1
  149. package/dist/web/assets/git-graph-CfJjl4E3.js +0 -1
  150. package/dist/web/assets/index-Db8uky1a.css +0 -2
  151. package/dist/web/assets/index-DxZuwBDe.js +0 -37
  152. package/dist/web/assets/jsx-runtime-BRW_vwa9.js +0 -1
  153. package/dist/web/assets/keybindings-store-_uWVCZMv.js +0 -1
  154. package/dist/web/assets/postgres-viewer-DEAvAyaX.js +0 -1
  155. package/dist/web/assets/settings-tab-BQedc-No.js +0 -1
  156. package/dist/web/assets/sqlite-viewer-BPA5idzT.js +0 -1
  157. package/dist/web/assets/tab-store-DhK6EpBT.js +0 -1
  158. package/dist/web/assets/table-CQVQM2SB.js +0 -1
  159. package/dist/web/assets/tag-Q2dZiSPX.js +0 -1
@@ -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;
@@ -362,12 +363,16 @@ export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
362
363
  }
363
364
  }
364
365
 
365
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
366
+ // Survive Bun --hot reloads: persist timer ref across module re-evaluations
367
+ const CLOUD_HOT_KEY = "__PPM_CLOUD_HEARTBEAT__" as const;
368
+ const cloudHotState = ((globalThis as any)[CLOUD_HOT_KEY] ??= {
369
+ heartbeatTimer: null as ReturnType<typeof setInterval> | null,
370
+ }) as { heartbeatTimer: ReturnType<typeof setInterval> | null };
366
371
 
367
372
  /** Start periodic heartbeat (call once after tunnel URL is obtained) */
368
373
  export function startHeartbeat(tunnelUrl: string): void {
369
374
  // Clear any existing heartbeat to prevent duplicates on restart
370
- if (heartbeatTimer) clearInterval(heartbeatTimer);
375
+ if (cloudHotState.heartbeatTimer) clearInterval(cloudHotState.heartbeatTimer);
371
376
 
372
377
  // Initial heartbeat immediately
373
378
  sendHeartbeat(tunnelUrl).then((ok) => {
@@ -376,16 +381,16 @@ export function startHeartbeat(tunnelUrl: string): void {
376
381
  });
377
382
 
378
383
  // Periodic heartbeat every 5 minutes
379
- heartbeatTimer = setInterval(() => {
384
+ cloudHotState.heartbeatTimer = setInterval(() => {
380
385
  sendHeartbeat(tunnelUrl).catch(() => {});
381
386
  }, HEARTBEAT_INTERVAL_MS);
382
387
  }
383
388
 
384
389
  /** Stop periodic heartbeat */
385
390
  export function stopHeartbeat(): void {
386
- if (heartbeatTimer) {
387
- clearInterval(heartbeatTimer);
388
- heartbeatTimer = null;
391
+ if (cloudHotState.heartbeatTimer) {
392
+ clearInterval(cloudHotState.heartbeatTimer);
393
+ cloudHotState.heartbeatTimer = null;
389
394
  }
390
395
  }
391
396
 
@@ -0,0 +1,110 @@
1
+ import type { ExtensionContributes, ContributedCommand, ContributedView, ContributedMenu } from "../types/extension.ts";
2
+
3
+ /**
4
+ * In-memory registry of all contribution points from enabled extensions.
5
+ * Populated when extensions activate, cleared when they deactivate.
6
+ */
7
+ class ContributionRegistry {
8
+ private commands = new Map<string, ContributedCommand & { extId: string }>();
9
+ private views = new Map<string, Map<string, ContributedView & { extId: string }>>();
10
+ private configs = new Map<string, Record<string, unknown>>();
11
+ private menus = new Map<string, Array<ContributedMenu & { extId: string }>>();
12
+
13
+ register(extId: string, contributes: ExtensionContributes): void {
14
+ if (contributes.commands) {
15
+ for (const cmd of contributes.commands) {
16
+ this.commands.set(cmd.command, { ...cmd, extId });
17
+ }
18
+ }
19
+ if (contributes.views) {
20
+ for (const [location, views] of Object.entries(contributes.views)) {
21
+ if (!this.views.has(location)) this.views.set(location, new Map());
22
+ const locationMap = this.views.get(location)!;
23
+ for (const view of views) {
24
+ locationMap.set(view.id, { ...view, extId });
25
+ }
26
+ }
27
+ }
28
+ if (contributes.configuration?.properties) {
29
+ this.configs.set(extId, contributes.configuration.properties);
30
+ }
31
+ if (contributes.menus) {
32
+ for (const [location, items] of Object.entries(contributes.menus)) {
33
+ if (!this.menus.has(location)) this.menus.set(location, []);
34
+ const list = this.menus.get(location)!;
35
+ for (const item of items) {
36
+ list.push({ ...item, extId });
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ unregister(extId: string): void {
43
+ for (const [key, cmd] of this.commands) {
44
+ if (cmd.extId === extId) this.commands.delete(key);
45
+ }
46
+ for (const [, locationMap] of this.views) {
47
+ for (const [key, view] of locationMap) {
48
+ if (view.extId === extId) locationMap.delete(key);
49
+ }
50
+ }
51
+ for (const [location, items] of this.menus) {
52
+ this.menus.set(location, items.filter((m) => m.extId !== extId));
53
+ }
54
+ this.configs.delete(extId);
55
+ }
56
+
57
+ getCommands(): Array<ContributedCommand & { extId: string }> {
58
+ return [...this.commands.values()];
59
+ }
60
+
61
+ getViews(location?: string): Array<ContributedView & { extId: string }> {
62
+ if (location) {
63
+ return [...(this.views.get(location)?.values() ?? [])];
64
+ }
65
+ const all: Array<ContributedView & { extId: string }> = [];
66
+ for (const locationMap of this.views.values()) {
67
+ all.push(...locationMap.values());
68
+ }
69
+ return all;
70
+ }
71
+
72
+ getViewLocations(): string[] {
73
+ return [...this.views.keys()];
74
+ }
75
+
76
+ getConfiguration(extId?: string): Record<string, Record<string, unknown>> {
77
+ if (extId) {
78
+ const cfg = this.configs.get(extId);
79
+ return cfg ? { [extId]: cfg } : {};
80
+ }
81
+ return Object.fromEntries(this.configs);
82
+ }
83
+
84
+ /** Get all contributions as a single object (for API responses) */
85
+ getAll() {
86
+ const viewsByLocation: Record<string, Array<ContributedView & { extId: string }>> = {};
87
+ for (const location of this.views.keys()) {
88
+ viewsByLocation[location] = this.getViews(location);
89
+ }
90
+ const menusByLocation: Record<string, Array<ContributedMenu & { extId: string }>> = {};
91
+ for (const [location, items] of this.menus) {
92
+ menusByLocation[location] = items;
93
+ }
94
+ return {
95
+ commands: this.getCommands(),
96
+ views: viewsByLocation,
97
+ menus: menusByLocation,
98
+ configuration: this.getConfiguration(),
99
+ };
100
+ }
101
+
102
+ clear(): void {
103
+ this.commands.clear();
104
+ this.views.clear();
105
+ this.configs.clear();
106
+ this.menus.clear();
107
+ }
108
+ }
109
+
110
+ export const contributionRegistry = new ContributionRegistry();
@@ -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 = 12;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -248,6 +248,95 @@ function runMigrations(database: Database): void {
248
248
  PRAGMA user_version = 8;
249
249
  `);
250
250
  }
251
+
252
+ if (current < 9) {
253
+ database.exec(`
254
+ CREATE TABLE IF NOT EXISTS session_pins (
255
+ session_id TEXT PRIMARY KEY,
256
+ pinned_at TEXT DEFAULT (datetime('now'))
257
+ );
258
+
259
+ PRAGMA user_version = 9;
260
+ `);
261
+ }
262
+
263
+ if (current < 10) {
264
+ database.exec(`
265
+ CREATE TABLE IF NOT EXISTS workspace_state (
266
+ project_name TEXT PRIMARY KEY,
267
+ layout_json TEXT NOT NULL,
268
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
269
+ );
270
+
271
+ PRAGMA user_version = 10;
272
+ `);
273
+ }
274
+
275
+ if (current < 11) {
276
+ try {
277
+ database.exec(`ALTER TABLE session_map ADD COLUMN project_path TEXT`);
278
+ } catch {
279
+ // Column may already exist
280
+ }
281
+ // Backfill project_path from projects table where project_name matches
282
+ database.exec(`
283
+ UPDATE session_map SET project_path = (
284
+ SELECT path FROM projects WHERE projects.name = session_map.project_name
285
+ ) WHERE project_path IS NULL AND project_name IS NOT NULL
286
+ `);
287
+ database.exec(`PRAGMA user_version = 11`);
288
+ }
289
+
290
+ if (current < 12) {
291
+ database.exec(`
292
+ CREATE TABLE IF NOT EXISTS extensions (
293
+ id TEXT PRIMARY KEY,
294
+ version TEXT NOT NULL,
295
+ display_name TEXT,
296
+ description TEXT,
297
+ icon TEXT,
298
+ enabled INTEGER DEFAULT 1,
299
+ manifest TEXT NOT NULL,
300
+ installed_at TEXT DEFAULT (datetime('now')),
301
+ updated_at TEXT DEFAULT (datetime('now'))
302
+ );
303
+
304
+ CREATE TABLE IF NOT EXISTS extension_storage (
305
+ ext_id TEXT NOT NULL,
306
+ scope TEXT NOT NULL,
307
+ key TEXT NOT NULL,
308
+ value TEXT,
309
+ PRIMARY KEY (ext_id, scope, key),
310
+ FOREIGN KEY (ext_id) REFERENCES extensions(id) ON DELETE CASCADE
311
+ );
312
+
313
+ PRAGMA user_version = 12;
314
+ `);
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Workspace helpers
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export interface WorkspaceRow {
323
+ project_name: string;
324
+ layout_json: string;
325
+ updated_at: string;
326
+ }
327
+
328
+ export function getWorkspace(projectName: string): WorkspaceRow | null {
329
+ return getDb().query(
330
+ "SELECT project_name, layout_json, updated_at FROM workspace_state WHERE project_name = ?",
331
+ ).get(projectName) as WorkspaceRow | null;
332
+ }
333
+
334
+ export function setWorkspace(projectName: string, layoutJson: string): string {
335
+ const now = new Date().toISOString();
336
+ getDb().query(
337
+ "INSERT INTO workspace_state (project_name, layout_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(project_name) DO UPDATE SET layout_json = excluded.layout_json, updated_at = excluded.updated_at",
338
+ ).run(projectName, layoutJson, now);
339
+ return now;
251
340
  }
252
341
 
253
342
  // ---------------------------------------------------------------------------
@@ -318,10 +407,15 @@ export function getSessionMapping(ppmId: string): string | null {
318
407
  return row?.sdk_id ?? null;
319
408
  }
320
409
 
321
- export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string): void {
410
+ export function getSessionProjectPath(ppmId: string): string | null {
411
+ const row = getDb().query("SELECT project_path FROM session_map WHERE ppm_id = ?").get(ppmId) as { project_path: string } | null;
412
+ return row?.project_path ?? null;
413
+ }
414
+
415
+ export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string, projectPath?: string): void {
322
416
  getDb().query(
323
- "INSERT INTO session_map (ppm_id, sdk_id, project_name) VALUES (?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = excluded.project_name",
324
- ).run(ppmId, sdkId, projectName ?? null);
417
+ "INSERT INTO session_map (ppm_id, sdk_id, project_name, project_path) VALUES (?, ?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = COALESCE(excluded.project_name, session_map.project_name), project_path = COALESCE(excluded.project_path, session_map.project_path)",
418
+ ).run(ppmId, sdkId, projectName ?? null, projectPath ?? null);
325
419
  }
326
420
 
327
421
  export function getAllSessionMappings(): Record<string, string> {
@@ -358,6 +452,33 @@ export function getSessionTitles(sessionIds: string[]): Record<string, string> {
358
452
  return result;
359
453
  }
360
454
 
455
+ // ---------------------------------------------------------------------------
456
+ // Session pin helpers
457
+ // ---------------------------------------------------------------------------
458
+
459
+ export function pinSession(sessionId: string): void {
460
+ getDb().query(
461
+ "INSERT INTO session_pins (session_id, pinned_at) VALUES (?, datetime('now')) ON CONFLICT(session_id) DO UPDATE SET pinned_at = datetime('now')",
462
+ ).run(sessionId);
463
+ }
464
+
465
+ export function unpinSession(sessionId: string): void {
466
+ getDb().query("DELETE FROM session_pins WHERE session_id = ?").run(sessionId);
467
+ }
468
+
469
+ export function getPinnedSessionIds(): Set<string> {
470
+ const rows = getDb().query("SELECT session_id FROM session_pins ORDER BY pinned_at DESC").all() as { session_id: string }[];
471
+ return new Set(rows.map((r) => r.session_id));
472
+ }
473
+
474
+ export function deleteSessionMapping(ppmId: string): void {
475
+ getDb().query("DELETE FROM session_map WHERE ppm_id = ?").run(ppmId);
476
+ }
477
+
478
+ export function deleteSessionTitle(sessionId: string): void {
479
+ getDb().query("DELETE FROM session_titles WHERE session_id = ?").run(sessionId);
480
+ }
481
+
361
482
  // ---------------------------------------------------------------------------
362
483
  // Push subscription helpers
363
484
  // ---------------------------------------------------------------------------
@@ -717,5 +838,61 @@ export function incrementAccountRequests(id: string): void {
717
838
  getDb().query("UPDATE accounts SET total_requests = total_requests + 1 WHERE id = ?").run(id);
718
839
  }
719
840
 
841
+ // ---------------------------------------------------------------------------
842
+ // Extension helpers
843
+ // ---------------------------------------------------------------------------
844
+
845
+ import type { ExtensionRow, ExtensionStorageRow } from "../types/extension.ts";
846
+
847
+ export function getExtensions(): ExtensionRow[] {
848
+ return getDb().query("SELECT * FROM extensions ORDER BY display_name, id").all() as ExtensionRow[];
849
+ }
850
+
851
+ export function getExtensionById(id: string): ExtensionRow | null {
852
+ return getDb().query("SELECT * FROM extensions WHERE id = ?").get(id) as ExtensionRow | null;
853
+ }
854
+
855
+ export function insertExtension(row: Omit<ExtensionRow, "installed_at" | "updated_at">): void {
856
+ getDb().query(
857
+ `INSERT INTO extensions (id, version, display_name, description, icon, enabled, manifest)
858
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
859
+ ).run(row.id, row.version, row.display_name, row.description, row.icon, row.enabled, row.manifest);
860
+ }
861
+
862
+ export function updateExtension(id: string, updates: Partial<Pick<ExtensionRow, "version" | "display_name" | "description" | "icon" | "enabled" | "manifest">>): void {
863
+ const sets: string[] = [];
864
+ const vals: unknown[] = [];
865
+ if (updates.version !== undefined) { sets.push("version = ?"); vals.push(updates.version); }
866
+ if (updates.display_name !== undefined) { sets.push("display_name = ?"); vals.push(updates.display_name); }
867
+ if (updates.description !== undefined) { sets.push("description = ?"); vals.push(updates.description); }
868
+ if (updates.icon !== undefined) { sets.push("icon = ?"); vals.push(updates.icon); }
869
+ if (updates.enabled !== undefined) { sets.push("enabled = ?"); vals.push(updates.enabled); }
870
+ if (updates.manifest !== undefined) { sets.push("manifest = ?"); vals.push(updates.manifest); }
871
+ if (sets.length === 0) return;
872
+ sets.push("updated_at = datetime('now')");
873
+ vals.push(id);
874
+ getDb().query(`UPDATE extensions SET ${sets.join(", ")} WHERE id = ?`).run(...(vals as SQLQueryBindings[]));
875
+ }
876
+
877
+ export function deleteExtension(id: string): void {
878
+ getDb().query("DELETE FROM extensions WHERE id = ?").run(id);
879
+ }
880
+
881
+ export function getExtensionStorage(extId: string, scope: string): ExtensionStorageRow[] {
882
+ return getDb().query("SELECT * FROM extension_storage WHERE ext_id = ? AND scope = ?").all(extId, scope) as ExtensionStorageRow[];
883
+ }
884
+
885
+ export function setExtensionStorageValue(extId: string, scope: string, key: string, value: string | null): void {
886
+ getDb().query(
887
+ `INSERT INTO extension_storage (ext_id, scope, key, value)
888
+ VALUES (?, ?, ?, ?)
889
+ ON CONFLICT(ext_id, scope, key) DO UPDATE SET value = excluded.value`,
890
+ ).run(extId, scope, key, value);
891
+ }
892
+
893
+ export function deleteExtensionStorage(extId: string): void {
894
+ getDb().query("DELETE FROM extension_storage WHERE ext_id = ?").run(extId);
895
+ }
896
+
720
897
  // Auto-close on process exit
721
898
  process.on("beforeExit", closeDb);
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Extension Host Worker — runs inside a Bun Worker thread.
3
+ * Loads, activates, and deactivates extensions in isolation.
4
+ * Communicates with the main process via typed RPC (postMessage).
5
+ */
6
+ import { RpcChannel } from "./extension-rpc.ts";
7
+ import { createVscodeCompat } from "@ppm/vscode-compat";
8
+ import type { WindowService } from "@ppm/vscode-compat/src/window.ts";
9
+ import type { CommandService } from "@ppm/vscode-compat/src/commands.ts";
10
+ import type { Disposable, RpcMessage } from "../types/extension.ts";
11
+
12
+ // Active extension instances: id → { module, context, deactivate, services }
13
+ const activeExtensions = new Map<string, {
14
+ deactivate?: () => void | Promise<void>;
15
+ context: { subscriptions: Disposable[] };
16
+ window?: WindowService;
17
+ commands?: CommandService;
18
+ }>();
19
+
20
+ const rpc = new RpcChannel((msg) => postMessage(msg));
21
+
22
+ // Listen for messages from main process
23
+ declare const self: Worker;
24
+ self.addEventListener("message", (event: MessageEvent<RpcMessage>) => {
25
+ rpc.handleMessage(event.data);
26
+ });
27
+
28
+ // --- RPC handlers ---
29
+
30
+ rpc.onRequest("ext:activate", async (params) => {
31
+ const [extId, entryPath, extensionPath, storedState, baseUrl] = params as [string, string, string, Record<string, Record<string, string | null>>?, string?];
32
+ if (activeExtensions.has(extId)) return { ok: true, already: true };
33
+
34
+ // Expose server base URL so extensions can use fetch() with absolute URLs
35
+ if (baseUrl) (globalThis as any).__PPM_BASE_URL__ = baseUrl;
36
+
37
+ // Create RpcClient adapter for vscode-compat (Worker's RPC → vscode-compat interface)
38
+ const rpcClient = {
39
+ request: <T = unknown>(method: string, ...p: unknown[]) => rpc.sendRequest<T>(method, ...p),
40
+ notify: (event: string, data: unknown) => rpc.sendEvent(event, data),
41
+ };
42
+
43
+ // Create vscode-compat API scoped to this extension
44
+ const api = createVscodeCompat({
45
+ extensionId: extId,
46
+ extensionPath,
47
+ storagePath: `${extensionPath}/.storage`,
48
+ rpc: rpcClient,
49
+ storedState: storedState as { global?: Record<string, string | null>; workspace?: Record<string, string | null> },
50
+ });
51
+
52
+ const context = api._createContext();
53
+
54
+ try {
55
+ const mod = await import(entryPath);
56
+ const activateFn = mod.activate || mod.default?.activate;
57
+ if (typeof activateFn === "function") {
58
+ // Activation timeout: 10s max to prevent hanging extensions
59
+ const activatePromise = Promise.resolve(activateFn(context, api));
60
+ const timeoutPromise = new Promise((_, reject) =>
61
+ setTimeout(() => reject(new Error(`Activation timeout (10s) for ${extId}`)), 10_000),
62
+ );
63
+ await Promise.race([activatePromise, timeoutPromise]);
64
+ }
65
+ activeExtensions.set(extId, {
66
+ deactivate: mod.deactivate || mod.default?.deactivate,
67
+ context,
68
+ window: api.window as WindowService,
69
+ commands: api.commands as CommandService,
70
+ });
71
+ return { ok: true };
72
+ } catch (e) {
73
+ const msg = e instanceof Error ? e.message : String(e);
74
+ console.error(`[ExtHost] Failed to activate ${extId}:`, msg);
75
+ return { ok: false, error: msg };
76
+ }
77
+ });
78
+
79
+ rpc.onRequest("ext:deactivate", async (params) => {
80
+ const [extId] = params as [string];
81
+ const ext = activeExtensions.get(extId);
82
+ if (!ext) return { ok: true, already: true };
83
+
84
+ try {
85
+ if (typeof ext.deactivate === "function") {
86
+ await ext.deactivate();
87
+ }
88
+ // Dispose all subscriptions
89
+ for (const sub of ext.context.subscriptions) {
90
+ try { (sub as Disposable).dispose(); } catch {}
91
+ }
92
+ activeExtensions.delete(extId);
93
+ return { ok: true };
94
+ } catch (e) {
95
+ const msg = e instanceof Error ? e.message : String(e);
96
+ console.error(`[ExtHost] Failed to deactivate ${extId}:`, msg);
97
+ activeExtensions.delete(extId);
98
+ return { ok: false, error: msg };
99
+ }
100
+ });
101
+
102
+ rpc.onRequest("ext:command:execute", async (params) => {
103
+ const [command, ...args] = params as [string, ...unknown[]];
104
+ for (const [, ext] of activeExtensions) {
105
+ if (ext.commands) {
106
+ try {
107
+ const result = await (ext.commands as any).executeCommand(command, ...args);
108
+ return { ok: true, result };
109
+ } catch {
110
+ // Command not found in this extension, try next
111
+ }
112
+ }
113
+ }
114
+ return { ok: false, error: `Command not found: ${command}` };
115
+ });
116
+
117
+ // Deliver webview messages from browser → extension's onDidReceiveMessage
118
+ rpc.onRequest("ext:webview:message", async (params) => {
119
+ const [panelId, message] = params as [string, unknown];
120
+ for (const [, ext] of activeExtensions) {
121
+ if (!ext.window) continue;
122
+ try {
123
+ if ((ext.window as any)._deliverWebviewMessage(panelId, message)) {
124
+ return { ok: true };
125
+ }
126
+ } catch (e) {
127
+ console.error(`[ExtHost] webview:message error (${panelId}):`, e);
128
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
129
+ }
130
+ }
131
+ return { ok: false, error: `No handler for panel ${panelId}` };
132
+ });
133
+
134
+ // Handle tree:expand — get children for a tree node
135
+ rpc.onRequest("ext:tree:expand", async (params) => {
136
+ const [viewId, itemId] = params as [string, string | undefined];
137
+ for (const [, ext] of activeExtensions) {
138
+ if (ext.window) {
139
+ try {
140
+ const items = await (ext.window as any)._getTreeChildren(viewId, itemId);
141
+ if (items.length > 0) return { ok: true, items };
142
+ } catch (e) {
143
+ console.error(`[ExtHost] tree:expand error (${viewId}):`, e);
144
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
145
+ }
146
+ }
147
+ }
148
+ return { ok: true, items: [] };
149
+ });
150
+
151
+ rpc.onRequest("ext:list-active", () => {
152
+ return [...activeExtensions.keys()];
153
+ });
154
+
155
+ rpc.onRequest("ext:ping", () => "pong");
156
+
157
+ // ExtensionContext is now created by @ppm/vscode-compat's createVscodeCompat()._createContext()
158
+
159
+ // Notify main process that worker is ready
160
+ rpc.sendEvent("worker:ready", {});
@@ -0,0 +1,112 @@
1
+ import { resolve } from "node:path";
2
+ import { existsSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
3
+ import type { ExtensionManifest } from "../types/extension.ts";
4
+ import { getExtensionById, insertExtension, updateExtension, deleteExtension, deleteExtensionStorage } from "./db.service.ts";
5
+ import { readManifestAt } from "./extension-manifest.ts";
6
+
7
+ const INSTALL_TIMEOUT = 60_000;
8
+ const NPM_PACKAGE_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[^@]+)?$/;
9
+
10
+ /** Ensure ~/.ppm/extensions/ dir + isolated package.json exist */
11
+ export function ensureExtensionsDir(extensionsDir: string): void {
12
+ if (!existsSync(extensionsDir)) {
13
+ mkdirSync(extensionsDir, { recursive: true });
14
+ }
15
+ const pkgJsonPath = resolve(extensionsDir, "package.json");
16
+ if (!existsSync(pkgJsonPath)) {
17
+ writeFileSync(pkgJsonPath, JSON.stringify({ name: "ppm-extensions", private: true, dependencies: {} }, null, 2));
18
+ }
19
+ }
20
+
21
+ /** Install an npm package into the extensions directory and persist to DB */
22
+ export async function installExtension(name: string, extensionsDir: string): Promise<ExtensionManifest> {
23
+ if (!NPM_PACKAGE_RE.test(name)) throw new Error(`Invalid package name: ${name}`);
24
+ ensureExtensionsDir(extensionsDir);
25
+
26
+ const proc = Bun.spawn(["bun", "add", name], {
27
+ cwd: extensionsDir,
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+
32
+ const timeout = setTimeout(() => proc.kill(), INSTALL_TIMEOUT);
33
+ const exitCode = await proc.exited;
34
+ clearTimeout(timeout);
35
+
36
+ if (exitCode !== 0) {
37
+ const stderr = await new Response(proc.stderr).text();
38
+ throw new Error(`Install failed (exit ${exitCode}): ${stderr.slice(0, 500)}`);
39
+ }
40
+
41
+ const pkgDir = resolve(extensionsDir, "node_modules", name);
42
+ const manifest = readManifestAt(pkgDir);
43
+ if (!manifest) throw new Error(`Installed ${name} but no valid manifest found`);
44
+
45
+ upsertExtensionInDb(manifest);
46
+ console.log(`[ExtService] Installed ${manifest.id}@${manifest.version}`);
47
+ return manifest;
48
+ }
49
+
50
+ /** Remove an extension from disk + DB */
51
+ export async function removeExtension(id: string, extensionsDir: string): Promise<void> {
52
+ try {
53
+ const proc = Bun.spawn(["bun", "remove", id], {
54
+ cwd: extensionsDir,
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ });
58
+ await proc.exited;
59
+ } catch (e) {
60
+ console.error(`[ExtService] npm remove ${id} failed (DB record still removed):`, e);
61
+ }
62
+
63
+ deleteExtensionStorage(id);
64
+ deleteExtension(id);
65
+ console.log(`[ExtService] Removed ${id}`);
66
+ }
67
+
68
+ /** Symlink a local extension path for development */
69
+ export function devLinkExtension(localPath: string, extensionsDir: string): ExtensionManifest {
70
+ const absPath = resolve(localPath);
71
+ const manifest = readManifestAt(absPath);
72
+ if (!manifest) throw new Error(`No valid package.json at ${absPath}`);
73
+
74
+ ensureExtensionsDir(extensionsDir);
75
+ const nodeModules = resolve(extensionsDir, "node_modules");
76
+ if (!existsSync(nodeModules)) mkdirSync(nodeModules, { recursive: true });
77
+
78
+ const targetDir = resolve(nodeModules, manifest.id);
79
+ const parentDir = resolve(targetDir, "..");
80
+ if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
81
+
82
+ if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
83
+ symlinkSync(absPath, targetDir, "dir");
84
+
85
+ upsertExtensionInDb(manifest);
86
+ console.log(`[ExtService] Dev-linked ${manifest.id} → ${absPath}`);
87
+ return manifest;
88
+ }
89
+
90
+ /** Insert or update extension record in DB */
91
+ function upsertExtensionInDb(manifest: ExtensionManifest): void {
92
+ const existing = getExtensionById(manifest.id);
93
+ if (existing) {
94
+ updateExtension(manifest.id, {
95
+ version: manifest.version,
96
+ display_name: manifest.displayName ?? null,
97
+ description: manifest.description ?? null,
98
+ icon: manifest.icon ?? null,
99
+ manifest: JSON.stringify(manifest),
100
+ });
101
+ } else {
102
+ insertExtension({
103
+ id: manifest.id,
104
+ version: manifest.version,
105
+ display_name: manifest.displayName ?? null,
106
+ description: manifest.description ?? null,
107
+ icon: manifest.icon ?? null,
108
+ enabled: 1,
109
+ manifest: JSON.stringify(manifest),
110
+ });
111
+ }
112
+ }