@timqi/pier 0.1.3 → 0.1.4

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.
Files changed (97) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/packages.js +1 -7
  5. package/dist/agent/pi.js +6 -47
  6. package/dist/channels/config.js +30 -20
  7. package/dist/channels/routes.js +0 -3
  8. package/dist/channels/slack-api.js +19 -10
  9. package/dist/channels/slack-cli.js +503 -0
  10. package/dist/channels/slack-directory.js +2 -2
  11. package/dist/channels/slack-outbound.js +1 -1
  12. package/dist/channels/slack-thread.js +41 -0
  13. package/dist/channels/slack-transcript.js +107 -0
  14. package/dist/channels/slack.js +9 -17
  15. package/dist/channels/types.js +0 -1
  16. package/dist/cli.js +158 -7
  17. package/dist/core/identity.js +20 -11
  18. package/dist/core/reply.js +16 -15
  19. package/dist/core/router.js +4 -1
  20. package/dist/db.js +28 -0
  21. package/dist/main.js +19 -32
  22. package/dist/paths.js +3 -0
  23. package/dist/secrets.js +2 -1
  24. package/dist/socket.js +95 -0
  25. package/dist/tasks/agent.js +5 -6
  26. package/dist/tasks/callbacks.js +2 -2
  27. package/dist/tasks/cli.js +215 -0
  28. package/dist/tasks/definitions.js +3 -3
  29. package/dist/tasks/execution.js +1 -3
  30. package/dist/tasks/groups.js +2 -5
  31. package/dist/tasks/messages.js +18 -158
  32. package/dist/tasks/operations.js +323 -0
  33. package/dist/tasks/routes.js +1 -11
  34. package/dist/tasks/runs.js +5 -20
  35. package/dist/tasks/service.js +18 -43
  36. package/dist/tasks/store.js +15 -15
  37. package/dist/tools.js +17 -0
  38. package/dist/vault.js +107 -0
  39. package/dist/web/auth.js +2 -1
  40. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-NLS8W9yl.js} +2 -2
  41. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  42. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  43. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-DleegoiC.js} +1 -1
  44. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  45. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  46. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-vJvV1Sx0.js} +1 -1
  47. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  48. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  49. package/dist/web/public/assets/{index-DVIt5Gio.css → index-Bs-gol9o.css} +1 -1
  50. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  51. package/dist/web/public/assets/{index-DVIt5Gio.css.gz → index-Bs-gol9o.css.gz} +0 -0
  52. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  53. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  54. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  55. package/dist/web/public/assets/{runs-DdERzeac.js → runs-C5FveZ75.js} +1 -1
  56. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  57. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  58. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  59. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  60. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  61. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-CJJ2j5Ks.js} +1 -1
  62. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  63. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  64. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-R2Pv0Abg.js} +1 -1
  65. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  66. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  67. package/dist/web/public/index.html +2 -2
  68. package/dist/web/public/index.html.br +0 -0
  69. package/dist/web/public/index.html.gz +0 -0
  70. package/dist/web/vault.js +68 -0
  71. package/docs/deploy.md +33 -10
  72. package/package.json +1 -1
  73. package/skills/pier-help/SKILL.md +10 -6
  74. package/skills/pier-slack/SKILL.md +52 -71
  75. package/skills/pier-tasks/SKILL.md +48 -145
  76. package/skills/pier-vault/SKILL.md +36 -0
  77. package/dist/channels/slack-tool.js +0 -416
  78. package/dist/tasks/tool.js +0 -416
  79. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  80. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  81. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  82. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  83. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  84. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  85. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  86. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  87. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  88. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  89. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  90. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  91. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  92. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  93. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  94. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  95. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  96. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  97. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
package/README.md CHANGED
@@ -37,8 +37,8 @@ otherwise).
37
37
  **The first start generates a password and prints it once.** Lost it?
38
38
  `sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart. Open
39
39
  `http://localhost:3141`, sign in; **Console → Settings** configures Models,
40
- Agent, Channels, the public URL, password and master key; **New session** picks
41
- the directory the agent's shell runs in.
40
+ Agent, Channels, Vault, the public URL, password and master key; **New session**
41
+ picks the directory the agent's shell runs in.
42
42
 
43
43
  ## Configure Pi
44
44
 
@@ -67,7 +67,12 @@ Console → Settings:
67
67
  copy of yours registering the same tool makes Pier's stand down) and the
68
68
  managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
