sideby 0.0.0-stage → 0.2.1

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +50 -0
  8. package/dist/src/cli/commands.js +566 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +259 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +276 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +20 -0
  20. package/dist/src/core/accounts.js +131 -0
  21. package/dist/src/core/config.d.ts +61 -0
  22. package/dist/src/core/config.js +184 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +37 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/resume.d.ts +9 -0
  48. package/dist/src/core/resume.js +15 -0
  49. package/dist/src/core/secret-file.d.ts +30 -0
  50. package/dist/src/core/secret-file.js +116 -0
  51. package/dist/src/core/share-meaning.d.ts +6 -0
  52. package/dist/src/core/share-meaning.js +32 -0
  53. package/dist/src/core/share-mode-names.d.ts +1 -0
  54. package/dist/src/core/share-mode-names.js +10 -0
  55. package/dist/src/core/share-modes.d.ts +24 -0
  56. package/dist/src/core/share-modes.js +405 -0
  57. package/dist/src/core/shell-init.d.ts +31 -0
  58. package/dist/src/core/shell-init.js +84 -0
  59. package/dist/src/core/skill-check.d.ts +11 -0
  60. package/dist/src/core/skill-check.js +57 -0
  61. package/dist/src/core/usage-days.d.ts +15 -0
  62. package/dist/src/core/usage-days.js +33 -0
  63. package/dist/src/core/version.d.ts +9 -0
  64. package/dist/src/core/version.js +46 -0
  65. package/dist/src/desktop/app.d.ts +60 -0
  66. package/dist/src/desktop/app.js +316 -0
  67. package/dist/src/desktop/icon.d.ts +16 -0
  68. package/dist/src/desktop/icon.js +174 -0
  69. package/dist/src/families/claude/index.d.ts +10 -0
  70. package/dist/src/families/claude/index.js +127 -0
  71. package/dist/src/families/claude/layout.d.ts +6 -0
  72. package/dist/src/families/claude/layout.js +5 -0
  73. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  74. package/dist/src/families/claude/quota-cache.js +111 -0
  75. package/dist/src/families/claude/quota.d.ts +3 -0
  76. package/dist/src/families/claude/quota.js +53 -0
  77. package/dist/src/families/claude/usage.d.ts +22 -0
  78. package/dist/src/families/claude/usage.js +146 -0
  79. package/dist/src/families/codex/index.d.ts +5 -0
  80. package/dist/src/families/codex/index.js +70 -0
  81. package/dist/src/families/codex/rollout.d.ts +67 -0
  82. package/dist/src/families/codex/rollout.js +298 -0
  83. package/dist/src/families/grok/index.d.ts +8 -0
  84. package/dist/src/families/grok/index.js +82 -0
  85. package/dist/src/families/index.d.ts +3 -0
  86. package/dist/src/families/index.js +40 -0
  87. package/dist/src/families/logos.d.ts +21 -0
  88. package/dist/src/families/logos.js +26 -0
  89. package/dist/src/families/pi/index.d.ts +3 -0
  90. package/dist/src/families/pi/index.js +54 -0
  91. package/dist/src/families/shared/sessions.d.ts +9 -0
  92. package/dist/src/families/shared/sessions.js +70 -0
  93. package/dist/src/families/shared/toml.d.ts +5 -0
  94. package/dist/src/families/shared/toml.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +4 -0
  97. package/dist/src/panel/background.d.ts +62 -0
  98. package/dist/src/panel/background.js +274 -0
  99. package/dist/src/panel/handler.d.ts +91 -0
  100. package/dist/src/panel/handler.js +396 -0
  101. package/dist/src/panel/i18n.d.ts +287 -0
  102. package/dist/src/panel/i18n.js +570 -0
  103. package/dist/src/panel/page-logic.d.ts +154 -0
  104. package/dist/src/panel/page-logic.js +326 -0
  105. package/dist/src/panel/page-script.d.ts +2 -0
  106. package/dist/src/panel/page-script.js +1633 -0
  107. package/dist/src/panel/page-styles.d.ts +7 -0
  108. package/dist/src/panel/page-styles.js +514 -0
  109. package/dist/src/panel/page.d.ts +20 -0
  110. package/dist/src/panel/page.js +96 -0
  111. package/dist/src/panel/server.d.ts +32 -0
  112. package/dist/src/panel/server.js +133 -0
  113. package/dist/src/panel/theme.d.ts +116 -0
  114. package/dist/src/panel/theme.js +132 -0
  115. package/dist/src/plugins/account-script.d.ts +4 -0
  116. package/dist/src/plugins/account-script.js +66 -0
  117. package/dist/src/plugins/bus.d.ts +33 -0
  118. package/dist/src/plugins/bus.js +84 -0
  119. package/dist/src/plugins/loader.d.ts +58 -0
  120. package/dist/src/plugins/loader.js +250 -0
  121. package/dist/src/quota/claude-statusline.d.ts +28 -0
  122. package/dist/src/quota/claude-statusline.js +301 -0
  123. package/dist/src/quota/statusline-tap.d.ts +11 -0
  124. package/dist/src/quota/statusline-tap.js +104 -0
  125. package/dist/src/runtime.d.ts +195 -0
  126. package/dist/src/runtime.js +424 -0
  127. package/dist/src/types.d.ts +285 -0
  128. package/dist/src/types.js +3 -0
  129. package/dist/testing/demo.d.ts +8 -0
  130. package/dist/testing/demo.js +50 -0
  131. package/dist/testing/index.d.ts +36 -0
  132. package/dist/testing/index.js +118 -0
  133. package/package.json +66 -4
  134. package/schemas/alias.json +102 -0
  135. package/schemas/app-install.json +74 -0
  136. package/schemas/app-uninstall.json +54 -0
  137. package/schemas/config.json +170 -0
  138. package/schemas/doctor.json +229 -0
  139. package/schemas/error.json +25 -0
  140. package/schemas/families.json +205 -0
  141. package/schemas/list.json +177 -0
  142. package/schemas/new.json +199 -0
  143. package/schemas/next.json +127 -0
  144. package/schemas/plugins.json +74 -0
  145. package/schemas/quota-setup.json +60 -0
  146. package/schemas/quota-teardown.json +30 -0
  147. package/schemas/quota.json +203 -0
  148. package/skills/sideby/SKILL.md +61 -0
  149. package/skills/sideby/references/accounts.md +31 -0
  150. package/skills/sideby/references/diagnose.md +47 -0
  151. package/skills/sideby/references/handoff.md +41 -0
  152. package/skills/sideby/references/quota.md +14 -0
  153. package/skills/sideby/references/setup.md +31 -0
  154. package/skills/sideby/references/sharing.md +17 -0
