@pinet/slack-bridge 0.2.4 → 0.2.6

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.
Files changed (93) hide show
  1. package/README.md +26 -1
  2. package/dist/activity-log.d.ts +10 -3
  3. package/dist/agent-event-runtime.d.ts +0 -2
  4. package/dist/agent-event-runtime.js +0 -1
  5. package/dist/agent-prompt-guidance.d.ts +1 -6
  6. package/dist/agent-prompt-guidance.js +1 -6
  7. package/dist/broker/adapters/slack.d.ts +4 -1
  8. package/dist/broker/adapters/slack.js +16 -9
  9. package/dist/broker/client.d.ts +21 -4
  10. package/dist/broker/client.js +65 -25
  11. package/dist/broker/hibernation-activation-authority.d.ts +22 -0
  12. package/dist/broker/hibernation-activation-authority.js +51 -0
  13. package/dist/broker/hibernation-activation.d.ts +75 -0
  14. package/dist/broker/hibernation-activation.js +95 -0
  15. package/dist/broker/hibernation-command-router.d.ts +54 -0
  16. package/dist/broker/hibernation-command-router.js +124 -0
  17. package/dist/broker/hibernation-runtime-adapters.d.ts +79 -0
  18. package/dist/broker/hibernation-runtime-adapters.js +473 -0
  19. package/dist/broker/hibernation-runtime-helpers.d.ts +146 -0
  20. package/dist/broker/hibernation-runtime-helpers.js +243 -0
  21. package/dist/broker/index.d.ts +16 -1
  22. package/dist/broker/index.js +68 -10
  23. package/dist/broker/lock-conflict.d.ts +109 -0
  24. package/dist/broker/lock-conflict.js +409 -0
  25. package/dist/broker/socket-server.d.ts +25 -0
  26. package/dist/broker/socket-server.js +234 -4
  27. package/dist/broker-delivery.d.ts +1 -0
  28. package/dist/broker-delivery.js +10 -1
  29. package/dist/broker-runtime.d.ts +14 -2
  30. package/dist/broker-runtime.js +114 -3
  31. package/dist/canvases.d.ts +12 -2
  32. package/dist/canvases.js +1 -3
  33. package/dist/compaction-gate.d.ts +7 -0
  34. package/dist/compaction-gate.js +35 -0
  35. package/dist/deploy-manifest.d.ts +8 -2
  36. package/dist/deploy-manifest.js +9 -7
  37. package/dist/follower-runtime.d.ts +5 -2
  38. package/dist/follower-runtime.js +39 -5
  39. package/dist/git-metadata.d.ts +5 -9
  40. package/dist/git-metadata.js +23 -42
  41. package/dist/guardrails.js +5 -0
  42. package/dist/helpers.d.ts +77 -27
  43. package/dist/helpers.js +150 -39
  44. package/dist/hibernation-config.d.ts +13 -0
  45. package/dist/hibernation-config.js +14 -0
  46. package/dist/home-tab.d.ts +13 -2
  47. package/dist/imessage-tools.d.ts +4 -1
  48. package/dist/inbox-drain-runtime.d.ts +1 -0
  49. package/dist/inbox-drain-runtime.js +5 -1
  50. package/dist/index.js +340 -108
  51. package/dist/pinet-commands.d.ts +14 -1
  52. package/dist/pinet-commands.js +93 -22
  53. package/dist/pinet-mesh-ops.d.ts +6 -4
  54. package/dist/pinet-mesh-ops.js +8 -1
  55. package/dist/pinet-runtime-composition.d.ts +2 -1
  56. package/dist/pinet-runtime-composition.js +14 -3
  57. package/dist/pinet-session-formatting.d.ts +26 -2
  58. package/dist/pinet-session-formatting.js +2 -0
  59. package/dist/pinet-tools.d.ts +14 -0
  60. package/dist/pinet-tools.js +355 -50
  61. package/dist/prompts/broker/default.md +3 -3
  62. package/dist/prompts/broker/tmux.md +3 -3
  63. package/dist/runtime-agent-context.d.ts +3 -3
  64. package/dist/runtime-agent-context.js +12 -6
  65. package/dist/runtime-mode.js +4 -0
  66. package/dist/session-ui-runtime.js +3 -1
  67. package/dist/single-player-runtime.d.ts +1 -1
  68. package/dist/single-player-runtime.js +21 -5
  69. package/dist/slack-access.d.ts +10 -2
  70. package/dist/slack-access.js +64 -22
  71. package/dist/slack-block-kit.d.ts +44 -2
  72. package/dist/slack-block-kit.js +5 -5
  73. package/dist/slack-file-access.d.ts +12 -1
  74. package/dist/slack-file-access.js +4 -4
  75. package/dist/slack-message-context.d.ts +9 -2
  76. package/dist/slack-modals.d.ts +4 -1
  77. package/dist/slack-modals.js +5 -5
  78. package/dist/slack-pinet-runtime-adapter.d.ts +1 -0
  79. package/dist/slack-pinet-runtime-adapter.js +10 -0
  80. package/dist/slack-presence.d.ts +5 -4
  81. package/dist/slack-request-runtime.d.ts +3 -2
  82. package/dist/slack-scope-diagnostics.d.ts +27 -2
  83. package/dist/slack-socket-dedup.d.ts +8 -5
  84. package/dist/slack-tools.d.ts +3 -1
  85. package/dist/slack-tools.js +26 -16
  86. package/dist/slack-upload.d.ts +17 -1
  87. package/dist/subtree-broker-runtime.d.ts +83 -2
  88. package/dist/subtree-broker-runtime.js +774 -109
  89. package/dist/tool-output-limits.d.ts +11 -0
  90. package/dist/tool-output-limits.js +20 -0
  91. package/dist/tool-registration-runtime.d.ts +3 -0
  92. package/dist/tool-registration-runtime.js +27 -0
  93. package/package.json +8 -8
@@ -1,17 +1,84 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  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 { sleep } from "@pinet/transport-core/async";
7
8
  import { summarizePinetStableId } from "./pinet-session-formatting.js";
8
9
  import { dispatchDirectAgentMessage, resolveDirectAgentTarget } from "./broker/agent-messaging.js";
9
10
  import { startBroker } from "./broker/index.js";
10
11
  import { HEARTBEAT_INTERVAL_MS } from "./broker/client.js";
11
- import { buildPinetOwnerToken, generateAgentName, normalizeOutgoingPinetControlMessage, resolvePinetMeshAuth, syncBrokerInboxEntries, } from "./helpers.js";
12
+ import { buildPinetOwnerToken, formatPinetSteeringMessage, generateAgentName, normalizeOutgoingPinetControlMessage, normalizeOutgoingPinetSteeringMessage, resolvePinetMeshAuth, syncBrokerInboxEntries, } from "./helpers.js";
13
+ import { resolveHibernationSettings } from "./hibernation-config.js";
14
+ import { hibernationRuntimeActive, createHibernationOrchestrator, persistSpawnedRuntimeSpec, recoverStrandedWakesBeforeRegistrations, } from "./broker/hibernation-activation.js";
15
+ import { freezeHibernationActivationAuthority } from "./broker/hibernation-activation-authority.js";
12
16
  const execFileAsync = promisify(execFile);
13
17
  const DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS = 45_000;
14
18
  const SUBTREE_CHILD_EXIT_GRACE_MS = 5_000;
