@geoqiao/pi-usage 0.1.0

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 (62) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +222 -0
  3. package/bin/pi-usage.js +69 -0
  4. package/data/models.dev-LICENSE +21 -0
  5. package/data/prices.json +2678 -0
  6. package/extensions/usage-report.js +36 -0
  7. package/package.json +51 -0
  8. package/src/analytics.js +189 -0
  9. package/src/collect.js +34 -0
  10. package/src/network.js +25 -0
  11. package/src/report.js +43 -0
  12. package/vendor/vibe-usage/NOTICE.md +58 -0
  13. package/vendor/vibe-usage/src/cindy-roots.js +85 -0
  14. package/vendor/vibe-usage/src/claude-roots.js +165 -0
  15. package/vendor/vibe-usage/src/cline-roots.js +40 -0
  16. package/vendor/vibe-usage/src/codex-roots.js +46 -0
  17. package/vendor/vibe-usage/src/craft-roots.js +15 -0
  18. package/vendor/vibe-usage/src/extra-roots.js +312 -0
  19. package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
  20. package/vendor/vibe-usage/src/parsers/alma.js +94 -0
  21. package/vendor/vibe-usage/src/parsers/amp.js +156 -0
  22. package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
  23. package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
  24. package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
  25. package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
  26. package/vendor/vibe-usage/src/parsers/cline.js +92 -0
  27. package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
  28. package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
  29. package/vendor/vibe-usage/src/parsers/contract.js +55 -0
  30. package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
  31. package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
  32. package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
  33. package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
  34. package/vendor/vibe-usage/src/parsers/droid.js +113 -0
  35. package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
  36. package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
  37. package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
  38. package/vendor/vibe-usage/src/parsers/grok.js +395 -0
  39. package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
  40. package/vendor/vibe-usage/src/parsers/index.js +61 -0
  41. package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
  42. package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
  43. package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
  44. package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
  45. package/vendor/vibe-usage/src/parsers/omp.js +10 -0
  46. package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
  47. package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
  48. package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
  49. package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
  50. package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
  51. package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
  52. package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
  53. package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
  54. package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
  55. package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
  56. package/vendor/vibe-usage/src/pi-roots.js +125 -0
  57. package/vendor/vibe-usage/src/tools.js +422 -0
  58. package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
  59. package/vendor/vibe-usage/upstream-files.json +48 -0
  60. package/web/report.css +10 -0
  61. package/web/report.html +81 -0
  62. package/web/report.js +310 -0
