@hienlh/ppm 0.20.0 → 0.20.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 +21 -0
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/accounts-settings-section-DzDmIsPe.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BgYIPGVP.js → ai-resource-editor-D7C9wmwA.js} +1 -1
- package/dist/web/assets/{ai-settings-section-iBRCQvBR.js → ai-settings-section-BW5a07-Z.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Cywmtnps.js → ai-settings-section-DZuwllLy.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-mhu9T6Fk.js → appearance-settings-section-RfH2N1rj.js} +1 -1
- package/dist/web/assets/chat-tab-Bz0pk0_E.js +15 -0
- package/dist/web/assets/{code-editor-Cv_pzHo7.js → code-editor-C4o1SyuO.js} +2 -2
- package/dist/web/assets/{conflict-editor-BnWaMSEa.js → conflict-editor-Dk65mBIe.js} +1 -1
- package/dist/web/assets/{csv-preview-DG4YAabr.js → csv-preview-DENi3CFz.js} +1 -1
- package/dist/web/assets/{database-viewer-B-wA2qT-.js → database-viewer-BiBC7ulB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DRtzdfXk.js → diff-viewer-CSvZJHak.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-6tzdk7Pn.js → edit-diff-preview-xdDQvPhs.js} +1 -1
- package/dist/web/assets/{explorer-body-uh8oJp6J.js → explorer-body-QXS0h4AF.js} +1 -1
- package/dist/web/assets/{explorer-window-content-DLn_4egF.js → explorer-window-content-CQgqoRsj.js} +1 -1
- package/dist/web/assets/{extension-manager-section-C_ZGYvWf.js → extension-manager-section-CyC7wX35.js} +1 -1
- package/dist/web/assets/{extension-webview-DbFWxJpj.js → extension-webview-Da8SIlRa.js} +1 -1
- package/dist/web/assets/{files-settings-section-BrUbJglA.js → files-settings-section-D8cebM51.js} +1 -1
- package/dist/web/assets/{general-settings-section-B9ZPZVso.js → general-settings-section-1HVTvlIw.js} +1 -1
- package/dist/web/assets/{git-log-panel-C0jnhmgC.js → git-log-panel-C7a4rp8f.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CZVKIum-.js → glide-data-grid-BT9p_4AN.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DnPtmbPc.js → group-chat-tab-BUHGnUyW.js} +1 -1
- package/dist/web/assets/index-CuYtJ5xF.css +2 -0
- package/dist/web/assets/{index-CHQAPZIX.js → index-kGmcC9cb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-Bjldyhru.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
- package/dist/web/assets/{keybindings-store-DBVXZKp8.js → keybindings-store-DjJum4OS.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-D7jaCPrv.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
- package/dist/web/assets/{markdown-renderer-B4B5oxQS.js → markdown-renderer-dHeak2mE.js} +2 -2
- package/dist/web/assets/{markdown-renderer-DwHh-jd_.js → markdown-renderer-iBw1fCI_.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-JJxZYWkV.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
- package/dist/web/assets/{notification-store-CCWNUHsS.js → notification-store-DVi6Ds71.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-DbbX7-Qp.js → notifications-settings-section-shQ-ugKh.js} +1 -1
- package/dist/web/assets/{pdf-preview-DxNjWa1v.js → pdf-preview-DB26KSzm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BScvk5ev.js → postgres-viewer-CJe2meyF.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-jFA9Ncgo.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
- package/dist/web/assets/{process-table-BexeYWQm.js → process-table-PXwLsMuG.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-C_seKq56.js → proxy-settings-section-BHBhK6HC.js} +1 -1
- package/dist/web/assets/{query-audit-section-BWXJmPDV.js → query-audit-section-BD5ZVqDz.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-B--PeL5i.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-jgn8nmQa.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-CjDth-W_.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CrHAUA03.js → remote-desktop-window-content-X324gYbW.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-DMFn3mYL.js → schedules-settings-section-a4-h6THc.js} +1 -1
- package/dist/web/assets/{settings-body-Dz2vPTEP.js → settings-body-Fq7BVDw2.js} +2 -2
- package/dist/web/assets/{settings-tab-B5QbsvS4.js → settings-tab-CVLQy-ie.js} +1 -1
- package/dist/web/assets/{settings-window-content-iM2bPoX5.js → settings-window-content-Bi7M0iaZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-C0mGgLfX.js → sql-query-editor-D1r265fl.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Bs3RKf1X.js → sqlite-viewer-DHkIotRl.js} +1 -1
- package/dist/web/assets/{system-monitor-body-DvUmEdNe.js → system-monitor-body-y6VUkYTL.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-CHZ5F_ZE.js → system-monitor-tab-DRAOejhk.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-DjzqoeCN.js → system-monitor-window-content-B5i57xHD.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-DKeAr5mD.js → tab-host-window-content-SkyPFFlc.js} +1 -1
- package/dist/web/assets/{team-member-sheet-Cl9ZB3dl.js → team-member-sheet-BB63u-Lf.js} +2 -2
- package/dist/web/assets/{team-member-window-content-DfP7WdFD.js → team-member-window-content-D6tVTXuV.js} +1 -1
- package/dist/web/assets/{terminal-tab-J1Fqa_rm.js → terminal-tab-BYaHAkI5.js} +1 -1
- package/dist/web/assets/{tool-cards-DQghSuKX.js → tool-cards-DRJu20sL.js} +3 -3
- package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
- package/dist/web/assets/{use-monaco-theme-aa4-wjKQ.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-B-TpQ-Ua.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
- package/dist/web/assets/{use-websocket-BBslmv3D.js → use-websocket-BC_EGf5q.js} +1 -1
- package/dist/web/assets/{video-preview-Bd0lcK3d.js → video-preview-BUb43zWb.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/deployment-guide.md +10 -0
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
- package/src/providers/codex-app-server/codex-history.ts +118 -45
- package/src/providers/codex-app-server/codex-provider.ts +277 -15
- package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
- package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
- package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
- package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
- package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
- package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
- package/src/server/routes/chat.ts +23 -2
- package/src/server/routes/codex-accounts.ts +32 -3
- package/src/server/ws/chat.ts +15 -11
- package/src/services/codex-account-cooldown.ts +56 -0
- package/src/services/codex-account-login.ts +109 -34
- package/src/services/codex-account.service.ts +56 -10
- package/src/services/codex-login-callback.ts +57 -0
- package/src/web/components/chat/chat-tab.tsx +8 -2
- package/src/web/components/chat/codex-usage-panel.tsx +129 -165
- package/src/web/components/chat/edit-diff-preview.tsx +14 -7
- package/src/web/components/chat/usage-badge.tsx +38 -129
- package/src/web/components/chat/usage-panel-shell.tsx +153 -0
- package/src/web/components/settings/accounts/account-card.tsx +20 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
- package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
- package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
- package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
- package/src/web/components/shared/markdown-code-block.tsx +10 -5
- package/src/web/components/ui/tooltip.tsx +14 -1
- package/src/web/hooks/use-usage.ts +13 -1
- package/dist/web/assets/accounts-settings-section-BQyru31C.js +0 -1
- package/dist/web/assets/chat-tab-Bs9OD6ss.js +0 -15
- package/dist/web/assets/index-CoaVwAF6.css +0 -2
- package/dist/web/assets/use-accounts-data-KWcMgRlc.js +0 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognising a Codex usage-limit refusal.
|
|
3
|
+
*
|
|
4
|
+
* Codex reports an exhausted quota as an ordinary `error` notification — the same shape it
|
|
5
|
+
* uses for a malformed request or a dead sandbox — so the wording is the only thing that
|
|
6
|
+
* separates "this account is out of credit until 4:21 PM" from a failure that switching
|
|
7
|
+
* accounts cannot fix. Matching text is therefore load-bearing here rather than cosmetic:
|
|
8
|
+
* it decides whether the turn moves to another account or stops in front of the user.
|
|
9
|
+
*
|
|
10
|
+
* The patterns are deliberately narrow. A false positive parks a working account for
|
|
11
|
+
* hours, so a match needs the phrase Codex actually uses and not the bare word "limit",
|
|
12
|
+
* which also shows up in context-window and file-size errors.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const USAGE_LIMIT_PATTERNS = [
|
|
16
|
+
/hit\s+your\s+(?:usage|rate)\s+limit/i,
|
|
17
|
+
/reached\s+your\s+usage\s+limit/i,
|
|
18
|
+
/usage\s+limit\s+reached/i,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Whether this error text means the account's quota is spent. */
|
|
22
|
+
export function isCodexUsageLimit(message: string): boolean {
|
|
23
|
+
return USAGE_LIMIT_PATTERNS.some((re) => re.test(message));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The message carried by a codex `error` notification, whatever shape it arrived in. */
|
|
27
|
+
export function codexErrorMessage(params: unknown): string {
|
|
28
|
+
const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
|
|
29
|
+
const err = (p.error && typeof p.error === "object" ? p.error : {}) as Record<string, unknown>;
|
|
30
|
+
if (typeof err.message === "string") return err.message;
|
|
31
|
+
if (typeof p.message === "string") return p.message;
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Turn an hour/minute into the next epoch-ms at which the clock next reads that time. */
|
|
36
|
+
function nextOccurrence(hour: number, minute: number, ampm?: string): number | undefined {
|
|
37
|
+
if (!Number.isFinite(hour) || hour > 23 || minute > 59) return undefined;
|
|
38
|
+
let h = hour;
|
|
39
|
+
if (ampm === "pm" && h < 12) h += 12;
|
|
40
|
+
if (ampm === "am" && h === 12) h = 0;
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const reset = new Date(now);
|
|
43
|
+
reset.setHours(h, minute, 0, 0);
|
|
44
|
+
// Already past today, so the refusal must be pointing at tomorrow.
|
|
45
|
+
if (reset.getTime() <= now.getTime()) reset.setDate(reset.getDate() + 1);
|
|
46
|
+
return reset.getTime();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* When the refused account is worth trying again, read out of the refusal itself.
|
|
51
|
+
*
|
|
52
|
+
* Codex phrases it two ways — an absolute "try again at 4:21 PM" and a relative "try again
|
|
53
|
+
* in 3 hours" — and both are parsed because the caller uses the answer to decide how long
|
|
54
|
+
* to park the account. Returns null when the message says nothing about a reset, which the
|
|
55
|
+
* caller treats as "park for the default window" rather than "do not park".
|
|
56
|
+
*/
|
|
57
|
+
export function parseCodexUsageLimitReset(message: string): { text?: string; atMs?: number } | null {
|
|
58
|
+
const relative = /try\s+again\s+in\s+(\d+)\s*(second|minute|hour|day)s?/i.exec(message);
|
|
59
|
+
if (relative) {
|
|
60
|
+
const n = Number(relative[1]);
|
|
61
|
+
const unitMs = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 }[relative[2]!.toLowerCase()]!;
|
|
62
|
+
return { text: `in ${n} ${relative[2]!.toLowerCase()}${n === 1 ? "" : "s"}`, atMs: Date.now() + n * unitMs };
|
|
63
|
+
}
|
|
64
|
+
const absolute = /(?:try\s+again|resets?)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(message);
|
|
65
|
+
if (!absolute) return null;
|
|
66
|
+
const text = absolute[0].replace(/^(?:try\s+again|resets?)\s+(?:at\s+)?/i, "").trim();
|
|
67
|
+
const atMs = nextOccurrence(Number(absolute[1]), absolute[2] ? Number(absolute[2]) : 0, absolute[3]?.toLowerCase());
|
|
68
|
+
return { text: text || undefined, ...(atMs != null ? { atMs } : {}) };
|
|
69
|
+
}
|
|
@@ -27,26 +27,43 @@ function toBucket(w: RateLimitWindow | null | undefined): LimitBucket | undefine
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** A window at least this long is a plan's long bucket rather than its short one. */
|
|
31
|
+
const LONG_WINDOW_MINS = 24 * 60;
|
|
32
|
+
|
|
30
33
|
/**
|
|
31
|
-
* Map codex `account/rateLimits/read` → PPM UsageInfo.
|
|
32
|
-
*
|
|
34
|
+
* Map codex `account/rateLimits/read` → PPM UsageInfo.
|
|
35
|
+
*
|
|
36
|
+
* `primary` and `secondary` are positions, not meanings, and each window states its own
|
|
37
|
+
* length in `windowDurationMins` — so that is what decides which bucket it belongs to. A
|
|
38
|
+
* Plus plan sends a 300-minute window followed by a 10080-minute one, which is the layout
|
|
39
|
+
* the old positional reading assumed. A ChatGPT Business plan sends the 10080-minute weekly
|
|
40
|
+
* window **alone, in the primary slot**, and reading position as meaning labelled that
|
|
41
|
+
* account's weekly quota "5-Hour" — complete with a reset nearly seven days out — while
|
|
42
|
+
* leaving its actual Weekly row empty.
|
|
43
|
+
*
|
|
44
|
+
* Empty/missing → `{}`, which the UI reads as "the quota could not be read" and is a
|
|
45
|
+
* different thing from a plan that genuinely has only one window.
|
|
33
46
|
*/
|
|
34
47
|
export function parseCodexUsage(res: GetAccountRateLimitsResponse | null | undefined): UsageInfo {
|
|
35
48
|
const snap = res?.rateLimits;
|
|
36
49
|
if (!snap) return {};
|
|
37
|
-
const primary = snap.primary ?? null;
|
|
38
|
-
const secondary = snap.secondary ?? null;
|
|
39
50
|
const out: UsageInfo = {};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
const windows = [snap.primary ?? null, snap.secondary ?? null];
|
|
52
|
+
windows.forEach((w, slot) => {
|
|
53
|
+
if (w?.usedPercent == null) return;
|
|
54
|
+
// A window with no declared duration leaves nothing to read, so the slot it arrived in
|
|
55
|
+
// is the best guess remaining: primary is the short one wherever both are present.
|
|
56
|
+
const isLong = w.windowDurationMins != null ? w.windowDurationMins >= LONG_WINDOW_MINS : slot === 1;
|
|
57
|
+
if (isLong) {
|
|
58
|
+
out.sevenDay = w.usedPercent / 100;
|
|
59
|
+
out.sevenDayResetsAt = toIso(w.resetsAt);
|
|
60
|
+
out.weekly = toBucket(w);
|
|
61
|
+
} else {
|
|
62
|
+
out.fiveHour = w.usedPercent / 100;
|
|
63
|
+
out.fiveHourResetsAt = toIso(w.resetsAt);
|
|
64
|
+
out.session = toBucket(w);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
50
67
|
if (snap.planType) out.activeAccountLabel = String(snap.planType);
|
|
51
68
|
return out;
|
|
52
69
|
}
|
|
@@ -498,6 +498,23 @@ chatRoutes.put("/sessions/:id/account", async (c) => {
|
|
|
498
498
|
if (!bindPickedAccount(sessionId, providerId, body.accountId)) {
|
|
499
499
|
return c.json(err(bindRefusalReason(providerId, body.accountId)), 400);
|
|
500
500
|
}
|
|
501
|
+
// Codex binds an account by spawning its app-server with that account's CODEX_HOME, so a
|
|
502
|
+
// subprocess already running keeps serving the old account however the binding reads — the
|
|
503
|
+
// switch appeared to do nothing until something else happened to kill it. Dropping it while
|
|
504
|
+
// idle makes the next message respawn on the account the user just picked. Claude needs
|
|
505
|
+
// none of this: it reads the binding per turn, and there is nothing stale to clear.
|
|
506
|
+
if (providerId === "codex") {
|
|
507
|
+
const { listRunningSessions, dropIdleSubprocess } = await import("../ws/chat.ts");
|
|
508
|
+
// Never mid-turn. The answer being streamed would be lost, and the switch takes effect
|
|
509
|
+
// on the next message either way — which is exactly what the picker promises.
|
|
510
|
+
if (!listRunningSessions().some((s) => s.sessionId === sessionId)) {
|
|
511
|
+
dropIdleSubprocess(
|
|
512
|
+
sessionId,
|
|
513
|
+
"account_switch",
|
|
514
|
+
"Subprocess released: the session was moved to another Codex account",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
501
518
|
return c.json(ok({ accountId: body.accountId }));
|
|
502
519
|
});
|
|
503
520
|
|
|
@@ -893,7 +910,7 @@ chatRoutes.post("/sessions/:id/images/strip", async (c) => {
|
|
|
893
910
|
const mode: StripMode = (body as { mode?: unknown }).mode === "all" ? "all" : "oversized";
|
|
894
911
|
const includeAttachments = (body as { includeAttachments?: unknown }).includeAttachments === true;
|
|
895
912
|
|
|
896
|
-
const { listRunningSessions,
|
|
913
|
+
const { listRunningSessions, dropIdleSubprocess } = await import("../ws/chat.ts");
|
|
897
914
|
if (listRunningSessions().some((s) => s.sessionId === sessionId)) {
|
|
898
915
|
return c.json(err("Session is running — wait for the turn to finish"), 409);
|
|
899
916
|
}
|
|
@@ -906,7 +923,11 @@ chatRoutes.post("/sessions/:id/images/strip", async (c) => {
|
|
|
906
923
|
// re-sends the oversized attachment from memory and fails exactly as before, which is the
|
|
907
924
|
// failure this endpoint exists to clear. Drop it so the turn is rebuilt from the file we
|
|
908
925
|
// are about to rewrite.
|
|
909
|
-
|
|
926
|
+
dropIdleSubprocess(
|
|
927
|
+
sessionId,
|
|
928
|
+
"transcript_rewritten",
|
|
929
|
+
"Subprocess released: the transcript was rewritten, so the next turn is rebuilt from disk",
|
|
930
|
+
);
|
|
910
931
|
|
|
911
932
|
const result = await stripTranscriptImagesFile(found.path, mode, { includeAttachments });
|
|
912
933
|
return c.json(ok({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { ok, err } from "../../types/api.ts";
|
|
3
|
-
import { listCodexAccounts, removeCodexAccount, getAllCodexUsages, getCodexStrategy, setCodexStrategy, selectCodexAccount, setCodexAccountStatus, type CodexStrategy } from "../../services/codex-account.service.ts";
|
|
4
|
-
import { addApiKeyAccount, startDeviceLogin, getDeviceLoginStatus, cancelDeviceLogin } from "../../services/codex-account-login.ts";
|
|
3
|
+
import { listCodexAccounts, removeCodexAccount, getAllCodexUsages, getCodexStrategy, setCodexStrategy, selectCodexAccount, setCodexAccountStatus, codexUsageLevel, type CodexStrategy } from "../../services/codex-account.service.ts";
|
|
4
|
+
import { addApiKeyAccount, startDeviceLogin, getDeviceLoginStatus, cancelDeviceLogin, startBrowserLogin, submitBrowserCallback, getBrowserLoginStatus, cancelBrowserLogin } from "../../services/codex-account-login.ts";
|
|
5
5
|
import { exportCodexEncrypted, importCodexEncrypted } from "../../services/codex-account-portability.ts";
|
|
6
6
|
|
|
7
7
|
/** Codex multi-account management. Mounted under /api/codex-accounts (auth-guarded). */
|
|
@@ -29,7 +29,7 @@ codexAccountsRoutes.post("/pick", async (c) => {
|
|
|
29
29
|
const usages = await getAllCodexUsages();
|
|
30
30
|
// A failed usage fetch yields {} → +Infinity, which the selector reads as "unknown", not
|
|
31
31
|
// as "capped": an account we could not measure stays a candidate.
|
|
32
|
-
const picked = selectCodexAccount({ usageOf: (id) => usages[id]
|
|
32
|
+
const picked = selectCodexAccount({ usageOf: (id) => codexUsageLevel(usages[id]) });
|
|
33
33
|
if (!picked) return c.json(ok(null));
|
|
34
34
|
return c.json(ok({ id: picked.id, label: picked.label }));
|
|
35
35
|
});
|
|
@@ -86,6 +86,35 @@ codexAccountsRoutes.delete("/device-login/:id", (c) => {
|
|
|
86
86
|
return c.json(ok({ cancelled: true }));
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
/** Browser OAuth uses the same durable polling as device-code login. */
|
|
90
|
+
codexAccountsRoutes.post("/browser-login", async (c) => {
|
|
91
|
+
const body = await c.req.json<{ label?: string }>().catch(() => ({} as { label?: string }));
|
|
92
|
+
if (body.label !== undefined && typeof body.label !== "string") return c.json(err("label must be a string"), 400);
|
|
93
|
+
c.header("Cache-Control", "no-store");
|
|
94
|
+
try { return c.json(ok(await startBrowserLogin(body.label))); }
|
|
95
|
+
catch (e) { return c.json(err((e as Error).message), 400); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
codexAccountsRoutes.post("/browser-login/:id/callback", async (c) => {
|
|
99
|
+
const body = await c.req.json<{ callbackUrl?: string }>().catch(() => ({} as { callbackUrl?: string }));
|
|
100
|
+
c.header("Cache-Control", "no-store");
|
|
101
|
+
if (typeof body.callbackUrl !== "string") return c.json(err("callbackUrl is required"), 400);
|
|
102
|
+
try {
|
|
103
|
+
submitBrowserCallback(c.req.param("id"), body.callbackUrl);
|
|
104
|
+
return c.json(ok({ submitted: true }));
|
|
105
|
+
} catch (e) { return c.json(err((e as Error).message), 400); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
codexAccountsRoutes.get("/browser-login/:id/status", (c) => {
|
|
109
|
+
c.header("Cache-Control", "no-store");
|
|
110
|
+
return c.json(ok(getBrowserLoginStatus(c.req.param("id"))));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
codexAccountsRoutes.delete("/browser-login/:id", (c) => {
|
|
114
|
+
cancelBrowserLogin(c.req.param("id"));
|
|
115
|
+
return c.json(ok({ cancelled: true }));
|
|
116
|
+
});
|
|
117
|
+
|
|
89
118
|
/** Download a password-encrypted backup of codex accounts (auth.json + apiKey creds). */
|
|
90
119
|
codexAccountsRoutes.post("/export", async (c) => {
|
|
91
120
|
const body = await c.req.json<{ password?: string; accountIds?: string[] }>().catch(() => ({} as { password?: string; accountIds?: string[] }));
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -164,27 +164,31 @@ function releaseSubprocess(sessionId: string, reason: string, note: string): voi
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/**
|
|
167
|
-
* Drop a session's live subprocess so its next turn is rebuilt from
|
|
167
|
+
* Drop a session's live subprocess so its next turn is rebuilt from what is on disk.
|
|
168
168
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* image, sends again, and the same image is re-sent from memory and fails
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
169
|
+
* Two kinds of caller need this, both because the subprocess holds state that a change
|
|
170
|
+
* made behind its back cannot reach. A route that rewrites the JSONL: the user strips an
|
|
171
|
+
* oversized image, sends again, and the same image is re-sent from memory and fails
|
|
172
|
+
* identically. And a codex account switch: the app-server was spawned with one account's
|
|
173
|
+
* CODEX_HOME and keeps serving that account no matter what the session's binding now says.
|
|
174
|
+
*
|
|
175
|
+
* `listRunningSessions()` does not cover either case — that skips `phase === "idle"`, and a
|
|
176
|
+
* warm idle subprocess is exactly what both are about. Unlike `releaseSubprocess` this does
|
|
177
|
+
* not require the session to be clientless, because having the tab open is the normal way
|
|
178
|
+
* to reach both buttons.
|
|
175
179
|
*/
|
|
176
|
-
export function
|
|
180
|
+
export function dropIdleSubprocess(sessionId: string, reason: string, note: string): void {
|
|
177
181
|
const entry = activeSessions.get(sessionId);
|
|
178
182
|
if (!entry) return;
|
|
179
183
|
const provider = providerRegistry.get(entry.providerId);
|
|
180
184
|
if (!provider?.hasStreamingSession?.(sessionId)) return;
|
|
181
|
-
provider.abortQuery?.(sessionId,
|
|
185
|
+
provider.abortQuery?.(sessionId, reason);
|
|
182
186
|
if (entry.cacheReleaseTimer) {
|
|
183
187
|
clearTimeout(entry.cacheReleaseTimer);
|
|
184
188
|
entry.cacheReleaseTimer = undefined;
|
|
185
189
|
}
|
|
186
|
-
console.log(`[chat] session=${sessionId} released subprocess (
|
|
187
|
-
logSessionEvent(sessionId, "INFO",
|
|
190
|
+
console.log(`[chat] session=${sessionId} released subprocess (${reason})`);
|
|
191
|
+
logSessionEvent(sessionId, "INFO", note);
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
/** Tear down the longest-idle subprocesses once too many sessions are holding one. */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex accounts parked after the app-server refused a turn for an exhausted quota.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from the account rows on purpose. `CodexAccountStatus` is the user's switch —
|
|
5
|
+
* on or off — and writing a quota refusal into it would show an account the user never
|
|
6
|
+
* disabled as switched off in Settings, with no way to tell the two apart when it came
|
|
7
|
+
* back. Parking is the server's own short-lived opinion about an account, so it lives in
|
|
8
|
+
* memory and lapses on its own.
|
|
9
|
+
*
|
|
10
|
+
* In-memory is also the honest lifetime: the park exists to stop a rotation from handing
|
|
11
|
+
* the next turn straight back to the account that just refused it, and a server restart
|
|
12
|
+
* re-learns that on the first refusal anyway.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** accountId → epoch ms at which the account is worth trying again. */
|
|
16
|
+
const parkedUntil = new Map<string, number>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long an account sits out when the refusal carried no reset time.
|
|
20
|
+
*
|
|
21
|
+
* Codex's short bucket is five hours, so that is the cheapest correct guess. Guessing long
|
|
22
|
+
* is safe in a way guessing short is not: an account that recovers early is only skipped
|
|
23
|
+
* while another one can serve, whereas a park that lapses too early sends the turn back to
|
|
24
|
+
* an account that refuses it again.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_PARK_MS = 5 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/** Park an account until its quota resets. `resetAtMs` in the past falls back to the default. */
|
|
29
|
+
export function markCodexAccountUsageLimited(accountId: string, resetAtMs?: number): void {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const until = resetAtMs != null && resetAtMs > now ? resetAtMs : now + DEFAULT_PARK_MS;
|
|
32
|
+
parkedUntil.set(accountId, until);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether the account is still sitting out. Lapsed entries are dropped as they are read. */
|
|
36
|
+
export function isCodexAccountUsageLimited(accountId: string): boolean {
|
|
37
|
+
const until = parkedUntil.get(accountId);
|
|
38
|
+
if (until == null) return false;
|
|
39
|
+
if (until <= Date.now()) { parkedUntil.delete(accountId); return false; }
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** When the account is next worth trying, or null if it is not parked. */
|
|
44
|
+
export function codexUsageLimitResetAt(accountId: string): number | null {
|
|
45
|
+
return isCodexAccountUsageLimited(accountId) ? parkedUntil.get(accountId)! : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Let an account back in early — a successful turn proves the park is stale. */
|
|
49
|
+
export function clearCodexAccountUsageLimit(accountId: string): void {
|
|
50
|
+
parkedUntil.delete(accountId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test seam: forget every park. */
|
|
54
|
+
export function _resetCodexCooldownsForTesting(): void {
|
|
55
|
+
parkedUntil.clear();
|
|
56
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codex account login orchestration. Logs codex into a per-account CODEX_HOME
|
|
3
3
|
* via a short-lived app-server, verifies with account/read, then persists the
|
|
4
|
-
* account.
|
|
4
|
+
* account. Supports API keys, device codes and browser OAuth callbacks.
|
|
5
5
|
* - apiKey: instant (account/login/start stores the key; validated on first use).
|
|
6
6
|
* - chatgptDeviceCode: returns a user-code + URL; the app-server's
|
|
7
7
|
* account/login/completed notification finalizes the account in the
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
17
|
import { mkdirSync, rmSync } from "node:fs";
|
|
18
|
-
import {
|
|
18
|
+
import { buildCodexCallback, checkCodexLoginPort, parseBrowserLogin, type BrowserLoginCallback } from "./codex-login-callback.ts";
|
|
19
|
+
import { CodexJsonRpcClient, CONTROL_REQUEST_TIMEOUT_MS } from "../providers/codex-app-server/codex-jsonrpc-client.ts";
|
|
19
20
|
import { codexAccountHome, createCodexAccount, type CodexAccount } from "./codex-account.service.ts";
|
|
20
21
|
|
|
21
22
|
const CLIENT_INFO = { name: "ppm", title: "PPM", version: "0.0.0" };
|
|
@@ -29,13 +30,13 @@ export interface LoginClient {
|
|
|
29
30
|
start(opts?: { cwd?: string; codexHome?: string }): void;
|
|
30
31
|
onNotification(fn: (n: { method: string; params?: unknown }) => void): void;
|
|
31
32
|
onClose(fn: (code: number | null) => void): void;
|
|
32
|
-
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
33
|
+
request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
|
|
33
34
|
notify(method: string, params?: unknown): void;
|
|
34
35
|
close(): void;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
async function handshake(c: LoginClient): Promise<void> {
|
|
38
|
-
await c.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES });
|
|
39
|
+
await c.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES }, CONTROL_REQUEST_TIMEOUT_MS);
|
|
39
40
|
c.notify("initialized");
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -72,7 +73,10 @@ export type DeviceLoginStatus =
|
|
|
72
73
|
| { state: "done"; account: CodexAccount }
|
|
73
74
|
| { state: "error"; error: string };
|
|
74
75
|
|
|
75
|
-
interface
|
|
76
|
+
interface ChatGptLogin {
|
|
77
|
+
method: "device" | "browser";
|
|
78
|
+
browser?: BrowserLoginCallback;
|
|
79
|
+
callbackAbort?: AbortController;
|
|
76
80
|
client: LoginClient; home: string; label?: string;
|
|
77
81
|
status: DeviceLoginStatus;
|
|
78
82
|
timer: ReturnType<typeof setTimeout>;
|
|
@@ -80,10 +84,14 @@ interface DeviceLogin {
|
|
|
80
84
|
* home must survive this window even if the browser walks away. */
|
|
81
85
|
finalizing: boolean;
|
|
82
86
|
}
|
|
83
|
-
const
|
|
87
|
+
const pendingLogins = new Map<string, ChatGptLogin>();
|
|
88
|
+
|
|
89
|
+
type ChatGptLoginStart = { id: string; userCode: string; verificationUrl: string; authUrl: string };
|
|
90
|
+
type CompletionNotification = { success?: boolean; error?: string | null };
|
|
84
91
|
|
|
85
92
|
/** How long an unfinished authorization may sit before it is reaped. */
|
|
86
93
|
const DEVICE_LOGIN_TTL = 200_000;
|
|
94
|
+
const BROWSER_LOGIN_TTL = 600_000;
|
|
87
95
|
/** How long a finished result stays readable for the polling browser. */
|
|
88
96
|
const RESULT_GRACE_MS = 120_000;
|
|
89
97
|
|
|
@@ -92,12 +100,21 @@ function unref(t: ReturnType<typeof setTimeout>): ReturnType<typeof setTimeout>
|
|
|
92
100
|
return t;
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
function hasPendingBrowserLogin(): boolean {
|
|
104
|
+
return [...pendingLogins.values()].some((p) => p.method === "browser" && p.status.state === "pending");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isCurrentPendingLogin(id: string, p: ChatGptLogin): boolean {
|
|
108
|
+
return pendingLogins.get(id) === p && p.status.state === "pending" && !p.finalizing;
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
/** Forget a flow entirely: stop its app-server, optionally drop its home. */
|
|
96
|
-
function
|
|
97
|
-
const p =
|
|
112
|
+
function disposeLogin(id: string, rmHome: boolean): void {
|
|
113
|
+
const p = pendingLogins.get(id);
|
|
98
114
|
if (!p) return;
|
|
99
|
-
|
|
115
|
+
pendingLogins.delete(id);
|
|
100
116
|
clearTimeout(p.timer);
|
|
117
|
+
p.callbackAbort?.abort();
|
|
101
118
|
try { p.client.close(); } catch { /* ignore */ }
|
|
102
119
|
if (rmHome) { try { rmSync(p.home, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
103
120
|
}
|
|
@@ -106,26 +123,30 @@ function disposeDeviceLogin(id: string, rmHome: boolean): void {
|
|
|
106
123
|
* event that trails a success cannot overwrite it. The entry lingers for
|
|
107
124
|
* RESULT_GRACE_MS — that window is what makes a lost poll response harmless. */
|
|
108
125
|
function settle(id: string, status: Exclude<DeviceLoginStatus, { state: "pending" }>): void {
|
|
109
|
-
const p =
|
|
126
|
+
const p = pendingLogins.get(id);
|
|
110
127
|
if (!p || p.status.state !== "pending") return;
|
|
111
128
|
p.status = status;
|
|
112
129
|
clearTimeout(p.timer);
|
|
130
|
+
p.callbackAbort?.abort();
|
|
113
131
|
try { p.client.close(); } catch { /* ignore */ }
|
|
114
132
|
// A failure leaves no account owning the dir, so the half-built home goes now.
|
|
115
133
|
// A success must keep it: createCodexAccount adopted it as the account's home.
|
|
116
134
|
if (status.state === "error") { try { rmSync(p.home, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
117
|
-
p.timer = unref(setTimeout(() => {
|
|
135
|
+
p.timer = unref(setTimeout(() => { pendingLogins.delete(id); }, RESULT_GRACE_MS));
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
/** Turn the app-server's completion notification into a persisted account. */
|
|
121
|
-
async function
|
|
122
|
-
const p =
|
|
123
|
-
if (!p || p.status.state !== "pending") return;
|
|
124
|
-
if (!notif?.success) { settle(id, { state: "error", error: notif?.error || "login not completed" }); return; }
|
|
139
|
+
async function finalizeLogin(id: string, notif: CompletionNotification): Promise<void> {
|
|
140
|
+
const p = pendingLogins.get(id);
|
|
141
|
+
if (!p || p.status.state !== "pending" || p.finalizing) return;
|
|
142
|
+
if (!notif?.success) { settle(id, { state: "error", error: p.method === "browser" ? "ChatGPT sign-in failed. Start a new browser login and try again." : notif?.error || "login not completed" }); return; }
|
|
125
143
|
p.finalizing = true;
|
|
144
|
+
// Authorization has completed; account/read has its own bounded timeout.
|
|
145
|
+
clearTimeout(p.timer);
|
|
126
146
|
try {
|
|
127
|
-
const read = await p.client.request<AccountRead>("account/read", {});
|
|
147
|
+
const read = await p.client.request<AccountRead>("account/read", {}, CONTROL_REQUEST_TIMEOUT_MS);
|
|
128
148
|
if (!read?.account) throw new Error("login completed but account is empty");
|
|
149
|
+
if (p.status.state !== "pending") return;
|
|
129
150
|
const account = createCodexAccount({
|
|
130
151
|
id, label: p.label || read.account.email || "ChatGPT", type: "chatgpt",
|
|
131
152
|
planType: read.account.planType ?? null,
|
|
@@ -138,43 +159,97 @@ async function finalizeDeviceLogin(id: string, notif: { success?: boolean; error
|
|
|
138
159
|
|
|
139
160
|
/** Begin a ChatGPT device-code login. Returns the code/URL the user enters in a
|
|
140
161
|
* browser; the caller then polls getDeviceLoginStatus. */
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
async function startChatGptLogin(
|
|
163
|
+
method: "device" | "browser",
|
|
164
|
+
label: string | undefined,
|
|
165
|
+
makeClient: () => LoginClient,
|
|
166
|
+
): Promise<ChatGptLoginStart> {
|
|
167
|
+
if (method === "browser" && hasPendingBrowserLogin()) {
|
|
168
|
+
throw new Error("A browser login is already in progress. Finish or cancel it first.");
|
|
169
|
+
}
|
|
145
170
|
const id = randomUUID();
|
|
146
171
|
const home = codexAccountHome(id);
|
|
147
172
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
148
173
|
const client = makeClient();
|
|
149
174
|
client.onNotification((n) => {
|
|
150
175
|
if (n.method === "account/login/completed") {
|
|
151
|
-
void
|
|
176
|
+
void finalizeLogin(id, (n.params ?? {}) as CompletionNotification);
|
|
152
177
|
}
|
|
153
178
|
});
|
|
154
179
|
client.onClose(() => settle(id, { state: "error", error: "login process exited" }));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// Registered before the first I/O so a notification (or an immediate exit)
|
|
159
|
-
// arriving mid-handshake still finds its entry.
|
|
160
|
-
deviceLogins.set(id, { client, home, label, status: { state: "pending" }, timer, finalizing: false });
|
|
180
|
+
const timer = unref(setTimeout(() => settle(id, { state: "error", error: "timed out" }), method === "browser" ? BROWSER_LOGIN_TTL : DEVICE_LOGIN_TTL));
|
|
181
|
+
const pending: ChatGptLogin = { method, client, home, label, status: { state: "pending" }, timer, finalizing: false };
|
|
182
|
+
pendingLogins.set(id, pending);
|
|
161
183
|
try {
|
|
184
|
+
if (method === "browser") await checkCodexLoginPort();
|
|
162
185
|
client.start({ codexHome: home });
|
|
163
186
|
await handshake(client);
|
|
164
|
-
const start = await client.request<{ userCode?: string; verificationUrl?: string }>(
|
|
165
|
-
"account/login/start", { type: "chatgptDeviceCode" },
|
|
187
|
+
const start = await client.request<{ userCode?: string; verificationUrl?: string; authUrl?: string }>(
|
|
188
|
+
"account/login/start", { type: method === "browser" ? "chatgpt" : "chatgptDeviceCode" }, CONTROL_REQUEST_TIMEOUT_MS,
|
|
166
189
|
);
|
|
167
|
-
|
|
190
|
+
if (method === "browser") pending.browser = parseBrowserLogin(start.authUrl ?? "");
|
|
191
|
+
return { id, userCode: start?.userCode ?? "", verificationUrl: start?.verificationUrl ?? "", authUrl: start?.authUrl ?? "" };
|
|
168
192
|
} catch (e) {
|
|
169
|
-
|
|
193
|
+
disposeLogin(id, true);
|
|
170
194
|
throw e;
|
|
171
195
|
}
|
|
172
196
|
}
|
|
173
197
|
|
|
198
|
+
export async function startDeviceLogin(label?: string, makeClient: () => LoginClient = () => new CodexJsonRpcClient()) {
|
|
199
|
+
const { id, userCode, verificationUrl } = await startChatGptLogin("device", label, makeClient);
|
|
200
|
+
return { id, userCode, verificationUrl };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function startBrowserLogin(label?: string, makeClient: () => LoginClient = () => new CodexJsonRpcClient()) {
|
|
204
|
+
const { id, authUrl } = await startChatGptLogin("browser", label, makeClient);
|
|
205
|
+
return { id, authUrl };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Submission is acknowledged immediately. Only the app-server notification and
|
|
209
|
+
* account/read determine success, even if the forwarding HTTP request drops. */
|
|
210
|
+
export function submitBrowserCallback(id: string, callbackUrl: string, send: typeof fetch = fetch): void {
|
|
211
|
+
const p = pendingLogins.get(id);
|
|
212
|
+
if (!p || p.method !== "browser" || !p.browser) throw new Error("No browser login found. Start a new login.");
|
|
213
|
+
if (p.status.state === "done" || p.finalizing) return;
|
|
214
|
+
if (p.status.state === "error") throw new Error(p.status.error);
|
|
215
|
+
const browser = p.browser;
|
|
216
|
+
const target = buildCodexCallback(callbackUrl, browser);
|
|
217
|
+
if (browser.submitted) return;
|
|
218
|
+
browser.submitted = true;
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
p.callbackAbort = controller;
|
|
221
|
+
const timeout = unref(setTimeout(() => controller.abort(), 30_000));
|
|
222
|
+
void (async () => {
|
|
223
|
+
try {
|
|
224
|
+
const response = await send(target, { redirect: "manual", signal: controller.signal });
|
|
225
|
+
// Do not render or log the OAuth response body or follow its redirect.
|
|
226
|
+
await response.body?.cancel();
|
|
227
|
+
if (response.status >= 400 && isCurrentPendingLogin(id, p)) {
|
|
228
|
+
settle(id, { state: "error", error: "Codex rejected the callback. Start a new browser login." });
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// A closed callback socket may mean successful login. Polling owns the result.
|
|
232
|
+
if (isCurrentPendingLogin(id, p)) browser.submitted = false;
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timeout);
|
|
235
|
+
if (p.callbackAbort === controller) p.callbackAbort = undefined;
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getBrowserLoginStatus(id: string): DeviceLoginStatus {
|
|
241
|
+
const p = pendingLogins.get(id);
|
|
242
|
+
return p?.method === "browser" ? p.status : { state: "error", error: "no pending browser login for that id" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function cancelBrowserLogin(id: string): void {
|
|
246
|
+
if (pendingLogins.get(id)?.method === "browser") cancelDeviceLogin(id);
|
|
247
|
+
}
|
|
248
|
+
|
|
174
249
|
/** Read a device-code flow's outcome. Idempotent — repeat polls are free and a
|
|
175
250
|
* dropped response costs one tick, not the flow. */
|
|
176
251
|
export function getDeviceLoginStatus(id: string): DeviceLoginStatus {
|
|
177
|
-
return
|
|
252
|
+
return pendingLogins.get(id)?.status ?? { state: "error", error: "no pending device login for that id" };
|
|
178
253
|
}
|
|
179
254
|
|
|
180
255
|
/** Abandon a flow the user cancelled. A login already being written is left
|
|
@@ -182,7 +257,7 @@ export function getDeviceLoginStatus(id: string): DeviceLoginStatus {
|
|
|
182
257
|
* authorization the user completed. A finished one keeps its home: an account
|
|
183
258
|
* owns it now. */
|
|
184
259
|
export function cancelDeviceLogin(id: string): void {
|
|
185
|
-
const p =
|
|
260
|
+
const p = pendingLogins.get(id);
|
|
186
261
|
if (!p || p.finalizing) return;
|
|
187
|
-
|
|
262
|
+
disposeLogin(id, p.status.state !== "done");
|
|
188
263
|
}
|