takomi 2.1.28 → 2.1.29

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.
@@ -404,15 +404,13 @@ async function handleRouterLogin(pi: ExtensionAPI, runtime: RouterRuntime, args:
404
404
  updatedAt: Date.now(),
405
405
  });
406
406
  runtime.clearAccountHealth(duplicate.id);
407
- await runtime.refreshUsageSnapshot(duplicate.id);
408
407
  setFooterStatus(ctx, runtime);
409
- emitReport(pi, `Updated existing account ${duplicate.id} (${duplicate.label}) with fresh credentials instead of adding a duplicate.`);
408
+ emitReport(pi, `Updated existing account ${duplicate.id} (${duplicate.label}) with fresh credentials instead of adding a duplicate. Run /router-refresh-usage ${duplicate.id} to refresh provider quota metadata.`);
410
409
  return;
411
410
  }
412
411
  runtime.addAccount(account);
413
- await runtime.refreshUsageSnapshot(account.id);
414
412
  setFooterStatus(ctx, runtime);
415
- emitReport(pi, `Added account ${account.id} (${account.label}) for upstream ${upstream.id}.`);
413
+ emitReport(pi, `Added account ${account.id} (${account.label}) for upstream ${upstream.id}. Run /router-refresh-usage ${account.id} to refresh provider quota metadata.`);
416
414
  ctx.ui.notify(`Added ${account.id}`, "info");
417
415
  return;
418
416
  }
@@ -461,17 +459,15 @@ async function handleRouterLogin(pi: ExtensionAPI, runtime: RouterRuntime, args:
461
459
  updatedAt: Date.now(),
462
460
  });
463
461
  runtime.clearAccountHealth(existing.id);
464
- await runtime.refreshUsageSnapshot(existing.id);
465
462
  setFooterStatus(ctx, runtime);
466
- emitReport(pi, `Re-logged account ${existing.id} (${existing.label}) and cleared auth state.`);
463
+ emitReport(pi, `Re-logged account ${existing.id} (${existing.label}) and cleared auth state. Run /router-refresh-usage ${existing.id} to refresh provider quota metadata.`);
467
464
  return;
468
465
  }
469
466
  case "refresh": {
470
467
  const account = await pickAccount(runtime, ctx, first, "Choose account to refresh");
471
468
  const refreshed = await runtime.refreshAccount(account.id);
472
- await runtime.refreshUsageSnapshot(refreshed.id);
473
469
  setFooterStatus(ctx, runtime);
474
- emitReport(pi, `Refreshed account ${refreshed.id} (${refreshed.label}).`);
470
+ emitReport(pi, `Refreshed account ${refreshed.id} (${refreshed.label}). Run /router-refresh-usage ${refreshed.id} to refresh provider quota metadata.`);
475
471
  return;
476
472
  }
477
473
  case "help":
@@ -554,7 +550,7 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
554
550
  });
555
551
 
