pi-quiver 5.0.0 → 5.2.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,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.0 - 2026-09-01
12
+
13
+ - 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).
14
+
15
+ ## v5.1.0 - 2026-09-01
16
+
17
+ - `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).
18
+
11
19
  ## v5.0.0 - 2026-08-29
12
20
 
13
21
  - **New opt-in `slack` extension** (#7): eight `slack_*` tools (search, thread, post, update, delete, pin, upload, cache refresh) for context-safe Slack search/threads/posting. Dual `user`/`bot` token identities resolved per call from process env or the repo's `.env`, never cross-identity fallback. Workspace-keyed channel/user name->ID cache with a repo-overridable `cachePath`. Fetch-style output size gating on search/thread reads. `slack_post`'s `thread_body` (no `thread_ts`) posts a transactional headline+thread announce - oversized detail bodies upload as a file - with a documented recovery path on delivery failure. OFF by default; nested-only `quiver.slack` config, no legacy flat form. See [doc/slack.md](doc/slack.md).
package/README.md CHANGED
@@ -18,7 +18,7 @@ But the moment an agent does that, one `fetch` or PDF read can dump hundreds of
18
18
 
19
19
  `fetch` and `doc_to_md` bring real web pages, GitHub issues/PRs, and local PDF/DOCX/PPTX files into context - and every result is size-gated by construction: over 32 KB or 1000 lines spills to a temp file with a preview and a grep/read hint, so a single call can never flood the window. Ingestion is what makes data-driven work possible; the gate is what keeps it safe.
20
20
 
21
- `session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in ergonomics, recovery, and integration controls: session labeling, a themed startup header, Anthropic fast mode, semantic-stall recovery, and context-safe Slack search/threads/posting.
21
+ `session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in ergonomics, recovery, and integration controls: session labeling, a themed startup header, Anthropic fast mode, semantic-stall recovery, and context-safe Slack search/threads/posting with repo-policy injection, `@name` mention resolution, cached emails, and per-call unfurl control.
22
22
 
23
23
  ## Part of the pi agent toolkit
24
24
 
@@ -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.
@@ -16,6 +16,7 @@ import { basename, isAbsolute, join } from "node:path";
16
16
  import {
17
17
  defaultApiCall,
18
18
  defaultUploadBytes,
19
+ buildPolicyBlock,
19
20
  discoverRepoRoot,
20
21
  resolveSlackConfig,
21
22
  resolveToken,
@@ -26,6 +27,7 @@ import {
26
27
  deleteMessage,
27
28
  pinMessage,
28
29
  uploadFile,
30
+ formatUnresolvedSuffix,
29
31
  SlackError,
30
32
  type SlackConfig,
31
33
  type CoreDeps,
@@ -33,8 +35,9 @@ import {
33
35
  type AnnounceResult,
34
36
  type SearchResult,
35
37
  type ThreadResult,
38
+ type UnresolvedMention,
36
39
  } from "../lib/slack-core.ts";
37
- import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
40
+ import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, resolveMentions, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
38
41
 
39
42
  const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
40
43
  description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which token env var is used and whose name shows as the author.',
@@ -139,6 +142,19 @@ export function channelLine(result: MutationResult | (MutationResult & { fileId:
139
142
  return parts.join(" | ");
140
143
  }
141
144
 
145
+ /** Shared by slack_post/slack_update: the channelLine + unresolved-mentions suffix result shape. */
146
+ function mentionAwareResult(
147
+ result: MutationResult | AnnounceResult,
148
+ mentions: { unresolved: UnresolvedMention[]; lookupError?: string },
149
+ opts: { detailUploaded?: boolean } = {},
150
+ ) {
151
+ const suffix = formatUnresolvedSuffix(mentions.unresolved, { lookupError: mentions.lookupError, ...opts });
152
+ return {
153
+ content: [{ type: "text" as const, text: suffix ? `${channelLine(result)} | ${suffix}` : channelLine(result) }],
154
+ details: { ...result, ...(mentions.unresolved.length > 0 ? { unresolvedMentions: mentions.unresolved } : {}) },
155
+ };
156
+ }
157
+
142
158
  export function searchResultText(result: SearchResult): string {
143
159
  return `${result.output}\n\ntotal: ${result.total} | page: ${result.page} of ${result.pageCount}`;
144
160
  }
@@ -191,6 +207,36 @@ export default function slackExtension(pi: ExtensionAPI) {
191
207
 
192
208
  const repoRoot = discoverRepoRoot(ctx.cwd);
193
209
 
210
+ const policyPath = cfg.policyPath;
211
+ if (policyPath !== undefined) {
212
+ const resolvedPolicyPath = isAbsolute(policyPath) ? policyPath : join(repoRoot, policyPath);
213
+ let policyWarned = false;
214
+ const warnOnce = (eventCtx: typeof ctx, message: string): void => {
215
+ if (policyWarned) return;
216
+ policyWarned = true;
217
+ if (eventCtx.hasUI) eventCtx.ui.notify(message, "warning");
218
+ else console.warn(message);
219
+ };
220
+
221
+ pi.on("before_agent_start", async (event, eventCtx) => {
222
+ let block: string;
223
+ try {
224
+ const body = readFileSync(resolvedPolicyPath, "utf8");
225
+ if (body.trim() === "") {
226
+ warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} is empty; posting policy is unknown this session.`);
227
+ block = buildPolicyBlock({ source: policyPath, status: "empty" });
228
+ } else {
229
+ block = buildPolicyBlock({ source: policyPath, status: "ok", body });
230
+ }
231
+ } catch (err) {
232
+ const code = (err as { code?: string }).code ?? (err instanceof Error ? err.message : String(err));
233
+ warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} could not be read (${code}); posting policy is unknown this session.`);
234
+ block = buildPolicyBlock({ source: policyPath, status: "unreadable", code });
235
+ }
236
+ return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
237
+ });
238
+ }
239
+
194
240
  pi.registerTool({
195
241
  name: "slack_search",
196
242
  label: "Slack Search",
@@ -248,7 +294,7 @@ export default function slackExtension(pi: ExtensionAPI) {
248
294
  label: "Slack Post",
249
295
  promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
250
296
  description:
251
- "Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name or a channel ID (user @names not accepted). Plain post: `text` and/or `blocks` (Block Kit JSON, passed through unvalidated). Threaded reply: also set `thread_ts` - no headline is ever emitted, `thread_body` (or `text`) becomes the reply body. Announce mode: set `thread_body` WITHOUT `thread_ts` - posts a short single-line `text` headline, then posts `thread_body` as the first threaded reply in the same call; if `thread_body`'s rendered length exceeds the configured uploadThresholdChars (default 4000), it is delivered as a threaded file upload instead. Recovery: re-invoke with `thread_ts` set (never re-omit it) to post only into the existing thread - a second headline is never sent. On detail-delivery failure the headline is marked \"detail pending\" and the detail is saved to a temp file; the error names the path.",
297
+ "Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name or a channel ID (user @names not accepted). Plain post: `text` and/or `blocks` (Block Kit JSON, passed through unvalidated). Threaded reply: also set `thread_ts` - no headline is ever emitted, `thread_body` (or `text`) becomes the reply body. Announce mode: set `thread_body` WITHOUT `thread_ts` - posts a short single-line `text` headline, then posts `thread_body` as the first threaded reply in the same call; if `thread_body`'s rendered length exceeds the configured uploadThresholdChars (default 4000), it is delivered as a threaded file upload instead. Recovery: re-invoke with `thread_ts` set (never re-omit it) to post only into the existing thread - a second headline is never sent. On detail-delivery failure the headline is marked \"detail pending\" and the detail is saved to a temp file; the error names the path. `unfurl_links`/`unfurl_media` apply to this post only, are omitted when unset (Slack's default stands), and slack_update cannot change unfurling after the fact.",
252
298
  parameters: Type.Object({
253
299
  as: IDENTITY,
254
300
  channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
@@ -256,20 +302,69 @@ export default function slackExtension(pi: ExtensionAPI) {
256
302
  blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
257
303
  thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
258
304
  thread_body: Type.Optional(Type.String({ description: "Detail body for an announce headline, or the reply body when thread_ts is set" })),
305
+ unfurl_links: Type.Optional(
306
+ Type.Boolean({ description: "Slack unfurls link previews by default; pass false to suppress text-link previews for this message." }),
307
+ ),
308
+ unfurl_media: Type.Optional(
309
+ Type.Boolean({ description: "Pass false to suppress image/video previews for this message." }),
310
+ ),
259
311
  }),
260
312
  async execute(_toolCallId, params, signal) {
261
313
  return guarded(async () => {
262
314
  assertNoMarkdownText(params);
263
315
  const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
264
316
  const channel = await resolveChannel(params.channel, cacheCtx);
317
+
318
+ // Only the fields core will actually send: announce uses text + thread_body (both
319
+ // scanned), a threaded reply collapses to thread_body ?? text (whichever param carried
320
+ // the body is the one core reads, so substitute into that same field - never the
321
+ // other), a plain post is text alone.
322
+ const isAnnounce = params.thread_body !== undefined && params.thread_ts === undefined;
323
+ const isReply = params.thread_ts !== undefined;
324
+ let text: string | undefined;
325
+ let threadBody: string | undefined;
326
+ let mentions: { unresolved: UnresolvedMention[]; lookupError?: string };
327
+ if (isAnnounce) {
328
+ const resolved = await resolveMentions(
329
+ [
330
+ { field: "text", value: params.text ?? "" },
331
+ { field: "thread_body", value: params.thread_body ?? "" },
332
+ ],
333
+ cacheCtx,
334
+ );
335
+ mentions = resolved;
336
+ text = resolved.values[0];
337
+ threadBody = resolved.values[1];
338
+ } else if (isReply) {
339
+ const replyField: "text" | "thread_body" = params.thread_body !== undefined ? "thread_body" : "text";
340
+ const resolved = await resolveMentions([{ field: replyField, value: params.thread_body ?? params.text ?? "" }], cacheCtx);
341
+ mentions = resolved;
342
+ if (replyField === "thread_body") {
343
+ text = params.text;
344
+ threadBody = resolved.values[0];
345
+ } else {
346
+ text = params.text === undefined ? undefined : resolved.values[0];
347
+ threadBody = undefined;
348
+ }
349
+ } else {
350
+ const resolved = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
351
+ mentions = resolved;
352
+ text = params.text === undefined ? undefined : resolved.values[0];
353
+ }
354
+
265
355
  const result = await postMessage(
266
- { channel, text: params.text, blocks: params.blocks, thread_ts: params.thread_ts, thread_body: params.thread_body },
356
+ {
357
+ channel,
358
+ text,
359
+ blocks: params.blocks,
360
+ thread_ts: params.thread_ts,
361
+ thread_body: threadBody,
362
+ unfurl_links: params.unfurl_links,
363
+ unfurl_media: params.unfurl_media,
364
+ },
267
365
  { ...deps, thresholdChars: cfg.uploadThresholdChars, uploadBytes: defaultUploadBytes },
268
366
  );
269
- return {
270
- content: [{ type: "text" as const, text: channelLine(result) }],
271
- details: result,
272
- };
367
+ return mentionAwareResult(result, mentions, { detailUploaded: "detailUploaded" in result && result.detailUploaded === true });
273
368
  }, params.as);
274
369
  },
275
370
  renderCall: (args, theme) => oneLine(theme, "slack_post", `as:${args.as} ${args.channel}`),
@@ -294,11 +389,12 @@ export default function slackExtension(pi: ExtensionAPI) {
294
389
  assertNoMarkdownText(params);
295
390
  const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
296
391
  const channel = await resolveChannel(params.channel, cacheCtx);
297
- const result = await updateMessage({ channel, ts: params.ts, text: params.text, blocks: params.blocks }, deps);
298
- return {
299
- content: [{ type: "text" as const, text: channelLine(result) }],
300
- details: result,
301
- };
392
+ const mentions = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
393
+ const result = await updateMessage(
394
+ { channel, ts: params.ts, text: params.text === undefined ? undefined : mentions.values[0], blocks: params.blocks },
395
+ deps,
396
+ );
397
+ return mentionAwareResult(result, mentions);
302
398
  }, params.as);
303
399
  },
304
400
  renderCall: (args, theme) => oneLine(theme, "slack_update", `as:${args.as} ${args.channel} ts:${args.ts}`),
@@ -410,15 +506,21 @@ export default function slackExtension(pi: ExtensionAPI) {
410
506
  label: "Slack Cache Refresh",
411
507
  promptSnippet: "Rebuild the Slack channel/user name->ID cache",
412
508
  description:
413
- 'Rebuild the Slack channel and user name->ID cache from scratch (full conversations.list + users.list scan, atomic replace). Uses the "user" identity when a user token is configured, else falls back to "bot" (no `as` param). Run this after channels/users change or when a #name/@name lookup unexpectedly fails with name_not_found. Reports the resulting channel and user counts.',
509
+ 'Rebuild the Slack channel and user name->ID cache from scratch (full conversations.list + users.list scan, atomic replace). Uses the "user" identity when a user token is configured, else falls back to "bot" (no `as` param). Run this after channels/users change or when a #name/@name lookup unexpectedly fails with name_not_found. Reports the resulting channel, user, and email counts - a low email/user ratio hints the users:read.email scope may be missing.',
414
510
  parameters: Type.Object({}),
415
511
  async execute(_toolCallId, _params, signal) {
416
512
  const identity = pickCacheRefreshIdentity(cfg, process.env, repoRoot);
417
513
  return guarded(async () => {
418
514
  const { cacheCtx } = await resolveCall(identity, cfg, ctx, signal, repoRoot);
419
515
  const result = await refreshCache(cacheCtx);
516
+ const ratio =
517
+ result.users === 0
518
+ ? ""
519
+ : result.emails === 0
520
+ ? ` | emails: 0/${result.users} (users:read.email scope may be missing)`
521
+ : ` | emails: ${result.emails}/${result.users}`;
420
522
  return {
421
- content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}` }],
523
+ content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}${ratio}` }],
422
524
  details: result,
423
525
  };
424
526
  }, identity);
@@ -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
+ }
@@ -7,14 +7,24 @@
7
7
 
8
8
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { dirname, isAbsolute, join } from "node:path";
10
- import type { ApiCall, SlackConfig } from "./slack-core.ts";
10
+ import type { ApiCall, SlackConfig, UnresolvedMention } from "./slack-core.ts";
11
11
  import { SlackError } from "./slack-core.ts";
12
12
 
13
+ export interface UserEntry {
14
+ id: string;
15
+ display_name: string;
16
+ real_name: string;
17
+ email?: string;
18
+ }
19
+
13
20
  export interface SlackCacheFile {
14
21
  team_id: string;
15
22
  channels: Record<string, string>;
16
- users: Record<string, { id: string; display_name: string; real_name: string }>;
23
+ users: Record<string, UserEntry>;
17
24
  refreshed_at: string;
25
+ /** Set only by refreshCache's full replacement; presence means this file once held a complete
26
+ * workspace listing, which is the only state where an alias match may be trusted from cache. */
27
+ snapshot_at?: string;
18
28
  }
19
29
 
20
30
  export interface CacheCtx {
@@ -99,6 +109,16 @@ function stripPrefix(input: string): string {
99
109
  return input.startsWith("#") || input.startsWith("@") ? input.slice(1) : input;
100
110
  }
101
111
 
112
+ function toUserEntry(u: SlackUser): UserEntry {
113
+ const email = u.profile?.email;
114
+ return {
115
+ id: u.id,
116
+ display_name: u.profile?.display_name ?? "",
117
+ real_name: u.profile?.real_name ?? u.real_name ?? "",
118
+ ...(email ? { email } : {}),
119
+ };
120
+ }
121
+
102
122
  export async function resolveChannel(input: string, ctx: CacheCtx): Promise<string> {
103
123
  if (RAW_CHANNEL_ID.test(input)) return input;
104
124
  if (input.startsWith("@")) {
@@ -150,7 +170,7 @@ export async function resolveChannel(input: string, ctx: CacheCtx): Promise<stri
150
170
  interface SlackUser {
151
171
  id: string;
152
172
  name: string;
153
- profile?: { display_name?: string; real_name?: string };
173
+ profile?: { display_name?: string; real_name?: string; email?: string };
154
174
  real_name?: string;
155
175
  }
156
176
 
@@ -208,11 +228,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
208
228
  for (const u of members) {
209
229
  if (u.name === name) {
210
230
  mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
211
- base.users[name] = {
212
- id: u.id,
213
- display_name: u.profile?.display_name ?? "",
214
- real_name: u.profile?.real_name ?? u.real_name ?? "",
215
- };
231
+ base.users[name] = toUserEntry(u);
216
232
  });
217
233
  return u.id;
218
234
  }
@@ -231,11 +247,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
231
247
  if (displayCandidates.length === 1) {
232
248
  const u = displayCandidates[0];
233
249
  mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
234
- base.users[u.name] = {
235
- id: u.id,
236
- display_name: u.profile?.display_name ?? "",
237
- real_name: u.profile?.real_name ?? u.real_name ?? "",
238
- };
250
+ base.users[u.name] = toUserEntry(u);
239
251
  });
240
252
  return u.id;
241
253
  }
@@ -249,11 +261,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
249
261
  if (realCandidates.length === 1) {
250
262
  const u = realCandidates[0];
251
263
  mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
252
- base.users[u.name] = {
253
- id: u.id,
254
- display_name: u.profile?.display_name ?? "",
255
- real_name: u.profile?.real_name ?? u.real_name ?? "",
256
- };
264
+ base.users[u.name] = toUserEntry(u);
257
265
  });
258
266
  return u.id;
259
267
  }
@@ -267,16 +275,202 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
267
275
  throw new SlackError("name_not_found", `No user named "${name}" was found in the workspace.`);
268
276
  }
269
277
 
278
+ const MENTION_DENY = new Set(["here", "channel", "everyone"]);
279
+ const MENTION_BOUNDARY = new Set([" ", "\t", "\n", "\r", "(", "[", "*", "_", '"', "'"]);
280
+ // `_` is trimmed here even though it is also a boundary character: it is a legal username
281
+ // character too, so an italic-wrapped mention like `_@alice_` is otherwise unreachable - the
282
+ // trailing `_` must be stripped for the candidate to resolve.
283
+ const MENTION_TRAILING = /[.,;:!?)_]+$/;
284
+ const MENTION_SCAN = /(\\?)@([A-Za-z0-9._-]+)/g;
285
+
286
+ function isBoundary(text: string, atIndex: number): boolean {
287
+ if (atIndex === 0) return true;
288
+ return MENTION_BOUNDARY.has(text[atIndex - 1]);
289
+ }
290
+
291
+ /**
292
+ * Substitutes `@name` -> `<@U...>` across every field a mutation will actually send.
293
+ * Never throws for an unresolvable name: not-found, ambiguous, and a failed live lookup all
294
+ * leave the text literal and report it, because a mention is prose, not an addressed parameter.
295
+ */
296
+ export async function resolveMentions(
297
+ fields: { field: "text" | "thread_body"; value: string }[],
298
+ ctx: CacheCtx,
299
+ ): Promise<{
300
+ values: string[];
301
+ unresolved: UnresolvedMention[];
302
+ lookupError?: string;
303
+ }> {
304
+ interface Candidate {
305
+ field: "text" | "thread_body";
306
+ start: number;
307
+ end: number;
308
+ escaped: boolean;
309
+ /** [name, endOffsetForThatName] pairs, trimmed variant first so it wins the lookup race. */
310
+ lookups: [string, number][];
311
+ }
312
+
313
+ const perField: Candidate[][] = fields.map(() => []);
314
+ const wanted = new Set<string>();
315
+
316
+ fields.forEach((f, fi) => {
317
+ for (const m of f.value.matchAll(MENTION_SCAN)) {
318
+ const escaped = m[1] === "\\";
319
+ const at = m.index + m[1].length;
320
+ if (escaped) {
321
+ if (!isBoundary(f.value, m.index)) continue;
322
+ } else if (!isBoundary(f.value, at)) continue;
323
+
324
+ const raw = m[2];
325
+ const fullEnd = m.index + m[0].length;
326
+ const trimmed = raw.replace(MENTION_TRAILING, "");
327
+ // Check the deny list against both forms: `_@channel_`/`@here.` still carry the
328
+ // trailing punctuation stripped below, so a raw-only check misses them.
329
+ if (MENTION_DENY.has(raw) || MENTION_DENY.has(trimmed)) continue;
330
+ // A name that is entirely trailing-punctuation (e.g. `@...`) trims to "" - not a
331
+ // real candidate, so skip it rather than looking up an empty username.
332
+ if (trimmed === "") continue;
333
+ const trimmedEnd = fullEnd - (raw.length - trimmed.length);
334
+ const lookups: [string, number][] =
335
+ trimmed === raw ? [[raw, fullEnd]] : [[trimmed, trimmedEnd], [raw, fullEnd]];
336
+ perField[fi].push({ field: f.field, start: m.index, end: fullEnd, escaped, lookups });
337
+ if (!escaped) for (const [name] of lookups) wanted.add(name);
338
+ }
339
+ });
340
+
341
+ const cached = readCacheFile(ctx.filePath);
342
+ const resolved = new Map<string, string>();
343
+ const aliasTrusted = cached?.snapshot_at !== undefined;
344
+ // Names an aliasTrusted FULL snapshot already proved ambiguous: a live users.list call
345
+ // cannot un-ambiguate them, so they must not fall into `outstanding`.
346
+ const conclusivelyAmbiguous = new Set<string>();
347
+
348
+ if (cached) {
349
+ for (const name of wanted) {
350
+ const byUsername = cached.users[name];
351
+ if (byUsername) {
352
+ resolved.set(name, byUsername.id);
353
+ continue;
354
+ }
355
+ if (!aliasTrusted) continue;
356
+ const entries = Object.values(cached.users);
357
+ const display = entries.filter((u) => u.display_name === name);
358
+ if (display.length === 1) {
359
+ resolved.set(name, display[0].id);
360
+ continue;
361
+ }
362
+ if (display.length > 1) {
363
+ conclusivelyAmbiguous.add(name);
364
+ continue;
365
+ }
366
+ const real = entries.filter((u) => u.real_name === name);
367
+ if (real.length === 1) resolved.set(name, real[0].id);
368
+ else if (real.length > 1) conclusivelyAmbiguous.add(name);
369
+ }
370
+ }
371
+
372
+ const outstanding = [...wanted].filter((n) => !resolved.has(n) && !conclusivelyAmbiguous.has(n));
373
+ let lookupError: string | undefined;
374
+
375
+ if (outstanding.length > 0) {
376
+ try {
377
+ const found = new Map<string, SlackUser>();
378
+ const aliasHits = new Map<string, SlackUser[]>();
379
+ let cursor = "";
380
+ for (let page = 1; ; page++) {
381
+ if (page > MAX_LIST_PAGES) {
382
+ throw new SlackError(
383
+ "pagination_overflow",
384
+ `resolveMentions: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
385
+ );
386
+ }
387
+ const data = await ctx.apiCall(
388
+ "users.list",
389
+ ctx.token,
390
+ { limit: 1000, ...(cursor ? { cursor } : {}) },
391
+ { retry: false, signal: ctx.signal },
392
+ );
393
+ for (const u of (data.members as SlackUser[]) ?? []) {
394
+ for (const name of outstanding) {
395
+ if (u.name === name) found.set(name, u);
396
+ else if (u.profile?.display_name === name || (u.profile?.real_name ?? u.real_name) === name) {
397
+ aliasHits.set(name, [...(aliasHits.get(name) ?? []), u]);
398
+ }
399
+ }
400
+ }
401
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
402
+ const nextCursor = meta?.next_cursor ?? "";
403
+ if (nextCursor !== "" && nextCursor === cursor) break;
404
+ cursor = nextCursor;
405
+ if (!cursor) break;
406
+ }
407
+
408
+ const writes: SlackUser[] = [];
409
+ for (const name of outstanding) {
410
+ const exact = found.get(name);
411
+ const aliases = aliasHits.get(name) ?? [];
412
+ const pick = exact ?? (aliases.length === 1 ? aliases[0] : undefined);
413
+ if (!pick) continue;
414
+ resolved.set(name, pick.id);
415
+ writes.push(pick);
416
+ }
417
+ if (writes.length > 0) {
418
+ const teamId = cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal));
419
+ mergeAndWrite(ctx.filePath, teamId, (base) => {
420
+ for (const u of writes) base.users[u.name] = toUserEntry(u);
421
+ });
422
+ }
423
+ } catch (err) {
424
+ lookupError = err instanceof Error ? err.message : String(err);
425
+ }
426
+ }
427
+
428
+ const unresolved: UnresolvedMention[] = [];
429
+ const seenUnresolved = new Set<string>();
430
+ const values = fields.map((f, fi) => {
431
+ let out = "";
432
+ let cursor = 0;
433
+ for (const c of perField[fi]) {
434
+ out += f.value.slice(cursor, c.start);
435
+ const literal = f.value.slice(c.start, c.end);
436
+ if (c.escaped) {
437
+ out += literal.slice(1);
438
+ cursor = c.end;
439
+ } else {
440
+ const hit = c.lookups.find(([n]) => resolved.has(n));
441
+ if (hit !== undefined) {
442
+ out += `<@${resolved.get(hit[0])}>`;
443
+ cursor = hit[1];
444
+ } else {
445
+ out += literal;
446
+ const name = `@${c.lookups[0][0]}`;
447
+ // Documented contract (doc/slack.md): unresolvedMentions is deduplicated by
448
+ // (field, name), so "cc @bob @bob" reports @bob once, not once per occurrence.
449
+ const key = `${c.field}\u0000${name}`;
450
+ if (!seenUnresolved.has(key)) {
451
+ seenUnresolved.add(key);
452
+ unresolved.push({ field: c.field, name });
453
+ }
454
+ cursor = c.end;
455
+ }
456
+ }
457
+ }
458
+ return out + f.value.slice(cursor);
459
+ });
460
+
461
+ return { values, unresolved, ...(lookupError !== undefined ? { lookupError } : {}) };
462
+ }
463
+
270
464
  /**
271
465
  * Full snapshot replace, deliberately asymmetric with mergeAndWrite: refresh's job is to
272
466
  * atomically overwrite the whole file, so a concurrent mergeAndWrite write racing this one may
273
467
  * be clobbered (last-writer-wins). Accepted per spec (doc/specs/2026-08-29-gh-7-slack-extension.md
274
468
  * Cache section) - a clobbered merge self-heals on the next cache miss.
275
469
  */
