@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.
- package/CHANGELOG.md +20 -0
- package/dist/web/assets/chat-tab-CmSLt4tg.js +10 -0
- package/dist/web/assets/{code-editor-kyaXcsZW.js → code-editor-BFe-hnpF.js} +1 -1
- package/dist/web/assets/{database-viewer-DmAux3OF.js → database-viewer-BeY2V5QI.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cikon1YK.js → diff-viewer-D6xzs8PP.js} +1 -1
- package/dist/web/assets/{extension-webview-DVvC7SQ-.js → extension-webview-Cd1XYFXO.js} +1 -1
- package/dist/web/assets/{git-graph-Bon2J1_A.js → git-graph-D2XXpiMQ.js} +1 -1
- package/dist/web/assets/index-BtwsLrdT.css +2 -0
- package/dist/web/assets/index-D6_wwsL_.js +30 -0
- package/dist/web/assets/keybindings-store-C8ryKudw.js +1 -0
- package/dist/web/assets/{markdown-renderer-ttL1fRGG.js → markdown-renderer-xYMhd9cE.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-Bljq2XEH.js → port-forwarding-tab-B5rj_I66.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CqburCkJ.js → postgres-viewer-DnlqzOnm.js} +1 -1
- package/dist/web/assets/{settings-tab-CQVn8u_D.js → settings-tab-CNZpuPD3.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DP6Kh2R8.js → sql-query-editor-Df2kzbPj.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CrqzbhyF.js → sqlite-viewer-Cj1G70z4.js} +1 -1
- package/dist/web/assets/{terminal-tab-BmBB838x.js → terminal-tab-Dv9A7Xe2.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-ZmSrfclJ.js → use-monaco-theme-CPfIEo8t.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +32 -90
- package/src/providers/cli-provider-base.ts +1 -1
- package/src/server/routes/chat.ts +26 -22
- package/src/server/ws/chat.ts +11 -17
- package/src/services/config.service.ts +4 -3
- package/src/services/db.service.ts +67 -37
- package/src/services/ppmbot/ppmbot-streamer.ts +0 -6
- package/src/types/chat.ts +0 -1
- package/src/web/components/chat/chat-tab.tsx +11 -8
- package/src/web/components/layout/project-bar.tsx +133 -87
- package/src/web/hooks/use-chat.ts +0 -11
- package/AGENTS.md +0 -80
- package/dist/web/assets/chat-tab-B3gpx-qv.js +0 -10
- package/dist/web/assets/index-B_sM201v.css +0 -2
- package/dist/web/assets/index-Buc4QA5O.js +0 -30
- package/dist/web/assets/keybindings-store-CT_EvCrb.js +0 -1
- 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 {
|
|
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
|
-
//
|
|
270
|
-
|
|
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)
|
|
277
|
+
// Try targeted lookup first (searches all project dirs)
|
|
284
278
|
try {
|
|
285
|
-
const
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
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, `${
|
|
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(
|
|
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
|
-
//
|
|
649
|
-
//
|
|
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}
|
|
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 :
|
|
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
|
-
|
|
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
|
|
864
|
-
if (
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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 ?
|
|
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 ?
|
|
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(
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
-
|
|
161
|
-
|
|
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(
|
|
177
|
+
setSessionTitle(id, title);
|
|
180
178
|
// Also persist to SDK so Claude Code CLI sees the custom title
|
|
181
|
-
await sdkRenameSession(
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
265
|
-
|
|
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(
|
|
271
|
-
?? getSessionProjectPath(
|
|
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, `${
|
|
279
|
+
const jsonlPath = jsonlDir ? resolve(jsonlDir, `${sessionId}.jsonl`) : "";
|
|
276
280
|
const jsonlExists = jsonlPath ? existsSync(jsonlPath) : false;
|
|
277
|
-
return c.json(ok({
|
|
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 */
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -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 {
|
|
193
|
+
const { setSessionMetadata } = require("./db.service.ts");
|
|
194
194
|
const map = JSON.parse(readFileSync(mapPath, "utf-8")) as Record<string, string>;
|
|
195
|
-
for (const [
|
|
196
|
-
|
|
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
|
|
567
|
+
// Session project metadata helpers (replaced session_map dual-ID system)
|
|
521
568
|
// ---------------------------------------------------------------------------
|
|
522
569
|
|
|
523
|
-
export function
|
|
524
|
-
const row = getDb().query("SELECT
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
return
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
|
|
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
|
|
552
|
-
).run(
|
|
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
|
|
556
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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 }
|