shraga 0.1.77 → 0.1.78

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.77",
3
+ "version": "0.1.78",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -2,11 +2,12 @@
2
2
  // the Claude Code CLI maintains on this box. Fails CLOSED: every error path returns null, and the
3
3
  // caller renders nothing — a broken or zeroed gauge is worse than no gauge.
4
4
  import { execFile } from 'node:child_process';
5
- import { readdir, readFile } from 'node:fs/promises';
5
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { createRequire } from 'node:module';
9
9
  import { promisify } from 'node:util';
10
+ import { dataPath } from './paths.ts';
10
11
 
11
12
  const execFileAsync = promisify(execFile);
12
13
 
@@ -71,6 +72,10 @@ export class ClaudeUsageOptions {
71
72
  keychainService = 'Claude Code-credentials';
72
73
  /** A locked keychain can block (or prompt) indefinitely — never let that stall a client poll. */
73
74
  keychainTimeoutMs = 3_000;
75
+ /** Where the last known-good reading is mirrored, so a restart (deploy, self-upgrade) does not
76
+ * blank the gauge on a box whose upstream is rate-limited for the next hour. Identity + percentages
77
+ * only — never a token. */
78
+ cachePath = dataPath('claude-usage-last.json');
74
79
  /** Seam: hands back the raw secret string, or null on ANY failure. Tests inject here so the suite
75
80
  * never shells out to the real `security` binary. */
76
81
  readKeychain: (options: ClaudeUsageOptions) => Promise<string | null> = readKeychainSecret;
@@ -144,6 +149,8 @@ export class ClaudeUsageReader {
144
149
  * a widget that flickers in and out reads as a bug. We keep serving this, flagged `stale`, and the
145
150
  * client shows how old it is. Only a box that never had a good reading answers null. */
146
151
  private lastGood: { at: number; value: ClaudeUsage } | null = null;
152
+ /** One-shot rehydrate of `lastGood` from disk, awaited by the first get(). */
153
+ private restored: Promise<void> | null = null;
147
154
 
148
155
  public constructor(options?: Partial<ClaudeUsageOptions>) {
149
156
  this.options = { ...new ClaudeUsageOptions(), ...options };
@@ -151,6 +158,7 @@ export class ClaudeUsageReader {
151
158
 
152
159
  /** null => this box is not on a Claude subscription, or we could not prove that it ever was. */
153
160
  async get(): Promise<ClaudeUsage | null> {
161
+ await (this.restored ??= this.restore());
154
162
  const now = Date.now();
155
163
  if (now < this.cooldownUntil) return this.stale();
156
164
  if (this.cache && now - this.cache.at < this.options.ttlMs) return this.cache.value ?? this.stale();
@@ -162,13 +170,36 @@ export class ClaudeUsageReader {
162
170
  const at = Date.now();
163
171
  const stamped = value ? { ...value, fetchedAt: new Date(at).toISOString() } : null;
164
172
  this.cache = { at, value: stamped };
165
- if (stamped) this.lastGood = { at, value: stamped };
173
+ if (stamped) { this.lastGood = { at, value: stamped }; void this.persist(this.lastGood); }
166
174
  return stamped ?? this.stale();
167
175
  })
168
176
  .finally(() => { this.inflight = null; });
169
177
  return this.inflight;
170
178
  }
171
179
 
180
+ /** Rehydrate the last reading a previous process wrote. Never throws: a missing or corrupt file
181
+ * just means we start with nothing, exactly as before. */
182
+ private async restore(): Promise<void> {
183
+ try {
184
+ const saved = JSON.parse(await readFile(this.options.cachePath, 'utf8'));
185
+ if (Array.isArray(saved?.value?.limits) && saved.value.limits.length && typeof saved.at === 'number') {
186
+ this.lastGood = { at: saved.at, value: saved.value };
187
+ }
188
+ } catch (err) {
189
+ const code = (err as NodeJS.ErrnoException).code;
190
+ if (code !== 'ENOENT') console.debug(`${TAG} could not restore the last reading: ${(err as Error).message}`);
191
+ }
192
+ }
193
+
194
+ /** Mirror a fresh reading to disk. Best-effort — a write failure must never break the response. */
195
+ private async persist(entry: { at: number; value: ClaudeUsage }) {
196
+ try {
197
+ await writeFile(this.options.cachePath, JSON.stringify(entry));
198
+ } catch (err) {
199
+ console.debug(`${TAG} could not persist the last reading: ${(err as Error).message}`);
200
+ }
201
+ }
202
+
172
203
  /** Last known-good reading, marked stale. Never invents numbers — null when we never had any. */
173
204
  private stale(): ClaudeUsage | null {
174
205
  return this.lastGood ? { ...this.lastGood.value, stale: true } : null;