@pinet/slack-bridge 0.1.2 → 0.2.0
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 +59 -32
- package/dist/broker/adapters/slack.d.ts +19 -1
- package/dist/broker/adapters/slack.js +111 -22
- package/dist/broker/client.d.ts +2 -1
- package/dist/broker/client.js +1 -0
- package/dist/broker/socket-server.js +18 -0
- package/dist/deploy-manifest.d.ts +5 -0
- package/dist/deploy-manifest.js +30 -1
- package/dist/follower-runtime.js +5 -1
- package/dist/helpers.d.ts +14 -0
- package/dist/helpers.js +45 -21
- package/dist/index.js +60 -0
- package/dist/pinet-commands.d.ts +6 -1
- package/dist/pinet-commands.js +166 -1
- package/dist/pinet-mesh-ops.d.ts +11 -0
- package/dist/pinet-mesh-ops.js +17 -0
- package/dist/pinet-tools.d.ts +47 -0
- package/dist/pinet-tools.js +496 -36
- package/dist/prompts/broker/tmux.md +2 -2
- package/dist/reaction-triggers.d.ts +1 -0
- package/dist/reaction-triggers.js +26 -15
- package/dist/runtime-agent-context.js +19 -0
- package/dist/runtime-mode.js +7 -1
- package/dist/single-player-runtime.js +22 -26
- package/dist/slack-access.d.ts +11 -0
- package/dist/slack-access.js +30 -0
- package/dist/slack-agents-command.d.ts +19 -0
- package/dist/slack-agents-command.js +90 -0
- package/dist/slack-export.d.ts +1 -1
- package/dist/slack-export.js +6 -4
- package/dist/slack-file-access.d.ts +34 -0
- package/dist/slack-file-access.js +209 -0
- package/dist/slack-message-context.d.ts +0 -1
- package/dist/slack-message-context.js +1 -6
- package/dist/slack-pinet-runtime-adapter.d.ts +4 -2
- package/dist/slack-pinet-runtime-adapter.js +12 -0
- package/dist/slack-tools.d.ts +6 -0
- package/dist/slack-tools.js +290 -36
- package/dist/slack-upload.d.ts +13 -1
- package/dist/slack-upload.js +29 -2
- package/dist/stale-slack-messages.d.ts +12 -0
- package/dist/stale-slack-messages.js +29 -0
- package/dist/subtree-broker-runtime.d.ts +109 -0
- package/dist/subtree-broker-runtime.js +558 -0
- package/manifest.yaml +9 -0
- package/package.json +9 -7
- package/skills/slack-bridge/SKILL.md +60 -1
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { dispatchDirectAgentMessage, resolveDirectAgentTarget } from "./broker/agent-messaging.js";
|
|
8
|
+
import { startBroker } from "./broker/index.js";
|
|
9
|
+
import { HEARTBEAT_INTERVAL_MS } from "./broker/client.js";
|
|
10
|
+
import { buildPinetOwnerToken, generateAgentName, normalizeOutgoingPinetControlMessage, resolvePinetMeshAuth, syncBrokerInboxEntries, } from "./helpers.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS = 45_000;
|
|
13
|
+
const SUBTREE_CHILD_EXIT_GRACE_MS = 5_000;
|
|
14
|
+
function sanitizePathSegment(value) {
|
|
15
|
+
const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
16
|
+
return sanitized || "agent";
|
|
17
|
+
}
|
|
18
|
+
function randomSuffix() {
|
|
19
|
+
return Math.random().toString(36).slice(2, 8);
|
|
20
|
+
}
|
|
21
|
+
function delay(ms) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
setTimeout(resolve, ms);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function buildSubtreeBrokerPaths(stableId) {
|
|
27
|
+
const rootDir = path.join(os.homedir(), ".pi", "pinet-subtrees", sanitizePathSegment(stableId));
|
|
28
|
+
return {
|
|
29
|
+
rootDir,
|
|
30
|
+
socketPath: path.join(rootDir, "pinet.sock"),
|
|
31
|
+
dbPath: path.join(rootDir, "pinet-broker.db"),
|
|
32
|
+
lockPath: path.join(rootDir, "pinet-broker.lock"),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function buildSelfAgentId(stableId) {
|
|
36
|
+
return `subbroker-${sanitizePathSegment(stableId).slice(0, 80)}`;
|
|
37
|
+
}
|
|
38
|
+
function buildChildLaunchEnv(paths, selfAgentId, input = {}) {
|
|
39
|
+
return {
|
|
40
|
+
PINET_SOCKET_PATH: paths.socketPath,
|
|
41
|
+
PINET_BROKER_MANAGED: "1",
|
|
42
|
+
PINET_PARENT_AGENT_ID: selfAgentId,
|
|
43
|
+
PINET_ROOT_AGENT_ID: selfAgentId,
|
|
44
|
+
PINET_SPAWNED_BY_AGENT_ID: selfAgentId,
|
|
45
|
+
PINET_LAUNCH_SOURCE: "subtree-broker-tmux",
|
|
46
|
+
...(input.launchId ? { PINET_LAUNCH_ID: input.launchId } : {}),
|
|
47
|
+
...(input.role ? { PINET_SUBTREE_ROLE: input.role } : {}),
|
|
48
|
+
...(input.laneId ? { PINET_LANE_ID: input.laneId } : {}),
|
|
49
|
+
...(input.tmuxSession ? { PINET_TMUX_SESSION: input.tmuxSession } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function quoteShellValue(value) {
|
|
53
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
54
|
+
}
|
|
55
|
+
function buildChildLaunchHint(paths, selfAgentId, cwd) {
|
|
56
|
+
const env = buildChildLaunchEnv(paths, selfAgentId);
|
|
57
|
+
const envPrefix = Object.entries(env)
|
|
58
|
+
.map(([key, value]) => `${key}=${quoteShellValue(value)}`)
|
|
59
|
+
.join(" ");
|
|
60
|
+
return `cd ${quoteShellValue(cwd)} && ${envPrefix} pi`;
|
|
61
|
+
}
|
|
62
|
+
function toFollowerInboxEntry(input) {
|
|
63
|
+
return {
|
|
64
|
+
inboxId: input.entry.id,
|
|
65
|
+
message: {
|
|
66
|
+
threadId: input.message.threadId,
|
|
67
|
+
source: input.message.source,
|
|
68
|
+
sender: input.message.sender,
|
|
69
|
+
body: input.message.body,
|
|
70
|
+
createdAt: input.message.createdAt,
|
|
71
|
+
metadata: input.message.metadata,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function metadataString(metadata, key) {
|
|
76
|
+
const value = metadata?.[key];
|
|
77
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
78
|
+
}
|
|
79
|
+
function resolveRepoPath(repo, cwd) {
|
|
80
|
+
const trimmed = repo.trim();
|
|
81
|
+
if (!trimmed)
|
|
82
|
+
throw new Error("spawn requires repo");
|
|
83
|
+
const candidates = [
|
|
84
|
+
path.isAbsolute(trimmed) ? trimmed : null,
|
|
85
|
+
trimmed === "." ? cwd : null,
|
|
86
|
+
path.resolve(cwd, trimmed),
|
|
87
|
+
path.join(os.homedir(), trimmed),
|
|
88
|
+
].filter((candidate) => Boolean(candidate));
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
try {
|
|
91
|
+
if (fs.statSync(candidate).isDirectory())
|
|
92
|
+
return candidate;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Try the next candidate.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`spawn repo not found: ${repo}`);
|
|
99
|
+
}
|
|
100
|
+
function normalizeRole(role) {
|
|
101
|
+
const normalized = role?.trim();
|
|
102
|
+
return normalized && normalized.length > 0 ? normalized : "subworker";
|
|
103
|
+
}
|
|
104
|
+
function buildTmuxSessionName(repoPath, role, launchId) {
|
|
105
|
+
const repoName = sanitizePathSegment(path.basename(repoPath));
|
|
106
|
+
const roleName = sanitizePathSegment(role);
|
|
107
|
+
const shortLaunch = sanitizePathSegment(launchId).slice(-8);
|
|
108
|
+
return sanitizePathSegment(`pinet-${repoName}-${roleName}-${shortLaunch}`).slice(0, 80);
|
|
109
|
+
}
|
|
110
|
+
function findTmuxSocketPath() {
|
|
111
|
+
const configuredDir = process.env.CLAUDE_TMUX_SOCKET_DIR?.trim();
|
|
112
|
+
const candidates = [
|
|
113
|
+
configuredDir ? path.join(configuredDir, "claude.sock") : null,
|
|
114
|
+
process.env.TMUX?.split(",")[0] ?? null,
|
|
115
|
+
process.env.TMPDIR ? path.join(process.env.TMPDIR, "claude-tmux-sockets", "claude.sock") : null,
|
|
116
|
+
].filter((candidate) => Boolean(candidate));
|
|
117
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
118
|
+
}
|
|
119
|
+
function buildTmuxBaseArgs(socketPath) {
|
|
120
|
+
return socketPath ? ["-S", socketPath] : [];
|
|
121
|
+
}
|
|
122
|
+
function buildTmuxMonitorCommand(sessionName, socketPath) {
|
|
123
|
+
const socketArgs = socketPath ? `-S ${quoteShellValue(socketPath)} ` : "";
|
|
124
|
+
return `tmux ${socketArgs}attach -t ${quoteShellValue(sessionName)}`;
|
|
125
|
+
}
|
|
126
|
+
function getExtensionEntryPath() {
|
|
127
|
+
const currentPath = fileURLToPath(import.meta.url);
|
|
128
|
+
const extension = path.extname(currentPath) || ".js";
|
|
129
|
+
return path.join(path.dirname(currentPath), `index${extension}`);
|
|
130
|
+
}
|
|
131
|
+
function childStartupPrompt(parentAgentId) {
|
|
132
|
+
return [
|
|
133
|
+
`You are a Pinet subtree child supervised by ${parentAgentId}.`,
|
|
134
|
+
"Wait for the supervising worker's Pinet task, then do that task and report back through Pinet.",
|
|
135
|
+
"If you are not following Pinet yet, wait for the launcher to run /pinet follow.",
|
|
136
|
+
].join(" ");
|
|
137
|
+
}
|
|
138
|
+
function buildLauncherScript(input) {
|
|
139
|
+
const inheritedEnvKeys = [
|
|
140
|
+
"PI_CODING_AGENT_DIR",
|
|
141
|
+
"PI_CODING_AGENT_SESSION_DIR",
|
|
142
|
+
"PI_OFFLINE",
|
|
143
|
+
"PI_SETTINGS_PATH",
|
|
144
|
+
"PINET_MESH_SECRET",
|
|
145
|
+
"PINET_MESH_SECRET_PATH",
|
|
146
|
+
"SLACK_APP_TOKEN",
|
|
147
|
+
"SLACK_BOT_TOKEN",
|
|
148
|
+
];
|
|
149
|
+
const inheritedExports = inheritedEnvKeys
|
|
150
|
+
.map((key) => {
|
|
151
|
+
const value = process.env[key];
|
|
152
|
+
return value ? `export ${key}=${quoteShellValue(value)}` : null;
|
|
153
|
+
})
|
|
154
|
+
.filter((line) => Boolean(line));
|
|
155
|
+
const envExports = Object.entries(input.env).map(([key, value]) => `export ${key}=${quoteShellValue(value)}`);
|
|
156
|
+
const nickname = `Subtree ${input.env.PINET_SUBTREE_ROLE ?? "Worker"} ${input.env.PINET_LAUNCH_ID ?? randomSuffix()}`;
|
|
157
|
+
return [
|
|
158
|
+
"#!/bin/bash",
|
|
159
|
+
"set -euo pipefail",
|
|
160
|
+
`cd ${quoteShellValue(input.repoPath)}`,
|
|
161
|
+
...inheritedExports,
|
|
162
|
+
...envExports,
|
|
163
|
+
`export PI_NICKNAME=${quoteShellValue(nickname)}`,
|
|
164
|
+
`exec pi -e ${quoteShellValue(input.extensionEntryPath)} ${quoteShellValue(input.startupPrompt)}`,
|
|
165
|
+
"",
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
function isSubtreeChildAgent(agent, selfAgentId) {
|
|
169
|
+
return agent.id !== selfAgentId && agent.parentAgentId === selfAgentId;
|
|
170
|
+
}
|
|
171
|
+
function toSubtreeAgentRecord(db, agent) {
|
|
172
|
+
return {
|
|
173
|
+
emoji: agent.emoji,
|
|
174
|
+
name: agent.name,
|
|
175
|
+
id: agent.id,
|
|
176
|
+
pid: agent.pid,
|
|
177
|
+
status: agent.status,
|
|
178
|
+
metadata: agent.metadata,
|
|
179
|
+
lastHeartbeat: agent.lastHeartbeat,
|
|
180
|
+
lastSeen: agent.lastSeen,
|
|
181
|
+
disconnectedAt: agent.disconnectedAt,
|
|
182
|
+
resumableUntil: agent.resumableUntil,
|
|
183
|
+
outboundCount: agent.outboundCount,
|
|
184
|
+
pendingInboxCount: db.getPendingInboxCount(agent.id),
|
|
185
|
+
parentAgentId: agent.parentAgentId,
|
|
186
|
+
rootAgentId: agent.rootAgentId,
|
|
187
|
+
treeDepth: agent.treeDepth,
|
|
188
|
+
supervisionState: agent.supervisionState,
|
|
189
|
+
subtreeRole: agent.subtreeRole,
|
|
190
|
+
laneId: agent.laneId,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export function createSubtreeBrokerRuntime(deps) {
|
|
194
|
+
let activeBroker = null;
|
|
195
|
+
let selfAgentId = null;
|
|
196
|
+
let startedAt = null;
|
|
197
|
+
let activePaths = null;
|
|
198
|
+
let heartbeatTimer = null;
|
|
199
|
+
const spawnedWorkers = new Map();
|
|
200
|
+
function stopHeartbeat() {
|
|
201
|
+
if (!heartbeatTimer)
|
|
202
|
+
return;
|
|
203
|
+
clearInterval(heartbeatTimer);
|
|
204
|
+
heartbeatTimer = null;
|
|
205
|
+
}
|
|
206
|
+
function startHeartbeat(broker, agentId) {
|
|
207
|
+
stopHeartbeat();
|
|
208
|
+
heartbeatTimer = setInterval(() => {
|
|
209
|
+
try {
|
|
210
|
+
broker.db.heartbeatAgent(agentId);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Best effort only; normal broker maintenance will notice if this fails persistently.
|
|
214
|
+
}
|
|
215
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
216
|
+
heartbeatTimer.unref?.();
|
|
217
|
+
}
|
|
218
|
+
function currentChildren() {
|
|
219
|
+
const broker = activeBroker;
|
|
220
|
+
const agentId = selfAgentId;
|
|
221
|
+
if (!broker || !agentId)
|
|
222
|
+
return [];
|
|
223
|
+
return broker.db.getAllAgents().filter((agent) => isSubtreeChildAgent(agent, agentId));
|
|
224
|
+
}
|
|
225
|
+
function getStatus() {
|
|
226
|
+
const childLaunchEnv = activePaths && selfAgentId ? buildChildLaunchEnv(activePaths, selfAgentId) : {};
|
|
227
|
+
return {
|
|
228
|
+
active: activeBroker !== null,
|
|
229
|
+
selfAgentId,
|
|
230
|
+
startedAt,
|
|
231
|
+
paths: activePaths,
|
|
232
|
+
childLaunchEnv,
|
|
233
|
+
childLaunchHint: activePaths && selfAgentId
|
|
234
|
+
? buildChildLaunchHint(activePaths, selfAgentId, deps.cwd)
|
|
235
|
+
: null,
|
|
236
|
+
childCount: currentChildren().length,
|
|
237
|
+
spawnedWorkers: [...spawnedWorkers.values()],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function drainSelfInbox(ctx, broker, agentId) {
|
|
241
|
+
const entries = broker.db.getInbox(agentId).map(toFollowerInboxEntry);
|
|
242
|
+
if (entries.length === 0)
|
|
243
|
+
return;
|
|
244
|
+
const synced = syncBrokerInboxEntries(entries);
|
|
245
|
+
const handledControlInboxIds = new Set();
|
|
246
|
+
for (const entry of synced.controlEntries) {
|
|
247
|
+
try {
|
|
248
|
+
const queued = deps.requestRemoteControl(entry.command, ctx);
|
|
249
|
+
if (queued.ackDisposition === "immediate") {
|
|
250
|
+
handledControlInboxIds.add(entry.inboxId);
|
|
251
|
+
}
|
|
252
|
+
if (queued.shouldStartNow) {
|
|
253
|
+
deps.runRemoteControl(entry.command, ctx);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
ctx.ui.notify(`Subtree Pinet control failed: ${deps.formatError(error)}`, "error");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (handledControlInboxIds.size > 0) {
|
|
261
|
+
broker.db.markDelivered([...handledControlInboxIds], agentId);
|
|
262
|
+
}
|
|
263
|
+
if (synced.inboxMessages.length === 0)
|
|
264
|
+
return;
|
|
265
|
+
deps.pushInboxMessages(synced.inboxMessages);
|
|
266
|
+
deps.updateBadge();
|
|
267
|
+
deps.maybeDrainInboxIfIdle(ctx);
|
|
268
|
+
}
|
|
269
|
+
function readInbox(options = {}) {
|
|
270
|
+
if (!activeBroker || !selfAgentId)
|
|
271
|
+
return null;
|
|
272
|
+
if (options.threadId && !activeBroker.db.getThread(options.threadId))
|
|
273
|
+
return null;
|
|
274
|
+
const result = activeBroker.db.readInbox(selfAgentId, options);
|
|
275
|
+
return {
|
|
276
|
+
messages: result.messages.map((item) => ({
|
|
277
|
+
inboxId: item.entry.id,
|
|
278
|
+
delivered: item.entry.delivered,
|
|
279
|
+
readAt: item.entry.readAt,
|
|
280
|
+
message: item.message,
|
|
281
|
+
})),
|
|
282
|
+
unreadCountBefore: result.unreadCountBefore,
|
|
283
|
+
unreadCountAfter: result.unreadCountAfter,
|
|
284
|
+
unreadThreads: result.unreadThreads,
|
|
285
|
+
markedReadIds: result.markedReadIds,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
async function sendMessage(target, body, metadata) {
|
|
289
|
+
if (!activeBroker || !selfAgentId)
|
|
290
|
+
return null;
|
|
291
|
+
const targetAgent = resolveDirectAgentTarget(activeBroker.db.getAgents(), target);
|
|
292
|
+
if (!targetAgent || targetAgent.id === selfAgentId)
|
|
293
|
+
return null;
|
|
294
|
+
const control = normalizeOutgoingPinetControlMessage(body, metadata);
|
|
295
|
+
const finalBody = control?.body ?? body;
|
|
296
|
+
const finalMetadata = control?.metadata ?? metadata;
|
|
297
|
+
const identity = deps.getAgentIdentity();
|
|
298
|
+
const result = dispatchDirectAgentMessage(activeBroker.db, {
|
|
299
|
+
senderAgentId: selfAgentId,
|
|
300
|
+
senderAgentName: identity.name || "Subtree Broker",
|
|
301
|
+
target,
|
|
302
|
+
body: finalBody,
|
|
303
|
+
...(finalMetadata ? { metadata: finalMetadata } : {}),
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
messageId: result.messageId,
|
|
307
|
+
target: result.target.name,
|
|
308
|
+
threadId: result.threadId,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function listAgents(includeGhosts = false) {
|
|
312
|
+
const broker = activeBroker;
|
|
313
|
+
if (!broker)
|
|
314
|
+
return null;
|
|
315
|
+
const agents = broker.db.getAllAgents();
|
|
316
|
+
const filtered = includeGhosts ? agents : agents.filter((agent) => !agent.disconnectedAt);
|
|
317
|
+
return filtered.map((agent) => toSubtreeAgentRecord(broker.db, agent));
|
|
318
|
+
}
|
|
319
|
+
async function sendFollowCommand(sessionName, tmuxBaseArgs) {
|
|
320
|
+
await execFileAsync("tmux", [
|
|
321
|
+
...tmuxBaseArgs,
|
|
322
|
+
"send-keys",
|
|
323
|
+
"-t",
|
|
324
|
+
sessionName,
|
|
325
|
+
"-l",
|
|
326
|
+
"--",
|
|
327
|
+
"/pinet follow",
|
|
328
|
+
]);
|
|
329
|
+
await execFileAsync("tmux", [...tmuxBaseArgs, "send-keys", "-t", sessionName, "Enter"]);
|
|
330
|
+
}
|
|
331
|
+
async function waitForSpawnedAgent(input) {
|
|
332
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
333
|
+
let lastFollowAttemptAt = 0;
|
|
334
|
+
while (Date.now() < deadline) {
|
|
335
|
+
const agent = input.broker.db
|
|
336
|
+
.getAllAgents()
|
|
337
|
+
.find((candidate) => metadataString(candidate.metadata, "launchId") === input.launchId);
|
|
338
|
+
if (agent)
|
|
339
|
+
return agent;
|
|
340
|
+
if (Date.now() - lastFollowAttemptAt > 6_000) {
|
|
341
|
+
lastFollowAttemptAt = Date.now();
|
|
342
|
+
await sendFollowCommand(input.sessionName, input.tmuxBaseArgs).catch(() => {
|
|
343
|
+
// The session may still be starting; the loop retries until timeout.
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
await delay(1_000);
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`subtree worker session ${input.sessionName} started but did not register within ${input.timeoutMs}ms`);
|
|
349
|
+
}
|
|
350
|
+
async function requestChildExit(agent) {
|
|
351
|
+
await sendMessage(agent.id, "/exit", { subtreeLifecycle: "stop" }).catch(() => null);
|
|
352
|
+
}
|
|
353
|
+
async function killTmuxSession(sessionName, tmuxBaseArgs) {
|
|
354
|
+
await execFileAsync("tmux", [...tmuxBaseArgs, "has-session", "-t", sessionName]).catch(() => {
|
|
355
|
+
throw new Error("missing");
|
|
356
|
+
});
|
|
357
|
+
await execFileAsync("tmux", [...tmuxBaseArgs, "kill-session", "-t", sessionName]);
|
|
358
|
+
}
|
|
359
|
+
function childTmuxSessions(broker, agentId) {
|
|
360
|
+
const sessions = new Set();
|
|
361
|
+
for (const worker of spawnedWorkers.values()) {
|
|
362
|
+
sessions.add(worker.sessionName);
|
|
363
|
+
}
|
|
364
|
+
for (const agent of broker.db.getAllAgents()) {
|
|
365
|
+
if (!isSubtreeChildAgent(agent, agentId))
|
|
366
|
+
continue;
|
|
367
|
+
const session = metadataString(agent.metadata, "tmuxSession");
|
|
368
|
+
if (session)
|
|
369
|
+
sessions.add(session);
|
|
370
|
+
}
|
|
371
|
+
return [...sessions];
|
|
372
|
+
}
|
|
373
|
+
async function stopChildren(broker, agentId) {
|
|
374
|
+
const children = broker.db
|
|
375
|
+
.getAllAgents()
|
|
376
|
+
.filter((agent) => isSubtreeChildAgent(agent, agentId));
|
|
377
|
+
await Promise.all(children.map(requestChildExit));
|
|
378
|
+
if (children.length > 0) {
|
|
379
|
+
await delay(SUBTREE_CHILD_EXIT_GRACE_MS);
|
|
380
|
+
}
|
|
381
|
+
const tmuxSocketPath = findTmuxSocketPath();
|
|
382
|
+
const tmuxBaseArgs = buildTmuxBaseArgs(tmuxSocketPath);
|
|
383
|
+
await Promise.all(childTmuxSessions(broker, agentId).map((sessionName) => killTmuxSession(sessionName, tmuxBaseArgs).catch(() => undefined)));
|
|
384
|
+
}
|
|
385
|
+
async function stop(options = {}) {
|
|
386
|
+
stopHeartbeat();
|
|
387
|
+
const broker = activeBroker;
|
|
388
|
+
const agentId = selfAgentId;
|
|
389
|
+
if (!broker)
|
|
390
|
+
return;
|
|
391
|
+
try {
|
|
392
|
+
if (agentId && options.stopChildren !== false) {
|
|
393
|
+
await stopChildren(broker, agentId);
|
|
394
|
+
}
|
|
395
|
+
if (options.releaseIdentity && agentId) {
|
|
396
|
+
broker.db.unregisterAgent(agentId);
|
|
397
|
+
}
|
|
398
|
+
await broker.stop();
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Best effort; callers should be able to continue even if shutdown cleanup is partial.
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
activeBroker = null;
|
|
405
|
+
selfAgentId = null;
|
|
406
|
+
startedAt = null;
|
|
407
|
+
activePaths = null;
|
|
408
|
+
spawnedWorkers.clear();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async function start(ctx) {
|
|
412
|
+
if (activeBroker)
|
|
413
|
+
return getStatus();
|
|
414
|
+
const stableId = deps.getCentralAgentId() ?? deps.getAgentStableId();
|
|
415
|
+
const paths = buildSubtreeBrokerPaths(stableId);
|
|
416
|
+
fs.mkdirSync(paths.rootDir, { recursive: true });
|
|
417
|
+
const meshAuth = resolvePinetMeshAuth(deps.getSettings());
|
|
418
|
+
const broker = await startBroker({
|
|
419
|
+
dbPath: paths.dbPath,
|
|
420
|
+
socketPath: paths.socketPath,
|
|
421
|
+
lockPath: paths.lockPath,
|
|
422
|
+
...(meshAuth.meshSecret ? { meshSecret: meshAuth.meshSecret } : {}),
|
|
423
|
+
...(meshAuth.meshSecretPath ? { meshSecretPath: meshAuth.meshSecretPath } : {}),
|
|
424
|
+
});
|
|
425
|
+
const selfId = buildSelfAgentId(stableId);
|
|
426
|
+
const { name, emoji } = deps.getAgentIdentity();
|
|
427
|
+
const metadata = {
|
|
428
|
+
...(await deps.getAgentMetadata("broker")),
|
|
429
|
+
subtreeBroker: true,
|
|
430
|
+
upstreamAgentId: deps.getCentralAgentId(),
|
|
431
|
+
subtreeSocketPath: paths.socketPath,
|
|
432
|
+
};
|
|
433
|
+
const selfAgent = broker.db.registerAgent(selfId, name ? `Subtree Broker ${name}` : "Subtree Broker", emoji || "🌳", process.pid, metadata, `${stableId}:subtree-broker`);
|
|
434
|
+
broker.server.setAgentRegistrationResolver((registration) => {
|
|
435
|
+
const role = deps.getMeshRoleFromMetadata(registration.metadata, "worker");
|
|
436
|
+
const identity = generateAgentName(registration.stableId ?? registration.agentId, role);
|
|
437
|
+
return {
|
|
438
|
+
name: registration.name || identity.name,
|
|
439
|
+
emoji: registration.emoji || identity.emoji,
|
|
440
|
+
metadata: {
|
|
441
|
+
...(registration.metadata ?? {}),
|
|
442
|
+
subtreeBrokerAgentId: selfAgent.id,
|
|
443
|
+
subtreeRootAgentId: selfAgent.id,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
broker.server.onAgentMessage((targetAgentId) => {
|
|
448
|
+
if (targetAgentId !== selfAgent.id)
|
|
449
|
+
return;
|
|
450
|
+
drainSelfInbox(ctx, broker, selfAgent.id);
|
|
451
|
+
});
|
|
452
|
+
activeBroker = broker;
|
|
453
|
+
selfAgentId = selfAgent.id;
|
|
454
|
+
startedAt = new Date().toISOString();
|
|
455
|
+
activePaths = paths;
|
|
456
|
+
startHeartbeat(broker, selfAgent.id);
|
|
457
|
+
broker.db.setSetting("pinet.subtreeBrokerParentStableId", deps.getAgentStableId());
|
|
458
|
+
broker.db.setSetting("pinet.subtreeBrokerOwnerToken", buildPinetOwnerToken(stableId));
|
|
459
|
+
return getStatus();
|
|
460
|
+
}
|
|
461
|
+
async function spawnWorker(ctx, input) {
|
|
462
|
+
if (!input.task.trim())
|
|
463
|
+
throw new Error("spawn requires task");
|
|
464
|
+
if (!input.repo.trim())
|
|
465
|
+
throw new Error("spawn requires repo");
|
|
466
|
+
if (!activeBroker) {
|
|
467
|
+
await start(ctx);
|
|
468
|
+
}
|
|
469
|
+
if (!activeBroker || !activePaths || !selfAgentId) {
|
|
470
|
+
throw new Error("Subtree broker is not running.");
|
|
471
|
+
}
|
|
472
|
+
const repoPath = resolveRepoPath(input.repo, deps.cwd);
|
|
473
|
+
const role = normalizeRole(input.role);
|
|
474
|
+
const launchId = `subtree-${Date.now().toString(36)}-${randomSuffix()}`;
|
|
475
|
+
const sessionName = buildTmuxSessionName(repoPath, role, launchId);
|
|
476
|
+
const tmuxSocketPath = findTmuxSocketPath();
|
|
477
|
+
const tmuxBaseArgs = buildTmuxBaseArgs(tmuxSocketPath);
|
|
478
|
+
const monitorCommand = buildTmuxMonitorCommand(sessionName, tmuxSocketPath);
|
|
479
|
+
const childLaunchEnv = buildChildLaunchEnv(activePaths, selfAgentId, {
|
|
480
|
+
launchId,
|
|
481
|
+
role,
|
|
482
|
+
...(input.laneId ? { laneId: input.laneId } : {}),
|
|
483
|
+
tmuxSession: sessionName,
|
|
484
|
+
});
|
|
485
|
+
const launchersDir = path.join(activePaths.rootDir, "launchers");
|
|
486
|
+
fs.mkdirSync(launchersDir, { recursive: true });
|
|
487
|
+
const launcherPath = path.join(launchersDir, `${sessionName}.sh`);
|
|
488
|
+
fs.writeFileSync(launcherPath, buildLauncherScript({
|
|
489
|
+
repoPath,
|
|
490
|
+
env: childLaunchEnv,
|
|
491
|
+
extensionEntryPath: getExtensionEntryPath(),
|
|
492
|
+
startupPrompt: childStartupPrompt(selfAgentId),
|
|
493
|
+
}), { mode: 0o700 });
|
|
494
|
+
await execFileAsync("tmux", [
|
|
495
|
+
...tmuxBaseArgs,
|
|
496
|
+
"new-session",
|
|
497
|
+
"-d",
|
|
498
|
+
"-s",
|
|
499
|
+
sessionName,
|
|
500
|
+
launcherPath,
|
|
501
|
+
]);
|
|
502
|
+
const workerRecord = {
|
|
503
|
+
launchId,
|
|
504
|
+
sessionName,
|
|
505
|
+
repoPath,
|
|
506
|
+
role,
|
|
507
|
+
laneId: input.laneId ?? null,
|
|
508
|
+
agentId: null,
|
|
509
|
+
startedAt: new Date().toISOString(),
|
|
510
|
+
monitorCommand,
|
|
511
|
+
};
|
|
512
|
+
spawnedWorkers.set(launchId, workerRecord);
|
|
513
|
+
const agent = await waitForSpawnedAgent({
|
|
514
|
+
broker: activeBroker,
|
|
515
|
+
launchId,
|
|
516
|
+
sessionName,
|
|
517
|
+
tmuxBaseArgs,
|
|
518
|
+
timeoutMs: input.waitForRegistrationMs ?? DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS,
|
|
519
|
+
});
|
|
520
|
+
const updatedRecord = { ...workerRecord, agentId: agent.id };
|
|
521
|
+
spawnedWorkers.set(launchId, updatedRecord);
|
|
522
|
+
const messageResult = await sendMessage(agent.id, input.task, {
|
|
523
|
+
subtreeTask: true,
|
|
524
|
+
launchId,
|
|
525
|
+
role,
|
|
526
|
+
...(input.laneId ? { laneId: input.laneId } : {}),
|
|
527
|
+
});
|
|
528
|
+
if (!messageResult) {
|
|
529
|
+
throw new Error(`subtree worker ${agent.id} registered but could not receive the task`);
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
status: "started",
|
|
533
|
+
launchId,
|
|
534
|
+
sessionName,
|
|
535
|
+
repoPath,
|
|
536
|
+
role,
|
|
537
|
+
laneId: input.laneId ?? null,
|
|
538
|
+
agentId: agent.id,
|
|
539
|
+
agentName: agent.name,
|
|
540
|
+
messageId: messageResult.messageId,
|
|
541
|
+
threadId: messageResult.threadId,
|
|
542
|
+
monitorCommand,
|
|
543
|
+
socketPath: activePaths.socketPath,
|
|
544
|
+
dbPath: activePaths.dbPath,
|
|
545
|
+
childLaunchEnv,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
start,
|
|
550
|
+
stop,
|
|
551
|
+
getStatus,
|
|
552
|
+
readInbox,
|
|
553
|
+
sendMessage,
|
|
554
|
+
listAgents,
|
|
555
|
+
spawnWorker,
|
|
556
|
+
isActive: () => activeBroker !== null,
|
|
557
|
+
};
|
|
558
|
+
}
|
package/manifest.yaml
CHANGED
|
@@ -11,6 +11,14 @@ features:
|
|
|
11
11
|
bot_user:
|
|
12
12
|
display_name: Pinet
|
|
13
13
|
always_online: true
|
|
14
|
+
# Default packaged command for the Pinet app. For Oathgate or another app name,
|
|
15
|
+
# either edit this command before manual Slack import or run `pnpm deploy:slack`
|
|
16
|
+
# with slackCommandName/slackCommandNames configured so deployment rewrites it.
|
|
17
|
+
slash_commands:
|
|
18
|
+
- command: /pinet
|
|
19
|
+
description: Show the Pinet broker roster and current work
|
|
20
|
+
usage_hint: "agents list [all]"
|
|
21
|
+
should_escape: false
|
|
14
22
|
assistant_view:
|
|
15
23
|
assistant_description: Pi coding agent
|
|
16
24
|
suggested_prompts: []
|
|
@@ -23,6 +31,7 @@ oauth_config:
|
|
|
23
31
|
- channels:history
|
|
24
32
|
- channels:read
|
|
25
33
|
- chat:write
|
|
34
|
+
- commands
|
|
26
35
|
- files:read
|
|
27
36
|
- files:write
|
|
28
37
|
- bookmarks:read
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pinet/slack-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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,17 +49,19 @@
|
|
|
49
49
|
"test": "vitest run"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@pinet/broker-core": "0.
|
|
53
|
-
"@pinet/imessage-bridge": "0.
|
|
54
|
-
"@pinet/pinet-core": "0.
|
|
55
|
-
"@pinet/transport-core": "0.
|
|
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",
|
|
56
56
|
"@sinclair/typebox": "^0.34.49"
|
|
57
57
|
},
|
|
58
58
|
"types": "./dist/index.d.ts",
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
60
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
61
|
+
"@earendil-works/pi-tui": "*"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
|
-
"@earendil-works/pi-coding-agent": "^0.74.0"
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.74.0"
|
|
64
66
|
}
|
|
65
67
|
}
|
|
@@ -50,7 +50,7 @@ should use the colon form.
|
|
|
50
50
|
|
|
51
51
|
- Thread/channel messaging: `post_channel`, `read`, `read_channel`, `export`
|
|
52
52
|
- Lightweight acknowledgement: `react`
|
|
53
|
-
- Files/snippets: `upload`
|
|
53
|
+
- Files/snippets: `upload`, `file`; `slack_send.files` and `post_channel.files` for text plus attachments in one message
|
|
54
54
|
- Time-based follow-up: `schedule`
|
|
55
55
|
- People/timing: `presence`
|
|
56
56
|
- Durable channel affordances: `pin`, `bookmark`
|
|
@@ -144,6 +144,65 @@ snippets:
|
|
|
144
144
|
]
|
|
145
145
|
````
|
|
146
146
|
|
|
147
|
+
## File workflows
|
|
148
|
+
|
|
149
|
+
For outbound local files, attach them to the message-posting surface you are
|
|
150
|
+
already using. Use `slack_send.files` for owned assistant-thread replies, and
|
|
151
|
+
`slack` action `post_channel` with `args.files` for explicit channel/thread
|
|
152
|
+
posts. Both use the same file object shape and send text plus files in one Slack
|
|
153
|
+
message:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"text": "Here is the report and the raw capture.",
|
|
158
|
+
"thread_ts": "1712345678.000100",
|
|
159
|
+
"files": [
|
|
160
|
+
{ "path": "./out/report.pdf", "title": "Report" },
|
|
161
|
+
{ "path": "/tmp/capture.bin", "filename": "capture.bin" }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"action": "post_channel",
|
|
169
|
+
"args": {
|
|
170
|
+
"channel": "#deployments",
|
|
171
|
+
"text": "Here is the deploy evidence.",
|
|
172
|
+
"files": [{ "path": "/tmp/evidence.png", "filename": "evidence.png" }]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Local path guardrails still apply: paths must resolve inside the current working
|
|
178
|
+
directory or the system temp directory. Binary files are supported.
|
|
179
|
+
|
|
180
|
+
For inbound Slack-hosted files, `slack` action `read` downloads attached files to
|
|
181
|
+
the local temp cache by default and returns safe descriptors alongside the
|
|
182
|
+
message metadata. Use `args.download_files=false` when you only need message text
|
|
183
|
+
and file metadata.
|
|
184
|
+
|
|
185
|
+
Use the explicit `file` action when you have a specific file ID to retry,
|
|
186
|
+
validate, or fetch outside a normal read flow:
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
{
|
|
190
|
+
"action": "file",
|
|
191
|
+
"args": {
|
|
192
|
+
"op": "download",
|
|
193
|
+
"file_id": "F0123456789",
|
|
194
|
+
"thread_ts": "1712345678.000100",
|
|
195
|
+
"message_ts": "1712345678.000200"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Both read-time downloads and the explicit file action download Slack-hosted user
|
|
201
|
+
content into the local temp cache and return safe descriptors: file ID,
|
|
202
|
+
filename/type, local temp path, size, SHA-256, cache directory, expiry, and
|
|
203
|
+
residual risk notes. They must not print private Slack download URLs or raw file
|
|
204
|
+
contents.
|
|
205
|
+
|
|
147
206
|
## Modal patterns
|
|
148
207
|
|
|
149
208
|
Modal actions require a fresh Slack `trigger_id` from a recent interaction. The
|