@hienlh/ppm 0.20.2 → 0.20.3

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
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.20.3] - 2026-09-15
6
+
7
+ ### Fixed
8
+ - **An existing Codex conversation no longer silently starts over when its transcript cannot be found** — resuming or rotating accounts now reports the missing history instead of creating a replacement thread and redirecting the original session to it. New chats still start normally, and a failed initial connection can be retried.
9
+
5
10
  ## [0.20.2] - 2026-09-15
6
11
 
7
12
  ### Added
@@ -71,4 +71,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
71
71
  - Third-party extensions (inspect via `ppm ext list`).
72
72
  - The Claude Agent SDK internals (separate skill).
73
73
 
74
- <!-- Generated for PPM v0.20.2 at build time. Re-run `ppm export skill --install` to refresh. -->
74
+ <!-- Generated for PPM v0.20.3 at build time. Re-run `ppm export skill --install` to refresh. -->
@@ -349,4 +349,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
349
349
  - `ws://<host>/ws/terminal` — PTY terminal multiplexer
350
350
  - `ws://<host>/ws/extensions` — extension host channel
351
351
 
352
- <!-- Generated from src/server/routes/ for PPM v0.20.2 -->
352
+ <!-- Generated from src/server/routes/ for PPM v0.20.3 -->
@@ -4,6 +4,20 @@ Knowledge and gotchas discovered during PPM development.
4
4
 
5
5
  ---
6
6
 
7
+ ## A missing Codex rollout must not restart an existing conversation
8
+
9
+ Each Codex account has its own history directory. Search all managed accounts,
10
+ preferring the session's current account, and copy the located rollout into the
11
+ serving account's directory before resuming. Only a session freshly created by
12
+ PPM may start a new thread without a rollout. An existing thread with missing
13
+ history must report an error, including during account rotation.
14
+
15
+ Starting a replacement thread and recording `migrated_to` hides the original
16
+ history behind the replacement while a tab can retain the original title.
17
+ For affected historical data, verify both transcripts and the migration log,
18
+ take a verified SQLite snapshot, then remove only the incorrect redirect.
19
+ Keep both transcripts; verify the live messages API returns each independently.
20
+
7
21
  ## Chat usage must stay scoped to its provider and session
8
22
 
9
23
  Usage API responses are snapshots: replace them instead of merging into previous
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -235,6 +235,10 @@ function extractThreadId(result: unknown): string | null {
235
235
  return r?.thread?.id ?? r?.threadId ?? r?.id ?? null;
236
236
  }
237
237
 
238
+ function missingRolloutError(sessionId: string): Error {
239
+ return new Error(`Cannot resume Codex session ${sessionId}: its transcript was not found in the available account homes for this project. Restore the original account/transcript and retry, or explicitly open a new chat.`);
240
+ }
241
+
238
242
  /** Human label for an approval prompt (dormant in MVP under default bypass). */
