@duckmind/dm-darwin-arm64 0.13.6 → 0.13.8

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.
Files changed (78) hide show
  1. package/dm +0 -0
  2. package/extensions/.dm-extensions.json +26 -2
  3. package/extensions/dm-phone/README.md +23 -0
  4. package/extensions/dm-phone/index.ts +12 -0
  5. package/extensions/dm-phone/node_modules/.package-lock.json +29 -0
  6. package/extensions/dm-phone/node_modules/ws/LICENSE +20 -0
  7. package/extensions/dm-phone/node_modules/ws/README.md +548 -0
  8. package/extensions/dm-phone/node_modules/ws/browser.js +8 -0
  9. package/extensions/dm-phone/node_modules/ws/index.js +22 -0
  10. package/extensions/dm-phone/node_modules/ws/lib/buffer-util.js +131 -0
  11. package/extensions/dm-phone/node_modules/ws/lib/constants.js +19 -0
  12. package/extensions/dm-phone/node_modules/ws/lib/event-target.js +292 -0
  13. package/extensions/dm-phone/node_modules/ws/lib/extension.js +203 -0
  14. package/extensions/dm-phone/node_modules/ws/lib/limiter.js +55 -0
  15. package/extensions/dm-phone/node_modules/ws/lib/permessage-deflate.js +528 -0
  16. package/extensions/dm-phone/node_modules/ws/lib/receiver.js +706 -0
  17. package/extensions/dm-phone/node_modules/ws/lib/sender.js +602 -0
  18. package/extensions/dm-phone/node_modules/ws/lib/stream.js +161 -0
  19. package/extensions/dm-phone/node_modules/ws/lib/subprotocol.js +62 -0
  20. package/extensions/dm-phone/node_modules/ws/lib/validation.js +152 -0
  21. package/extensions/dm-phone/node_modules/ws/lib/websocket-server.js +554 -0
  22. package/extensions/dm-phone/node_modules/ws/lib/websocket.js +1393 -0
  23. package/extensions/dm-phone/node_modules/ws/package.json +70 -0
  24. package/extensions/dm-phone/node_modules/ws/wrapper.mjs +21 -0
  25. package/extensions/dm-phone/package-lock.json +66 -0
  26. package/extensions/dm-phone/package.json +35 -0
  27. package/extensions/dm-phone/phone-session-pool.ts +8 -0
  28. package/extensions/dm-phone/public/app/attachments.js +233 -0
  29. package/extensions/dm-phone/public/app/autocomplete-controller.js +81 -0
  30. package/extensions/dm-phone/public/app/autocomplete.js +135 -0
  31. package/extensions/dm-phone/public/app/bindings.js +178 -0
  32. package/extensions/dm-phone/public/app/command-catalog.js +76 -0
  33. package/extensions/dm-phone/public/app/commands.js +370 -0
  34. package/extensions/dm-phone/public/app/constants.js +60 -0
  35. package/extensions/dm-phone/public/app/formatters.js +131 -0
  36. package/extensions/dm-phone/public/app/handlers.js +442 -0
  37. package/extensions/dm-phone/public/app/main.js +6 -0
  38. package/extensions/dm-phone/public/app/markdown.js +105 -0
  39. package/extensions/dm-phone/public/app/messages.js +418 -0
  40. package/extensions/dm-phone/public/app/sheet-actions.js +113 -0
  41. package/extensions/dm-phone/public/app/sheet-navigation.js +19 -0
  42. package/extensions/dm-phone/public/app/sheets-view.js +272 -0
  43. package/extensions/dm-phone/public/app/state.js +95 -0
  44. package/extensions/dm-phone/public/app/tool-rendering.js +562 -0
  45. package/extensions/dm-phone/public/app/transport.js +176 -0
  46. package/extensions/dm-phone/public/app/ui.js +409 -0
  47. package/extensions/dm-phone/public/app.js +1 -0
  48. package/extensions/dm-phone/public/icon.svg +15 -0
  49. package/extensions/dm-phone/public/index.html +147 -0
  50. package/extensions/dm-phone/public/manifest.webmanifest +17 -0
  51. package/extensions/dm-phone/public/styles.css +1139 -0
  52. package/extensions/dm-phone/public/sw.js +78 -0
  53. package/extensions/dm-phone/src/extension/phone-args.ts +121 -0
  54. package/extensions/dm-phone/src/extension/phone-paths.ts +250 -0
  55. package/extensions/dm-phone/src/extension/phone-quota.ts +188 -0
  56. package/extensions/dm-phone/src/extension/phone-runtime.ts +154 -0
  57. package/extensions/dm-phone/src/extension/phone-server-runtime.ts +1217 -0
  58. package/extensions/dm-phone/src/extension/phone-sessions.ts +139 -0
  59. package/extensions/dm-phone/src/extension/phone-static.ts +30 -0
  60. package/extensions/dm-phone/src/extension/phone-tailscale.ts +148 -0
  61. package/extensions/dm-phone/src/extension/phone-theme.ts +85 -0
  62. package/extensions/dm-phone/src/extension/register-phone-child-extension.ts +112 -0
  63. package/extensions/dm-phone/src/extension/register-phone-extension.ts +106 -0
  64. package/extensions/dm-phone/src/extension/types.ts +73 -0
  65. package/extensions/dm-phone/src/session-pool/parent-session-worker.ts +881 -0
  66. package/extensions/dm-phone/src/session-pool/session-pool.ts +470 -0
  67. package/extensions/dm-phone/src/session-pool/session-worker.ts +734 -0
  68. package/extensions/dm-phone/src/session-pool/types.ts +105 -0
  69. package/extensions/dm-phone/src/session-pool/utils.ts +23 -0
  70. package/extensions/dm-subagents/agent-management.ts +15 -6
  71. package/extensions/dm-subagents/agent-manager-detail.ts +12 -2
  72. package/extensions/dm-subagents/agent-manager-edit.ts +75 -23
  73. package/extensions/dm-subagents/agent-manager-list.ts +9 -2
  74. package/extensions/dm-subagents/agent-manager.ts +199 -11
  75. package/extensions/dm-subagents/agents.ts +315 -20
  76. package/extensions/dm-ultrathink/README.md +5 -0
  77. package/extensions/dm-ultrathink/src/naming.ts +75 -3
  78. package/package.json +1 -1
