@estebanforge/pi-antigravity-bridge 1.7.3 → 1.7.5

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.
@@ -0,0 +1,245 @@
1
+ // In-memory subagent roster, folded from driver activities.
2
+ //
3
+ // agy has no read-only subcommand for live subagent state, so the only
4
+ // zero-token source is the turn stream: subagent spawns arrive as ordinary
5
+ // tool steps (invoke_subagent, run_subagent, define_subagent,
6
+ // browser_subagent), send_message addresses a spawned agent, and
7
+ // manage_subagents carries lifecycle actions. The roster folds
8
+ // DriverActivity — the engine-agnostic contract both turn engines emit — so
9
+ // stream-json (stepId-keyed) and ACP (toolCallId-keyed) feed one roster.
10
+ //
11
+ // Accepted heuristic limits (telemetry only, never persisted — agy's own
12
+ // conversation database remains the durable record):
13
+ // - send_message routes to the FIRST entry with the matching agent name
14
+ // regardless of status, so a message after a name is reused may credit a
15
+ // stale entry.
16
+ // - manage_subagents kill marks ALL running entries killed; the target arg
17
+ // is ignored because its shape is not stable across agy builds.
18
+ // - Parallel spawns with the same agent name are separate entries, but
19
+ // name-based routing cannot tell them apart.
20
+
21
+ import type { DriverActivity } from "./driver-types.js";
22
+
23
+ /** Tools whose start opens a roster entry and whose done/error closes it. */
24
+ const SPAWN_TOOLS = new Set(["invoke_subagent", "run_subagent", "browser_subagent"]);
25
+ /** Tools addressed at an already-spawned agent. */
26
+ const MESSAGE_TOOLS = new Set(["send_message"]);
27
+ /** Tools carrying lifecycle actions (kill/stop/...). */
28
+ const MANAGE_TOOLS = new Set(["manage_subagents"]);
29
+
30
+ /** Arg keys that plausibly name the target agent (their casing varies). */
31
+ const NAME_ARG_KEYS = /^(name|subagent|subagent_name|agent|agent_name|type|recipient|role|to)$/i;
32
+ /** Arg keys plausibly holding a manage_subagents lifecycle action. */
33
+ const ACTION_ARG_KEYS = /^(action|command|operation|op)$/i;
34
+ const DETAIL_MAX = 120;
35
+
36
+ export interface SubagentEntry {
37
+ /** Stable fold key: `s:<stepId>` on stream-json, `n:<seq>` on ACP. */
38
+ key: string;
39
+ name: string;
40
+ status: "running" | "done" | "error" | "killed";
41
+ spawnedAtMs: number;
42
+ lastActivityMs: number;
43
+ /** send_message count since spawn. */
44
+ messages: number;
45
+ /** First non-name string arg, truncated — usually the task text. */
46
+ detail: string;
47
+ error?: string;
48
+ }
49
+
50
+ function truncate(value: string): string {
51
+ return value.length > DETAIL_MAX ? `${value.slice(0, DETAIL_MAX)}…` : value;
52
+ }
53
+
54
+ /** First string arg whose key plausibly names the agent. */
55
+ function subagentNameFromArgs(args: Record<string, unknown>): string {
56
+ for (const [k, v] of Object.entries(args)) {
57
+ if (NAME_ARG_KEYS.test(k) && typeof v === "string" && v.trim()) return truncate(v.trim());
58
+ }
59
+ return "subagent";
60
+ }
61
+
62
+ /** First string arg that is not the name — the task text, when agy passes one. */
63
+ function detailFromArgs(args: Record<string, unknown>): string {
64
+ for (const [k, v] of Object.entries(args)) {
65
+ if (!NAME_ARG_KEYS.test(k) && typeof v === "string" && v.trim()) return truncate(v.trim());
66
+ }
67
+ return "";
68
+ }
69
+
70
+ interface ExtractedSpawn {
71
+ name: string;
72
+ detail: string;
73
+ }
74
+
75
+ function extractSpawnsFromArgs(args: Record<string, unknown>): ExtractedSpawn[] {
76
+ const raw = args.Subagents ?? args.subagents;
77
+ if (Array.isArray(raw) && raw.length > 0) {
78
+ const out: ExtractedSpawn[] = [];
79
+ for (const item of raw) {
80
+ if (item && typeof item === "object") {
81
+ const rec = item as Record<string, unknown>;
82
+ const name =
83
+ (typeof rec.Role === "string" && rec.Role.trim()) ||
84
+ (typeof rec.role === "string" && rec.role.trim()) ||
85
+ (typeof rec.TypeName === "string" && rec.TypeName.trim()) ||
86
+ (typeof rec.type_name === "string" && rec.type_name.trim()) ||
87
+ (typeof rec.name === "string" && rec.name.trim()) ||
88
+ "subagent";
89
+ const detail =
90
+ (typeof rec.Prompt === "string" && rec.Prompt.trim()) ||
91
+ (typeof rec.prompt === "string" && rec.prompt.trim()) ||
92
+ (typeof rec.task === "string" && rec.task.trim()) ||
93
+ "";
94
+ out.push({ name: truncate(name), detail: truncate(detail) });
95
+ }
96
+ }
97
+ if (out.length > 0) return out;
98
+ }
99
+ return [{ name: subagentNameFromArgs(args), detail: detailFromArgs(args) }];
100
+ }
101
+
102
+ function manageActionIsKill(args: Record<string, unknown>): boolean {
103
+ for (const [k, v] of Object.entries(args)) {
104
+ if (ACTION_ARG_KEYS.test(k) && typeof v === "string" && /kill|stop|terminate|cancel/i.test(v))
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ export class SubagentRoster {
111
+ #entries = new Map<string, SubagentEntry>();
112
+ #acpSeq = 0;
113
+
114
+ /** Fold key precedence: ACP toolCallId (stable protocol id), stream-json
115
+ * numeric stepId, then a process-local sequence for hypothetical engines
116
+ * with neither. */
117
+ #keyFor(activity: Extract<DriverActivity, { type: "tool_start" }>): string {
118
+ if (activity.toolCallId !== undefined) return `t:${activity.toolCallId}`;
119
+ if (activity.stepId !== undefined) return `s:${activity.stepId}`;
120
+ return `n:${this.#acpSeq++}`;
121
+ }
122
+
123
+ #entriesFor(activity: Extract<DriverActivity, { type: "tool_done" | "tool_error" }>): SubagentEntry[] {
124
+ const baseKey =
125
+ activity.toolCallId !== undefined
126
+ ? `t:${activity.toolCallId}`
127
+ : activity.stepId !== undefined
128
+ ? `s:${activity.stepId}`
129
+ : undefined;
130
+ if (baseKey !== undefined) {
131
+ const matched: SubagentEntry[] = [];
132
+ for (const [k, v] of this.#entries) {
133
+ if (k === baseKey || k.startsWith(`${baseKey}:`)) matched.push(v);
134
+ }
135
+ return matched;
136
+ }
137
+ for (const entry of this.#entries.values()) {
138
+ if (entry.status === "running") return [entry];
139
+ }
140
+ return [];
141
+ }
142
+
143
+ /** Fold one driver activity. Non-tool activities are ignored; unknown
144
+ * tool names are other features' business. Never throws: roster
145
+ * bookkeeping must not be able to fail a turn. */
146
+ fold(activity: DriverActivity): void {
147
+ try {
148
+ this.#fold(activity);
149
+ } catch {
150
+ /* roster is best-effort telemetry */
151
+ }
152
+ }
153
+
154
+ #fold(activity: DriverActivity): void {
155
+ if (activity.type === "tool_start") {
156
+ if (SPAWN_TOOLS.has(activity.name)) {
157
+ const baseKey = this.#keyFor(activity);
158
+ const spawns = extractSpawnsFromArgs(activity.args);
159
+ const now = Date.now();
160
+ for (let i = 0; i < spawns.length; i++) {
161
+ const key = spawns.length === 1 ? baseKey : `${baseKey}:${i}`;
162
+ this.#entries.set(key, {
163
+ key,
164
+ name: spawns[i]!.name,
165
+ status: "running",
166
+ spawnedAtMs: now,
167
+ lastActivityMs: now,
168
+ messages: 0,
169
+ detail: spawns[i]!.detail,
170
+ });
171
+ }
172
+ return;
173
+ }
174
+ if (MESSAGE_TOOLS.has(activity.name)) {
175
+ const entry = this.#entryByName(subagentNameFromArgs(activity.args));
176
+ if (entry) {
177
+ entry.messages += 1;
178
+ entry.lastActivityMs = Date.now();
179
+ }
180
+ return;
181
+ }
182
+ if (MANAGE_TOOLS.has(activity.name) && manageActionIsKill(activity.args)) {
183
+ const now = Date.now();
184
+ for (const entry of this.#entries.values()) {
185
+ if (entry.status === "running") {
186
+ entry.status = "killed";
187
+ entry.lastActivityMs = now;
188
+ }
189
+ }
190
+ }
191
+ return;
192
+ }
193
+ if (activity.type === "tool_done" || activity.type === "tool_error") {
194
+ const entries = this.#entriesFor(activity);
195
+ if (entries.length === 0) return;
196
+ const now = Date.now();
197
+ for (const entry of entries) {
198
+ entry.lastActivityMs = now;
199
+ if (activity.type === "tool_error") {
200
+ entry.status = "error";
201
+ entry.error = truncate(activity.message);
202
+ } else if (entry.status === "running") {
203
+ entry.status = "done";
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ #entryByName(name: string, ...statuses: SubagentEntry["status"][]): SubagentEntry | undefined {
210
+ for (const entry of this.#entries.values()) {
211
+ if (entry.name === name && (statuses.length === 0 || statuses.includes(entry.status)))
212
+ return entry;
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ /** Entries in spawn order (Map preserves insertion). */
218
+ snapshot(): SubagentEntry[] {
219
+ return [...this.#entries.values()];
220
+ }
221
+
222
+ /** Live wire for status surfaces. */
223
+ runningCount(): number {
224
+ let n = 0;
225
+ for (const entry of this.#entries.values()) if (entry.status === "running") n += 1;
226
+ return n;
227
+ }
228
+ }
229
+
230
+ export function formatSubagentRoster(entries: SubagentEntry[]): string {
231
+ const running = entries.filter((e) => e.status === "running").length;
232
+ const lines = [`antigravity subagents: ${entries.length} tracked, ${running} running`];
233
+ for (const entry of entries) {
234
+ const secs = Math.max(0, Math.round((Date.now() - entry.spawnedAtMs) / 1000));
235
+ const dur = secs >= 60 ? `${Math.floor(secs / 60)}m${String(secs % 60).padStart(2, "0")}s` : `${secs}s`;
236
+ const parts = [
237
+ `${entry.name} · ${entry.status} · ${dur}`,
238
+ entry.messages > 0 ? `${entry.messages} msg` : undefined,
239
+ entry.detail || undefined,
240
+ entry.error ? `error: ${entry.error}` : undefined,
241
+ ].filter((p): p is string => p !== undefined);
242
+ lines.push(`- ${parts.join(" · ")}`);
243
+ }
244
+ return lines.join("\n");
245
+ }
@@ -0,0 +1,88 @@
1
+ // /agy tasks overlay: SelectList over one conversation's background tasks.
2
+ //
3
+ // UI mirrors the artifacts browser (engine-picker pattern). Watch-only:
4
+ // enter closes (the list IS the dashboard), r rescans, esc closes. Tails
5
+ // live in the command form: /agy tasks tail <id> — a log tail is chat
6
+ // content, not overlay content.
7
+
8
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
9
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
10
+ import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
11
+ import type { AgyTask } from "./tasks.js";
12
+
13
+ export type TasksBrowserAction = { type: "rescan" } | { type: "close" };
14
+
15
+ export function taskItems(tasks: AgyTask[]): SelectItem[] {
16
+ return tasks.map((t) => {
17
+ const when = new Date(t.modifiedMs);
18
+ const stamp = `${String(when.getHours()).padStart(2, "0")}:${String(when.getMinutes()).padStart(2, "0")}`;
19
+ const state = t.livenessKnown ? (t.active ? "ACTIVE" : "idle") : "liveness?";
20
+ return {
21
+ value: String(t.id),
22
+ label: `#${t.id} · ${state}`,
23
+ description: `${humanBytes(t.bytes)} · last write ${stamp}`,
24
+ };
25
+ });
26
+ }
27
+
28
+ function humanBytes(n: number): string {
29
+ if (n < 1024) return `${n}B`;
30
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
31
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
32
+ }
33
+
34
+ /** Render the overlay. Resolves rescan (caller re-lists and reopens) or
35
+ * close. */
36
+ export async function showTasksBrowser(
37
+ ctx: ExtensionUIContext,
38
+ tasks: AgyTask[],
39
+ livenessKnown: boolean,
40
+ ): Promise<TasksBrowserAction> {
41
+ const items = taskItems(tasks);
42
+ return ctx.custom<TasksBrowserAction>((tui, theme, _keybindings, done) => {
43
+ const container = new Container();
44
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
45
+ container.addChild(
46
+ new Text(theme.fg("accent", theme.bold(`Antigravity tasks (${tasks.length})`)), 1, 0),
47
+ );
48
+ if (!livenessKnown) {
49
+ container.addChild(
50
+ new Text(theme.fg("warning", "lsof not found; liveness unknown (install lsof for ACTIVE/idle)"), 1, 0),
51
+ );
52
+ }
53
+ container.addChild(new Spacer(1));
54
+
55
+ const list = new SelectList(items, Math.min(items.length, 12), {
56
+ selectedPrefix: (t: string) => theme.fg("accent", t),
57
+ selectedText: (t: string) => theme.fg("accent", t),
58
+ description: (t: string) => theme.fg("muted", t),
59
+ scrollInfo: (t: string) => theme.fg("dim", t),
60
+ noMatch: (t: string) => theme.fg("warning", t),
61
+ });
62
+ list.onSelect = () => done({ type: "close" });
63
+ list.onCancel = () => done({ type: "close" });
64
+ container.addChild(list);
65
+
66
+ container.addChild(new Spacer(1));
67
+ container.addChild(
68
+ new Text(theme.fg("dim", "↑↓ navigate · r rescan · esc close · /agy tasks tail <id> for a log"), 1, 0),
69
+ );
70
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
71
+
72
+ return {
73
+ render: (width: number) => container.render(width),
74
+ invalidate: () => container.invalidate(),
75
+ handleInput: (data: string) => {
76
+ if (data.trim().toLowerCase() === "r") {
77
+ done({ type: "rescan" });
78
+ return;
79
+ }
80
+ list.handleInput(data);
81
+ tui.requestRender();
82
+ },
83
+ };
84
+ }, {
85
+ overlay: true,
86
+ overlayOptions: { anchor: "center" as const, width: 72, maxHeight: "85%" as const, margin: 1 },
87
+ });
88
+ }
package/src/tasks.ts ADDED
@@ -0,0 +1,174 @@
1
+ // Read-only task dashboard over one agy conversation's background tasks
2
+ // (.system_generated/tasks/task-<N>.log under the brain dir).
3
+ //
4
+ // WATCH-ONLY by design. The stream RPC never reports agy's background
5
+ // tasks, so visibility comes from this filesystem scan; liveness comes
6
+ // from one batched lsof call over every log path (per-file lsof would
7
+ // fork-storm). Ownership is deliberately NOT resolved: modern agy pipes
8
+ // task output through itself, so the process holding a log open proves
9
+ // nothing about which spawned job it is, and we never kill what we cannot
10
+ // prove we own. A task is "active" while any process holds its log open
11
+ // (a reader counts too — cat/less/tail -f will light it up; the dashboard
12
+ // is a watch, not a fact), "idle" otherwise; when lsof is missing,
13
+ // liveness is unknown and the dashboard says so instead of guessing.
14
+
15
+ import { readdir, stat } from "node:fs/promises";
16
+ import { spawn } from "node:child_process";
17
+ import path from "node:path";
18
+ import { readTranscriptTail } from "./parked-turn.js";
19
+
20
+ const TASK_LOG_RE = /^task-(\d+)\.log$/;
21
+ const MAX_TASKS = 128;
22
+ const LSOFT_TIMEOUT_MS = 5_000;
23
+ const LSOFT_CAP_BYTES = 1024 * 1024;
24
+ export const TASK_TAIL_BYTES = 2 * 1024;
25
+
26
+ export interface AgyTask {
27
+ id: number;
28
+ logPath: string;
29
+ bytes: number;
30
+ modifiedMs: number;
31
+ /** Any process currently holds the log open. */
32
+ active: boolean;
33
+ /** false when lsof is unavailable (liveness could not be checked). */
34
+ livenessKnown: boolean;
35
+ }
36
+
37
+ /** Scan task logs under one conversation dir. Missing dirs return []
38
+ * (tasks are created on demand). Newest activity first, capped. */
39
+ export async function scanAgyTasks(conversationDir: string): Promise<AgyTask[]> {
40
+ const dir = path.join(conversationDir, ".system_generated", "tasks");
41
+ let entries;
42
+ try {
43
+ entries = await readdir(dir, { withFileTypes: true });
44
+ } catch {
45
+ return [];
46
+ }
47
+ const tasks: AgyTask[] = [];
48
+ for (const entry of entries) {
49
+ if (!entry.isFile()) continue;
50
+ const match = TASK_LOG_RE.exec(entry.name);
51
+ if (!match) continue;
52
+ const logPath = path.join(dir, entry.name);
53
+ try {
54
+ const stats = await stat(logPath);
55
+ tasks.push({
56
+ id: Number(match[1]),
57
+ logPath,
58
+ bytes: stats.size,
59
+ modifiedMs: stats.mtimeMs,
60
+ active: false,
61
+ livenessKnown: false,
62
+ });
63
+ } catch {
64
+ continue; // vanished mid-scan
65
+ }
66
+ }
67
+ tasks.sort((a, b) => b.modifiedMs - a.modifiedMs || b.id - a.id);
68
+ return tasks.slice(0, MAX_TASKS);
69
+ }
70
+
71
+ /** Parse `lsof -nP -Fpn` output into path -> pids holding it open.
72
+ * Format: `p<PID>` starts a process block; `n<PATH>` marks a file of the
73
+ * current process. Pure; exported for tests.
74
+ * Known gap: a log unlinked while held open gets an "n<path> (deleted)"
75
+ * line whose key never equals the scanned path, so that holder is
76
+ * invisible and the task reads idle. Left as-is: stripping the suffix
77
+ * would attribute a rotation survivor to the REPLACEMENT file at the
78
+ * same path, which is a worse lie. */
79
+ export function parseLsofOwners(output: string): Map<string, Set<number>> {
80
+ const owners = new Map<string, Set<number>>();
81
+ let pid: number | undefined;
82
+ for (const line of output.split("\n")) {
83
+ if (line.startsWith("p")) {
84
+ const parsed = Number(line.slice(1));
85
+ pid = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
86
+ continue;
87
+ }
88
+ if (!line.startsWith("n")) continue;
89
+ if (pid === undefined) continue;
90
+ const filePath = line.slice(1);
91
+ if (!filePath) continue;
92
+ let set = owners.get(filePath);
93
+ if (!set) owners.set(filePath, (set = new Set()));
94
+ set.add(pid);
95
+ }
96
+ return owners;
97
+ }
98
+
99
+ type SpawnRaw = (cmd: string, args: string[], timeoutMs: number, capBytes: number) => Promise<string>;
100
+
101
+ /** One batched lsof over every task log. Resolves undefined when lsof is
102
+ * missing or failed (spawn error / timeout); resolves "" when lsof ran
103
+ * and nothing is open (its normal exit-1-empty answer). */
104
+ async function lsofOwners(
105
+ logPaths: string[],
106
+ spawnRaw: SpawnRaw = defaultSpawnRaw,
107
+ ): Promise<string | undefined> {
108
+ if (logPaths.length === 0) return "";
109
+ try {
110
+ return await spawnRaw("lsof", ["-nP", "-Fpn", ...logPaths], LSOFT_TIMEOUT_MS, LSOFT_CAP_BYTES);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
116
+ async function defaultSpawnRaw(
117
+ cmd: string,
118
+ args: string[],
119
+ timeoutMs: number,
120
+ capBytes: number,
121
+ ): Promise<string> {
122
+ return new Promise<string>((resolve, reject) => {
123
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
124
+ let out = "";
125
+ let settled = false;
126
+ const done = (fn: () => void) => {
127
+ if (settled) return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ fn();
131
+ };
132
+ const timer = setTimeout(() => {
133
+ child.kill("SIGKILL");
134
+ done(() => reject(new Error(`${cmd} timed out`)));
135
+ }, timeoutMs);
136
+ child.stdout.on("data", (d: Buffer) => {
137
+ if (out.length + d.length > capBytes) {
138
+ try {
139
+ child.kill("SIGKILL");
140
+ } catch {
141
+ /* already gone */
142
+ }
143
+ done(() => reject(new Error(`${cmd} output cap exceeded`)));
144
+ return;
145
+ }
146
+ out += d.toString("utf8");
147
+ });
148
+ child.on("error", (err) => done(() => reject(err)));
149
+ child.on("close", () => done(() => resolve(out)));
150
+ });
151
+ }
152
+
153
+ /** Scan plus liveness in one call. Never throws; a missing or failed lsof
154
+ * leaves livenessKnown false rather than guessing. */
155
+ export async function listAgyTasks(
156
+ conversationDir: string,
157
+ spawnRaw?: SpawnRaw,
158
+ ): Promise<AgyTask[]> {
159
+ const tasks = await scanAgyTasks(conversationDir);
160
+ if (tasks.length === 0) return tasks;
161
+ const rawOwners = await lsofOwners(tasks.map((t) => t.logPath), spawnRaw);
162
+ const owners = rawOwners === undefined ? undefined : parseLsofOwners(rawOwners);
163
+ for (const task of tasks) {
164
+ task.livenessKnown = owners !== undefined;
165
+ task.active = (owners?.get(task.logPath)?.size ?? 0) > 0;
166
+ }
167
+ return tasks;
168
+ }
169
+
170
+ /** Last bytes of a task log for the tail command. Empty string on any
171
+ * error — tails are best-effort views, not facts. */
172
+ export async function tailAgyTaskLog(logPath: string): Promise<string> {
173
+ return readTranscriptTail(logPath, TASK_TAIL_BYTES);
174
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,174 @@
1
+ // Subscription quota view: `agy --print /usage --output-format json`.
2
+ //
3
+ // agy has no usage subcommand; the /usage slash command inside print mode
4
+ // returns the same structured payload the agy TUI panel shows and burns
5
+ // zero tokens (live-verified agy 1.2.10: status SUCCESS, usage all zeros,
6
+ // command.data.groups[] with window "weekly" | "5h" buckets carrying
7
+ // remaining_fraction and reset_time). Print mode answers in ~10s on this
8
+ // machine, so the spawn budget here is its own 30s, not the 8s discovery
9
+ // watchdog shared by models/agents.
10
+ //
11
+ // The quota is account-level: it reads the agy CLI's Google identity, so it
12
+ // is equally true for stream-json turns and ACP-server turns (same account)
13
+ // — but a pure-ACP install without the CLI cannot fetch it; the command
14
+ // layer fails closed with a pointer instead of a raw error.
15
+
16
+ import { spawnAgyRaw } from "./models.js";
17
+
18
+ export const USAGE_TIMEOUT_MS = 30_000;
19
+ const USAGE_CAP_BYTES = 1024 * 1024;
20
+
21
+ export interface AgyQuotaBucket {
22
+ id?: string;
23
+ name: string;
24
+ /** agy's window tag: "weekly" | "5h" (tolerant of future tags). */
25
+ window: string;
26
+ remainingFraction: number;
27
+ resetTime?: string;
28
+ }
29
+
30
+ export interface AgyQuotaGroup {
31
+ name: string;
32
+ description?: string;
33
+ buckets: AgyQuotaBucket[];
34
+ }
35
+
36
+ export interface AgyQuotaReport {
37
+ groups: AgyQuotaGroup[];
38
+ }
39
+
40
+ type Json = Record<string, unknown>;
41
+
42
+ const asObject = (v: unknown): Json | undefined => (v !== null && typeof v === "object" ? (v as Json) : undefined);
43
+ const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
44
+ const asNumber = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
45
+ const firstString = (o: Json, keys: string[]): string | undefined => {
46
+ for (const k of keys) {
47
+ const v = asString(o[k]);
48
+ if (v !== undefined) return v;
49
+ }
50
+ return undefined;
51
+ };
52
+
53
+ function parseBucket(v: unknown): AgyQuotaBucket | undefined {
54
+ const o = asObject(v);
55
+ if (!o) return undefined;
56
+ const remaining = asNumber(o.remaining_fraction) ?? asNumber(o.remainingFraction) ?? asNumber(o.remaining);
57
+ const name = firstString(o, ["name", "id"]);
58
+ if (remaining === undefined || name === undefined) return undefined;
59
+ return {
60
+ id: asString(o.id),
61
+ name,
62
+ window: firstString(o, ["window"]) ?? "unknown",
63
+ remainingFraction: Math.max(0, Math.min(1, remaining)),
64
+ resetTime: firstString(o, ["reset_time", "resetTime"]),
65
+ };
66
+ }
67
+
68
+ function parseGroup(v: unknown): AgyQuotaGroup | undefined {
69
+ const o = asObject(v);
70
+ if (!o) return undefined;
71
+ const name = asString(o.name);
72
+ if (name === undefined) return undefined;
73
+ const buckets = Array.isArray(o.buckets)
74
+ ? o.buckets.map(parseBucket).filter((b): b is AgyQuotaBucket => b !== undefined)
75
+ : [];
76
+ return { name, description: asString(o.description), buckets };
77
+ }
78
+
79
+ /** Parse the /usage JSON payload. Tolerant: unwraps a stream-json result
80
+ * envelope when present (print mode does not send one), accepts snake_case
81
+ * and camelCase field aliases, clamps fractions. Throws a short reason on
82
+ * any structural mismatch — callers render it, never stack it. */
83
+ export function parseAgyUsageJson(text: string): AgyQuotaReport {
84
+ let payload: Json | undefined;
85
+ try {
86
+ payload = asObject(JSON.parse(text));
87
+ } catch {
88
+ throw new Error("response was not JSON");
89
+ }
90
+ if (!payload) throw new Error("response was not an object");
91
+ // Print mode answers at the top level; if some future agy shells the
92
+ // command through its stream instead, the wrapper is the same event-keyed
93
+ // NDJSON grammar the driver already parses (see fake-agy-bin frames:
94
+ // {"event":"init",...}), so unwrap {event:"result", result:{...}}.
95
+ const inner = payload.event !== undefined ? asObject(payload.result) : payload;
96
+ if (!inner) throw new Error("result envelope carried no result object");
97
+ const status = (firstString(inner, ["status"]) ?? "").toUpperCase();
98
+ if (status !== "" && status !== "SUCCESS" && status !== "OK") {
99
+ throw new Error(`agy reported status ${status}`);
100
+ }
101
+ const command = asObject(inner.command);
102
+ if (command === undefined || command.name !== "usage") {
103
+ throw new Error("payload is not a /usage command result");
104
+ }
105
+ const data = asObject(command.data) ?? asObject(inner.data);
106
+ const rawGroups = data !== undefined && Array.isArray(data.groups) ? data.groups : undefined;
107
+ if (rawGroups === undefined) throw new Error("no quota groups in payload");
108
+ const groups = rawGroups.map(parseGroup).filter((g): g is AgyQuotaGroup => g !== undefined);
109
+ if (groups.length === 0) throw new Error("no quota groups returned");
110
+ return { groups };
111
+ }
112
+
113
+ /** Fetch quota through the CLI. Undefined on any fetch failure (missing
114
+ * binary, non-zero exit, timeout, cap) — same degrade-to-empty contract as
115
+ * the model catalog; the caller renders the difference. The spawn watchdog
116
+ * runs 5s past agy's own --print-timeout so the CLI's clean timeout-exit
117
+ * always wins the race over our SIGKILL. */
118
+ export async function fetchAgyQuota(binary: string): Promise<AgyQuotaReport | undefined> {
119
+ const raw = await spawnAgyRaw(
120
+ binary,
121
+ ["--print", "/usage", "--output-format", "json", "--print-timeout", `${Math.ceil(USAGE_TIMEOUT_MS / 1000)}s`],
122
+ USAGE_TIMEOUT_MS + 5_000,
123
+ USAGE_CAP_BYTES,
124
+ );
125
+ if (raw === "") return undefined;
126
+ try {
127
+ return parseAgyUsageJson(raw);
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ // --- rendering ---------------------------------------------------------------
134
+
135
+ /** 20-segment remaining bar. */
136
+ export function quotaBar(fraction: number): string {
137
+ const filled = Math.round(Math.max(0, Math.min(1, fraction)) * 20);
138
+ return `${"█".repeat(filled)}${"░".repeat(20 - filled)}`;
139
+ }
140
+
141
+ /** Clock-style reset label: "resets 19:53" today, else "resets 09:10 on 4 Sep". */
142
+ export function resetLabel(resetTime: string, now: Date): string {
143
+ const at = new Date(resetTime);
144
+ if (Number.isNaN(at.getTime())) return "";
145
+ const hhmm = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
146
+ const sameDay =
147
+ at.getFullYear() === now.getFullYear() &&
148
+ at.getMonth() === now.getMonth() &&
149
+ at.getDate() === now.getDate();
150
+ if (sameDay) return `resets ${hhmm}`;
151
+ const day = at.getDate();
152
+ const mon = at.toLocaleString("en", { month: "short" });
153
+ return `resets ${hhmm} on ${day} ${mon}`;
154
+ }
155
+
156
+ const WINDOW_RANK: Record<string, number> = { "5h": 0, weekly: 1 };
157
+
158
+ /** Full report text: per group, 5h bucket before weekly (the window users
159
+ * hit first), name labeled bar with percent and reset time. */
160
+ export function formatAgyQuotaReport(report: AgyQuotaReport, now: Date = new Date()): string {
161
+ const lines: string[] = ["Antigravity quota (account-level, both engines)"];
162
+ for (const group of report.groups) {
163
+ lines.push("", group.name);
164
+ const buckets = [...group.buckets].sort(
165
+ (a, b) => (WINDOW_RANK[a.window] ?? 9) - (WINDOW_RANK[b.window] ?? 9),
166
+ );
167
+ for (const bucket of buckets) {
168
+ const pct = Math.round(bucket.remainingFraction * 100);
169
+ const reset = bucket.resetTime !== undefined ? ` · ${resetLabel(bucket.resetTime, now)}` : "";
170
+ lines.push(` ${bucket.name}: ${quotaBar(bucket.remainingFraction)} ${pct}%${reset}`);
171
+ }
172
+ }
173
+ return lines.join("\n");
174
+ }