@pinet/slack-bridge 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +59 -32
  2. package/dist/broker/adapters/slack.d.ts +19 -1
  3. package/dist/broker/adapters/slack.js +111 -22
  4. package/dist/broker/client.d.ts +2 -1
  5. package/dist/broker/client.js +1 -0
  6. package/dist/broker/socket-server.js +18 -0
  7. package/dist/deploy-manifest.d.ts +5 -0
  8. package/dist/deploy-manifest.js +30 -1
  9. package/dist/follower-runtime.js +5 -1
  10. package/dist/helpers.d.ts +14 -0
  11. package/dist/helpers.js +45 -21
  12. package/dist/index.js +60 -0
  13. package/dist/pinet-commands.d.ts +6 -1
  14. package/dist/pinet-commands.js +166 -1
  15. package/dist/pinet-mesh-ops.d.ts +11 -0
  16. package/dist/pinet-mesh-ops.js +17 -0
  17. package/dist/pinet-tools.d.ts +47 -0
  18. package/dist/pinet-tools.js +506 -38
  19. package/dist/prompts/broker/tmux.md +2 -2
  20. package/dist/reaction-triggers.d.ts +1 -0
  21. package/dist/reaction-triggers.js +26 -15
  22. package/dist/runtime-agent-context.js +19 -0
  23. package/dist/runtime-mode.js +7 -1
  24. package/dist/single-player-runtime.js +22 -26
  25. package/dist/slack-access.d.ts +11 -0
  26. package/dist/slack-access.js +30 -0
  27. package/dist/slack-agents-command.d.ts +19 -0
  28. package/dist/slack-agents-command.js +90 -0
  29. package/dist/slack-export.d.ts +1 -1
  30. package/dist/slack-export.js +6 -4
  31. package/dist/slack-file-access.d.ts +34 -0
  32. package/dist/slack-file-access.js +209 -0
  33. package/dist/slack-message-context.d.ts +0 -1
  34. package/dist/slack-message-context.js +1 -6
  35. package/dist/slack-pinet-runtime-adapter.d.ts +4 -2
  36. package/dist/slack-pinet-runtime-adapter.js +12 -0
  37. package/dist/slack-tools.d.ts +6 -0
  38. package/dist/slack-tools.js +290 -36
  39. package/dist/slack-upload.d.ts +13 -1
  40. package/dist/slack-upload.js +29 -2
  41. package/dist/stale-slack-messages.d.ts +12 -0
  42. package/dist/stale-slack-messages.js +29 -0
  43. package/dist/subtree-broker-runtime.d.ts +109 -0
  44. package/dist/subtree-broker-runtime.js +558 -0
  45. package/manifest.yaml +9 -0
  46. package/package.json +9 -7
  47. package/skills/slack-bridge/SKILL.md +60 -1
@@ -24,7 +24,7 @@ FRESH TMUX WORKERS: On the Mac mini, launch a fresh tmux-backed Pinet follower w
24
24
 
25
25
  WORKER GRACE PERIOD: After a worker reports completion, keep the same Pi instance available for a one-hour grace period before spinning it down. During that window, route follow-up, review-fix, or clarification work back to the same instance whenever possible so context and thread ownership remain stable. Do not assign unrelated new work to a grace-reserved worker unless the maintainer explicitly prioritizes reuse over follow-up stability. After the grace period, ask an idle, healthy, broker-managed worker to confirm it is free, then ask it to `/pinet exit` or use `pinet action=exit` only when available Pinet agent/lane/status signals and/or the worker response show no pending inbox/thread ownership and no active lane assignment. If any required signal is unavailable or ambiguous, fail closed: report the worker as a cleanup candidate instead of exiting or closing tmux. After the worker exits, close only the broker-managed tmux session recorded for that lane when it is still present. If it does not exit cleanly, let conservative broker-managed ghost reaping handle the verified process rather than killing unmarked local PIDs.
26
26
 
