@vanillagreen/pi-qol 2.2.0 → 2.3.2

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
@@ -29,6 +29,17 @@ Requires Pi 0.86.0 or newer. Restart Pi after installation. Use `kendex update-p
29
29
 
30
30
  The extension reads your enabled features when Pi starts. It adds their editor controls, commands and event handlers. Session actions update the session or queue messages for the agent. Notifications report events through your selected channels. Compaction settings control when long conversations are summarized.
31
31
 
32
+ ## Memory use
33
+
34
+ - A session without a UI loads no session-search index. With a UI, the index is loaded shortly after start and kept for `sessionSearch.cacheTtlSeconds` (default 300 seconds; 0 keeps it until the session ends). A search after the index was released opens at once and fills in when the index has loaded again.
35
+ - The index keeps at most 32,768 characters of message text per session and 8,388,608 characters in total, newest session first. A session past the total is searched by name, path and first prompt only.
36
+ - Where Pi cannot switch sessions directly, a chosen resume or fork waits as one `/search:resume-pending` command in the editor. Only the latest one is kept, and it is dropped when the session ends.
37
+ - Parsed prompts are kept for at most 64 sessions, and finished thinking times for at most 256 blocks. Thinking labels are released at the end of each agent run, and notification cooldown entries once their cooldown has passed and all of them when the session ends.
38
+
39
+ ## Saved files
40
+
41
+ At session start, the extension deletes budget handoff files whose recorded working directory no longer exists. It also deletes files older than 5 days. Both timestamped snapshots and `latest.json` follow this rule.
42
+
32
43
  ## Settings
33
44
 
34
45
  The settings editor writes project values to `.pi/settings.json`. The default user file is `~/.pi/agent/settings.json`. `PI_CODING_AGENT_DIR` changes the user directory. Package values are stored under `kendex.extensionManager.config["@vanillagreen/pi-qol"]`.
@@ -4,11 +4,12 @@
4
4
  // drops something critical. The pure data-building + write logic lives here
5
5
  // so it can be unit-tested without the pi-coding-agent / pi-ai peer deps.
6
6
 
7
- import { mkdirSync, writeFileSync } from "node:fs";
8
- import { basename, dirname, join, resolve } from "node:path";
7
+ import { writeFileSync } from "node:fs";
8
+ import { basename, dirname, join } from "node:path";
9
9
 
10
+ import { openLaneDir } from "../../scripts/lane-retention.js";
10
11
  import { QOL_BUDGET_HANDOFF_FOLDER, QOL_BUDGET_HANDOFF_LATEST } from "./constants.js";
11
- import { expandHome } from "./settings.js";
12
+ import { piUserDir } from "./package-config.js";
12
13
 
13
14
  export interface QolBudgetHandoff {
14
15
  reason: string;
@@ -22,17 +23,6 @@ export interface QolBudgetHandoff {
22
23
  model?: string;
23
24
  }
24
25
 
25
- /** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
26
- * means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
27
- * where the renderer does not, putting the two on different roots. Hoisted, so
28
- * a circular import cannot reach it inside a temporal dead zone. */
29
- function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
30
-
31
- export function piUserDir(): string {
32
- const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
33
- return resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
34
- }
35
-
36
26
  export function safeFileName(value: string): string {
37
27
  return value.replace(/[^\w.-]+/g, "_");
38
28
  }
@@ -157,16 +147,13 @@ export function handoffBaseDir(sessionId: string, root: string = piUserDir()): s
157
147
 
158
148
  export interface WriteHandoffOptions {
159
149
  enabled: boolean;
150
+ cwd: string;
160
151
  root?: string;
161
152
  /** Injection point so tests can stub fs without mocking node:fs. */
162
153
  writer?: (path: string, payload: string) => void;
163
154
  mkdir?: (path: string) => void;
164
155
  }
165
156
 
166
- function defaultMkdir(path: string): void {
167
- mkdirSync(path, { recursive: true, mode: 0o700 });
168
- }
169
-
170
157
  function defaultWriter(path: string, payload: string): void {
171
158
  writeFileSync(path, payload, { mode: 0o600 });
172
159
  }
@@ -176,7 +163,7 @@ export function writeBudgetHandoffArtifact(handoff: QolBudgetHandoff, options: W
176
163
  const baseDir = handoffBaseDir(handoff.sessionId, options.root);
177
164
  const stampedPath = handoffStampedPath(baseDir, handoff.timestamp);
178
165
  const latestPath = join(dirname(baseDir), basename(baseDir), QOL_BUDGET_HANDOFF_LATEST);
179
- const mkdir = options.mkdir ?? defaultMkdir;
166
+ const mkdir = options.mkdir ?? ((path: string) => openLaneDir(path, options.cwd));
180
167
  const writer = options.writer ?? defaultWriter;
181
168
  try {
182
169
  mkdir(baseDir);
@@ -252,7 +252,7 @@ export function buildBudgetHandoff(ctx: ExtensionContext, options: {
252
252
 
253
253
  export function writeBudgetHandoffArtifact(ctx: ExtensionContext, handoff: QolBudgetHandoff): HandoffWriteResult {
254
254
  const enabled = settingBoolean("compaction.handoffArtifactEnabled", true, ctx.cwd);
255
- const result = writeBudgetHandoffArtifactRaw(handoff, { enabled });
255
+ const result = writeBudgetHandoffArtifactRaw(handoff, { cwd: ctx.cwd, enabled });
256
256
  if (result.error) {
257
257
  compactionNotify(ctx, `QOL handoff artifact write failed: ${result.error}`, "warning");
258
258
  }
@@ -33,6 +33,8 @@ export const DEFAULT_NOTIFICATION_TITLE = "Pi";
33
33
  export const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
34
34
  export const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
35
35
  export const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
36
+ /** Deadline for each tmux call a notification makes; a stalled server is skipped, not waited on. */
37
+ export const TMUX_COMMAND_TIMEOUT_MS = 1000;
36
38
 
37
39
  export const DEFAULT_COMPACTION_MODEL = "current";
38
40
  export const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
@@ -72,7 +74,18 @@ export const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
72
74
  export const DEFAULT_SESSION_SEARCH_SHORTCUT = "f2";
73
75
  export const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
74
76
  export const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
75
- export const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
77
+ /** How long a loaded session-search index is kept. Past it the index is
78
+ * released, and the next search loads it again. */
79
+ export const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 300;
80
+ /** Characters of one session's message text the search index keeps. */
81
+ export const SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION = 32_768;
82
+ /** Characters of message text the whole search index keeps, newest session
83
+ * first; sessions past it are searched by name, path and first prompt only. */
84
+ export const SESSION_SEARCH_TEXT_MAX_CHARS = 8_388_608;
85
+ /** Sessions whose user prompts the search overlay keeps parsed. */
86
+ export const SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS = 64;
87
+ /** Finished thinking blocks whose time a redrawn message can show again. */
88
+ export const THINKING_TIMER_MAX_DURATIONS = 256;
76
89
  export const SESSION_SEARCH_OVERLAY_HEIGHT_RATIO = 0.9;
77
90
 
78
91
  export const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
@@ -1,4 +1,4 @@
1
- import { readPackageConfig } from "./settings.js";
1
+ import { readPackageConfig } from "./package-config.js";
2
2
 
3
3
  export type GlyphStyle = "unicode" | "ascii";
4
4
  export type GlobalGlyphStyleOverride = "inherit" | GlyphStyle;
@@ -3,7 +3,8 @@ import { basename, resolve } from "node:path";
3
3
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js";
5
5
  import { IMAGE_EXTENSIONS, IMAGE_PATH_PATTERN } from "./constants.js";
6
- import { expandHome, settingBoolean } from "./settings.js";
6
+ import { expandHome } from "./package-config.js";
7
+ import { settingBoolean } from "./settings.js";
7
8
 
8
9
  function stripAtPrefix(path: string): string {
9
10
  return path.startsWith("@") ? path.slice(1) : path;
@@ -1,23 +1,30 @@
1
- import { execFile, execFileSync } from "node:child_process";
2
- import { writeFileSync } from "node:fs";
3
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import {
5
6
  DEFAULT_NOTIFICATION_BODY_MAX_CHARS,
6
7
  DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
7
8
  DEFAULT_NOTIFICATION_TITLE,
8
9
  DEFAULT_TMUX_MESSAGE_DURATION_MS,
9
10
  QUESTION_NOTIFY_DEDUP_MS,
11
+ TMUX_COMMAND_TIMEOUT_MS,
10
12
  } from "./constants.js";
11
13
  import { settingBoolean, settingNumber, settingString } from "./settings.js";
12
14
  import type { QuestionOpenedEventLike, QuestionRequestLike } from "./bridges.js";
13
15
 
14
16
  export type QolNotificationKind = "ready" | "direction" | "question" | "task-complete" | "critical" | "test";
15
17
  export type QolNotificationLevel = "info" | "warning" | "error";
18
+ /** The one Pi call notifications need: `pi.exec`, whose `timeout` kills a stalled tmux. */
19
+ export type QolNotificationExec = Pick<ExtensionAPI, "exec">;
16
20
 
17
21
  export interface QolNotificationService {
18
22
  notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike): boolean;
19
23
  }
20
24
 
25
+ /** Last send time per notification key, for the cooldown. An entry older than
26
+ * the cooldown suppresses nothing, so each send drops those; question keys
27
+ * carry a request id, so without the drop the map grows per question. */
21
28
  const lastNotificationAt = new Map<string, number>();
22
29
  const lastQuestionNotificationAt = new Map<string, number>();
23
30
  let tmuxMarkedTarget: string | undefined;
@@ -65,54 +72,93 @@ function notificationBellMuted(cwd?: string): boolean {
65
72
  return settingBoolean("notification.muteBellSound", false, cwd);
66
73
  }
67
74
 
68
- function sourcePaneTty(): string | undefined {
69
- try {
70
- if (!process.env.TMUX_PANE) return undefined;
71
- const tty = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{pane_tty}"], { encoding: "utf8" }).trim();
72
- return tty || undefined;
73
- } catch {
74
- return undefined;
75
- }
75
+ /** The source pane as one tmux query reports it when a notification fires. */
76
+ interface TmuxPaneState {
77
+ paneTty: string;
78
+ windowActive: boolean;
79
+ sessionId: string;
80
+ windowId: string;
81
+ windowName: string;
76
82
  }
77
83
 
78
- function sourceTmuxSession(): string | undefined {
84
+ /** `#W` is last: a window name may hold a tab, so every field after the fourth is the name. */
85
+ const PANE_STATE_FORMAT = "#{pane_tty}\t#{window_active}\t#{session_id}\t#{window_id}\t#W";
86
+
87
+ /**
88
+ * Every terminal write joins this chain and runs alone, in queue order. Each
89
+ * write opens its tty non-blocking, so a full terminal fails the write rather
90
+ * than holding it, and the chain always advances.
91
+ */
92
+ let terminalWrites: Promise<void> = Promise.resolve();
93
+ /** Bumped by `clearTmuxWindowMark`; a notification decided before the bump does not mark. */
94
+ let tmuxMarkGeneration = 0;
95
+
96
+ /**
97
+ * Runs one tmux command under `TMUX_COMMAND_TIMEOUT_MS`. Undefined means no
98
+ * answer: a non-zero exit, a timeout, or a Pi runtime that went stale after
99
+ * session replacement (`pi.exec` then throws). Pi reports a command its
100
+ * timeout killed with `killed` and, when the signal left no exit status,
101
+ * with code 0, so `killed` is read first.
102
+ */
103
+ async function tmux(pi: QolNotificationExec, args: string[]): Promise<string | undefined> {
79
104
  try {
80
- if (!process.env.TMUX_PANE) return undefined;
81
- const session = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}"], { encoding: "utf8" }).trim();
82
- return session || undefined;
105
+ const result = await pi.exec("tmux", args, { timeout: TMUX_COMMAND_TIMEOUT_MS });
106
+ if (result.killed || result.code !== 0) return undefined;
107
+ return result.stdout;
83
108
  } catch {
84
109
  return undefined;
85
110
  }
