pi-quiver 5.3.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,26 @@ 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
+
16
+ ## v5.4.0 - 2026-09-10
17
+
18
+ - Settings: one condensed `pi-quiver settings` warning (each distinct message
19
+ once per process) lists flat legacy keys in use, unknown `quiver.<block>`
20
+ names, and unknown fields inside
21
+ known blocks, with accepted names inline; unknown keys fall back to defaults.
22
+ Replaces the separate non-object-`quiver` and flat+nested-duplicate sentences;
23
+ a flat legacy key alone now warns (still honoured). Accepted names are the
24
+ exported `QUIVER_CONFIG_KEYS` registry in `lib/extension-config.ts` (#14).
25
+ - Settings warnings from `fast-mode`, `session-name`, and `slack` reach stderr
26
+ when no UI is bound (print/json modes) instead of being dropped by the no-op
27
+ headless `notify`.
28
+ - doc_to_md: the coercer no longer warns about unknown `quiver.docToMd` keys (the
29
+ settings lint does); the `pi-quiver` CLI reader warns about them itself.
30
+
11
31
  ## v5.3.0 - 2026-09-09
12
32
 
13
33
  - doc_to_md: results are now a bundle on disk (`<stem>.md` + `images/`, temp dir
package/README.md CHANGED
@@ -173,6 +173,22 @@ legacy keys, frozen at `fastMode`, `sessionAutoName`, `swordHeader`, and
173
173
  `providerStallWatchdog` - never extended to new settings (see [Migrating from
174
174
  flat keys](#migrating-from-flat-keys)).
175
175
 
176
+ pi-quiver lints the whole `quiver` object in both files each time a setting is
177
+ resolved and emits **one** condensed warning (TUI: a `Warning:` block in the
178
+ chat; headless: stderr) listing every flat legacy key in use, every unknown
179
+ `quiver.<block>`, and every unknown field inside a known block, with the
180
+ accepted names inline. Unknown keys fall back to their defaults. Each distinct
181
+ message fires once per pi process; a clean file emits nothing. A wrong value
182
+ type (`"fastMode": "yes"`) is a separate per-key `unrecognized value` warning.
183
+ The accepted names live in `QUIVER_CONFIG_KEYS` in `lib/extension-config.ts` -
184
+ a new setting is registered there or it warns as unknown.
185
+
186
+ ```text
187
+ Warning: pi-quiver settings (/Users/x/.pi/agent/settings.json): unknown or misplaced keys - unknown ones fall back to defaults
188
+ "providerStallWatchdog" at top level - move under "quiver"
189
+ "quiver.providerStallWatchdog.timeoutMs" - unknown; accepted: enabled, firstEventMs, warningMs, recoveryMs, maxStallRetries
190
+ ```
191
+
176
192
  Worked mixed-shape example: global `settings.json` has flat
177
193
  `"fastMode": false`, project `.pi/settings.json` has
178
194
  `"quiver": { "fastMode": { "enabled": true } }`. The project layer's
@@ -258,11 +274,13 @@ Operational notes:
258
274
  | --- | --- | --- |
259
275
  | `enabled` | `false` | Master switch, checked at `session_start`; toggling takes effect next session. |
260
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. |
261
- | `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. |
262
280
  | `botTokenEnv` | `SLACK_BOT_TOKEN` | Env var name holding the bot token. |
263
281
  | `uploadThresholdChars` | `4000` | Link-collapsed length above which an announce/thread detail body is delivered as a file upload instead of inline text. |
264
282
 
265
- 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).
266
284
 
267
285
  ### doc_to_md settings
268
286
 
@@ -304,13 +322,12 @@ existing keys under `"quiver": { ... }` and delete the flat copies:
304
322
  { "quiver": { "fastMode": true } }
305
323
  ```
306
324
 
307
- Until you delete the flat copy, having both set is not an error - the
308
- duplicate resolves per the precedence above (nested wins within a layer) -
309
- but it emits a warning notification, deduped per process (each unique
310
- message fires at most once per pi process - in practice once per interactive
311
- session) until the flat entry is removed. Every new pi-quiver setting introduced after this change
312
- (for example a future `slack` key) is nested-only from day one: it has no
313
- flat form to fall back to.
325
+ A flat copy keeps resolving (nested wins within a layer, project layer wins
326
+ across layers) but every flat legacy key in use is listed in the condensed
327
+ settings warning above (`"fastMode" at top level - move under "quiver"`) until
328
+ it is moved. Every new pi-quiver setting introduced after this change (for
329
+ example `slack`) is nested-only from day one: it has no flat form to fall back
330
+ to, and a flat `slack` block is ignored without a warning.
314
331
 
315
332
  ## Claude Code support
316
333
 
@@ -796,10 +796,7 @@ function coerceDocToMdSettings(raw, warn = console.warn) {
796
796
  const out = {};
797
797
  for (const [k, v] of Object.entries(raw)) {
798
798
  const d = DOC_TO_MD_OPTIONS.find((o) => o.key === k);
799
- if (!d || !d.settable) {
800
- warn(`pi-quiver: quiver.docToMd.${k} is not a tunable setting; ignored.`);
801
- continue;
802
- }
799
+ if (!d || !d.settable) continue;
803
800
  const c = coerceValue(d, v);
804
801
  if (!c.ok) {
805
802
  warn(`pi-quiver: quiver.docToMd.${k} ${c.reason}; ignored.`);
@@ -1406,6 +1403,10 @@ function readCliSettings(cwd, env, warn) {
1406
1403
  continue;
1407
1404
  }
1408
1405
  if (raw === void 0) continue;
1406
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1407
+ const unknown = Object.keys(raw).filter((k) => !DOC_TO_MD_OPTIONS.some((o) => o.key === k && o.settable));
1408
+ if (unknown.length > 0) warn(`pi-quiver: quiver.docToMd in ${file} has keys that are not tunable settings; ignored: ${unknown.join(", ")}`);
1409
+ }
1409
1410
  const patch = coerceDocToMdSettings(raw, warn);
1410
1411
  if (patch) Object.assign(out, patch);
1411
1412
  else warn(`pi-quiver: "docToMd" in ${file} has an unrecognized value; ignored.`);
@@ -65,7 +65,7 @@ const STATUS_KEY = "fast-mode";
65
65
  const BETA_HEADER = "anthropic-beta";
66
66
 
67
67
  type Config = { enabled: boolean };
68
- const DEFAULT_CONFIG: Config = { enabled: false };
68
+ export const DEFAULT_CONFIG: Config = { enabled: false };
69
69
 
70
70
  export function coerce(raw: unknown): Partial<Config> | undefined {
71
71
  if (raw === undefined) return undefined;
@@ -189,7 +189,7 @@ export default function (pi: ExtensionAPI) {
189
189
  const readFlag = (): boolean => pi.getFlag("fast") === true;
190
190
 
191
191
  const resolveState = (ctx: ExtensionContext): boolean => {
192
- const config = resolveConfig(ctx.cwd, "fastMode", DEFAULT_CONFIG, coerce, (m) => ctx.ui.notify(m, "warning")).enabled;
192
+ const config = resolveConfig(ctx.cwd, "fastMode", DEFAULT_CONFIG, coerce, (m) => ctx.hasUI ? ctx.ui.notify(m, "warning") : console.warn(m)).enabled;
193
193
  enabled = resolveEnabled({ config, flag: readFlag(), live: liveOverride });
194
194
  return enabled;
195
195
  };
@@ -64,7 +64,7 @@ type Config = {
64
64
  revisitFirstTurn: number;
65
65
  revisitEveryTurns: number;
66
66
  };
67
- const DEFAULT_CONFIG: Config = {
67
+ export const DEFAULT_CONFIG: Config = {
68
68
  enabled: false,
69
69
  ghosttyTab: true,
70
70
  herdrTab: true,
@@ -105,7 +105,7 @@ export function coerce(raw: unknown): Partial<Config> | undefined {
105
105
  }
106
106
 
107
107
  function loadConfig(ctx: ExtensionContext): Config {
108
- return resolveConfig(ctx.cwd, "sessionAutoName", DEFAULT_CONFIG, coerce, (m) => ctx.ui.notify(m, "warning"));
108
+ return resolveConfig(ctx.cwd, "sessionAutoName", DEFAULT_CONFIG, coerce, (m) => ctx.hasUI ? ctx.ui.notify(m, "warning") : console.warn(m));
109
109
  }
110
110
 
111
111
  type ContentBlock = { type?: string; text?: string };
@@ -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";
@@ -200,7 +202,7 @@ export default function slackExtension(pi: ExtensionAPI) {
200
202
  let registered = false;
201
203
 
202
204
  pi.on("session_start", async (_event, ctx) => {
203
- const cfg = resolveSlackConfig(ctx.cwd, process.env, (m) => ctx.ui.notify(m, "warning"));
205
+ const cfg = resolveSlackConfig(ctx.cwd, process.env, (m) => ctx.hasUI ? ctx.ui.notify(m, "warning") : console.warn(m));
204
206
  if (cfg.enabled !== true) return;
205
207
  if (registered) return;
206
208
  registered = true;
@@ -18,7 +18,7 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
18
18
  import { resolveConfig } from "../lib/extension-config.ts";
19
19
 
20
20
  type Config = { enabled: boolean };
21
- const DEFAULT_CONFIG: Config = { enabled: false };
21
+ export const DEFAULT_CONFIG: Config = { enabled: false };
22
22
 
23
23
  export function coerce(raw: unknown): Partial<Config> | undefined {
24
24
  if (raw === undefined) return undefined;
@@ -105,13 +105,13 @@ function coerceValue(d: OptionDescriptor, raw: unknown, fromString = false): { o
105
105
  }
106
106
  }
107
107
 
108
- /** Single validation boundary for `quiver.docToMd`: unknown/ill-typed/intent keys are dropped with one warning each. */
108
+ /** Type-validation boundary for `quiver.docToMd`: ill-typed keys are dropped with one warning each; unknown/intent keys are skipped (the settings lint reports them). */
109
109
  export function coerceDocToMdSettings(raw: unknown, warn: (message: string) => void = console.warn): Partial<Tunables> | undefined {
110
110
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
111
111
  const out: Record<string, unknown> = {};
112
112
  for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
113
113
  const d = DOC_TO_MD_OPTIONS.find((o) => o.key === k);
114
- if (!d || !d.settable) { warn(`pi-quiver: quiver.docToMd.${k} is not a tunable setting; ignored.`); continue; }
114
+ if (!d || !d.settable) continue;
115
115
  const c = coerceValue(d, v);
116
116
  if (!c.ok) { warn(`pi-quiver: quiver.docToMd.${k} ${c.reason}; ignored.`); continue; }
117
117
  out[k] = c.value;
@@ -20,6 +20,7 @@
20
20
  import { readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
+ import { DOC_TO_MD_OPTIONS } from "./doc-to-md-options.ts";
23
24
 
24
25
  export function readSettings(path: string): Record<string, unknown> | undefined {
25
26
  try {
@@ -36,6 +37,22 @@ export function settingsPaths(cwd: string): string[] {
36
37
  /** Flat top-level fallback is frozen to these pre-quiver keys; never extend. */
37
38
  const LEGACY_FLAT_KEYS = new Set(["fastMode", "sessionAutoName", "swordHeader", "providerStallWatchdog"]);
38
39
 
40
+ /**
41
+ * Lint allowlist: every pi-quiver settings block and its fields. A key read
42
+ * by an extension but missing here is reported as unknown - register new
43
+ * keys here first.
44
+ */
45
+ export const QUIVER_CONFIG_KEYS: Record<string, readonly string[]> = {
46
+ fastMode: ["enabled"],
47
+ sessionAutoName: ["enabled", "ghosttyTab", "herdrTab", "rules", "deny", "revisitFirstTurn", "revisitEveryTurns"],
48
+ swordHeader: ["enabled"],
49
+ providerStallWatchdog: ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"],
50
+ slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "userTokenCommand", "userTokenCommandTimeoutSeconds", "botTokenEnv", "uploadThresholdChars"],
51
+ docToMd: DOC_TO_MD_OPTIONS.filter((o) => o.settable).map((o) => o.key),
52
+ };
53
+
54
+ const isPlainObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
55
+
39
56
  const emittedWarnings = new Set<string>();
40
57
 
41
58
  function emitWarning(warn: ((message: string) => void) | undefined, message: string): void {
@@ -44,49 +61,68 @@ function emitWarning(warn: ((message: string) => void) | undefined, message: str
44
61
  warn(message);
45
62
  }
46
63
 
64
+ /** Structural findings for one parsed settings file: flat legacy use, non-object quiver, unknown blocks, unknown fields. */
65
+ function lintSettings(settings: Record<string, unknown>): string[] {
66
+ const lines: string[] = [];
67
+ for (const key of Object.keys(settings)) if (LEGACY_FLAT_KEYS.has(key)) lines.push(`"${key}" at top level - move under "quiver"`);
68
+ const root = settings.quiver;
69
+ if (root === undefined) return lines;
70
+ if (!isPlainObject(root)) return [...lines, `"quiver" is not an object - ignored`];
71
+ const fieldLines: string[] = [];
72
+ for (const [block, value] of Object.entries(root)) {
73
+ if (!Object.hasOwn(QUIVER_CONFIG_KEYS, block)) {
74
+ lines.push(`"quiver.${block}" - unknown block; accepted: ${Object.keys(QUIVER_CONFIG_KEYS).join(", ")}`);
75
+ continue;
76
+ }
77
+ if (!isPlainObject(value)) continue;
78
+ const fields = QUIVER_CONFIG_KEYS[block];
79
+ for (const field of Object.keys(value)) if (!fields.includes(field)) fieldLines.push(`"quiver.${block}.${field}" - unknown; accepted: ${fields.join(", ")}`);
80
+ }
81
+ return [...lines, ...fieldLines];
82
+ }
83
+
84
+ function formatLint(findings: Array<[path: string, lines: string[]]>): string {
85
+ const tail = "unknown or misplaced keys - unknown ones fall back to defaults";
86
+ if (findings.length === 1) {
87
+ const [path, lines] = findings[0];
88
+ return [`pi-quiver settings (${path}): ${tail}`, ...lines.map((l) => ` ${l}`)].join("\n");
89
+ }
90
+ return [`pi-quiver settings: ${tail}`, ...findings.flatMap(([path, lines]) => [` ${path}`, ...lines.map((l) => ` ${l}`)])].join("\n");
91
+ }
92
+
47
93
  /**
48
94
  * Resolve a single extension config key across the settings layers.
49
95
  * `quiver.<key>` wins over flat `<key>` within a layer by presence (even
50
96
  * when malformed); flat fallback exists only for LEGACY_FLAT_KEYS. `coerce`
51
97
  * validates the layer's candidate into a partial patch (or `undefined` to
52
98
  * skip); patches merge over `defaults` in layer order (project wins).
53
- * `warn` receives one sentence per malformed or flat/nested-duplicated key,
54
- * deduped per process.
99
+ * `warn` receives one sentence per malformed key plus one condensed
100
+ * structural lint (flat legacy use, unknown blocks, unknown fields) - each
101
+ * distinct message once per process.
55
102
  */
56
103
  export function resolveConfig<T extends object>(
57
104
  cwd: string,
58
105
  key: string,
59
106
  defaults: T,
60
- coerce: (raw: unknown) => Partial<T> | undefined,
107
+ coerce: (raw: unknown, warn?: (message: string) => void) => Partial<T> | undefined,
61
108
  warn?: (message: string) => void,
62
109
  ): T {
63
110
  const cfg: T = { ...defaults };
64
- let nestedSeen = false;
65
- let flatSeen = false;
111
+ const findings: Array<[string, string[]]> = [];
66
112
  for (const path of settingsPaths(cwd)) {
67
113
  const settings = readSettings(path);
68
114
  if (!settings) continue;
69
- let root = settings.quiver;
70
- if (root !== undefined && (root === null || typeof root !== "object" || Array.isArray(root))) {
71
- emitWarning(warn, `pi-quiver: "quiver" in ${path} is not an object; ignored.`);
72
- root = undefined;
73
- }
74
- const nested = root as Record<string, unknown> | undefined;
115
+ const lines = lintSettings(settings);
116
+ if (lines.length > 0) findings.push([path, lines]);
117
+ const nested = isPlainObject(settings.quiver) ? settings.quiver : undefined;
75
118
  const hasNested = nested !== undefined && Object.hasOwn(nested, key);
76
119
  const hasFlat = Object.hasOwn(settings, key);
77
- nestedSeen ||= hasNested;
78
- flatSeen ||= hasFlat;
79
120
  if (!hasNested && !(hasFlat && LEGACY_FLAT_KEYS.has(key))) continue;
80
121
  const candidate = hasNested ? nested![key] : settings[key];
81
- const patch = coerce(candidate);
122
+ const patch = coerce(candidate, (message) => emitWarning(warn, message));
82
123
  if (patch) Object.assign(cfg, patch);
83
124
  else emitWarning(warn, `pi-quiver: "${key}" in ${path} has an unrecognized value; ignored.`);
84
125
  }
85
- if (nestedSeen && flatSeen) {
86
- emitWarning(
87
- warn,
88
- `pi-quiver: "${key}" is set both flat and under "quiver" (nested wins within a layer; across layers the project layer wins regardless of shape) - move the flat entry under "quiver".`,
89
- );
90
- }
126
+ if (findings.length > 0) emitWarning(warn, formatLint(findings));
91
127
  return cfg;
92
128
  }
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.3.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():