@hienlh/ppm 0.9.78 → 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 +20 -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 +32 -90
  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,9 +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);
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.
651
624
  // Fallback cwd: SDK needs a valid working directory even when no project is selected.
652
625
  // On Windows daemons, undefined cwd can cause the subprocess to fail silently.
653
626
  // Resolve path and validate existence — invalid cwd causes spawn to hang on Windows.
@@ -717,7 +690,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
717
690
  console.warn(`[sdk] session=${sessionId} no account and no API key in env — Claude CLI will use its own auth (if any)`);
718
691
  }
719
692
  }
720
- console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} 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}`);
721
694
 
722
695
  // Read MCP servers from PPM DB (fresh per query — user may add/remove between chats)
723
696
  const mcpServers = mcpConfigService.list();
@@ -738,8 +711,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
738
711
  const queryOptions: Record<string, any> = {
739
712
  // On Windows, child_process.spawn("bun") fails with ENOENT — force node
740
713
  ...(process.platform === "win32" && { executable: "node" }),
714
+ // First message: create session with this ID. Subsequent: resume by same ID.
741
715
  sessionId: isFirstMessage && !shouldFork ? sessionId : undefined,
742
- resume: (isFirstMessage && !shouldFork) ? undefined : sdkId,
716
+ resume: (isFirstMessage && !shouldFork) ? undefined : (shouldFork ? forkSourceId : sessionId),
743
717
  ...(shouldFork && { forkSession: true }),
744
718
  cwd: effectiveCwd,
745
719
  systemPrompt: systemPromptOpt,
@@ -815,9 +789,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
815
789
  let retryCount = 0;
816
790
  let rateLimitRetryCount = 0;
817
791
  let authRetried = false;
818
- /** True after the first init event maps ppmId → sdkId. Prevents retry init events from overwriting the mapping. */
819
- let initMappingDone = false;
820
-
821
792
  let hadAnyEvents = false;
822
793
  retryLoop: while (true) {
823
794
  let sdkEventCount = 0;
@@ -834,7 +805,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
834
805
  closeCurrentStream();
835
806
  const { generator: retryGen, controller: retryCtrl } = createMessageChannel();
836
807
  retryCtrl.push(firstMsg);
837
- 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 };
838
810
  const rq = query({
839
811
  prompt: retryGen,
840
812
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -858,35 +830,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
858
830
  const subtype = (msg as any).subtype ?? "none";
859
831
  console.log(`[sdk] session=${sessionId} system: subtype=${subtype} ${JSON.stringify(msg).slice(0, 500)}`);
860
832
 
861
- // Capture SDK session metadata from init message
862
833
  if (subtype === "init") {
863
- const initMsg = msg as any;
864
- if (initMsg.session_id && initMsg.session_id !== sessionId) {
865
- // Only update sdk_id mapping once per session lifecycle.
866
- // Retries (auth refresh, rate limit) create new SDK queries that
867
- // emit fresh init events — overwriting would orphan the original JSONL.
868
- if (!initMappingDone) {
869
- const existingSdkId = getSessionMapping(sessionId);
870
- const isFirstMapping = existingSdkId === null || existingSdkId === sessionId;
871
- if (isFirstMapping) {
872
- setSessionMapping(sessionId, initMsg.session_id, meta.projectName, meta.projectPath);
873
- initMappingDone = true;
874
- } else {
875
- // Already mapped to a real SDK id from a previous conversation
876
- initMappingDone = true;
877
- console.log(`[sdk] session=${sessionId} preserving existing mapping → ${existingSdkId}`);
878
- }
879
- } else {
880
- console.log(`[sdk] session=${sessionId} ignoring retry init sdk_id=${initMsg.session_id} (mapping already set)`);
881
- }
882
- // Only create activeSessions alias for first-time SDK id mapping.
883
- // Retry init events create phantom entries that pollute the map.
884
- if (isFirstMessage) {
885
- const oldMeta = this.activeSessions.get(sessionId);
886
- if (oldMeta) {
887
- this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
888
- }
889
- }
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}`);
890
839
  }
891
840
  }
892
841
 
@@ -927,7 +876,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
927
876
  const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
928
877
  const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
929
878
  if (!hasHistory) earlyAuthCtrl.push(firstMsg);
