@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
@@ -18,6 +18,13 @@ export interface ActivityLogEntry {
18
18
  export interface LoggedActivityLogEntry extends ActivityLogEntry {
19
19
  timestamp: string;
20
20
  }
21
+ export type ActivityLogSlackBlock = Record<string, unknown>;
22
+ export interface ActivityLogPostMessageBody extends Record<string, unknown> {
23
+ channel: string;
24
+ text: string;
25
+ blocks: ActivityLogSlackBlock[];
26
+ thread_ts?: string;
27
+ }
21
28
  export interface SlackActivityLoggerDeps {
22
29
  getBotToken: () => string | undefined;
23
30
  getLogChannel: () => string | undefined;
@@ -25,7 +32,7 @@ export interface SlackActivityLoggerDeps {
25
32
  getAgentName: () => string;
26
33
  getAgentEmoji: () => string;
27
34
  resolveChannel: (nameOrId: string) => Promise<string>;
28
- slack: (method: string, token: string, body?: Record<string, unknown>) => Promise<SlackResult>;
35
+ slack: (method: string, token: string, body?: ActivityLogPostMessageBody) => Promise<SlackResult>;
29
36
  onError?: (error: unknown) => void;
30
37
  now?: () => Date;
31
38
  maxRecentEntries?: number;
@@ -34,10 +41,10 @@ export declare function normalizeActivityLogLevel(value: string | undefined): Ac
34
41
  export declare function shouldLogActivity(configuredLevel: ActivityLogLevel, eventLevel: ActivityLogLevel): boolean;
35
42
  export declare function redactSensitiveText(value: string): string;
36
43
  export declare function buildActivityLogText(agentName: string, agentEmoji: string, entry: LoggedActivityLogEntry): string;
37
- export declare function buildActivityLogBlocks(agentName: string, agentEmoji: string, entry: LoggedActivityLogEntry): Record<string, unknown>[];
44
+ export declare function buildActivityLogBlocks(agentName: string, agentEmoji: string, entry: LoggedActivityLogEntry): ActivityLogSlackBlock[];
38
45
  export declare function buildActivityLogThreadHeader(agentName: string, agentEmoji: string, dateKey: string): {
39
46
  text: string;
40
- blocks: Record<string, unknown>[];
47
+ blocks: ActivityLogSlackBlock[];
41
48
  };
42
49
  export declare function formatRecentActivityLogEntries(entries: ReadonlyArray<LoggedActivityLogEntry>): string;
43
50
  export declare class SlackActivityLogger {
@@ -1,9 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentCompletionRuntime } from "./agent-completion-runtime.js";
3
- import type { AgentPromptGuidance } from "./agent-prompt-guidance.js";
4
3
  import { type SlackToolPolicyRuntime, type SlackToolPolicyRuntimeDeps } from "./slack-tool-policy-runtime.js";
5
4
  export interface AgentEventRuntimeDeps extends SlackToolPolicyRuntimeDeps {
6
- beforeAgentStart: AgentPromptGuidance["beforeAgentStart"];
7
5
  onCompletionAgentEnd: AgentCompletionRuntime["onAgentEnd"];
8
6
  setDeliverTrackedSlackFollowUpMessage: (deliver: SlackToolPolicyRuntime["deliverTrackedSlackFollowUpMessage"]) => void;
9
7
  }
@@ -18,7 +18,6 @@ export function createAgentEventRuntime(deps) {
18
18
  pi.on("turn_end", slackToolPolicyRuntime.onTurnEnd);
19
19
  pi.on("agent_end", slackToolPolicyRuntime.onAgentEnd);
20
20
  pi.on("tool_call", slackToolPolicyRuntime.onToolCall);
21
- pi.on("before_agent_start", deps.beforeAgentStart);
22
21
  pi.on("agent_end", deps.onCompletionAgentEnd);
23
22
  }
24
23
  return {
@@ -1,7 +1,4 @@
1
1
  import { type BrokerPromptLoadResult } from "./broker-prompt-loader.js";
2
- export interface BeforeAgentStartEvent {
3
- systemPrompt: string;
4
- }
5
2
  export interface AgentPromptGuidanceDeps {
6
3
  getIdentityGuidelines: () => string[];
7
4
  getAgentName: () => string;
@@ -15,8 +12,6 @@ export interface AgentPromptGuidanceDeps {
15
12
  reportBrokerPromptDiagnostic?: (diagnostic: string) => void;
16
13
  }
17
14
  export interface AgentPromptGuidance {
18
- beforeAgentStart: (event: BeforeAgentStartEvent) => Promise<{
19
- systemPrompt: string;
20
- }>;
15
+ buildPromptGuidelines: () => Promise<string[]>;
21
16
  }
22
17
  export declare function createAgentPromptGuidance(deps: AgentPromptGuidanceDeps): AgentPromptGuidance;
@@ -35,12 +35,7 @@ export function createAgentPromptGuidance(deps) {
35
35
  }
36
36
  return guidelines;
37
37
  }
38
- async function beforeAgentStart(event) {
39
- return {
40
- systemPrompt: event.systemPrompt + "\n\n" + (await buildPromptGuidelines()).join("\n"),
41
- };
42
- }
43
38
  return {
44
- beforeAgentStart,
39
+ buildPromptGuidelines,
45
40
  };
46
41
  }
@@ -1,4 +1,4 @@
1
- import { type ParsedAppHomeOpened, type ParsedSlashCommand, type ParsedThreadStarted } from "../../slack-access.js";
1
+ import { type SlackSocketErrorSource, type ParsedAppHomeOpened, type ParsedSlashCommand, type ParsedThreadStarted } from "../../slack-access.js";
2
2
  import { type SlackIngressGuardSettings } from "../../helpers.js";
3
3
  import { type ReactionCommandSettings } from "../../reaction-triggers.js";
4
4
  import type { AdapterCapabilityRequest, AdapterCapabilityResult, InboundMessage, OutboundMessage, MessageAdapter } from "./types.js";
@@ -41,6 +41,9 @@ export interface SlackAdapterConfig {
41
41
  onAppHomeOpened?: (event: ParsedAppHomeOpened) => Promise<void> | void;
42
42
  /** Best-effort callback for Slack slash commands handled by the broker process. */
43
43
  onSlashCommand?: (event: ParsedSlashCommand) => Promise<string | null> | string | null;
44
+ onSocketOpen?: () => void;
45
+ onSocketReconnectScheduled?: () => void;
46
+ onSocketError?: (message: string, source: SlackSocketErrorSource) => void;
44
47
  }
45
48
  export declare const SLACK_THREAD_CACHE_MAX_SIZE = 5000;
46
49
  export declare const SLACK_THREAD_CACHE_TTL_MS: number;
@@ -69,6 +69,8 @@ export class SlackAdapter {
69
69
  appToken: this.config.appToken,
70
70
  dedup: this.processedSocketDeliveries,
71
71
  abortAndWait: () => this.slackRequests.abortAndWait(),
72
+ onOpen: () => this.config.onSocketOpen?.(),
73
+ onReconnectScheduled: () => this.config.onSocketReconnectScheduled?.(),
72
74
  onThreadStarted: (event) => this.onThreadStarted(event),
73
75
  onThreadContextChanged: (event) => this.onContextChanged(event),
74
76
  onMessage: (event) => this.onMessage(event),
@@ -77,9 +79,9 @@ export class SlackAdapter {
77
79
  onAppHomeOpened: (event) => this.onAppHomeOpened(event),
78
80
  onInteractive: (event) => this.emitInteractiveInbound(event),
79
81
  onSlashCommand: (event) => this.onSlashCommand(event),
80
- onError: (error) => {
82
+ onError: (error, source) => {
81
83
  if (!isAbortError(error)) {
82
- console.error(`[slack-adapter] Socket Mode: ${errorMsg(error)}`);
84
+ this.config.onSocketError?.(errorMsg(error), source);
83
85
  }
84
86
  },
85
87
  });
@@ -88,14 +90,19 @@ export class SlackAdapter {
88
90
  }
89
91
  async disconnect() {
90
92
  this.shuttingDown = true;
91
- await this.threadStatuses.clearAll();
92
- const socketMode = this.socketMode;
93
- this.socketMode = null;
94
- if (socketMode) {
95
- await socketMode.disconnect();
96
- return;
93
+ try {
94
+ await this.threadStatuses.clearAll();
95
+ }
96
+ finally {
97
+ const socketMode = this.socketMode;
98
+ this.socketMode = null;
99
+ if (socketMode) {
100
+ await socketMode.disconnect();
101
+ }
102
+ else {
103
+ await this.slackRequests.abortAndWait();
104
+ }
97
105
  }
98
- await this.slackRequests.abortAndWait();
99
106
  }
100
107
  onInbound(handler) {
101
108
  this.inboundHandler = handler;
@@ -34,13 +34,23 @@ export interface ScheduledWakeupInfo {
34
34
  export type { PortLeaseAcquireInput, PortLeaseInfo, PortLeaseListOptions, PortLeaseReleaseInput, PortLeaseRenewInput, PinetLaneInfo, PinetLaneListOptions, PinetLaneParticipantInfo, PinetLaneParticipantUpsertInput, PinetLaneUpsertInput, };
35
35
  export declare const DEFAULT_SOCKET_PATH: string;
36
36
  export declare const REQUEST_TIMEOUT_MS = 5000;
37
- export declare const RECONNECT_DELAY_MS = 3000;
38
37
  export declare const INITIAL_RECONNECT_DELAY_MS = 1000;
39
38
  export declare const MAX_RECONNECT_DELAY_MS = 30000;
40
39
  export declare const HEARTBEAT_INTERVAL_MS = 5000;
41
40
  export declare const HEARTBEAT_METADATA_PROVIDER_TIMEOUT_MS = 2500;
42
- /** Compute reconnect delay with exponential backoff and jitter. */
41
+ /** Compute reconnect delay with exponential backoff and jitter (±25%). */
43
42
  export declare function computeReconnectDelay(attempt: number, random?: number): number;
43
+ /**
44
+ * A single-use, broker-issued wake fence presented by a runtime that the broker
45
+ * cold-launched to revive a hibernated identity. Consumed on the first
46
+ * successful register and never replayed on reconnect.
47
+ */
48
+ interface WakeFence {
49
+ wakeLeaseId: string;
50
+ fenceToken: number;
51
+ runtimeGeneration: number;
52
+ reservationNonce: string;
53
+ }
44
54
  export interface AgentBroadcastResult {
45
55
  channel: string;
46
56
  messageIds: number[];
@@ -59,11 +69,18 @@ export interface BrokerClientAuthOptions {
59
69
  meshSecret?: string;
60
70
  meshSecretPath?: string;
61
71
  }
72
+ export interface BrokerClientTimingOptions {
73
+ /** @internal Allows tests to exercise reconnect behavior without waiting for production backoff. */
74
+ reconnectDelayMs?: (attempt: number) => number;
75
+ }
76
+ export type BrokerClientOptions = BrokerConnectOpts & BrokerClientAuthOptions & BrokerClientTimingOptions;
62
77
  export declare class BrokerClient {
63
78
  private readonly connectOpts;
64
79
  private readonly meshSecret;
65
80
  private readonly meshSecretPath;
81
+ private readonly reconnectDelayMs;
66
82
  private socket;
83
+ private connectingSocket;
67
84
  private connected;
68
85
  private shuttingDown;
69
86
  private reconnectTimer;
@@ -79,14 +96,14 @@ export declare class BrokerClient {
79
96
  private nextId;
80
97
  private readonly pending;
81
98
  private buffer;
82
- constructor(opts?: string | (BrokerConnectOpts & BrokerClientAuthOptions));
99
+ constructor(opts?: string | BrokerClientOptions);
83
100
  connect(): Promise<void>;
84
101
  disconnect(): void;
85
102
  disconnectGracefully(): Promise<void>;
86
103
  isConnected(): boolean;
87
104
  private resolveMeshSecret;
88
105
  private authenticateIfNeeded;
89
- register(name: string, emoji: string, metadata?: Record<string, unknown>, stableId?: string): Promise<{
106
+ register(name: string, emoji: string, metadata?: Record<string, unknown>, stableId?: string, wakeFence?: WakeFence): Promise<{
90
107
  agentId: string;
91
108
  name: string;
92
109
  emoji: string;
@@ -1,4 +1,5 @@
1
1
  import * as net from "node:net";
2
+ import { computeBackoffDelay, withTimeout } from "@pinet/transport-core/async";
2
3
  import { readMeshSecret } from "./auth.js";
3
4
  import { DEFAULT_SOCKET_PATH as PINET_DEFAULT_SOCKET_PATH } from "./paths.js";
4
5
  import { assertLoopbackTcpHost } from "./raw-tcp-loopback.js";
@@ -6,31 +7,17 @@ import { RPC_AGENT_NAME_CONFLICT, RPC_METHOD_NOT_FOUND } from "./types.js";
6
7
  // ─── Constants (exported for testing) ────────────────────
7
8
  export const DEFAULT_SOCKET_PATH = PINET_DEFAULT_SOCKET_PATH;
8
9
  export const REQUEST_TIMEOUT_MS = 5000;
9
- export const RECONNECT_DELAY_MS = 3000;
10
10
  export const INITIAL_RECONNECT_DELAY_MS = 1000;
11
11
  export const MAX_RECONNECT_DELAY_MS = 30000;
12
12
  export const HEARTBEAT_INTERVAL_MS = 5000;
13
13
  export const HEARTBEAT_METADATA_PROVIDER_TIMEOUT_MS = 2500;
14
- function withTimeout(promise, timeoutMs) {
15
- return new Promise((resolve, reject) => {
16
- const timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
17
- timer.unref?.();
18
- promise.then((value) => {
19
- clearTimeout(timer);
20
- resolve(value);
21
- }, (error) => {
22
- clearTimeout(timer);
23
- reject(error instanceof Error ? error : new Error(String(error)));
24
- });
25
- });
26
- }
27
- /** Compute reconnect delay with exponential backoff and jitter. */
14
+ /** Compute reconnect delay with exponential backoff and jitter (±25%). */
28
15
  export function computeReconnectDelay(attempt, random = Math.random()) {
29
- const baseDelay = INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt);
30
- const capped = Math.min(baseDelay, MAX_RECONNECT_DELAY_MS);
31
- // Add jitter: ±25%
32
- const jitter = capped * (0.75 + random * 0.5);
33
- return Math.round(jitter);
16
+ return computeBackoffDelay(attempt, {
17
+ initialMs: INITIAL_RECONNECT_DELAY_MS,
18
+ maxMs: MAX_RECONNECT_DELAY_MS,
19
+ random,
20
+ });
34
21
  }
35
22
  function createRpcRequestError(method, error) {
36
23
  const err = new Error(error.message);
@@ -78,7 +65,9 @@ export class BrokerClient {
78
65
  connectOpts;
79
66
  meshSecret;
80
67
  meshSecretPath;
68
+ reconnectDelayMs;
81
69
  socket = null;
70
+ connectingSocket = null;
82
71
  connected = false;
83
72
  shuttingDown = false;
84
73
  reconnectTimer = null;
@@ -95,6 +84,10 @@ export class BrokerClient {
95
84
  pending = new Map();
96
85
  buffer = "";
97
86
  constructor(opts) {
87
+ this.reconnectDelayMs =
88
+ typeof opts === "object" && opts !== null
89
+ ? (opts.reconnectDelayMs ?? computeReconnectDelay)
90
+ : computeReconnectDelay;
98
91
  if (opts === undefined) {
99
92
  this.connectOpts = { path: DEFAULT_SOCKET_PATH };
100
93
  this.meshSecret = null;
@@ -139,7 +132,10 @@ export class BrokerClient {
139
132
  }
140
133
  this.stopHeartbeat();
141
134
  this.rejectAllPending(new Error("Client disconnected"));
135
+ const connectingSocket = this.connectingSocket;
136
+ this.connectingSocket = null;
142
137
  try {
138
+ connectingSocket?.destroy();
143
139
  this.socket?.destroy();
144
140
  }
145
141
  catch {
@@ -198,13 +194,14 @@ export class BrokerClient {
198
194
  }
199
195
  }
200
196
  // ─── Registration ────────────────────────────────────
201
- async register(name, emoji, metadata, stableId) {
197
+ async register(name, emoji, metadata, stableId, wakeFence) {
202
198
  this.registrationSnapshot = {
203
199
  name,
204
200
  emoji,
205
201
  ...(metadata ? { metadata } : {}),
206
202
  ...(stableId ? { stableId } : {}),
207
203
  ...(name.trim().length === 0 ? { brokerAssignedIdentity: true } : {}),
204
+ ...(wakeFence ? { wakeFence } : {}),
208
205
  };
209
206
  return this.performRegister(this.registrationSnapshot);
210
207
  }
@@ -368,7 +365,12 @@ export class BrokerClient {
368
365
  }
369
366
  async searchAgentSessions(options = {}) {
370
367
  try {
371
- return (await this.request("agent.sessions.search", options));
368
+ const sessions = (await this.request("agent.sessions.search", options));
369
+ return sessions.map((session) => ({
370
+ ...session,
371
+ runtimeKind: session.runtimeKind ?? (session.tmuxSession ? "tmux" : null),
372
+ runtimeLocator: session.runtimeLocator ?? (session.runtimeKind === "herdr" ? null : session.tmuxSession),
373
+ }));
372
374
  }
373
375
  catch (err) {
374
376
  if (isRpcMethodNotFoundError(err, "agent.sessions.search")) {
@@ -480,7 +482,7 @@ export class BrokerClient {
480
482
  scheduleReconnect() {
481
483
  if (this.shuttingDown || this.reconnectTimer)
482
484
  return;
483
- const delay = computeReconnectDelay(this.reconnectAttempt);
485
+ const delay = this.reconnectDelayMs(this.reconnectAttempt);
484
486
  this.reconnectAttempt++;
485
487
  this.reconnectTimer = setTimeout(() => {
486
488
  this.reconnectTimer = null;
@@ -491,7 +493,27 @@ export class BrokerClient {
491
493
  connectSocket() {
492
494
  return new Promise((resolve, reject) => {
493
495
  const sock = net.createConnection(this.connectOpts);
496
+ this.connectingSocket = sock;
497
+ let settled = false;
498
+ const rejectConnect = (error) => {
499
+ if (settled)
500
+ return;
501
+ settled = true;
502
+ if (this.connectingSocket === sock) {
503
+ this.connectingSocket = null;
504
+ }
505
+ reject(error);
506
+ };
494
507
  sock.on("connect", () => {
508
+ if (this.shuttingDown) {
509
+ rejectConnect(new Error("Client disconnected while connecting"));
510
+ sock.destroy();
511
+ return;
512
+ }
513
+ settled = true;
514
+ if (this.connectingSocket === sock) {
515
+ this.connectingSocket = null;
516
+ }
495
517
  this.socket = sock;
496
518
  this.connected = true;
497
519
  this.buffer = "";
@@ -501,6 +523,12 @@ export class BrokerClient {
501
523
  this.onData(chunk.toString("utf-8"));
502
524
  });
503
525
  sock.on("close", () => {
526
+ if (this.connectingSocket === sock) {
527
+ this.connectingSocket = null;
528
+ }
529
+ if (!settled) {
530
+ rejectConnect(new Error("Socket closed before connection completed"));
531
+ }
504
532
  const wasConnected = this.connected;
505
533
  this.connected = false;
506
534
  this.socket = null;
@@ -512,8 +540,8 @@ export class BrokerClient {
512
540
  }
513
541
  });
514
542
  sock.on("error", (err) => {
515
- if (!this.connected) {
516
- reject(err);
543
+ if (!settled) {
544
+ rejectConnect(err);
517
545
  }
518
546
  // If already connected, the close event handles cleanup
519
547
  });
@@ -526,14 +554,26 @@ export class BrokerClient {
526
554
  pid: process.pid,
527
555
  ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}),
528
556
  ...(snapshot.stableId ? { stableId: snapshot.stableId } : {}),
557
+ ...(snapshot.wakeFence
558
+ ? {
559
+ wakeLeaseId: snapshot.wakeFence.wakeLeaseId,
560
+ fenceToken: snapshot.wakeFence.fenceToken,
561
+ runtimeGeneration: snapshot.wakeFence.runtimeGeneration,
562
+ reservationNonce: snapshot.wakeFence.reservationNonce,
563
+ }
564
+ : {}),
529
565
  }));
566
+ // Retain a reconnect snapshot but drop the single-use wake fence so a
567
+ // reconnect never replays a consumed generation reservation.
530
568
  this.registrationSnapshot = snapshot.brokerAssignedIdentity
531
569
  ? {
532
570
  ...snapshot,
571
+ wakeFence: undefined,
533
572
  ...(result.metadata ? { metadata: result.metadata } : {}),
534
573
  }
535
574
  : {
536
575
  ...snapshot,
576
+ wakeFence: undefined,
537
577
  name: result.name,
538
578
  emoji: result.emoji,
539
579
  ...(result.metadata ? { metadata: result.metadata } : {}),
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Capture the activation authority from the process-launch environment ONCE and
3
+ * freeze it for the process lifetime. Idempotent: the first call wins, and any
4
+ * later environment or settings mutation cannot change the frozen value. Returns
5
+ * the frozen authority. Call this explicitly at broker start so the freeze point
6
+ * is deterministic; reads via {@link hibernationActivationAuthorized} also
7
+ * freeze lazily on first use.
8
+ *
9
+ * Accepts `1`, `true`, `yes`, or `on` (case-insensitive, trimmed) as authorized;
10
+ * everything else — including unset/empty — is unauthorized (the production
11
+ * default, a strict no-op).
12
+ */
13
+ export declare function freezeHibernationActivationAuthority(env?: NodeJS.ProcessEnv): boolean;
14
+ /**
15
+ * The frozen live-runtime activation authority. Lazily freezes on first read
16
+ * from the process-launch environment when {@link freezeHibernationActivationAuthority}
17
+ * has not run yet, so the gate is always a process-lifetime constant. Config
18
+ * reloads and settings edits can never flip it.
19
+ */
20
+ export declare function hibernationActivationAuthorized(): boolean;
21
+ /** Test-only: clear the frozen value so a test can re-capture a chosen env. */
22
+ export declare function __resetHibernationActivationAuthorityForTest(): void;
@@ -0,0 +1,51 @@
1
+ // Durable, non-reloadable hibernation runtime-activation authority.
2
+ //
3
+ // The Phase B live process/tmux adapters must be activated ONLY by a durable
4
+ // authority captured at broker start and FROZEN for the entire process
5
+ // lifetime. This deliberately does NOT read agent-editable `SlackBridgeSettings`
6
+ // (which pi re-reads on config reload): there must be NO settings, reload, or
7
+ // other in-process path that can elevate a running broker into live-runtime
8
+ // activation. The single source of truth is an external, process-launch
9
+ // environment variable, captured exactly once.
10
+ //
11
+ // Security rationale: an attacker (or a well-meaning agent) who can edit the
12
+ // broker's settings file or trigger a settings reload must never be able to turn
13
+ // on the live runtime. Requiring an external launch-environment variable moves
14
+ // the activation decision to whoever started the broker process and freezes it
15
+ // for the lifetime of that process.
16
+ /** External process-launch env var that authorizes live-runtime activation. */
17
+ const ACTIVATION_ENV_VAR = "PINET_HIBERNATION_RUNTIME_ACTIVATION";
18
+ /** Captured once at broker start; never re-read afterwards. */
19
+ let frozen;
20
+ /**
21
+ * Capture the activation authority from the process-launch environment ONCE and
22
+ * freeze it for the process lifetime. Idempotent: the first call wins, and any
23
+ * later environment or settings mutation cannot change the frozen value. Returns
24
+ * the frozen authority. Call this explicitly at broker start so the freeze point
25
+ * is deterministic; reads via {@link hibernationActivationAuthorized} also
26
+ * freeze lazily on first use.
27
+ *
28
+ * Accepts `1`, `true`, `yes`, or `on` (case-insensitive, trimmed) as authorized;
29
+ * everything else — including unset/empty — is unauthorized (the production
30
+ * default, a strict no-op).
31
+ */
32
+ export function freezeHibernationActivationAuthority(env = process.env) {
33
+ if (frozen === undefined) {
34
+ const raw = env[ACTIVATION_ENV_VAR]?.trim().toLowerCase();
35
+ frozen = raw === "1" || raw === "true" || raw === "yes" || raw === "on";
36
+ }
37
+ return frozen;
38
+ }
39
+ /**
40
+ * The frozen live-runtime activation authority. Lazily freezes on first read
41
+ * from the process-launch environment when {@link freezeHibernationActivationAuthority}
42
+ * has not run yet, so the gate is always a process-lifetime constant. Config
43
+ * reloads and settings edits can never flip it.
44
+ */
45
+ export function hibernationActivationAuthorized() {
46
+ return freezeHibernationActivationAuthority();
47
+ }
48
+ /** Test-only: clear the frozen value so a test can re-capture a chosen env. */
49
+ export function __resetHibernationActivationAuthorityForTest() {
50
+ frozen = undefined;
51
+ }
@@ -0,0 +1,75 @@
1
+ import { HibernationOrchestrator, type HibernationOrchestratorConfig, type RuntimeLaunchContext, type StrandedWakeRecovery } from "@pinet/broker-core";
2
+ import type { AgentRuntimeSpec } from "@pinet/broker-core/types";
3
+ import type { BrokerDB } from "./schema.js";
4
+ import { resolveVcsIdentity } from "./hibernation-runtime-adapters.js";
5
+ import { type SpawnAuthoredRuntimeFacts } from "./hibernation-runtime-helpers.js";
6
+ /** Optional command runner override (git remote resolution); defaults to real `execFile`. */
7
+ type VcsRunner = Parameters<typeof resolveVcsIdentity>[1];
8
+ /**
9
+ * The DEFAULT-OFF live-runtime gate. Live process/tmux composition happens ONLY
10
+ * when the durable, process-lifetime activation authority — captured at broker
11
+ * start from the external launch environment, never agent-editable settings — is
12
+ * set. Config reloads and settings edits can NEVER flip it, so the production
13
+ * default (authority unset) keeps the broker on the `activation_pending` stub
14
+ * with zero live-runtime side effects. Operational permission (enabled / mode /
15
+ * repo allowlist) is a SEPARATE settings policy enforced by the command layer;
16
+ * this gate only decides whether the real machinery is wired in at all.
17
+ */
18
+ export declare function hibernationRuntimeActive(): boolean;
19
+ export interface HibernationRuntimeDeps {
20
+ db: BrokerDB;
21
+ /** Broker owner id recorded as `brokerOwnerId`/`brokerInstanceId` on managed rows. */
22
+ brokerInstanceId: string;
23
+ /** slack-bridge extension entry a woken runtime loads (`pi -e <path>`). */
24
+ extensionEntryPath: string;
25
+ /** Base PINET_* env re-establishing the mesh connection for a woken worker. */
26
+ baseLaunchEnv: Record<string, string>;
27
+ /** Broker env var NAMES (never values) re-exported into a woken runtime when present. */
28
+ inheritedEnvKeys: string[];
29
+ config?: Partial<HibernationOrchestratorConfig>;
30
+ /**
31
+ * Confirm the woken runtime registered and its reserved generation was accepted.
32
+ * PRODUCTION leaves this undefined: the orchestrator's default polls the broker
33
+ * DB until the socket server's fenced-registration handler accepts the woken
34
+ * worker's generation. Injectable so an isolated E2E can key acceptance off the
35
+ * REAL respawned process coming back alive instead of a live socket handshake.
36
+ */
37
+ awaitRuntimeRegistration?: (ctx: RuntimeLaunchContext) => Promise<boolean>;
38
+ }
39
+ /**
40
+ * Seam 1 — compose the real orchestrator-backed executor.
41
+ *
42
+ * The process and tmux controllers are constructed INDEPENDENTLY: the immutable
43
+ * launch generation rides inside the attempt handle `respawnRuntime` returns, so
44
+ * attempt-scoped stop/liveness bind to the exact launched process with no shared
45
+ * registry. The returned {@link HibernationOrchestrator} structurally satisfies
46
+ * `HibernateCommandExecutor & WakeCommandExecutor`, so it drops directly into the
47
+ * command executor slot in place of the `activation_pending` stub — but only the
48
+ * caller's {@link hibernationRuntimeActive} gate decides when to use it.
49
+ */
50
+ export declare function createHibernationOrchestrator(deps: HibernationRuntimeDeps): HibernationOrchestrator;
51
+ /** Broker-authored spawn facts minus the VCS identity (derived here from the git remote). */
52
+ type SpawnRuntimeSpecFactsWithoutVcs<T extends SpawnAuthoredRuntimeFacts> = T extends SpawnAuthoredRuntimeFacts ? Omit<T, "vcsIdentity"> : never;
53
+ export type SpawnRuntimeSpecFacts = SpawnRuntimeSpecFactsWithoutVcs<SpawnAuthoredRuntimeFacts>;
54
+ /**
55
+ * Seam 2 — persist a durable, broker-authored runtime spec at worker spawn.
56
+ *
57
+ * The canonical `owner/repo` VCS identity (the ONLY value the repo allowlist
58
+ * authorizes against) is resolved HERE from the runtime's actual git `origin`
59
+ * remote — never from filesystem directory names. Fails closed: an unresolvable
60
+ * remote records `vcsIdentity: null` (the fail-closed gate then refuses), and a
61
+ * spec that could not be safely hibernated/woken (missing session/tmux/repo
62
+ * locators) is never written. Returns the persisted spec, or null when the facts
63
+ * do not compose a durable spec.
64
+ */
65
+ export declare function persistSpawnedRuntimeSpec(db: BrokerDB, facts: SpawnRuntimeSpecFacts, runner?: VcsRunner): Promise<AgentRuntimeSpec | null>;
66
+ /**
67
+ * Seam 3 — reconcile crash-stranded wake state at broker startup.
68
+ *
69
+ * Must run BEFORE the broker socket accepts new registrations so a stranded
70
+ * `waking`/`dispatching` row is completed, quarantined, or requeued deterministically
71
+ * rather than racing an incoming (possibly duplicate) wake registration. This is a
72
+ * synchronous DB reconciliation; it launches nothing.
73
+ */
74
+ export declare function recoverStrandedWakesBeforeRegistrations(orchestrator: HibernationOrchestrator): StrandedWakeRecovery[];
75
+ export {};
@@ -0,0 +1,95 @@
1
+ // Phase B live-runtime activation composition.
2
+ //
3
+ // Wires the reviewed, approved hibernation RUNTIME ADAPTERS (real process/tmux
4
+ // controllers + spawn-authored runtime-spec builder + git-remote VCS identity)
5
+ // into the broker's three live seams, all behind the DEFAULT-OFF durable,
6
+ // non-reloadable activation authority (see hibernation-activation-authority.ts;
7
+ // `hibernationRuntimeActive()`):
8
+ //
9
+ // 1. createHibernationOrchestrator — the real orchestrator-backed executor
10
+ // that replaces the `activation_pending` stub ONLY under the gate.
11
+ // 2. persistSpawnedRuntimeSpec — records a durable, broker-authored
12
+ // runtime spec (canonical git-remote VCS identity) at worker spawn.
13
+ // 3. recoverStrandedWakesBeforeRegistrations — reconciles crash-stranded wake
14
+ // rows at broker startup, before the socket accepts new registrations.
15
+ //
16
+ // Keeping the composition here (rather than inline in index.ts / the subtree
17
+ // runtime) makes every seam unit- and E2E-testable against REAL dependencies
18
+ // (real BrokerDB, real git, real tmux/pi) with no production side effects.
19
+ import { HibernationOrchestrator, } from "@pinet/broker-core";
20
+ import { createHibernationProcessController, createHibernationTmuxController, resolveVcsIdentity, } from "./hibernation-runtime-adapters.js";
21
+ import { buildRuntimeSpecInput, } from "./hibernation-runtime-helpers.js";
22
+ import { hibernationActivationAuthorized } from "./hibernation-activation-authority.js";
23
+ /**
24
+ * The DEFAULT-OFF live-runtime gate. Live process/tmux composition happens ONLY
25
+ * when the durable, process-lifetime activation authority — captured at broker
26
+ * start from the external launch environment, never agent-editable settings — is
27
+ * set. Config reloads and settings edits can NEVER flip it, so the production
28
+ * default (authority unset) keeps the broker on the `activation_pending` stub
29
+ * with zero live-runtime side effects. Operational permission (enabled / mode /
30
+ * repo allowlist) is a SEPARATE settings policy enforced by the command layer;
31
+ * this gate only decides whether the real machinery is wired in at all.
32
+ */
33
+ export function hibernationRuntimeActive() {
34
+ return hibernationActivationAuthorized();
35
+ }
36
+ /**
37
+ * Seam 1 — compose the real orchestrator-backed executor.
38
+ *
39
+ * The process and tmux controllers are constructed INDEPENDENTLY: the immutable
40
+ * launch generation rides inside the attempt handle `respawnRuntime` returns, so
41
+ * attempt-scoped stop/liveness bind to the exact launched process with no shared
42
+ * registry. The returned {@link HibernationOrchestrator} structurally satisfies
43
+ * `HibernateCommandExecutor & WakeCommandExecutor`, so it drops directly into the
44
+ * command executor slot in place of the `activation_pending` stub — but only the
45
+ * caller's {@link hibernationRuntimeActive} gate decides when to use it.
46
+ */
47
+ export function createHibernationOrchestrator(deps) {
48
+ const process = createHibernationProcessController({
49
+ pendingInboxCount: (agentId) => deps.db.getUnreadInboxCount(agentId),
50
+ });
51
+ const tmux = createHibernationTmuxController({
52
+ extensionEntryPath: deps.extensionEntryPath,
53
+ baseLaunchEnv: deps.baseLaunchEnv,
54
+ inheritedEnvKeys: deps.inheritedEnvKeys,
55
+ });
56
+ return new HibernationOrchestrator({
57
+ db: deps.db,
58
+ process,
59
+ tmux,
60
+ brokerInstanceId: deps.brokerInstanceId,
61
+ ...(deps.config ? { config: deps.config } : {}),
62
+ ...(deps.awaitRuntimeRegistration
63
+ ? { awaitRuntimeRegistration: deps.awaitRuntimeRegistration }
64
+ : {}),
65
+ });
66
+ }
67
+ /**
68
+ * Seam 2 — persist a durable, broker-authored runtime spec at worker spawn.
69
+ *
70
+ * The canonical `owner/repo` VCS identity (the ONLY value the repo allowlist
71
+ * authorizes against) is resolved HERE from the runtime's actual git `origin`
72
+ * remote — never from filesystem directory names. Fails closed: an unresolvable
73
+ * remote records `vcsIdentity: null` (the fail-closed gate then refuses), and a
74
+ * spec that could not be safely hibernated/woken (missing session/tmux/repo
75
+ * locators) is never written. Returns the persisted spec, or null when the facts
76
+ * do not compose a durable spec.
77
+ */
78
+ export async function persistSpawnedRuntimeSpec(db, facts, runner) {
79
+ const vcsIdentity = await resolveVcsIdentity(facts.repoRoot, runner);
80
+ const input = buildRuntimeSpecInput({ ...facts, vcsIdentity });
81
+ if (!input)
82
+ return null;
83
+ return db.upsertAgentRuntimeSpec(input);
84
+ }
85
+ /**
86
+ * Seam 3 — reconcile crash-stranded wake state at broker startup.
87
+ *
88
+ * Must run BEFORE the broker socket accepts new registrations so a stranded
89
+ * `waking`/`dispatching` row is completed, quarantined, or requeued deterministically
90
+ * rather than racing an incoming (possibly duplicate) wake registration. This is a
91
+ * synchronous DB reconciliation; it launches nothing.
92
+ */
93
+ export function recoverStrandedWakesBeforeRegistrations(orchestrator) {
94
+ return orchestrator.recoverStrandedWakes();
95
+ }