@pinet/slack-bridge 0.2.2 → 0.2.6

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 (93) hide show
  1. package/README.md +316 -451
  2. package/dist/activity-log.d.ts +10 -3
  3. package/dist/agent-event-runtime.d.ts +0 -2
  4. package/dist/agent-event-runtime.js +0 -1
  5. package/dist/agent-prompt-guidance.d.ts +1 -6
  6. package/dist/agent-prompt-guidance.js +1 -6
  7. package/dist/broker/adapters/slack.d.ts +4 -1
  8. package/dist/broker/adapters/slack.js +16 -9
  9. package/dist/broker/client.d.ts +21 -4
  10. package/dist/broker/client.js +65 -25
  11. package/dist/broker/hibernation-activation-authority.d.ts +22 -0
  12. package/dist/broker/hibernation-activation-authority.js +51 -0
  13. package/dist/broker/hibernation-activation.d.ts +75 -0
  14. package/dist/broker/hibernation-activation.js +95 -0
  15. package/dist/broker/hibernation-command-router.d.ts +54 -0
  16. package/dist/broker/hibernation-command-router.js +124 -0
  17. package/dist/broker/hibernation-runtime-adapters.d.ts +79 -0
  18. package/dist/broker/hibernation-runtime-adapters.js +473 -0
  19. package/dist/broker/hibernation-runtime-helpers.d.ts +146 -0
  20. package/dist/broker/hibernation-runtime-helpers.js +243 -0
  21. package/dist/broker/index.d.ts +16 -1
  22. package/dist/broker/index.js +68 -10
  23. package/dist/broker/lock-conflict.d.ts +109 -0
  24. package/dist/broker/lock-conflict.js +409 -0
  25. package/dist/broker/socket-server.d.ts +26 -0
  26. package/dist/broker/socket-server.js +287 -4
  27. package/dist/broker-delivery.d.ts +1 -0
  28. package/dist/broker-delivery.js +10 -1
  29. package/dist/broker-runtime.d.ts +14 -2
  30. package/dist/broker-runtime.js +114 -3
  31. package/dist/canvases.d.ts +12 -2
  32. package/dist/canvases.js +1 -3
  33. package/dist/compaction-gate.d.ts +7 -0
  34. package/dist/compaction-gate.js +35 -0
  35. package/dist/deploy-manifest.d.ts +8 -2
  36. package/dist/deploy-manifest.js +9 -7
  37. package/dist/follower-runtime.d.ts +5 -2
  38. package/dist/follower-runtime.js +39 -5
  39. package/dist/git-metadata.d.ts +5 -9
  40. package/dist/git-metadata.js +23 -42
  41. package/dist/guardrails.js +5 -0
  42. package/dist/helpers.d.ts +77 -27
  43. package/dist/helpers.js +150 -39
  44. package/dist/hibernation-config.d.ts +13 -0
  45. package/dist/hibernation-config.js +14 -0
  46. package/dist/home-tab.d.ts +13 -2
  47. package/dist/imessage-tools.d.ts +4 -1
  48. package/dist/inbox-drain-runtime.d.ts +1 -0
  49. package/dist/inbox-drain-runtime.js +5 -1
  50. package/dist/index.js +341 -108
  51. package/dist/pinet-commands.d.ts +14 -1
  52. package/dist/pinet-commands.js +93 -22
  53. package/dist/pinet-mesh-ops.d.ts +6 -4
  54. package/dist/pinet-mesh-ops.js +9 -1
  55. package/dist/pinet-runtime-composition.d.ts +2 -1
  56. package/dist/pinet-runtime-composition.js +14 -3
  57. package/dist/pinet-session-formatting.d.ts +26 -2
  58. package/dist/pinet-session-formatting.js +2 -0
  59. package/dist/pinet-tools.d.ts +14 -0
  60. package/dist/pinet-tools.js +355 -50
  61. package/dist/prompts/broker/default.md +3 -3
  62. package/dist/prompts/broker/tmux.md +3 -3
  63. package/dist/runtime-agent-context.d.ts +3 -3
  64. package/dist/runtime-agent-context.js +12 -6
  65. package/dist/runtime-mode.js +4 -0
  66. package/dist/session-ui-runtime.js +3 -1
  67. package/dist/single-player-runtime.d.ts +1 -1
  68. package/dist/single-player-runtime.js +21 -5
  69. package/dist/slack-access.d.ts +10 -2
  70. package/dist/slack-access.js +64 -22
  71. package/dist/slack-block-kit.d.ts +44 -2
  72. package/dist/slack-block-kit.js +5 -5
  73. package/dist/slack-file-access.d.ts +12 -1
  74. package/dist/slack-file-access.js +4 -4
  75. package/dist/slack-message-context.d.ts +9 -2
  76. package/dist/slack-modals.d.ts +4 -1
  77. package/dist/slack-modals.js +5 -5
  78. package/dist/slack-pinet-runtime-adapter.d.ts +1 -0
  79. package/dist/slack-pinet-runtime-adapter.js +10 -0
  80. package/dist/slack-presence.d.ts +5 -4
  81. package/dist/slack-request-runtime.d.ts +3 -2
  82. package/dist/slack-scope-diagnostics.d.ts +27 -2
  83. package/dist/slack-socket-dedup.d.ts +8 -5
  84. package/dist/slack-tools.d.ts +10 -0
  85. package/dist/slack-tools.js +51 -64
  86. package/dist/slack-upload.d.ts +17 -1
  87. package/dist/subtree-broker-runtime.d.ts +83 -2
  88. package/dist/subtree-broker-runtime.js +774 -109
  89. package/dist/tool-output-limits.d.ts +11 -0
  90. package/dist/tool-output-limits.js +20 -0
  91. package/dist/tool-registration-runtime.d.ts +3 -0
  92. package/dist/tool-registration-runtime.js +27 -0
  93. package/package.json +8 -8
