@hienlh/ppm 0.9.79 → 0.9.80

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 (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/web/assets/chat-tab-CmSLt4tg.js +10 -0
  3. package/dist/web/assets/{code-editor-kyaXcsZW.js → code-editor-BFe-hnpF.js} +1 -1
  4. package/dist/web/assets/{database-viewer-DmAux3OF.js → database-viewer-BeY2V5QI.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-Cikon1YK.js → diff-viewer-D6xzs8PP.js} +1 -1
  6. package/dist/web/assets/{extension-webview-DVvC7SQ-.js → extension-webview-Cd1XYFXO.js} +1 -1
  7. package/dist/web/assets/{git-graph-Bon2J1_A.js → git-graph-D2XXpiMQ.js} +1 -1
  8. package/dist/web/assets/index-BtwsLrdT.css +2 -0
  9. package/dist/web/assets/index-D6_wwsL_.js +30 -0
  10. package/dist/web/assets/keybindings-store-C8ryKudw.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-ttL1fRGG.js → markdown-renderer-xYMhd9cE.js} +1 -1
  12. package/dist/web/assets/{port-forwarding-tab-Bljq2XEH.js → port-forwarding-tab-B5rj_I66.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-CqburCkJ.js → postgres-viewer-DnlqzOnm.js} +1 -1
  14. package/dist/web/assets/{settings-tab-CQVn8u_D.js → settings-tab-CNZpuPD3.js} +1 -1
  15. package/dist/web/assets/{sql-query-editor-DP6Kh2R8.js → sql-query-editor-Df2kzbPj.js} +1 -1
  16. package/dist/web/assets/{sqlite-viewer-CrqzbhyF.js → sqlite-viewer-Cj1G70z4.js} +1 -1
  17. package/dist/web/assets/{terminal-tab-BmBB838x.js → terminal-tab-Dv9A7Xe2.js} +1 -1
  18. package/dist/web/assets/{use-monaco-theme-ZmSrfclJ.js → use-monaco-theme-CPfIEo8t.js} +1 -1
  19. package/dist/web/index.html +2 -2
  20. package/dist/web/sw.js +1 -1
  21. package/package.json +1 -1
  22. package/src/providers/claude-agent-sdk.ts +33 -99
  23. package/src/providers/cli-provider-base.ts +1 -1
  24. package/src/server/routes/chat.ts +26 -22
  25. package/src/server/ws/chat.ts +11 -17
  26. package/src/services/config.service.ts +4 -3
  27. package/src/services/db.service.ts +67 -37
  28. package/src/services/ppmbot/ppmbot-streamer.ts +0 -6
  29. package/src/types/chat.ts +0 -1
  30. package/src/web/components/chat/chat-tab.tsx +11 -8
  31. package/src/web/components/layout/project-bar.tsx +133 -87
  32. package/src/web/hooks/use-chat.ts +0 -11
  33. package/AGENTS.md +0 -80
  34. package/dist/web/assets/chat-tab-B3gpx-qv.js +0 -10
  35. package/dist/web/assets/index-B_sM201v.css +0 -2
  36. package/dist/web/assets/index-Buc4QA5O.js +0 -30
  37. package/dist/web/assets/keybindings-store-CT_EvCrb.js +0 -1
  38. package/output/pdf/ppm-app-summary.pdf +0 -80
@@ -16,7 +16,7 @@ import type {
16
16
  import { configService } from "../services/config.service.ts";
17
17
  import { mcpConfigService } from "../services/mcp-config.service.ts";
18
18
  import { updateFromSdkEvent } from "../services/claude-usage.service.ts";
19
- import { getSessionMapping, getSessionProjectPath, setSessionMapping, getSessionTitles } from "../services/db.service.ts";
19
+ import { getSessionProjectPath, setSessionMetadata, getSessionTitles } from "../services/db.service.ts";
20
20
  import { accountSelector } from "../services/account-selector.service.ts";
21
21
  import { accountService, type AccountWithTokens } from "../services/account.service.ts";
22
22
  import { resolve } from "node:path";
@@ -25,10 +25,6 @@ import { homedir } from "node:os";
25
25
 
26
26
  const CLAUDE_PROJECTS_DIR = resolve(homedir(), ".claude/projects");
27
27
 
28
- function getSdkSessionId(ppmId: string): string {
29
- return getSessionMapping(ppmId) ?? ppmId;
30
- }
31
-
32
28
  // ── Streaming Input: message channel for persistent query ──
33
29
 
34
30
  interface MessageController {
@@ -266,8 +262,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
266
262
  };
267
263
  this.activeSessions.set(id, meta);
268
264
  this.messageCount.set(id, 0);
269
- // Pre-persist mapping so project_path survives server restarts
270
- setSessionMapping(id, id, config.projectName, config.projectPath);
265
+ // Persist project metadata so project_path survives server restarts
266
+ setSessionMetadata(id, config.projectName, config.projectPath);
271
267
  return meta;
272
268
  }
