@vanillagreen/pi-qol 1.4.3 → 1.6.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/extensions/qol.ts CHANGED
@@ -2,7 +2,9 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@e
2
2
  import type { TUI } from "@earendil-works/pi-tui";
3
3
  import { criticalInfo, lastAssistantTextFromAgentEnd, needsDirection, taskStats } from "./qol/agent-end.js";
4
4
  import { getQuestionService, readCavemanBridge, type QuestionOpenedEventLike } from "./qol/bridges.js";
5
+ import { BudgetGuardDriver } from "./qol/budget-guard-runtime.js";
5
6
  import {
7
+ budgetGuardTrigger,
6
8
  compactionTriggerReason,
7
9
  handleQolBranchSummary,
8
10
  handleQolCompaction,
@@ -44,6 +46,7 @@ import {
44
46
  restorePendingQueueThemePatch,
45
47
  } from "./qol/pending-queue.js";
46
48
  import { permissionGateMatch, permissionGatePrompt } from "./qol/permission-gate.js";
49
+ import { createRateLimitAutoResumeController, RATE_LIMIT_AUTO_RESUME_EVENT } from "./qol/rate-limit-auto-resume.js";
47
50
  import {
48
51
  autoRenameEnabled,
49
52
  autoRenameNotify,
@@ -53,6 +56,7 @@ import {
53
56
  isStaleCtxError,
54
57
  withAutoRenamePrefix,
55
58
  } from "./qol/session-rename.js";
59
+ import { createScheduleController, getScheduleArgumentCompletions } from "./qol/schedule.js";
56
60
  import {
57
61
  consumePendingSessionSearchContext,
58
62
  openQolSessionSearch,
@@ -257,6 +261,35 @@ export default function qol(pi: ExtensionAPI): void {
257
261
  idleCompactionTimer = undefined;
258
262
  };
259
263
 
264
+ const budgetGuardDriver = new BudgetGuardDriver();
265
+
266
+ const resetBudgetGuard = () => {
267
+ budgetGuardDriver.reset();
268
+ };
269
+
270
+ const maybeFireBudgetGuard = (ctx: ExtensionContext) => {
271
+ let trigger;
272
+ try {
273
+ trigger = budgetGuardTrigger(ctx);
274
+ } catch (error) {
275
+ if (isStaleCtxError(error)) return;
276
+ throw error;
277
+ }
278
+ const notifySafely = (message: string, level: "info" | "warning" | "error") => {
279
+ try {
280
+ if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify(message, level);
281
+ } catch (error) {
282
+ if (isStaleCtxError(error)) return;
283
+ throw error;
284
+ }
285
+ };
286
+ budgetGuardDriver.dispatch({
287
+ compact: typeof ctx.compact === "function" ? ctx.compact.bind(ctx) : undefined,
288
+ notify: notifySafely,
289
+ trigger,
290
+ });
291
+ };
292
+
260
293
  const scheduleIdleCompaction = (ctx: ExtensionContext) => {
261
294
  clearIdleCompactionTimer();
262
295
  if (!settingBoolean("compaction.idleEnabled", false, ctx.cwd)) return;
@@ -344,7 +377,13 @@ export default function qol(pi: ExtensionAPI): void {
344
377
  let lastSessionTitle: string | undefined;
345
378
 
346
379
  const requestRender = () => activeTui?.requestRender();
380
+ const scheduleController = createScheduleController(pi);
381
+ scheduleController.setOnChange(requestRender);
382
+ const rateLimitAutoResumeController = createRateLimitAutoResumeController(pi);
383
+ rateLimitAutoResumeController.setOnChange(requestRender);
384
+ const statuslineEnabled = (ctx: ExtensionContext): boolean => settingBoolean("statusline.enabled", true, ctx.cwd);
347
385
  const refreshStatusline = (ctx: ExtensionContext) => {
386
+ if (!statuslineEnabled(ctx)) return Promise.resolve();
348
387
  if (refreshInFlight) return refreshInFlight;
349
388
  refreshInFlight = refreshGitState(pi, ctx)
350
389
  .then((next) => {
@@ -503,13 +542,22 @@ export default function qol(pi: ExtensionAPI): void {
503
542
  const event = data as QuestionOpenedEventLike;
504
543
  notifyQuestionOpened(currentCtx, event, "question");
505
544
  });
545
+ pi.events.on(RATE_LIMIT_AUTO_RESUME_EVENT, (payload: unknown) => {
546
+ if (!currentCtx) return;
547
+ rateLimitAutoResumeController.noteExternalRateLimitEvent(payload, currentCtx);
548
+ });
506
549
 
507
550
  pi.on("session_start", (event, ctx) => {
508
551
  currentCtx = ctx;
509
552
  subscribeCavemanBridge();
510
553
  latestSystemPromptOptions = undefined;
511
554
  resetAutoRename();
555
+ resetBudgetGuard();
512
556
  resetThinkingTimer(ctx);
557
+ const scheduleEnabled = settingBoolean("enableScheduleCommand", true, ctx.cwd);
558
+ const rateLimitAutoResumeEnabled = rateLimitAutoResumeController.enabled(ctx);
559
+ if (scheduleEnabled) scheduleController.restoreFromBranch(ctx);
560
+ else scheduleController.clearTimers();
513
561
  void consumePendingSessionSearchContext(pi, ctx, event.reason);
514
562
  installAutocompleteHintStyling(ctx);
515
563
  installPendingQueueThemePatch(ctx);
@@ -517,7 +565,8 @@ export default function qol(pi: ExtensionAPI): void {
517
565
  ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
518
566
  applyWorkingIndicatorMode(ctx);
519
567
  gitState = makeFallbackGitState(ctx.cwd);
520
- void refreshStatusline(ctx);
568
+ const showStatusline = statuslineEnabled(ctx);
569
+ if (showStatusline) void refreshStatusline(ctx);
521
570
  installSessionTitle(ctx);
522
571
  installSessionTitleSync(ctx);
523
572
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
@@ -526,27 +575,32 @@ export default function qol(pi: ExtensionAPI): void {
526
575
  ? new QolCompactPromptEditor(tui, theme, keybindings, Math.max(0, Math.floor(settingNumber("inputBottomPaddingLines", DEFAULT_INPUT_BOTTOM_PADDING_LINES, ctx.cwd))), ctx)
527
576
  : new QolEditor(tui, theme, keybindings, ctx);
528
577
  });
529
- const statusWidgetTimer = setTimeout(() => {
530
- ctx.ui.setWidget("statusline", (tui, theme) => {
531
- activeTui = tui;
532
- return {
533
- invalidate() {},
534
- render(width: number): string[] {
535
- return [renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme)];
536
- },
537
- };
538
- });
539
- }, 0);
540
- statusWidgetTimer.unref?.();
541
- if (settingBoolean("replaceFooter", true, ctx.cwd)) {
542
- ctx.ui.setFooter((tui, _theme, footerData) => {
543
- activeTui = tui;
544
- const unsubscribe = footerData.onBranchChange(() => {
545
- void refreshStatusline(ctx);
546
- requestRender();
578
+ if (showStatusline || scheduleEnabled || rateLimitAutoResumeEnabled) {
579
+ const statusWidgetTimer = setTimeout(() => {
580
+ ctx.ui.setWidget("statusline", (tui, theme) => {
581
+ activeTui = tui;
582
+ return {
583
+ invalidate() {},
584
+ render(width: number): string[] {
585
+ const lines = rateLimitAutoResumeController.enabled(ctx) ? rateLimitAutoResumeController.renderPreviewLines(width) : [];
586
+ if (scheduleEnabled) lines.push(...scheduleController.renderPreviewLines(width));
587
+ if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme));
588
+ return lines;
589
+ },
590
+ };
547
591
  });
548
- return { dispose: unsubscribe, invalidate() {}, render: () => [] };
549
- });
592
+ }, 0);
593
+ statusWidgetTimer.unref?.();
594
+ if (showStatusline && settingBoolean("replaceFooter", true, ctx.cwd)) {
595
+ ctx.ui.setFooter((tui, _theme, footerData) => {
596
+ activeTui = tui;
597
+ const unsubscribe = footerData.onBranchChange(() => {
598
+ void refreshStatusline(ctx);
599
+ requestRender();
600
+ });
601
+ return { dispose: unsubscribe, invalidate() {}, render: () => [] };
602
+ });
603
+ }
550
604
  }
551
605
  }
552
606
  startQuestionSubscription(ctx);
@@ -568,7 +622,10 @@ export default function qol(pi: ExtensionAPI): void {
568
622
  pi.on("session_shutdown", (_event, ctx) => {
569
623
  cavemanUnsubscribe?.();
570
624
  cavemanUnsubscribe = undefined;
625
+ scheduleController.clearTimers();
626
+ rateLimitAutoResumeController.clearTimers();
571
627
  resetAutoRename();
628
+ resetBudgetGuard();
572
629
  clearIdleCompactionTimer();
573
630
  clearQuestionSubscribeTimer();
574
631
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
@@ -598,6 +655,7 @@ export default function qol(pi: ExtensionAPI): void {
598
655
  pi.on("agent_start", (_event, ctx) => {
599
656
  clearIdleCompactionTimer();
600
657
  clearTmuxWindowMark();
658
+ rateLimitAutoResumeController.noteAgentStart(ctx);
601
659
  if (ctx.hasUI) {
602
660
  void refreshStatusline(ctx);
603
661
  requestRender();
@@ -624,6 +682,7 @@ export default function qol(pi: ExtensionAPI): void {
624
682
  }
625
683
  });
626
684
  pi.on("message_end", (event, ctx) => {
685
+ rateLimitAutoResumeController.noteMessageEnd(event, ctx);
627
686
  if (!updateThinkingTimerEnabled(ctx)) return;
628
687
  const message = event.message as any;
629
688
  if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
@@ -637,6 +696,8 @@ export default function qol(pi: ExtensionAPI): void {
637
696
  void refreshStatusline(ctx);
638
697
  requestRender();
639
698
  }
699
+ rateLimitAutoResumeController.noteAgentEnd(event, ctx);
700
+ maybeFireBudgetGuard(ctx);
640
701
  scheduleIdleCompaction(ctx);
641
702
  void attemptAutoRename(ctx);
642
703
  const text = lastAssistantTextFromAgentEnd(event, ctx);
@@ -660,10 +721,21 @@ export default function qol(pi: ExtensionAPI): void {
660
721
  sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
661
722
  });
662
723
  pi.on("session_compact", (_event, ctx) => {
724
+ // After a successful compaction usage drops below the budget, so reset the
725
+ // crossing key so the next threshold crossing re-fires the guard.
726
+ budgetGuardDriver.noteSessionCompacted();
663
727
  if (!ctx.hasUI) return;
664
728
  void refreshStatusline(ctx);
665
729
  requestRender();
666
730
  });
731
+ pi.on("session_tree", (_event, ctx) => {
732
+ currentCtx = ctx;
733
+ if (settingBoolean("enableScheduleCommand", true, ctx.cwd)) scheduleController.restoreFromBranch(ctx);
734
+ else scheduleController.clearTimers();
735
+ });
736
+ pi.on("after_provider_response", (event, ctx) => {
737
+ rateLimitAutoResumeController.noteProviderResponse(event, ctx);
738
+ });
667
739
  pi.on("session_before_compact", (event, ctx) => handleQolCompaction(event, ctx));
668
740
  pi.on("session_before_tree", (event, ctx) => handleQolBranchSummary(event, ctx));
669
741
  pi.on("tool_call", async (event: any, ctx) => {
@@ -721,6 +793,14 @@ export default function qol(pi: ExtensionAPI): void {
721
793
  });
722
794
  }
723
795
 
796
+ if (settingBoolean("enableScheduleCommand", true)) {
797
+ pi.registerCommand("schedule", {
798
+ description: "Send a user message after a timer without invoking the model now.",
799
+ getArgumentCompletions: getScheduleArgumentCompletions,
800
+ handler: async (args, ctx) => scheduleController.handleCommand(args, ctx),
801
+ });
802
+ }
803
+
724
804
  if (settingBoolean("enableContextCommand", true)) {
725
805
  pi.registerMessageRenderer(CONTEXT_USAGE_MESSAGE_TYPE, renderQolContextUsageMessage);
726
806
  pi.registerCommand("context", {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "1.4.3",
4
- "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
3
+ "version": "1.6.0",
4
+ "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, scheduled prompts, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -16,6 +16,9 @@
16
16
  ],
17
17
  "image": "https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-qol/assets/settings-panel.png"
18
18
  },
19
+ "scripts": {
20
+ "test": "bun test ./tests"
21
+ },
19
22
  "vstack": {
20
23
  "extensionManager": {
21
24
  "displayName": "QOL",
@@ -30,10 +33,33 @@
30
33
  "apply": "reload",
31
34
  "requiresReload": true
32
35
  },
36
+ {
37
+ "key": "glyphStyle",
38
+ "label": "Glyph style",
39
+ "description": "Glyphs for vstack-owned UI chrome. Unicode is the default; ASCII uses plain box/status/separator characters for terminal compatibility. pi-tool-renderer's global override wins when set.",
40
+ "type": "enum",
41
+ "enumValues": [
42
+ "unicode",
43
+ "ascii"
44
+ ],
45
+ "default": "unicode",
46
+ "category": "Rendering",
47
+ "apply": "live"
48
+ },
49
+ {
50
+ "key": "statusline.enabled",
51
+ "label": "Show compact statusline",
52
+ "description": "Render the QOL statusline row with repo, branch, model, thinking level, and context percentage. Disable when another statusline/footer extension owns this UI.",
53
+ "type": "boolean",
54
+ "default": true,
55
+ "category": "Statusline",
56
+ "apply": "reload",
57
+ "requiresReload": true
58
+ },
33
59
  {
34
60
  "key": "replaceFooter",
35
61
  "label": "Replace built-in footer",
36
- "description": "Hide Pi default footer while the compact QOL statusline is active.",
62
+ "description": "Hide Pi default footer while the compact QOL statusline is enabled.",
37
63
  "type": "boolean",
38
64
  "default": true,
39
65
  "category": "Statusline",
@@ -318,6 +344,61 @@
318
344
  "apply": "reload",
319
345
  "requiresReload": true
320
346
  },
347
+ {
348
+ "key": "enableScheduleCommand",
349
+ "label": "Enable /schedule command",
350
+ "description": "Register /schedule for timer-based user messages, e.g. /schedule 20m retry after rate reset. The timer runs in the extension without invoking the model immediately.",
351
+ "type": "boolean",
352
+ "default": true,
353
+ "category": "Session",
354
+ "apply": "reload",
355
+ "requiresReload": true
356
+ },
357
+ {
358
+ "key": "rateLimitAutoResume.enabled",
359
+ "label": "Auto-resume after rate limits",
360
+ "description": "When Pi detects a rate-limit stop with a reset time, schedule a follow-up user message at the reset time so the agent can continue if nothing else has continued since.",
361
+ "type": "boolean",
362
+ "default": false,
363
+ "category": "Rate Limit Recovery",
364
+ "apply": "live"
365
+ },
366
+ {
367
+ "key": "rateLimitAutoResume.message",
368
+ "label": "Auto-resume message",
369
+ "description": "User message sent after a detected rate limit resets.",
370
+ "type": "string",
371
+ "default": "Hit API rate limit. It has been reset. Continue",
372
+ "category": "Rate Limit Recovery",
373
+ "apply": "live"
374
+ },
375
+ {
376
+ "key": "rateLimitAutoResume.bufferSeconds",
377
+ "label": "Reset buffer seconds",
378
+ "description": "Seconds to wait after the reported reset time before sending the auto-resume message.",
379
+ "type": "number",
380
+ "default": 10,
381
+ "category": "Rate Limit Recovery",
382
+ "apply": "live"
383
+ },
384
+ {
385
+ "key": "rateLimitAutoResume.maxDelayMinutes",
386
+ "label": "Maximum auto-resume delay",
387
+ "description": "Ignore detected reset times farther away than this many minutes, to avoid stale or malformed schedules.",
388
+ "type": "number",
389
+ "default": 1440,
390
+ "category": "Rate Limit Recovery",
391
+ "apply": "live"
392
+ },
393
+ {
394
+ "key": "rateLimitAutoResume.notify",
395
+ "label": "Notify on auto-resume",
396
+ "description": "Show Pi UI notices when rate-limit auto-resume is scheduled, sent, or fails.",
397
+ "type": "boolean",
398
+ "default": true,
399
+ "category": "Rate Limit Recovery",
400
+ "apply": "live"
401
+ },
321
402
  {
322
403
  "key": "handoffReviewPrompt",
323
404
  "label": "Review handoff prompt",
@@ -654,9 +735,9 @@
654
735
  {
655
736
  "key": "compaction.model",
656
737
  "label": "Compaction model",
657
- "description": "Summarizer model as provider/model, a bare model id, or current. Default mirrors Pi's custom-compaction example.",
738
+ "description": "Summarizer model as provider/model, a bare model id, or current. Default uses Pi's active model.",
658
739
  "type": "string",
659
- "default": "google/gemini-2.5-flash",
740
+ "default": "current",
660
741
  "category": "Compaction",
661
742
  "apply": "live"
662
743
  },
@@ -782,6 +863,60 @@
782
863
  "category": "Compaction Threshold",
783
864
  "apply": "live"
784
865
  },
866
+ {
867
+ "key": "compaction.budgetGuardEnabled",
868
+ "label": "Long-session budget guard",
869
+ "description": "Trigger compaction on agent_end (no idle wait) when context usage crosses budgetPercent or budgetTokens. Designed for autonomous/Flightdeck runs so transcript growth cannot silently hit provider/buffer limits. Fires once per threshold crossing, not in a loop.",
870
+ "type": "boolean",
871
+ "default": true,
872
+ "category": "Compaction Budget Guard",
873
+ "apply": "live"
874
+ },
875
+ {
876
+ "key": "compaction.budgetPercent",
877
+ "label": "Budget guard percent",
878
+ "description": "Context-window percentage that fires the budget guard at agent_end. Set -1 to disable percent-based firing and rely on absolute tokens instead.",
879
+ "type": "number",
880
+ "default": 85,
881
+ "category": "Compaction Budget Guard",
882
+ "apply": "live"
883
+ },
884
+ {
885
+ "key": "compaction.budgetTokens",
886
+ "label": "Budget guard token limit",
887
+ "description": "Absolute token count above which the budget guard fires at agent_end. Set -1 to use only budgetPercent.",
888
+ "type": "number",
889
+ "default": -1,
890
+ "category": "Compaction Budget Guard",
891
+ "apply": "live"
892
+ },
893
+ {
894
+ "key": "compaction.maxInputChars",
895
+ "label": "Chunked compaction input cap",
896
+ "description": "Maximum serialized characters sent to the summarizer in a single request. Inputs larger than this are split into chunks, summarized chunk-by-chunk, then merged via a summary-of-summaries pass so the compaction request payload itself cannot blow past provider buffer limits. Set 0 to disable chunking.",
897
+ "type": "number",
898
+ "default": 240000,
899
+ "category": "Compaction Budget Guard",
900
+ "apply": "live"
901
+ },
902
+ {
903
+ "key": "compaction.handoffArtifactEnabled",
904
+ "label": "Write pre-compaction handoff artifact",
905
+ "description": "Before compaction, write a JSON handoff artifact under ~/.pi/agent/vstack/sessions/<session>/pi-qol/handoff/ containing the previous summary, last task state, and recently-referenced file/artifact paths. Always writes a stamped file and a latest.json pointer. Runs whenever the QOL bounded compaction handler runs — that is, on every budget-guard-triggered compaction (regardless of compaction.customEnabled), and on manual/idle compactions only when compaction.customEnabled is on. Write failures surface a QOL warning and a handoffArtifactError field in the compaction details.",
906
+ "type": "boolean",
907
+ "default": true,
908
+ "category": "Compaction Budget Guard",
909
+ "apply": "live"
910
+ },
911
+ {
912
+ "key": "compaction.transcriptRiskWarnChars",
913
+ "label": "Transcript-risk warn budget (chars)",
914
+ "description": "Show a transcript-risk warning in /context when the serialized payload of the messages-to-send exceeds this many characters, even if the token count is still below the model context window. Set 0 to disable the warning.",
915
+ "type": "number",
916
+ "default": 600000,
917
+ "category": "Compaction Budget Guard",
918
+ "apply": "live"
919
+ },
785
920
  {
786
921
  "key": "thinkingLabel.text",
787
922
  "label": "Hidden thinking label",