@eiei114/pi-sub-core 1.5.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 (52) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +178 -0
  3. package/index.ts +540 -0
  4. package/package.json +35 -0
  5. package/src/cache.ts +546 -0
  6. package/src/config.ts +35 -0
  7. package/src/dependencies.ts +37 -0
  8. package/src/errors.ts +71 -0
  9. package/src/paths.ts +55 -0
  10. package/src/provider.ts +66 -0
  11. package/src/providers/detection.ts +51 -0
  12. package/src/providers/impl/anthropic.ts +174 -0
  13. package/src/providers/impl/antigravity.ts +226 -0
  14. package/src/providers/impl/codex.ts +186 -0
  15. package/src/providers/impl/copilot.ts +176 -0
  16. package/src/providers/impl/gemini.ts +130 -0
  17. package/src/providers/impl/kiro.ts +92 -0
  18. package/src/providers/impl/zai.ts +120 -0
  19. package/src/providers/index.ts +5 -0
  20. package/src/providers/metadata.ts +16 -0
  21. package/src/providers/registry.ts +54 -0
  22. package/src/providers/settings.ts +109 -0
  23. package/src/providers/status.ts +25 -0
  24. package/src/settings/behavior.ts +58 -0
  25. package/src/settings/menu.ts +83 -0
  26. package/src/settings/tools.ts +38 -0
  27. package/src/settings/ui.ts +450 -0
  28. package/src/settings-types.ts +95 -0
  29. package/src/settings-ui.ts +1 -0
  30. package/src/settings.ts +137 -0
  31. package/src/status.ts +245 -0
  32. package/src/storage/lock.ts +150 -0
  33. package/src/storage.ts +61 -0
  34. package/src/types.ts +33 -0
  35. package/src/ui/keybindings.ts +92 -0
  36. package/src/ui/settings-list.ts +290 -0
  37. package/src/usage/controller.ts +250 -0
  38. package/src/usage/fetch.ts +215 -0
  39. package/src/usage/types.ts +5 -0
  40. package/src/utils.ts +158 -0
  41. package/test/all.test.ts +9 -0
  42. package/test/cache.test.ts +157 -0
  43. package/test/controller.test.ts +101 -0
  44. package/test/detection.test.ts +24 -0
  45. package/test/extension.test.ts +233 -0
  46. package/test/helpers.ts +48 -0
  47. package/test/keybindings.test.ts +59 -0
  48. package/test/lock.test.ts +49 -0
  49. package/test/prioritize.test.ts +81 -0
  50. package/test/providers.test.ts +385 -0
  51. package/test/status.test.ts +70 -0
  52. package/tsconfig.json +5 -0