86
111
  }
87
112
 
88
- export function sourceTmuxWindowActive(): boolean {
89
- try {
90
- if (!process.env.TMUX_PANE) return false;
91
- const active = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{window_active}"], { encoding: "utf8" }).trim();
92
- return active === "1";
93
- } catch {
94
- return false;
95
- }
113
+ /**
114
+ * Reads the source pane once per notification. The session and window are
115
+ * read fresh each time: tmux `break-pane`, `join-pane` and `move-window` move
116
+ * a pane to another window or session and keep its id and tty.
117
+ */
118
+ async function sourceTmuxPaneState(pi: QolNotificationExec): Promise<TmuxPaneState | undefined> {
119
+ const pane = process.env.TMUX_PANE;
120
+ if (!pane) return undefined;
121
+ const stdout = await tmux(pi, ["display-message", "-p", "-t", pane, PANE_STATE_FORMAT]);
122
+ const [paneTty = "", windowActive = "", sessionId = "", windowId = "", ...name] = (stdout ?? "").replace(/\r?\n$/, "").split("\t");
123
+ if (!paneTty || !sessionId || !windowId) return undefined;
124
+ return { paneTty, windowActive: windowActive === "1", sessionId, windowId, windowName: name.join("\t") };
125
+ }
126
+
127
+ async function tmuxClientTtys(pi: QolNotificationExec, pane: TmuxPaneState | undefined): Promise<string[]> {
128
+ if (!process.env.TMUX) return [];
129
+ const args = ["list-clients", "-F", "#{client_tty}"];
130
+ if (pane) args.splice(1, 0, "-t", pane.sessionId);
131
+ const output = await tmux(pi, args);
132
+ if (output === undefined) return [];
133
+ return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
134
+ }
135
+
136
+ function queueTerminalWrite(write: () => Promise<void>): Promise<void> {
137
+ const queued = terminalWrites.then(write);
138
+ terminalWrites = queued.catch(() => undefined);
139
+ return queued;
96
140
  }
97
141
 
98
- function tmuxClientTtys(): string[] {
142
+ /**
143
+ * Writes to a terminal without waiting on it. A tmux client whose SSH link
144
+ * dropped stays attached with a full tty buffer, and a blocking write to it
145
+ * would hang until the link times out; non-blocking, it fails with `EAGAIN`.
146
+ * `O_NOCTTY` keeps the open from making the tty Pi's controlling terminal.
147
+ */
148
+ async function writeTty(path: string, output: string): Promise<void> {
149
+ const handle = await open(path, constants.O_WRONLY | constants.O_NONBLOCK | constants.O_NOCTTY);
99
150
  try {
100
- if (!process.env.TMUX) return [];
101
- const session = sourceTmuxSession();
102
- const args = ["list-clients", "-F", "#{client_tty}"];
103
- if (session) args.splice(1, 0, "-t", session);
104
- const output = execFileSync("tmux", args, { encoding: "utf8" });
105
- return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
106
- } catch {
107
- return [];
151
+ await handle.writeFile(output, "utf8");
152
+ } finally {
153
+ await handle.close();
108
154
  }
109
155
  }
110
156
 
111
- function writeRawToPaths(paths: string[], output: string): boolean {
157
+ async function writeRawToPaths(paths: string[], output: string): Promise<boolean> {
112
158
  let wrote = false;
113
159
  for (const path of paths) {
114
160
  try {
115
- writeFileSync(path, output, "utf8");
161
+ await writeTty(path, output);
116
162
  wrote = true;
117
163
  } catch {
118
164
  // Try remaining paths.
@@ -121,10 +167,9 @@ function writeRawToPaths(paths: string[], output: string): boolean {
121
167
  return wrote;
122
168
  }
123
169
 
124
- function writeToTerminal(output: string): void {
125
- const tty = sourcePaneTty();
170
+ async function writeToTerminal(pane: TmuxPaneState | undefined, output: string): Promise<void> {
126
171
  try {
127
- writeFileSync(tty ?? "/dev/tty", output, "utf8");
172
+ await writeTty(pane?.paneTty ?? "/dev/tty", output);
128
173
  return;
129
174
  } catch {
130
175
  // Fall through to stdout best-effort.
@@ -136,38 +181,40 @@ function writeToTerminal(output: string): void {
136
181
  }
137
182
  }
138
183
 
139
- function writeTerminalSequence(sequence: string, cwd?: string): void {
140
- if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
141
- // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
142
- // Send native terminal notifications straight to attached tmux client TTYs,
143
- // while the explicit terminal bell still goes through the source pane when unmuted.
144
- if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
145
- }
184
+ async function writeTerminalSequence(pi: QolNotificationExec, pane: TmuxPaneState | undefined, sequence: string, cwd?: string): Promise<void> {
185
+ // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
186
+ // Send native terminal notifications straight to attached tmux client TTYs,
187
+ // while the explicit terminal bell still goes through the source pane when unmuted.
188
+ const clientTtys = process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd) ? await tmuxClientTtys(pi, pane) : [];
146
189
  const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
147
- writeToTerminal(output);
190
+ await queueTerminalWrite(async () => {
191
+ if (await writeRawToPaths(clientTtys, sequence)) return;
192
+ await writeToTerminal(pane, output);
193
+ });
148
194
  }
149
195
 
150
- function writeTerminalBell(cwd?: string): void {
196
+ function writeTerminalBell(pane: TmuxPaneState | undefined, cwd?: string): Promise<void> {
151
197
  // Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
152
198
  // This lets tmux set window_bell_flag for the correct source window.
153
199
  const sequence = terminalBellSequence(notificationBellMuted(cwd));
154
- if (sequence) writeToTerminal(sequence);
200
+ if (!sequence) return Promise.resolve();
201
+ return queueTerminalWrite(() => writeToTerminal(pane, sequence));
155
202
  }
156
203
 
157
- function notifyOSC777(title: string, body: string, cwd?: string): void {
158
- writeTerminalSequence(osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
204
+ function notifyOSC777(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
205
+ return writeTerminalSequence(pi, pane, osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
159
206
  }
160
207
 
161
- function notifyOSC99(title: string, body: string, cwd?: string): void {
162
- writeTerminalSequence(`\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
163
- writeTerminalSequence(`\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
208
+ async function notifyOSC99(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
209
+ await writeTerminalSequence(pi, pane, `\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
210
+ await writeTerminalSequence(pi, pane, `\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
164
211
  }
165
212
 
166
213
  function notifyWindows(title: string, body: string): void {
167
214
  execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)], () => undefined);
168
215
  }
169
216
 
170
- function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
217
+ async function notifyNativeTerminal(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
171
218
  const protocol = settingString("notification.oscProtocol", "auto", cwd);
172
219
  if (process.env.WT_SESSION) {
173
220
  notifyWindows(title, body);
@@ -175,60 +222,61 @@ function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
175
222
  }
176
223
  if (protocol === "off") return;
177
224
  if (protocol === "osc99" || (protocol === "auto" && process.env.KITTY_WINDOW_ID)) {
178
- notifyOSC99(title, body, cwd);
225
+ await notifyOSC99(pi, pane, title, body, cwd);
179
226
  return;
180
227
  }
181
- notifyOSC777(title, body, cwd);
228
+ await notifyOSC777(pi, pane, title, body, cwd);
182
229
  }
183
230
 
184
- function notifyTmux(title: string, body: string, cwd?: string): void {
231
+ function notifyTmux(pi: QolNotificationExec, title: string, body: string, cwd?: string): void {
185
232
  if (!process.env.TMUX && !process.env.TMUX_PANE) return;
186
233
  const duration = Math.max(500, Math.floor(settingNumber("notification.tmuxMessageDurationMs", DEFAULT_TMUX_MESSAGE_DURATION_MS, cwd)));
187
234
  const message = `${title}: ${body}`;
188
235
  const args = ["display-message", "-d", String(duration)];
189
236
  if (process.env.TMUX_PANE) args.push("-t", process.env.TMUX_PANE);
190
237
  args.push(message);
191
- execFile("tmux", args, () => undefined);
238
+ void tmux(pi, args);
192
239
  }
193
240
 
194
- export function clearTmuxWindowMark(): void {
195
- if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
196
- tmuxWindowMarkTimer = undefined;
241
+ /** Renames the marked window back, if one is marked. */
242
+ function unmarkTmuxWindow(pi: QolNotificationExec): void {
197
243
  const target = tmuxMarkedTarget;
198
244
  const original = tmuxOriginalWindowName;
199
245
  tmuxMarkedTarget = undefined;
200
246
  tmuxOriginalWindowName = undefined;
201
247
  if (!target || !original) return;
202
- execFile("tmux", ["rename-window", "-t", target, original], () => undefined);
248
+ void tmux(pi, ["rename-window", "-t", target, original]);
249
+ }
250
+
251
+ /** Removes the window mark, and keeps a notification still in flight from setting one. */
252
+ export function clearTmuxWindowMark(pi: QolNotificationExec): void {
253
+ tmuxMarkGeneration += 1;
254
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
255
+ tmuxWindowMarkTimer = undefined;
256
+ unmarkTmuxWindow(pi);
203
257
  }
204
258
 
205
- function markTmuxWindow(cwd?: string): void {
259
+ function markTmuxWindow(pi: QolNotificationExec, pane: TmuxPaneState | undefined, generation: number, cwd?: string): void {
206
260
  if (!settingBoolean("notification.tmuxWindowMark", false, cwd)) return;
207
- if (!process.env.TMUX || !process.env.TMUX_PANE) return;
261
+ if (!process.env.TMUX || !pane || generation !== tmuxMarkGeneration) return;
208
262
  const mark = sanitizeNotificationPart(settingString("notification.tmuxWindowMarkText", "!", cwd), 12) || "!";
209
263
  const prefix = `${mark} `;
210
- execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}:#{window_index}"], (targetError, targetStdout) => {
211
- if (targetError) return;
212
- const target = targetStdout.trim();
213
- if (!target) return;
214
- execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE!, "#W"], (nameError, nameStdout) => {
215
- if (nameError) return;
216
- const current = nameStdout.replace(/\r?\n$/, "");
217
- if (!current) return;
218
- const original = current.startsWith(prefix) ? current.slice(prefix.length) : current;
219
- if (!tmuxMarkedTarget || tmuxMarkedTarget !== target) {
220
- tmuxMarkedTarget = target;
221
- tmuxOriginalWindowName = original;
222
- }
223
- if (!current.startsWith(prefix)) execFile("tmux", ["rename-window", "-t", target, `${prefix}${current}`], () => undefined);
224
- const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
225
- if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
226
- if (duration > 0) {
227
- tmuxWindowMarkTimer = setTimeout(clearTmuxWindowMark, duration);
228
- tmuxWindowMarkTimer.unref?.();
229
- }
230
- });
231
- });
264
+ const target = pane.windowId;
265
+ const current = pane.windowName;
266
+ if (!current) return;
267
+ // The pane moved to another window since the last mark: that window gets its name back.
268
+ if (tmuxMarkedTarget && tmuxMarkedTarget !== target) unmarkTmuxWindow(pi);
269
+ if (!tmuxMarkedTarget) {
270
+ tmuxMarkedTarget = target;
271
+ tmuxOriginalWindowName = current.startsWith(prefix) ? current.slice(prefix.length) : current;
272
+ }
273
+ if (!current.startsWith(prefix)) void tmux(pi, ["rename-window", "-t", target, `${prefix}${current}`]);
274
+ const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
275
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
276
+ if (duration > 0) {
277
+ tmuxWindowMarkTimer = setTimeout(() => clearTmuxWindowMark(pi), duration);
278
+ tmuxWindowMarkTimer.unref?.();
279
+ }
232
280
  }
233
281
 
234
282
  function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolean {
@@ -243,24 +291,51 @@ function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolea
243
291
  }
244
292
  }
245
293
 
246
- export function sendQolNotification(ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): void {
294
+ /** Forget every notification cooldown and question dedup key; they belong to
295
+ * the session that sent them. */
296
+ export function resetQolNotificationCooldowns(): void {
297
+ lastNotificationAt.clear();
298
+ lastQuestionNotificationAt.clear();
299
+ }
300
+
301
+ /**
302
+ * The window mark and the tmux message never touch the terminal, so they start
303
+ * beside the terminal writes rather than behind them.
304
+ */
305
+ async function deliverTerminalNotification(pi: QolNotificationExec, title: string, text: string, markGeneration: number, cwd?: string): Promise<void> {
306
+ const pane = await sourceTmuxPaneState(pi);
307
+ const windowActive = pane?.windowActive ?? false;
308
+ if (!windowActive) markTmuxWindow(pi, pane, markGeneration, cwd);
309
+ if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(pi, title, text, cwd);
310
+ if (settingBoolean("notification.bell", true, cwd) && (!windowActive || settingBoolean("notification.bellWhenActive", false, cwd))) await writeTerminalBell(pane, cwd);
311
+ if (settingBoolean("notification.native", true, cwd)) await notifyNativeTerminal(pi, pane, title, text, cwd);
312
+ }
313
+
314
+ /**
315
+ * Decides and records the notification synchronously, then delivers the
316
+ * terminal and tmux channels in the background: the returned promise settles
317
+ * when delivery is done, and no caller on Pi's event path awaits it, so a
318
+ * stalled tmux server or terminal never holds up a turn end.
319
+ */
320
+ export function sendQolNotification(pi: QolNotificationExec, ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): Promise<void> {
247
321
  const cwd = ctx?.cwd;
248
- if (ctx && !ctx.hasUI) return;
249
- if (!notificationEnabledFor(kind, cwd)) return;
322
+ if (ctx && !ctx.hasUI) return Promise.resolve();
323
+ if (!notificationEnabledFor(kind, cwd)) return Promise.resolve();
250
324
  const cooldownMs = Math.max(0, settingNumber("notification.cooldownSeconds", DEFAULT_NOTIFICATION_COOLDOWN_SECONDS, cwd) * 1000);
251
325
  const now = Date.now();
252
326
  const last = lastNotificationAt.get(key) ?? 0;
253
- if (cooldownMs > 0 && now - last < cooldownMs) return;
254
- lastNotificationAt.set(key, now);
327
+ if (cooldownMs > 0 && now - last < cooldownMs) return Promise.resolve();
328
+ for (const [storedKey, sentAt] of lastNotificationAt) {
329
+ if (now - sentAt >= cooldownMs) lastNotificationAt.delete(storedKey);
330
+ }
331
+ if (cooldownMs > 0) lastNotificationAt.set(key, now);
255
332
 
256
333
  const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
257
334
  const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
258
- const tmuxWindowActive = sourceTmuxWindowActive();
259
- if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell(cwd);
260
- if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
261
- if (!tmuxWindowActive) markTmuxWindow(cwd);
262
- if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
263
335
  if (ctx?.hasUI && settingBoolean("notification.piUi", false, cwd)) ctx.ui.notify(text, level);
336
+ // Every channel already absorbs its own failure; this catch keeps an
337
+ // unexpected throw from becoming an unhandled rejection, which ends Pi.
338
+ return deliverTerminalNotification(pi, title, text, tmuxMarkGeneration, cwd).catch(() => undefined);
264
339
  }
265
340
 
266
341
  function questionNotificationTitle(request?: QuestionRequestLike): string {
@@ -269,7 +344,7 @@ function questionNotificationTitle(request?: QuestionRequestLike): string {
269
344
  return "Question";
270
345
  }
271
346
 
272
- export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
347
+ export function notifyQuestionOpened(pi: QolNotificationExec, ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
273
348
  const title = questionNotificationTitle(event.request);
274
349
  const key = `${keyPrefix}:${event.requestId ?? title}`;
275
350
  const now = Date.now();
@@ -279,5 +354,5 @@ export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: Q
279
354
  for (const [storedKey, timestamp] of lastQuestionNotificationAt) {
280
355
  if (now - timestamp > 60_000) lastQuestionNotificationAt.delete(storedKey);
281
356
  }
282
- sendQolNotification(ctx, "question", `Input required: ${title}`, "warning", key);
357
+ void sendQolNotification(pi, ctx, "question", `Input required: ${title}`, "warning", key);
283
358
  }