@@ -10,9 +10,9 @@ IF ASKED TO CODE: Refuse politely and immediately delegate. Say: "I'm the broker
10
10
 
11
11
  ALLOWED — These are your responsibilities: (1) Route messages between humans and agents. (2) Check `pinet action=agents` for idle workers and delegate tasks via `pinet action=send`. (3) Coordinate GitHub issues/PRs and request reviews, but do NOT launch local review subagents from the broker. (4) Monitor agent health via the RALPH loop. (5) Relay status updates, answer questions about system state, and coordinate workflows. (6) Use bash for read-only inspection and lightweight GitHub coordination: git log, git status, gh pr list, gh pr view, ls, cat — never for code changes or implementation work. (7) Use tmux only to launch repo-scoped Pinet follower workers when capacity is missing.
12
12
 
13
- PRIORITIZED ISSUE GATE: Do not start, assign, or broadcast extension changes unless the request names a GitHub issue/PR and carries clear maintainer priority/approval. Do not self-start from open issue lists. If unclear, stop and ask.
13
+ GUGU91/PINET PRIORITIZED ISSUE GATE: For work in `gugu91/pinet`, do not start, assign, or broadcast changes unless the request names a GitHub issue/PR and carries clear maintainer priority/approval. Do not self-start from that repository's open issue lists. If unclear, stop and ask. This gate is specific to `gugu91/pinet`; unrelated repositories, including `tmustier/pi-extensions`, follow their own instructions and normal requester authority.
14
14
 
15
- DELEGATE, THEN TRACK: Do not perform task triage yourself beyond checking explicit routing requirements already present in the request: repo, issue/PR number, maintainer approval, branch/worktree, and worker availability. If required routing facts are missing, ask; otherwise assign promptly.
15
+ DELEGATE, THEN TRACK: Do not perform task triage yourself beyond checking explicit routing requirements already present in the request: repo, and, where that repo's instructions require them, issue/PR number and maintainer approval, plus branch/worktree and worker availability. If required routing facts are missing, ask; otherwise assign promptly.
16
16
 
17
17
  PM MODE AWARENESS: For complex or coordination-heavy maintainer-delegated tasks, offer PM mode instead of assuming it. PM mode is consent-gated: ask/confirm before enabling unless the maintainer explicitly requested PM-style coordination. When enabled, assign an accountable follower as PM/coordinator; that follower nominates an implementation lead, delegates implementation, coordinates blockers/status, performs second-pass review, and coordinates merge readiness. The broker remains coordination-only and must not implement or launch local subagents. Use durable lane metadata (`pinet action=lanes`) so PM-mode role/lane state is inspectable by RALPH/status flows. A `detached` lane means human/manual supervision; keep it visible, but do not auto-reassign it as normal broker-managed work without explicit human/broker action.
18
18
 
@@ -26,7 +26,7 @@ When a human asks for work to be done, ALWAYS check `pinet action=agents` for id
26
26
 
27
27
  If a repo instruction says to use the `code-reviewer` subagent, treat that as work for the owning connected worker in the same repo to run locally and summarize back — never the broker itself.
28
28
 
29
- When delegating, include: task, issue/PR numbers, maintainer priority/approval, repo/branch/worktree setup, and where to report back (Slack thread_ts).
29
+ When delegating, include: task, repo/branch/worktree setup, where to report back (Slack thread_ts), and any issue/PR numbers or maintainer priority/approval required by the target repository's instructions.
30
30
 
31
31
  If no repo-matched workers are available and new capacity is needed, you may spin up a worker as broker infrastructure: create a tmux session in the target repo, launch `pi` with `PINET_BROKER_MANAGED=1 PINET_BROKER_AGENT_ID=<current-broker-agent-id> PINET_LAUNCH_SOURCE=broker-tmux PINET_TMUX_SESSION=<session>`, run `/pinet follow`, wait for the worker in `pinet action=agents`, then delegate via `pinet action=send`. These env vars let the broker safely distinguish broker-managed follower PIDs from unrelated local processes before ghost reaping. NEVER do the work yourself or cross-route to another repo as a fallback.
32
32
 
@@ -10,9 +10,9 @@ IF ASKED TO CODE: Refuse politely and immediately delegate. Say: "I'm the broker
10
10
 
11
11
  ALLOWED — These are your responsibilities: (1) Route messages between humans and agents. (2) Check `pinet action=agents` for idle workers and delegate tasks via `pinet action=send`. (3) Coordinate GitHub issues/PRs and request reviews, but do NOT launch local review subagents from the broker. (4) Monitor agent health via the RALPH loop. (5) Relay status updates, answer questions about system state, and coordinate workflows. (6) Use bash for read-only inspection and lightweight GitHub coordination: git log, git status, gh pr list, gh pr view, ls, cat — never for code changes or implementation work. (7) Use tmux only for broker-managed follower lifecycle operations: launch repo-scoped workers, inspect recorded broker-managed sessions, and close safely mapped sessions after their workers have exited; never manipulate unrelated or unmapped tmux sessions.
12
12
 
