@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.
|
|
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.
|
|
352
|
+
<!-- Generated from src/server/routes/ for PPM v0.20.3 -->
|
package/docs/lessons-learned.md
CHANGED
|
@@ -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
|
@@ -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
|
-
//
|
|
600
|
-
//
|
|
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
|
-
|
|
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) {
|