@pinet/slack-bridge 0.1.2 → 0.2.1

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 (47) hide show
  1. package/README.md +59 -32
  2. package/dist/broker/adapters/slack.d.ts +19 -1
  3. package/dist/broker/adapters/slack.js +111 -22
  4. package/dist/broker/client.d.ts +2 -1
  5. package/dist/broker/client.js +1 -0
  6. package/dist/broker/socket-server.js +18 -0
  7. package/dist/deploy-manifest.d.ts +5 -0
  8. package/dist/deploy-manifest.js +30 -1
  9. package/dist/follower-runtime.js +5 -1
  10. package/dist/helpers.d.ts +14 -0
  11. package/dist/helpers.js +45 -21
  12. package/dist/index.js +60 -0
  13. package/dist/pinet-commands.d.ts +6 -1
  14. package/dist/pinet-commands.js +166 -1
  15. package/dist/pinet-mesh-ops.d.ts +11 -0
  16. package/dist/pinet-mesh-ops.js +17 -0
  17. package/dist/pinet-tools.d.ts +47 -0
  18. package/dist/pinet-tools.js +506 -38
  19. package/dist/prompts/broker/tmux.md +2 -2
  20. package/dist/reaction-triggers.d.ts +1 -0
  21. package/dist/reaction-triggers.js +26 -15
  22. package/dist/runtime-agent-context.js +19 -0
  23. package/dist/runtime-mode.js +7 -1
  24. package/dist/single-player-runtime.js +22 -26
  25. package/dist/slack-access.d.ts +11 -0
  26. package/dist/slack-access.js +30 -0
  27. package/dist/slack-agents-command.d.ts +19 -0
  28. package/dist/slack-agents-command.js +90 -0
  29. package/dist/slack-export.d.ts +1 -1
  30. package/dist/slack-export.js +6 -4
  31. package/dist/slack-file-access.d.ts +34 -0
  32. package/dist/slack-file-access.js +209 -0
  33. package/dist/slack-message-context.d.ts +0 -1
  34. package/dist/slack-message-context.js +1 -6
  35. package/dist/slack-pinet-runtime-adapter.d.ts +4 -2
  36. package/dist/slack-pinet-runtime-adapter.js +12 -0
  37. package/dist/slack-tools.d.ts +6 -0
  38. package/dist/slack-tools.js +290 -36
  39. package/dist/slack-upload.d.ts +13 -1
  40. package/dist/slack-upload.js +29 -2
  41. package/dist/stale-slack-messages.d.ts +12 -0
  42. package/dist/stale-slack-messages.js +29 -0
  43. package/dist/subtree-broker-runtime.d.ts +109 -0
  44. package/dist/subtree-broker-runtime.js +558 -0
  45. package/manifest.yaml +9 -0
  46. package/package.json +9 -7
  47. package/skills/slack-bridge/SKILL.md +60 -1
package/README.md CHANGED
@@ -60,9 +60,10 @@ bump versions without explicit maintainer release approval.
60
60
  2. Choose **From a manifest**
61
61
  3. Select your workspace
62
62
  4. Paste the contents of [`manifest.yaml`](./manifest.yaml) from this directory
63
- 5. Click **Create**
63
+ 5. If the Slack app is not named Pinet, change `features.slash_commands[0].command` before creating the app (for example, Oathgate uses `/oathgate` instead of the packaged `/pinet` default)
64
+ 6. Click **Create**
64
65
 
65
- The manifest configures Socket Mode, the assistant view, all required bot scopes, and event subscriptions automatically.
66
+ The manifest configures Socket Mode, the assistant view, all required bot scopes, event subscriptions, and the packaged Pinet slash-command default automatically.
66
67
 
67
68
  ### 2. Generate tokens
68
69
 
@@ -81,13 +82,13 @@ These are included in the manifest, but for reference:
81
82
  app_mentions:read assistant:write bookmarks:read
82
83
  bookmarks:write canvases:read canvases:write
83
84
  channels:history channels:read chat:write
84
- files:read files:write groups:history
85
+ commands files:read files:write groups:history
85
86
  groups:read im:history im:read
86
87
  im:write pins:read pins:write
87
88
  reactions:read reactions:write users:read
88
89
  ```
89
90
 
90
- `files:read` is required because Slack exposes canvas comment pagination through `files.info`, even when the target is first validated via canvas-specific APIs.
91
+ `commands` is required for the Slack slash-command surface (`/<app> agents list [all]`). `files:read` is required because Slack exposes canvas comment pagination through `files.info`, even when the target is first validated via canvas-specific APIs.
91
92
 
92
93
  Slack thread shimmer/status updates use `assistant.threads.setStatus`; Slack's 2026 scope update allows this method with the existing `chat:write` bot scope, so no new `assistant:write` scope is needed for status-only support.
93
94
 
@@ -198,6 +199,8 @@ Slack access is now **default-deny** unless you configure one of these explicitl
198
199
  | `ralphSnoozeAfterEmptyCycles` | no | Broker RALPH auto-snooze trigger after N empty cycles; defaults to `0` (disabled), valid range `0`-`100` |
199
200
  | `ralphSnoozeDurationMs` | no | Broker RALPH auto-snooze duration in milliseconds; defaults to `1800000` (30 minutes), valid range `60000`-`86400000` |
200
201
  | `skinTheme` | no | Pinet presentation skin selected at broker startup/reload (`default`, `foundation`, `cosmere`, or free-form) |
202
+ | `slackCommandName` | no | Slack web app slash command name for `agents list`; defaults to `/pinet`, or `/oathgate` for Oathgate/Cosmere skins |
203
+ | `slackCommandNames` | no | Optional list of accepted/deployed Slack slash command aliases when one app needs multiple command names |
201
204
  | `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
