@hienlh/ppm 0.9.80 → 0.9.82

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 (162) hide show
  1. package/.opencode/.env.example +98 -0
  2. package/.opencode/skills/ads-management/scripts/.env.example +13 -0
  3. package/.opencode/skills/ai-multimodal/.env.example +230 -0
  4. package/.opencode/skills/cip-design/.env.example +6 -0
  5. package/.opencode/skills/devops/.env.example +76 -0
  6. package/.opencode/skills/docs-seeker/.env.example +15 -0
  7. package/.opencode/skills/elevenlabs/.env.example +3 -0
  8. package/.opencode/skills/marketing-dashboard/.env.example +15 -0
  9. package/.opencode/skills/marketing-dashboard/app/.env.example +2 -0
  10. package/.opencode/skills/marketing-dashboard/server/.env.example +2 -0
  11. package/.opencode/skills/mcp-management/scripts/dist/analyze-tools.js +70 -0
  12. package/.opencode/skills/mcp-management/scripts/dist/cli.js +160 -0
  13. package/.opencode/skills/mcp-management/scripts/dist/mcp-client.js +183 -0
  14. package/.opencode/skills/payment-integration/scripts/.env.example +20 -0
  15. package/.opencode/skills/sequential-thinking/.env.example +8 -0
  16. package/.repomixignore +22 -0
  17. package/AGENTS.md +62 -0
  18. package/CHANGELOG.md +17 -0
  19. package/CLAUDE.md +12 -0
  20. package/assets/skills/ppm-guide/SKILL.md +61 -0
  21. package/bun.lock +9 -1
  22. package/dist/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  23. package/dist/web/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  24. package/dist/web/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  25. package/dist/web/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  26. package/dist/web/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  27. package/dist/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  28. package/dist/web/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  29. package/dist/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  30. package/dist/web/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  31. package/dist/web/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  32. package/dist/web/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  33. package/dist/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  34. package/dist/web/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  35. package/dist/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  36. package/dist/web/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  37. package/dist/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  38. package/dist/web/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  39. package/dist/web/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  40. package/dist/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  41. package/dist/web/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  42. package/dist/web/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  43. package/dist/web/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  44. package/dist/web/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  45. package/dist/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  46. package/dist/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  47. package/dist/web/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  48. package/dist/web/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  49. package/dist/web/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  50. package/dist/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  51. package/dist/web/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  52. package/dist/web/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  53. package/dist/web/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  54. package/dist/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  55. package/dist/web/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  56. package/dist/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  57. package/dist/web/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  58. package/dist/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  59. package/dist/web/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  60. package/dist/web/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  61. package/dist/web/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  62. package/dist/web/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  63. package/dist/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  64. package/dist/web/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  65. package/dist/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  66. package/dist/web/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  67. package/dist/web/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  68. package/dist/web/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  69. package/dist/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  70. package/dist/web/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  71. package/dist/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  72. package/dist/web/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  73. package/dist/web/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  74. package/dist/web/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  75. package/dist/web/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  76. package/dist/web/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  77. package/dist/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  78. package/dist/web/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  79. package/dist/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  80. package/dist/web/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  81. package/dist/web/assets/chat-tab-bS86TsT5.js +10 -0
  82. package/dist/web/assets/{code-editor-BFe-hnpF.js → code-editor-BaNaQ33b.js} +1 -1
  83. package/dist/web/assets/{database-viewer-BeY2V5QI.js → database-viewer-C5MVw8cJ.js} +1 -1
  84. package/dist/web/assets/{diff-viewer-D6xzs8PP.js → diff-viewer-CUbFMWVo.js} +1 -1
  85. package/dist/web/assets/{extension-webview-Cd1XYFXO.js → extension-webview-CwGufYEP.js} +1 -1
  86. package/dist/web/assets/{git-graph-D2XXpiMQ.js → git-graph-BD7A7MLo.js} +1 -1
  87. package/dist/web/assets/index-BYXjCNlK.css +2 -0
  88. package/dist/web/assets/index-CpzkPHOC.js +30 -0
  89. package/dist/web/assets/keybindings-store-DsaANvBz.js +1 -0
  90. package/dist/web/assets/markdown-renderer-C19IsITh.js +326 -0
  91. package/dist/web/assets/{port-forwarding-tab-B5rj_I66.js → port-forwarding-tab-BF79F1iL.js} +1 -1
  92. package/dist/web/assets/{postgres-viewer-DnlqzOnm.js → postgres-viewer-_nYiO_wp.js} +1 -1
  93. package/dist/web/assets/{settings-tab-CNZpuPD3.js → settings-tab-C1SQMbSu.js} +1 -1
  94. package/dist/web/assets/{sql-query-editor-Df2kzbPj.js → sql-query-editor-6OFvxxuN.js} +1 -1
  95. package/dist/web/assets/{sqlite-viewer-Cj1G70z4.js → sqlite-viewer-SNVYFXvB.js} +1 -1
  96. package/dist/web/assets/{terminal-tab-Dv9A7Xe2.js → terminal-tab-BJEkmrDt.js} +1 -1
  97. package/dist/web/assets/{use-monaco-theme-CPfIEo8t.js → use-monaco-theme-r8FzlCWr.js} +1 -1
  98. package/dist/web/index.html +2 -2
  99. package/dist/web/sw.js +1 -1
  100. package/docs/codebase-summary.md +78 -0
  101. package/docs/project-changelog.md +29 -0
  102. package/docs/system-architecture.md +2 -0
  103. package/package.json +5 -2
  104. package/release-manifest.json +15784 -0
  105. package/scripts/check-ppm-dir-usage.sh +21 -0
  106. package/scripts/generate-ppm-guide.ts +92 -0
  107. package/src/cli/commands/init.ts +2 -1
  108. package/src/cli/commands/logs.ts +11 -11
  109. package/src/cli/commands/report.ts +3 -2
  110. package/src/cli/commands/restart.ts +22 -23
  111. package/src/cli/commands/skills-cmd.ts +123 -0
  112. package/src/cli/commands/status.ts +7 -8
  113. package/src/cli/commands/stop.ts +18 -19
  114. package/src/index.ts +3 -0
  115. package/src/lib/account-crypto.ts +12 -7
  116. package/src/providers/claude-agent-sdk.ts +42 -11
  117. package/src/server/index.ts +8 -8
  118. package/src/server/routes/chat.ts +4 -2
  119. package/src/server/routes/upgrade.ts +3 -5
  120. package/src/server/ws/chat.ts +31 -0
  121. package/src/services/cloud-ws.service.ts +6 -3
  122. package/src/services/cloud.service.ts +20 -19
  123. package/src/services/cloudflared.service.ts +13 -13
  124. package/src/services/config.service.ts +5 -7
  125. package/src/services/db.service.ts +5 -6
  126. package/src/services/extension-rpc-handlers.ts +2 -2
  127. package/src/services/extension.service.ts +9 -12
  128. package/src/services/ppm-dir.ts +14 -0
  129. package/src/services/slash-discovery/builtin-commands.ts +53 -0
  130. package/src/services/slash-discovery/builtin-handlers.ts +65 -0
  131. package/src/services/slash-discovery/definition-source.ts +27 -0
  132. package/src/services/slash-discovery/discover-skill-roots.ts +128 -0
  133. package/src/services/slash-discovery/fuzzy-search.ts +76 -0
  134. package/src/services/slash-discovery/index.ts +42 -0
  135. package/src/services/slash-discovery/resolve-overrides.ts +41 -0
  136. package/src/services/slash-discovery/skill-loader.ts +156 -0
  137. package/src/services/slash-discovery/types.ts +51 -0
  138. package/src/services/slash-items.service.ts +4 -182
  139. package/src/services/supervisor-state.ts +14 -15
  140. package/src/services/supervisor-stopped-page.ts +2 -4
  141. package/src/services/supervisor.ts +15 -15
  142. package/src/services/tunnel.service.ts +22 -5
  143. package/src/services/upgrade.service.ts +2 -3
  144. package/src/types/chat.ts +3 -1
  145. package/src/web/components/chat/chat-history-bar.tsx +2 -15
  146. package/src/web/components/chat/chat-tab.tsx +5 -2
  147. package/src/web/components/chat/message-input.tsx +48 -6
  148. package/src/web/components/chat/message-list.tsx +19 -5
  149. package/src/web/components/chat/slash-command-picker.tsx +21 -12
  150. package/src/web/components/layout/mobile-nav.tsx +47 -21
  151. package/src/web/components/layout/panel-layout.tsx +11 -0
  152. package/src/web/components/layout/upgrade-banner.tsx +48 -2
  153. package/src/web/components/shared/markdown-renderer.tsx +5 -2
  154. package/src/web/hooks/use-chat.ts +33 -1
  155. package/src/web/main.tsx +1 -0
  156. package/src/web/stores/panel-store.ts +25 -1
  157. package/src/web/styles/globals.css +14 -0
  158. package/dist/web/assets/chat-tab-CmSLt4tg.js +0 -10
  159. package/dist/web/assets/index-BtwsLrdT.css +0 -2
  160. package/dist/web/assets/index-D6_wwsL_.js +0 -30
  161. package/dist/web/assets/keybindings-store-C8ryKudw.js +0 -1
  162. package/dist/web/assets/markdown-renderer-xYMhd9cE.js +0 -69
