@pinet/slack-bridge 0.2.1 → 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.
@@ -0,0 +1,80 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as path from "node:path";
3
+ function stableIdDigest(stableId) {
4
+ return crypto.createHash("sha256").update(stableId).digest("hex").slice(0, 12);
5
+ }
6
+ export function parsePinetStableId(stableId) {
7
+ const value = stableId?.trim();
8
+ if (!value)
9
+ return null;
10
+ const firstColon = value.indexOf(":");
11
+ const secondColon = firstColon >= 0 ? value.indexOf(":", firstColon + 1) : -1;
12
+ if (firstColon < 0 || secondColon < 0) {
13
+ return { host: null, kind: "unknown", locator: value, hasPath: false };
14
+ }
15
+ const host = value.slice(0, firstColon) || null;
16
+ const rawKind = value.slice(firstColon + 1, secondColon);
17
+ const locator = value.slice(secondColon + 1);
18
+ const kind = rawKind === "session" || rawKind === "leaf" || rawKind === "cwd" || rawKind === "broker"
19
+ ? rawKind
20
+ : "unknown";
21
+ return {
22
+ host,
23
+ kind,
24
+ locator,
25
+ hasPath: locator.startsWith("/") && (kind === "session" || kind === "cwd" || kind === "broker"),
26
+ };
27
+ }
28
+ export function summarizePinetStableId(stableId) {
29
+ const value = stableId?.trim();
30
+ if (!value)
31
+ return null;
32
+ const parsed = parsePinetStableId(value);
33
+ const kind = parsed?.kind ?? "unknown";
34
+ return {
35
+ kind,
36
+ ref: `${kind}:${stableIdDigest(value)}`,
37
+ host: parsed?.host ?? null,
38
+ hasPath: parsed?.hasPath ?? false,
39
+ };
40
+ }
41
+ export function getPinetSessionPath(stableId) {
42
+ const parsed = parsePinetStableId(stableId);
43
+ if (!parsed?.hasPath)
44
+ return null;
45
+ return parsed.locator;
46
+ }
47
+ export function getPinetSessionFilename(stableId) {
48
+ const sessionPath = getPinetSessionPath(stableId);
49
+ return sessionPath ? path.basename(sessionPath) : null;
50
+ }
51
+ export function buildPinetSessionFullDetails(session) {
52
+ const summary = summarizePinetStableId(session.stableId);
53
+ const sessionPath = getPinetSessionPath(session.stableId);
54
+ return {
55
+ ...session,
56
+ session: summary,
57
+ ...(sessionPath ? { jsonlPath: sessionPath } : {}),
58
+ };
59
+ }
60
+ export function buildPinetSessionCompactDetails(session) {
61
+ const summary = summarizePinetStableId(session.stableId);
62
+ return {
63
+ agentId: session.agentId,
64
+ agentName: session.agentName,
65
+ emoji: session.emoji,
66
+ pid: session.pid,
67
+ status: session.status,
68
+ health: session.disconnectedAt ? "disconnected" : "live",
69
+ session: summary?.ref ?? null,
70
+ sessionKind: summary?.kind ?? null,
71
+ host: summary?.host ?? null,
72
+ repo: session.repo,
73
+ branch: session.branch,
74
+ tmuxSession: session.tmuxSession,
75
+ lastSeen: session.lastSeen,
76
+ disconnectedAt: session.disconnectedAt,
77
+ relatedThreadIds: session.relatedThreadIds.slice(0, 5),
78
+ matchedBy: session.matchedBy,
79
+ };
80
+ }
@@ -1,12 +1,14 @@
1
1
  import { type PinetReadOptions, type PinetReadResult } from "@pinet/pinet-core/pinet-read-formatting";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { RalphSnoozeStatus } from "./ralph-loop.js";