@@ -0,0 +1,78 @@
1
+ const CACHE = "dm-phone-v17";
2
+ const ASSETS = [
3
+ "/",
4
+ "/styles.css",
5
+ "/app.js",
6
+ "/app/attachments.js",
7
+ "/app/autocomplete-controller.js",
8
+ "/app/autocomplete.js",
9
+ "/app/bindings.js",
10
+ "/app/command-catalog.js",
11
+ "/app/commands.js",
12
+ "/app/constants.js",
13
+ "/app/formatters.js",
14
+ "/app/handlers.js",
15
+ "/app/main.js",
16
+ "/app/markdown.js",
17
+ "/app/messages.js",
18
+ "/app/sheet-actions.js",
19
+ "/app/sheet-navigation.js",
20
+ "/app/sheets-view.js",
21
+ "/app/state.js",
22
+ "/app/tool-rendering.js",
23
+ "/app/transport.js",
24
+ "/app/ui.js",
25
+ "/manifest.webmanifest",
26
+ "/icon.svg",
27
+ ];
28
+ const APP_SHELL = new Set(ASSETS);
29
+
30
+ self.addEventListener("install", (event) => {
31
+ event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS)));
32
+ self.skipWaiting();
33
+ });
34
+
35
+ self.addEventListener("activate", (event) => {
36
+ event.waitUntil(
37
+ caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))),
38
+ );
39
+ self.clients.claim();
40
+ });
41
+
42
+ async function updateCache(request) {
43
+ const response = await fetch(request);
44
+ if (response.ok) {
45
+ const copy = response.clone();
46
+ caches.open(CACHE).then((cache) => cache.put(request, copy));
47
+ }
48
+ return response;
49
+ }
50
+
51
+ self.addEventListener("fetch", (event) => {
52
+ const { request } = event;
53
+ if (request.method !== "GET") return;
54
+
55
+ const url = new URL(request.url);
56
+ if (url.origin !== self.location.origin) return;
57
+ if (url.pathname.startsWith("/api/") || url.pathname === "/ws") return;
58
+
59
+ const useNetworkFirst = request.mode === "navigate" || APP_SHELL.has(url.pathname);
60
+
61
+ if (useNetworkFirst) {
62
+ event.respondWith(
63
+ updateCache(request).catch(async () => {
64
+ const cached = await caches.match(request);
65
+ if (cached) return cached;
66
+ return caches.match("/");
67
+ }),
68
+ );
69
+ return;
70
+ }
71
+
72
+ event.respondWith(
73
+ caches.match(request).then((cached) => {
74
+ if (cached) return cached;
75
+ return updateCache(request);
76
+ }),
77
+ );
78
+ });
@@ -0,0 +1,121 @@
1
+ import { resolve } from "node:path";
2
+ import type { ParsedPhoneArgs, PhoneConfig } from "./types";
3
+
4
+ export function parsePhoneStartArgs(args: string | undefined, current: PhoneConfig): ParsedPhoneArgs {
5
+ const next = { ...current };
6
+ let tokenSpecified = false;
7
+ let idleSpecified = false;
8
+
9
+ if (!args?.trim()) {
10
+ return { config: next, tokenSpecified, idleSpecified };
11
+ }
12
+
13
+ const tokens = args.trim().split(/\s+/);
14
+ let index = 0;
15
+
16
+ while (index < tokens.length) {
17
+ const token = tokens[index];
18
+
19
+ if (token === "--port" && tokens[index + 1]) {
20
+ const port = Number(tokens[index + 1]);
21
+ if (Number.isFinite(port) && port > 0) next.port = port;
22
+ index += 2;
23
+ continue;
24
+ }
25
+
26
+ if (token.startsWith("--port=")) {
27
+ const port = Number(token.slice(7));
28
+ if (Number.isFinite(port) && port > 0) next.port = port;
29
+ index += 1;
30
+ continue;
31
+ }
32
+
33
+ if (token === "--host" && tokens[index + 1]) {
34
+ next.host = tokens[index + 1];
35
+ index += 2;
36
+ continue;
37
+ }
38
+
39
+ if (token.startsWith("--host=")) {
40
+ next.host = token.slice(7);
41
+ index += 1;
42
+ continue;
43
+ }
44
+
45
+ if (token === "--token" && tokens[index + 1] !== undefined) {
46
+ tokenSpecified = true;
47
+ next.token = tokens[index + 1] === "-" ? "" : tokens[index + 1];
48
+ index += 2;
49
+ continue;
50
+ }
51
+
52
+ if (token.startsWith("--token=")) {
53
+ tokenSpecified = true;
54
+ const value = token.slice(8);
55
+ next.token = value === "-" ? "" : value;
56
+ index += 1;
57
+ continue;
58
+ }
59
+
60
+ if (token === "--cwd" && tokens[index + 1]) {
61
+ next.cwd = resolve(tokens[index + 1]);
62
+ index += 2;
63
+ continue;
64
+ }
65
+
66
+ if (token.startsWith("--cwd=")) {
67
+ next.cwd = resolve(token.slice(6));
68
+ index += 1;
69
+ continue;
70
+ }
71
+
72
+ if (token === "--idle-mins" && tokens[index + 1] !== undefined) {
73
+ idleSpecified = true;
74
+ const minutes = Number(tokens[index + 1]);
75
+ if (Number.isFinite(minutes) && minutes >= 0) next.idleTimeoutMs = Math.round(minutes * 60_000);
76
+ index += 2;
77
+ continue;
78
+ }
79
+
80
+ if (token.startsWith("--idle-mins=")) {
81
+ idleSpecified = true;
82
+ const minutes = Number(token.slice(12));
83
+ if (Number.isFinite(minutes) && minutes >= 0) next.idleTimeoutMs = Math.round(minutes * 60_000);
84
+ index += 1;
85
+ continue;
86
+ }
87
+
88
+ if (token === "--idle-secs" && tokens[index + 1] !== undefined) {
89
+ idleSpecified = true;
90
+ const seconds = Number(tokens[index + 1]);
91
+ if (Number.isFinite(seconds) && seconds >= 0) next.idleTimeoutMs = Math.round(seconds * 1_000);
92
+ index += 2;
93
+ continue;
94
+ }
95
+
96
+ if (token.startsWith("--idle-secs=")) {
97
+ idleSpecified = true;
98
+ const seconds = Number(token.slice(12));
99
+ if (Number.isFinite(seconds) && seconds >= 0) next.idleTimeoutMs = Math.round(seconds * 1_000);
100
+ index += 1;
101
+ continue;
102
+ }
103
+
104
+ if (/^\d+$/.test(token)) {
105
+ next.port = Number(token);
106
+ index += 1;
107
+ continue;
108
+ }
109
+
110
+ if (!token.startsWith("--") && next.token === current.token) {
111
+ tokenSpecified = true;
112
+ next.token = token === "-" ? "" : token;
113
+ index += 1;
114
+ continue;
115
+ }
116
+
117
+ index += 1;
118
+ }
119
+
120
+ return { config: next, tokenSpecified, idleSpecified };
121
+ }
@@ -0,0 +1,250 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import type { PhonePathSuggestion, PhonePathSuggestionMode } from "./types";
6
+
7
+ const agentDirFromEnv = process.env.DM_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
8
+ const agentDir = agentDirFromEnv
9
+ ? agentDirFromEnv
10
+ : join(process.env.HOME || process.env.USERPROFILE || process.cwd(), ".dm", "agent");
11
+
12
+ const PATH_SUGGESTION_LIMIT = 20;
13
+ const PATH_SUGGESTION_MAX_RESULTS = 100;
14
+
15
+ function resolveFdBinaryPath(): string | null {
16
+ const bundledFd = join(agentDir, "bin", "fd");
17
+ if (existsSync(bundledFd)) return bundledFd;
18
+
19
+ for (const candidate of ["fd", "fdfind"]) {
20
+ const result = spawnSync(candidate, ["--version"], { stdio: "ignore" });
21
+ if (!result.error && result.status === 0) return candidate;
22
+ }
23
+
24
+ return null;
25
+ }
26
+
27
+ const fdBinaryPath = resolveFdBinaryPath();
28
+
29
+ function stripWrappingQuotes(value: string): string {
30
+ const trimmed = value.trim();
31
+ if (
32
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
33
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))
34
+ ) {
35
+ return trimmed.slice(1, -1);
36
+ }
37
+ return trimmed;
38
+ }
39
+
40
+ function expandHomePath(value: string): string {
41
+ if (value === "~") return homedir();
42
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
43
+ return value;
44
+ }
45
+
46
+ export function resolvePhoneCdTargetPath(rawArgs: string | undefined, currentCwd: string, previousCwd?: string | null): string {
47
+ const input = stripWrappingQuotes(rawArgs ?? "").trim();
48
+
49
+ if (!input) return homedir();
50
+ if (input === "-") {
51
+ if (!previousCwd) {
52
+ throw new Error("No previous directory available yet.");
53
+ }
54
+ return previousCwd;
55
+ }
56
+
57
+ const expanded = expandHomePath(input);
58
+ return resolve(currentCwd, expanded);
59
+ }
60
+
61
+ function createCdPathSuggestions(prefix: string, currentCwd: string, previousCwd?: string | null): PhonePathSuggestion[] {
62
+ const raw = prefix ?? "";
63
+ const trimmed = raw.trimStart();
64
+ const suggestions: PhonePathSuggestion[] = [];
65
+
66
+ if ("-".startsWith(trimmed)) {
67
+ suggestions.push({
68
+ value: "-",
69
+ label: "-",
70
+ description: previousCwd || "Previous directory",
71
+ isDirectory: true,
72
+ kind: "previous",
73
+ });
74
+ }
75
+
76
+ const expanded = expandHomePath(trimmed);
77
+ const endsWithSeparator = /[\\/]$/.test(expanded);
78
+ const resolvedInput = expanded ? resolve(currentCwd, expanded) : currentCwd;
79
+
80
+ const baseDir = expanded
81
+ ? endsWithSeparator
82
+ ? resolvedInput
83
+ : dirname(resolvedInput)
84
+ : currentCwd;
85
+
86
+ const partial = expanded && !endsWithSeparator ? basename(expanded) : "";
87
+ const valuePrefix = trimmed
88
+ ? endsWithSeparator
89
+ ? trimmed
90
+ : trimmed.slice(0, Math.max(0, trimmed.length - partial.length))
91
+ : "";
92
+
93
+ try {
94
+ if (!existsSync(baseDir) || !statSync(baseDir).isDirectory()) {
95
+ return suggestions.slice(0, PATH_SUGGESTION_LIMIT);
96
+ }
97
+
98
+ const directories = readdirSync(baseDir, { withFileTypes: true })
99
+ .filter((entry) => {
100
+ if (entry.isDirectory()) return true;
101
+ if (!entry.isSymbolicLink()) return false;
102
+ try {
103
+ return statSync(join(baseDir, entry.name)).isDirectory();
104
+ } catch {
105
+ return false;
106
+ }
107
+ })
108
+ .filter((entry) => !partial || entry.name.startsWith(partial))
109
+ .sort((left, right) => left.name.localeCompare(right.name));
110
+
111
+ for (const entry of directories.slice(0, PATH_SUGGESTION_LIMIT)) {
112
+ suggestions.push({
113
+ value: `${valuePrefix}${entry.name}/`,
114
+ label: `${entry.name}/`,
115
+ description: join(baseDir, entry.name),
116
+ isDirectory: true,
117
+ kind: "path",
118
+ });
119
+ }
120
+ } catch {
121
+ return suggestions.slice(0, PATH_SUGGESTION_LIMIT);
122
+ }
123
+
124
+ return suggestions.slice(0, PATH_SUGGESTION_LIMIT);
125
+ }
126
+
127
+ function resolveScopedMentionQuery(rawQuery: string, currentCwd: string): { baseDir: string; query: string; displayBase: string } | null {
128
+ const slashIndex = rawQuery.lastIndexOf("/");
129
+ if (slashIndex === -1) return null;
130
+
131
+ const displayBase = rawQuery.slice(0, slashIndex + 1);
132
+ const query = rawQuery.slice(slashIndex + 1);
133
+ const baseDir = displayBase.startsWith("~/")
134
+ ? expandHomePath(displayBase)
135
+ : displayBase.startsWith("/")
136
+ ? displayBase
137
+ : join(currentCwd, displayBase);
138
+
139
+ try {
140
+ if (!statSync(baseDir).isDirectory()) return null;
141
+ } catch {
142
+ return null;
143
+ }
144
+
145
+ return { baseDir, query, displayBase };
146
+ }
147
+
148
+ function scopedPathForDisplay(displayBase: string, relativePath: string): string {
149
+ if (displayBase === "/") return `/${relativePath}`;
150
+ return `${displayBase}${relativePath}`;
151
+ }
152
+
153
+ function scorePhonePathEntry(filePath: string, query: string, isDirectory: boolean): number {
154
+ if (!query) return isDirectory ? 2 : 1;
155
+
156
+ const fileName = basename(filePath).toLowerCase();
157
+ const normalizedQuery = query.toLowerCase();
158
+ let score = 0;
159
+
160
+ if (fileName === normalizedQuery) score = 100;
161
+ else if (fileName.startsWith(normalizedQuery)) score = 80;
162
+ else if (fileName.includes(normalizedQuery)) score = 50;
163
+ else if (filePath.toLowerCase().includes(normalizedQuery)) score = 30;
164
+
165
+ if (isDirectory && score > 0) score += 10;
166
+ return score;
167
+ }
168
+
169
+ function walkDirectoryWithFd(baseDir: string, query: string, maxResults = PATH_SUGGESTION_MAX_RESULTS) {
170
+ if (!fdBinaryPath) return [] as Array<{ path: string; isDirectory: boolean }>;
171
+
172
+ const args = [
173
+ "--base-directory",
174
+ baseDir,
175
+ "--max-results",
176
+ String(maxResults),
177
+ "--type",
178
+ "f",
179
+ "--type",
180
+ "d",
181
+ "--full-path",
182
+ "--hidden",
183
+ "--exclude",
184
+ ".git",
185
+ "--exclude",
186
+ ".git/*",
187
+ "--exclude",
188
+ ".git/**",
189
+ ];
190
+
191
+ if (query) args.push(query);
192
+
193
+ const result = spawnSync(fdBinaryPath, args, {
194
+ encoding: "utf8",
195
+ stdio: ["pipe", "pipe", "pipe"],
196
+ maxBuffer: 10 * 1024 * 1024,
197
+ });
198
+
199
+ if (result.status !== 0 || !result.stdout) return [];
200
+
201
+ return result.stdout
202
+ .trim()
203
+ .split("\n")
204
+ .filter(Boolean)
205
+ .map((line) => ({
206
+ path: line.endsWith("/") ? line.slice(0, -1) : line,
207
+ isDirectory: line.endsWith("/"),
208
+ }))
209
+ .filter((entry) => entry.path !== ".git" && !entry.path.startsWith(".git/") && !entry.path.includes("/.git/"));
210
+ }
211
+
212
+ function createMentionPathSuggestions(query: string, currentCwd: string): PhonePathSuggestion[] {
213
+ const scopedQuery = resolveScopedMentionQuery(query, currentCwd);
214
+ const fdBaseDir = scopedQuery?.baseDir ?? currentCwd;
215
+ const fdQuery = scopedQuery?.query ?? query;
216
+ const entries = walkDirectoryWithFd(fdBaseDir, fdQuery, PATH_SUGGESTION_MAX_RESULTS);
217
+
218
+ return entries
219
+ .map((entry) => ({
220
+ ...entry,
221
+ score: scorePhonePathEntry(entry.path, fdQuery, entry.isDirectory),
222
+ }))
223
+ .filter((entry) => entry.score > 0)
224
+ .sort((left, right) => right.score - left.score)
225
+ .slice(0, PATH_SUGGESTION_LIMIT)
226
+ .map((entry) => {
227
+ const displayPath = scopedQuery
228
+ ? scopedPathForDisplay(scopedQuery.displayBase, entry.path)
229
+ : entry.path;
230
+ const completionPath = entry.isDirectory ? `${displayPath}/` : displayPath;
231
+ return {
232
+ value: completionPath,
233
+ label: `${basename(entry.path)}${entry.isDirectory ? "/" : ""}`,
234
+ description: displayPath,
235
+ isDirectory: entry.isDirectory,
236
+ kind: "path" as const,
237
+ };
238
+ });
239
+ }
240
+
241
+ export function listPhonePathSuggestions(
242
+ mode: PhonePathSuggestionMode,
243
+ query: string,
244
+ currentCwd: string,
245
+ previousCwd?: string | null,
246
+ ): PhonePathSuggestion[] {
247
+ return mode === "cd"
248
+ ? createCdPathSuggestions(query, currentCwd, previousCwd)
249
+ : createMentionPathSuggestions(query, currentCwd);
250
+ }
@@ -0,0 +1,188 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type {
4
+ CodexUsageResponse,
5
+ PhoneQuotaResponse,
6
+ PhoneQuotaWindow,
7
+ RateLimitBucket,
8
+ UsageWindow,
9
+ } from "./types";
10
+
11
+ const agentDirFromEnv = process.env.DM_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
12
+ const agentDir = agentDirFromEnv
13
+ ? agentDirFromEnv
14
+ : join(process.env.HOME || process.env.USERPROFILE || process.cwd(), ".dm", "agent");
15
+ const authFile = join(agentDir, "auth.json");
16
+ const codexUsageUrl = "https://chatgpt.com/backend-api/wham/usage";
17
+ const sparkModelId = "gpt-5.3-codex-spark";
18
+ const sparkLimitName = "GPT-5.3-Codex-Spark";
19
+ const missingAuthErrorPrefix = "Missing openai-codex OAuth access/accountId";
20
+
21
+ function clampPercent(value: number): number {
22
+ return Math.min(100, Math.max(0, value));
23
+ }
24
+
25
+ function usedToLeftPercent(value: number | null | undefined): number | null {
26
+ if (typeof value !== "number" || Number.isNaN(value)) return null;
27
+ return clampPercent(100 - value);
28
+ }
29
+
30
+ function asObject(value: unknown): Record<string, unknown> | null {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
32
+ return value as Record<string, unknown>;
33
+ }
34
+
35
+ function normalizeRateLimitBucket(value: unknown): RateLimitBucket | null {
36
+ const record = asObject(value);
37
+ if (!record) return null;
38
+ if (!("primary_window" in record || "secondary_window" in record || "limit_reached" in record || "allowed" in record)) {
39
+ return null;
40
+ }
41
+ return record as RateLimitBucket;
42
+ }
43
+
44
+ function extractSparkRateLimitFromEntry(value: unknown): RateLimitBucket | null {
45
+ const record = asObject(value);
46
+ if (!record) return null;
47
+ if (typeof record.limit_name !== "string" || record.limit_name.trim() !== sparkLimitName) return null;
48
+ return normalizeRateLimitBucket(record.rate_limit);
49
+ }
50
+
51
+ function findSparkRateLimitBucket(data: CodexUsageResponse): RateLimitBucket | null {
52
+ const additional = data.additional_rate_limits;
53
+
54
+ if (Array.isArray(additional)) {
55
+ for (const entry of additional) {
56
+ const bucket = extractSparkRateLimitFromEntry(entry);
57
+ if (bucket) return bucket;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ const additionalMap = asObject(additional);
63
+ if (!additionalMap) return null;
64
+
65
+ for (const value of Object.values(additionalMap)) {
66
+ const bucket = extractSparkRateLimitFromEntry(value);
67
+ if (bucket) return bucket;
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ function selectRateLimitBucket(data: CodexUsageResponse, modelId: string): RateLimitBucket | null {
74
+ if (modelId === sparkModelId) {
75
+ return findSparkRateLimitBucket(data);
76
+ }
77
+ return normalizeRateLimitBucket(data.rate_limit);
78
+ }
79
+
80
+ function getResetSeconds(window: UsageWindow | null | undefined): number | null {
81
+ const resetAfterSeconds = window?.reset_after_seconds;
82
+ if (typeof resetAfterSeconds === "number" && !Number.isNaN(resetAfterSeconds)) {
83
+ return resetAfterSeconds;
84
+ }
85
+
86
+ const resetAt = window?.reset_at;
87
+ if (typeof resetAt !== "number" || Number.isNaN(resetAt)) return null;
88
+
89
+ const resetAtSeconds = resetAt > 100_000_000_000 ? resetAt / 1000 : resetAt;
90
+ return Math.max(0, resetAtSeconds - Date.now() / 1000);
91
+ }
92
+
93
+ function buildQuotaWindow(label: "5h" | "7d", window: UsageWindow | null | undefined): PhoneQuotaWindow | null {
94
+ const leftPercent = usedToLeftPercent(window?.used_percent);
95
+ if (leftPercent === null) return null;
96
+
97
+ const roundedLeftPercent = Math.round(leftPercent);
98
+ const roundedUsedPercent = Math.round(clampPercent(typeof window?.used_percent === "number" ? window.used_percent : 100 - leftPercent));
99
+
100
+ return {
101
+ label,
102
+ leftPercent: roundedLeftPercent,
103
+ usedPercent: roundedUsedPercent,
104
+ resetAfterSeconds: getResetSeconds(window),
105
+ text: `${roundedLeftPercent}%`,
106
+ };
107
+ }
108
+
109
+ function shouldShowQuotaForModel(provider: string | null | undefined, modelId: string | null | undefined): boolean {
110
+ return provider === "openai-codex" && typeof modelId === "string" && /^gpt-/i.test(modelId);
111
+ }
112
+
113
+ async function loadCodexAuthCredentials(): Promise<{ accessToken: string; accountId: string }> {
114
+ const authRaw = await readFile(authFile, "utf8");
115
+ const auth = JSON.parse(authRaw) as Record<
116
+ string,
117
+ | {
118
+ type?: string;
119
+ access?: string | null;
120
+ accountId?: string | null;
121
+ account_id?: string | null;
122
+ }
123
+ | undefined
124
+ >;
125
+
126
+ const codexEntry = auth["openai-codex"];
127
+ const authEntry = codexEntry?.type === "oauth" ? codexEntry : undefined;
128
+ const accessToken = authEntry?.access?.trim();
129
+ const accountId = (authEntry?.accountId ?? authEntry?.account_id)?.trim();
130
+
131
+ if (!accessToken || !accountId) {
132
+ throw new Error(`${missingAuthErrorPrefix} in ${authFile}`);
133
+ }
134
+
135
+ return { accessToken, accountId };
136
+ }
137
+
138
+ async function requestCodexUsageJson(): Promise<CodexUsageResponse> {
139
+ const credentials = await loadCodexAuthCredentials();
140
+ const response = await fetch(codexUsageUrl, {
141
+ headers: {
142
+ accept: "*/*",
143
+ authorization: `Bearer ${credentials.accessToken}`,
144
+ "chatgpt-account-id": credentials.accountId,
145
+ "content-type": "application/json",
146
+ "user-agent": "codex-cli",
147
+ },
148
+ });
149
+
150
+ if (!response.ok) {
151
+ throw new Error(`Codex usage request failed (${response.status})`);
152
+ }
153
+
154
+ return (await response.json()) as CodexUsageResponse;
155
+ }
156
+
157
+ export async function getQuotaForModel(provider: string | null | undefined, modelId: string | null | undefined): Promise<PhoneQuotaResponse> {
158
+ if (!shouldShowQuotaForModel(provider, modelId)) {
159
+ return {
160
+ visible: false,
161
+ limited: false,
162
+ primaryWindow: null,
163
+ secondaryWindow: null,
164
+ };
165
+ }
166
+
167
+ try {
168
+ const usage = await requestCodexUsageJson();
169
+ const selectedBucket = selectRateLimitBucket(usage, modelId || "");
170
+ const primaryWindow = buildQuotaWindow("5h", selectedBucket?.primary_window);
171
+ const secondaryWindow = buildQuotaWindow("7d", selectedBucket?.secondary_window);
172
+
173
+ return {
174
+ visible: Boolean(primaryWindow || secondaryWindow),
175
+ limited: selectedBucket?.limit_reached === true || selectedBucket?.allowed === false,
176
+ primaryWindow,
177
+ secondaryWindow,
178
+ };
179
+ } catch (error) {
180
+ return {
181
+ visible: false,
182
+ limited: false,
183
+ primaryWindow: null,
184
+ secondaryWindow: null,
185
+ error: error instanceof Error ? error.message : String(error),
186
+ };
187
+ }
188
+ }