@vanillagreen/pi-qol 1.5.0 → 1.7.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.
- package/README.md +17 -6
- package/extensions/qol/budget-guard-runtime.ts +5 -0
- package/extensions/qol/notifications.ts +19 -5
- package/extensions/qol/rate-limit-auto-resume.ts +447 -0
- package/extensions/qol/schedule.ts +344 -0
- package/extensions/qol/status-message.ts +7 -2
- package/extensions/qol.ts +81 -22
- package/package.json +77 -3
package/README.md
CHANGED
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|

|
|
6
6
|

|
|
7
7
|
|
|
8
|
-
Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, notifications, and a permission gate.
|
|
8
|
+
Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, scheduled prompts, notifications, and a permission gate.
|
|
9
9
|
|
|
10
10
|
## Highlights
|
|
11
11
|
|
|
12
|
-
- Compact statusline with repo, branch, model, thinking level, and context percent.
|
|
12
|
+
- Compact statusline with repo, branch, model, thinking level, and context percent; can be disabled.
|
|
13
13
|
- Newline-insert in the editor (multi-line drafts without auto-submit), with a fallback binding for terminals that can't distinguish the primary key.
|
|
14
14
|
- Sessions auto-name from your first prompt. `/rename` overrides anytime.
|
|
15
15
|
- `/search` browses previous sessions with snippet previews; the configured shortcut opens it instantly.
|
|
16
16
|
- `/context` shows a Claude-style context-window breakdown.
|
|
17
17
|
- `/handoff <goal>` drafts a focused prompt for a new session.
|
|
18
|
+
- `/schedule 20m <message>` or `/schedule 1h45m <message>` sends a delayed prompt without invoking the model until the timer fires.
|
|
19
|
+
- Optional rate-limit auto-resume sends a configurable continuation after reset.
|
|
18
20
|
- Permission gate prompts before risky `bash` commands. Default match: `rm -Rf`.
|
|
19
21
|
- Notifications for ready, questions, blocked states, and task completion.
|
|
20
22
|
- Thinking timer next to collapsed `Thinking...` labels.
|
|
@@ -51,9 +53,12 @@ Restart Pi after installation.
|
|
|
51
53
|
| `/search [query]` | Open previous-session search. |
|
|
52
54
|
| `/search:refresh` | Refresh the session search cache. |
|
|
53
55
|
| `/handoff <goal>` | Draft a handoff prompt for a new session. |
|
|
56
|
+
| `/schedule <delay> <message>` | Send a user message after a timer without invoking the model now. Example: `/schedule 1h45m retry the previous request`. |
|
|
54
57
|
|
|
55
58
|
Arguments support autocomplete.
|
|
56
59
|
|
|
60
|
+
`/schedule` accepts `ms`, `s`, `m`, `h`, and `d` units; bare numbers mean minutes. Compact composite durations are accepted in largest-to-smallest order, like `1h45m`, `45m10s`, or `1h45m30s`. Pending prompts render above the statusline like steering/follow-up previews until they are sent or cancelled. Manage pending prompts with `/schedule list` and `/schedule cancel <id|all>`. Schedules are stored in the Pi session and re-armed on reload/resume; if Pi is not running at the due time, an overdue prompt sends when that session is next loaded.
|
|
61
|
+
|
|
57
62
|
## Settings
|
|
58
63
|
|
|
59
64
|
Open `/extensions:settings`; settings appear under the **QOL** tab. Names below match the labels shown there.
|
|
@@ -65,7 +70,8 @@ Glyph style: each package exposes `glyphStyle` (`unicode` default, `ascii` for t
|
|
|
65
70
|
| Setting | What it does |
|
|
66
71
|
| --- | --- |
|
|
67
72
|
| Enable QOL editor helpers | Master toggle for QOL statusline, commands, notifications, search, compaction, and editor helpers. |
|
|
68
|
-
|
|
|
73
|
+
| Show compact statusline | Render or disable the QOL statusline row. |
|
|
74
|
+
| Replace built-in footer | Hide Pi's default footer while the QOL statusline is enabled. |
|
|
69
75
|
| Use π prompt editor | Use the compact prompt editor. |
|
|
70
76
|
| Show session name title | Show the session name above the prompt and in the tmux pane title. |
|
|
71
77
|
| Sync session name to tmux window name | Rename the tmux window to `π <session>`. |
|
|
@@ -82,11 +88,13 @@ Glyph style: each package exposes `glyphStyle` (`unicode` default, `ascii` for t
|
|
|
82
88
|
| Style image chips | Render `[Image #N]` placeholders as distinct chips. |
|
|
83
89
|
| Show attachment count | Show a status badge when the draft has image placeholders. |
|
|
84
90
|
|
|
85
|
-
### Session
|
|
91
|
+
### Session
|
|
86
92
|
|
|
87
93
|
| Setting | What it does |
|
|
88
94
|
| --- | --- |
|
|
89
95
|
| Enable /rename command | Register the `/rename` command. |
|
|
96
|
+
| Enable /schedule command | Register `/schedule` for timer-based prompts, useful for retrying after rate limits reset. |
|
|
97
|
+
| Auto-resume after rate limits | Send the configured continuation after a detected reset; cancels on newer turn. |
|
|
90
98
|
| Auto-name new sessions | Generate a friendly session name from the first prompt. |
|
|
91
99
|
| Auto-rename model | Model used for title generation. |
|
|
92
100
|
| Auto-rename fallback model | Model tried when the primary fails. |
|
|
@@ -130,7 +138,7 @@ Master toggle: **Enable notifications**.
|
|
|
130
138
|
|
|
131
139
|
Triggers (notify when): ready, direction needed, question popups, all tasks complete, critical/blocked.
|
|
132
140
|
|
|
133
|
-
Channels: terminal bell, native terminal notifications (OSC 777/99 or Windows toast), tmux `display-message`, tmux window marking, OSC passthrough, and an optional in-Pi UI notice.
|
|
141
|
+
Channels: terminal bell, **Mute bell sound**, native terminal notifications (OSC 777/99 or Windows toast), tmux `display-message`, tmux window marking, OSC passthrough, and an optional in-Pi UI notice.
|
|
134
142
|
|
|
135
143
|
Tuning: cooldown seconds, title, ready message, body length, tmux durations.
|
|
136
144
|
|
|
@@ -138,9 +146,10 @@ Notes:
|
|
|
138
146
|
|
|
139
147
|
- **Terminal notification protocol** picks between OSC 99 (Kitty) and OSC 777 automatically.
|
|
140
148
|
- **Bell when tmux window active** is off so you don't get bells while looking at Pi.
|
|
149
|
+
- **Mute bell sound** keeps notification routing enabled but suppresses QOL-emitted BEL bytes and uses ST terminators for OSC 777/99 where supported. Terminals or operating systems may still play their own sound for native notifications outside QOL control.
|
|
141
150
|
- **tmux native via client TTY** sends OSC notifications to attached tmux clients so notifications still appear when the Pi window is inactive.
|
|
142
151
|
|
|
143
|
-
Use `/qol notify-test` to verify your terminal/tmux setup.
|
|
152
|
+
Use `/qol notify-test` to verify your terminal/tmux setup, including silent behavior with **Mute bell sound** enabled.
|
|
144
153
|
|
|
145
154
|
### Permission gate
|
|
146
155
|
|
|
@@ -184,6 +193,8 @@ For autonomous/Flightdeck-style runs the agent never goes idle, so idle compacti
|
|
|
184
193
|
|
|
185
194
|
When the budget guard fires it injects a sentinel into the compaction request so the QOL bounded handler always runs — chunked summarizer + handoff artifact — even if **Custom compaction summaries** is off. Manual compactions (`/tree`, idle compaction, user-triggered) still only use the QOL handler when **Custom compaction summaries** is on; otherwise they fall through to Pi's default compaction with no handoff artifact and no chunking. If you want every compaction to use the QOL bounded path, turn **Custom compaction summaries** on.
|
|
186
195
|
|
|
196
|
+
While budget-guard compaction is running, QOL keeps a persistent status line above the prompt (and in the normal status footer when the compact statusline is disabled). After Pi prints the compacted-summary block, the line changes to `QOL budget guard finalizing compaction…` until `ctx.compact()` reports completion, so long reload/finalization gaps do not look frozen.
|
|
197
|
+
|
|
187
198
|
Recommended values for autonomous/Flightdeck runs:
|
|
188
199
|
|
|
189
200
|
- Keep **Long-session budget guard** on. Lower **Budget guard percent** to `75` if your provider buffers are tight.
|
|
@@ -17,6 +17,7 @@ export interface GuardDispatchInput {
|
|
|
17
17
|
trigger: BudgetTrigger | undefined;
|
|
18
18
|
compact: ((options: GuardCompactOptions) => void) | undefined;
|
|
19
19
|
notify: (message: string, level: GuardLevel) => void;
|
|
20
|
+
onStatus?: (message: string | undefined) => void;
|
|
20
21
|
staleCtx?: () => boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -70,6 +71,7 @@ export class BudgetGuardDriver {
|
|
|
70
71
|
return { kind: "no-compact-fn" };
|
|
71
72
|
}
|
|
72
73
|
input.notify(`QOL budget guard starting compaction: ${trigger.reason}`, "info");
|
|
74
|
+
input.onStatus?.(`QOL budget guard compacting session: ${trigger.reason}`);
|
|
73
75
|
this.inFlight = true;
|
|
74
76
|
this.lastKey = trigger.key;
|
|
75
77
|
const instructions = `${QOL_BUDGET_GUARD_SENTINEL} QOL budget guard triggered at agent_end because ${trigger.reason}. Bound the summary input, preserve current task state, decisions, files, blockers, and next steps.`;
|
|
@@ -78,6 +80,7 @@ export class BudgetGuardDriver {
|
|
|
78
80
|
customInstructions: instructions,
|
|
79
81
|
onComplete: () => {
|
|
80
82
|
this.inFlight = false;
|
|
83
|
+
input.onStatus?.(undefined);
|
|
81
84
|
input.notify("QOL budget guard compaction completed.", "info");
|
|
82
85
|
},
|
|
83
86
|
onError: (error: Error) => {
|
|
@@ -85,6 +88,7 @@ export class BudgetGuardDriver {
|
|
|
85
88
|
// Allow the next agent_end to retry rather than poisoning the
|
|
86
89
|
// crossing key after a transient failure.
|
|
87
90
|
this.lastKey = undefined;
|
|
91
|
+
input.onStatus?.(undefined);
|
|
88
92
|
input.notify(`QOL budget guard compaction failed: ${error.message}`, "error");
|
|
89
93
|
},
|
|
90
94
|
});
|
|
@@ -92,6 +96,7 @@ export class BudgetGuardDriver {
|
|
|
92
96
|
} catch (error) {
|
|
93
97
|
this.inFlight = false;
|
|
94
98
|
this.lastKey = undefined;
|
|
99
|
+
input.onStatus?.(undefined);
|
|
95
100
|
const message = error instanceof Error ? error.message : String(error);
|
|
96
101
|
input.notify(`QOL budget guard compaction failed to start: ${message}`, "error");
|
|
97
102
|
return { kind: "dispatch-threw", error: message, reason: trigger.reason };
|
|
@@ -52,6 +52,19 @@ function tmuxPassthrough(sequence: string): string {
|
|
|
52
52
|
return `\x1bPtmux;${sequence.replace(/\x1b/g, "\x1b\x1b")}\x1b\\`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export function terminalBellSequence(muteBellSound: boolean): string | undefined {
|
|
56
|
+
return muteBellSound ? undefined : "\x07";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function osc777NotificationSequence(title: string, body: string, muteBellSound = false): string {
|
|
60
|
+
const terminator = muteBellSound ? "\x1b\\" : "\x07";
|
|
61
|
+
return `\x1b]777;notify;${title};${body}${terminator}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function notificationBellMuted(cwd?: string): boolean {
|
|
65
|
+
return settingBoolean("notification.muteBellSound", false, cwd);
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
function sourcePaneTty(): string | undefined {
|
|
56
69
|
try {
|
|
57
70
|
if (!process.env.TMUX_PANE) return undefined;
|
|
@@ -127,21 +140,22 @@ function writeTerminalSequence(sequence: string, cwd?: string): void {
|
|
|
127
140
|
if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
|
|
128
141
|
// Inactive tmux windows do not forward arbitrary OSC output to the terminal.
|
|
129
142
|
// Send native terminal notifications straight to attached tmux client TTYs,
|
|
130
|
-
// while
|
|
143
|
+
// while the explicit terminal bell still goes through the source pane when unmuted.
|
|
131
144
|
if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
|
|
132
145
|
}
|
|
133
146
|
const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
|
|
134
147
|
writeToTerminal(output);
|
|
135
148
|
}
|
|
136
149
|
|
|
137
|
-
function writeTerminalBell(): void {
|
|
150
|
+
function writeTerminalBell(cwd?: string): void {
|
|
138
151
|
// Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
|
|
139
152
|
// This lets tmux set window_bell_flag for the correct source window.
|
|
140
|
-
|
|
153
|
+
const sequence = terminalBellSequence(notificationBellMuted(cwd));
|
|
154
|
+
if (sequence) writeToTerminal(sequence);
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
function notifyOSC777(title: string, body: string, cwd?: string): void {
|
|
144
|
-
writeTerminalSequence(
|
|
158
|
+
writeTerminalSequence(osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
|
|
145
159
|
}
|
|
146
160
|
|
|
147
161
|
function notifyOSC99(title: string, body: string, cwd?: string): void {
|
|
@@ -242,7 +256,7 @@ export function sendQolNotification(ctx: ExtensionContext | undefined, kind: Qol
|
|
|
242
256
|
const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
|
|
243
257
|
const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
|
|
244
258
|
const tmuxWindowActive = sourceTmuxWindowActive();
|
|
245
|
-
if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell();
|
|
259
|
+
if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell(cwd);
|
|
246
260
|
if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
|
|
247
261
|
if (!tmuxWindowActive) markTmuxWindow(cwd);
|
|
248
262
|
if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ansiRed } from "./ansi.js";
|
|
4
|
+
import { boundedSettingNumber, settingBoolean, settingString } from "./settings.js";
|
|
5
|
+
import { stringifyError } from "./util.js";
|
|
6
|
+
|
|
7
|
+
export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
8
|
+
export const DEFAULT_RATE_LIMIT_RESUME_MESSAGE = "Hit API rate limit. It has been reset. Continue";
|
|
9
|
+
|
|
10
|
+
const MAX_TIMER_DELAY_MS = 2_147_000_000;
|
|
11
|
+
const DEFAULT_BUFFER_SECONDS = 10;
|
|
12
|
+
const DEFAULT_MAX_DELAY_MINUTES = 24 * 60;
|
|
13
|
+
const RATE_LIMIT_TOKEN = "[rate-limit]";
|
|
14
|
+
|
|
15
|
+
interface RateLimitTimer {
|
|
16
|
+
unref?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RateLimitClock {
|
|
20
|
+
now(): number;
|
|
21
|
+
setTimeout(callback: () => void, delayMs: number): RateLimitTimer;
|
|
22
|
+
clearTimeout(timer: RateLimitTimer): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RateLimitHint {
|
|
26
|
+
message?: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
observedAt: number;
|
|
29
|
+
provider?: string;
|
|
30
|
+
reason?: string;
|
|
31
|
+
resetAt?: number;
|
|
32
|
+
source: string;
|
|
33
|
+
turnId: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PendingRateLimitResume {
|
|
37
|
+
dueAt: number;
|
|
38
|
+
message: string;
|
|
39
|
+
reason?: string;
|
|
40
|
+
resetAt: number;
|
|
41
|
+
source: string;
|
|
42
|
+
timer?: RateLimitTimer;
|
|
43
|
+
turnId: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_CLOCK: RateLimitClock = {
|
|
47
|
+
now: () => Date.now(),
|
|
48
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
49
|
+
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
53
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stringValue(value: unknown): string | undefined {
|
|
57
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function numberValue(value: unknown): number | undefined {
|
|
61
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : Number.NaN;
|
|
62
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function allHeaderValues(headers: unknown, key: string): string[] {
|
|
66
|
+
const wanted = key.toLowerCase();
|
|
67
|
+
if (!headers || typeof headers !== "object") return [];
|
|
68
|
+
const anyHeaders = headers as any;
|
|
69
|
+
try {
|
|
70
|
+
if (typeof anyHeaders.get === "function") {
|
|
71
|
+
const value = anyHeaders.get(key) ?? anyHeaders.get(wanted);
|
|
72
|
+
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Fall through to object form.
|
|
76
|
+
}
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const [rawKey, rawValue] of Object.entries(headers as Record<string, unknown>)) {
|
|
79
|
+
if (rawKey.toLowerCase() !== wanted) continue;
|
|
80
|
+
if (Array.isArray(rawValue)) {
|
|
81
|
+
for (const item of rawValue) if (typeof item === "string" && item.trim()) out.push(item.trim());
|
|
82
|
+
} else if (typeof rawValue === "string" && rawValue.trim()) out.push(rawValue.trim());
|
|
83
|
+
else if (typeof rawValue === "number" && Number.isFinite(rawValue)) out.push(String(rawValue));
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function looksLikeRateLimitText(text: string): boolean {
|
|
89
|
+
return /\b(rate[-\s]?limit(?:ed)?|too many requests|\b429\b|quota exceeded|resource exhausted|try again later|retry[-\s]?after|out of extra usage|extra usage)\b/i.test(text);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseUnitDurationMs(amount: number, unit: string | undefined, defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
|
|
93
|
+
if (!Number.isFinite(amount) || amount < 0) return undefined;
|
|
94
|
+
const normalized = (unit ?? defaultUnit).toLowerCase();
|
|
95
|
+
const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(normalized)
|
|
96
|
+
? 1
|
|
97
|
+
: ["s", "sec", "secs", "second", "seconds"].includes(normalized)
|
|
98
|
+
? 1000
|
|
99
|
+
: ["m", "min", "mins", "minute", "minutes"].includes(normalized)
|
|
100
|
+
? 60 * 1000
|
|
101
|
+
: ["h", "hr", "hrs", "hour", "hours"].includes(normalized)
|
|
102
|
+
? 60 * 60 * 1000
|
|
103
|
+
: ["d", "day", "days"].includes(normalized)
|
|
104
|
+
? 24 * 60 * 60 * 1000
|
|
105
|
+
: undefined;
|
|
106
|
+
if (!multiplier) return undefined;
|
|
107
|
+
const delay = Math.round(amount * multiplier);
|
|
108
|
+
return Number.isFinite(delay) ? delay : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseDurationLikeMs(value: string, defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
|
|
112
|
+
const text = value.trim().toLowerCase();
|
|
113
|
+
if (!text) return undefined;
|
|
114
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return parseUnitDurationMs(Number(text), undefined, defaultUnit);
|
|
115
|
+
const single = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)$/i);
|
|
116
|
+
if (single) return parseUnitDurationMs(Number(single[1]), single[2], defaultUnit);
|
|
117
|
+
let total = 0;
|
|
118
|
+
let matched = false;
|
|
119
|
+
const re = /(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)/gi;
|
|
120
|
+
for (const match of text.matchAll(re)) {
|
|
121
|
+
const part = parseUnitDurationMs(Number(match[1]), match[2], defaultUnit);
|
|
122
|
+
if (part === undefined) return undefined;
|
|
123
|
+
total += part;
|
|
124
|
+
matched = true;
|
|
125
|
+
}
|
|
126
|
+
return matched && Number.isFinite(total) ? Math.round(total) : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseRetryAfterMs(value: string, now = Date.now(), defaultUnit: "ms" | "s" | "m" = "s"): number | undefined {
|
|
130
|
+
const duration = parseDurationLikeMs(value, defaultUnit);
|
|
131
|
+
if (duration !== undefined) return now + duration;
|
|
132
|
+
const parsedDate = Date.parse(value);
|
|
133
|
+
return Number.isFinite(parsedDate) ? parsedDate : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseResetValueMs(value: string, now = Date.now()): number | undefined {
|
|
137
|
+
const trimmed = value.trim();
|
|
138
|
+
if (!trimmed) return undefined;
|
|
139
|
+
const numeric = Number(trimmed);
|
|
140
|
+
if (Number.isFinite(numeric)) {
|
|
141
|
+
if (numeric > 1_000_000_000_000) return numeric;
|
|
142
|
+
if (numeric > 1_000_000_000) return numeric * 1000;
|
|
143
|
+
return now + numeric * 1000;
|
|
144
|
+
}
|
|
145
|
+
const parsedDate = Date.parse(trimmed);
|
|
146
|
+
if (Number.isFinite(parsedDate)) return parsedDate;
|
|
147
|
+
const duration = parseDurationLikeMs(trimmed, "s");
|
|
148
|
+
return duration !== undefined ? now + duration : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function extractResetAtFromHeaders(headers: unknown, now = Date.now()): { resetAt: number; source: string } | undefined {
|
|
152
|
+
for (const value of allHeaderValues(headers, "retry-after-ms")) {
|
|
153
|
+
const resetAt = parseRetryAfterMs(value, now, "ms");
|
|
154
|
+
if (resetAt !== undefined) return { resetAt, source: "retry-after-ms" };
|
|
155
|
+
}
|
|
156
|
+
for (const value of allHeaderValues(headers, "retry-after")) {
|
|
157
|
+
const resetAt = parseRetryAfterMs(value, now, "s");
|
|
158
|
+
if (resetAt !== undefined) return { resetAt, source: "retry-after" };
|
|
159
|
+
}
|
|
160
|
+
const resetHeaders = [
|
|
161
|
+
"x-ratelimit-reset",
|
|
162
|
+
"x-rate-limit-reset",
|
|
163
|
+
"ratelimit-reset",
|
|
164
|
+
"x-ratelimit-reset-requests",
|
|
165
|
+
"x-ratelimit-reset-tokens",
|
|
166
|
+
"anthropic-ratelimit-requests-reset",
|
|
167
|
+
"anthropic-ratelimit-tokens-reset",
|
|
168
|
+
"anthropic-ratelimit-input-tokens-reset",
|
|
169
|
+
"anthropic-ratelimit-output-tokens-reset",
|
|
170
|
+
];
|
|
171
|
+
for (const header of resetHeaders) {
|
|
172
|
+
for (const value of allHeaderValues(headers, header)) {
|
|
173
|
+
const resetAt = parseResetValueMs(value, now);
|
|
174
|
+
if (resetAt !== undefined) return { resetAt, source: header };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseClockReset(text: string, now = Date.now()): number | undefined {
|
|
181
|
+
const match = text.match(/\bresets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(am|pm)?\b/i);
|
|
182
|
+
if (!match) return undefined;
|
|
183
|
+
let hour = Number(match[1]);
|
|
184
|
+
const minute = Number(match[2] ?? "0");
|
|
185
|
+
const second = Number(match[3] ?? "0");
|
|
186
|
+
const meridiem = match[4]?.toLowerCase();
|
|
187
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) || !Number.isInteger(second)) return undefined;
|
|
188
|
+
if (minute > 59 || second > 59) return undefined;
|
|
189
|
+
if (meridiem) {
|
|
190
|
+
if (hour < 1 || hour > 12) return undefined;
|
|
191
|
+
if (meridiem === "pm" && hour !== 12) hour += 12;
|
|
192
|
+
if (meridiem === "am" && hour === 12) hour = 0;
|
|
193
|
+
} else if (hour > 23) return undefined;
|
|
194
|
+
const date = new Date(now);
|
|
195
|
+
date.setHours(hour, minute, second, 0);
|
|
196
|
+
if (date.getTime() <= now) date.setDate(date.getDate() + 1);
|
|
197
|
+
return date.getTime();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function extractResetAtFromText(text: string, now = Date.now()): { resetAt: number; source: string } | undefined {
|
|
201
|
+
const iso = text.match(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})\b/);
|
|
202
|
+
if (iso) {
|
|
203
|
+
const resetAt = Date.parse(iso[0]);
|
|
204
|
+
if (Number.isFinite(resetAt)) return { resetAt, source: "text-iso" };
|
|
205
|
+
}
|
|
206
|
+
const durationMatch = text.match(/\b(?:retry(?:\s+after|\s+in)?|try again in|wait)\s+(\d+(?:\.\d+)?\s*(?:ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)(?:\s*\d+(?:\.\d+)?\s*(?:ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?))*)\b/i);
|
|
207
|
+
if (durationMatch) {
|
|
208
|
+
const duration = parseDurationLikeMs(durationMatch[1], "s");
|
|
209
|
+
if (duration !== undefined) return { resetAt: now + duration, source: "text-duration" };
|
|
210
|
+
}
|
|
211
|
+
const clock = parseClockReset(text, now);
|
|
212
|
+
return clock !== undefined ? { resetAt: clock, source: "text-clock" } : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function messageText(message: any): string {
|
|
216
|
+
const parts: string[] = [];
|
|
217
|
+
if (typeof message?.errorMessage === "string") parts.push(message.errorMessage);
|
|
218
|
+
if (typeof message?.content === "string") parts.push(message.content);
|
|
219
|
+
else if (Array.isArray(message?.content)) {
|
|
220
|
+
for (const block of message.content) {
|
|
221
|
+
if (typeof block === "string") parts.push(block);
|
|
222
|
+
else if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return parts.filter(Boolean).join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function lastAssistantMessage(event: any, ctx: ExtensionContext): any | undefined {
|
|
229
|
+
const eventMessages = Array.isArray(event?.messages) ? event.messages : [];
|
|
230
|
+
for (let index = eventMessages.length - 1; index >= 0; index -= 1) {
|
|
231
|
+
const message = eventMessages[index];
|
|
232
|
+
if (message?.role === "assistant") return message;
|
|
233
|
+
}
|
|
234
|
+
const branch = ctx.sessionManager.getBranch?.() ?? [];
|
|
235
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
236
|
+
const entry = branch[index] as any;
|
|
237
|
+
if (entry?.type === "message" && entry.message?.role === "assistant") return entry.message;
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function formatDueTime(timestamp: number): string {
|
|
243
|
+
return new Date(timestamp).toLocaleString(undefined, {
|
|
244
|
+
day: "numeric",
|
|
245
|
+
hour: "numeric",
|
|
246
|
+
minute: "2-digit",
|
|
247
|
+
month: "short",
|
|
248
|
+
timeZoneName: "short",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function previewMessage(message: string, max = 72): string {
|
|
253
|
+
const singleLine = message.replace(/\s+/g, " ").trim();
|
|
254
|
+
return singleLine.length <= max ? singleLine : `${singleLine.slice(0, Math.max(0, max - 1))}…`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function settingsEnabled(ctx: ExtensionContext): boolean {
|
|
258
|
+
return settingBoolean("rateLimitAutoResume.enabled", false, ctx.cwd);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function settingsNotify(ctx: ExtensionContext): boolean {
|
|
262
|
+
return settingBoolean("rateLimitAutoResume.notify", true, ctx.cwd);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function settingsMessage(ctx: ExtensionContext): string {
|
|
266
|
+
return settingString("rateLimitAutoResume.message", DEFAULT_RATE_LIMIT_RESUME_MESSAGE, ctx.cwd);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function settingsBufferMs(ctx: ExtensionContext): number {
|
|
270
|
+
return boundedSettingNumber("rateLimitAutoResume.bufferSeconds", DEFAULT_BUFFER_SECONDS, 0, 600, ctx.cwd) * 1000;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function settingsMaxDelayMs(ctx: ExtensionContext): number {
|
|
274
|
+
return boundedSettingNumber("rateLimitAutoResume.maxDelayMinutes", DEFAULT_MAX_DELAY_MINUTES, 1, 30 * 24 * 60, ctx.cwd) * 60 * 1000;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function createRateLimitAutoResumeController(pi: ExtensionAPI, clock: RateLimitClock = DEFAULT_CLOCK) {
|
|
278
|
+
let onChange: (() => void) | undefined;
|
|
279
|
+
let turnId = 0;
|
|
280
|
+
let latestHint: RateLimitHint | undefined;
|
|
281
|
+
let pending: PendingRateLimitResume | undefined;
|
|
282
|
+
|
|
283
|
+
const notifyChanged = (): void => {
|
|
284
|
+
try { onChange?.(); }
|
|
285
|
+
catch { /* UI refresh must never break recovery state. */ }
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const clearPendingTimer = (): void => {
|
|
289
|
+
if (!pending?.timer) return;
|
|
290
|
+
clock.clearTimeout(pending.timer);
|
|
291
|
+
pending.timer = undefined;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const clearPending = (): void => {
|
|
295
|
+
const hadPending = Boolean(pending);
|
|
296
|
+
clearPendingTimer();
|
|
297
|
+
pending = undefined;
|
|
298
|
+
if (hadPending) notifyChanged();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const noteHint = (hint: Omit<RateLimitHint, "observedAt" | "turnId"> & { observedAt?: number; turnId?: number }): void => {
|
|
302
|
+
if (hint.resetAt !== undefined && (!Number.isFinite(hint.resetAt) || hint.resetAt <= 0)) return;
|
|
303
|
+
latestHint = {
|
|
304
|
+
...hint,
|
|
305
|
+
observedAt: hint.observedAt ?? clock.now(),
|
|
306
|
+
turnId: hint.turnId ?? turnId,
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const deliverPending = async (snapshot: PendingRateLimitResume, ctx: ExtensionContext): Promise<void> => {
|
|
311
|
+
if (pending !== snapshot) return;
|
|
312
|
+
if (snapshot.turnId !== turnId || !settingsEnabled(ctx)) {
|
|
313
|
+
clearPending();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (clock.now() < snapshot.dueAt) {
|
|
317
|
+
armPending(snapshot, ctx);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
clearPending();
|
|
321
|
+
try {
|
|
322
|
+
let idle = true;
|
|
323
|
+
try { idle = ctx.isIdle?.() ?? true; }
|
|
324
|
+
catch { idle = false; }
|
|
325
|
+
await Promise.resolve(idle ? pi.sendUserMessage(snapshot.message) : pi.sendUserMessage(snapshot.message, { deliverAs: "followUp" }));
|
|
326
|
+
if (ctx.hasUI && settingsNotify(ctx)) ctx.ui.notify(`${RATE_LIMIT_TOKEN} Auto-resume sent after rate-limit reset.`, "info");
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (ctx.hasUI) ctx.ui.notify(`${RATE_LIMIT_TOKEN} Auto-resume failed: ${stringifyError(error)}`, "error");
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
function armPending(snapshot: PendingRateLimitResume, ctx: ExtensionContext): void {
|
|
333
|
+
clearPendingTimer();
|
|
334
|
+
const remaining = Math.max(0, snapshot.dueAt - clock.now());
|
|
335
|
+
snapshot.timer = clock.setTimeout(() => { void deliverPending(snapshot, ctx); }, Math.min(remaining, MAX_TIMER_DELAY_MS));
|
|
336
|
+
snapshot.timer.unref?.();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const scheduleFromHint = (hint: RateLimitHint, ctx: ExtensionContext): boolean => {
|
|
340
|
+
if (!settingsEnabled(ctx)) return false;
|
|
341
|
+
if (hint.turnId !== turnId) return false;
|
|
342
|
+
if (ctx.hasPendingMessages?.()) return false;
|
|
343
|
+
if (hint.resetAt === undefined || !Number.isFinite(hint.resetAt)) return false;
|
|
344
|
+
const dueAt = Math.max(clock.now() + 1000, hint.resetAt + settingsBufferMs(ctx));
|
|
345
|
+
const delay = dueAt - clock.now();
|
|
346
|
+
if (delay > settingsMaxDelayMs(ctx)) return false;
|
|
347
|
+
const message = settingsMessage(ctx);
|
|
348
|
+
clearPendingTimer();
|
|
349
|
+
pending = { dueAt, message, reason: hint.reason, resetAt: hint.resetAt, source: hint.source, turnId };
|
|
350
|
+
armPending(pending, ctx);
|
|
351
|
+
notifyChanged();
|
|
352
|
+
if (ctx.hasUI && settingsNotify(ctx)) {
|
|
353
|
+
ctx.ui.notify(`${RATE_LIMIT_TOKEN} Rate limit hit. Auto-resume scheduled for ${formatDueTime(dueAt)}.`, "warning");
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
enabled(ctx: ExtensionContext): boolean {
|
|
360
|
+
return settingsEnabled(ctx);
|
|
361
|
+
},
|
|
362
|
+
clearTimers(): void {
|
|
363
|
+
clearPending();
|
|
364
|
+
},
|
|
365
|
+
noteAgentStart(_ctx: ExtensionContext): void {
|
|
366
|
+
turnId += 1;
|
|
367
|
+
latestHint = undefined;
|
|
368
|
+
clearPending();
|
|
369
|
+
},
|
|
370
|
+
noteAgentEnd(event: any, ctx: ExtensionContext): boolean {
|
|
371
|
+
const assistant = lastAssistantMessage(event, ctx);
|
|
372
|
+
const text = messageText(assistant);
|
|
373
|
+
const textShowsRateLimit = Boolean(text && looksLikeRateLimitText(text));
|
|
374
|
+
if (textShowsRateLimit) {
|
|
375
|
+
const reset = extractResetAtFromText(text, clock.now());
|
|
376
|
+
noteHint({
|
|
377
|
+
message: text,
|
|
378
|
+
model: stringValue(assistant?.model),
|
|
379
|
+
provider: stringValue(assistant?.provider),
|
|
380
|
+
reason: "assistant error",
|
|
381
|
+
resetAt: reset?.resetAt ?? latestHint?.resetAt,
|
|
382
|
+
source: reset?.source ?? latestHint?.source ?? "assistant-message",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const stoppedOnHint = !assistant || assistant?.stopReason === "error" || assistant?.stopReason === "aborted";
|
|
386
|
+
if (!textShowsRateLimit && !stoppedOnHint) return false;
|
|
387
|
+
return latestHint ? scheduleFromHint(latestHint, ctx) : false;
|
|
388
|
+
},
|
|
389
|
+
noteMessageEnd(event: any, _ctx: ExtensionContext): void {
|
|
390
|
+
const message = event?.message;
|
|
391
|
+
if (!message || message.role !== "assistant") return;
|
|
392
|
+
const text = messageText(message);
|
|
393
|
+
if (!looksLikeRateLimitText(text)) return;
|
|
394
|
+
const reset = extractResetAtFromText(text, clock.now());
|
|
395
|
+
noteHint({
|
|
396
|
+
message: text,
|
|
397
|
+
model: stringValue(message.model),
|
|
398
|
+
provider: stringValue(message.provider),
|
|
399
|
+
reason: "assistant message",
|
|
400
|
+
resetAt: reset?.resetAt ?? latestHint?.resetAt,
|
|
401
|
+
source: reset?.source ?? latestHint?.source ?? "message_end",
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
noteProviderResponse(event: any, _ctx: ExtensionContext): void {
|
|
405
|
+
if (Number(event?.status) !== 429) return;
|
|
406
|
+
const reset = extractResetAtFromHeaders(event?.headers, clock.now());
|
|
407
|
+
noteHint({
|
|
408
|
+
model: stringValue(event?.model?.id) ?? stringValue(event?.model),
|
|
409
|
+
provider: stringValue(event?.provider?.id) ?? stringValue(event?.provider),
|
|
410
|
+
reason: "HTTP 429",
|
|
411
|
+
resetAt: reset?.resetAt ?? latestHint?.resetAt,
|
|
412
|
+
source: reset?.source ?? latestHint?.source ?? "after_provider_response",
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
noteExternalRateLimitEvent(payload: unknown, _ctx: ExtensionContext): void {
|
|
416
|
+
const event = asRecord(payload);
|
|
417
|
+
if (!event) return;
|
|
418
|
+
const status = stringValue(event.status);
|
|
419
|
+
if (status && status !== "rejected" && status !== "rate_limited" && status !== "error") return;
|
|
420
|
+
const rawReset = numberValue(event.resetAtMs) ?? numberValue(event.resetAtUnixMs);
|
|
421
|
+
const textReset = stringValue(event.resetAt) ?? stringValue(event.resetsAt);
|
|
422
|
+
const parsedTextReset = textReset ? parseResetValueMs(textReset, clock.now()) : undefined;
|
|
423
|
+
const resetAt = rawReset ?? parsedTextReset;
|
|
424
|
+
noteHint({
|
|
425
|
+
message: stringValue(event.message),
|
|
426
|
+
model: stringValue(event.model),
|
|
427
|
+
provider: stringValue(event.provider),
|
|
428
|
+
reason: stringValue(event.reason) ?? stringValue(event.rateLimitType),
|
|
429
|
+
resetAt: resetAt ?? latestHint?.resetAt,
|
|
430
|
+
source: stringValue(event.source) ?? latestHint?.source ?? RATE_LIMIT_AUTO_RESUME_EVENT,
|
|
431
|
+
});
|
|
432
|
+
},
|
|
433
|
+
renderPreviewLines(width: number): string[] {
|
|
434
|
+
if (!pending) return [];
|
|
435
|
+
const line = `| ${RATE_LIMIT_TOKEN} Auto-resume ${formatDueTime(pending.dueAt)} — ${previewMessage(pending.message)}`;
|
|
436
|
+
return [truncateToWidth(ansiRed(line), width, "")];
|
|
437
|
+
},
|
|
438
|
+
setOnChange(callback: (() => void) | undefined): void {
|
|
439
|
+
onChange = callback;
|
|
440
|
+
},
|
|
441
|
+
// Test helper / low-level extension bridge.
|
|
442
|
+
scheduleForHint(hint: RateLimitHint, ctx: ExtensionContext): boolean {
|
|
443
|
+
latestHint = hint;
|
|
444
|
+
return scheduleFromHint(hint, ctx);
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|