pi-quiver 6.0.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,15 @@ 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.2.0 - 2026-09-15
12
+
13
+ - `slack_thread` flattens Block Kit blocks in its default rendering (blocks joined by ` / `, `[<type>]` for unknown types) and gains an opt-in `raw: true` mode returning thread messages as a pure JSON array for `slack_update` round-trips; both modes go through the existing size gate. (#21)
14
+
15
+ ## v6.1.0 - 2026-09-15
16
+
17
+ - `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).
18
+ - `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).
19
+
11
20
  ## v6.0.1 - 2026-09-13
12
21
 
13
22
  - `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).
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, Block Kit flattening and optional raw JSON output for threads, 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
 
@@ -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 and then `.env` (or the primary checkout's, for a worktree with none). 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).
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. `slack_thread` flattens Block Kit blocks by default and accepts `raw: true` for JSON output suitable for `slack_update` round-trips. Full reference incl. cache layering, the announce protocol, thread output modes, and the `search.messages`/`conversations.replies` throttle caveats: [doc/slack.md](doc/slack.md).
284
284
 
285
285
  ### doc_to_md settings
286
286
 
@@ -161,7 +161,8 @@ export function searchResultText(result: SearchResult): string {
161
161
  return `${result.output}\n\ntotal: ${result.total} | page: ${result.page} of ${result.pageCount}`;
162
162
  }
163
163
 
164
- export function threadResultText(result: ThreadResult): string {
164
+ export function threadResultText(result: ThreadResult, raw = false): string {
165
+ if (raw) return result.output;
165
166
  const lines = [result.output, "", `complete: ${result.complete}`];
166
167
  if (!result.complete && result.nextCursor) lines.push(`next_cursor: ${result.nextCursor}`);
167
168
  if (result.caveat) lines.push(result.caveat);
@@ -269,20 +270,25 @@ export default function slackExtension(pi: ExtensionAPI) {
269
270
  label: "Slack Thread",
270
271
  promptSnippet: "Read all replies in a Slack thread",
271
272
  description:
272
- 'Read a Slack thread via conversations.replies. Always uses the "user" identity (no `as` param). Provide either `channel` (#name or channel ID; user @names not accepted) 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
+ '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. Default output is one line per message; messages with Block Kit blocks render those blocks flattened (blocks joined by " / ") instead of the text fallback. `raw: true` is for block extraction and slack_update round-trips: it returns the thread messages as a pure JSON array (no status trailer - `complete`/`next_cursor` stay in the result details and are not visible in the content, so an incomplete raw thread is a partial array with no in-content signal: try JSON.parse, on failure read the file named in the truncation line, and use default mode when completeness matters). Both modes are size-gated like slack_search: over the cap the full output is written to a temp file.',
273
274
  parameters: Type.Object({
274
- channel: Type.Optional(Type.String({ description: "#name or channel ID (user @names not accepted)" })),
275
+ channel: Type.Optional(Type.String({ description: "#name, channel ID, @name, or user ID (DM)" })),
275
276
  ts: Type.Optional(Type.String({ description: "Thread parent timestamp" })),
276
277
  permalink: Type.Optional(Type.String({ description: "A Slack message permalink URL to parse channel+ts from" })),
277
278
  cursor: Type.Optional(Type.String({ description: "Resume pagination from a next_cursor returned by a prior capped call" })),
279
+ raw: Type.Optional(Type.Boolean({ description: "Return thread messages as a JSON array of raw message objects (blocks untouched) instead of compact lines" })),
278
280
  }),
279
281
  async execute(_toolCallId, params, signal) {
280
282
  return guarded(async () => {
281
283
  const { deps, cacheCtx } = await resolveCall("user", cfg, ctx, signal, repoRoot);
282
- const channel = params.channel !== undefined ? await resolveChannel(params.channel, cacheCtx) : undefined;
283
- const result = await readThread({ channel, ts: params.ts, permalink: params.permalink, cursor: params.cursor }, deps);
284
+ const channel =
285
+ params.permalink === undefined && params.channel !== undefined ? await resolveChannel(params.channel, cacheCtx) : undefined;
286
+ const result = await readThread(
287
+ { channel, ts: params.ts, permalink: params.permalink, cursor: params.cursor, raw: params.raw },
288
+ deps,
289
+ );
284
290
  return {
285
- content: [{ type: "text" as const, text: threadResultText(result) }],
291
+ content: [{ type: "text" as const, text: threadResultText(result, params.raw === true) }],
286
292
  details: result,
287
293
  };
288
294
  }, "user");
@@ -296,10 +302,10 @@ export default function slackExtension(pi: ExtensionAPI) {
296
302
  label: "Slack Post",
297
303
  promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
298
304
  description:
299
- "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.",
305
+ "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
306
  parameters: Type.Object({
301
307
  as: IDENTITY,
302
- channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
308
+ channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
303
309
  text: Type.Optional(Type.String({ description: "Message text, or the announce headline when thread_body is set" })),
304
310
  blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
305
311
  thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
@@ -378,10 +384,10 @@ export default function slackExtension(pi: ExtensionAPI) {
378
384
  label: "Slack Update",
379
385
  promptSnippet: "Edit an existing Slack message",
380
386
  description:
381
- 'Edit a message via chat.update, as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). 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).',
387
+ '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
388
  parameters: Type.Object({
383
389
  as: IDENTITY,
384
- channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
390
+ channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
385
391
  ts: Type.String({ description: "Timestamp of the message to edit" }),
386
392
  text: Type.Optional(Type.String()),
387
393
  blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
@@ -408,10 +414,10 @@ export default function slackExtension(pi: ExtensionAPI) {
408
414
  label: "Slack Delete",
409
415
  promptSnippet: "Delete a Slack message",
410
416
  description:
411
- 'Delete a message via chat.delete, as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). Only the identity that originally posted the message can delete it (Slack constraint; surfaced as an error otherwise).',
417
+ '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
418
  parameters: Type.Object({
413
419
  as: IDENTITY,
414
- channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
420
+ channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
415
421
  ts: Type.String({ description: "Timestamp of the message to delete" }),
416
422
  }),
417
423
  async execute(_toolCallId, params, signal) {
@@ -434,10 +440,10 @@ export default function slackExtension(pi: ExtensionAPI) {
434
440
  label: "Slack Pin",
435
441
  promptSnippet: "Pin a Slack message to its channel",
436
442
  description:
437
- 'Pin a message via pins.add, as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). Slack errors are mapped: already_pinned, not_pinnable (this message type cannot be pinned), too_many_pins (the channel hit Slack\'s pin limit).',
443
+ '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
444
  parameters: Type.Object({
439
445
  as: IDENTITY,
440
- channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
446
+ channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
441
447
  ts: Type.String({ description: "Timestamp of the message to pin" }),
442
448
  }),
443
449
  async execute(_toolCallId, params, signal) {
@@ -460,10 +466,10 @@ export default function slackExtension(pi: ExtensionAPI) {
460
466
  label: "Slack Upload",
461
467
  promptSnippet: "Upload a file to a Slack channel or thread",
462
468
  description:
463
- 'Upload a file to Slack (getUploadURLExternal -> upload -> completeUploadExternal), as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). `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`.',
469
+ '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
470
  parameters: Type.Object({
465
471
  as: IDENTITY,
466
- channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
472
+ channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
467
473
  path: Type.String({ description: "Absolute path, or a path relative to the current working directory" }),
468
474
  filename: Type.Optional(Type.String({ description: "Defaults to the basename of path" })),
469
475
  title: Type.Optional(Type.String()),
@@ -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
- throw new SlackError(
126
- "invalid_channel",
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
- const displayMatches = Object.values(cached.users).filter((u) => u.display_name === name);
193
- if (displayMatches.length === 1) return displayMatches[0].id;
194
- if (displayMatches.length > 1) {
195
- throw new SlackError(
196
- "ambiguous_user",
197
- `Multiple users have display name "${name}": ${displayMatches.map((u) => u.id).join(", ")}.`,
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
- const realMatches = Object.values(cached.users).filter((u) => u.real_name === name);
202
- if (realMatches.length === 1) return realMatches[0].id;
203
- if (realMatches.length > 1) {
204
- throw new SlackError(
205
- "ambiguous_user",
206
- `Multiple users have real name "${name}": ${realMatches.map((u) => u.id).join(", ")}.`,
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 readEnvFile(dir: string): Map<string, string> | undefined {
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(join(dir, ".env"), "utf8"));
208
+ return parseEnvFile(readFileSync(path, "utf8"));
197
209
  } catch (err) {
198
- // Deliberate: a genuinely absent .env degrades to "token not found", not a thrown error.
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
- let repoEnvFile: Map<string, string> | undefined;
218
- let repoEnvUnreadable = false;
219
- try {
220
- repoEnvFile = readEnvFile(repoRoot);
221
- } catch {
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
- if (repoEnvFile) {
226
- // An empty (after quote-strip) .env value is treated as missing, not a usable empty token.
227
- const value = repoEnvFile.get(envVar);
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 .env entry was found.`,
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
  /**
@@ -571,15 +567,107 @@ export async function searchMessages(
571
567
  return { ...gated, total, page, pageCount };
572
568
  }
573
569
 
570
+ function inlineElementText(element: unknown): string {
571
+ if (typeof element !== "object" || element === null) return "[unknown]";
572
+ const el = element as Record<string, unknown>;
573
+ const type = typeof el.type === "string" ? el.type : "unknown";
574
+ switch (type) {
575
+ case "text":
576
+ return typeof el.text === "string" ? el.text : "[text]";
577
+ case "user":
578
+ return typeof el.user_id === "string" ? `<@${el.user_id}>` : "[user]";
579
+ case "channel":
580
+ return typeof el.channel_id === "string" ? `<#${el.channel_id}>` : "[channel]";
581
+ case "link":
582
+ if (typeof el.text === "string" && el.text.length > 0) return el.text;
583
+ return typeof el.url === "string" ? el.url : "[link]";
584
+ case "emoji":
585
+ return typeof el.name === "string" ? `:${el.name}:` : "[emoji]";
586
+ default:
587
+ return `[${type}]`;
588
+ }
589
+ }
590
+
591
+ function richTextContainerText(element: unknown): string {
592
+ if (typeof element !== "object" || element === null) return "[unknown]";
593
+ const el = element as Record<string, unknown>;
594
+ const type = typeof el.type === "string" ? el.type : "unknown";
595
+ switch (type) {
596
+ case "rich_text_section":
597
+ case "rich_text_quote":
598
+ case "rich_text_preformatted":
599
+ return Array.isArray(el.elements) ? el.elements.map(inlineElementText).join("") : "";
600
+ case "rich_text_list":
601
+ return Array.isArray(el.elements)
602
+ ? el.elements.map(richTextContainerText).filter((s) => s.length > 0).join(" ")
603
+ : "";
604
+ default:
605
+ return `[${type}]`;
606
+ }
607
+ }
608
+
609
+ function contextElementText(element: unknown): string {
610
+ if (typeof element !== "object" || element === null) return "[unknown]";
611
+ const el = element as Record<string, unknown>;
612
+ const type = typeof el.type === "string" ? el.type : "unknown";
613
+ if (type === "plain_text" || type === "mrkdwn") {
614
+ return typeof el.text === "string" ? el.text : `[${type}]`;
615
+ }
616
+ return `[${type}]`;
617
+ }
618
+
619
+ function blockText(block: unknown): string {
620
+ if (typeof block !== "object" || block === null) return "[unknown]";
621
+ const b = block as Record<string, unknown>;
622
+ const type = typeof b.type === "string" ? b.type : "unknown";
623
+ switch (type) {
624
+ case "header": {
625
+ const text = (b.text as Record<string, unknown> | undefined)?.text;
626
+ return typeof text === "string" ? text : "";
627
+ }
628
+ case "section": {
629
+ const parts: string[] = [];
630
+ const text = (b.text as Record<string, unknown> | undefined)?.text;
631
+ if (typeof text === "string" && text.length > 0) parts.push(text);
632
+ if (Array.isArray(b.fields)) {
633
+ for (const field of b.fields) {
634
+ const fieldText = (field as Record<string, unknown> | undefined)?.text;
635
+ if (typeof fieldText === "string" && fieldText.length > 0) parts.push(fieldText);
636
+ }
637
+ }
638
+ return parts.join(" ");
639
+ }
640
+ case "context":
641
+ return Array.isArray(b.elements)
642
+ ? b.elements.map(contextElementText).filter((s) => s.length > 0).join(" ")
643
+ : "";
644
+ case "rich_text":
645
+ return Array.isArray(b.elements)
646
+ ? b.elements.map(richTextContainerText).filter((s) => s.length > 0).join(" ")
647
+ : "";
648
+ default:
649
+ return `[${type}]`;
650
+ }
651
+ }
652
+
653
+ export function renderBlocks(blocks: unknown[]): string {
654
+ return blocks
655
+ .map(blockText)
656
+ .filter((s) => s.length > 0)
657
+ .join(" / ")
658
+ .replace(/\r?\n/g, " ");
659
+ }
660
+
574
661
  function renderThreadLine(message: Record<string, unknown>): string {
575
662
  const author = typeof message.user === "string" ? message.user : String(message.username ?? "unknown");
576
663
  const ts = String(message.ts ?? "");
577
- const text = typeof message.text === "string" ? message.text.replace(/\r?\n/g, " ") : "";
578
- return `${author} | ${ts} | ${text}`;
664
+ const flattened = Array.isArray(message.blocks) && message.blocks.length > 0 ? renderBlocks(message.blocks) : "";
665
+ const body = (flattened.length > 0 ? flattened : typeof message.text === "string" ? message.text : "").replace(/\r?\n/g, " ");
666
+ return `${author} | ${ts} | ${body}`;
579
667
  }
580
668
 
581
669
  export async function readThread(
582
- args: { channel?: string; ts?: string; permalink?: string; cursor?: string },
670
+ args: { channel?: string; ts?: string; permalink?: string; cursor?: string; raw?: boolean },
583
671
  deps: CoreDeps,
584
672
  ): Promise<ThreadResult> {
585
673
  let channel = args.channel;
@@ -645,7 +733,7 @@ export async function readThread(
645
733
  cursor = fetchedCursor;
646
734
  }
647
735
 
648
- const rendered = messages.map(renderThreadLine).join("\n");
736
+ const rendered = args.raw === true ? JSON.stringify(messages, null, 2) : messages.map(renderThreadLine).join("\n");
649
737
  const gated = gateOutput(rendered, "thread");
650
738
  return { ...gated, complete, nextCursor, caveat, messageCount: messages.length };
651
739
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "6.0.1",
3
+ "version": "6.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",