@vanillagreen/pi-qol 2.2.1 → 2.3.4

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
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { pruneLanes } from "../scripts/lane-retention.js";
2
4
  import { truncateToWidth, type TUI } from "@earendil-works/pi-tui";
3
5
  import { criticalInfo, lastAssistantTextFromAgentEnd, needsDirection, taskStats } from "./qol/agent-end.js";
4
6
  import { getQuestionService, readCavemanBridge, type QuestionOpenedEventLike } from "./qol/bridges.js";
@@ -16,6 +18,7 @@ import {
16
18
  DEFAULT_IDLE_COMPACTION_SECONDS,
17
19
  DEFAULT_INPUT_BOTTOM_PADDING_LINES,
18
20
  INSTALL_SYMBOL,
21
+ QOL_BUDGET_HANDOFF_FOLDER,
19
22
  QOL_NOTIFICATION_SERVICE_SYMBOL,
20
23
  QUESTION_OPENED_EVENT,
21
24
  SESSION_MANAGER_STATUS_KEY,
@@ -23,6 +26,7 @@ import {
23
26
  SESSION_SEARCH_STATUS_KEY,
24
27
  SESSION_TITLE_SYNC_INTERVAL_MS,
25
28
  STATUS_KEY,
29
+ THINKING_TIMER_MAX_DURATIONS,
26
30
  THINKING_TIMER_STORE_SYMBOL,
27
31
  TMUX_SESSION_TITLE_BORDER_FORMAT,
28
32
  } from "./qol/constants.js";
@@ -35,10 +39,11 @@ import {
35
39
  QolEditor,
36
40
  } from "./qol/editor.js";
37
41
  import { runHandoff } from "./qol/handoff.js";
38
- import { imageContentForPath, resolveSubmittedImagePaths } from "./qol/images.js";
42
+ import { imageContentsForPaths, resolveSubmittedImagePaths } from "./qol/images.js";
39
43
  import {
40
44
  clearTmuxWindowMark,
41
45
  notifyQuestionOpened,
46
+ resetQolNotificationCooldowns,
42
47
  sendQolNotification,
43
48
  type QolNotificationService,
44
49
  } from "./qol/notifications.js";
@@ -61,15 +66,18 @@ import {
61
66
  } from "./qol/session-rename.js";
62
67
  import { createScheduleController, getScheduleArgumentCompletions } from "./qol/schedule.js";
63
68
  import {
69
+ clearSessionSearchPendingAction,
64
70
  consumePendingSessionSearchContext,
65
71
  openQolSessionSearch,
66
- qolSessionSearchPendingActions,
67
72
  refreshQolSessionSearchCache,
73
+ releaseQolSessionSearchCache,
68
74
  renderSessionSearchContextMessage,
69
75
  runSessionSearchResumeOrFork,
70
76
  sessionSearchShortcut,
77
+ takeSessionSearchPendingAction,
71
78
  } from "./qol/session-search/index.js";
72
- import { recordProjectTrust, settingBoolean, settingNumber, settingString } from "./qol/settings.js";
79
+ import { installSettingsCacheRefresh, piUserDir, recordProjectTrust } from "./qol/package-config.js";
80
+ import { settingBoolean, settingNumber, settingString } from "./qol/settings.js";
73
81
  import { statusMessage } from "./qol/status-message.js";
74
82
  import {
75
83
  formatTmuxSessionTitle,
@@ -248,11 +256,24 @@ export default function qol(pi: ExtensionAPI): void {
248
256
  const duration = Math.max(0, endTimeMs - start);
249
257
  thinkingTimerStore.starts.delete(key);
250
258
  thinkingTimerStore.durations.set(key, duration);
259
+ for (const oldest of thinkingTimerStore.durations.keys()) {
260
+ if (thinkingTimerStore.durations.size <= THINKING_TIMER_MAX_DURATIONS) break;
261
+ thinkingTimerStore.durations.delete(oldest);
262
+ }
251
263
  const label = thinkingTimerStore.labels.get(key);
252
264
  if (label) label.setText(thinkingTimerLabel(thinkingTimerStore.theme, duration, thinkingTimerStore.cwd));
253
265
  if (thinkingTimerStore.starts.size === 0) stopThinkingTimerTicker();
254
266
  };
255
267
 
268
+ // A label is held only to tick it while its block runs. A finished block's
269
+ // label already shows its final time, and a redraw of that message labels
270
+ // it again from `durations`.
271
+ const releaseFinishedThinkingLabels = () => {
272
+ for (const key of thinkingTimerStore.labels.keys()) {
273
+ if (!thinkingTimerStore.starts.has(key)) thinkingTimerStore.labels.delete(key);
274
+ }
275
+ };
276
+
256
277
  const clearIdleCompactionTimer = () => {
257
278
  if (idleCompactionTimer) clearTimeout(idleCompactionTimer);
258
279
  idleCompactionTimer = undefined;
@@ -586,8 +607,13 @@ export default function qol(pi: ExtensionAPI): void {
586
607
  rateLimitAutoResumeController.noteExternalRateLimitEvent(payload, currentCtx);
587
608
  });
588
609
 
610
+ installSettingsCacheRefresh(pi);
589
611
  pi.on("session_start", (event, ctx) => {
590
612
  recordProjectTrust(ctx);
613
+ const pruned = pruneLanes(join(piUserDir(), "kendex", "sessions"), QOL_BUDGET_HANDOFF_FOLDER.split("/"));
614
+ for (const failure of pruned.failed) {
615
+ console.warn(`handoff-prune-error: ${failure.path}\n${failure.error}`);
616
+ }
591
617
  currentCtx = ctx;
592
618
  subscribeCavemanBridge();
593
619
  latestSystemPromptOptions = undefined;
@@ -648,7 +674,8 @@ export default function qol(pi: ExtensionAPI): void {
648
674
  }
649
675
  startQuestionSubscription(ctx);
650
676
  void attemptAutoRename(ctx);
651
- if (settingBoolean("sessionSearch.enabled", true, ctx.cwd)) {
677
+ // A headless session has no search overlay to open, so it loads no index.
678
+ if (ctx.hasUI && settingBoolean("sessionSearch.enabled", true, ctx.cwd)) {
652
679
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
653
680
  sessionSearchWarmupTimer = setTimeout(() => {
654
681
  sessionSearchWarmupTimer = undefined;
@@ -673,8 +700,11 @@ export default function qol(pi: ExtensionAPI): void {
673
700
  clearQuestionSubscribeTimer();
674
701
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
675
702
  sessionSearchWarmupTimer = undefined;
703
+ releaseQolSessionSearchCache();
704
+ clearSessionSearchPendingAction();
676
705
  resetThinkingTimer(undefined);
677
706
  clearTmuxWindowMark(pi);
707
+ resetQolNotificationCooldowns();
678
708
  questionUnsubscribe?.();
679
709
  questionUnsubscribe = undefined;
680
710
  currentCtx = undefined;
@@ -744,6 +774,7 @@ export default function qol(pi: ExtensionAPI): void {
744
774
  }
745
775
  });
746
776
  pi.on("agent_end", (event, ctx) => {
777
+ releaseFinishedThinkingLabels();
747
778
  if (ctx.hasUI) {
748
779
  void refreshStatusline(ctx);
749
780
  requestRender();
@@ -816,15 +847,24 @@ export default function qol(pi: ExtensionAPI): void {
816
847
  if (event?.toolName === "tasks_write") maybeNotifyTaskCompletion(ctx, event.details?.state);
817
848
  });
818
849
 
819
- pi.on("input", async (event) => {
850
+ pi.on("input", async (event, ctx) => {
820
851
  clearTmuxWindowMark(pi);
821
852
  if (event.source === "extension") return { action: "continue" };
822
853
  const text = event.text ?? "";
823
- const paths = currentCtx?.cwd ? resolveSubmittedImagePaths(text, currentCtx.cwd) : [];
854
+ const paths = resolveSubmittedImagePaths(text, ctx.cwd);
824
855
  if (paths.length === 0) return { action: "continue" };
825
- const images = paths.map(imageContentForPath).filter(Boolean);
826
- if (images.length === 0) return { action: "continue" };
827
- return { action: "transform", images: [...(event.images ?? []), ...images], text: event.text };
856
+ try {
857
+ const images = await imageContentsForPaths(paths, event.images ?? []);
858
+ return { action: "transform", images: [...(event.images ?? []), ...images], text: event.text };
859
+ } catch (error) {
860
+ const message = `Image submission refused: ${stringifyError(error)}`;
861
+ ctx.ui.notify(message, "error");
862
+ // Print and JSON modes reserve stderr for diagnostics; input exceptions
863
+ // would let Pi continue submission instead of refusing the image.
864
+ if (!ctx.hasUI) console.error(message);
865
+ if (ctx.hasUI && ctx.ui.getEditorText() === "") ctx.ui.setEditorText(text);
866
+ return { action: "handled" };
867
+ }
828
868
  });
829
869
 
830
870
  if (settingBoolean("enableSessionNameCommand", true)) {
@@ -903,13 +943,11 @@ export default function qol(pi: ExtensionAPI): void {
903
943
  pi.registerCommand("search:resume-pending", {
904
944
  description: "Run a pending session-search resume or fork action",
905
945
  handler: async (args, ctx) => {
906
- const id = args.trim();
907
- const action = qolSessionSearchPendingActions.get(id);
946
+ const action = takeSessionSearchPendingAction(args.trim());
908
947
  if (!action) {
909
948
  ctx.ui.notify("No pending session-search resume/fork action found.", "warning");
910
949
  return;
911
950
  }
912
- qolSessionSearchPendingActions.delete(id);
913
951
  if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) ctx.ui.notify("Session resume/fork is unavailable in this context.", "error");
914
952
  },
915
953
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "2.2.1",
3
+ "version": "2.3.4",
4
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": [
@@ -504,9 +504,9 @@
504
504
  {
505
505
  "key": "sessionSearch.cacheTtlSeconds",
506
506
  "label": "Session cache TTL",
507
- "description": "Seconds before the in-memory session list is refreshed. Default 0 keeps the cache until you explicitly run /search refresh.",
507
+ "description": "Seconds a loaded session index is kept in memory before it is released; the next search opens at once and reloads it. 0 keeps the index until the session ends.",
508
508
  "type": "number",
509
- "default": 0,
509
+ "default": 300,
510
510
  "category": "Session Search",
511
511
  "apply": "live"
512
512
  },
@@ -988,6 +988,7 @@
988
988
  },
989
989
  "files": [
990
990
  "extensions/",
991
+ "scripts/",
991
992
  "assets/",
992
993
  "README.md",
993
994
  "package.json"
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Lane file retention: the one rule for files a Pi extension package keeps on
3
+ * disk for a lane (fetched text, transcripts, task logs). A lane's files are
4
+ * deleted once the lane's working directory is gone, which is how a merged
5
+ * lane's worktree ends, and no file is kept past LANE_FILE_MAX_AGE_MS.
6
+ *
7
+ * A lane directory is one the package made with openLaneDir: a real directory,
8
+ * owned by this user, that holds a LANE_CWD_FILE record. The prune touches
9
+ * nothing else under its root, so a folder another tool keeps there, or a
10
+ * symbolic link to one, is left alone.
11
+ *
12
+ * Vendored byte-identical into every package that keeps such files, under
13
+ * `scripts/`; pi-extensions/package-policy.test.mjs holds the copies equal.
14
+ * Edit one copy, then copy it over the others.
15
+ */
16
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ export const LANE_FILE_MAX_AGE_MS = 5 * 24 * 60 * 60 * 1000;
20
+
21
+ /** The file in a lane directory that names the lane's working directory. */
22
+ export const LANE_CWD_FILE = ".lane-cwd";
23
+
24
+ export interface LanePruneFailure {
25
+ path: string;
26
+ error: string;
27
+ }
28
+
29
+ export interface LanePruneResult {
30
+ removed: string[];
31
+ failed: LanePruneFailure[];
32
+ }
33
+
34
+ /** Create `dir` as a lane directory for `cwd`. Rewriting the record marks the
35
+ * lane live, so its directory is not pruned as empty and old. */
36
+ export function openLaneDir(dir: string, cwd: string): string {
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ writeFileSync(join(dir, LANE_CWD_FILE), cwd, { mode: 0o600 });
39
+ return dir;
40
+ }
41
+
42
+ function message(error: unknown): string {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+
46
+ /** Whether `path` is a real directory, not a symbolic link, owned by this
47
+ * user. A platform without user ids (Windows) checks the type alone. */
48
+ function ownDirectory(path: string): boolean {
49
+ let stat;
50
+ try {
51
+ stat = lstatSync(path);
52
+ } catch {
53
+ return false;
54
+ }
55
+ const uid = process.getuid?.();
56
+ return stat.isDirectory() && (uid === undefined || stat.uid === uid);
57
+ }
58
+
59
+ function remove(path: string, result: LanePruneResult): void {
60
+ try {
61
+ rmSync(path, { recursive: true, force: true });
62
+ result.removed.push(path);
63
+ } catch (error) {
64
+ result.failed.push({ path, error: message(error) });
65
+ }
66
+ }
67
+
68
+ /** The lane's recorded working directory; undefined when `dir` holds no
69
+ * record, so the package did not make it. */
70
+ function recordedCwd(dir: string): string | undefined {
71
+ try {
72
+ const cwd = readFileSync(join(dir, LANE_CWD_FILE), "utf8");
73
+ return cwd === "" ? undefined : cwd;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /** Whether a lane's recorded working directory is still there. Only a stat
80
+ * that answers ENOENT or ENOTDIR confirms it is gone. Any other failure, such
81
+ * as a refused permission or a filesystem that is briefly unavailable, is
82
+ * reported against the lane and answers "unknown", so the lane is not
83
+ * removed as gone. */
84
+ function cwdState(dir: string, cwd: string, result: LanePruneResult): "present" | "gone" | "unknown" {
85
+ try {
86
+ statSync(cwd);
87
+ return "present";
88
+ } catch (error) {
89
+ const code = (error as NodeJS.ErrnoException).code;
90
+ if (code === "ENOENT" || code === "ENOTDIR") return "gone";
91
+ result.failed.push({ path: dir, error: `lane-cwd-unchecked: the lane is kept; its working directory could not be checked: ${message(error)}` });
92
+ return "unknown";
93
+ }
94
+ }
95
+
96
+ function modifiedAt(path: string, result: LanePruneResult): number | undefined {
97
+ try {
98
+ return lstatSync(path).mtimeMs;
99
+ } catch (error) {
100
+ result.failed.push({ path, error: message(error) });
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /** Remove the files under `dir` older than `cutoff`, never the lane record;
106
+ * return how many entries other than the record remain. */
107
+ function pruneOldFiles(dir: string, cutoff: number, result: LanePruneResult): number {
108
+ let remaining = 0;
109
+ let entries;
110
+ try {
111
+ entries = readdirSync(dir, { withFileTypes: true });
112
+ } catch (error) {
113
+ result.failed.push({ path: dir, error: message(error) });
114
+ return 1;
115
+ }
116
+ for (const entry of entries) {
117
+ const path = join(dir, entry.name);
118
+ if (entry.name === LANE_CWD_FILE) continue;
119
+ if (entry.isDirectory()) {
120
+ if (pruneOldFiles(path, cutoff, result) === 0) remove(path, result);
121
+ else remaining++;
122
+ continue;
123
+ }
124
+ const mtime = modifiedAt(path, result);
125
+ if (mtime !== undefined && mtime < cutoff) remove(path, result);
126
+ else remaining++;
127
+ }
128
+ return remaining;
129
+ }
130
+
131
+ /**
132
+ * Apply the retention rule to each lane directory at `root/<entry>/<...below>`,
133
+ * one entry per session. A lane whose recorded working directory is gone is
134
+ * removed whole; a working directory that cannot be checked is reported and
135
+ * does not count as gone. Otherwise each file older than LANE_FILE_MAX_AGE_MS is
136
+ * removed, and the lane goes too once only its record is left and the record
137
+ * is that old. The record is kept while the lane is: it is what marks the
138
+ * directory as the package's. An absent root has no lanes. Every failure,
139
+ * including an unreadable root, is reported, never thrown, so the caller's
140
+ * other session_start work still runs.
141
+ */
142
+ export function pruneLanes(root: string, below: string[] = [], now = Date.now()): LanePruneResult {
143
+ const result: LanePruneResult = { removed: [], failed: [] };
144
+ let entries: string[];
145
+ try {
146
+ entries = readdirSync(root);
147
+ } catch (error) {
148
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") result.failed.push({ path: root, error: message(error) });
149
+ return result;
150
+ }
151
+ if (!ownDirectory(root)) {
152
+ result.failed.push({ path: root, error: "lane-root-not-owned: not a directory this user owns; nothing under it was pruned" });
153
+ return result;
154
+ }
155
+ const cutoff = now - LANE_FILE_MAX_AGE_MS;
156
+ for (const entry of entries) {
157
+ let dir = root;
158
+ let owned = true;
159
+ for (const part of [entry, ...below]) {
160
+ dir = join(dir, part);
161
+ if (!ownDirectory(dir)) {
162
+ owned = false;
163
+ break;
164
+ }
165
+ }
166
+ if (!owned) continue;
167
+ const cwd = recordedCwd(dir);
168
+ if (cwd === undefined) continue;
169
+ const state = cwdState(dir, cwd, result);
170
+ switch (state) {
171
+ case "gone":
172
+ remove(dir, result);
173
+ continue;
174
+ case "present":
175
+ case "unknown":
176
+ break;
177
+ default: {
178
+ const unhandled: never = state;
179
+ throw new Error(`lane-cwd-state-unhandled: ${String(unhandled)}`);
180
+ }
181
+ }
182
+ if (pruneOldFiles(dir, cutoff, result) > 0) continue;
183
+ const recordAt = modifiedAt(join(dir, LANE_CWD_FILE), result);
184
+ if (recordAt !== undefined && recordAt < cutoff) remove(dir, result);
185
+ }
186
+ return result;
187
+ }