4
- import type { PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./broker/types.js";
4
+ import type { AgentSessionSearchInfo, AgentSessionSearchOptions, AgentSessionSummary, PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./broker/types.js";
5
5
  export interface PinetToolsAgentRecord {
6
6
  emoji: string;
7
7
  name: string;
8
8
  id: string;
9
9
  pid?: number;
10
+ stableId?: string | null;
11
+ session?: AgentSessionSummary | null;
10
12
  status: "working" | "idle";
11
13
  metadata: Record<string, unknown> | null;
12
14
  lastHeartbeat: string;
@@ -76,6 +78,7 @@ export interface RegisterPinetToolsDeps {
76
78
  readPinetInbox: (options: PinetReadOptions) => Promise<PinetReadResult>;
77
79
  listBrokerAgents: () => PinetToolsAgentRecord[];
78
80
  listFollowerAgents: (includeGhosts: boolean) => Promise<PinetToolsAgentRecord[]>;
81
+ searchPinetSessions: (options: AgentSessionSearchOptions) => Promise<AgentSessionSearchInfo[]>;
79
82
  listSubtreeAgents?: (includeGhosts: boolean) => PinetToolsAgentRecord[] | null;
80
83
  getSubtreeSelfAgentId?: () => string | null;
81
84
  spawnSubtreeWorker?: (input: PinetSubtreeSpawnInput) => Promise<PinetSubtreeSpawnResult>;
@@ -8,6 +8,7 @@ import { buildAgentDisplayInfo, filterAgentsForMeshVisibility, formatAgentList,
8
8
  import { isBroadcastChannelTarget } from "./broker/agent-messaging.js";
9
9
  import { DEFAULT_HEARTBEAT_TIMEOUT_MS } from "./broker/socket-server.js";
10
10
  import { HEARTBEAT_INTERVAL_MS } from "./broker/client.js";
11
+ import { buildPinetSessionCompactDetails, buildPinetSessionFullDetails, getPinetSessionFilename, getPinetSessionPath, summarizePinetStableId, } from "./pinet-session-formatting.js";
11
12
  const PINET_DISPATCHER_EXAMPLES = {
12
13
  send: [{ action: "send", args: { to: "@worker", message: "Please review PR #123" } }],
13
14
  read: [
@@ -29,6 +30,10 @@ const PINET_DISPATCHER_EXAMPLES = {
29
30
  ],
30
31
  schedule: [{ action: "schedule", args: { delay: "30m", message: "Check queue state" } }],
31
32
  agents: [{ action: "agents", args: { repo: "<repo>", role: "worker" } }],
33
+ sessions: [
34
+ { action: "sessions", args: { agent_name: "Frozen Hazel Whale" } },
35
+ { action: "sessions", args: { thread_id: "a2a:<broker>:<worker>", full: true } },
36
+ ],
32
37
  spawn: [
33
38
  {
34
39
  action: "spawn",
@@ -89,6 +94,7 @@ function normalizeDispatcherAction(value) {
89
94
  "snooze",
90
95
  "schedule",
91
96
  "agents",
97
+ "sessions",
92
98
  "lanes",
93
99
  "ports",
94
100
  "spawn",
@@ -137,7 +143,8 @@ function classifyPinetError(message) {
137
143
  message.includes("ttl_ms") ||
138
144
  message.includes("purpose") ||
139
145
  message.includes("port") ||
140
- message.includes("spawn")) {
146
+ message.includes("spawn") ||
147
+ message.includes("sessions op")) {
141
148
  return {
142
149
  class: "input",
143
150
  message,
@@ -803,6 +810,8 @@ function buildCompactAgentDetails(agents, hint) {
803
810
  repo: getAgentRepo(agent) ?? null,
804
811
  branch: getAgentBranch(agent) ?? null,
805
812
  role: getAgentRole(agent) ?? null,
813
+ session: agent.session?.ref ?? null,
814
+ sessionKind: agent.session?.kind ?? null,
806
815
  brokerManaged: agent.metadata?.brokerManaged === true,
807
816
  parentAgentId: agent.metadata?.parentAgentId ?? null,
808
817
  treeDepth: agent.metadata?.treeDepth ?? 0,
@@ -1363,6 +1372,123 @@ function runPinetAgentsAction(params, deps, toolName, output) {
1363
1372
  };
1364
1373
  })();
1365
1374
  }
1375
+ function buildPinetSessionSearchOptions(params) {
1376
+ return {
1377
+ ...((getMaybeString(params, "agent_name") ?? getMaybeString(params, "name"))
1378
+ ? { agentName: getMaybeString(params, "agent_name") ?? getMaybeString(params, "name") }
1379
+ : {}),
1380
+ ...(getMaybeString(params, "agent_id") ? { agentId: getMaybeString(params, "agent_id") } : {}),
1381
+ ...(getMaybeString(params, "thread_id")
1382
+ ? { threadId: getMaybeString(params, "thread_id") }
1383
+ : {}),
1384
+ ...(getMaybeString(params, "repo") ? { repo: getMaybeString(params, "repo") } : {}),
1385
+ ...(getMaybeString(params, "worktree_path")
1386
+ ? { worktreePath: getMaybeString(params, "worktree_path") }
1387
+ : {}),
1388
+ ...(getMaybeString(params, "tmux_session")
1389
+ ? { tmuxSession: getMaybeString(params, "tmux_session") }
1390
+ : {}),
1391
+ ...(getMaybeString(params, "since") ? { since: getMaybeString(params, "since") } : {}),
1392
+ ...(getMaybeString(params, "until") ? { until: getMaybeString(params, "until") } : {}),
1393
+ ...(getMaybeNumber(params, "limit") ? { limit: getMaybeNumber(params, "limit") } : {}),
1394
+ };
1395
+ }
1396
+ function formatPinetSessionSearchHeader(sessions, options) {
1397
+ const filters = [
1398
+ options.agentName ? `agent_name=${options.agentName}` : null,
1399
+ options.agentId ? `agent_id=${options.agentId}` : null,
1400
+ options.threadId ? `thread_id=${options.threadId}` : null,
1401
+ options.repo ? `repo=${options.repo}` : null,
1402
+ options.worktreePath ? `worktree=${options.worktreePath}` : null,
1403
+ options.tmuxSession ? `tmux=${options.tmuxSession}` : null,
1404
+ options.since ? `since=${options.since}` : null,
1405
+ options.until ? `until=${options.until}` : null,
1406
+ ].filter((item) => Boolean(item));
1407
+ const filterText = filters.length > 0 ? ` for ${filters.join(" · ")}` : "";
1408
+ return `Pinet sessions: ${sessions.length} match${sessions.length === 1 ? "" : "es"}${filterText}.`;
1409
+ }
1410
+ function formatPinetSessionLine(session, full) {
1411
+ const summary = summarizePinetStableId(session.stableId);
1412
+ const health = session.disconnectedAt ? "disconnected" : "live";
1413
+ const where = [
1414
+ session.repo ?? null,
1415
+ session.branch ? `branch=${session.branch}` : null,
1416
+ session.tmuxSession ? `tmux=${session.tmuxSession}` : null,
1417
+ ].filter((item) => Boolean(item));
1418
+ const threads = session.relatedThreadIds.slice(0, full ? 10 : 3).join(", ");
1419
+ const suffix = session.relatedThreadIds.length > (full ? 10 : 3) ? " …" : "";
1420
+ const base = [
1421
+ `- ${session.emoji} ${session.agentName} (${session.agentId})`,
1422
+ `${health}`,
1423
+ `pid:${session.pid}`,
1424
+ summary?.ref ? `session:${summary.ref}` : "session:none",
1425
+ `lastSeen:${session.lastSeen}`,
1426
+ where.length > 0 ? where.join(" · ") : null,
1427
+ ].filter((item) => Boolean(item));
1428
+ const lines = [base.join(" — ")];
1429
+ if (threads) {
1430
+ lines.push(` threads: ${threads}${suffix}`);
1431
+ }
1432
+ if (full) {
1433
+ const sessionPath = getPinetSessionPath(session.stableId);
1434
+ const sessionFilename = getPinetSessionFilename(session.stableId);
1435
+ if (session.stableId)
1436
+ lines.push(` stableId: ${session.stableId}`);
1437
+ if (sessionPath)
1438
+ lines.push(` jsonl: ${sessionPath}`);
1439
+ if (sessionFilename)
1440
+ lines.push(` jsonlFile: ${sessionFilename}`);
1441
+ if (session.cwd)
1442
+ lines.push(` cwd: ${session.cwd}`);
1443
+ if (session.repoRoot)
1444
+ lines.push(` repoRoot: ${session.repoRoot}`);
1445
+ if (session.worktreePath)
1446
+ lines.push(` worktreePath: ${session.worktreePath}`);
1447
+ if (session.brokerManaged) {
1448
+ lines.push(` managed: source=${session.launchSource ?? "broker"}${session.brokerManagedBy ? ` by=${session.brokerManagedBy}` : ""}`);
1449
+ }
1450
+ lines.push(` matchedBy: ${session.matchedBy.join(", ")}`);
1451
+ }
1452
+ return lines.join("\n");
1453
+ }
1454
+ function formatPinetSessionSearch(sessions, options, full) {
1455
+ const header = formatPinetSessionSearchHeader(sessions, options);
1456
+ if (sessions.length === 0)
1457
+ return `${header} Try a broader agent_name, thread_id, or repo filter.`;
1458
+ const body = sessions.map((session) => formatPinetSessionLine(session, full)).join("\n");
1459
+ const fullHint = full ? "" : "\nUse args.full=true for exact stableId/jsonl paths.";
1460
+ return `${header}\n${body}${fullHint}`;
1461
+ }
1462
+ function runPinetSessionsAction(params, deps, toolName, output) {
1463
+ return (async () => {
1464
+ const op = getMaybeString(params, "op") ?? "search";
1465
+ if (op !== "search") {
1466
+ throw new Error("sessions op must be search");
1467
+ }
1468
+ const options = buildPinetSessionSearchOptions(params);
1469
+ deps.requireToolPolicy(toolName, undefined, `agent_name=${options.agentName ?? ""} | agent_id=${options.agentId ?? ""} | thread_id=${options.threadId ?? ""} | repo=${options.repo ?? ""} | worktree_path=${options.worktreePath ?? ""} | tmux_session=${options.tmuxSession ?? ""} | since=${options.since ?? ""} | until=${options.until ?? ""} | limit=${options.limit ?? ""} | format=${output.format} | full=${output.full}`);
1470
+ if (!deps.pinetEnabled()) {
1471
+ throw new Error("Pinet is not running. Use /pinet start or /pinet follow first.");
1472
+ }
1473
+ const sessions = await deps.searchPinetSessions(options);
1474
+ const text = formatPinetSessionSearch(sessions, options, output.full);
1475
+ return {
1476
+ content: [{ type: "text", text }],
1477
+ details: { count: sessions.length, sessions, options },
1478
+ compactDetails: {
1479
+ count: sessions.length,
1480
+ options,
1481
+ sessions: sessions.map(buildPinetSessionCompactDetails),
1482
+ },
1483
+ fullDetails: {
1484
+ count: sessions.length,
1485
+ options,
1486
+ sessions: sessions.map(buildPinetSessionFullDetails),
1487
+ },
1488
+ expandedText: text,
1489
+ };
1490
+ })();
1491
+ }
1366
1492
  export function registerPinetTools(pi, deps) {
1367
1493
  const actionDefinitions = new Map();
1368
1494
  function registerAction(definition) {
@@ -1478,6 +1604,25 @@ export function registerPinetTools(pi, deps) {
1478
1604
  }),
1479
1605
  execute: (_id, params, output) => runPinetAgentsAction(params, deps, "pinet:agents", output),
1480
1606
  });
1607
+ registerAction({
1608
+ name: "sessions",
1609
+ description: "Search live and historical Pinet worker Pi sessions by display name, agent id, thread, repo/worktree, tmux session, or time range.",
1610
+ parameters: Type.Object({
1611
+ op: Type.Optional(Type.String({ description: "Operation: search (default)" })),
1612
+ agent_name: Type.Optional(Type.String({ description: "Worker display name, e.g. Frozen Hazel Whale" })),
1613
+ name: Type.Optional(Type.String({ description: "Alias for agent_name" })),
1614
+ agent_id: Type.Optional(Type.String({ description: "Pinet agent id or id prefix" })),
1615
+ thread_id: Type.Optional(Type.String({ description: "Related Pinet/Slack/A2A thread id" })),
1616
+ repo: Type.Optional(Type.String({ description: "Repo name or path fragment" })),
1617
+ worktree_path: Type.Optional(Type.String({ description: "Worktree/cwd path fragment" })),
1618
+ tmux_session: Type.Optional(Type.String({ description: "Broker-managed tmux session name" })),
1619
+ since: Type.Optional(Type.String({ description: "Only sessions active after this ISO time" })),
1620
+ until: Type.Optional(Type.String({ description: "Only sessions started before this ISO time" })),
1621
+ limit: Type.Optional(Type.Number({ description: "Maximum matches (default 20, max 100)" })),
1622
+ ...PINET_OUTPUT_OPTION_PARAMETERS,
1623
+ }),
1624
+ execute: (_id, params, output) => runPinetSessionsAction(params, deps, "pinet:sessions", output),
1625
+ });
1481
1626
  registerAction({
1482
1627
  name: "ports",
1483
1628
  description: "Acquire, renew, release, inspect, list, or expire durable Pinet local port leases.",
@@ -1549,10 +1694,10 @@ export function registerPinetTools(pi, deps) {
1549
1694
  name: "pinet",
1550
1695
  label: "Pinet Dispatcher",
1551
1696
  description: "Dispatch Pinet operations by action with compact help and schema discovery.",
1552
- promptSnippet: 'Use this compact dispatcher for Pinet actions: send, read, free, snooze, schedule, agents, lanes, ports, reload, exit, spawn, and help. Use /pinet start, /pinet follow, /pinet unfollow, and /pinet subtree start for TUI lifecycle changes. Defaults to terse CLI text; pass args.format="json" for the compact envelope or args.full=true for verbose/debug detail.',
1697
+ promptSnippet: 'Use this compact dispatcher for Pinet actions: send, read, free, snooze, schedule, agents, sessions, lanes, ports, reload, exit, spawn, and help. Use /pinet start, /pinet follow, /pinet unfollow, and /pinet subtree start for TUI lifecycle changes. Defaults to terse CLI text; pass args.format="json" for the compact envelope or args.full=true for verbose/debug detail.',
1553
1698
  parameters: Type.Object({
1554
1699
  action: Type.String({
1555
- description: "Action name: help, send, read, free, snooze, schedule, agents, lanes, ports, reload, or exit. Also supports spawn for launching worker-owned subtree children.",
1700
+ description: "Action name: help, send, read, free, snooze, schedule, agents, sessions, lanes, ports, reload, or exit. Also supports spawn for launching worker-owned subtree children.",
1556
1701
  }),
1557
1702
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
1558
1703
  description: 'Action arguments. Add format="cli"|"json" (or f/"-f") for presentation, and full=true (or "--full": true) only for verbose/debug details. Default cli and non-full json keep data.details compact.',
@@ -0,0 +1 @@
1
+ export declare function renderMarkdownForSlackMrkdwn(text: string): string;
@@ -0,0 +1,128 @@
1
+ export function renderMarkdownForSlackMrkdwn(text) {
2
+ if (!text.includes("**")) {
3
+ return text;
4
+ }
5
+ return transformOutsideBacktickCode(text, convertMarkdownStrongToSlackBold);
6
+ }
7
+ function transformOutsideBacktickCode(text, transform) {
8
+ let output = "";
9
+ let segmentStart = 0;
10
+ let index = 0;
11
+ while (index < text.length) {
12
+ if (text[index] !== "`") {
13
+ index += 1;
14
+ continue;
15
+ }
16
+ const runLength = countRepeated(text, index, "`");
17
+ const closingIndex = findClosingBacktickRun(text, index + runLength, runLength);
18
+ if (closingIndex === -1) {
19
+ index += runLength;
20
+ continue;
21
+ }
22
+ output += transform(text.slice(segmentStart, index));
23
+ output += text.slice(index, closingIndex + runLength);
24
+ index = closingIndex + runLength;
25
+ segmentStart = index;
26
+ }
27
+ output += transform(text.slice(segmentStart));
28
+ return output;
29
+ }
30
+ function convertMarkdownStrongToSlackBold(segment) {
31
+ let output = "";
32
+ let index = 0;
33
+ while (index < segment.length) {
34
+ if (segment.startsWith("**", index) && isStrongOpeningDelimiter(segment, index)) {
35
+ const closingIndex = findStrongClosingDelimiter(segment, index + 2);
36
+ if (closingIndex !== -1) {
37
+ output += `*${segment.slice(index + 2, closingIndex)}*`;
38
+ index = closingIndex + 2;
39
+ continue;
40
+ }
41
+ }
42
+ output += segment[index];
43
+ index += 1;
44
+ }
45
+ return output;
46
+ }
47
+ function findStrongClosingDelimiter(segment, fromIndex) {
48
+ let index = fromIndex;
49
+ while (index < segment.length) {
50
+ const next = segment.indexOf("**", index);
51
+ if (next === -1) {
52
+ return -1;
53
+ }
54
+ if (isStrongClosingDelimiter(segment, next)) {
55
+ return next;
56
+ }
57
+ index = next + 2;
58
+ }
59
+ return -1;
60
+ }
61
+ function isStrongOpeningDelimiter(text, index) {
62
+ if (isEscaped(text, index)) {
63
+ return false;
64
+ }
65
+ const previous = text[index - 1];
66
+ const next = text[index + 2];
67
+ return isSafeOpeningBoundary(previous, next);
68
+ }
69
+ function isStrongClosingDelimiter(text, index) {
70
+ if (isEscaped(text, index)) {
71
+ return false;
72
+ }
73
+ const previous = text[index - 1];
74
+ const next = text[index + 2];
75
+ return isSafeClosingBoundary(previous, next);
76
+ }
77
+ function isSafeOpeningBoundary(previous, next) {
78
+ if (!next || isWhitespace(next) || next === "*" || next === "/") {
79
+ return false;
80
+ }
81
+ if (previous === "*" || previous === "/") {
82
+ return false;
83
+ }
84
+ return true;
85
+ }
86
+ function isSafeClosingBoundary(previous, next) {
87
+ if (!previous || isWhitespace(previous) || previous === "*" || previous === "/") {
88
+ return false;
89
+ }
90
+ if (next === "*" || next === "/") {
91
+ return false;
92
+ }
93
+ return true;
94
+ }
95
+ function isWhitespace(value) {
96
+ return /\s/u.test(value);
97
+ }
98
+ function countRepeated(text, index, char) {
99
+ let count = 0;
100
+ while (text[index + count] === char) {
101
+ count += 1;
102
+ }
103
+ return count;
104
+ }
105
+ function findClosingBacktickRun(text, fromIndex, runLength) {
106
+ let index = fromIndex;
107
+ while (index < text.length) {
108
+ const next = text.indexOf("`", index);
109
+ if (next === -1) {
110
+ return -1;
111
+ }
112
+ const nextRunLength = countRepeated(text, next, "`");
113
+ if (nextRunLength === runLength) {
114
+ return next;
115
+ }
116
+ index = next + nextRunLength;
117
+ }
118
+ return -1;
119
+ }
120
+ function isEscaped(text, index) {
121
+ let slashCount = 0;
122
+ let cursor = index - 1;
123
+ while (cursor >= 0 && text[cursor] === "\\") {
124
+ slashCount += 1;
125
+ cursor -= 1;
126
+ }
127
+ return slashCount % 2 === 1;
128
+ }
@@ -61,6 +61,7 @@ export function createSlackPinetRuntimeAdapterFactory(deps) {
61
61
  appToken: deps.getAppToken(),
62
62
  allowedUsers: allowedUsers ? [...allowedUsers] : undefined,
63
63
  allowAllWorkspaceUsers: deps.shouldAllowAllWorkspaceUsers(),
64
+ ingressGuard: settings.ingressGuard,
64
65
  suggestedPrompts: settings.suggestedPrompts,
65
66
  reactionCommands: settings.reactionCommands,
66
67
  isKnownThread: (threadTs) => shouldRouteKnownSlackThread(broker.db.getThread(threadTs)),
@@ -69,6 +70,12 @@ export function createSlackPinetRuntimeAdapterFactory(deps) {
69
70
  rememberKnownSlackThread(broker, threadTs, channelId, context);
70
71
  },
71
72
  isReactionThreadAuthorized: (threadTs, channelId) => isAuthorizedReactionThread(broker, threadTs, channelId),
73
+ isPinetOwnedThread: (threadTs, channelId) => {
74
+ const thread = broker.db.getThread(threadTs);
75
+ if (!thread || thread.source !== "slack" || thread.channel !== channelId)
76
+ return false;
77
+ return !!thread.ownerAgent || readStoredSlackThreadContext(thread.metadata) !== null;
78
+ },
72
79
  onAppHomeOpened: async ({ userId }) => {
73
80
  await deps.onAppHomeOpened(userId, ctx);
74
81
  },
@@ -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
  },
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentSessionSummary } from "./broker/types.js";
2
3
  import type { PinetReadOptions, PinetReadResult } from "@pinet/pinet-core/pinet-read-formatting";
3
4
  import { type InboxMessage, type PinetControlCommand, type PinetRemoteControlRequestResult, type SlackBridgeSettings } from "./helpers.js";
4
5
  export interface SubtreeBrokerPaths {
@@ -32,6 +33,8 @@ export interface SubtreeAgentRecord {
32
33
  name: string;
33
34
  id: string;
34
35
  pid?: number;
36
+ stableId?: string | null;
37
+ session?: AgentSessionSummary | null;
35
38
  status: "working" | "idle";
36
39
  metadata: Record<string, unknown> | null;
37
40
  lastHeartbeat: string;
@@ -4,6 +4,7 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
+ import { summarizePinetStableId } from "./pinet-session-formatting.js";
7
8
  import { dispatchDirectAgentMessage, resolveDirectAgentTarget } from "./broker/agent-messaging.js";
8
9
  import { startBroker } from "./broker/index.js";
9
10
  import { HEARTBEAT_INTERVAL_MS } from "./broker/client.js";
@@ -174,6 +175,8 @@ function toSubtreeAgentRecord(db, agent) {
174
175
  name: agent.name,
175
176
  id: agent.id,
176
177
  pid: agent.pid,
178
+ stableId: agent.stableId ?? null,
179
+ session: summarizePinetStableId(agent.stableId),
177
180
  status: agent.status,
178
181
  metadata: agent.metadata,
179
182
  lastHeartbeat: agent.lastHeartbeat,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinet/slack-bridge",
3
- "version": "0.2.1",
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.1",
53
- "@pinet/imessage-bridge": "0.2.1",
54
- "@pinet/pinet-core": "0.2.1",
55
- "@pinet/transport-core": "0.2.1",
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",