202
205
  | `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
203
206
  | `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
@@ -234,7 +237,7 @@ Messages queue while the agent is busy. When the agent finishes, it automaticall
234
237
 
235
238
  ### Reaction triggers
236
239
 
237
- Configured emoji reactions create structured Pinet requests from the reacted-to Slack message. The default set includes `:arrow_up:` / ⬆️ as `steer`, which marks and redelivers the referenced message as steering so the current thread owner sees an unread operator instruction when relevant and safe. The default `:octagonal_sign:` / 🛑 mapping sends an explicit `interrupt` control to the current thread owner; it aborts the active turn when the owner is busy, but does not reload or exit the process. Pinet adds ✅ when it accepts the reaction-triggered request. If it cannot process the reaction at all, it adds ❌; check broker logs for the underlying Slack/API error. When Slack cannot return the reacted message text, Pinet still routes the reaction with channel/thread/message IDs so steering reactions do not fail solely because message lookup was unavailable.
240
+ Slack emoji reactions are ignored by default: they do not enqueue Pinet work, trigger reviews, steer agents, interrupt owners, or cause broker/worker replies. To opt in deliberately, configure `reactionCommands` for the exact emoji aliases that should become structured Pinet requests from the reacted-to Slack message. Even configured reactions are accepted only inside an already authorized Pinet thread (for example a thread with a current Pinet owner, or persisted Slack assistant-thread context). Reaction authorization is deny-by-default: it requires an explicit broker-backed authorization gate, and a thread the adapter has merely seen or cached never qualifies on its own. Reactions in ordinary, uninvoked Slack channel threads remain no-op even from authorized users and they do not enqueue work, persist thread state, claim ownership, or receive a Slack ACK. Messages and interactive events from users outside the allowlist also never mint known-thread state that could later admit reactions or replies. Pinet adds ✅ only when it accepts an opt-in reaction-triggered request. If it cannot process an accepted opted-in reaction, it adds ❌; check broker logs for the underlying Slack/API error. When Slack cannot return the reacted message text, Pinet can still route configured reactions when the message timestamp itself identifies an already authorized thread; otherwise it ignores the reaction safely.
238
241
 
239
242
  ### Available tools
240
243
 
@@ -254,6 +257,7 @@ Cold Slack actions live behind the `slack` dispatcher:
254
257
  | `react` | Add an emoji reaction to a message |
255
258
  | `read` | Read messages from a thread |
256
259
  | `upload` | Upload files, snippets, or diffs into Slack |
260
+ | `file` | Download Slack-hosted files to a controlled local temp cache by file ID |
257
261
  | `schedule` | Schedule a message for later delivery |
258
262
  | `post_channel` | Post to a channel (by name or ID) |
259
263
  | `delete` | Delete a bot-posted message or an entire thread |
@@ -307,7 +311,21 @@ migration.
307
311
  - **Uploads are for bulky artifacts.** Use `upload` for logs, screenshots,
308
312
  long diffs, and generated files instead of large inline messages. Inline
309
313
  uploads require `filename`; path uploads are guarded and must stay within the
310
- current working directory or system temp directory.
314
+ current working directory or system temp directory. `slack_send` also accepts
315
+ `files: [{ path, filename?, title?, filetype? }]` so one assistant-thread
316
+ reply can contain both text and local binary attachments in the same Slack
317
+ file upload message. Slack external file uploads cannot include Block Kit in
318
+ that same message, so omit `blocks` when sending files or send a separate
319
+ block-only reply.
320
+ - **Inbound Slack files are fetched explicitly.** Incoming file-share messages
321
+ preserve safe `slackFiles` metadata such as file ID, name, type, size, and
322
+ permalink, but private Slack download URLs are not exposed in normal tool
323
+ output. To inspect raw content, call dispatcher action `file` with
324
+ `op: "download"`, `file_id`, and optionally `thread_ts`, `message_ts`, and
325
+ `channel`. The bot fetches the file with Slack bot auth, stores it under the
326
+ system temp `pi-slack-files` cache with best-effort TTL cleanup, and returns a
327
+ descriptor containing the local path, filename, type, size, SHA-256, expiry,
328
+ and residual privacy risks.
311
329
  - **Upload host egress note.** The second upload leg goes to Slack file upload
312
330
  hosts (`files.slack.com`/`uploads.slack.com`) for the raw payload. In