556
552
  pi.registerCommand("router-refresh-usage", {
557
- description: "Refresh oauth-router account metadata from token claims",
553
+ description: "Refresh oauth-router account metadata and best-effort provider quota",
558
554
  handler: async (args, ctx) => {
559
555
  const [requestedId] = parseArgs(args || "");
560
556
  const target = await pickUsageTarget(runtime, ctx, requestedId);
@@ -98,6 +98,7 @@ const DEFAULT_UPSTREAMS: RouterUpstreamConfig[] = [
98
98
  modelIds: ["gpt-5.1", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5"],
99
99
  usageProbe: {
100
100
  enabled: true,
101
+ timeoutMs: 2_500,
101
102
  endpoints: [
102
103
  "/wham/usage",
103
104
  "/codex/usage",
@@ -138,6 +139,8 @@ function sleepSync(ms: number): void {
138
139
  }
139
140
 
140
141
  const HELD_JSON_LOCKS = new Set<string>();
142
+ const JSON_LOCK_WAIT_TIMEOUT_MS = 2_000;
143
+ const JSON_LOCK_STALE_AFTER_MS = 5_000;
141
144
 
142
145
  export function withJsonFileLock<T>(filePath: string, fn: () => T): T {
143
146
  const lockPath = `${filePath}.lock`;
@@ -150,12 +153,12 @@ export function withJsonFileLock<T>(filePath: string, fn: () => T): T {
150
153
  } catch {
151
154
  try {
152
155
  const ageMs = Date.now() - statSync(lockPath).mtimeMs;
153
- if (ageMs > 30_000) rmSync(lockPath, { recursive: true, force: true });
156
+ if (ageMs > JSON_LOCK_STALE_AFTER_MS) rmSync(lockPath, { recursive: true, force: true });
154
157
  } catch {
155
158
  // The lock disappeared between mkdir attempts.
156
159
  }
157
- if (Date.now() - started > 10_000) {
158
- throw new Error(`Timed out waiting for oauth-router JSON lock: ${lockPath}`);
160
+ if (Date.now() - started > JSON_LOCK_WAIT_TIMEOUT_MS) {
161
+ throw new Error(`Timed out after ${JSON_LOCK_WAIT_TIMEOUT_MS}ms waiting for oauth-router JSON lock: ${lockPath}`);
159
162
  }
160
163
  sleepSync(50);
161
164
  }
@@ -331,6 +331,30 @@ function collectRateLimitHeaders(headers: Headers): Record<string, string> {
331
331
  return result;
332
332
  }
333
333
 
334
+ const DEFAULT_USAGE_PROBE_TIMEOUT_MS = 2_500;
335
+
336
+ function getUsageProbeTimeoutMs(upstream: RouterUpstreamConfig): number {
337
+ const configured = upstream.usageProbe?.timeoutMs;
338
+ return typeof configured === "number" && Number.isFinite(configured) && configured > 0
339
+ ? Math.min(configured, 10_000)
340
+ : DEFAULT_USAGE_PROBE_TIMEOUT_MS;
341
+ }
342
+
343
+ async function fetchJsonWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<{ response: Response; text: string }> {
344
+ const controller = new AbortController();
345
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
346
+ try {
347
+ const response = await fetch(url, { ...init, signal: controller.signal });
348
+ const text = await response.text();
349
+ return { response, text };
350
+ } catch (error) {
351
+ if (controller.signal.aborted) throw new Error(`timed out after ${Math.round(timeoutMs)}ms`);
352
+ throw error;
353
+ } finally {
354
+ clearTimeout(timer);
355
+ }
356
+ }
357
+
334
358
  export async function refreshProviderUsageSnapshot(account: StoredRouterAccount, upstream: RouterUpstreamConfig): Promise<RouterProviderUsageSnapshot> {
335
359
  const base = inspectAccountToken(account);
336
360
  if (account.provider !== "openai-codex" || upstream.usageProbe?.enabled === false) return base;
@@ -345,12 +369,20 @@ export async function refreshProviderUsageSnapshot(account: StoredRouterAccount,
345
369
  let lastEndpoint: string | undefined;
346
370
  let lastHeaders: Record<string, string> | undefined;
347
371
  const errors: string[] = [];
372
+ const startedAt = now();
373
+ const totalTimeoutMs = getUsageProbeTimeoutMs(upstream);
348
374
 
349
375
  for (const endpoint of endpoints) {
376
+ const remainingMs = totalTimeoutMs - (now() - startedAt);
377
+ if (remainingMs <= 0) {
378
+ errors.push(`probe timed out after ${Math.round(totalTimeoutMs)}ms`);
379
+ break;
380
+ }
381
+
350
382
  const url = resolveProbeUrl(upstream.baseUrl, endpoint);
351
383
  lastEndpoint = url;
352
384
  try {
353
- const response = await fetch(url, {
385
+ const { response, text } = await fetchJsonWithTimeout(url, {
354
386
  method: "GET",
355
387
  headers: {
356
388
  Authorization: `Bearer ${account.access}`,
@@ -361,10 +393,9 @@ export async function refreshProviderUsageSnapshot(account: StoredRouterAccount,
361
393
  "User-Agent": "codex_cli_rs/0.133.0 (Windows; x86_64) pi/oauth-router",
362
394
  accept: "application/json",
363
395
  },
364
- });
396
+ }, Math.max(1, remainingMs));
365
397
  lastStatus = response.status;
366
398
  lastHeaders = collectRateLimitHeaders(response.headers);
367
- const text = await response.text();
368
399
  const json = text ? JSON.parse(text) : undefined;
369
400
  const extracted = extractProviderUsage(json);
370
401
  const hasQuota = Boolean(extracted.fiveHour || extracted.weekly || extracted.planType);
@@ -27,6 +27,8 @@ export interface RouterModelConfig {
27
27
  export interface RouterProviderUsageProbeConfig {
28
28
  enabled?: boolean;
29
29
  endpoints?: string[];
30
+ /** Total best-effort probe budget per account. Provider usage checks must not block login/refresh UX. */
31
+ timeoutMs?: number;
30
32
  }
31
33
 
32
34
  export interface RouterUpstreamConfig {
@@ -70,6 +70,15 @@ type BoardCounts = {
70
70
  blocked: number;
71
71
  };
72
72
 
73
+ type BoardCountsCacheEntry = {
74
+ checkedAt: number;
75
+ mtimeMs: number;
76
+ counts?: BoardCounts;
77
+ };
78
+
79
+ const BOARD_COUNTS_CACHE_MS = 1_000;
80
+ const boardCountsCache = new Map<string, BoardCountsCacheEntry>();
81
+
73
82
  function createEmptyState(sessionStart = Date.now(), runtime: ContextRuntimeState = {}): ContextPanelState {
74
83
  return {
75
84
  fileChanges: [],
@@ -186,8 +195,19 @@ function formatDisplayPath(filePath: string, cwd?: string): string {
186
195
 
187
196
  function loadBoardCounts(cwd: string, sessionId?: string): BoardCounts | undefined {
188
197
  if (!sessionId) return undefined;
198
+ const stateFile = path.join(cwd, ".pi", "takomi", "orchestrator", `${sessionId}.json`);
199
+ const cached = boardCountsCache.get(stateFile);
200
+ const checkedAt = Date.now();
201
+
202
+ if (cached && checkedAt - cached.checkedAt < BOARD_COUNTS_CACHE_MS) return cached.counts;
203
+
189
204
  try {
190
- const stateFile = path.join(cwd, ".pi", "takomi", "orchestrator", `${sessionId}.json`);
205
+ const mtimeMs = statSync(stateFile).mtimeMs;
206
+ if (cached && cached.mtimeMs === mtimeMs) {
207
+ boardCountsCache.set(stateFile, { ...cached, checkedAt });
208
+ return cached.counts;
209
+ }
210
+
191
211
  const parsed = JSON.parse(readFileSync(stateFile, "utf8")) as { tasks?: Array<{ status?: string }> };
192
212
  const counts: BoardCounts = { completed: 0, pending: 0, inProgress: 0, blocked: 0 };
193
213
  for (const task of parsed.tasks ?? []) {
@@ -196,8 +216,10 @@ function loadBoardCounts(cwd: string, sessionId?: string): BoardCounts | undefin
196
216
  else if (task.status === "blocked") counts.blocked += 1;
197
217
  else counts.pending += 1;
198
218
  }
219
+ boardCountsCache.set(stateFile, { checkedAt, mtimeMs, counts });
199
220
  return counts;
200
221
  } catch {
222
+ boardCountsCache.set(stateFile, { checkedAt, mtimeMs: 0, counts: undefined });
201
223
  return undefined;
202
224
  }
203
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takomi",
3
- "version": "2.1.28",
3
+ "version": "2.1.29",
4
4
  "description": "🎯 Stop wrestling with AI. Start building with purpose. The artisan's toolkit for agent workflows, Codex skills, and original Takomi capabilities like 21st.dev integration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "scripts": {
25
25
  "postinstall": "node src/postinstall.js",
26
- "test": "echo \"Error: no test specified\" && exit 1",
26
+ "test": "node scripts/test-skill-selection.js",
27
27
  "test:skills": "node scripts/test-skill-selection.js"
28
28
  },
29
29
  "repository": {
@@ -45,6 +45,11 @@
45
45
  "author": "J StaR Films Studios",
46
46
  "license": "ISC",
47
47
  "dependencies": {
48
+ "@earendil-works/pi-ai": "^0.78.1",
49
+ "@mariozechner/pi-agent-core": "^0.70.6",
50
+ "@mariozechner/pi-ai": "^0.70.2",
51
+ "@mariozechner/pi-coding-agent": "^0.70.2",
52
+ "@mariozechner/pi-tui": "^0.70.6",
48
53
  "commander": "^13.1.0",
49
54
  "figlet": "^1.8.0",
50
55
  "fs-extra": "^11.3.0",
@@ -58,11 +63,8 @@
58
63
  "homepage": "https://github.com/JStaRFilms/VibeCode-Protocol-Suite#readme",
59
64
  "devDependencies": {
60
65
  "@earendil-works/pi-agent-core": "^0.78.1",
61
- "@earendil-works/pi-ai": "^0.78.1",
62
66
  "@earendil-works/pi-coding-agent": "^0.78.1",
63
67
  "@earendil-works/pi-tui": "^0.78.1",
64
- "@mariozechner/pi-ai": "^0.70.2",
65
- "@mariozechner/pi-coding-agent": "^0.70.2",
66
68
  "@types/node": "^25.5.2",
67
69
  "typebox": "^1.1.33",
68
70
  "typescript": "^6.0.2"
package/src/pi-harness.js CHANGED
@@ -213,6 +213,42 @@ function runCommand(command, args) {
213
213
  return spawnSync(command, args, { stdio: 'pipe', encoding: 'utf8', shell: process.platform === 'win32' });
214
214
  }
215
215
 
216
+ function runCommandWithTimeout(command, args, timeoutMs) {
217
+ return new Promise((resolve) => {
218
+ let stdout = '';
219
+ let stderr = '';
220
+ let settled = false;
221
+ let timedOut = false;
222
+
223
+ const child = spawn(command, args, {
224
+ stdio: ['ignore', 'pipe', 'pipe'],
225
+ shell: process.platform === 'win32',
226
+ windowsHide: true,
227
+ });
228
+
229
+ const finish = (result) => {
230
+ if (settled) return;
231
+ settled = true;
232
+ clearTimeout(timer);
233
+ resolve({ stdout, stderr, timedOut, ...result });
234
+ };
235
+
236
+ const timer = setTimeout(() => {
237
+ timedOut = true;
238
+ try { child.kill(); } catch {}
239
+ setTimeout(() => {
240
+ try { child.kill('SIGKILL'); } catch {}
241
+ }, 1500).unref?.();
242
+ }, timeoutMs);
243
+ timer.unref?.();
244
+
245
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
246
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
247
+ child.on('error', (error) => finish({ status: 1, error }));
248
+ child.on('close', (code) => finish({ status: timedOut ? null : code }));
249
+ });
250
+ }
251
+
216
252
  export async function ensurePiInstalled() {
217
253
  const before = await detectPiCommand();
218
254
  if (before.installed) {
@@ -300,17 +336,28 @@ export function printPiSubagentsInstallResult(result) {
300
336
  if (result.report) console.log(pc.dim(result.report.split(/\r?\n/).slice(-8).join('\n')));
301
337
  }
302
338
 
303
- export async function updatePiManagedPackages() {
339
+ export async function updatePiManagedPackages({ timeoutMs = 20_000 } = {}) {
340
+ if (process.env.TAKOMI_SKIP_PI_PACKAGE_UPDATE === '1') {
341
+ return { ok: true, changed: false, report: 'Skipped Pi package update because TAKOMI_SKIP_PI_PACKAGE_UPDATE=1.' };
342
+ }
343
+
304
344
  const pi = await detectPiCommand();
305
345
  if (!pi.installed) return { ok: false, changed: false, report: 'Pi is not installed.' };
306
346
 
307
- // `pi update` reconciles every package listed in Pi settings, including
308
- // user-installed npm/git/local packages. Keep this broad so Takomi refresh
309
- // updates old, new, custom, and optional feature packages without needing to
310
- // know their names ahead of time.
347
+ // `pi update` reconciles packages from Pi settings, including user-installed
348
+ // npm/git/local packages. Treat this as a best-effort follow-up: third-party
349
+ // packages or manual extension entries must never hang Takomi installation.
311
350
  const command = pi.path || 'pi';
312
- const result = runCommand(command, ['update']);
351
+ const result = await runCommandWithTimeout(command, ['update'], timeoutMs);
313
352
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
353
+ if (result.timedOut) {
354
+ return {
355
+ ok: false,
356
+ changed: false,
357
+ report: `Timed out after ${Math.round(timeoutMs / 1000)}s while running \`pi update\`. Takomi setup is complete; retry package updates later with \`pi update\` or skip this step with TAKOMI_SKIP_PI_PACKAGE_UPDATE=1. Last output:\n${output}`.trim(),
358
+ };
359
+ }
360
+
314
361
  return {
315
362
  ok: result.status === 0,
316
363
  changed: result.status === 0,