@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
package/README.md CHANGED
@@ -263,6 +263,7 @@ Main commands:
263
263
  Coordinator commands:
264
264
 
265
265
  - `/pinet start` or `/pinet broker` - become the broker
266
+ - `/pinet start replace` - take over a stale or stranded broker (graceful shutdown first, then a fenced SIGTERM fallback)
266
267
  - `/pinet follow` - become a follower
267
268
  - `/pinet unfollow` - disconnect from broker
268
269
  - `/pinet reload <agent>` - ask another agent to reload
@@ -380,6 +381,30 @@ If Pinet does not respond:
380
381
  4. Look in the log channel for errors
381
382
  5. Try `/pinet status` to check if Pinet is running
382
383
 
384
+ ### Stranded broker (lock held, controlling session lost)
385
+
386
+ If `/pinet start` reports that another broker is already running but you no
387
+ longer have that broker's Pi session (crash, laptop restart, stalled process):
388
+
389
+ 1. Run `/pinet status` from any session — it reports machine-wide broker lock
390
+ ownership and socket health even while disconnected.
391
+ 2. If the broker is healthy and you just want to participate, run
392
+ `/pinet follow`.
393
+ 3. If the broker is stranded (or you need the broker in this session), run
394
+ `/pinet start replace`. It asks the current broker to shut down gracefully
395
+ over the socket, falls back to a verified SIGTERM against the recorded lock
396
+ owner, and never escalates to SIGKILL.
397
+
398
+ Two cases intentionally refuse automatic termination:
399
+
400
+ - **Legacy locks** (written by older builds, PID-only): there is no recorded
401
+ process start identity, so a SIGTERM could hit an unrelated process that
402
+ reused the PID. Inspect the process manually (`ps -p <pid>`), terminate it
403
+ yourself if it is truly the stranded broker, then run `/pinet start`.
404
+ - **Rejected shutdown**: a broker that responds but rejects the shutdown
405
+ request (usually a mesh secret mismatch) is alive, not stranded. Fix the
406
+ mesh secret configuration or stop that broker from its own session.
407
+
383
408
  ### Stalled agents
384
409
 
385
410
  If work gets stuck:
@@ -471,6 +496,6 @@ Prevent all modifications:
471
496
 
472
497
  ## Support
473
498
 
474
- - [GitHub repository](https://github.com/gugu91/extensions)
499
+ - [GitHub repository](https://github.com/gugu91/pinet)
475
500
  - [Architecture documentation](../plans/)
476
501
  - Check the log channel in Slack for runtime issues
@@ -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 {};