march-cli 0.1.35 → 0.1.37

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 (63) hide show
  1. package/package.json +1 -1
  2. package/src/agent/code-search/cache.mjs +133 -0
  3. package/src/agent/code-search/chunk-rules.mjs +107 -0
  4. package/src/agent/code-search/chunker.mjs +125 -0
  5. package/src/agent/code-search/engine.mjs +109 -0
  6. package/src/agent/code-search/languages.mjs +25 -0
  7. package/src/agent/code-search/parser-pool.mjs +29 -0
  8. package/src/agent/code-search/rerank.mjs +43 -0
  9. package/src/agent/code-search/retrieval/bm25.mjs +47 -0
  10. package/src/agent/code-search/retrieval/fusion.mjs +18 -0
  11. package/src/agent/code-search/retrieval/model2vec.mjs +96 -0
  12. package/src/agent/code-search/retrieval/safetensors.mjs +49 -0
  13. package/src/agent/code-search/retrieval/vector.mjs +107 -0
  14. package/src/agent/code-search/retrieval/wordpiece.mjs +82 -0
  15. package/src/agent/code-search/scanner.mjs +84 -0
  16. package/src/agent/code-search/tokenize.mjs +16 -0
  17. package/src/agent/code-search/tool.mjs +75 -0
  18. package/src/agent/runner/provider-quota-runtime.mjs +38 -0
  19. package/src/agent/runner.mjs +14 -10
  20. package/src/agent/runtime/remote-runner-client.mjs +2 -0
  21. package/src/agent/runtime/runner-ipc-target.mjs +7 -0
  22. package/src/agent/runtime/runner-process-client.mjs +5 -0
  23. package/src/agent/runtime/runner-process-factory.mjs +5 -0
  24. package/src/agent/runtime/runner-runtime-host.mjs +2 -0
  25. package/src/agent/runtime/state/runner-state.mjs +1 -0
  26. package/src/agent/runtime/ui-event-bridge.mjs +2 -0
  27. package/src/agent/session/session-options.mjs +2 -1
  28. package/src/agent/tools.mjs +7 -1
  29. package/src/agent/turn/turn-events.mjs +41 -0
  30. package/src/agent/turn/turn-runner.mjs +5 -2
  31. package/src/cli/commands/registry/slash-command-registry.mjs +10 -7
  32. package/src/cli/commands/status-command.mjs +61 -35
  33. package/src/cli/input/history-store.mjs +65 -3
  34. package/src/cli/repl-loop.mjs +8 -6
  35. package/src/cli/startup/app-runtime.mjs +5 -29
  36. package/src/cli/startup/create-runtime-runner.mjs +4 -46
  37. package/src/cli/tui/input/history-navigation-controller.mjs +56 -0
  38. package/src/cli/turn/turn-input-preparer.mjs +0 -1
  39. package/src/cli/ui.mjs +9 -0
  40. package/src/context/engine.mjs +4 -2
  41. package/src/context/system-core/base.md +9 -1
  42. package/src/history/runner.mjs +11 -0
  43. package/src/history/store.mjs +129 -0
  44. package/src/history/tool.mjs +39 -0
  45. package/src/lsp/client.mjs +12 -5
  46. package/src/lsp/service.mjs +15 -3
  47. package/src/main.mjs +1 -2
  48. package/src/provider/quota/codex.mjs +278 -0
  49. package/src/provider/quota/index.mjs +46 -0
  50. package/src/provider/quota/transport-observer.mjs +99 -0
  51. package/src/web-ui/command.mjs +2 -2
  52. package/src/web-ui/runtime-host.mjs +7 -23
  53. package/src/web-ui/server.mjs +1 -0
  54. package/src/web-ui/session-manager.mjs +4 -2
  55. package/src/web-ui/src/components/AppShell.tsx +1 -0
  56. package/src/web-ui/src/components/RightSidebar.tsx +47 -2
  57. package/src/web-ui/src/model.ts +20 -0
  58. package/src/web-ui/src/runtime/client.ts +8 -1
  59. package/src/web-ui/src/runtime/useWebRuntime.ts +13 -1
  60. package/src/web-ui/src/styles/shell.css +10 -0
  61. package/src/web-ui/dist/assets/index-BUmhnID4.css +0 -1
  62. package/src/web-ui/dist/assets/index-CtuqTjcB.js +0 -1845
  63. package/src/web-ui/dist/index.html +0 -13
