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 +8 -0
- package/README.md +5 -2
- package/extensions/session-name.ts +107 -11
- package/extensions/slack.ts +116 -14
- package/lib/herdr-tab.ts +93 -0
- package/lib/slack-cache.ts +222 -23
- package/lib/slack-core.ts +104 -11
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/extensions/slack.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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);
|
package/lib/herdr-tab.ts
ADDED
|
@@ -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/lib/slack-cache.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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] =
|
|
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
|
-
|
|
337
|
-
|
|
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: {
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 & {
|
|
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: {
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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(
|
|
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.
|
|
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",
|