313
331
  environments with restricted egress this can fail with `403` (proxy
@@ -342,10 +360,13 @@ migration.
342
360
  ...` string from the error, request confirmation, wait for the user's approval
343
361
  via `slack_inbox`, then retry the guarded call unchanged. Batched
344
362
  multi-thread Slack turns cannot satisfy a single-thread confirmation.
345
- - **Reaction and interaction triggers are explicit tasks.** Reaction-triggered
346
- requests and Block Kit/modal interaction payloads arrive through
347
- `slack_inbox` with metadata; treat them as user instructions tied to the
348
- referenced Slack thread or message.
363
+ - **Plain emoji reactions are not tasks.** Slack emoji reactions are ignored
364
+ unless `reactionCommands` explicitly opts that emoji into structured
365
+ reaction-trigger handling and the reacted message belongs to an already
366
+ authorized Pinet thread. If an opt-in reaction-triggered request or a Block
367
+ Kit/modal interaction payload arrives through `slack_inbox` with metadata,
368
+ treat it as a user instruction tied to the referenced Slack thread or
369
+ message.
349
370
 
350
371
  #### Common dispatcher examples
351
372
 
@@ -418,12 +439,13 @@ The `canvas_comments_read` dispatcher action is intentionally narrow:
418
439
 
419
440
  ### Slash commands
420
441
 
421
- | Command | Description |
422
- | ----------------- | ---------------------------------------------------------- |
423
- | `/pinet <action>` | Unified Pinet command surface; run `/pinet help` for usage |
424
- | `/pinet status` | Show connection status, threads, and agent identity |
425
- | `/pinet rename` | Change the agent's display name |
426
- | `/pinet logs` | Show recent broker activity log entries |
442
+ | Command | Description |
443
+ | -------------------------- | ---------------------------------------------------------- |
444
+ | `/pinet <action>` | Unified Pinet command surface; run `/pinet help` for usage |
445
+ | `/pinet status` | Show connection status, threads, and agent identity |
446
+ | `/pinet rename` | Change the agent's display name |
447
+ | `/pinet logs` | Show recent broker activity log entries |
448
+ | `/<app> agents list [all]` | Slack-native broker roster, workload, task, and lane view |
427
449
 
428
450
  ## Runtime modes
429
451
 
@@ -494,13 +516,17 @@ Only broker prompt content is replaceable. Broker runtime/tool restrictions rema
494
516
 
495
517
  ### Multi-agent tools
496
518
 
497
- | Tool | Description |
498
- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
499
- | `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `lanes`, `ports`, `reload`, `exit`) |
519
+ | Tool | Description |
520
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
521
+ | `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `lanes`, `ports`, `spawn`, `reload`, `exit`) |
500
522
 
501
- Use the dispatcher for Pinet tool actions: `pinet action=send`, `pinet action=read`, `pinet action=free`, `pinet action=snooze`, `pinet action=schedule`, `pinet action=agents`, `pinet action=lanes`, `pinet action=ports`, `pinet action=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, and `/pinet unfollow`. Dedicated direct Pinet tools (`pinet_message`, `pinet_read`, `pinet_agents`, `pinet_free`, `pinet_schedule`) are no longer registered. Legacy `pinet_*` guardrail patterns still match dispatcher action names, and legacy send policies such as `pinet_send` or `pinet_message` also cover `pinet action=send`, so existing security configs fail closed during migration.
523
+ Use the dispatcher for Pinet tool actions: `pinet action=send`, `pinet action=read`, `pinet action=free`, `pinet action=snooze`, `pinet action=schedule`, `pinet action=agents`, `pinet action=lanes`, `pinet action=ports`, `pinet action=spawn`, `pinet action=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, `/pinet unfollow`, and `/pinet subtree start`. Dedicated direct Pinet tools (`pinet_message`, `pinet_read`, `pinet_agents`, `pinet_free`, `pinet_schedule`) are no longer registered. Legacy `pinet_*` guardrail patterns still match dispatcher action names, and legacy send policies such as `pinet_send` or `pinet_message` also cover `pinet action=send`, so existing security configs fail closed during migration.
502
524
 
503
- Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, and agent lists. In default CLI mode, bulky read/agent payloads are also compacted in `data.details` so tool renderers do not surface full message bodies or agent metadata by accident. Pass `args.format="json"` (or `args.f` / `args["-f"]`) for the dispatcher envelope in content with full structured `data.details`, or `args.full=true` / `args["--full"]=true` for verbose text with full structured `data.details`.
525
+ Worker-owned subtree brokers let a follower worker supervise its own child mesh without registering those children in the central broker. Run `/pinet subtree start` (alias: `/pinet subbroker start`) from a follower worker. The worker remains connected to the central broker as a normal worker, and it also starts a separate broker socket/database under `~/.pi/pinet-subtrees/<worker>/`. Child workers launched by this worker receive `PINET_SOCKET_PATH`, `PINET_PARENT_AGENT_ID`, `PINET_ROOT_AGENT_ID`, `PINET_LAUNCH_ID`, `PINET_SUBTREE_ROLE`, and related metadata, so they follow the worker's subtree broker instead of the central Pinet broker.
526
+
527
+ Use `pinet action=spawn args.repo=<repo> args.task=<task> [args.role=<role>] [args.lane_id=<lane>]` or `/pinet subtree spawn repo=<repo> [role=<role>] [lane=<lane>] <task>` to launch a tmux-backed child worker, wait for it to register in the subtree broker, and deliver the task over private Pinet A2A. Use `pinet action=agents args.scope=subtree args.full=true` from the supervising worker to list subtree children, `pinet action=send args.to=<child> args.message=<message>` to reply/control them, `pinet action=read` to read child reports, and `pinet action=exit args.target=<child>` or `/pinet subtree stop` to clean them up. The central broker sees only the supervising worker; the subtree DB contains the child roster and messages.
528
+
529
+ Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, and agent lists. Bulky read/agent payloads are compacted in `data.details` by default, including when `args.format="json"` (or `args.f` / `args["-f"]`) renders the dispatcher envelope in content. Use `args.full=true` / `args["--full"]=true` only when you need verbose text and full structured debug details such as exact message bodies or agent metadata.
504
530
 
505
531
  Durable Pinet inbox notifications are classified as `steering`, `fwup`, or `maintenance/context` from explicit metadata or message cues. Follower prompts receive compact pointers such as `pinet action=read args.thread_id=...` instead of the full durable message body; agents use `pinet action=read` to retrieve the actual context. Delivery, read/ack state, and mail classification remain separate.
506
532
 
@@ -516,17 +542,18 @@ RALPH snooze quiets non-urgent empty maintenance cycles without disabling human-
516
542
 
517
543
  ### Pinet command surface
518
544
 
519
- Use `/pinet <action> [args]` for mesh lifecycle and broker operations.
545
+ Use `/pinet <action> [args]` for mesh lifecycle and broker operations. In the Slack web app, use `/<app> agents list [all]` for the Slack-native broker roster/current-work view: `/pinet agents list` for the Pinet app, or `/oathgate agents list` for an Oathgate-named app. Set `slackCommandName` (or `slackCommandNames`) in `slack-bridge` settings before deploying the manifest when the Slack command should match a non-default app name.
520
546
 
521
- | Command | Description |
522
- | ------------------------------------- | ----------------------------------------------------------------------------- |
523
- | `/pinet start` | Start as the mesh broker |
524
- | `/pinet follow` | Connect as a follower worker |
525
- | `/pinet unfollow` | Disconnect from the broker |
526
- | `/pinet reload <agent>` | Ask another agent to reload |
527
- | `/pinet exit <agent>` | Ask another agent to exit |
528
- | `/pinet free` | Mark this agent as idle |
529
- | `/pinet snooze [duration/off/status]` | Quiet empty RALPH cycles while preserving human-triggered wake/route behavior |
547
+ | Command | Description |
548
+ | ------------------------------------------ | ----------------------------------------------------------------------------- |
549
+ | `/pinet start` | Start as the mesh broker |
550
+ | `/pinet follow` | Connect as a follower worker |
551
+ | `/pinet unfollow` | Disconnect from the broker |
552
+ | `/pinet reload <agent>` | Ask another agent to reload |
553
+ | `/pinet exit <agent>` | Ask another agent to exit |
554
+ | `/pinet free` | Mark this agent as idle |
555
+ | `/pinet snooze [duration/off/status]` | Quiet empty RALPH cycles while preserving human-triggered wake/route behavior |
556
+ | `/pinet subtree [start/status/spawn/stop]` | Run this worker as a local subtree broker for child followers |
530
557
 
531
558
  ### Pinet skins
532
559
 
@@ -580,7 +607,7 @@ pnpm test
580
607
  pnpm deploy:slack
581
608
  ```
