@pinet/slack-bridge 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -160,6 +160,15 @@ Behavior and precedence:
160
160
  "appToken": "xapp-...",
161
161
  "runtimeMode": "single",
162
162
  "allowedUsers": ["U_EXAMPLE_MEMBER_ID"],
163
+ "ingressGuard": {
164
+ "requireMention": {
165
+ "channels": ["C_EXTERNAL_CHANNEL_ID"],
166
+ "mixedParticipantThreads": {
167
+ "enabled": true,
168
+ "trustedUsers": ["U_EXAMPLE_MEMBER_ID"]
169
+ }
170
+ }
171
+ },
163
172
  "defaultChannel": "C_EXAMPLE_CHANNEL_ID",
164
173
  "logChannel": "#pinet-logs",
165
174
  "logLevel": "actions",
@@ -183,30 +192,33 @@ Slack access is now **default-deny** unless you configure one of these explicitl
183
192
  - `allowedUsers` / `SLACK_ALLOWED_USERS` — allow only specific Slack user IDs
184
193
  - `allowAllWorkspaceUsers: true` / `SLACK_ALLOW_ALL_WORKSPACE_USERS=true` — explicit workspace-wide opt-in
185
194
 
186
- | Key | Required | Description |
187
- | ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
188
- | `botToken` | **yes** | Bot User OAuth Token (`xoxb-...`) |
189
- | `appToken` | **yes** | App-Level Token for Socket Mode (`xapp-...`) |
190
- | `allowedUsers` | no | Slack user IDs that can interact; when unset, access is denied unless `allowAllWorkspaceUsers` is true |
191
- | `allowAllWorkspaceUsers` | no | Explicit opt-in for workspace-wide Slack access when you do not want a user allowlist |
192
- | `defaultChannel` | no | Default channel for the `slack` dispatcher `post_channel` action |
193
- | `logChannel` | no | Channel for broker activity logs |
194
- | `logLevel` | no | `"errors"`, `"actions"` (default), or `"verbose"` |
195
- | `runtimeMode` | no | Explicit startup mode: `"off"`, `"single"`, `"broker"`, or `"follower"` |
196
- | `autoConnect` | no | Legacy compatibility alias for `runtimeMode: "single"` |
197
- | `autoFollow` | no | Legacy compatibility alias for follower startup when a broker socket exists |
198
- | `ralphLoopIntervalMs` | no | Broker RALPH maintenance cadence in milliseconds; defaults to `300000` (5 minutes), valid range `1000`-`2147483647` |
199
- | `ralphSnoozeAfterEmptyCycles` | no | Broker RALPH auto-snooze trigger after N empty cycles; defaults to `0` (disabled), valid range `0`-`100` |
200
- | `ralphSnoozeDurationMs` | no | Broker RALPH auto-snooze duration in milliseconds; defaults to `1800000` (30 minutes), valid range `60000`-`86400000` |
201
- | `skinTheme` | no | Pinet presentation skin selected at broker startup/reload (`default`, `foundation`, `cosmere`, or free-form) |
202
- | `slackCommandName` | no | Slack web app slash command name for `agents list`; defaults to `/pinet`, or `/oathgate` for Oathgate/Cosmere skins |
203
- | `slackCommandNames` | no | Optional list of accepted/deployed Slack slash command aliases when one app needs multiple command names |
204
- | `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
205
- | `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
206
- | `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
207
- | `security.readOnly` | no | Runtime-block write-capable tools for Slack-triggered turns, including core tools like `bash`, `edit`, and `write` |
208
- | `security.requireConfirmation` | no | Runtime-require Slack approval before matching tools execute; core tools need a specific Slack thread context |
209
- | `security.blockedTools` | no | Runtime-block matching tools for Slack-triggered turns, including core tools |
195
+ Optional explicit invocation guard: `ingressGuard.requireMention` is off by default. Set `channels` to Slack channel IDs where otherwise-actionable messages must mention the bot (`@pinet` / `<@bot>`), and set `mixedParticipantThreads.enabled: true` with `trustedUsers` to require a mention in Pinet-owned Slack threads once anyone outside `trustedUsers` plus the bot has participated. This guard is orthogonal to `allowedUsers`: sender authorization still decides who may invoke Pinet; the guard only decides when an explicit mention is required.
196
+
197
+ | Key | Required | Description |
198
+ | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- |
199
+ | `botToken` | **yes** | Bot User OAuth Token (`xoxb-...`) |
200
+ | `appToken` | **yes** | App-Level Token for Socket Mode (`xapp-...`) |
201
+ | `allowedUsers` | no | Slack user IDs that can interact; when unset, access is denied unless `allowAllWorkspaceUsers` is true |
202
+ | `allowAllWorkspaceUsers` | no | Explicit opt-in for workspace-wide Slack access when you do not want a user allowlist |
203
+ | `ingressGuard.requireMention` | no | Optional Slack ingress guard requiring `@pinet` in configured channel IDs and/or mixed-participant Pinet-owned threads |
204
+ | `defaultChannel` | no | Default channel for the `slack` dispatcher `post_channel` action |
205
+ | `logChannel` | no | Channel for broker activity logs |
206
+ | `logLevel` | no | `"errors"`, `"actions"` (default), or `"verbose"` |
207
+ | `runtimeMode` | no | Explicit startup mode: `"off"`, `"single"`, `"broker"`, or `"follower"` |
208
+ | `autoConnect` | no | Legacy compatibility alias for `runtimeMode: "single"` |
209
+ | `autoFollow` | no | Legacy compatibility alias for follower startup when a broker socket exists |
210
+ | `ralphLoopIntervalMs` | no | Broker RALPH maintenance cadence in milliseconds; defaults to `300000` (5 minutes), valid range `1000`-`2147483647` |
211
+ | `ralphSnoozeAfterEmptyCycles` | no | Broker RALPH auto-snooze trigger after N empty cycles; defaults to `0` (disabled), valid range `0`-`100` |
212
+ | `ralphSnoozeDurationMs` | no | Broker RALPH auto-snooze duration in milliseconds; defaults to `1800000` (30 minutes), valid range `60000`-`86400000` |
213
+ | `skinTheme` | no | Pinet presentation skin selected at broker startup/reload (`default`, `foundation`, `cosmere`, or free-form) |
214
+ | `slackCommandName` | no | Slack web app slash command name for `agents list`; defaults to `/pinet`, or `/oathgate` for Oathgate/Cosmere skins |
215
+ | `slackCommandNames` | no | Optional list of accepted/deployed Slack slash command aliases when one app needs multiple command names |
216
+ | `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
217
+ | `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
218
+ | `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
219
+ | `security.readOnly` | no | Runtime-block write-capable tools for Slack-triggered turns, including core tools like `bash`, `edit`, and `write` |
220
+ | `security.requireConfirmation` | no | Runtime-require Slack approval before matching tools execute; core tools need a specific Slack thread context |
221
+ | `security.blockedTools` | no | Runtime-block matching tools for Slack-triggered turns, including core tools |
210
222
 
