@pinet/slack-bridge 0.1.0 → 0.2.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.
Files changed (47) hide show
  1. package/README.md +86 -34
  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 +496 -36
  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 +16 -9
  47. package/skills/slack-bridge/SKILL.md +60 -1
package/README.md CHANGED
@@ -4,17 +4,42 @@ Slack assistant integration for [pi](https://github.com/badlogic/pi-mono) — mu
4
4
 
5
5
  ## Install
6
6
 
7
+ Install the latest Pinet Slack bridge pi package:
8
+
7
9
  ```bash
8
10
  pi install npm:@pinet/slack-bridge
9
11
  ```
10
12
 
11
- Or with npm:
13
+ Or pin an exact published version for reproducible installs:
14
+
15
+ ```bash
16
+ pi install npm:@pinet/slack-bridge@0.1.0
17
+ ```
18
+
19
+ For package managers or local inspection, the npm package is also installable directly:
12
20
 
13
21
  ```bash
14
22
  npm install @pinet/slack-bridge
15
23
  ```
16
24
 
17
- ## Publishing
25
+ ## Package metadata and publishing
26
+
27
+ This package declares pi package/gallery metadata in [`package.json`](./package.json):
28
+
29
+ - `keywords` includes `pi-package` for gallery discovery.
30
+ - `pi.extensions` points at the built extension entrypoint, `./dist/index.js`.
31
+ - `pi.skills` points at the bundled skill directory, `./skills`.
32
+ - No `pi.image` or `pi.video` preview is declared yet because this package does
33
+ not currently include a reviewed gallery image/video asset.
34
+
35
+ The published tarball is expected to include the package metadata, README,
36
+ Slack app manifest, built `dist/` files, bundled `skills/`, and LICENSE. Verify
37
+ that locally with:
38
+
39
+ ```bash
40
+ cd slack-bridge
41
+ npm pack --dry-run
42
+ ```
18
43
 
19
44
  This package is included in the full npm publish set tracked in
20
45
  [`../plans/npm-publish.md`](../plans/npm-publish.md). Use the GitHub Actions
@@ -35,9 +60,10 @@ bump versions without explicit maintainer release approval.
35
60
  2. Choose **From a manifest**
36
61
  3. Select your workspace
37
62
  4. Paste the contents of [`manifest.yaml`](./manifest.yaml) from this directory
38
- 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**
39
65
 
40
- 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.
41
67
 
42
68
  ### 2. Generate tokens
43
69
 
@@ -56,13 +82,13 @@ These are included in the manifest, but for reference:
56
82
  app_mentions:read assistant:write bookmarks:read
57
83
  bookmarks:write canvases:read canvases:write
58
84
  channels:history channels:read chat:write
59
- files:read files:write groups:history
85
+ commands files:read files:write groups:history
60
86
  groups:read im:history im:read
61
87
  im:write pins:read pins:write
62
88
  reactions:read reactions:write users:read
63
89
  ```
64
90
 
65
- `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.
66
92
 
67
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.
68
94
 
@@ -173,6 +199,8 @@ Slack access is now **default-deny** unless you configure one of these explicitl
173
199
  | `ralphSnoozeAfterEmptyCycles` | no | Broker RALPH auto-snooze trigger after N empty cycles; defaults to `0` (disabled), valid range `0`-`100` |
174
200
  | `ralphSnoozeDurationMs` | no | Broker RALPH auto-snooze duration in milliseconds; defaults to `1800000` (30 minutes), valid range `60000`-`86400000` |
175
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 |
176
204
  | `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
177
205
  | `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
178
206
  | `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
@@ -209,7 +237,7 @@ Messages queue while the agent is busy. When the agent finishes, it automaticall
209
237
 
210
238
  ### Reaction triggers
211
239
 
212
- 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.
213
241
 
214
242
  ### Available tools
215
243
 
@@ -229,6 +257,7 @@ Cold Slack actions live behind the `slack` dispatcher:
229
257
  | `react` | Add an emoji reaction to a message |
230
258
  | `read` | Read messages from a thread |
231
259
  | `upload` | Upload files, snippets, or diffs into Slack |
260
+ | `file` | Download Slack-hosted files to a controlled local temp cache by file ID |
232
261
  | `schedule` | Schedule a message for later delivery |
233
262
  | `post_channel` | Post to a channel (by name or ID) |
234
263
  | `delete` | Delete a bot-posted message or an entire thread |
@@ -282,7 +311,21 @@ migration.
282
311
  - **Uploads are for bulky artifacts.** Use `upload` for logs, screenshots,
283
312
  long diffs, and generated files instead of large inline messages. Inline
284
313
  uploads require `filename`; path uploads are guarded and must stay within the
285
- 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.
286
329
  - **Upload host egress note.** The second upload leg goes to Slack file upload
287
330
  hosts (`files.slack.com`/`uploads.slack.com`) for the raw payload. In
288
331
  environments with restricted egress this can fail with `403` (proxy
@@ -317,10 +360,13 @@ migration.
317
360
  ...` string from the error, request confirmation, wait for the user's approval
318
361
  via `slack_inbox`, then retry the guarded call unchanged. Batched
319
362
  multi-thread Slack turns cannot satisfy a single-thread confirmation.
320
- - **Reaction and interaction triggers are explicit tasks.** Reaction-triggered
321
- requests and Block Kit/modal interaction payloads arrive through
322
- `slack_inbox` with metadata; treat them as user instructions tied to the
323
- 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.
324
370
 
325
371
  #### Common dispatcher examples
326
372
 
@@ -393,12 +439,13 @@ The `canvas_comments_read` dispatcher action is intentionally narrow:
393
439
 
394
440
  ### Slash commands
395
441
 
396
- | Command | Description |
397
- | ----------------- | ---------------------------------------------------------- |
398
- | `/pinet <action>` | Unified Pinet command surface; run `/pinet help` for usage |
399
- | `/pinet status` | Show connection status, threads, and agent identity |
400
- | `/pinet rename` | Change the agent's display name |
401
- | `/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 |
402
449
 
403
450
  ## Runtime modes
404
451
 
@@ -469,13 +516,17 @@ Only broker prompt content is replaceable. Broker runtime/tool restrictions rema
469
516
 
470
517
  ### Multi-agent tools
471
518
 
472
- | Tool | Description |
473
- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
474
- | `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`) |
522
+
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.
524
+
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.
475
526
 
476
- 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.
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.
477
528
 
478
- 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`.
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.
479
530
 
480
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.
481
532
 
@@ -491,17 +542,18 @@ RALPH snooze quiets non-urgent empty maintenance cycles without disabling human-
491
542
 
492
543
  ### Pinet command surface
493
544
 
494
- 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.
495
546
 
496
- | Command | Description |
497
- | ------------------------------------- | ----------------------------------------------------------------------------- |
498
- | `/pinet start` | Start as the mesh broker |
499
- | `/pinet follow` | Connect as a follower worker |
500
- | `/pinet unfollow` | Disconnect from the broker |
501
- | `/pinet reload <agent>` | Ask another agent to reload |
502
- | `/pinet exit <agent>` | Ask another agent to exit |
503
- | `/pinet free` | Mark this agent as idle |
504
- | `/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 |
505
557
 
506
558
  ### Pinet skins
507
559
 
@@ -555,7 +607,7 @@ pnpm test
555
607
  pnpm deploy:slack
556
608
  ```
557
609
 
558
- 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.
559
611
 
560
612
  ### Architecture
561
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);