276
- export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number }> {
470
+ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number; emails: number }> {
277
471
  const teamId = await teamIdFor(ctx.token, ctx.apiCall, ctx.signal);
278
472
  const channels: Record<string, string> = {};
279
- const users: Record<string, { id: string; display_name: string; real_name: string }> = {};
473
+ const users: Record<string, UserEntry> = {};
280
474
 
281
475
  let cursor = "";
282
476
  for (let page = 1; ; page++) {
@@ -319,7 +513,7 @@ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; u
319
513
  const data = await ctx.apiCall("users.list", ctx.token, { limit: 1000, ...(cursor ? { cursor } : {}) }, { retry: false, signal: ctx.signal });
320
514
  const members = (data.members as SlackUser[]) ?? [];
321
515
  for (const u of members) {
322
- users[u.name] = { id: u.id, display_name: u.profile?.display_name ?? "", real_name: u.profile?.real_name ?? u.real_name ?? "" };
516
+ users[u.name] = toUserEntry(u);
323
517
  }
324
518
  const meta = data.response_metadata as { next_cursor?: string } | undefined;
325
519
  const nextCursor = meta?.next_cursor ?? "";
@@ -333,6 +527,11 @@ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; u
333
527
  if (!cursor) break;
334
528
  }
335
529
 
336
- atomicWrite(ctx.filePath, { team_id: teamId, channels, users, refreshed_at: new Date().toISOString() });
337
- return { channels: Object.keys(channels).length, users: Object.keys(users).length };
530
+ const snapshotAt = new Date().toISOString();
531
+ atomicWrite(ctx.filePath, { team_id: teamId, channels, users, refreshed_at: snapshotAt, snapshot_at: snapshotAt });
532
+ return {
533
+ channels: Object.keys(channels).length,
534
+ users: Object.keys(users).length,
535
+ emails: Object.values(users).filter((u) => u.email !== undefined).length,
536
+ };
338
537
  }