930
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
879
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
931
880
  const rq = query({
932
881
  prompt: earlyAuthGen,
933
882
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -953,7 +902,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
953
902
  const { generator: switchGen, controller: switchCtrl } = createMessageChannel();
954
903
  const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
955
904
  if (!hasHistory) switchCtrl.push(firstMsg);
956
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: switchEnv };
905
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: switchEnv };
957
906
  const rq = query({
958
907
  prompt: switchGen,
959
908
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1000,7 +949,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1000
949
  // Skip this for child messages (parentId set) — subagent internals don't mean parent tools finished.
1001
950
  if (pendingToolCount > 0 && !parentId && (msg.type === "assistant" || (msg as any).type === "partial" || (msg as any).type === "stream_event")) {
1002
951
  try {
1003
- const sessionMsgs = await getSessionMessages(sdkId);
952
+ const sessionMsgs = await getSessionMessages(sessionId);
1004
953
  // Find the last user message — it contains tool_result blocks
1005
954
  const lastUserMsg = [...sessionMsgs].reverse().find(
1006
955
  (m: any) => m.type === "user",
@@ -1097,14 +1046,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1097
1046
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1098
1047
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1099
1048
  // Close failed query and old channel, create new channel + query with refreshed token.
1100
- // Re-resolve sdkId: the init event may have mapped ppmId → real SDK session_id
1101
- // after sdkId was originally resolved. Using the stale value would try to
1102
- // resume a non-existent session, causing the SDK to hang forever.
1103
1049
  closeCurrentStream();
1104
1050
  const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
1105
1051
  const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1106
1052
  if (!hasHistory) authRetryCtrl.push(firstMsg);
1107
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
1053
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
1108
1054
  const rq = query({
1109
1055
  prompt: authRetryGen,
1110
1056
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1151,13 +1097,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1151
1097
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1152
1098
  await new Promise((r) => setTimeout(r, backoff));
1153
1099
  // Close current streaming session and recreate with (potentially new) account env.
1154
- // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1155
1100
  closeCurrentStream();
1156
1101
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1157
1102
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1158
1103
  const rlHasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1159
1104
  if (!rlHasHistory) rlRetryCtrl.push(firstMsg);
1160
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory ? getSdkSessionId(sessionId) : undefined, env: rlRetryEnv };
1105
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory ? sessionId : undefined, env: rlRetryEnv };
1161
1106
  const rq = query({
1162
1107
  prompt: rlRetryGen,
1163
1108
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1248,13 +1193,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1248
1193
  }
1249
1194
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1250
1195
  await new Promise((r) => setTimeout(r, backoff));
1251
- // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1252
1196
  closeCurrentStream();
1253
1197
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1254
1198
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1255
1199
  const rlHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1256
1200
  if (!rlHasHistory2) rlRetryCtrl.push(firstMsg);
1257
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory2 ? getSdkSessionId(sessionId) : undefined, env: rlRetryEnv };
1201
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory2 ? sessionId : undefined, env: rlRetryEnv };
1258
1202
  const rq = query({
1259
1203
  prompt: rlRetryGen,
1260
1204
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1279,13 +1223,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1279
1223
  const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1280
1224
  console.log(`[sdk] 401 in result on account ${account.id} (${label}) — token refreshed, retrying`);
1281
1225
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1282
- // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1283
1226
  closeCurrentStream();
1284
1227
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1285
1228
  const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1286
1229
  const authHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1287
1230
  if (!authHasHistory2) authRetryCtrl2.push(firstMsg);
1288
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
1231
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? sessionId : undefined, env: retryEnv };
1289
1232
  const rq = query({
1290
1233
  prompt: authRetryGen2,
1291
1234
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
@@ -1309,7 +1252,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1309
1252
  // Flush any remaining pending tool_results before finishing
1310
1253
  if (pendingToolCount > 0) {
1311
1254
  try {
1312
- const sessionMsgs = await getSessionMessages(sdkId);
1255
+ const sessionMsgs = await getSessionMessages(sessionId);
1313
1256
  const lastUserMsg = [...sessionMsgs].reverse().find(
1314
1257
  (m: any) => m.type === "user",
1315
1258
  );
@@ -1522,8 +1465,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1522
1465
 
1523
1466
  async getMessages(sessionId: string): Promise<ChatMessage[]> {
1524
1467
  try {
1525
- const sdkId = getSdkSessionId(sessionId);
1526
- const messages = await getSessionMessages(sdkId);
1468
+ const messages = await getSessionMessages(sessionId);
1527
1469
  const parsed = messages.map((msg) => parseSessionMessage(msg));
1528
1470
 
1529
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 }