@hienlh/ppm 0.7.0 → 0.7.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.2] - 2026-03-20
4
+
5
+ ### Fixed
6
+ - **Tunnel URL sync**: `ppm start --share` tunnel URL now synced into `tunnelService` on daemon startup — Share button in web UI shows correct URL instead of treating tunnel as inactive and starting a duplicate
7
+
8
+ ## [0.7.1] - 2026-03-20
9
+
10
+ ### Fixed
11
+ - **Session rename persistence**: Use SDK `customTitle` field instead of volatile `summary` when listing/resuming sessions, so custom titles survive reloads
12
+ - **Session rename ID resolution**: Resolve PPM UUID → SDK session ID and pass project dir when renaming, ensuring SDK finds the correct session file
13
+ - **SDK crash recovery**: When SDK subprocess exits with code 1 during session resume, automatically retry as a fresh session instead of showing a cryptic error
14
+
3
15
  ## [0.7.0] - 2026-03-20
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -9,6 +9,8 @@ A mobile-first web IDE with AI chat, terminal, git, database tools, and file exp
9
9
  ```bash
10
10
  # 1. Install Bun (if you don't have it)
11
11
  curl -fsSL https://bun.sh/install | bash
12
+ # or via npm
13
+ npm install -g bun
12
14
 
13
15
  # 2. Run directly (no install needed)
14
16
  bunx @hienlh/ppm start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -261,7 +261,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
261
261
  const meta: Session = {
262
262
  id: sessionId,
263
263
  providerId: this.id,
264
- title: found.summary ?? "Resumed Chat",
264
+ title: found.customTitle ?? found.summary ?? "Resumed Chat",
265
265
  createdAt: new Date(found.lastModified).toISOString(),
266
266
  };
267
267
  this.activeSessions.set(sessionId, meta);
@@ -295,7 +295,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
295
295
  return sdkSessions.map((s) => ({
296
296
  id: s.sessionId,
297
297
  providerId: this.id,
298
- title: s.summary ?? s.firstPrompt ?? "Chat",
298
+ title: s.customTitle ?? s.summary ?? s.firstPrompt ?? "Chat",
299
299
  createdAt: new Date(s.lastModified).toISOString(),
300
300
  updatedAt: new Date(s.lastModified).toISOString(),
301
301
  }));
@@ -806,7 +806,65 @@ export class ClaudeAgentSdkProvider implements AIProvider {
806
806
  } catch (e) {
807
807
  const msg = (e as Error).message ?? String(e);
808
808
  console.error(`[sdk] error: ${msg}`);
809
- if (!msg.includes("abort") && !msg.includes("closed")) {
809
+ if (msg.includes("abort") || msg.includes("closed")) {
810
+ // User-initiated abort or WS closed — nothing to report
811
+ } else if (!isFirstMessage && msg.includes("exited with code")) {
812
+ // SDK subprocess crashed during session resume — retry as fresh session
813
+ console.warn(`[sdk] session resume failed, retrying as fresh session`);
814
+ try {
815
+ const providerConfig = this.getProviderConfig();
816
+ const effectiveCwd = meta.projectPath || homedir();
817
+ const queryEnv = { ...process.env, ...this.getProjectEnvOverrides(meta.projectPath) };
818
+ const retryQuery = query({
819
+ prompt: message,
820
+ options: {
821
+ cwd: effectiveCwd,
822
+ systemPrompt: { type: "preset", preset: "claude_code" },
823
+ settingSources: ["user", "project"],
824
+ env: queryEnv,
825
+ settings: { permissions: { allow: [], deny: [] } },
826
+ allowedTools: [
827
+ "Read", "Write", "Edit", "Bash", "Glob", "Grep",
828
+ "WebSearch", "WebFetch", "AskUserQuestion",
829
+ "Agent", "Skill", "TodoWrite", "ToolSearch",
830
+ ],
831
+ permissionMode: "bypassPermissions",
832
+ allowDangerouslySkipPermissions: true,
833
+ ...(providerConfig.model && { model: providerConfig.model }),
834
+ maxTurns: providerConfig.max_turns ?? 100,
835
+ canUseTool,
836
+ includePartialMessages: true,
837
+ } as any,
838
+ });
839
+ this.activeQueries.set(sessionId, retryQuery);
840
+ for await (const retryMsg of retryQuery) {
841
+ if (retryMsg.type === "system") continue;
842
+ if (retryMsg.type === "result") {
843
+ const r = retryMsg as any;
844
+ if (r.subtype && r.subtype !== "success") {
845
+ yield { type: "error", message: r.error ?? `Agent stopped: ${r.subtype}` };
846
+ }
847
+ resultSubtype = r.subtype;
848
+ resultNumTurns = r.num_turns;
849
+ break;
850
+ }
851
+ if ((retryMsg as any).type === "assistant") {
852
+ const content = (retryMsg as any).message?.content;
853
+ if (Array.isArray(content)) {
854
+ for (const block of content) {
855
+ if (block.type === "text" && typeof block.text === "string") {
856
+ yield { type: "text", content: block.text };
857
+ }
858
+ }
859
+ }
860
+ }
861
+ }
862
+ } catch (retryErr) {
863
+ const retryMsg = (retryErr as Error).message ?? String(retryErr);
864
+ console.error(`[sdk] retry also failed: ${retryMsg}`);
865
+ yield { type: "error", message: `SDK error: ${msg}` };
866
+ }
867
+ } else {
810
868
  yield { type: "error", message: `SDK error: ${msg}` };
811
869
  }
812
870
  } finally {
@@ -448,6 +448,20 @@ if (process.argv.includes("__serve__")) {
448
448
  configService.load(configPath);
449
449
  await setupLogFile();
450
450
 
451
+ // Sync externally-started tunnel URL (from `ppm start --share`) into tunnelService
452
+ // so GET /api/tunnel reflects the correct state and Share button doesn't start a duplicate.
453
+ try {
454
+ const { resolve: r } = await import("node:path");
455
+ const { homedir: h } = await import("node:os");
456
+ const { readFileSync: rf } = await import("node:fs");
457
+ const statusFile = r(h(), ".ppm", "status.json");
458
+ const status = JSON.parse(rf(statusFile, "utf-8"));
459
+ if (status.shareUrl) {
460
+ const { tunnelService } = await import("../services/tunnel.service.ts");
461
+ tunnelService.setExternalUrl(status.shareUrl);
462
+ }
463
+ } catch { /* status.json missing or no shareUrl — normal */ }
464
+
451
465
  Bun.serve({
452
466
  port,
453
467
  hostname: host,
@@ -8,6 +8,7 @@ import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sd
8
8
  import { listSlashItems } from "../../services/slash-items.service.ts";
9
9
  import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
10
10
  import { getSessionLog } from "../../services/session-log.service.ts";
11
+ import { getSessionMapping } from "../../services/db.service.ts";
11
12
  import { ok, err } from "../../types/api.ts";
12
13
 
13
14
  type Env = { Variables: { projectPath: string; projectName: string } };
@@ -114,8 +115,11 @@ chatRoutes.patch("/sessions/:id", async (c) => {
114
115
  const body = await c.req.json<{ title?: string }>();
115
116
  if (!body.title?.trim()) return c.json(err("title is required"), 400);
116
117
  const title = body.title.trim();
118
+ // Resolve PPM UUID → SDK session ID if mapped
119
+ const sdkId = getSessionMapping(id) ?? id;
120
+ const projectPath = c.get("projectPath");
117
121
  // Persist to SDK so Claude Code CLI also sees the custom title
118
- await sdkRenameSession(id, title);
122
+ await sdkRenameSession(sdkId, title, { dir: projectPath });
119
123
  // Also update in-memory session
120
124
  const session = chatService.getSession(id);
121
125
  if (session) session.title = title;
@@ -160,11 +160,12 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
160
160
  // Fire-and-forget: fetch updated session title from SDK summary
161
161
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
162
162
  const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
163
- if (found?.summary) {
164
- safeSend(sessionId, { type: "title_updated", title: found.summary });
163
+ const title = found?.customTitle ?? found?.summary;
164
+ if (title) {
165
+ safeSend(sessionId, { type: "title_updated", title });
165
166
  // Also update in-memory session title
166
167
  const session = chatService.getSession(sessionId);
167
- if (session) session.title = found.summary;
168
+ if (session) session.title = title;
168
169
  }
169
170
  }).catch(() => {});
170
171
  // Fire-and-forget notification broadcast (push + telegram)
@@ -298,9 +299,10 @@ export const chatWebSocket = {
298
299
  if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
299
300
  sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
300
301
  const found = sessions.find((s) => s.sessionId === sessionId);
301
- if (found?.summary) {
302
- safeSend(sessionId, { type: "title_updated", title: found.summary });
303
- if (session) session.title = found.summary;
302
+ const title = found?.customTitle ?? found?.summary;
303
+ if (title) {
304
+ safeSend(sessionId, { type: "title_updated", title });
305
+ if (session) session.title = title;
304
306
  }
305
307
  }).catch(() => {});
306
308
  }
@@ -330,9 +332,10 @@ export const chatWebSocket = {
330
332
  if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
331
333
  sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
332
334
  const found = sessions.find((s) => s.sessionId === sessionId);
333
- if (found?.summary) {
334
- safeSend(sessionId, { type: "title_updated", title: found.summary });
335
- if (session) session.title = found.summary;
335
+ const title = found?.customTitle ?? found?.summary;
336
+ if (title) {
337
+ safeSend(sessionId, { type: "title_updated", title });
338
+ if (session) session.title = title;
336
339
  }
337
340
  }).catch(() => {});
338
341
  }
@@ -98,6 +98,11 @@ class TunnelService {
98
98
  getTunnelUrl(): string | null {
99
99
  return this.url;
100
100
  }
101
+
102
+ /** Inject an externally-started tunnel URL (e.g. from daemon --share) */
103
+ setExternalUrl(url: string): void {
104
+ this.url = url;
105
+ }
101
106
  }
102
107
 
103
108
  /** Singleton tunnel service */