@@ -0,0 +1,278 @@
1
+ import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
2
+
3
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
4
+ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
5
+
6
+ export async function fetchOpenAICodexQuota({ authStorage, model, fetchImpl = fetch, now = new Date() } = {}) {
7
+ const token = await resolveCodexAccessToken(authStorage);
8
+ const accountId = resolveCodexAccountId(authStorage, token);
9
+ const response = await fetchImpl(CODEX_USAGE_URL, {
10
+ method: "GET",
11
+ headers: buildCodexUsageHeaders(token, accountId),
12
+ });
13
+ if (!response.ok) {
14
+ const text = await response.text().catch(() => "");
15
+ throw new Error(`Codex quota request failed (${response.status}): ${text || response.statusText}`);
16
+ }
17
+ const payload = await response.json();
18
+ return normalizeCodexQuotaPayload(payload, { model, capturedAt: now.toISOString() });
19
+ }
20
+
21
+ export function normalizeCodexQuotaPayload(payload, { model = null, capturedAt = new Date().toISOString() } = {}) {
22
+ const snapshots = Array.isArray(payload) ? payload : snapshotsFromCodexUsagePayload(payload);
23
+ return normalizeCodexQuotaSnapshots(snapshots, { model, capturedAt });
24
+ }
25
+
26
+ export function normalizeCodexQuotaSnapshots(snapshots, { model = null, capturedAt = new Date().toISOString() } = {}) {
27
+ const limits = snapshots.map(normalizeSnapshot).filter((limit) => limit.windows.length > 0);
28
+ if (limits.length === 0) return null;
29
+ return {
30
+ providerId: "openai-codex",
31
+ modelId: model?.id ?? null,
32
+ label: "GPT usage",
33
+ planType: firstNonEmpty(snapshots.map((snapshot) => readField(snapshot, "planType", "plan_type"))),
34
+ capturedAt,
35
+ limits,
36
+ };
37
+ }
38
+
39
+ export function parseOpenAICodexQuotaHeaders(headers, { model = null, capturedAt = new Date().toISOString() } = {}) {
40
+ const normalized = normalizeHeaders(headers);
41
+ const limitIds = new Set(["codex"]);
42
+ for (const name of Object.keys(normalized)) {
43
+ const prefix = name.endsWith("-primary-used-percent") ? name.slice(2, -"-primary-used-percent".length) : null;
44
+ if (prefix) limitIds.add(prefix.replaceAll("-", "_"));
45
+ }
46
+ const snapshots = [...limitIds]
47
+ .map((limitId) => snapshotFromHeaders(normalized, limitId))
48
+ .filter((snapshot) => snapshot.primary || snapshot.secondary || snapshot.credits);
49
+ return normalizeCodexQuotaSnapshots(snapshots, { model, capturedAt });
50
+ }
51
+
52
+ export function parseOpenAICodexQuotaEvent(payload, { model = null, capturedAt = new Date().toISOString() } = {}) {
53
+ const event = typeof payload === "string" ? parseJson(payload) : payload;
54
+ if (!event || event.type !== "codex.rate_limits") return null;
55
+ const snapshot = {
56
+ limitId: readField(event, "metered_limit_name", "limit_name") ?? "codex",
57
+ limitName: null,
58
+ primary: mapWindow(readField(readField(event, "rate_limits", "rateLimits"), "primary")),
59
+ secondary: mapWindow(readField(readField(event, "rate_limits", "rateLimits"), "secondary")),
60
+ credits: readField(event, "credits") ?? null,
61
+ planType: readField(event, "plan_type", "planType") ?? null,
62
+ rateLimitReachedType: null,
63
+ };
64
+ return normalizeCodexQuotaSnapshots([snapshot], { model, capturedAt });
65
+ }
66
+
67
+ async function resolveCodexAccessToken(authStorage) {
68
+ const token = await authStorage?.getApiKey?.("openai-codex", { includeFallback: false });
69
+ if (token) return token;
70
+ const credentials = authStorage?.get?.("openai-codex");
71
+ if (!credentials) throw new Error("OpenAI Codex not authenticated. Run: march login openai-codex");
72
+ const provider = getOAuthProvider("openai-codex");
73
+ if (!provider) throw new Error("OpenAI Codex OAuth provider is not available");
74
+ return provider.getApiKey(credentials);
75
+ }
76
+
77
+ function resolveCodexAccountId(authStorage, token) {
78
+ const credentials = authStorage?.get?.("openai-codex");
79
+ return credentials?.accountId ?? credentials?.chatgpt_account_id ?? extractAccountId(token);
80
+ }
81
+
82
+ function extractAccountId(token) {
83
+ try {
84
+ const [, payload] = String(token).split(".");
85
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
86
+ const accountId = parsed?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
87
+ if (typeof accountId === "string" && accountId) return accountId;
88
+ } catch {}
89
+ throw new Error("Failed to extract Codex account ID from token");
90
+ }
91
+
92
+ function buildCodexUsageHeaders(token, accountId) {
93
+ return {
94
+ authorization: `Bearer ${token}`,
95
+ "chatgpt-account-id": accountId,
96
+ originator: "march",
97
+ "user-agent": "march-cli",
98
+ accept: "application/json",
99
+ };
100
+ }
101
+
102
+ function snapshotsFromCodexUsagePayload(payload) {
103
+ if (!payload || typeof payload !== "object") return [];
104
+ const planType = readField(payload, "planType", "plan_type") ?? null;
105
+ const reached = readField(payload, "rateLimitReachedType", "rate_limit_reached_type") ?? null;
106
+ const snapshots = [makeSnapshot({
107
+ limitId: "codex",
108
+ limitName: null,
109
+ rateLimit: unwrap(readField(payload, "rateLimit", "rate_limit")),
110
+ credits: unwrap(readField(payload, "credits")),
111
+ planType,
112
+ rateLimitReachedType: unwrap(reached)?.kind ?? reached,
113
+ })];
114
+ const additional = readField(payload, "additionalRateLimits", "additional_rate_limits");
115
+ if (Array.isArray(additional)) {
116
+ for (const item of additional) {
117
+ snapshots.push(makeSnapshot({
118
+ limitId: readField(item, "meteredFeature", "metered_feature"),
119
+ limitName: readField(item, "limitName", "limit_name"),
120
+ rateLimit: unwrap(readField(item, "rateLimit", "rate_limit")),
121
+ credits: null,
122
+ planType,
123
+ rateLimitReachedType: null,
124
+ }));
125
+ }
126
+ }
127
+ return snapshots;
128
+ }
129
+
130
+ function makeSnapshot({ limitId, limitName, rateLimit, credits, planType, rateLimitReachedType }) {
131
+ return {
132
+ limitId: limitId ?? null,
133
+ limitName: limitName ?? null,
134
+ primary: mapWindow(readField(rateLimit, "primary", "primaryWindow", "primary_window")),
135
+ secondary: mapWindow(readField(rateLimit, "secondary", "secondaryWindow", "secondary_window")),
136
+ credits: credits ?? null,
137
+ planType,
138
+ rateLimitReachedType,
139
+ };
140
+ }
141
+
142
+ function snapshotFromHeaders(headers, limitId) {
143
+ const headerPrefix = `x-${limitId.replaceAll("_", "-")}`;
144
+ return {
145
+ limitId,
146
+ limitName: readHeader(headers, `${headerPrefix}-limit-name`),
147
+ primary: windowFromHeaders(headers, headerPrefix, "primary"),
148
+ secondary: windowFromHeaders(headers, headerPrefix, "secondary"),
149
+ credits: limitId === "codex" ? creditsFromHeaders(headers) : null,
150
+ planType: null,
151
+ rateLimitReachedType: null,
152
+ };
153
+ }
154
+
155
+ function windowFromHeaders(headers, prefix, windowId) {
156
+ const usedPercent = readHeader(headers, `${prefix}-${windowId}-used-percent`);
157
+ if (usedPercent === undefined) return null;
158
+ return {
159
+ usedPercent,
160
+ windowDurationMins: readHeader(headers, `${prefix}-${windowId}-window-minutes`),
161
+ resetsAt: readHeader(headers, `${prefix}-${windowId}-reset-at`),
162
+ };
163
+ }
164
+
165
+ function creditsFromHeaders(headers) {
166
+ const hasCredits = parseHeaderBool(readHeader(headers, "x-codex-credits-has-credits"));
167
+ const unlimited = parseHeaderBool(readHeader(headers, "x-codex-credits-unlimited"));
168
+ if (hasCredits === null || unlimited === null) return null;
169
+ return { hasCredits, unlimited, balance: readHeader(headers, "x-codex-credits-balance") ?? null };
170
+ }
171
+
172
+ function normalizeSnapshot(snapshot) {
173
+ const id = readField(snapshot, "limitId", "limit_id") ?? "quota";
174
+ return {
175
+ id,
176
+ name: readField(snapshot, "limitName", "limit_name") ?? id,
177
+ windows: [
178
+ normalizeWindow("primary", readField(snapshot, "primary")),
179
+ normalizeWindow("secondary", readField(snapshot, "secondary")),
180
+ ].filter(Boolean),
181
+ rateLimitReachedType: readField(snapshot, "rateLimitReachedType", "rate_limit_reached_type") ?? null,
182
+ };
183
+ }
184
+
185
+ function normalizeWindow(id, window) {
186
+ const normalized = mapWindow(window);
187
+ if (!normalized) return null;
188
+ const label = formatWindowLabel(normalized.windowDurationMins, id);
189
+ return {
190
+ id,
191
+ label,
192
+ usedPercent: normalized.usedPercent,
193
+ remainingPercent: Math.max(0, 100 - normalized.usedPercent),
194
+ windowDurationMins: normalized.windowDurationMins,
195
+ resetsAt: normalized.resetsAt,
196
+ };
197
+ }
198
+
199
+ function mapWindow(window) {
200
+ const unwrapped = unwrap(window);
201
+ if (!unwrapped || typeof unwrapped !== "object") return null;
202
+ const rawUsed = readField(unwrapped, "usedPercent", "used_percent");
203
+ const usedPercent = Number(rawUsed);
204
+ if (!Number.isFinite(usedPercent)) return null;
205
+ const rawMinutes = readField(unwrapped, "windowDurationMins", "window_minutes", "windowDurationMinutes");
206
+ const rawSeconds = readField(unwrapped, "limitWindowSeconds", "limit_window_seconds");
207
+ return {
208
+ usedPercent,
209
+ windowDurationMins: normalizeWindowMinutes(rawMinutes, rawSeconds),
210
+ resetsAt: normalizeResetTime(readField(unwrapped, "resetsAt", "resets_at", "resetAt", "reset_at")),
211
+ };
212
+ }
213
+
214
+ function normalizeWindowMinutes(minutes, seconds) {
215
+ const minuteValue = Number(minutes);
216
+ if (Number.isFinite(minuteValue) && minuteValue > 0) return minuteValue;
217
+ const secondValue = Number(seconds);
218
+ return Number.isFinite(secondValue) && secondValue > 0 ? Math.round(secondValue / 60) : null;
219
+ }
220
+
221
+ function normalizeResetTime(value) {
222
+ const numeric = Number(value);
223
+ if (Number.isFinite(numeric) && numeric > 0) {
224
+ const millis = numeric < 10_000_000_000 ? numeric * 1000 : numeric;
225
+ return new Date(millis).toISOString();
226
+ }
227
+ const parsed = Date.parse(String(value));
228
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
229
+ }
230
+
231
+ function formatWindowLabel(minutes, fallback) {
232
+ if (!Number.isFinite(minutes) || minutes <= 0) return fallback;
233
+ if (minutes % (60 * 24 * 7) === 0) return "weekly";
234
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
235
+ return `${minutes}m`;
236
+ }
237
+
238
+ function unwrap(value) {
239
+ return Array.isArray(value) && value.length === 1 ? value[0] : value;
240
+ }
241
+
242
+ function readField(object, ...keys) {
243
+ if (!object || typeof object !== "object") return undefined;
244
+ for (const key of keys) if (Object.hasOwn(object, key)) return object[key];
245
+ return undefined;
246
+ }
247
+
248
+ function normalizeHeaders(headers) {
249
+ if (!headers) return {};
250
+ if (typeof headers.entries === "function") {
251
+ return Object.fromEntries([...headers.entries()].map(([key, value]) => [key.toLowerCase(), String(value)]));
252
+ }
253
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), String(value)]));
254
+ }
255
+
256
+ function readHeader(headers, name) {
257
+ const value = headers[name.toLowerCase()];
258
+ return value === undefined || value === "" ? undefined : value;
259
+ }
260
+
261
+ function parseHeaderBool(value) {
262
+ if (value === undefined) return null;
263
+ if (value === "1" || value.toLowerCase() === "true") return true;
264
+ if (value === "0" || value.toLowerCase() === "false") return false;
265
+ return null;
266
+ }
267
+
268
+ function parseJson(value) {
269
+ try {
270
+ return JSON.parse(value);
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ function firstNonEmpty(values) {
277
+ return values.find((value) => value !== null && value !== undefined && value !== "") ?? null;
278
+ }
@@ -0,0 +1,46 @@
1
+ import { fetchOpenAICodexQuota, parseOpenAICodexQuotaEvent, parseOpenAICodexQuotaHeaders } from "./codex.mjs";
2
+
3
+ const quotaAdapters = new Map([
4
+ ["openai-codex", {
5
+ refresh: fetchOpenAICodexQuota,
6
+ observeHeaders: parseOpenAICodexQuotaHeaders,
7
+ observeEvent: parseOpenAICodexQuotaEvent,
8
+ }],
9
+ ]);
10
+
11
+ export function supportsProviderQuota(providerId) {
12
+ return quotaAdapters.has(providerId);
13
+ }
14
+
15
+ export async function getProviderQuotaSnapshot({ providerId, model, authStorage, fetchImpl, now } = {}) {
16
+ const adapter = quotaAdapters.get(providerId);
17
+ if (!adapter) return null;
18
+ return adapter.refresh({ authStorage, model, fetchImpl, now });
19
+ }
20
+
21
+ export function observeProviderQuotaHeaders({ providerId, headers, model, capturedAt } = {}) {
22
+ const adapter = quotaAdapters.get(providerId);
23
+ return adapter?.observeHeaders?.(headers, { model, capturedAt }) ?? null;
24
+ }
25
+
26
+ export function observeProviderQuotaEvent({ providerId, payload, model, capturedAt } = {}) {
27
+ const adapter = quotaAdapters.get(providerId);
28
+ return adapter?.observeEvent?.(payload, { model, capturedAt }) ?? null;
29
+ }
30
+
31
+ export function createProviderQuotaService({ authStorage, fetchImpl = fetch, now = () => new Date() } = {}) {
32
+ return {
33
+ supports(providerId) {
34
+ return supportsProviderQuota(providerId);
35
+ },
36
+ refresh(model) {
37
+ return getProviderQuotaSnapshot({ providerId: model?.provider, model, authStorage, fetchImpl, now: now() });
38
+ },
39
+ observeHeaders(headers, model) {
40
+ return observeProviderQuotaHeaders({ providerId: model?.provider, headers, model, capturedAt: now().toISOString() });
41
+ },
42
+ observeEvent(payload, model) {
43
+ return observeProviderQuotaEvent({ providerId: model?.provider, payload, model, capturedAt: now().toISOString() });
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,99 @@
1
+ const FETCH_INSTALLED = Symbol.for("march.providerQuota.fetchObserverInstalled");
2
+ const WEBSOCKET_INSTALLED = Symbol.for("march.providerQuota.websocketObserverInstalled");
3
+ const LISTENERS = Symbol.for("march.providerQuota.transportListeners");
4
+
5
+ export function installProviderQuotaTransportObserver() {
6
+ installFetchObserver();
7
+ installWebSocketObserver();
8
+ }
9
+
10
+ export function subscribeProviderQuotaTransport(listener) {
11
+ const listeners = ensureListeners();
12
+ listeners.add(listener);
13
+ return () => listeners.delete(listener);
14
+ }
15
+
16
+ function installFetchObserver() {
17
+ if (globalThis[FETCH_INSTALLED]) return;
18
+ const originalFetch = globalThis.fetch;
19
+ if (typeof originalFetch !== "function") return;
20
+ globalThis.fetch = async function marchProviderQuotaFetch(input, init = {}) {
21
+ const response = await originalFetch.call(this, input, init);
22
+ if (isCodexResponsesHttpRequest(input, init)) {
23
+ notifyTransportListeners({ providerId: "openai-codex", source: "headers", headers: response.headers });
24
+ }
25
+ return response;
26
+ };
27
+ globalThis[FETCH_INSTALLED] = true;
28
+ }
29
+
30
+ function installWebSocketObserver() {
31
+ if (globalThis[WEBSOCKET_INSTALLED]) return;
32
+ const OriginalWebSocket = globalThis.WebSocket;
33
+ if (typeof OriginalWebSocket !== "function") return;
34
+
35
+ class MarchProviderQuotaWebSocket extends OriginalWebSocket {
36
+ constructor(url, protocolsOrOptions, maybeOptions) {
37
+ if (!isCodexResponsesWebSocketUrl(url)) {
38
+ return new OriginalWebSocket(url, protocolsOrOptions, maybeOptions);
39
+ }
40
+ super(url, protocolsOrOptions, maybeOptions);
41
+ this.addEventListener?.("message", async (event) => {
42
+ const payload = await decodeWebSocketData(event?.data).catch(() => null);
43
+ if (payload?.includes?.('"codex.rate_limits"')) {
44
+ notifyTransportListeners({ providerId: "openai-codex", source: "event", payload });
45
+ }
46
+ });
47
+ }
48
+ }
49
+ copyReadyStateConstants(MarchProviderQuotaWebSocket, OriginalWebSocket);
50
+ globalThis.WebSocket = MarchProviderQuotaWebSocket;
51
+ globalThis[WEBSOCKET_INSTALLED] = true;
52
+ }
53
+
54
+ function ensureListeners() {
55
+ globalThis[LISTENERS] = globalThis[LISTENERS] ?? new Set();
56
+ return globalThis[LISTENERS];
57
+ }
58
+
59
+ function notifyTransportListeners(event) {
60
+ for (const listener of [...ensureListeners()]) {
61
+ try {
62
+ listener(event);
63
+ } catch {}
64
+ }
65
+ }
66
+
67
+ function isCodexResponsesHttpRequest(input, init) {
68
+ const url = getRequestUrl(input);
69
+ if (!url || !url.includes("/codex/responses")) return false;
70
+ const method = init?.method ?? input?.method ?? "GET";
71
+ return String(method).toUpperCase() === "POST";
72
+ }
73
+
74
+ function isCodexResponsesWebSocketUrl(url) {
75
+ const raw = getRequestUrl(url);
76
+ return Boolean(raw?.includes("/codex/responses") && (raw.startsWith("ws://") || raw.startsWith("wss://")));
77
+ }
78
+
79
+ function getRequestUrl(input) {
80
+ if (typeof input === "string") return input;
81
+ if (input instanceof URL) return input.toString();
82
+ if (input && typeof input.url === "string") return input.url;
83
+ return "";
84
+ }
85
+
86
+ async function decodeWebSocketData(data) {
87
+ if (typeof data === "string") return data;
88
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
89
+ if (ArrayBuffer.isView(data)) return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
90
+ if (data && typeof data.arrayBuffer === "function") return new TextDecoder().decode(new Uint8Array(await data.arrayBuffer()));
91
+ return null;
92
+ }
93
+
94
+ function copyReadyStateConstants(Target, OriginalWebSocket) {
95
+ for (const key of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]) {
96
+ const value = OriginalWebSocket[key];
97
+ if (typeof value === "number") Object.defineProperty(Target, key, { value, enumerable: true });
98
+ }
99
+ }
@@ -6,11 +6,11 @@ import { createWebSessionManager, resolveWorkspace } from "./session-manager.mjs
6
6
  const DEFAULT_HOST = "127.0.0.1";
7
7
  const DEFAULT_PORT = 4174;
8
8
 
9
- export async function runWebUiCommand(args, { config, cwd, stateRoot, useRuntimeProcess = true } = {}) {
9
+ export async function runWebUiCommand(args, { config, cwd, stateRoot } = {}) {
10
10
  const host = args.host ?? DEFAULT_HOST;
11
11
  assertLoopbackHost(host);
12
12
  const port = Number.parseInt(args.port ?? "", 10) || DEFAULT_PORT;
13
- const runtime = createWebSessionManager({ args, config, launchCwd: cwd, stateRoot, useRuntimeProcess });
13
+ const runtime = createWebSessionManager({ args, config, launchCwd: cwd, stateRoot });
14
14
  const initialWorkspace = resolveInitialWorkspace(args, cwd);
15
15
  if (initialWorkspace) await runtime.createSession(initialWorkspace);
16
16
 
@@ -3,17 +3,12 @@ import { basename, join, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
4
4
  import { createMarchAuthStorage } from "../auth/storage.mjs";
5
5
  import { createRuntimeRunner } from "../cli/startup/create-runtime-runner.mjs";
6
- import { createPermissionController, MODE } from "../cli/permissions.mjs";
7
6
  import { createCliShellRuntime } from "../shell/cli-runtime.mjs";
8
7
  import { MarkdownMemoryStore } from "../memory/markdown-store.mjs";
9
- import { createMarkdownMemoryTools } from "../memory/markdown-tools.mjs";
10
8
  import { resolveMemoryRoot } from "../memory/root.mjs";
11
9
  import { defaultProfilePaths, ensureProfileFiles } from "../context/profiles.mjs";
12
10
  import { loadOrCreateProjectId } from "../cli/startup/startup-session.mjs";
13
- import { createWebToolsFromConfig } from "../web/tools.mjs";
14
11
  import { createLogger, installProcessLogHandlers } from "../debug/logger.mjs";
15
- import { createModelContextDumper } from "../debug/model-context-dumper.mjs";
16
- import { createDesktopTurnNotifier } from "../notification/desktop-notifier.mjs";
17
12
  import { discoverProjectExtensionPaths } from "../extensions/discovery.mjs";
18
13
  import { loadProjectLifecycleHookManifests } from "../extensions/lifecycle-manifest.mjs";
19
14
  import { normalizeRemoteMemorySources } from "../memory/remote/config.mjs";
@@ -22,7 +17,7 @@ import { prepareTurnInput } from "../cli/turn/turn-input-preparer.mjs";
22
17
  const MAX_WORKSPACE_DEPTH = 3;
23
18
  const MAX_WORKSPACE_ENTRIES = 200;
24
19
 
25
- export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRuntimeProcess = true } = {}) {
20
+ export async function createWebRuntimeHost({ args, config, cwd, stateRoot } = {}) {
26
21
  stateRoot ??= join(homedir(), ".march");
27
22
  if (!existsSync(stateRoot)) mkdirSync(stateRoot, { recursive: true });
28
23
  const logger = createLogger({ logDir: join(stateRoot, "logs") });
@@ -40,14 +35,10 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
40
35
 
41
36
  const memoryStore = new MarkdownMemoryStore({ root: memoryRoot });
42
37
  const remoteMemorySources = normalizeRemoteMemorySources(config);
43
- const memoryTools = createMarkdownMemoryTools(memoryStore, { remoteSources: remoteMemorySources });
44
38
  const shellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
45
39
  const extensionPaths = discoverProjectExtensionPaths(cwd);
46
40
  const lifecycleManifests = loadProjectLifecycleHookManifests(cwd);
47
41
  const contextDumpRoot = resolve(projectMarchDir, "context-dumps", Date.now().toString(36));
48
- const modelContextDumper = createModelContextDumper({ enabled: args.dumpContext, rootDir: contextDumpRoot });
49
- const permissionController = createPermissionController({ mode: args.permissionMode ?? MODE.BYPASS });
50
- const turnNotifier = createDesktopTurnNotifier({ enabled: Boolean(config.notifications?.turnEnd), config: config.notifications });
51
42
  const ui = createHeadlessWebUi();
52
43
  const currentProject = basename(cwd);
53
44
  const namespace = loadOrCreateProjectId(projectMarchDir);
@@ -72,20 +63,9 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
72
63
  remoteMemorySources,
73
64
  };
74
65
  const runner = await createRuntimeRunner({
75
- useRuntimeProcess,
76
66
  runnerOptions,
77
67
  ui,
78
- memoryStore,
79
- memoryTools,
80
68
  shellRuntime,
81
- webTools: createWebToolsFromConfig(config),
82
- usePiSessions: true,
83
- usePiRuntimeHost: true,
84
- authStorage: authConfig.authStorage,
85
- permissionController,
86
- modelContextDumper,
87
- turnNotifier,
88
- logger,
89
69
  });
90
70
  let turnRunning = false;
91
71
 
@@ -96,12 +76,14 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
96
76
  currentProject,
97
77
  snapshot: () => createWebSnapshot({ cwd, runner, currentProject }),
98
78
  subscribe: (listener) => runner.runtimeUiEvents.on(listener),
79
+ refreshProviderQuota: () => runner.getProviderQuotaSnapshot?.({ emit: true }) ?? null,
99
80
  async runTurn(prompt) {
100
81
  if (turnRunning) throw new Error("A turn is already running");
101
82
  turnRunning = true;
102
- const input = prepareTurnInput({ prompt, runner, memoryStore, currentProject });
103
- runner.runtimeUiEvents.emit({ type: "web_user_message", text: input.userMessage });
83
+ memoryStore.beginTurn();
104
84
  try {
85
+ const input = prepareTurnInput({ prompt, runner, memoryStore, currentProject });
86
+ runner.runtimeUiEvents.emit({ type: "web_user_message", text: input.userMessage });
105
87
  return await runner.runTurn(input.fullPrompt, input.userMessage, input.runOptions);
106
88
  } finally {
107
89
  turnRunning = false;
@@ -123,6 +105,7 @@ export function createHeadlessWebUi() {
123
105
  thinkingBlock: () => {}, toggleLastThinking: () => false,
124
106
  toolStart: () => {}, toolEnd: () => {}, textDelta: () => {},
125
107
  assistantReplyEnd: () => {}, status: () => {}, recall: () => {},
108
+ providerQuotaSnapshot: () => {},
126
109
  clearOutput: () => {}, restoreTranscript: () => {}, setStatusBar: () => {},
127
110
  turnStart: () => {}, turnEnd: () => {}, retryStart: () => {}, retryEnd: () => {},
128
111
  editDiff: () => {}, requestPermission: async () => true,
@@ -138,6 +121,7 @@ export function createWebSnapshot({ cwd, runner, currentProject = basename(cwd)
138
121
  return {
139
122
  workspace: readWorkspaceTree(cwd),
140
123
  timeline: { title: currentProject, meta: runtimeMeta(model), events: [] },
124
+ providerQuota: runner.getCachedProviderQuotaSnapshot?.() ?? null,
141
125
  sessions: [{ id: "current", title: runner.engine?.sessionName ?? currentProject, time: "now", active: true }],
142
126
  activity: [{ id: "runtime", action: "runner connected", time: "now" }],
143
127
  composer: { mode: "Chat", placeholder: "Message March…" },
@@ -34,6 +34,7 @@ export async function handleApiRequest(req, res, runtime) {
34
34
  if (req.method === "POST" && pathname === "/api/sessions") return sendJson(res, await createRuntimeSession(req, runtime));
35
35
  if (req.method === "GET" && pathname === "/api/fs/roots") return sendJson(res, { roots: runtime.fsRoots() });
36
36
  if (req.method === "GET" && pathname === "/api/fs/list") return sendJson(res, { entries: runtime.fsList(getPathParam(req)) });
37
+ if (req.method === "GET" && pathname === "/api/provider-quota") return sendJson(res, { snapshot: await runtime.refreshProviderQuota(getSessionId(req)) });
37
38
  if (req.method === "POST" && pathname === "/api/turn") return sendJson(res, await runRuntimeTurn(req, runtime));
38
39
  if (req.method === "POST" && pathname === "/api/abort") return sendJson(res, { ok: true, result: runtime.abort(getSessionId(req)) });
39
40
  sendJson(res, { error: "Not found" }, 404);
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { createWebRuntimeHost } from "./runtime-host.mjs";
5
5
 
6
- export function createWebSessionManager({ args, config, launchCwd, stateRoot, useRuntimeProcess = true } = {}) {
6
+ export function createWebSessionManager({ args, config, launchCwd, stateRoot } = {}) {
7
7
  const sessions = new Map();
8
8
  const activities = [];
9
9
  let activeSessionId = null;
@@ -12,7 +12,7 @@ export function createWebSessionManager({ args, config, launchCwd, stateRoot, us
12
12
  async function createSession(workspacePath) {
13
13
  const workspace = resolveWorkspace(workspacePath, launchCwd);
14
14
  const id = `session-${Date.now().toString(36)}-${nextSessionNumber++}`;
15
- const runtime = await createWebRuntimeHost({ args, config, cwd: workspace, stateRoot, useRuntimeProcess });
15
+ const runtime = await createWebRuntimeHost({ args, config, cwd: workspace, stateRoot });
16
16
  const session = { id, workspace, title: basename(workspace) || workspace, runtime, createdAt: Date.now() };
17
17
  sessions.set(id, session);
18
18
  activeSessionId = id;
@@ -33,6 +33,7 @@ export function createWebSessionManager({ args, config, launchCwd, stateRoot, us
33
33
  },
34
34
  subscribe(sessionId, listener) { return getSession(sessions, sessionId).runtime.subscribe(listener); },
35
35
  runTurn(sessionId, prompt) { return getSession(sessions, sessionId).runtime.runTurn(prompt); },
36
+ refreshProviderQuota(sessionId) { return getSession(sessions, sessionId).runtime.refreshProviderQuota?.() ?? null; },
36
37
  abort(sessionId) { return getSession(sessions, sessionId).runtime.abort?.(); },
37
38
  fsRoots() { return listFsRoots(launchCwd); },
38
39
  fsList(path) { return listFsDirectory(path); },
@@ -70,6 +71,7 @@ function createEmptySnapshot({ sessions, activeSessionId, activities }) {
70
71
  workspace: { id: "no-workspace", name: "Choose workspace", kind: "folder", selected: true },
71
72
  timeline: { title: "No session selected", meta: "Create a session and bind a workspace", events: [] },
72
73
  sessions: Array.from(sessions.values()).map((session) => toSessionSummary(session, session.id === activeSessionId)),
74
+ providerQuota: null,
73
75
  activity: activities,
74
76
  activeSessionId: null,
75
77
  composer: { mode: "No session", placeholder: "Choose a workspace to start…" },
@@ -28,6 +28,7 @@ export function AppShell({ runtime }: AppShellProps) {
28
28
  activity={model.activity}
29
29
  fsEntries={runtime.fsEntries}
30
30
  fsPath={runtime.fsPath}
31
+ providerQuota={model.providerQuota}
31
32
  running={runtime.running}
32
33
  onOpenSession={runtime.openSession}
33
34
  onCreateSession={runtime.createSession}
@@ -1,5 +1,5 @@
1
1
  import { useState } from "react";
2
- import type { ActivityEvent, SessionSummary } from "../model";
2
+ import type { ActivityEvent, ProviderQuotaSnapshot, SessionSummary } from "../model";
3
3
  import type { FsEntry } from "../runtime/client";
4
4
 
5
5
  type RightSidebarProps = {
@@ -7,6 +7,7 @@ type RightSidebarProps = {
7
7
  activity: ActivityEvent[];
8
8
  fsEntries: FsEntry[];
9
9
  fsPath: string | null;
10
+ providerQuota?: ProviderQuotaSnapshot | null;
10
11
  running: boolean;
11
12
  onOpenSession: (sessionId: string) => Promise<void>;
12
13
  onCreateSession: (workspacePath: string) => Promise<void>;
@@ -14,7 +15,9 @@ type RightSidebarProps = {
14
15
  onBrowsePath: (path: string) => Promise<void>;
15
16
  };
16
17
 
17
- export function RightSidebar({ sessions, activity, fsEntries, fsPath, running, onOpenSession, onCreateSession, onBrowseRoots, onBrowsePath }: RightSidebarProps) {
18
+ export function RightSidebar(props: RightSidebarProps) {
19
+ const { sessions, activity, fsEntries, fsPath, providerQuota, running } = props;
20
+ const { onOpenSession, onCreateSession, onBrowseRoots, onBrowsePath } = props;
18
21
  const [workspacePath, setWorkspacePath] = useState("");
19
22
  const canCreate = workspacePath.trim().length > 0 && !running;
20
23
 
@@ -50,6 +53,8 @@ export function RightSidebar({ sessions, activity, fsEntries, fsPath, running, o
50
53
  ))}
51
54
  </div>
52
55
 
56
+ {providerQuota ? <ProviderQuotaCard quota={providerQuota} /> : null}
57
+
53
58
  <div className="right-divider">Sessions</div>
54
59
  {sessions.map((session) => (
55
60
  <button key={session.id} className={session.active ? "session-row active" : "session-row"} type="button" onClick={() => onOpenSession(session.id)}>
@@ -68,3 +73,43 @@ export function RightSidebar({ sessions, activity, fsEntries, fsPath, running, o
68
73
  </aside>
69
74
  );
70
75
  }
76
+
77
+ function ProviderQuotaCard({ quota }: { quota: ProviderQuotaSnapshot }) {
78
+ return (
79
+ <div className="provider-quota" aria-label="Provider quota">
80
+ <div className="provider-quota-header">
81
+ <span>{quota.label}</span>
82
+ <time>{quota.providerId}</time>
83
+ </div>
84
+ {quota.limits.flatMap((limit) => limit.windows.map((window) => {
85
+ const left = Math.round(window.remainingPercent);
86
+ return (
87
+ <div key={`${limit.id}:${window.id}`} className="quota-row">
88
+ <div className="quota-row-main">
89
+ <span>{formatQuotaLabel(window.label)}</span>
90
+ <strong>{left}% left</strong>
91
+ </div>
92
+ <div className="quota-bar" aria-label={`${left}% quota left`}>
93
+ <span style={{ width: `${Math.max(0, Math.min(100, left))}%` }} />
94
+ </div>
95
+ <em>{formatReset(window.resetsAt)}</em>
96
+ </div>
97
+ );
98
+ }))}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ function formatQuotaLabel(label: string) {
104
+ return label === "weekly" ? "Weekly limit:" : `${label} limit:`;
105
+ }
106
+
107
+ function formatReset(resetsAt?: string | null) {
108
+ if (!resetsAt) return "reset unknown";
109
+ const date = new Date(resetsAt);
110
+ if (Number.isNaN(date.getTime())) return "reset unknown";
111
+ const hours = String(date.getHours()).padStart(2, "0");
112
+ const minutes = String(date.getMinutes()).padStart(2, "0");
113
+ const month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][date.getMonth()];
114
+ return `resets ${hours}:${minutes} on ${date.getDate()} ${month}`;
115
+ }