package/lib/slack-core.ts CHANGED
@@ -19,6 +19,7 @@ import { resolveConfig } from "./extension-config.ts";
19
19
  export interface SlackConfig {
20
20
  enabled: boolean;
21
21
  cachePath: string | undefined;
22
+ policyPath: string | undefined;
22
23
  userTokenEnv: string;
23
24
  botTokenEnv: string;
24
25
  uploadThresholdChars: number;
@@ -27,6 +28,7 @@ export interface SlackConfig {
27
28
  export const DEFAULT_SLACK_CONFIG: SlackConfig = {
28
29
  enabled: false,
29
30
  cachePath: undefined,
31
+ policyPath: undefined,
30
32
  userTokenEnv: "SLACK_USER_TOKEN",
31
33
  botTokenEnv: "SLACK_BOT_TOKEN",
32
34
  uploadThresholdChars: 4000,
@@ -49,6 +51,7 @@ export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
49
51
  const patch: Partial<SlackConfig> = {};
50
52
  if (typeof o.enabled === "boolean") patch.enabled = o.enabled;
51
53
  if (typeof o.cachePath === "string") patch.cachePath = o.cachePath;
54
+ if (typeof o.policyPath === "string") patch.policyPath = o.policyPath;
52
55
  if (typeof o.userTokenEnv === "string") patch.userTokenEnv = o.userTokenEnv;
53
56
  if (typeof o.botTokenEnv === "string") patch.botTokenEnv = o.botTokenEnv;
54
57
  if (typeof o.uploadThresholdChars === "number" && Number.isInteger(o.uploadThresholdChars) && o.uploadThresholdChars > 0) {
@@ -635,7 +638,14 @@ async function withPermalink(deps: CoreDeps, channel: string, ts: string): Promi
635
638
  }
636
639
 
637
640
  export async function postPlain(
638
- args: { channel: string; text?: string; blocks?: unknown[]; thread_ts?: string },
641
+ args: {
642
+ channel: string;
643
+ text?: string;
644
+ blocks?: unknown[];
645
+ thread_ts?: string;
646
+ unfurl_links?: boolean;
647
+ unfurl_media?: boolean;
648
+ },
639
649
  deps: CoreDeps,
640
650
  ): Promise<MutationResult> {
641
651
  assertTextWithinLimit(args.text);
@@ -644,6 +654,8 @@ export async function postPlain(
644
654
  if (args.text !== undefined) params.text = args.text;
645
655
  if (args.blocks !== undefined) params.blocks = args.blocks;
646
656
  if (args.thread_ts !== undefined) params.thread_ts = args.thread_ts;
657
+ if (args.unfurl_links !== undefined) params.unfurl_links = args.unfurl_links;
658
+ if (args.unfurl_media !== undefined) params.unfurl_media = args.unfurl_media;
647
659
 
648
660
  const data = await deps.apiCall("chat.postMessage", deps.token, params, { retry: true, signal: deps.signal });
649
661
  const channel = typeof data.channel === "string" ? data.channel : args.channel;
@@ -740,6 +752,49 @@ export function linkCollapsedLength(text: string): number {
740
752
  return text.replace(/<([^|>]+)\|([^>]+)>/g, "$2").replace(/<[^>]+>/g, "x").length;
741
753
  }
742
754
 
755
+ function escapeAttr(value: string): string {
756
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
757
+ }
758
+
759
+ /** Pure: the handler in extensions/slack.ts owns readFileSync and maps the outcome to this input. */
760
+ export function buildPolicyBlock(input: {
761
+ source: string;
762
+ status: "ok" | "unreadable" | "empty";
763
+ body?: string;
764
+ code?: string;
765
+ }): string {
766
+ const source = escapeAttr(input.source);
767
+ if (input.status === "ok") {
768
+ return `<slack-policy source="${source}">\n${input.body ?? ""}</slack-policy>`;
769
+ }
770
+ const reason =
771
+ input.status === "empty"
772
+ ? `Configured Slack policy file is empty.`
773
+ : `Configured Slack policy file could not be read (${input.code ?? "unknown error"}).`;
774
+ return `<slack-policy source="${source}" status="${input.status}">\n${reason} Slack tools are available but the repository's posting policy is unknown - ask the operator before posting.\n</slack-policy>`;
775
+ }
776
+
777
+ export interface UnresolvedMention {
778
+ field: "text" | "thread_body";
779
+ name: string;
780
+ }
781
+
782
+ /** Pure: the `channelLine` suffix segment for unresolved mentions. Empty string means "append nothing". */
783
+ export function formatUnresolvedSuffix(
784
+ unresolved: UnresolvedMention[],
785
+ opts: { lookupError?: string; detailUploaded?: boolean },
786
+ ): string {
787
+ if (unresolved.length === 0) return "";
788
+ const names: string[] = [];
789
+ for (const u of unresolved) if (!names.includes(u.name)) names.push(u.name);
790
+ let suffix = `unresolved mentions: ${names.join(", ")}`;
791
+ if (opts.lookupError !== undefined) suffix += ` (lookup failed: ${opts.lookupError})`;
792
+ if (opts.detailUploaded && unresolved.some((u) => u.field === "thread_body")) {
793
+ suffix += ` (detail uploaded as a file - slack_update cannot repair it; repost to fix)`;
794
+ }
795
+ return suffix;
796
+ }
797
+
743
798
  export function persistDetail(body: string): string {
744
799
  const dir = join(tmpdir(), "pi-slack");
745
800
  mkdirSync(dir, { recursive: true });
@@ -763,6 +818,11 @@ function assertHeadline(text: string): void {
763
818
 
764
819
  export interface AnnounceResult extends MutationResult {
765
820
  detailTs?: string;
821
+ // Set only when the detail leg took the file-upload path (deliverDetailUpload) rather than the
822
+ // inline threaded chat.postMessage reply - detailTs alone can't distinguish the two, since both
823
+ // paths set it. slack_update can edit the headline or the "Detail attached." stub, never an
824
+ // uploaded file's contents, so callers need this to know a mention-repair edit won't reach it.
825
+ detailUploaded?: true;
766
826
  }
767
827
 
768
828
  async function deliverDetailUpload(
@@ -874,14 +934,23 @@ async function recoverFromDetailFailure(
874
934
 
875
935
  export async function announce(
876
936
  args: { channel: string; text: string; thread_body: string },
877
- deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
937
+ deps: CoreDeps & {
938
+ uploadBytes: UploadBytes;
939
+ thresholdChars: number;
940
+ persist?: (body: string) => string;
941
+ unfurl_links?: boolean;
942
+ unfurl_media?: boolean;
943
+ },
878
944
  ): Promise<AnnounceResult> {
879
945
  assertHeadline(args.text);
880
946
  const persist = deps.persist ?? persistDetail;
947
+ const unfurl: Record<string, unknown> = {};
948
+ if (deps.unfurl_links !== undefined) unfurl.unfurl_links = deps.unfurl_links;
949
+ if (deps.unfurl_media !== undefined) unfurl.unfurl_media = deps.unfurl_media;
881
950
 
882
951
  let headlineData: Record<string, unknown>;
883
952
  try {
884
- headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text }, { retry: false, signal: deps.signal });
953
+ headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text, ...unfurl }, { retry: false, signal: deps.signal });
885
954
  } catch (err) {
886
955
  if (err instanceof SlackError && err.code === "transport") {
887
956
  const { detailPath, note } = persistOrDescribe(args.thread_body, persist);
@@ -923,7 +992,7 @@ export async function announce(
923
992
  const causeMessage = err instanceof Error ? err.message : String(err);
924
993
  return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
925
994
  }
926
- return { channel, ts, permalink, warning, detailTs };
995
+ return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
927
996
  }
928
997
 
929
998
  let detailData: Record<string, unknown>;
@@ -931,14 +1000,14 @@ export async function announce(
931
1000
  detailData = await deps.apiCall(
932
1001
  "chat.postMessage",
933
1002
  deps.token,
934
- { channel, text: args.thread_body, thread_ts: ts },
1003
+ { channel, text: args.thread_body, thread_ts: ts, ...unfurl },
935
1004
  { retry: true, signal: deps.signal },
936
1005
  );
937
1006
  } catch (err) {
938
1007
  if (err instanceof SlackError && err.code === "msg_too_long") {
939
1008
  try {
940
1009
  const { detailTs } = await deliverDetailUpload(channel, ts, args.thread_body, deps);
941
- return { channel, ts, permalink, warning, detailTs };
1010
+ return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
942
1011
  } catch (uploadErr) {
943
1012
  const causeMessage = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
944
1013
  return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
@@ -953,11 +1022,22 @@ export async function announce(
953
1022
  }
954
1023
 
955
1024
  export async function postMessage(
956
- args: { channel: string; text?: string; blocks?: unknown[]; thread_ts?: string; thread_body?: string },
1025
+ args: {
1026
+ channel: string;
1027
+ text?: string;
1028
+ blocks?: unknown[];
1029
+ thread_ts?: string;
1030
+ thread_body?: string;
1031
+ unfurl_links?: boolean;
1032
+ unfurl_media?: boolean;
1033
+ },
957
1034
  deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
958
1035
  ): Promise<MutationResult | AnnounceResult> {
959
1036
  if (args.thread_body !== undefined && args.thread_ts === undefined) {
960
- return announce({ channel: args.channel, text: args.text ?? "", thread_body: args.thread_body }, deps);
1037
+ return announce(
1038
+ { channel: args.channel, text: args.text ?? "", thread_body: args.thread_body },
1039
+ { ...deps, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
1040
+ );
961
1041
  }
962
1042
 
963
1043
  if (args.thread_ts !== undefined) {
@@ -965,7 +1045,14 @@ export async function postMessage(
965
1045
  // the threshold/upload path only applies when composing plain mrkdwn from thread_body/text.
966
1046
  if (args.blocks !== undefined) {
967
1047
  return postPlain(
968
- { channel: args.channel, text: args.thread_body ?? args.text, blocks: args.blocks, thread_ts: args.thread_ts },
1048
+ {
1049
+ channel: args.channel,
1050
+ text: args.thread_body ?? args.text,
1051
+ blocks: args.blocks,
1052
+ thread_ts: args.thread_ts,
1053
+ unfurl_links: args.unfurl_links,
1054
+ unfurl_media: args.unfurl_media,
1055
+ },
969
1056
  deps,
970
1057
  );
971
1058
  }
@@ -980,7 +1067,10 @@ export async function postMessage(
980
1067
  // (server-side). This body is extension-composed detail, same as announce's detail leg, so
981
1068
  // it gets the same upload fallback instead of a bare throw.
982
1069
  try {
983
- return await postPlain({ channel: args.channel, text: body, thread_ts: args.thread_ts }, deps);
1070
+ return await postPlain(
1071
+ { channel: args.channel, text: body, thread_ts: args.thread_ts, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
1072
+ deps,
1073
+ );
984
1074
  } catch (err) {
985
1075
  if (err instanceof SlackError && (err.code === "text_too_long" || err.code === "msg_too_long")) {
986
1076
  const { detailTs } = await deliverDetailUploadOrPersist(args.channel, args.thread_ts, body, deps);
@@ -990,5 +1080,8 @@ export async function postMessage(
990
1080
  }
991
1081
  }
992
1082
 
993
- return postPlain({ channel: args.channel, text: args.text, blocks: args.blocks }, deps);
1083
+ return postPlain(
1084
+ { channel: args.channel, text: args.text, blocks: args.blocks, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
1085
+ deps,
1086
+ );
994
1087
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "5.0.0",
3
+ "version": "5.2.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",