@tduyng/pi-session 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,20 +1,24 @@
1
1
  # @tduyng/pi-session
2
2
 
3
- Session lifecycle: sessions name themselves and resume once a provider quota resets. No more `untitled` rows in the picker, no more waiting out a usage limit by hand.
3
+ Sessions name themselves, and a turn killed by a usage limit resumes when the limit resets. No more `untitled` rows in the picker, no more sitting out a quota by hand.
4
+
5
+ ## What it costs
6
+
7
+ Nothing. No tool is registered, no prompt text is injected, and no session entry is created for the naming. The extension reads at `agent_end` and writes a name.
4
8
 
5
9
  ## Auto-name
6
10
 
7
- A new session starts unnamed. On `agent_end`, the extension reads the first user message and uses it as the name. It takes the first line, strips a leading `/` or `#`, collapses whitespace, and limits the result to 60 characters, cutting at a word boundary.
11
+ A new session starts unnamed. On `agent_end` the extension reads the first user message and uses it: first line, a leading `/` or `#` stripped, whitespace collapsed, 60 characters at most, cut at a word boundary.
8
12
 
9
- An expanded skill uses its argument when one is present, or the skill name when it has no argument. The extension ignores the expanded instructions and the skill's filesystem path. A `/skill:name task` command follows the same rule and uses `task` when present. A `Skill invocation protocol` message without a skill block is skipped because an extension injected it. A name you set with `/name` always wins. Naming runs last and swallows its own errors. It is cosmetic and never blocks the turn.
13
+ Before, a week of `2026-09-01 14:22`, `2026-09-01 14:47`, `2026-09-02 09:03`. After, `fix retry backoff`, `review auth diff`, `migrate schema`.
10
14
 
11
- The extension finds the first user-role message in the `event.messages` array. If the message content is a block array, it extracts text blocks and joins them with spaces. When no user message exists, naming is skipped silently.
15
+ An expanded skill uses its argument when one is present, or the skill name when it has none. The extension ignores the expanded instructions and the skill's filesystem path. A `/skill:name task` command follows the same rule and uses `task`. A `Skill invocation protocol` message with no skill block is skipped, because an extension injected it. A name you set with `/name` always wins.
12
16
 
13
- Before: a week of `2026-09-01 14:22`, `2026-09-01 14:47`, `2026-09-02 09:03`. After: `fix retry backoff`, `review auth diff`, `migrate schema`.
17
+ Naming runs last and swallows its own errors. It is cosmetic and never blocks a turn.
14
18
 
15
19
  ## Autoresume
16
20
 
17
- When a provider quota exhaustion kills a turn, the extension parses a reset time from the error text. Formats it accepts:
21
+ When a provider quota exhaustion kills a turn, the extension parses a reset time out of the error text. It accepts:
18
22
 
19
23
  - Epoch timestamps: `limit reached|1757193000`
20
24
  - ISO timestamps: `resets at 2026-09-06T23:30:00+02:00`
@@ -22,15 +26,22 @@ When a provider quota exhaustion kills a turn, the extension parses a reset time
22
26
  - Clock times: `resets 11:30pm (Europe/Paris)`, `reset at 23:30`
23
27
  - Retry-after headers: `retry-after: 3600`
24
28
 
25
- For claude-bridge sessions where the error carries no reset time, autoresume queries the Anthropic OAuth usage endpoint and picks the latest reset among all exhausted windows, with the credential from the macOS keychain (`Claude Code-credentials`) or `~/.claude/.credentials.json`.
29
+ For a claude-bridge session whose error carries no reset time, it asks the Anthropic OAuth usage endpoint and picks the latest reset among the exhausted windows, with the credential from the macOS keychain or `~/.claude/.credentials.json`.
26
30
 
27
- A 5-second safety delay is added past the reset time. The timer caps at 24 hours. A parsed reset further than 24 hours out schedules nothing and posts a warning: Usage limit resets in over 24h, auto-resume not scheduled. Any user turn or session shutdown cancels it. The resume sends a real user message: "The usage limit has reset. Continue the previous task from where it stopped."
31
+ A 5-second safety delay is added past the reset, and the timer caps at 24 hours. A reset further out than 24 hours schedules nothing and posts a warning instead. Any user turn or session shutdown cancels it. The resume sends a real user message: "The usage limit has reset. Continue the previous task from where it stopped."
28
32
 
29
- Commands: `/autoresume`, `/autoresume status`, `/autoresume on`, `/autoresume off`, `/autoresume cancel`.
33
+ A quota reset is the cheapest minute of the day to be running: the window opened, the work is ready, and nobody has to be awake for it.
34
+
35
+ | Command | Effect |
36
+ | --- | --- |
37
+ | `/autoresume` | Status |
38
+ | `/autoresume on` | Arm it |
39
+ | `/autoresume off` | Disarm it |
40
+ | `/autoresume cancel` | Drop the pending resume |
30
41
 
31
42
  ## Design
32
43
 
33
- Auto-naming reads at `agent_end`, when real conversation content exists. Autoresume touches no session storage and adds no tools beyond the one command. Session creation, storage, and selection stay with pi.
44
+ Autoresume touches no session storage and adds no tool beyond its command. Session creation, storage, and selection stay with pi.
34
45
 
35
46
  ## Install
36
47
 
@@ -40,6 +51,4 @@ pi install npm:@tduyng/pi-session
40
51
 
41
52
  Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-session`
42
53
 
43
- ## Configure
44
-
45
- None. It works out of the box.
54
+ No configuration required.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tduyng/pi-session",
3
- "version": "0.0.1",
4
- "description": "Session lifecycle: name an unnamed session after its first message, and resume once a provider quota resets.",
3
+ "version": "0.0.3",
4
+ "description": "Sessions name themselves, and a turn killed by a usage limit resumes when the limit resets.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
@@ -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
+ }