582
609
 
583
- Requires `appId` and `appConfigToken` in settings (or `SLACK_APP_ID` / `SLACK_APP_CONFIG_TOKEN` env vars).
610
+ Requires `appId` and `appConfigToken` in settings (or `SLACK_APP_ID` / `SLACK_APP_CONFIG_TOKEN` env vars). The deploy path rewrites `features.slash_commands` from `slackCommandName` / `slackCommandNames` (or the configured `skinTheme`) before validating and uploading, so set `slackCommandName: "/oathgate"` for an Oathgate app and leave it unset for the packaged Pinet `/pinet` default.
584
611
 
585
612
  ### Architecture
586
613
 
@@ -1,4 +1,4 @@
1
- import { type ParsedAppHomeOpened, type ParsedThreadStarted } from "../../slack-access.js";
1
+ import { type ParsedAppHomeOpened, type ParsedSlashCommand, type ParsedThreadStarted } from "../../slack-access.js";
2
2
  import { type ReactionCommandSettings } from "../../reaction-triggers.js";
3
3
  import type { AdapterCapabilityRequest, AdapterCapabilityResult, InboundMessage, OutboundMessage, MessageAdapter } from "./types.js";
4
4
  export { classifyMessage, extractAppHomeOpened, extractThreadStarted, parseMemberJoinedChannel, parseSocketFrame, RECONNECT_DELAY_MS, } from "../../slack-access.js";
@@ -21,8 +21,22 @@ export interface SlackAdapterConfig {
21
21
  } | null;
22
22
  /** Persist thread metadata in the broker DB without claiming ownership. */
23
23
  rememberKnownThread?: (threadTs: string, channelId: string, context?: ParsedThreadStarted["context"] | null) => void;
24
+ /**
25
+ * Gate reaction-trigger handling after the reacted message thread is known.
26
+ * Use this to require an already authorized/Pinet-owned thread before an
27
+ * opt-in reaction command can enqueue work or mutate durable thread state.
28
+ *
29
+ * Reaction triggers are denied by default: when this callback is not
30
+ * configured, opt-in reaction commands never route, even for threads the
31
+ * adapter has merely seen/cached. Authorization must be explicit so that
32
+ * reactions pass the same invoked/owned-thread admission bar as normal
33
+ * Slack messages (#812).
34
+ */
35
+ isReactionThreadAuthorized?: (threadTs: string, channelId: string) => boolean;
24
36
  /** Best-effort callback for Home tab opens. */
25
37
  onAppHomeOpened?: (event: ParsedAppHomeOpened) => Promise<void> | void;
38
+ /** Best-effort callback for Slack slash commands handled by the broker process. */
39
+ onSlashCommand?: (event: ParsedSlashCommand) => Promise<string | null> | string | null;
26
40
  }