273
269
 
@@ -275,31 +271,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
275
271
  const existing = this.activeSessions.get(sessionId);
276
272
  if (existing) return existing;
277
273
 
278
- // Check if we have a mapped SDK session ID (from a previous query)
279
- const mappedSdkId = getSdkSessionId(sessionId);
280
274
  // Restore project_path from DB so resumed sessions can find JSONL
281
275
  const dbProjectPath = getSessionProjectPath(sessionId) ?? undefined;
282
276
 
283
- // Try targeted lookup first (searches all project dirs), then fall back to list scan
277
+ // Try targeted lookup first (searches all project dirs)
284
278
  try {
285
- const lookupId = mappedSdkId ?? sessionId;
286
- const info = await sdkGetSessionInfo(lookupId, { dir: dbProjectPath });
287
- if (!info && mappedSdkId) {
288
- // Try the original PPM session ID as well
289
- const info2 = await sdkGetSessionInfo(sessionId, { dir: dbProjectPath });
290
- if (info2) {
291
- const meta: Session = {
292
- id: sessionId,
293
- providerId: this.id,
294
- title: info2.customTitle ?? info2.summary ?? "Resumed Chat",
295
- projectPath: dbProjectPath,
296
- createdAt: new Date(info2.lastModified).toISOString(),
297
- };
298
- this.activeSessions.set(sessionId, meta);
299
- this.messageCount.set(sessionId, 1);
300
- return meta;
301
- }
302
- }
279
+ const info = await sdkGetSessionInfo(sessionId, { dir: dbProjectPath });
303
280
  if (info) {
304
281
  const meta: Session = {
305
282
  id: sessionId,
@@ -316,11 +293,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
316
293
  // SDK not available
317
294
  }
318
295
 
319
- // Session not found in SDK list — it may still have a JSONL on disk
320
- // (sdkListSessions may not search the correct project directory).
321
- // Use messageCount=1 so sendMessage uses --resume instead of --session-id.
322
- // --resume gracefully fails if no JSONL exists, while --session-id crashes
323
- // when a JSONL file for the same ID is already present on disk.
296
+ // Session not found in SDK list — it may still have a JSONL on disk.
297
+ // Use messageCount=1 so sendMessage uses resume instead of sessionId.
298
+ // resume gracefully handles missing JSONL, while sessionId crashes
299
+ // when a JSONL file for the same ID already exists on disk.
324
300
  const meta: Session = {
325
301
  id: sessionId,
326
302
  providerId: this.id,
@@ -388,13 +364,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
388
364
  this.forkSources.delete(sessionId);
389
365
 
390
366
  // Best-effort: delete JSONL from ~/.claude/projects/
391
- const sdkId = getSessionMapping(sessionId) ?? sessionId;
392
367
  try {
393
368
  if (existsSync(CLAUDE_PROJECTS_DIR)) {
394
369
  const projectDirs = readdirSync(CLAUDE_PROJECTS_DIR);
395
370
  for (const dir of projectDirs) {
396
371
  if (dir.includes("..") || dir.includes("/")) continue; // safety
397
- const jsonlPath = resolve(CLAUDE_PROJECTS_DIR, dir, `${sdkId}.jsonl`);
372
+ const jsonlPath = resolve(CLAUDE_PROJECTS_DIR, dir, `${sessionId}.jsonl`);
398
373
  if (existsSync(jsonlPath)) { unlinkSync(jsonlPath); break; }
399
374
  }
400
375
  }
@@ -423,11 +398,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
423
398
  messageId: string,
424
399
  opts?: { title?: string; dir?: string },
425
400
  ): Promise<{ sessionId: string }> {
426
- const sdkId = getSessionMapping(sessionId) ?? sessionId;
427
401
  // Dynamic import: Bun's ESM linker fails to resolve forkSession as a static named export
428
402
  // in certain test configurations. Lazy import avoids the module linking issue.
429
403
  const { forkSession } = await import("@anthropic-ai/claude-agent-sdk");
430
- const result = await forkSession(sdkId, {
404
+ const result = await forkSession(sessionId, {
431
405
  upToMessageId: messageId,
432
406
  title: opts?.title,
433
407
  dir: opts?.dir,
@@ -645,17 +619,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
645
619
  let resultContextWindowPct: number | undefined;
646
620
  let yieldedDone = false;
647
621
  try {
648
- // Resolve SDK's actual session ID for resume (may differ from PPM's UUID)
649
- // For fork: use the source session's SDK id
650
- const sdkId = shouldFork ? getSdkSessionId(forkSourceId!) : getSdkSessionId(sessionId);
651
- // If messageCount > 0 (resumeSession ran) but no real SDK mapping exists,
652
- // the session never received an init event (prior attempt hung/crashed).
653
- // Treat as first message to avoid resuming a non-existent JSONL → silent hang.
654
- const hasRealSdkMapping = getSessionMapping(sessionId) !== null && getSessionMapping(sessionId) !== sessionId;
655
- const effectiveIsFirst = isFirstMessage || (!shouldFork && !hasRealSdkMapping);
656
- if (effectiveIsFirst && !isFirstMessage) {
657
- console.warn(`[sdk] session=${sessionId} no SDK mapping found — treating as new session (was isFirst=false)`);
658
- }
622
+ // Session ID is the canonical ID for both PPM and SDK (no dual-ID mapping).
623
+ // First message creates a new session; subsequent messages resume.
659
624
  // Fallback cwd: SDK needs a valid working directory even when no project is selected.
660
625
  // On Windows daemons, undefined cwd can cause the subprocess to fail silently.
661
626
  // Resolve path and validate existence — invalid cwd causes spawn to hang on Windows.
@@ -725,7 +690,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
725
690
  console.warn(`[sdk] session=${sessionId} no account and no API key in env — Claude CLI will use its own auth (if any)`);
726
691
  }
727
692
  }
728
- console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} effectiveIsFirst=${effectiveIsFirst} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform} accountMode=${!!account} permissionMode=${permissionMode} isBypass=${isBypass}`);
693
+ console.log(`[sdk] query: session=${sessionId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform} accountMode=${!!account} permissionMode=${permissionMode} isBypass=${isBypass}`);
729
694
 
730
695
  // Read MCP servers from PPM DB (fresh per query — user may add/remove between chats)
731
696
  const mcpServers = mcpConfigService.list();
@@ -746,8 +711,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
746
711
  const queryOptions: Record<string, any> = {
747
712
  // On Windows, child_process.spawn("bun") fails with ENOENT — force node
748
713
  ...(process.platform === "win32" && { executable: "node" }),
749
- sessionId: effectiveIsFirst && !shouldFork ? sessionId : undefined,
750
- resume: (effectiveIsFirst && !shouldFork) ? undefined : sdkId,
714
+ // First message: create session with this ID. Subsequent: resume by same ID.
715
+ sessionId: isFirstMessage && !shouldFork ? sessionId : undefined,
716
+ resume: (isFirstMessage && !shouldFork) ? undefined : (shouldFork ? forkSourceId : sessionId),
751
717
  ...(shouldFork && { forkSession: true }),
752
718
  cwd: effectiveCwd,
753
719
  systemPrompt: systemPromptOpt,
@@ -823,9 +789,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
823
789
  let retryCount = 0;
824
790
  let rateLimitRetryCount = 0;
825
791
  let authRetried = false;
826
- /** True after the first init event maps ppmId → sdkId. Prevents retry init events from overwriting the mapping. */
827
- let initMappingDone = false;
828
-
829
792
  let hadAnyEvents = false;
830
793
  retryLoop: while (true) {
831
794
  let sdkEventCount = 0;
@@ -842,7 +805,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
842
805
  closeCurrentStream();
843
806
  const { generator: retryGen, controller: retryCtrl } = createMessageChannel();
844
807
  retryCtrl.push(firstMsg);
845
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
808
+ // Retry with resume (safe even if JSONL doesn't exist yet — SDK handles gracefully)
809
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: sessionId };
846
810
  const rq = query({
847
811
  prompt: retryGen,
848
812
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -866,35 +830,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
866
830
  const subtype = (msg as any).subtype ?? "none";
867
831
  console.log(`[sdk] session=${sessionId} system: subtype=${subtype} ${JSON.stringify(msg).slice(0, 500)}`);
868
832
 
869
- // Capture SDK session metadata from init message
870
833
  if (subtype === "init") {
871
- const initMsg = msg as any;
872
- if (initMsg.session_id && initMsg.session_id !== sessionId) {
873
- // Only update sdk_id mapping once per session lifecycle.
874
- // Retries (auth refresh, rate limit) create new SDK queries that
875
- // emit fresh init events — overwriting would orphan the original JSONL.
876
- if (!initMappingDone) {
877
- const existingSdkId = getSessionMapping(sessionId);
878
- const isFirstMapping = existingSdkId === null || existingSdkId === sessionId;
879
- if (isFirstMapping) {
880
- setSessionMapping(sessionId, initMsg.session_id, meta.projectName, meta.projectPath);
881
- initMappingDone = true;
882
- } else {
883
- // Already mapped to a real SDK id from a previous conversation
884
- initMappingDone = true;
885
- console.log(`[sdk] session=${sessionId} preserving existing mapping → ${existingSdkId}`);
886
- }
887
- } else {
888
- console.log(`[sdk] session=${sessionId} ignoring retry init sdk_id=${initMsg.session_id} (mapping already set)`);
889
- }
890
- // Only create activeSessions alias for first-time SDK id mapping.
891
- // Retry init events create phantom entries that pollute the map.
892
- if (isFirstMessage) {
893
- const oldMeta = this.activeSessions.get(sessionId);
894
- if (oldMeta) {
895
- this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
896
- }
897
- }
834
+ const sdkSid = (msg as any).session_id;
835
+ if (sdkSid && sdkSid !== sessionId) {
836
+ console.warn(`[sdk] session=${sessionId} SDK returned different session_id=${sdkSid} JSONL may be orphaned`);
837
+ } else {
838
+ console.log(`[sdk] session=${sessionId} init: sdk_session_id=${sdkSid}`);
898
839
  }
899
840
  }
900
841
 
@@ -935,7 +876,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
935
876
  const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
936
877
  const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
937
878
  if (!hasHistory) earlyAuthCtrl.push(firstMsg);
938
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
879
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
939
880
  const rq = query({
940
881
  prompt: earlyAuthGen,
941
882
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -961,7 +902,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
961
902
  const { generator: switchGen, controller: switchCtrl } = createMessageChannel();
962
903
  const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
963
904
  if (!hasHistory) switchCtrl.push(firstMsg);
964
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: switchEnv };
905
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: switchEnv };
965
906
  const rq = query({
966
907
  prompt: switchGen,
967
908
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1008,7 +949,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1008
949
  // Skip this for child messages (parentId set) — subagent internals don't mean parent tools finished.
1009
950
  if (pendingToolCount > 0 && !parentId && (msg.type === "assistant" || (msg as any).type === "partial" || (msg as any).type === "stream_event")) {
1010
951
  try {
1011
- const sessionMsgs = await getSessionMessages(sdkId);
952
+ const sessionMsgs = await getSessionMessages(sessionId);
1012
953
  // Find the last user message — it contains tool_result blocks
1013
954
  const lastUserMsg = [...sessionMsgs].reverse().find(
1014
955
  (m: any) => m.type === "user",
@@ -1105,14 +1046,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1105
1046
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1106
1047
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1107
1048
  // Close failed query and old channel, create new channel + query with refreshed token.
1108
- // Re-resolve sdkId: the init event may have mapped ppmId → real SDK session_id
1109
- // after sdkId was originally resolved. Using the stale value would try to
1110
- // resume a non-existent session, causing the SDK to hang forever.
1111
1049
  closeCurrentStream();
1112
1050
  const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
1113
1051
  const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1114
1052
  if (!hasHistory) authRetryCtrl.push(firstMsg);
1115
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
1053
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
1116
1054
  const rq = query({
1117
1055
  prompt: authRetryGen,
1118
1056
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1159,13 +1097,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1159
1097
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1160
1098
  await new Promise((r) => setTimeout(r, backoff));
1161
1099
  // Close current streaming session and recreate with (potentially new) account env.
1162
- // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1163
1100
  closeCurrentStream();
1164
1101
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1165
1102
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1166
1103
  const rlHasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1167
1104
  if (!rlHasHistory) rlRetryCtrl.push(firstMsg);
1168
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory ? getSdkSessionId(sessionId) : undefined, env: rlRetryEnv };
1105
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory ? sessionId : undefined, env: rlRetryEnv };
1169
1106
  const rq = query({
1170
1107
  prompt: rlRetryGen,
1171
1108
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1256,13 +1193,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1256
1193
  }
1257
1194
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1258
1195
  await new Promise((r) => setTimeout(r, backoff));
1259
- // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1260
1196
  closeCurrentStream();
1261
1197
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1262
1198
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1263
1199
  const rlHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1264
1200
  if (!rlHasHistory2) rlRetryCtrl.push(firstMsg);
1265
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory2 ? getSdkSessionId(sessionId) : undefined, env: rlRetryEnv };
1201
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory2 ? sessionId : undefined, env: rlRetryEnv };
1266
1202
  const rq = query({
1267
1203
  prompt: rlRetryGen,
1268
1204
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1287,13 +1223,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1287
1223
  const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1288
1224
  console.log(`[sdk] 401 in result on account ${account.id} (${label}) — token refreshed, retrying`);
1289
1225
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1290
- // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1291
1226
  closeCurrentStream();
1292
1227
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1293
1228
  const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1294
1229
  const authHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1295
1230
  if (!authHasHistory2) authRetryCtrl2.push(firstMsg);
1296
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
1231
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? sessionId : undefined, env: retryEnv };
1297
1232
  const rq = query({
1298
1233
  prompt: authRetryGen2,
1299
1234
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1317,7 +1252,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1317
1252
  // Flush any remaining pending tool_results before finishing
1318
1253
  if (pendingToolCount > 0) {
1319
1254
  try {
1320
- const sessionMsgs = await getSessionMessages(sdkId);
1255
+ const sessionMsgs = await getSessionMessages(sessionId);
1321
1256
  const lastUserMsg = [...sessionMsgs].reverse().find(
1322
1257
  (m: any) => m.type === "user",
1323
1258
  );
@@ -1530,8 +1465,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1530
1465
 
1531
1466
  async getMessages(sessionId: string): Promise<ChatMessage[]> {
1532
1467
  try {
1533
- const sdkId = getSdkSessionId(sessionId);
1534
- const messages = await getSessionMessages(sdkId);
1468
+ const messages = await getSessionMessages(sessionId);
1535
1469
  const parsed = messages.map((msg) => parseSessionMessage(msg));
1536
1470
 
1537
1471
  // Merge tool_result user messages into the preceding assistant message
@@ -152,7 +152,7 @@ export abstract class CliProvider implements AIProvider {
152
152
  this.messageCount.delete(processKey);
153
153
  this.messageCount.set(capturedSessionId, cnt);
154
154
  // Notify frontend about the session ID change
155
- yield { type: "session_migrated", oldSessionId: processKey, newSessionId: capturedSessionId } as ChatEvent;
155
+ yield { type: "session_migrated", oldSessionId: processKey, newSessionId: capturedSessionId } as unknown as ChatEvent;
156
156
  }
157
157
  }
158
158
  }
@@ -8,7 +8,7 @@ import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sd
8
8
  import { listSlashItems } from "../../services/slash-items.service.ts";
9
9
  import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
10
10
  import { getSessionLog } from "../../services/session-log.service.ts";
11
- import { getSessionMapping, getSessionProjectPath, setSessionMapping, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionTitle } from "../../services/db.service.ts";
11
+ import { getSessionProjectPath, setSessionMetadata, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionMetadata, deleteSessionTitle } from "../../services/db.service.ts";
12
12
  import { ok, err } from "../../types/api.ts";
13
13
 
14
14
  type Env = { Variables: { projectPath: string; projectName: string } };
@@ -152,13 +152,13 @@ chatRoutes.delete("/sessions/:id", async (c) => {
152
152
  try {
153
153
  const id = c.req.param("id");
154
154
  const providerId = c.req.query("providerId") ?? "claude";
155
- const sdkId = getSessionMapping(id) ?? id;
156
155
  // Provider-specific cleanup (JSONL, process, etc.)
157
156
  await chatService.deleteSession(providerId, id);
158
157
  // Shared DB cleanup
159
- deleteSessionMapping(id);
160
- deleteSessionTitle(sdkId);
161
- unpinSession(sdkId);
158
+ deleteSessionMapping(id); // legacy cleanup
159
+ deleteSessionMetadata(id);
160
+ deleteSessionTitle(id);
161
+ unpinSession(id);
162
162
  return c.json(ok({ deleted: id }));
163
163
  } catch (e) {
164
164
  return c.json(err((e as Error).message), 404);
@@ -172,13 +172,11 @@ chatRoutes.patch("/sessions/:id", async (c) => {
172
172
  const body = await c.req.json<{ title?: string }>();
173
173
  if (!body.title?.trim()) return c.json(err("title is required"), 400);
174
174
  const title = body.title.trim();
175
- // Resolve PPM UUID → SDK session ID if mapped
176
- const sdkId = getSessionMapping(id) ?? id;
177
175
  const projectPath = c.get("projectPath");
178
176
  // Persist to PPM DB (authoritative source for user-set titles)
179
- setSessionTitle(sdkId, title);
177
+ setSessionTitle(id, title);
180
178
  // Also persist to SDK so Claude Code CLI sees the custom title
181
- await sdkRenameSession(sdkId, title, { dir: projectPath });
179
+ await sdkRenameSession(id, title, { dir: projectPath });
182
180
  // Also update in-memory session
183
181
  const session = chatService.getSession(id);
184
182
  if (session) session.title = title;
@@ -229,12 +227,19 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
229
227
  const result = await provider.forkAtMessage(sourceId, body.messageId, {
230
228
  title: "Forked Chat", dir: projectPath,
231
229
  });
232
- const session = await chatService.createSession(providerId, {
233
- projectName, projectPath, title: "Forked Chat",
234
- });
235
- setSessionMapping(session.id, result.sessionId);
236
- provider.markAsResumed?.(session.id);
237
- return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
230
+ // Register forked session with provider + DB so it's tracked in memory
231
+ setSessionMetadata(result.sessionId, projectName, projectPath);
232
+ await provider.resumeSession(result.sessionId);
233
+ provider.markAsResumed?.(result.sessionId);
234
+ const forkedSession = {
235
+ id: result.sessionId,
236
+ providerId,
237
+ title: "Forked Chat",
238
+ projectName,
239
+ projectPath,
240
+ createdAt: new Date().toISOString(),
241
+ };
242
+ return c.json(ok({ ...forkedSession, forkedFrom: sourceId }), 201);
238
243
  } else {
239
244
  // No messageId (fork at first message) — create a fresh empty session
240
245
  const session = await chatService.createSession(providerId, {
@@ -261,20 +266,19 @@ chatRoutes.get("/sessions/:id/logs", (c) => {
261
266
 
262
267
  /** GET /chat/sessions/:id/debug — session debug info (IDs, JSONL path) */
263
268
  chatRoutes.get("/sessions/:id/debug", (c) => {
264
- const ppmId = c.req.param("id");
265
- const sdkId = getSessionMapping(ppmId) ?? ppmId;
266
- // Resolve JSONL path: ~/.claude/projects/<encoded-cwd>/<sdkId>.jsonl
269
+ const sessionId = c.req.param("id");
270
+ // Resolve JSONL path: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl
267
271
  const homedir = process.env.HOME ?? process.env.USERPROFILE ?? "";
268
272
  const provider = providerRegistry.get("claude") as any;
269
273
  // Try in-memory first, fall back to DB-persisted project_path
270
- const projectPath = provider?.activeSessions?.get(ppmId)?.projectPath
271
- ?? getSessionProjectPath(ppmId)
274
+ const projectPath = provider?.activeSessions?.get(sessionId)?.projectPath
275
+ ?? getSessionProjectPath(sessionId)
272
276
  ?? "";
273
277
  const encodedCwd = projectPath ? projectPath.replace(/\//g, "-") : "";
274
278
  const jsonlDir = encodedCwd ? resolve(homedir, ".claude", "projects", encodedCwd) : "";
275
- const jsonlPath = jsonlDir ? resolve(jsonlDir, `${sdkId}.jsonl`) : "";
279
+ const jsonlPath = jsonlDir ? resolve(jsonlDir, `${sessionId}.jsonl`) : "";
276
280
  const jsonlExists = jsonlPath ? existsSync(jsonlPath) : false;
277
- return c.json(ok({ ppmSessionId: ppmId, sdkSessionId: sdkId, jsonlPath: jsonlExists ? jsonlPath : null, jsonlDir, projectPath }));
281
+ return c.json(ok({ sessionId, jsonlPath: jsonlExists ? jsonlPath : null, jsonlDir, projectPath }));
278
282
  });
279
283
 
280
284
  /** POST /chat/upload — upload files for chat attachments, returns server-side paths */
@@ -208,23 +208,6 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
208
208
  const ev = event as any;
209
209
  const evType = ev.type ?? "unknown";
210
210
 
211
- // Session ID migrated: CLI provider assigned a different ID than PPM generated.
212
- // Migrate activeSessions key so all subsequent events use the real ID.
213
- if (evType === "session_migrated") {
214
- const { oldSessionId, newSessionId } = ev;
215
- const migrated = activeSessions.get(oldSessionId);
216
- if (migrated) {
217
- activeSessions.delete(oldSessionId);
218
- activeSessions.set(newSessionId, migrated);
219
- sessionId = newSessionId; // update local ref for subsequent setPhase/broadcast calls
220
- // Notify frontend to update its sessionId state
221
- broadcast(newSessionId, { type: "session_migrated", oldSessionId, newSessionId });
222
- console.log(`[chat] session migrated: ${oldSessionId} → ${newSessionId}`);
223
- logSessionEvent(newSessionId, "INFO", `Session ID migrated from ${oldSessionId}`);
224
- }
225
- continue;
226
- }
227
-
228
211
  // System events → transition connecting → thinking, forward compact events
229
212
  if (evType === "system") {
230
213
  const sub = (ev as any).subtype;
@@ -339,6 +322,17 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
339
322
  : `${project} — ${ev.tool} needs permission`;
340
323
  notificationService.broadcast(nType as any, { title, body, project, sessionId, sessionTitle: sTitle, tool: ev.tool });
341
324
  }).catch(() => {});
325
+ } else if (evType === "session_migrated") {
326
+ // CLI providers discover real session ID from CLI output — migrate WS tracking
327
+ const newId = ev.newSessionId as string;
328
+ if (newId && newId !== sessionId) {
329
+ console.log(`[chat] session_migrated: ${sessionId} → ${newId}`);
330
+ const oldEntry = activeSessions.get(sessionId);
331
+ if (oldEntry) {
332
+ activeSessions.delete(sessionId);
333
+ activeSessions.set(newId, oldEntry);
334
+ }
335
+ }
342
336
  } else {
343
337
  logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
344
338
  }
@@ -190,10 +190,11 @@ class ConfigService {
190
190
  const mapPath = resolve(PPM_DIR, "session-map.json");
191
191
  if (!existsSync(mapPath)) return;
192
192
  try {
193
- const { setSessionMapping } = require("./db.service.ts");
193
+ const { setSessionMetadata } = require("./db.service.ts");
194
194
  const map = JSON.parse(readFileSync(mapPath, "utf-8")) as Record<string, string>;
195
- for (const [ppmId, sdkId] of Object.entries(map)) {
196
- setSessionMapping(ppmId, sdkId);
195
+ for (const [_ppmId, sdkId] of Object.entries(map)) {
196
+ // Use SDK ID as canonical session ID (ppmId is legacy)
197
+ setSessionMetadata(sdkId);
197
198
  }
198
199
  renameSync(mapPath, mapPath + ".bak");
199
200
  console.log("[config] Migrated session-map.json → SQLite");
@@ -431,6 +431,53 @@ function runMigrations(database: Database): void {
431
431
  }
432
432
  database.exec("PRAGMA user_version = 15");
433
433
  }
434
+
435
+ if (current < 16) {
436
+ // Create session_metadata table — replaces dual-ID session_map system.
437
+ // Session ID is now always the SDK session ID (no more ppmId vs sdkId).
438
+ database.exec(`
439
+ CREATE TABLE IF NOT EXISTS session_metadata (
440
+ session_id TEXT PRIMARY KEY,
441
+ project_name TEXT,
442
+ project_path TEXT,
443
+ created_at TEXT DEFAULT (datetime('now'))
444
+ );
445
+ `);
446
+ // Migrate existing data from session_map: use sdk_id as the canonical session_id.
447
+ // Also migrate session_titles and session_pins that used old ppm_id keys.
448
+ try {
449
+ const rows = database.query("SELECT ppm_id, sdk_id, project_name, project_path FROM session_map").all() as
450
+ { ppm_id: string; sdk_id: string; project_name: string | null; project_path: string | null }[];
451
+ for (const row of rows) {
452
+ const canonicalId = row.sdk_id || row.ppm_id;
453
+ // Insert into session_metadata using canonical SDK ID
454
+ database.query(
455
+ "INSERT OR IGNORE INTO session_metadata (session_id, project_name, project_path) VALUES (?, ?, ?)",
456
+ ).run(canonicalId, row.project_name, row.project_path);
457
+ // Migrate session_titles from ppm_id to sdk_id if they differ
458
+ if (row.ppm_id !== row.sdk_id) {
459
+ const titleRow = database.query("SELECT title FROM session_titles WHERE session_id = ?").get(row.ppm_id) as { title: string } | null;
460
+ if (titleRow) {
461
+ database.query(
462
+ "INSERT OR IGNORE INTO session_titles (session_id, title, updated_at) VALUES (?, ?, datetime('now'))",
463
+ ).run(canonicalId, titleRow.title);
464
+ // Remove orphaned ppm_id entry
465
+ database.query("DELETE FROM session_titles WHERE session_id = ?").run(row.ppm_id);
466
+ }
467
+ // Migrate session_pins from ppm_id to sdk_id
468
+ const pinRow = database.query("SELECT 1 FROM session_pins WHERE session_id = ?").get(row.ppm_id);
469
+ if (pinRow) {
470
+ database.query("INSERT OR IGNORE INTO session_pins (session_id) VALUES (?)").run(canonicalId);
471
+ // Remove orphaned ppm_id entry
472
+ database.query("DELETE FROM session_pins WHERE session_id = ?").run(row.ppm_id);
473
+ }
474
+ }
475
+ }
476
+ } catch (e) {
477
+ console.warn(`[db] session_map migration warning: ${(e as Error).message}`);
478
+ }
479
+ database.exec("PRAGMA user_version = 16");
480
+ }
434
481
  }
435
482
 
436
483
  // ---------------------------------------------------------------------------
@@ -517,46 +564,28 @@ export function updateProject(currentName: string, newName: string, newPath: str
517
564
  }
518
565
 
519
566
  // ---------------------------------------------------------------------------
520
- // Session map helpers
567
+ // Session project metadata helpers (replaced session_map dual-ID system)
521
568
  // ---------------------------------------------------------------------------
522
569
 
523
- export function getSessionMapping(ppmId: string): string | null {
524
- const row = getDb().query("SELECT sdk_id FROM session_map WHERE ppm_id = ?").get(ppmId) as { sdk_id: string } | null;
525
- return row?.sdk_id ?? null;
526
- }
527
-
528
- export function getSessionProjectPath(ppmId: string): string | null {
529
- const row = getDb().query("SELECT project_path FROM session_map WHERE ppm_id = ?").get(ppmId) as { project_path: string } | null;
530
- return row?.project_path ?? null;
531
- }
532
-
533
- /**
534
- * Set session mapping. By default, refuses to overwrite an existing real SDK ID
535
- * (one that differs from both ppmId and the new sdkId) to prevent orphaning JSONL history.
536
- * Pass force=true to override (e.g. session delete + recreate).
537
- */
538
- export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string, projectPath?: string, force = false): void {
539
- if (!force) {
540
- const existing = getSessionMapping(ppmId);
541
- if (existing && existing !== ppmId && existing !== sdkId) {
542
- console.warn(`[db] Refusing to overwrite session mapping ${ppmId} → ${existing} with ${sdkId} (use force=true to override)`);
543
- // Still update project metadata even when refusing sdk_id overwrite
544
- getDb().query(
545
- "UPDATE session_map SET project_name = COALESCE(?, session_map.project_name), project_path = COALESCE(?, session_map.project_path) WHERE ppm_id = ?",
546
- ).run(projectName ?? null, projectPath ?? null, ppmId);
547
- return;
548
- }
549
- }
570
+ export function getSessionProjectPath(sessionId: string): string | null {
571
+ const row = getDb().query("SELECT project_path FROM session_metadata WHERE session_id = ?").get(sessionId) as { project_path: string } | null;
572
+ if (row?.project_path) return row.project_path;
573
+ // Legacy fallback: session_map.sdk_id is indexed (PRIMARY KEY is ppm_id, so try both)
574
+ const legacy = getDb().query("SELECT project_path FROM session_map WHERE ppm_id = ?").get(sessionId) as { project_path: string } | null;
575
+ if (legacy?.project_path) return legacy.project_path;
576
+ const legacySdk = getDb().query("SELECT project_path FROM session_map WHERE sdk_id = ?").get(sessionId) as { project_path: string } | null;
577
+ return legacySdk?.project_path ?? null;
578
+ }
579
+
580
+ /** Store project metadata for a session */
581
+ export function setSessionMetadata(sessionId: string, projectName?: string, projectPath?: string): void {
550
582
  getDb().query(
551
- "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)",
552
- ).run(ppmId, sdkId, projectName ?? null, projectPath ?? null);
583
+ "INSERT INTO session_metadata (session_id, project_name, project_path) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET project_name = COALESCE(excluded.project_name, session_metadata.project_name), project_path = COALESCE(excluded.project_path, session_metadata.project_path)",
584
+ ).run(sessionId, projectName ?? null, projectPath ?? null);
553
585
  }
554
586
 
555
- export function getAllSessionMappings(): Record<string, string> {
556
- const rows = getDb().query("SELECT ppm_id, sdk_id FROM session_map").all() as { ppm_id: string; sdk_id: string }[];
557
- const result: Record<string, string> = {};
558
- for (const r of rows) result[r.ppm_id] = r.sdk_id;
559
- return result;
587
+ export function deleteSessionMetadata(sessionId: string): void {
588
+ getDb().query("DELETE FROM session_metadata WHERE session_id = ?").run(sessionId);
560
589
  }
561
590
 
562
591
  // ---------------------------------------------------------------------------
@@ -605,8 +634,9 @@ export function getPinnedSessionIds(): Set<string> {
605
634
  return new Set(rows.map((r) => r.session_id));
606
635
  }
607
636
 
608
- export function deleteSessionMapping(ppmId: string): void {
609
- getDb().query("DELETE FROM session_map WHERE ppm_id = ?").run(ppmId);
637
+ /** @deprecated Legacy cleanup removes from old session_map if present */
638
+ export function deleteSessionMapping(sessionId: string): void {
639
+ getDb().query("DELETE FROM session_map WHERE ppm_id = ? OR sdk_id = ?").run(sessionId, sessionId);
610
640
  }
611
641
 
612
642
  export function deleteSessionTitle(sessionId: string): void {
@@ -48,7 +48,6 @@ export interface StreamResult {
48
48
  contextWindowPct?: number;
49
49
  resultSubtype?: ResultSubtype;
50
50
  messageIds: number[];
51
- newSessionId?: string;
52
51
  }
53
52
 
54
53
  /**
@@ -222,11 +221,6 @@ export async function streamToTelegram(
222
221
  break eventLoop; // break the for-await, not just the switch
223
222
  }
224
223
 
225
- case "session_migrated": {
226
- result.newSessionId = event.newSessionId;
227
- break;
228
- }
229
-
230
224
  case "account_retry": {
231
225
  appendHtml(
232
226
  segments,
package/src/types/chat.ts CHANGED
@@ -117,7 +117,6 @@ export type ChatEvent =
117
117
  | { type: "approval_request"; requestId: string; tool: string; input: unknown }
118
118
  | { type: "error"; message: string }
119
119
  | { type: "done"; sessionId: string; resultSubtype?: ResultSubtype; numTurns?: number; contextWindowPct?: number }
120
- | { type: "session_migrated"; oldSessionId: string; newSessionId: string }
121
120
  | { type: "account_info"; accountId: string; accountLabel: string }
122
121
  | { type: "account_retry"; reason: string; accountId?: string; accountLabel?: string }
123
122
  | { type: "status_update"; phase: "routing" | "refreshing" | "switching"; message: string; accountLabel?: string }