@pinet/slack-bridge 0.2.2 → 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 +316 -451
  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 +26 -0
  26. package/dist/broker/socket-server.js +287 -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 +341 -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 +9 -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 +10 -0
  85. package/dist/slack-tools.js +51 -64
  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
@@ -8,7 +8,7 @@ import { dispatchDirectAgentMessage } from "./agent-messaging.js";
8
8
  import { sendBrokerMessage } from "./message-send.js";
9
9
  import { assertLoopbackTcpHost } from "./raw-tcp-loopback.js";
10
10
  import { summarizePinetStableId } from "../pinet-session-formatting.js";
11
- import { RPC_PARSE_ERROR, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_INVALID_PARAMS, RPC_INTERNAL_ERROR, RPC_AUTH_REQUIRED, RPC_AGENT_NAME_CONFLICT, RPC_AGENT_STABLE_ID_CONFLICT, } from "./types.js";
11
+ import { RPC_PARSE_ERROR, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_INVALID_PARAMS, RPC_INTERNAL_ERROR, RPC_AUTH_REQUIRED, RPC_AGENT_NAME_CONFLICT, RPC_AGENT_STABLE_ID_CONFLICT, RPC_AGENT_WAKE_FENCE_REJECTED, } from "./types.js";
12
12
  export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 15_000;
13
13
  export const DEFAULT_PRUNE_INTERVAL_MS = 5_000;
14
14
  export const DEFAULT_AUTH_TIMEOUT_MS = 2_000;
