pi-quiver 5.4.0 → 5.5.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,11 @@ 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.5.0 - 2026-09-10
12
+
13
+ - doc_to_md: rewrite primary-PDF image links correctly on Windows.
14
+ - slack: optionally resolve the user token through a credential command on every user-identity tool call, with a configurable timeout in seconds, allowing running pi sessions to use replaced credentials without leaking secrets on command failure or falling back to stale env tokens.
15
+
11
16
  ## v5.4.0 - 2026-09-10
12
17
 
13
18
  - Settings: one condensed `pi-quiver settings` warning (each distinct message
package/README.md CHANGED
@@ -274,11 +274,13 @@ Operational notes:
274
274
  | --- | --- | --- |
275
275
  | `enabled` | `false` | Master switch, checked at `session_start`; toggling takes effect next session. |
276
276
  | `cachePath` | user-scope per-OS cache dir | Overrides where the workspace-keyed channel/user name->ID cache file is written; relative paths resolve against the repo root. |
277
- | `userTokenEnv` | `SLACK_USER_TOKEN` | Env var name holding the user token (required for `slack_search`/`slack_thread`, no bot fallback). |
277
+ | `userTokenEnv` | `SLACK_USER_TOKEN` | Env var name holding the user token when `userTokenCommand` is unset (required for `slack_search`/`slack_thread`, no bot fallback). |
278
+ | `userTokenCommand` | unset | Executable argv array that prints the current user token to stdout; resolved on every user-identity tool call. |
279
+ | `userTokenCommandTimeoutSeconds` | `10` | Positive finite timeout in seconds for `userTokenCommand`. Increase this when an interactive credential helper may need authorization. |
278
280
  | `botTokenEnv` | `SLACK_BOT_TOKEN` | Env var name holding the bot token. |
279
281
  | `uploadThresholdChars` | `4000` | Link-collapsed length above which an announce/thread detail body is delivered as a file upload instead of inline text. |
280
282
 
281
- 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. Tokens themselves are resolved per call: process env first, then the repo's `.env` file (or the primary checkout's, for a worktree with none) - never a fallback across identities. 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 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).
282
284
 
283
285
  ### doc_to_md settings
284
286
 
@@ -19,6 +19,7 @@ import {
19
19
  buildPolicyBlock,
20
20
  discoverRepoRoot,
21
21
  resolveSlackConfig,
22
+ resolveCredential,
22
23
  resolveToken,
23
24
  searchMessages,
24
25
  readThread,
@@ -40,7 +41,7 @@ import {
40
41
  import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, resolveMentions, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
41
42
 
42
43
  const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
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.',
44
+ description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which credential source is used and whose name shows as the author.',
44
45
  });
45
46
 
46
47
  // Approved recovery fields only - never dump the raw Slack API response (err.data can carry it
