pi-quiver 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +9 -7
- package/extensions/session-name.ts +21 -8
- package/extensions/slack.ts +14 -13
- package/lib/slack-cache.ts +32 -20
- package/lib/slack-core.ts +29 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,19 @@ 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
|
+
## v6.1.0 - 2026-09-15
|
|
12
|
+
|
|
13
|
+
- `slack`: tokens also resolve from a fixed per-user file - `$XDG_CONFIG_HOME/pi-quiver/.env` or `~/.config/pi-quiver/.env` (Linux/macOS), `%APPDATA%\pi-quiver\.env` (Windows) - after process env, the repo `.env`, and the primary checkout's `.env`. Every rung now falls through when it lacks the key; a repo `.env` holding only the bot token no longer blocks the user token. Breaking: an unreadable `.env` at any rung propagates the raw filesystem error instead of collapsing to `missing_token`; the `missing_token` message now lists every checked path (#22).
|
|
14
|
+
- `slack`: every `channel` parameter accepts `@name` or a user ID (`U...`/`W...`) and targets that person's DM - `resolveChannel` opens it via `conversations.open` (needs `im:write`; `slack_thread` DM reads need `im:history`) and tools echo the resulting `D...`. Cached display/real-name aliases are trusted only after a `slack_cache_refresh` snapshot (#20).
|
|
15
|
+
|
|
16
|
+
## v6.0.1 - 2026-09-13
|
|
17
|
+
|
|
18
|
+
- `session-name` Herdr sink tolerates herdr-ntfy-notify's armed marker: exactly one leading `* ` on the live label no longer counts as a human rename, is preserved on every rename and on the shutdown restore, and its removal keeps the claim (#19).
|
|
19
|
+
- `release.sh <level>` promotes the CHANGELOG `## Unreleased` section to `## vX.Y.Z - <date>` and commits it with `package.json` in the single `Release X.Y.Z` commit; a missing or empty section fails the run. New CONFIG field `CHANGELOG_HEADING`.
|
|
20
|
+
- Release skill and `/release` prompt: a user instruction naming the level is the approval - no proposal step or re-confirmation; bundled follow-ups run after `verify`.
|
|
21
|
+
- AGENTS.md rewritten to always-on essentials plus routing; shared core bumped to v3.
|
|
22
|
+
- Added `.pi/gauntlet-overrides.md` (`tracker: github`, release path, write-gate carve-out for user-named writes).
|
|
23
|
+
|
|
11
24
|
## v6.0.0 - 2026-09-10
|
|
12
25
|
|
|
13
26
|
- doc_to_md (Excel, breaking): `maxCellsPerSheet` is removed from the tool schema, CLI (`--max-cells-per-sheet`), and `quiver.docToMd` settings; a leftover key is reported by the settings lint. Sheet indices are now 0-based workbook positions covering worksheets and chartsheets, so embedded-image files move from `<stem>-s1-<n>.*` to `<stem>-s0-<n>.*` and `SheetInfo.index` is rebased.
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
|
|
|
70
70
|
| `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
|
|
71
71
|
| `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. |
|
|
72
72
|
| `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. |
|
|
73
|
-
| `extensions/slack.ts` | `slack_search`, `slack_thread`, `slack_post`, `slack_update`, `slack_delete`, `slack_pin`, `slack_upload`, `slack_cache_refresh` | Context-safe Slack search/threads/posting with dual `user`/`bot` token identities, a workspace-keyed channel/user name->ID cache, fetch-style output size gating, and a transactional headline+detail-thread announce protocol with a documented recovery path. OFF by default. Behavior lives in `lib/slack-core.ts` and `lib/slack-cache.ts`. |
|
|
73
|
+
| `extensions/slack.ts` | `slack_search`, `slack_thread`, `slack_post`, `slack_update`, `slack_delete`, `slack_pin`, `slack_upload`, `slack_cache_refresh` | Context-safe Slack search/threads/posting with dual `user`/`bot` token identities, a workspace-keyed channel/user name->ID cache, DM targets by `@name` / user ID, fetch-style output size gating, and a transactional headline+detail-thread announce protocol with a documented recovery path. OFF by default. Behavior lives in `lib/slack-core.ts` and `lib/slack-cache.ts`. |
|
|
74
74
|
|
|
75
75
|
Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md), [doc/doc-to-md.md](doc/doc-to-md.md), [doc/slack.md](doc/slack.md).
|
|
76
76
|
|
|
@@ -200,7 +200,7 @@ survive untouched.
|
|
|
200
200
|
|
|
201
201
|
`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.
|
|
202
202
|
|
|
203
|
-
`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.
|
|
203
|
+
`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. One exception: exactly one leading `* ` (herdr-ntfy-notify's armed marker) is not a human rename - it is preserved across renames and the shutdown restore, and removing it keeps the claim. 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.
|
|
204
204
|
|
|
205
205
|
`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. The `anthropic-beta` header is discovered at request time by probing pi's own request assembly (no network), so conditional betas pi adds - e.g. `server-side-fallback-2026-07-01` for models with server-side fallback - are preserved alongside `fast-mode-2026-02-01`. If the probe fails, the header falls back to the static OAuth-identity + fast-mode list. 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.
|
|
206
206
|
|
|
@@ -280,7 +280,7 @@ Operational notes:
|
|
|
280
280
|
| `botTokenEnv` | `SLACK_BOT_TOKEN` | Env var name holding the bot token. |
|
|
281
281
|
| `uploadThresholdChars` | `4000` | Link-collapsed length above which an announce/thread detail body is delivered as a file upload instead of inline text. |
|
|
282
282
|
|
|
283
|
-
Each setting can also be overridden per-process via `PI_QUIVER_SLACK_ENABLED`, `PI_QUIVER_SLACK_CACHE_PATH`, `PI_QUIVER_SLACK_USER_TOKEN_ENV`, `PI_QUIVER_SLACK_BOT_TOKEN_ENV`, and `PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS` - applied on top of the resolved `settings.json` layers, same override rung the extension's config resolver defines. `userTokenCommand` and `userTokenCommandTimeoutSeconds` are settings-only: pi executes the argv directly, without a shell, on every user-identity Slack tool call. For example, macOS Keychain can supply the token with `"userTokenCommand": ["security", "find-generic-password", "-s", "slack-user-token", "-w"]`. Its stdout is the token; empty output, nonzero exit, or timeout is a sanitized hard error and never falls back to `userTokenEnv`. Without the command, tokens resolve per call from process env
|
|
283
|
+
Each setting can also be overridden per-process via `PI_QUIVER_SLACK_ENABLED`, `PI_QUIVER_SLACK_CACHE_PATH`, `PI_QUIVER_SLACK_USER_TOKEN_ENV`, `PI_QUIVER_SLACK_BOT_TOKEN_ENV`, and `PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS` - applied on top of the resolved `settings.json` layers, same override rung the extension's config resolver defines. `userTokenCommand` and `userTokenCommandTimeoutSeconds` are settings-only: pi executes the argv directly, without a shell, on every user-identity Slack tool call. For example, macOS Keychain can supply the token with `"userTokenCommand": ["security", "find-generic-password", "-s", "slack-user-token", "-w"]`. Its stdout is the token; empty output, nonzero exit, or timeout is a sanitized hard error and never falls back to `userTokenEnv`. Without the command, tokens resolve per call from process env, then the repo `.env`, then the primary checkout's `.env` (linked worktrees), then a per-user `~/.config/pi-quiver/.env` (`$XDG_CONFIG_HOME` / `%APPDATA%` aware); a file lacking the key falls through to the next rung - see [doc/slack.md](doc/slack.md). Bot resolution is unchanged. Restart pi after changing Slack settings because the extension captures them at session start. Full reference incl. cache layering, the announce protocol, and the `search.messages`/`conversations.replies` throttle caveats: [doc/slack.md](doc/slack.md).
|
|
284
284
|
|
|
285
285
|
### doc_to_md settings
|
|
286
286
|
|
|
@@ -392,7 +392,9 @@ bash .agents/skills/release/scripts/release.sh patch # or minor / major
|
|
|
392
392
|
bash .agents/skills/release/scripts/release.sh --dry-run patch
|
|
393
393
|
```
|
|
394
394
|
|
|
395
|
-
It
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
395
|
+
It promotes the `## Unreleased` CHANGELOG section to `## vX.Y.Z - <date>`,
|
|
396
|
+
bumps `package.json`, commits `Release <version>`, runs the tests, creates and
|
|
397
|
+
pushes the `vX.Y.Z` tag, then monitors the publish. See
|
|
398
|
+
`.agents/skills/release/SKILL.md` for the full flow (`sync-presets --apply`
|
|
399
|
+
rewrites same-form `npm:pi-quiver@<old>` pins; git-tag pins are reported for
|
|
400
|
+
manual migration).
|
|
@@ -498,8 +498,10 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
498
498
|
|
|
499
499
|
// Herdr sink. Claim-once: adopt the tab only while it shows its default
|
|
500
500
|
// (position) label; a human rename - before or during the session - wins
|
|
501
|
-
// permanently.
|
|
502
|
-
//
|
|
501
|
+
// permanently. The one exception is exactly one leading "* ": that is
|
|
502
|
+
// herdr-ntfy-notify's armed marker, ignored for ownership and carried
|
|
503
|
+
// through on every write. All syncs serialize on one chain so overlapping
|
|
504
|
+
// hooks never interleave a read with a rename.
|
|
503
505
|
// Used both for turn_start syncs (bounds a wedged-but-accepting Herdr so it
|
|
504
506
|
// can't stall the turn) and for the session_end restore.
|
|
505
507
|
const HERDR_TIMEOUT_MS = 500;
|
|
@@ -511,6 +513,13 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
511
513
|
return siblings.findIndex((t) => t.tab_id === tabId) + 1;
|
|
512
514
|
};
|
|
513
515
|
|
|
516
|
+
const ARMED_PREFIX = "* ";
|
|
517
|
+
const matchOwned = (live: string, expected: string): { owned: boolean; armed: boolean } => {
|
|
518
|
+
if (live === expected) return { owned: true, armed: false };
|
|
519
|
+
if (live === ARMED_PREFIX + expected) return { owned: true, armed: true };
|
|
520
|
+
return { owned: false, armed: false };
|
|
521
|
+
};
|
|
522
|
+
|
|
514
523
|
const syncHerdrTab = (cfg: Config, label: string | null, mode: Mode | undefined): Promise<void> => {
|
|
515
524
|
const run = async (): Promise<void> => {
|
|
516
525
|
if (!cfg.herdrTab || mode !== "tui" || !label) return;
|
|
@@ -524,23 +533,25 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
524
533
|
const position = positionOf(tabs, tabId);
|
|
525
534
|
if (position === -1) return; // stale tab id (pane moved); retry harmlessly
|
|
526
535
|
const own = tabs.find((t) => t.tab_id === tabId)!;
|
|
527
|
-
|
|
536
|
+
const owned = matchOwned(own.label, String(position));
|
|
537
|
+
if (!owned.owned) {
|
|
528
538
|
herdrClaim = "backed-off"; // human (or crashed predecessor) owns it
|
|
529
539
|
return;
|
|
530
540
|
}
|
|
531
|
-
if (await renameTab(sock, tabId, label, HERDR_TIMEOUT_MS)) {
|
|
541
|
+
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) {
|
|
532
542
|
herdrClaim = { lastWritten: label };
|
|
533
543
|
}
|
|
534
544
|
return;
|
|
535
545
|
}
|
|
536
546
|
const live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
|
|
537
547
|
if (!live) return; // failed read is not a human rename; stay claimed
|
|
538
|
-
|
|
548
|
+
const owned = matchOwned(live.label, herdrClaim.lastWritten);
|
|
549
|
+
if (!owned.owned) {
|
|
539
550
|
herdrClaim = "backed-off";
|
|
540
551
|
return;
|
|
541
552
|
}
|
|
542
553
|
if (label === herdrClaim.lastWritten) return;
|
|
543
|
-
if (await renameTab(sock, tabId, label, HERDR_TIMEOUT_MS)) herdrClaim.lastWritten = label;
|
|
554
|
+
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) herdrClaim.lastWritten = label;
|
|
544
555
|
};
|
|
545
556
|
herdrChain = herdrChain.then(run, run);
|
|
546
557
|
return herdrChain;
|
|
@@ -555,12 +566,14 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
555
566
|
const sock = process.env.HERDR_SOCKET_PATH as string;
|
|
556
567
|
const tabId = process.env.HERDR_TAB_ID as string;
|
|
557
568
|
const live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
|
|
558
|
-
if (!live
|
|
569
|
+
if (!live) return;
|
|
570
|
+
const owned = matchOwned(live.label, herdrClaim.lastWritten);
|
|
571
|
+
if (!owned.owned) return; // human's label wins
|
|
559
572
|
const tabs = await listTabs(sock, HERDR_TIMEOUT_MS);
|
|
560
573
|
if (!tabs) return;
|
|
561
574
|
const position = positionOf(tabs, tabId);
|
|
562
575
|
if (position === -1) return;
|
|
563
|
-
await renameTab(sock, tabId, String(position), HERDR_TIMEOUT_MS);
|
|
576
|
+
await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + String(position), HERDR_TIMEOUT_MS);
|
|
564
577
|
};
|
|
565
578
|
herdrChain = herdrChain.then(run, run);
|
|
566
579
|
return herdrChain;
|
package/extensions/slack.ts
CHANGED
|
@@ -269,9 +269,9 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
269
269
|
label: "Slack Thread",
|
|
270
270
|
promptSnippet: "Read all replies in a Slack thread",
|
|
271
271
|
description:
|
|
272
|
-
'Read a Slack thread via conversations.replies. Always uses the "user" identity (no `as` param). Provide either `channel` (#name
|
|
272
|
+
'Read a Slack thread via conversations.replies. Always uses the "user" identity (no `as` param). Provide either `channel` (#name, channel ID, @name, or user ID (DM)) plus `ts`, or a `permalink` (parsed for channel+ts). Paginates by cursor until Slack reports no more replies or a cap of 50 pages / 5,000 messages is hit; the result carries a `complete` flag and a resumable `next_cursor` when capped. Caveat: since 2025-05-29, conversations.replies is rate-limited to ~1 request/minute (limit capped at 15) for apps that are neither Marketplace-listed nor classified internal - hitting that throttle mid-pagination returns the messages collected so far plus a caveat and a resumable cursor instead of spinning. Output is size-gated like slack_search.',
|
|
273
273
|
parameters: Type.Object({
|
|
274
|
-
channel: Type.Optional(Type.String({ description: "#name
|
|
274
|
+
channel: Type.Optional(Type.String({ description: "#name, channel ID, @name, or user ID (DM)" })),
|
|
275
275
|
ts: Type.Optional(Type.String({ description: "Thread parent timestamp" })),
|
|
276
276
|
permalink: Type.Optional(Type.String({ description: "A Slack message permalink URL to parse channel+ts from" })),
|
|
277
277
|
cursor: Type.Optional(Type.String({ description: "Resume pagination from a next_cursor returned by a prior capped call" })),
|
|
@@ -279,7 +279,8 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
279
279
|
async execute(_toolCallId, params, signal) {
|
|
280
280
|
return guarded(async () => {
|
|
281
281
|
const { deps, cacheCtx } = await resolveCall("user", cfg, ctx, signal, repoRoot);
|
|
282
|
-
const channel =
|
|
282
|
+
const channel =
|
|
283
|
+
params.permalink === undefined && params.channel !== undefined ? await resolveChannel(params.channel, cacheCtx) : undefined;
|
|
283
284
|
const result = await readThread({ channel, ts: params.ts, permalink: params.permalink, cursor: params.cursor }, deps);
|
|
284
285
|
return {
|
|
285
286
|
content: [{ type: "text" as const, text: threadResultText(result) }],
|
|
@@ -296,10 +297,10 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
296
297
|
label: "Slack Post",
|
|
297
298
|
promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
|
|
298
299
|
description:
|
|
299
|
-
"Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name
|
|
300
|
+
"Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name, channel ID, @name, or user ID (DM). 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.",
|
|
300
301
|
parameters: Type.Object({
|
|
301
302
|
as: IDENTITY,
|
|
302
|
-
channel: Type.String({ description: "#name
|
|
303
|
+
channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
|
|
303
304
|
text: Type.Optional(Type.String({ description: "Message text, or the announce headline when thread_body is set" })),
|
|
304
305
|
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
|
|
305
306
|
thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
|
|
@@ -378,10 +379,10 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
378
379
|
label: "Slack Update",
|
|
379
380
|
promptSnippet: "Edit an existing Slack message",
|
|
380
381
|
description:
|
|
381
|
-
'Edit a message via chat.update, as `as: "user"` or `as: "bot"`. `channel` accepts #name
|
|
382
|
+
'Edit a message via chat.update, as `as: "user"` or `as: "bot"`. `channel` accepts #name, channel ID, @name, or user ID (DM). Only the identity that originally posted the message can edit it (Slack constraint; surfaced as an error otherwise). Accepts `text` and/or `blocks` (Block Kit JSON, unvalidated).',
|
|
382
383
|
parameters: Type.Object({
|
|
383
384
|
as: IDENTITY,
|
|
384
|
-
channel: Type.String({ description: "#name
|
|
385
|
+
channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
|
|
385
386
|
ts: Type.String({ description: "Timestamp of the message to edit" }),
|
|
386
387
|
text: Type.Optional(Type.String()),
|
|
387
388
|
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
|
|
@@ -408,10 +409,10 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
408
409
|
label: "Slack Delete",
|
|
409
410
|
promptSnippet: "Delete a Slack message",
|
|
410
411
|
description:
|
|
411
|
-
'Delete a message via chat.delete, as `as: "user"` or `as: "bot"`. `channel` accepts #name
|
|
412
|
+
'Delete a message via chat.delete, as `as: "user"` or `as: "bot"`. `channel` accepts #name, channel ID, @name, or user ID (DM). Only the identity that originally posted the message can delete it (Slack constraint; surfaced as an error otherwise).',
|
|
412
413
|
parameters: Type.Object({
|
|
413
414
|
as: IDENTITY,
|
|
414
|
-
channel: Type.String({ description: "#name
|
|
415
|
+
channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
|
|
415
416
|
ts: Type.String({ description: "Timestamp of the message to delete" }),
|
|
416
417
|
}),
|
|
417
418
|
async execute(_toolCallId, params, signal) {
|
|
@@ -434,10 +435,10 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
434
435
|
label: "Slack Pin",
|
|
435
436
|
promptSnippet: "Pin a Slack message to its channel",
|
|
436
437
|
description:
|
|
437
|
-
'Pin a message via pins.add, as `as: "user"` or `as: "bot"`. `channel` accepts #name
|
|
438
|
+
'Pin a message via pins.add, as `as: "user"` or `as: "bot"`. `channel` accepts #name, channel ID, @name, or user ID (DM). Slack errors are mapped: already_pinned, not_pinnable (this message type cannot be pinned), too_many_pins (the channel hit Slack\'s pin limit).',
|
|
438
439
|
parameters: Type.Object({
|
|
439
440
|
as: IDENTITY,
|
|
440
|
-
channel: Type.String({ description: "#name
|
|
441
|
+
channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
|
|
441
442
|
ts: Type.String({ description: "Timestamp of the message to pin" }),
|
|
442
443
|
}),
|
|
443
444
|
async execute(_toolCallId, params, signal) {
|
|
@@ -460,10 +461,10 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
460
461
|
label: "Slack Upload",
|
|
461
462
|
promptSnippet: "Upload a file to a Slack channel or thread",
|
|
462
463
|
description:
|
|
463
|
-
'Upload a file to Slack (getUploadURLExternal -> upload -> completeUploadExternal), as `as: "user"` or `as: "bot"`. `channel` accepts #name
|
|
464
|
+
'Upload a file to Slack (getUploadURLExternal -> upload -> completeUploadExternal), as `as: "user"` or `as: "bot"`. `channel` accepts #name, channel ID, @name, or user ID (DM). `path` is an absolute path or resolved relative to the current working directory; a missing file errors before any network call. `filename` defaults to the path\'s basename. Optional `title`, `thread_ts` (attach to an existing thread), and `initial_comment`.',
|
|
464
465
|
parameters: Type.Object({
|
|
465
466
|
as: IDENTITY,
|
|
466
|
-
channel: Type.String({ description: "#name
|
|
467
|
+
channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
|
|
467
468
|
path: Type.String({ description: "Absolute path, or a path relative to the current working directory" }),
|
|
468
469
|
filename: Type.Optional(Type.String({ description: "Defaults to the basename of path" })),
|
|
469
470
|
title: Type.Optional(Type.String()),
|
package/lib/slack-cache.ts
CHANGED
|
@@ -119,13 +119,21 @@ function toUserEntry(u: SlackUser): UserEntry {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** DM conversations are opened per identity: Slack returns the existing D... for a repeat call, so this is idempotent and safe to retry. */
|
|
123
|
+
async function openDm(userId: string, ctx: CacheCtx): Promise<string> {
|
|
124
|
+
const data = await ctx.apiCall("conversations.open", ctx.token, { users: userId }, { retry: true, signal: ctx.signal });
|
|
125
|
+
const channel = data.channel as { id?: unknown } | undefined;
|
|
126
|
+
if (typeof channel?.id !== "string") {
|
|
127
|
+
throw new SlackError("unexpected_response", 'conversations.open returned an unexpected response: missing "channel.id".');
|
|
128
|
+
}
|
|
129
|
+
return channel.id;
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
export async function resolveChannel(input: string, ctx: CacheCtx): Promise<string> {
|
|
123
133
|
if (RAW_CHANNEL_ID.test(input)) return input;
|
|
124
|
-
if (input.startsWith("@")) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`"${input}" looks like a user name, which is not accepted in a channel position (opening a DM is out of scope).`,
|
|
128
|
-
);
|
|
134
|
+
if (RAW_USER_ID.test(input) || input.startsWith("@")) {
|
|
135
|
+
const userId = await resolveUser(input, ctx);
|
|
136
|
+
return openDm(userId, ctx);
|
|
129
137
|
}
|
|
130
138
|
const name = stripPrefix(input);
|
|
131
139
|
|
|
@@ -189,22 +197,26 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
|
|
|
189
197
|
const byUsername = cached.users[name];
|
|
190
198
|
if (byUsername) return byUsername.id;
|
|
191
199
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
// Same gate as resolveMentions' aliasTrusted: an alias match is conclusive only against a
|
|
201
|
+
// complete workspace snapshot; a partial cache may hold a different person with that alias.
|
|
202
|
+
if (cached.snapshot_at !== undefined) {
|
|
203
|
+
const displayMatches = Object.values(cached.users).filter((u) => u.display_name === name);
|
|
204
|
+
if (displayMatches.length === 1) return displayMatches[0].id;
|
|
205
|
+
if (displayMatches.length > 1) {
|
|
206
|
+
throw new SlackError(
|
|
207
|
+
"ambiguous_user",
|
|
208
|
+
`Multiple users have display name "${name}": ${displayMatches.map((u) => u.id).join(", ")}.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
200
211
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
212
|
+
const realMatches = Object.values(cached.users).filter((u) => u.real_name === name);
|
|
213
|
+
if (realMatches.length === 1) return realMatches[0].id;
|
|
214
|
+
if (realMatches.length > 1) {
|
|
215
|
+
throw new SlackError(
|
|
216
|
+
"ambiguous_user",
|
|
217
|
+
`Multiple users have real name "${name}": ${realMatches.map((u) => u.id).join(", ")}.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
208
220
|
}
|
|
209
221
|
}
|
|
210
222
|
|
package/lib/slack-core.ts
CHANGED
|
@@ -191,14 +191,25 @@ export function parseEnvFile(content: string): Map<string, string> {
|
|
|
191
191
|
return result;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
function
|
|
194
|
+
export function userConfigEnvPath(
|
|
195
|
+
env: Record<string, string | undefined>,
|
|
196
|
+
platform: NodeJS.Platform = process.platform,
|
|
197
|
+
): string | undefined {
|
|
198
|
+
if (platform === "win32") {
|
|
199
|
+
return env.APPDATA ? join(env.APPDATA, "pi-quiver", ".env") : undefined;
|
|
200
|
+
}
|
|
201
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi-quiver", ".env");
|
|
202
|
+
if (env.HOME) return join(env.HOME, ".config", "pi-quiver", ".env");
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readEnvFile(path: string): Map<string, string> | undefined {
|
|
195
207
|
try {
|
|
196
|
-
return parseEnvFile(readFileSync(
|
|
208
|
+
return parseEnvFile(readFileSync(path, "utf8"));
|
|
197
209
|
} catch (err) {
|
|
198
|
-
// Deliberate: a genuinely absent .env degrades to "token not found"
|
|
210
|
+
// Deliberate: a genuinely absent .env degrades to "token not found". Anything else (permissions,
|
|
211
|
+
// a directory at that path) is a misconfiguration the caller must see, so it propagates raw.
|
|
199
212
|
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
200
|
-
// Present but unreadable (e.g. permissions): re-thrown so callers treat it as present-but-empty,
|
|
201
|
-
// not absent - this must NOT authorize the primary-checkout fallback.
|
|
202
213
|
throw err;
|
|
203
214
|
}
|
|
204
215
|
}
|
|
@@ -208,45 +219,29 @@ export function resolveToken(
|
|
|
208
219
|
cfg: SlackConfig,
|
|
209
220
|
env: Record<string, string | undefined>,
|
|
210
221
|
repoRoot: string,
|
|
222
|
+
platform: NodeJS.Platform = process.platform,
|
|
211
223
|
): string {
|
|
212
224
|
const envVar = identity === "user" ? cfg.userTokenEnv : cfg.botTokenEnv;
|
|
213
225
|
|
|
214
226
|
const fromEnv = env[envVar];
|
|
215
227
|
if (fromEnv) return fromEnv;
|
|
216
228
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
repoEnvUnreadable = true;
|
|
223
|
-
}
|
|
229
|
+
const candidates = [join(repoRoot, ".env")];
|
|
230
|
+
const primaryRoot = primaryCheckoutRoot(repoRoot);
|
|
231
|
+
if (primaryRoot !== undefined && primaryRoot !== repoRoot) candidates.push(join(primaryRoot, ".env"));
|
|
232
|
+
const userPath = userConfigEnvPath(env, platform);
|
|
233
|
+
if (userPath !== undefined) candidates.push(userPath);
|
|
224
234
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
235
|
+
for (const path of candidates) {
|
|
236
|
+
const parsed = readEnvFile(path);
|
|
237
|
+
// An empty (after quote-strip) value is treated as missing, not a usable empty token.
|
|
238
|
+
const value = parsed?.get(envVar);
|
|
228
239
|
if (value) return value;
|
|
229
|
-
} else if (!repoEnvUnreadable) {
|
|
230
|
-
// File-level fallback only, per spec: this only triggers when the worktree root has no .env at
|
|
231
|
-
// all (ENOENT); an existing-but-unreadable local .env blocks the fallback just like an existing
|
|
232
|
-
// one that lacks this key.
|
|
233
|
-
const primaryRoot = primaryCheckoutRoot(repoRoot);
|
|
234
|
-
if (primaryRoot && primaryRoot !== repoRoot) {
|
|
235
|
-
// Best-effort: any error reading the primary .env (missing or otherwise) just means no fallback.
|
|
236
|
-
let primaryEnvFile: Map<string, string> | undefined;
|
|
237
|
-
try {
|
|
238
|
-
primaryEnvFile = readEnvFile(primaryRoot);
|
|
239
|
-
} catch {
|
|
240
|
-
primaryEnvFile = undefined;
|
|
241
|
-
}
|
|
242
|
-
const value = primaryEnvFile?.get(envVar);
|
|
243
|
-
if (value) return value;
|
|
244
|
-
}
|
|
245
240
|
}
|
|
246
241
|
|
|
247
242
|
throw new SlackError(
|
|
248
243
|
"missing_token",
|
|
249
|
-
`No Slack ${identity} token: env var ${envVar} is empty and no
|
|
244
|
+
`No Slack ${identity} token: env var ${envVar} is empty and no entry found in ${candidates.join(", ")}.`,
|
|
250
245
|
);
|
|
251
246
|
}
|
|
252
247
|
|
|
@@ -279,11 +274,12 @@ export async function resolveCredential(
|
|
|
279
274
|
cfg: SlackConfig,
|
|
280
275
|
env: Record<string, string | undefined>,
|
|
281
276
|
repoRoot: string,
|
|
277
|
+
platform: NodeJS.Platform = process.platform,
|
|
282
278
|
): Promise<string> {
|
|
283
279
|
if (identity === "user" && cfg.userTokenCommand) {
|
|
284
280
|
return runCredentialCommand(cfg.userTokenCommand, Math.ceil(cfg.userTokenCommandTimeoutSeconds * 1000));
|
|
285
281
|
}
|
|
286
|
-
return resolveToken(identity, cfg, env, repoRoot);
|
|
282
|
+
return resolveToken(identity, cfg, env, repoRoot, platform);
|
|
287
283
|
}
|
|
288
284
|
|
|
289
285
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|