@hienlh/ppm 0.23.23 → 0.23.24

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.23.24] - 2026-10-31
4
+
5
+ ### Fixed
6
+
7
+ - **A chat could send one of your earlier messages again by itself, and act on it.** When a Claude process died, PPM restarted it and re-sent a message to get going again — but it had already thrown away the record of what the chat was doing, so it reached back to the message that *opened* the chat, sometimes hours old, and handed it to Claude as if you had just typed it. On 30 October two chats in different projects replayed their opening instruction within a second of each other after the same crash. A replayed instruction runs for real, so one that published, deployed or changed a database would have done it a second time. A restart now resumes a turn that was interrupted, re-sends your message only when nothing of it had reached Claude yet, and sends nothing at all when the chat was simply idle.
8
+
3
9
  ## [0.23.23] - 2026-10-30
4
10
 
5
11
  ### Fixed
@@ -73,4 +73,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
73
73
  - Third-party extensions (inspect via `ppm ext list`).
74
74
  - The Claude Agent SDK internals (separate skill).
75
75
 
76
- <!-- Generated for PPM v0.23.23 at build time. Re-run `ppm export skill --install` to refresh. -->
76
+ <!-- Generated for PPM v0.23.24 at build time. Re-run `ppm export skill --install` to refresh. -->
@@ -491,4 +491,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
491
491
  - `ws://<host>/ws/terminal` — PTY terminal multiplexer
492
492
  - `ws://<host>/ws/extensions` — extension host channel
493
493
 
494
- <!-- Generated from src/server/routes/ for PPM v0.23.23 -->
494
+ <!-- Generated from src/server/routes/ for PPM v0.23.24 -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.23.23",
3
+ "version": "0.23.24",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -227,6 +227,14 @@ interface StreamingSession {
227
227
  lastUserContent: string;
228
228
  /** Latest user message images — updated on follow-ups for accurate retry */
229
229
  lastUserImages?: Array<{ data: string; mediaType: string }>;
230
+ /**
231
+ * A user message has been pushed whose turn has not finished.
232
+ *
233
+ * A crash retry may only re-send a prompt while this is true. Without it the retry re-sent
234
+ * whatever content it could find into a session that was simply idle, and an instruction the
235
+ * user gave hours earlier ran a second time as a real turn.
236
+ */
237
+ awaitingReply?: boolean;
230
238
  /** The sign-in this subprocess was started with — see `SubprocessCredential`. */
231
239
  credential: SubprocessCredential;
232
240
  }
@@ -838,6 +846,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
838
846
  // Track latest message for retry paths (fixes stale firstMsg bug)
839
847
  ss.lastUserContent = modelInput;
840
848
  ss.lastUserImages = opts?.images;
849
+ ss.awaitingReply = true;
841
850
  log.debug(`pushMessage: session=${sessionId} priority=${opts?.priority ?? 'next'}`);
842
851
  }
843
852
 
@@ -1472,6 +1481,24 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1472
1481
  const MAX_CRASH_RETRIES = 1;
1473
1482
  let crashRetryCount = 0;
1474
1483
 
