@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 CHANGED
@@ -5,16 +5,18 @@
5
5
  ![Session search popup](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-qol/assets/session-search.gif)
6
6
  ![/context usage breakdown](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-qol/assets/context-usage.png)
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
- | Replace built-in footer | Hide Pi's default footer while the QOL statusline is active. |
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 naming
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 BEL still goes through the source pane so tmux marks the right tab.
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
- writeToTerminal("\x07");
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(`\x1b]777;notify;${title};${body}\x07`, cwd);
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
+ }