@@ -79,6 +79,7 @@ export class BrokerSocketServer {
79
79
  agentStatusChangeCallback = null;
80
80
  outboundMessageAdapters = [];
81
81
  agentRegistrationResolver = null;
82
+ adminShutdownHandler = null;
82
83
  constructor(db, target, options = {}) {
83
84
  this.db = db;
84
85
  this.router = new MessageRouter(db);
@@ -199,6 +200,24 @@ export class BrokerSocketServer {
199
200
  setAgentRegistrationResolver(resolver) {
200
201
  this.agentRegistrationResolver = resolver;
201
202
  }
203
+ disconnectAgentConnections(agentId) {
204
+ for (const [socket, state] of this.connections) {
205
+ if (state.agentId !== agentId)
206
+ continue;
207
+ state.agentId = null;
208
+ socket.destroy();
209
+ }
210
+ }
211
+ /**
212
+ * Register a handler invoked when an authenticated local client requests a
213
+ * graceful broker shutdown via the `admin.shutdown` RPC (used by
214
+ * `/pinet start replace` to take over a broker whose controlling session
215
+ * was lost). When no handler is wired, the RPC reports method-not-found so
216
+ * callers treat the broker as not supporting remote shutdown.
217
+ */
218
+ setAdminShutdownHandler(handler) {
219
+ this.adminShutdownHandler = handler;
220
+ }
202
221
  setOutboundMessageAdapters(adapters) {
203
222
  this.outboundMessageAdapters = adapters;
204
223
  }
@@ -386,6 +405,8 @@ export class BrokerSocketServer {
386
405
  return this.handleScheduleCreate(req, state);
387
406
  case "status.update":
388
407
  return this.handleStatusUpdate(req, state);
408
+ case "admin.shutdown":
409
+ return this.handleAdminShutdown(req);
389
410
  case "adapter.capability":
390
411
  return await this.handleAdapterCapability(req, state);
391
412
  case "slack.proxy":
@@ -420,15 +441,147 @@ export class BrokerSocketServer {
420
441
  this.clearAuthTimer(state);
421
442
  return rpcOk(req.id, { ok: true });
422
443
  }
444
+ /**
445
+ * Enforce the accepted-generation wake fence for durable hibernation
446
+ * identities. Returns a rejection response when the fence fails; on success
447
+ * for a hibernation identity it accepts the generation and binds this
448
+ * connection to the revived agent id. Ordinary registration returns no
449
+ * rejection and leaves state untouched.
450
+ */
451
+ enforceWakeFence(req, stableId, fence) {
452
+ const { wakeLeaseId, fenceToken, reservedGeneration, reservationNonce } = fence;
453
+ const hasFence = wakeLeaseId !== undefined ||
454
+ fenceToken !== undefined ||
455
+ reservedGeneration !== undefined ||
456
+ reservationNonce !== undefined;
457
+ // A wake fence is only meaningful for a specific durable stable identity.
458
+ // Compute `hasFence` BEFORE the ordinary-registration fast path so a runtime
459
+ // cannot omit `stableId`, present its broker-issued fence, and register as a
460
+ // fresh authorized agent that skipped fence enforcement entirely (a malformed
461
+ // wake runtime could otherwise do exactly this during the wake window). A
462
+ // fence with no stableId has no identity to revive → reject fail-closed; only
463
+ // a fence-free, stableId-less registration is a legitimate ordinary one.
464
+ if (!stableId) {
465
+ if (hasFence) {
466
+ return {
467
+ rejection: rpcError(req.id, RPC_AGENT_WAKE_FENCE_REJECTED, "Wake fence presented without a stableId; no hibernated identity to revive.", { code: "WAKE_FENCE_REJECTED", reason: "fence_without_stable_id", retryable: false }),
468
+ };
469
+ }
470
+ return { rejection: null };
471
+ }
472
+ const existing = this.db.getAgentByStableId(stableId);
473
+ const lifecycleState = existing?.lifecycleState;
474
+ const durableHibernation = lifecycleState === "hibernated" || lifecycleState === "waking";
475
+ // Non-live lifecycle states that must NEVER accept a self-registration into
476
+ // their stable identity. `hibernating` is mid-teardown: the old runtime may
477
+ // still be alive and only a broker-driven wake (from `hibernated`) may revive
478
+ // it, so a fresh registration during the teardown window must not bind the
479
+ // socket. `reap-candidate` is quarantined pending manual review and
480
+ // `terminated` is a closed identity — neither may silently reconnect and
481
+ // regain broker RPC access. Fail closed regardless of any presented fence.
482
+ const registrationBlocked = lifecycleState === "hibernating" ||
483
+ lifecycleState === "reap-candidate" ||
484
+ lifecycleState === "terminated";
485
+ if (registrationBlocked) {
486
+ return {
487
+ rejection: rpcError(req.id, RPC_AGENT_WAKE_FENCE_REJECTED, `Registration into a ${lifecycleState} identity is not permitted; this stableId is not awaiting wake.`, {
488
+ code: "WAKE_FENCE_REJECTED",
489
+ reason: `state_${lifecycleState}`,
490
+ retryable: false,
491
+ }),
492
+ };
493
+ }
494
+ if (!durableHibernation) {
495
+ // Ordinary registration. A wake fence presented with no hibernation
496
+ // identity to revive is a stale/mismatched wake — reject fail-closed
497
+ // rather than silently registering a fresh row.
498
+ if (hasFence) {
499
+ // EXCEPTION — idempotent crash-recovery replay. If the durable row is now
500
+ // `live` with the generation ALREADY advanced to the reserved generation,
501
+ // and an exact acceptance receipt matches the presented single-use fence,
502
+ // this is a runtime whose acceptance committed but whose register RPC
503
+ // response was lost to a broker crash (recovery then promoted the
504
+ // accepted-but-stranded wake to `live`). Re-bind it idempotently WITHOUT
505
+ // accepting a new generation, rather than reject a legitimate,
506
+ // already-accepted runtime and strand it. Fail-closed: only an EXACT
507
+ // receipt match on a `live` identity qualifies; the receipt is cleared by
508
+ // the next wake reservation, so a stale fence cannot rebind during a fresh
509
+ // wake window. A live duplicate connection is already rejected earlier by
510
+ // `findLiveStableIdConflict`, so this never double-binds a running runtime.
511
+ if (lifecycleState === "live" &&
512
+ existing &&
513
+ wakeLeaseId !== undefined &&
514
+ fenceToken !== undefined &&
515
+ reservedGeneration !== undefined &&
516
+ reservationNonce !== undefined) {
517
+ const receipt = this.db.getAgentWakeAcceptanceReceipt(existing.id);
518
+ if (receipt &&
519
+ receipt.stableId === stableId &&
520
+ receipt.wakeLeaseId === wakeLeaseId &&
521
+ receipt.fenceToken === fenceToken &&
522
+ receipt.reservedGeneration === reservedGeneration &&
523
+ receipt.reservationNonce === reservationNonce &&
524
+ existing.runtimeGeneration === reservedGeneration) {
525
+ return { rejection: null, rebind: { agentId: existing.id } };
526
+ }
527
+ }
528
+ return {
529
+ rejection: rpcError(req.id, RPC_AGENT_WAKE_FENCE_REJECTED, "Wake fence presented but no hibernated identity is awaiting wake for this stableId.", { code: "WAKE_FENCE_REJECTED", reason: "no_hibernation_identity", retryable: false }),
530
+ };
531
+ }
532
+ return { rejection: null };
533
+ }
534
+ if (wakeLeaseId === undefined ||
535
+ fenceToken === undefined ||
536
+ reservedGeneration === undefined ||
537
+ reservationNonce === undefined) {
538
+ return {
539
+ rejection: rpcError(req.id, RPC_AGENT_WAKE_FENCE_REJECTED, "Registration into a hibernated identity requires a broker-issued wake lease, fence token, runtime generation, and reservation nonce.", { code: "WAKE_FENCE_REJECTED", reason: "missing_fence", retryable: false }),
540
+ };
541
+ }
542
+ // Preflight only: validate the fence WITHOUT advancing the generation or
543
+ // consuming the reservation. The generation is accepted in handleRegister
544
+ // *after* the agent registration commits, so a later name conflict or
545
+ // registration failure can never advance the generation (and thus falsely
546
+ // signal the orchestrator that a runtime registered) for a runtime that did
547
+ // not actually register. Registration is synchronous, so a passing
548
+ // preflight is immediately followed by acceptance with no interleaving.
549
+ const accept = {
550
+ agentId: existing.id,
551
+ wakeLeaseId,
552
+ fenceToken,
553
+ reservedGeneration,
554
+ reservationNonce,
555
+ };
556
+ const preflight = this.db.checkRuntimeGenerationAcceptable(accept);
557
+ if (!preflight.accepted) {
558
+ return {
559
+ rejection: rpcError(req.id, RPC_AGENT_WAKE_FENCE_REJECTED, `Wake fence rejected: ${preflight.reason}.`, { code: "WAKE_FENCE_REJECTED", reason: preflight.reason, retryable: false }),
560
+ };
561
+ }
562
+ return { rejection: null, revive: { agentId: existing.id, accept } };
563
+ }
423
564
  handleRegister(req, state, socket) {
424
565
  const params = req.params ?? {};
425
566
  const requestedName = typeof params.name === "string" ? params.name.trim() : "";
426
567
  const requestedEmoji = typeof params.emoji === "string" ? params.emoji : "";
427
568
  const pid = typeof params.pid === "number" ? params.pid : 0;
428
569
  const stableId = typeof params.stableId === "string" ? params.stableId : undefined;
429
- const metadata = params.metadata && typeof params.metadata === "object"
570
+ const rawMetadata = params.metadata && typeof params.metadata === "object"
430
571
  ? params.metadata
431
572
  : undefined;
573
+ const legacyTmuxSession = typeof rawMetadata?.tmuxSession === "string" ? rawMetadata.tmuxSession.trim() : "";
574
+ const launchSource = typeof rawMetadata?.launchSource === "string" ? rawMetadata.launchSource.trim() : "";
575
+ const explicitlyHerdr = rawMetadata?.runtimeKind === "herdr" ||
576
+ launchSource === "broker-herdr" ||
577
+ launchSource === "subtree-broker-herdr";
578
+ const metadata = legacyTmuxSession && !explicitlyHerdr && typeof rawMetadata?.runtimeLocator !== "string"
579
+ ? {
580
+ ...rawMetadata,
581
+ runtimeKind: "tmux",
582
+ runtimeLocator: legacyTmuxSession,
583
+ }
584
+ : rawMetadata;
432
585
  if (stableId) {
433
586
  const liveStableIdConflict = this.findLiveStableIdConflict(stableId, socket);
434
587
  if (liveStableIdConflict) {
@@ -440,7 +593,32 @@ export class BrokerSocketServer {
440
593
  });
441
594
  }
442
595
  }
443
- const candidateId = state.agentId ?? crypto.randomUUID();
596
+ // Fenced revival of a durable hibernation identity. A hibernated/waking
597
+ // agent row has no live process and may only be revived by a
598
+ // broker-initiated wake presenting its exact wake lease, fence token, and
599
+ // reserved runtime generation. Ordinary (non-wake) registration is
600
+ // unaffected; a wake fence presented against a non-hibernation identity is
601
+ // treated as stale and rejected. Fails closed: on any mismatch the socket
602
+ // is not bound and the row is not revived.
603
+ const wakeFence = this.enforceWakeFence(req, stableId, {
604
+ wakeLeaseId: typeof params.wakeLeaseId === "string" ? params.wakeLeaseId : undefined,
605
+ fenceToken: typeof params.fenceToken === "number" ? params.fenceToken : undefined,
606
+ reservedGeneration: typeof params.runtimeGeneration === "number" ? params.runtimeGeneration : undefined,
607
+ reservationNonce: typeof params.reservationNonce === "string" ? params.reservationNonce : undefined,
608
+ });
609
+ if (wakeFence.rejection)
610
+ return wakeFence.rejection;
611
+ // A fenced revival targets the existing hibernated row so registerAgent
612
+ // updates it (preserving lifecycle + generation) instead of minting a new
613
+ // one. state.agentId is bound only after full success below, so a failure
614
+ // path never leaves this connection authorized as the revived agent. An
615
+ // idempotent crash-recovery rebind likewise targets the existing (now `live`)
616
+ // row: the plain-register branch below updates its pid/metadata and binds the
617
+ // socket WITHOUT accepting a new generation (already accepted).
618
+ const candidateId = wakeFence.revive?.agentId ??
619
+ wakeFence.rebind?.agentId ??
620
+ state.agentId ??
621
+ crypto.randomUUID();
444
622
  const resolved = this.agentRegistrationResolver?.({
445
623
  agentId: candidateId,
446
624
  name: requestedName,
@@ -469,7 +647,35 @@ export class BrokerSocketServer {
469
647
  });
470
648
  }
471
649
  }
472
- const agent = this.db.registerAgent(candidateId, finalName, finalEmoji, pid, finalMetadata, stableId);
650
+ let agent;
651
+ if (wakeFence.revive) {
652
+ // Fenced revival: perform the registration mutation AND accept the reserved
653
+ // generation in ONE broker transaction. If acceptance fails (e.g. the wake
654
+ // lease expires in the sub-ms window after the preflight), the whole
655
+ // registration mutation rolls back and the socket is refused and unbound —
656
+ // the durable row is never left with a mutated pid/metadata/connectivity.
657
+ // The orchestrator's registration wait then times out and quarantines
658
+ // rather than promoting a half-revived identity to live.
659
+ const revived = this.db.registerAgentWithGenerationAcceptance({
660
+ registration: {
661
+ id: candidateId,
662
+ name: finalName,
663
+ emoji: finalEmoji,
664
+ pid,
665
+ metadata: finalMetadata,
666
+ stableId,
667
+ },
668
+ accept: wakeFence.revive.accept,
669
+ });
670
+ if (!revived.agent || !revived.acceptance.accepted) {
671
+ const reason = revived.acceptance.accepted ? "unknown" : revived.acceptance.reason;
672
+ return rpcError(req.id, RPC_AGENT_WAKE_FENCE_REJECTED, `Wake fence rejected during revival: ${reason}.`, { code: "WAKE_FENCE_REJECTED", reason, retryable: false });
673
+ }
674
+ agent = revived.agent;
675
+ }
676
+ else {
677
+ agent = this.db.registerAgent(candidateId, finalName, finalEmoji, pid, finalMetadata, stableId);
678
+ }
473
679
  this.disconnectDuplicateConnections(agent.id, socket);
474
680
  state.agentId = agent.id;
475
681
  return rpcOk(req.id, {
@@ -713,6 +919,9 @@ export class BrokerSocketServer {
713
919
  ...(typeof params.threadId === "string" ? { threadId: params.threadId } : {}),
714
920
  ...(typeof params.repo === "string" ? { repo: params.repo } : {}),
715
921
  ...(typeof params.worktreePath === "string" ? { worktreePath: params.worktreePath } : {}),
922
+ ...(typeof params.runtimeLocator === "string"
923
+ ? { runtimeLocator: params.runtimeLocator }
924
+ : {}),
716
925
  ...(typeof params.tmuxSession === "string" ? { tmuxSession: params.tmuxSession } : {}),
717
926
  ...(typeof params.since === "string" ? { since: params.since } : {}),
718
927
  ...(typeof params.until === "string" ? { until: params.until } : {}),
@@ -1076,8 +1285,37 @@ export class BrokerSocketServer {
1076
1285
  }
1077
1286
  return rpcOk(req.id, { ok: true });
1078
1287
  }
1288
+ // ─── Admin shutdown handler ───────────────────────────
1289
+ /**
1290
+ * Graceful remote shutdown. Requires authentication (enforced by the
1291
+ * generic auth gate). Responds first, then invokes the wired handler on the
1292
+ * next tick so the requester receives the acknowledgement before the broker
1293
+ * tears down its socket.
1294
+ */
1295
+ handleAdminShutdown(req) {
1296
+ const handler = this.adminShutdownHandler;
1297
+ if (!handler) {
1298
+ return rpcError(req.id, RPC_METHOD_NOT_FOUND, "This broker does not support remote shutdown.");
1299
+ }
1300
+ setImmediate(() => {
1301
+ void Promise.resolve()
1302
+ .then(() => handler())
1303
+ .catch(() => {
1304
+ /* best effort — the requester falls back to fenced termination */
1305
+ });
1306
+ });
1307
+ return rpcOk(req.id, { ok: true, shuttingDown: true });
1308
+ }
1079
1309
  // ─── Adapter capability handler ───────────────────────
1080
1310
  async handleAdapterCapability(req, state) {
1311
+ // #855: adapter.capability must be identity-bound so the broker can
1312
+ // enforce thread ownership. Unregistered callers cannot own or claim
1313
+ // threads, so they must not be able to invoke outbound-side capabilities
1314
+ // (chat.postMessage in particular) that would race a first-responder
1315
+ // claim or take over a thread already owned by another agent.
1316
+ if (!state.agentId) {
1317
+ return rpcError(req.id, RPC_INVALID_PARAMS, "Not registered");
1318
+ }
1081
1319
  const params = req.params ?? {};
1082
1320
  const adapterName = typeof params.adapter === "string"
1083
1321
  ? params.adapter.trim()
@@ -1097,6 +1335,12 @@ export class BrokerSocketServer {
1097
1335
  return await this.dispatchAdapterCapability(req.id, adapterName, capability, capabilityParams, state);
1098
1336
  }
1099
1337
  async handleLegacySlackProxy(req, state) {
1338
+ // #855: legacy slack.proxy is a compatibility wrapper over
1339
+ // adapter.capability and must enforce the same registration bar so
1340
+ // unregistered callers cannot bypass thread ownership via chat.postMessage.
1341
+ if (!state.agentId) {
1342
+ return rpcError(req.id, RPC_INVALID_PARAMS, "Not registered");
1343
+ }
1100
1344
  const params = req.params ?? {};
1101
1345
  const method = typeof params.method === "string" ? params.method.trim() : "";
1102
1346
  if (!method) {
@@ -1112,6 +1356,15 @@ export class BrokerSocketServer {
1112
1356
  if (!adapter?.invokeCapability) {
1113
1357
  return rpcError(id, RPC_METHOD_NOT_FOUND, `Adapter ${adapterName} does not implement capability ${capability}`);
1114
1358
  }
1359
+ // #855: refuse cross-owner Slack chat.postMessage before hitting Slack.
1360
+ // Without this pre-check the adapter posts first and the broker races a
1361
+ // first-responder-wins claim via effects.claimThread — letting an
1362
+ // unauthorized follower take over a thread already owned by another
1363
+ // agent simply by winning the send.
1364
+ const ownershipError = this.checkAdapterCapabilityThreadOwnership(adapterName, capability, capabilityParams, state.agentId);
1365
+ if (ownershipError) {
1366
+ return rpcError(id, RPC_INVALID_PARAMS, `${errorPrefix}: ${ownershipError}`);
1367
+ }
1115
1368
  try {
1116
1369
  const response = await adapter.invokeCapability({ capability, params: capabilityParams });
1117
1370
  this.applyAdapterCapabilityEffects(adapterName, response, state);
@@ -1122,6 +1375,36 @@ export class BrokerSocketServer {
1122
1375
  return rpcError(id, RPC_INTERNAL_ERROR, `${errorPrefix}: ${message}`);
1123
1376
  }
1124
1377
  }
1378
+ checkAdapterCapabilityThreadOwnership(adapterName, capability, capabilityParams, callerAgentId) {
1379
+ if (adapterName !== "slack")
1380
+ return null;
1381
+ if (capability !== "api.call")
1382
+ return null;
1383
+ const method = typeof capabilityParams.method === "string" ? capabilityParams.method.trim() : "";
1384
+ if (method !== "chat.postMessage")
1385
+ return null;
1386
+ const inner = capabilityParams.params &&
1387
+ typeof capabilityParams.params === "object" &&
1388
+ !Array.isArray(capabilityParams.params)
1389
+ ? capabilityParams.params
1390
+ : {};
1391
+ const threadTs = typeof inner.thread_ts === "string" ? inner.thread_ts.trim() : "";
1392
+ if (!threadTs)
1393
+ return null;
1394
+ // Defense in depth (#855): even if a future call path forgot the
1395
+ // registration guard on the handler, refuse threaded chat.postMessage
1396
+ // for unregistered callers here — they cannot own a Slack thread and
1397
+ // must not be able to post into one.
1398
+ if (!callerAgentId) {
1399
+ return `Slack thread ${threadTs}: refusing threaded chat.postMessage from an unregistered caller`;
1400
+ }
1401
+ const thread = this.db.getThread(threadTs);
1402
+ if (!thread?.ownerAgent)
1403
+ return null;
1404
+ if (thread.ownerAgent === callerAgentId)
1405
+ return null;
1406
+ return `Slack thread ${threadTs} is already owned by another agent; refusing cross-owner chat.postMessage`;
1407
+ }
1125
1408
  applyAdapterCapabilityEffects(adapterName, response, state) {
1126
1409
  if (!state.agentId)
1127
1410
  return;
@@ -8,3 +8,4 @@ export declare function isBrokerInboxIdTracked(state: BrokerDeliveryState, inbox
8
8
  export declare function queueBrokerInboxIds(state: BrokerDeliveryState, inboxIds: Iterable<number>): void;
9
9
  export declare function markBrokerInboxIdsHandled(state: BrokerDeliveryState, inboxIds: Iterable<number>): void;
10
10
  export declare function getBrokerInboxIds(messages: InboxMessage[]): number[];
11
+ export declare function getSubtreeInboxIds(messages: InboxMessage[]): number[];
@@ -21,6 +21,15 @@ export function markBrokerInboxIdsHandled(state, inboxIds) {
21
21
  }
22
22
  export function getBrokerInboxIds(messages) {
23
23
  return [
24
- ...new Set(messages.flatMap((message) => (message.brokerInboxId ? [message.brokerInboxId] : []))),
24
+ ...new Set(messages.flatMap((message) => message.brokerInboxId && message.brokerInboxOrigin !== "subtree"
25
+ ? [message.brokerInboxId]
26
+ : [])),
27
+ ];
28
+ }
29
+ export function getSubtreeInboxIds(messages) {
30
+ return [
31
+ ...new Set(messages.flatMap((message) => message.brokerInboxId && message.brokerInboxOrigin === "subtree"
32
+ ? [message.brokerInboxId]
33
+ : [])),
25
34
  ];
26
35
  }
@@ -23,7 +23,7 @@ export interface BrokerRuntimeDeps {
23
23
  getActiveSkinTheme: () => string | null;
24
24
  setActiveSkinTheme: (theme: string) => void;
25
25
  setAgentOwnerToken: (ownerToken: string) => void;
26
- getAgentMetadata: (role: "broker" | "worker") => Promise<Record<string, unknown>>;
26
+ getAgentMetadata: (role: "broker" | "worker", signal?: AbortSignal) => Promise<Record<string, unknown>>;
27
27
  applyLocalAgentIdentity: (name: string, emoji: string, personality: string | null) => void;
28
28
  buildSkinMetadata: (metadata: Record<string, unknown> | undefined, personality: string, statusVocabulary?: PinetSkinStatusVocabulary) => Record<string, unknown>;
29
29
  getMeshRoleFromMetadata: (metadata: Record<string, unknown> | undefined, fallback?: "broker" | "worker") => "broker" | "worker";
@@ -37,6 +37,7 @@ export interface BrokerRuntimeDeps {
37
37
  pushInboxMessages: (messages: InboxMessage[]) => void;
38
38
  updateBadge: () => void;
39
39
  maybeDrainInboxIfIdle: (ctx: ExtensionContext) => boolean;
40
+ deliverSteeringMessage: (text: string, ctx: ExtensionContext) => boolean;
40
41
  requestRemoteControl: (command: PinetControlCommand, ctx: ExtensionContext) => PinetRemoteControlRequestResult;
41
42
  deferControlAck: (command: PinetControlCommand, inboxId: number) => void;
42
43
  runRemoteControl: (command: PinetControlCommand, ctx: ExtensionContext) => void;
@@ -62,10 +63,21 @@ export interface BrokerRuntimeDeps {
62
63
  buildControlPlaneDashboardSnapshot: (input: Record<string, unknown>) => BrokerControlPlaneDashboardSnapshot;
63
64
  buildCurrentDashboardSnapshot: (openedAt?: string) => Promise<BrokerControlPlaneDashboardSnapshot | null>;
64
65
  createAdapterBindings: readonly PinetRuntimeAdapterFactory[];
66
+ /**
67
+ * Invoked when an authenticated local client asks this broker to shut down
68
+ * gracefully (`admin.shutdown`, used by `/pinet start replace`). The
69
+ * implementation should stop the Pinet runtime in the owning session and
70
+ * notify its operator; the session itself stays alive.
71
+ */
72
+ onAdminShutdownRequested: (ctx: ExtensionContext) => Promise<void>;
65
73
  }
66
74
  export declare function resolveConfiguredBrokerSkinTheme(settings: SlackBridgeSettings): string;
67
75
  export interface BrokerRuntime {
68
- connect: (ctx: ExtensionContext) => Promise<BrokerRuntimeConnectResult>;
76
+ connect: (ctx: ExtensionContext, signal?: AbortSignal) => Promise<BrokerRuntimeConnectResult>;
77
+ reloadAdapters: (ctx: ExtensionContext) => Promise<{
78
+ botUserId: string | null;
79
+ }>;
80
+ canReloadInPlace: () => boolean;
69
81
  disconnect: (options?: {
70
82
  releaseIdentity?: boolean;
71
83
  }) => Promise<void>;
@@ -1,5 +1,7 @@
1
- import { buildPinetOwnerToken, buildPinetSkinAssignment, DEFAULT_PINET_SKIN_THEME, normalizePinetSkinTheme, resolvePinetMeshAuth, syncBrokerInboxEntries, } from "./helpers.js";
1
+ import * as crypto from "node:crypto";
2
+ import { buildPinetOwnerToken, buildPinetSkinAssignment, DEFAULT_PINET_SKIN_THEME, formatPinetSteeringMessage, normalizePinetSkinTheme, resolvePinetMeshAuth, syncBrokerInboxEntries, } from "./helpers.js";
2
3
  import { startBroker } from "./broker/index.js";
4
+ import { readMeshSecret } from "./broker/auth.js";
3
5
  import { DEFAULT_HEARTBEAT_TIMEOUT_MS } from "./broker/socket-server.js";
4
6
  import { MessageRouter } from "./broker/router.js";
5
7
  import { DEFAULT_BROKER_MAINTENANCE_INTERVAL_MS, DEFAULT_BUSY_ASSIGNMENT_AGE_MS, runBrokerMaintenancePass, } from "./broker/maintenance.js";
@@ -14,6 +16,22 @@ function normalizeOptionalSetting(value) {
14
16
  const trimmed = value?.trim();
15
17
  return trimmed && trimmed.length > 0 ? trimmed : null;
16
18
  }
19
+ function buildBrokerInPlaceSettingsSignature(settings) {
20
+ const meshAuth = resolvePinetMeshAuth(settings);
21
+ const effectiveMeshSecret = meshAuth.meshSecret
22
+ ? meshAuth.meshSecret
23
+ : meshAuth.meshSecretPath
24
+ ? readMeshSecret(meshAuth.meshSecretPath)
25
+ : null;
26
+ const meshSecretFingerprint = effectiveMeshSecret
27
+ ? crypto.createHash("sha256").update(effectiveMeshSecret).digest("hex")
28
+ : null;
29
+ return JSON.stringify({
30
+ meshSecretFingerprint,
31
+ meshSecretPath: meshAuth.meshSecretPath ?? null,
32
+ imessageEnabled: settings.imessage?.enabled ?? false,
33
+ });
34
+ }
17
35
  export function resolveConfiguredBrokerSkinTheme(settings) {
18
36
  return normalizePinetSkinTheme(settings.skinTheme) ?? DEFAULT_PINET_SKIN_THEME;
19
37
  }
@@ -21,6 +39,8 @@ export function createBrokerRuntime(deps) {
21
39
  let activeBroker = null;
22
40
  let activeRouter = null;
23
41
  let activeSelfId = null;
42
+ let activeRuntimeAdapters = [];
43
+ let activeInPlaceSettingsSignature = null;
24
44
  let brokerHeartbeatTimer = null;
25
45
  let brokerMaintenanceTimer = null;
26
46
  let brokerScheduledWakeupTimer = null;
@@ -132,6 +152,20 @@ export function createBrokerRuntime(deps) {
132
152
  for (const command of commandsToStart) {
133
153
  deps.runRemoteControl(command, ctx);
134
154
  }
155
+ const steeredInboxIds = [];
156
+ for (const entry of synced.steeringEntries) {
157
+ try {
158
+ if (deps.deliverSteeringMessage(formatPinetSteeringMessage(entry), ctx)) {
159
+ steeredInboxIds.push(entry.inboxId);
160
+ }
161
+ }
162
+ catch (error) {
163
+ ctx.ui.notify(`Pinet steering delivery failed: ${deps.formatError(error)}`, "error");
164
+ }
165
+ }
166
+ if (steeredInboxIds.length > 0) {
167
+ db.markDelivered(steeredInboxIds, agentId);
168
+ }
135
169
  if (synced.inboxMessages.length === 0) {
136
170
  return;
137
171
  }
@@ -303,12 +337,15 @@ export function createBrokerRuntime(deps) {
303
337
  activeBroker = null;
304
338
  activeRouter = null;
305
339
  activeSelfId = null;
340
+ activeRuntimeAdapters = [];
341
+ activeInPlaceSettingsSignature = null;
306
342
  lastBrokerMaintenance = null;
307
343
  lastBrokerMaintenanceSignature = "";
308
344
  resetBrokerDeliveryState(deps.deliveryState);
309
345
  }
310
346
  return {
311
- async connect(ctx) {
347
+ async connect(ctx, signal) {
348
+ signal?.throwIfAborted();
312
349
  const settings = deps.getSettings();
313
350
  const meshAuth = resolvePinetMeshAuth(settings);
314
351
  const allowedUsers = deps.getAllowedUsers();
@@ -319,6 +356,7 @@ export function createBrokerRuntime(deps) {
319
356
  let selfId = null;
320
357
  activityLogContext = ctx;
321
358
  try {
359
+ signal?.throwIfAborted();
322
360
  broker.db.setAllowedUsers(allowedUsers);
323
361
  const router = new MessageRouter(broker.db);
324
362
  const persistedBrokerStableId = normalizeOptionalSetting(broker.db.getSetting("pinet.brokerStableId")) ??
@@ -368,7 +406,9 @@ export function createBrokerRuntime(deps) {
368
406
  role: "broker",
369
407
  seed: brokerStableId,
370
408
  });
371
- const selfAgent = broker.db.registerAgent(ctx.sessionManager.getLeafId() ?? `broker-${process.pid}`, selfAssignment.name, selfAssignment.emoji, process.pid, deps.buildSkinMetadata(await deps.getAgentMetadata("broker"), selfAssignment.personality, selfAssignment.statusVocabulary), brokerStableId);
409
+ const agentMetadata = await deps.getAgentMetadata("broker", signal);
410
+ signal?.throwIfAborted();
411
+ const selfAgent = broker.db.registerAgent(ctx.sessionManager.getLeafId() ?? `broker-${process.pid}`, selfAssignment.name, selfAssignment.emoji, process.pid, deps.buildSkinMetadata(agentMetadata, selfAssignment.personality, selfAssignment.statusVocabulary), brokerStableId);
372
412
  selfId = selfAgent.id;
373
413
  deps.applyLocalAgentIdentity(selfAgent.name, selfAgent.emoji, selfAssignment.personality);
374
414
  const brokerSelfId = selfId;
@@ -384,8 +424,12 @@ export function createBrokerRuntime(deps) {
384
424
  onInbound: (message) => {
385
425
  void deps.handleInboundMessage({ message, broker, router, selfId: brokerSelfId, ctx });
386
426
  },
427
+ signal,
387
428
  });
429
+ signal?.throwIfAborted();
388
430
  activeBroker = broker;
431
+ activeRuntimeAdapters = adapterBindings.map((binding) => binding.adapter);
432
+ activeInPlaceSettingsSignature = buildBrokerInPlaceSettingsSignature(settings);
389
433
  activeRouter = router;
390
434
  activeSelfId = brokerSelfId;
391
435
  resetBrokerDeliveryState(deps.deliveryState);
@@ -404,6 +448,7 @@ export function createBrokerRuntime(deps) {
404
448
  }
405
449
  deps.onAgentStatusChange(ctx, changedAgentId, status);
406
450
  });
451
+ broker.server.setAdminShutdownHandler(() => deps.onAdminShutdownRequested(ctx));
407
452
  startBrokerHeartbeat();
408
453
  startBrokerMaintenance(ctx);
409
454
  startBrokerScheduledWakeups(ctx);
@@ -432,6 +477,8 @@ export function createBrokerRuntime(deps) {
432
477
  activeBroker = null;
433
478
  activeRouter = null;
434
479
  activeSelfId = null;
480
+ activeRuntimeAdapters = [];
481
+ activeInPlaceSettingsSignature = null;
435
482
  lastBrokerMaintenance = null;
436
483
  lastBrokerMaintenanceSignature = "";
437
484
  resetBrokerDeliveryState(deps.deliveryState);
@@ -439,6 +486,70 @@ export function createBrokerRuntime(deps) {
439
486
  throw error;
440
487
  }
441
488
  },
489
+ async reloadAdapters(ctx) {
490
+ if (!activeBroker || !activeRouter || !activeSelfId) {
491
+ throw new Error("Pinet broker is not running.");
492
+ }
493
+ const broker = activeBroker;
494
+ const router = activeRouter;
495
+ const selfId = activeSelfId;
496
+ const bindings = await buildPinetRuntimeAdapterBindings(deps.createAdapterBindings, {
497
+ broker,
498
+ router,
499
+ selfId,
500
+ ctx,
501
+ });
502
+ const nextAdapters = bindings.map((binding) => binding.adapter);
503
+ const activeSkinTheme = resolveConfiguredBrokerSkinTheme(deps.getSettings());
504
+ const selfAssignment = buildPinetSkinAssignment({
505
+ theme: activeSkinTheme,
506
+ role: "broker",
507
+ seed: deps.getBrokerStableId(),
508
+ });
509
+ deps.setActiveSkinTheme(activeSkinTheme);
510
+ const selfMetadata = deps.buildSkinMetadata(await deps.getAgentMetadata("broker"), selfAssignment.personality, selfAssignment.statusVocabulary);
511
+ const previousRuntimeAdapters = activeRuntimeAdapters;
512
+ activeRuntimeAdapters = [];
513
+ await broker.removeAdapters(previousRuntimeAdapters);
514
+ broker.db.setAllowedUsers(deps.getAllowedUsers());
515
+ broker.db.setSetting("pinet.skinTheme", activeSkinTheme);
516
+ broker.db.updateAgentIdentity(selfId, {
517
+ name: selfAssignment.name,
518
+ emoji: selfAssignment.emoji,
519
+ metadata: selfMetadata,
520
+ });
521
+ deps.applyLocalAgentIdentity(selfAssignment.name, selfAssignment.emoji, selfAssignment.personality);
522
+ try {
523
+ const result = await connectPinetRuntimeAdapters({
524
+ broker,
525
+ bindings,
526
+ onInbound: (message) => {
527
+ void deps.handleInboundMessage({ message, broker, router, selfId, ctx });
528
+ },
529
+ });
530
+ activeRuntimeAdapters = nextAdapters;
531
+ return result;
532
+ }
533
+ catch (error) {
534
+ try {
535
+ await broker.removeAdapters(nextAdapters);
536
+ }
537
+ catch {
538
+ /* preserve the adapter connection error */
539
+ }
540
+ throw error;
541
+ }
542
+ },
543
+ canReloadInPlace() {
544
+ try {
545
+ return (activeInPlaceSettingsSignature !== null &&
546
+ activeInPlaceSettingsSignature === buildBrokerInPlaceSettingsSignature(deps.getSettings()));
547
+ }
548
+ catch (error) {
549
+ const detail = error instanceof Error ? error.message : String(error);
550
+ throw new Error(`Cannot reload Pinet broker because the mesh secret could not be read: ${detail}. The existing broker remains running; restore the secret and retry /pinet start.`);
551
+ }
552
+ },
442
553
  async disconnect(options = {}) {
443
554
  await disconnect(options);
444
555
  },