69
69
  [ubix](https://github.com/timqi/ubix) block), installed into
70
- `~/.pier/tools/bin`, first on every session's PATH.
70
+ `~/.pier/tools/bin`, first on every session's PATH beside the `pier` shim
71
+ Pier writes there at start.
72
+ - **Vault** — named secrets an agent's commands receive through `pier vault
73
+ run` without the value entering its context (`docs/design/07-vault.md`);
74
+ `pier slack` and `pier task` reach the running Pier the same way
75
+ (`docs/design/08-cli-socket.md`).
71
76
 
72
77
  On first credential access an existing `auth.json` is imported into the sealed
73
78
  store and renamed `auth.json.imported`; literal keys in `models.json` likewise
@@ -15,9 +15,15 @@ const PROJECT_FILES = ["AGENTS.md"];
15
15
  const READONLY_FILES = ["settings.json"];
16
16
  const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
17
17
  /** What a first boot writes (docs/deploy.md says why each key): the list the
18
- * Console installs into, and Pi's attribution headers off — a server is not a
19
- * person to survey. Every other key stays Pi's default by omission. */
20
- const SEED_SETTINGS = { packages: [], enableInstallTelemetry: false };
18
+ * Console installs into, Pi's attribution headers off — a server is not a
19
+ * person to survey — and a retry budget of minutes, because an unattended
20
+ * instance has nobody to re-ask after Pi's 14-second default gives up. Every
21
+ * other key stays Pi's default by omission. */
22
+ const SEED_SETTINGS = {
23
+ packages: [],
24
+ enableInstallTelemetry: false,
25
+ retry: { maxRetries: 5, baseDelayMs: 5000 },
26
+ };
21
27
  /** Pier owns the Pi runtime dir; main.ts exports it as PI_CODING_AGENT_DIR. */
22
28
  export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
23
29
  /** Stable mask: mapped back by field, without exposing key fragments. */
@@ -33,8 +33,7 @@ function systemOrigin(message) {
33
33
  }
34
34
  if (origin.kind === "task-message" &&
35
35
  typeof origin.messageId === "string" &&
36
- (origin.messageKind === "steer" || origin.messageKind === "follow_up" ||
37
- origin.messageKind === "progress" || origin.messageKind === "decision" || origin.messageKind === "reply"))
36
+ (origin.messageKind === "steer" || origin.messageKind === "follow_up"))
38
37
  return shape;
39
38
  return null;
40
39
  }
@@ -14,8 +14,6 @@ const log = logger("packages");
14
14
  /** Set by how long a released fix may sit unnoticed, like update.ts. */
15
15
  const CHECK_EVERY_MS = 24 * 60 * 60_000;
16
16
  const KINDS = { extensions: "extension", skills: "skill" };
17
- /** The state line for a skill whose tool this instance is not given (docs/design/03). */
18
- const FOLLOWS_TOOL = "follows Channels → agent tool";
19
17
  /** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): a `local` file whose
20
18
  * switch is the rtk tool's, so a settings.json pattern would fight the tool. */
21
19
  const RTK_FILE = join("extensions", "rtk.ts");
@@ -160,16 +158,12 @@ export class PiPackageStore {
160
158
  kind: "extension", name, path: `<inline:${name}>`, enabled: on.includes(name),
161
159
  state: on.includes(name) ? shadowedBuiltin(name) : null,
162
160
  }));
163
- const gone = new Set(this.pier.tools.filter((t) => t.skill && !(t.available?.() ?? true)).map((t) => t.skill));
164
161
  for (const dir of this.skillDirs) {
165
162
  for (const entry of (await fs.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory())) {
166
163
  const path = join(dir, entry.name, "SKILL.md");
167
164
  if (!existsSync(path))
168
165
  continue;
169
- pkg.resources.push({
170
- kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name),
171
- state: gone.has(entry.name) ? FOLLOWS_TOOL : null,
172
- });
166
+ pkg.resources.push({ kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name), state: null });
173
167
  }
174
168
  }
175
169
  }
package/dist/agent/pi.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Pi SDK. No Pi type may appear in an exported signature.
4
4
  import { realpathSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
- import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
6
+ import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
7
7
  import { inlineExtensions } from "../extensions/index.js";
8
8
  import { SESSION_TITLE_MAX } from "../core/types.js";
9
9
  import { logger } from "../log.js";
@@ -140,14 +140,6 @@ export const standDownShadowed = (base) => {
140
140
  }),
141
141
  };
142
142
  };
143
- /** A skill's description is resident in every prompt; one pointing at a tool
144
- * this session was not given is a route the agent cannot take. */
145
- export const standDownUndocumented = (tools, skills) => {
146
- const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
147
- if (!gone.size)
148
- return skills;
149
- return skills.filter((skill) => !gone.has(skill.name));
150
- };
151
143
  export class PiSession {
152
144
  pi;
153
145
  pinned;
@@ -400,7 +392,6 @@ export class PiSession {
400
392
  }
401
393
  }
402
394
  export class PiAgentFactory {
403
- extraTools;
404
395
  instructions;
405
396
  skillPaths;
406
397
  credentials;
@@ -409,9 +400,9 @@ export class PiAgentFactory {
409
400
  pier;
410
401
  titleModel;
411
402
  listings;
412
- constructor(extraTools = [],
413
- /** Getters are read per session open, so a Console change reaches the next
414
- * session without a restart. Appended as a context file so the user's own
403
+ constructor(
404
+ /** Read per session open, so a Console change reaches the next session
405
+ * without a restart; appended as a context file so the user's own
415
406
  * instructions still win. */
416
407
  instructions = () => "",
417
408
  /** Loaded per session, never installed into the user's skill directories. */
@@ -423,7 +414,6 @@ export class PiAgentFactory {
423
414
  pier = () => ({ extensions: [], skillsOff: [] }), titleModel = () => undefined,
424
415
  /** Injected so a test needs no session directory or database. */
425
416
  listings = new IndexedListing()) {
426
- this.extraTools = extraTools;
427
417
  this.instructions = instructions;
428
418
  this.skillPaths = skillPaths;
429
419
  this.credentials = credentials;
@@ -628,7 +618,7 @@ export class PiAgentFactory {
628
618
  // same name is Pi's to switch (settings.json).
629
619
  skillsOverride: (base) => ({
630
620
  ...base,
631
- skills: standDownUndocumented(this.extraTools, base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep)))),
621
+ skills: base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep))),
632
622
  }),
633
623
  extensionFactories: [
634
624
  { name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
@@ -651,36 +641,6 @@ export class PiAgentFactory {
651
641
  return this.providerConfig.withWrite(() => this.openSnapshot(cwd, sessionManager, opts));
652
642
  }
653
643
  async openSnapshot(cwd, sessionManager, opts) {
654
- let live;
655
- // Per open: an unconfigured tool would cost context on every turn and answer nothing.
656
- const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
657
- const customTools = active.map((tool) => defineTool({
658
- name: tool.name,
659
- label: tool.label,
660
- description: tool.description,
661
- parameters: tool.parameters,
662
- execute: async (_id, params, signal) => {
663
- const caller = live?.sessionId ?? "unknown";
664
- log.debug(`tool ${tool.name} called by ${caller}`);
665
- try {
666
- return {
667
- content: [
668
- {
669
- type: "text",
670
- // Pretty-printing costs ~20% more tokens and buys the model nothing.
671
- text: JSON.stringify(await tool.execute(params, caller, signal)),
672
- },
673
- ],
674
- details: {},
675
- };
676
- }
677
- catch (err) {
678
- // Pi turns this into tool-result text: the right recovery, the wrong record.
679
- log.warn(`tool ${tool.name} failed for ${caller}`, err);
680
- throw err;
681
- }
682
- },
683
- }));
684
644
  // A locked store is a refusal with a reason here, not "provider not
685
645
  // configured" later. Before appendSessionInfo, so nothing is written.
686
646
  this.credentials?.assertUnlocked();
@@ -696,11 +656,10 @@ export class PiAgentFactory {
696
656
  const created = await createAgentSession({
697
657
  cwd,
698
658
  sessionManager,
699
- customTools,
700
659
  modelRuntime: runtime,
701
660
  resourceLoader: await this.resourceLoader(cwd),
702
661
  });
703
- live = created.session;
662
+ const live = created.session;
704
663
  // Pi defaults to one follow-up per turn boundary, so N queued messages cost
705
664
  // N turns. The agent's setter flips only the in-memory queue;
706
665
  // `session.setFollowUpMode` would persist it to Pi's settings.json.
@@ -1,23 +1,31 @@
1
1
  // Channel config persistence and the permission gate every adapter shares: one
2
2
  // JSON document per platform, so a surface configuring one reads and writes one row.
3
3
  import { randomInt } from "node:crypto";
4
- import { pierDb } from "../db.js";
5
- import { isSealed } from "../secrets.js";
6
4
  import { defaultChannelConfig, } from "./types.js";
7
5
  const BIND_CODE_TTL_MS = 10 * 60_000;
8
6
  /** Six symbols out of 36 is 2 billion, but a caller who may retry forever only
9
7
  * needs the TTL; five wrong tries void the code instead. */
10
8
  const BIND_CODE_TRIES = 5;
11
9
  const BIND_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
10
+ /** Where each platform's credentials live in the vault — fixed names, so a
11
+ * skill and the Console agree without a setting. Migration 24 spells the same
12
+ * table in SQL. Telegram authenticates with one token. */
13
+ const CREDENTIAL_NAMES = {
14
+ telegram: { token: "TELEGRAM_TOKEN" },
15
+ slack: { token: "SLACK_TOKEN", appToken: "SLACK_APP_TOKEN" },
16
+ lark: { token: "LARK_APP_ID", appToken: "LARK_APP_SECRET" },
17
+ };
18
+ const credentials = (platform) => Object.entries(CREDENTIAL_NAMES[platform]);
12
19
  export class ChannelStore {
13
- secrets;
14
20
  db;
21
+ vault;
15
22
  cache = new Map();
16
- /** Without `secrets` (tests), tokens persist as given. A locked store throws
17
- * rather than serving a token it cannot read. */
18
- constructor(db = pierDb(), secrets) {
19
- this.secrets = secrets;
23
+ /** The row never holds a credential: `token`/`appToken` are filled from the
24
+ * vault on read and filed there on save. A locked vault throws rather than
25
+ * serving a token it cannot read. */
26
+ constructor(db, vault) {
20
27
  this.db = db;
28
+ this.vault = vault;
21
29
  }
22
30
  /** Private: handed out, a caller could mutate config without saving. */
23
31
  cached(platform) {
@@ -28,11 +36,8 @@ export class ChannelStore {
28
36
  const config = row
29
37
  ? { ...defaultChannelConfig(), ...JSON.parse(row.json) }
30
38
  : defaultChannelConfig();
31
- if (this.secrets) {
32
- for (const key of ["token", "appToken"]) {
33
- if (isSealed(config[key]))
34
- config[key] = this.secrets.decrypt(config[key]);
35
- }
39
+ for (const [key, name] of credentials(platform)) {
40
+ config[key] = this.vault.get(name) ?? "";
36
41
  }
37
42
  this.cache.set(platform, config);
38
43
  return config;
@@ -41,16 +46,21 @@ export class ChannelStore {
41
46
  return structuredClone(this.cached(platform));
42
47
  }
43
48
  save(platform, config) {
49
+ // The cache holds plaintext; the row holds neither key. Only a changed
50
+ // credential touches the vault, so its `updated` is the rotation, not the
51
+ // last chat discovered; an emptied field removes the row.
52
+ const before = this.cached(platform);
53
+ for (const [key, name] of credentials(platform)) {
54
+ if (config[key] === before[key])
55
+ continue;
56
+ if (config[key])
57
+ this.vault.seal(name, config[key]);
58
+ else
59
+ this.vault.remove(name);
60
+ }
44
61
  // Cloned, so the caller's object cannot reach into the cache later.
45
62
  this.cache.set(platform, structuredClone(config));
46
- // The cache holds plaintext; only the row is sealed.
47
- const stored = this.secrets ? structuredClone(config) : config;
48
- if (this.secrets) {
49
- for (const key of ["token", "appToken"]) {
50
- if (stored[key])
51
- stored[key] = this.secrets.encrypt(stored[key]);
52
- }
53
- }
63
+ const { token: _token, appToken: _appToken, ...stored } = config;
54
64
  this.db.prepare(`
55
65
  INSERT INTO channels(platform, json) VALUES (?, ?)
56
66
  ON CONFLICT(platform) DO UPDATE SET json = excluded.json
@@ -70,9 +70,6 @@ export function registerChannelRoutes(app, store, runtime) {
70
70
  enabled: asBool(body.enabled),
71
71
  token: kept(asString(body.token), current.token),
72
72
  appToken: kept(asString(body.appToken), current.appToken),
73
- // Absent means "on": a client that predates the field must not silently
74
- // switch off a capability the operator never touched.
75
- agentTool: body.agentTool === undefined ? current.agentTool : asBool(body.agentTool),
76
73
  requireMention: asBool(body.requireMention),
77
74
  requireBind: asBool(body.requireBind),
78
75
  topicMode: asBool(body.topicMode),
@@ -21,18 +21,24 @@ export class SlackApi {
21
21
  appToken;
22
22
  log;
23
23
  openSocket;
24
+ retries;
24
25
  socketRunning = false;
25
26
  constructor(token, appToken, log = () => { },
26
27
  /** Injected in tests. */
27
- openSocket = (url) => new WebSocket(url)) {
28
+ openSocket = (url) => new WebSocket(url),
29
+ /** How many rate-limit waits one call sits through. The adapter takes one:
30
+ * a turn must not hang for minutes. A batch process (`pier slack`) takes more. */
31
+ retries = 1) {
28
32
  this.token = token;
29
33
  this.appToken = appToken;
30
34
  this.log = log;
31
35
  this.openSocket = openSocket;
36
+ this.retries = retries;
32
37
  }
33
38
  /** Slack accepts a JSON body only on write methods; a read method silently
34
39
  * ignores it and reports the parameter missing (`users.info` answers
35
- * `user_not_found`). So reads go form-encoded. */
40
+ * `user_not_found`). So reads go form-encoded. Public for the long tail of
41
+ * read methods `pier slack` needs and the adapter never calls. */
36
42
  async read(method, params) {
37
43
  const form = new URLSearchParams();
38
44
  for (const [key, value] of Object.entries(params)) {
@@ -41,7 +47,7 @@ export class SlackApi {
41
47
  }
42
48
  return this.call(method, form);
43
49
  }
44
- async call(method, payload, token = this.token, retry = true) {
50
+ async call(method, payload, token = this.token, tries = this.retries) {
45
51
  const form = payload instanceof URLSearchParams;
46
52
  const res = await fetch(`${BASE}/${method}`, {
47
53
  method: "POST",
@@ -54,16 +60,17 @@ export class SlackApi {
54
60
  body: form ? payload.toString() : JSON.stringify(payload),
55
61
  signal: AbortSignal.timeout(30_000),
56
62
  });
63
+ const body = (await res.json().catch(() => null));
57
64
  // A long turn split into chunks hits ~1 msg/s per channel; the header
58
- // carries the exact wait. Obeyed once; a second 429 throws.
59
- if (res.status === 429 && retry) {
65
+ // carries the exact wait. Some methods say `ratelimited` in a 200 body
66
+ // instead. Obeyed `retries` times; the next one throws.
67
+ if ((res.status === 429 || body?.error === "ratelimited") && tries > 0) {
60
68
  const after = Number(res.headers.get("retry-after") ?? "1");
61
69
  if (Number.isFinite(after) && after <= 60) {
62
70
  await new Promise((r) => setTimeout(r, (after + 1) * 1000));
63
- return this.call(method, payload, token, false);
71
+ return this.call(method, payload, token, tries - 1);
64
72
  }
65
73
  }
66
- const body = (await res.json().catch(() => null));
67
74
  if (!body)
68
75
  throw new Error(`slack ${method}: ${res.status} with no JSON body`);
69
76
  if (!body.ok)
@@ -259,7 +266,7 @@ export class SlackApi {
259
266
  }
260
267
  /** Three calls is Slack's current upload (`files.upload` is retired); the
261
268
  * upload host is not the Web API and answers plain text. */
262
- async uploadFile(channel, threadTs, file) {
269
+ async uploadFile(channel, threadTs, file, comment) {
263
270
  const slot = await this.read("files.getUploadURLExternal", { filename: file.name, length: file.bytes.length }).catch((err) => {
264
271
  // The manifest is only applied when an app is created; name the fix.
265
272
  if (!/missing_scope/.test(String(err)))
@@ -279,10 +286,12 @@ export class SlackApi {
279
286
  });
280
287
  if (!put.ok)
281
288
  throw new Error(`slack file upload: ${put.status}`);
282
- await this.call("files.completeUploadExternal", {
289
+ const done = await this.call("files.completeUploadExternal", {
283
290
  files: [{ id: slot.file_id, title: file.name }],
284
291
  channel_id: channel,
285
- thread_ts: threadTs,
292
+ thread_ts: threadTs || undefined,
293
+ initial_comment: comment,
286
294
  });
295
+ return { id: done.files?.[0]?.id ?? slot.file_id };
287
296
  }
288
297
  }