@hienlh/ppm 0.12.10 → 0.12.12
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 +17 -0
- package/bun.lock +2062 -0
- package/bunfig.toml +2 -0
- package/dist/web/assets/ai-settings-section-NNWp6nw7.js +1 -0
- package/dist/web/assets/{api-settings-DAk7D-NP.js → api-settings-C3T95dWg.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-DDuzYaUV.js +1 -0
- package/dist/web/assets/{audio-preview-DnQmf9fu.js → audio-preview-BkbgGtDH.js} +1 -1
- package/dist/web/assets/chat-tab-BZlP1qjX.js +12 -0
- package/dist/web/assets/chevron-up-BWBvMZkp.js +1 -0
- package/dist/web/assets/{code-editor-B-lU1fz3.js → code-editor-BtspASkW.js} +4 -4
- package/dist/web/assets/{conflict-editor-BYzf3LuW.js → conflict-editor-Dgsu6fmj.js} +1 -1
- package/dist/web/assets/{csv-preview-HMSavgBb.js → csv-preview-DcWCjQkZ.js} +1 -1
- package/dist/web/assets/{database-viewer-DjvnIn8p.js → database-viewer-C85RxdMV.js} +2 -2
- package/dist/web/assets/diff-viewer-2pPy97Tl.js +4 -0
- package/dist/web/assets/{esm-K1XIK4vc.js → esm-_CLpyLJ_.js} +1 -1
- package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-BZDZ9QRc.js} +1 -1
- package/dist/web/assets/{extension-webview-4xMREn_x.js → extension-webview-U1lMYZ0p.js} +1 -1
- package/dist/web/assets/{file-store-BrbCNyLm.js → file-store-4BpOJthN.js} +1 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BURAevTc.js +1 -0
- package/dist/web/assets/{image-preview-CkS2PVdQ.js → image-preview-BcT1SbY2.js} +1 -1
- package/dist/web/assets/index-BWSRKVZn.js +23 -0
- package/dist/web/assets/index-b6tIZImC.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-tSD4Fpi3.js +1 -0
- package/dist/web/assets/{input-Dk49gO8E.js → input-2eDVjcRZ.js} +1 -1
- package/dist/web/assets/{keybindings-store-B-zET-0o.js → keybindings-store-BOG1yviy.js} +1 -1
- package/dist/web/assets/keybindings-store-BvdUoEC7.js +1 -0
- package/dist/web/assets/{markdown-renderer-Bj2B05Km.js → markdown-renderer-Dbam_-04.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-DmDLZUrV.js +1 -0
- package/dist/web/assets/{pdf-preview-CCyw5cuH.js → pdf-preview-BmHVGx32.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-w03Pc9ZR.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-Cebb5Eix.js → port-forwarding-tab-Dkq1upWC.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BrOiliEv.js → postgres-viewer-BgBJAJ9q.js} +3 -3
- package/dist/web/assets/pre-compact-button-Dp7Hs49L.js +1 -0
- package/dist/web/assets/pre-compact-section-DnM5fGSR.js +1 -0
- package/dist/web/assets/radar-KQ55EAFF-C9XQvoey.js +1 -0
- package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-CdxNNnN-.js} +1 -1
- package/dist/web/assets/{settings-store-BLLR7ed8.js → settings-store-CMAssqyb.js} +2 -2
- package/dist/web/assets/settings-tab-zYWKTq5z.js +1 -0
- package/dist/web/assets/{sql-query-editor-CVAnRFbi.js → sql-query-editor-b7zJ8XPp.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-OEVq_-Po.js → sqlite-viewer-4lLAz1es.js} +1 -1
- package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-DNBsLdPn.js} +1 -1
- package/dist/web/assets/{terminal-tab-MjmJaQyA.js → terminal-tab-BtnqkN1H.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-lmftxSky.js +1 -0
- package/dist/web/assets/{use-blob-url-e9uTXjv5.js → use-blob-url-QX-XajU8.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BkZDwoVd.js → use-monaco-theme-D68oX3XU.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-Dx86tuVP.js → vendor-mermaid-sQS4C_iL.js} +2 -2
- package/dist/web/assets/{video-preview-B819qvlp.js → video-preview-CkOKvVLt.js} +1 -1
- package/dist/web/index.html +18 -18
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/autostart.ts +1 -3
- package/src/cli/commands/init.ts +5 -8
- package/src/cli/commands/restart.ts +4 -5
- package/src/index.ts +0 -5
- package/src/providers/claude-agent-sdk.ts +1 -135
- package/src/server/index.ts +9 -13
- package/src/server/routes/chat.ts +18 -0
- package/src/server/routes/git.ts +16 -0
- package/src/services/autostart-generator.ts +1 -6
- package/src/services/config.service.ts +3 -96
- package/src/services/git.service.ts +34 -0
- package/src/services/jsonl-transcript-parser.ts +216 -0
- package/src/services/ppmbot/cli-reference-default.ts +1 -4
- package/src/services/supervisor.ts +5 -6
- package/src/web/components/chat/message-list.tsx +41 -2
- package/src/web/components/chat/pre-compact-button.tsx +50 -0
- package/src/web/components/chat/pre-compact-section.tsx +69 -0
- package/src/web/components/editor/diff-viewer.tsx +21 -5
- package/dist/web/assets/ai-settings-section-QE6nBNgN.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +0 -1
- package/dist/web/assets/chat-tab-Cf6T3mGO.js +0 -12
- package/dist/web/assets/diff-viewer-CP2jcR5J.js +0 -4
- package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +0 -1
- package/dist/web/assets/index-BTjuH4fn.css +0 -2
- package/dist/web/assets/index-FGlF8IWZ.js +0 -23
- package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +0 -1
- package/dist/web/assets/keybindings-store-DaBV6qhz.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +0 -1
- package/dist/web/assets/plus-51UQ45rf.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +0 -1
- package/dist/web/assets/settings-tab-D0XjupJm.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +0 -1
- /package/dist/web/assets/{api-client-Dvzcc_EO.js → api-client-DIhJ5qVW.js} +0 -0
- /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-B5QW8pZ6.js} +0 -0
- /package/dist/web/assets/{dist-im4ynINo.js → dist-GtkSekuX.js} +0 -0
- /package/dist/web/assets/{katex-CKoArbIw.js → katex-C3cZrCvP.js} +0 -0
- /package/dist/web/assets/{lib-DQHnkzGy.js → lib-Bu71-TFS.js} +0 -0
- /package/dist/web/assets/{react-GqWghJ-L.js → react-DMIOAtcX.js} +0 -0
- /package/dist/web/assets/{refresh-cw-LlbZDJpO.js → refresh-cw-BjrAbUJe.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-CULTsCqR.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-tf7pRkME.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-BV-R4Vvy.js} +0 -0
- /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-DjQOpgUV.js} +0 -0
- /package/dist/web/assets/{utils-CTg5uAYR.js → utils-CQux7CsO.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-CU2c3f0A.js → vendor-xterm-K3_Xwigj.js} +0 -0
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync, renameSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
1
|
import { randomBytes } from "node:crypto";
|
|
4
2
|
import type { PpmConfig, ProjectConfig } from "../types/config.ts";
|
|
5
3
|
import { DEFAULT_CONFIG, sanitizeConfig } from "../types/config.ts";
|
|
@@ -15,7 +13,6 @@ import {
|
|
|
15
13
|
getProjectSettingsJson,
|
|
16
14
|
patchProjectSettingsJson,
|
|
17
15
|
} from "./db.service.ts";
|
|
18
|
-
import { getPpmDir } from "./ppm-dir.ts";
|
|
19
16
|
|
|
20
17
|
/** Top-level config keys stored in the config table (not projects) */
|
|
21
18
|
const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
|
|
@@ -32,20 +29,8 @@ export const FILE_CONFIG_KEYS = {
|
|
|
32
29
|
class ConfigService {
|
|
33
30
|
private config: PpmConfig = structuredClone(DEFAULT_CONFIG);
|
|
34
31
|
|
|
35
|
-
/** Load config from
|
|
36
|
-
load(
|
|
37
|
-
// Import explicit YAML if provided (e.g. `ppm start -c path`)
|
|
38
|
-
if (explicitPath && existsSync(explicitPath)) {
|
|
39
|
-
this.importFromYaml(explicitPath);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Auto-migrate: if config.yaml exists but DB has no config rows
|
|
43
|
-
// Skip migration when using in-memory DB (tests)
|
|
44
|
-
if (!getDbFilePath().includes(":memory:")) {
|
|
45
|
-
this.migrateYamlIfNeeded();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Load from DB
|
|
32
|
+
/** Load config from SQLite. Creates defaults if DB is empty. */
|
|
33
|
+
load(): PpmConfig {
|
|
49
34
|
const dbConfig = getAllConfig();
|
|
50
35
|
const dbProjects = getProjects();
|
|
51
36
|
|
|
@@ -102,7 +87,7 @@ class ConfigService {
|
|
|
102
87
|
return this.config;
|
|
103
88
|
}
|
|
104
89
|
|
|
105
|
-
/** Get the DB file path
|
|
90
|
+
/** Get the DB file path */
|
|
106
91
|
getConfigPath(): string {
|
|
107
92
|
return getDbFilePath();
|
|
108
93
|
}
|
|
@@ -184,84 +169,6 @@ class ConfigService {
|
|
|
184
169
|
stmt.run(p.path, p.name, p.color ?? null, i);
|
|
185
170
|
}
|
|
186
171
|
}
|
|
187
|
-
|
|
188
|
-
private migrateYamlIfNeeded(): void {
|
|
189
|
-
const yamlPaths = [
|
|
190
|
-
resolve(getPpmDir(), "config.yaml"),
|
|
191
|
-
resolve(getPpmDir(), "config.dev.yaml"),
|
|
192
|
-
];
|
|
193
|
-
for (const yamlPath of yamlPaths) {
|
|
194
|
-
if (!existsSync(yamlPath)) continue;
|
|
195
|
-
const existing = getAllConfig();
|
|
196
|
-
if (Object.keys(existing).length > 0) return;
|
|
197
|
-
this.importFromYaml(yamlPath);
|
|
198
|
-
try {
|
|
199
|
-
renameSync(yamlPath, yamlPath + ".bak");
|
|
200
|
-
console.log(`[config] Migrated ${yamlPath} → SQLite (backup: .bak)`);
|
|
201
|
-
} catch {}
|
|
202
|
-
}
|
|
203
|
-
this.migrateSessionMapIfNeeded();
|
|
204
|
-
this.migratePushSubsIfNeeded();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
private importFromYaml(path: string): void {
|
|
208
|
-
try {
|
|
209
|
-
const yaml = require("js-yaml");
|
|
210
|
-
const raw = readFileSync(path, "utf-8");
|
|
211
|
-
const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
|
|
212
|
-
if (!parsed) return;
|
|
213
|
-
const merged = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
|
|
214
|
-
for (const key of CONFIG_TABLE_KEYS) {
|
|
215
|
-
const value = (merged as any)[key];
|
|
216
|
-
if (value !== undefined) {
|
|
217
|
-
setConfigValue(String(key), JSON.stringify(value));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (merged.projects?.length) {
|
|
221
|
-
this.syncProjectsToDb(merged.projects);
|
|
222
|
-
}
|
|
223
|
-
} catch (err) {
|
|
224
|
-
console.error(`[config] Error importing YAML ${path}:`, (err as Error).message);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
private migrateSessionMapIfNeeded(): void {
|
|
229
|
-
const mapPath = resolve(getPpmDir(), "session-map.json");
|
|
230
|
-
if (!existsSync(mapPath)) return;
|
|
231
|
-
try {
|
|
232
|
-
const { setSessionMetadata } = require("./db.service.ts");
|
|
233
|
-
const map = JSON.parse(readFileSync(mapPath, "utf-8")) as Record<string, string>;
|
|
234
|
-
for (const [_ppmId, sdkId] of Object.entries(map)) {
|
|
235
|
-
// Use SDK ID as canonical session ID (ppmId is legacy)
|
|
236
|
-
setSessionMetadata(sdkId);
|
|
237
|
-
}
|
|
238
|
-
renameSync(mapPath, mapPath + ".bak");
|
|
239
|
-
console.log("[config] Migrated session-map.json → SQLite");
|
|
240
|
-
} catch {}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
private migratePushSubsIfNeeded(): void {
|
|
244
|
-
const subsPath = resolve(getPpmDir(), "push-subscriptions.json");
|
|
245
|
-
if (!existsSync(subsPath)) return;
|
|
246
|
-
try {
|
|
247
|
-
const { upsertPushSubscription } = require("./db.service.ts");
|
|
248
|
-
const subs = JSON.parse(readFileSync(subsPath, "utf-8")) as Array<{
|
|
249
|
-
endpoint: string;
|
|
250
|
-
keys: { p256dh: string; auth: string };
|
|
251
|
-
expirationTime?: number | null;
|
|
252
|
-
}>;
|
|
253
|
-
for (const sub of subs) {
|
|
254
|
-
upsertPushSubscription(
|
|
255
|
-
sub.endpoint,
|
|
256
|
-
sub.keys.p256dh,
|
|
257
|
-
sub.keys.auth,
|
|
258
|
-
sub.expirationTime != null ? String(sub.expirationTime) : null,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
renameSync(subsPath, subsPath + ".bak");
|
|
262
|
-
console.log("[config] Migrated push-subscriptions.json → SQLite");
|
|
263
|
-
} catch {}
|
|
264
|
-
}
|
|
265
172
|
}
|
|
266
173
|
|
|
267
174
|
/** Singleton config service */
|
|
@@ -122,6 +122,40 @@ class GitService {
|
|
|
122
122
|
return files;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Returns full file contents for both sides of a diff (VSCode-style).
|
|
127
|
+
* - original: file at HEAD (empty if new/untracked/ref missing)
|
|
128
|
+
* - modified: working tree content (empty if deleted on disk)
|
|
129
|
+
* Monaco DiffEditor will compute/render the diff from these full contents.
|
|
130
|
+
*/
|
|
131
|
+
async fileFullDiff(
|
|
132
|
+
projectPath: string,
|
|
133
|
+
filePath: string,
|
|
134
|
+
ref: string = "HEAD",
|
|
135
|
+
): Promise<{ original: string; modified: string }> {
|
|
136
|
+
const git = this.git(projectPath);
|
|
137
|
+
const absPath = path.resolve(projectPath, filePath);
|
|
138
|
+
|
|
139
|
+
let original = "";
|
|
140
|
+
try {
|
|
141
|
+
original = await git.show([`${ref}:${filePath}`]);
|
|
142
|
+
} catch {
|
|
143
|
+
// File does not exist at ref (new/untracked/added) → empty original
|
|
144
|
+
original = "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let modified = "";
|
|
148
|
+
try {
|
|
149
|
+
const f = Bun.file(absPath);
|
|
150
|
+
if (await f.exists()) modified = await f.text();
|
|
151
|
+
} catch {
|
|
152
|
+
// File missing on disk (deleted) → empty modified
|
|
153
|
+
modified = "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { original, modified };
|
|
157
|
+
}
|
|
158
|
+
|
|
125
159
|
async fileDiff(
|
|
126
160
|
projectPath: string,
|
|
127
161
|
filePath: string,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a Claude Code JSONL transcript file into ChatMessage[].
|
|
3
|
+
* Reusable across live SDK session history (claude-agent-sdk.ts) and
|
|
4
|
+
* pre-compact transcript loading (chat route /pre-compact-messages).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import type { ChatEvent, ChatMessage } from "../types/chat.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
12
|
+
const TEAMMATE_MSG_RE = /<teammate-message[^>]*>[\s\S]*?<\/teammate-message>/g;
|
|
13
|
+
|
|
14
|
+
/** Strip SDK teammate-message XML tags from assistant text */
|
|
15
|
+
export function stripTeammateXml(text: string): string {
|
|
16
|
+
if (!text.includes("<teammate-message")) return text;
|
|
17
|
+
return text.replace(TEAMMATE_MSG_RE, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Extract plain text from message payload */
|
|
21
|
+
export function extractText(message: unknown): string {
|
|
22
|
+
if (!message || typeof message !== "object") return "";
|
|
23
|
+
const msg = message as Record<string, unknown>;
|
|
24
|
+
if (typeof msg.content === "string") return msg.content;
|
|
25
|
+
if (Array.isArray(msg.content)) {
|
|
26
|
+
return (msg.content as Array<Record<string, unknown>>)
|
|
27
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
28
|
+
.map((b) => b.text as string)
|
|
29
|
+
.join("");
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse SDK SessionMessage into ChatMessage with events for tool_use blocks */
|
|
35
|
+
export function parseSessionMessage(
|
|
36
|
+
msg: { uuid: string; type: string; message: unknown; parent_tool_use_id?: string | null },
|
|
37
|
+
): ChatMessage {
|
|
38
|
+
const message = msg.message as Record<string, unknown> | undefined;
|
|
39
|
+
const role = msg.type as "user" | "assistant";
|
|
40
|
+
const parentId = (msg as any).parent_tool_use_id as string | undefined;
|
|
41
|
+
|
|
42
|
+
// Filter synthetic SDK-generated error messages (auth failures, rate limits, etc.)
|
|
43
|
+
const isSdkErrorMessage =
|
|
44
|
+
(msg as any).isApiErrorMessage === true ||
|
|
45
|
+
typeof (msg as any).error === "string" ||
|
|
46
|
+
(message && (message as any).model === "<synthetic>" &&
|
|
47
|
+
Array.isArray(message.content) &&
|
|
48
|
+
(message.content as Array<Record<string, unknown>>).some(
|
|
49
|
+
(b) => b.type === "text" && typeof b.text === "string" &&
|
|
50
|
+
/Failed to authenticate|API Error: 40[13]|hit your limit|rate.?limit/i.test(b.text as string),
|
|
51
|
+
));
|
|
52
|
+
if (isSdkErrorMessage) {
|
|
53
|
+
return {
|
|
54
|
+
id: msg.uuid,
|
|
55
|
+
role,
|
|
56
|
+
content: "",
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
sdkUuid: msg.uuid,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const events: ChatEvent[] = [];
|
|
63
|
+
let textContent = "";
|
|
64
|
+
|
|
65
|
+
if (message && Array.isArray(message.content)) {
|
|
66
|
+
for (const block of message.content as Array<Record<string, unknown>>) {
|
|
67
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
68
|
+
const cleaned = role === "assistant" ? stripTeammateXml(block.text) : block.text;
|
|
69
|
+
textContent += cleaned;
|
|
70
|
+
if (role === "assistant" && cleaned) {
|
|
71
|
+
events.push({ type: "text", content: cleaned, ...(parentId && { parentToolUseId: parentId }) });
|
|
72
|
+
}
|
|
73
|
+
} else if (block.type === "tool_use") {
|
|
74
|
+
events.push({
|
|
75
|
+
type: "tool_use",
|
|
76
|
+
tool: (block.name as string) ?? "unknown",
|
|
77
|
+
input: block.input ?? {},
|
|
78
|
+
toolUseId: block.id as string | undefined,
|
|
79
|
+
...(parentId && { parentToolUseId: parentId }),
|
|
80
|
+
});
|
|
81
|
+
} else if (block.type === "tool_result") {
|
|
82
|
+
const output = block.content ?? block.output ?? "";
|
|
83
|
+
events.push({
|
|
84
|
+
type: "tool_result",
|
|
85
|
+
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
86
|
+
isError: !!(block as Record<string, unknown>).is_error,
|
|
87
|
+
toolUseId: block.tool_use_id as string | undefined,
|
|
88
|
+
...(parentId && { parentToolUseId: parentId }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
textContent = extractText(message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// SDK-generated user messages carry system text (tool_result blocks, teammate XML) —
|
|
97
|
+
// clear so they don't render as user bubbles.
|
|
98
|
+
if (role === "user" && (events.some((e) => e.type === "tool_result") || textContent.includes("<teammate-message"))) {
|
|
99
|
+
textContent = "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: msg.uuid,
|
|
104
|
+
role,
|
|
105
|
+
content: textContent,
|
|
106
|
+
events: events.length > 0 ? events : undefined,
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
sdkUuid: msg.uuid,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Move events with parentToolUseId into their parent Agent/Task tool_use's children array.
|
|
114
|
+
* Mutates the array in-place.
|
|
115
|
+
*/
|
|
116
|
+
export function nestChildEvents(events: ChatEvent[]): void {
|
|
117
|
+
const parentMap = new Map<string, ChatEvent & { type: "tool_use" }>();
|
|
118
|
+
for (const ev of events) {
|
|
119
|
+
if (ev.type === "tool_use" && (ev.tool === "Agent" || ev.tool === "Task") && ev.toolUseId) {
|
|
120
|
+
parentMap.set(ev.toolUseId, ev);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (parentMap.size === 0) return;
|
|
124
|
+
|
|
125
|
+
const childIndices: number[] = [];
|
|
126
|
+
for (let i = 0; i < events.length; i++) {
|
|
127
|
+
const ev = events[i]!;
|
|
128
|
+
const pid = (ev as any).parentToolUseId as string | undefined;
|
|
129
|
+
if (!pid) continue;
|
|
130
|
+
const parent = parentMap.get(pid);
|
|
131
|
+
if (parent) {
|
|
132
|
+
if (!parent.children) parent.children = [];
|
|
133
|
+
parent.children.push(ev);
|
|
134
|
+
childIndices.push(i);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (let i = childIndices.length - 1; i >= 0; i--) {
|
|
138
|
+
events.splice(childIndices[i]!, 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate JSONL path — must be under ~/.claude/ (prevents arbitrary file reads).
|
|
144
|
+
* Throws Error with descriptive message. Returns resolved realpath on success.
|
|
145
|
+
*/
|
|
146
|
+
export function validateJsonlPath(inputPath: string): string {
|
|
147
|
+
if (!inputPath) throw new Error("jsonlPath is required");
|
|
148
|
+
// Reject obvious traversal attempts before resolution
|
|
149
|
+
if (inputPath.includes("\0")) throw new Error("Invalid path: denied");
|
|
150
|
+
if (!inputPath.endsWith(".jsonl")) throw new Error("Invalid path: must be a .jsonl file");
|
|
151
|
+
|
|
152
|
+
const resolved = resolve(inputPath);
|
|
153
|
+
if (!existsSync(resolved)) throw new Error("File not found");
|
|
154
|
+
|
|
155
|
+
let real: string;
|
|
156
|
+
try {
|
|
157
|
+
real = realpathSync(resolved);
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error("File not found");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const claudeDir = resolve(homedir(), ".claude") + "/";
|
|
163
|
+
if (!(real + "/").startsWith(claudeDir)) {
|
|
164
|
+
throw new Error("Access denied: path traversal detected");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const stat = statSync(real);
|
|
168
|
+
if (!stat.isFile()) throw new Error("Not a regular file");
|
|
169
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
170
|
+
throw new Error(`File too large: ${Math.round(stat.size / 1024 / 1024)}MB exceeds 50MB limit`);
|
|
171
|
+
}
|
|
172
|
+
return real;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read a JSONL transcript file, parse entries, apply merge/nest pipeline, return ChatMessage[].
|
|
177
|
+
* Applies the same logic as ClaudeAgentSdkProvider.getMessages() but reads from file directly.
|
|
178
|
+
*/
|
|
179
|
+
export async function parseJsonlTranscript(filePath: string): Promise<ChatMessage[]> {
|
|
180
|
+
const text = await Bun.file(filePath).text();
|
|
181
|
+
const parsed: ChatMessage[] = [];
|
|
182
|
+
for (const line of text.split("\n")) {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (!trimmed) continue;
|
|
185
|
+
let entry: any;
|
|
186
|
+
try {
|
|
187
|
+
entry = JSON.parse(trimmed);
|
|
188
|
+
} catch {
|
|
189
|
+
continue; // skip malformed lines defensively
|
|
190
|
+
}
|
|
191
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
192
|
+
if (!entry.uuid || !entry.message) continue;
|
|
193
|
+
parsed.push(parseSessionMessage(entry));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Merge tool_result-only user messages into preceding assistant
|
|
197
|
+
const merged: ChatMessage[] = [];
|
|
198
|
+
for (const msg of parsed) {
|
|
199
|
+
if (msg.events?.length && msg.events.every((e) => e.type === "tool_result")) {
|
|
200
|
+
const lastAssistant = [...merged].reverse().find((m) => m.role === "assistant");
|
|
201
|
+
if (lastAssistant?.events) {
|
|
202
|
+
lastAssistant.events.push(...msg.events);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
merged.push(msg);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const msg of merged) {
|
|
210
|
+
if (msg.events) nestChildEvents(msg.events);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return merged.filter(
|
|
214
|
+
(msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -13,7 +13,7 @@ ppm start
|
|
|
13
13
|
Start the PPM server (background by default)
|
|
14
14
|
-p, --port <port> — Port to listen on
|
|
15
15
|
-s, --share — (deprecated) Tunnel is now always enabled
|
|
16
|
-
|
|
16
|
+
--profile <name> — DB profile name (e.g. 'dev' → ppm.dev.db)
|
|
17
17
|
|
|
18
18
|
ppm stop
|
|
19
19
|
Stop the PPM server (supervisor stays alive)
|
|
@@ -25,7 +25,6 @@ ppm down
|
|
|
25
25
|
|
|
26
26
|
ppm restart
|
|
27
27
|
Restart the server (keeps tunnel alive)
|
|
28
|
-
-c, --config <path> — Path to config file
|
|
29
28
|
--force — Force resume from paused state
|
|
30
29
|
|
|
31
30
|
ppm status
|
|
@@ -35,7 +34,6 @@ ppm status
|
|
|
35
34
|
|
|
36
35
|
ppm open
|
|
37
36
|
Open PPM in browser
|
|
38
|
-
-c, --config <path> — Path to config file
|
|
39
37
|
|
|
40
38
|
ppm logs
|
|
41
39
|
View PPM daemon logs
|
|
@@ -203,7 +201,6 @@ ppm autostart enable
|
|
|
203
201
|
Register PPM to start automatically on boot
|
|
204
202
|
-p, --port <port> — Override port
|
|
205
203
|
-s, --share — (deprecated) Tunnel is now always enabled
|
|
206
|
-
-c, --config <path> — Config file path
|
|
207
204
|
--profile <name> — DB profile name
|
|
208
205
|
|
|
209
206
|
ppm autostart disable
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Supervisor process — long-lived parent that manages server child + tunnel child.
|
|
3
3
|
* Respawns children on crash with exponential backoff.
|
|
4
4
|
* Health-checks server (/api/health) and tunnel URL (public probe).
|
|
5
|
-
* Entry: __supervise__ <port> <host> [
|
|
5
|
+
* Entry: __supervise__ <port> <host> [profile] [--share]
|
|
6
6
|
*/
|
|
7
7
|
import type { Subprocess } from "bun";
|
|
8
8
|
import { resolve } from "node:path";
|
|
@@ -782,7 +782,6 @@ export function shutdown() {
|
|
|
782
782
|
export async function runSupervisor(opts: {
|
|
783
783
|
port: number;
|
|
784
784
|
host: string;
|
|
785
|
-
config?: string;
|
|
786
785
|
profile?: string;
|
|
787
786
|
share: boolean;
|
|
788
787
|
}) {
|
|
@@ -822,7 +821,7 @@ export async function runSupervisor(opts: {
|
|
|
822
821
|
// Build __serve__ args
|
|
823
822
|
const serverArgs = [
|
|
824
823
|
"__serve__", String(opts.port), opts.host,
|
|
825
|
-
opts.
|
|
824
|
+
opts.profile ?? "",
|
|
826
825
|
];
|
|
827
826
|
// Strip trailing empty args
|
|
828
827
|
while (serverArgs.length > 0 && serverArgs[serverArgs.length - 1] === "") serverArgs.pop();
|
|
@@ -950,8 +949,8 @@ if (process.argv.includes("__supervise__")) {
|
|
|
950
949
|
const idx = process.argv.indexOf("__supervise__");
|
|
951
950
|
const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
|
|
952
951
|
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
953
|
-
const
|
|
954
|
-
const profile =
|
|
952
|
+
const profileRaw = process.argv[idx + 3];
|
|
953
|
+
const profile = profileRaw && profileRaw !== "_" && !profileRaw.startsWith("--") ? profileRaw : undefined;
|
|
955
954
|
const share = process.argv.includes("--share");
|
|
956
955
|
|
|
957
956
|
// Set DB profile for supervisor (needed to read config)
|
|
@@ -960,5 +959,5 @@ if (process.argv.includes("__supervise__")) {
|
|
|
960
959
|
setDbProfile(profile);
|
|
961
960
|
}
|
|
962
961
|
|
|
963
|
-
runSupervisor({ port, host,
|
|
962
|
+
runSupervisor({ port, host, profile, share });
|
|
964
963
|
}
|
|
@@ -5,6 +5,10 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
|
5
5
|
import type { SessionPhase } from "../../../types/api";
|
|
6
6
|
import type { BashPartialEntry } from "../../hooks/use-chat";
|
|
7
7
|
import { ToolCard } from "./tool-cards";
|
|
8
|
+
import { extractJsonlPath } from "./pre-compact-button";
|
|
9
|
+
const PreCompactSection = lazy(() =>
|
|
10
|
+
import("./pre-compact-section").then((m) => ({ default: m.PreCompactSection }))
|
|
11
|
+
);
|
|
8
12
|
const MarkdownRenderer = lazy(() =>
|
|
9
13
|
import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
|
|
10
14
|
);
|
|
@@ -310,11 +314,11 @@ const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
|
|
|
310
314
|
|
|
311
315
|
/** User message bubble — full width, collapsible, with system tag badges */
|
|
312
316
|
function UserBubble({ content, projectName, onFork }: { content: string; projectName?: string; onFork?: () => void }) {
|
|
313
|
-
const { files, text, tags, command } = useMemo(() => {
|
|
317
|
+
const { files, text, tags, command, jsonlPath } = useMemo(() => {
|
|
314
318
|
const parsed = parseUserAttachments(content);
|
|
315
319
|
const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
|
|
316
320
|
const { command, cleanText } = parseCommandTags(noSysTags);
|
|
317
|
-
return { files: parsed.files, text: cleanText, tags, command };
|
|
321
|
+
return { files: parsed.files, text: cleanText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
|
|
318
322
|
}, [content]);
|
|
319
323
|
|
|
320
324
|
const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
|
|
@@ -399,6 +403,23 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
399
403
|
{expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
|
|
400
404
|
</button>
|
|
401
405
|
)}
|
|
406
|
+
{/* Expand compacted conversation: detect JSONL path in compact summary user message */}
|
|
407
|
+
{jsonlPath && (
|
|
408
|
+
<Suspense fallback={<div className="mt-2 animate-pulse h-10 bg-surface/50 rounded" />}>
|
|
409
|
+
<PreCompactSection
|
|
410
|
+
jsonlPath={jsonlPath}
|
|
411
|
+
projectName={projectName}
|
|
412
|
+
renderMessage={(msg, idx) => (
|
|
413
|
+
<MessageBubble
|
|
414
|
+
key={msg.id ?? `pc-${idx}`}
|
|
415
|
+
message={msg}
|
|
416
|
+
isStreaming={false}
|
|
417
|
+
projectName={projectName}
|
|
418
|
+
/>
|
|
419
|
+
)}
|
|
420
|
+
/>
|
|
421
|
+
</Suspense>
|
|
422
|
+
)}
|
|
402
423
|
{/* Fork/Rewind button — only for real user messages */}
|
|
403
424
|
{!isSystemContext && onFork && (
|
|
404
425
|
<button
|
|
@@ -788,9 +809,27 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
|
|
|
788
809
|
}
|
|
789
810
|
if (group.kind === "text") {
|
|
790
811
|
const isLast = isStreaming && i === groups.length - 1;
|
|
812
|
+
const jsonlPath = extractJsonlPath(group.content);
|
|
791
813
|
return (
|
|
792
814
|
<div key={`text-${i}`} className="text-sm text-text-primary select-text">
|
|
793
815
|
<StreamingText content={group.content} animate={isLast} projectName={projectName} />
|
|
816
|
+
{jsonlPath && (
|
|
817
|
+
<Suspense fallback={<div className="mt-2 animate-pulse h-10 bg-surface/50 rounded" />}>
|
|
818
|
+
<PreCompactSection
|
|
819
|
+
jsonlPath={jsonlPath}
|
|
820
|
+
projectName={projectName}
|
|
821
|
+
renderMessage={(msg, idx) => (
|
|
822
|
+
<MessageBubble
|
|
823
|
+
key={msg.id ?? `pc-${idx}`}
|
|
824
|
+
message={msg}
|
|
825
|
+
isStreaming={false}
|
|
826
|
+
projectName={projectName}
|
|
827
|
+
bashPartialOutput={bashPartialOutput}
|
|
828
|
+
/>
|
|
829
|
+
)}
|
|
830
|
+
/>
|
|
831
|
+
</Suspense>
|
|
832
|
+
)}
|
|
794
833
|
</div>
|
|
795
834
|
);
|
|
796
835
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { AlertCircle, ChevronUp, History, Loader2 } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
/** Detects a JSONL transcript path in Claude's compact summary message text. */
|
|
4
|
+
const JSONL_PATH_RE = /read the full transcript at:\s*(\S+\.jsonl)/i;
|
|
5
|
+
|
|
6
|
+
export function extractJsonlPath(text: string): string | null {
|
|
7
|
+
const match = text.match(JSONL_PATH_RE);
|
|
8
|
+
return match?.[1]?.trim() ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PreCompactStatus = "idle" | "loading" | "loaded" | "error";
|
|
12
|
+
|
|
13
|
+
interface PreCompactButtonProps {
|
|
14
|
+
status: PreCompactStatus;
|
|
15
|
+
onLoad?: () => void;
|
|
16
|
+
count?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Button shown when Claude's compact summary is detected.
|
|
21
|
+
* Clicking triggers the pre-compact-messages fetch. Shows loading/loaded/error states.
|
|
22
|
+
* Responsive: full-width on mobile, inline on desktop. Min 44px touch target.
|
|
23
|
+
*/
|
|
24
|
+
export function PreCompactButton({ status, onLoad, count }: PreCompactButtonProps) {
|
|
25
|
+
const isBusy = status === "loading";
|
|
26
|
+
const isLoaded = status === "loaded";
|
|
27
|
+
const isError = status === "error";
|
|
28
|
+
|
|
29
|
+
const label = isBusy
|
|
30
|
+
? "Loading previous conversation..."
|
|
31
|
+
: isLoaded
|
|
32
|
+
? `Previous conversation loaded${count != null ? ` (${count})` : ""}`
|
|
33
|
+
: isError
|
|
34
|
+
? "Failed to load — retry"
|
|
35
|
+
: "Load previous conversation";
|
|
36
|
+
|
|
37
|
+
const Icon = isBusy ? Loader2 : isLoaded ? ChevronUp : isError ? AlertCircle : History;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={onLoad}
|
|
43
|
+
disabled={isBusy || isLoaded}
|
|
44
|
+
className="mt-2 inline-flex items-center justify-center gap-2 rounded-md border border-border bg-surface/50 px-4 py-2.5 text-sm text-text-primary hover:bg-surface transition-colors disabled:opacity-70 disabled:cursor-default w-full md:w-auto min-h-[44px]"
|
|
45
|
+
>
|
|
46
|
+
<Icon className={`size-4 shrink-0 ${isBusy ? "animate-spin" : ""} ${isError ? "text-red-400" : ""}`} />
|
|
47
|
+
<span>{label}</span>
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import type { ChatMessage } from "../../../types/chat";
|
|
5
|
+
import { PreCompactButton, type PreCompactStatus } from "./pre-compact-button";
|
|
6
|
+
|
|
7
|
+
interface PreCompactSectionProps {
|
|
8
|
+
jsonlPath: string;
|
|
9
|
+
projectName?: string;
|
|
10
|
+
/** Renders each loaded pre-compact message. Passed from parent to avoid circular imports. */
|
|
11
|
+
renderMessage: (msg: ChatMessage, idx: number) => React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Orchestrates the "Load previous conversation" flow:
|
|
16
|
+
* 1. Shows button when idle/loading/error
|
|
17
|
+
* 2. On click: GET /api/project/:name/chat/pre-compact-messages?jsonlPath=...
|
|
18
|
+
* 3. Renders returned messages in a collapsible section
|
|
19
|
+
*/
|
|
20
|
+
export function PreCompactSection({ jsonlPath, projectName, renderMessage }: PreCompactSectionProps) {
|
|
21
|
+
const [status, setStatus] = useState<PreCompactStatus>("idle");
|
|
22
|
+
const [messages, setMessages] = useState<ChatMessage[] | null>(null);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [expanded, setExpanded] = useState(false);
|
|
25
|
+
|
|
26
|
+
const handleLoad = useCallback(async () => {
|
|
27
|
+
if (!projectName) { setError("No project context available"); setStatus("error"); return; }
|
|
28
|
+
setStatus("loading");
|
|
29
|
+
setError(null);
|
|
30
|
+
try {
|
|
31
|
+
const path = `${projectUrl(projectName)}/chat/pre-compact-messages?jsonlPath=${encodeURIComponent(jsonlPath)}`;
|
|
32
|
+
const data = await api.get<ChatMessage[]>(path);
|
|
33
|
+
setMessages(data);
|
|
34
|
+
setStatus("loaded");
|
|
35
|
+
setExpanded(true);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
setError(e instanceof Error ? e.message : "Unknown error");
|
|
38
|
+
setStatus("error");
|
|
39
|
+
}
|
|
40
|
+
}, [jsonlPath, projectName]);
|
|
41
|
+
|
|
42
|
+
if (status !== "loaded" || !messages) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="mt-2 flex flex-col gap-1">
|
|
45
|
+
<PreCompactButton status={status} onLoad={status === "loading" ? undefined : handleLoad} />
|
|
46
|
+
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="mt-2 rounded-lg border border-border/50 bg-surface/30 overflow-hidden">
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={() => setExpanded((v) => !v)}
|
|
56
|
+
className="flex items-center gap-2 w-full px-3 py-2.5 text-sm text-text-secondary hover:bg-surface/50 transition-colors min-h-[44px]"
|
|
57
|
+
>
|
|
58
|
+
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
|
59
|
+
<History className="size-4" />
|
|
60
|
+
<span>Previous conversation ({messages.length} messages)</span>
|
|
61
|
+
</button>
|
|
62
|
+
{expanded && (
|
|
63
|
+
<div className="border-t border-border/30 px-2 md:px-3 py-3 space-y-3 max-h-[60vh] overflow-y-auto">
|
|
64
|
+
{messages.map((msg, idx) => renderMessage(msg, idx))}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|