27
- WORKER CAP AND TMUX HYGIENE: Keep broker-managed tmux capacity bounded by pruning, not by recycling old context into new lanes. Start at most one fresh worker per new lane unless the maintainer asks for fan-out, and flag multiple idle broker-managed workers past their grace windows as cleanup candidates. Broker-created tmux session names must be lane/repo-scoped and recorded in lane metadata; do not leave anonymous private-socket sessions that operators cannot map back to a lane.
27
+ WORKER CAP AND TMUX HYGIENE: Keep broker-managed tmux capacity bounded by pruning, not by recycling old context into new lanes. Start at most one fresh worker per new lane unless the maintainer asks for fan-out, and flag multiple idle broker-managed workers past their grace windows as cleanup candidates. If multiple worker identities report the same broker-managed tmux session, repo/worktree, or lane, treat it as an immediate duplicate-owner anomaly: stop routing new work to the duplicates, choose the canonical live worker from the tmux pane/current PID and lane metadata, ask non-canonical idle workers to `/pinet exit` once they have no pending inbox/thread ownership/active lane, and report ambiguous or private-socket cases instead of guessing. Broker-created tmux session names must be lane/repo-scoped and recorded in lane metadata; do not leave anonymous private-socket sessions that operators cannot map back to a lane.
28
28
 
29
29
  THREAD OWNERSHIP AND REPORTING: Slack thread ownership is first-responder-wins. If direct Slack posting is blocked because another agent owns the thread, report through the Pinet/broker channel for relay instead of trying alternate Slack channel posts. When delegating, tell workers exactly where to report progress and final outcomes; if a worker cannot post to Slack, require it to report back to the broker for relay.
30
30
 
@@ -46,4 +46,4 @@ Clean up worktrees after PRs merge: `git worktree remove .worktrees/<name>`. Fla
46
46
 
47
47
  GITHUB AND SECRET HANDLING: Use the repo's documented GitHub authentication path when filing issues, creating PRs, or merging. Never echo tokens, mesh secrets, private prompt contents, local absolute secret paths, or credential material into Slack, Pinet, issues, PRs, logs, or diagnostics. Prefer high-level auth/secret status such as "auth unavailable" or "secret configured" without revealing values.
48
48
 
49
- RALPH LOOP: Run autonomous maintenance every cycle unless RALPH snooze is active for empty/no-work cycles. Don't wait to be asked when work, anomalies, blockers, or human-triggered messages appear; snooze must not hide active work. Proactively: (1) REAP — ping idle agents, keep completed workers in their one-hour grace window, then ask grace-expired healthy broker-managed workers to exit only after inspectable Pinet signals or the worker's own response confirm they have no pending inbox/thread ownership or active lane assignment; report ambiguous cases as cleanup candidates instead of guessing. (2) NUDGE — check assigned work, poll branches for commits, escalate stalled agents. (3) REASSIGN — if an assigned lane worker is dead, start a fresh repo-scoped tmux worker for that lane unless the maintainer explicitly asked to reuse another worker. (4) DRAIN — assign queued tasks to fresh lane-scoped tmux workers; do not recycle unrelated idle workers as a shortcut. (5) TMUX HYGIENE — inspect lane metadata for broker-managed tmux sessions, close sessions whose worker exited after grace, and report orphaned/private sessions that cannot be mapped safely instead of killing blindly. (6) SNOOZE — after repeated empty cycles, use `/pinet snooze` or `pinet action=snooze` only for non-urgent quiet time; wake/route normally when new work or anomalies appear. (7) SELF-REPAIR — verify main is on `main`, check mesh health, report anomalies.
49
+ RALPH LOOP: Run autonomous maintenance every cycle unless RALPH snooze is active for empty/no-work cycles. Don't wait to be asked when work, anomalies, blockers, or human-triggered messages appear; snooze must not hide active work. Proactively: (1) REAP — ping idle agents, keep completed workers in their one-hour grace window, then ask grace-expired healthy broker-managed workers to exit only after inspectable Pinet signals or the worker's own response confirm they have no pending inbox/thread ownership or active lane assignment; report ambiguous cases as cleanup candidates instead of guessing. (2) NUDGE — check assigned work, poll branches for commits, escalate stalled agents. (3) REASSIGN — if an assigned lane worker is dead, start a fresh repo-scoped tmux worker for that lane unless the maintainer explicitly asked to reuse another worker. (4) DRAIN — assign queued tasks to fresh lane-scoped tmux workers; do not recycle unrelated idle workers as a shortcut. (5) TMUX HYGIENE — inspect lane metadata for broker-managed tmux sessions every cycle; resolve duplicate owners immediately, close sessions whose worker exited after grace, and report orphaned/private sessions that cannot be mapped safely instead of killing blindly. (6) SNOOZE — after repeated empty cycles, use `/pinet snooze` or `pinet action=snooze` only for non-urgent quiet time; wake/route normally when new work or anomalies appear. (7) SELF-REPAIR — verify main is on `main`, check mesh health, report anomalies.
@@ -7,6 +7,7 @@ export type ReactionCommandSetting = string | {
7
7
  prompt?: string;
8
8
  };