@@ -109,6 +109,7 @@ interface StreamingSession {
109
109
  */
110
110
  interface PendingApproval {
111
111
  resolve: (result: { approved: boolean; data?: unknown }) => void;
112
+ sessionId: string;
112
113
  }
113
114
 
114
115
  /**
@@ -360,7 +361,13 @@ export class ClaudeAgentSdkProvider implements AIProvider {
360
361
  this.closeStreamingSession(sessionId);
361
362
  this.activeSessions.delete(sessionId);
362
363
  this.messageCount.delete(sessionId);
363
- this.pendingApprovals.delete(sessionId);
364
+ // Resolve and clean up all pending approvals for this session
365
+ for (const [reqId, pending] of this.pendingApprovals) {
366
+ if (pending.sessionId === sessionId) {
367
+ pending.resolve({ approved: false });
368
+ this.pendingApprovals.delete(reqId);
369
+ }
370
+ }
364
371
  this.forkSources.delete(sessionId);
365
372
 
366
373
  // Best-effort: delete JSONL from ~/.claude/projects/
@@ -472,10 +479,32 @@ export class ClaudeAgentSdkProvider implements AIProvider {
472
479
  }
473
480
 
474
481
  async *sendMessage(
475
- sessionId: string,
482
+ _sessionId: string,
476
483
  message: string,
477
484
  opts?: import("./provider.interface.ts").SendMessageOpts & { forkSession?: boolean; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> },
478
485
  ): AsyncIterable<ChatEvent> {
486
+ // SDK requires valid UUID session IDs. Short/random IDs can leak from
487
+ // tab derivation or URL parsing — migrate to a real UUID early.
488
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
489
+ let sessionId = _sessionId;
490
+ if (!UUID_RE.test(sessionId)) {
491
+ const newId = crypto.randomUUID();
492
+ console.warn(`[sdk] session=${sessionId} is not a valid UUID — migrating to ${newId}`);
493
+ // Migrate internal maps
494
+ const oldMeta = this.activeSessions.get(sessionId);
495
+ if (oldMeta) {
496
+ this.activeSessions.delete(sessionId);
497
+ oldMeta.id = newId;
498
+ this.activeSessions.set(newId, oldMeta);
499
+ }
500
+ const oldCount = this.messageCount.get(sessionId);
501
+ if (oldCount != null) { this.messageCount.set(newId, oldCount); this.messageCount.delete(sessionId); }
502
+ const oldStream = this.streamingSessions.get(sessionId);
503
+ if (oldStream) { this.streamingSessions.set(newId, oldStream); this.streamingSessions.delete(sessionId); }
504
+ yield { type: "session_migrated" as const, oldSessionId: sessionId, newSessionId: newId };
505
+ sessionId = newId;
506
+ }
507
+
479
508
  // Follow-up: push into existing streaming session, yield nothing
480
509
  const existingStream = this.streamingSessions.get(sessionId);
481
510
  if (existingStream) {
@@ -543,15 +572,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
543
572
  */
544
573
  const waitForApproval = (toolName: string, input: unknown): Promise<{ approved: boolean; data?: unknown }> => {
545
574
  const requestId = crypto.randomUUID();
546
- const APPROVAL_TIMEOUT_MS = 5 * 60_000;
575
+ // No timeout approval waits indefinitely until user responds or session cleanup resolves it.
547
576
  const promise = new Promise<{ approved: boolean; data?: unknown }>((resolve) => {
548
- this.pendingApprovals.set(requestId, { resolve });
549
- setTimeout(() => {
550
- if (this.pendingApprovals.has(requestId)) {
551
- this.pendingApprovals.delete(requestId);
552
- resolve({ approved: false });
553
- }
554
- }, APPROVAL_TIMEOUT_MS);
577
+ this.pendingApprovals.set(requestId, { resolve, sessionId });
555
578
  });
556
579
  approvalEvents.push({ type: "approval_request", requestId, tool: toolName, input });
557
580
  approvalNotify?.();
@@ -609,7 +632,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
609
632
  PreToolUse: [{
610
633
  matcher: ".*", // Match all tools — our hook checks internally
611
634
  hooks: [preToolUseHook],
612
- timeout: 300, // 5min for user approval
613
635
  }],
614
636
  };
615
637
 
@@ -617,6 +639,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
617
639
  let resultSubtype: string | undefined;
618
640
  let resultNumTurns: number | undefined;
619
641
  let resultContextWindowPct: number | undefined;
642
+ let lastAssistantUuid: string | undefined;
620
643
  let yieldedDone = false;
621
644
  try {
622
645
  // Session ID is the canonical ID for both PPM and SDK (no dual-ID mapping).
@@ -977,6 +1000,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
977
1000
  // Partial assistant message — streaming text deltas
978
1001
  if ((msg as any).type === "partial" || (msg as any).type === "stream_event") {
979
1002
  const partial = msg as any;
1003
+ // Track assistant UUID from top-level messages (not subagent children)
1004
+ if (!parentId && partial.uuid) lastAssistantUuid = partial.uuid;
980
1005
  // Handle stream_event (raw API events) for text deltas
981
1006
  if ((msg as any).type === "stream_event") {
982
1007
  const event = partial.event;
@@ -1014,6 +1039,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1014
1039
 
1015
1040
  // Full assistant message
1016
1041
  if (msg.type === "assistant") {
1042
+ // Track assistant UUID from top-level messages (not subagent children)
1043
+ if (!parentId && (msg as any).uuid) lastAssistantUuid = (msg as any).uuid;
1017
1044
  // SDK assistant messages can carry an error field for auth/billing/rate-limit failures
1018
1045
  let assistantError = (msg as any).error as string | undefined;
1019
1046
 
@@ -1361,6 +1388,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1361
1388
  resultSubtype: resultSubtype as any,
1362
1389
  numTurns: resultNumTurns,
1363
1390
  contextWindowPct: resultContextWindowPct,
1391
+ lastMessageUuid: lastAssistantUuid,
1364
1392
  };
1365
1393
 
1366
1394
  // Reset per-turn state for next turn
@@ -1370,6 +1398,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1370
1398
  resultSubtype = undefined;
1371
1399
  resultNumTurns = undefined;
1372
1400
  resultContextWindowPct = undefined;
1401
+ lastAssistantUuid = undefined;
1373
1402
  sdkEventCount = 0;
1374
1403
  continue; // Wait for next turn from generator
1375
1404
  }
@@ -1438,6 +1467,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1438
1467
  resultSubtype: resultSubtype as any,
1439
1468
  numTurns: resultNumTurns,
1440
1469
  contextWindowPct: resultContextWindowPct,
1470
+ lastMessageUuid: lastAssistantUuid,
1441
1471
  };
1442
1472
  }
1443
1473
  }