239
243
  function approvalToolLabel(method: string, params: unknown): string {
240
244
  const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
@@ -273,6 +277,8 @@ export class CodexAppServerProvider implements AIProvider {
273
277
  readonly name = "Codex";
274
278
 
275
279
  private sessions = new Map<string, Session>();
280
+ /** Only IDs minted here may start a thread. Missing history is never proof of a new chat. */
281
+ private unstartedSessions = new Set<string>();
276
282
  private live = new Map<string, LiveSession>();
277
283
  private modelsCache: { models: ModelOption[]; expiry: number } | null = null;
278
284
  /** Keyed by `cwd\0codexHome` — skills differ per workspace AND per account. */
@@ -294,6 +300,7 @@ export class CodexAppServerProvider implements AIProvider {
294
300
  createdAt: new Date().toISOString(),
295
301
  };
296
302
  this.sessions.set(id, session);
303
+ this.unstartedSessions.add(id);
297
304
  if (config.projectPath) setSessionMetadata(id, config.projectName, config.projectPath);
298
305
  return session;
299
306
  }
@@ -354,6 +361,7 @@ export class CodexAppServerProvider implements AIProvider {
354
361
  async deleteSession(sessionId: string): Promise<void> {
355
362
  this.abortQuery(sessionId, "delete");
356
363
  this.sessions.delete(sessionId);
364
+ this.unstartedSessions.delete(sessionId);
357
365
  }
358
366
 
359
367
  // ── Streaming (multi-turn) ──
@@ -363,6 +371,7 @@ export class CodexAppServerProvider implements AIProvider {
363
371
  try {
364
372
  live = await this.connect(sessionId, opts);
365
373
  } catch (err) {
374
+ this.abortQuery(sessionId, "connect_failed");
366
375
  yield { type: "error", message: redactTruncate((err as Error)?.message ?? String(err), 512) };
367
376
  yield { type: "done", sessionId, resultSubtype: "error_during_execution" };
368
377
  return;
@@ -586,6 +595,11 @@ export class CodexAppServerProvider implements AIProvider {
586
595
  * had just been repaired.
587
596
  */
588
597
  private async respawnOn(live: LiveSession, threadId: string, account: CodexAccount): Promise<void> {
598
+ // Validate before replacing the client or its account binding. A missing file
599
+ // can mean inaccessible history, including on the first turn; it cannot justify
600
+ // migrating an existing provider thread onto an empty conversation.
601
+ const found = locateRollout(threadId, live.cwd);
602
+ if (!found) throw missingRolloutError(threadId);
589
603
  const old = live.client;
590
604
  old.onNotification(() => {});
591
605
  old.onServerRequest(() => {});
@@ -596,8 +610,8 @@ export class CodexAppServerProvider implements AIProvider {
596
610
  if (process.platform === "win32" && pid) killProcessTree(pid);
597
611
  else if (proc) setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* dead */ } }, 2000).unref?.();
598
612
 
599
- // Written before the rollout lookup so the new account's own directory is searched
600
- // first on a second rotation that is where the freshest history sits.
613
+ // Locate history while the outgoing account is still bound, so its current
614
+ // transcript wins. Then bind the account that will receive the localized copy.
601
615
  setSessionCodexAccount(threadId, account.id);
602
616
 
603
617
  const client = new CodexJsonRpcClient();
@@ -616,26 +630,7 @@ export class CodexAppServerProvider implements AIProvider {
616
630
  approvalPolicy: live.permission.approvalPolicy,
617
631
  ...(live.model ? { model: live.model } : {}),
618
632
  };
619
- const found = locateRollout(threadId, live.cwd);
620
- if (found) {
621
- await this.resumeThread(client, threadId, found, account.home, resumeBase);
622
- return;
623
- }
624
- // No rollout yet means the limit hit the very first turn, before codex had written one.
625
- // There is nothing to resume, so the thread starts fresh — and it comes back under a
626
- // new id, which every map keyed by the old one has to learn about or the next turn is
627
- // sent to a thread this app-server has never heard of.
628
- const started = await client.request("thread/start", resumeBase);
629
- const newId = extractThreadId(started);
630
- if (!newId || newId === threadId) return;
631
- live.threadId = newId;
632
- this.live.set(newId, live);
633
- const meta = this.sessions.get(threadId);
634
- if (meta) { this.sessions.delete(threadId); meta.id = newId; this.sessions.set(newId, meta); }
635
- setSessionMetadata(newId, meta?.projectName, live.cwd);
636
- setSessionProvider(newId, this.id);
637
- setSessionCodexAccount(newId, account.id);
638
- live.channel.push({ type: "session_migrated", oldSessionId: threadId, newSessionId: newId });
633
+ await this.resumeThread(client, threadId, found, account.home, resumeBase);
639
634
  }
640
635
 
641
636
  /**
@@ -672,6 +667,11 @@ export class CodexAppServerProvider implements AIProvider {
672
667
  const permission = permissionModeToCodex(opts?.permissionMode ?? this.config?.permission_mode);
673
668
  const model = codexModel(opts?.model ?? this.config?.model);
674
669
 
670
+ // Only resume a rollout attributable to this project. An unknown/resumed ID
671
+ // without one must not silently become a fresh thread with a different identity.
672
+ const found = locateRollout(sessionId, cwd);
673
+ if (!found && !this.unstartedSessions.has(sessionId)) throw missingRolloutError(sessionId);
674
+
675
675
  const client = new CodexJsonRpcClient();
676
676
  const channel = createEventChannel();
677
677
  const live: LiveSession = {
@@ -697,13 +697,13 @@ export class CodexAppServerProvider implements AIProvider {
697
697
  const resumeBase = { cwd, sandbox: permission.sandbox, approvalPolicy: permission.approvalPolicy, ...(model ? { model } : {}) };
698
698
  // Only treat as a resume when a rollout for this id is attributable to THIS
699
699
  // project (fail-closed cwd guard) — never resume another project's thread.
700
- const found = locateRollout(sessionId, cwd);
701
700
  const result = found
702
701
  ? await this.resumeThread(client, sessionId, found, account?.home, resumeBase)
703
702
  : await client.request("thread/start", resumeBase);
704
703
 
705
704
  const threadId = extractThreadId(result) ?? (found ? sessionId : null);
706
705
  if (!threadId) throw new Error("codex thread/start returned no thread id");
706
+ this.unstartedSessions.delete(sessionId);
707
707
  live.threadId = threadId;
708
708
 
709
709
  if (threadId !== sessionId) {