@@ -0,0 +1,165 @@
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const MAX_DESKTOP_DISCOVERY_DEPTH = 8;
6
+ const DESKTOP_NON_SESSION_DIRS = new Set(['rpm', 'skills']);
7
+
8
+ function expandHome(value) {
9
+ const trimmed = value.trim().replace(/[/\\]+$/, '');
10
+ if (trimmed === '~') return homedir();
11
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
12
+ return join(homedir(), trimmed.slice(2));
13
+ }
14
+ return trimmed;
15
+ }
16
+
17
+ function hasClaudeData(root) {
18
+ return existsSync(join(root, 'projects')) || existsSync(join(root, 'transcripts'));
19
+ }
20
+
21
+ function defaultClaudeDesktopDataDir() {
22
+ if (process.platform === 'darwin') {
23
+ return join(homedir(), 'Library', 'Application Support', 'Claude');
24
+ }
25
+ if (process.platform === 'win32') {
26
+ const appData = process.env.APPDATA?.trim();
27
+ return appData
28
+ ? join(expandHome(appData), 'Claude')
29
+ : join(homedir(), 'AppData', 'Roaming', 'Claude');
30
+ }
31
+ const configHome = process.env.XDG_CONFIG_HOME?.trim();
32
+ return join(configHome ? expandHome(configHome) : join(homedir(), '.config'), 'Claude');
33
+ }
34
+
35
+ function getClaudeDesktopDataDirs() {
36
+ const override = process.env.VIBE_USAGE_CLAUDE_DESKTOP_DIRS?.trim();
37
+ return override
38
+ ? override.split(delimiter).map(expandHome).filter(Boolean)
39
+ : [defaultClaudeDesktopDataDir()];
40
+ }
41
+
42
+ function discoverDesktopRoots(dir, depth, roots, onWarning) {
43
+ let entries;
44
+ try {
45
+ entries = readdirSync(dir, { withFileTypes: true });
46
+ } catch (err) {
47
+ if (err?.code !== 'ENOENT') {
48
+ onWarning(`Claude Desktop: cannot read directory ${dir}: ${err.message}`);
49
+ }
50
+ return;
51
+ }
52
+
53
+ // Once a session root is found, do not descend into the user's Cowork files.
54
+ // Those directories can be large and are unrelated to Claude's transcript.
55
+ const claudeEntry = entries.find(
56
+ (entry) => entry.name === '.claude' && entry.isDirectory(),
57
+ );
58
+ if (claudeEntry) {
59
+ roots.push(join(dir, claudeEntry.name));
60
+ return;
61
+ }
62
+
63
+ for (const entry of entries) {
64
+ if (!entry.isDirectory()) continue;
65
+ if (DESKTOP_NON_SESSION_DIRS.has(entry.name)) continue;
66
+ const candidate = join(dir, entry.name);
67
+ if (depth < MAX_DESKTOP_DISCOVERY_DEPTH) {
68
+ discoverDesktopRoots(candidate, depth + 1, roots, onWarning);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Find the private Claude Code state roots created for Claude Desktop Cowork.
75
+ * Desktop Code itself uses the normal ~/.claude root, while Cowork isolates
76
+ * each local-agent session below the Electron user-data directory.
77
+ */
78
+ export function findClaudeDesktopRoots(
79
+ desktopDataDirs = getClaudeDesktopDataDirs(),
80
+ onWarning = () => {},
81
+ ) {
82
+ const roots = [];
83
+ for (const dataDir of desktopDataDirs) {
84
+ discoverDesktopRoots(
85
+ join(dataDir, 'local-agent-mode-sessions'),
86
+ 0,
87
+ roots,
88
+ onWarning,
89
+ );
90
+ }
91
+ return roots;
92
+ }
93
+
94
+ /**
95
+ * Return every Claude Code-compatible state root visible from this process.
96
+ *
97
+ * In addition to the default and CLAUDE_CONFIG_DIR, discover the documented
98
+ * multi-profile convention (~/.claude-work, ~/.claude-personal, ...). This is
99
+ * important for launchd/systemd and GUI processes, which commonly do not
100
+ * inherit the shell environment used to launch Claude Code.
101
+ *
102
+ * Claude Desktop Code uses the default Claude Code root. Cowork creates a
103
+ * private .claude root per local-agent session, so those roots are discovered
104
+ * recursively under the app's user-data directory.
105
+ *
106
+ * VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces all normal
107
+ * and Desktop discovery with a path.delimiter-separated root list.
108
+ */
109
+ export function getClaudeRoots({ onWarning = () => {} } = {}) {
110
+ const override = process.env.VIBE_USAGE_CLAUDE_DIRS?.trim();
111
+ const roots = override
112
+ ? override.split(delimiter).map(expandHome).filter(Boolean)
113
+ : [join(homedir(), '.claude')];
114
+
115
+ if (!override) {
116
+ const configured = process.env.CLAUDE_CONFIG_DIR?.trim();
117
+ if (configured) roots.push(expandHome(configured));
118
+
119
+ try {
120
+ for (const entry of readdirSync(homedir(), { withFileTypes: true })) {
121
+ // Profiles are sometimes symlinked, so let hasClaudeData() follow the
122
+ // entry instead of requiring Dirent.isDirectory() here.
123
+ if (!/^\.claude-.+/.test(entry.name)) continue;
124
+ const candidate = join(homedir(), entry.name);
125
+ if (hasClaudeData(candidate)) roots.push(candidate);
126
+ }
127
+ } catch {
128
+ // The default/configured roots remain usable if home discovery fails.
129
+ }
130
+
131
+ for (const root of findClaudeDesktopRoots(getClaudeDesktopDataDirs(), onWarning)) {
132
+ roots.push(root);
133
+ }
134
+ }
135
+
136
+ const seen = new Set();
137
+ const unique = [];
138
+ for (const root of roots) {
139
+ let canonical = root;
140
+ try {
141
+ canonical = realpathSync(root);
142
+ } catch {
143
+ // Keep a missing explicit/default root so callers can report it normally.
144
+ }
145
+ if (seen.has(canonical)) continue;
146
+ seen.add(canonical);
147
+ unique.push(root);
148
+ }
149
+ return unique;
150
+ }
151
+
152
+ export function findClaudeCodeDataDirs() {
153
+ const dirs = [];
154
+ for (const root of getClaudeRoots()) {
155
+ for (const name of ['projects', 'transcripts']) {
156
+ const candidate = join(root, name);
157
+ try {
158
+ if (statSync(candidate).isDirectory()) dirs.push(candidate);
159
+ } catch {
160
+ // Missing or unreadable roots are handled by the parser.
161
+ }
162
+ }
163
+ }
164
+ return dirs;
165
+ }
@@ -0,0 +1,40 @@
1
+ import { statSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const EXTENSION_ID = 'saoudrizwan.claude-dev';
6
+ const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
7
+
8
+ function hasTaskHistory(root) {
9
+ try {
10
+ return statSync(join(root, 'state', 'taskHistory.json')).isFile();
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ function hostRoots() {
17
+ const out = [];
18
+ if (process.platform === 'darwin') {
19
+ const base = join(homedir(), 'Library', 'Application Support');
20
+ for (const host of HOSTS) out.push(join(base, host));
21
+ } else if (process.platform === 'win32') {
22
+ const base = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
23
+ for (const host of HOSTS) out.push(join(base, host));
24
+ } else {
25
+ const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
26
+ for (const host of HOSTS) out.push(join(base, host));
27
+ }
28
+ return out;
29
+ }
30
+
31
+ export function findClineDataDirs() {
32
+ const override = process.env.VIBE_USAGE_CLINE_DIRS?.trim();
33
+ const candidates = override
34
+ ? override.split(delimiter).map((value) => value.trim()).filter(Boolean)
35
+ : [
36
+ join(homedir(), '.cline'),
37
+ ...hostRoots().map((root) => join(root, 'User', 'globalStorage', EXTENSION_ID)),
38
+ ];
39
+ return [...new Set(candidates)].filter(hasTaskHistory);
40
+ }
@@ -0,0 +1,46 @@
1
+ import { statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ function normalizeHomePath(value) {
6
+ const trimmed = value.trim();
7
+ if (trimmed === '~') return homedir();
8
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
9
+ return resolve(homedir(), trimmed.slice(2));
10
+ }
11
+ return resolve(trimmed);
12
+ }
13
+
14
+ export function primaryCodexHome() {
15
+ const configured = process.env.CODEX_HOME?.trim();
16
+ return configured ? normalizeHomePath(configured) : join(homedir(), '.codex');
17
+ }
18
+
19
+ export function resolveCodexHomes(extraCodexHome) {
20
+ const roots = [primaryCodexHome()];
21
+ if (extraCodexHome?.trim()) roots.push(normalizeHomePath(extraCodexHome));
22
+ return [...new Set(roots)];
23
+ }
24
+
25
+ export function codexSessionDirs(codexHome) {
26
+ return [
27
+ join(codexHome, 'sessions'),
28
+ join(codexHome, 'archived_sessions'),
29
+ ];
30
+ }
31
+
32
+ function isDirectory(path) {
33
+ try {
34
+ return statSync(path).isDirectory();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export function validateExtraCodexHome(value) {
41
+ const path = normalizeHomePath(value);
42
+ return {
43
+ ok: isDirectory(path) && codexSessionDirs(path).some(isDirectory),
44
+ path,
45
+ };
46
+ }
@@ -0,0 +1,15 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ export function getCraftWorkspacesDir() {
6
+ const root = process.env.CRAFT_AGENT_DIR?.trim()
7
+ || process.env.CRAFTAGENT_DIR?.trim()
8
+ || join(homedir(), '.craft-agent');
9
+ return join(root, 'workspaces');
10
+ }
11
+
12
+ export function findCraftDataDirs() {
13
+ const workspaces = getCraftWorkspacesDir();
14
+ return existsSync(workspaces) ? [workspaces] : [];
15
+ }
@@ -0,0 +1,312 @@
1
+ import { accessSync, closeSync, constants, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { codexSessionDirs } from './codex-roots.js';
5
+
6
+ export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok', 'pi-coding-agent'];
7
+
8
+ // Probing a candidate Pi store has three outcomes, never two: a confirmed
9
+ // session, a directory proven to hold none, and one that could not be read.
10
+ // Collapsing the last two into a single false is what let an unreadable subtree
11
+ // be treated as an empty one.
12
+ const PI_SESSIONS_FOUND = 'found';
13
+ const PI_SESSIONS_ABSENT = 'absent';
14
+ const PI_SESSIONS_UNREADABLE = 'unreadable';
15
+
16
+ export function extraRootList(value) {
17
+ return Array.isArray(value) ? value.filter(root => typeof root === 'string' && root.trim()) : [];
18
+ }
19
+
20
+ export function normalizeExtraRoot(value) {
21
+ const trimmed = value.trim();
22
+ if (trimmed === '~') return homedir();
23
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
24
+ return resolve(homedir(), trimmed.slice(2));
25
+ }
26
+ return resolve(trimmed);
27
+ }
28
+
29
+ function isReadableDirectory(path) {
30
+ try {
31
+ if (!statSync(path).isDirectory()) return false;
32
+ accessSync(path, constants.R_OK);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function isCodexHome(path) {
40
+ return isReadableDirectory(path) && codexSessionDirs(path).some(isReadableDirectory);
41
+ }
42
+
43
+ // Multica stores task-local Codex homes below a bounded
44
+ // <container>/<workspace>/<task>/codex-home hierarchy. Do not follow symlinks
45
+ // or descend beyond that shape: configured containers may also contain large
46
+ // workdirs that are unrelated to usage logs.
47
+ export function discoverCodexHomes(value, maxDepth = 3) {
48
+ const root = normalizeExtraRoot(value);
49
+ if (isCodexHome(root)) return { root, homes: [root], readable: true };
50
+ if (!isReadableDirectory(root)) return { root, homes: [], readable: false };
51
+
52
+ const homes = [];
53
+ const queue = [{ path: root, depth: 0 }];
54
+ let readable = true;
55
+ while (queue.length > 0) {
56
+ const current = queue.shift();
57
+ let children;
58
+ try {
59
+ children = readdirSync(current.path, { withFileTypes: true });
60
+ } catch {
61
+ readable = false;
62
+ continue;
63
+ }
64
+ for (const child of children) {
65
+ // Dirent#isDirectory is false for symbolic links, so traversal stays
66
+ // inside the explicitly selected tree.
67
+ if (!child.isDirectory()) continue;
68
+ const childPath = join(current.path, child.name);
69
+ const depth = current.depth + 1;
70
+ if (basename(childPath) === 'codex-home' && isCodexHome(childPath)) {
71
+ homes.push(childPath);
72
+ continue;
73
+ }
74
+ if (depth < maxDepth) queue.push({ path: childPath, depth });
75
+ }
76
+ }
77
+ return { root, homes: [...new Set(homes)], readable };
78
+ }
79
+
80
+ export function grokSessionsDir(value) {
81
+ return join(normalizeExtraRoot(value), 'sessions');
82
+ }
83
+
84
+ export function antigravityConversationDirs(value) {
85
+ const root = normalizeExtraRoot(value);
86
+ return [
87
+ join(root, '.gemini', 'antigravity', 'conversations'),
88
+ join(root, '.gemini', 'antigravity-cli', 'conversations'),
89
+ ];
90
+ }
91
+
92
+ // Pi's own discoverable settings (PI_CODING_AGENT_SESSION_DIR, `sessionDir` in
93
+ // settings.json) name a sessions directory directly, and a harness that calls
94
+ // `pi --session <file>` writes bare session trees with no agent home above
95
+ // them. Accept either shape, but resolve to exactly one directory per root:
96
+ // the parser walks nested directories, so returning both a root and its
97
+ // `sessions/` child would read every file twice. Canonical-path dedup in the
98
+ // parser makes the overlap harmless even for a mixed root that holds sessions
99
+ // directly *and* under `sessions/`.
100
+ //
101
+ // Every candidate is confirmed by content, never by name alone. A readable but
102
+ // unconfirmed `sessions/` child used to win unconditionally, so creating an
103
+ // empty `<root>/sessions` was enough to redirect the scan away from a bare
104
+ // store's own files — a successful sync reporting zero, which then pruned the
105
+ // source's incremental state.
106
+ //
107
+ // The shape is re-resolved on every run rather than remembered from add-root
108
+ // time, and an unresolvable root returns null instead of falling back to the
109
+ // root itself. Falling back would turn an agent home that lost its `sessions/`
110
+ // child into a readable directory holding no sessions, i.e. exactly the silent
111
+ // zero this feature exists to prevent.
112
+ //
113
+ // Probing is tri-state on purpose. A boolean collapsed "holds no sessions" into
114
+ // "could not be read", so making one sibling store unreadable was enough to
115
+ // make a container look like an agent home and narrow the scan to `sessions/`,
116
+ // dropping every readable sibling with no `skipped` flag. Absence has to be
117
+ // proven; where it is only assumed, resolution gives up and the caller skips.
118
+ export function piSessionsDir(value) {
119
+ const root = normalizeExtraRoot(value);
120
+ if (!isReadableDirectory(root)) return null;
121
+ // A bare store is identified by the session files it holds directly, and
122
+ // outranks any `sessions/` child: those files are what the user configured.
123
+ const direct = probePiSessions(root, 0);
124
+ if (direct === PI_SESSIONS_FOUND) return root;
125
+
126
+ const outside = probePiSessionsOutsideNested(root);
127
+ // Both decisions below rest on absence: that the root holds no sessions
128
+ // directly, and that no sibling of `sessions/` holds any. An unreadable
129
+ // candidate proves neither, so stop rather than narrow past it.
130
+ if (direct === PI_SESSIONS_UNREADABLE || outside === PI_SESSIONS_UNREADABLE) return null;
131
+
132
+ const nested = join(root, 'sessions');
133
+ // Agent-home shape: narrowing to the child is only safe when every confirmed
134
+ // session lives below it. A container whose per-task stores happen to include
135
+ // one named `sessions` is still a container, and resolving it to that child
136
+ // would drop all its siblings — the same silent undercount as above.
137
+ if (
138
+ outside === PI_SESSIONS_ABSENT
139
+ && isReadableDirectory(nested)
140
+ && probePiSessions(nested) === PI_SESSIONS_FOUND
141
+ ) {
142
+ return nested;
143
+ }
144
+ // A configured container holding per-task stores somewhere below it. Anything
145
+ // else — no sessions at all, or a subtree that could not be read — resolves
146
+ // to null, which the parser reports as skipped instead of as an empty sync.
147
+ return probePiSessions(root) === PI_SESSIONS_FOUND ? root : null;
148
+ }
149
+
150
+ // Confirmed sessions in some child other than `sessions/`. The per-child depth
151
+ // is one less than the root scan's own so both reach the same files.
152
+ function probePiSessionsOutsideNested(root) {
153
+ let children;
154
+ try {
155
+ children = readdirSync(root, { withFileTypes: true });
156
+ } catch {
157
+ return PI_SESSIONS_UNREADABLE;
158
+ }
159
+ let unreadable = false;
160
+ for (const child of children) {
161
+ if (!child.isDirectory() || child.name === 'sessions') continue;
162
+ const probe = probePiSessions(join(root, child.name), 1);
163
+ if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
164
+ if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
165
+ }
166
+ return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
167
+ }
168
+
169
+ const PI_PROBE_BYTES = 16 * 1024;
170
+ const PI_PROBE_LINES = 10;
171
+ // Roles the Pi parser turns into events; anything else contributes nothing.
172
+ const PI_MESSAGE_ROLES = new Set(['user', 'assistant', 'toolResult']);
173
+
174
+ function isNonEmptyString(value) {
175
+ return typeof value === 'string' && value.trim() !== '';
176
+ }
177
+
178
+ // Pi's session format opens a store with a full SessionHeader. `version` is
179
+ // written as both a number and a string across real stores, so only its
180
+ // presence is required.
181
+ function isPiSessionHeader(obj) {
182
+ return obj.type === 'session'
183
+ && obj.version !== undefined
184
+ && obj.version !== null
185
+ && isNonEmptyString(obj.id)
186
+ && isNonEmptyString(obj.timestamp)
187
+ && typeof obj.cwd === 'string';
188
+ }
189
+
190
+ // A store an external harness appends to may carry no header inside the probed
191
+ // prefix, so a message record alone can confirm the directory — but only a
192
+ // complete one. Matching on `type` and an object-valued `message` accepted
193
+ // `{"type":"message","message":{}}`, which the parser reads to exactly zero.
194
+ function isPiSessionMessage(obj) {
195
+ return obj.type === 'message'
196
+ && isNonEmptyString(obj.id)
197
+ && 'parentId' in obj
198
+ && isNonEmptyString(obj.timestamp)
199
+ && Boolean(obj.message)
200
+ && typeof obj.message === 'object'
201
+ && PI_MESSAGE_ROLES.has(obj.message.role);
202
+ }
203
+
204
+ // A `.jsonl` extension proves nothing: an unrelated log would validate an
205
+ // entirely wrong directory, which the parser then ignores without complaining.
206
+ // Neither does a bare `type` name — validation has to require the fields the
207
+ // parser reads, or a malformed lookalike is accepted and still syncs zero.
208
+ // A file that cannot be opened is reported as unreadable, not as "not a
209
+ // session": it may well be the store the user configured.
210
+ function probePiSessionFile(filePath) {
211
+ let text;
212
+ let fd;
213
+ try {
214
+ fd = openSync(filePath, 'r');
215
+ const buffer = Buffer.alloc(PI_PROBE_BYTES);
216
+ const read = readSync(fd, buffer, 0, PI_PROBE_BYTES, 0);
217
+ text = buffer.subarray(0, read).toString('utf8');
218
+ // A prefix read can cut the final line in half; drop the partial tail.
219
+ if (read === PI_PROBE_BYTES) text = text.slice(0, text.lastIndexOf('\n') + 1);
220
+ } catch {
221
+ return PI_SESSIONS_UNREADABLE;
222
+ } finally {
223
+ if (fd !== undefined) {
224
+ try {
225
+ closeSync(fd);
226
+ } catch { /* already closed */ }
227
+ }
228
+ }
229
+
230
+ let checked = 0;
231
+ for (const line of text.split('\n')) {
232
+ if (!line.trim()) continue;
233
+ if (++checked > PI_PROBE_LINES) return PI_SESSIONS_ABSENT;
234
+ let obj;
235
+ try {
236
+ obj = JSON.parse(line);
237
+ } catch {
238
+ continue;
239
+ }
240
+ if (!obj || typeof obj !== 'object') continue;
241
+ if (isPiSessionHeader(obj) || isPiSessionMessage(obj)) return PI_SESSIONS_FOUND;
242
+ }
243
+ return PI_SESSIONS_ABSENT;
244
+ }
245
+
246
+ // A session store is only recognizable by the Pi session files in it. Stay
247
+ // shallow: a configured root may sit next to large unrelated trees.
248
+ //
249
+ // `found` outranks `unreadable`: one confirmed session is enough to resolve the
250
+ // shape, and the shared parser reports whatever it cannot read on the way in.
251
+ // `unreadable` outranks `absent`, so a caller never mistakes a subtree it could
252
+ // not open for one it proved empty.
253
+ function probePiSessions(dir, depth = 2) {
254
+ let children;
255
+ try {
256
+ children = readdirSync(dir, { withFileTypes: true });
257
+ } catch {
258
+ return PI_SESSIONS_UNREADABLE;
259
+ }
260
+ let unreadable = false;
261
+ // Dirent#isDirectory is false for symbolic links, matching the parser's own
262
+ // walk: linked session files are read, linked directories are not entered.
263
+ for (const child of children) {
264
+ if (child.isDirectory() || !child.name.endsWith('.jsonl')) continue;
265
+ const probe = probePiSessionFile(join(dir, child.name));
266
+ if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
267
+ if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
268
+ }
269
+ if (depth > 0) {
270
+ for (const child of children) {
271
+ if (!child.isDirectory()) continue;
272
+ const probe = probePiSessions(join(dir, child.name), depth - 1);
273
+ if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
274
+ if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
275
+ }
276
+ }
277
+ return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
278
+ }
279
+
280
+ export function validateExtraRoot(source, value) {
281
+ if (!EXTRA_ROOT_SOURCES.includes(source)) {
282
+ return { ok: false, path: value, reason: `不支持的工具: ${source}` };
283
+ }
284
+ const path = normalizeExtraRoot(value);
285
+ if (source === 'codex') {
286
+ const result = discoverCodexHomes(path);
287
+ return {
288
+ ok: result.readable && result.homes.length > 0,
289
+ path,
290
+ reason: '需要是 Codex Home,或包含 */*/codex-home 的 Multica 容器',
291
+ };
292
+ }
293
+ if (source === 'pi-coding-agent') {
294
+ // piSessionsDir only returns a directory it has already confirmed by
295
+ // content, so there is nothing left to re-check here.
296
+ return {
297
+ ok: piSessionsDir(path) !== null,
298
+ path,
299
+ reason: '需要是直接包含 Pi 会话 .jsonl 的目录,或包含 sessions/ 的 Pi agent 目录',
300
+ };
301
+ }
302
+ const dirs = source === 'grok'
303
+ ? [grokSessionsDir(path)]
304
+ : antigravityConversationDirs(path);
305
+ return {
306
+ ok: dirs.some(isReadableDirectory),
307
+ path,
308
+ reason: source === 'grok'
309
+ ? '需要包含 sessions/'
310
+ : '需要包含 .gemini/antigravity*/conversations/',
311
+ };
312
+ }