@shumkov/orchestra 0.2.0 → 0.3.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 (2) hide show
  1. package/lib/claude-bin.js +41 -0
  2. package/package.json +1 -1
package/lib/claude-bin.js CHANGED
@@ -238,10 +238,51 @@ function ensureVendoredClaudeBin(version, { logger = console } = {}) {
238
238
  return { ok: true, path: vendored, vendored: true };
239
239
  }
240
240
 
241
+ /**
242
+ * Check Claude CLI OAuth health from the credentials file — FREE (no API call,
243
+ * no model spawn). The CLI stores its login in ~/.claude/.credentials.json under
244
+ * `claudeAiOauth`, with two expiries: the short-lived `expiresAt` (access token,
245
+ * which the CLI silently auto-refreshes) and `refreshTokenExpiresAt` (the HARD
246
+ * limit — once the refresh token itself ages out, auto-refresh 401s and every
247
+ * channels-backend turn wedges INVISIBLY: claude fires UserPromptSubmit then
248
+ * dies with "OAuth access token has expired", which never reaches polygram's
249
+ * classifier, so it degrades to a silent "⏱ went quiet"). We watch the
250
+ * refresh-token expiry so the daemon can refuse turns with a clear message
251
+ * instead of wedging.
252
+ *
253
+ * Pure + injectable (`home`/`now`) for tests.
254
+ *
255
+ * @param {{home?:string, now?:number, warnWithinMs?:number}} [opts]
256
+ * @returns {{state:'healthy'|'expiring'|'expired'|'unknown', reason?:string,
257
+ * refreshTokenExpiresAt:?number, msLeft:?number, daysLeft:?number}}
258
+ */
259
+ function checkClaudeAuthHealth({ home = os.homedir(), now = Date.now(), warnWithinMs = 3 * 86_400_000 } = {}) {
260
+ const credPath = path.join(home, '.claude', '.credentials.json');
261
+ let creds;
262
+ try {
263
+ creds = JSON.parse(fs.readFileSync(credPath, 'utf8'));
264
+ } catch (err) {
265
+ // Missing/unreadable → can't prove expiry. Report 'unknown' (caller logs it)
266
+ // rather than 'expired', so a transient read error never hard-refuses traffic.
267
+ return { state: 'unknown', reason: `credentials unreadable: ${err.code || err.message}`, refreshTokenExpiresAt: null, msLeft: null, daysLeft: null };
268
+ }
269
+ const exp = creds && creds.claudeAiOauth && creds.claudeAiOauth.refreshTokenExpiresAt;
270
+ if (typeof exp !== 'number') {
271
+ return { state: 'unknown', reason: 'no claudeAiOauth.refreshTokenExpiresAt', refreshTokenExpiresAt: null, msLeft: null, daysLeft: null };
272
+ }
273
+ const msLeft = exp - now;
274
+ const daysLeft = Math.round((msLeft / 86_400_000) * 10) / 10;
275
+ let state = 'healthy';
276
+ if (msLeft <= 0) state = 'expired';
277
+ else if (msLeft <= warnWithinMs) state = 'expiring';
278
+ return { state, refreshTokenExpiresAt: exp, msLeft, daysLeft };
279
+ }
280
+
241
281
  module.exports = {
242
282
  resolvePinnedClaudeBin,
243
283
  verifyPinnedClaudeBin,
244
284
  ensureVendoredClaudeBin,
245
285
  vendorDir,
246
286
  CLAUDE_CLI_PINNED_VERSION,
287
+ checkClaudeAuthHealth,
247
288
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shumkov/orchestra",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Drive interactive Claude Code CLI sessions: spawn, inject messages via the MCP channels bridge, observe turns, recover. The transport-agnostic session engine extracted from polygram, shared by polygram (Telegram) and water (WhatsApp).",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",