@pinet/slack-bridge 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ export type ContextJsonValue = string | number | boolean | null | ContextJsonObject | ContextJsonValue[];
2
+ export interface ContextJsonObject {
3
+ [key: string]: ContextJsonValue | undefined;
4
+ }
5
+ export type CodeAnchorSide = "old" | "new";
6
+ export interface CodeRevisionIdentity extends ContextJsonObject {
7
+ repository: string;
8
+ worktree: string;
9
+ path: string;
10
+ baseOid: string | null;
11
+ headOid: string;
12
+ blobOid: string;
13
+ side: CodeAnchorSide;
14
+ }
15
+ export interface CodeAnchor extends CodeRevisionIdentity {
16
+ startLine: number;
17
+ endLine: number;
18
+ selectedTextSha256: string | null;
19
+ contextSha256: string | null;
20
+ }
21
+ export interface ContextualThreadState extends ContextJsonObject {
22
+ resolved: boolean;
23
+ resolvedAt?: string;
24
+ resolvedBy?: string;
25
+ }
26
+ export interface ContextualThreadMetadata extends ContextJsonObject {
27
+ pinetKind: "contextual_thread";
28
+ schemaVersion: 1;
29
+ codeAnchor: CodeAnchor;
30
+ state: ContextualThreadState;
31
+ }
32
+ export declare function sha256Text(text: string): string;
33
+ export declare function buildNvimThreadId(repoSocketHash: string): string;
34
+ export declare function buildContextualThreadMetadata(input: {
35
+ repository: string;
36
+ worktree: string;
37
+ path: string;
38
+ baseOid?: string | null;
39
+ headOid: string;
40
+ blobOid: string;
41
+ side: CodeAnchorSide;
42
+ startLine: number;
43
+ endLine?: number;
44
+ selectedText?: string | null;
45
+ contextText?: string | null;
46
+ }): ContextualThreadMetadata;
47
+ export declare function parseContextualThreadMetadata(value: ContextJsonValue | undefined): ContextualThreadMetadata | null;
48
+ export declare function updateContextualThreadResolvedState(metadata: ContextualThreadMetadata, resolved: boolean, actor: string, now?: string): ContextualThreadMetadata;
49
+ export declare function hasSameCodeRevision(anchor: CodeAnchor, candidate: CodeRevisionIdentity): boolean;
50
+ export declare function formatAnchorForMessage(metadata: ContextualThreadMetadata): string;
@@ -0,0 +1,118 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ export function sha256Text(text) {
3
+ return createHash("sha256").update(text).digest("hex");
4
+ }
5
+ export function buildNvimThreadId(repoSocketHash) {
6
+ return `nvim:${repoSocketHash}:${randomUUID()}`;
7
+ }
8
+ export function buildContextualThreadMetadata(input) {
9
+ const startLine = Math.max(1, Math.floor(input.startLine));
10
+ const endLine = Math.max(startLine, Math.floor(input.endLine ?? startLine));
11
+ return {
12
+ pinetKind: "contextual_thread",
13
+ schemaVersion: 1,
14
+ codeAnchor: {
15
+ repository: input.repository,
16
+ worktree: input.worktree,
17
+ path: input.path,
18
+ baseOid: input.baseOid ?? null,
19
+ headOid: input.headOid,
20
+ blobOid: input.blobOid,
21
+ side: input.side,
22
+ startLine,
23
+ endLine,
24
+ selectedTextSha256: input.selectedText ? sha256Text(input.selectedText) : null,
25
+ contextSha256: input.contextText ? sha256Text(input.contextText) : null,
26
+ },
27
+ state: { resolved: false },
28
+ };
29
+ }
30
+ function readRecord(value) {
31
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
32
+ }
33
+ function readString(record, key) {
34
+ const value = record[key];
35
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
36
+ }
37
+ function readPositiveInteger(record, key) {
38
+ const value = record[key];
39
+ if (typeof value !== "number" || !Number.isFinite(value))
40
+ return null;
41
+ const integer = Math.floor(value);
42
+ return integer > 0 ? integer : null;
43
+ }
44
+ export function parseContextualThreadMetadata(value) {
45
+ const metadata = readRecord(value);
46
+ if (!metadata || metadata.pinetKind !== "contextual_thread" || metadata.schemaVersion !== 1) {
47
+ return null;
48
+ }
49
+ const anchorRecord = readRecord(metadata.codeAnchor);
50
+ const stateRecord = readRecord(metadata.state);
51
+ if (!anchorRecord || !stateRecord || typeof stateRecord.resolved !== "boolean")
52
+ return null;
53
+ const repository = readString(anchorRecord, "repository");
54
+ const worktree = readString(anchorRecord, "worktree");
55
+ const path = readString(anchorRecord, "path");
56
+ const headOid = readString(anchorRecord, "headOid");
57
+ const blobOid = readString(anchorRecord, "blobOid");
58
+ const side = anchorRecord.side === "old" || anchorRecord.side === "new" ? anchorRecord.side : null;
59
+ const startLine = readPositiveInteger(anchorRecord, "startLine");
60
+ const endLine = readPositiveInteger(anchorRecord, "endLine");
61
+ if (!repository ||
62
+ !worktree ||
63
+ !path ||
64
+ !headOid ||
65
+ !blobOid ||
66
+ !side ||
67
+ startLine == null ||
68
+ endLine == null) {
69
+ return null;
70
+ }
71
+ const resolvedAt = readString(stateRecord, "resolvedAt");
72
+ const resolvedBy = readString(stateRecord, "resolvedBy");
73
+ return {
74
+ pinetKind: "contextual_thread",
75
+ schemaVersion: 1,
76
+ codeAnchor: {
77
+ repository,
78
+ worktree,
79
+ path,
80
+ baseOid: readString(anchorRecord, "baseOid"),
81
+ headOid,
82
+ blobOid,
83
+ side,
84
+ startLine,
85
+ endLine,
86
+ selectedTextSha256: readString(anchorRecord, "selectedTextSha256"),
87
+ contextSha256: readString(anchorRecord, "contextSha256"),
88
+ },
89
+ state: {
90
+ resolved: stateRecord.resolved,
91
+ ...(resolvedAt ? { resolvedAt } : {}),
92
+ ...(resolvedBy ? { resolvedBy } : {}),
93
+ },
94
+ };
95
+ }
96
+ export function updateContextualThreadResolvedState(metadata, resolved, actor, now = new Date().toISOString()) {
97
+ return {
98
+ ...metadata,
99
+ state: resolved ? { resolved: true, resolvedAt: now, resolvedBy: actor } : { resolved: false },
100
+ };
101
+ }
102
+ export function hasSameCodeRevision(anchor, candidate) {
103
+ return (anchor.repository === candidate.repository &&
104
+ anchor.worktree === candidate.worktree &&
105
+ anchor.path === candidate.path &&
106
+ anchor.baseOid === candidate.baseOid &&
107
+ anchor.headOid === candidate.headOid &&
108
+ anchor.blobOid === candidate.blobOid &&
109
+ anchor.side === candidate.side);
110
+ }
111
+ export function formatAnchorForMessage(metadata) {
112
+ const anchor = metadata.codeAnchor;
113
+ const range = anchor.startLine === anchor.endLine
114
+ ? `${anchor.path}:${anchor.startLine}`
115
+ : `${anchor.path}:${anchor.startLine}-${anchor.endLine}`;
116
+ const base = anchor.baseOid ? ` base=${anchor.baseOid}` : "";
117
+ return `[code-anchor ${range} side=${anchor.side} head=${anchor.headOid} blob=${anchor.blobOid}${base}]`;
118
+ }
@@ -0,0 +1,19 @@
1
+ import { type ContextualThreadMetadata } from "./code-anchor.js";
2
+ import type { ThreadInfo } from "./broker/types.js";
3
+ export interface ContextualThreadContextMessage {
4
+ sender: string;
5
+ body: string;
6
+ }
7
+ export interface ContextualThreadContextRepository {
8
+ repository: string;
9
+ worktree: string;
10
+ headOid: string;
11
+ baseOid: string | null;
12
+ }
13
+ interface OpenContextualThread {
14
+ thread: ThreadInfo;
15
+ metadata: ContextualThreadMetadata;
16
+ }
17
+ export declare function selectOpenContextualThreads(threads: readonly ThreadInfo[], repository: ContextualThreadContextRepository, limit?: number): OpenContextualThread[];
18
+ export declare function formatContextualThreadContext(items: readonly OpenContextualThread[], messagesByThread: ReadonlyMap<string, readonly ContextualThreadContextMessage[]>, maxCharacters?: number): string;
19
+ export {};
@@ -0,0 +1,39 @@
1
+ import { parseContextualThreadMetadata, } from "./code-anchor.js";
2
+ export function selectOpenContextualThreads(threads, repository, limit = 5) {
3
+ return threads
4
+ .map((thread) => ({
5
+ thread,
6
+ metadata: parseContextualThreadMetadata(thread.metadata),
7
+ }))
8
+ .filter((item) => item.metadata !== null &&
9
+ !item.metadata.state.resolved &&
10
+ item.metadata.codeAnchor.repository === repository.repository &&
11
+ item.metadata.codeAnchor.worktree === repository.worktree &&
12
+ item.metadata.codeAnchor.headOid === repository.headOid &&
13
+ item.metadata.codeAnchor.baseOid === repository.baseOid)
14
+ .sort((left, right) => right.thread.updatedAt.localeCompare(left.thread.updatedAt))
15
+ .slice(0, Math.max(0, limit));
16
+ }
17
+ export function formatContextualThreadContext(items, messagesByThread, maxCharacters = 5000) {
18
+ if (items.length === 0)
19
+ return "";
20
+ const lines = [
21
+ "Open persisted Pinet code threads relevant to this worktree and revision:",
22
+ "Reply with `pinet action=reply args.thread_id=<id> args.message=<text>`; resolved threads are omitted.",
23
+ ];
24
+ for (const { thread, metadata } of items) {
25
+ const anchor = metadata.codeAnchor;
26
+ const range = anchor.startLine === anchor.endLine
27
+ ? `${anchor.path}:${anchor.startLine}`
28
+ : `${anchor.path}:${anchor.startLine}-${anchor.endLine}`;
29
+ lines.push(`- ${thread.threadId} — ${range} (${anchor.side})`);
30
+ for (const message of (messagesByThread.get(thread.threadId) ?? []).slice(-3)) {
31
+ const compactBody = message.body.replace(/\s+/g, " ").trim().slice(0, 600);
32
+ if (compactBody)
33
+ lines.push(` ${message.sender}: ${compactBody}`);
34
+ }
35
+ if (lines.join("\n").length >= maxCharacters)
36
+ break;
37
+ }
38
+ return lines.join("\n").slice(0, maxCharacters);
39
+ }
package/dist/index.js CHANGED
@@ -22,6 +22,8 @@ import { createFollowerRuntime, resolveBrokerSocketPath, } from "./follower-runt
22
22
  import { createSinglePlayerRuntime, } from "./single-player-runtime.js";