@@ -0,0 +1,146 @@
1
+ // Usage for a Claude Code Account, counted from its own session logs (`<dir>/projects/**/*.jsonl`).
2
+ // One assistant message can be logged on several lines; the last line per `message.id` wins. Each file's parse is
3
+ // remembered until the file changes (core/file-memo.ts), so a refresh reads only the logs that grew.
4
+ import { createReadStream } from 'node:fs';
5
+ import { readdir, stat } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { createInterface } from 'node:readline';
8
+ import { FileMemo } from '../../core/file-memo.js';
9
+ import { USAGE_DAYS } from '../../core/quota-levels.js';
10
+ import { dailyBuckets } from '../../core/usage-days.js';
11
+ const DAY_MS = 86_400_000;
12
+ async function listJsonl(dir, out = []) {
13
+ let entries;
14
+ try {
15
+ entries = await readdir(dir, { withFileTypes: true });
16
+ }
17
+ catch {
18
+ return out;
19
+ }
20
+ for (const e of entries) {
21
+ const p = join(dir, e.name);
22
+ // Dirent types do not follow symlinks, so a linked directory can never loop the walk.
23
+ if (e.isDirectory())
24
+ await listJsonl(p, out);
25
+ else if (e.isFile() && e.name.endsWith('.jsonl'))
26
+ out.push(p);
27
+ }
28
+ return out;
29
+ }
30
+ const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
31
+ async function parseFile(file) {
32
+ const out = { lines: 0, parsed: 0, rows: [], failed: false };
33
+ try {
34
+ const rl = createInterface({ input: createReadStream(file), crlfDelay: Number.POSITIVE_INFINITY });
35
+ for await (const line of rl) {
36
+ if (!line.trim())
37
+ continue;
38
+ out.lines++;
39
+ let row;
40
+ try {
41
+ row = JSON.parse(line);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ out.parsed++;
47
+ if (row?.type !== 'assistant')
48
+ continue;
49
+ const usage = row.message?.usage;
50
+ if (typeof usage !== 'object' || usage === null)
51
+ continue;
52
+ const id = row.message?.id;
53
+ const key = typeof id === 'string'
54
+ ? id
55
+ : typeof row.requestId === 'string'
56
+ ? `request:${row.requestId}`
57
+ : `${file}:${out.lines}`;
58
+ out.rows.push([
59
+ key,
60
+ {
61
+ timestamp: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : Number.NaN,
62
+ session: typeof row.sessionId === 'string' ? row.sessionId : file,
63
+ input: count(usage.input_tokens),
64
+ output: count(usage.output_tokens),
65
+ cacheRead: count(usage.cache_read_input_tokens),
66
+ cacheWrite: count(usage.cache_creation_input_tokens),
67
+ },
68
+ ]);
69
+ }
70
+ }
71
+ catch {
72
+ // An unreadable file is skipped; the others still count.
73
+ out.failed = true;
74
+ }
75
+ return out;
76
+ }
77
+ /** Parsed session logs per Account directory; a file is parsed again only after it changed. */
78
+ export const claudeUsageMemo = new FileMemo();
79
+ export async function readClaudeUsage(account, ctx) {
80
+ const files = await listJsonl(join(account.dir, 'projects'));
81
+ if (!files.length)
82
+ return {
83
+ status: 'unavailable',
84
+ reason: 'no-session',
85
+ detail: 'no Claude Code sessions recorded for this account yet',
86
+ };
87
+ const now = ctx.now.getTime();
88
+ const since = now - USAGE_DAYS * DAY_MS;
89
+ const entries = new Map();
90
+ let lines = 0;
91
+ let parsed = 0;
92
+ const memo = claudeUsageMemo.pass(account.dir);
93
+ for (const file of files) {
94
+ const st = await stat(file).catch(() => null);
95
+ if (!st || st.mtimeMs < since)
96
+ continue;
97
+ const p = await memo.get(file, st, () => parseFile(file), (v) => !v.failed);
98
+ lines += p.lines;
99
+ parsed += p.parsed;
100
+ for (const [key, entry] of p.rows) {
101
+ // Re-insert so the last occurrence wins.
102
+ entries.delete(key);
103
+ entries.set(key, entry);
104
+ }
105
+ }
106
+ memo.done();
107
+ if (lines > 0 && parsed === 0)
108
+ return {
109
+ status: 'unavailable',
110
+ reason: 'unrecognized',
111
+ detail: `session logs in the last ${USAGE_DAYS} days could not be parsed as JSON lines`,
112
+ };
113
+ const sessions = new Set();
114
+ const days = dailyBuckets(ctx.now);
115
+ let last = Number.NEGATIVE_INFINITY;
116
+ let inputTokens = 0;
117
+ let outputTokens = 0;
118
+ let cacheReadTokens = 0;
119
+ let cacheWriteTokens = 0;
120
+ for (const e of entries.values()) {
121
+ if (!Number.isFinite(e.timestamp) || e.timestamp > now)
122
+ continue;
123
+ if (e.timestamp > last)
124
+ last = e.timestamp;
125
+ if (e.timestamp < since)
126
+ continue;
127
+ sessions.add(e.session);
128
+ inputTokens += e.input;
129
+ outputTokens += e.output;
130
+ cacheReadTokens += e.cacheRead;
131
+ cacheWriteTokens += e.cacheWrite;
132
+ days.add(e.timestamp, e.input + e.output + e.cacheRead + e.cacheWrite);
133
+ }
134
+ return {
135
+ status: 'ok',
136
+ days: USAGE_DAYS,
137
+ sessions: sessions.size,
138
+ inputTokens,
139
+ outputTokens,
140
+ cacheReadTokens,
141
+ cacheWriteTokens,
142
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
143
+ daily: days.daily,
144
+ ...(Number.isFinite(last) ? { lastActivityAt: new Date(last).toISOString() } : {}),
145
+ };
146
+ }
@@ -0,0 +1,5 @@
1
+ import type { Account, FamilyDef, Plugin } from '../../types.ts';
2
+ /** The email claim of `tokens.id_token` in auth.json; the token and every other claim are dropped. */
3
+ export declare function codexIdentity(account: Account): Promise<import("../../types.ts").AccountIdentity | undefined>;
4
+ export declare const codexFamily: FamilyDef;
5
+ export declare const codexPlugin: Plugin;
@@ -0,0 +1,70 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { identityFromJsonFile, identityOf, jwtEmail } from '../../core/identity.js';
4
+ import { BUILTIN_LOGOS } from '../logos.js';
5
+ import { nestedFileWrittenAt, resumeSubcommandSession } from '../shared/sessions.js';
6
+ import { topLevelTomlString } from '../shared/toml.js';
7
+ import { readCodexQuota, readCodexUsage } from './rollout.js';
8
+ const FILE_STORE_KEY = 'cli_auth_credentials_store';
9
+ async function readModel(account) {
10
+ try {
11
+ return topLevelTomlString(await readFile(join(account.dir, 'config.toml'), 'utf8'), 'model');
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** The email claim of `tokens.id_token` in auth.json; the token and every other claim are dropped. */
18
+ export function codexIdentity(account) {
19
+ return identityFromJsonFile(join(account.dir, 'auth.json'), (data) => identityOf(jwtEmail(data?.tokens?.id_token)));
20
+ }
21
+ export const codexFamily = {
22
+ id: 'codex',
23
+ title: 'Codex',
24
+ logo: BUILTIN_LOGOS.codex,
25
+ bin: 'codex',
26
+ installUrl: 'https://github.com/openai/codex',
27
+ selectVar: 'CODEX_HOME',
28
+ layout: { main: '.codex', account: '.codex-<name>' },
29
+ hijackVars: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'CODEX_API_BASE_URL'],
30
+ apiVars: ['OPENAI_BASE_URL', 'OPENAI_API_KEY'],
31
+ sharedItems: [
32
+ { path: 'AGENTS.md', mode: 'link' },
33
+ { path: 'hooks.json', mode: 'link' },
34
+ { path: 'plugins', mode: 'link' },
35
+ { path: 'rules', mode: 'link' },
36
+ { path: 'skills', mode: 'link' },
37
+ { path: 'config.toml', mode: 'local-if-api' },
38
+ { path: 'auth.json', mode: 'info', credential: true },
39
+ ],
40
+ login: {
41
+ args: ['login'],
42
+ hint: "Sign in with ChatGPT in the browser; the login is saved to this account's auth.json",
43
+ },
44
+ // A non-main Account keeps its login in its own auth.json instead of the shared OS keyring.
45
+ defaultArgs(account, userArgs) {
46
+ if (account.isMain || userArgs.some((a) => a.includes(FILE_STORE_KEY)))
47
+ return [];
48
+ return ['-c', `${FILE_STORE_KEY}="file"`];
49
+ },
50
+ async loginState(account) {
51
+ try {
52
+ return (await stat(join(account.dir, 'auth.json'))).isFile() ? 'logged-in' : 'unknown';
53
+ }
54
+ catch (err) {
55
+ return err.code === 'ENOENT' ? 'logged-out' : 'unknown';
56
+ }
57
+ },
58
+ model: readModel,
59
+ identity: codexIdentity,
60
+ readQuota: (account) => readCodexQuota(account.dir),
61
+ readUsage: (account, ctx) => readCodexUsage(account.dir, ctx.now),
62
+ resumedSession: resumeSubcommandSession,
63
+ sessionWrittenAt: (account, id) => nestedFileWrittenAt(join(account.dir, 'sessions'), `-${id}.jsonl`),
64
+ };
65
+ export const codexPlugin = {
66
+ name: 'codex',
67
+ register(api) {
68
+ api.family(codexFamily);
69
+ },
70
+ };
@@ -0,0 +1,67 @@
1
+ import { FileMemo, type FileStats } from '../../core/file-memo.ts';
2
+ import type { QuotaResult, QuotaWindow, UsageResult } from '../../types.ts';
3
+ export declare const MAX_QUOTA_FILES = 20;
4
+ export interface RolloutFile {
5
+ path: string;
6
+ mtimeMs: number;
7
+ stats: FileStats;
8
+ }
9
+ /** Every rollout file under `<dir>/sessions`, newest mtime first. */
10
+ export declare function listRollouts(dir: string): Promise<RolloutFile[]>;
11
+ export declare function windowLabel(minutes: number): string;
12
+ interface FileLimits {
13
+ /** The file's newest well-formed `rate_limits` (the later line on a tie), or null. */
14
+ best: {
15
+ at: number;
16
+ windows: QuotaWindow[];
17
+ plan?: string;
18
+ } | null;
19
+ failed: boolean;
20
+ }
21
+ /** Parsed `rate_limits` per Account directory; a rollout is read again only after it changed. */
22
+ export declare const codexQuotaMemo: FileMemo<FileLimits>;
23
+ /** Newest well-formed `rate_limits` across the newest rollout files, by event timestamp. */
24
+ export declare function readCodexQuota(dir: string): Promise<QuotaResult>;
25
+ interface Totals {
26
+ input: number;
27
+ cached: number;
28
+ output: number;
29
+ cacheWrite: number;
30
+ }
31
+ /**
32
+ * What each event adds, by index. `total_token_usage` is cumulative per segment: every field, cached input
33
+ * included, only grows until the Host starts a new segment, where they all start again from zero. A subagent's
34
+ * file starts from its parent's running total. So the first event adds its total minus the starting baseline
35
+ * (that total minus the first turn's own usage, zero for an ordinary session), an event where any field went
36
+ * down starts a new segment and adds its whole total, and any other event adds its growth. Every field of every
37
+ * increment is therefore zero or more, and any run of events sums to exactly what it added.
38
+ */
39
+ export declare function fileDeltas(events: {
40
+ total: Totals;
41
+ turn: Totals | null;
42
+ }[]): Totals[];
43
+ /** Tokens one whole rollout file adds: the sum of its fileDeltas. */
44
+ export declare function fileTokens(events: {
45
+ total: Totals;
46
+ turn: Totals | null;
47
+ }[]): Totals | null;
48
+ interface FileUsage {
49
+ /** Time of every `token_count` event, with or without totals. */
50
+ times: number[];
51
+ events: {
52
+ at: number;
53
+ total: Totals;
54
+ turn: Totals | null;
55
+ }[];
56
+ deltas: Totals[];
57
+ failed: boolean;
58
+ }
59
+ /** Parsed token totals per Account directory; a rollout is read again only after it changed. */
60
+ export declare const codexUsageMemo: FileMemo<FileUsage>;
61
+ /**
62
+ * Token totals for events in the last `days` x 24 h, from rollout files modified in that window (mtime only
63
+ * picks which files to read). Each event counts its fileDeltas increment, so a long session resumed today
64
+ * adds only its recent turns. `daily` spreads the same increments over local days.
65
+ */
66
+ export declare function readCodexUsage(dir: string, now: Date, days?: number): Promise<UsageResult>;
67
+ export {};
@@ -0,0 +1,298 @@
1
+ // Reads Codex Quota and Usage from the Account's local rollout files (spec §3.7, ADR-0003).
2
+ // No network, no credentials: only `token_count` events the Host writes into `sessions/**/rollout-*.jsonl`.
3
+ // Each file's parse is remembered until the file changes (core/file-memo.ts), so a refresh reads only new rollouts.
4
+ import { createReadStream } from 'node:fs';
5
+ import { readdir, stat } from 'node:fs/promises';
6
+ import { join, relative } from 'node:path';
7
+ import { createInterface } from 'node:readline';
8
+ import { FileMemo } from '../../core/file-memo.js';
9
+ import { USAGE_DAYS } from '../../core/quota-levels.js';
10
+ import { dailyBuckets } from '../../core/usage-days.js';
11
+ export const MAX_QUOTA_FILES = 20;
12
+ const ROLLOUT = /^rollout-.*\.jsonl$/;
13
+ const MAX_RESETS_AT_SECONDS = 1e11;
14
+ function isObj(v) {
15
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
16
+ }
17
+ function isNum(v) {
18
+ return typeof v === 'number' && Number.isFinite(v);
19
+ }
20
+ /** Every rollout file under `<dir>/sessions`, newest mtime first. */
21
+ export async function listRollouts(dir) {
22
+ const root = join(dir, 'sessions');
23
+ let entries;
24
+ try {
25
+ entries = await readdir(root, { recursive: true, withFileTypes: true });
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ const out = [];
31
+ for (const e of entries) {
32
+ if (!e.isFile() || !ROLLOUT.test(e.name))
33
+ continue;
34
+ const path = join(e.parentPath, e.name);
35
+ try {
36
+ const st = await stat(path);
37
+ out.push({ path, mtimeMs: st.mtimeMs, stats: st });
38
+ }
39
+ catch {
40
+ // removed while scanning
41
+ }
42
+ }
43
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
44
+ }
45
+ /**
46
+ * Yields parsed `token_count` payloads with their event time; bad and truncated lines are skipped. A read error
47
+ * ends the file early and sets `read.failed`, so a caller does not remember a partial parse.
48
+ */
49
+ async function* tokenCounts(file, read = { failed: false }) {
50
+ const lines = createInterface({ input: createReadStream(file), crlfDelay: Number.POSITIVE_INFINITY });
51
+ try {
52
+ for await (const line of lines) {
53
+ if (!line.includes('token_count'))
54
+ continue;
55
+ let o;
56
+ try {
57
+ o = JSON.parse(line);
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ if (!isObj(o) || !isObj(o.payload) || o.payload.type !== 'token_count')
63
+ continue;
64
+ const at = typeof o.timestamp === 'string' ? Date.parse(o.timestamp) : Number.NaN;
65
+ if (!Number.isFinite(at))
66
+ continue;
67
+ yield { at, payload: o.payload };
68
+ }
69
+ }
70
+ catch {
71
+ // unreadable file: keep what was read
72
+ read.failed = true;
73
+ }
74
+ finally {
75
+ lines.close();
76
+ }
77
+ }
78
+ export function windowLabel(minutes) {
79
+ if (minutes === 300)
80
+ return '5h';
81
+ if (minutes === 10080)
82
+ return '7d';
83
+ return `${minutes}m`;
84
+ }
85
+ function quotaWindow(raw) {
86
+ if (!isObj(raw))
87
+ return null;
88
+ const { used_percent: used, window_minutes: minutes, resets_at: resets } = raw;
89
+ if (!isNum(used) || !isNum(minutes) || minutes <= 0)
90
+ return null;
91
+ // Seconds since the epoch; a millisecond value would land tens of thousands of years ahead.
92
+ if (!isNum(resets) || resets <= 0 || resets >= MAX_RESETS_AT_SECONDS)
93
+ return null;
94
+ return {
95
+ label: windowLabel(minutes),
96
+ windowMinutes: minutes,
97
+ usedPercent: used,
98
+ resetsAt: new Date(resets * 1000).toISOString(),
99
+ };
100
+ }
101
+ function rateLimits(payload) {
102
+ const rl = payload.rate_limits;
103
+ if (!isObj(rl))
104
+ return null;
105
+ const primary = quotaWindow(rl.primary);
106
+ if (!primary)
107
+ return null;
108
+ const windows = [primary];
109
+ if (rl.secondary !== null && rl.secondary !== undefined) {
110
+ const secondary = quotaWindow(rl.secondary);
111
+ if (!secondary)
112
+ return null;
113
+ windows.push(secondary);
114
+ }
115
+ return { windows, ...(typeof rl.plan_type === 'string' ? { plan: rl.plan_type } : {}) };
116
+ }
117
+ async function fileLimits(file) {
118
+ const read = { failed: false };
119
+ let best = null;
120
+ for await (const { at, payload } of tokenCounts(file, read)) {
121
+ if (best && at < best.at)
122
+ continue;
123
+ const rl = rateLimits(payload);
124
+ if (rl)
125
+ best = { at, ...rl };
126
+ }
127
+ return { best, failed: read.failed };
128
+ }
129
+ /** Parsed `rate_limits` per Account directory; a rollout is read again only after it changed. */
130
+ export const codexQuotaMemo = new FileMemo();
131
+ /** Newest well-formed `rate_limits` across the newest rollout files, by event timestamp. */
132
+ export async function readCodexQuota(dir) {
133
+ const files = (await listRollouts(dir)).slice(0, MAX_QUOTA_FILES);
134
+ if (files.length === 0)
135
+ return { status: 'unavailable', reason: 'no-session', detail: 'no Codex session in this account yet' };
136
+ let best = null;
137
+ const memo = codexQuotaMemo.pass(dir);
138
+ for (const f of files) {
139
+ // Same pick as reading every line in this order: the newest event wins, a later one on a tie.
140
+ const fb = (await memo.get(f.path, f.stats, () => fileLimits(f.path), (v) => !v.failed)).best;
141
+ if (fb && (!best || fb.at >= best.at))
142
+ best = { ...fb, file: f.path };
143
+ }
144
+ memo.done();
145
+ if (!best)
146
+ return {
147
+ status: 'unavailable',
148
+ reason: 'unrecognized',
149
+ detail: `no rate_limits event in the newest ${files.length} rollout file(s)`,
150
+ };
151
+ return {
152
+ status: 'ok',
153
+ windows: best.windows,
154
+ observedAt: new Date(best.at).toISOString(),
155
+ source: relative(dir, best.file),
156
+ ...(best.plan ? { plan: best.plan } : {}),
157
+ };
158
+ }
159
+ function parseTotals(t) {
160
+ if (!isObj(t) || !isNum(t.input_tokens) || !isNum(t.output_tokens))
161
+ return null;
162
+ return {
163
+ input: t.input_tokens,
164
+ cached: isNum(t.cached_input_tokens) ? t.cached_input_tokens : 0,
165
+ output: t.output_tokens,
166
+ cacheWrite: isNum(t.cache_write_input_tokens) ? t.cache_write_input_tokens : 0,
167
+ };
168
+ }
169
+ /** Cumulative totals so far in the session, plus this turn's own usage when the event carries it. */
170
+ function totals(payload) {
171
+ const info = payload.info;
172
+ if (!isObj(info))
173
+ return null;
174
+ const total = parseTotals(info.total_token_usage);
175
+ return total ? { total, turn: parseTotals(info.last_token_usage) } : null;
176
+ }
177
+ const ZERO = { input: 0, cached: 0, output: 0, cacheWrite: 0 };
178
+ const FIELDS = ['input', 'cached', 'output', 'cacheWrite'];
179
+ const size = (t) => t.input + t.output + t.cacheWrite;
180
+ const plus = (a, b) => ({
181
+ input: a.input + b.input,
182
+ cached: a.cached + b.cached,
183
+ output: a.output + b.output,
184
+ cacheWrite: a.cacheWrite + b.cacheWrite,
185
+ });
186
+ const minus = (a, b) => ({
187
+ input: Math.max(0, a.input - b.input),
188
+ cached: Math.max(0, a.cached - b.cached),
189
+ output: Math.max(0, a.output - b.output),
190
+ cacheWrite: Math.max(0, a.cacheWrite - b.cacheWrite),
191
+ });
192
+ /** True when any counter went down: each one only grows within a segment. */
193
+ const dropped = (now, prev) => FIELDS.some((k) => now[k] < prev[k]);
194
+ /**
195
+ * What each event adds, by index. `total_token_usage` is cumulative per segment: every field, cached input
196
+ * included, only grows until the Host starts a new segment, where they all start again from zero. A subagent's
197
+ * file starts from its parent's running total. So the first event adds its total minus the starting baseline
198
+ * (that total minus the first turn's own usage, zero for an ordinary session), an event where any field went
199
+ * down starts a new segment and adds its whole total, and any other event adds its growth. Every field of every
200
+ * increment is therefore zero or more, and any run of events sums to exactly what it added.
201
+ */
202
+ export function fileDeltas(events) {
203
+ if (events.length === 0)
204
+ return [];
205
+ const first = events[0];
206
+ const baseline = first.turn ? minus(first.total, first.turn) : ZERO;
207
+ const out = [minus(first.total, baseline)];
208
+ let prev = first.total;
209
+ for (const e of events.slice(1)) {
210
+ out.push(dropped(e.total, prev) ? minus(e.total, ZERO) : minus(e.total, prev));
211
+ prev = e.total;
212
+ }
213
+ return out;
214
+ }
215
+ /** Tokens one whole rollout file adds: the sum of its fileDeltas. */
216
+ export function fileTokens(events) {
217
+ if (events.length === 0)
218
+ return null;
219
+ return fileDeltas(events).reduce(plus, ZERO);
220
+ }
221
+ async function fileUsage(file) {
222
+ const read = { failed: false };
223
+ const times = [];
224
+ const events = [];
225
+ for await (const { at, payload } of tokenCounts(file, read)) {
226
+ times.push(at);
227
+ const t = totals(payload);
228
+ if (t)
229
+ events.push({ at, ...t });
230
+ }
231
+ return { times, events, deltas: fileDeltas(events), failed: read.failed };
232
+ }
233
+ /** Parsed token totals per Account directory; a rollout is read again only after it changed. */
234
+ export const codexUsageMemo = new FileMemo();
235
+ /**
236
+ * Token totals for events in the last `days` x 24 h, from rollout files modified in that window (mtime only
237
+ * picks which files to read). Each event counts its fileDeltas increment, so a long session resumed today
238
+ * adds only its recent turns. `daily` spreads the same increments over local days.
239
+ */
240
+ export async function readCodexUsage(dir, now, days = USAGE_DAYS) {
241
+ const end = now.getTime();
242
+ const since = end - days * 24 * 60 * 60 * 1000;
243
+ const files = (await listRollouts(dir)).filter((f) => f.mtimeMs >= since);
244
+ if (files.length === 0)
245
+ return {
246
+ status: 'unavailable',
247
+ reason: 'no-session',
248
+ detail: `no Codex session in the last ${days} days`,
249
+ };
250
+ let sessions = 0;
251
+ let sawTokenCount = false;
252
+ let sawTotals = false;
253
+ let last = Number.NEGATIVE_INFINITY;
254
+ const daily = dailyBuckets(now, days);
255
+ const sum = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
256
+ const memo = codexUsageMemo.pass(dir);
257
+ for (const f of files) {
258
+ const { times, events, deltas } = await memo.get(f.path, f.stats, () => fileUsage(f.path), (v) => !v.failed);
259
+ if (times.length)
260
+ sawTokenCount = true;
261
+ for (const at of times)
262
+ if (at <= end && at > last)
263
+ last = at;
264
+ if (events.length)
265
+ sawTotals = true;
266
+ let used = ZERO;
267
+ let inWindow = false;
268
+ for (const [i, e] of events.entries()) {
269
+ if (e.at < since || e.at > end)
270
+ continue;
271
+ inWindow = true;
272
+ used = plus(used, deltas[i]);
273
+ daily.add(e.at, size(deltas[i]));
274
+ }
275
+ if (!inWindow)
276
+ continue;
277
+ sessions++;
278
+ const cached = Math.min(used.cached, used.input);
279
+ sum.inputTokens += used.input - cached;
280
+ sum.cacheReadTokens += cached;
281
+ sum.outputTokens += used.output;
282
+ sum.cacheWriteTokens += used.cacheWrite;
283
+ }
284
+ memo.done();
285
+ if (sessions === 0)
286
+ return sawTokenCount && !sawTotals
287
+ ? { status: 'unavailable', reason: 'unrecognized', detail: 'token_count events have no usable totals' }
288
+ : { status: 'unavailable', reason: 'no-session', detail: `no Codex turn in the last ${days} days` };
289
+ return {
290
+ status: 'ok',
291
+ days,
292
+ sessions,
293
+ ...sum,
294
+ totalTokens: sum.inputTokens + sum.outputTokens + sum.cacheReadTokens + sum.cacheWriteTokens,
295
+ daily: daily.daily,
296
+ ...(Number.isFinite(last) ? { lastActivityAt: new Date(last).toISOString() } : {}),
297
+ };
298
+ }
@@ -0,0 +1,8 @@
1
+ import type { Account, FamilyDef, Plugin } from '../../types.ts';
2
+ /**
3
+ * The `email` of the single sign-in entry in auth.json (keyed by the auth server, such as `https://auth.x.ai::<id>`).
4
+ * Only that field is read; tokens and every other field are dropped. Several entries give no answer.
5
+ */
6
+ export declare function grokIdentity(account: Account): Promise<import("../../types.ts").AccountIdentity | undefined>;
7
+ export declare const grokFamily: FamilyDef;
8
+ export declare const grokPlugin: Plugin;
@@ -0,0 +1,82 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { identityFromJsonFile, identityOf } from '../../core/identity.js';
4
+ import { BUILTIN_LOGOS } from '../logos.js';
5
+ import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
6
+ import { topLevelTomlString } from '../shared/toml.js';
7
+ // Grok's sandbox rejects these paths when they are symlinks; doctor quotes the Host's own words.
8
+ const HOOKS_DIR_MESSAGE = 'Grok hooks directory has wrong type (expected real directory)';
9
+ const REAL_FILE_MESSAGE = 'Grok hooks-paths registry has wrong type (expected real file)';
10
+ const AUTH_MESSAGE = 'the policy lock file under ~/.grok-00x is not accessible';
11
+ /** `model = "..."` before the first `[table]` of config.toml. */
12
+ async function readModel(account) {
13
+ let text;
14
+ try {
15
+ text = await readFile(join(account.dir, 'config.toml'), 'utf8');
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ return topLevelTomlString(text, 'model');
21
+ }
22
+ /**
23
+ * The `email` of the single sign-in entry in auth.json (keyed by the auth server, such as `https://auth.x.ai::<id>`).
24
+ * Only that field is read; tokens and every other field are dropped. Several entries give no answer.
25
+ */
26
+ export function grokIdentity(account) {
27
+ return identityFromJsonFile(join(account.dir, 'auth.json'), (data) => {
28
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
29
+ return undefined;
30
+ const entries = Object.values(data);
31
+ if (entries.length !== 1)
32
+ return undefined;
33
+ const entry = entries[0];
34
+ return typeof entry === 'object' && entry !== null
35
+ ? identityOf(entry.email)
36
+ : undefined;
37
+ });
38
+ }
39
+ export const grokFamily = {
40
+ id: 'grok',
41
+ title: 'Grok Build',
42
+ logo: BUILTIN_LOGOS.grok,
43
+ bin: 'grok',
44
+ installUrl: 'https://docs.x.ai/build/overview',
45
+ selectVar: 'GROK_HOME',
46
+ layout: { main: '.grok', account: '.grok-<name>' },
47
+ hijackVars: ['XAI_API_KEY', 'GROK_MODELS_BASE_URL'],
48
+ apiVars: ['GROK_MODELS_BASE_URL', 'XAI_API_KEY'],
49
+ sharedItems: [
50
+ { path: 'skills', mode: 'link' },
51
+ { path: 'installed-plugins', mode: 'link' },
52
+ { path: 'hooks', mode: 'copy', hostMessage: HOOKS_DIR_MESSAGE },
53
+ { path: 'hooks-paths', mode: 'copy', hostMessage: REAL_FILE_MESSAGE },
54
+ { path: 'trusted_folders.toml', mode: 'local', hostMessage: REAL_FILE_MESSAGE },
55
+ { path: 'config.toml', mode: 'local-if-api', noSymlink: true, hostMessage: REAL_FILE_MESSAGE },
56
+ { path: 'auth.json', mode: 'info', noSymlink: true, credential: true, hostMessage: AUTH_MESSAGE },
57
+ ],
58
+ login: {
59
+ args: ['login'],
60
+ hint: "Sign in to your xAI account in the browser; the login is saved to this account's auth.json",
61
+ },
62
+ async loginState(account) {
63
+ try {
64
+ const st = await lstat(join(account.dir, 'auth.json'));
65
+ return st.isFile() ? 'logged-in' : 'unknown';
66
+ }
67
+ catch (err) {
68
+ return err.code === 'ENOENT' ? 'logged-out' : 'unknown';
69
+ }
70
+ },
71
+ model: readModel,
72
+ identity: grokIdentity,
73
+ resumedSession: resumeFlagSession,
74
+ // One directory per session, grouped by working directory: sessions/<encoded cwd>/<id>/.
75
+ sessionWrittenAt: (account, id) => groupedEntryWrittenAt(join(account.dir, 'sessions'), id, 'dir'),
76
+ };
77
+ export const grokPlugin = {
78
+ name: 'grok',
79
+ register(api) {
80
+ api.family(grokFamily);
81
+ },
82
+ };
@@ -0,0 +1,3 @@
1
+ import type { BuiltinPlugin } from '../plugins/loader.ts';
2
+ /** Built-in Plugins in load order. Families register here; `account-script` is off unless enabled. */
3
+ export declare const BUILTIN_PLUGINS: BuiltinPlugin[];