pi-quiver 4.0.1 → 4.1.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/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v4.1.0 - 2026-08-14
12
+
13
+ - **`session-name`: configurable naming policy** (#5). New `sessionAutoName` keys: `rules` (house conventions appended to the naming prompt, later rules win), `deny` (literal case-insensitive phrases stripped from every name, loose interior whitespace so `"acme corp"` also catches `AcmeCorp`), and `revisitFirstTurn`/`revisitEveryTurns` (re-derive the name once those round-trip counts are crossed; default `0` = off, each revisit is one short LLM call). Machine-generated names are replaced when stale; human-set names only get a non-blocking suggestion notification. Name provenance and cadence persist across resume. Revisits run detached and only after the agent has fully settled, so automated multi-turn runs (chains, workflows) are never renamed or delayed mid-flight.
14
+
11
15
  ## v4.0.1 - 2026-08-04
12
16
 
13
17
  - **Trimmed `fetch`/`doc_to_md` system-prompt guidelines that duplicated the tool descriptions** (~200 tokens). The two additive bits (degraded-result fidelity warning, grep-the-spilled-file guidance) moved into the tool descriptions; `promptGuidelines` dropped from both tools.
package/README.md CHANGED
@@ -64,7 +64,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
64
64
  | --- | --- | --- |
65
65
  | `extensions/fetch.ts` | `fetch` | Retrieve URLs over HTTP(S). HTML -> Markdown (Readability extraction, Turndown conversion). Binary saved untouched to a temp file. GitHub issue/PR/repo/actions-run URLs auto-route through `gh` (falls back to HTTP). Same size gate as `doc_to_md`. |
66
66
  | `extensions/doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm` (run through `uv`); degraded pure-JS fallback (`unpdf`) when `uv`/Python is unavailable or conversion times out. DOCX/PPTX convert via LibreOffice first. |
67
- | `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, with Ghostty tab rename. OFF by default. |
67
+ | `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, naming rules and deny list, long-session revisits, and Ghostty tab rename. OFF by default. |
68
68
  | `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
69
69
  | `extensions/fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 / Opus 5 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
70
70
  | `extensions/provider-stall-watchdog.ts` | - | Opt-in provider-stall recovery, in two tiers: a pre-first-event deadline (`firstEventMs`, 20s) on every provider request in every mode, and the mid-stream pair (warn at 2 min, recover at 4 min) in TUI runs only. Policy D offers each stall to Pi's retry loop until the stall retry budget (`maxStallRetries`, default = `retry.maxRetries`) is exhausted. OFF by default. |
@@ -141,14 +141,23 @@ These extensions are opt-in via `settings.json` (project `.pi/settings.json` ove
141
141
 
142
142
  ```jsonc
143
143
  {
144
- "sessionAutoName": { "enabled": false, "ghosttyTab": true }, // or boolean shorthand
145
- "swordHeader": false, // or { "enabled": true }
144
+ "sessionAutoName": {
145
+ "enabled": false,
146
+ "ghosttyTab": true,
147
+ "rules": [],
148
+ "deny": [],
149
+ "revisitFirstTurn": 0,
150
+ "revisitEveryTurns": 0
151
+ }, // or boolean shorthand
152
+ "swordHeader": false, // or { "enabled": true }
146
153
  "fastMode": false, // or { "enabled": true }
147
154
  "providerStallWatchdog": false // or { "enabled": true }
148
155
  }
149
156
  ```
150
157
 
151
- `sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `fastMode` only affects `claude-opus-4-8` and `claude-opus-5` requests on Anthropic's `anthropic-messages` API; enabling it opts into premium fast-mode pricing. `--fast` forces it on for one launch; `/fast on|off` toggles live. Proxy providers (opencode, cloudflare-ai-gateway) are excluded. `fastMode`'s header injection needs the `before_provider_headers` hook (pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5); on older pi the beta header is silently not sent. See [doc/fetch.md](doc/fetch.md) and [doc/doc-to-md.md](doc/doc-to-md.md) for the ingestion tools' full reference; session-name/sword-header behavior above is complete.
158
+ `sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `rules` appends house conventions to the naming prompt (later rules win when they conflict with the built-ins). Literal, case-insensitive `deny` phrases are stripped from every name; whitespace inside a phrase is loose, so `"acme corp"` also catches `AcmeCorp`. `revisitFirstTurn` re-evaluates the name once that many model round trips have completed, while `revisitEveryTurns` does so at every multiple; both default to `0` (off) because each revisit costs another short LLM call. For example, `10` and `100` mark round trips 10, 100, 200, 300. Revisits only run when the agent has fully settled (idle, nothing queued) - an automated multi-turn run such as a subagent chain is never renamed or delayed mid-flight; cadence points it crossed fire once, at the settle. A machine-generated name is replaced when stale. A name set by a human is never overwritten: the extension strongly prefers it, and announces a suggestion only when the work has clearly moved on. Counts come from the persisted transcript, so they survive resume.
159
+
160
+ `fastMode` only affects `claude-opus-4-8` and `claude-opus-5` requests on Anthropic's `anthropic-messages` API; enabling it opts into premium fast-mode pricing. `--fast` forces it on for one launch; `/fast on|off` toggles live. Proxy providers (opencode, cloudflare-ai-gateway) are excluded. `fastMode`'s header injection needs the `before_provider_headers` hook (pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5); on older pi the beta header is silently not sent. See [doc/fetch.md](doc/fetch.md) and [doc/doc-to-md.md](doc/doc-to-md.md) for the ingestion tools' full reference; session-name/sword-header behavior above is complete.
152
161
 
153
162
  `pi-ai` prices every fast request at standard rates - it has no `usage.speed` support and no request-level pricing modifier - so `fastMode` corrects the reported cost itself: a `message_end` handler scales all four `usage.cost` components by `FAST_MODE_COST_MULTIPLIER` (2x) and returns the corrected message. Persisted session JSONL and pi's own native cost display are always exact, since they're written from this corrected message. pi-cohort's live `Σ$` reflects the correction only when pi-quiver's `message_end` handler runs before pi-cohort's - best-effort, depending on extension load order - and is reconciled on pi-cohort's next `session_start` regardless. The upstream fix (teaching `pi-ai`'s `Usage`/`calculateCost` about `usage.speed`) is the better long-term path and is tracked separately.
154
163
 
@@ -4,6 +4,10 @@
4
4
  * - /session-name [name] : manually set or show the session name.
5
5
  * - Auto-naming : after the first agent turn, derive a concise name
6
6
  * from the conversation (unless one is already set).
7
+ * - Revisiting : re-derive the name later in a long session, once
8
+ * the work has revealed what it actually is.
9
+ * - Deny list : keep chosen words out of every name, whatever set
10
+ * it - the model, /session-name, or pi's builtin.
7
11
  * - Ghostty tab rename : whenever the name changes, rename the current
8
12
  * Ghostty tab - but only if the active terminal is
9
13
  * really Ghostty.
@@ -13,8 +17,20 @@
13
17
  * /session-name command always works.
14
18
  *
15
19
  * Config (settings.json, project overrides global). Defaults shown:
16
- * "sessionAutoName": { "enabled": false, "ghosttyTab": true }
17
- * Boolean shorthand: "sessionAutoName": true // enables everything
20
+ * "sessionAutoName": {
21
+ * "enabled": false,
22
+ * "ghosttyTab": true,
23
+ * "rules": [], // extra naming rules appended to the prompt
24
+ * "deny": [], // phrases stripped from any name
25
+ * "revisitFirstTurn": 0, // re-derive once at this round trip (0 = off)
26
+ * "revisitEveryTurns": 0 // and at every multiple of this (0 = off)
27
+ * }
28
+ * Boolean shorthand: "sessionAutoName": true // enables naming + tab sync
29
+ *
30
+ * Revisiting is opt-in and costs one short LLM call each time it fires, so
31
+ * both cadence knobs default to 0. `revisitFirstTurn: 10` with
32
+ * `revisitEveryTurns: 100` fires at round trips 10, 100, 200, 300 - early
33
+ * once, because the first turn rarely knows what the session is, then rarely.
18
34
  */
19
35
 
20
36
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -33,8 +49,30 @@ async function loadComplete(): Promise<CompleteFn> {
33
49
  return (await import("@earendil-works/pi-ai/compat")).complete;
34
50
  }
35
51
 
36
- type Config = { enabled: boolean; ghosttyTab: boolean };
37
- const DEFAULT_CONFIG: Config = { enabled: false, ghosttyTab: true };
52
+ type Config = {
53
+ enabled: boolean;
54
+ ghosttyTab: boolean;
55
+ rules: string[];
56
+ deny: string[];
57
+ revisitFirstTurn: number;
58
+ revisitEveryTurns: number;
59
+ };
60
+ const DEFAULT_CONFIG: Config = {
61
+ enabled: false,
62
+ ghosttyTab: true,
63
+ rules: [],
64
+ deny: [],
65
+ revisitFirstTurn: 0,
66
+ revisitEveryTurns: 0,
67
+ };
68
+
69
+ const stringList = (v: unknown): string[] | undefined =>
70
+ Array.isArray(v) && v.every((s) => typeof s === "string")
71
+ ? v.map((s) => s.trim()).filter(Boolean)
72
+ : undefined;
73
+
74
+ const turnCount = (v: unknown): number | undefined =>
75
+ typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : undefined;
38
76
 
39
77
  export function coerce(raw: unknown): Partial<Config> | undefined {
40
78
  if (raw === undefined) return undefined;
@@ -44,6 +82,14 @@ export function coerce(raw: unknown): Partial<Config> | undefined {
44
82
  const out: Partial<Config> = {};
45
83
  if (typeof o.enabled === "boolean") out.enabled = o.enabled;
46
84
  if (typeof o.ghosttyTab === "boolean") out.ghosttyTab = o.ghosttyTab;
85
+ const rules = stringList(o.rules);
86
+ if (rules) out.rules = rules;
87
+ const deny = stringList(o.deny);
88
+ if (deny) out.deny = deny;
89
+ const first = turnCount(o.revisitFirstTurn);
90
+ if (first !== undefined) out.revisitFirstTurn = first;
91
+ const every = turnCount(o.revisitEveryTurns);
92
+ if (every !== undefined) out.revisitEveryTurns = every;
47
93
  return out;
48
94
  }
49
95
  return undefined;
@@ -80,7 +126,11 @@ export function stripSkillBodies(text: string): string {
80
126
  );
81
127
  }
82
128
 
83
- export function buildConversationText(ctx: ExtensionContext, maxChars = 4000): string {
129
+ export function buildConversationText(
130
+ ctx: ExtensionContext,
131
+ maxChars = 4000,
132
+ mostRecent = false,
133
+ ): string {
84
134
  const sections: string[] = [];
85
135
  for (const entry of ctx.sessionManager.getEntries()) {
86
136
  const e = entry as { type?: string; message?: { role?: string; content?: unknown } };
@@ -96,7 +146,8 @@ export function buildConversationText(ctx: ExtensionContext, maxChars = 4000): s
96
146
  if (text.length > 0) sections.push(`Result: ${text.slice(0, 400)}`);
97
147
  }
98
148
  }
99
- return sections.join("\n\n").slice(0, maxChars);
149
+ const conversation = sections.join("\n\n");
150
+ return mostRecent ? conversation.slice(-maxChars) : conversation.slice(0, maxChars);
100
151
  }
101
152
 
102
153
  export function isGhosttyActive(
@@ -121,6 +172,91 @@ export function toTabLabel(name: string, maxWords = 4): string {
121
172
  .join(" ");
122
173
  }
123
174
 
175
+ /**
176
+ * Compile a deny phrase to a matcher. Phrases are literal, not regex: metachars
177
+ * are escaped so a user can't crash naming with a bad pattern from settings.
178
+ * Interior whitespace matches loosely, so one entry covers the spaced and
179
+ * jammed spellings alike (`acme corp` catches `AcmeCorp` and `Acme Corp`).
180
+ * Word boundaries are added only where the phrase itself starts/ends on a word
181
+ * character, since `\b` next to punctuation asserts the opposite of what a
182
+ * reader expects.
183
+ */
184
+ function denyMatcher(phrase: string): RegExp | undefined {
185
+ const escaped = phrase.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
+ if (!escaped) return undefined;
187
+ const body = escaped.replace(/\s+/g, "\\s*");
188
+ const trimmed = phrase.trim();
189
+ const word = /[\p{L}\p{N}_]/u;
190
+ const lead = word.test(trimmed[0] ?? "") ? "(?<![\\p{L}\\p{N}_])" : "";
191
+ const tail = word.test(trimmed.at(-1) ?? "") ? "(?![\\p{L}\\p{N}_])" : "";
192
+ return new RegExp(`${lead}${body}${tail}`, "giu");
193
+ }
194
+
195
+ /**
196
+ * Strip every deny phrase from a name and tidy the seam left behind: doubled
197
+ * spaces, brackets emptied by the removal, and separators stranded against each
198
+ * other. Structure the author intended is preserved - dropping the middle of
199
+ * `Fix - Acme - login` leaves `Fix - login`, not `Fix login`.
200
+ *
201
+ * Returns `Session` when stripping would empty the name. A generic fallback is
202
+ * less informative, but unlike either a blank or the original it actually
203
+ * honors the deny-list contract.
204
+ */
205
+ export function applyDenyList(name: string, deny: string[]): string {
206
+ if (deny.length === 0) return name;
207
+ let out = name;
208
+ for (const phrase of deny) {
209
+ const re = denyMatcher(phrase);
210
+ if (re) out = out.replace(re, " ");
211
+ }
212
+ out = out
213
+ .replace(/\s+/g, " ")
214
+ .replace(/\(\s*\)|\[\s*\]|\{\s*\}/g, "")
215
+ .replace(/([-:|/])(?:\s*[-:|/])+/g, "$1")
216
+ .replace(/^[\s\-:|/]+|[\s\-:|/]+$/g, "")
217
+ .replace(/\s+/g, " ")
218
+ .trim();
219
+ return out || "Session";
220
+ }
221
+
222
+ /**
223
+ * Whether a revisit point lies in `(prev, n]`. Points are `revisitFirstTurn`
224
+ * once, then every multiple of `revisitEveryTurns` - so 10/100 gives 10, 100,
225
+ * 200. Interval semantics rather than exact-hit because the check only runs
226
+ * when the agent settles: a 30-round-trip automated run never stops exactly on
227
+ * round trip 10, but it does cross it. Either knob at 0 disables its own
228
+ * trigger.
229
+ */
230
+ export function shouldRevisit(
231
+ prev: number,
232
+ n: number,
233
+ cfg: { revisitFirstTurn: number; revisitEveryTurns: number },
234
+ ): boolean {
235
+ if (n <= 0 || n <= prev) return false;
236
+ const from = Math.max(prev, 0);
237
+ if (cfg.revisitFirstTurn > 0 && from < cfg.revisitFirstTurn && n >= cfg.revisitFirstTurn) {
238
+ return true;
239
+ }
240
+ return (
241
+ cfg.revisitEveryTurns > 0 &&
242
+ Math.floor(n / cfg.revisitEveryTurns) > Math.floor(from / cfg.revisitEveryTurns)
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Assistant messages so far, i.e. completed model round trips. Derived from the
248
+ * transcript rather than tallied in memory so a resumed session keeps counting
249
+ * where it left off instead of restarting the revisit cadence.
250
+ */
251
+ export function countRoundTrips(ctx: ExtensionContext): number {
252
+ let n = 0;
253
+ for (const entry of ctx.sessionManager.getEntries()) {
254
+ const e = entry as { type?: string; message?: { role?: string } };
255
+ if (e.type === "message" && e.message?.role === "assistant") n++;
256
+ }
257
+ return n;
258
+ }
259
+
124
260
  function renameGhosttyTab(label: string, enabled: boolean): void {
125
261
  if (!enabled || !isGhosttyActive()) return;
126
262
  // OSC 2: set window/tab title. Ghostty shows this as the tab title and
@@ -150,21 +286,41 @@ export function parseGeneratedName(raw: string): GeneratedName | undefined {
150
286
  return { sessionName, tabLabel: toTabLabel(tabLabel) };
151
287
  }
152
288
 
153
- async function generateName(ctx: ExtensionContext): Promise<GeneratedName | undefined> {
154
- const conversation = buildConversationText(ctx);
155
- if (conversation.length < 8) return undefined;
289
+ /**
290
+ * A revisit may conclude the existing name still fits, which is the common case
291
+ * and must not cost a rename.
292
+ */
293
+ export const KEEP = "keep" as const;
156
294
 
157
- const model = ctx.model;
158
- if (!model) return undefined;
159
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
160
- // ok=true with apiKey=undefined is the env-key path: the key lives in
161
- // process.env (e.g. ANTHROPIC_API_KEY), not auth.json. getApiKeyAndHeaders
162
- // deliberately opts out of the env fallback (includeFallback: false), so it
163
- // reports no apiKey. Don't bail on that - complete() resolves the env key
164
- // itself via withEnvApiKey/getEnvApiKey. Only bail when auth genuinely failed.
165
- if (!auth?.ok) return undefined;
295
+ /**
296
+ * Parse a revisit reply: either the KEEP verdict or a fresh two-line name.
297
+ * Checked before {@link parseGeneratedName} because a KEEP reply has no SESSION
298
+ * line and would otherwise be indistinguishable from a failed generation.
299
+ */
300
+ export function parseRevisitReply(raw: string): typeof KEEP | GeneratedName | undefined {
301
+ if (/^\W*KEEP\W*$/i.test(raw.trim())) return KEEP;
302
+ return parseGeneratedName(raw);
303
+ }
166
304
 
167
- const prompt = [
305
+ type NameAuthor = "auto" | "human";
306
+ type NameAuthorEntry = { name: string; author: NameAuthor };
307
+ const NAME_AUTHOR_ENTRY = "pi-quiver.session-name-author";
308
+
309
+ type PromptOptions = { rules?: string[]; currentName?: string };
310
+
311
+ /**
312
+ * Build the naming prompt. With `currentName` set this is a revisit: the model
313
+ * is told what the session is already called and given the option to keep it,
314
+ * because a long session usually earns its name early and churning it on every
315
+ * revisit would be worse than not revisiting at all.
316
+ *
317
+ * User `rules` land after the built-in ones so they win on conflict - that is
318
+ * the point of exposing them, since house naming conventions routinely
319
+ * contradict a general-purpose default.
320
+ */
321
+ export function buildNamingPrompt(conversation: string, opts: PromptOptions = {}): string {
322
+ const { rules = [], currentName } = opts;
323
+ const lines = [
168
324
  "Name this work session based on the concrete task being done below.",
169
325
  "Reply with EXACTLY two lines:",
170
326
  "SESSION: <3-6 word descriptive title>",
@@ -174,12 +330,41 @@ async function generateName(ctx: ExtensionContext): Promise<GeneratedName | unde
174
330
  "- Lead with an action verb (e.g. refine, fix, add, rework).",
175
331
  "- Preserve ticket/issue IDs (e.g. ABC-123, PROJ-42, #99) verbatim.",
176
332
  "- No quotes, no trailing punctuation, plain ASCII.",
333
+ ...rules.map((r) => `- ${r}`),
177
334
  "Example -> SESSION: Refine Linear Ticket ABC-123 / TAB: Refine ABC-123",
178
- "",
179
- "<conversation>",
180
- conversation,
181
- "</conversation>",
182
- ].join("\n");
335
+ ];
336
+ if (currentName) {
337
+ lines.push(
338
+ "",
339
+ `This session is already named: ${currentName}`,
340
+ "That name may have been chosen by a human, so prefer keeping it.",
341
+ "Reply with the single word KEEP if it still describes the work below,",
342
+ "even loosely. Only propose a new name if the session has clearly moved",
343
+ "on to different work, or the rules above are now plainly violated.",
344
+ );
345
+ }
346
+ lines.push("", "<conversation>", conversation, "</conversation>");
347
+ return lines.join("\n");
348
+ }
349
+
350
+ async function generateName(
351
+ ctx: ExtensionContext,
352
+ opts: PromptOptions = {},
353
+ ): Promise<typeof KEEP | GeneratedName | undefined> {
354
+ const conversation = buildConversationText(ctx, 4000, Boolean(opts.currentName));
355
+ if (conversation.length < 8) return undefined;
356
+
357
+ const model = ctx.model;
358
+ if (!model) return undefined;
359
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
360
+ // ok=true with apiKey=undefined is the env-key path: the key lives in
361
+ // process.env (e.g. ANTHROPIC_API_KEY), not auth.json. getApiKeyAndHeaders
362
+ // deliberately opts out of the env fallback (includeFallback: false), so it
363
+ // reports no apiKey. Don't bail on that - complete() resolves the env key
364
+ // itself via withEnvApiKey/getEnvApiKey. Only bail when auth genuinely failed.
365
+ if (!auth?.ok) return undefined;
366
+
367
+ const prompt = buildNamingPrompt(conversation, opts);
183
368
 
184
369
  const complete = await loadComplete();
185
370
  const response = await complete(
@@ -197,11 +382,29 @@ async function generateName(ctx: ExtensionContext): Promise<GeneratedName | unde
197
382
  .map((c) => c.text)
198
383
  .join("\n");
199
384
 
200
- return parseGeneratedName(raw);
385
+ return opts.currentName ? parseRevisitReply(raw) : parseGeneratedName(raw);
201
386
  }
202
387
 
203
- export default function (pi: ExtensionAPI) {
388
+ type NameGenerator = (
389
+ ctx: ExtensionContext,
390
+ opts?: PromptOptions,
391
+ ) => Promise<typeof KEEP | GeneratedName | undefined>;
392
+
393
+ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = generateName) {
204
394
  let autoNameTried = false;
395
+ // Who chose the current name. A human's wording is never overwritten by a
396
+ // revisit - at most we suggest - so unknown provenance (a resumed session, a
397
+ // rename from outside this extension) is treated as human.
398
+ let nameAuthor: NameAuthor = "human";
399
+ // Round trips already acted on; the next check covers (lastRevisitAt, n].
400
+ let lastRevisitAt = 0;
401
+ let revisitInFlight = false;
402
+ // Last detached revisit, exposed so tests (and a curious host) can await
403
+ // completion of work that deliberately outlives the agent_settled handler.
404
+ let revisitDone: Promise<void> = Promise.resolve();
405
+ // The next name-change event expected from our own write. Tracking the value
406
+ // rather than a synchronous flag also covers hosts that emit the event later.
407
+ let expectedInternalName: string | null = null;
205
408
  // The session name is the single source of truth; the tab label is derived
206
409
  // from it. We remember the name we last reflected and the (possibly curated)
207
410
  // label we wrote so turn_start can re-assert it - pi writes its own OS title
@@ -214,10 +417,40 @@ export default function (pi: ExtensionAPI) {
214
417
  // Adopt a name we set ourselves, keeping any curated tab label (auto-naming
215
418
  // produces a separate TAB line that need not match the first words of the
216
419
  // session name), then write the tab.
217
- const setName = (cfg: Config, name: string, tabLabel?: string): void => {
218
- pi.setSessionName(name);
219
- lastSyncedName = name;
220
- currentTabLabel = toTabLabel(tabLabel ?? name);
420
+ const recordNameAuthor = (name: string, author: NameAuthor): void => {
421
+ nameAuthor = author;
422
+ pi.appendEntry<NameAuthorEntry>(NAME_AUTHOR_ENTRY, { name, author });
423
+ };
424
+
425
+ const restoredNameAuthor = (ctx: ExtensionContext, name: string): NameAuthor => {
426
+ const entries = ctx.sessionManager.getEntries();
427
+ for (let i = entries.length - 1; i >= 0; i--) {
428
+ const entry = entries[i] as {
429
+ type?: string;
430
+ customType?: string;
431
+ data?: Partial<NameAuthorEntry>;
432
+ };
433
+ if (entry.type !== "custom" || entry.customType !== NAME_AUTHOR_ENTRY) continue;
434
+ if (entry.data?.name === name && (entry.data.author === "auto" || entry.data.author === "human")) {
435
+ return entry.data.author;
436
+ }
437
+ break;
438
+ }
439
+ return "human";
440
+ };
441
+
442
+ const setName = (
443
+ cfg: Config,
444
+ name: string,
445
+ tabLabel?: string,
446
+ author?: NameAuthor,
447
+ ): void => {
448
+ const clean = applyDenyList(name, cfg.deny);
449
+ if (author) recordNameAuthor(clean, author);
450
+ expectedInternalName = clean;
451
+ pi.setSessionName(clean);
452
+ lastSyncedName = clean;
453
+ currentTabLabel = toTabLabel(applyDenyList(tabLabel ?? clean, cfg.deny));
221
454
  renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
222
455
  };
223
456
 
@@ -241,8 +474,8 @@ export default function (pi: ExtensionAPI) {
241
474
  const name = args.trim();
242
475
  if (name) {
243
476
  autoNameTried = true; // manual name wins; don't auto-overwrite later
244
- setName(loadConfig(ctx), name);
245
- ctx.ui.notify(`Session named: ${name}`, "info");
477
+ setName(loadConfig(ctx), name, undefined, "human");
478
+ ctx.ui.notify(`Session named: ${pi.getSessionName()}`, "info");
246
479
  } else {
247
480
  const current = pi.getSessionName();
248
481
  ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
@@ -258,16 +491,38 @@ export default function (pi: ExtensionAPI) {
258
491
  // A loaded/resumed/reloaded session already carries a name; reflect it.
259
492
  // The curated tab label is not persisted, so derive from the name.
260
493
  autoNameTried = true;
261
- lastSyncedName = current;
262
- currentTabLabel = toTabLabel(current);
263
- renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
494
+ nameAuthor = restoredNameAuthor(ctx, current);
495
+ setName(cfg, current);
264
496
  } else {
265
497
  // Fresh session: clear carryover so a new name re-derives cleanly and
266
498
  // auto-naming can run again.
267
499
  autoNameTried = false;
500
+ nameAuthor = "human";
268
501
  lastSyncedName = null;
269
502
  currentTabLabel = null;
270
503
  }
504
+ lastRevisitAt = countRoundTrips(ctx);
505
+ });
506
+
507
+ // The deny gate. setName cleans everything this extension writes, but pi's
508
+ // builtin rename and any other extension bypass it, so re-clean whatever the
509
+ // name ends up as. session_info_changed cannot veto a change - it only
510
+ // reports one - so the correction is a second write, guarded against the
511
+ // echo of our own.
512
+ pi.on("session_info_changed", async (_event, ctx) => {
513
+ const current = pi.getSessionName();
514
+ if (current === expectedInternalName) {
515
+ expectedInternalName = null;
516
+ return;
517
+ }
518
+ expectedInternalName = null;
519
+ if (!current) return;
520
+ const cfg = loadConfig(ctx);
521
+ if (!cfg.enabled) return;
522
+ recordNameAuthor(current, "human");
523
+ if (cfg.deny.length === 0) return;
524
+ const clean = applyDenyList(current, cfg.deny);
525
+ if (clean !== current) setName(cfg, clean, undefined, "human");
271
526
  });
272
527
 
273
528
  // Re-assert at the start of every turn. This is the only signal we get that
@@ -283,13 +538,65 @@ export default function (pi: ExtensionAPI) {
283
538
  if (!cfg.enabled) return; // off by default; opt in via settings.json
284
539
  autoNameTried = true;
285
540
  try {
286
- const generated = await generateName(ctx);
287
- if (generated && !pi.getSessionName()) {
288
- setName(cfg, generated.sessionName, generated.tabLabel);
289
- if (ctx.hasUI) ctx.ui.notify(`Auto-named session: ${generated.sessionName}`, "info");
541
+ const generated = await generate(ctx, { rules: cfg.rules });
542
+ if (generated && generated !== KEEP && !pi.getSessionName()) {
543
+ setName(cfg, generated.sessionName, generated.tabLabel, "auto");
544
+ if (ctx.hasUI) ctx.ui.notify(`Auto-named session: ${pi.getSessionName()}`, "info");
290
545
  }
291
546
  } catch {
292
547
  // best-effort; ignore failures
293
548
  }
294
549
  });
550
+
551
+ // Revisit. A name derived from the first turn describes the opening move,
552
+ // which is frequently not what the session turns out to be about - the work
553
+ // only reveals itself after the agent has been at it a while. Re-deriving at
554
+ // a couple of points catches that drift.
555
+ //
556
+ // agent_settled, not turn_end: it fires only once the run is fully idle (no
557
+ // retry, compaction, or queued continuation pending), so an automated
558
+ // multi-turn workflow - a subagent chain, a gauntlet phase - is never
559
+ // renamed mid-flight and never waits on a naming call. Cadence points the
560
+ // run crossed fire once, at the settle. The LLM call still runs detached so
561
+ // the freed-up UI is not held hostage by a slow provider.
562
+ pi.on("agent_settled", (_event, ctx) => {
563
+ if (revisitInFlight) return;
564
+ const cfg = loadConfig(ctx);
565
+ if (!cfg.enabled) return;
566
+ if (cfg.revisitFirstTurn === 0 && cfg.revisitEveryTurns === 0) return;
567
+ const current = pi.getSessionName();
568
+ if (!current) return; // unnamed; auto-naming owns that case
569
+ const n = countRoundTrips(ctx);
570
+ if (!shouldRevisit(lastRevisitAt, n, cfg)) return;
571
+ lastRevisitAt = n;
572
+ revisitInFlight = true;
573
+ revisitDone = (async () => {
574
+ try {
575
+ const result = await generate(ctx, { rules: cfg.rules, currentName: current });
576
+ if (!result || result === KEEP) return;
577
+ if (pi.getSessionName() !== current) return; // renamed under us mid-call
578
+ if (nameAuthor === "auto") {
579
+ setName(cfg, result.sessionName, result.tabLabel, "auto");
580
+ if (ctx.hasUI) ctx.ui.notify(`Renamed session: ${pi.getSessionName()}`, "info");
581
+ } else if (ctx.hasUI) {
582
+ // Human wording is theirs to change; surface the drift and stop.
583
+ const suggestion = applyDenyList(result.sessionName, cfg.deny);
584
+ ctx.ui.notify(
585
+ `Session name looks stale. Suggested: ${suggestion} - /session-name to apply`,
586
+ "info",
587
+ );
588
+ }
589
+ } catch {
590
+ // best-effort; ignore failures
591
+ } finally {
592
+ revisitInFlight = false;
593
+ }
594
+ })();
595
+ });
596
+
597
+ return { revisitSettled: () => revisitDone };
598
+ }
599
+
600
+ export default function (pi: ExtensionAPI) {
601
+ installSessionName(pi);
295
602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",