@timqi/pier 0.1.2 → 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 (113) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +63 -59
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/listing.js +38 -15
  5. package/dist/agent/packages.js +347 -0
  6. package/dist/agent/pi.js +33 -59
  7. package/dist/channels/config.js +30 -20
  8. package/dist/channels/routes.js +0 -3
  9. package/dist/channels/slack-api.js +19 -10
  10. package/dist/channels/slack-cli.js +503 -0
  11. package/dist/channels/slack-directory.js +2 -2
  12. package/dist/channels/slack-outbound.js +1 -1
  13. package/dist/channels/slack-thread.js +41 -0
  14. package/dist/channels/slack-transcript.js +107 -0
  15. package/dist/channels/slack.js +9 -17
  16. package/dist/channels/types.js +0 -1
  17. package/dist/cli.js +158 -7
  18. package/dist/core/identity.js +20 -11
  19. package/dist/core/reply.js +16 -15
  20. package/dist/core/router.js +4 -1
  21. package/dist/core/types.js +7 -0
  22. package/dist/db.js +28 -0
  23. package/dist/extensions/index.js +1 -20
  24. package/dist/main.js +34 -42
  25. package/dist/paths.js +3 -0
  26. package/dist/secrets.js +2 -1
  27. package/dist/settings.js +5 -0
  28. package/dist/socket.js +95 -0
  29. package/dist/tasks/agent.js +5 -6
  30. package/dist/tasks/callbacks.js +2 -2
  31. package/dist/tasks/cli.js +215 -0
  32. package/dist/tasks/definitions.js +3 -3
  33. package/dist/tasks/execution.js +1 -3
  34. package/dist/tasks/groups.js +2 -5
  35. package/dist/tasks/messages.js +18 -158
  36. package/dist/tasks/operations.js +323 -0
  37. package/dist/tasks/routes.js +1 -11
  38. package/dist/tasks/runs.js +5 -20
  39. package/dist/tasks/service.js +18 -43
  40. package/dist/tasks/store.js +15 -15
  41. package/dist/tools.js +63 -85
  42. package/dist/vault.js +107 -0
  43. package/dist/web/auth.js +2 -1
  44. package/dist/web/config.js +25 -11
  45. package/dist/web/explorer.js +5 -3
  46. package/dist/web/instance.js +6 -18
  47. package/dist/web/packages.js +84 -0
  48. package/dist/web/public/assets/{activity-DOr8dWeX.js → activity-NLS8W9yl.js} +2 -2
  49. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  50. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  51. package/dist/web/public/assets/boards-DleegoiC.js +1 -0
  52. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  53. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  54. package/dist/web/public/assets/explorer-vJvV1Sx0.js +4 -0
  55. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  56. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  57. package/dist/web/public/assets/index-Bs-gol9o.css +2 -0
  58. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  59. package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
  60. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  61. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  62. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  63. package/dist/web/public/assets/runs-C5FveZ75.js +1 -0
  64. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  65. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  66. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  67. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  68. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  69. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +3 -0
  70. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  71. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  72. package/dist/web/public/assets/tasks-R2Pv0Abg.js +4 -0
  73. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  74. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  75. package/dist/web/public/index.html +2 -2
  76. package/dist/web/public/index.html.br +0 -0
  77. package/dist/web/public/index.html.gz +0 -0
  78. package/dist/web/server.js +3 -1
  79. package/dist/web/vault.js +68 -0
  80. package/docs/deploy.md +42 -8
  81. package/package.json +1 -1
  82. package/skills/pier-help/SKILL.md +10 -6
  83. package/skills/pier-slack/SKILL.md +52 -71
  84. package/skills/pier-tasks/SKILL.md +48 -145
  85. package/skills/pier-vault/SKILL.md +36 -0
  86. package/dist/channels/slack-tool.js +0 -416
  87. package/dist/tasks/tool.js +0 -416
  88. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  89. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  90. package/dist/web/public/assets/boards-CneyR23E.js +0 -1
  91. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  92. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  93. package/dist/web/public/assets/explorer-D0srXT1c.js +0 -4
  94. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  95. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  96. package/dist/web/public/assets/index-DbFu15NN.js +0 -85
  97. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  98. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  99. package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
  100. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  101. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  102. package/dist/web/public/assets/runs-Cv0A-e08.js +0 -1
  103. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  104. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  105. package/dist/web/public/assets/settings-VmCjhGBd.js +0 -5
  106. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  107. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  108. package/dist/web/public/assets/task-runs-CqpTV644.js +0 -3
  109. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  110. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  111. package/dist/web/public/assets/tasks-BffPVgXg.js +0 -4
  112. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  113. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