13
- PRIORITIZED ISSUE GATE: Do not start, assign, or broadcast extension changes unless the request names a GitHub issue/PR and carries clear maintainer priority/approval. Do not self-start from open issue lists. If unclear, stop and ask.
13
+ GUGU91/PINET PRIORITIZED ISSUE GATE: For work in `gugu91/pinet`, do not start, assign, or broadcast changes unless the request names a GitHub issue/PR and carries clear maintainer priority/approval. Do not self-start from that repository's open issue lists. If unclear, stop and ask. This gate is specific to `gugu91/pinet`; unrelated repositories, including `tmustier/pi-extensions`, follow their own instructions and normal requester authority.
14
14
 
15
- DELEGATE, THEN TRACK: Do not perform task triage yourself beyond checking explicit routing requirements already present in the request: repo, issue/PR number, maintainer approval, branch/worktree, and worker availability. If required routing facts are missing, ask; otherwise assign promptly.
15
+ DELEGATE, THEN TRACK: Do not perform task triage yourself beyond checking explicit routing requirements already present in the request: repo, and, where that repo's instructions require them, issue/PR number and maintainer approval, plus branch/worktree and worker availability. If required routing facts are missing, ask; otherwise assign promptly.
16
16
 
17
17
  PM MODE AWARENESS: For complex or coordination-heavy maintainer-delegated tasks, offer PM mode instead of assuming it. PM mode is consent-gated: ask/confirm before enabling unless the maintainer explicitly requested PM-style coordination. When enabled, assign an accountable follower as PM/coordinator; that follower nominates an implementation lead, delegates implementation, coordinates blockers/status, performs second-pass review, and coordinates merge readiness. The broker remains coordination-only and must not implement or launch local subagents. Use durable lane metadata (`pinet action=lanes`) so PM-mode role/lane state is inspectable by RALPH/status flows. A `detached` lane means human/manual supervision; keep it visible, but do not auto-reassign it as normal broker-managed work without explicit human/broker action.
18
18
 
@@ -34,7 +34,7 @@ When a human asks for work to be done, ALWAYS check `pinet action=agents` for id
34
34
 
35
35
  If a repo instruction says to use the `code-reviewer` subagent, treat that as work for the owning connected worker in the same repo to run locally and summarize back — never the broker itself.
36
36
 
37
- When delegating, include: task, issue/PR numbers, maintainer priority/approval, repo/branch/worktree setup, acceptance criteria, relevant files or docs, and where to report back (Slack thread_ts or Pinet thread).
37
+ When delegating, include: task, repo/branch/worktree setup, acceptance criteria, relevant files or docs, where to report back (Slack thread_ts or Pinet thread), and any issue/PR numbers or maintainer priority/approval required by the target repository's instructions.
38
38
 
39
39
  If no repo-matched workers are available and new capacity is needed, you may spin up a worker as broker infrastructure: create a tmux session in the target repo, launch `pi` with `PINET_BROKER_MANAGED=1 PINET_BROKER_AGENT_ID=<current-broker-agent-id> PINET_LAUNCH_SOURCE=broker-tmux PINET_TMUX_SESSION=<session>`, run `/pinet follow`, wait for the worker in `pinet action=agents`, then delegate via `pinet action=send`. NEVER do the work yourself or cross-route to another repo as a fallback.
40
40
 
@@ -56,9 +56,9 @@ export interface RuntimeAgentContextDeps {
56
56
  getExtensionContext: () => ExtensionContext | null;
57
57
  persistState: () => void;
58
58
  updateBadge: () => void;
59
- getGitContext: () => Promise<GitContext>;
59
+ getGitContext: (signal?: AbortSignal) => Promise<GitContext>;
60
60
  /** Optional test hook for live branch/dirty probing. */
61
- getDynamicGitState?: (cwd: string) => Promise<GitDynamicState>;
61
+ getDynamicGitState?: (cwd: string, signal?: AbortSignal) => Promise<GitDynamicState>;
62
62
  }
