@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.
@@ -1,4 +1,5 @@
1
1
  import { type ParsedAppHomeOpened, type ParsedSlashCommand, type ParsedThreadStarted } from "../../slack-access.js";
2
+ import { type SlackIngressGuardSettings } from "../../helpers.js";
2
3
  import { type ReactionCommandSettings } from "../../reaction-triggers.js";
3
4
  import type { AdapterCapabilityRequest, AdapterCapabilityResult, InboundMessage, OutboundMessage, MessageAdapter } from "./types.js";
4
5
  export { classifyMessage, extractAppHomeOpened, extractThreadStarted, parseMemberJoinedChannel, parseSocketFrame, RECONNECT_DELAY_MS, } from "../../slack-access.js";
@@ -7,6 +8,7 @@ export interface SlackAdapterConfig {
7
8
  appToken: string;
8
9
  allowedUsers?: string[];
9
10
  allowAllWorkspaceUsers?: boolean;
11
+ ingressGuard?: SlackIngressGuardSettings;
10
12
  suggestedPrompts?: {
11
13
  title: string;
12
14
  message: string;
@@ -33,6 +35,8 @@ export interface SlackAdapterConfig {
33
35
  * Slack messages (#812).
34
36
  */
35
37
  isReactionThreadAuthorized?: (threadTs: string, channelId: string) => boolean;
38
+ /** Check whether a known Slack thread is Pinet-owned for mixed-participant mention gating. */
39
+ isPinetOwnedThread?: (threadTs: string, channelId: string) => boolean;
36
40
  /** Best-effort callback for Home tab opens. */
37
41
  onAppHomeOpened?: (event: ParsedAppHomeOpened) => Promise<void> | void;
38
42
  /** Best-effort callback for Slack slash commands handled by the broker process. */
@@ -43,6 +47,7 @@ export declare const SLACK_THREAD_CACHE_TTL_MS: number;
43
47
  export declare const SLACK_PENDING_ATTENTION_MAX_THREADS = 1000;
44
48
  export declare const SLACK_PENDING_ATTENTION_TTL_MS: number;
45
49
  export declare const SLACK_PENDING_ATTENTION_MAX_MESSAGES_PER_THREAD = 50;
50
+ export declare const SLACK_INGRESS_GUARD_THREAD_PARTICIPANT_LIMIT = 200;
46
51
  export declare class SlackAdapter implements MessageAdapter {
47
52
  readonly name = "slack";
48
53
  private readonly config;
@@ -79,6 +84,12 @@ export declare class SlackAdapter implements MessageAdapter {
79
84
  private onReactionAdded;
80
85
  private getCachedThread;
81
86
  private isReactionThreadAuthorized;
87
+ private messageMentionsBot;
88
+ private requiresMentionInChannel;
89
+ private shouldRequireMentionForMixedParticipantThread;
90
+ private isPinetOwnedThreadForIngressGuard;
91
+ private fetchThreadParticipantUserIds;
92
+ private shouldRequireExplicitMention;
82
93
  private onMessage;
83
94
  private emitInteractiveInbound;
84
95
  private onMemberJoined;
@@ -5,6 +5,7 @@ import { buildReactionTriggerMessage, normalizeReactionName, resolveReactionComm
5
5
  import { TtlCache, TtlSet } from "../../ttl-cache.js";
6
6
  import { SLACK_SOCKET_DELIVERY_DEDUP_MAX_SIZE, SLACK_SOCKET_DELIVERY_DEDUP_TTL_MS, } from "../../slack-access.js";
7
7
  import { DEFAULT_SLACK_THREAD_STATUS, SlackThreadStatusManager, } from "../../slack-thread-status.js";
8
+ import { renderMarkdownForSlackMrkdwn } from "../../slack-markdown.js";
8
9
  import { performSlackUploads, prepareSlackUpload } from "../../slack-upload.js";
9
10
  export { classifyMessage, extractAppHomeOpened, extractThreadStarted, parseMemberJoinedChannel, parseSocketFrame, RECONNECT_DELAY_MS, } from "../../slack-access.js";
10
11
  export const SLACK_THREAD_CACHE_MAX_SIZE = 5000;
@@ -12,6 +13,7 @@ export const SLACK_THREAD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
12
13
  export const SLACK_PENDING_ATTENTION_MAX_THREADS = 1000;
13
14
  export const SLACK_PENDING_ATTENTION_TTL_MS = 2 * 60 * 60 * 1000;
14
15
  export const SLACK_PENDING_ATTENTION_MAX_MESSAGES_PER_THREAD = 50;
16
+ export const SLACK_INGRESS_GUARD_THREAD_PARTICIPANT_LIMIT = 200;
15
17
  export class SlackAdapter {
16
18
  name = "slack";
17
19
  config;
@@ -124,9 +126,10 @@ export class SlackAdapter {
124
126
  : msg.blocks && msg.blocks.length > 0
125
127
  ? msg.blocks
126
128
  : undefined;
129
+ const renderedText = renderMarkdownForSlackMrkdwn(msg.content?.markdown ?? msg.content?.text ?? msg.text);
127
130
  const body = {
128
131
  channel: msg.channel,
129
- text: msg.content?.text ?? msg.text,
132
+ text: renderedText,
130
133
  thread_ts: msg.threadId,
131
134
  ...(slackBlocks ? { blocks: slackBlocks } : {}),
132
135
  };
@@ -157,7 +160,7 @@ export class SlackAdapter {
157
160
  uploads,
158
161
  channelId: msg.channel,
159
162
  threadTs: msg.threadId,
160
- initialComment: msg.content?.text ?? msg.text,
163
+ initialComment: renderedText,
161
164
  slack: this.callSlack.bind(this),
162
165
  token: this.config.botToken,
163
166
  });
@@ -464,6 +467,87 @@ export class SlackAdapter {
464
467
  return false;
465
468
  }
466
469
  }
470
+ messageMentionsBot(evt) {
471
+ if (!this.botUserId)
472
+ return false;
473
+ const text = typeof evt.text === "string" ? evt.text : "";
474
+ return text.includes(`<@${this.botUserId}>`) || text.includes(`<@${this.botUserId}|`);
475
+ }
476
+ requiresMentionInChannel(channelId) {
477
+ const channels = this.config.ingressGuard?.requireMention?.channels ?? [];
478
+ return channels.some((channel) => channel.trim() === channelId);
479
+ }
480
+ async shouldRequireMentionForMixedParticipantThread(input) {
481
+ const config = this.config.ingressGuard?.requireMention?.mixedParticipantThreads;
482
+ if (!config?.enabled || input.isDM || typeof input.evt.thread_ts !== "string") {
483
+ return false;
484
+ }
485
+ if (!this.isPinetOwnedThreadForIngressGuard(input.threadTs, input.channel)) {
486
+ return false;
487
+ }
488
+ const participants = await this.fetchThreadParticipantUserIds(input.channel, input.threadTs, input.userId);
489
+ if (!participants) {
490
+ // Fail closed for this guard: if we cannot inspect the thread makeup,
491
+ // require an explicit mention before Pinet acts in a configured mixed-thread posture.
492
+ return true;
493
+ }
494
+ const trustedUsers = new Set((config.trustedUsers ?? []).map((user) => user.trim()).filter(Boolean));
495
+ for (const participant of participants) {
496
+ if (this.botUserId && participant === this.botUserId)
497
+ continue;
498
+ if (trustedUsers.has(participant))
499
+ continue;
500
+ return true;
501
+ }
502
+ return false;
503
+ }
504
+ isPinetOwnedThreadForIngressGuard(threadTs, channelId) {
505
+ const isOwned = this.config.isPinetOwnedThread;
506
+ if (isOwned) {
507
+ try {
508
+ return isOwned(threadTs, channelId);
509
+ }
510
+ catch (error) {
511
+ console.error(`[slack-adapter] Pinet-owned thread check failed: ${errorMsg(error)}`);
512
+ return false;
513
+ }
514
+ }
515
+ const thread = this.getThread(threadTs);
516
+ return !!thread && thread.channelId === channelId;
517
+ }
518
+ async fetchThreadParticipantUserIds(channelId, threadTs, currentUserId) {
519
+ const participants = new Set();
520
+ if (currentUserId)
521
+ participants.add(currentUserId);
522
+ try {
523
+ const response = await this.callSlack("conversations.replies", this.config.botToken, {
524
+ channel: channelId,
525
+ ts: threadTs,
526
+ limit: SLACK_INGRESS_GUARD_THREAD_PARTICIPANT_LIMIT,
527
+ });
528
+ const messages = Array.isArray(response.messages) ? response.messages : [];
529
+ for (const message of messages) {
530
+ if (!message || typeof message !== "object" || Array.isArray(message))
531
+ continue;
532
+ const userId = message.user;
533
+ if (typeof userId === "string" && userId.length > 0) {
534
+ participants.add(userId);
535
+ }
536
+ }
537
+ return participants;
538
+ }
539
+ catch (error) {
540
+ console.error(`[slack-adapter] failed to inspect Slack thread participants: ${errorMsg(error)}`);
541
+ return null;
542
+ }
543
+ }
544
+ async shouldRequireExplicitMention(input) {
545
+ if (!this.config.ingressGuard?.requireMention)
546
+ return false;
547
+ if (this.requiresMentionInChannel(input.channel))
548
+ return true;
549
+ return this.shouldRequireMentionForMixedParticipantThread(input);
550
+ }
467
551
  async onMessage(evt) {
468
552
  if (this.shuttingDown)
469
553
  return;
@@ -480,6 +564,10 @@ export class SlackAdapter {
480
564
  // traffic can never mint known-thread/affinity side effects (#812).
481
565
  if (!isSlackUserAllowed(this.allowlist, userId))
482
566
  return;
567
+ if (!this.messageMentionsBot(evt) &&
568
+ (await this.shouldRequireExplicitMention({ evt, channel, threadTs, userId, isDM }))) {
569
+ return;
570
+ }
483
571
  if (!this.getThread(threadTs)) {
484
572
  this.threads.set(threadTs, {
485
573
  channelId: channel,
@@ -1,5 +1,5 @@
1
1
  import type { PinetReadOptions, PinetReadResult } from "@pinet/pinet-core/pinet-read-formatting";
2
- import type { ClientAgentInfo, NormalizedMessageContent, OutboundAttachmentFile, PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./types.js";
2
+ import type { AgentSessionSearchInfo, AgentSessionSearchOptions, ClientAgentInfo, NormalizedMessageContent, OutboundAttachmentFile, PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput } from "./types.js";
3
3
  export interface InboxItem {
4
4
  inboxId: number;
5
5
  message: {
@@ -25,6 +25,7 @@ export interface ThreadInfo {
25
25
  updatedAt: string;
26
26
  }
27
27
  export type AgentInfo = ClientAgentInfo;
28
+ export type { AgentSessionSearchInfo, AgentSessionSearchOptions };
28
29
  export interface ScheduledWakeupInfo {
29
30
  id: number;
30
31
  threadId: string;
@@ -136,6 +137,7 @@ export declare class BrokerClient {
136
137
  expirePortLeases(): Promise<PortLeaseInfo[]>;
137
138
  listThreads(): Promise<ThreadInfo[]>;
138
139
  listAgents(includeDisconnected?: boolean): Promise<AgentInfo[]>;
140
+ searchAgentSessions(options?: AgentSessionSearchOptions): Promise<AgentSessionSearchInfo[]>;
139
141
  invokeAdapterCapability(adapter: string, capability: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
140
142
  /**
141
143
  * Compatibility wrapper for callers that still need direct Slack API access.
@@ -366,6 +366,17 @@ export class BrokerClient {
366
366
  const result = (await this.request("agents.list", includeDisconnected ? { includeDisconnected: true } : undefined));
367
367
  return result;
368
368
  }
369
+ async searchAgentSessions(options = {}) {
370
+ try {
371
+ return (await this.request("agent.sessions.search", options));
372
+ }
373
+ catch (err) {
374
+ if (isRpcMethodNotFoundError(err, "agent.sessions.search")) {
375
+ throw new Error("Broker does not support Pinet session search (`agent.sessions.search`). Upgrade the broker before using pinet action=sessions.");
376
+ }
377
+ throw err;
378
+ }
379
+ }
369
380
  // ─── Adapter capabilities ───────────────────────────
370
381
  async invokeAdapterCapability(adapter, capability, params = {}) {
371
382
  const result = (await this.request("adapter.capability", {
@@ -98,6 +98,7 @@ export declare class BrokerSocketServer {
98
98
  private handleMessageSend;
99
99
  private handleThreadsList;
100
100
  private handleAgentsList;
101
+ private handleAgentSessionsSearch;
101
102
  private handleThreadClaim;
102
103
  private handleResolveThread;
103
104
  private handleAgentMessage;
@@ -116,5 +117,6 @@ export declare class BrokerSocketServer {
116
117
  private handleAdapterCapability;
117
118
  private handleLegacySlackProxy;
118
119
  private dispatchAdapterCapability;
120
+ private checkAdapterCapabilityThreadOwnership;
119
121
  private applyAdapterCapabilityEffects;
120
122
  }
@@ -7,14 +7,17 @@ import { MessageRouter } from "./router.js";
7
7
  import { dispatchDirectAgentMessage } from "./agent-messaging.js";
8
8
  import { sendBrokerMessage } from "./message-send.js";
9
9
  import { assertLoopbackTcpHost } from "./raw-tcp-loopback.js";
10
+ import { summarizePinetStableId } from "../pinet-session-formatting.js";
10
11
  import { RPC_PARSE_ERROR, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_INVALID_PARAMS, RPC_INTERNAL_ERROR, RPC_AUTH_REQUIRED, RPC_AGENT_NAME_CONFLICT, RPC_AGENT_STABLE_ID_CONFLICT, } from "./types.js";
11
12
  export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 15_000;
12
13
  export const DEFAULT_PRUNE_INTERVAL_MS = 5_000;
13
14
  export const DEFAULT_AUTH_TIMEOUT_MS = 2_000;
14
15
  function toClientAgentInfo(agent) {
15
16
  const { stableId, ...clientAgent } = agent;
16
- void stableId;
17
- return clientAgent;
17
+ return {
18
+ ...clientAgent,
19
+ session: summarizePinetStableId(stableId),
20
+ };
18
21
  }
19
22
  // ─── RPC helpers ─────────────────────────────────────────
20
23
  function rpcOk(id, result) {
@@ -351,6 +354,8 @@ export class BrokerSocketServer {
351
354
  return this.handleThreadsList(req, state);
352
355
  case "agents.list":
353
356
  return this.handleAgentsList(req);
357
+ case "agent.sessions.search":
358
+ return this.handleAgentSessionsSearch(req, state);
354
359
  case "thread.claim":
355
360
  return this.handleThreadClaim(req, state);
356
361
  case "resolveThread":
@@ -686,6 +691,35 @@ export class BrokerSocketServer {
686
691
  }));
687
692
  return rpcOk(req.id, agents);
688
693
  }
694
+ handleAgentSessionsSearch(req, state) {
695
+ if (!state.agentId) {
696
+ return rpcError(req.id, RPC_INVALID_PARAMS, "Not registered");
697
+ }
698
+ const caller = this.db.getAgentById(state.agentId);
699
+ const metadataRole = typeof caller?.metadata?.role === "string" ? caller.metadata.role.trim().toLowerCase() : "";
700
+ const capabilities = caller?.metadata?.capabilities &&
701
+ typeof caller.metadata.capabilities === "object" &&
702
+ !Array.isArray(caller.metadata.capabilities)
703
+ ? caller.metadata.capabilities
704
+ : null;
705
+ const capabilityRole = typeof capabilities?.role === "string" ? capabilities.role.trim().toLowerCase() : "";
706
+ if (metadataRole !== "broker" && capabilityRole !== "broker") {
707
+ return rpcError(req.id, RPC_INVALID_PARAMS, "agent.sessions.search requires a broker agent");
708
+ }
709
+ const params = req.params ?? {};
710
+ const options = {
711
+ ...(typeof params.agentName === "string" ? { agentName: params.agentName } : {}),
712
+ ...(typeof params.agentId === "string" ? { agentId: params.agentId } : {}),
713
+ ...(typeof params.threadId === "string" ? { threadId: params.threadId } : {}),
714
+ ...(typeof params.repo === "string" ? { repo: params.repo } : {}),
715
+ ...(typeof params.worktreePath === "string" ? { worktreePath: params.worktreePath } : {}),
716
+ ...(typeof params.tmuxSession === "string" ? { tmuxSession: params.tmuxSession } : {}),
717
+ ...(typeof params.since === "string" ? { since: params.since } : {}),
718
+ ...(typeof params.until === "string" ? { until: params.until } : {}),
719
+ ...(typeof params.limit === "number" ? { limit: params.limit } : {}),
720
+ };
721
+ return rpcOk(req.id, this.db.searchAgentSessions(options));
722
+ }
689
723
  // ─── Thread claim handler ─────────────────────────────
690
724
  handleThreadClaim(req, state) {
691
725
  if (!state.agentId) {
@@ -1044,6 +1078,14 @@ export class BrokerSocketServer {
1044
1078
  }
1045
1079
  // ─── Adapter capability handler ───────────────────────
1046
1080
  async handleAdapterCapability(req, state) {
1081
+ // #855: adapter.capability must be identity-bound so the broker can
1082
+ // enforce thread ownership. Unregistered callers cannot own or claim
1083
+ // threads, so they must not be able to invoke outbound-side capabilities
1084
+ // (chat.postMessage in particular) that would race a first-responder
1085
+ // claim or take over a thread already owned by another agent.
1086
+ if (!state.agentId) {
1087
+ return rpcError(req.id, RPC_INVALID_PARAMS, "Not registered");
1088
+ }
1047
1089
  const params = req.params ?? {};
1048
1090
  const adapterName = typeof params.adapter === "string"
1049
1091
  ? params.adapter.trim()
@@ -1063,6 +1105,12 @@ export class BrokerSocketServer {
1063
1105
  return await this.dispatchAdapterCapability(req.id, adapterName, capability, capabilityParams, state);
1064
1106
  }
1065
1107
  async handleLegacySlackProxy(req, state) {
1108
+ // #855: legacy slack.proxy is a compatibility wrapper over
1109
+ // adapter.capability and must enforce the same registration bar so
1110
+ // unregistered callers cannot bypass thread ownership via chat.postMessage.
1111
+ if (!state.agentId) {
1112
+ return rpcError(req.id, RPC_INVALID_PARAMS, "Not registered");
1113
+ }
1066
1114
  const params = req.params ?? {};
1067
1115
  const method = typeof params.method === "string" ? params.method.trim() : "";
1068
1116
  if (!method) {
@@ -1078,6 +1126,15 @@ export class BrokerSocketServer {
1078
1126
  if (!adapter?.invokeCapability) {
1079
1127
  return rpcError(id, RPC_METHOD_NOT_FOUND, `Adapter ${adapterName} does not implement capability ${capability}`);
1080
1128
  }
1129
+ // #855: refuse cross-owner Slack chat.postMessage before hitting Slack.
1130
+ // Without this pre-check the adapter posts first and the broker races a
1131
+ // first-responder-wins claim via effects.claimThread — letting an
1132
+ // unauthorized follower take over a thread already owned by another
1133
+ // agent simply by winning the send.
1134
+ const ownershipError = this.checkAdapterCapabilityThreadOwnership(adapterName, capability, capabilityParams, state.agentId);
1135
+ if (ownershipError) {
1136
+ return rpcError(id, RPC_INVALID_PARAMS, `${errorPrefix}: ${ownershipError}`);
1137
+ }
1081
1138
  try {
1082
1139
  const response = await adapter.invokeCapability({ capability, params: capabilityParams });
1083
1140
  this.applyAdapterCapabilityEffects(adapterName, response, state);
@@ -1088,6 +1145,36 @@ export class BrokerSocketServer {
1088
1145
  return rpcError(id, RPC_INTERNAL_ERROR, `${errorPrefix}: ${message}`);
1089
1146
  }
1090
1147
  }
1148
+ checkAdapterCapabilityThreadOwnership(adapterName, capability, capabilityParams, callerAgentId) {
1149
+ if (adapterName !== "slack")
1150
+ return null;
1151
+ if (capability !== "api.call")
1152
+ return null;
1153
+ const method = typeof capabilityParams.method === "string" ? capabilityParams.method.trim() : "";
1154
+ if (method !== "chat.postMessage")
1155
+ return null;
1156
+ const inner = capabilityParams.params &&
1157
+ typeof capabilityParams.params === "object" &&
1158
+ !Array.isArray(capabilityParams.params)
1159
+ ? capabilityParams.params
1160
+ : {};
1161
+ const threadTs = typeof inner.thread_ts === "string" ? inner.thread_ts.trim() : "";
1162
+ if (!threadTs)
1163
+ return null;
1164
+ // Defense in depth (#855): even if a future call path forgot the
1165
+ // registration guard on the handler, refuse threaded chat.postMessage
1166
+ // for unregistered callers here — they cannot own a Slack thread and
1167
+ // must not be able to post into one.
1168
+ if (!callerAgentId) {
1169
+ return `Slack thread ${threadTs}: refusing threaded chat.postMessage from an unregistered caller`;
1170
+ }
1171
+ const thread = this.db.getThread(threadTs);
1172
+ if (!thread?.ownerAgent)
1173
+ return null;
1174
+ if (thread.ownerAgent === callerAgentId)
1175
+ return null;
1176
+ return `Slack thread ${threadTs} is already owned by another agent; refusing cross-owner chat.postMessage`;
1177
+ }
1091
1178
  applyAdapterCapabilityEffects(adapterName, response, state) {
1092
1179
  if (!state.agentId)
1093
1180
  return;
package/dist/helpers.d.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  import { type RuntimeScopeCarrier } from "@pinet/transport-core";
2
2
  import type { ReactionCommandSettings } from "./reaction-triggers.js";
3
+ import type { AgentSessionSummary } from "./broker/types.js";
4
+ export interface SlackIngressGuardSettings {
5
+ requireMention?: {
6
+ /** Slack channel IDs where every actionable message must explicitly mention the bot. */
7
+ channels?: string[];
8
+ /** Require mention in Pinet-owned threads once anyone outside trustedUsers + the bot participates. */
9
+ mixedParticipantThreads?: {
10
+ enabled?: boolean;
11
+ trustedUsers?: string[];
12
+ };
13
+ };
14
+ }
3
15
  export interface SlackBridgeSettings {
4
16
  botToken?: string;
5
17
  appToken?: string;
@@ -7,6 +19,7 @@ export interface SlackBridgeSettings {
7
19
  appConfigToken?: string;
8
20
  allowedUsers?: string[];
9
21
  allowAllWorkspaceUsers?: boolean;
22
+ ingressGuard?: SlackIngressGuardSettings;
10
23
  defaultChannel?: string;
11
24
  logChannel?: string;
12
25
  logLevel?: "errors" | "actions" | "verbose";
@@ -157,6 +170,8 @@ export interface AgentDisplayInfo {
157
170
  name: string;
158
171
  id: string;
159
172
  pid?: number;
173
+ stableId?: string | null;
174
+ session?: AgentSessionSummary | null;
160
175
  status: "working" | "idle";
161
176
  metadata?: {
162
177
  cwd?: string;
@@ -210,6 +225,8 @@ export interface AgentVisibilityInput {
210
225
  name: string;
211
226
  id: string;
212
227
  pid?: number;
228
+ stableId?: string | null;
229
+ session?: AgentSessionSummary | null;
213
230
  status: "working" | "idle";
214
231
  metadata?: Record<string, unknown> | null;
215
232
  lastHeartbeat?: string;
package/dist/helpers.js CHANGED
@@ -5,6 +5,7 @@ import { classifyPinetMail, formatPinetMailClassLabel, } from "@pinet/broker-cor
5
5
  import { buildCompatibilityInstanceScope, buildCompatibilityWorkspaceScope, buildRuntimeScopeCarrier, } from "@pinet/transport-core";
6
6
  import { buildPinetReadPointer } from "./broker-inbound-persistence.js";
7
7
  import { matchesToolPattern } from "./guardrails.js";
8
+ import { getPinetSessionPath, summarizePinetStableId } from "./pinet-session-formatting.js";
8
9
  function normalizeOptionalSetting(value) {
9
10
  const trimmed = value?.trim();
10
11
  return trimmed && trimmed.length > 0 ? trimmed : null;
@@ -855,6 +856,8 @@ export function buildAgentDisplayInfo(agent, options = {}) {
855
856
  name: agent.name,
856
857
  id: agent.id,
857
858
  ...(agent.pid != null ? { pid: agent.pid } : {}),
859
+ ...(agent.stableId !== undefined ? { stableId: agent.stableId } : {}),
860
+ session: agent.session ?? summarizePinetStableId(agent.stableId),
858
861
  status: agent.status,
859
862
  metadata: metadata || hasHierarchyMetadata
860
863
  ? {
@@ -1733,7 +1736,15 @@ export function formatAgentList(agents, homedir) {
1733
1736
  : "";
1734
1737
  const stuckTag = a.stuck ? " [stuck]" : "";
1735
1738
  const pid = a.pid != null ? ` pid:${a.pid}` : "";
1736
- let line = `${a.emoji} ${a.name} (${a.id}) \u2014 ${a.status}${statusFlavor}${health}${stuckTag}${pid}`;
1739
+ const sessionRef = a.session?.ref ? ` session:${a.session.ref}` : "";
1740
+ let line = `${a.emoji} ${a.name} (${a.id}) \u2014 ${a.status}${statusFlavor}${health}${stuckTag}${pid}${sessionRef}`;
1741
+ if (a.stableId) {
1742
+ const sessionPath = getPinetSessionPath(a.stableId);
1743
+ line += `\n stable: ${a.stableId}`;
1744
+ if (sessionPath) {
1745
+ line += `\n jsonl: ${sessionPath}`;
1746
+ }
1747
+ }
1737
1748
  const meta = a.metadata;
1738
1749
  if (meta && (meta.cwd || meta.branch || meta.host || meta.gitProbeFailed)) {
1739
1750
  const cwd = meta.cwd ? shortenPath(meta.cwd, homedir) : "";
package/dist/index.js CHANGED
@@ -733,7 +733,7 @@ export default function (pi) {
733
733
  brokerRuntime.logActivity(entry);
734
734
  },
735
735
  });
736
- const { sendPinetAgentMessage, sendPinetBroadcastMessage, scheduleBrokerWakeup, scheduleFollowerWakeup, listBrokerAgents, listFollowerAgents, } = pinetMeshOps;
736
+ const { sendPinetAgentMessage, sendPinetBroadcastMessage, scheduleBrokerWakeup, scheduleFollowerWakeup, listBrokerAgents, listFollowerAgents, searchPinetSessions, } = pinetMeshOps;
737
737
  async function listPinetLanes(options) {
738
738
  if (brokerRole === "broker") {
739
739
  const db = getActiveBrokerDb();
@@ -1002,6 +1002,7 @@ export default function (pi) {
1002
1002
  getBotUserId: () => botUserId,
1003
1003
  registerConfirmationRequest,
1004
1004
  pinetDelivery: {
1005
+ isEnabled: () => pinetEnabled,
1005
1006
  isAvailable: () => pinetEnabled && brokerRole !== null,
1006
1007
  sendSlackMessage: async (input) => {
1007
1008
  const content = {
@@ -1072,6 +1073,7 @@ export default function (pi) {
1072
1073
  readPinetInbox,
1073
1074
  listBrokerAgents,
1074
1075
  listFollowerAgents,
1076
+ searchPinetSessions,
1075
1077
  listSubtreeAgents: (includeGhosts) => subtreeBrokerRuntime.listAgents(includeGhosts),
1076
1078
  getSubtreeSelfAgentId: () => subtreeBrokerRuntime.getStatus().selfAgentId,
1077
1079
  spawnSubtreeWorker: async (input) => {
@@ -1,11 +1,13 @@
1
1
  import { type AgentMessageStorage } from "./broker/agent-messaging.js";
2
- import type { AgentInfo, TaskAssignmentInfo, TaskAssignmentKind } from "./broker/types.js";
2
+ import type { AgentInfo, AgentSessionSearchInfo, AgentSessionSearchOptions, AgentSessionSummary, TaskAssignmentInfo, TaskAssignmentKind } from "./broker/types.js";
3
3
  import type { ActivityLogEntry } from "./activity-log.js";
4
4
  export interface PinetMeshOpsAgentRecord {
5
5
  emoji: string;
6
6
  name: string;
7
7
  id: string;
8
8
  pid?: number;
9
+ stableId?: string | null;
10
+ session?: AgentSessionSummary | null;
9
11
  status: "working" | "idle";
10
12
  metadata: Record<string, unknown> | null;
11
13
  lastHeartbeat: string;
@@ -32,6 +34,7 @@ export interface PinetMeshOpsTransferableThread {
32
34
  }
33
35
  export interface PinetMeshOpsBrokerDbPort extends AgentMessageStorage {
34
36
  getAllAgents: () => AgentInfo[];
37
+ searchAgentSessions: (options?: AgentSessionSearchOptions) => AgentSessionSearchInfo[];
35
38
  getPendingInboxCount: (agentId: string) => number;
36
39
  getThread: (threadId: string) => PinetMeshOpsTransferableThread | null;
37
40
  transferThreadOwnership: (threadId: string, ownerAgent: string) => {
@@ -62,6 +65,7 @@ export interface PinetMeshOpsFollowerClientPort {
62
65
  fireAt: string;
63
66
  }>;
64
67
  listAgents: (includeGhosts: boolean) => Promise<PinetMeshOpsFollowerAgentRecord[]>;
68
+ searchAgentSessions: (options: AgentSessionSearchOptions) => Promise<AgentSessionSearchInfo[]>;
65
69
  }
66
70
  export interface PinetMeshOpsDeps {
67
71
  getPinetEnabled: () => boolean;
@@ -100,6 +104,7 @@ export interface PinetMeshOps {
100
104
  }>;
101
105
  listBrokerAgents: () => PinetMeshOpsAgentRecord[];
102
106
  listFollowerAgents: (includeGhosts: boolean) => Promise<PinetMeshOpsAgentRecord[]>;
107
+ searchPinetSessions: (options: AgentSessionSearchOptions) => Promise<AgentSessionSearchInfo[]>;
103
108
  }
104
109
  export declare function parseGitHubRemoteRepo(remoteUrl: string): {
105
110
  repoOwner: string;
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { normalizeOutgoingPinetControlMessage } from "./helpers.js";
4
4
  import { dispatchBroadcastAgentMessage, dispatchDirectAgentMessage, isBroadcastChannelTarget, } from "./broker/agent-messaging.js";
5
+ import { summarizePinetStableId } from "./pinet-session-formatting.js";
5
6
  import { extractTaskAssignmentsFromMessage } from "./task-assignments.js";
6
7
  const execFileAsync = promisify(execFile);
7
8
  function prepareOutgoingPinetAgentMessage(body, metadata) {
@@ -33,6 +34,7 @@ function appendSlackThreadTransferNotice(body, threadId, channel) {
33
34
  `- channel: ${channel}`,
34
35
  `- To report directly in the transferred Slack thread, use slack_send with thread_ts ${threadId}; the channel is already recorded in Pinet.`,
35
36
  "- If slack_send says the thread is already owned by another agent, ask the broker to inspect ownership and transfer it again.",
37
+ "- If slack_send says the Pinet broker is unavailable, wait for the broker to reconnect \u2014 do NOT retry via post_channel; direct posting would bypass thread ownership (#855).",
36
38
  ].join("\n");
37
39
  }
38
40
  export function parseGitHubRemoteRepo(remoteUrl) {
@@ -230,6 +232,8 @@ export function createPinetMeshOps(deps) {
230
232
  name: agent.name,
231
233
  id: agent.id,
232
234
  pid: agent.pid,
235
+ stableId: agent.stableId ?? null,
236
+ session: summarizePinetStableId(agent.stableId),
233
237
  status: agent.status,
234
238
  metadata: agent.metadata,
235
239
  lastHeartbeat: agent.lastHeartbeat,
@@ -256,6 +260,7 @@ export function createPinetMeshOps(deps) {
256
260
  name: agent.name,
257
261
  id: agent.id,
258
262
  pid: agent.pid,
263
+ session: agent.session ?? null,
259
264
  status: agent.status ?? "idle",
260
265
  metadata: agent.metadata,
261
266
  lastHeartbeat: agent.lastHeartbeat,
@@ -272,6 +277,20 @@ export function createPinetMeshOps(deps) {
272
277
  laneId: agent.laneId,
273
278
  }));
274
279
  }
280
+ async function searchPinetSessions(options) {
281
+ if (deps.getBrokerRole() === "broker") {
282
+ const db = deps.getActiveBrokerDb();
283
+ if (!db) {
284
+ throw new Error("Broker agent identity is unavailable.");
285
+ }
286
+ return db.searchAgentSessions(options);
287
+ }
288
+ const client = deps.getFollowerClient();
289
+ if (!client) {
290
+ throw new Error("Pinet is in an unexpected state.");
291
+ }
292
+ return await client.searchAgentSessions(options);
293
+ }
275
294
  return {
276
295
  sendPinetAgentMessage,
277
296
  sendPinetBroadcastMessage,
@@ -279,5 +298,6 @@ export function createPinetMeshOps(deps) {
279
298
  scheduleFollowerWakeup,
280
299
  listBrokerAgents,
281
300
  listFollowerAgents,
301
+ searchPinetSessions,
282
302
  };
283
303
  }
@@ -0,0 +1,13 @@
1
+ import type { AgentSessionKind, AgentSessionSearchInfo, AgentSessionSummary } from "./broker/types.js";
2
+ export interface ParsedPinetStableId {
3
+ host: string | null;
4
+ kind: AgentSessionKind;
5
+ locator: string;
6
+ hasPath: boolean;
7
+ }
8
+ export declare function parsePinetStableId(stableId: string | null | undefined): ParsedPinetStableId | null;
9
+ export declare function summarizePinetStableId(stableId: string | null | undefined): AgentSessionSummary | null;
10
+ export declare function getPinetSessionPath(stableId: string | null | undefined): string | null;
11
+ export declare function getPinetSessionFilename(stableId: string | null | undefined): string | null;
12
+ export declare function buildPinetSessionFullDetails(session: AgentSessionSearchInfo): Record<string, unknown>;
13
+ export declare function buildPinetSessionCompactDetails(session: AgentSessionSearchInfo): Record<string, unknown>;