27
41
  export declare const SLACK_THREAD_CACHE_MAX_SIZE = 5000;
28
42
  export declare const SLACK_THREAD_CACHE_TTL_MS: number;
@@ -58,9 +72,13 @@ export declare class SlackAdapter implements MessageAdapter {
58
72
  private resolveScopeForThread;
59
73
  private onThreadStarted;
60
74
  private onContextChanged;
75
+ private sendSlashCommandResponse;
76
+ private onSlashCommand;
61
77
  private onAppHomeOpened;
62
78
  private fetchMessageByTs;
63
79
  private onReactionAdded;
80
+ private getCachedThread;
81
+ private isReactionThreadAuthorized;
64
82
  private onMessage;
65
83
  private emitInteractiveInbound;
66
84
  private onMemberJoined;
@@ -1,9 +1,11 @@
1
+ import os from "node:os";
1
2
  import { addSlackReaction, buildSlackThreadRuntimeScope, classifyMessage, extractAppHomeOpened, extractThreadContextChanged, extractThreadStarted, fetchSlackMessageByTs, isSlackUserAllowed, removeSlackReaction, resolveSlackUserName, setSlackSuggestedPrompts, SlackSocketModeClient, } from "../../slack-access.js";
2
3
  import { createAbortableOperationTracker, callSlackAPI, isAbortError, buildAllowlist, buildPinetControlMessage, buildPinetControlMetadata, } from "../../helpers.js";
3
4
  import { buildReactionTriggerMessage, normalizeReactionName, resolveReactionCommands, } from "../../reaction-triggers.js";
4
5
  import { TtlCache, TtlSet } from "../../ttl-cache.js";
5
6
  import { SLACK_SOCKET_DELIVERY_DEDUP_MAX_SIZE, SLACK_SOCKET_DELIVERY_DEDUP_TTL_MS, } from "../../slack-access.js";
6
7
  import { DEFAULT_SLACK_THREAD_STATUS, SlackThreadStatusManager, } from "../../slack-thread-status.js";
8
+ import { performSlackUploads, prepareSlackUpload } from "../../slack-upload.js";
7
9
  export { classifyMessage, extractAppHomeOpened, extractThreadStarted, parseMemberJoinedChannel, parseSocketFrame, RECONNECT_DELAY_MS, } from "../../slack-access.js";
8
10
  export const SLACK_THREAD_CACHE_MAX_SIZE = 5000;
9
11
  export const SLACK_THREAD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -72,6 +74,7 @@ export class SlackAdapter {
72
74
  onMemberJoinedChannel: (event) => this.onMemberJoined(event),
73
75
  onAppHomeOpened: (event) => this.onAppHomeOpened(event),
74
76
  onInteractive: (event) => this.emitInteractiveInbound(event),
77
+ onSlashCommand: (event) => this.onSlashCommand(event),
75
78
  onError: (error) => {
76
79
  if (!isAbortError(error)) {
77
80
  console.error(`[slack-adapter] Socket Mode: ${errorMsg(error)}`);
@@ -140,7 +143,28 @@ export class SlackAdapter {
140
143
  },
141
144
  };
142
145
  }
143
- await this.callSlack("chat.postMessage", this.config.botToken, body);
146
+ if (msg.files && msg.files.length > 0) {
147
+ if (slackBlocks && slackBlocks.length > 0) {
148
+ throw new Error("Slack text+file replies use Slack's external upload flow, which does not support Block Kit blocks in the same upload message. Omit blocks or send a separate block-only message.");
149
+ }
150
+ const uploads = await Promise.all(msg.files.map((file) => prepareSlackUpload({
151
+ path: file.path,
152
+ ...(file.filename ? { filename: file.filename } : {}),
153
+ ...(file.title ? { title: file.title } : {}),
154
+ ...(file.filetype ? { filetype: file.filetype } : {}),
155
+ }, process.cwd(), os.tmpdir())));
156
+ await performSlackUploads({
157
+ uploads,
158
+ channelId: msg.channel,
159
+ threadTs: msg.threadId,
160
+ initialComment: msg.content?.text ?? msg.text,
161
+ slack: this.callSlack.bind(this),
162
+ token: this.config.botToken,
163
+ });
164
+ }
165
+ else {
166
+ await this.callSlack("chat.postMessage", this.config.botToken, body);
167
+ }
144
168
  if (this.shuttingDown)
145
169
  return;
146
170
  const pending = this.pendingEyes.get(msg.threadId);
@@ -233,6 +257,50 @@ export class SlackAdapter {
233
257
  /* best effort — DB cache sync must not break Slack event handling */
234
258
  }
235
259
  }
260
+ async sendSlashCommandResponse(event, text, options = {}) {
261
+ if (options.useResponseUrl !== false && event.responseUrl) {
262
+ try {
263
+ const response = await this.slackRequests.run((signal) => fetch(event.responseUrl, {
264
+ method: "POST",
265
+ headers: { "Content-Type": "application/json; charset=utf-8" },
266
+ body: JSON.stringify({ response_type: "ephemeral", text }),
267
+ signal,
268
+ }));
269
+ if (response.ok) {
270
+ return;
271
+ }
272
+ console.error(`[slack-adapter] Slash command response_url failed: HTTP ${response.status}`);
273
+ }
274
+ catch (error) {
275
+ console.error(`[slack-adapter] Slash command response_url failed: ${errorMsg(error)}`);
276
+ }
277
+ }
278
+ await this.callSlack("chat.postEphemeral", this.config.botToken, {
279
+ channel: event.channelId,
280
+ user: event.userId,
281
+ text,
282
+ });
283
+ }
284
+ async onSlashCommand(event) {
285
+ if (this.shuttingDown || !this.config.onSlashCommand)
286
+ return;
287
+ if (!isSlackUserAllowed(this.allowlist, event.userId)) {
288
+ await this.sendSlashCommandResponse(event, "Sorry, I can only respond to authorized users. Please contact an admin if you need access.");
289
+ return;
290
+ }
291
+ try {
292
+ const responseText = await this.config.onSlashCommand(event);
293
+ if (responseText && !this.shuttingDown) {
294
+ await this.sendSlashCommandResponse(event, responseText);
295
+ }
296
+ }
297
+ catch (error) {
298
+ console.error(`[slack-adapter] Slash command failed: ${errorMsg(error)}`);
299
+ await this.sendSlashCommandResponse(event, `Slack command failed: ${errorMsg(error)}`, {
300
+ useResponseUrl: false,
301
+ });
302
+ }
303
+ }
236
304
  async onAppHomeOpened(event) {
237
305
  if (this.shuttingDown)
238
306
  return;
@@ -280,25 +348,17 @@ export class SlackAdapter {
280
348
  try {
281
349
  const isInterruptReaction = command.action === "interrupt";
282
350
  const reactedMessage = await this.fetchMessageByTs(item.channel, item.ts);
283
- if (!reactedMessage && isInterruptReaction) {
284
- throw new Error(`Unable to identify Slack thread for interrupt reaction ${item.ts} in channel ${item.channel}`);
285
- }
286
351
  const reactedMessageFetchStatus = reactedMessage ? "found" : "unavailable";
287
352
  const threadTs = reactedMessage?.thread_ts ??
288
353
  reactedMessage?.ts ??
289
354
  item.ts;
290
- if (!this.getThread(threadTs)) {
291
- this.threads.set(threadTs, {
292
- channelId: item.channel,
293
- threadTs,
294
- userId: reactedMessage?.user ?? userId,
295
- });
296
- try {
297
- this.config.rememberKnownThread?.(threadTs, item.channel, null);
298
- }
299
- catch {
300
- /* best effort — DB cache sync must not break reaction handling */
301
- }
355
+ const cachedThread = this.getCachedThread(threadTs);
356
+ if (!this.isReactionThreadAuthorized(threadTs, item.channel, cachedThread)) {
357
+ return;
358
+ }
359
+ const existingThread = cachedThread ?? this.getThread(threadTs);
360
+ if (!existingThread || existingThread.channelId !== item.channel) {
361
+ return;
302
362
  }
303
363
  const reactorName = isInterruptReaction
304
364
  ? await this.resolveUser(userId).catch(() => userId)
@@ -323,7 +383,6 @@ export class SlackAdapter {
323
383
  : reactedMessage
324
384
  ? "(no text)"
325
385
  : "(message text unavailable; Slack did not return the reacted message, so use the channel/thread/message ids for context)";
326
- const threadInfo = this.getThread(threadTs);
327
386
  const reactionEventTs = evt.event_ts ?? item.ts;
328
387
  this.inboundHandler?.({
329
388
  source: "slack",
@@ -370,7 +429,7 @@ export class SlackAdapter {
370
429
  },
371
430
  scope: buildSlackThreadRuntimeScope({
372
431
  channelId: item.channel,
373
- context: threadInfo?.context,
432
+ context: existingThread.context,
374
433
  }),
375
434
  });
376
435
  await this.addReaction(item.channel, item.ts, "white_check_mark");
@@ -380,6 +439,31 @@ export class SlackAdapter {
380
439
  await this.addReaction(item.channel, item.ts, "x");
381
440
  }
382
441
  }
442
+ getCachedThread(threadTs) {
443
+ const cached = this.threads.get(threadTs);
444
+ if (cached) {
445
+ this.threads.set(threadTs, cached);
446
+ }
447
+ return cached;
448
+ }
449
+ isReactionThreadAuthorized(threadTs, channelId, cachedThread) {
450
+ if (cachedThread && cachedThread.channelId !== channelId)
451
+ return false;
452
+ // Deny by default (#812): without an explicit authorization gate, a
453
+ // merely cached/known thread must never authorize reaction routing.
454
+ const authorize = this.config.isReactionThreadAuthorized;
455
+ if (!authorize)
456
+ return false;
457
+ try {
458
+ return authorize(threadTs, channelId);
459
+ }
460
+ catch (error) {
461
+ // Authorization failures must fail closed without posting visible
462
+ // error reactions into threads Pinet does not own.
463
+ console.error(`[slack-adapter] reaction thread authorization failed: ${errorMsg(error)}`);
464
+ return false;
465
+ }
466
+ }
383
467
  async onMessage(evt) {
384
468
  if (this.shuttingDown)
385
469
  return;
@@ -392,6 +476,10 @@ export class SlackAdapter {
392
476
  this.shouldSuppressLegacyThreadedDm(threadTs)) {
393
477
  return;
394
478
  }
479
+ // Check the allowlist before recording any thread state so unauthorized
480
+ // traffic can never mint known-thread/affinity side effects (#812).
481
+ if (!isSlackUserAllowed(this.allowlist, userId))
482
+ return;
395
483
  if (!this.getThread(threadTs)) {
396
484
  this.threads.set(threadTs, {
397
485
  channelId: channel,
@@ -399,8 +487,6 @@ export class SlackAdapter {
399
487
  userId,
400
488
  });
401
489
  }
402
- if (!isSlackUserAllowed(this.allowlist, userId))
403
- return;
404
490
  void this.threadStatuses.begin(channel, threadTs, DEFAULT_SLACK_THREAD_STATUS);
405
491
  void this.addReaction(channel, messageTs, "eyes");
406
492
  const pending = this.pendingEyes.get(threadTs) ?? [];
@@ -430,6 +516,11 @@ export class SlackAdapter {
430
516
  });
431
517
  }
432
518
  async emitInteractiveInbound(normalized) {
519
+ // Check the allowlist before recording in-memory or durable thread state
520
+ // so unauthorized interactive events cannot mint known-thread/affinity
521
+ // side effects (#812).
522
+ if (!isSlackUserAllowed(this.allowlist, normalized.userId))
523
+ return;
433
524
  if (!this.getThread(normalized.threadTs)) {
434
525
  this.threads.set(normalized.threadTs, {
435
526
  channelId: normalized.channel,
@@ -443,8 +534,6 @@ export class SlackAdapter {
443
534
  catch {
444
535
  /* best effort — DB cache sync must not break Slack event handling */
445
536
  }
446
- if (!isSlackUserAllowed(this.allowlist, normalized.userId))
447
- return;
448
537
  const userName = await this.resolveUser(normalized.userId);
449
538
  if (this.shuttingDown)
450
539
  return;
@@ -1,5 +1,5 @@
1
1
  import type { PinetReadOptions, PinetReadResult } from "@pinet/pinet-core/pinet-read-formatting";
2
- import type { ClientAgentInfo, NormalizedMessageContent, PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./types.js";
2
+ import type { ClientAgentInfo, NormalizedMessageContent, OutboundAttachmentFile, PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./types.js";
3
3
  export interface InboxItem {
4
4
  inboxId: number;
5
5
  message: {
@@ -105,6 +105,7 @@ export declare class BrokerClient {
105
105
  channel?: string;
106
106
  content?: NormalizedMessageContent;
107
107
  blocks?: ReadonlyArray<Record<string, unknown>>;
108
+ files?: ReadonlyArray<OutboundAttachmentFile>;
108
109
  agentName?: string;
109
110
  agentEmoji?: string;
110
111
  agentOwnerToken?: string;
@@ -269,6 +269,7 @@ export class BrokerClient {
269
269
  ...(input.channel ? { channel: input.channel } : {}),
270
270
  ...(input.content ? { content: input.content } : {}),
271
271
  ...(input.blocks && input.blocks.length > 0 ? { blocks: input.blocks } : {}),
272
+ ...(input.files && input.files.length > 0 ? { files: input.files } : {}),
272
273
  ...(input.agentName ? { agentName: input.agentName } : {}),
273
274
  ...(input.agentEmoji ? { agentEmoji: input.agentEmoji } : {}),
274
275
  ...(input.agentOwnerToken ? { agentOwnerToken: input.agentOwnerToken } : {}),
@@ -601,6 +601,23 @@ export class BrokerSocketServer {
601
601
  const blocks = Array.isArray(params.blocks)
602
602
  ? params.blocks.filter((entry) => !!entry && typeof entry === "object")
603
603
  : undefined;
604
+ const files = Array.isArray(params.files)
605
+ ? params.files.flatMap((entry) => {
606
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
607
+ return [];
608
+ const file = entry;
609
+ if (typeof file.path !== "string" || file.path.trim().length === 0)
610
+ return [];
611
+ return [
612
+ {
613
+ path: file.path,
614
+ ...(typeof file.filename === "string" ? { filename: file.filename } : {}),
615
+ ...(typeof file.title === "string" ? { title: file.title } : {}),
616
+ ...(typeof file.filetype === "string" ? { filetype: file.filetype } : {}),
617
+ },
618
+ ];
619
+ })
620
+ : undefined;
604
621
  let content;
605
622
  if (params.content !== undefined) {
606
623
  if (!params.content || typeof params.content !== "object" || Array.isArray(params.content)) {
@@ -638,6 +655,7 @@ export class BrokerSocketServer {
638
655
  ...(channel ? { channel } : {}),
639
656
  ...(content ? { content } : {}),
640
657
  ...(blocks && blocks.length > 0 ? { blocks } : {}),
658
+ ...(files && files.length > 0 ? { files } : {}),
641
659
  ...(agentName ? { agentName } : {}),
642
660
  ...(agentEmoji ? { agentEmoji } : {}),
643
661
  ...(agentOwnerToken ? { agentOwnerToken } : {}),
@@ -2,6 +2,9 @@ export interface SlackBridgeSettings {
2
2
  appId?: string;
3
3
  appToken?: string;
4
4
  appConfigToken?: string;
5
+ skinTheme?: string;
6
+ slackCommandName?: string;
7
+ slackCommandNames?: string[];
5
8
  }
6
9
  export interface ManifestScopeChanges {
7
10
  addedBotScopes: string[];
@@ -14,6 +17,7 @@ export interface ResolvedDeployConfig {
14
17
  appId?: string;
15
18
  appConfigToken?: string;
16
19
  appToken?: string;
20
+ settings: SlackBridgeSettings;
17
21
  }
18
22
  export interface DeployResult {
19
23
  appId: string;
@@ -23,5 +27,6 @@ export declare function diffManifestScopes(beforeManifest: Record<string, unknow
23
27
  export declare function formatScopeChangeSummary(changes: ManifestScopeChanges): string[];
24
28
  export declare function resolveDeployConfig(settings: SlackBridgeSettings, env: NodeJS.ProcessEnv, cwd?: string): ResolvedDeployConfig;
25
29
  export declare function getDeployConfigError(config: ResolvedDeployConfig): string | null;
30
+ export declare function applyConfiguredSlashCommands(manifest: Record<string, unknown>, settings: SlackBridgeSettings): Record<string, unknown>;
26
31
  export declare function deploySlackManifest(config: ResolvedDeployConfig): Promise<DeployResult>;
27
32
  export declare function run(): Promise<void>;
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { execFile as execFileCallback } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { resolveSlackAgentCommandNames } from "./slack-agents-command.js";
6
7
  const execFile = promisify(execFileCallback);
7
8
  class SlackMethodError extends Error {
8
9
  method;
@@ -104,6 +105,7 @@ export function resolveDeployConfig(settings, env, cwd = process.cwd()) {
104
105
  appId: settings.appId ?? env.SLACK_APP_ID,
105
106
  appConfigToken: settings.appConfigToken ?? env.SLACK_APP_CONFIG_TOKEN ?? env.SLACK_CONFIG_TOKEN,
106
107
  appToken: settings.appToken ?? env.SLACK_APP_TOKEN,
108
+ settings,
107
109
  };
108
110
  }
109
111
  export function getDeployConfigError(config) {
@@ -123,6 +125,33 @@ export function getDeployConfigError(config) {
123
125
  }
124
126
  return messages.length > 0 ? messages.join(" ") : null;
125
127
  }
128
+ export function applyConfiguredSlashCommands(manifest, settings) {
129
+ const commandNames = resolveSlackAgentCommandNames(settings);
130
+ const features = asRecord(manifest.features) ?? {};
131
+ const oauthConfig = asRecord(manifest.oauth_config) ?? {};
132
+ const scopes = asRecord(oauthConfig.scopes) ?? {};
133
+ const botScopes = readScopeList(manifest, "bot");
134
+ const nextBotScopes = botScopes.includes("commands") ? botScopes : [...botScopes, "commands"];
135
+ return {
136
+ ...manifest,
137
+ features: {
138
+ ...features,
139
+ slash_commands: commandNames.map((command) => ({
140
+ command,
141
+ description: "Show the Pinet broker roster and current work",
142
+ usage_hint: "agents list [all]",
143
+ should_escape: false,
144
+ })),
145
+ },
146
+ oauth_config: {
147
+ ...oauthConfig,
148
+ scopes: {
149
+ ...scopes,
150
+ bot: nextBotScopes,
151
+ },
152
+ },
153
+ };
154
+ }
126
155
  async function parseManifestYaml(manifestPath) {
127
156
  try {
128
157
  const program = [
@@ -169,7 +198,7 @@ export async function deploySlackManifest(config) {
169
198
  }
170
199
  const appId = config.appId;
171
200
  const appConfigToken = config.appConfigToken;
172
- const manifest = await parseManifestYaml(config.manifestPath);
201
+ const manifest = applyConfiguredSlashCommands(await parseManifestYaml(config.manifestPath), config.settings);
173
202
  const previousManifest = await exportRemoteManifest(appId, appConfigToken);
174
203
  await validateManifest(manifest, appConfigToken);
175
204
  await updateManifest(appId, manifest, appConfigToken);
@@ -1,6 +1,10 @@
1
1
  import { buildFollowerRuntimeDiagnostic, buildPinetOwnerToken, extractPinetControlCommand, formatPinetInboxMessages, getFollowerOwnedThreadReclaims, getFollowerReconnectUiUpdate, partitionFollowerInboxEntries, resolvePinetMeshAuth, resolveRuntimeAgentIdentity, syncFollowerInboxEntries, syncTransferredSlackThreadContexts, } from "./helpers.js";
2
2
  import { drainFollowerAckBatches, hasDeliveredFollowerInboxIds, isFollowerInboxIdTracked, markFollowerInboxIdsDelivered, queueFollowerInboxIds, resetFollowerDeliveryState, } from "./follower-delivery.js";
3
3
  import { BrokerClient, DEFAULT_SOCKET_PATH } from "./broker/client.js";
4
+ function resolveBrokerSocketPath() {
5
+ const envPath = process.env.PINET_SOCKET_PATH?.trim();
6
+ return envPath && envPath.length > 0 ? envPath : DEFAULT_SOCKET_PATH;
7
+ }
4
8
  function getInboxIds(entries) {
5
9
  return entries.flatMap((entry) => (typeof entry.inboxId === "number" ? [entry.inboxId] : []));
6
10
  }
@@ -91,7 +95,7 @@ export function createFollowerRuntime(deps) {
91
95
  deps.refreshSettings();
92
96
  const meshAuth = resolvePinetMeshAuth(deps.getSettings());
93
97
  const client = new BrokerClient({
94
- path: DEFAULT_SOCKET_PATH,
98
+ path: resolveBrokerSocketPath(),
95
99
  ...(meshAuth.meshSecret ? { meshSecret: meshAuth.meshSecret } : {}),
96
100
  ...(meshAuth.meshSecretPath ? { meshSecretPath: meshAuth.meshSecretPath } : {}),
97
101
  });