211
223
  ## Scope carrier model (compatibility-first)
212
224
 
@@ -516,17 +528,19 @@ Only broker prompt content is replaceable. Broker runtime/tool restrictions rema
516
528
 
517
529
  ### Multi-agent tools
518
530
 
519
- | Tool | Description |
520
- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
521
- | `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `lanes`, `ports`, `spawn`, `reload`, `exit`) |
531
+ | Tool | Description |
532
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
533
+ | `pinet` | Pinet dispatcher with token-efficient `action`-based routing (`help`, `send`, `read`, `free`, `snooze`, `schedule`, `agents`, `sessions`, `lanes`, `ports`, `spawn`, `reload`, `exit`) |
522
534
 
523
- Use the dispatcher for Pinet tool actions: `pinet action=send`, `pinet action=read`, `pinet action=free`, `pinet action=snooze`, `pinet action=schedule`, `pinet action=agents`, `pinet action=lanes`, `pinet action=ports`, `pinet action=spawn`, `pinet action=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, `/pinet unfollow`, and `/pinet subtree start`. Dedicated direct Pinet tools (`pinet_message`, `pinet_read`, `pinet_agents`, `pinet_free`, `pinet_schedule`) are no longer registered. Legacy `pinet_*` guardrail patterns still match dispatcher action names, and legacy send policies such as `pinet_send` or `pinet_message` also cover `pinet action=send`, so existing security configs fail closed during migration.
535
+ Use the dispatcher for Pinet tool actions: `pinet action=send`, `pinet action=read`, `pinet action=free`, `pinet action=snooze`, `pinet action=schedule`, `pinet action=agents`, `pinet action=sessions`, `pinet action=lanes`, `pinet action=ports`, `pinet action=spawn`, `pinet action=reload`, and `pinet action=exit`. Use slash commands for UI lifecycle transitions: `/pinet start`, `/pinet follow`, `/pinet unfollow`, and `/pinet subtree start`. Dedicated direct Pinet tools (`pinet_message`, `pinet_read`, `pinet_agents`, `pinet_free`, `pinet_schedule`) are no longer registered. Legacy `pinet_*` guardrail patterns still match dispatcher action names, and legacy send policies such as `pinet_send` or `pinet_message` also cover `pinet action=send`, so existing security configs fail closed during migration.
524
536
 
