@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bun.lock +2062 -0
  3. package/bunfig.toml +2 -0
  4. package/dist/web/assets/ai-settings-section-NNWp6nw7.js +1 -0
  5. package/dist/web/assets/{api-settings-DAk7D-NP.js → api-settings-C3T95dWg.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-DDuzYaUV.js +1 -0
  7. package/dist/web/assets/{audio-preview-DnQmf9fu.js → audio-preview-BkbgGtDH.js} +1 -1
  8. package/dist/web/assets/chat-tab-BZlP1qjX.js +12 -0
  9. package/dist/web/assets/chevron-up-BWBvMZkp.js +1 -0
  10. package/dist/web/assets/{code-editor-B-lU1fz3.js → code-editor-BtspASkW.js} +4 -4
  11. package/dist/web/assets/{conflict-editor-BYzf3LuW.js → conflict-editor-Dgsu6fmj.js} +1 -1
  12. package/dist/web/assets/{csv-preview-HMSavgBb.js → csv-preview-DcWCjQkZ.js} +1 -1
  13. package/dist/web/assets/{database-viewer-DjvnIn8p.js → database-viewer-C85RxdMV.js} +2 -2
  14. package/dist/web/assets/diff-viewer-2pPy97Tl.js +4 -0
  15. package/dist/web/assets/{esm-K1XIK4vc.js → esm-_CLpyLJ_.js} +1 -1
  16. package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-BZDZ9QRc.js} +1 -1
  17. package/dist/web/assets/{extension-webview-4xMREn_x.js → extension-webview-U1lMYZ0p.js} +1 -1
  18. package/dist/web/assets/{file-store-BrbCNyLm.js → file-store-4BpOJthN.js} +1 -1
  19. package/dist/web/assets/gitGraph-HDMCJU4V-BURAevTc.js +1 -0
  20. package/dist/web/assets/{image-preview-CkS2PVdQ.js → image-preview-BcT1SbY2.js} +1 -1
  21. package/dist/web/assets/index-BWSRKVZn.js +23 -0
  22. package/dist/web/assets/index-b6tIZImC.css +2 -0
  23. package/dist/web/assets/info-3K5VOQVL-tSD4Fpi3.js +1 -0
  24. package/dist/web/assets/{input-Dk49gO8E.js → input-2eDVjcRZ.js} +1 -1
  25. package/dist/web/assets/{keybindings-store-B-zET-0o.js → keybindings-store-BOG1yviy.js} +1 -1
  26. package/dist/web/assets/keybindings-store-BvdUoEC7.js +1 -0
  27. package/dist/web/assets/{markdown-renderer-Bj2B05Km.js → markdown-renderer-Dbam_-04.js} +3 -3
  28. package/dist/web/assets/packet-RMMSAZCW-DmDLZUrV.js +1 -0
  29. package/dist/web/assets/{pdf-preview-CCyw5cuH.js → pdf-preview-BmHVGx32.js} +1 -1
  30. package/dist/web/assets/pie-UPGHQEXC-w03Pc9ZR.js +1 -0
  31. package/dist/web/assets/{port-forwarding-tab-Cebb5Eix.js → port-forwarding-tab-Dkq1upWC.js} +1 -1
  32. package/dist/web/assets/{postgres-viewer-BrOiliEv.js → postgres-viewer-BgBJAJ9q.js} +3 -3
  33. package/dist/web/assets/pre-compact-button-Dp7Hs49L.js +1 -0
  34. package/dist/web/assets/pre-compact-section-DnM5fGSR.js +1 -0
  35. package/dist/web/assets/radar-KQ55EAFF-C9XQvoey.js +1 -0
  36. package/dist/web/assets/{scroll-area-BEllam7_.js → scroll-area-CdxNNnN-.js} +1 -1
  37. package/dist/web/assets/{settings-store-BLLR7ed8.js → settings-store-CMAssqyb.js} +2 -2
  38. package/dist/web/assets/settings-tab-zYWKTq5z.js +1 -0
  39. package/dist/web/assets/{sql-query-editor-CVAnRFbi.js → sql-query-editor-b7zJ8XPp.js} +1 -1
  40. package/dist/web/assets/{sqlite-viewer-OEVq_-Po.js → sqlite-viewer-4lLAz1es.js} +1 -1
  41. package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-DNBsLdPn.js} +1 -1
  42. package/dist/web/assets/{terminal-tab-MjmJaQyA.js → terminal-tab-BtnqkN1H.js} +1 -1
  43. package/dist/web/assets/treemap-KZPCXAKY-lmftxSky.js +1 -0
  44. package/dist/web/assets/{use-blob-url-e9uTXjv5.js → use-blob-url-QX-XajU8.js} +1 -1
  45. package/dist/web/assets/{use-monaco-theme-BkZDwoVd.js → use-monaco-theme-D68oX3XU.js} +1 -1
  46. package/dist/web/assets/{vendor-mermaid-Dx86tuVP.js → vendor-mermaid-sQS4C_iL.js} +2 -2
  47. package/dist/web/assets/{video-preview-B819qvlp.js → video-preview-CkOKvVLt.js} +1 -1
  48. package/dist/web/index.html +18 -18
  49. package/dist/web/sw.js +1 -1
  50. package/package.json +1 -1
  51. package/src/cli/commands/autostart.ts +1 -3
  52. package/src/cli/commands/init.ts +5 -8
  53. package/src/cli/commands/restart.ts +4 -5
  54. package/src/index.ts +0 -5
  55. package/src/providers/claude-agent-sdk.ts +1 -135
  56. package/src/server/index.ts +9 -13
  57. package/src/server/routes/chat.ts +18 -0
  58. package/src/server/routes/git.ts +16 -0
  59. package/src/services/autostart-generator.ts +1 -6
  60. package/src/services/config.service.ts +3 -96
  61. package/src/services/git.service.ts +34 -0
  62. package/src/services/jsonl-transcript-parser.ts +216 -0
  63. package/src/services/ppmbot/cli-reference-default.ts +1 -4
  64. package/src/services/supervisor.ts +5 -6
  65. package/src/web/components/chat/message-list.tsx +41 -2
  66. package/src/web/components/chat/pre-compact-button.tsx +50 -0
  67. package/src/web/components/chat/pre-compact-section.tsx +69 -0
  68. package/src/web/components/editor/diff-viewer.tsx +21 -5
  69. package/dist/web/assets/ai-settings-section-QE6nBNgN.js +0 -1
  70. package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +0 -1
  71. package/dist/web/assets/chat-tab-Cf6T3mGO.js +0 -12
  72. package/dist/web/assets/diff-viewer-CP2jcR5J.js +0 -4
  73. package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +0 -1
  74. package/dist/web/assets/index-BTjuH4fn.css +0 -2
  75. package/dist/web/assets/index-FGlF8IWZ.js +0 -23
  76. package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +0 -1
  77. package/dist/web/assets/keybindings-store-DaBV6qhz.js +0 -1
  78. package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +0 -1
  79. package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +0 -1
  80. package/dist/web/assets/plus-51UQ45rf.js +0 -1
  81. package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +0 -1
  82. package/dist/web/assets/settings-tab-D0XjupJm.js +0 -1
  83. package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +0 -1
  84. /package/dist/web/assets/{api-client-Dvzcc_EO.js → api-client-DIhJ5qVW.js} +0 -0
  85. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-B5QW8pZ6.js} +0 -0
  86. /package/dist/web/assets/{dist-im4ynINo.js → dist-GtkSekuX.js} +0 -0
  87. /package/dist/web/assets/{katex-CKoArbIw.js → katex-C3cZrCvP.js} +0 -0
  88. /package/dist/web/assets/{lib-DQHnkzGy.js → lib-Bu71-TFS.js} +0 -0
  89. /package/dist/web/assets/{react-GqWghJ-L.js → react-DMIOAtcX.js} +0 -0
  90. /package/dist/web/assets/{refresh-cw-LlbZDJpO.js → refresh-cw-BjrAbUJe.js} +0 -0
  91. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-CULTsCqR.js} +0 -0
  92. /package/dist/web/assets/{table-Dq575bPF.js → table-tf7pRkME.js} +0 -0
  93. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-BV-R4Vvy.js} +0 -0
  94. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-DjQOpgUV.js} +0 -0
  95. /package/dist/web/assets/{utils-CTg5uAYR.js → utils-CQux7CsO.js} +0 -0
  96. /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 DB. If explicitPath given, import that YAML first. */
36
- load(explicitPath?: string): PpmConfig {
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 (replaces getConfigPath for YAML) */
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
- -c, --config <path> — Path to config file (YAML import into DB)
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> [config] [profile] [--share]
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.config ?? "", opts.profile ?? "",
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 config = process.argv[idx + 3] && process.argv[idx + 3] !== "_" ? process.argv[idx + 3] : undefined;
954
- const profile = process.argv[idx + 4] && process.argv[idx + 4] !== "_" ? process.argv[idx + 4] : undefined;
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, config, profile, share });
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
+ }