pi-quiver 5.1.0 → 5.2.1

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,14 @@ 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
+ ## v5.2.1 - 2026-09-02
12
+
13
+ - sword-header: clip each header line to the terminal width in `render(width)` (ANSI-aware `truncateToWidth`, hard clip). Fixes a hard crash (`Rendered line N exceeds terminal width`) in terminals/panes narrower than 80 columns.
14
+
15
+ ## v5.2.0 - 2026-09-01
16
+
17
+ - session-name: sync the session name to the Herdr tab label (`herdrTab`, default true within the opt-in `quiver.sessionAutoName`). Claim-once - only a tab still on its default numeric label is adopted; manual renames always win. Restores the default label on shutdown; crash leaves the last label (rename by hand to recover).
18
+
11
19
  ## v5.1.0 - 2026-09-01
12
20
 
13
21
  - `slack`: optional `policyPath` config injects a repo policy file into the system prompt every turn (`<slack-policy source="...">`); a missing/unreadable/empty file degrades to a `status=` block plus one deduped warning, tools stay fully usable either way (#9). `slack_post`/`slack_update` now resolve `@name` mentions to `<@U...>` (cache-first, one batched `users.list` live pass), leaving unresolvable names literal and reported via `unresolved mentions: ...` plus `details.unresolvedMentions`. The name cache gained an optional per-user `email` field and a file-level `snapshot_at` marker (set only by a full `slack_cache_refresh`, gating whether an alias match can be trusted straight from cache); `slack_cache_refresh`'s result line now reports an email/user ratio with a missing-scope hint. `slack_post` gained `unfurl_links`/`unfurl_media` params, applied to the headline and inline detail leg (never the upload stub), omitted when unset so Slack's default stands; `slack_update` has no equivalent (`chat.update` has no unfurl argument). See [doc/slack.md](doc/slack.md).
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/actions-job URLs auto-route through `gh` (falls back to HTTP); failed runs/jobs include failed-step logs (best-effort, summary-only otherwise). Same size gate as `doc_to_md`. Behavior lives in `lib/fetch-core.ts`; also exposed as the `pi-quiver fetch` CLI (see [Claude Code support](#claude-code-support)). |
66
66
  | `extensions/doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm`, resolved per process (`uv` -> system Python >= 3.12 with the package -> one-time managed venv in the user cache dir); degraded pure-JS fallback (`unpdf`) otherwise. DOCX/PPTX convert via LibreOffice first. Behavior lives in `lib/doc-to-md-core.ts`; also exposed as the `pi-quiver doc-to-md` CLI (see [Claude Code support](#claude-code-support)). |
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. |
67
+ | `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, naming rules and deny list, long-session revisits, and Ghostty/Herdr 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. |
@@ -146,6 +146,7 @@ These extensions are opt-in via `settings.json` (project `.pi/settings.json` ove
146
146
  "sessionAutoName": {
147
147
  "enabled": false,
148
148
  "ghosttyTab": true,
149
+ "herdrTab": true,
149
150
  "rules": [],
150
151
  "deny": [],
151
152
  "revisitFirstTurn": 0,
@@ -181,6 +182,8 @@ survive untouched.
181
182
 
182
183
  `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.
183
184
 
185
+ `herdrTab` (default `true`) mirrors the same curated label to the Herdr tab bar over Herdr's unix socket, independent of `ghosttyTab` - either sink can be toggled off without affecting the other. It's claim-once: the extension adopts a tab only while it still shows its default numeric label (its live 1-based position among the workspace's tabs); a manually renamed tab, or one renamed mid-session by hand, is never touched again for that session - the human's label always wins. On every shutdown (quit, reload, `/new`, resume, fork) a claimed tab's label is restored to its then-current default position, so a successor session in the same pane can claim cleanly. It only ever runs in TUI mode, on an attached TTY, under Herdr (`HERDR_ENV`/`HERDR_TAB_ID`/`HERDR_SOCKET_PATH` all set) - `pi -p`/json/rpc runs and background subagents never touch the tab. Caveats: a hard crash (`kill -9`) skips the restore and leaves the stale label - rename the tab by hand to recover; and the restored label is internally a custom name (Herdr has no clear-to-auto API), so that tab keeps a number-looking label but stops renumbering on later tab closes/reorders.
186
+
184
187
  `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.
185
188
 
186
189
  `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.
@@ -36,6 +36,12 @@
36
36
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
37
  import { resolveConfig } from "../lib/extension-config.ts";
38
38
 
39
+ // ExtensionMode ("tui" | "rpc" | "json" | "print") isn't among the package's
40
+ // public exports; derive it from ExtensionContext, which is, instead of
41
+ // duplicating the union here.
42
+ type Mode = ExtensionContext["mode"];
43
+ import { getTab, isHerdrActive, listTabs, renameTab } from "../lib/herdr-tab.ts";
44
+
39
45
  // `complete` moved between pi-ai layouts: older builds re-export it from the
40
46
  // package index, newer ones expose it only via the `/compat` subpath. A static
41
47
  // import that targets one breaks at load time on the other (and a missing
@@ -52,6 +58,7 @@ async function loadComplete(): Promise<CompleteFn> {
52
58
  type Config = {
53
59
  enabled: boolean;
54
60
  ghosttyTab: boolean;
61
+ herdrTab: boolean;
55
62
  rules: string[];
56
63
  deny: string[];
57
64
  revisitFirstTurn: number;
@@ -60,6 +67,7 @@ type Config = {
60
67
  const DEFAULT_CONFIG: Config = {
61
68
  enabled: false,
62
69
  ghosttyTab: true,
70
+ herdrTab: true,
63
71
  rules: [],
64
72
  deny: [],
65
73
  revisitFirstTurn: 0,
@@ -76,12 +84,13 @@ const turnCount = (v: unknown): number | undefined =>
76
84
 
77
85
  export function coerce(raw: unknown): Partial<Config> | undefined {
78
86
  if (raw === undefined) return undefined;
79
- if (typeof raw === "boolean") return { enabled: raw, ghosttyTab: raw };
87
+ if (typeof raw === "boolean") return { enabled: raw, ghosttyTab: raw, herdrTab: raw };
80
88
  if (raw && typeof raw === "object") {
81
89
  const o = raw as Record<string, unknown>;
82
90
  const out: Partial<Config> = {};
83
91
  if (typeof o.enabled === "boolean") out.enabled = o.enabled;
84
92
  if (typeof o.ghosttyTab === "boolean") out.ghosttyTab = o.ghosttyTab;
93
+ if (typeof o.herdrTab === "boolean") out.herdrTab = o.herdrTab;
85
94
  const rules = stringList(o.rules);
86
95
  if (rules) out.rules = rules;
87
96
  const deny = stringList(o.deny);
@@ -425,6 +434,8 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
425
434
  // per-turn re-assert wins the race and keeps the tab in sync with the name.
426
435
  let lastSyncedName: string | null = null;
427
436
  let currentTabLabel: string | null = null;
437
+ let herdrClaim: { lastWritten: string } | "backed-off" | null = null;
438
+ let herdrChain: Promise<void> = Promise.resolve();
428
439
 
429
440
  // Adopt a name we set ourselves, keeping any curated tab label (auto-naming
430
441
  // produces a separate TAB line that need not match the first words of the
@@ -451,12 +462,13 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
451
462
  return "human";
452
463
  };
453
464
 
454
- const setName = (
465
+ const setName = async (
455
466
  cfg: Config,
456
467
  name: string,
457
468
  tabLabel?: string,
458
469
  author?: NameAuthor,
459
- ): void => {
470
+ mode?: Mode,
471
+ ): Promise<void> => {
460
472
  const clean = applyDenyList(name, cfg.deny);
461
473
  if (author) recordNameAuthor(clean, author);
462
474
  expectedInternalName = clean;
@@ -464,13 +476,17 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
464
476
  lastSyncedName = clean;
465
477
  currentTabLabel = toTabLabel(applyDenyList(tabLabel ?? clean, cfg.deny));
466
478
  renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
479
+ await syncHerdrTab(cfg, currentTabLabel, mode);
467
480
  };
468
481
 
469
482
  // Re-assert the tab from the current session name. Self-heals when the name
470
483
  // changed outside setName (builtin/manual rename we didn't author): the
471
- // curated label no longer applies, so re-derive from the name.
484
+ // curated label no longer applies, so re-derive from the name. The Ghostty
485
+ // write is already internally gated by renameGhosttyTab's own cfg.ghosttyTab
486
+ // check, which lets turn_start re-derive currentTabLabel for the Herdr sink
487
+ // even with the Ghostty sink off.
472
488
  const syncTab = (cfg: Config): void => {
473
- if (!cfg.enabled || !cfg.ghosttyTab) return;
489
+ if (!cfg.enabled) return;
474
490
  const name = pi.getSessionName();
475
491
  if (!name) return;
476
492
  if (name !== lastSyncedName) {
@@ -480,13 +496,83 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
480
496
  if (currentTabLabel) renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
481
497
  };
482
498
 
499
+ // Herdr sink. Claim-once: adopt the tab only while it shows its default
500
+ // (position) label; a human rename - before or during the session - wins
501
+ // permanently. All syncs serialize on one chain so overlapping hooks never
502
+ // interleave a read with a rename.
503
+ // Used both for turn_start syncs (bounds a wedged-but-accepting Herdr so it
504
+ // can't stall the turn) and for the session_end restore.
505
+ const HERDR_TIMEOUT_MS = 500;
506
+
507
+ const positionOf = (tabs: { tab_id: string; workspace_id: string }[], tabId: string): number => {
508
+ const own = tabs.find((t) => t.tab_id === tabId);
509
+ if (!own) return -1;
510
+ const siblings = tabs.filter((t) => t.workspace_id === own.workspace_id);
511
+ return siblings.findIndex((t) => t.tab_id === tabId) + 1;
512
+ };
513
+
514
+ const syncHerdrTab = (cfg: Config, label: string | null, mode: Mode | undefined): Promise<void> => {
515
+ const run = async (): Promise<void> => {
516
+ if (!cfg.herdrTab || mode !== "tui" || !label) return;
517
+ if (herdrClaim === "backed-off") return;
518
+ if (!isHerdrActive()) return;
519
+ const sock = process.env.HERDR_SOCKET_PATH as string;
520
+ const tabId = process.env.HERDR_TAB_ID as string;
521
+ if (herdrClaim === null) {
522
+ const tabs = await listTabs(sock, HERDR_TIMEOUT_MS);
523
+ if (!tabs) return; // transient; retry next sync
524
+ const position = positionOf(tabs, tabId);
525
+ if (position === -1) return; // stale tab id (pane moved); retry harmlessly
526
+ const own = tabs.find((t) => t.tab_id === tabId)!;
527
+ if (own.label !== String(position)) {
528
+ herdrClaim = "backed-off"; // human (or crashed predecessor) owns it
529
+ return;
530
+ }
531
+ if (await renameTab(sock, tabId, label, HERDR_TIMEOUT_MS)) {
532
+ herdrClaim = { lastWritten: label };
533
+ }
534
+ return;
535
+ }
536
+ const live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
537
+ if (!live) return; // failed read is not a human rename; stay claimed
538
+ if (live.label !== herdrClaim.lastWritten) {
539
+ herdrClaim = "backed-off";
540
+ return;
541
+ }
542
+ if (label === herdrClaim.lastWritten) return;
543
+ if (await renameTab(sock, tabId, label, HERDR_TIMEOUT_MS)) herdrClaim.lastWritten = label;
544
+ };
545
+ herdrChain = herdrChain.then(run, run);
546
+ return herdrChain;
547
+ };
548
+
549
+ // No mode gate needed here: herdrClaim can only become an object via
550
+ // syncHerdrTab, which itself gates on mode === "tui".
551
+ const restoreHerdrTab = (): Promise<void> => {
552
+ const run = async (): Promise<void> => {
553
+ if (typeof herdrClaim !== "object" || herdrClaim === null) return;
554
+ if (!isHerdrActive()) return;
555
+ const sock = process.env.HERDR_SOCKET_PATH as string;
556
+ const tabId = process.env.HERDR_TAB_ID as string;
557
+ const live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
558
+ if (!live || live.label !== herdrClaim.lastWritten) return; // human's label wins
559
+ const tabs = await listTabs(sock, HERDR_TIMEOUT_MS);
560
+ if (!tabs) return;
561
+ const position = positionOf(tabs, tabId);
562
+ if (position === -1) return;
563
+ await renameTab(sock, tabId, String(position), HERDR_TIMEOUT_MS);
564
+ };
565
+ herdrChain = herdrChain.then(run, run);
566
+ return herdrChain;
567
+ };
568
+
483
569
  pi.registerCommand("session-name", {
484
570
  description: "Set or show session name (usage: /session-name [new name])",
485
571
  handler: async (args, ctx) => {
486
572
  const name = args.trim();
487
573
  if (name) {
488
574
  autoNameTried = true; // manual name wins; don't auto-overwrite later
489
- setName(loadConfig(ctx), name, undefined, "human");
575
+ await setName(loadConfig(ctx), name, undefined, "human", ctx.mode);
490
576
  ctx.ui.notify(`Session named: ${pi.getSessionName()}`, "info");
491
577
  } else {
492
578
  const current = pi.getSessionName();
@@ -504,7 +590,7 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
504
590
  // The curated tab label is not persisted, so derive from the name.
505
591
  autoNameTried = true;
506
592
  nameAuthor = restoredNameAuthor(ctx, current);
507
- setName(cfg, current);
593
+ await setName(cfg, current, undefined, undefined, ctx.mode);
508
594
  } else {
509
595
  // Fresh session: clear carryover so a new name re-derives cleanly and
510
596
  // auto-naming can run again.
@@ -534,14 +620,24 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
534
620
  recordNameAuthor(current, "human");
535
621
  if (cfg.deny.length === 0) return;
536
622
  const clean = applyDenyList(current, cfg.deny);
537
- if (clean !== current) setName(cfg, clean, undefined, "human");
623
+ if (clean !== current) await setName(cfg, clean, undefined, "human", ctx.mode);
538
624
  });
539
625
 
540
626
  // Re-assert at the start of every turn. This is the only signal we get that
541
627
  // fires after pi's own OS-title writer on session replacement, so it keeps
542
628
  // the tab pinned to the session name and picks up external renames.
543
629
  pi.on("turn_start", async (_event, ctx) => {
544
- syncTab(loadConfig(ctx));
630
+ const cfg = loadConfig(ctx);
631
+ syncTab(cfg);
632
+ if (cfg.enabled) await syncHerdrTab(cfg, currentTabLabel, ctx.mode);
633
+ });
634
+
635
+ // Restore the numeric label on every teardown reason (quit/reload/new/
636
+ // resume/fork): a successor session in this pane must find the default so
637
+ // claim-once stays sound. The restored label is internally a custom name -
638
+ // Herdr has no clear-to-auto API - so it looks right but won't renumber.
639
+ pi.on("session_shutdown", async () => {
640
+ await restoreHerdrTab();
545
641
  });
546
642
 
547
643
  pi.on("agent_end", async (_event, ctx) => {
@@ -552,7 +648,7 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
552
648
  try {
553
649
  const generated = await generate(ctx, { rules: cfg.rules });
554
650
  if (generated && generated !== KEEP && !pi.getSessionName()) {
555
- setName(cfg, generated.sessionName, generated.tabLabel, "auto");
651
+ await setName(cfg, generated.sessionName, generated.tabLabel, "auto", ctx.mode);
556
652
  if (ctx.hasUI) ctx.ui.notify(`Auto-named session: ${pi.getSessionName()}`, "info");
557
653
  }
558
654
  } catch {
@@ -588,7 +684,7 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
588
684
  if (!result || result === KEEP) return;
589
685
  if (pi.getSessionName() !== current) return; // renamed under us mid-call
590
686
  if (nameAuthor === "auto") {
591
- setName(cfg, result.sessionName, result.tabLabel, "auto");
687
+ await setName(cfg, result.sessionName, result.tabLabel, "auto", ctx.mode);
592
688
  if (ctx.hasUI) ctx.ui.notify(`Renamed session: ${pi.getSessionName()}`, "info");
593
689
  } else if (ctx.hasUI) {
594
690
  // Human wording is theirs to change; surface the drift and stop.
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
17
+ import { truncateToWidth } from "@earendil-works/pi-tui";
17
18
  import { resolveConfig } from "../lib/extension-config.ts";
18
19
 
19
20
  type Config = { enabled: boolean };
@@ -69,8 +70,8 @@ export default function (pi: ExtensionAPI) {
69
70
  const cfg = resolveConfig(ctx.cwd, "swordHeader", DEFAULT_CONFIG, coerce, (m) => ctx.ui.notify(m, "warning"));
70
71
  if (!cfg.enabled) return;
71
72
  ctx.ui.setHeader((_tui, theme) => ({
72
- render(_width: number): string[] {
73
- return renderSwordLines(theme);
73
+ render(width: number): string[] {
74
+ return renderSwordLines(theme).map((line) => truncateToWidth(line, width, ""));
74
75
  },
75
76
  invalidate() {},
76
77
  }));
@@ -0,0 +1,93 @@
1
+ // Pi-free Herdr socket client for the session-name extension's tab sink.
2
+ // One-shot newline-delimited JSON RPC against Herdr's unix socket (protocol
3
+ // 20, herdr 0.8.2). Every failure mode resolves null: a dead, wedged, or
4
+ // absent Herdr must never break a session or stall shutdown.
5
+ // Responses are trusted structurally: result.tab/result.tabs shapes are not
6
+ // validated beyond presence, and the consumer treats their fields as
7
+ // Herdr-provided truth.
8
+ import net from "node:net";
9
+
10
+ export interface HerdrTab {
11
+ tab_id: string;
12
+ workspace_id: string;
13
+ label: string;
14
+ number: number;
15
+ }
16
+
17
+ export function isHerdrActive(
18
+ env: Record<string, string | undefined> = process.env,
19
+ isTTY: boolean = process.stdout.isTTY === true,
20
+ ): boolean {
21
+ return env.HERDR_ENV === "1" && !!env.HERDR_TAB_ID && !!env.HERDR_SOCKET_PATH && isTTY;
22
+ }
23
+
24
+ let nextRequestId = 1;
25
+
26
+ export function herdrRequest(
27
+ socketPath: string,
28
+ method: string,
29
+ params: Record<string, unknown>,
30
+ timeoutMs = 1500,
31
+ ): Promise<Record<string, unknown> | null> {
32
+ return new Promise((resolve) => {
33
+ const path = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
34
+ let settled = false;
35
+ const timer = setTimeout(() => done(null), timeoutMs);
36
+ const socket = net.createConnection({ path });
37
+ const done = (value: Record<string, unknown> | null) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ socket.destroy();
42
+ resolve(value);
43
+ };
44
+ socket.on("error", () => done(null));
45
+ socket.on("close", () => done(null));
46
+ socket.on("connect", () => {
47
+ socket.write(`${JSON.stringify({ id: String(nextRequestId++), method, params })}\n`);
48
+ });
49
+ let buffer = "";
50
+ socket.on("data", (chunk) => {
51
+ buffer += chunk.toString("utf8");
52
+ const newline = buffer.indexOf("\n");
53
+ if (newline === -1) return;
54
+ try {
55
+ const message = JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>;
56
+ const result = message.result;
57
+ if (!("error" in message) && result && typeof result === "object") {
58
+ done(result as Record<string, unknown>);
59
+ } else {
60
+ done(null);
61
+ }
62
+ } catch {
63
+ done(null);
64
+ }
65
+ });
66
+ });
67
+ }
68
+
69
+ export async function getTab(
70
+ socketPath: string,
71
+ tabId: string,
72
+ timeoutMs = 1500,
73
+ ): Promise<HerdrTab | null> {
74
+ const result = await herdrRequest(socketPath, "tab.get", { tab_id: tabId }, timeoutMs);
75
+ const tab = result?.tab;
76
+ return tab && typeof tab === "object" ? (tab as unknown as HerdrTab) : null;
77
+ }
78
+
79
+ export async function listTabs(socketPath: string, timeoutMs = 1500): Promise<HerdrTab[] | null> {
80
+ const result = await herdrRequest(socketPath, "tab.list", {}, timeoutMs);
81
+ const tabs = result?.tabs;
82
+ return Array.isArray(tabs) ? (tabs as unknown as HerdrTab[]) : null;
83
+ }
84
+
85
+ export async function renameTab(
86
+ socketPath: string,
87
+ tabId: string,
88
+ label: string,
89
+ timeoutMs = 1500,
90
+ ): Promise<true | null> {
91
+ const result = await herdrRequest(socketPath, "tab.rename", { tab_id: tabId, label }, timeoutMs);
92
+ return result ? true : null;
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "5.1.0",
3
+ "version": "5.2.1",
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",