ftown 0.25.2 → 0.25.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/dist/command-rpc.d.ts +3 -0
- package/dist/command-rpc.js +41 -0
- package/dist/command-rpc.js.map +1 -1
- package/dist/ftown-cli.js +5 -2
- package/dist/ftown-cli.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/quota/agy.d.ts +16 -0
- package/dist/quota/agy.js +287 -0
- package/dist/quota/agy.js.map +1 -0
- package/dist/quota/annotate.d.ts +5 -0
- package/dist/quota/annotate.js +56 -0
- package/dist/quota/annotate.js.map +1 -0
- package/dist/quota/claude.d.ts +27 -0
- package/dist/quota/claude.js +265 -0
- package/dist/quota/claude.js.map +1 -0
- package/dist/quota/codex.d.ts +25 -0
- package/dist/quota/codex.js +378 -0
- package/dist/quota/codex.js.map +1 -0
- package/dist/quota/format.d.ts +6 -0
- package/dist/quota/format.js +113 -0
- package/dist/quota/format.js.map +1 -0
- package/dist/quota/grok.d.ts +97 -0
- package/dist/quota/grok.js +563 -0
- package/dist/quota/grok.js.map +1 -0
- package/dist/quota/models-agy.d.ts +10 -0
- package/dist/quota/models-agy.js +144 -0
- package/dist/quota/models-agy.js.map +1 -0
- package/dist/quota/models-claude.d.ts +10 -0
- package/dist/quota/models-claude.js +227 -0
- package/dist/quota/models-claude.js.map +1 -0
- package/dist/quota/models-codex.d.ts +18 -0
- package/dist/quota/models-codex.js +305 -0
- package/dist/quota/models-codex.js.map +1 -0
- package/dist/quota/models-grok.d.ts +19 -0
- package/dist/quota/models-grok.js +272 -0
- package/dist/quota/models-grok.js.map +1 -0
- package/dist/quota/models-muse.d.ts +13 -0
- package/dist/quota/models-muse.js +200 -0
- package/dist/quota/models-muse.js.map +1 -0
- package/dist/quota/models-opencode.d.ts +51 -0
- package/dist/quota/models-opencode.js +400 -0
- package/dist/quota/models-opencode.js.map +1 -0
- package/dist/quota/muse.d.ts +38 -0
- package/dist/quota/muse.js +225 -0
- package/dist/quota/muse.js.map +1 -0
- package/dist/quota/opencode.d.ts +7 -0
- package/dist/quota/opencode.js +47 -0
- package/dist/quota/opencode.js.map +1 -0
- package/dist/quota/routing-config.d.ts +18 -0
- package/dist/quota/routing-config.js +438 -0
- package/dist/quota/routing-config.js.map +1 -0
- package/dist/quota/routing-service.d.ts +24 -0
- package/dist/quota/routing-service.js +118 -0
- package/dist/quota/routing-service.js.map +1 -0
- package/dist/quota/routing.d.ts +10 -0
- package/dist/quota/routing.js +180 -0
- package/dist/quota/routing.js.map +1 -0
- package/dist/quota/types.d.ts +304 -0
- package/dist/quota/types.js +45 -0
- package/dist/quota/types.js.map +1 -0
- package/dist/quota-cli.d.ts +80 -0
- package/dist/quota-cli.js +462 -0
- package/dist/quota-cli.js.map +1 -0
- package/dist/security/bridge/permissions.js +4 -2
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/package.json +3 -2
- package/skills/ftown/SKILL.md +2 -1
- package/skills/ftown/references/orchestrator.md +5 -0
- package/skills/ftown/references/quota.md +177 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ProviderQuota, QuotaFetchOptions, QuotaFetcher } from './types.js';
|
|
2
|
+
export interface GrokRefreshContext {
|
|
3
|
+
homeDir: string;
|
|
4
|
+
/** ts of the newest billing entry before the refresh, or null when there was none. */
|
|
5
|
+
previousTs: string | null;
|
|
6
|
+
budgetMs: number;
|
|
7
|
+
}
|
|
8
|
+
export interface GrokFetchDeps {
|
|
9
|
+
refresh: (ctx: GrokRefreshContext) => Promise<void>;
|
|
10
|
+
shouldRefresh: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** The script-wrapped grok TUI as seen by the refresh. */
|
|
13
|
+
export interface GrokTui {
|
|
14
|
+
readonly pid: number | undefined;
|
|
15
|
+
/** True once the script process has exited (or never started). */
|
|
16
|
+
hasExited(): boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Every OS interaction of the refresh, injectable for tests. */
|
|
19
|
+
export interface GrokSys {
|
|
20
|
+
spawnTui(grokBinary: string, scratchDir: string): GrokTui;
|
|
21
|
+
/** Direct children of pid. Resolves [] when there are none or the query fails. */
|
|
22
|
+
listChildren(pid: number): Promise<number[]>;
|
|
23
|
+
/** `ps -o command=` for pid. Rejects when ps fails or times out. */
|
|
24
|
+
commandOf(pid: number): Promise<string>;
|
|
25
|
+
/** kill(pid) or kill(-pid) when group is true. Returns false when the call fails. */
|
|
26
|
+
kill(pid: number, signal: NodeJS.Signals | 0, group: boolean): boolean;
|
|
27
|
+
sleep(ms: number): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** What the refresh created, so cleanup can find it again. */
|
|
30
|
+
export interface GrokRefreshState {
|
|
31
|
+
scratchDir: string;
|
|
32
|
+
tui: GrokTui;
|
|
33
|
+
/** Direct children of the script pid, recorded by parentage. */
|
|
34
|
+
children: Set<number>;
|
|
35
|
+
}
|
|
36
|
+
/** Placeholder until the script is spawned: no pid, so nothing is ever signalled. */
|
|
37
|
+
export declare const NOT_STARTED_TUI: GrokTui;
|
|
38
|
+
export interface InterruptDeps {
|
|
39
|
+
emitter: Pick<NodeJS.Process, 'listenerCount'>;
|
|
40
|
+
cleanup: () => void;
|
|
41
|
+
removeHandlers: () => void;
|
|
42
|
+
reraise: (signal: NodeJS.Signals) => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Handles SIGINT/SIGTERM during a refresh: cleans up, removes our listeners, then
|
|
46
|
+
* re-raises the signal only when no other listener will handle it. Otherwise the
|
|
47
|
+
* other listeners already received the signal and own the shutdown.
|
|
48
|
+
*/
|
|
49
|
+
export declare function handleInterrupt(signal: NodeJS.Signals, deps: InterruptDeps): void;
|
|
50
|
+
/**
|
|
51
|
+
* Pure parser over the tail of grok's unified.jsonl. The first line may be cut
|
|
52
|
+
* mid-way and garbage lines are ignored, so only well-formed billing entries count.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseGrokBillingLog(tailText: string, now: Date): ProviderQuota;
|
|
55
|
+
/**
|
|
56
|
+
* True only when a `ps -o command=` line contains the exact scratch dir path as a
|
|
57
|
+
* whole path: preceded by start, whitespace or '=', and followed by end, whitespace
|
|
58
|
+
* or '/'. A longer path that merely starts or ends with it does not match.
|
|
59
|
+
*/
|
|
60
|
+
export declare function isOwnGrokProcess(psCommand: string, scratchDir: string): boolean;
|
|
61
|
+
/** The real OS layer. */
|
|
62
|
+
export declare const realGrokSys: GrokSys;
|
|
63
|
+
/** True for pids the module may signal: integers greater than 1. */
|
|
64
|
+
export declare function isSignalablePid(pid: number): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* The single guarded path for every signal this module sends. Refuses any pid that
|
|
67
|
+
* is not an integer greater than 1, so kill(0), kill(-0), kill(-1) and NaN never happen.
|
|
68
|
+
*/
|
|
69
|
+
export declare function signalSafely(sys: Pick<GrokSys, 'kill'>, pid: number, signal: NodeJS.Signals | 0, group?: boolean): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Stops what the refresh created. Verified grok children get SIGTERM (pid and
|
|
72
|
+
* group), then the script group gets SIGTERM. After up to 1 s, anything still alive
|
|
73
|
+
* gets SIGKILL: the script group if it has not exited, and each verified child
|
|
74
|
+
* (re-verified first). A child whose command line does not contain our scratch dir
|
|
75
|
+
* is never signalled, and nothing is signalled after the script has exited.
|
|
76
|
+
*/
|
|
77
|
+
export declare function stopGrokProcesses(sys: GrokSys, state: GrokRefreshState): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort synchronous cleanup for process exit and SIGINT/SIGTERM. Safe to run
|
|
80
|
+
* more than once: verification and guarded signals make a repeat a no-op.
|
|
81
|
+
*/
|
|
82
|
+
export declare function cleanupGrokProcessesSync(state: GrokRefreshState): void;
|
|
83
|
+
/**
|
|
84
|
+
* Starts grok's TUI under a pseudo-terminal with no input until it appends a
|
|
85
|
+
* billing entry newer than `previousTs`, the script exits, or the budget runs out.
|
|
86
|
+
* Interrupts (exit, SIGINT, SIGTERM) run a synchronous cleanup while the refresh is
|
|
87
|
+
* in flight. The process group and the scratch directory are always cleaned up.
|
|
88
|
+
* Never throws.
|
|
89
|
+
*/
|
|
90
|
+
export declare function refreshGrokBilling(ctx: GrokRefreshContext, sys?: GrokSys): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Read -> optional refresh -> re-read -> parse. Never throws and never prints.
|
|
93
|
+
* The 60 s freshness skip applies even when deps.shouldRefresh is true.
|
|
94
|
+
*/
|
|
95
|
+
export declare function fetchGrokQuotaWith(options: QuotaFetchOptions, deps: GrokFetchDeps): Promise<ProviderQuota>;
|
|
96
|
+
/** Grok quota fetcher: refreshes grok's log when allowed, then reads it. Never throws. */
|
|
97
|
+
export declare const fetchGrokQuota: QuotaFetcher;
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { open, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Grok (xAI Grok Build CLI) subscription quota, read from grok's own log.
|
|
8
|
+
*
|
|
9
|
+
* Grok's plan has a single 7-day credit window. Its only readable source is
|
|
10
|
+
* `unified.jsonl`, where grok records "billing: fetched credits config" entries
|
|
11
|
+
* shaped like:
|
|
12
|
+
*
|
|
13
|
+
* { "ts": "<ISO>", "msg": "billing: fetched credits config",
|
|
14
|
+
* "ctx": { "subscriptionTier": "SuperGrok" | null,
|
|
15
|
+
* "config": { "creditUsagePercent": 57.0, // absent when 0% used
|
|
16
|
+
* "currentPeriod": { "type": "USAGE_PERIOD_TYPE_WEEKLY",
|
|
17
|
+
* "start": "...", "end": "..." } } } }
|
|
18
|
+
*
|
|
19
|
+
* To make the source live, the fetcher can start the grok TUI in a pseudo-terminal
|
|
20
|
+
* with no input; grok then fetches billing on its own and appends a fresh entry.
|
|
21
|
+
* No prompt is sent, so no quota is spent.
|
|
22
|
+
*/
|
|
23
|
+
const BILLING_MESSAGE = 'billing: fetched credits config';
|
|
24
|
+
const LOG_TAIL_BYTES = 4 * 1024 * 1024;
|
|
25
|
+
const MS_PER_MINUTE = 60_000;
|
|
26
|
+
const REFRESH_FRESH_MS = 60_000;
|
|
27
|
+
const LIVE_MS = 120_000;
|
|
28
|
+
const REFRESH_RESERVE_MS = 3500;
|
|
29
|
+
const REFRESH_BUDGET_CAP_MS = 20_000;
|
|
30
|
+
const REFRESH_MIN_BUDGET_MS = 2000;
|
|
31
|
+
const REFRESH_POLL_MS = 500;
|
|
32
|
+
const REFRESH_EXIT_WAIT_MS = 1000;
|
|
33
|
+
const CLEANUP_POLL_MS = 50;
|
|
34
|
+
const CMD_TIMEOUT_MS = 1000;
|
|
35
|
+
const SYNC_CMD_TIMEOUT_MS = 500;
|
|
36
|
+
const SCRIPT_BINARY = '/usr/bin/script';
|
|
37
|
+
const PGREP_BIN = '/usr/bin/pgrep';
|
|
38
|
+
const PS_BIN = '/bin/ps';
|
|
39
|
+
const NO_DATA_MESSAGE = "no billing data in grok's log yet: open grok and run /usage";
|
|
40
|
+
const PERIOD_ENDED_MESSAGE = "grok's last recorded billing period has ended: open grok and run /usage to refresh";
|
|
41
|
+
const ZERO_PERCENT_MESSAGE = 'grok omitted the usage figure, which it does at 0% used';
|
|
42
|
+
const NOT_FOUND_MESSAGE = 'grok log not found (is grok installed?)';
|
|
43
|
+
/** Placeholder until the script is spawned: no pid, so nothing is ever signalled. */
|
|
44
|
+
export const NOT_STARTED_TUI = { pid: undefined, hasExited: () => true };
|
|
45
|
+
/**
|
|
46
|
+
* Handles SIGINT/SIGTERM during a refresh: cleans up, removes our listeners, then
|
|
47
|
+
* re-raises the signal only when no other listener will handle it. Otherwise the
|
|
48
|
+
* other listeners already received the signal and own the shutdown.
|
|
49
|
+
*/
|
|
50
|
+
export function handleInterrupt(signal, deps) {
|
|
51
|
+
deps.cleanup();
|
|
52
|
+
deps.removeHandlers();
|
|
53
|
+
if (deps.emitter.listenerCount(signal) === 0) {
|
|
54
|
+
deps.reraise(signal);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function isRecord(value) {
|
|
58
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
function parseLine(line) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(line);
|
|
63
|
+
return isRecord(parsed) ? parsed : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function isoOrNull(value) {
|
|
70
|
+
return typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
71
|
+
}
|
|
72
|
+
function observedIso(ts, now) {
|
|
73
|
+
return new Date(Number.isFinite(ts) ? ts : now.getTime()).toISOString();
|
|
74
|
+
}
|
|
75
|
+
function buildQuota(status, observedAt, plan, windows, message) {
|
|
76
|
+
return { provider: 'grok', status, plan, windows, source: 'cached', observedAt, message };
|
|
77
|
+
}
|
|
78
|
+
/** The last billing entry whose currentPeriod.end parses, or null. */
|
|
79
|
+
function lastBilling(tailText) {
|
|
80
|
+
const lines = tailText.split('\n');
|
|
81
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
82
|
+
const record = parseLine(lines[index]);
|
|
83
|
+
if (!record || record.msg !== BILLING_MESSAGE || !isRecord(record.ctx)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const ctx = record.ctx;
|
|
87
|
+
const config = isRecord(ctx.config) ? ctx.config : null;
|
|
88
|
+
const period = config && isRecord(config.currentPeriod) ? config.currentPeriod : null;
|
|
89
|
+
if (!config || !period) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const periodEnd = isoOrNull(period.end);
|
|
93
|
+
if (!Number.isFinite(periodEnd)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ts: isoOrNull(record.ts),
|
|
98
|
+
plan: typeof ctx.subscriptionTier === 'string' && ctx.subscriptionTier !== ''
|
|
99
|
+
? ctx.subscriptionTier
|
|
100
|
+
: null,
|
|
101
|
+
periodStart: isoOrNull(period.start),
|
|
102
|
+
periodEnd,
|
|
103
|
+
usedPercent: typeof config.creditUsagePercent === 'number' && Number.isFinite(config.creditUsagePercent)
|
|
104
|
+
? config.creditUsagePercent
|
|
105
|
+
: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/** ts of the newest billing line with a readable timestamp, or null. */
|
|
111
|
+
function newestBillingTs(tailText) {
|
|
112
|
+
const lines = tailText.split('\n');
|
|
113
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
114
|
+
const record = parseLine(lines[index]);
|
|
115
|
+
if (record &&
|
|
116
|
+
record.msg === BILLING_MESSAGE &&
|
|
117
|
+
typeof record.ts === 'string' &&
|
|
118
|
+
Number.isFinite(Date.parse(record.ts))) {
|
|
119
|
+
return record.ts;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
function parseBillingTail(tailText, now) {
|
|
125
|
+
const last = lastBilling(tailText);
|
|
126
|
+
if (!last) {
|
|
127
|
+
return {
|
|
128
|
+
quota: buildQuota('unavailable', now.toISOString(), null, [], NO_DATA_MESSAGE),
|
|
129
|
+
entryTs: null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const observedAt = observedIso(last.ts, now);
|
|
133
|
+
const entryTs = Number.isFinite(last.ts) ? last.ts : null;
|
|
134
|
+
if (last.periodEnd <= now.getTime()) {
|
|
135
|
+
return {
|
|
136
|
+
quota: buildQuota('unavailable', observedAt, last.plan, [], PERIOD_ENDED_MESSAGE),
|
|
137
|
+
entryTs,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const windowMinutes = Number.isFinite(last.periodStart) && last.periodEnd > last.periodStart
|
|
141
|
+
? Math.round((last.periodEnd - last.periodStart) / MS_PER_MINUTE)
|
|
142
|
+
: null;
|
|
143
|
+
const window = {
|
|
144
|
+
kind: 'weekly',
|
|
145
|
+
usedPercent: last.usedPercent ?? 0,
|
|
146
|
+
resetsAt: new Date(last.periodEnd).toISOString(),
|
|
147
|
+
windowMinutes,
|
|
148
|
+
note: null,
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
quota: buildQuota('ok', observedAt, last.plan, [window], last.usedPercent === null ? ZERO_PERCENT_MESSAGE : null),
|
|
152
|
+
entryTs,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Pure parser over the tail of grok's unified.jsonl. The first line may be cut
|
|
157
|
+
* mid-way and garbage lines are ignored, so only well-formed billing entries count.
|
|
158
|
+
*/
|
|
159
|
+
export function parseGrokBillingLog(tailText, now) {
|
|
160
|
+
return parseBillingTail(tailText, now).quota;
|
|
161
|
+
}
|
|
162
|
+
function errorCode(err) {
|
|
163
|
+
if (isRecord(err) && typeof err.code === 'string') {
|
|
164
|
+
return err.code;
|
|
165
|
+
}
|
|
166
|
+
return 'unknown error';
|
|
167
|
+
}
|
|
168
|
+
async function readLogTail(logPath) {
|
|
169
|
+
const handle = await open(logPath, 'r');
|
|
170
|
+
try {
|
|
171
|
+
const { size } = await handle.stat();
|
|
172
|
+
const length = Math.min(size, LOG_TAIL_BYTES);
|
|
173
|
+
const buffer = Buffer.alloc(length);
|
|
174
|
+
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
|
175
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await handle.close().catch(() => undefined);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function readTailOrMissing(logPath) {
|
|
182
|
+
try {
|
|
183
|
+
return { kind: 'ok', tail: await readLogTail(logPath) };
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
const code = errorCode(err);
|
|
187
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
188
|
+
return { kind: 'missing' };
|
|
189
|
+
}
|
|
190
|
+
return { kind: 'error', code };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* True only when a `ps -o command=` line contains the exact scratch dir path as a
|
|
195
|
+
* whole path: preceded by start, whitespace or '=', and followed by end, whitespace
|
|
196
|
+
* or '/'. A longer path that merely starts or ends with it does not match.
|
|
197
|
+
*/
|
|
198
|
+
export function isOwnGrokProcess(psCommand, scratchDir) {
|
|
199
|
+
if (scratchDir === '') {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
let index = psCommand.indexOf(scratchDir);
|
|
203
|
+
while (index !== -1) {
|
|
204
|
+
const before = index === 0 ? '' : psCommand.charAt(index - 1);
|
|
205
|
+
const after = psCommand.charAt(index + scratchDir.length);
|
|
206
|
+
const leftOk = index === 0 || /\s/.test(before) || before === '=';
|
|
207
|
+
const rightOk = after === '' || /\s/.test(after) || after === '/';
|
|
208
|
+
if (leftOk && rightOk) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
index = psCommand.indexOf(scratchDir, index + 1);
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
/** Runs a fixed binary without a shell. Resolves with stdout, or '' on any failure. */
|
|
216
|
+
function runCommand(bin, args) {
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
execFile(bin, args, { timeout: CMD_TIMEOUT_MS, encoding: 'utf8' }, (err, stdout) => {
|
|
219
|
+
resolve(err ? '' : stdout);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function sleep(ms) {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
setTimeout(resolve, ms);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** The real OS layer. */
|
|
229
|
+
export const realGrokSys = {
|
|
230
|
+
spawnTui(grokBinary, scratchDir) {
|
|
231
|
+
let exited = false;
|
|
232
|
+
const child = spawn(SCRIPT_BINARY, ['-q', '/dev/null', grokBinary, '--cwd', scratchDir], {
|
|
233
|
+
stdio: 'ignore',
|
|
234
|
+
detached: true,
|
|
235
|
+
});
|
|
236
|
+
child.on('exit', () => {
|
|
237
|
+
exited = true;
|
|
238
|
+
});
|
|
239
|
+
child.on('error', () => {
|
|
240
|
+
exited = true;
|
|
241
|
+
});
|
|
242
|
+
child.unref();
|
|
243
|
+
return {
|
|
244
|
+
pid: child.pid,
|
|
245
|
+
hasExited: () => exited || child.pid === undefined,
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
async listChildren(pid) {
|
|
249
|
+
const out = await runCommand(PGREP_BIN, ['-P', String(pid)]);
|
|
250
|
+
const children = [];
|
|
251
|
+
for (const line of out.split('\n')) {
|
|
252
|
+
const child = Number(line.trim());
|
|
253
|
+
if (Number.isInteger(child) && child > 0) {
|
|
254
|
+
children.push(child);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return children;
|
|
258
|
+
},
|
|
259
|
+
commandOf(pid) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
execFile(PS_BIN, ['-o', 'command=', '-p', String(pid)], { timeout: CMD_TIMEOUT_MS, encoding: 'utf8' }, (err, stdout) => {
|
|
262
|
+
if (err) {
|
|
263
|
+
reject(err);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
resolve(stdout);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
kill(pid, signal, group) {
|
|
272
|
+
try {
|
|
273
|
+
process.kill(group ? -pid : pid, signal);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
// kill(0) on a pid we do not own reports EPERM, which still means it exists.
|
|
278
|
+
return signal === 0 && isRecord(err) && err.code === 'EPERM';
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
sleep,
|
|
282
|
+
};
|
|
283
|
+
/** True for pids the module may signal: integers greater than 1. */
|
|
284
|
+
export function isSignalablePid(pid) {
|
|
285
|
+
return Number.isInteger(pid) && pid > 1;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The single guarded path for every signal this module sends. Refuses any pid that
|
|
289
|
+
* is not an integer greater than 1, so kill(0), kill(-0), kill(-1) and NaN never happen.
|
|
290
|
+
*/
|
|
291
|
+
export function signalSafely(sys, pid, signal, group = false) {
|
|
292
|
+
if (!isSignalablePid(pid)) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
return sys.kill(pid, signal, group);
|
|
296
|
+
}
|
|
297
|
+
function isAlive(sys, pid) {
|
|
298
|
+
return signalSafely(sys, pid, 0);
|
|
299
|
+
}
|
|
300
|
+
/** Ownership check: a failing ps is retried once; if it still fails the pid is NOT ours. */
|
|
301
|
+
async function verifiedOwned(sys, pid, scratchDir) {
|
|
302
|
+
if (!isSignalablePid(pid)) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
306
|
+
try {
|
|
307
|
+
return isOwnGrokProcess(await sys.commandOf(pid), scratchDir);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Retry once.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
/** Records direct children of the script pid. Stops querying once any child is recorded. */
|
|
316
|
+
async function collectChildren(sys, state) {
|
|
317
|
+
const scriptPid = state.tui.pid;
|
|
318
|
+
if (scriptPid === undefined || state.tui.hasExited() || state.children.size > 0) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
let found;
|
|
322
|
+
try {
|
|
323
|
+
found = await sys.listChildren(scriptPid);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
for (const child of found) {
|
|
329
|
+
if (isSignalablePid(child)) {
|
|
330
|
+
state.children.add(child);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Stops what the refresh created. Verified grok children get SIGTERM (pid and
|
|
336
|
+
* group), then the script group gets SIGTERM. After up to 1 s, anything still alive
|
|
337
|
+
* gets SIGKILL: the script group if it has not exited, and each verified child
|
|
338
|
+
* (re-verified first). A child whose command line does not contain our scratch dir
|
|
339
|
+
* is never signalled, and nothing is signalled after the script has exited.
|
|
340
|
+
*/
|
|
341
|
+
export async function stopGrokProcesses(sys, state) {
|
|
342
|
+
const { tui, scratchDir } = state;
|
|
343
|
+
const scriptPid = tui.pid;
|
|
344
|
+
const scriptRunning = () => scriptPid !== undefined && isSignalablePid(scriptPid) && !tui.hasExited();
|
|
345
|
+
const owned = [];
|
|
346
|
+
for (const pid of state.children) {
|
|
347
|
+
if (await verifiedOwned(sys, pid, scratchDir)) {
|
|
348
|
+
owned.push(pid);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const pid of owned) {
|
|
352
|
+
signalSafely(sys, pid, 'SIGTERM');
|
|
353
|
+
signalSafely(sys, pid, 'SIGTERM', true);
|
|
354
|
+
}
|
|
355
|
+
if (scriptPid !== undefined && scriptRunning()) {
|
|
356
|
+
signalSafely(sys, scriptPid, 'SIGTERM', true);
|
|
357
|
+
}
|
|
358
|
+
let waited = 0;
|
|
359
|
+
while ((scriptRunning() || owned.some((pid) => isAlive(sys, pid))) && waited < REFRESH_EXIT_WAIT_MS) {
|
|
360
|
+
await sys.sleep(CLEANUP_POLL_MS);
|
|
361
|
+
waited += CLEANUP_POLL_MS;
|
|
362
|
+
}
|
|
363
|
+
if (scriptPid !== undefined && scriptRunning()) {
|
|
364
|
+
signalSafely(sys, scriptPid, 'SIGKILL', true);
|
|
365
|
+
}
|
|
366
|
+
for (const pid of owned) {
|
|
367
|
+
if (isAlive(sys, pid) && (await verifiedOwned(sys, pid, scratchDir))) {
|
|
368
|
+
signalSafely(sys, pid, 'SIGKILL');
|
|
369
|
+
signalSafely(sys, pid, 'SIGKILL', true);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Ownership check for the synchronous exit path (execFileSync, one retry). */
|
|
374
|
+
function verifiedOwnedSync(pid, scratchDir) {
|
|
375
|
+
if (!isSignalablePid(pid)) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
379
|
+
try {
|
|
380
|
+
const out = execFileSync(PS_BIN, ['-o', 'command=', '-p', String(pid)], {
|
|
381
|
+
encoding: 'utf8',
|
|
382
|
+
timeout: SYNC_CMD_TIMEOUT_MS,
|
|
383
|
+
});
|
|
384
|
+
return isOwnGrokProcess(out, scratchDir);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// Retry once.
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Best-effort synchronous cleanup for process exit and SIGINT/SIGTERM. Safe to run
|
|
394
|
+
* more than once: verification and guarded signals make a repeat a no-op.
|
|
395
|
+
*/
|
|
396
|
+
export function cleanupGrokProcessesSync(state) {
|
|
397
|
+
const scriptPid = state.tui.pid;
|
|
398
|
+
for (const pid of state.children) {
|
|
399
|
+
if (verifiedOwnedSync(pid, state.scratchDir)) {
|
|
400
|
+
signalSafely(realGrokSys, pid, 'SIGKILL');
|
|
401
|
+
signalSafely(realGrokSys, pid, 'SIGKILL', true);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (scriptPid !== undefined && !state.tui.hasExited()) {
|
|
405
|
+
signalSafely(realGrokSys, scriptPid, 'SIGKILL', true);
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
rmSync(state.scratchDir, { recursive: true, force: true });
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// Ignored: best effort.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Starts grok's TUI under a pseudo-terminal with no input until it appends a
|
|
416
|
+
* billing entry newer than `previousTs`, the script exits, or the budget runs out.
|
|
417
|
+
* Interrupts (exit, SIGINT, SIGTERM) run a synchronous cleanup while the refresh is
|
|
418
|
+
* in flight. The process group and the scratch directory are always cleaned up.
|
|
419
|
+
* Never throws.
|
|
420
|
+
*/
|
|
421
|
+
export async function refreshGrokBilling(ctx, sys = realGrokSys) {
|
|
422
|
+
const logPath = join(ctx.homeDir, '.grok', 'logs', 'unified.jsonl');
|
|
423
|
+
const grokBinary = join(ctx.homeDir, '.grok', 'bin', 'grok');
|
|
424
|
+
// Synchronous, so no signal can land between creating the scratch dir and knowing it.
|
|
425
|
+
let scratchDir;
|
|
426
|
+
try {
|
|
427
|
+
scratchDir = mkdtempSync(join(tmpdir(), 'ftown-quota-grok-'));
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const state = { scratchDir, tui: NOT_STARTED_TUI, children: new Set() };
|
|
433
|
+
const removeHandlers = () => {
|
|
434
|
+
process.removeListener('exit', onExit);
|
|
435
|
+
process.removeListener('SIGINT', onSigint);
|
|
436
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
437
|
+
};
|
|
438
|
+
const onExit = () => {
|
|
439
|
+
cleanupGrokProcessesSync(state);
|
|
440
|
+
};
|
|
441
|
+
const onSigint = () => {
|
|
442
|
+
handleInterrupt('SIGINT', {
|
|
443
|
+
emitter: process,
|
|
444
|
+
cleanup: () => cleanupGrokProcessesSync(state),
|
|
445
|
+
removeHandlers,
|
|
446
|
+
reraise: (signal) => {
|
|
447
|
+
signalSafely(realGrokSys, process.pid, signal);
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
const onSigterm = () => {
|
|
452
|
+
handleInterrupt('SIGTERM', {
|
|
453
|
+
emitter: process,
|
|
454
|
+
cleanup: () => cleanupGrokProcessesSync(state),
|
|
455
|
+
removeHandlers,
|
|
456
|
+
reraise: (signal) => {
|
|
457
|
+
signalSafely(realGrokSys, process.pid, signal);
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
process.once('exit', onExit);
|
|
462
|
+
process.once('SIGINT', onSigint);
|
|
463
|
+
process.once('SIGTERM', onSigterm);
|
|
464
|
+
try {
|
|
465
|
+
state.tui = sys.spawnTui(grokBinary, scratchDir);
|
|
466
|
+
await collectChildren(sys, state);
|
|
467
|
+
const deadline = Date.now() + ctx.budgetMs;
|
|
468
|
+
while (Date.now() < deadline) {
|
|
469
|
+
await sys.sleep(REFRESH_POLL_MS);
|
|
470
|
+
await collectChildren(sys, state);
|
|
471
|
+
const tail = await readLogTail(logPath).catch(() => null);
|
|
472
|
+
if (tail !== null) {
|
|
473
|
+
const ts = newestBillingTs(tail);
|
|
474
|
+
if (ts !== null && ts !== ctx.previousTs) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (state.tui.hasExited()) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// Best effort: the cached data is used instead.
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
await stopGrokProcesses(sys, state).catch(() => undefined);
|
|
488
|
+
await rm(scratchDir, { recursive: true, force: true }).catch(() => undefined);
|
|
489
|
+
removeHandlers();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** The real refresh runs only on darwin, when not disabled by env, and when grok is installed. */
|
|
493
|
+
async function refreshAllowed(homeDir) {
|
|
494
|
+
if (process.env.FTOWN_QUOTA_GROK_NO_REFRESH === '1' || process.platform !== 'darwin') {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
return (await stat(join(homeDir, '.grok', 'bin', 'grok'))).isFile();
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Read -> optional refresh -> re-read -> parse. Never throws and never prints.
|
|
506
|
+
* The 60 s freshness skip applies even when deps.shouldRefresh is true.
|
|
507
|
+
*/
|
|
508
|
+
export async function fetchGrokQuotaWith(options, deps) {
|
|
509
|
+
const logPath = join(options.homeDir, '.grok', 'logs', 'unified.jsonl');
|
|
510
|
+
const startedAt = currentTime(options);
|
|
511
|
+
try {
|
|
512
|
+
const first = await readTailOrMissing(logPath);
|
|
513
|
+
if (first.kind === 'error') {
|
|
514
|
+
return buildQuota('error', options.now().toISOString(), null, [], `grok log read failed (${first.code})`);
|
|
515
|
+
}
|
|
516
|
+
let tail = first.kind === 'ok' ? first.tail : '';
|
|
517
|
+
let missing = first.kind === 'missing';
|
|
518
|
+
const nowMs = options.now().getTime();
|
|
519
|
+
const previousTs = newestBillingTs(tail);
|
|
520
|
+
const previousMs = previousTs === null ? Number.NaN : Date.parse(previousTs);
|
|
521
|
+
const fresh = Number.isFinite(previousMs) && nowMs - previousMs < REFRESH_FRESH_MS;
|
|
522
|
+
const budgetMs = Math.min(options.timeoutMs - REFRESH_RESERVE_MS, REFRESH_BUDGET_CAP_MS);
|
|
523
|
+
if (deps.shouldRefresh && !fresh && budgetMs >= REFRESH_MIN_BUDGET_MS) {
|
|
524
|
+
try {
|
|
525
|
+
await deps.refresh({ homeDir: options.homeDir, previousTs, budgetMs });
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// Keep the cached data.
|
|
529
|
+
}
|
|
530
|
+
const after = await readTailOrMissing(logPath);
|
|
531
|
+
if (after.kind === 'ok') {
|
|
532
|
+
tail = after.tail;
|
|
533
|
+
missing = false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (missing) {
|
|
537
|
+
return buildQuota('unavailable', options.now().toISOString(), null, [], NOT_FOUND_MESSAGE);
|
|
538
|
+
}
|
|
539
|
+
const parsed = parseBillingTail(tail, options.now());
|
|
540
|
+
const live = parsed.quota.status === 'ok' &&
|
|
541
|
+
parsed.entryTs !== null &&
|
|
542
|
+
Math.abs(nowMs - parsed.entryTs) <= LIVE_MS;
|
|
543
|
+
return { ...parsed.quota, source: live ? 'live' : 'cached' };
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return buildQuota('error', startedAt.toISOString(), null, [], 'grok quota check failed');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/** options.now(), falling back to the real clock if the injected clock throws. */
|
|
550
|
+
function currentTime(options) {
|
|
551
|
+
try {
|
|
552
|
+
return options.now();
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return new Date();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** Grok quota fetcher: refreshes grok's log when allowed, then reads it. Never throws. */
|
|
559
|
+
export const fetchGrokQuota = async (options) => {
|
|
560
|
+
const shouldRefresh = await refreshAllowed(options.homeDir);
|
|
561
|
+
return fetchGrokQuotaWith(options, { refresh: (ctx) => refreshGrokBilling(ctx), shouldRefresh });
|
|
562
|
+
};
|
|
563
|
+
//# sourceMappingURL=grok.js.map
|