@@ -1551,6 +1581,7 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
1551
1581
  content: textContent,
1552
1582
  events: events.length > 0 ? events : undefined,
1553
1583
  timestamp: new Date().toISOString(),
1584
+ sdkUuid: msg.uuid,
1554
1585
  };
1555
1586
  }
1556
1587
 
@@ -29,10 +29,10 @@ async function setupLogFile() {
29
29
  (globalThis as any).__PPM_LOG_SETUP__ = true;
30
30
 
31
31
  const { resolve } = await import("node:path");
32
- const { homedir } = await import("node:os");
33
32
  const { appendFileSync, mkdirSync, existsSync } = await import("node:fs");
33
+ const { getPpmDir } = await import("../services/ppm-dir.ts");
34
34
 
35
- const ppmDir = process.env.PPM_HOME || resolve(homedir(), ".ppm");
35
+ const ppmDir = getPpmDir();
36
36
  if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
37
37
  const logPath = resolve(ppmDir, "ppm.log");
38
38
 
@@ -104,9 +104,9 @@ app.get("/api/info", (c) => c.json(ok({
104
104
  // Public: recent logs for bug reports (last 30 lines)
105
105
  app.get("/api/logs/recent", async (c) => {
106
106
  const { resolve } = await import("node:path");
107
- const { homedir } = await import("node:os");
108
107
  const { existsSync, readFileSync } = await import("node:fs");
109
- const logFile = resolve(homedir(), ".ppm", "ppm.log");
108
+ const { getPpmDir } = await import("../services/ppm-dir.ts");
109
+ const logFile = resolve(getPpmDir(), "ppm.log");
110
110
  if (!existsSync(logFile)) return c.json(ok({ logs: "" }));
111
111
  const content = readFileSync(logFile, "utf-8");
112
112
  const lines = content.split("\n").slice(-30).join("\n").trim();
@@ -234,12 +234,12 @@ export async function startServer(options: {
234
234
 
235
235
  {
236
236
  const { resolve } = await import("node:path");
237
- const { homedir } = await import("node:os");
238
237
  const { writeFileSync, readFileSync, mkdirSync, existsSync, openSync } = await import("node:fs");
239
238
  const { isCompiledBinary } = await import("../services/autostart-generator.ts");
240
239
  const { writeCmd, acquireLock, releaseLock } = await import("../services/supervisor-state.ts");
240
+ const { getPpmDir } = await import("../services/ppm-dir.ts");
241
241
 
242
- const ppmDir = process.env.PPM_HOME || resolve(homedir(), ".ppm");
242
+ const ppmDir = getPpmDir();
243
243
  if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
244
244
  const pidFile = resolve(ppmDir, "ppm.pid");
245
245
  const statusFile = resolve(ppmDir, "status.json");
@@ -490,9 +490,9 @@ if (process.argv.includes("__serve__")) {
490
490
  // Also write server version to status.json so supervisor heartbeat reports the actual running version.
491
491
  try {
492
492
  const { resolve: r } = await import("node:path");
493
- const { homedir: h } = await import("node:os");
494
493
  const { readFileSync: rf, writeFileSync: wf, renameSync: rn } = await import("node:fs");
495
- const statusFile = r(h(), ".ppm", "status.json");
494
+ const { getPpmDir: gd } = await import("../services/ppm-dir.ts");
495
+ const statusFile = r(gd(), "status.json");
496
496
  const status = JSON.parse(rf(statusFile, "utf-8"));
497
497
  // Write running server version — source of truth for heartbeat
498
498
  status.serverVersion = VERSION;
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { chatService } from "../../services/chat.service.ts";
6
6
  import { providerRegistry } from "../../providers/registry.ts";
7
7
  import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sdk";
8
- import { listSlashItems } from "../../services/slash-items.service.ts";
8
+ import { listSlashItems, searchSlashItems } 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
11
  import { getSessionProjectPath, setSessionMetadata, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionMetadata, deleteSessionTitle } from "../../services/db.service.ts";
@@ -19,7 +19,9 @@ export const chatRoutes = new Hono<Env>();
19
19
  chatRoutes.get("/slash-items", (c) => {
20
20
  try {
21
21
  const projectPath = c.get("projectPath");
22
- const items = listSlashItems(projectPath);
22
+ const q = c.req.query("q");
23
+ let items = listSlashItems(projectPath);
24
+ if (q) items = searchSlashItems(items, q);
23
25
  return c.json(ok(items));
24
26
  } catch (e) {
25
27
  return c.json(err((e as Error).message), 500);
@@ -1,6 +1,5 @@
1
1
  import { Hono } from "hono";
2
2
  import { resolve } from "node:path";
3
- import { homedir } from "node:os";
4
3
  import { readFileSync, existsSync } from "node:fs";
5
4
  import { VERSION } from "../../version.ts";
6
5
  import {
@@ -10,8 +9,7 @@ import {
10
9
  signalSupervisorUpgrade,
11
10
  } from "../../services/upgrade.service.ts";
12
11
  import { ok, err } from "../../types/api.ts";
13
-
14
- const STATUS_FILE = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "status.json");
12
+ import { getPpmDir } from "../../services/ppm-dir.ts";
15
13
 
16
14
  export const upgradeRoutes = new Hono();
17
15
 
@@ -19,8 +17,8 @@ export const upgradeRoutes = new Hono();
19
17
  upgradeRoutes.get("/", (c) => {
20
18
  let availableVersion: string | null = null;
21
19
  try {
22
- if (existsSync(STATUS_FILE)) {
23
- const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
20
+ if (existsSync(resolve(getPpmDir(), "status.json"))) {
21
+ const data = JSON.parse(readFileSync(resolve(getPpmDir(), "status.json"), "utf-8"));
24
22
  const candidate = data.availableVersion ?? null;
25
23
  // Only report if actually newer than current version
26
24
  if (candidate && compareSemver(VERSION, candidate) < 0) {
@@ -85,6 +85,21 @@ function bufferAndBroadcast(sessionId: string, event: unknown): void {
85
85
  if (entry.turnEvents.length < MAX_TURN_EVENTS) {
86
86
  entry.turnEvents.push({ ...(event as Record<string, unknown>) });
87
87
  }
88
+ // Enrich: embed tool_result onto matching tool_use for reconnect reliability.
89
+ // Reconnecting clients may miss separate tool_result events — this ensures
90
+ // the tool_use event itself carries the result as a fallback.
91
+ if (evType === "tool_result") {
92
+ const toolUseId = (event as any)?.toolUseId;
93
+ if (toolUseId) {
94
+ for (let i = entry.turnEvents.length - 1; i >= 0; i--) {
95
+ const buffered = entry.turnEvents[i] as any;
96
+ if (buffered.type === "tool_use" && buffered.toolUseId === toolUseId) {
97
+ buffered.result = { output: (event as any).output, isError: (event as any).isError };
98
+ break;
99
+ }
100
+ }
101
+ }
102
+ }
88
103
  }
89
104
  broadcast(sessionId, event);
90
105
  }
@@ -567,6 +582,22 @@ export const chatWebSocket = {
567
582
  entry.permissionMode = parsed.permissionMode;
568
583
  }
569
584
 
585
+ // Intercept PPM-handled built-in commands (e.g. /skills, /version)
586
+ const content = parsed.content.trim();
587
+ const slashMatch = content.match(/^\/(\S+)/);
588
+ if (slashMatch) {
589
+ const { isPpmHandled, executeBuiltin } = await import("../../services/slash-discovery/index.ts");
590
+ const cmdName = slashMatch[1]!;
591
+ if (isPpmHandled(cmdName)) {
592
+ const response = executeBuiltin(cmdName, entry.projectPath ?? "");
593
+ if (response) {
594
+ broadcast(sessionId, { type: "text", content: response });
595
+ broadcast(sessionId, { type: "done", resultSubtype: "builtin", numTurns: 0 });
596
+ return;
597
+ }
598
+ }
599
+ }
600
+
570
601
  const provider = providerRegistry.get(providerId);
571
602
 
572
603
  if (!entry.isStreamingActive) {
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { appendFileSync } from "node:fs";
6
6
  import { resolve } from "node:path";
7
- import { homedir } from "node:os";
7
+ import { getPpmDir } from "./ppm-dir.ts";
8
8
 
9
9
  // ─── Types (must match Cloud's ws-types.ts) ─────────
10
10
  interface WsMessage {
@@ -152,7 +152,9 @@ function doConnect(): void {
152
152
  sock.onopen = () => {
153
153
  if (ws !== sock) return; // stale — newer connection replaced us
154
154
  reconnecting = false;
155
- reconnectAttempt = 0;
155
+ // Don't reset reconnectAttempt here — only after auth succeeds.
156
+ // Resetting on open causes tight reconnect loops when the server
157
+ // keeps closing immediately after connect (backoff never builds up).
156
158
  log("INFO", "Cloud WS connected, sending auth");
157
159
 
158
160
  // Send auth as first message — server must process this before any other msg
@@ -170,6 +172,7 @@ function doConnect(): void {
170
172
  setTimeout(() => {
171
173
  if (ws !== sock) return; // replaced during delay
172
174
  connected = true;
175
+ reconnectAttempt = 0; // Auth succeeded — reset backoff
173
176
 
174
177
  // Flush queued messages
175
178
  while (outboundQueue.length > 0 && connected) {
@@ -234,6 +237,6 @@ function scheduleReconnect(source = "unknown"): void {
234
237
 
235
238
  function log(level: string, msg: string): void {
236
239
  const ts = new Date().toISOString();
237
- const logFile = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "ppm.log");
240
+ const logFile = resolve(getPpmDir(), "ppm.log");
238
241
  try { appendFileSync(logFile, `[${ts}] [${level}] [cloud-ws] ${msg}\n`); } catch {}
239
242
  }
@@ -1,14 +1,14 @@
1
1
  import { resolve } from "node:path";
2
- import { homedir, hostname } from "node:os";
2
+ import { hostname } from "node:os";
3
3
  import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { VERSION } from "../version.ts";
6
6
  import { configService } from "./config.service.ts";
7
+ import { getPpmDir } from "./ppm-dir.ts";
7
8
 
8
- const PPM_DIR = resolve(homedir(), ".ppm");
9
- const AUTH_FILE = resolve(PPM_DIR, "cloud-auth.json");
10
- const DEVICE_FILE = resolve(PPM_DIR, "cloud-device.json");
11
- const MACHINE_ID_FILE = resolve(PPM_DIR, "machine-id");
9
+ const authFile = () => resolve(getPpmDir(), "cloud-auth.json");
10
+ const deviceFile = () => resolve(getPpmDir(), "cloud-device.json");
11
+ const machineIdFile = () => resolve(getPpmDir(), "machine-id");
12
12
 
13
13
  const DEFAULT_CLOUD_URL = "https://ppm.hienle.tech";
14
14
  const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -47,12 +47,12 @@ interface DeviceInfo {
47
47
 
48
48
  /** Get or generate a stable machine ID (random UUID, persists across reboots) */
49
49
  export function getMachineId(): string {
50
- if (existsSync(MACHINE_ID_FILE)) {
51
- return readFileSync(MACHINE_ID_FILE, "utf-8").trim();
50
+ if (existsSync(machineIdFile())) {
51
+ return readFileSync(machineIdFile(), "utf-8").trim();
52
52
  }
53
53
  const id = randomBytes(16).toString("hex");
54
54
  ensurePpmDir();
55
- writeFileSync(MACHINE_ID_FILE, id);
55
+ writeFileSync(machineIdFile(), id);
56
56
  return id;
57
57
  }
58
58
 
@@ -61,8 +61,8 @@ export function getMachineId(): string {
61
61
  /** Read saved cloud auth credentials */
62
62
  export function getCloudAuth(): CloudAuth | null {
63
63
  try {
64
- if (!existsSync(AUTH_FILE)) return null;
65
- return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
64
+ if (!existsSync(authFile())) return null;
65
+ return JSON.parse(readFileSync(authFile(), "utf-8"));
66
66
  } catch {
67
67
  return null;
68
68
  }
@@ -71,13 +71,13 @@ export function getCloudAuth(): CloudAuth | null {
71
71
  /** Save cloud auth credentials (restricted permissions) */
72
72
  export function saveCloudAuth(auth: CloudAuth): void {
73
73
  ensurePpmDir();
74
- writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
75
- try { chmodSync(AUTH_FILE, 0o600); } catch {}
74
+ writeFileSync(authFile(), JSON.stringify(auth, null, 2));
75
+ try { chmodSync(authFile(), 0o600); } catch {}
76
76
  }
77
77
 
78
78
  /** Remove cloud auth credentials */
79
79
  export function removeCloudAuth(): void {
80
- try { if (existsSync(AUTH_FILE)) unlinkSync(AUTH_FILE); } catch {}
80
+ try { if (existsSync(authFile())) unlinkSync(authFile()); } catch {}
81
81
  }
82
82
 
83
83
  // ─── Device ─────────────────────────────────────────────────────────────
@@ -85,8 +85,8 @@ export function removeCloudAuth(): void {
85
85
  /** Read saved cloud device info */
86
86
  export function getCloudDevice(): CloudDevice | null {
87
87
  try {
88
- if (!existsSync(DEVICE_FILE)) return null;
89
- return JSON.parse(readFileSync(DEVICE_FILE, "utf-8"));
88
+ if (!existsSync(deviceFile())) return null;
89
+ return JSON.parse(readFileSync(deviceFile(), "utf-8"));
90
90
  } catch {
91
91
  return null;
92
92
  }
@@ -95,13 +95,13 @@ export function getCloudDevice(): CloudDevice | null {
95
95
  /** Save cloud device info (restricted permissions) */
96
96
  export function saveCloudDevice(device: CloudDevice): void {
97
97
  ensurePpmDir();
98
- writeFileSync(DEVICE_FILE, JSON.stringify(device, null, 2));
99
- try { chmodSync(DEVICE_FILE, 0o600); } catch {}
98
+ writeFileSync(deviceFile(), JSON.stringify(device, null, 2));
99
+ try { chmodSync(deviceFile(), 0o600); } catch {}
100
100
  }
101
101
 
102
102
  /** Remove cloud device info */
103
103
  export function removeCloudDevice(): void {
104
- try { if (existsSync(DEVICE_FILE)) unlinkSync(DEVICE_FILE); } catch {}
104
+ try { if (existsSync(deviceFile())) unlinkSync(deviceFile()); } catch {}
105
105
  }
106
106
 
107
107
  // ─── API Client ─────────────────────────────────────────────────────────
@@ -398,7 +398,8 @@ export function stopHeartbeat(): void {
398
398
  // ─── Helpers ────────────────────────────────────────────────────────────
399
399
 
400
400
  function ensurePpmDir(): void {
401
- if (!existsSync(PPM_DIR)) mkdirSync(PPM_DIR, { recursive: true });
401
+ const dir = getPpmDir();
402
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
402
403
  }
403
404
 
404
405
  function openBrowser(url: string): void {
@@ -1,10 +1,10 @@
1
1
  import { resolve } from "node:path";
2
- import { homedir } from "node:os";
3
2
  import { existsSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
3
+ import { getPpmDir } from "./ppm-dir.ts";
4
4
 
5
- const CLOUDFLARED_DIR = resolve(homedir(), ".ppm", "bin");
6
5
  const isWindows = process.platform === "win32";
7
- const CLOUDFLARED_PATH = resolve(CLOUDFLARED_DIR, isWindows ? "cloudflared.exe" : "cloudflared");
6
+ const cloudflaredDir = () => resolve(getPpmDir(), "bin");
7
+ const cloudflaredPath = () => resolve(cloudflaredDir(), isWindows ? "cloudflared.exe" : "cloudflared");
8
8
 
9
9
  const OS_MAP: Record<string, string> = { darwin: "darwin", linux: "linux", win32: "windows" };
10
10
  const ARCH_MAP: Record<string, string> = { x64: "amd64", arm64: "arm64" };
@@ -67,40 +67,40 @@ async function extractTgz(tgzPath: string, destDir: string): Promise<void> {
67
67
  * Downloads from GitHub releases if missing. Returns path to binary.
68
68
  */
69
69
  export async function ensureCloudflared(): Promise<string> {
70
- if (existsSync(CLOUDFLARED_PATH)) return CLOUDFLARED_PATH;
70
+ if (existsSync(cloudflaredPath())) return cloudflaredPath();
71
71
 
72
- if (!existsSync(CLOUDFLARED_DIR)) {
73
- mkdirSync(CLOUDFLARED_DIR, { recursive: true });
72
+ if (!existsSync(cloudflaredDir())) {
73
+ mkdirSync(cloudflaredDir(), { recursive: true });
74
74
  }
75
75
 
76
76
  const url = getDownloadUrl();
77
77
  const isTgz = url.endsWith(".tgz");
78
78
  const isExe = url.endsWith(".exe");
79
- const tmpPath = resolve(CLOUDFLARED_DIR, isTgz ? "cloudflared.tgz" : isExe ? "cloudflared.exe.tmp" : "cloudflared.tmp");
79
+ const tmpPath = resolve(cloudflaredDir(), isTgz ? "cloudflared.tgz" : isExe ? "cloudflared.exe.tmp" : "cloudflared.tmp");
80
80
 
81
81
  try {
82
82
  const data = await downloadWithProgress(url);
83
83
  await Bun.write(tmpPath, data);
84
84
 
85
85
  if (isTgz) {
86
- await extractTgz(tmpPath, CLOUDFLARED_DIR);
86
+ await extractTgz(tmpPath, cloudflaredDir());
87
87
  unlinkSync(tmpPath);
88
88
  } else {
89
- renameSync(tmpPath, CLOUDFLARED_PATH);
89
+ renameSync(tmpPath, cloudflaredPath());
90
90
  }
91
91
  if (!isWindows) {
92
- chmodSync(CLOUDFLARED_PATH, 0o755);
92
+ chmodSync(cloudflaredPath(), 0o755);
93
93
  }
94
94
  } catch (err) {
95
95
  try { unlinkSync(tmpPath); } catch {}
96
- try { unlinkSync(CLOUDFLARED_PATH); } catch {}
96
+ try { unlinkSync(cloudflaredPath()); } catch {}
97
97
  throw err;
98
98
  }
99
99
 
100
- return CLOUDFLARED_PATH;
100
+ return cloudflaredPath();
101
101
  }
102
102
 
103
103
  /** Get path where cloudflared binary is/will be stored */
104
104
  export function getCloudflaredPath(): string {
105
- return CLOUDFLARED_PATH;
105
+ return cloudflaredPath();
106
106
  }
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readFileSync, renameSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { homedir } from "node:os";
4
3
  import { randomBytes } from "node:crypto";
5
4
  import type { PpmConfig, ProjectConfig } from "../types/config.ts";
6
5
  import { DEFAULT_CONFIG, sanitizeConfig } from "../types/config.ts";
@@ -14,8 +13,7 @@ import {
14
13
  getDb,
15
14
  getDbFilePath,
16
15
  } from "./db.service.ts";
17
-
18
- const PPM_DIR = resolve(homedir(), ".ppm");
16
+ import { getPpmDir } from "./ppm-dir.ts";
19
17
 
20
18
  /** Top-level config keys stored in the config table (not projects) */
21
19
  const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
@@ -148,8 +146,8 @@ class ConfigService {
148
146
 
149
147
  private migrateYamlIfNeeded(): void {
150
148
  const yamlPaths = [
151
- resolve(PPM_DIR, "config.yaml"),
152
- resolve(PPM_DIR, "config.dev.yaml"),
149
+ resolve(getPpmDir(), "config.yaml"),
150
+ resolve(getPpmDir(), "config.dev.yaml"),
153
151
  ];
154
152
  for (const yamlPath of yamlPaths) {
155
153
  if (!existsSync(yamlPath)) continue;
@@ -187,7 +185,7 @@ class ConfigService {
187
185
  }
188
186
 
189
187
  private migrateSessionMapIfNeeded(): void {
190
- const mapPath = resolve(PPM_DIR, "session-map.json");
188
+ const mapPath = resolve(getPpmDir(), "session-map.json");
191
189
  if (!existsSync(mapPath)) return;
192
190
  try {
193
191
  const { setSessionMetadata } = require("./db.service.ts");
@@ -202,7 +200,7 @@ class ConfigService {
202
200
  }
203
201
 
204
202
  private migratePushSubsIfNeeded(): void {
205
- const subsPath = resolve(PPM_DIR, "push-subscriptions.json");
203
+ const subsPath = resolve(getPpmDir(), "push-subscriptions.json");
206
204
  if (!existsSync(subsPath)) return;
207
205
  try {
208
206
  const { upsertPushSubscription } = require("./db.service.ts");
@@ -1,10 +1,8 @@
1
1
  import { Database, type SQLQueryBindings } from "bun:sqlite";
2
2
  import { resolve } from "node:path";
3
- import { homedir } from "node:os";
4
3
  import { mkdirSync, existsSync } from "node:fs";
5
4
  import { encrypt, decrypt } from "../lib/account-crypto.ts";
6
-
7
- const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
5
+ import { getPpmDir } from "./ppm-dir.ts";
8
6
  const CURRENT_SCHEMA_VERSION = 15;
9
7
 
10
8
  let db: Database | null = null;
@@ -17,14 +15,15 @@ export function setDbProfile(profile: string | null): void {
17
15
  }
18
16
 
19
17
  function getDbPath(): string {
20
- if (dbProfile) return resolve(PPM_DIR, `ppm.${dbProfile}.db`);
21
- return resolve(PPM_DIR, "ppm.db");
18
+ if (dbProfile) return resolve(getPpmDir(), `ppm.${dbProfile}.db`);
19
+ return resolve(getPpmDir(), "ppm.db");
22
20
  }
23
21
 
24
22
  /** Get or create the singleton DB instance (lazy init) */
25
23
  export function getDb(): Database {
26
24
  if (db) return db;
27
- if (!existsSync(PPM_DIR)) mkdirSync(PPM_DIR, { recursive: true });
25
+ const ppmDir = getPpmDir();
26
+ if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
28
27
  db = new Database(getDbPath());
29
28
  db.exec("PRAGMA journal_mode = WAL");
30
29
  db.exec("PRAGMA foreign_keys = ON");
@@ -156,8 +156,8 @@ export function registerVscodeCompatHandlers(rpc: RpcChannel): void {
156
156
  const { resolve, relative } = await import("node:path");
157
157
  const resolved = resolve(filePath);
158
158
  // Allow: CWD (project root) and ~/.ppm/extensions/ (extension storage)
159
- const { homedir } = await import("node:os");
160
- const allowedRoots = [resolve(process.cwd()), resolve(homedir(), ".ppm", "extensions")];
159
+ const { getPpmDir } = await import("./ppm-dir.ts");
160
+ const allowedRoots = [resolve(process.cwd()), resolve(getPpmDir(), "extensions")];
161
161
  const isSafe = allowedRoots.some((root) => {
162
162
  const rel = relative(root, resolved);
163
163
  return !rel.startsWith("..") && !rel.startsWith("/");
@@ -1,5 +1,4 @@
1
1
  import { resolve } from "node:path";
2
- import { homedir } from "node:os";
3
2
  import { existsSync } from "node:fs";
4
3
  import type { ExtensionManifest, ExtensionInfo, RpcMessage } from "../types/extension.ts";
5
4
  import { getExtensions, getExtensionById, insertExtension, getExtensionStorage, setExtensionStorageValue } from "./db.service.ts";
@@ -8,9 +7,7 @@ import { RpcChannel } from "./extension-rpc.ts";
8
7
  import { parseManifest, discoverManifests } from "./extension-manifest.ts";
9
8
  import { installExtension, removeExtension, devLinkExtension, ensureExtensionsDir } from "./extension-installer.ts";
10
9
  import { registerVscodeCompatHandlers } from "./extension-rpc-handlers.ts";
11
-
12
- const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
13
- const EXTENSIONS_DIR = resolve(PPM_DIR, "extensions");
10
+ import { getPpmDir } from "./ppm-dir.ts";
14
11
 
15
12
  class ExtensionService {
16
13
  private worker: Worker | null = null;
@@ -67,15 +64,15 @@ class ExtensionService {
67
64
  }
68
65
 
69
66
  async discover(): Promise<ExtensionManifest[]> {
70
- ensureExtensionsDir(EXTENSIONS_DIR);
71
- return discoverManifests(EXTENSIONS_DIR);
67
+ ensureExtensionsDir(resolve(getPpmDir(), "extensions"));
68
+ return discoverManifests(resolve(getPpmDir(), "extensions"));
72
69
  }
73
70
 
74
71
  async install(name: string): Promise<ExtensionManifest> {
75
72
  if (this.installing.has(name)) throw new Error(`Already installing ${name}`);
76
73
  this.installing.add(name);
77
74
  try {
78
- return await installExtension(name, EXTENSIONS_DIR);
75
+ return await installExtension(name, resolve(getPpmDir(), "extensions"));
79
76
  } finally {
80
77
  this.installing.delete(name);
81
78
  }
@@ -83,7 +80,7 @@ class ExtensionService {
83
80
 
84
81
  async remove(id: string): Promise<void> {
85
82
  if (this.activatedIds.has(id)) await this.deactivate(id);
86
- await removeExtension(id, EXTENSIONS_DIR);
83
+ await removeExtension(id, resolve(getPpmDir(), "extensions"));
87
84
  contributionRegistry.unregister(id);
88
85
  }
89
86
 
@@ -95,7 +92,7 @@ class ExtensionService {
95
92
  if (!row.enabled) throw new Error(`Extension ${id} is disabled`);
96
93
 
97
94
  const manifest: ExtensionManifest = JSON.parse(row.manifest);
98
- const extDir = resolve(EXTENSIONS_DIR, "node_modules", id);
95
+ const extDir = resolve(resolve(getPpmDir(), "extensions"), "node_modules", id);
99
96
  const entryPath = resolve(extDir, manifest.main);
100
97
  if (!existsSync(entryPath)) throw new Error(`Entry point not found: ${entryPath}`);
101
98
 
@@ -176,7 +173,7 @@ class ExtensionService {
176
173
  }
177
174
 
178
175
  async devLink(localPath: string): Promise<ExtensionManifest> {
179
- const manifest = devLinkExtension(localPath, EXTENSIONS_DIR);
176
+ const manifest = devLinkExtension(localPath, resolve(getPpmDir(), "extensions"));
180
177
  // Auto-activate after dev-link (DB record is created with enabled=1)
181
178
  if (!this.activatedIds.has(manifest.id)) {
182
179
  try { await this.activate(manifest.id); } catch (e) {
@@ -187,7 +184,7 @@ class ExtensionService {
187
184
  }
188
185
 
189
186
  async startup(): Promise<void> {
190
- ensureExtensionsDir(EXTENSIONS_DIR);
187
+ ensureExtensionsDir(resolve(getPpmDir(), "extensions"));
191
188
  const manifests = await this.discover();
192
189
  for (const m of manifests) {
193
190
  if (!getExtensionById(m.id)) {
@@ -214,7 +211,7 @@ class ExtensionService {
214
211
  }
215
212
 
216
213
  isActivated(id: string): boolean { return this.activatedIds.has(id); }
217
- getExtensionsDir(): string { return EXTENSIONS_DIR; }
214
+ getExtensionsDir(): string { return resolve(getPpmDir(), "extensions"); }
218
215
 
219
216
  /** Push current contributions to all connected browser clients */
220
217
  private broadcastContributions(): void {