525
537
  Worker-owned subtree brokers let a follower worker supervise its own child mesh without registering those children in the central broker. Run `/pinet subtree start` (alias: `/pinet subbroker start`) from a follower worker. The worker remains connected to the central broker as a normal worker, and it also starts a separate broker socket/database under `~/.pi/pinet-subtrees/<worker>/`. Child workers launched by this worker receive `PINET_SOCKET_PATH`, `PINET_PARENT_AGENT_ID`, `PINET_ROOT_AGENT_ID`, `PINET_LAUNCH_ID`, `PINET_SUBTREE_ROLE`, and related metadata, so they follow the worker's subtree broker instead of the central Pinet broker.
526
538
 
527
539
  Use `pinet action=spawn args.repo=<repo> args.task=<task> [args.role=<role>] [args.lane_id=<lane>]` or `/pinet subtree spawn repo=<repo> [role=<role>] [lane=<lane>] <task>` to launch a tmux-backed child worker, wait for it to register in the subtree broker, and deliver the task over private Pinet A2A. Use `pinet action=agents args.scope=subtree args.full=true` from the supervising worker to list subtree children, `pinet action=send args.to=<child> args.message=<message>` to reply/control them, `pinet action=read` to read child reports, and `pinet action=exit args.target=<child>` or `/pinet subtree stop` to clean them up. The central broker sees only the supervising worker; the subtree DB contains the child roster and messages.
528
540
 
529
- Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, and agent lists. Bulky read/agent payloads are compacted in `data.details` by default, including when `args.format="json"` (or `args.f` / `args["-f"]`) renders the dispatcher envelope in content. Use `args.full=true` / `args["--full"]=true` only when you need verbose text and full structured debug details such as exact message bodies or agent metadata.
541
+ Dispatcher content defaults to terse CLI-style confirmations/summaries for noisy reads, sends, agent lists, and session lookups. Bulky read/agent/session payloads are compacted in `data.details` by default, including when `args.format="json"` (or `args.f` / `args["-f"]`) renders the dispatcher envelope in content. Use `args.full=true` / `args["--full"]=true` only when you need verbose text and full structured debug details such as exact message bodies, agent metadata, stable session IDs, or local session JSONL paths.
542
+
543
+ `pinet action=agents` shows a broker-safe session reference (`session:<digest>`) alongside pid in verbose roster output without exposing raw local paths by default. Use `pinet action=sessions args.agent_name="Frozen Hazel Whale"` to search live and historical worker sessions by display name; the search also accepts `agent_id`, `thread_id`, `repo`, `worktree_path`, `tmux_session`, `since`, `until`, and `limit`. Default output redacts path-bearing stable IDs; add `args.full=true` (or JSON plus `full`) only in local/debug contexts when the broker needs the exact stable ID or Pi session JSONL path for inspection/resume.
530
544
 