@@ -0,0 +1,233 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
4
+ import createExtension from '../index.js';
5
+ import { CACHE_PATH, clearCache } from '../src/cache.js';
6
+ import { SETTINGS_PATH } from '../src/settings.js';
7
+ import { getStorage, setStorage, type StorageAdapter } from '../src/storage.js';
8
+ import { createDeps, getAuthPath } from './helpers.js';
9
+
10
+ interface PiHarness {
11
+ pi: ExtensionAPI;
12
+ emitLifecycle: (event: string, ctx: ExtensionContext) => Promise<void>;
13
+ }
14
+
15
+ /**
16
+ * Build a minimal ExtensionAPI mock that captures lifecycle handlers
17
+ * (`pi.on(event, handler)`) and exposes them via `emitLifecycle` for
18
+ * deterministic invocation in tests.
19
+ */
20
+ function createPiHarness(): PiHarness {
21
+ const lifecycle = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
22
+ const eventListeners = new Map<string, Array<(payload: unknown) => unknown>>();
23
+
24
+ const events = {
25
+ on(event: string, handler: (payload: unknown) => unknown) {
26
+ const list = eventListeners.get(event) ?? [];
27
+ list.push(handler);
28
+ eventListeners.set(event, list);
29
+ },
30
+ emit(event: string, payload?: unknown) {
31
+ for (const handler of eventListeners.get(event) ?? []) {
32
+ handler(payload);
33
+ }
34
+ },
35
+ };
36
+
37
+ const pi = {
38
+ events,
39
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
40
+ const list = lifecycle.get(event) ?? [];
41
+ list.push(handler);
42
+ lifecycle.set(event, list);
43
+ },
44
+ registerCommand: () => {},
45
+ registerTool: () => {},
46
+ registerShortcut: () => {},
47
+ registerFlag: () => {},
48
+ getFlag: () => undefined,
49
+ registerMessageRenderer: () => {},
50
+ sendMessage: () => {},
51
+ sendUserMessage: () => {},
52
+ appendEntry: () => {},
53
+ setSessionName: () => {},
54
+ getSessionName: () => undefined,
55
+ setLabel: () => {},
56
+ exec: async () => ({ code: 0, stdout: '', stderr: '' }),
57
+ getActiveTools: () => [],
58
+ getAllTools: () => [],
59
+ setActiveTools: () => {},
60
+ setModel: async () => true,
61
+ getThinkingLevel: () => 'high',
62
+ setThinkingLevel: () => {},
63
+ registerProvider: () => {},
64
+ } as unknown as ExtensionAPI;
65
+
66
+ return {
67
+ pi,
68
+ async emitLifecycle(event, ctx) {
69
+ for (const handler of lifecycle.get(event) ?? []) {
70
+ await handler({ type: event }, ctx);
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ function createMemoryStorage(): { storage: StorageAdapter; files: Map<string, string> } {
77
+ const files = new Map<string, string>();
78
+ const storage: StorageAdapter = {
79
+ readFile: (filePath) => files.get(filePath),
80
+ writeFile: (filePath, contents) => {
81
+ files.set(filePath, contents);
82
+ },
83
+ writeFileExclusive: (filePath, contents) => {
84
+ if (files.has(filePath)) return false;
85
+ files.set(filePath, contents);
86
+ return true;
87
+ },
88
+ exists: (filePath) => files.has(filePath),
89
+ removeFile: (filePath) => {
90
+ files.delete(filePath);
91
+ },
92
+ ensureDir: () => {},
93
+ };
94
+ return { storage, files };
95
+ }
96
+
97
+ function createCtx(): ExtensionContext {
98
+ return {
99
+ ui: {
100
+ select: async () => undefined,
101
+ confirm: async () => false,
102
+ input: async () => undefined,
103
+ notify: () => {},
104
+ setStatus: () => {},
105
+ setWorkingMessage: () => {},
106
+ setWidget: () => {},
107
+ setFooter: () => {},
108
+ setHeader: () => {},
109
+ setTitle: () => {},
110
+ custom: async () => undefined,
111
+ setEditorText: () => {},
112
+ },
113
+ hasUI: false,
114
+ cwd: '/tmp/project',
115
+ sessionManager: {} as ExtensionContext['sessionManager'],
116
+ modelRegistry: {} as ExtensionContext['modelRegistry'],
117
+ model: { provider: 'anthropic', id: 'claude-opus-4-7' } as ExtensionContext['model'],
118
+ isIdle: () => true,
119
+ abort: () => {},
120
+ hasPendingMessages: () => false,
121
+ shutdown: () => {},
122
+ getContextUsage: () => undefined,
123
+ compact: () => {},
124
+ getSystemPrompt: () => '',
125
+ } as ExtensionContext;
126
+ }
127
+
128
+ function resetGlobal(): void {
129
+ const g = globalThis as typeof globalThis & { __piSubCore?: { active: boolean } };
130
+ g.__piSubCore = undefined;
131
+ }
132
+
133
+ /**
134
+ * Regression for marckrenn/pi-sub#58.
135
+ *
136
+ * Before the fix, `turn_end` and `tool_result` (with `refreshOnToolResult` enabled)
137
+ * called `refresh(ctx, { force: true })`. That bypassed the cache TTL and triggered
138
+ * an upstream fetch on every turn, hammering rate-limited endpoints (e.g. anthropic
139
+ * OAuth `/usage`) and, when the endpoint started returning 429, leaving the cache
140
+ * stuck because `fetchWithCache` refuses to write entries with errors.
141
+ *
142
+ * With the fix, both handlers call `refresh(ctx)` without `force`, so the existing
143
+ * `behavior.refreshInterval` (60s) and `behavior.minRefreshInterval` (10s) gates
144
+ * correctly suppress redundant network calls when the cache is fresh.
145
+ */
146
+ test('turn_end and tool_result do not bypass the cache TTL when the entry is fresh', async () => {
147
+ const originalStorage = getStorage();
148
+ const { storage, files } = createMemoryStorage();
149
+ setStorage(storage);
150
+ clearCache();
151
+ resetGlobal();
152
+
153
+ try {
154
+ const home = '/home/test';
155
+ let fetchCount = 0;
156
+ const { deps, files: depFiles } = createDeps({
157
+ fetch: async () => {
158
+ fetchCount += 1;
159
+ return {
160
+ ok: true,
161
+ status: 200,
162
+ json: async () => ({}),
163
+ } as Response;
164
+ },
165
+ homedir: home,
166
+ });
167
+ // anthropic provider reads ~/.pi/agent/auth.json under `anthropic.access`.
168
+ depFiles.set(getAuthPath(home), JSON.stringify({ anthropic: { access: 'token' } }));
169
+
170
+ // Pre-populate the cache: 30s old. That's past the 10s minRefreshInterval but
171
+ // well within the 60s ttl. Without `force`, fetchUsageForProvider must return
172
+ // the cached value; with `force: true` (the bug) it bypasses the ttl and fetches.
173
+ const fetchedAt = Date.now() - 30_000;
174
+ files.set(
175
+ CACHE_PATH,
176
+ JSON.stringify({
177
+ anthropic: {
178
+ fetchedAt,
179
+ statusFetchedAt: fetchedAt,
180
+ usage: {
181
+ provider: 'anthropic',
182
+ displayName: 'Anthropic (Claude)',
183
+ windows: [
184
+ { label: '5h', usedPercent: 12, resetDescription: '4h' },
185
+ { label: 'Week', usedPercent: 25, resetDescription: '3d' },
186
+ ],
187
+ },
188
+ status: { indicator: 'none' },
189
+ },
190
+ }),
191
+ );
192
+
193
+ // Enable refresh on tool result so the tool_result handler actually calls refresh.
194
+ files.set(
195
+ SETTINGS_PATH,
196
+ JSON.stringify({
197
+ version: 3,
198
+ behavior: { refreshOnToolResult: true, refreshInterval: 60, minRefreshInterval: 10 },
199
+ statusRefresh: { refreshOnToolResult: true, refreshInterval: 60, minRefreshInterval: 10 },
200
+ providers: { anthropic: { enabled: 'on', fetchStatus: false } },
201
+ }),
202
+ );
203
+
204
+ const harness = createPiHarness();
205
+ createExtension(harness.pi, deps);
206
+
207
+ const ctx = createCtx();
208
+ await harness.emitLifecycle('session_start', ctx);
209
+ // session_start emits with skipFetch:true, so it should not hit the network.
210
+ assert.equal(fetchCount, 0, 'session_start should never fetch');
211
+
212
+ await harness.emitLifecycle('turn_end', ctx);
213
+ assert.equal(
214
+ fetchCount,
215
+ 0,
216
+ 'turn_end must respect cache TTL and not refetch when the entry is fresh',
217
+ );
218
+
219
+ await harness.emitLifecycle('tool_result', ctx);
220
+ assert.equal(
221
+ fetchCount,
222
+ 0,
223
+ 'tool_result must respect cache TTL and not refetch when the entry is fresh',
224
+ );
225
+
226
+ // Cleanup so the global guard, intervals and watchers do not leak across tests.
227
+ await harness.emitLifecycle('session_shutdown', ctx);
228
+ } finally {
229
+ resetGlobal();
230
+ setStorage(originalStorage);
231
+ clearCache();
232
+ }
233
+ });
@@ -0,0 +1,48 @@
1
+ import path from "node:path";
2
+ import type { Dependencies } from "../src/types.js";
3
+
4
+ export type FetchResponse<T> = {
5
+ ok: boolean;
6
+ status: number;
7
+ json: () => Promise<T>;
8
+ };
9
+
10
+ export function createJsonResponse<T>(data: T, init?: { ok?: boolean; status?: number }): FetchResponse<T> {
11
+ return {
12
+ ok: init?.ok ?? true,
13
+ status: init?.status ?? 200,
14
+ json: async () => data,
15
+ };
16
+ }
17
+
18
+ export function createDeps(options?: {
19
+ files?: Record<string, string>;
20
+ fetch?: Dependencies["fetch"];
21
+ execFileSync?: Dependencies["execFileSync"];
22
+ env?: NodeJS.ProcessEnv;
23
+ homedir?: string;
24
+ }): { deps: Dependencies; files: Map<string, string> } {
25
+ const files = new Map<string, string>(Object.entries(options?.files ?? {}));
26
+ const homedir = options?.homedir ?? "/home/test";
27
+
28
+ const deps: Dependencies = {
29
+ fetch: options?.fetch
30
+ ?? (async () => {
31
+ throw new Error("fetch not mocked");
32
+ }),
33
+ readFile: (filePath: string) => files.get(filePath),
34
+ fileExists: (filePath: string) => files.has(filePath),
35
+ execFileSync: options?.execFileSync
36
+ ?? (() => {
37
+ throw new Error("execFileSync not mocked");
38
+ }),
39
+ homedir: () => homedir,
40
+ env: options?.env ?? {},
41
+ };
42
+
43
+ return { deps, files };
44
+ }
45
+
46
+ export function getAuthPath(home: string): string {
47
+ return path.join(home, ".pi", "agent", "auth.json");
48
+ }
@@ -0,0 +1,59 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createSettingsKeybindings } from "../src/ui/keybindings.js";
4
+
5
+ test("settings keybindings prefer editor keybindings when available", () => {
6
+ const kb = createSettingsKeybindings({
7
+ getEditorKeybindings: () => ({
8
+ matches: (data, action) => data === "X" && action === "selectDown",
9
+ }),
10
+ getKeybindings: () => ({
11
+ matches: () => {
12
+ throw new Error("legacy keybindings should not be used when editor keybindings exist");
13
+ },
14
+ }),
15
+ });
16
+
17
+ assert.equal(kb.matches("X", "selectDown"), true);
18
+ assert.equal(kb.matches("X", "selectUp"), false);
19
+ });
20
+
21
+ test("settings keybindings map actions to legacy keybinding IDs", () => {
22
+ const seen: string[] = [];
23
+ const kb = createSettingsKeybindings({
24
+ getKeybindings: () => ({
25
+ matches: (_data, action) => {
26
+ seen.push(action);
27
+ return action === "tui.select.down";
28
+ },
29
+ }),
30
+ });
31
+
32
+ assert.equal(kb.matches("ignored", "selectDown"), true);
33
+ assert.equal(kb.matches("ignored", "cursorLeft"), false);
34
+ assert.deepEqual(seen, ["tui.select.down", "tui.editor.cursorLeft"]);
35
+ });
36
+
37
+ test("settings keybindings fallback uses matchesKey helper when no keybinding manager exists", () => {
38
+ const kb = createSettingsKeybindings({
39
+ matchesKey: (data, key) => data === `<<${key}>>`,
40
+ });
41
+
42
+ assert.equal(kb.matches("<<up>>", "selectUp"), true);
43
+ assert.equal(kb.matches("<<down>>", "selectDown"), true);
44
+ assert.equal(kb.matches("<<left>>", "cursorLeft"), true);
45
+ assert.equal(kb.matches("<<enter>>", "selectConfirm"), true);
46
+ assert.equal(kb.matches("<<escape>>", "selectCancel"), true);
47
+ assert.equal(kb.matches("<<right>>", "cursorLeft"), false);
48
+ });
49
+
50
+ test("settings keybindings fallback handles raw escape sequences", () => {
51
+ const kb = createSettingsKeybindings({});
52
+
53
+ assert.equal(kb.matches("\u001b[A", "selectUp"), true);
54
+ assert.equal(kb.matches("\u001b[B", "selectDown"), true);
55
+ assert.equal(kb.matches("\u001b[D", "cursorLeft"), true);
56
+ assert.equal(kb.matches("\u001b[C", "cursorRight"), true);
57
+ assert.equal(kb.matches("\r", "selectConfirm"), true);
58
+ assert.equal(kb.matches("\u001b", "selectCancel"), true);
59
+ });
@@ -0,0 +1,49 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getStorage, setStorage, type StorageAdapter } from "../src/storage.js";
4
+ import { tryAcquireFileLock, releaseFileLock } from "../src/storage/lock.js";
5
+
6
+ function createMemoryStorage(): { storage: StorageAdapter; files: Map<string, string> } {
7
+ const files = new Map<string, string>();
8
+ const storage: StorageAdapter = {
9
+ readFile: (filePath) => files.get(filePath),
10
+ writeFile: (filePath, contents) => {
11
+ files.set(filePath, contents);
12
+ },
13
+ writeFileExclusive: (filePath, contents) => {
14
+ if (files.has(filePath)) return false;
15
+ files.set(filePath, contents);
16
+ return true;
17
+ },
18
+ exists: (filePath) => files.has(filePath),
19
+ removeFile: (filePath) => {
20
+ files.delete(filePath);
21
+ },
22
+ ensureDir: () => {},
23
+ };
24
+ return { storage, files };
25
+ }
26
+
27
+ test("tryAcquireFileLock replaces stale locks with owned tokens", () => {
28
+ const { storage, files } = createMemoryStorage();
29
+ const originalStorage = getStorage();
30
+ setStorage(storage);
31
+
32
+ try {
33
+ const lockPath = "/tmp/lock";
34
+ const firstToken = tryAcquireFileLock(lockPath, 10);
35
+ assert.ok(firstToken);
36
+ files.set(lockPath, String(Date.now() - 1000));
37
+ const secondToken = tryAcquireFileLock(lockPath, 10);
38
+ assert.ok(secondToken);
39
+ assert.notEqual(secondToken, firstToken);
40
+
41
+ releaseFileLock(lockPath, firstToken ?? undefined);
42
+ assert.equal(files.has(lockPath), true);
43
+
44
+ releaseFileLock(lockPath, secondToken ?? undefined);
45
+ assert.equal(files.has(lockPath), false);
46
+ } finally {
47
+ setStorage(originalStorage);
48
+ }
49
+ });
@@ -0,0 +1,81 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { RateWindow } from "../src/types.js";
4
+ import { prioritizeWindowsForModel } from "../src/utils.js";
5
+
6
+ function window(label: string, usedPercent = 0): RateWindow {
7
+ return { label, usedPercent };
8
+ }
9
+
10
+ test("codex spark windows are moved before general windows", () => {
11
+ const windows = [
12
+ window("5h", 0),
13
+ window("Week", 1),
14
+ window("GPT-5.3-Codex-Spark 5h", 2),
15
+ window("GPT-5.3-Codex-Spark Week", 3),
16
+ ];
17
+
18
+ const result = prioritizeWindowsForModel(windows, { id: "gpt-5.3-codex-spark" });
19
+
20
+ assert.deepEqual(
21
+ result.map((w) => w.label),
22
+ ["GPT-5.3-Codex-Spark 5h", "GPT-5.3-Codex-Spark Week", "5h", "Week"],
23
+ );
24
+ });
25
+
26
+ test("antigravity current model window is moved first", () => {
27
+ const windows = [
28
+ window("Gemini 2.5 Pro"),
29
+ window("Gemini 3 Flash"),
30
+ window("Gemini 3 Pro"),
31
+ window("Gemini 3.5 Flash"),
32
+ ];
33
+
34
+ const result = prioritizeWindowsForModel(windows, { id: "gemini-3-pro" });
35
+
36
+ assert.deepEqual(
37
+ result.map((w) => w.label),
38
+ ["Gemini 3 Pro", "Gemini 2.5 Pro", "Gemini 3 Flash", "Gemini 3.5 Flash"],
39
+ );
40
+ });
41
+
42
+ test("returns original array when no windows match the model", () => {
43
+ const windows = [window("5h"), window("Week")];
44
+
45
+ const result = prioritizeWindowsForModel(windows, { id: "claude-3.5-sonnet" });
46
+
47
+ assert.equal(result, windows);
48
+ });
49
+
50
+ test("returns original array when all windows match", () => {
51
+ const windows = [
52
+ window("GPT-5.3-Codex-Spark 5h"),
53
+ window("GPT-5.3-Codex-Spark Week"),
54
+ ];
55
+
56
+ const result = prioritizeWindowsForModel(windows, { id: "gpt-5.3-codex-spark" });
57
+
58
+ assert.equal(result, windows);
59
+ });
60
+
61
+ test("non-spark codex model does not match spark windows", () => {
62
+ const windows = [
63
+ window("5h", 0),
64
+ window("Week", 1),
65
+ window("GPT-5.3-Codex-Spark 5h", 2),
66
+ window("GPT-5.3-Codex-Spark Week", 3),
67
+ ];
68
+
69
+ const result = prioritizeWindowsForModel(windows, { id: "gpt-5.3" });
70
+
71
+ assert.equal(result, windows);
72
+ });
73
+
74
+ test("returns original array when model is absent", () => {
75
+ const windows = [window("5h"), window("Week")];
76
+
77
+ assert.equal(prioritizeWindowsForModel(windows, undefined), windows);
78
+ assert.equal(prioritizeWindowsForModel(windows, null), windows);
79
+ assert.equal(prioritizeWindowsForModel(windows, { id: undefined }), windows);
80
+ assert.equal(prioritizeWindowsForModel(windows, { id: "" }), windows);
81
+ });