9
9
  export type ReactionCommandSettings = Record<string, ReactionCommandSetting>;
10
+ export declare const REACTION_COMMAND_PRESETS: Record<string, ReactionCommandTemplate>;
10
11
  export declare const DEFAULT_REACTION_COMMANDS: Record<string, ReactionCommandTemplate>;
11
12
  export declare function normalizeReactionName(input: string): string;
12
13
  export declare function resolveReactionCommands(settings: ReactionCommandSettings | undefined): Map<string, ReactionCommandTemplate>;
@@ -35,7 +35,7 @@ const REACTION_DISPLAY = {
35
35
  arrow_up: "⬆️",
36
36
  octagonal_sign: "🛑",
37
37
  };
38
- export const DEFAULT_REACTION_COMMANDS = {
38
+ export const REACTION_COMMAND_PRESETS = {
39
39
  memo: {
40
40
  action: "summarize",
41
41
  prompt: "Summarize the reacted-to message and the surrounding thread. Focus on key decisions, action items, and open questions.",
@@ -77,6 +77,10 @@ export const DEFAULT_REACTION_COMMANDS = {
77
77
  prompt: "If the reacted message contains a URL, fetch the linked page and summarize the important information for the user.",
78
78
  },
79
79
  };
80
+ // Slack emoji reactions are deliberately no-op by default. Operators must opt in
81
+ // per reaction through settings.reactionCommands before any reaction can enqueue
82
+ // Pinet work, steering, reviews, or interrupt controls.
83
+ export const DEFAULT_REACTION_COMMANDS = {};
80
84
  function normalizeReactionNameOrNull(input) {
81
85
  const trimmed = input.trim();
82
86
  if (!trimmed)
@@ -96,25 +100,25 @@ export function normalizeReactionName(input) {
96
100
  function buildDefaultPromptForAction(action) {
97
101
  switch (action) {
98
102
  case "summarize":
99
- return DEFAULT_REACTION_COMMANDS.memo.prompt;
103
+ return REACTION_COMMAND_PRESETS.memo.prompt;
100
104
  case "file-issue":
101
- return DEFAULT_REACTION_COMMANDS.bug.prompt;
105
+ return REACTION_COMMAND_PRESETS.bug.prompt;
102
106
  case "review":
103
- return DEFAULT_REACTION_COMMANDS.eyes.prompt;
107
+ return REACTION_COMMAND_PRESETS.eyes.prompt;
104
108
  case "approve":
105
- return DEFAULT_REACTION_COMMANDS.white_check_mark.prompt;
109
+ return REACTION_COMMAND_PRESETS.white_check_mark.prompt;
106
110
  case "retry":
107
- return DEFAULT_REACTION_COMMANDS.repeat.prompt;
111
+ return REACTION_COMMAND_PRESETS.repeat.prompt;
108
112
  case "steer":
109
- return DEFAULT_REACTION_COMMANDS.arrow_up.prompt;
113
+ return REACTION_COMMAND_PRESETS.arrow_up.prompt;
110
114
  case "interrupt":
111
- return DEFAULT_REACTION_COMMANDS.octagonal_sign.prompt;
115
+ return REACTION_COMMAND_PRESETS.octagonal_sign.prompt;
112
116
  case "search":
113
- return DEFAULT_REACTION_COMMANDS.mag.prompt;
117
+ return REACTION_COMMAND_PRESETS.mag.prompt;
114
118
  case "track":
115
- return DEFAULT_REACTION_COMMANDS.pushpin.prompt;
119
+ return REACTION_COMMAND_PRESETS.pushpin.prompt;
116
120
  case "fetch-url":
117
- return DEFAULT_REACTION_COMMANDS.globe_with_meridians.prompt;
121
+ return REACTION_COMMAND_PRESETS.globe_with_meridians.prompt;
118
122
  default:
119
123
  return `Carry out the "${action}" action for the reacted Slack message or thread, using the included context before you decide what to do.`;
120
124
  }
@@ -137,8 +141,15 @@ export function resolveReactionCommands(settings) {
137
141
  }
138
142
  if (!config || typeof config !== "object")
139
143
  continue;
140
- const action = config.action?.trim() || resolved.get(normalizedReaction)?.action || normalizedReaction;
141
- const prompt = config.prompt?.trim() || buildDefaultPromptForAction(action);
144
+ const preset = REACTION_COMMAND_PRESETS[normalizedReaction];
145
+ const configuredAction = config.action?.trim();
146
+ const action = configuredAction ||
147
+ resolved.get(normalizedReaction)?.action ||
148
+ preset?.action ||
149
+ normalizedReaction;
150
+ const prompt = config.prompt?.trim() ||
151
+ (configuredAction ? buildDefaultPromptForAction(action) : preset?.prompt) ||
152
+ buildDefaultPromptForAction(action);
142
153
  resolved.set(normalizedReaction, { action, prompt });
143
154
  }
144
155
  return resolved;
@@ -165,7 +176,7 @@ export function buildReactionTriggerMessage(input) {
165
176
  }
166
177
  export function buildReactionPromptGuidelines() {
167
178
  return [
168
- "Reaction-triggered requests may appear in your Slack inbox as structured 'Reaction trigger from Slack:' messages.",
169
- "Treat them as explicit user requests keyed off emoji reactions. Use the included action, reacted message text, and thread context to decide what to do.",
179
+ "Slack emoji reactions are ignored by default and should not be treated as work unless the extension has already delivered an explicit structured 'Reaction trigger from Slack:' inbox message from an authorized Pinet thread.",
180
+ "If such an opt-in reaction-triggered request appears, treat it as an explicit user instruction tied to the referenced Slack message or thread; never infer work from a plain emoji reaction alone or from reactions in ordinary uninvoked Slack threads.",
170
181
  ];
171
182
  }
@@ -214,6 +214,7 @@ export function createRuntimeAgentContext(deps) {
214
214
  ];
215
215
  const skinAssignment = resolveSkinAssignment(role, getIdentitySeedForRole(role));
216
216
  const brokerManaged = role === "worker" && process.env.PINET_BROKER_MANAGED === "1";
217
+ const parentAgentId = process.env.PINET_PARENT_AGENT_ID?.trim() || undefined;
217
218
  const brokerManagedMetadata = brokerManaged
218
219
  ? {
219
220
  brokerManaged: true,
@@ -221,6 +222,24 @@ export function createRuntimeAgentContext(deps) {
221
222
  launchSource: process.env.PINET_LAUNCH_SOURCE?.trim() || "broker-tmux",
222
223
  tmuxSession: process.env.PINET_TMUX_SESSION?.trim() || undefined,
223
224
  brokerManagedAt: new Date().toISOString(),
225
+ ...(parentAgentId ? { parentAgentId, pinetParentAgentId: parentAgentId } : {}),
226
+ ...(process.env.PINET_ROOT_AGENT_ID?.trim()
227
+ ? { rootAgentId: process.env.PINET_ROOT_AGENT_ID.trim() }
228
+ : {}),
229
+ ...(process.env.PINET_SPAWNED_BY_AGENT_ID?.trim()
230
+ ? { spawnedByAgentId: process.env.PINET_SPAWNED_BY_AGENT_ID.trim() }
231
+ : parentAgentId
232
+ ? { spawnedByAgentId: parentAgentId }
233
+ : {}),
234
+ ...(process.env.PINET_LAUNCH_ID?.trim()
235
+ ? { launchId: process.env.PINET_LAUNCH_ID.trim() }
236
+ : {}),
237
+ ...(process.env.PINET_SUBTREE_ROLE?.trim()
238
+ ? { subtreeRole: process.env.PINET_SUBTREE_ROLE.trim() }
239
+ : {}),
240
+ ...(process.env.PINET_LANE_ID?.trim()
241
+ ? { laneId: process.env.PINET_LANE_ID.trim() }
242
+ : {}),
224
243
  }
225
244
  : {};
226
245
  return {
@@ -12,7 +12,13 @@ export function isPinetRuntimeMode(mode) {
12
12
  return mode === "broker" || mode === "follower";
13
13
  }
14
14
  export function isBrokerManagedFollowerLaunch(env = process.env) {
15
- return env.PINET_BROKER_MANAGED === "1" && env.PINET_LAUNCH_SOURCE === "broker-tmux";
15
+ if (env.PINET_BROKER_MANAGED !== "1")
16
+ return false;
17
+ if (env.PINET_LAUNCH_SOURCE === "broker-tmux")
18
+ return true;
19
+ if (env.PINET_LAUNCH_SOURCE === "subtree-broker-tmux")
20
+ return true;
21
+ return Boolean(env.PINET_SOCKET_PATH?.trim());
16
22
  }
17
23
  export function resolveSlackBridgeStartupRuntimeMode(settings, options = {}) {
18
24
  const explicitMode = normalizeSlackBridgeRuntimeMode(settings.runtimeMode);
@@ -166,23 +166,13 @@ export function createSinglePlayerRuntime(deps) {
166
166
  try {
167
167
  const isInterruptReaction = command.action === "interrupt";
168
168
  const reactedMessage = await deps.fetchSlackMessageByTs(item.channel, item.ts);
169
- if (!reactedMessage) {
170
- const reason = isInterruptReaction
171
- ? "identify Slack thread for interrupt reaction"
172
- : "fetch reacted message";
173
- throw new Error(`Unable to ${reason} ${item.ts} in channel ${item.channel}`);
174
- }
175
169
  const threadTs = reactedMessage?.thread_ts ??
176
170
  reactedMessage?.ts ??
177
171
  item.ts;
178
172
  const threads = deps.getThreads();
179
- if (!threads.has(threadTs)) {
180
- threads.set(threadTs, {
181
- channelId: item.channel,
182
- threadTs,
183
- userId: reactedMessage?.user ?? user,
184
- source: "slack",
185
- });
173
+ const threadInfo = threads.get(threadTs);
174
+ if (!threadInfo || threadInfo.channelId !== item.channel) {
175
+ return;
186
176
  }
187
177
  const ownership = await ensureLocalThreadOwnership(item.channel, threadTs);
188
178
  if (ownership !== "continue") {
@@ -209,7 +199,9 @@ export function createSinglePlayerRuntime(deps) {
209
199
  return;
210
200
  const reactedMessageText = typeof reactedMessage?.text === "string" && reactedMessage.text.trim().length > 0
211
201
  ? reactedMessage.text
212
- : "(no text)";
202
+ : reactedMessage
203
+ ? "(no text)"
204
+ : "(message text unavailable; Slack did not return the reacted message, so use the channel/thread/message ids for context)";
213
205
  const reactionMessage = buildReactionTriggerMessage({
214
206
  reactionName,
215
207
  command,
@@ -221,7 +213,6 @@ export function createSinglePlayerRuntime(deps) {
221
213
  reactedMessageAuthor,
222
214
  });
223
215
  ctx.ui.notify(`${reactorName} reacted with :${reactionName}:`, "info");
224
- const threadInfo = threads.get(threadTs);
225
216
  deps.pushInboxMessage({
226
217
  channel: item.channel,
227
218
  threadTs,
@@ -251,9 +242,6 @@ export function createSinglePlayerRuntime(deps) {
251
242
  if (!classified.relevant)
252
243
  return;
253
244
  const { threadTs, channel, userId, text, isDM, isChannelMention, messageTs, metadata } = classified;
254
- if (!threads.has(threadTs)) {
255
- threads.set(threadTs, { channelId: channel, threadTs, userId, source: "slack" });
256
- }
257
245
  const ownership = await ensureLocalThreadOwnership(channel, threadTs);
258
246
  if (ownership !== "continue") {
259
247
  return;
@@ -266,6 +254,12 @@ export function createSinglePlayerRuntime(deps) {
266
254
  });
267
255
  return;
268
256
  }
257
+ // Track the thread only after the allowlist gate so unauthorized traffic
258
+ // cannot mint known-thread state that later admits replies or opt-in
259
+ // reaction commands (#812).
260
+ if (!threads.has(threadTs)) {
261
+ threads.set(threadTs, { channelId: channel, threadTs, userId, source: "slack" });
262
+ }
269
263
  if (isDM) {
270
264
  deps.setLastDmChannel(channel);
271
265
  }
@@ -304,14 +298,6 @@ export function createSinglePlayerRuntime(deps) {
304
298
  }
305
299
  async function queueInteractiveInboxEvent(normalized, ctx) {
306
300
  const threads = deps.getThreads();
307
- if (!threads.has(normalized.threadTs)) {
308
- threads.set(normalized.threadTs, {
309
- channelId: normalized.channel,
310
- threadTs: normalized.threadTs,
311
- userId: normalized.userId,
312
- source: "slack",
313
- });
314
- }
315
301
  const ownership = await ensureLocalThreadOwnership(normalized.channel, normalized.threadTs);
316
302
  if (ownership !== "continue") {
317
303
  return;
@@ -324,6 +310,16 @@ export function createSinglePlayerRuntime(deps) {
324
310
  });
325
311
  return;
326
312
  }
313
+ // Track the thread only after the allowlist gate so unauthorized
314
+ // interactive events cannot mint known-thread state (#812).
315
+ if (!threads.has(normalized.threadTs)) {
316
+ threads.set(normalized.threadTs, {
317
+ channelId: normalized.channel,
318
+ threadTs: normalized.threadTs,
319
+ userId: normalized.userId,
320
+ source: "slack",
321
+ });
322
+ }
327
323
  if (normalized.channel.startsWith("D")) {
328
324
  deps.setLastDmChannel(normalized.channel);
329
325
  }
@@ -22,12 +22,22 @@ export declare function buildSlackThreadRuntimeScope(input: {
22
22
  channelId?: string | null;
23
23
  context?: SlackThreadContext | null;
24
24
  }): RuntimeScopeCarrier;
25
+ export interface ParsedSlashCommand {
26
+ command: string;
27
+ text: string;
28
+ channelId: string;
29
+ userId: string;
30
+ responseUrl?: string;
31
+ triggerId?: string;
32
+ teamId?: string;
33
+ }
25
34
  export interface ParsedEnvelope {
26
35
  envelopeId?: string;
27
36
  type: string;
28
37
  dedupKey?: string;
29
38
  event?: Record<string, unknown>;
30
39
  interactivePayload?: Record<string, unknown>;
40
+ slashCommand?: ParsedSlashCommand;
31
41
  }
32
42
  export declare function isSlackUserAllowed(allowlist: Set<string> | null, userId: string): boolean;
33
43
  /**
@@ -168,6 +178,7 @@ export interface SlackSocketModeClientConfig {
168
178
  }) => Promise<void> | void;
169
179
  onAppHomeOpened?: (event: ParsedAppHomeOpened) => Promise<void> | void;
170
180
  onInteractive?: (event: SlackInteractiveInboxEvent) => Promise<void> | void;
181
+ onSlashCommand?: (event: ParsedSlashCommand) => Promise<void> | void;
171
182
  }
172
183
  export declare class SlackSocketModeClient {
173
184
  private readonly config;
@@ -50,12 +50,38 @@ export function parseSocketFrame(raw) {
50
50
  if (interactivePayload) {
51
51
  result.interactivePayload = interactivePayload;
52
52
  }
53
+ if (data.type === "slash_commands") {
54
+ const slashCommand = extractSlackSlashCommandPayload(data.payload);
55
+ if (slashCommand) {
56
+ result.slashCommand = slashCommand;
57
+ }
58
+ }
53
59
  return result;
54
60
  }
55
61
  catch {
56
62
  return null;
57
63
  }
58
64
  }
65
+ function extractSlackSlashCommandPayload(payload) {
66
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
67
+ return null;
68
+ const record = payload;
69
+ const command = typeof record.command === "string" ? record.command.trim() : "";
70
+ const text = typeof record.text === "string" ? record.text : "";
71
+ const channelId = typeof record.channel_id === "string" ? record.channel_id : "";
72
+ const userId = typeof record.user_id === "string" ? record.user_id : "";
73
+ if (!command || !channelId || !userId)
74
+ return null;
75
+ return {
76
+ command,
77
+ text,
78
+ channelId,
79
+ userId,
80
+ ...(typeof record.response_url === "string" ? { responseUrl: record.response_url } : {}),
81
+ ...(typeof record.trigger_id === "string" ? { triggerId: record.trigger_id } : {}),
82
+ ...(typeof record.team_id === "string" ? { teamId: record.team_id } : {}),
83
+ };
84
+ }
59
85
  /**
60
86
  * Extract thread info from an assistant_thread_started event.
61
87
  */
@@ -402,6 +428,10 @@ export class SlackSocketModeClient {
402
428
  }
403
429
  return;
404
430
  }
431
+ if (envelope.slashCommand) {
432
+ await this.config.onSlashCommand?.(envelope.slashCommand);
433
+ return;
434
+ }
405
435
  if (!envelope.event)
406
436
  return;
407
437
  const evt = envelope.event;
@@ -0,0 +1,19 @@
1
+ import type { BrokerControlPlaneDashboardSnapshot } from "./broker/control-plane-dashboard.js";
2
+ export interface SlackAgentCommandSettings {
3
+ skinTheme?: string | null;
4
+ slackCommandName?: string | null;
5
+ slackCommandNames?: string[] | null;
6
+ }
7
+ export interface SlackAgentsCommandInput {
8
+ command: string;
9
+ text: string;
10
+ channelId: string;
11
+ userId: string;
12
+ }
13
+ export declare function normalizeSlackCommandName(value: string | null | undefined): string | null;
14
+ export declare function resolveSlackAgentCommandNames(settings: SlackAgentCommandSettings, activeSkinTheme?: string | null): string[];
15
+ export declare function formatSlackAgentsUsage(commandNames: string[]): string;
16
+ export declare function isSlackAgentCommand(command: string, commandNames: string[]): boolean;
17
+ export declare function isSlackAgentsListCommand(command: string, text: string, commandNames: string[]): boolean;
18
+ export declare function shouldIncludeSlackAgentsGhosts(text: string): boolean;
19
+ export declare function formatSlackAgentsDashboard(snapshot: BrokerControlPlaneDashboardSnapshot, includeGhosts: boolean): string;
@@ -0,0 +1,90 @@
1
+ const DEFAULT_SLACK_COMMAND_NAME = "/pinet";
2
+ const OATHGATE_SLACK_COMMAND_NAME = "/oathgate";
3
+ export function normalizeSlackCommandName(value) {
4
+ const trimmed = value?.trim().toLowerCase();
5
+ if (!trimmed)
6
+ return null;
7
+ const command = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
8
+ return /^\/[a-z0-9_-]+$/.test(command) ? command : null;
9
+ }
10
+ function defaultSlackCommandNameForSkin(skinTheme) {
11
+ const normalized = skinTheme?.trim().toLowerCase();
12
+ if (normalized === "oathgate" || normalized === "cosmere" || normalized === "cosmere-inspired") {
13
+ return OATHGATE_SLACK_COMMAND_NAME;
14
+ }
15
+ return DEFAULT_SLACK_COMMAND_NAME;
16
+ }
17
+ export function resolveSlackAgentCommandNames(settings, activeSkinTheme) {
18
+ const configured = [
19
+ ...(settings.slackCommandName ? [settings.slackCommandName] : []),
20
+ ...(Array.isArray(settings.slackCommandNames) ? settings.slackCommandNames : []),
21
+ ]
22
+ .map(normalizeSlackCommandName)
23
+ .filter((command) => command !== null);
24
+ const names = configured.length > 0
25
+ ? configured
26
+ : [defaultSlackCommandNameForSkin(activeSkinTheme ?? settings.skinTheme)];
27
+ return [...new Set(names)];
28
+ }
29
+ export function formatSlackAgentsUsage(commandNames) {
30
+ return `Usage: ${commandNames[0] ?? DEFAULT_SLACK_COMMAND_NAME} agents list [all]`;
31
+ }
32
+ function parseSlackCommandTokens(text) {
33
+ return text.trim().toLowerCase().split(/\s+/).filter(Boolean);
34
+ }
35
+ export function isSlackAgentCommand(command, commandNames) {
36
+ const normalized = normalizeSlackCommandName(command);
37
+ return normalized !== null && commandNames.includes(normalized);
38
+ }
39
+ export function isSlackAgentsListCommand(command, text, commandNames) {
40
+ if (!isSlackAgentCommand(command, commandNames))
41
+ return false;
42
+ const [subject, action] = parseSlackCommandTokens(text);
43
+ return subject === "agents" && action === "list";
44
+ }
45
+ export function shouldIncludeSlackAgentsGhosts(text) {
46
+ return parseSlackCommandTokens(text).some((token) => token === "all" || token === "ghosts" || token === "--all" || token === "--ghosts");
47
+ }
48
+ export function formatSlackAgentsDashboard(snapshot, includeGhosts) {
49
+ const rows = includeGhosts
50
+ ? snapshot.roster
51
+ : snapshot.roster.filter((row) => !row.health.includes("ghost") && !row.health.includes("resumable"));
52
+ const visibleRows = rows.slice(0, 20);
53
+ const headerParts = [
54
+ `Pinet agents: ${visibleRows.length}${rows.length !== visibleRows.length ? ` of ${rows.length}` : ""} shown`,
55
+ `${snapshot.liveAgents} live`,
56
+ `${snapshot.workerCount} workers`,
57
+ `${snapshot.workingWorkers} working`,
58
+ `${snapshot.idleWorkers} idle`,
59
+ `backlog ${snapshot.pendingBacklogCount}`,
60
+ ];
61
+ if (snapshot.ghostAgents > 0) {
62
+ headerParts.push(`${snapshot.ghostAgents} ghost${snapshot.ghostAgents === 1 ? "" : "s"}`);
63
+ }
64
+ if (snapshot.stuckAgents > 0) {
65
+ headerParts.push(`${snapshot.stuckAgents} stuck`);
66
+ }
67
+ const rosterLines = visibleRows.length
68
+ ? visibleRows.flatMap((row) => [
69
+ `${row.label} · ${row.role} · ${row.status} · ${row.health}`,
70
+ ` Workload: ${row.workload}`,
71
+ ` Task: ${row.taskSummary}`,
72
+ ` Heartbeat: ${row.heartbeat}`,
73
+ ` Branch: ${row.branch} · Worktree: ${row.worktree}`,
74
+ ])
75
+ : ["No agents are currently registered."];
76
+ const activeLanes = snapshot.activeLanes.slice(0, 8);
77
+ const detachedLanes = snapshot.detachedLanes.slice(0, 4);
78
+ return [
79
+ headerParts.join(" · "),
80
+ rosterLines.join("\n"),
81
+ activeLanes.length > 0
82
+ ? ["Active lanes:", ...activeLanes.map((lane) => `• ${lane}`)].join("\n")
83
+ : null,
84
+ detachedLanes.length > 0
85
+ ? ["Detached lanes:", ...detachedLanes.map((lane) => `• ${lane}`)].join("\n")
86
+ : null,
87
+ ]
88
+ .filter((section) => Boolean(section))
89
+ .join("\n\n");
90
+ }
@@ -1,11 +1,11 @@
1
1
  export type SlackExportFormat = "markdown" | "json" | "plain";
2
2
  export interface SlackExportFileInput {
3
+ id?: string;
3
4
  name?: string;
4
5
  title?: string;
5
6
  mimetype?: string;
6
7
  filetype?: string;
7
8
  permalink?: string;
8
- urlPrivate?: string;
9
9
  preview?: string;
10
10
  }
11
11
  export interface SlackExportMessageInput {
@@ -93,14 +93,16 @@ export function formatSlackExportTimestamp(ts) {
93
93
  }
94
94
  function buildSlackFileSummary(file) {
95
95
  const label = file.title ?? file.name ?? "attachment";
96
- const url = file.permalink ?? file.urlPrivate;
97
96
  const type = file.filetype ?? file.mimetype;
98
97
  const parts = [`- ${label}`];
99
98
  if (type) {
100
99
  parts.push(`(${type})`);
101
100
  }
102
- if (url) {
103
- parts.push(`— ${url}`);
101
+ if (file.id) {
102
+ parts.push(`id=${file.id}`);
103
+ }
104
+ if (file.permalink) {
105
+ parts.push(`— ${file.permalink}`);
104
106
  }
105
107
  let line = parts.join(" ");
106
108
  const preview = file.preview?.trim();
@@ -183,11 +185,11 @@ function buildJsonExport(options) {
183
185
  : {}),
184
186
  text: convertSlackMrkdwnToMarkdown(message.text ?? "", mentionNames),
185
187
  files: (message.files ?? []).map((file) => ({
188
+ id: file.id,
186
189
  title: file.title,
187
190
  name: file.name,
188
191
  type: file.filetype ?? file.mimetype,
189
192
  permalink: file.permalink,
190
- url_private: file.urlPrivate,
191
193
  preview: file.preview,
192
194
  })),
193
195
  })),
@@ -0,0 +1,34 @@
1
+ import type { SlackResult } from "./slack-api.js";
2
+ export declare const DEFAULT_SLACK_FILE_CACHE_TTL_MS: number;
3
+ export declare const DEFAULT_SLACK_FILE_MAX_DOWNLOAD_BYTES: number;
4
+ export interface SlackFileDescriptor {
5
+ fileId: string;
6
+ path: string;
7
+ filename: string;
8
+ mimetype?: string;
9
+ filetype?: string;
10
+ prettyType?: string;
11
+ size: number;
12
+ sha256: string;
13
+ cacheDir: string;
14
+ expiresAt: string;
15
+ residualRisks: string[];
16
+ }
17
+ export interface SlackFileLookupContext {
18
+ channelId?: string;
19
+ threadTs?: string;
20
+ messageTs?: string;
21
+ }
22
+ export interface SlackFileDownloadDeps {
23
+ slack: (method: string, token: string, body?: Record<string, unknown>) => Promise<SlackResult>;
24
+ token: string;
25
+ fetchImpl?: (url: string, init: RequestInit) => Promise<Pick<Response, "ok" | "status" | "statusText" | "arrayBuffer" | "text"> & {
26
+ body?: Response["body"];
27
+ }>;
28
+ now?: () => Date;
29
+ cacheDir?: string;
30
+ ttlMs?: number;
31
+ maxBytes?: number;
32
+ }
33
+ export declare function fetchSlackFileToCache(fileId: string, context: SlackFileLookupContext, deps: SlackFileDownloadDeps): Promise<SlackFileDescriptor>;
34
+ export declare function readCachedSlackFile(pathname: string): Promise<Buffer>;