package/dist/agent/pi.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
3
3
  // Pi SDK. No Pi type may appear in an exported signature.
4
4
  import { realpathSync } from "node:fs";
5
- import { basename, dirname, join } from "node:path";
6
- import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
5
+ import { basename, dirname, join, sep } from "node:path";
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";
@@ -112,32 +112,34 @@ const bashTimeoutDefault = (pi) => {
112
112
  }
113
113
  });
114
114
  };
115
+ /** What the last session open stood down, by bundled extension name: the
116
+ * registry (agent/packages.ts) shows it as the row's state. Known only at
117
+ * open, so a built-in no session has loaded since reads nothing. */
118
+ const shadowed = new Map();
119
+ export const shadowedBuiltin = (name) => shadowed.get(name) ?? null;
115
120
  /** Pi loads a bundled extension and its on-disk twin both, leaving two tools
116
121
  * of one name; the copy the user put there wins, and the journal says so (§5). */
117
122
  export const standDownShadowed = (base) => {
118
123
  const inline = (ext) => ext.path.startsWith("<inline:");
119
- const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
120
- if (!onDisk.size)
121
- return base;
124
+ const onDisk = new Map(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()].map((tool) => [tool, ext.path])));
122
125
  return {
123
126
  ...base,
124
127
  extensions: base.extensions.filter((ext) => {
125
- const clash = inline(ext) && [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
126
- if (!clash || !clash.length)
128
+ if (!inline(ext))
127
129
  return true;
128
- log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from disk`);
130
+ const name = ext.path.slice("<inline:".length, -1);
131
+ const clash = [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
132
+ if (!clash.length) {
133
+ shadowed.delete(name);
134
+ return true;
135
+ }
136
+ const from = onDisk.get(clash[0]);
137
+ shadowed.set(name, `stood down — ${clash.join(", ")} from ${from}`);
138
+ log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from ${from}`);
129
139
  return false;
130
140
  }),
131
141
  };
132
142
  };
133
- /** A skill's description is resident in every prompt; one pointing at a tool
134
- * this session was not given is a route the agent cannot take. */
135
- export const standDownUndocumented = (tools, skills) => {
136
- const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
137
- if (!gone.size)
138
- return skills;
139
- return skills.filter((skill) => !gone.has(skill.name));
140
- };
141
143
  export class PiSession {
142
144
  pi;
143
145
  pinned;
@@ -390,34 +392,34 @@ export class PiSession {
390
392
  }
391
393
  }
