@tduyng/pi-session 0.0.1 → 0.0.2

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": "@tduyng/pi-session",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Session lifecycle: name an unnamed session after its first message, and resume once a provider quota resets.",
5
5
  "keywords": [
6
6
  "pi",
@@ -31,6 +31,9 @@
31
31
  "@earendil-works/pi-ai": "*",
32
32
  "@earendil-works/pi-coding-agent": "*"
33
33
  },
34
+ "dependencies": {
35
+ "ata-validator": "^1.62.1"
36
+ },
34
37
  "publishConfig": {
35
38
  "access": "public"
36
39
  },
package/src/autoresume.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
- import { claudeToken, fetchAnthropicUsage } from '../../pi-cost/src/cost/index.js'
3
- import { exhaustedResetAt } from '../../pi-cost/src/cost/logic.js'
2
+ import { claudeToken, exhaustedResetAt, fetchAnthropicUsage } from './usage.js'
4
3
 
5
4
  const SAFETY_DELAY_MS = 5_000
6
5
  const MAX_DELAY_MS = 24 * 60 * 60 * 1000
@@ -33,6 +32,7 @@ export default function autoResume(pi: ExtensionAPI) {
33
32
  pendingError = undefined
34
33
 
35
34
  if (!enabled || !error || resumeAt) return
35
+ if (!isQuotaError(error)) return
36
36
 
37
37
  const reportedTarget = parseReset(error)
38
38
  const providerTarget = ctx.model?.provider === 'claude-bridge' ? await usageResetAt(pi, ctx) : undefined
@@ -78,9 +78,9 @@ export default function autoResume(pi: ExtensionAPI) {
78
78
  })
79
79
 
80
80
  pi.on('session_shutdown', async (_event, ctx) => cancel(ctx))
81
- pi.on('session_start', () => {
81
+ pi.on('session_start', (_event, ctx) => {
82
82
  pendingError = undefined
83
- stopTicker()
83
+ cancel(ctx)
84
84
  })
85
85
  }
86
86
 
@@ -244,10 +244,14 @@ function parseRetryAfter(input: string, now: number): number | undefined {
244
244
  return match ? now + Number(match[1]) * 1000 : undefined
245
245
  }
246
246
 
247
+ function isQuotaError(input: string): boolean {
248
+ return /\b(?:usage|quota|limit|rate[- ]?limit|too many requests|429)\b/i.test(input)
249
+ }
250
+
247
251
  function parseClock(input: string, now: number): number | undefined {
248
252
  const match = input.match(/\breset(?:s)?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(([^)]+)\))?/i)
249
253
 
250
- if (!match) return
254
+ if (!match || (match[2] === undefined && match[3] === undefined)) return
251
255
 
252
256
  let hour = Number(match[1])
253
257
  const minute = Number(match[2] ?? '0')
package/src/usage.ts ADDED
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
+ import { Validator } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ const REQUEST_TIMEOUT_MS = 10_000
9
+ const KEYCHAIN_TIMEOUT_MS = 5_000
10
+
11
+ const WindowSchema = t.object({
12
+ utilization: t.optional(t.union([t.number(), t.null()])),
13
+ resets_at: t.optional(t.union([t.string(), t.number(), t.null()]))
14
+ })
15
+ const UsageSchema = t.object({
16
+ five_hour: t.optional(t.union([WindowSchema, t.null()])),
17
+ seven_day: t.optional(t.union([WindowSchema, t.null()])),
18
+ seven_day_opus: t.optional(t.union([WindowSchema, t.null()])),
19
+ seven_day_sonnet: t.optional(t.union([WindowSchema, t.null()]))
20
+ })
21
+ const CredentialsSchema = t.object(
22
+ {
23
+ claudeAiOauth: t.optional(t.object({ accessToken: t.optional(t.string()) }, { additionalProperties: true }))
24
+ },
25
+ { additionalProperties: true }
26
+ )
27
+
28
+ const isUsage = new Validator(UsageSchema).isValidObject
29
+ const isCredentials = new Validator(CredentialsSchema).isValidObject
30
+
31
+ export type AnthropicWindow = { utilization?: number | null; resets_at?: string | number | null }
32
+
33
+ export type AnthropicUsagePayload = {
34
+ five_hour?: AnthropicWindow | null
35
+ seven_day?: AnthropicWindow | null
36
+ seven_day_opus?: AnthropicWindow | null
37
+ seven_day_sonnet?: AnthropicWindow | null
38
+ }
39
+
40
+ function finite(value: number | null | undefined): number | undefined {
41
+ const numeric = value ?? Number.NaN
42
+ return Number.isFinite(numeric) ? numeric : undefined
43
+ }
44
+
45
+ export function resetAtMs(value: string | number | null | undefined): number | undefined {
46
+ if (value === null || value === undefined) return undefined
47
+ const numeric = Number(value)
48
+ const parsed = Number.isFinite(numeric) ? numeric : Date.parse(String(value))
49
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined
50
+ return parsed < 1e12 ? parsed * 1000 : parsed
51
+ }
52
+
53
+ export function exhaustedResetAt(payload: AnthropicUsagePayload): number | undefined {
54
+ const windows = [payload.five_hour, payload.seven_day, payload.seven_day_opus, payload.seven_day_sonnet]
55
+ let latest: number | undefined
56
+ for (const window of windows) {
57
+ const utilization = finite(window?.utilization)
58
+ const resetAt = resetAtMs(window?.resets_at)
59
+ if (utilization === undefined || utilization < 100 || resetAt === undefined) continue
60
+ latest = latest === undefined ? resetAt : Math.max(latest, resetAt)
61
+ }
62
+ return latest
63
+ }
64
+
65
+ export function claudeAccessToken(raw: string | undefined): string | undefined {
66
+ if (!raw) return undefined
67
+ try {
68
+ const parsed: unknown = JSON.parse(raw)
69
+ if (!isCredentials(parsed)) return undefined
70
+ const token = parsed.claudeAiOauth?.accessToken?.trim()
71
+ return token || undefined
72
+ } catch {
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ export async function claudeToken(
78
+ pi: ExtensionAPI,
79
+ platform: NodeJS.Platform = process.platform
80
+ ): Promise<string | undefined> {
81
+ let raw: string | undefined
82
+ if (platform === 'darwin') {
83
+ const result = await pi.exec('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], {
84
+ cwd: os.homedir(),
85
+ timeout: KEYCHAIN_TIMEOUT_MS
86
+ })
87
+ if (result.code && result.code !== 44) throw new Error(`keychain lookup failed (exit ${result.code})`)
88
+ raw = result.stdout
89
+ }
90
+ if (!raw) {
91
+ raw = await fs.readFile(path.join(os.homedir(), '.claude', '.credentials.json'), 'utf8').catch(() => undefined)
92
+ }
93
+ return claudeAccessToken(raw)
94
+ }
95
+
96
+ export async function fetchAnthropicUsage(token: string): Promise<AnthropicUsagePayload> {
97
+ const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
98
+ headers: {
99
+ authorization: `Bearer ${token}`,
100
+ 'anthropic-beta': 'oauth-2025-04-20',
101
+ accept: 'application/json'
102
+ },
103
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
104
+ })
105
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
106
+ const payload: unknown = await response.json()
107
+ if (!isUsage(payload)) throw new Error('usage response did not match the Anthropic schema')
108
+ return payload
109
+ }