63
63
  export interface RuntimeAgentContext {
64
64
  isUserAllowed: (userId: string) => boolean;
@@ -79,7 +79,7 @@ export interface RuntimeAgentContext {
79
79
  snapshotReloadableRuntime: () => ReloadableRuntimeSnapshot;
80
80
  restoreReloadableRuntime: (snapshot: ReloadableRuntimeSnapshot) => void;
81
81
  detectProjectTools: (repoRoot: string, cwd: string) => string[];
82
- getAgentMetadata: (role?: RuntimeAgentRole) => Promise<Record<string, unknown>>;
82
+ getAgentMetadata: (role?: RuntimeAgentRole, signal?: AbortSignal) => Promise<Record<string, unknown>>;
83
83
  asStringValue: (value: unknown) => string | undefined;
84
84
  getMeshRoleFromMetadata: (metadata: Record<string, unknown> | undefined, fallback?: RuntimeAgentRole) => RuntimeAgentRole;
85
85
  buildSkinMetadata: (metadata: Record<string, unknown> | undefined, personality: string, statusVocabulary?: PinetSkinStatusVocabulary) => Record<string, unknown>;
@@ -185,18 +185,20 @@ export function createRuntimeAgentContext(deps) {
185
185
  tools.add("git");
186
186
  return [...tools].sort();
187
187
  }
188
- const dynamicGitProbe = deps.getDynamicGitState ?? ((cwd) => probeGitDynamic(cwd));
189
- async function getAgentMetadata(role = "worker") {
190
- const gitContext = await deps.getGitContext();
188
+ const dynamicGitProbe = deps.getDynamicGitState ??
189
+ ((cwd, signal) => probeGitDynamic(cwd, undefined, signal));
190
+ async function getAgentMetadata(role = "worker", signal) {
191
+ const gitContext = await deps.getGitContext(signal);
191
192
  const { cwd, repo, repoRoot } = gitContext;
192
193
  const resolvedRepoRoot = repoRoot ?? cwd;
193
194
  const tools = detectProjectTools(resolvedRepoRoot, cwd);
194
195
  const scope = buildSlackCompatibilityScope();
195
196
  let dynamic;
196
197
  try {
197
- dynamic = await dynamicGitProbe(cwd);
198
+ dynamic = await dynamicGitProbe(cwd, signal);
198
199
  }
199
200
  catch {
201
+ signal?.throwIfAborted();
200
202
  dynamic = { probeFailed: true };
201
203
  }
202
204
  const branch = dynamic.branch;
@@ -215,12 +217,16 @@ export function createRuntimeAgentContext(deps) {
215
217
  const skinAssignment = resolveSkinAssignment(role, getIdentitySeedForRole(role));
216
218
  const brokerManaged = role === "worker" && process.env.PINET_BROKER_MANAGED === "1";
217
219
  const parentAgentId = process.env.PINET_PARENT_AGENT_ID?.trim() || undefined;
220
+ const launchSource = process.env.PINET_LAUNCH_SOURCE?.trim() || "broker-tmux";
221
+ const herdrLaunch = launchSource === "broker-herdr" || launchSource === "subtree-broker-herdr";
222
+ const tmuxSession = process.env.PINET_TMUX_SESSION?.trim() || undefined;
218
223
  const brokerManagedMetadata = brokerManaged
219
224
  ? {
220
225
  brokerManaged: true,
221
226
  brokerManagedBy: process.env.PINET_BROKER_AGENT_ID?.trim() || undefined,
222
- launchSource: process.env.PINET_LAUNCH_SOURCE?.trim() || "broker-tmux",
223
- tmuxSession: process.env.PINET_TMUX_SESSION?.trim() || undefined,
227
+ launchSource,
228
+ runtimeKind: herdrLaunch ? "herdr" : "tmux",
229
+ ...(!herdrLaunch && tmuxSession ? { runtimeLocator: tmuxSession, tmuxSession } : {}),
224
230
  brokerManagedAt: new Date().toISOString(),
225
231
  ...(parentAgentId ? { parentAgentId, pinetParentAgentId: parentAgentId } : {}),
226
232
  ...(process.env.PINET_ROOT_AGENT_ID?.trim()
@@ -16,8 +16,12 @@ export function isBrokerManagedFollowerLaunch(env = process.env) {
16
16
  return false;
17
17
  if (env.PINET_LAUNCH_SOURCE === "broker-tmux")
18
18
  return true;
19
+ if (env.PINET_LAUNCH_SOURCE === "broker-herdr")
20
+ return true;
19
21
  if (env.PINET_LAUNCH_SOURCE === "subtree-broker-tmux")
20
22
  return true;
23
+ if (env.PINET_LAUNCH_SOURCE === "subtree-broker-herdr")
24
+ return true;
21
25
  return Boolean(env.PINET_SOCKET_PATH?.trim());
22
26
  }
23
27
  export function resolveSlackBridgeStartupRuntimeMode(settings, options = {}) {
@@ -43,6 +43,7 @@ export function createSessionUiRuntime(deps) {
43
43
  let terminalInputUnsubscribe = null;
44
44
  let deferredDrainTimer = null;
45
45
  let extCtx = null;
46
+ let statusState = "ok";
46
47
  function getExtensionContext() {
47
48
  return extCtx;
48
49
  }
@@ -50,7 +51,7 @@ export function createSessionUiRuntime(deps) {
50
51
  extCtx = ctx;
51
52
  }
52
53
  function updateBadge() {
53
- if (!extCtx?.hasUI)
54
+ if (statusState !== "ok" || !extCtx?.hasUI)
54
55
  return;
55
56
  const t = extCtx.ui.theme;
56
57
  const n = deps.getInboxLength();
@@ -60,6 +61,7 @@ export function createSessionUiRuntime(deps) {
60
61
  extCtx.ui.setStatus("slack-bridge", label);
61
62
  }
62
63
  function setExtStatus(ctx, state) {
64
+ statusState = state;
63
65
  if (!ctx.hasUI)
64
66
  return;
65
67
  extCtx = ctx;
@@ -59,7 +59,7 @@ export interface SinglePlayerRuntimeDeps {
59
59
  beginThreadStatus?: (channelId: string, threadTs: string, status: string) => Promise<void>;
60
60
  }
61
61
  export interface SinglePlayerRuntime {
62
- connect: (ctx: ExtensionContext) => Promise<void>;
62
+ connect: (ctx: ExtensionContext, signal?: AbortSignal) => Promise<void>;
63
63
  disconnect: () => Promise<void>;
64
64
  getBotUserId: () => string | null;
65
65
  isConnected: () => boolean;
@@ -356,7 +356,8 @@ export function createSinglePlayerRuntime(deps) {
356
356
  },
357
357
  };
358
358
  return {
359
- async connect(ctx) {
359
+ async connect(ctx, signal) {
360
+ signal?.throwIfAborted();
360
361
  shuttingDown = false;
361
362
  const socket = new SlackSocketModeClient({
362
363
  slack: deps.slack,
@@ -371,9 +372,14 @@ export function createSinglePlayerRuntime(deps) {
371
372
  deps.setExtStatus(ctx, "reconnecting");
372
373
  }
373
374
  },
374
- onError: (error) => {
375
- if (!isAbortError(error)) {
376
- console.error(`[slack-bridge] Slack access: ${deps.formatError(error)}`);
375
+ onError: (error, source) => {
376
+ if (isAbortError(error))
377
+ return;
378
+ if (source === "connection") {
379
+ deps.setExtStatus(ctx, "error");
380
+ }
381
+ else {
382
+ ctx.ui.notify(`Slack event failed: ${deps.formatError(error)}`, "error");
377
383
  }
378
384
  },
379
385
  onThreadStarted: (event) => onThreadStarted(event),
@@ -398,7 +404,17 @@ export function createSinglePlayerRuntime(deps) {
398
404
  onInteractive: (event) => queueInteractiveInboxEvent(event, ctx),
399
405
  });
400
406
  slackSocket = socket;
401
- await socket.connect();
407
+ const abortConnect = () => {
408
+ void socket.disconnect().catch(() => undefined);
409
+ };
410
+ signal?.addEventListener("abort", abortConnect, { once: true });
411
+ try {
412
+ await socket.connect();
413
+ signal?.throwIfAborted();
414
+ }
415
+ finally {
416
+ signal?.removeEventListener("abort", abortConnect);
417
+ }
402
418
  },
403
419
  async disconnect() {
404
420
  shuttingDown = true;
@@ -1,4 +1,5 @@
1
1
  import type { RuntimeScopeCarrier } from "@pinet/transport-core";
2
+ import { type SlackMessageFileMetadata } from "./slack-message-context.js";
2
3
  import { type SlackInteractiveInboxEvent } from "./slack-block-kit.js";
3
4
  import { type ThreadOwnerHint } from "./broker/router.js";
4
5
  export { SLACK_SOCKET_DELIVERY_DEDUP_MAX_SIZE, SLACK_SOCKET_DELIVERY_DEDUP_TTL_MS, } from "./slack-socket-dedup.js";
@@ -70,6 +71,10 @@ export declare function extractAppHomeOpened(evt: Record<string, unknown>): Pars
70
71
  * Classification result for an incoming message event.
71
72
  * Uses a discriminated union so TypeScript narrows fields when relevant is true.
72
73
  */
74
+ export interface SlackMessageClassificationMetadata extends Record<string, unknown> {
75
+ slackSubtype?: string;
76
+ slackFiles?: SlackMessageFileMetadata[];
77
+ }
73
78
  export type MessageClassification = {
74
79
  relevant: false;
75
80
  } | {
@@ -81,7 +86,7 @@ export type MessageClassification = {
81
86
  isDM: boolean;
82
87
  isChannelMention: boolean;
83
88
  messageTs: string;
84
- metadata?: Record<string, unknown>;
89
+ metadata?: SlackMessageClassificationMetadata;
85
90
  };
86
91
  /**
87
92
  * Classify an incoming Slack message event. Determines whether the
@@ -157,6 +162,7 @@ export interface ResolveSlackThreadOwnerHintInput {
157
162
  }
158
163
  export declare function resolveSlackThreadOwnerHint(input: ResolveSlackThreadOwnerHintInput): Promise<ThreadOwnerHint | null>;
159
164
  export declare const RECONNECT_DELAY_MS = 5000;
165
+ export type SlackSocketErrorSource = "connection" | "event";
160
166
  export interface SlackSocketModeClientConfig {
161
167
  slack: SlackCall;
162
168
  botToken: string;
@@ -167,7 +173,7 @@ export interface SlackSocketModeClientConfig {
167
173
  abortAndWait?: () => Promise<void>;
168
174
  onOpen?: () => void;
169
175
  onReconnectScheduled?: () => void;
170
- onError?: (error: unknown) => void;
176
+ onError?: (error: unknown, source: SlackSocketErrorSource) => void;
171
177
  onThreadStarted?: (event: ParsedThreadStarted) => Promise<void> | void;
172
178
  onThreadContextChanged?: (event: ParsedThreadContextChanged) => Promise<void> | void;
173
179
  onMessage?: (event: Record<string, unknown>) => Promise<void> | void;
@@ -184,7 +190,9 @@ export declare class SlackSocketModeClient {
184
190
  private readonly config;
185
191
  private botUserId;
186
192
  private ws;
193
+ private pendingSocket;
187
194
  private reconnectTimer;
195
+ private connecting;
188
196
  private shuttingDown;
189
197
  constructor(config: SlackSocketModeClientConfig);
190
198
  getBotUserId(): string | null;
@@ -330,7 +330,9 @@ export class SlackSocketModeClient {
330
330
  config;
331
331
  botUserId = null;
332
332
  ws = null;
333
+ pendingSocket = null;
333
334
  reconnectTimer = null;
335
+ connecting = false;
334
336
  shuttingDown = false;
335
337
  constructor(config) {
336
338
  this.config = config;
@@ -343,11 +345,22 @@ export class SlackSocketModeClient {
343
345
  }
344
346
  async connect() {
345
347
  this.shuttingDown = false;
346
- if (this.config.resolveBotUserIdOnConnect ?? true) {
347
- const auth = await this.config.slack("auth.test", this.config.botToken);
348
- this.botUserId = typeof auth.user_id === "string" ? auth.user_id : null;
348
+ const authPromise = (this.config.resolveBotUserIdOnConnect ?? true)
349
+ ? this.config.slack("auth.test", this.config.botToken)
350
+ : Promise.resolve(null);
351
+ const socketResponsePromise = this.config.slack("apps.connections.open", this.config.appToken);
352
+ void socketResponsePromise.catch(() => undefined);
353
+ try {
354
+ const auth = await authPromise;
355
+ if (auth) {
356
+ this.botUserId = typeof auth.user_id === "string" ? auth.user_id : null;
357
+ }
358
+ await this.connectSocketMode(socketResponsePromise);
359
+ }
360
+ catch (error) {
361
+ await this.disconnect().catch(() => undefined);
362
+ throw error;
349
363
  }
350
- await this.connectSocketMode();
351
364
  }
352
365
  async disconnect() {
353
366
  this.shuttingDown = true;
@@ -357,51 +370,78 @@ export class SlackSocketModeClient {
357
370
  }
358
371
  try {
359
372
  this.ws?.close();
373
+ this.pendingSocket?.close();
360
374
  }
361
375
  catch {
362
376
  /* ignore close errors */
363
377
  }
364
378
  this.ws = null;
379
+ this.pendingSocket = null;
365
380
  await this.config.abortAndWait?.();
366
381
  }
367
- async connectSocketMode() {
368
- if (this.shuttingDown)
382
+ async connectSocketMode(pendingResponse) {
383
+ if (this.shuttingDown || this.connecting)
369
384
  return;
385
+ this.connecting = true;
386
+ let shouldReconnect = false;
370
387
  try {
371
- const response = await this.config.slack("apps.connections.open", this.config.appToken);
372
- this.ws = new WebSocket(response.url);
373
- this.ws.addEventListener("open", () => {
388
+ const response = await (pendingResponse ??
389
+ this.config.slack("apps.connections.open", this.config.appToken));
390
+ if (this.shuttingDown)
391
+ return;
392
+ const previous = this.ws;
393
+ const socket = new WebSocket(response.url);
394
+ this.pendingSocket = socket;
395
+ socket.addEventListener("open", () => {
396
+ if (this.pendingSocket !== socket || this.shuttingDown)
397
+ return;
398
+ this.pendingSocket = null;
399
+ this.ws = socket;
400
+ previous?.close();
374
401
  this.config.onOpen?.();
375
402
  });
376
- this.ws.addEventListener("message", (event) => {
377
- void this.handleFrame(String(event.data)).catch((error) => {
378
- this.config.onError?.(error);
403
+ socket.addEventListener("message", (event) => {
404
+ void this.handleFrame(socket, String(event.data)).catch((error) => {
405
+ if (!this.shuttingDown) {
406
+ this.config.onError?.(error, "event");
407
+ }
379
408
  });
380
409
  });
381
- this.ws.addEventListener("close", () => {
382
- if (!this.shuttingDown) {
410
+ socket.addEventListener("close", () => {
411
+ if (this.pendingSocket === socket) {
412
+ this.pendingSocket = null;
413
+ this.scheduleReconnect();
414
+ }
415
+ else if (this.ws === socket) {
416
+ this.ws = null;
383
417
  this.scheduleReconnect();
384
418
  }
385
419
  });
386
- this.ws.addEventListener("error", () => {
420
+ socket.addEventListener("error", () => {
387
421
  /* close fires after error — handled there */
388
422
  });
389
423
  }
390
424
  catch (error) {
391
- if (!isAbortError(error)) {
392
- this.config.onError?.(error);
425
+ if (!this.shuttingDown && !isAbortError(error)) {
426
+ this.config.onError?.(error, "connection");
393
427
  }
428
+ shouldReconnect = !this.shuttingDown;
429
+ }
430
+ finally {
431
+ this.connecting = false;
432
+ }
433
+ if (shouldReconnect) {
394
434
  this.scheduleReconnect();
395
435
  }
396
436
  }
397
- async handleFrame(raw) {
437
+ async handleFrame(socket, raw) {
398
438
  if (this.shuttingDown)
399
439
  return;
400
440
  const envelope = parseSocketFrame(raw);
401
441
  if (!envelope)
402
442
  return;
403
- if (envelope.envelopeId) {
404
- this.ws?.send(JSON.stringify({ envelope_id: envelope.envelopeId }));
443
+ if (envelope.envelopeId && socket.readyState === WebSocket.OPEN) {
444
+ socket.send(JSON.stringify({ envelope_id: envelope.envelopeId }));
405
445
  }
406
446
  const dedupKey = envelope.dedupKey ?? null;
407
447
  try {
@@ -412,7 +452,9 @@ export class SlackSocketModeClient {
412
452
  this.config.dedup?.add(dedupKey);
413
453
  }
414
454
  if (envelope.type === "disconnect") {
415
- this.scheduleReconnect();
455
+ if (this.ws === socket) {
456
+ this.scheduleReconnect();
457
+ }
416
458
  return;
417
459
  }
418
460
  if (envelope.interactivePayload) {
@@ -480,7 +522,7 @@ export class SlackSocketModeClient {
480
522
  }
481
523
  }
482
524
  scheduleReconnect() {
483
- if (this.shuttingDown || this.reconnectTimer)
525
+ if (this.shuttingDown || this.connecting || this.pendingSocket || this.reconnectTimer)
484
526
  return;
485
527
  this.config.onReconnectScheduled?.();
486
528
  this.reconnectTimer = setTimeout(() => {
@@ -1,3 +1,4 @@
1
+ import { type DecodedSlackModalPrivateMetadata } from "./slack-modals.js";
1
2
  export type SlackBlock = Record<string, unknown>;
2
3
  export interface SlackNormalizedBlockAction {
3
4
  actionId: string;
@@ -10,13 +11,54 @@ export interface SlackNormalizedBlockAction {
10
11
  parsedValue?: unknown;
11
12
  actionTs?: string;
12
13
  }
14
+ export interface SlackSanitizedBlockAction extends Record<string, unknown> {
15
+ actionId: string;
16
+ blockId: string | null;
17
+ text: string | null;
18
+ type: string | null;
19
+ style: string | null;
20
+ value: string | null;
21
+ parsedValue: SlackNormalizedBlockAction["parsedValue"];
22
+ actionTs: string | null;
23
+ }
24
+ export interface SlackBlockActionMetadata extends Record<string, unknown> {
25
+ kind: "slack_block_action";
26
+ triggerId: string | null;
27
+ viewId: string | null;
28
+ callbackId: string | null;
29
+ viewHash: string | null;
30
+ blockId: string | null;
31
+ actionId: string;
32
+ value: string | null;
33
+ parsedValue: SlackNormalizedBlockAction["parsedValue"];
34
+ actionText: string | null;
35
+ channel: string;
36
+ threadTs: string;
37
+ messageTs: string | null;
38
+ modalPrivateMetadata: DecodedSlackModalPrivateMetadata["value"];
39
+ actions: SlackSanitizedBlockAction[];
40
+ }
41
+ export type SlackViewSubmissionStateValue = SlackBlock;
42
+ export type SlackViewSubmissionStateValues = Record<string, Record<string, SlackViewSubmissionStateValue>>;
43
+ export interface SlackViewSubmissionMetadata extends Record<string, unknown> {
44
+ kind: "slack_view_submission";
45
+ triggerId: string | null;
46
+ callbackId: string | null;
47
+ viewId: string;
48
+ externalId: string | null;
49
+ viewHash: string | null;
50
+ channel: string;
51
+ threadTs: string;
52
+ privateMetadata: DecodedSlackModalPrivateMetadata["value"];
53
+ stateValues: SlackViewSubmissionStateValues;
54
+ }
13
55
  export interface SlackBlockActionInboxEvent {
14
56
  channel: string;
15
57
  threadTs: string;
16
58
  userId: string;
17
59
  text: string;
18
60
  timestamp: string;
19
- metadata: Record<string, unknown>;
61
+ metadata: SlackBlockActionMetadata;
20
62
  }
21
63
  export interface SlackViewSubmissionInboxEvent {
22
64
  channel: string;
@@ -24,7 +66,7 @@ export interface SlackViewSubmissionInboxEvent {
24
66
  userId: string;
25
67
  text: string;
26
68
  timestamp: string;
27
- metadata: Record<string, unknown>;
69
+ metadata: SlackViewSubmissionMetadata;
28
70
  }
29
71
  export type SlackInteractiveInboxEvent = SlackBlockActionInboxEvent | SlackViewSubmissionInboxEvent;
30
72
  export declare function normalizeSlackBlocksInput(blocks: unknown): SlackBlock[];
@@ -1,15 +1,15 @@
1
- import { decodeSlackModalPrivateMetadata } from "./slack-modals.js";
2
- function isRecord(value) {
1
+ import { decodeSlackModalPrivateMetadata, } from "./slack-modals.js";
2
+ function isSlackBlockKitObject(value) {
3
3
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
4
  }
5
5
  function asString(value) {
6
6
  return typeof value === "string" && value.length > 0 ? value : undefined;
7
7
  }
8
8
  function asRecord(value) {
9
- return isRecord(value) ? value : undefined;
9
+ return isSlackBlockKitObject(value) ? value : undefined;
10
10
  }
11
11
  function asRecordArray(value) {
12
- if (!Array.isArray(value) || value.some((item) => !isRecord(item))) {
12
+ if (!Array.isArray(value) || value.some((item) => !isSlackBlockKitObject(item))) {
13
13
  throw new Error("Slack blocks must be a JSON array of objects.");
14
14
  }
15
15
  return value;
@@ -122,7 +122,7 @@ export function extractSlackInteractivePayloadFromEnvelope(envelope) {
122
122
  return null;
123
123
  }
124
124
  }
125
- if (!isRecord(payload))
125
+ if (!isSlackBlockKitObject(payload))
126
126
  return null;
127
127
  return payload;
128
128
  }
@@ -19,8 +19,18 @@ export interface SlackFileLookupContext {
19
19
  threadTs?: string;
20
20
  messageTs?: string;
21
21
  }
22
+ type SlackFileObject = Record<string, unknown>;
23
+ export interface SlackThreadRepliesRequest extends SlackFileObject {
24
+ channel: string;
25
+ ts: string;
26
+ limit: number;
27
+ }
28
+ export interface SlackFilesInfoRequest extends SlackFileObject {
29
+ file: string;
30
+ }
31
+ export type SlackFileLookupRequest = SlackThreadRepliesRequest | SlackFilesInfoRequest;
22
32
  export interface SlackFileDownloadDeps {
23
- slack: (method: string, token: string, body?: Record<string, unknown>) => Promise<SlackResult>;
33
+ slack: (method: string, token: string, body?: SlackFileLookupRequest) => Promise<SlackResult>;
24
34
  token: string;
25
35
  fetchImpl?: (url: string, init: RequestInit) => Promise<Pick<Response, "ok" | "status" | "statusText" | "arrayBuffer" | "text"> & {
26
36
  body?: Response["body"];
@@ -32,3 +42,4 @@ export interface SlackFileDownloadDeps {
32
42
  }
33
43
  export declare function fetchSlackFileToCache(fileId: string, context: SlackFileLookupContext, deps: SlackFileDownloadDeps): Promise<SlackFileDescriptor>;
34
44
  export declare function readCachedSlackFile(pathname: string): Promise<Buffer>;
45
+ export {};
@@ -10,7 +10,7 @@ const SLACK_FILE_DOWNLOAD_HOSTS = new Set([
10
10
  "files.slack-edge.com",
11
11
  "downloads.slack-edge.com",
12
12
  ]);
13
- function isRecord(value) {
13
+ function isSlackFileObject(value) {
14
14
  return typeof value === "object" && value !== null && !Array.isArray(value);
15
15
  }
16
16
  function getString(record, key) {
@@ -44,7 +44,7 @@ function assertSafeSlackFileDownloadUrl(rawUrl) {
44
44
  }
45
45
  }
46
46
  function metadataFromSlackFile(value, expectedFileId) {
47
- if (!isRecord(value))
47
+ if (!isSlackFileObject(value))
48
48
  return null;
49
49
  const id = getString(value, "id");
50
50
  if (id !== expectedFileId)
@@ -65,7 +65,7 @@ function metadataFromSlackFile(value, expectedFileId) {
65
65
  };
66
66
  }
67
67
  function filesFromMessage(message) {
68
- if (!isRecord(message) || !Array.isArray(message.files))
68
+ if (!isSlackFileObject(message) || !Array.isArray(message.files))
69
69
  return [];
70
70
  return message.files;
71
71
  }
@@ -73,7 +73,7 @@ function findFileInMessages(messages, fileId, messageTs) {
73
73
  if (!Array.isArray(messages))
74
74
  return null;
75
75
  for (const message of messages) {
76
- if (messageTs && (!isRecord(message) || getString(message, "ts") !== messageTs))
76
+ if (messageTs && (!isSlackFileObject(message) || getString(message, "ts") !== messageTs))
77
77
  continue;
78
78
  for (const file of filesFromMessage(message)) {
79
79
  const metadata = metadataFromSlackFile(file, fileId);
@@ -1,3 +1,9 @@
1
+ type SlackMessageContextObject = Record<string, unknown>;
2
+ export interface SlackMessageContextEvent extends SlackMessageContextObject {
3
+ blocks?: unknown;
4
+ attachments?: unknown;
5
+ files?: unknown;
6
+ }
1
7
  export interface SlackMessageFileMetadata {
2
8
  id?: string;
3
9
  name?: string;
@@ -10,5 +16,6 @@ export interface SlackMessageFileMetadata {
10
16
  size?: number;
11
17
  }
12
18
  export declare function extractSlackMessageFileMetadata(files: unknown): SlackMessageFileMetadata[];
13
- export declare function extractSlackMessageContextLines(evt: Record<string, unknown>, baseText?: string): string[];
14
- export declare function buildSlackInboundMessageText(baseText: string, evt: Record<string, unknown>): string;
19
+ export declare function extractSlackMessageContextLines(evt: SlackMessageContextEvent, baseText?: string): string[];
20
+ export declare function buildSlackInboundMessageText(baseText: string, evt: SlackMessageContextEvent): string;
21
+ export {};
@@ -1,4 +1,7 @@
1
- export type SlackModalView = Record<string, unknown>;
1
+ export type SlackModalObject = Record<string, unknown>;
2
+ export interface SlackModalView extends SlackModalObject {
3
+ type: "modal";
4
+ }
2
5
  export declare function normalizeSlackModalViewInput(view: unknown): SlackModalView;
3
6
  export interface SlackModalThreadContext {
4
7
  threadTs: string;
@@ -1,5 +1,5 @@
1
1
  const PI_SLACK_MODAL_CONTEXT_KEY = "__piSlackModalContext";
2
- function isRecord(value) {
2
+ function isSlackModalObject(value) {
3
3
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
4
  }
5
5
  function asString(value) {
@@ -9,7 +9,7 @@ function asOptionalString(value) {
9
9
  return typeof value === "string" ? value : null;
10
10
  }
11
11
  export function normalizeSlackModalViewInput(view) {
12
- if (!isRecord(view)) {
12
+ if (!isSlackModalObject(view)) {
13
13
  throw new Error("Slack modal view must be a JSON object.");
14
14
  }
15
15
  const cloned = structuredClone(view);
@@ -32,7 +32,7 @@ export function encodeSlackModalPrivateMetadata(privateMetadata, threadContext)
32
32
  value = raw;
33
33
  }
34
34
  }
35
- if (isRecord(value)) {
35
+ if (isSlackModalObject(value)) {
36
36
  return JSON.stringify({
37
37
  ...value,
38
38
  [PI_SLACK_MODAL_CONTEXT_KEY]: threadContext,
@@ -50,9 +50,9 @@ export function decodeSlackModalPrivateMetadata(privateMetadata) {
50
50
  }
51
51
  try {
52
52
  const parsed = JSON.parse(raw);
53
- if (isRecord(parsed)) {
53
+ if (isSlackModalObject(parsed)) {
54
54
  const context = parsed[PI_SLACK_MODAL_CONTEXT_KEY];
55
- const threadContext = isRecord(context)
55
+ const threadContext = isSlackModalObject(context)
56
56
  ? {
57
57
  threadTs: asString(context.threadTs) ?? "",
58
58
  channel: asString(context.channel) ?? "",
@@ -11,6 +11,7 @@ export interface SlackPinetRuntimeAdapterDeps {
11
11
  getAppToken: () => string;
12
12
  getAllowedUsers: () => Set<string> | null;
13
13
  shouldAllowAllWorkspaceUsers: () => boolean;
14
+ setExtStatus: (ctx: ExtensionContext, state: "ok" | "reconnecting" | "error") => void;
14
15
  onAppHomeOpened: (userId: string, ctx: ExtensionContext) => Promise<void> | void;
15
16
  onSlashCommand?: (event: ParsedSlashCommand, ctx: ExtensionContext) => Promise<string | null> | string | null;
16
17
  }