392
394
  export class PiAgentFactory {
393
- extraTools;
394
395
  instructions;
395
396
  skillPaths;
396
397
  credentials;
397
398
  providerConfig;
398
399
  pinned;
399
- enabledExtensions;
400
+ pier;
400
401
  titleModel;
401
402
  listings;
402
- constructor(extraTools = [],
403
- /** Getters are read per session open, so a Console change reaches the next
404
- * 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
405
406
  * instructions still win. */
406
407
  instructions = () => "",
407
408
  /** Loaded per session, never installed into the user's skill directories. */
408
409
  skillPaths = [],
409
410
  /** Optional only for bare test factories; an OAuth refresh persists here,
410
411
  * never to a plaintext auth.json. */
411
- credentials, providerConfig = new PiConfigStore(), pinned = () => [], enabledExtensions = () => [], titleModel = () => undefined,
412
+ credentials, providerConfig = new PiConfigStore(), pinned = () => [],
413
+ /** The built-in `pier` package's switches: bundled extensions on, Pier's own skills off. */
414
+ pier = () => ({ extensions: [], skillsOff: [] }), titleModel = () => undefined,
412
415
  /** Injected so a test needs no session directory or database. */
413
416
  listings = new IndexedListing()) {
414
- this.extraTools = extraTools;
415
417
  this.instructions = instructions;
416
418
  this.skillPaths = skillPaths;
417
419
  this.credentials = credentials;
418
420
  this.providerConfig = providerConfig;
419
421
  this.pinned = pinned;
420
- this.enabledExtensions = enabledExtensions;
422
+ this.pier = pier;
421
423
  this.titleModel = titleModel;
422
424
  this.listings = listings;
423
425
  }
@@ -605,19 +607,22 @@ export class PiAgentFactory {
605
607
  await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
606
608
  }
607
609
  async resourceLoader(cwd) {
610
+ const { extensions, skillsOff } = this.pier();
608
611
  const loader = new DefaultResourceLoader({
609
612
  cwd,
610
613
  agentDir: defaultAgentDir(),
611
614
  // The user's SYSTEM.md is appended after Pier's baseline, so it still wins.
612
615
  systemPromptOverride: pierSystemPrompt,
613
616
  additionalSkillPaths: this.skillPaths,
617
+ // Only Pier's own skills answer to the off-list; a user's skill of the
618
+ // same name is Pi's to switch (settings.json).
614
619
  skillsOverride: (base) => ({
615
620
  ...base,
616
- skills: standDownUndocumented(this.extraTools, base.skills),
621
+ skills: base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep))),
617
622
  }),
618
623
  extensionFactories: [
619
624
  { name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
620
- ...inlineExtensions(this.enabledExtensions()),
625
+ ...inlineExtensions(extensions),
621
626
  ],
622
627
  extensionsOverride: standDownShadowed,
623
628
  agentsFilesOverride: (current) => {
@@ -633,39 +638,9 @@ export class PiAgentFactory {
633
638
  return loader;
634
639
  }
635
640
  open(cwd, sessionManager, opts = { cwd }) {
636
- return this.providerConfig.withSnapshot(() => this.openSnapshot(cwd, sessionManager, opts));
641
+ return this.providerConfig.withWrite(() => this.openSnapshot(cwd, sessionManager, opts));
637
642
  }
638
643
  async openSnapshot(cwd, sessionManager, opts) {
639
- let live;
640
- // Per open: an unconfigured tool would cost context on every turn and answer nothing.
641
- const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
642
- const customTools = active.map((tool) => defineTool({
643
- name: tool.name,
644
- label: tool.label,
645
- description: tool.description,
646
- parameters: tool.parameters,
647
- execute: async (_id, params, signal) => {
648
- const caller = live?.sessionId ?? "unknown";
649
- log.debug(`tool ${tool.name} called by ${caller}`);
650
- try {
651
- return {
652
- content: [
653
- {
654
- type: "text",
655
- // Pretty-printing costs ~20% more tokens and buys the model nothing.
656
- text: JSON.stringify(await tool.execute(params, caller, signal)),
657
- },
658
- ],
659
- details: {},
660
- };
661
- }
662
- catch (err) {
663
- // Pi turns this into tool-result text: the right recovery, the wrong record.
664
- log.warn(`tool ${tool.name} failed for ${caller}`, err);
665
- throw err;
666
- }
667
- },
668
- }));
669
644
  // A locked store is a refusal with a reason here, not "provider not
670
645
  // configured" later. Before appendSessionInfo, so nothing is written.
671
646
  this.credentials?.assertUnlocked();
@@ -681,11 +656,10 @@ export class PiAgentFactory {
681
656
  const created = await createAgentSession({
682
657
  cwd,
683
658
  sessionManager,
684
- customTools,
685
659
  modelRuntime: runtime,
686
660
  resourceLoader: await this.resourceLoader(cwd),
687
661
  });
688
- live = created.session;
662
+ const live = created.session;
689
663
  // Pi defaults to one follow-up per turn boundary, so N queued messages cost
690
664
  // N turns. The agent's setter flips only the in-memory queue;
691
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
  }