@pinet/slack-bridge 0.2.2 → 0.2.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.
package/README.md CHANGED
@@ -1,157 +1,185 @@
1
1
  # slack-bridge (Pinet)
2
2
 
3
- Slack assistant integration for [pi](https://github.com/badlogic/pi-mono) multi-agent broker, thread routing, and inbox tools powered by Socket Mode.
3
+ Connect pi coding agents to Slack. Pinet provides multi-agent coordination, thread routing, and inbox tools through Socket Mode.
4
4
 
5
- ## Install
5
+ ## Install Pinet
6
6
 
7
- Install the latest Pinet Slack bridge pi package:
7
+ Install the latest version:
8
8
 
9
9
  ```bash
10
10
  pi install npm:@pinet/slack-bridge
11
11
  ```
12
12
 
13
- Or pin an exact published version for reproducible installs:
13
+ Pin a specific version:
14
14
 
15
15
  ```bash
16
- pi install npm:@pinet/slack-bridge@0.1.0
16
+ pi install npm:@pinet/slack-bridge@0.2.2
17
17
  ```
18
18
 
19
- For package managers or local inspection, the npm package is also installable directly:
19
+ For direct npm installation:
20
20
 
21
21
  ```bash
22
22
  npm install @pinet/slack-bridge
23
23
  ```
24
24
 
25
- ## Package metadata and publishing
25
+ ## What you need
26
26
 
27
- This package declares pi package/gallery metadata in [`package.json`](./package.json):
27
+ - a Slack workspace where you can install apps
28
+ - Node.js 22 or later
29
+ - pi installed on your system
28
30
 
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.
31
+ ## Set up your Slack app
34
32
 
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:
33
+ ### Create the app
38
34
 
39
- ```bash
40
- cd slack-bridge
41
- npm pack --dry-run
42
- ```
43
-
44
- This package is included in the full npm publish set tracked in
45
- [`../plans/npm-publish.md`](../plans/npm-publish.md). Use the GitHub Actions
46
- workflow's default dry-run/readiness path for validation; do not publish, tag, or
47
- bump versions without explicit maintainer release approval.
48
-
49
- ## Prerequisites
50
-
51
- - A Slack workspace where you have permission to install apps
52
- - Node.js 22+ (uses native `fetch` and `WebSocket`)
53
- - [pi](https://github.com/badlogic/pi-mono) installed
54
-
55
- ## Slack App Setup
35
+ 1. Go to [api.slack.com/apps](https://api.slack.com/apps)
36
+ 2. Select 'Create New App'
37
+ 3. Choose 'From a manifest'
38
+ 4. Select your workspace
39
+ 5. Paste the contents of [`manifest.yaml`](./manifest.yaml)
40
+ 6. If you want a different Slack command, change `features.slash_commands[0].command` before creating and set `slackCommandName` or `slackCommandNames` in `settings.json`
41
+ 7. Select 'Create'
56
42
 
57
- ### 1. Create the app
43
+ The manifest configures Socket Mode, the assistant view, bot scopes, event subscriptions, and slash commands automatically.
58
44
 
59
- 1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App**
60
- 2. Choose **From a manifest**
61
- 3. Select your workspace
62
- 4. Paste the contents of [`manifest.yaml`](./manifest.yaml) from this directory
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**
45
+ ### Get your tokens
65
46
 
66
- The manifest configures Socket Mode, the assistant view, all required bot scopes, event subscriptions, and the packaged Pinet slash-command default automatically.
47
+ Generate two tokens:
67
48
 
68
- ### 2. Generate tokens
49
+ | Token | Where to find it | Format |
50
+ | --------------- | ------------------------------------------------------------------------------- | ------------ |
51
+ | App-Level Token | Basic Information → App-Level Tokens → Generate (add `connections:write` scope) | `xapp-1-...` |
52
+ | Bot Token | OAuth & Permissions → Install to Workspace → Bot User OAuth Token | `xoxb-...` |
69
53
 
70
- You need two tokens:
54
+ ### Required bot scopes
71
55
 
72
- | Token | Where to find it | Looks like |
73
- | ------------------- | -------------------------------------------------------------------------------- | ------------ |
74
- | **App-Level Token** | Basic Information → App-Level Tokens → Generate (with `connections:write` scope) | `xapp-1-...` |
75
- | **Bot Token** | OAuth & Permissions → Install to Workspace → Bot User OAuth Token | `xoxb-...` |
76
-
77
- ### 3. Required bot scopes
78
-
79
- These are included in the manifest, but for reference:
56
+ The manifest includes these scopes:
80
57
 
81
58
  ```
82
59
  app_mentions:read assistant:write bookmarks:read
83
60
  bookmarks:write canvases:read canvases:write
84
61
  channels:history channels:read chat:write
85
- commands files:read files:write groups:history
86
- groups:read im:history im:read
87
- im:write pins:read pins:write
88
- reactions:read reactions:write users:read
62
+ commands files:read files:write
63
+ groups:history groups:read im:history
64
+ im:read im:write pins:read
65
+ pins:write reactions:read reactions:write
66
+ users:read
89
67
  ```
90
68
 
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.
69
+ The `commands` scope enables slash commands. The `files:read` scope is needed because Slack uses `files.info` for canvas comment pagination.
92
70
 
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.
71
+ ## Configure Pinet
94
72
 
95
- ## Configuration
96
-
97
- Add your tokens to `~/.pi/agent/settings.json`:
73
+ Add tokens, runtime mode, and access rules to `~/.pi/agent/settings.json`:
98
74
 
99
75
  ```json
100
76
  {
101
77
  "slack-bridge": {
102
78
  "botToken": "xoxb-your-bot-token",
103
- "appToken": "xapp-your-app-token"
79
+ "appToken": "xapp-your-app-token",
80
+ "runtimeMode": "single",
81
+ "allowedUsers": ["U_YOUR_USER_ID"]
104
82
  }
105
83
  }
106
84
  ```
107
85
 
108
- That's it for a minimal setup. Start pi and Pinet appears in Slack's sidebar.
86
+ Pinet stays off unless you set `runtimeMode`, `autoConnect`, or `autoFollow`. Start pi after you configure access.
109
87
 
110
- ### Environment variables (alternative)
88
+ ### Use environment variables instead
111
89
 
112
90
  ```bash
113
91
  export SLACK_BOT_TOKEN="xoxb-..."
114
92
  export SLACK_APP_TOKEN="xapp-..."
115
93
  ```
116
94
 
117
- Settings in `settings.json` take priority over env vars.
95
+ Settings in `settings.json` override environment variables.
118
96
 
119
- ### Optional Pinet mesh auth
97
+ ## Control who can use Pinet
120
98
 
121
- Shared-secret mesh auth is **optional**. You can configure it with either settings keys or environment variables:
99
+ Slack access is default-deny. Configure one of these:
100
+
101
+ - `allowedUsers`: list specific Slack user IDs
102
+ - `allowAllWorkspaceUsers: true`: allow everyone in the workspace
103
+
104
+ Example with specific users:
122
105
 
123
106
  ```json
124
107
  {
125
108
  "slack-bridge": {
126
- "meshSecret": "shared-secret"
109
+ "botToken": "xoxb-...",
110
+ "appToken": "xapp-...",
111
+ "allowedUsers": ["U_USER_ID_1", "U_USER_ID_2"]
127
112
  }
128
113
  }
129
114
  ```
130
115
 
116
+ Find user IDs by selecting a user's profile in Slack and choosing 'Copy member ID'.
117
+
118
+ ## Optional settings
119
+
120
+ ### Mesh authentication
121
+
122
+ Shared-secret authentication is optional. Configure it with settings or environment variables:
123
+
124
+ Settings:
125
+
131
126
  ```json
132
127
  {
133
128
  "slack-bridge": {
134
- "meshSecretPath": "/Users/alice/.config/pi/pinet.secret"
129
+ "meshSecret": "your-shared-secret"
135
130
  }
136
131
  }
137
132
  ```
138
133
 
134
+ Or use a file:
135
+
136
+ ```json
137
+ {
138
+ "slack-bridge": {
139
+ "meshSecretPath": "/path/to/secret.txt"
140
+ }
141
+ }
142
+ ```
143
+
144
+ Environment variables:
145
+
139
146
  ```bash
140
- export PINET_MESH_SECRET="shared-secret"
147
+ export PINET_MESH_SECRET="your-shared-secret"
141
148
  # or
142
- export PINET_MESH_SECRET_PATH="$HOME/.config/pi/pinet.secret"
149
+ export PINET_MESH_SECRET_PATH="/path/to/secret.txt"
143
150
  ```
144
151
 
145
- Behavior and precedence:
152
+ How it works:
153
+
154
+ - settings override environment variables
155
+ - inline secrets override file paths
156
+ - if nothing is set, mesh auth is disabled
157
+ - brokers create the secret file if it does not exist
158
+ - followers need an existing file or will show an error
146
159
 
147
- - `slack-bridge.meshSecret` and `slack-bridge.meshSecretPath` override the environment fallbacks.
148
- - Inline secrets win over secret paths. If `meshSecret` or `PINET_MESH_SECRET` is set, the corresponding `*Path` value is ignored.
149
- - If all four values are unset, broker/follower mesh auth is disabled.
150
- - A broker started with `meshSecretPath` creates the secret file if it does not exist yet.
151
- - A follower started with `meshSecretPath` does **not** create the file. If the configured file is missing, follow fails with a clear error telling you to point at an existing file, provide `meshSecret` directly, or leave both unset to disable shared-secret auth.
152
- - A follower configured for mesh auth will fail closed against an older/no-auth broker with a clear compatibility error. It will **not** silently retry as an unauthenticated follower.
160
+ ### Require mentions in channels
153
161
 
154
- ### Full settings reference
162
+ Make Pinet respond only when mentioned in specific channels:
163
+
164
+ ```json
165
+ {
166
+ "slack-bridge": {
167
+ "ingressGuard": {
168
+ "requireMention": {
169
+ "channels": ["C_CHANNEL_ID"],
170
+ "mixedParticipantThreads": {
171
+ "enabled": true,
172
+ "trustedUsers": ["U_TRUSTED_USER"]
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ This is separate from `allowedUsers`. Authorization decides who can use Pinet. The guard decides when a mention is needed.
181
+
182
+ ### All configuration options
155
183
 
156
184
  ```json
157
185
  {
@@ -159,25 +187,32 @@ Behavior and precedence:
159
187
  "botToken": "xoxb-...",
160
188
  "appToken": "xapp-...",
161
189
  "runtimeMode": "single",
162
- "allowedUsers": ["U_EXAMPLE_MEMBER_ID"],
190
+ "allowedUsers": ["U_USER_ID"],
191
+ "allowAllWorkspaceUsers": false,
163
192
  "ingressGuard": {
164
193
  "requireMention": {
165
- "channels": ["C_EXTERNAL_CHANNEL_ID"],
194
+ "channels": ["C_CHANNEL_ID"],
166
195
  "mixedParticipantThreads": {
167
196
  "enabled": true,
168
- "trustedUsers": ["U_EXAMPLE_MEMBER_ID"]
197
+ "trustedUsers": ["U_USER_ID"]
169
198
  }
170
199
  }
171
200
  },
172
- "defaultChannel": "C_EXAMPLE_CHANNEL_ID",
201
+ "defaultChannel": "C_CHANNEL_ID",
173
202
  "logChannel": "#pinet-logs",
174
203
  "logLevel": "actions",
175
- "autoFollow": true,
204
+ "autoConnect": false,
205
+ "autoFollow": false,
176
206
  "ralphLoopIntervalMs": 300000,
177
207
  "ralphSnoozeAfterEmptyCycles": 0,
178
208
  "ralphSnoozeDurationMs": 1800000,
179
- "meshSecretPath": "/Users/alice/.config/pi/pinet.secret",
180
- "suggestedPrompts": [{ "title": "Status", "message": "What are you working on?" }],
209
+ "meshSecretPath": "/path/to/secret",
210
+ "suggestedPrompts": [
211
+ {
212
+ "title": "Status",
213
+ "message": "What are you working on?"
214
+ }
215
+ ],
181
216
  "security": {
182
217
  "readOnly": false,
183
218
  "requireConfirmation": ["slack:create_channel"],
@@ -187,450 +222,255 @@ Behavior and precedence:
187
222
  }
188
223
  ```
189
224
 
190
- Slack access is now **default-deny** unless you configure one of these explicitly:
191
-
192
- - `allowedUsers` / `SLACK_ALLOWED_USERS` allow only specific Slack user IDs
193
- - `allowAllWorkspaceUsers: true` / `SLACK_ALLOW_ALL_WORKSPACE_USERS=true` explicit workspace-wide opt-in
194
-
195
- Optional explicit invocation guard: `ingressGuard.requireMention` is off by default. Set `channels` to Slack channel IDs where otherwise-actionable messages must mention the bot (`@pinet` / `<@bot>`), and set `mixedParticipantThreads.enabled: true` with `trustedUsers` to require a mention in Pinet-owned Slack threads once anyone outside `trustedUsers` plus the bot has participated. This guard is orthogonal to `allowedUsers`: sender authorization still decides who may invoke Pinet; the guard only decides when an explicit mention is required.
196
-
197
- | Key | Required | Description |
198
- | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- |
199
- | `botToken` | **yes** | Bot User OAuth Token (`xoxb-...`) |
200
- | `appToken` | **yes** | App-Level Token for Socket Mode (`xapp-...`) |
201
- | `allowedUsers` | no | Slack user IDs that can interact; when unset, access is denied unless `allowAllWorkspaceUsers` is true |
202
- | `allowAllWorkspaceUsers` | no | Explicit opt-in for workspace-wide Slack access when you do not want a user allowlist |
203
- | `ingressGuard.requireMention` | no | Optional Slack ingress guard requiring `@pinet` in configured channel IDs and/or mixed-participant Pinet-owned threads |
204
- | `defaultChannel` | no | Default channel for the `slack` dispatcher `post_channel` action |
205
- | `logChannel` | no | Channel for broker activity logs |
206
- | `logLevel` | no | `"errors"`, `"actions"` (default), or `"verbose"` |
207
- | `runtimeMode` | no | Explicit startup mode: `"off"`, `"single"`, `"broker"`, or `"follower"` |
208
- | `autoConnect` | no | Legacy compatibility alias for `runtimeMode: "single"` |
209
- | `autoFollow` | no | Legacy compatibility alias for follower startup when a broker socket exists |
210
- | `ralphLoopIntervalMs` | no | Broker RALPH maintenance cadence in milliseconds; defaults to `300000` (5 minutes), valid range `1000`-`2147483647` |
211
- | `ralphSnoozeAfterEmptyCycles` | no | Broker RALPH auto-snooze trigger after N empty cycles; defaults to `0` (disabled), valid range `0`-`100` |
212
- | `ralphSnoozeDurationMs` | no | Broker RALPH auto-snooze duration in milliseconds; defaults to `1800000` (30 minutes), valid range `60000`-`86400000` |
213
- | `skinTheme` | no | Pinet presentation skin selected at broker startup/reload (`default`, `foundation`, `cosmere`, or free-form) |
214
- | `slackCommandName` | no | Slack web app slash command name for `agents list`; defaults to `/pinet`, or `/oathgate` for Oathgate/Cosmere skins |
215
- | `slackCommandNames` | no | Optional list of accepted/deployed Slack slash command aliases when one app needs multiple command names |
216
- | `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
217
- | `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
218
- | `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
219
- | `security.readOnly` | no | Runtime-block write-capable tools for Slack-triggered turns, including core tools like `bash`, `edit`, and `write` |
220
- | `security.requireConfirmation` | no | Runtime-require Slack approval before matching tools execute; core tools need a specific Slack thread context |
221
- | `security.blockedTools` | no | Runtime-block matching tools for Slack-triggered turns, including core tools |
222
-
223
- ## Scope carrier model (compatibility-first)
224
-
225
- Slack/Pinet now threads a first-class runtime `scope` carrier through shared message contracts and runtime metadata.
226
-
227
- For this first slice:
228
-
229
- - **workspace/install scope** is carried as compatibility-first metadata for Slack
230
- - **instance scope** is also carried as a first-class compatibility carrier
231
- - today’s single-workspace deployments use one default compatibility scope
232
- - a missing or empty Slack `teamId` stays **unknown** — the bridge does not invent a fake workspace ID
233
- - these carriers are metadata only in this slice; enforcement and multi-install behavior land later in `#547` / `#550`
234
-
235
- ## Usage
236
-
237
- Once configured, Pinet appears in Slack's sidebar. Users open it, type a message, and the pi agent responds.
225
+ | Setting | Description | Default |
226
+ | ------------------------ | ------------------------------------------------------ | -------------------- |
227
+ | `botToken` | Bot User OAuth Token (required) | none |
228
+ | `appToken` | App-Level Token for Socket Mode (required) | none |
229
+ | `runtimeMode` | How Pinet runs (`off`, `single`, `broker`, `follower`) | `off` |
230
+ | `allowedUsers` | Slack user IDs who can use Pinet | none |
231
+ | `allowAllWorkspaceUsers` | Allow all workspace members | `false` |
232
+ | `defaultChannel` | Where to post updates | none |
233
+ | `logChannel` | Where to post logs | none |
234
+ | `logLevel` | What to log (`errors`, `actions`, `verbose`) | `actions` |
235
+ | `autoConnect` | Start as a single instance when `runtimeMode` is unset | `false` |
236
+ | `autoFollow` | Start as follower if broker exists | `false` |
237
+ | `ralphLoopIntervalMs` | How often to check for stalls (milliseconds) | `300000` (5 minutes) |
238
+ | `meshSecret` | Shared secret for mesh auth | none |
239
+ | `meshSecretPath` | File containing shared secret | none |
238
240
 
239
- ```
240
- User opens Pinet in Slack sidebar
241
- └─► types a message
242
- └─► 👀 reaction appears (thinking)
243
- └─► message queued for pi agent
244
- └─► agent responds via slack_send
245
- └─► 👀 removed, reply appears in thread
246
- ```
241
+ ## Using Pinet
247
242
 
248
- Messages queue while the agent is busy. When the agent finishes, it automatically drains the inbox and responds.
249
-
250
- ### Reaction triggers
251
-
252
- 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.
253
-
254
- ### Available tools
255
-
256
- Slack-bridge uses progressive disclosure to keep the per-turn tool surface
257
- small:
258
-
259
- | Tool | Description |
260
- | ------------- | --------------------------------------------------------------------------- |
261
- | `slack_inbox` | Hot-path inbox drain for pending incoming Slack messages |
262
- | `slack_send` | Hot-path reply tool for Slack assistant threads |
263
- | `slack` | Dispatcher for all non-hot Slack actions; call `action: "help"` for schemas |
264
-
265
- Cold Slack actions live behind the `slack` dispatcher:
266
-
267
- | Dispatcher action | Description |
268
- | ---------------------- | --------------------------------------------------------------------------------- |
269
- | `react` | Add an emoji reaction to a message |
270
- | `read` | Read messages from a thread |
271
- | `upload` | Upload files, snippets, or diffs into Slack |
272
- | `file` | Download Slack-hosted files to a controlled local temp cache by file ID |
273
- | `schedule` | Schedule a message for later delivery |
274
- | `post_channel` | Post to a channel (by name or ID) |
275
- | `delete` | Delete a bot-posted message or an entire thread |
276
- | `read_channel` | Read channel history or a thread in a channel |
277
- | `create_channel` | Create a new Slack channel |
278
- | `project_create` | Create a project channel + RFC canvas + bot invite in one call |
279
- | `pin` | Pin or unpin a message |
280
- | `bookmark` | Add, list, or remove channel bookmarks |
281
- | `export` | Export a thread as markdown, plain text, or JSON |
282
- | `presence` | Check if users are active, away, or in DND |
283
- | `canvas_comments_read` | Read comments attached to a verified canvas by canvas ID or channel canvas lookup |
284
- | `canvas_create` | Create a standalone or channel canvas |
285
- | `canvas_update` | Append, prepend, or replace canvas content |
286
- | `modal_open` | Open a modal from a trigger interaction |
287
- | `modal_push` | Push a new step onto a modal stack |
288
- | `modal_update` | Update an existing open modal |
289
- | `confirm_action` | Request user confirmation before a dangerous action |
290
-
291
- Use `slack` with `action: "help"` for the action catalogue, or
292
- `action: "help", args: { "topic": "canvas_update" }` for a specific JSON
293
- schema and example invocations. Dispatcher responses use a consistent
294
- `{ "status", "data", "errors", "warnings" }` envelope. Guardrails match
295
- cold Slack actions as `slack:<action>` (for example `slack:upload` or
296
- `slack:canvas_update`); legacy `slack_<action>` patterns are accepted during
297
- migration.
298
-
299
- #### Tool and workflow usage notes
300
-
301
- - **Reply where the work arrived.** Use `slack_send` for assistant-thread
302
- replies. If a task was delivered in a Slack thread, acknowledge briefly,
303
- do the work, report blockers immediately, and finish with the outcome. If
304
- you know only a channel/thread pair, use dispatcher action `post_channel`
305
- with `channel` and optional `thread_ts` instead.
306
- - **Channel posting is explicit.** `post_channel` posts to a named channel or
307
- channel ID. When `channel` is omitted, it first resolves a provided
308
- `thread_ts` to a tracked thread channel, then falls back to `defaultChannel`
309
- from settings. `slack_send` is intentionally narrower and resolves the
310
- current tracked assistant thread/DM context.
311
- - **Rich messages use Block Kit JSON.** Pass `blocks` directly to
312
- `slack_send` or `post_channel`; keep `text` as the notification/fallback.
313
- Block Kit builder tools are not registered by this package. Load the bundled
314
- `slack-bridge` skill for copyable status-report, button, code, and diff
315
- templates. The package also bundles `pinet-skin-creator` for safely drafting
316
- or reviewing curated Pinet skin descriptors and character/status-vocabulary
317
- pools before changing runtime skin wiring.
318
- - **Modal helpers are patterns, not hot tools.** Use dispatcher actions
319
- `modal_open`, `modal_push`, and `modal_update` with Slack view JSON. Open or
320
- push immediately after receiving a fresh `trigger_id`; Slack trigger IDs
321
- expire quickly. Include `thread_ts` when submissions should route back to an
322
- original assistant thread.
323
- - **Uploads are for bulky artifacts.** Use `upload` for logs, screenshots,
324
- long diffs, and generated files instead of large inline messages. Inline
325
- uploads require `filename`; path uploads are guarded and must stay within the
326
- current working directory or system temp directory. `slack_send` also accepts
327
- `files: [{ path, filename?, title?, filetype? }]` so one assistant-thread
328
- reply can contain both text and local binary attachments in the same Slack
329
- file upload message. Slack external file uploads cannot include Block Kit in
330
- that same message, so omit `blocks` when sending files or send a separate
331
- block-only reply.
332
- - **Inbound Slack files are fetched explicitly.** Incoming file-share messages
333
- preserve safe `slackFiles` metadata such as file ID, name, type, size, and
334
- permalink, but private Slack download URLs are not exposed in normal tool
335
- output. To inspect raw content, call dispatcher action `file` with
336
- `op: "download"`, `file_id`, and optionally `thread_ts`, `message_ts`, and
337
- `channel`. The bot fetches the file with Slack bot auth, stores it under the
338
- system temp `pi-slack-files` cache with best-effort TTL cleanup, and returns a
339
- descriptor containing the local path, filename, type, size, SHA-256, expiry,
340
- and residual privacy risks.
341
- - **Upload host egress note.** The second upload leg goes to Slack file upload
342
- hosts (`files.slack.com`/`uploads.slack.com`) for the raw payload. In
343
- environments with restricted egress this can fail with `403` (proxy
344
- allowlist) or DNS errors after `files.getUploadURLExternal`; verify the proxy
345
- allowlist first, or route through an environment that can reach those hosts.
346
- - **Upload metadata note.** Slack snippet uploads attempt to use inferred
347
- `snippet_type` values for inline content and retry with plain upload metadata
348
- when Slack returns `invalid_arguments`, preserving syntax highlighting for
349
- supported types while avoiding hard failures on unsupported snippet types.
350
- - **Canvases are long-lived docs.** `canvas_create` creates standalone or
351
- channel canvases. If Slack rejects channel tab creation with
352
- `canvas_tab_creation_failed`, it falls back to a standalone canvas attached to
353
- the channel, attempts to bookmark the canvas URL, and returns the fallback
354
- `canvas_id` for future `canvas_update` calls. `canvas_update` can append,
355
- prepend, replace the whole canvas, or replace a matched section;
356
- `canvas_comments_read` is read-only and limited to verified canvas targets.
357
- - **Scheduling, pins, and bookmarks are durable affordances.** Use `schedule`
358
- for delayed reminders instead of waiting; use `pin` for important thread
359
- messages; use `bookmark` for persistent channel-header links to repos,
360
- dashboards, docs, or runbooks.
361
- - **Presence helps choose timing.** Use `presence` before pinging humans when
362
- active/away/DND status affects routing or whether to schedule a follow-up.
363
- - **Destructive actions stay constrained.** `delete` can remove only messages
364
- posted by the current bot and every delete call requires `confirm: true`.
365
- Whole-thread deletion additionally requires `thread: true` and succeeds only
366
- when every message in the target thread belongs to the current bot. Prefer
367
- asking for explicit approval before destructive cleanup.
368
- - **Confirm guarded actions in the same thread.** If guardrails require
369
- confirmation, call `confirm_action` with the target `thread_ts`, exact tool
370
- name, and the exact action string required by the guarded tool. The safest
371
- flow is: attempt the guarded call, copy the `requires confirmation for action
372
- ...` string from the error, request confirmation, wait for the user's approval
373
- via `slack_inbox`, then retry the guarded call unchanged. Batched
374
- multi-thread Slack turns cannot satisfy a single-thread confirmation.
375
- - **Plain emoji reactions are not tasks.** Slack emoji reactions are ignored
376
- unless `reactionCommands` explicitly opts that emoji into structured
377
- reaction-trigger handling and the reacted message belongs to an already
378
- authorized Pinet thread. If an opt-in reaction-triggered request or a Block
379
- Kit/modal interaction payload arrives through `slack_inbox` with metadata,
380
- treat it as a user instruction tied to the referenced Slack thread or
381
- message.
382
-
383
- #### Common dispatcher examples
384
-
385
- Reply in the current Slack assistant thread with Block Kit:
243
+ ### In Slack
386
244
 
387
- ```json
388
- {
389
- "text": "Deploy complete — branch main, checks passed.",
390
- "blocks": [
391
- {
392
- "type": "section",
393
- "fields": [
394
- { "type": "mrkdwn", "text": "*Branch*\n`main`" },
395
- { "type": "mrkdwn", "text": "*Checks*\n✅ lint/typecheck/test" }
396
- ]
397
- }
398
- ]
399
- }
400
- ```
245
+ Talk to Pinet:
401
246
 
402
- Post a channel/thread update through the dispatcher:
247
+ - Direct message: open a DM with Pinet
248
+ - In channels: mention `@pinet` (or your app name)
249
+ - Slack slash command: type `/pinet agents list` or `/pinet agents list all`
403
250
 
404
- ```json
405
- {
406
- "action": "post_channel",
407
- "args": {
408
- "channel": "#pinet-logs",
409
- "thread_ts": "1712345678.000100",
410
- "text": "PR #123 is ready for review."
411
- }
412
- }
413
- ```
251
+ ### Pi commands
414
252
 
415
- Upload a generated diff snippet:
253
+ Run these inside pi.
416
254
 
417
- ```json
418
- {
419
- "action": "upload",
420
- "args": {
421
- "content": "diff --git a/README.md b/README.md\n...",
422
- "filename": "docs.diff",
423
- "filetype": "diff",
424
- "title": "Docs changes",
425
- "thread_ts": "1712345678.000100"
426
- }
427
- }
428
- ```
255
+ Main commands:
256
+
257
+ - `/pinet` - show available commands
258
+ - `/pinet status` - show current Pinet status
259
+ - `/pinet logs` - show recent broker activity logs
260
+ - `/pinet rename [name]` - rename this agent
261
+ - `/pinet free` - mark this agent idle
262
+
263
+ Coordinator commands:
264
+
265
+ - `/pinet start` or `/pinet broker` - become the broker
266
+ - `/pinet follow` - become a follower
267
+ - `/pinet unfollow` - disconnect from broker
268
+ - `/pinet reload <agent>` - ask another agent to reload
269
+ - `/pinet exit <agent>` - ask another agent to exit
270
+ - `/pinet snooze [duration|off|status]` - quiet empty RALPH cycles
271
+ - `/pinet subtree [start|status|spawn|stop]` - manage subtree broker mode
429
272
 
430
- Request confirmation before a guarded destructive action after copying the
431
- exact action string from the guardrail error:
273
+ ### From pi
274
+
275
+ Use the Pinet dispatcher for agent coordination:
432
276
 
433
277
  ```json
434
278
  {
435
- "action": "confirm_action",
279
+ "action": "send",
436
280
  "args": {
437
- "thread_ts": "1712345678.000100",
438
- "tool": "slack:delete",
439
- "action": "channel=#pinet-logs | thread_ts=1712345678.000100 | ts=1712345678.000200 | thread=false"
281
+ "to": "@worker",
282
+ "message": "Please review PR #123"
440
283
  }
441
284
  }
442
285
  ```
443
286
 
444
- #### Canvas comment inspection
445
-
446
- The `canvas_comments_read` dispatcher action is intentionally narrow:
447
-
448
- - it validates the target with `canvases.sections.lookup` before reading comment pages via `files.info`
449
- - it needs `files:read` because Slack exposes canvas comments through the file API surface
450
- - it will **not** inspect generic Slack files, non-canvas file comments, or full canvas body/history
451
-
452
- ### Slash commands
287
+ Common actions:
453
288
 
454
- | Command | Description |
455
- | -------------------------- | ---------------------------------------------------------- |
456
- | `/pinet <action>` | Unified Pinet command surface; run `/pinet help` for usage |
457
- | `/pinet status` | Show connection status, threads, and agent identity |
458
- | `/pinet rename` | Change the agent's display name |
459
- | `/pinet logs` | Show recent broker activity log entries |
460
- | `/<app> agents list [all]` | Slack-native broker roster, workload, task, and lane view |
289
+ - `send` - send a message to an agent or broker-only channel
290
+ - `read` - read this agent's inbox
291
+ - `schedule` - schedule a future wake-up
292
+ - `free` - mark this agent idle
293
+ - `help` - discover actions and schemas
461
294
 
462
- ## Runtime modes
295
+ Use `slack_send` for hot-path Slack replies. Use the `slack` dispatcher for uploads, canvases, pins, bookmarks, and other Slack actions.
463
296
 
464
- `slack-bridge` now treats runtime mode as an explicit concept:
297
+ ## Architecture
465
298
 
466
- | Mode | Meaning |
467
- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
468
- | `off` | Slack bridge is loaded, but **no Slack Socket Mode ingress** and no coordination runtime are started. |
469
- | `single` | One local Pi session owns Slack ingress and local thread/inbox ownership only. No broker DB/socket/client, no RALPH/control plane, no mesh auth, no multi-agent surface. |
470
- | `broker` | The session runs the broker coordination runtime. |
471
- | `follower` | The session connects to an existing broker as a worker runtime. |
299
+ ### Broker and followers
472
300
 
473
- Startup selection:
301
+ Pinet can run as:
474
302
 
475
- - `runtimeMode` is the explicit startup selector.
476
- - `autoConnect` is a legacy compatibility alias for `runtimeMode: "single"`.
477
- - `autoFollow` is a legacy compatibility alias for `runtimeMode: "follower"` when a broker socket is available.
478
- - explicit `runtimeMode` wins over the legacy flags.
479
- - `/pinet start` and `/pinet follow` still switch the live session into broker/follower runtimes explicitly.
303
+ - single - one instance handles everything
304
+ - broker - coordinates and routes messages
305
+ - follower - receives work from the broker
480
306
 
481
- ## Scope carriers (compatibility-first)
307
+ The broker:
482
308
 
483
- `slack-bridge` now emits first-class runtime scope carriers in shared transport contracts and agent runtime metadata.
309
+ - watches Slack for messages
310
+ - assigns work to agents
311
+ - tracks who owns what
312
+ - syncs state across followers
484
313
 
485
- - `scope.workspace` models the current Slack install/workspace scope.
486
- - `scope.instance` models the current broker/runtime instance scope.
487
- - in the first slice, both stay **compatibility-first**: today’s singleton runtime gets one default compatibility scope
488
- - if Slack omits `team_id`, the carrier keeps the workspace id **unknown** instead of inventing a fake one
489
- - this slice is metadata/plumbing only: it does **not** change routing, enforcement, or multi-install orchestration yet
314
+ Followers:
490
315
 
491
- ## Pinet (Multi-Agent Mode)
316
+ - connect to the broker
317
+ - receive assigned work
318
+ - stay in sync automatically
492
319
 
493
- Pinet supports a broker/follower architecture for coordinating multiple pi agents over Slack.
320
+ ### RALPH maintenance loop
494
321
 
495
- ### Runtime composition boundary
322
+ RALPH keeps broker state healthy. It:
496
323
 
497
- Broker startup is composed as Pinet core plus injected transport adapter factories. `broker-runtime.ts` starts the broker DB/socket/router and skin/agent state, then calls `createAdapterBindings` to attach transports. The packaged Slack bridge passes `createSlackPinetRuntimeAdapterFactory(...)`, while tests use an in-memory non-Slack adapter to demonstrate the same boundary without Slack tokens or Slack-specific metadata. Adapter factories return `MessageAdapter` bindings; the core wires inbound delivery, registers adapters on the broker, and connects them.
324
+ - runs every 5 minutes by default
325
+ - checks worker presence
326
+ - releases stale claims held by unavailable workers
327
+ - observes pending backlog while broker maintenance handles assignment
328
+ - triggers wake-ups
498
329
 
499
- ### Quick start
500
-
501
- **Broker** (one per mesh — coordinates routing and health):
502
-
503
- ```
504
- /pinet start
505
- ```
330
+ Configure RALPH:
506
331
 
507
- **Follower** (workers that connect to the broker):
508
-
509
- ```
510
- /pinet follow
332
+ ```json
333
+ {
334
+ "slack-bridge": {
335
+ "ralphLoopIntervalMs": 120000,
336
+ "ralphSnoozeAfterEmptyCycles": 3,
337
+ "ralphSnoozeDurationMs": 1800000
338
+ }
339
+ }
511
340
  ```
512
341
 
513
- Or set `"runtimeMode": "follower"` in settings (or the legacy `"autoFollow": true`) to auto-connect when a broker is running.
342
+ ### Inbox and threading
514
343
 
515
- ### Broker prompt MD
344
+ Pinet maintains an inbox for each agent. Messages are:
516
345
 
517
- Broker coordination policy is loaded from Markdown. Configure `slack-bridge.brokerPrompt` to choose a packaged prompt preset such as `tmux` or to point at a custom Markdown file path. Relative paths resolve under the current repo/worktree root; `~/...` paths resolve under the user home directory. When no setting is present, the broker scans for the first valid prompt in this order:
346
+ - routed based on thread ownership
347
+ - queued when agents are busy
348
+ - marked read when processed
349
+ - preserved across restarts
518
350
 
519
- 1. workspace override: `.pi/slack-bridge/tmux.md` under the current repo/worktree root
520
- 2. user-local override: `~/.pi/agent/slack-bridge/tmux.md`
521
- 3. packaged default: `dist/prompts/broker/tmux.md`
351
+ Thread ownership ensures continuity. Once an agent owns a thread, it keeps receiving those messages.
522
352
 
523
- Invalid higher-priority files (unsafe symlink/path escape, unreadable file, oversized content, invalid UTF-8/binary-looking content, or empty file) emit a concise warning and fall through to lower-priority candidates. Warnings identify only the candidate kind and reason; prompt bodies and private paths are not echoed.
353
+ ## Troubleshooting
524
354
 
525
- The packaged `tmux.md` captures the default fully autonomous / unchained broker operating policy: the broker coordinates and never implements, delegates to repo-scoped workers, starts fresh tmux-backed workers on the Mac mini for new repo-scoped tasks/lanes unless a maintainer explicitly asks for reuse, marks broker-launched followers with `PINET_BROKER_MANAGED=1 PINET_BROKER_AGENT_ID=<current-broker-agent-id> PINET_LAUNCH_SOURCE=broker-tmux PINET_TMUX_SESSION=<session>` so PID ownership is inspectable, records tmux session/socket and repo/worktree metadata in durable lane state, keeps completed workers available for a one-hour follow-up grace period, routes follow-up back to the same Pi instance when possible, asks grace-expired healthy idle broker-managed workers to exit only when inspectable Pinet signals or the worker confirm they are free, fails closed by reporting ambiguous cleanup candidates, prunes old broker-managed tmux capacity instead of recycling stale context into new lanes, observes RALPH loop maintenance expectations, handles Slack thread ownership/reporting caveats, and describes GitHub/secret handling without exposing secrets.
355
+ ### Socket Mode connection issues
526
356
 
527
- Only broker prompt content is replaceable. Broker runtime/tool restrictions remain code-owned and are appended after the loaded MD prompt, including the forbidden local `Agent` path and broker `edit`/`write` blocking. Followers keep append-only worker guidance and do not load broker prompt MD. Prompt changes are picked up on `/pinet start` / runtime restart; this slice does not hot-reload per turn.
357
+ If you see 'WebSocket error' or connection failures:
528
358
 
529
- ### Multi-agent tools
359
+ 1. Check your app token is valid
360
+ 2. Verify Socket Mode is enabled in your Slack app
361
+ 3. Check network connectivity
362
+ 4. Look for rate limiting (Slack allows 10 connections per app)
530
363
 
531
- | Tool | Description |
532
- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
533
- | `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `sessions`, `lanes`, `ports`, `spawn`, `reload`, `exit`) |
364
+ ### Permission errors
534
365
 
535
- 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=sessions`, `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.
366
+ If Pinet cannot perform actions:
536
367
 
537
- 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.
368
+ 1. Check the bot is in the channel (invite with `/invite @pinet`)
369
+ 2. Verify bot scopes match the manifest
370
+ 3. Reinstall the app to update permissions
371
+ 4. Check `allowedUsers` includes the right user IDs
538
372
 
539
- 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.
373
+ ### Messages not received
540
374
 
541
- Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, agent lists, and session lookups. Bulky read/agent/session 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, agent metadata, stable session IDs, or local session JSONL paths.
375
+ If Pinet does not respond:
542
376
 
543
- `pinet action=agents` shows a broker-safe session reference (`session:<digest>`) alongside pid in verbose roster output without exposing raw local paths by default. Use `pinet action=sessions args.agent_name="Frozen Hazel Whale"` to search live and historical worker sessions by display name; the search also accepts `agent_id`, `thread_id`, `repo`, `worktree_path`, `tmux_session`, `since`, `until`, and `limit`. Default output redacts path-bearing stable IDs; add `args.full=true` (or JSON plus `full`) only in local/debug contexts when the broker needs the exact stable ID or Pi session JSONL path for inspection/resume.
377
+ 1. Check Socket Mode shows 'Connected' in Slack app settings
378
+ 2. Verify event subscriptions are enabled
379
+ 3. Check `allowedUsers` or `allowAllWorkspaceUsers`
380
+ 4. Look in the log channel for errors
381
+ 5. Try `/pinet status` to check if Pinet is running
544
382
 
545
- 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.
383
+ ### Stalled agents
546
384
 
547
- Scheduled Pinet wake-ups use the same durable read surface: due wake-ups are persisted/stamped as Pinet follow-up mail and surfaced through compact `pinet action=read` pointers rather than direct reminder-body prompts. Wake-up bodies and metadata are treated as mail content only; they do not trigger Pinet remote-control commands such as `/exit`, `/reload`, or structured `pinet:control` JSON.
385
+ If work gets stuck:
548
386
 
549
- Durable lane metadata is stored in SQLite and can be inspected/updated with `pinet action=lanes`. PM-mode lanes can record the accountable follower/PM, implementation lead, participant roles (`pm`, `lead`, `implementer`, `reviewer`, `second_pass_reviewer`, etc.), linked issue/PR, state, and summary. The `detached` lane state means a lane is manually supervised by a human; broker/RALPH/status surfaces keep it visible but should not treat it as normal auto-reassignment work without explicit human/broker action.
387
+ 1. Check `/pinet status` for current state.
388
+ 2. Check `/pinet logs` for repeated failures.
389
+ 3. Wait for RALPH to run automatically.
390
+ 4. Reduce `ralphLoopIntervalMs` for faster recovery if needed.
550
391
 
551
- Durable local port leases are stored in SQLite and can be managed with `pinet action=ports`. Use `op=acquire` with `purpose` and `ttl_ms` to reserve either a requested `port` (for example `3000`) or the first free port in `min_port..max_port` (default `49152..65535`, host default `127.0.0.1`). Use `op=renew` with `lease_id` and `ttl_ms`, `op=release`, `op=status`, `op=list`, or `op=expire`. Follower RPC access is scoped to the caller-owned leases; broker-local maintenance can still expire all stale leases. Active leases are unique by `(host, port)`; broker maintenance expires stale leases conservatively, and process-kill behavior should be layered on explicit cleanup hooks rather than hidden in lease acquisition.
392
+ ## Package information
552
393
 
553
- Broker-mode ghost cleanup is deliberately conservative. The broker stores follower PIDs in the agent registry, but it only sends real process signals for ghosts that registered with broker-managed launch metadata (`PINET_BROKER_MANAGED=1`) and still verify as Pi follower processes. Reaping sends `SIGTERM` first and schedules a bounded `SIGKILL` only if the same verified broker-managed process remains; unmarked or mismatched PIDs are never killed.
394
+ ### Publishing metadata
554
395
 
555
- RALPH snooze quiets non-urgent empty maintenance cycles without disabling human-triggered routing. Use `/pinet snooze 30m no work available` or `pinet action=snooze args.op=set args.duration=30m` to quiet the broker manually, `/pinet snooze off` or `op=clear` to wake it, and `/pinet status` / Home tab to inspect snooze state. An empty cycle means no active live workers, no active tracked assignments, no visible RALPH anomalies, no pending backlog, no assigned backlog from broker maintenance, no maintenance anomalies, no pending task-assignment report, and no tracked task progress change. If active work, anomalies, or task progress appears during snooze, RALPH wakes and reports normally. Auto-snooze is opt-in via `ralphSnoozeAfterEmptyCycles`; the default is disabled.
396
+ The package declares pi metadata in [`package.json`](./package.json):
556
397
 
557
- ### Pinet command surface
398
+ - `keywords` includes `pi-package` for gallery discovery
399
+ - `pi.extensions` points to `./dist/index.js`
400
+ - `pi.skills` points to bundled skills
401
+ - No preview assets yet
558
402
 
559
- 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.
403
+ Check the package contents:
560
404
 
561
- | Command | Description |
562
- | ------------------------------------------ | ----------------------------------------------------------------------------- |
563
- | `/pinet start` | Start as the mesh broker |
564
- | `/pinet follow` | Connect as a follower worker |
565
- | `/pinet unfollow` | Disconnect from the broker |
566
- | `/pinet reload <agent>` | Ask another agent to reload |
567
- | `/pinet exit <agent>` | Ask another agent to exit |
568
- | `/pinet free` | Mark this agent as idle |
569
- | `/pinet snooze [duration/off/status]` | Quiet empty RALPH cycles while preserving human-triggered wake/route behavior |
570
- | `/pinet subtree [start/status/spawn/stop]` | Run this worker as a local subtree broker for child followers |
405
+ ```bash
406
+ cd slack-bridge
407
+ npm pack --dry-run
408
+ ```
571
409
 
572
- ### Pinet skins
410
+ ### Development
573
411
 
574
- Pinet skin selection is configuration-driven. Set `skinTheme` under the `slack-bridge` settings object (for example, `"skinTheme": "foundation"`) and restart/reload the broker; broker startup applies the configured presentation to broker and follower registrations. Skin selection updates mesh presentation only: names, emoji palette, persona/tone guidance, and optional display vocabulary for statuses. Core roles and states stay skin-neutral (`broker`, `worker`, `idle`, `working`, routing, repo, and guardrails are not redefined by skins).
412
+ Build the package:
575
413
 
576
- Built-in skins:
414
+ ```bash
415
+ cd slack-bridge
416
+ pnpm build
417
+ ```
577
418
 
578
- - `default` / `classic` — preserves the current whimsical animal names, animal emoji palette, and playful-but-focused persona.
579
- - `foundation` / `foundation/space` / `space` — JSON descriptor with curated institutional sci-fi characters, full-name aliases, and archive, relay, frontier, and crisis-room flavor.
580
- - `cosmere` / `cosmere-inspired` / `oathgate` — JSON descriptor with curated/prebaked 1–3 word identities, static emoji, and whimsical Mistborn/Stormlight/Emberdark-inspired agents, spren, artifacts, places, and jokes while avoiding exact third-party character names.
419
+ Run tests:
581
420
 
582
- Free-form themes are still accepted as deterministic legacy/custom presentation themes. Shipped non-default skins live in `skins/*.json`; use the bundled `pinet-skin-creator` skill to author and review curated character/name/persona/status-vocabulary pools before adding runtime descriptors.
421
+ ```bash
422
+ pnpm test
423
+ ```
583
424
 
584
- ### How it works
425
+ Deploy the Slack manifest:
585
426
 
586
- - The **broker** runs Slack Socket Mode, routes messages to agents, and monitors health via the RALPH loop. The loop defaults to every 5 minutes and can be configured with `ralphLoopIntervalMs` under `slack-bridge` settings.
587
- - **Followers** connect to the broker over a local Unix socket, poll for work, and report results
588
- - Agents can optionally authenticate using a shared local secret (`meshSecret` or `meshSecretPath`); when both are unset, mesh auth is disabled
589
- - Thread ownership is first-responder-wins — the first agent to reply claims the thread
427
+ ```bash
428
+ pnpm deploy:slack
429
+ ```
590
430
 
591
431
  ## Security
592
432
 
593
- - **User access**: Slack access is default-deny. Set `allowedUsers` for a narrow allowlist, or `allowAllWorkspaceUsers: true` only if you explicitly want workspace-wide access
594
- - **Tool guardrails**: `security.readOnly`, `security.requireConfirmation`, and `security.blockedTools` are runtime-enforced for Slack-triggered turns, including core tools such as `bash`, `edit`, and `write`
595
- - **Guardrail posture**: If Slack/Pinet access is enabled for admitted users and `security.readOnly`, `security.blockedTools`, and `security.requireConfirmation` are all effectively empty (`readOnly !== true` and both arrays are absent or empty), the bridge emits a startup/runtime warning and `/pinet status` shows `Guardrails: empty (warn-first posture; behavior unchanged)`. This is visibility-only: it does **not** auto-enable `readOnly`, block startup, or require an acknowledgement flow.
596
- - **Mesh authentication**: Optional. Configure `meshSecret` or `meshSecretPath` (or `PINET_MESH_SECRET` / `PINET_MESH_SECRET_PATH`) to require a shared secret; leave them unset to disable shared-secret auth. Configured followers fail closed on missing secret files or older/no-auth brokers rather than silently downgrading.
597
-
598
- Find Slack user IDs: click a user's profile → **More** → **Copy member ID**.
433
+ ### Default-deny access
599
434
 
600
- ---
435
+ Pinet requires explicit configuration to allow users. Without `allowedUsers` or `allowAllWorkspaceUsers`, nobody can use it.
601
436
 
602
- ## Development
437
+ ### Token safety
603
438
 
604
- ### Build
439
+ - Never commit tokens to git
440
+ - Use environment variables in production
441
+ - Rotate tokens regularly
442
+ - Use separate apps for development and production
605
443
 
606
- ```bash
607
- pnpm run build
608
- ```
444
+ ### Confirmation for dangerous actions
609
445
 
610
- ### Lint / Typecheck / Test
446
+ Configure confirmation for sensitive operations:
611
447
 
612
- ```bash
613
- pnpm lint
614
- pnpm typecheck
615
- pnpm test
616
- ```
617
-
618
- ### Deploy manifest to Slack
619
-
620
- ```bash
621
- pnpm deploy:slack
448
+ ```json
449
+ {
450
+ "slack-bridge": {
451
+ "security": {
452
+ "requireConfirmation": ["slack:create_channel", "slack:upload", "slack:delete"]
453
+ }
454
+ }
455
+ }
622
456
  ```
623
457
 
624
- 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.
458
+ ### Read-only mode
625
459
 
626
- ### Architecture
460
+ Prevent all modifications:
627
461
 
628
- - **Socket Mode** — outbound WebSocket, no public URL needed
629
- - **Zero runtime npm deps** — native `fetch`, `WebSocket`, `node:sqlite` (Node 22+)
630
- - **Hybrid inbox** — queue when busy, auto-drain when idle
631
- - **Reactions** — 👀 as a lightweight "thinking" indicator
632
- - **Thread persistence** — thread state survives `/reload`
462
+ ```json
463
+ {
464
+ "slack-bridge": {
465
+ "security": {
466
+ "readOnly": true
467
+ }
468
+ }
469
+ }
470
+ ```
633
471
 
634
- ## License
472
+ ## Support
635
473
 
636
- MIT. See [`LICENSE`](./LICENSE).
474
+ - [GitHub repository](https://github.com/gugu91/extensions)
475
+ - [Architecture documentation](../plans/)
476
+ - Check the log channel in Slack for runtime issues
@@ -117,5 +117,6 @@ export declare class BrokerSocketServer {
117
117
  private handleAdapterCapability;
118
118
  private handleLegacySlackProxy;
119
119
  private dispatchAdapterCapability;
120
+ private checkAdapterCapabilityThreadOwnership;
120
121
  private applyAdapterCapabilityEffects;
121
122
  }
@@ -1078,6 +1078,14 @@ export class BrokerSocketServer {
1078
1078
  }
1079
1079
  // ─── Adapter capability handler ───────────────────────
1080
1080
  async handleAdapterCapability(req, state) {
1081
+ // #855: adapter.capability must be identity-bound so the broker can
1082
+ // enforce thread ownership. Unregistered callers cannot own or claim
1083
+ // threads, so they must not be able to invoke outbound-side capabilities
1084
+ // (chat.postMessage in particular) that would race a first-responder
1085
+ // claim or take over a thread already owned by another agent.
1086
+ if (!state.agentId) {
1087
+ return rpcError(req.id, RPC_INVALID_PARAMS, "Not registered");
1088
+ }
1081
1089
  const params = req.params ?? {};
1082
1090
  const adapterName = typeof params.adapter === "string"
1083
1091
  ? params.adapter.trim()
@@ -1097,6 +1105,12 @@ export class BrokerSocketServer {
1097
1105
  return await this.dispatchAdapterCapability(req.id, adapterName, capability, capabilityParams, state);
1098
1106
  }
1099
1107
  async handleLegacySlackProxy(req, state) {
1108
+ // #855: legacy slack.proxy is a compatibility wrapper over
1109
+ // adapter.capability and must enforce the same registration bar so
1110
+ // unregistered callers cannot bypass thread ownership via chat.postMessage.
1111
+ if (!state.agentId) {
1112
+ return rpcError(req.id, RPC_INVALID_PARAMS, "Not registered");
1113
+ }
1100
1114
  const params = req.params ?? {};
1101
1115
  const method = typeof params.method === "string" ? params.method.trim() : "";
1102
1116
  if (!method) {
@@ -1112,6 +1126,15 @@ export class BrokerSocketServer {
1112
1126
  if (!adapter?.invokeCapability) {
1113
1127
  return rpcError(id, RPC_METHOD_NOT_FOUND, `Adapter ${adapterName} does not implement capability ${capability}`);
1114
1128
  }
1129
+ // #855: refuse cross-owner Slack chat.postMessage before hitting Slack.
1130
+ // Without this pre-check the adapter posts first and the broker races a
1131
+ // first-responder-wins claim via effects.claimThread — letting an
1132
+ // unauthorized follower take over a thread already owned by another
1133
+ // agent simply by winning the send.
1134
+ const ownershipError = this.checkAdapterCapabilityThreadOwnership(adapterName, capability, capabilityParams, state.agentId);
1135
+ if (ownershipError) {
1136
+ return rpcError(id, RPC_INVALID_PARAMS, `${errorPrefix}: ${ownershipError}`);
1137
+ }
1115
1138
  try {
1116
1139
  const response = await adapter.invokeCapability({ capability, params: capabilityParams });
1117
1140
  this.applyAdapterCapabilityEffects(adapterName, response, state);
@@ -1122,6 +1145,36 @@ export class BrokerSocketServer {
1122
1145
  return rpcError(id, RPC_INTERNAL_ERROR, `${errorPrefix}: ${message}`);
1123
1146
  }
1124
1147
  }
1148
+ checkAdapterCapabilityThreadOwnership(adapterName, capability, capabilityParams, callerAgentId) {
1149
+ if (adapterName !== "slack")
1150
+ return null;
1151
+ if (capability !== "api.call")
1152
+ return null;
1153
+ const method = typeof capabilityParams.method === "string" ? capabilityParams.method.trim() : "";
1154
+ if (method !== "chat.postMessage")
1155
+ return null;
1156
+ const inner = capabilityParams.params &&
1157
+ typeof capabilityParams.params === "object" &&
1158
+ !Array.isArray(capabilityParams.params)
1159
+ ? capabilityParams.params
1160
+ : {};
1161
+ const threadTs = typeof inner.thread_ts === "string" ? inner.thread_ts.trim() : "";
1162
+ if (!threadTs)
1163
+ return null;
1164
+ // Defense in depth (#855): even if a future call path forgot the
1165
+ // registration guard on the handler, refuse threaded chat.postMessage
1166
+ // for unregistered callers here — they cannot own a Slack thread and
1167
+ // must not be able to post into one.
1168
+ if (!callerAgentId) {
1169
+ return `Slack thread ${threadTs}: refusing threaded chat.postMessage from an unregistered caller`;
1170
+ }
1171
+ const thread = this.db.getThread(threadTs);
1172
+ if (!thread?.ownerAgent)
1173
+ return null;
1174
+ if (thread.ownerAgent === callerAgentId)
1175
+ return null;
1176
+ return `Slack thread ${threadTs} is already owned by another agent; refusing cross-owner chat.postMessage`;
1177
+ }
1125
1178
  applyAdapterCapabilityEffects(adapterName, response, state) {
1126
1179
  if (!state.agentId)
1127
1180
  return;
package/dist/index.js CHANGED
@@ -1002,6 +1002,7 @@ export default function (pi) {
1002
1002
  getBotUserId: () => botUserId,
1003
1003
  registerConfirmationRequest,
1004
1004
  pinetDelivery: {
1005
+ isEnabled: () => pinetEnabled,
1005
1006
  isAvailable: () => pinetEnabled && brokerRole !== null,
1006
1007
  sendSlackMessage: async (input) => {
1007
1008
  const content = {
@@ -34,6 +34,7 @@ function appendSlackThreadTransferNotice(body, threadId, channel) {
34
34
  `- channel: ${channel}`,
35
35
  `- To report directly in the transferred Slack thread, use slack_send with thread_ts ${threadId}; the channel is already recorded in Pinet.`,
36
36
  "- If slack_send says the thread is already owned by another agent, ask the broker to inspect ownership and transfer it again.",
37
+ "- If slack_send says the Pinet broker is unavailable, wait for the broker to reconnect \u2014 do NOT retry via post_channel; direct posting would bypass thread ownership (#855).",
37
38
  ].join("\n");
38
39
  }
39
40
  export function parseGitHubRemoteRepo(remoteUrl) {
@@ -26,6 +26,14 @@ export interface SlackPinetDeliveryResult {
26
26
  source: string;
27
27
  }
28
28
  export interface SlackPinetDeliveryPort {
29
+ /**
30
+ * True when Pinet is configured/enabled for this session, regardless of
31
+ * live broker connectivity. When true, threaded Slack replies MUST route
32
+ * through the broker; direct Slack fallback is refused even if the broker
33
+ * is momentarily unavailable, to preserve broker-enforced thread
34
+ * ownership (see gugu91/extensions#855).
35
+ */
36
+ isEnabled: () => boolean;
29
37
  isAvailable: () => boolean;
30
38
  sendSlackMessage: (input: SlackPinetDeliveryInput) => Promise<SlackPinetDeliveryResult>;
31
39
  }
@@ -180,25 +180,6 @@ function isAbortError(error) {
180
180
  function getErrorMessage(error) {
181
181
  return error instanceof Error ? error.message : String(error);
182
182
  }
183
- function isPinetDeliveryFallbackError(error) {
184
- const lower = getErrorMessage(error).toLowerCase();
185
- if (lower.includes("already owned"))
186
- return false;
187
- return (lower.includes("not running") ||
188
- lower.includes("unexpected state") ||
189
- lower.includes("unavailable") ||
190
- lower.includes("not connected") ||
191
- lower.includes("disconnected") ||
192
- lower.includes("timeout") ||
193
- lower.includes("timed out") ||
194
- lower.includes("econn") ||
195
- lower.includes("socket") ||
196
- lower.includes("no transport source") ||
197
- lower.includes("no transport channel") ||
198
- lower.includes("only allows local file paths") ||
199
- lower.includes("no adapter") ||
200
- lower.includes("identity is unavailable"));
201
- }
202
183
  function classifySlackDispatcherError(error) {
203
184
  const message = getErrorMessage(error);
204
185
  const lower = message.toLowerCase();
@@ -423,6 +404,7 @@ function buildSlackSendPromptGuidelines() {
423
404
  return [
424
405
  "Use slack_send for replies in the current Slack assistant thread; always reply where the task came from.",
425
406
  "For rich Block Kit JSON examples or modal/canvas patterns, load the slack-bridge skill instead of relying on tool schemas.",
407
+ "If slack_send fails with 'broker is unavailable' or 'already owned by another agent', do NOT retry via post_channel or a different tool \u2014 report the blocker in the same thread (or to the broker) and wait for ownership to be transferred; direct posting would bypass thread ownership (#855).",
426
408
  ];
427
409
  }
428
410
  function getSlackCanvasSummary(markdown) {
@@ -624,32 +606,31 @@ export function registerSlackTools(pi, deps) {
624
606
  }
625
607
  async function deliverSlackMessage(input) {
626
608
  const blocks = input.blocks ? normalizeSlackBlocksInput(input.blocks) : undefined;
627
- let fallbackReason;
628
- if (input.threadTs && pinetDelivery) {
629
- try {
630
- if (pinetDelivery.isAvailable()) {
631
- const result = await pinetDelivery.sendSlackMessage({
632
- threadId: input.threadTs,
633
- channel: input.channel,
634
- text: input.text,
635
- ...(blocks ? { blocks } : {}),
636
- ...(input.files ? { files: input.files } : {}),
637
- });
638
- return {
639
- threadTs: input.threadTs,
640
- channel: result.channel,
641
- blocksCount: blocks?.length ?? 0,
642
- delivery: "pinet",
643
- adapter: result.adapter,
644
- messageId: result.messageId,
645
- };
646
- }
647
- }
648
- catch (error) {
649
- if (!isPinetDeliveryFallbackError(error))
650
- throw error;
651
- fallbackReason = getErrorMessage(error);
652
- }
609
+ // Threaded Slack replies routed through Pinet MUST NOT fall back to direct
610
+ // Slack when the broker is unavailable or the delivery errors: direct
611
+ // Slack posting bypasses broker thread-ownership enforcement and lets any
612
+ // worker with a valid bot token take over a Slack thread it does not own.
613
+ // See gugu91/extensions#855. Single-player mode (isEnabled === false) and
614
+ // top-level channel posts (no threadTs) remain unaffected.
615
+ if (input.threadTs && pinetDelivery?.isEnabled()) {
616
+ if (!pinetDelivery.isAvailable()) {
617
+ throw new Error("Cannot post to Slack thread: Pinet broker is unavailable and direct Slack fallback would bypass thread-ownership enforcement. Wait for the broker to reconnect, or ask the broker to transfer this thread.");
618
+ }
619
+ const result = await pinetDelivery.sendSlackMessage({
620
+ threadId: input.threadTs,
621
+ channel: input.channel,
622
+ text: input.text,
623
+ ...(blocks ? { blocks } : {}),
624
+ ...(input.files ? { files: input.files } : {}),
625
+ });
626
+ return {
627
+ threadTs: input.threadTs,
628
+ channel: result.channel,
629
+ blocksCount: blocks?.length ?? 0,
630
+ delivery: "pinet",
631
+ adapter: result.adapter,
632
+ messageId: result.messageId,
633
+ };
653
634
  }
654
635
  if (input.files && input.files.length > 0) {
655
636
  if (blocks && blocks.length > 0) {
@@ -676,7 +657,6 @@ export function registerSlackTools(pi, deps) {
676
657
  channel: input.channel,
677
658
  blocksCount: blocks?.length ?? 0,
678
659
  delivery: "slack",
679
- ...(fallbackReason ? { fallbackReason } : {}),
680
660
  };
681
661
  }
682
662
  const body = {
@@ -700,7 +680,6 @@ export function registerSlackTools(pi, deps) {
700
680
  channel: input.channel,
701
681
  blocksCount: blocks?.length ?? 0,
702
682
  delivery: "slack",
703
- ...(fallbackReason ? { fallbackReason } : {}),
704
683
  };
705
684
  }
706
685
  const slackActionRegistry = new Map();
@@ -1479,7 +1458,6 @@ export function registerSlackTools(pi, deps) {
1479
1458
  filesCount: Array.isArray(params.files) ? params.files.length : 0,
1480
1459
  ...(delivery.adapter ? { adapter: delivery.adapter } : {}),
1481
1460
  ...(delivery.messageId ? { messageId: delivery.messageId } : {}),
1482
- ...(delivery.fallbackReason ? { fallbackReason: delivery.fallbackReason } : {}),
1483
1461
  },
1484
1462
  };
1485
1463
  },
@@ -2194,7 +2172,6 @@ export function registerSlackTools(pi, deps) {
2194
2172
  filesCount: Array.isArray(params.files) ? params.files.length : 0,
2195
2173
  ...(delivery.adapter ? { adapter: delivery.adapter } : {}),
2196
2174
  ...(delivery.messageId ? { messageId: delivery.messageId } : {}),
2197
- ...(delivery.fallbackReason ? { fallbackReason: delivery.fallbackReason } : {}),
2198
2175
  },
2199
2176
  };
2200
2177
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinet/slack-bridge",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Pi package for Pinet Slack assistant integration — multi-agent broker, thread routing, and inbox tools",
6
6
  "author": "Will Porcellini <5994936+gugu91@users.noreply.github.com>",
@@ -49,10 +49,10 @@
49
49
  "test": "vitest run"
50
50
  },
51
51
  "dependencies": {
52
- "@pinet/broker-core": "0.2.2",
53
- "@pinet/imessage-bridge": "0.2.2",
54
- "@pinet/pinet-core": "0.2.2",
55
- "@pinet/transport-core": "0.2.2",
52
+ "@pinet/broker-core": "0.2.4",
53
+ "@pinet/imessage-bridge": "0.2.4",
54
+ "@pinet/pinet-core": "0.2.4",
55
+ "@pinet/transport-core": "0.2.4",
56
56
  "@sinclair/typebox": "^0.34.49"
57
57
  },
58
58
  "types": "./dist/index.d.ts",