23
23
  import { createBrokerRuntime } from "./broker-runtime.js";
24
24
  import { createSlackPinetRuntimeAdapterFactory } from "./slack-pinet-runtime-adapter.js";
25
+ import { createNvimPinetRuntimeAdapterFactory, resolveNvimRepositoryContext, } from "./nvim-pinet-adapter.js";
26
+ import { formatContextualThreadContext, selectOpenContextualThreads, } from "./contextual-thread-context.js";
25
27
  import { SlackActivityLogger } from "./activity-log.js";
26
28
  import { createBrokerDeliveryState, queueBrokerInboxIds } from "./broker-delivery.js";
27
29
  import { buildBrokerControlPlaneDashboardSnapshot } from "./broker/control-plane-dashboard.js";
@@ -630,7 +632,7 @@ export default function (pi) {
630
632
  return;
631
633
  }
632
634
  if (decision.action === "deliver" || decision.action === "unrouted") {
633
- const persisted = routedMessage.source === "slack" && routedMessage.threadId
635
+ const persisted = routedMessage.threadId
634
636
  ? persistDeliveredInboundMessage(broker.db, selfId, routedMessage)
635
637
  : null;
636
638
  if (persisted && !persisted.result.freshDelivery) {
@@ -688,7 +690,7 @@ export default function (pi) {
688
690
  },
689
691
  buildControlPlaneDashboardSnapshot: (input) => buildBrokerControlPlaneDashboardSnapshot(input),
690
692
  buildCurrentDashboardSnapshot: async (openedAt) => buildCurrentBrokerControlPlaneDashboardSnapshot(openedAt),
691
- createAdapterBindings: [slackPinetAdapterFactory],
693
+ createAdapterBindings: [slackPinetAdapterFactory, createNvimPinetRuntimeAdapterFactory()],
692
694
  onAdminShutdownRequested: async (ctx) => {
693
695
  // `/pinet start replace` from another local session: stop being the
694
696
  // broker but keep this session alive (issue #951).
@@ -1223,6 +1225,30 @@ export default function (pi) {
1223
1225
  requireToolPolicy,
1224
1226
  sendPinetAgentMessage,
1225
1227
  sendPinetBroadcastMessage,
1228
+ replyToPinetThread: async (threadId, body) => {
1229
+ if (brokerRole !== "broker") {
1230
+ if (!brokerClient?.client)
1231
+ throw new Error("Pinet is in an unexpected state.");
1232
+ const result = await brokerClient.client.sendMessage({ threadId, body });
1233
+ return {
1234
+ messageId: result.messageId,
1235
+ threadId: result.threadId,
1236
+ source: result.source,
1237
+ channel: result.channel,
1238
+ };
1239
+ }
1240
+ const broker = getActiveBroker();
1241
+ const selfId = getActiveBrokerSelfId();
1242
+ if (!broker || !selfId)
1243
+ throw new Error("Broker agent identity is unavailable.");
1244
+ const result = await sendBrokerMessage({ db: broker.db, adapters: broker.adapters }, { threadId, body, senderAgentId: selfId });
1245
+ return {
1246
+ messageId: result.message.id,
1247
+ threadId: result.thread.threadId,
1248
+ source: result.thread.source,
1249
+ channel: result.thread.channel,
1250
+ };
1251
+ },
1226
1252
  signalAgentFree,
1227
1253
  scheduleBrokerWakeup,
1228
1254
  scheduleFollowerWakeup,
@@ -1603,6 +1629,57 @@ export default function (pi) {
1603
1629
  });
1604
1630
  // ─── Agent event wiring ──────────────────────────────
1605
1631
  agentEventRuntime.register(pi);
1632
+ pi.on("before_agent_start", async (_event, ctx) => {
1633
+ if (!pinetEnabled || !brokerRole)
1634
+ return;
1635
+ const repository = resolveNvimRepositoryContext(ctx.cwd);
1636
+ if (!repository)
1637
+ return;
1638
+ try {
1639
+ const brokerDb = brokerRole === "broker" ? getActiveBrokerDb() : null;
1640
+ const selfId = brokerRole === "broker" ? getActiveBrokerSelfId() : null;
1641
+ const threads = brokerDb && selfId
1642
+ ? brokerDb.getThreads(selfId)
1643
+ : brokerRole === "follower" && brokerClient?.client
1644
+ ? await brokerClient.client.listThreads()
1645
+ : [];
1646
+ const selected = selectOpenContextualThreads(threads, repository, 5);
1647
+ if (selected.length === 0)
1648
+ return;
1649
+ const messagesByThread = new Map();
1650
+ for (const item of selected) {
1651
+ if (brokerDb) {
1652
+ messagesByThread.set(item.thread.threadId, brokerDb
1653
+ .getMessagesForThread(item.thread.threadId, 6)
1654
+ .map((message) => ({ sender: message.sender, body: message.body })));
1655
+ continue;
1656
+ }
1657
+ if (brokerClient?.client) {
1658
+ const result = await brokerClient.client.readInbox({
1659
+ threadId: item.thread.threadId,
1660
+ unreadOnly: false,
1661
+ markRead: false,
1662
+ limit: 6,
1663
+ });
1664
+ messagesByThread.set(item.thread.threadId, result.messages.map(({ message }) => ({ sender: message.sender, body: message.body })));
1665
+ }
1666
+ }
1667
+ const content = formatContextualThreadContext(selected, messagesByThread);
1668
+ if (!content)
1669
+ return;
1670
+ return {
1671
+ message: {
1672
+ customType: "pinet-contextual-threads",
1673
+ content,
1674
+ display: true,
1675
+ },
1676
+ };
1677
+ }
1678
+ catch (error) {
1679
+ console.error(`[slack-bridge] contextual thread hydration failed: ${msg(error)}`);
1680
+ return;
1681
+ }
1682
+ });
1606
1683
  pi.on("session_before_compact", (event) => {
1607
1684
  compactionGate.begin(event.signal);
1608
1685
  });
@@ -0,0 +1,66 @@
1
+ import { type ContextJsonValue } from "./code-anchor.js";
2
+ import type { BrokerMessage, InboundMessage, MessageAdapter, OutboundMessage, ThreadInfo } from "./broker/types.js";
3
+ export interface NvimAdapterDbPort {
4
+ getThread(threadId: string): ThreadInfo | null;
5
+ createThread(thread: ThreadInfo): ThreadInfo;
6
+ updateThread(threadId: string, updates: Partial<ThreadInfo>): void;
7
+ getThreads(ownerAgent?: string): ThreadInfo[];
8
+ getMessagesForThread(threadId: string, limit?: number): BrokerMessage[];
9
+ insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: Record<string, ContextJsonValue>): BrokerMessage;
10
+ getAgent?(agentId: string): {
11
+ id: string;
12
+ name: string;
13
+ } | null;
14
+ }
15
+ export interface NvimRepositoryContext {
16
+ repository: string;
17
+ worktree: string;
18
+ branch: string;
19
+ headOid: string;
20
+ baseOid: string | null;
21
+ }
22
+ export interface NvimPinetAdapterOptions extends NvimRepositoryContext {
23
+ db: NvimAdapterDbPort;
24
+ getAgentById: (agentId: string) => {
25
+ id: string;
26
+ name: string;
27
+ } | null;
28
+ }
29
+ export declare function resolveNvimRepositoryContext(cwd: string): NvimRepositoryContext | null;
30
+ export declare function computeNvimSocketPath(worktree: string, branch: string): string;
31
+ export declare class NvimPinetAdapter implements MessageAdapter {
32
+ private readonly options;
33
+ readonly name = "nvim";
34
+ private readonly repoSocketHash;
35
+ private readonly socketPath;
36
+ private server;
37
+ private inboundHandler;
38
+ private readonly clients;
39
+ private readonly editorState;
40
+ constructor(options: NvimPinetAdapterOptions);
41
+ connect(): Promise<void>;
42
+ disconnect(): Promise<void>;
43
+ onInbound(handler: (msg: InboundMessage) => void): void;
44
+ send(message: OutboundMessage): Promise<void>;
45
+ private accept;
46
+ private handleLine;
47
+ private handleRequest;
48
+ private createThread;
49
+ private replyToThread;
50
+ private resolveThread;
51
+ private listThreads;
52
+ private getThread;
53
+ private serializeThread;
54
+ private emitInbound;
55
+ private broadcast;
56
+ }
57
+ export declare function createNvimPinetRuntimeAdapterFactory(): (context: {
58
+ broker: {
59
+ db: NvimAdapterDbPort;
60
+ };
61
+ ctx: {
62
+ cwd: string;
63
+ };
64
+ }) => {
65
+ adapter: MessageAdapter;
66
+ } | [];
@@ -0,0 +1,454 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as net from "node:net";
5
+ import * as path from "node:path";
6
+ import { buildContextualThreadMetadata, buildNvimThreadId, formatAnchorForMessage, hasSameCodeRevision, parseContextualThreadMetadata, updateContextualThreadResolvedState, } from "./code-anchor.js";
7
+ function git(cwd, ...args) {
8
+ return execFileSync("git", args, {
9
+ cwd,
10
+ encoding: "utf-8",
11
+ stdio: ["ignore", "pipe", "ignore"],
12
+ }).trim();
13
+ }
14
+ export function resolveNvimRepositoryContext(cwd) {
15
+ try {
16
+ const worktree = fs.realpathSync(git(cwd, "rev-parse", "--show-toplevel"));
17
+ const commonDir = fs.realpathSync(git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"));
18
+ const repository = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
19
+ const branch = git(cwd, "branch", "--show-current");
20
+ const headOid = git(cwd, "rev-parse", "HEAD");
21
+ let baseOid = null;
22
+ try {
23
+ baseOid = git(cwd, "merge-base", "HEAD", "@{upstream}");
24
+ }
25
+ catch {
26
+ baseOid = null;
27
+ }
28
+ return { repository, worktree, branch, headOid, baseOid };
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function computeRepoSocketHash(worktree, branch) {
35
+ return createHash("sha256").update(`${worktree}:${branch}`).digest("hex");
36
+ }
37
+ export function computeNvimSocketPath(worktree, branch) {
38
+ const dir = "/tmp/pi-nvim";
39
+ if (!fs.existsSync(dir))
40
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ fs.chmodSync(dir, 0o700);
42
+ return path.join(dir, `${computeRepoSocketHash(worktree, branch)}.sock`);
43
+ }
44
+ function getRecord(value) {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
46
+ }
47
+ function getString(record, key) {
48
+ const value = record[key];
49
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
50
+ }
51
+ function getPositiveInteger(record, key, fallback) {
52
+ const value = record[key];
53
+ if (typeof value !== "number" || !Number.isFinite(value))
54
+ return fallback;
55
+ const integer = Math.floor(value);
56
+ return integer > 0 ? integer : fallback;
57
+ }
58
+ // agent-standards-ignore prefer-inline-single-use-helper: versioned socket envelope parser is a protocol boundary.
59
+ function parseRequest(line) {
60
+ const parsed = JSON.parse(line);
61
+ const record = getRecord(parsed);
62
+ const id = record ? getString(record, "id") : null;
63
+ const type = record ? getString(record, "type") : null;
64
+ if (!record || !type)
65
+ return { id, request: null };
66
+ return {
67
+ id,
68
+ request: {
69
+ id,
70
+ type,
71
+ payload: getRecord(record.payload) ?? record,
72
+ },
73
+ };
74
+ }
75
+ function parseAnchor(payload) {
76
+ const anchor = getRecord(payload.anchor);
77
+ if (!anchor)
78
+ return null;
79
+ const repository = getString(anchor, "repository");
80
+ const worktree = getString(anchor, "worktree");
81
+ const anchorPath = getString(anchor, "path");
82
+ const headOid = getString(anchor, "headOid");
83
+ const blobOid = getString(anchor, "blobOid");
84
+ const side = anchor.side === "old" || anchor.side === "new" ? anchor.side : null;
85
+ if (!repository || !worktree || !anchorPath || !headOid || !blobOid || !side)
86
+ return null;
87
+ return {
88
+ repository,
89
+ worktree,
90
+ path: anchorPath,
91
+ baseOid: getString(anchor, "baseOid"),
92
+ headOid,
93
+ blobOid,
94
+ side,
95
+ };
96
+ }
97
+ // agent-standards-ignore prefer-inline-single-use-helper: create payload parser is a named protocol DTO boundary.
98
+ function parseCreateThreadRequest(payload) {
99
+ const targetAgentId = getString(payload, "targetAgentId");
100
+ const body = getString(payload, "body");
101
+ const anchor = parseAnchor(payload);
102
+ const startLine = getPositiveInteger(payload, "startLine", null);
103
+ const endLine = getPositiveInteger(payload, "endLine", startLine);
104
+ if (!targetAgentId || !body || !anchor || startLine == null || endLine == null)
105
+ return null;
106
+ return {
107
+ targetAgentId,
108
+ body,
109
+ anchor,
110
+ startLine,
111
+ endLine,
112
+ selectedText: getString(payload, "selectedText"),
113
+ contextText: getString(payload, "contextText"),
114
+ };
115
+ }
116
+ // agent-standards-ignore prefer-inline-single-use-helper: reply payload parser is a named protocol DTO boundary.
117
+ function parseReplyRequest(payload) {
118
+ const threadId = getString(payload, "threadId");
119
+ const body = getString(payload, "body");
120
+ return threadId && body ? { threadId, body } : null;
121
+ }
122
+ // agent-standards-ignore prefer-inline-single-use-helper: resolution payload parser is a named protocol DTO boundary.
123
+ function parseResolveRequest(payload) {
124
+ const threadId = getString(payload, "threadId");
125
+ if (!threadId || typeof payload.resolved !== "boolean")
126
+ return null;
127
+ return { threadId, resolved: payload.resolved };
128
+ }
129
+ // agent-standards-ignore prefer-inline-single-use-helper: list payload parser is a named protocol DTO boundary.
130
+ function parseListRequest(payload) {
131
+ const anchor = parseAnchor(payload);
132
+ if (!anchor)
133
+ return null;
134
+ return {
135
+ anchor,
136
+ includeResolved: typeof payload.includeResolved === "boolean" ? payload.includeResolved : false,
137
+ limit: getPositiveInteger(payload, "limit", 100) ?? 100,
138
+ };
139
+ }
140
+ function serializeMetadata(metadata) {
141
+ return JSON.parse(JSON.stringify(metadata));
142
+ }
143
+ function sendJson(socket, payload) {
144
+ socket.write(`${JSON.stringify(payload)}\n`);
145
+ }
146
+ export class NvimPinetAdapter {
147
+ options;
148
+ name = "nvim";
149
+ repoSocketHash;
150
+ socketPath;
151
+ server = null;
152
+ inboundHandler = null;
153
+ clients = new Set();
154
+ editorState = {
155
+ file: null,
156
+ line: null,
157
+ visibleStart: null,
158
+ visibleEnd: null,
159
+ selectionStart: null,
160
+ selectionEnd: null,
161
+ };
162
+ constructor(options) {
163
+ this.options = options;
164
+ this.repoSocketHash = computeRepoSocketHash(options.worktree, options.branch);
165
+ this.socketPath = computeNvimSocketPath(options.worktree, options.branch);
166
+ }
167
+ async connect() {
168
+ try {
169
+ fs.unlinkSync(this.socketPath);
170
+ }
171
+ catch {
172
+ // Missing stale socket is fine.
173
+ }
174
+ await new Promise((resolve, reject) => {
175
+ this.server = net.createServer((socket) => this.accept(socket));
176
+ this.server.once("error", reject);
177
+ this.server.listen(this.socketPath, () => {
178
+ this.server?.off("error", reject);
179
+ fs.chmodSync(this.socketPath, 0o600);
180
+ resolve();
181
+ });
182
+ });
183
+ }
184
+ async disconnect() {
185
+ for (const client of this.clients)
186
+ client.destroy();
187
+ this.clients.clear();
188
+ await new Promise((resolve) => {
189
+ if (!this.server)
190
+ return resolve();
191
+ this.server.close(() => resolve());
192
+ this.server = null;
193
+ });
194
+ try {
195
+ fs.unlinkSync(this.socketPath);
196
+ }
197
+ catch {
198
+ // Ignore stale socket cleanup failures.
199
+ }
200
+ }
201
+ onInbound(handler) {
202
+ this.inboundHandler = handler;
203
+ }
204
+ async send(message) {
205
+ this.broadcast({
206
+ type: "thread.updated",
207
+ payload: {
208
+ threadId: message.threadId,
209
+ channel: message.channel,
210
+ body: message.text,
211
+ sender: message.agentName ?? "agent",
212
+ },
213
+ });
214
+ }
215
+ accept(socket) {
216
+ this.clients.add(socket);
217
+ let buffer = "";
218
+ socket.on("data", (data) => {
219
+ buffer += data.toString();
220
+ const lines = buffer.split("\n");
221
+ buffer = lines.pop() ?? "";
222
+ for (const line of lines) {
223
+ if (line.trim())
224
+ this.handleLine(socket, line);
225
+ }
226
+ });
227
+ socket.on("close", () => this.clients.delete(socket));
228
+ socket.on("error", () => this.clients.delete(socket));
229
+ }
230
+ handleLine(socket, line) {
231
+ let request = null;
232
+ let requestId = null;
233
+ try {
234
+ const parsed = parseRequest(line);
235
+ request = parsed.request;
236
+ requestId = parsed.id;
237
+ if (!request) {
238
+ sendJson(socket, {
239
+ type: "error",
240
+ id: requestId ?? "request-error",
241
+ error: { code: "invalid_request", message: "type is required" },
242
+ });
243
+ return;
244
+ }
245
+ const result = this.handleRequest(request);
246
+ if (request.id)
247
+ sendJson(socket, { type: "ok", id: request.id, result });
248
+ }
249
+ catch (error) {
250
+ sendJson(socket, {
251
+ type: "error",
252
+ id: requestId ?? request?.id ?? "request-error",
253
+ error: {
254
+ code: "request_error",
255
+ message: error instanceof Error ? error.message : "nvim request failed",
256
+ },
257
+ });
258
+ }
259
+ }
260
+ handleRequest(request) {
261
+ switch (request.type) {
262
+ case "buffer_focus": {
263
+ const file = getString(request.payload, "file");
264
+ const line = getPositiveInteger(request.payload, "line", null);
265
+ if (file)
266
+ this.editorState.file = file;
267
+ if (line != null)
268
+ this.editorState.line = line;
269
+ return { status: "ok" };
270
+ }
271
+ case "visible_range": {
272
+ const file = getString(request.payload, "file");
273
+ if (file)
274
+ this.editorState.file = file;
275
+ this.editorState.visibleStart = getPositiveInteger(request.payload, "start", null);
276
+ this.editorState.visibleEnd = getPositiveInteger(request.payload, "end", null);
277
+ return { status: "ok" };
278
+ }
279
+ case "selection": {
280
+ const file = getString(request.payload, "file");
281
+ if (file)
282
+ this.editorState.file = file;
283
+ this.editorState.selectionStart = getPositiveInteger(request.payload, "start", null);
284
+ this.editorState.selectionEnd = getPositiveInteger(request.payload, "end", null);
285
+ return { status: "ok" };
286
+ }
287
+ case "editor.context":
288
+ return { ...this.editorState };
289
+ case "editor.open": {
290
+ const file = getString(request.payload, "file");
291
+ if (!file)
292
+ throw new Error("file is required");
293
+ const line = getPositiveInteger(request.payload, "line", null);
294
+ this.broadcast({ type: "open_file", file, ...(line ? { line } : {}) });
295
+ return { delivered: this.clients.size > 1 };
296
+ }
297
+ case "pinet.thread.create":
298
+ return this.createThread(request.payload);
299
+ case "pinet.thread.reply":
300
+ return this.replyToThread(request.payload);
301
+ case "pinet.thread.resolve":
302
+ return this.resolveThread(request.payload);
303
+ case "pinet.thread.list":
304
+ return this.listThreads(request.payload);
305
+ case "pinet.thread.get":
306
+ return this.getThread(request.payload);
307
+ default:
308
+ throw new Error(`Unknown nvim request: ${request.type}`);
309
+ }
310
+ }
311
+ createThread(payload) {
312
+ const parsed = parseCreateThreadRequest(payload);
313
+ if (!parsed)
314
+ throw new Error("targetAgentId, body, anchor, startLine, and endLine are required");
315
+ if (parsed.anchor.repository !== this.options.repository ||
316
+ parsed.anchor.worktree !== this.options.worktree) {
317
+ throw new Error("code anchor does not belong to this Pinet worktree");
318
+ }
319
+ if (!this.options.getAgentById(parsed.targetAgentId)) {
320
+ throw new Error(`Unknown target agent: ${parsed.targetAgentId}`);
321
+ }
322
+ const metadata = buildContextualThreadMetadata({
323
+ ...parsed.anchor,
324
+ startLine: parsed.startLine,
325
+ endLine: parsed.endLine,
326
+ selectedText: parsed.selectedText,
327
+ contextText: parsed.contextText,
328
+ });
329
+ const threadId = buildNvimThreadId(this.repoSocketHash);
330
+ const now = new Date().toISOString();
331
+ this.options.db.createThread({
332
+ threadId,
333
+ source: "nvim",
334
+ channel: this.repoSocketHash,
335
+ ownerAgent: parsed.targetAgentId,
336
+ ownerBinding: "explicit",
337
+ metadata: serializeMetadata(metadata),
338
+ createdAt: now,
339
+ updatedAt: now,
340
+ });
341
+ this.emitInbound(threadId, parsed.targetAgentId, `${formatAnchorForMessage(metadata)}\n\n${parsed.body}`, {
342
+ pinetKind: "contextual_thread_message",
343
+ schemaVersion: 1,
344
+ event: "thread.created",
345
+ });
346
+ return { threadId, metadata: serializeMetadata(metadata) };
347
+ }
348
+ replyToThread(payload) {
349
+ const parsed = parseReplyRequest(payload);
350
+ if (!parsed)
351
+ throw new Error("threadId and body are required");
352
+ const thread = this.options.db.getThread(parsed.threadId);
353
+ if (!thread)
354
+ throw new Error(`Unknown thread: ${parsed.threadId}`);
355
+ this.emitInbound(parsed.threadId, thread.ownerAgent ?? "", parsed.body, {
356
+ pinetKind: "contextual_thread_message",
357
+ schemaVersion: 1,
358
+ event: "thread.reply",
359
+ });
360
+ return { threadId: parsed.threadId };
361
+ }
362
+ resolveThread(payload) {
363
+ const parsed = parseResolveRequest(payload);
364
+ if (!parsed)
365
+ throw new Error("threadId and resolved are required");
366
+ const thread = this.options.db.getThread(parsed.threadId);
367
+ const metadata = parseContextualThreadMetadata(thread?.metadata);
368
+ if (!thread || !metadata)
369
+ throw new Error(`Unknown contextual thread: ${parsed.threadId}`);
370
+ const updated = updateContextualThreadResolvedState(metadata, parsed.resolved, "nvim");
371
+ this.options.db.updateThread(parsed.threadId, { metadata: serializeMetadata(updated) });
372
+ this.options.db.insertMessage(parsed.threadId, "nvim", "inbound", "nvim", parsed.resolved ? "Resolved this thread." : "Reopened this thread.", thread.ownerAgent ? [thread.ownerAgent] : [], {
373
+ pinetKind: "contextual_thread_message",
374
+ schemaVersion: 1,
375
+ event: parsed.resolved ? "thread.resolved" : "thread.reopened",
376
+ });
377
+ this.broadcast({ type: "thread.updated", payload: { threadId: parsed.threadId } });
378
+ return { threadId: parsed.threadId, metadata: serializeMetadata(updated) };
379
+ }
380
+ listThreads(payload) {
381
+ const parsed = parseListRequest(payload);
382
+ if (!parsed)
383
+ throw new Error("revision-aware anchor is required");
384
+ const threads = this.options.db
385
+ .getThreads()
386
+ .map((thread) => ({
387
+ thread,
388
+ metadata: parseContextualThreadMetadata(thread.metadata),
389
+ }))
390
+ .filter((item) => item.metadata !== null && hasSameCodeRevision(item.metadata.codeAnchor, parsed.anchor))
391
+ .filter((item) => parsed.includeResolved || !item.metadata.state.resolved)
392
+ .slice(0, parsed.limit)
393
+ .map((item) => this.serializeThread(item.thread, item.metadata, 20));
394
+ return { threads };
395
+ }
396
+ getThread(payload) {
397
+ const threadId = getString(payload, "threadId");
398
+ if (!threadId)
399
+ throw new Error("threadId is required");
400
+ const thread = this.options.db.getThread(threadId);
401
+ const metadata = parseContextualThreadMetadata(thread?.metadata);
402
+ if (!thread || !metadata)
403
+ throw new Error(`Unknown contextual thread: ${threadId}`);
404
+ return this.serializeThread(thread, metadata, 50);
405
+ }
406
+ serializeThread(thread, metadata, messageLimit) {
407
+ const messages = this.options.db.getMessagesForThread(thread.threadId, messageLimit);
408
+ return {
409
+ threadId: thread.threadId,
410
+ updatedAt: thread.updatedAt,
411
+ metadata: serializeMetadata(metadata),
412
+ messages: messages.map((message) => ({
413
+ id: message.id,
414
+ sender: message.sender,
415
+ direction: message.direction,
416
+ body: message.body,
417
+ createdAt: message.createdAt,
418
+ })),
419
+ };
420
+ }
421
+ emitInbound(threadId, targetAgentId, text, metadata) {
422
+ this.inboundHandler?.({
423
+ source: "nvim",
424
+ threadId,
425
+ channel: this.repoSocketHash,
426
+ userId: "nvim",
427
+ userName: "Neovim",
428
+ text,
429
+ timestamp: new Date().toISOString(),
430
+ metadata: {
431
+ ...metadata,
432
+ ...(targetAgentId ? { threadAffinityOwnerAgentId: targetAgentId } : {}),
433
+ },
434
+ });
435
+ }
436
+ broadcast(payload) {
437
+ for (const client of this.clients)
438
+ sendJson(client, payload);
439
+ }
440
+ }
441
+ export function createNvimPinetRuntimeAdapterFactory() {
442
+ return ({ broker, ctx }) => {
443
+ const repositoryContext = resolveNvimRepositoryContext(ctx.cwd);
444
+ if (!repositoryContext)
445
+ return [];
446
+ return {
447
+ adapter: new NvimPinetAdapter({
448
+ ...repositoryContext,
449
+ db: broker.db,
450
+ getAgentById: (agentId) => broker.db.getAgent?.(agentId) ?? null,
451
+ }),
452
+ };
453
+ };
454
+ }
@@ -66,6 +66,12 @@ export interface RegisterPinetToolsDeps {
66
66
  messageIds: number[];
67
67
  recipients: string[];
68
68
  };
69
+ replyToPinetThread?: (threadId: string, body: string) => Promise<{
70
+ messageId: number;
71
+ threadId: string;
72
+ source: string;
73
+ channel: string;
74
+ }>;
69
75
  signalAgentFree: (ctx: ExtensionContext | undefined, options: {
70
76
  requirePinet?: boolean;
71
77
  }) => Promise<{
@@ -19,6 +19,9 @@ const PINET_DISPATCHER_EXAMPLES = {
19
19
  { action: "send", args: { to: "@worker", message: "Please review PR #123" } },
20
20
  { action: "send", args: { to: "@worker", message: "/steer stop polling" } },
21
21
  ],
22
+ reply: [
23
+ { action: "reply", args: { thread_id: "nvim:<repo>:<id>", message: "I updated this block." } },
24
+ ],
22
25
  read: [
23
26
  // Routine inbox drain: defaults are `unread_only: true` + `mark_read: true`,
24
27
  // so this returns and consumes only this agent's unread rows. The compact
@@ -100,6 +103,7 @@ function normalizeDispatcherAction(value) {
100
103
  "help",
101
104
  "send",
102
105
  "read",
106
+ "reply",
103
107
  "free",
104
108
  "snooze",
105
109
  "schedule",
@@ -681,6 +685,44 @@ function runPinetSendAction(params, deps, toolName, output) {
681
685
  };
682
686
  })();
683
687
  }
688
+ // agent-standards-ignore prefer-inline-single-use-helper: dispatcher actions are kept as named seams for progressive discovery.
689
+ function runPinetReplyAction(params, deps, toolName, output) {
690
+ return (async () => {
691
+ const threadId = typeof params.thread_id === "string" ? params.thread_id.trim() : "";
692
+ const message = typeof params.message === "string" ? params.message : "";
693
+ if (!threadId)
694
+ throw new Error("thread_id is required");
695
+ if (!message)
696
+ throw new Error("message is required");
697
+ if (!deps.replyToPinetThread) {
698
+ throw new Error("pinet:reply is only available when the broker thread sender is active.");
699
+ }
700
+ deps.requireToolPolicy(toolName, undefined, `thread_id=${threadId} | message=${message}`);
701
+ const result = await deps.replyToPinetThread(threadId, message);
702
+ return {
703
+ content: [
704
+ {
705
+ type: "text",
706
+ text: output.full
707
+ ? `Reply sent to ${result.source} thread ${result.threadId} (${result.channel}); message id ${result.messageId}.`
708
+ : `Pinet thread reply sent to ${result.threadId}.`,
709
+ },
710
+ ],
711
+ details: result,
712
+ compactDetails: {
713
+ messageId: result.messageId,
714
+ threadId: result.threadId,
715
+ source: result.source,
716
+ },
717
+ expandedText: buildSentMessageExpandedText([
718
+ ["thread id", result.threadId],
719
+ ["source", result.source],
720
+ ["channel", result.channel],
721
+ ["message id", String(result.messageId)],
722
+ ], message),
723
+ };
724
+ })();
725
+ }
684
726
  function runPinetReadAction(params, deps, toolName, output) {
685
727
  return (async () => {
686
728
  deps.requireToolPolicy(toolName, undefined, `thread_id=${params.thread_id ?? ""} | limit=${params.limit ?? ""} | unread_only=${params.unread_only ?? ""} | mark_read=${params.mark_read ?? ""} | format=${output.format} | full=${output.full}`);
@@ -1765,6 +1807,16 @@ export function registerPinetTools(pi, deps) {
1765
1807
  }),
1766
1808
  execute: (_id, params, output) => runPinetSendAction(params, deps, "pinet:send", output),
1767
1809
  });
1810
+ registerAction({
1811
+ name: "reply",
1812
+ description: "Reply to an existing Pinet transport thread using the thread's stored source/channel. Use for ordinary anchored contextual threads such as Neovim code comments.",
1813
+ parameters: Type.Object({
1814
+ thread_id: Type.String({ description: "Existing Pinet transport thread id." }),
1815
+ message: Type.String({ description: "Reply body." }),
1816
+ ...PINET_OUTPUT_OPTION_PARAMETERS,
1817
+ }),
1818
+ execute: (_id, params, output) => runPinetReplyAction(params, deps, "pinet:reply", output),
1819
+ });
1768
1820
  registerAction({
1769
1821
  name: "read",
1770
1822
  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.",
@@ -1998,7 +2050,7 @@ export function registerPinetTools(pi, deps) {
1998
2050
  promptGuidelines: deps.promptGuidelines,
1999
2051
  parameters: Type.Object({
2000
2052
  action: Type.String({
2001
- description: "Action name: help, send, read, free, snooze, schedule, agents, sessions, lanes, ports, reload, exit, hibernate (broker-managed checkpoint+hibernate), or wake (broker-managed fenced wake). Also supports spawn for launching worker-owned subtree children.",
2053
+ description: "Action name: help, send, read, reply, free, snooze, schedule, agents, sessions, lanes, ports, reload, exit, hibernate (broker-managed checkpoint+hibernate), or wake (broker-managed fenced wake). Also supports spawn for launching worker-owned subtree children.",
2002
2054
  }),
2003
2055
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
2004
2056
  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.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinet/slack-bridge",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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 --config ../vitest.config.ts"
50
50
  },
51
51
  "dependencies": {
52
- "@pinet/broker-core": "0.2.10",
53
- "@pinet/imessage-bridge": "0.2.10",
54
- "@pinet/pinet-core": "0.2.10",
55
- "@pinet/transport-core": "0.2.10",
52
+ "@pinet/broker-core": "0.2.12",
53
+ "@pinet/imessage-bridge": "0.2.12",
54
+ "@pinet/pinet-core": "0.2.12",
55
+ "@pinet/transport-core": "0.2.12",
56
56
  "@sinclair/typebox": "^0.34.49"
57
57
  },
58
58
  "types": "./dist/index.d.ts",