@hienlh/ppm 0.20.1 → 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 +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-BLctP1Pr.js → ai-resource-editor-D7C9wmwA.js} +1 -1
- package/dist/web/assets/{ai-settings-section-0hSDp7xx.js → ai-settings-section-BW5a07-Z.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CTEU2uGa.js → ai-settings-section-DZuwllLy.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-BkcoSV0E.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-C4kKhamq.js → code-editor-C4o1SyuO.js} +2 -2
- package/dist/web/assets/{conflict-editor-BEULKwHk.js → conflict-editor-Dk65mBIe.js} +1 -1
- package/dist/web/assets/{csv-preview-k3eM8nFf.js → csv-preview-DENi3CFz.js} +1 -1
- package/dist/web/assets/{database-viewer-Cslir1dr.js → database-viewer-BiBC7ulB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DzAIvBbn.js → diff-viewer-CSvZJHak.js} +1 -1
- package/dist/web/assets/{explorer-body-D6H-4cz9.js → explorer-body-QXS0h4AF.js} +1 -1
- package/dist/web/assets/{explorer-window-content-C5W_JzCz.js → explorer-window-content-CQgqoRsj.js} +1 -1
- package/dist/web/assets/{extension-manager-section-DwicI3CA.js → extension-manager-section-CyC7wX35.js} +1 -1
- package/dist/web/assets/{extension-webview-Z3HhC8Pd.js → extension-webview-Da8SIlRa.js} +1 -1
- package/dist/web/assets/{files-settings-section-ByV7bRbB.js → files-settings-section-D8cebM51.js} +1 -1
- package/dist/web/assets/{general-settings-section-nLksKHur.js → general-settings-section-1HVTvlIw.js} +1 -1
- package/dist/web/assets/{git-log-panel-iUXltiTB.js → git-log-panel-C7a4rp8f.js} +1 -1
- package/dist/web/assets/{glide-data-grid-C1QB4DHt.js → glide-data-grid-BT9p_4AN.js} +1 -1
- package/dist/web/assets/{group-chat-tab-ezXKVVpg.js → group-chat-tab-BUHGnUyW.js} +1 -1
- package/dist/web/assets/index-CuYtJ5xF.css +2 -0
- package/dist/web/assets/{index-CJWbFurD.js → index-kGmcC9cb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-DwARwskB.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
- package/dist/web/assets/{keybindings-store-S7YLGd8B.js → keybindings-store-DjJum4OS.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-BoUDvdDy.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DKdfiSIX.js → markdown-renderer-dHeak2mE.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DYFmMC_8.js → markdown-renderer-iBw1fCI_.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CMdqLCV-.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
- package/dist/web/assets/{notification-store-DpSYFj36.js → notification-store-DVi6Ds71.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-CIf3-3xd.js → notifications-settings-section-shQ-ugKh.js} +1 -1
- package/dist/web/assets/{pdf-preview-C-7IdkIK.js → pdf-preview-DB26KSzm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BYXFYlLg.js → postgres-viewer-CJe2meyF.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-B9dXvQ6p.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
- package/dist/web/assets/{process-table-B6NeTWt8.js → process-table-PXwLsMuG.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-CUqq7pdU.js → proxy-settings-section-BHBhK6HC.js} +1 -1
- package/dist/web/assets/{query-audit-section-C1TYrPIp.js → query-audit-section-BD5ZVqDz.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-DuKwKe5F.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-DxMTtsa6.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BwjfLWqg.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-BIl4N5Hn.js → remote-desktop-window-content-X324gYbW.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-C0qOb7GI.js → schedules-settings-section-a4-h6THc.js} +1 -1
- package/dist/web/assets/{settings-body-qbrJXX_A.js → settings-body-Fq7BVDw2.js} +2 -2
- package/dist/web/assets/{settings-tab-BKRgYVxf.js → settings-tab-CVLQy-ie.js} +1 -1
- package/dist/web/assets/{settings-window-content-DWsxJDu-.js → settings-window-content-Bi7M0iaZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-CsZOpCW3.js → sql-query-editor-D1r265fl.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DUrnCaP7.js → sqlite-viewer-DHkIotRl.js} +1 -1
- package/dist/web/assets/{system-monitor-body-C5cMpg7I.js → system-monitor-body-y6VUkYTL.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-C66ywbVZ.js → system-monitor-tab-DRAOejhk.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-CENaXYJ-.js → system-monitor-window-content-B5i57xHD.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-HViABGoS.js → tab-host-window-content-SkyPFFlc.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CK6eyBRL.js → team-member-sheet-BB63u-Lf.js} +2 -2
- package/dist/web/assets/{team-member-window-content-D6wxg37P.js → team-member-window-content-D6tVTXuV.js} +1 -1
- package/dist/web/assets/{terminal-tab-BmyTawmN.js → terminal-tab-BYaHAkI5.js} +1 -1
- package/dist/web/assets/{tool-cards-y-XJcJHo.js → tool-cards-DRJu20sL.js} +2 -2
- package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BkM5rMgL.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-BT2r1td1.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
- package/dist/web/assets/{use-websocket-BvVTwMjI.js → use-websocket-BC_EGf5q.js} +1 -1
- package/dist/web/assets/{video-preview-BEaofjN7.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/docs/lessons-learned.md +14 -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/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/ui/tooltip.tsx +14 -1
- package/src/web/hooks/use-usage.ts +13 -1
- package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
- package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
- package/dist/web/assets/index-CoaVwAF6.css +0 -2
- package/dist/web/assets/use-accounts-data-BbOb2QNP.js +0 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ChatEvent, ChatMessage } from "../provider.interface.ts";
|
|
2
|
+
import { redactTruncate } from "./codex-redact.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subagent threads, rendered as one Agent card in the conversation that spawned
|
|
6
|
+
* them.
|
|
7
|
+
*
|
|
8
|
+
* Codex writes a spawned subagent as a rollout of its own, in the same sessions
|
|
9
|
+
* tree and with the same `cwd` as a real conversation. Left alone it surfaces
|
|
10
|
+
* twice over: once as a top-level session that opens mid-stream (it has no user
|
|
11
|
+
* prompt — the task is handed over as encrypted scaffolding), and once as two
|
|
12
|
+
* bare `SubAgentActivity` cards in the parent. Folding the child transcript into
|
|
13
|
+
* the parent's card is what makes it one step of one conversation again.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Codex spells the item PascalCase in a rollout and camelCase on the wire. */
|
|
17
|
+
export const SUBAGENT_ITEM_TYPES = new Set(["SubAgentActivity", "subAgentActivity"]);
|
|
18
|
+
|
|
19
|
+
export interface SubagentActivity {
|
|
20
|
+
/** Rollout id of the spawned thread — the link to its transcript. */
|
|
21
|
+
threadId: string;
|
|
22
|
+
/** Codex's name for the agent, e.g. `/root/simplify_login`. */
|
|
23
|
+
path: string;
|
|
24
|
+
done: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The transcript of a spawned thread, ready to hang under its card. */
|
|
28
|
+
export interface SubagentTranscript {
|
|
29
|
+
events: ChatEvent[];
|
|
30
|
+
/** The agent's closing message — its report back to the parent. */
|
|
31
|
+
finalText: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function str(v: unknown): string | undefined {
|
|
35
|
+
return typeof v === "string" && v ? v : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A `SubAgentActivity` item → normalized activity, or null if it is not one. */
|
|
39
|
+
export function parseSubagentActivity(item: unknown): SubagentActivity | null {
|
|
40
|
+
if (!item || typeof item !== "object") return null;
|
|
41
|
+
const it = item as Record<string, unknown>;
|
|
42
|
+
if (!SUBAGENT_ITEM_TYPES.has(String(it.type ?? ""))) return null;
|
|
43
|
+
const threadId = str(it.agent_thread_id) ?? str(it.agentThreadId);
|
|
44
|
+
if (!threadId) return null;
|
|
45
|
+
const kind = String(it.kind ?? "");
|
|
46
|
+
return {
|
|
47
|
+
threadId,
|
|
48
|
+
path: str(it.agent_path) ?? str(it.agentPath) ?? "subagent",
|
|
49
|
+
done: kind === "completed" || kind === "failed",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Card id derived from the spawned thread.
|
|
55
|
+
*
|
|
56
|
+
* The started and completed records carry DIFFERENT item ids (`call_…` vs
|
|
57
|
+
* `subagent-completed-…`), so pairing on the item id leaves the call unanswered
|
|
58
|
+
* and the completion orphaned as a second card. The thread they both name is
|
|
59
|
+
* the stable key.
|
|
60
|
+
*/
|
|
61
|
+
export function subagentCardId(threadId: string): string {
|
|
62
|
+
return `subagent-${threadId}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The spawn, as the Agent card the chat already knows how to expand. */
|
|
66
|
+
export function subagentToolUse(activity: SubagentActivity, transcript?: SubagentTranscript | null): ChatEvent {
|
|
67
|
+
return {
|
|
68
|
+
type: "tool_use",
|
|
69
|
+
tool: "Agent",
|
|
70
|
+
input: { description: activity.path },
|
|
71
|
+
toolUseId: subagentCardId(activity.threadId),
|
|
72
|
+
...(transcript?.events.length ? { children: transcript.events } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The completion, answering the card above with the agent's own report. */
|
|
77
|
+
export function subagentToolResult(activity: SubagentActivity, transcript?: SubagentTranscript | null): ChatEvent {
|
|
78
|
+
return {
|
|
79
|
+
type: "tool_result",
|
|
80
|
+
output: redactTruncate(transcript?.finalText || `${activity.path} finished`),
|
|
81
|
+
toolUseId: subagentCardId(activity.threadId),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A parsed child transcript → the flat event list a card nests. */
|
|
86
|
+
export function transcriptToEvents(messages: ChatMessage[]): ChatEvent[] {
|
|
87
|
+
const events: ChatEvent[] = [];
|
|
88
|
+
for (const m of messages) {
|
|
89
|
+
// An assistant turn with tool calls already carries its text as the last event.
|
|
90
|
+
if (m.events?.length) events.push(...m.events);
|
|
91
|
+
else if (m.content) events.push({ type: "text", content: m.content });
|
|
92
|
+
}
|
|
93
|
+
return events;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The agent's closing message, which is what it reported back. */
|
|
97
|
+
export function finalAssistantText(messages: ChatMessage[]): string {
|
|
98
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
99
|
+
const m = messages[i]!;
|
|
100
|
+
if (m.role === "assistant" && m.content) return m.content;
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|