1484
+ /**
1485
+ * What the crashed subprocess was doing, read off its session entry before that entry is
1486
+ * deleted. Undefined until a crash. `awaiting` false means no turn was in flight, and a
1487
+ * retry must then push nothing at all: the session is only being rebuilt so the next real
1488
+ * message has somewhere to go.
1489
+ */
1490
+ let crashed: { content: string; images?: Array<{ data: string; mediaType: string }>; awaiting: boolean } | undefined;
1491
+
1492
+ // Once the turn has produced output (top-level assistant text/tool_use), the user
1493
+ // message + any tool_results are persisted to JSONL. Mid-turn retries must resume and
1494
+ // continue — NOT re-push the original message, which restarts the turn and re-displays
1495
+ // an already-answered AskUserQuestion.
1496
+ //
1497
+ // Declared outside `crashRetryLoop` on purpose: inside it, a crash re-entered the loop
1498
+ // body and cleared the very flag that keeps the retry from re-sending the prompt. It is
1499
+ // reset at the end of each turn instead.
1500
+ let turnProgressed = false;
1501
+
1475
1502
  crashRetryLoop: for (;;) {
1476
1503
  try {
1477
1504
  // Streaming input: create message channel and persistent query.
@@ -1485,11 +1512,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1485
1512
  session_id: sessionId,
1486
1513
  };
1487
1514
 
1488
- // Once the turn has produced output (top-level assistant text/tool_use), the user
1489
- // message + any tool_results are persisted to JSONL. Mid-turn retries must resume and
1490
- // continue — NOT re-push the original message, which restarts the turn and re-displays
1491
- // an already-answered AskUserQuestion. Persists across retryLoop iterations.
1492
- let turnProgressed = false;
1493
1515
 
1494
1516
  // Build a retry message for token-refresh / transient-error retries.
1495
1517
  // Pre-turn (nothing persisted yet): re-push the LATEST user content so the SDK has a
@@ -1500,8 +1522,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1500
1522
  // Also returns the raw content/images for re-populating the new streaming session.
1501
1523
  const buildRetryMsg = () => {
1502
1524
  const ss = this.streamingSessions.get(sessionId);
1503
- const content = ss?.lastUserContent ?? modelInput;
1504
- const images = ss?.lastUserImages;
1525
+ // `crashed` is the only source once the entry is gone: `?? modelInput` would otherwise
1526
+ // reach past every follow-up to the message that opened the stream, hours earlier.
1527
+ const content = ss?.lastUserContent ?? crashed?.content ?? modelInput;
1528
+ const images = ss?.lastUserImages ?? crashed?.images;
1505
1529
  const retryContent = turnProgressed ? "Continue from where you left off." : content;
1506
1530
  return {
1507
1531
  msg: {
@@ -1522,9 +1546,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1522
1546
  : undefined;
1523
1547
  const channel = spare ? undefined : createMessageChannel();
1524
1548
  const initialCtrl = spare?.controller ?? channel!.controller;
1525
- // On crash retry, use buildRetryMsg to get the latest user message (not the stale firstMsg)
1549
+ // On crash retry, use buildRetryMsg to get the latest user message (not the stale firstMsg).
1550
+ // A crash with no turn in flight pushes NOTHING: the subprocess is replaced so the next real
1551
+ // message has somewhere to go, and re-sending a finished instruction runs it a second time.
1526
1552
  const initRetry = crashRetryCount > 0 ? buildRetryMsg() : null;
1527
- initialCtrl.push(initRetry?.msg ?? firstMsg);
1553
+ const resendOnCrash = crashRetryCount > 0 && crashed?.awaiting === true;
1554
+ if (crashRetryCount === 0) initialCtrl.push(firstMsg);
1555
+ else if (resendOnCrash && initRetry) initialCtrl.push(initRetry.msg);
1556
+ else log.info(`session=${sessionId} crash retry rebuilt the subprocess with no turn to resume — nothing re-sent`);
1528
1557
  const initContent = initRetry?.lastUserContent ?? modelInput;
1529
1558
  const initImages = initRetry?.lastUserImages ?? opts?.images;
1530
1559
 
@@ -1539,7 +1568,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1539
1568
  // The query's one start line: a warm CLI and the MCP servers belong to it.
1540
1569
  log.info(`query: session=${sessionId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform} accountMode=${!!account} permissionMode=${permissionMode} isBypass=${isBypass}`
1541
1570
  + ` cli=${spare ? `warm ageMs=${spare.ageMs}` : "cold"}${crashRetryCount > 0 ? ` crashRetry=${crashRetryCount}` : ""} mcp=${Object.keys(mcpServers).join(",") || "none"}`);
1542
- this.streamingSessions.set(sessionId, { meta, query: initialQuery, controller: initialCtrl, lastUserContent: initContent, lastUserImages: initImages, credential: queryCredential });
1571
+ this.streamingSessions.set(sessionId, { meta, query: initialQuery, controller: initialCtrl, lastUserContent: initContent, lastUserImages: initImages, awaitingReply: crashRetryCount === 0 || resendOnCrash, credential: queryCredential });
1543
1572
  this.activeQueries.set(sessionId, initialQuery);
1544
1573
  let eventSource: AsyncIterable<any> = initialQuery;
1545
1574
  log.debug(`session=${sessionId} query() created, waiting for first SDK event...`);
@@ -2404,6 +2433,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
2404
2433
  }
2405
2434
  }
2406
2435
 
2436
+ // The turn is over: a crash from here on has nothing to resume, and must not re-send
2437
+ // this instruction. `turnProgressed` goes with it, so the next turn starts clean.
2438
+ const finished = this.streamingSessions.get(sessionId);
2439
+ if (finished) finished.awaitingReply = false;
2440
+ turnProgressed = false;
2441
+
2407
2442
  // Streaming input: yield done for this turn, then continue for next turn
2408
2443
  yieldedDone = true;
2409
2444
  yield {
@@ -2451,10 +2486,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
2451
2486
  const crashMsg = (crashErr as Error).message ?? String(crashErr);
2452
2487
  const stderrInfo = stderrBuffer.trim() ? ` stderr: ${stderrBuffer.trim().slice(-500)}` : "";
2453
2488
 
2454
- // Clean up crashed subprocess before retry or error
2489
+ // Clean up crashed subprocess before retry or error. Read what the turn was doing FIRST:
2490
+ // the retry below needs it, and deleting the entry is what used to send it back to the
2491
+ // message that opened the stream.
2455
2492
  this.activeQueries.delete(sessionId);
2456
2493
  const ss = this.streamingSessions.get(sessionId);
2457
- if (ss) { ss.controller.done(); ss.query.close(); this.streamingSessions.delete(sessionId); }
2494
+ if (ss) {
2495
+ crashed = { content: ss.lastUserContent, images: ss.lastUserImages, awaiting: ss.awaitingReply === true };
2496
+ ss.controller.done(); ss.query.close(); this.streamingSessions.delete(sessionId);
2497
+ } else {
2498
+ crashed = { content: modelInput, images: opts?.images, awaiting: false };
2499
+ }
2458
2500
  log.debug(`session=${sessionId} streaming session ended`);
2459
2501
 
2460
2502
  // Each case below logs itself: a cancel or a closed socket is not a failure.