19
+ const PENDING_HERDR_RUNTIMES_SETTING = "pinet.subtreePendingHerdrRuntimes";
20
+ function readPendingHerdrRuntimes(db) {
21
+ const records = db.getSetting(PENDING_HERDR_RUNTIMES_SETTING);
22
+ if (!Array.isArray(records))
23
+ return [];
24
+ return records.filter((record) => record !== null &&
25
+ typeof record === "object" &&
26
+ record.runtimeKind === "herdr" &&
27
+ typeof record.launchId === "string" &&
28
+ record.launchId.length > 0 &&
29
+ typeof record.sessionName === "string" &&
30
+ record.sessionName.length > 0 &&
31
+ typeof record.herdrSession === "string" &&
32
+ record.herdrSession.length > 0 &&
33
+ typeof record.herdrConfigDir === "string" &&
34
+ record.herdrConfigDir.length > 0 &&
35
+ typeof record.herdrPaneId === "string" &&
36
+ record.herdrPaneId.length > 0 &&
37
+ typeof record.herdrShellPid === "number" &&
38
+ Number.isInteger(record.herdrShellPid) &&
39
+ record.herdrShellPid > 0);
40
+ }
41
+ function updatePendingHerdrRuntime(db, launchId, spec) {
42
+ const records = readPendingHerdrRuntimes(db).filter((record) => record.launchId !== launchId);
43
+ if (spec) {
44
+ if (!spec.herdrPaneId || spec.herdrShellPid === null) {
45
+ throw new Error(`Herdr runtime ${spec.sessionName} has no cleanup identity`);
46
+ }
47
+ records.push({
48
+ ...spec,
49
+ launchId,
50
+ herdrPaneId: spec.herdrPaneId,
51
+ herdrShellPid: spec.herdrShellPid,
52
+ });
53
+ }
54
+ if (records.length === 0)
55
+ db.deleteSetting(PENDING_HERDR_RUNTIMES_SETTING);
56
+ else
57
+ db.setSetting(PENDING_HERDR_RUNTIMES_SETTING, records);
58
+ }
59
+ export class SubtreeSpawnRegistrationTimeoutError extends Error {
60
+ handle;
61
+ constructor(timeoutMs, handle) {
62
+ const runtimeKind = handle.runtimeKind ?? "tmux";
63
+ const monitorHint = handle.monitorCommand ? `; monitor=${handle.monitorCommand}` : "";
64
+ super(`subtree ${runtimeKind} worker ${handle.tmuxSessionName} started but did not register within ${timeoutMs}ms; ` +
65
+ `launchId=${handle.launchId}; tmuxSessionName=${handle.tmuxSessionName}; ` +
66
+ `socketPath=${handle.socketPath}; state=${handle.state}${monitorHint}`);
67
+ this.name = "SubtreeSpawnRegistrationTimeoutError";
68
+ this.handle = handle;
69
+ }
70
+ }
71
+ export class SubtreeSpawnLaunchError extends Error {
72
+ handle;
73
+ constructor(error, handle, runtimeKind = "tmux") {
74
+ const message = error.message;
75
+ super(`subtree worker session ${handle.tmuxSessionName} may have started after ${runtimeKind} reported: ${message}; ` +
76
+ `launchId=${handle.launchId}; tmuxSessionName=${handle.tmuxSessionName}; ` +
77
+ `socketPath=${handle.socketPath}; state=${handle.state}`, { cause: error });
78
+ this.name = "SubtreeSpawnLaunchError";
79
+ this.handle = handle;
80
+ }
81
+ }
15
82
  function sanitizePathSegment(value) {
16
83
  const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
17
84
  return sanitized || "agent";
@@ -19,11 +86,6 @@ function sanitizePathSegment(value) {
19
86
  function randomSuffix() {
20
87
  return Math.random().toString(36).slice(2, 8);
21
88
  }
22
- function delay(ms) {
23
- return new Promise((resolve) => {
24
- setTimeout(resolve, ms);
25
- });
26
- }
27
89
  export function buildSubtreeBrokerPaths(stableId) {
28
90
  const rootDir = path.join(os.homedir(), ".pi", "pinet-subtrees", sanitizePathSegment(stableId));
29
91
  return {
@@ -40,10 +102,11 @@ function buildChildLaunchEnv(paths, selfAgentId, input = {}) {
40
102
  return {
41
103
  PINET_SOCKET_PATH: paths.socketPath,
42
104
  PINET_BROKER_MANAGED: "1",
105
+ PINET_BROKER_AGENT_ID: selfAgentId,
43
106
  PINET_PARENT_AGENT_ID: selfAgentId,
44
107
  PINET_ROOT_AGENT_ID: selfAgentId,
45
108
  PINET_SPAWNED_BY_AGENT_ID: selfAgentId,
46
- PINET_LAUNCH_SOURCE: "subtree-broker-tmux",
109
+ PINET_LAUNCH_SOURCE: input.runtimeKind === "herdr" ? "subtree-broker-herdr" : "subtree-broker-tmux",
47
110
  ...(input.launchId ? { PINET_LAUNCH_ID: input.launchId } : {}),
48
111
  ...(input.role ? { PINET_SUBTREE_ROLE: input.role } : {}),
49
112
  ...(input.laneId ? { PINET_LANE_ID: input.laneId } : {}),
@@ -124,11 +187,253 @@ function buildTmuxMonitorCommand(sessionName, socketPath) {
124
187
  const socketArgs = socketPath ? `-S ${quoteShellValue(socketPath)} ` : "";
125
188
  return `tmux ${socketArgs}attach -t ${quoteShellValue(sessionName)}`;
126
189
  }
127
- function getExtensionEntryPath() {
190
+ function isMissingTmuxTarget(error) {
191
+ const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr : "";
192
+ return /can't find session:|no server running|error connecting to .*\(no such file or directory\)/i.test(`${error.message}\n${stderr}`);
193
+ }
194
+ function herdrErrorText(error) {
195
+ const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr : "";
196
+ return `${error.message}\n${stderr}`;
197
+ }
198
+ function isMissingHerdrSession(error) {
199
+ return /Error:\s*Os\s*\{\s*code:\s*2,\s*kind:\s*NotFound,\s*message:\s*"No such file or directory"\s*\}/.test(herdrErrorText(error));
200
+ }
201
+ // agent-standards-ignore prefer-inline-single-use-helper: precise Herdr absence classifier
202
+ function isMissingHerdrPane(error) {
203
+ return /"code"\s*:\s*"pane_not_found"/.test(herdrErrorText(error));
204
+ }
205
+ // agent-standards-ignore prefer-inline-single-use-helper: runtime backend construction seam
206
+ function createTmuxWorkerRuntimeController(runTmuxCommand) {
207
+ return {
208
+ createLaunchSpec: (sessionName) => ({
209
+ runtimeKind: "tmux",
210
+ sessionName,
211
+ tmuxSocketPath: findTmuxSocketPath(),
212
+ }),
213
+ monitorCommand: (spec) => buildTmuxMonitorCommand(spec.sessionName, spec.tmuxSocketPath),
214
+ launch: async (spec, launcherPath) => {
215
+ await runTmuxCommand([
216
+ ...buildTmuxBaseArgs(spec.tmuxSocketPath),
217
+ "new-session",
218
+ "-d",
219
+ "-s",
220
+ spec.sessionName,
221
+ launcherPath,
222
+ ]);
223
+ },
224
+ cleanup: async (spec) => {
225
+ const exactTarget = `=${spec.sessionName}`;
226
+ try {
227
+ await runTmuxCommand([
228
+ ...buildTmuxBaseArgs(spec.tmuxSocketPath),
229
+ "has-session",
230
+ "-t",
231
+ exactTarget,
232
+ ]);
233
+ }
234
+ catch (error) {
235
+ if (error instanceof Error && isMissingTmuxTarget(error))
236
+ return;
237
+ throw error;
238
+ }
239
+ try {
240
+ await runTmuxCommand([
241
+ ...buildTmuxBaseArgs(spec.tmuxSocketPath),
242
+ "kill-session",
243
+ "-t",
244
+ exactTarget,
245
+ ]);
246
+ }
247
+ catch (error) {
248
+ if (!(error instanceof Error) || !isMissingTmuxTarget(error))
249
+ throw error;
250
+ }
251
+ },
252
+ };
253
+ }
254
+ function runHerdrSessionCommand(runner, spec, args, detached = false) {
255
+ return runner(["--session", spec.herdrSession, ...args], {
256
+ env: { ...process.env, XDG_CONFIG_HOME: spec.herdrConfigDir },
257
+ ...(detached ? { detached: true } : {}),
258
+ });
259
+ }
260
+ // agent-standards-ignore prefer-inline-single-use-helper: dedicated Herdr ownership boundary
261
+ function ensureHerdrConfig(configDir) {
262
+ const herdrDir = path.join(configDir, "herdr");
263
+ const configPath = path.join(herdrDir, "config.toml");
264
+ fs.mkdirSync(herdrDir, { recursive: true });
265
+ if (!fs.existsSync(configPath)) {
266
+ fs.writeFileSync(configPath, "[experimental]\npane_history = true\n", { mode: 0o600 });
267
+ }
268
+ }
269
+ export function createHerdrWorkerRuntimeController(runHerdrCommand, options) {
270
+ return {
271
+ createLaunchSpec: (sessionName) => ({
272
+ runtimeKind: "herdr",
273
+ sessionName,
274
+ herdrSession: options.herdrSession,
275
+ herdrConfigDir: options.herdrConfigDir,
276
+ herdrPaneId: null,
277
+ herdrShellPid: null,
278
+ }),
279
+ monitorCommand: (spec) => `XDG_CONFIG_HOME=${quoteShellValue(spec.herdrConfigDir)} herdr session attach ${quoteShellValue(spec.herdrSession)}`,
280
+ launch: async (spec, launcherPath, launchEnv, onRuntimeOwned) => {
281
+ ensureHerdrConfig(spec.herdrConfigDir);
282
+ try {
283
+ await runHerdrSessionCommand(runHerdrCommand, spec, ["pane", "list"]);
284
+ }
285
+ catch (error) {
286
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
287
+ throw new Error("Herdr worker runtime is configured but the 'herdr' executable is unavailable.", { cause: error });
288
+ }
289
+ if (!(error instanceof Error) || !isMissingHerdrSession(error))
290
+ throw error;
291
+ await runHerdrSessionCommand(runHerdrCommand, spec, ["server"], true);
292
+ let ready = false;
293
+ let lastMissingError = error;
294
+ for (let attempt = 0; attempt < 40; attempt += 1) {
295
+ try {
296
+ await runHerdrSessionCommand(runHerdrCommand, spec, ["pane", "list"]);
297
+ ready = true;
298
+ break;
299
+ }
300
+ catch (probeError) {
301
+ if (!(probeError instanceof Error) || !isMissingHerdrSession(probeError)) {
302
+ throw probeError;
303
+ }
304
+ lastMissingError = probeError;
305
+ await sleep(50);
306
+ }
307
+ }
308
+ if (!ready) {
309
+ throw new Error(`Herdr session ${spec.herdrSession} did not become ready`, {
310
+ cause: lastMissingError,
311
+ });
312
+ }
313
+ }
314
+ const createOutput = await runHerdrSessionCommand(runHerdrCommand, spec, [
315
+ "workspace",
316
+ "create",
317
+ "--cwd",
318
+ path.dirname(launcherPath),
319
+ "--label",
320
+ spec.sessionName,
321
+ ...Object.entries(launchEnv).flatMap(([key, value]) => ["--env", `${key}=${value}`]),
322
+ ...PER_LAUNCH_PINET_ENV_KEYS.filter((key) => !(key in launchEnv)).flatMap((key) => [
323
+ "--env",
324
+ `${key}=`,
325
+ ]),
326
+ ...SUBTREE_INHERITED_ENV_KEYS.flatMap((key) => ["--env", `${key}=`]),
327
+ ...HERDR_UNSET_ENV_KEYS.flatMap((key) => ["--env", `${key}=`]),
328
+ "--no-focus",
329
+ ]);
330
+ const createResponse = JSON.parse(createOutput);
331
+ const paneId = createResponse.result?.root_pane?.pane_id;
332
+ if (typeof paneId !== "string" || paneId.length === 0) {
333
+ throw new Error("Herdr workspace create returned no pane id");
334
+ }
335
+ spec.herdrPaneId = paneId;
336
+ try {
337
+ const processOutput = await runHerdrSessionCommand(runHerdrCommand, spec, [
338
+ "pane",
339
+ "process-info",
340
+ "--pane",
341
+ paneId,
342
+ ]);
343
+ const processResponse = JSON.parse(processOutput);
344
+ const shellPid = processResponse.result?.process_info?.shell_pid;
345
+ if (typeof shellPid !== "number" || !Number.isInteger(shellPid) || shellPid <= 0) {
346
+ throw new Error(`Herdr pane ${paneId} returned no shell PID`);
347
+ }
348
+ spec.herdrShellPid = shellPid;
349
+ onRuntimeOwned?.(spec);
350
+ }
351
+ catch (launchError) {
352
+ try {
353
+ await runHerdrSessionCommand(runHerdrCommand, spec, ["pane", "close", paneId]);
354
+ spec.herdrPaneId = null;
355
+ spec.herdrShellPid = null;
356
+ }
357
+ catch (rollbackError) {
358
+ if (rollbackError instanceof Error &&
359
+ (isMissingHerdrPane(rollbackError) || isMissingHerdrSession(rollbackError))) {
360
+ spec.herdrPaneId = null;
361
+ spec.herdrShellPid = null;
362
+ }
363
+ else {
364
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
365
+ throw new Error(`Herdr launch failed before PID capture for pane ${paneId}, and rollback failed: ${rollbackMessage}`, { cause: launchError });
366
+ }
367
+ }
368
+ throw launchError;
369
+ }
370
+ await runHerdrSessionCommand(runHerdrCommand, spec, [
371
+ "pane",
372
+ "run",
373
+ paneId,
374
+ quoteShellValue(launcherPath),
375
+ ]);
376
+ },
377
+ cleanup: async (spec) => {
378
+ const paneId = spec.herdrPaneId;
379
+ const recordedShellPid = spec.herdrShellPid;
380
+ if (!paneId && recordedShellPid === null)
381
+ return;
382
+ if (!paneId || recordedShellPid === null) {
383
+ throw new Error(`Herdr runtime ${spec.sessionName} has an incomplete pane generation`);
384
+ }
385
+ try {
386
+ await runHerdrSessionCommand(runHerdrCommand, spec, ["pane", "get", paneId]);
387
+ const processOutput = await runHerdrSessionCommand(runHerdrCommand, spec, [
388
+ "pane",
389
+ "process-info",
390
+ "--pane",
391
+ paneId,
392
+ ]);
393
+ const processResponse = JSON.parse(processOutput);
394
+ const observedShellPid = processResponse.result?.process_info?.shell_pid;
395
+ if (typeof observedShellPid !== "number" ||
396
+ !Number.isInteger(observedShellPid) ||
397
+ observedShellPid <= 0) {
398
+ throw new Error(`Herdr pane ${paneId} returned no shell PID`);
399
+ }
400
+ if (observedShellPid !== recordedShellPid) {
401
+ throw new Error(`Refusing to clean up Herdr pane ${paneId}: recorded shell PID ${recordedShellPid}, observed ${observedShellPid}`);
402
+ }
403
+ await runHerdrSessionCommand(runHerdrCommand, spec, ["pane", "close", paneId]);
404
+ }
405
+ catch (error) {
406
+ if (error instanceof Error && (isMissingHerdrPane(error) || isMissingHerdrSession(error))) {
407
+ return;
408
+ }
409
+ throw error;
410
+ }
411
+ },
412
+ };
413
+ }
414
+ export function getExtensionEntryPath() {
128
415
  const currentPath = fileURLToPath(import.meta.url);
129
416
  const extension = path.extname(currentPath) || ".js";
130
417
  return path.join(path.dirname(currentPath), `index${extension}`);
131
418
  }
419
+ /**
420
+ * Broker env var NAMES (never values) a spawned — or later WOKEN — worker
421
+ * re-exports to re-establish itself. Single source of truth shared by the
422
+ * ordinary spawn launcher and the Phase B wake path so both stay in lockstep.
423
+ */
424
+ export const SUBTREE_INHERITED_ENV_KEYS = [
425
+ "PI_CODING_AGENT_DIR",
426
+ "PI_CODING_AGENT_SESSION_DIR",
427
+ "PI_OFFLINE",
428
+ "PI_SETTINGS_PATH",
429
+ "PINET_MESH_SECRET",
430
+ "PINET_MESH_SECRET_PATH",
431
+ "PINET_HIBERNATION_RUNTIME_ACTIVATION",
432
+ "SLACK_ALLOW_ALL_WORKSPACE_USERS",
433
+ "SLACK_ALLOWED_USERS",
434
+ "SLACK_APP_TOKEN",
435
+ "SLACK_BOT_TOKEN",
436
+ ];
132
437
  function childStartupPrompt(parentAgentId) {
133
438
  return [
134
439
  `You are a Pinet subtree child supervised by ${parentAgentId}.`,
@@ -136,31 +441,47 @@ function childStartupPrompt(parentAgentId) {
136
441
  "If you are not following Pinet yet, wait for the launcher to run /pinet follow.",
137
442
  ].join(" ");
138
443
  }
444
+ /**
445
+ * Per-launch PINET identity env var NAMES. A child launch must either set
446
+ * these explicitly or have them cleared at the pane/launcher boundary, so a
447
+ * child can never inherit stale identity (broker owner, lane, tmux locator,
448
+ * parentage) from the parent process environment or a shared, long-lived
449
+ * Herdr server started by an earlier agent.
450
+ */
451
+ const PER_LAUNCH_PINET_ENV_KEYS = [
452
+ "PINET_BROKER_AGENT_ID",
453
+ "PINET_BROKER_MANAGED",
454
+ "PINET_LANE_ID",
455
+ "PINET_LAUNCH_ID",
456
+ "PINET_LAUNCH_SOURCE",
457
+ "PINET_PARENT_AGENT_ID",
458
+ "PINET_ROOT_AGENT_ID",
459
+ "PINET_SOCKET_PATH",
460
+ "PINET_SPAWNED_BY_AGENT_ID",
461
+ "PINET_SUBTREE_ROLE",
462
+ "PINET_TMUX_SESSION",
463
+ "PINET_WAKE_CORRELATION_ID",
464
+ "PINET_WAKE_FENCE_TOKEN",
465
+ "PINET_WAKE_LEASE_ID",
466
+ "PINET_WAKE_RESERVED_GENERATION",
467
+ "PINET_WAKE_RESERVATION_NONCE",
468
+ ];
469
+ const HERDR_UNSET_ENV_KEYS = ["CLAUDE_TMUX_SOCKET_DIR", "TMUX", "TMUX_PANE"];
139
470
  function buildLauncherScript(input) {
140
- const inheritedEnvKeys = [
141
- "PI_CODING_AGENT_DIR",
142
- "PI_CODING_AGENT_SESSION_DIR",
143
- "PI_OFFLINE",
144
- "PI_SETTINGS_PATH",
145
- "PINET_MESH_SECRET",
146
- "PINET_MESH_SECRET_PATH",
147
- "SLACK_APP_TOKEN",
148
- "SLACK_BOT_TOKEN",
149
- ];
150
- const inheritedExports = inheritedEnvKeys
151
- .map((key) => {
471
+ const inheritedEnv = SUBTREE_INHERITED_ENV_KEYS.map((key) => {
152
472
  const value = process.env[key];
153
- return value ? `export ${key}=${quoteShellValue(value)}` : null;
154
- })
155
- .filter((line) => Boolean(line));
473
+ return value ? `export ${key}=${quoteShellValue(value)}` : `unset ${key}`;
474
+ });
156
475
  const envExports = Object.entries(input.env).map(([key, value]) => `export ${key}=${quoteShellValue(value)}`);
157
476
  const nickname = `Subtree ${input.env.PINET_SUBTREE_ROLE ?? "Worker"} ${input.env.PINET_LAUNCH_ID ?? randomSuffix()}`;
158
477
  return [
159
478
  "#!/bin/bash",
160
479
  "set -euo pipefail",
161
480
  `cd ${quoteShellValue(input.repoPath)}`,
162
- ...inheritedExports,
481
+ ...(input.runtimeKind === "herdr" ? HERDR_UNSET_ENV_KEYS.map((key) => `unset ${key}`) : []),
482
+ ...inheritedEnv,
163
483
  ...envExports,
484
+ ...PER_LAUNCH_PINET_ENV_KEYS.filter((key) => !(key in input.env)).map((key) => `unset ${key}`),
164
485
  `export PI_NICKNAME=${quoteShellValue(nickname)}`,
165
486
  `exec pi -e ${quoteShellValue(input.extensionEntryPath)} ${quoteShellValue(input.startupPrompt)}`,
166
487
  "",
@@ -199,7 +520,47 @@ export function createSubtreeBrokerRuntime(deps) {
199
520
  let startedAt = null;
200
521
  let activePaths = null;
201
522
  let heartbeatTimer = null;
523
+ let brokerStartPromise = null;
202
524
  const spawnedWorkers = new Map();
525
+ const fencedLaunchIds = new Set();
526
+ const pendingInboxIds = new Set();
527
+ const runTmuxCommand = deps.runTmuxCommand ??
528
+ (async (args) => {
529
+ await execFileAsync("tmux", args);
530
+ });
531
+ const runHerdrCommand = deps.runHerdrCommand ??
532
+ (async (args, options) => {
533
+ if (!options.detached) {
534
+ const { stdout } = await execFileAsync("herdr", args, { env: options.env });
535
+ return stdout;
536
+ }
537
+ await new Promise((resolve, reject) => {
538
+ const child = spawn("herdr", args, {
539
+ detached: true,
540
+ env: options.env,
541
+ stdio: "ignore",
542
+ });
543
+ child.once("error", reject);
544
+ child.once("spawn", () => {
545
+ child.unref();
546
+ resolve();
547
+ });
548
+ });
549
+ return "";
550
+ });
551
+ const workerRuntimeControllers = {
552
+ tmux: createTmuxWorkerRuntimeController(runTmuxCommand),
553
+ herdr: createHerdrWorkerRuntimeController(runHerdrCommand, {
554
+ herdrSession: "pinet-workers",
555
+ herdrConfigDir: path.join(os.homedir(), ".pi", "pinet-herdr-config"),
556
+ }),
557
+ };
558
+ // agent-standards-ignore prefer-inline-single-use-helper: discriminated runtime cleanup dispatch
559
+ function cleanupWorkerRuntime(spec) {
560
+ return spec.runtimeKind === "tmux"
561
+ ? workerRuntimeControllers.tmux.cleanup(spec)
562
+ : workerRuntimeControllers.herdr.cleanup(spec);
563
+ }
203
564
  function stopHeartbeat() {
204
565
  if (!heartbeatTimer)
205
566
  return;
@@ -218,6 +579,15 @@ export function createSubtreeBrokerRuntime(deps) {
218
579
  }, HEARTBEAT_INTERVAL_MS);
219
580
  heartbeatTimer.unref?.();
220
581
  }
582
+ function fenceLaunch(broker, launchId) {
583
+ fencedLaunchIds.add(launchId);
584
+ for (const agent of broker.db.getAllAgents()) {
585
+ if (metadataString(agent.metadata, "launchId") === launchId) {
586
+ broker.server.disconnectAgentConnections(agent.id);
587
+ broker.db.unregisterAgent(agent.id);
588
+ }
589
+ }
590
+ }
221
591
  function currentChildren() {
222
592
  const broker = activeBroker;
223
593
  const agentId = selfAgentId;
@@ -241,7 +611,10 @@ export function createSubtreeBrokerRuntime(deps) {
241
611
  };
242
612
  }
243
613
  function drainSelfInbox(ctx, broker, agentId) {
244
- const entries = broker.db.getInbox(agentId).map(toFollowerInboxEntry);
614
+ const entries = broker.db
615
+ .getInbox(agentId)
616
+ .filter((item) => !pendingInboxIds.has(item.entry.id))
617
+ .map(toFollowerInboxEntry);
245
618
  if (entries.length === 0)
246
619
  return;
247
620
  const synced = syncBrokerInboxEntries(entries);
@@ -263,12 +636,46 @@ export function createSubtreeBrokerRuntime(deps) {
263
636
  if (handledControlInboxIds.size > 0) {
264
637
  broker.db.markDelivered([...handledControlInboxIds], agentId);
265
638
  }
639
+ const steeredInboxIds = [];
640
+ for (const entry of synced.steeringEntries) {
641
+ try {
642
+ if (deps.deliverSteeringMessage(formatPinetSteeringMessage(entry), ctx)) {
643
+ steeredInboxIds.push(entry.inboxId);
644
+ }
645
+ }
646
+ catch (error) {
647
+ ctx.ui.notify(`Subtree Pinet steering failed: ${deps.formatError(error)}`, "error");
648
+ }
649
+ }
650
+ if (steeredInboxIds.length > 0) {
651
+ broker.db.markDelivered(steeredInboxIds, agentId);
652
+ }
266
653
  if (synced.inboxMessages.length === 0)
267
654
  return;
268
- deps.pushInboxMessages(synced.inboxMessages);
655
+ const inboxMessages = synced.inboxMessages.map((message) => ({
656
+ ...message,
657
+ brokerInboxOrigin: "subtree",
658
+ }));
659
+ deps.pushInboxMessages(inboxMessages);
660
+ for (const message of inboxMessages) {
661
+ if (message.brokerInboxId != null)
662
+ pendingInboxIds.add(message.brokerInboxId);
663
+ }
269
664
  deps.updateBadge();
270
665
  deps.maybeDrainInboxIfIdle(ctx);
271
666
  }
667
+ function drainInbox(ctx) {
668
+ if (!activeBroker || !selfAgentId)
669
+ return;
670
+ drainSelfInbox(ctx, activeBroker, selfAgentId);
671
+ }
672
+ function markDelivered(inboxIds) {
673
+ if (!activeBroker || !selfAgentId)
674
+ return;
675
+ activeBroker.db.markDelivered(inboxIds, selfAgentId);
676
+ for (const inboxId of inboxIds)
677
+ pendingInboxIds.delete(inboxId);
678
+ }
272
679
  function readInbox(options = {}) {
273
680
  if (!activeBroker || !selfAgentId)
274
681
  return null;
@@ -294,9 +701,10 @@ export function createSubtreeBrokerRuntime(deps) {
294
701
  const targetAgent = resolveDirectAgentTarget(activeBroker.db.getAgents(), target);
295
702
  if (!targetAgent || targetAgent.id === selfAgentId)
296
703
  return null;
297
- const control = normalizeOutgoingPinetControlMessage(body, metadata);
298
- const finalBody = control?.body ?? body;
299
- const finalMetadata = control?.metadata ?? metadata;
704
+ const normalized = normalizeOutgoingPinetControlMessage(body, metadata) ??
705
+ normalizeOutgoingPinetSteeringMessage(body, metadata);
706
+ const finalBody = normalized?.body ?? body;
707
+ const finalMetadata = normalized?.metadata ?? metadata;
300
708
  const identity = deps.getAgentIdentity();
301
709
  const result = dispatchDirectAgentMessage(activeBroker.db, {
302
710
  senderAgentId: selfAgentId,
@@ -320,7 +728,7 @@ export function createSubtreeBrokerRuntime(deps) {
320
728
  return filtered.map((agent) => toSubtreeAgentRecord(broker.db, agent));
321
729
  }
322
730
  async function sendFollowCommand(sessionName, tmuxBaseArgs) {
323
- await execFileAsync("tmux", [
731
+ await runTmuxCommand([
324
732
  ...tmuxBaseArgs,
325
733
  "send-keys",
326
734
  "-t",
@@ -329,7 +737,7 @@ export function createSubtreeBrokerRuntime(deps) {
329
737
  "--",
330
738
  "/pinet follow",
331
739
  ]);
332
- await execFileAsync("tmux", [...tmuxBaseArgs, "send-keys", "-t", sessionName, "Enter"]);
740
+ await runTmuxCommand([...tmuxBaseArgs, "send-keys", "-t", sessionName, "Enter"]);
333
741
  }
334
742
  async function waitForSpawnedAgent(input) {
335
743
  const deadline = Date.now() + input.timeoutMs;
@@ -337,41 +745,84 @@ export function createSubtreeBrokerRuntime(deps) {
337
745
  while (Date.now() < deadline) {
338
746
  const agent = input.broker.db
339
747
  .getAllAgents()
340
- .find((candidate) => metadataString(candidate.metadata, "launchId") === input.launchId);
748
+ .find((candidate) => metadataString(candidate.metadata, "launchId") === input.handle.launchId);
341
749
  if (agent)
342
750
  return agent;
343
- if (Date.now() - lastFollowAttemptAt > 6_000) {
751
+ if (input.runtimeSpec.runtimeKind === "tmux" && Date.now() - lastFollowAttemptAt > 6_000) {
344
752
  lastFollowAttemptAt = Date.now();
345
- await sendFollowCommand(input.sessionName, input.tmuxBaseArgs).catch(() => {
753
+ await sendFollowCommand(input.runtimeSpec.sessionName, buildTmuxBaseArgs(input.runtimeSpec.tmuxSocketPath)).catch(() => {
346
754
  // The session may still be starting; the loop retries until timeout.
347
755
  });
348
756
  }
349
- await delay(1_000);
757
+ await sleep(Math.min(1_000, Math.max(1, deadline - Date.now())));
350
758
  }
351
- throw new Error(`subtree worker session ${input.sessionName} started but did not register within ${input.timeoutMs}ms`);
759
+ fenceLaunch(input.broker, input.handle.launchId);
760
+ throw new SubtreeSpawnRegistrationTimeoutError(input.timeoutMs, input.handle);
352
761
  }
353
762
  async function requestChildExit(agent) {
354
763
  await sendMessage(agent.id, "/exit", { subtreeLifecycle: "stop" }).catch(() => null);
355
764
  }
356
- async function killTmuxSession(sessionName, tmuxBaseArgs) {
357
- await execFileAsync("tmux", [...tmuxBaseArgs, "has-session", "-t", sessionName]).catch(() => {
358
- throw new Error("missing");
359
- });
360
- await execFileAsync("tmux", [...tmuxBaseArgs, "kill-session", "-t", sessionName]);
361
- }
362
- function childTmuxSessions(broker, agentId) {
363
- const sessions = new Set();
364
- for (const worker of spawnedWorkers.values()) {
365
- sessions.add(worker.sessionName);
765
+ // agent-standards-ignore prefer-inline-single-use-helper: restart-safe runtime cleanup resolution
766
+ function childRuntimeSpecs(broker, agentId) {
767
+ const specs = new Map();
768
+ const addSpec = (spec) => {
769
+ const key = spec.runtimeKind === "tmux"
770
+ ? `tmux:${spec.tmuxSocketPath ?? ""}:${spec.sessionName}`
771
+ : `herdr:${spec.herdrConfigDir}:${spec.herdrSession}:${spec.herdrPaneId ?? ""}:${spec.herdrShellPid ?? ""}`;
772
+ specs.set(key, spec);
773
+ };
774
+ const workers = [...spawnedWorkers.values()];
775
+ for (const worker of workers)
776
+ addSpec(worker);
777
+ const activeLaunchIds = new Set(spawnedWorkers.keys());
778
+ for (const { launchId, ...spec } of readPendingHerdrRuntimes(broker.db)) {
779
+ if (!activeLaunchIds.has(launchId))
780
+ addSpec(spec);
366
781
  }
782
+ const recordedAgentIds = new Set(workers.flatMap((worker) => (worker.agentId ? [worker.agentId] : [])));
367
783
  for (const agent of broker.db.getAllAgents()) {
368
- if (!isSubtreeChildAgent(agent, agentId))
784
+ if (!isSubtreeChildAgent(agent, agentId) || recordedAgentIds.has(agent.id))
785
+ continue;
786
+ const durableSpec = broker.db.getAgentRuntimeSpec(agent.id);
787
+ if (durableSpec?.runtimeKind === "tmux") {
788
+ addSpec({
789
+ runtimeKind: "tmux",
790
+ sessionName: durableSpec.tmuxSession,
791
+ tmuxSocketPath: durableSpec.tmuxSocket,
792
+ });
793
+ continue;
794
+ }
795
+ if (durableSpec?.runtimeKind === "herdr") {
796
+ addSpec({
797
+ runtimeKind: "herdr",
798
+ sessionName: agent.id,
799
+ herdrSession: durableSpec.herdrSession,
800
+ herdrConfigDir: durableSpec.herdrConfigDir,
801
+ herdrPaneId: durableSpec.herdrPaneId,
802
+ herdrShellPid: durableSpec.herdrShellPid,
803
+ });
369
804
  continue;
370
- const session = metadataString(agent.metadata, "tmuxSession");
371
- if (session)
372
- sessions.add(session);
805
+ }
806
+ const tmuxSession = metadataString(agent.metadata, "tmuxSession");
807
+ const metadataRuntimeKind = metadataString(agent.metadata, "runtimeKind");
808
+ const launchSource = metadataString(agent.metadata, "launchSource");
809
+ if (metadataRuntimeKind === "herdr" ||
810
+ launchSource === "broker-herdr" ||
811
+ launchSource === "subtree-broker-herdr") {
812
+ if (tmuxSession) {
813
+ console.warn(`[slack-bridge] Ignoring inherited tmuxSession metadata for Herdr child ${agent.id}; no durable Herdr runtime spec is available for cleanup.`);
814
+ }
815
+ continue;
816
+ }
817
+ if (tmuxSession) {
818
+ addSpec({
819
+ runtimeKind: "tmux",
820
+ sessionName: tmuxSession,
821
+ tmuxSocketPath: findTmuxSocketPath(),
822
+ });
823
+ }
373
824
  }
374
- return [...sessions];
825
+ return [...specs.values()];
375
826
  }
376
827
  async function stopChildren(broker, agentId) {
377
828
  const children = broker.db
@@ -379,11 +830,20 @@ export function createSubtreeBrokerRuntime(deps) {
379
830
  .filter((agent) => isSubtreeChildAgent(agent, agentId));
380
831
  await Promise.all(children.map(requestChildExit));
381
832
  if (children.length > 0) {
382
- await delay(SUBTREE_CHILD_EXIT_GRACE_MS);
833
+ await sleep(SUBTREE_CHILD_EXIT_GRACE_MS);
834
+ }
835
+ const cleanupResults = await Promise.all(childRuntimeSpecs(broker, agentId).map(async (spec) => {
836
+ try {
837
+ await cleanupWorkerRuntime(spec);
838
+ return true;
839
+ }
840
+ catch {
841
+ return false;
842
+ }
843
+ }));
844
+ if (cleanupResults.every(Boolean)) {
845
+ broker.db.deleteSetting(PENDING_HERDR_RUNTIMES_SETTING);
383
846
  }
384
- const tmuxSocketPath = findTmuxSocketPath();
385
- const tmuxBaseArgs = buildTmuxBaseArgs(tmuxSocketPath);
386
- await Promise.all(childTmuxSessions(broker, agentId).map((sessionName) => killTmuxSession(sessionName, tmuxBaseArgs).catch(() => undefined)));
387
847
  }
388
848
  async function stop(options = {}) {
389
849
  stopHeartbeat();
@@ -409,102 +869,209 @@ export function createSubtreeBrokerRuntime(deps) {
409
869
  startedAt = null;
410
870
  activePaths = null;
411
871
  spawnedWorkers.clear();
872
+ fencedLaunchIds.clear();
873
+ deps.discardQueuedInboxMessages();
874
+ pendingInboxIds.clear();
875
+ deps.updateBadge();
412
876
  }
413
877
  }
414
- async function start(ctx) {
878
+ // agent-standards-ignore prefer-inline-single-use-helper: single-flight start implementation
879
+ async function startOnce(ctx) {
415
880
  if (activeBroker)
416
881
  return getStatus();
417
882
  const stableId = deps.getCentralAgentId() ?? deps.getAgentStableId();
418
883
  const paths = buildSubtreeBrokerPaths(stableId);
419
884
  fs.mkdirSync(paths.rootDir, { recursive: true });
420
885
  const meshAuth = resolvePinetMeshAuth(deps.getSettings());
886
+ // Capture the durable, process-lifetime activation authority at broker start
887
+ // (frozen; never re-read from reloadable settings) and resolve the self id up
888
+ // front so Phase B, Seam 3 startup stranded-wake recovery can run inside
889
+ // `beforeListen` — strictly BEFORE the socket accepts any registration.
890
+ freezeHibernationActivationAuthority();
891
+ const selfId = buildSelfAgentId(stableId);
892
+ const runtimeActive = hibernationRuntimeActive();
893
+ const startupHib = resolveHibernationSettings(deps.getSettings());
421
894
  const broker = await startBroker({
422
895
  dbPath: paths.dbPath,
423
896
  socketPath: paths.socketPath,
424
897
  lockPath: paths.lockPath,
425
898
  ...(meshAuth.meshSecret ? { meshSecret: meshAuth.meshSecret } : {}),
426
899
  ...(meshAuth.meshSecretPath ? { meshSecretPath: meshAuth.meshSecretPath } : {}),
900
+ beforeListen: async ({ db }) => {
901
+ const pendingHerdrRuntimes = readPendingHerdrRuntimes(db);
902
+ for (const pending of pendingHerdrRuntimes) {
903
+ const spec = pending;
904
+ await cleanupWorkerRuntime(spec);
905
+ }
906
+ if (pendingHerdrRuntimes.length > 0) {
907
+ db.deleteSetting(PENDING_HERDR_RUNTIMES_SETTING);
908
+ }
909
+ if (runtimeActive) {
910
+ recoverStrandedWakesBeforeRegistrations(createHibernationOrchestrator({
911
+ db,
912
+ brokerInstanceId: selfId,
913
+ extensionEntryPath: getExtensionEntryPath(),
914
+ baseLaunchEnv: buildChildLaunchEnv(paths, selfId),
915
+ inheritedEnvKeys: SUBTREE_INHERITED_ENV_KEYS,
916
+ config: {
917
+ handshakeTimeoutMs: startupHib.handshakeTimeoutMs,
918
+ wakeLeaseMs: startupHib.wakeLeaseMs,
919
+ maxConcurrentWakes: startupHib.maxConcurrentWakes,
920
+ maxConcurrentWakesPerRepo: startupHib.maxConcurrentWakesPerRepo,
921
+ },
922
+ }));
923
+ }
924
+ },
427
925
  });
428
- const selfId = buildSelfAgentId(stableId);
429
- const { name, emoji } = deps.getAgentIdentity();
430
- const metadata = {
431
- ...(await deps.getAgentMetadata("broker")),
432
- subtreeBroker: true,
433
- upstreamAgentId: deps.getCentralAgentId(),
434
- subtreeSocketPath: paths.socketPath,
435
- };
436
- const selfAgent = broker.db.registerAgent(selfId, name ? `Subtree Broker ${name}` : "Subtree Broker", emoji || "🌳", process.pid, metadata, `${stableId}:subtree-broker`);
437
- broker.server.setAgentRegistrationResolver((registration) => {
438
- const role = deps.getMeshRoleFromMetadata(registration.metadata, "worker");
439
- const identity = generateAgentName(registration.stableId ?? registration.agentId, role);
440
- return {
441
- name: registration.name || identity.name,
442
- emoji: registration.emoji || identity.emoji,
443
- metadata: {
444
- ...(registration.metadata ?? {}),
445
- subtreeBrokerAgentId: selfAgent.id,
446
- subtreeRootAgentId: selfAgent.id,
447
- },
926
+ try {
927
+ const { name, emoji } = deps.getAgentIdentity();
928
+ const metadata = {
929
+ ...(await deps.getAgentMetadata("broker")),
930
+ subtreeBroker: true,
931
+ upstreamAgentId: deps.getCentralAgentId(),
932
+ subtreeSocketPath: paths.socketPath,
448
933
  };
449
- });
450
- broker.server.onAgentMessage((targetAgentId) => {
451
- if (targetAgentId !== selfAgent.id)
452
- return;
934
+ const selfAgent = broker.db.registerAgent(selfId, name ? `Subtree Broker ${name}` : "Subtree Broker", emoji || "🌳", process.pid, metadata, `${stableId}:subtree-broker`);
935
+ broker.server.setAgentRegistrationResolver((registration) => {
936
+ const launchId = metadataString(registration.metadata, "launchId");
937
+ if (launchId && fencedLaunchIds.has(launchId)) {
938
+ throw new Error("spawn launch has already been cleaned up");
939
+ }
940
+ const role = deps.getMeshRoleFromMetadata(registration.metadata, "worker");
941
+ const identity = generateAgentName(registration.stableId ?? registration.agentId, role);
942
+ const worker = launchId ? spawnedWorkers.get(launchId) : undefined;
943
+ return {
944
+ name: registration.name || identity.name,
945
+ emoji: registration.emoji || identity.emoji,
946
+ metadata: {
947
+ ...(registration.metadata ?? {}),
948
+ ...(worker
949
+ ? {
950
+ runtimeKind: worker.runtimeKind,
951
+ runtimeLocator: worker.runtimeKind === "tmux"
952
+ ? worker.sessionName
953
+ : (worker.herdrPaneId ?? worker.sessionName),
954
+ }
955
+ : {}),
956
+ subtreeBrokerAgentId: selfAgent.id,
957
+ subtreeRootAgentId: selfAgent.id,
958
+ },
959
+ };
960
+ });
961
+ broker.server.onAgentMessage((targetAgentId) => {
962
+ if (targetAgentId !== selfAgent.id)
963
+ return;
964
+ drainSelfInbox(ctx, broker, selfAgent.id);
965
+ });
966
+ activeBroker = broker;
967
+ selfAgentId = selfAgent.id;
968
+ startedAt = new Date().toISOString();
969
+ activePaths = paths;
970
+ startHeartbeat(broker, selfAgent.id);
971
+ broker.db.recoverPendingTargetedBacklog(selfAgent.id);
453
972
  drainSelfInbox(ctx, broker, selfAgent.id);
454
- });
455
- activeBroker = broker;
456
- selfAgentId = selfAgent.id;
457
- startedAt = new Date().toISOString();
458
- activePaths = paths;
459
- startHeartbeat(broker, selfAgent.id);
460
- broker.db.setSetting("pinet.subtreeBrokerParentStableId", deps.getAgentStableId());
461
- broker.db.setSetting("pinet.subtreeBrokerOwnerToken", buildPinetOwnerToken(stableId));
462
- return getStatus();
973
+ broker.db.setSetting("pinet.subtreeBrokerParentStableId", deps.getAgentStableId());
974
+ broker.db.setSetting("pinet.subtreeBrokerOwnerToken", buildPinetOwnerToken(stableId));
975
+ return getStatus();
976
+ }
977
+ catch (error) {
978
+ stopHeartbeat();
979
+ activeBroker = null;
980
+ selfAgentId = null;
981
+ startedAt = null;
982
+ activePaths = null;
983
+ deps.discardQueuedInboxMessages();
984
+ pendingInboxIds.clear();
985
+ deps.updateBadge();
986
+ await broker.stop().catch(() => undefined);
987
+ throw error;
988
+ }
989
+ }
990
+ function start(ctx) {
991
+ if (activeBroker)
992
+ return Promise.resolve(getStatus());
993
+ if (!brokerStartPromise) {
994
+ brokerStartPromise = startOnce(ctx).finally(() => {
995
+ brokerStartPromise = null;
996
+ });
997
+ }
998
+ return brokerStartPromise;
463
999
  }
464
1000
  async function spawnWorker(ctx, input) {
465
1001
  if (!input.task.trim())
466
1002
  throw new Error("spawn requires task");
467
1003
  if (!input.repo.trim())
468
1004
  throw new Error("spawn requires repo");
469
- if (!activeBroker) {
470
- await start(ctx);
471
- }
1005
+ await start(ctx);
472
1006
  if (!activeBroker || !activePaths || !selfAgentId) {
473
1007
  throw new Error("Subtree broker is not running.");
474
1008
  }
1009
+ const broker = activeBroker;
1010
+ if (input.cleanupHandle) {
1011
+ const handle = input.cleanupHandle;
1012
+ if (!activePaths || handle.socketPath !== activePaths.socketPath) {
1013
+ throw new Error("spawn cleanup handle does not belong to this subtree broker");
1014
+ }
1015
+ const worker = spawnedWorkers.get(handle.launchId);
1016
+ if (!worker) {
1017
+ throw new Error(fencedLaunchIds.has(handle.launchId)
1018
+ ? "spawn cleanup handle has already been consumed"
1019
+ : "spawn cleanup handle does not belong to this subtree broker");
1020
+ }
1021
+ if (worker.sessionName !== handle.tmuxSessionName) {
1022
+ throw new Error("spawn cleanup handle does not match its recorded tmux session");
1023
+ }
1024
+ fenceLaunch(activeBroker, handle.launchId);
1025
+ spawnedWorkers.delete(handle.launchId);
1026
+ try {
1027
+ await cleanupWorkerRuntime(worker);
1028
+ if (worker.runtimeKind === "herdr") {
1029
+ updatePendingHerdrRuntime(activeBroker.db, handle.launchId, null);
1030
+ }
1031
+ }
1032
+ catch (error) {
1033
+ spawnedWorkers.set(handle.launchId, worker);
1034
+ throw error;
1035
+ }
1036
+ }
1037
+ const socketPath = activePaths.socketPath;
475
1038
  const repoPath = resolveRepoPath(input.repo, deps.cwd);
476
1039
  const role = normalizeRole(input.role);
477
1040
  const launchId = `subtree-${Date.now().toString(36)}-${randomSuffix()}`;
478
1041
  const sessionName = buildTmuxSessionName(repoPath, role, launchId);
479
- const tmuxSocketPath = findTmuxSocketPath();
480
- const tmuxBaseArgs = buildTmuxBaseArgs(tmuxSocketPath);
481
- const monitorCommand = buildTmuxMonitorCommand(sessionName, tmuxSocketPath);
1042
+ const configuredRuntime = deps.getSettings()
1043
+ .subtreeWorkerRuntime;
1044
+ const runtimeKind = configuredRuntime === undefined ? "tmux" : configuredRuntime;
1045
+ if (runtimeKind !== "tmux" && runtimeKind !== "herdr") {
1046
+ const invalidValue = JSON.stringify(runtimeKind);
1047
+ throw new Error(`Invalid subtreeWorkerRuntime setting ${invalidValue}; valid options are "tmux" and "herdr".`);
1048
+ }
1049
+ const runtimeSpec = runtimeKind === "tmux"
1050
+ ? workerRuntimeControllers.tmux.createLaunchSpec(sessionName)
1051
+ : workerRuntimeControllers.herdr.createLaunchSpec(sessionName);
1052
+ const monitorCommand = runtimeSpec.runtimeKind === "tmux"
1053
+ ? workerRuntimeControllers.tmux.monitorCommand(runtimeSpec)
1054
+ : workerRuntimeControllers.herdr.monitorCommand(runtimeSpec);
482
1055
  const childLaunchEnv = buildChildLaunchEnv(activePaths, selfAgentId, {
483
1056
  launchId,
484
1057
  role,
1058
+ runtimeKind: runtimeSpec.runtimeKind,
485
1059
  ...(input.laneId ? { laneId: input.laneId } : {}),
486
- tmuxSession: sessionName,
1060
+ ...(runtimeSpec.runtimeKind === "tmux" ? { tmuxSession: sessionName } : {}),
487
1061
  });
488
1062
  const launchersDir = path.join(activePaths.rootDir, "launchers");
489
1063
  fs.mkdirSync(launchersDir, { recursive: true });
490
1064
  const launcherPath = path.join(launchersDir, `${sessionName}.sh`);
491
1065
  fs.writeFileSync(launcherPath, buildLauncherScript({
1066
+ runtimeKind: runtimeSpec.runtimeKind,
492
1067
  repoPath,
493
1068
  env: childLaunchEnv,
494
1069
  extensionEntryPath: getExtensionEntryPath(),
495
1070
  startupPrompt: childStartupPrompt(selfAgentId),
496
1071
  }), { mode: 0o700 });
497
- await execFileAsync("tmux", [
498
- ...tmuxBaseArgs,
499
- "new-session",
500
- "-d",
501
- "-s",
502
- sessionName,
503
- launcherPath,
504
- ]);
505
1072
  const workerRecord = {
1073
+ ...runtimeSpec,
506
1074
  launchId,
507
- sessionName,
508
1075
  repoPath,
509
1076
  role,
510
1077
  laneId: input.laneId ?? null,
@@ -512,16 +1079,101 @@ export function createSubtreeBrokerRuntime(deps) {
512
1079
  startedAt: new Date().toISOString(),
513
1080
  monitorCommand,
514
1081
  };
1082
+ const launchHandle = {
1083
+ launchId,
1084
+ tmuxSessionName: sessionName,
1085
+ socketPath,
1086
+ state: "launched_unregistered",
1087
+ runtimeKind: workerRecord.runtimeKind,
1088
+ monitorCommand: workerRecord.monitorCommand,
1089
+ };
515
1090
  spawnedWorkers.set(launchId, workerRecord);
1091
+ try {
1092
+ if (workerRecord.runtimeKind === "tmux") {
1093
+ await workerRuntimeControllers.tmux.launch(workerRecord, launcherPath, childLaunchEnv);
1094
+ }
1095
+ else {
1096
+ await workerRuntimeControllers.herdr.launch(workerRecord, launcherPath, childLaunchEnv, (spec) => updatePendingHerdrRuntime(broker.db, launchId, spec));
1097
+ }
1098
+ }
1099
+ catch (error) {
1100
+ fenceLaunch(activeBroker, launchId);
1101
+ throw new SubtreeSpawnLaunchError(error instanceof Error ? error : new Error(String(error)), launchHandle, workerRecord.runtimeKind);
1102
+ }
516
1103
  const agent = await waitForSpawnedAgent({
517
1104
  broker: activeBroker,
518
- launchId,
519
- sessionName,
520
- tmuxBaseArgs,
1105
+ handle: launchHandle,
1106
+ runtimeSpec: workerRecord,
521
1107
  timeoutMs: input.waitForRegistrationMs ?? DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS,
522
1108
  });
523
1109
  const updatedRecord = { ...workerRecord, agentId: agent.id };
524
1110
  spawnedWorkers.set(launchId, updatedRecord);
1111
+ const commonRuntimeFacts = {
1112
+ agentId: agent.id,
1113
+ stableId: agent.stableId ?? "",
1114
+ brokerOwnerId: selfAgentId,
1115
+ cwd: repoPath,
1116
+ repoRoot: repoPath,
1117
+ worktreePath: repoPath,
1118
+ extensionEntryPath: getExtensionEntryPath(),
1119
+ envAllowlist: Object.keys(childLaunchEnv),
1120
+ configFingerprint: `subtree-broker-${workerRecord.runtimeKind}`,
1121
+ expectedUser: os.userInfo().username,
1122
+ launchSource: childLaunchEnv.PINET_LAUNCH_SOURCE,
1123
+ };
1124
+ if (workerRecord.runtimeKind === "tmux") {
1125
+ if (hibernationRuntimeActive()) {
1126
+ await persistSpawnedRuntimeSpec(activeBroker.db, {
1127
+ ...commonRuntimeFacts,
1128
+ runtimeKind: "tmux",
1129
+ tmuxSocket: workerRecord.tmuxSocketPath ?? "",
1130
+ tmuxSession: workerRecord.sessionName,
1131
+ tmuxTarget: workerRecord.sessionName,
1132
+ });
1133
+ }
1134
+ }
1135
+ else {
1136
+ let persisted = false;
1137
+ let persistenceError = null;
1138
+ if (workerRecord.herdrPaneId && workerRecord.herdrShellPid !== null) {
1139
+ try {
1140
+ persisted = Boolean(await persistSpawnedRuntimeSpec(activeBroker.db, {
1141
+ ...commonRuntimeFacts,
1142
+ runtimeKind: "herdr",
1143
+ herdrSession: workerRecord.herdrSession,
1144
+ herdrConfigDir: workerRecord.herdrConfigDir,
1145
+ herdrPaneId: workerRecord.herdrPaneId,
1146
+ herdrShellPid: workerRecord.herdrShellPid,
1147
+ }));
1148
+ }
1149
+ catch (error) {
1150
+ persistenceError = error instanceof Error ? error : new Error(String(error));
1151
+ }
1152
+ }
1153
+ if (persisted) {
1154
+ updatePendingHerdrRuntime(activeBroker.db, launchId, null);
1155
+ }
1156
+ else {
1157
+ const failureMessage = `Herdr worker ${agent.id} has no durable restart-safe cleanup identity`;
1158
+ const failure = persistenceError
1159
+ ? new Error(failureMessage, { cause: persistenceError })
1160
+ : new Error(failureMessage);
1161
+ fenceLaunch(activeBroker, launchId);
1162
+ await requestChildExit(agent);
1163
+ try {
1164
+ await cleanupWorkerRuntime(workerRecord);
1165
+ updatePendingHerdrRuntime(activeBroker.db, launchId, null);
1166
+ spawnedWorkers.delete(launchId);
1167
+ activeBroker.db.unregisterAgent(agent.id);
1168
+ }
1169
+ catch (error) {
1170
+ throw new SubtreeSpawnLaunchError(new Error(`${failure.message}; cleanup also failed`, {
1171
+ cause: error instanceof Error ? error : new Error(String(error)),
1172
+ }), launchHandle, "herdr");
1173
+ }
1174
+ throw new Error(`${failure.message}; worker stopped`, { cause: failure });
1175
+ }
1176
+ }
525
1177
  const messageResult = await sendMessage(agent.id, input.task, {
526
1178
  subtreeTask: true,
527
1179
  launchId,
@@ -534,6 +1186,7 @@ export function createSubtreeBrokerRuntime(deps) {
534
1186
  return {
535
1187
  status: "started",
536
1188
  launchId,
1189
+ runtimeKind: workerRecord.runtimeKind,
537
1190
  sessionName,
538
1191
  repoPath,
539
1192
  role,
@@ -548,10 +1201,22 @@ export function createSubtreeBrokerRuntime(deps) {
548
1201
  childLaunchEnv,
549
1202
  };
550
1203
  }
1204
+ function getHibernationRuntimeControl() {
1205
+ if (!activeBroker || !selfAgentId || !activePaths)
1206
+ return null;
1207
+ return {
1208
+ db: activeBroker.db,
1209
+ brokerInstanceId: selfAgentId,
1210
+ baseLaunchEnv: buildChildLaunchEnv(activePaths, selfAgentId),
1211
+ };
1212
+ }
551
1213
  return {
552
1214
  start,
1215
+ getHibernationRuntimeControl,
553
1216
  stop,
554
1217
  getStatus,
1218
+ drainInbox,
1219
+ markDelivered,
555
1220
  readInbox,
556
1221
  sendMessage,
557
1222
  listAgents,