531
545
  Durable Pinet inbox notifications are classified as `steering`, `fwup`, or `maintenance/context` from explicit metadata or message cues. Follower prompts receive compact pointers such as `pinet action=read args.thread_id=...` instead of the full durable message body; agents use `pinet action=read` to retrieve the actual context. Delivery, read/ack state, and mail classification remain separate.
532
546
 
@@ -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;
@@ -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) {
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();
@@ -1072,6 +1072,7 @@ export default function (pi) {
1072
1072
  readPinetInbox,
1073
1073
  listBrokerAgents,
1074
1074
  listFollowerAgents,
1075
+ searchPinetSessions,
1075
1076
  listSubtreeAgents: (includeGhosts) => subtreeBrokerRuntime.listAgents(includeGhosts),
1076
1077
  getSubtreeSelfAgentId: () => subtreeBrokerRuntime.getStatus().selfAgentId,
1077
1078
  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) {
@@ -230,6 +231,8 @@ export function createPinetMeshOps(deps) {
230
231
  name: agent.name,
231
232
  id: agent.id,
232
233
  pid: agent.pid,
234
+ stableId: agent.stableId ?? null,
235
+ session: summarizePinetStableId(agent.stableId),
233
236
  status: agent.status,
234
237
  metadata: agent.metadata,
235
238
  lastHeartbeat: agent.lastHeartbeat,
@@ -256,6 +259,7 @@ export function createPinetMeshOps(deps) {
256
259
  name: agent.name,
257
260
  id: agent.id,
258
261
  pid: agent.pid,
262
+ session: agent.session ?? null,
259
263
  status: agent.status ?? "idle",
260
264
  metadata: agent.metadata,
261
265
  lastHeartbeat: agent.lastHeartbeat,
@@ -272,6 +276,20 @@ export function createPinetMeshOps(deps) {
272
276
  laneId: agent.laneId,
273
277
  }));
274
278
  }
279
+ async function searchPinetSessions(options) {
280
+ if (deps.getBrokerRole() === "broker") {
281
+ const db = deps.getActiveBrokerDb();
282
+ if (!db) {
283
+ throw new Error("Broker agent identity is unavailable.");
284
+ }
285
+ return db.searchAgentSessions(options);
286
+ }
287
+ const client = deps.getFollowerClient();
288
+ if (!client) {
289
+ throw new Error("Pinet is in an unexpected state.");
290
+ }
291
+ return await client.searchAgentSessions(options);
292
+ }
275
293
  return {
276
294
  sendPinetAgentMessage,
277
295
  sendPinetBroadcastMessage,
@@ -279,5 +297,6 @@ export function createPinetMeshOps(deps) {
279
297
  scheduleFollowerWakeup,
280
298
  listBrokerAgents,
281
299
  listFollowerAgents,
300
+ searchPinetSessions,
282
301
  };
283
302
  }
@@ -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>;
@@ -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,11 +8,20 @@ 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: [
15
+ // Routine inbox drain: defaults are `unread_only: true` + `mark_read: true`,
16
+ // so this returns and consumes only this agent's unread rows. The compact
17
+ // output already includes capped per-message previews; no `full=true` needed.
18
+ { action: "read", args: { limit: 20 } },
19
+ // Same defaults, scoped to one thread.
14
20
  { action: "read", args: { thread_id: "a2a:<broker>:<worker>", limit: 20 } },
15
- { action: "read", args: { unread_only: false, mark_read: false, full: true } },
21
+ // Non-destructive latest-history peek (does NOT mark anything read or
22
+ // change unread state). Use only when you need to revisit recently-read
23
+ // context, not as a routine inbox read.
24
+ { action: "read", args: { unread_only: false, mark_read: false, limit: 5 } },
16
25
  ],
17
26
  free: [{ action: "free", args: { note: "Wrapped up <issue>" } }],
18
27
  snooze: [
@@ -21,6 +30,10 @@ const PINET_DISPATCHER_EXAMPLES = {
21
30
  ],
22
31
  schedule: [{ action: "schedule", args: { delay: "30m", message: "Check queue state" } }],
23
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
+ ],
24
37
  spawn: [
25
38
  {
26
39
  action: "spawn",
@@ -81,6 +94,7 @@ function normalizeDispatcherAction(value) {
81
94
  "snooze",
82
95
  "schedule",
83
96
  "agents",
97
+ "sessions",
84
98
  "lanes",
85
99
  "ports",
86
100
  "spawn",
@@ -129,7 +143,8 @@ function classifyPinetError(message) {
129
143
  message.includes("ttl_ms") ||
130
144
  message.includes("purpose") ||
131
145
  message.includes("port") ||
132
- message.includes("spawn")) {
146
+ message.includes("spawn") ||
147
+ message.includes("sessions op")) {
133
148
  return {
134
149
  class: "input",
135
150
  message,
@@ -795,6 +810,8 @@ function buildCompactAgentDetails(agents, hint) {
795
810
  repo: getAgentRepo(agent) ?? null,
796
811
  branch: getAgentBranch(agent) ?? null,
797
812
  role: getAgentRole(agent) ?? null,
813
+ session: agent.session?.ref ?? null,
814
+ sessionKind: agent.session?.kind ?? null,
798
815
  brokerManaged: agent.metadata?.brokerManaged === true,
799
816
  parentAgentId: agent.metadata?.parentAgentId ?? null,
800
817
  treeDepth: agent.metadata?.treeDepth ?? 0,
@@ -1355,6 +1372,123 @@ function runPinetAgentsAction(params, deps, toolName, output) {
1355
1372
  };
1356
1373
  })();
1357
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
+ }
1358
1492
  export function registerPinetTools(pi, deps) {
1359
1493
  const actionDefinitions = new Map();
1360
1494
  function registerAction(definition) {
@@ -1377,7 +1511,7 @@ export function registerPinetTools(pi, deps) {
1377
1511
  });
1378
1512
  registerAction({
1379
1513
  name: "read",
1380
- description: "Read this agent's durable SQLite-backed Pinet inbox context with unread/read semantics. Ordinary workers only see rows addressed to their own agent identity; broker coordination visibility is limited to broker-addressed inbox rows.",
1514
+ description: "Read this agent's durable SQLite-backed Pinet inbox context with unread/read semantics. Defaults to draining unread rows (`unread_only: true`, `mark_read: true`) with compact per-message previews — no `full=true` required for routine reads. Override `unread_only`/`mark_read` only for a non-destructive latest-history peek. Ordinary workers only see rows addressed to their own agent identity; broker coordination visibility is limited to broker-addressed inbox rows.",
1381
1515
  parameters: Type.Object({
1382
1516
  thread_id: Type.Optional(Type.String({
1383
1517
  description: "Optional Pinet/Slack broker thread ID to filter this agent's own inbox rows; it does not grant cross-agent thread access.",
@@ -1470,6 +1604,25 @@ export function registerPinetTools(pi, deps) {
1470
1604
  }),
1471
1605
  execute: (_id, params, output) => runPinetAgentsAction(params, deps, "pinet:agents", output),
1472
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
+ });
1473
1626
  registerAction({
1474
1627
  name: "ports",
1475
1628
  description: "Acquire, renew, release, inspect, list, or expire durable Pinet local port leases.",
@@ -1541,10 +1694,10 @@ export function registerPinetTools(pi, deps) {
1541
1694
  name: "pinet",
1542
1695
  label: "Pinet Dispatcher",
1543
1696
  description: "Dispatch Pinet operations by action with compact help and schema discovery.",
1544
- 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.',
1545
1698
  parameters: Type.Object({
1546
1699
  action: Type.String({
1547
- 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.",
1548
1701
  }),
1549
1702
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
1550
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
  },
@@ -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.0",
3
+ "version": "0.2.2",
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.0",
53
- "@pinet/imessage-bridge": "0.2.0",
54
- "@pinet/pinet-core": "0.2.0",
55
- "@pinet/transport-core": "0.2.0",
52
+ "@pinet/broker-core": "0.2.2",
53
+ "@pinet/imessage-bridge": "0.2.2",
54
+ "@pinet/pinet-core": "0.2.2",
55
+ "@pinet/transport-core": "0.2.2",
56
56
  "@sinclair/typebox": "^0.34.49"
57
57
  },
58
58
  "types": "./dist/index.d.ts",