@@ -75,12 +76,13 @@ async function resolveCall(
75
76
  signal: AbortSignal | undefined,
76
77
  repoRoot: string,
77
78
  ): Promise<ResolvedCall> {
78
- const token = resolveToken(identity, cfg, process.env, repoRoot);
79
+ const token = await resolveCredential(identity, cfg, process.env, repoRoot);
79
80
  const teamId = await teamIdFor(token, defaultApiCall, signal);
80
81
  const otherIdentity = identity === "user" ? "bot" : "user";
81
82
  const otherTeamId = await (async () => {
83
+ if (otherIdentity === "user" && cfg.userTokenCommand) return undefined;
82
84
  try {
83
- const otherToken = resolveToken(otherIdentity, cfg, process.env, repoRoot);
85
+ const otherToken = await resolveCredential(otherIdentity, cfg, process.env, repoRoot);
84
86
  return await teamIdFor(otherToken, defaultApiCall, signal);
85
87
  } catch {
86
88
  // Other identity's token can't be resolved/authenticated - best-effort
@@ -99,14 +101,14 @@ async function resolveCall(
99
101
 
100
102
  /**
101
103
  * slack_cache_refresh's identity pick: user token when present, else bot (spec "Cache" section).
102
- * Pure and network-free - resolveToken only reads env/.env - so it's unit-testable without a
103
- * transport seam.
104
+ * Pure and network-free - command configuration counts as a user credential without executing it.
104
105
  */
105
106
  export function pickCacheRefreshIdentity(
106
107
  cfg: SlackConfig,
107
108
  env: Record<string, string | undefined>,
108
109
  repoRoot: string,
109
110
  ): "user" | "bot" {
111
+ if (cfg.userTokenCommand) return "user";
110
112
  try {
111
113
  resolveToken("user", cfg, env, repoRoot);
112
114
  return "user";
@@ -47,7 +47,7 @@ export const QUIVER_CONFIG_KEYS: Record<string, readonly string[]> = {
47
47
  sessionAutoName: ["enabled", "ghosttyTab", "herdrTab", "rules", "deny", "revisitFirstTurn", "revisitEveryTurns"],
48
48
  swordHeader: ["enabled"],
49
49
  providerStallWatchdog: ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"],
50
- slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "botTokenEnv", "uploadThresholdChars"],
50
+ slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "userTokenCommand", "userTokenCommandTimeoutSeconds", "botTokenEnv", "uploadThresholdChars"],
51
51
  docToMd: DOC_TO_MD_OPTIONS.filter((o) => o.settable).map((o) => o.key),
52
52
  };
53
53
 
@@ -104,7 +104,7 @@ export function resolveConfig<T extends object>(
104
104
  cwd: string,
105
105
  key: string,
106
106
  defaults: T,
107
- coerce: (raw: unknown) => Partial<T> | undefined,
107
+ coerce: (raw: unknown, warn?: (message: string) => void) => Partial<T> | undefined,
108
108
  warn?: (message: string) => void,
109
109
  ): T {
110
110
  const cfg: T = { ...defaults };
@@ -119,7 +119,7 @@ export function resolveConfig<T extends object>(
119
119
  const hasFlat = Object.hasOwn(settings, key);
120
120
  if (!hasNested && !(hasFlat && LEGACY_FLAT_KEYS.has(key))) continue;
121
121
  const candidate = hasNested ? nested![key] : settings[key];
122
- const patch = coerce(candidate);
122
+ const patch = coerce(candidate, (message) => emitWarning(warn, message));
123
123
  if (patch) Object.assign(cfg, patch);
124
124
  else emitWarning(warn, `pi-quiver: "${key}" in ${path} has an unrecognized value; ignored.`);
125
125
  }
package/lib/slack-core.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * slack-local `PI_QUIVER_SLACK_*` env-var overlay applied on top.
10
10
  */
11
11
 
12
- import { execFileSync } from "node:child_process";
12
+ import { execFile, execFileSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
@@ -21,6 +21,8 @@ export interface SlackConfig {
21
21
  cachePath: string | undefined;
22
22
  policyPath: string | undefined;
23
23
  userTokenEnv: string;
24
+ userTokenCommand: string[] | undefined;
25
+ userTokenCommandTimeoutSeconds: number;
24
26
  botTokenEnv: string;
25
27
  uploadThresholdChars: number;
26
28
  }
@@ -30,6 +32,8 @@ export const DEFAULT_SLACK_CONFIG: SlackConfig = {
30
32
  cachePath: undefined,
31
33
  policyPath: undefined,
32
34
  userTokenEnv: "SLACK_USER_TOKEN",
35
+ userTokenCommand: undefined,
36
+ userTokenCommandTimeoutSeconds: 10,
33
37
  botTokenEnv: "SLACK_BOT_TOKEN",
34
38
  uploadThresholdChars: 4000,
35
39
  };
@@ -44,7 +48,9 @@ export class SlackError extends Error {
44
48
  }
45
49
  }
46
50
 
47
- export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
51
+ const MAX_TIMER_SECONDS = 2_147_483.647;
52
+
53
+ export function coerce(raw: unknown, warn?: (message: string) => void): Partial<SlackConfig> | undefined {
48
54
  if (typeof raw === "boolean") return { enabled: raw };
49
55
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
50
56
  const o = raw as Record<string, unknown>;
@@ -53,6 +59,28 @@ export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
53
59
  if (typeof o.cachePath === "string") patch.cachePath = o.cachePath;
54
60
  if (typeof o.policyPath === "string") patch.policyPath = o.policyPath;
55
61
  if (typeof o.userTokenEnv === "string") patch.userTokenEnv = o.userTokenEnv;
62
+ if (
63
+ Array.isArray(o.userTokenCommand) &&
64
+ typeof o.userTokenCommand[0] === "string" &&
65
+ o.userTokenCommand[0].length > 0 &&
66
+ o.userTokenCommand.every((part) => typeof part === "string")
67
+ ) {
68
+ patch.userTokenCommand = [...o.userTokenCommand];
69
+ }
70
+ if (o.userTokenCommandTimeoutSeconds !== undefined) {
71
+ if (
72
+ typeof o.userTokenCommandTimeoutSeconds === "number" &&
73
+ Number.isFinite(o.userTokenCommandTimeoutSeconds) &&
74
+ o.userTokenCommandTimeoutSeconds > 0 &&
75
+ o.userTokenCommandTimeoutSeconds <= MAX_TIMER_SECONDS
76
+ ) {
77
+ patch.userTokenCommandTimeoutSeconds = o.userTokenCommandTimeoutSeconds;
78
+ } else {
79
+ warn?.(
80
+ `pi-quiver: quiver.slack.userTokenCommandTimeoutSeconds must be a positive finite number no greater than ${MAX_TIMER_SECONDS}; ignored.`,
81
+ );
82
+ }
83
+ }
56
84
  if (typeof o.botTokenEnv === "string") patch.botTokenEnv = o.botTokenEnv;
57
85
  if (typeof o.uploadThresholdChars === "number" && Number.isInteger(o.uploadThresholdChars) && o.uploadThresholdChars > 0) {
58
86
  patch.uploadThresholdChars = o.uploadThresholdChars;
@@ -222,6 +250,42 @@ export function resolveToken(
222
250
  );
223
251
  }
224
252
 
253
+ function runCredentialCommand(command: string[], timeoutMs: number): Promise<string> {
254
+ return new Promise((resolve, reject) => {
255
+ const [file, ...args] = command;
256
+ execFile(file, args, { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, encoding: "utf8" }, (err, stdout) => {
257
+ if (err) {
258
+ const childError = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string | null };
259
+ if (childError.killed) {
260
+ reject(new SlackError("credential_command_failed", `Slack user credential command timed out after ${timeoutMs}ms.`));
261
+ return;
262
+ }
263
+ const status = typeof childError.code === "number" ? ` (status ${childError.code})` : "";
264
+ reject(new SlackError("credential_command_failed", `Slack user credential command failed${status}.`));
265
+ return;
266
+ }
267
+ const token = stdout.trim();
268
+ if (!token) {
269
+ reject(new SlackError("missing_token", "Slack user credential command returned no token."));
270
+ return;
271
+ }
272
+ resolve(token);
273
+ });
274
+ });
275
+ }
276
+
277
+ export async function resolveCredential(
278
+ identity: "user" | "bot",
279
+ cfg: SlackConfig,
280
+ env: Record<string, string | undefined>,
281
+ repoRoot: string,
282
+ ): Promise<string> {
283
+ if (identity === "user" && cfg.userTokenCommand) {
284
+ return runCredentialCommand(cfg.userTokenCommand, Math.ceil(cfg.userTokenCommandTimeoutSeconds * 1000));
285
+ }
286
+ return resolveToken(identity, cfg, env, repoRoot);
287
+ }
288
+
225
289
  /**
226
290
  * Data-plane transport: every Slack API call and file upload funnels through
227
291
  * these two injectable functions so tests script responses without network.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "5.4.0",
3
+ "version": "5.5.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",
@@ -21,6 +21,12 @@ HTML_IMAGE_RE = re.compile(
21
21
  )
22
22
 
23
23
 
24
+ def image_source_map(source, target, filename):
25
+ real = os.path.realpath(source)
26
+ return {source: target, source.replace("\\", "/"): target,
27
+ real: target, real.replace("\\", "/"): target, filename: target}
28
+
29
+
24
30
  def rewrite_image_destinations(md, sources):
25
31
  def markdown(match):
26
32
  dest = match.group(2) if match.group(2) is not None else match.group(3)
@@ -106,7 +112,7 @@ def mode_pdf_primary(o):
106
112
  dest = f"img{i}{os.path.splitext(f)[1].lower()}"
107
113
  source = os.path.join(tmp, f)
108
114
  target = f"p{n}/{dest}"
109
- sources.update({source: target, os.path.realpath(source): target, f: target})
115
+ sources.update(image_source_map(source, target, f))
110
116
  os.replace(source, os.path.join(d, dest))
111
117
  md = rewrite_image_destinations(md, sources)
112
118
  if not md.strip():