@llblab/pi-kit 0.25.0 → 0.26.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.
- package/BACKLOG.md +5 -1
- package/CHANGELOG.md +6 -0
- package/README.md +9 -7
- package/node_modules/@llblab/pi-actors/AGENTS.md +2 -0
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +4 -1
- package/node_modules/@llblab/pi-actors/LICENSE +21 -0
- package/node_modules/@llblab/pi-actors/README.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +1 -1
- package/node_modules/@llblab/pi-actors/package.json +4 -3
- package/node_modules/@llblab/pi-claude-usage/AGENTS.md +6 -3
- package/node_modules/@llblab/pi-claude-usage/BACKLOG.md +2 -1
- package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +8 -0
- package/node_modules/@llblab/pi-claude-usage/README.md +48 -3
- package/node_modules/@llblab/pi-claude-usage/index.ts +8 -1159
- package/node_modules/@llblab/pi-claude-usage/lib/extension.ts +30 -0
- package/node_modules/@llblab/pi-claude-usage/lib/fast.ts +24 -0
- package/node_modules/@llblab/pi-claude-usage/lib/query.ts +146 -0
- package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +297 -0
- package/node_modules/@llblab/pi-claude-usage/lib/status.ts +366 -0
- package/node_modules/@llblab/pi-claude-usage/lib/telegram.ts +44 -0
- package/node_modules/@llblab/pi-claude-usage/lib/usage-store.ts +221 -0
- package/node_modules/@llblab/pi-claude-usage/lib/usage.ts +128 -0
- package/node_modules/@llblab/pi-claude-usage/package.json +9 -5
- package/node_modules/@llblab/pi-clean-room/AGENTS.md +1 -0
- package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-clean-room/LICENSE +21 -0
- package/node_modules/@llblab/pi-clean-room/README.md +1 -1
- package/node_modules/@llblab/pi-clean-room/package.json +3 -2
- package/node_modules/@llblab/pi-codex-usage/AGENTS.md +9 -6
- package/node_modules/@llblab/pi-codex-usage/BACKLOG.md +2 -1
- package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +17 -0
- package/node_modules/@llblab/pi-codex-usage/README.md +75 -17
- package/node_modules/@llblab/pi-codex-usage/index.ts +8 -1602
- package/node_modules/@llblab/pi-codex-usage/lib/extension.ts +25 -0
- package/node_modules/@llblab/pi-codex-usage/lib/fast.ts +23 -0
- package/node_modules/@llblab/pi-codex-usage/lib/query.ts +368 -0
- package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +347 -0
- package/node_modules/@llblab/pi-codex-usage/lib/status.ts +435 -0
- package/node_modules/@llblab/pi-codex-usage/lib/telegram.ts +45 -0
- package/node_modules/@llblab/pi-codex-usage/lib/usage-store.ts +229 -0
- package/node_modules/@llblab/pi-codex-usage/lib/usage.ts +425 -0
- package/node_modules/@llblab/pi-codex-usage/package.json +11 -6
- package/node_modules/@llblab/pi-command-fast/AGENTS.md +7 -0
- package/node_modules/@llblab/pi-command-fast/BACKLOG.md +9 -0
- package/node_modules/@llblab/pi-command-fast/CHANGELOG.md +7 -0
- package/node_modules/@llblab/pi-command-fast/LICENSE +21 -0
- package/node_modules/@llblab/pi-command-fast/README.md +42 -0
- package/node_modules/@llblab/pi-command-fast/dist/command.d.ts +8 -0
- package/node_modules/@llblab/pi-command-fast/dist/command.js +52 -0
- package/node_modules/@llblab/pi-command-fast/dist/index.d.ts +3 -0
- package/node_modules/@llblab/pi-command-fast/dist/index.js +3 -0
- package/node_modules/@llblab/pi-command-fast/dist/models-json.d.ts +10 -0
- package/node_modules/@llblab/pi-command-fast/dist/models-json.js +81 -0
- package/node_modules/@llblab/pi-command-fast/package.json +49 -0
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -0
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -1
- package/node_modules/@llblab/pi-grow-loop/LICENSE +21 -0
- package/node_modules/@llblab/pi-grow-loop/README.md +1 -1
- package/node_modules/@llblab/pi-grow-loop/package.json +3 -2
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -5
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +7 -1
- package/node_modules/@llblab/pi-state-flow/LICENSE +21 -0
- package/node_modules/@llblab/pi-state-flow/README.md +5 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +238 -46
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +16 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +10 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +60 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +8 -5
- package/node_modules/@llblab/pi-state-flow/dist/package.json +10 -9
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -7
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +2 -2
- package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +4 -2
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +14 -13
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +221 -46
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +14 -5
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +52 -1
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +7 -5
- package/node_modules/@llblab/pi-state-flow/package.json +10 -9
- package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
- package/node_modules/jsonc-parser/LICENSE.md +21 -0
- package/node_modules/jsonc-parser/README.md +364 -0
- package/node_modules/jsonc-parser/SECURITY.md +41 -0
- package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
- package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
- package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
- package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
- package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
- package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
- package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
- package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
- package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
- package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
- package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
- package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
- package/node_modules/jsonc-parser/package.json +37 -0
- package/package.json +7 -7
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/** Domain: status lifecycle. Owns: guarded refresh orchestration, stale display, terminal timers and optional Telegram row. Excludes: command arbitration and request adaptation. */
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isFastEnabled } from "./fast.ts";
|
|
5
|
+
import { claimRefresh, isRefreshDue, nextRefreshAt, ownsRefreshClaim, publishRefresh, readState, MIN_ATTEMPT_GAP_MS, type SharedState } from "./usage-store.ts";
|
|
6
|
+
import { isAnthropicModel, type ClaudeUsageModel, type ClaudeUsageReport } from "./usage.ts";
|
|
7
|
+
import { DUAL_BAR_WIDTH, STATUS_LABEL_TEXT, formatClaudeUsageBar, formatClaudeUsageStatusValue, formatClaudeUsageStatusline, formatStatuslineLoading, formatStatuslineProblem, nextResetCountdownDelayMs } from "./status-format.ts";
|
|
8
|
+
import { queryUsage } from "./query.ts";
|
|
9
|
+
import { registerClaudeUsageTelegramStatusLine } from "./telegram.ts";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
12
|
+
const SECOND_MS = 1000;
|
|
13
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
14
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
15
|
+
/** How often an instance re-reads the shared file to redraw fresh data. */
|
|
16
|
+
const MAX_TICK_MS = 30 * SECOND_MS;
|
|
17
|
+
const MIN_TICK_MS = SECOND_MS;
|
|
18
|
+
const TAKEOVER_JITTER_MS = 2 * SECOND_MS;
|
|
19
|
+
/** A report older than this is no longer shown as current. */
|
|
20
|
+
const STALE_REPORT_MAX_AGE_MS = HOUR_MS;
|
|
21
|
+
const LOADING_FRAME_MS = 30;
|
|
22
|
+
const REDRAW_BLINK_MS = 150;
|
|
23
|
+
const STATUS_KEY = "aa-claude-usage";
|
|
24
|
+
|
|
25
|
+
type TimeoutHandle = ReturnType<typeof setTimeout> & { unref?: () => void };
|
|
26
|
+
|
|
27
|
+
export function createClaudeUsageStatus(pi: ExtensionAPI) {
|
|
28
|
+
const instanceId = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
29
|
+
const stateDir = join(getAgentDir(), "tmp", "pi-claude-usage");
|
|
30
|
+
let lastAttemptAt = 0;
|
|
31
|
+
let inFlightRefresh: Promise<void> | undefined;
|
|
32
|
+
let shown: { key?: string; report?: ClaudeUsageReport; updatedAt?: number } = {};
|
|
33
|
+
let statuslineBlinkTimer: TimeoutHandle | undefined;
|
|
34
|
+
let statuslineCountdownTimer: TimeoutHandle | undefined;
|
|
35
|
+
let statuslineLoadingTimer: TimeoutHandle | undefined;
|
|
36
|
+
let statuslineRefreshTimer: TimeoutHandle | undefined;
|
|
37
|
+
let statuslineLoadingFrame = 0;
|
|
38
|
+
let statuslineRequestId = 0;
|
|
39
|
+
let unregisterTelegramStatusLine: (() => void) | undefined;
|
|
40
|
+
let telegramStatusLineRegistration: Promise<void> | undefined;
|
|
41
|
+
let fastEnabled = false;
|
|
42
|
+
let fastModelId: string | undefined;
|
|
43
|
+
let lastStatusText: string | undefined;
|
|
44
|
+
|
|
45
|
+
const syncFast = (model: ClaudeUsageModel | undefined) => {
|
|
46
|
+
fastModelId = isAnthropicModel(model) ? model?.id : undefined;
|
|
47
|
+
fastEnabled = fastModelId !== undefined && isFastEnabled(fastModelId);
|
|
48
|
+
};
|
|
49
|
+
const setClaudeStatus = (ctx: ExtensionContext, text: string | undefined) => {
|
|
50
|
+
lastStatusText = text;
|
|
51
|
+
ctx.ui.setStatus(STATUS_KEY, text !== undefined && fastEnabled
|
|
52
|
+
? `${text} ${ctx.ui.theme.fg("dim", "fast")}` : text);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const loadState = () => readState(stateDir);
|
|
56
|
+
|
|
57
|
+
const ensureTelegramStatusLineRegistered = () => {
|
|
58
|
+
if (unregisterTelegramStatusLine || telegramStatusLineRegistration) return;
|
|
59
|
+
telegramStatusLineRegistration = registerClaudeUsageTelegramStatusLine(
|
|
60
|
+
({ activeModel }) => {
|
|
61
|
+
if (!isAnthropicModel(activeModel)) return undefined;
|
|
62
|
+
if (!shown.report) return undefined;
|
|
63
|
+
const value = formatClaudeUsageStatusValue(shown.report);
|
|
64
|
+
return value ? { label: STATUS_LABEL_TEXT, value } : undefined;
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
.then((unregister) => {
|
|
68
|
+
unregisterTelegramStatusLine = unregister;
|
|
69
|
+
})
|
|
70
|
+
.finally(() => {
|
|
71
|
+
telegramStatusLineRegistration = undefined;
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const clearStatuslineTimers = () => {
|
|
76
|
+
if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
|
|
77
|
+
if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
|
|
78
|
+
if (statuslineLoadingTimer) clearTimeout(statuslineLoadingTimer);
|
|
79
|
+
if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
|
|
80
|
+
statuslineBlinkTimer = undefined;
|
|
81
|
+
statuslineCountdownTimer = undefined;
|
|
82
|
+
statuslineLoadingTimer = undefined;
|
|
83
|
+
statuslineRefreshTimer = undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const stopStatuslineLoading = () => {
|
|
87
|
+
if (statuslineLoadingTimer) clearTimeout(statuslineLoadingTimer);
|
|
88
|
+
statuslineLoadingTimer = undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const startStatuslineLoading = (ctx: ExtensionContext) => {
|
|
92
|
+
if (statuslineLoadingTimer) return;
|
|
93
|
+
statuslineLoadingFrame = Math.random() < 0.5 ? 0 : DUAL_BAR_WIDTH * 2 - 1;
|
|
94
|
+
const drawNextFrame = () => {
|
|
95
|
+
try {
|
|
96
|
+
setClaudeStatus(ctx, formatStatuslineLoading(ctx, statuslineLoadingFrame));
|
|
97
|
+
statuslineLoadingFrame += 1;
|
|
98
|
+
statuslineLoadingTimer = setTimeout(
|
|
99
|
+
drawNextFrame,
|
|
100
|
+
LOADING_FRAME_MS,
|
|
101
|
+
) as TimeoutHandle;
|
|
102
|
+
statuslineLoadingTimer.unref?.();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
statuslineLoadingTimer = undefined;
|
|
105
|
+
handleTimerError(error);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
drawNextFrame();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const clearUsageStatusline = (ctx: ExtensionContext) => {
|
|
112
|
+
statuslineRequestId += 1;
|
|
113
|
+
clearStatuslineTimers();
|
|
114
|
+
shown = {};
|
|
115
|
+
setClaudeStatus(ctx, undefined);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const scheduleStatuslineRefresh = (ctx: ExtensionContext, delayMs: number) => {
|
|
119
|
+
if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
|
|
120
|
+
statuslineRefreshTimer = setTimeout(() => {
|
|
121
|
+
void refreshCurrentClaudeUsageStatusline(ctx, false).catch(
|
|
122
|
+
handleAsyncTimerError,
|
|
123
|
+
);
|
|
124
|
+
}, delayMs) as TimeoutHandle;
|
|
125
|
+
statuslineRefreshTimer.unref?.();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const scheduleStatuslineCountdown = (
|
|
129
|
+
ctx: ExtensionContext,
|
|
130
|
+
report: ClaudeUsageReport,
|
|
131
|
+
) => {
|
|
132
|
+
if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
|
|
133
|
+
const delayMs = nextResetCountdownDelayMs(report, Date.now());
|
|
134
|
+
if (delayMs === undefined) {
|
|
135
|
+
statuslineCountdownTimer = undefined;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
statuslineCountdownTimer = setTimeout(() => {
|
|
139
|
+
try {
|
|
140
|
+
if (isAnthropicModel(ctx.model)) {
|
|
141
|
+
setClaudeStatus(ctx, formatClaudeUsageStatusline(report, ctx));
|
|
142
|
+
scheduleStatuslineCountdown(ctx, report);
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
handleTimerError(error);
|
|
146
|
+
}
|
|
147
|
+
}, delayMs) as TimeoutHandle;
|
|
148
|
+
statuslineCountdownTimer.unref?.();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const setUsageStatusline = (
|
|
152
|
+
ctx: ExtensionContext,
|
|
153
|
+
report: ClaudeUsageReport,
|
|
154
|
+
blink: boolean,
|
|
155
|
+
) => {
|
|
156
|
+
if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
|
|
157
|
+
if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
|
|
158
|
+
stopStatuslineLoading();
|
|
159
|
+
statuslineBlinkTimer = undefined;
|
|
160
|
+
statuslineCountdownTimer = undefined;
|
|
161
|
+
const text = formatClaudeUsageStatusline(report, ctx);
|
|
162
|
+
if (blink) {
|
|
163
|
+
setClaudeStatus(ctx, formatStatuslineLoading(ctx, statuslineLoadingFrame));
|
|
164
|
+
statuslineBlinkTimer = setTimeout(() => {
|
|
165
|
+
try {
|
|
166
|
+
setClaudeStatus(ctx, text);
|
|
167
|
+
scheduleStatuslineCountdown(ctx, report);
|
|
168
|
+
statuslineBlinkTimer = undefined;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
handleTimerError(error);
|
|
171
|
+
}
|
|
172
|
+
}, REDRAW_BLINK_MS) as TimeoutHandle;
|
|
173
|
+
statuslineBlinkTimer.unref?.();
|
|
174
|
+
} else {
|
|
175
|
+
setClaudeStatus(ctx, text);
|
|
176
|
+
scheduleStatuslineCountdown(ctx, report);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Draws the shared state; skips redraws when nothing changed. */
|
|
181
|
+
const renderState = (
|
|
182
|
+
ctx: ExtensionContext,
|
|
183
|
+
state: SharedState | undefined,
|
|
184
|
+
force: boolean,
|
|
185
|
+
) => {
|
|
186
|
+
// Cached display data is not permission to query when the file is unreadable.
|
|
187
|
+
if (!state && shown.report && Date.now() - (shown.updatedAt ?? 0) < STALE_REPORT_MAX_AGE_MS) {
|
|
188
|
+
if (force) setUsageStatusline(ctx, shown.report, false);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const usable =
|
|
192
|
+
state?.report &&
|
|
193
|
+
state.updatedAt !== undefined &&
|
|
194
|
+
Date.now() - state.updatedAt < STALE_REPORT_MAX_AGE_MS
|
|
195
|
+
? state.report
|
|
196
|
+
: undefined;
|
|
197
|
+
if (usable) {
|
|
198
|
+
const key = `report:${state?.updatedAt}`;
|
|
199
|
+
if (!force && shown.key === key) return;
|
|
200
|
+
const blink = shown.report
|
|
201
|
+
? formatClaudeUsageBar(shown.report) !== formatClaudeUsageBar(usable)
|
|
202
|
+
: false;
|
|
203
|
+
shown = { key, report: usable, updatedAt: state?.updatedAt };
|
|
204
|
+
setUsageStatusline(ctx, usable, blink);
|
|
205
|
+
} else if (state?.error) {
|
|
206
|
+
const key = `error:${state.error}`;
|
|
207
|
+
if (!force && shown.key === key) return;
|
|
208
|
+
shown = { key };
|
|
209
|
+
clearStatuslineTimers();
|
|
210
|
+
setClaudeStatus(ctx, formatStatuslineProblem(ctx, [{ message: state.error }]));
|
|
211
|
+
} else {
|
|
212
|
+
shown = { key: "loading" };
|
|
213
|
+
startStatuslineLoading(ctx);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* When this instance is due (leader after 1 minute, anyone after 90 seconds) it
|
|
219
|
+
* claims leadership under the lock (owner and timestamp, so no other
|
|
220
|
+
* instance is due), then fetches, then stamps the outcome. Failures are
|
|
221
|
+
* published with a backoff so other instances do not pile on.
|
|
222
|
+
*/
|
|
223
|
+
const refreshSharedState = (ctx: ExtensionContext) => {
|
|
224
|
+
if (inFlightRefresh) return inFlightRefresh;
|
|
225
|
+
const promise = (async () => {
|
|
226
|
+
const claim = claimRefresh(stateDir, instanceId);
|
|
227
|
+
if (!claim) return;
|
|
228
|
+
lastAttemptAt = Date.now();
|
|
229
|
+
const result = await queryUsage(
|
|
230
|
+
ctx,
|
|
231
|
+
DEFAULT_TIMEOUT_MS,
|
|
232
|
+
() => ownsRefreshClaim(stateDir, claim),
|
|
233
|
+
);
|
|
234
|
+
if (!result) return;
|
|
235
|
+
if (result.ok) {
|
|
236
|
+
publishRefresh(stateDir, claim, result);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// A stale extension context is a local shutdown, not a server failure.
|
|
240
|
+
if (result.message.includes("ctx is stale")) return;
|
|
241
|
+
publishRefresh(stateDir, claim, {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: result.message,
|
|
244
|
+
rateLimited: result.rateLimited,
|
|
245
|
+
retryAfterMs: result.retryAfterMs,
|
|
246
|
+
});
|
|
247
|
+
})().finally(() => {
|
|
248
|
+
if (inFlightRefresh === promise) inFlightRefresh = undefined;
|
|
249
|
+
});
|
|
250
|
+
inFlightRefresh = promise;
|
|
251
|
+
return promise;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const nextTickDelayMs = (state: SharedState | undefined) => {
|
|
255
|
+
// Nothing to show yet (e.g. a claimed fetch is in flight): poll quickly.
|
|
256
|
+
if (shown.key === "loading") return MIN_TICK_MS;
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
const dueAt = Math.max(
|
|
259
|
+
nextRefreshAt(state, instanceId, now),
|
|
260
|
+
lastAttemptAt + MIN_ATTEMPT_GAP_MS,
|
|
261
|
+
);
|
|
262
|
+
const jitter =
|
|
263
|
+
state?.owner === instanceId ? 0 : Math.random() * TAKEOVER_JITTER_MS;
|
|
264
|
+
return Math.min(MAX_TICK_MS, Math.max(MIN_TICK_MS, dueAt - now + jitter));
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const refreshCurrentClaudeUsageStatusline = async (
|
|
268
|
+
ctx: ExtensionContext,
|
|
269
|
+
forceRender: boolean,
|
|
270
|
+
model?: ClaudeUsageModel,
|
|
271
|
+
) => {
|
|
272
|
+
try {
|
|
273
|
+
const previousFast = fastEnabled;
|
|
274
|
+
syncFast(model ?? ctx.model);
|
|
275
|
+
forceRender ||= previousFast !== fastEnabled;
|
|
276
|
+
if (!isAnthropicModel(model ?? ctx.model)) {
|
|
277
|
+
clearUsageStatusline(ctx);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const requestId = statuslineRequestId + 1;
|
|
282
|
+
statuslineRequestId = requestId;
|
|
283
|
+
let state = loadState();
|
|
284
|
+
renderState(ctx, state, forceRender);
|
|
285
|
+
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
if (
|
|
288
|
+
isRefreshDue(state, instanceId, now) &&
|
|
289
|
+
now - lastAttemptAt >= MIN_ATTEMPT_GAP_MS
|
|
290
|
+
) {
|
|
291
|
+
await refreshSharedState(ctx);
|
|
292
|
+
if (requestId !== statuslineRequestId) return;
|
|
293
|
+
if (!isAnthropicModel(ctx.model)) {
|
|
294
|
+
clearUsageStatusline(ctx);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
state = loadState();
|
|
298
|
+
renderState(ctx, state, false);
|
|
299
|
+
}
|
|
300
|
+
scheduleStatuslineRefresh(ctx, nextTickDelayMs(state));
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (isStaleExtensionContextError(error)) {
|
|
303
|
+
clearStatuslineTimers();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
ensureTelegramStatusLineRegistered();
|
|
311
|
+
|
|
312
|
+
pi.on("session_start", (_event, ctx) => {
|
|
313
|
+
ensureTelegramStatusLineRegistered();
|
|
314
|
+
if (isAnthropicModel(ctx.model))
|
|
315
|
+
void refreshCurrentClaudeUsageStatusline(ctx, true).catch(
|
|
316
|
+
handleAsyncTimerError,
|
|
317
|
+
);
|
|
318
|
+
else clearUsageStatusline(ctx);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
322
|
+
if (isAnthropicModel(ctx.model))
|
|
323
|
+
void refreshCurrentClaudeUsageStatusline(ctx, true).catch(
|
|
324
|
+
handleAsyncTimerError,
|
|
325
|
+
);
|
|
326
|
+
else clearUsageStatusline(ctx);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
pi.on("model_select", (event, ctx) => {
|
|
330
|
+
syncFast(event.model);
|
|
331
|
+
if (lastStatusText !== undefined) setClaudeStatus(ctx, lastStatusText);
|
|
332
|
+
if (isAnthropicModel(event.model)) {
|
|
333
|
+
void refreshCurrentClaudeUsageStatusline(ctx, true, event.model).catch(
|
|
334
|
+
handleAsyncTimerError,
|
|
335
|
+
);
|
|
336
|
+
} else {
|
|
337
|
+
clearUsageStatusline(ctx);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
342
|
+
clearUsageStatusline(ctx);
|
|
343
|
+
unregisterTelegramStatusLine?.();
|
|
344
|
+
unregisterTelegramStatusLine = undefined;
|
|
345
|
+
});
|
|
346
|
+
return {
|
|
347
|
+
fastChanged(ctx: ExtensionContext, model: ClaudeUsageModel) {
|
|
348
|
+
syncFast(model);
|
|
349
|
+
if (lastStatusText !== undefined) setClaudeStatus(ctx, lastStatusText);
|
|
350
|
+
else renderState(ctx, loadState(), true);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function handleAsyncTimerError(error: unknown): void {
|
|
356
|
+
handleTimerError(error);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function handleTimerError(error: unknown): void {
|
|
360
|
+
if (isStaleExtensionContextError(error)) return;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function isStaleExtensionContextError(error: unknown): boolean {
|
|
365
|
+
return error instanceof Error && error.message.includes("ctx is stale");
|
|
366
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Domain: Telegram status. Owns: optional status-provider discovery and registration. Excludes: quota state and terminal decoration. */
|
|
2
|
+
import type { ClaudeUsageModel } from "./usage.ts";
|
|
3
|
+
const CLAUDE_USAGE_EXTENSION_ID = "@llblab/pi-claude-usage";
|
|
4
|
+
const TELEGRAM_STATUS_IMPORT_SPECIFIERS = [
|
|
5
|
+
"@llblab/pi-telegram/status",
|
|
6
|
+
new URL("../../pi-telegram/api/status.ts", import.meta.url).href,
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
type TelegramStatusLineProviderResult =
|
|
10
|
+
{ label: string; value: string } | undefined;
|
|
11
|
+
type TelegramStatusLineProvider = (ctx: {
|
|
12
|
+
activeModel?: ClaudeUsageModel;
|
|
13
|
+
}) => TelegramStatusLineProviderResult;
|
|
14
|
+
type TelegramStatusLineModule = {
|
|
15
|
+
registerTelegramStatusLineProvider?: (
|
|
16
|
+
provider: TelegramStatusLineProvider,
|
|
17
|
+
options: { id: string },
|
|
18
|
+
) => () => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function importTelegramStatusLineModule(): Promise<
|
|
22
|
+
TelegramStatusLineModule | undefined
|
|
23
|
+
> {
|
|
24
|
+
for (const specifier of TELEGRAM_STATUS_IMPORT_SPECIFIERS) {
|
|
25
|
+
try {
|
|
26
|
+
const imported = (await import(specifier)) as TelegramStatusLineModule;
|
|
27
|
+
if (typeof imported.registerTelegramStatusLineProvider === "function") {
|
|
28
|
+
return imported;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// pi-telegram is optional; absence just disables the Telegram status line.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function registerClaudeUsageTelegramStatusLine(
|
|
38
|
+
provider: TelegramStatusLineProvider,
|
|
39
|
+
): Promise<(() => void) | undefined> {
|
|
40
|
+
const telegramStatus = await importTelegramStatusLineModule();
|
|
41
|
+
return telegramStatus?.registerTelegramStatusLineProvider?.(provider, {
|
|
42
|
+
id: CLAUDE_USAGE_EXTENSION_ID,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/** Domain: shared usage state. Owns: lease claims, fenced publication, atomic state and OS-backed mutex. Excludes: auth, network and rendering. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import type { ClaudeUsageReport } from "./usage.ts";
|
|
7
|
+
|
|
8
|
+
// --- Shared Refresh ---
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cross-instance coordination for quota polling. Every Pi instance reads one
|
|
12
|
+
* JSON file; a single "leader"
|
|
13
|
+
* refreshes it every `LEADER_INTERVAL_MS`. Any other instance may take over once
|
|
14
|
+
* the file is `TAKEOVER_AFTER_MS` old: it first claims leadership (owner and
|
|
15
|
+
* timestamp) so nobody else is due, then fetches, then stamps the result. The
|
|
16
|
+
* file is the only authority for requests and fenced publication. Writes are
|
|
17
|
+
* atomic renames; short critical sections use an OS-backed SQLite mutex.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const LEADER_INTERVAL_MS = 60_000;
|
|
21
|
+
/** The leader is considered gone after missing its slot by 30 seconds. */
|
|
22
|
+
export const TAKEOVER_AFTER_MS = LEADER_INTERVAL_MS + 30_000;
|
|
23
|
+
/** Minimum pause between two fetch attempts of the same instance. */
|
|
24
|
+
export const MIN_ATTEMPT_GAP_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
const STATE_FILE = "usage.json";
|
|
27
|
+
const LOCK_FILE = "mutex.sqlite";
|
|
28
|
+
|
|
29
|
+
export type SharedState = {
|
|
30
|
+
report?: ClaudeUsageReport;
|
|
31
|
+
/** When `report` was last fetched successfully. */
|
|
32
|
+
updatedAt?: number;
|
|
33
|
+
/**
|
|
34
|
+
* When the leader last touched the file: written when it claims a refresh,
|
|
35
|
+
* before fetching, and again when the fetch finishes.
|
|
36
|
+
*/
|
|
37
|
+
claimedAt?: number;
|
|
38
|
+
/** Instance id of the current leader (the last instance to claim). */
|
|
39
|
+
owner?: string;
|
|
40
|
+
/** Unique generation for this refresh, including renewals by the same owner. */
|
|
41
|
+
claimId?: string;
|
|
42
|
+
/** Last failure message; cleared by the next success. */
|
|
43
|
+
error?: string;
|
|
44
|
+
failures?: number;
|
|
45
|
+
/** No instance should fetch before this time. */
|
|
46
|
+
retryNotBefore?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function readState(dir: string): SharedState | undefined {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(
|
|
52
|
+
readFileSync(join(dir, STATE_FILE), "utf8"),
|
|
53
|
+
) as unknown;
|
|
54
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
55
|
+
? (parsed as SharedState)
|
|
56
|
+
: undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function writeState(dir: string, state: SharedState): boolean {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
const temp = join(dir, `${STATE_FILE}.${process.pid}.tmp`);
|
|
66
|
+
writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
|
|
67
|
+
renameSync(temp, join(dir, STATE_FILE));
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type RefreshClaim = { owner: string; claimId: string };
|
|
75
|
+
|
|
76
|
+
export type RefreshOutcome =
|
|
77
|
+
| { ok: true; report: ClaudeUsageReport }
|
|
78
|
+
| {
|
|
79
|
+
ok: false;
|
|
80
|
+
error: string;
|
|
81
|
+
rateLimited: boolean;
|
|
82
|
+
retryAfterMs?: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Read, decide and claim under the lock; never authorize an unwritten claim. */
|
|
86
|
+
export function claimRefresh(
|
|
87
|
+
dir: string,
|
|
88
|
+
owner: string,
|
|
89
|
+
now?: number,
|
|
90
|
+
): RefreshClaim | undefined {
|
|
91
|
+
const release = tryAcquireLock(dir);
|
|
92
|
+
if (!release) return undefined;
|
|
93
|
+
try {
|
|
94
|
+
const current = readState(dir);
|
|
95
|
+
const at = now ?? Date.now();
|
|
96
|
+
if (!isRefreshDue(current, owner, at)) return undefined;
|
|
97
|
+
const claim = { owner, claimId: randomUUID() };
|
|
98
|
+
return writeState(dir, { ...current, ...claim, claimedAt: at })
|
|
99
|
+
? claim
|
|
100
|
+
: undefined;
|
|
101
|
+
} finally {
|
|
102
|
+
release();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function matchesClaim(
|
|
107
|
+
state: SharedState | undefined,
|
|
108
|
+
claim: RefreshClaim,
|
|
109
|
+
now: number,
|
|
110
|
+
): boolean {
|
|
111
|
+
return (
|
|
112
|
+
state?.owner === claim.owner &&
|
|
113
|
+
state.claimId === claim.claimId &&
|
|
114
|
+
typeof state.claimedAt === "number" &&
|
|
115
|
+
now - state.claimedAt < TAKEOVER_AFTER_MS
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Each admission check reads the file, not a remembered leadership flag. */
|
|
120
|
+
export function ownsRefreshClaim(
|
|
121
|
+
dir: string,
|
|
122
|
+
claim: RefreshClaim,
|
|
123
|
+
now?: number,
|
|
124
|
+
): boolean {
|
|
125
|
+
const current = readState(dir);
|
|
126
|
+
return matchesClaim(current, claim, now ?? Date.now());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A late success OR failure must not overwrite a successor's state. */
|
|
130
|
+
export function publishRefresh(
|
|
131
|
+
dir: string,
|
|
132
|
+
claim: RefreshClaim,
|
|
133
|
+
outcome: RefreshOutcome,
|
|
134
|
+
now?: number,
|
|
135
|
+
): boolean {
|
|
136
|
+
const release = tryAcquireLock(dir);
|
|
137
|
+
if (!release) return false;
|
|
138
|
+
try {
|
|
139
|
+
const current = readState(dir);
|
|
140
|
+
const at = now ?? Date.now();
|
|
141
|
+
if (!matchesClaim(current, claim, at)) return false;
|
|
142
|
+
if (outcome.ok) {
|
|
143
|
+
return writeState(dir, {
|
|
144
|
+
...claim,
|
|
145
|
+
claimedAt: at,
|
|
146
|
+
updatedAt: at,
|
|
147
|
+
report: outcome.report,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const failures = (current?.failures ?? 0) + 1;
|
|
151
|
+
return writeState(dir, {
|
|
152
|
+
...current,
|
|
153
|
+
...claim,
|
|
154
|
+
claimedAt: at,
|
|
155
|
+
error: outcome.error,
|
|
156
|
+
failures,
|
|
157
|
+
retryNotBefore: at + failureBackoffMs(failures, outcome),
|
|
158
|
+
});
|
|
159
|
+
} finally {
|
|
160
|
+
release();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Earliest time at which `owner` should try to refresh the state. */
|
|
165
|
+
export function nextRefreshAt(
|
|
166
|
+
state: SharedState | undefined,
|
|
167
|
+
owner: string,
|
|
168
|
+
now: number,
|
|
169
|
+
): number {
|
|
170
|
+
const hold = state?.retryNotBefore ?? 0;
|
|
171
|
+
const touchedAt = Math.max(
|
|
172
|
+
state?.updatedAt ?? -Infinity,
|
|
173
|
+
state?.claimedAt ?? -Infinity,
|
|
174
|
+
);
|
|
175
|
+
if (touchedAt === -Infinity) return Math.max(now, hold);
|
|
176
|
+
const interval =
|
|
177
|
+
state?.owner === owner ? LEADER_INTERVAL_MS : TAKEOVER_AFTER_MS;
|
|
178
|
+
return Math.max(touchedAt + interval, hold);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isRefreshDue(
|
|
182
|
+
state: SharedState | undefined,
|
|
183
|
+
owner: string,
|
|
184
|
+
now: number,
|
|
185
|
+
): boolean {
|
|
186
|
+
return nextRefreshAt(state, owner, now) <= now;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Exponential failure backoff; HTTP 429 gets a longer base and cap. */
|
|
190
|
+
export function failureBackoffMs(
|
|
191
|
+
failures: number,
|
|
192
|
+
options: { rateLimited: boolean; retryAfterMs?: number },
|
|
193
|
+
): number {
|
|
194
|
+
const base = options.rateLimited ? 5 * 60_000 : 60_000;
|
|
195
|
+
const cap = options.rateLimited ? 30 * 60_000 : 5 * 60_000;
|
|
196
|
+
const exponential = Math.min(cap, base * 2 ** Math.max(0, failures - 1));
|
|
197
|
+
return Math.max(options.retryAfterMs ?? 0, exponential);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* An empty SQLite transaction is a non-waiting, OS-backed mutex, not storage
|
|
202
|
+
* for quota or leadership. Close or process death releases it; a paused live
|
|
203
|
+
* holder cannot be evicted. Never unlink/replace mutex.sqlite while in use.
|
|
204
|
+
*/
|
|
205
|
+
export function tryAcquireLock(dir: string): (() => void) | undefined {
|
|
206
|
+
let database: DatabaseSync | undefined;
|
|
207
|
+
try {
|
|
208
|
+
mkdirSync(dir, { recursive: true });
|
|
209
|
+
database = new DatabaseSync(join(dir, LOCK_FILE));
|
|
210
|
+
// Contention must not wait on Pi's TUI thread; PRAGMA also works on Node 22.
|
|
211
|
+
database.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE");
|
|
212
|
+
} catch {
|
|
213
|
+
database?.close();
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
return () => {
|
|
217
|
+
const held = database;
|
|
218
|
+
database = undefined;
|
|
219
|
+
held?.close();
|
|
220
|
+
};
|
|
221
|
+
}
|