@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
@@ -0,0 +1,473 @@
1
+ // Real hibernation runtime adapters: the live `HibernationProcessController` and
2
+ // `HibernationTmuxController` the broker injects into `HibernationOrchestrator`.
3
+ //
4
+ // Design (proven feasible on disposable tmux/pi workers):
5
+ // • A broker-managed worker runs `pi` as the foreground process of a tmux pane
6
+ // whose session was launched with `remain-on-exit on`. The pane's `pane_pid`
7
+ // IS the Pi runtime pid; its session `.jsonl` path is recorded in the runtime
8
+ // spec's `sessionResumeRef` (`session:<path>`).
9
+ // • Hibernate = stop the Pi pid (TERM then bounded KILL). Because the pane has
10
+ // `remain-on-exit on`, the tmux session survives (operator-attachable) and is
11
+ // NEVER destroyed here; the pane transitions to `pane_dead=1`.
12
+ // • Wake = respawn a launcher into the recorded pane ONLY when that exact pane
13
+ // is present and explicitly dead, exporting the single-use wake fence and
14
+ // resuming the exact session via `pi --session <path>`. The woken Pi
15
+ // re-registers under the SAME stable id and presents the fence for atomic
16
+ // generation acceptance.
17
+ //
18
+ // PID-reuse / signal safety (independent-review P1): a numeric pid is NEVER
19
+ // signalled on its own. Every TERM/KILL/liveness action is bound to a process
20
+ // GENERATION and re-verified immediately before each signal. A generation is the
21
+ // tuple (pane-authoritative `pane_pid`, `pane_dead=0`, and the OS process START
22
+ // TIME from `ps -o lstart=`). If the pane no longer hosts that exact live
23
+ // generation — the process exited, the pane died, or the pid was reused by a
24
+ // process with a different start time — the action is abandoned as "already gone"
25
+ // rather than risking a signal to an unrelated process. Start time is used (not
26
+ // the command line) because the wake launcher `exec`s Pi over its own shell: that
27
+ // preserves the pid AND the start time but REWRITES the command line, so a
28
+ // command-line token would spuriously drift across the exec while start time stays
29
+ // stable and correctly pins the process instance.
30
+ //
31
+ // Honest limits (independent-review P2 hardening): `ps -o lstart=` is only
32
+ // second-resolution and the verify→signal step is not atomic, so a residual TOCTOU
33
+ // window remains — a same-user process that reused the pid WITHIN the same wall
34
+ // second could compare equal. No higher-resolution start token is portably
35
+ // available via `ps` across macOS/Linux, so this is strong defense-in-depth that
36
+ // makes cross-second reuse safe, NOT an absolute guarantee; the same-second window
37
+ // is an accepted, documented residual rather than a claimed "never".
38
+ //
39
+ // The wake attempt's generation travels INSIDE the `RuntimeAttemptHandle` the
40
+ // controller returns (see {@link LaunchedAttemptHandle}); the orchestrator
41
+ // round-trips that handle opaquely and hands it back to the attempt-scoped
42
+ // stop/liveness probes. There is therefore NO shared mutable registry between the
43
+ // two controllers to mis-compose — the generation binding is carried by the
44
+ // handle itself, so independent construction of the two controllers (as the live
45
+ // wiring does) is inherently correct and a failed/timed-out wake is always
46
+ // cleanable by the exact process it launched.
47
+ //
48
+ // All process/tmux/ps/git interaction is funnelled through an injectable
49
+ // `CommandRunner` + signal/liveness hooks so the security- and correctness-
50
+ // critical control flow is unit-testable without spawning real processes.
51
+ import { execFile } from "node:child_process";
52
+ import * as fs from "node:fs";
53
+ import * as os from "node:os";
54
+ import * as path from "node:path";
55
+ import { buildResumeLauncherScript, buildWakeFenceEnv, deriveVcsIdentity, parseRssBytesFromPs, resumePathFromSessionRef, } from "./hibernation-runtime-helpers.js";
56
+ /** Real `CommandRunner` over `child_process.execFile` that never throws on a
57
+ * non-zero exit — it resolves the captured exit code so callers branch on it. */
58
+ export function createExecFileRunner() {
59
+ return {
60
+ run(file, args) {
61
+ return new Promise((resolve) => {
62
+ execFile(file, args, { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
63
+ const rawCode = error?.code;
64
+ const code = typeof rawCode === "number" ? rawCode : error ? 1 : 0;
65
+ resolve({ stdout: stdout ?? "", stderr: stderr ?? "", code });
66
+ });
67
+ });
68
+ },
69
+ };
70
+ }
71
+ /**
72
+ * Boundary read of the launch generation carried by a handle. The handle arrives
73
+ * typed as the public broker-core shape; if it was minted by our
74
+ * `respawnRuntime` it carries a well-formed {@link AttemptGeneration}, otherwise
75
+ * the field is absent and we return null (probes then fail closed).
76
+ */
77
+ function launchedGenerationOf(handle) {
78
+ return handle.generation ?? null;
79
+ }
80
+ function tmuxSocketArgs(socket) {
81
+ return socket ? ["-S", socket] : [];
82
+ }
83
+ // agent-standards-ignore prefer-inline-single-use-helper: centralizes default
84
+ // resolution for the ResolvedProcessDeps bundle shared by the controller and the
85
+ // module-level generation-termination helper.
86
+ function resolveProcessDeps(deps) {
87
+ return {
88
+ runner: deps.runner ?? createExecFileRunner(),
89
+ sessionByteSize: deps.sessionByteSize ??
90
+ ((p) => {
91
+ try {
92
+ return fs.statSync(p).size;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }),
98
+ processAlive: deps.processAlive ??
99
+ ((pid) => {
100
+ try {
101
+ process.kill(pid, 0);
102
+ return true;
103
+ }
104
+ catch (error) {
105
+ // EPERM means the process exists but we may not signal it — still "alive".
106
+ return error.code === "EPERM";
107
+ }
108
+ }),
109
+ sendSignal: deps.sendSignal ?? ((pid, signal) => process.kill(pid, signal)),
110
+ now: deps.now ?? (() => Date.now()),
111
+ sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
112
+ pendingInboxCount: deps.pendingInboxCount ?? (() => 0),
113
+ stopGraceMs: deps.stopGraceMs ?? 5_000,
114
+ pollMs: deps.pollMs ?? 100,
115
+ };
116
+ }
117
+ async function paneField(runner, addr, field) {
118
+ const result = await runner.run("tmux", [
119
+ ...tmuxSocketArgs(addr.tmuxSocket),
120
+ "display-message",
121
+ "-p",
122
+ "-t",
123
+ addr.tmuxTarget,
124
+ `#{${field}}`,
125
+ ]);
126
+ if (result.code !== 0)
127
+ return null;
128
+ const value = result.stdout.trim();
129
+ return value.length > 0 ? value : null;
130
+ }
131
+ async function panePid(runner, addr) {
132
+ const raw = await paneField(runner, addr, "pane_pid");
133
+ const pid = raw ? Number.parseInt(raw, 10) : Number.NaN;
134
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
135
+ }
136
+ async function rssBytesOf(runner, pid) {
137
+ const result = await runner.run("ps", ["-o", "rss=", "-p", String(pid)]);
138
+ if (result.code !== 0)
139
+ return null;
140
+ return parseRssBytesFromPs(result.stdout);
141
+ }
142
+ /**
143
+ * The OS generation token for a pid: its process START TIME (`ps -o lstart=`),
144
+ * normalized. Two processes sharing a pid but not this start time are DIFFERENT
145
+ * process instances (cross-second reuse), so this pins the generation against the
146
+ * common pid-reuse case. The command line is deliberately NOT included: the wake
147
+ * launcher `exec`s Pi over its shell, rewriting the command line while preserving
148
+ * the pid and start time, so a command token would drift spuriously across that
149
+ * exec. Returns null when the pid is gone (no ps row). See the "Honest limits"
150
+ * header note re: the residual same-second window.
151
+ */
152
+ async function readGenerationToken(runner, pid) {
153
+ const started = await runner.run("ps", ["-o", "lstart=", "-p", String(pid)]);
154
+ if (started.code !== 0)
155
+ return null;
156
+ const startedAt = started.stdout.trim().replace(/\s+/g, " ");
157
+ return startedAt.length > 0 ? startedAt : null;
158
+ }
159
+ /**
160
+ * Read the pane's current process generation: its foreground pid, whether the
161
+ * pane is dead (only an explicit `pane_dead=0` is live; null/"1" ⇒ dead/gone),
162
+ * and the pid's generation token. `generationToken` is null when the pane has no
163
+ * live pid.
164
+ */
165
+ async function readPaneGeneration(runner, addr) {
166
+ const pid = await panePid(runner, addr);
167
+ const deadRaw = await paneField(runner, addr, "pane_dead");
168
+ const dead = deadRaw !== "0";
169
+ const generationToken = pid != null ? await readGenerationToken(runner, pid) : null;
170
+ return { pid, dead, generationToken };
171
+ }
172
+ /**
173
+ * True only if the pane provably still hosts the EXACT expected live generation:
174
+ * the pane's foreground pid equals the expected pid, the pane is not dead, the
175
+ * pid is a live process, and its current generation token matches the expected
176
+ * one (defeating pid reuse). Any drift ⇒ false (the expected process is gone /
177
+ * not ours), so a signal is never sent to a reused or replaced pid.
178
+ */
179
+ async function paneHostsGeneration(runner, addr, expected, processAlive) {
180
+ const current = await readPaneGeneration(runner, addr);
181
+ return (current.pid === expected.pid &&
182
+ !current.dead &&
183
+ current.generationToken != null &&
184
+ current.generationToken === expected.generationToken &&
185
+ processAlive(expected.pid));
186
+ }
187
+ /**
188
+ * TERM then bounded KILL a process addressed by its pane + generation. The pane
189
+ * is re-verified to still host the exact expected live generation immediately
190
+ * before EVERY signal; the moment it no longer does (exit, pane death, or pid
191
+ * reuse with a different generation token) the process is treated as confirmed
192
+ * gone and no further signal is sent. Shared by pre-hibernation stop and
193
+ * failed-wake attempt cleanup. Resolves whether the generation is confirmed gone.
194
+ */
195
+ async function terminateGeneration(addr, expected, deps) {
196
+ const stillOurs = () => paneHostsGeneration(deps.runner, addr, expected, deps.processAlive);
197
+ if (!(await stillOurs()))
198
+ return true;
199
+ try {
200
+ deps.sendSignal(expected.pid, "SIGTERM");
201
+ }
202
+ catch {
203
+ // Raced to exit between the verification and the signal.
204
+ return !(await stillOurs());
205
+ }
206
+ const deadline = deps.now() + deps.stopGraceMs;
207
+ while (deps.now() < deadline) {
208
+ await deps.sleep(deps.pollMs);
209
+ if (!(await stillOurs()))
210
+ return true;
211
+ }
212
+ if (!(await stillOurs()))
213
+ return true;
214
+ try {
215
+ deps.sendSignal(expected.pid, "SIGKILL");
216
+ }
217
+ catch {
218
+ // Raced to exit during escalation.
219
+ }
220
+ await deps.sleep(deps.pollMs);
221
+ return !(await stillOurs());
222
+ }
223
+ export function createHibernationProcessController(deps = {}) {
224
+ const d = resolveProcessDeps(deps);
225
+ async function runtimeAlive(spec) {
226
+ const addr = { tmuxSocket: spec.tmuxSocket, tmuxTarget: spec.tmuxTarget };
227
+ const current = await readPaneGeneration(d.runner, addr);
228
+ const alive = current.pid != null &&
229
+ !current.dead &&
230
+ current.generationToken != null &&
231
+ d.processAlive(current.pid);
232
+ return {
233
+ alive,
234
+ generation: alive && current.pid != null && current.generationToken != null
235
+ ? { pid: current.pid, generationToken: current.generationToken }
236
+ : null,
237
+ };
238
+ }
239
+ return {
240
+ async requestCheckpoint(spec) {
241
+ // Contract (narrowed, evidence-backed): a `hibernateSafe` result asserts
242
+ // the RESUMABILITY PRECONDITION — the recorded pane still hosts a live
243
+ // process generation (pane-authoritative pid, `pane_dead=0`, live pid with
244
+ // a readable generation token) AND the session `.jsonl` exists and is
245
+ // NON-EMPTY on disk. It does NOT perform a cooperative in-flight flush/ack
246
+ // handshake with Pi (which would require Pi-side support and is future
247
+ // work); the orchestrator additionally refuses to hibernate while any inbox
248
+ // work is pending/arriving, so a mid-turn runtime is not silently discarded.
249
+ const { alive, generation } = await runtimeAlive(spec);
250
+ const rssBytes = generation != null ? await rssBytesOf(d.runner, generation.pid) : null;
251
+ const pendingInboxCount = d.pendingInboxCount(spec.agentId);
252
+ const base = { sessionResumeRef: spec.sessionResumeRef, pendingInboxCount, rssBytes };
253
+ if (!alive) {
254
+ return { hibernateSafe: false, reason: "runtime_not_alive", ...base };
255
+ }
256
+ const resumePath = resumePathFromSessionRef(spec.sessionResumeRef);
257
+ const sessionBytes = resumePath != null ? d.sessionByteSize(resumePath) : null;
258
+ if (sessionBytes == null) {
259
+ return { hibernateSafe: false, reason: "session_unresumable", ...base };
260
+ }
261
+ if (sessionBytes <= 0) {
262
+ return { hibernateSafe: false, reason: "session_empty", ...base };
263
+ }
264
+ return { hibernateSafe: true, reason: null, ...base };
265
+ },
266
+ async stopRuntime(spec) {
267
+ const addr = { tmuxSocket: spec.tmuxSocket, tmuxTarget: spec.tmuxTarget };
268
+ // Capture the live generation to stop BEFORE any mutation; if the pane is
269
+ // already dead / has no live generation there is nothing to stop.
270
+ const current = await readPaneGeneration(d.runner, addr);
271
+ if (current.pid == null || current.dead || current.generationToken == null) {
272
+ return { stopped: true, rssBytes: null };
273
+ }
274
+ if (!d.processAlive(current.pid))
275
+ return { stopped: true, rssBytes: null };
276
+ const generation = {
277
+ pid: current.pid,
278
+ generationToken: current.generationToken,
279
+ };
280
+ const rssBytes = await rssBytesOf(d.runner, generation.pid);
281
+ // Ensure the pane survives the Pi exit BEFORE stopping it, so hibernation
282
+ // requires no change to the worker spawn path. With remain-on-exit on,
283
+ // killing the pane's foreground Pi leaves the tmux session intact and
284
+ // operator-attachable, and a wake can respawn into the (now dead) pane.
285
+ await d.runner.run("tmux", [
286
+ ...tmuxSocketArgs(spec.tmuxSocket),
287
+ "set-option",
288
+ "-t",
289
+ spec.tmuxSession,
290
+ "remain-on-exit",
291
+ "on",
292
+ ]);
293
+ const stopped = await terminateGeneration(addr, generation, d);
294
+ return { stopped, rssBytes };
295
+ },
296
+ async isRuntimeAlive(spec) {
297
+ return (await runtimeAlive(spec)).alive;
298
+ },
299
+ async stopLaunchedAttempt(handle) {
300
+ // Bind to the generation THIS attempt carried in its handle. A handle with
301
+ // no generation is unprovable, so fail closed rather than risk relaunching
302
+ // over a live runtime.
303
+ const generation = launchedGenerationOf(handle);
304
+ if (!generation)
305
+ return { stopped: false };
306
+ const addr = {
307
+ tmuxSocket: generation.tmuxSocket,
308
+ tmuxTarget: generation.tmuxTarget,
309
+ };
310
+ return { stopped: await terminateGeneration(addr, generation, d) };
311
+ },
312
+ async isLaunchedAttemptAlive(handle) {
313
+ // Unprovable (no carried generation) ⇒ treat as still alive so cleanup
314
+ // never assumes a phantom exit.
315
+ const generation = launchedGenerationOf(handle);
316
+ if (!generation)
317
+ return true;
318
+ const addr = {
319
+ tmuxSocket: generation.tmuxSocket,
320
+ tmuxTarget: generation.tmuxTarget,
321
+ };
322
+ return paneHostsGeneration(d.runner, addr, generation, d.processAlive);
323
+ },
324
+ };
325
+ }
326
+ export function createHibernationTmuxController(deps) {
327
+ const runner = deps.runner ?? createExecFileRunner();
328
+ const writeFile = deps.writeFile ?? ((filePath, content, mode) => fs.writeFileSync(filePath, content, { mode }));
329
+ const unlink = deps.unlink ??
330
+ ((filePath) => {
331
+ try {
332
+ fs.unlinkSync(filePath);
333
+ }
334
+ catch {
335
+ // Best effort: already gone (e.g. the launcher self-deleted).
336
+ }
337
+ });
338
+ const readEnv = deps.readEnv ?? ((key) => process.env[key]);
339
+ const buildNickname = deps.buildNickname ??
340
+ ((ctx) => `Woken ${ctx.spec.launchSource || "worker"} ${ctx.reservationNonce.slice(0, 8)}`);
341
+ // Private, owner-only launcher dir (0700), created lazily so constructing the
342
+ // controller has no filesystem side effect and tests can inject an explicit dir.
343
+ let launcherDir = deps.launcherDir;
344
+ const resolveLauncherDir = () => {
345
+ if (launcherDir)
346
+ return launcherDir;
347
+ launcherDir = fs.mkdtempSync(path.join(os.tmpdir(), "pinet-wake-"));
348
+ return launcherDir;
349
+ };
350
+ return {
351
+ async isSessionAttachable(spec) {
352
+ const result = await runner.run("tmux", [
353
+ ...tmuxSocketArgs(spec.tmuxSocket),
354
+ "has-session",
355
+ "-t",
356
+ spec.tmuxSession,
357
+ ]);
358
+ return result.code === 0;
359
+ },
360
+ async respawnRuntime(ctx) {
361
+ if (ctx.spec.runtimeKind !== "tmux") {
362
+ throw new Error("hibernation unsupported on this runtime");
363
+ }
364
+ const spec = ctx.spec;
365
+ const resumePath = resumePathFromSessionRef(spec.sessionResumeRef);
366
+ // Unresumable spec ⇒ no session to bring back; fail closed (no handle).
367
+ if (!resumePath)
368
+ return { launched: false, handle: null };
369
+ const addr = { tmuxSocket: spec.tmuxSocket, tmuxTarget: spec.tmuxTarget };
370
+ // Destructive-wake guard (independent-review P1): only respawn when the
371
+ // EXACT recorded pane is present AND explicitly dead. A missing pane cannot
372
+ // be woken; a LIVE pane must never be clobbered (drift / manual recovery /
373
+ // ambiguous state), so we neither force-kill it with `respawn-pane -k` nor
374
+ // proceed. `respawn-pane` (no `-k`) additionally refuses a non-dead pane at
375
+ // the tmux layer, giving defence in depth.
376
+ const sessionPresent = (await runner.run("tmux", [
377
+ ...tmuxSocketArgs(spec.tmuxSocket),
378
+ "has-session",
379
+ "-t",
380
+ spec.tmuxSession,
381
+ ])).code === 0;
382
+ if (!sessionPresent)
383
+ return { launched: false, handle: null };
384
+ const deadRaw = await paneField(runner, addr, "pane_dead");
385
+ if (deadRaw !== "1")
386
+ return { launched: false, handle: null };
387
+ const inheritedEnv = {};
388
+ for (const key of deps.inheritedEnvKeys)
389
+ inheritedEnv[key] = readEnv(key);
390
+ const pinetEnv = {
391
+ ...deps.baseLaunchEnv,
392
+ ...buildWakeFenceEnv({
393
+ wakeLeaseId: ctx.wakeLeaseId,
394
+ fenceToken: ctx.fenceToken,
395
+ reservedGeneration: ctx.reservedGeneration,
396
+ reservationNonce: ctx.reservationNonce,
397
+ correlationId: ctx.correlationId,
398
+ }),
399
+ };
400
+ const script = buildResumeLauncherScript({
401
+ repoPath: spec.cwd || spec.repoRoot,
402
+ sessionPath: resumePath,
403
+ extensionEntryPath: deps.extensionEntryPath,
404
+ inheritedEnv,
405
+ pinetEnv,
406
+ nickname: buildNickname(ctx),
407
+ });
408
+ const launcherPath = path.join(resolveLauncherDir(), `pinet-wake-${ctx.reservationNonce}.sh`);
409
+ let launched = false;
410
+ try {
411
+ writeFile(launcherPath, script, 0o700);
412
+ // No `-k`: only a dead pane is respawned (guarded above); a live pane is
413
+ // never force-killed.
414
+ const result = await runner.run("tmux", [
415
+ ...tmuxSocketArgs(spec.tmuxSocket),
416
+ "respawn-pane",
417
+ "-t",
418
+ spec.tmuxTarget,
419
+ launcherPath,
420
+ ]);
421
+ if (result.code !== 0)
422
+ return { launched: false, handle: null };
423
+ launched = true;
424
+ // Bind the launched attempt to its exact process generation and carry it
425
+ // INSIDE the handle, so retry cleanup can prove THIS process (not a reused
426
+ // pid) is gone with no shared registry. If the pid/token cannot be
427
+ // captured, the handle carries no generation and the attempt-scoped probes
428
+ // fail closed (orchestrator quarantines as ambiguous).
429
+ const pid = await panePid(runner, addr);
430
+ const generationToken = pid != null ? await readGenerationToken(runner, pid) : null;
431
+ const base = {
432
+ reservationNonce: ctx.reservationNonce,
433
+ tmuxTarget: spec.tmuxTarget,
434
+ pid,
435
+ };
436
+ if (pid != null && generationToken != null) {
437
+ const handle = {
438
+ ...base,
439
+ generation: {
440
+ pid,
441
+ generationToken,
442
+ tmuxSocket: spec.tmuxSocket,
443
+ tmuxTarget: spec.tmuxTarget,
444
+ },
445
+ };
446
+ return { launched: true, handle };
447
+ }
448
+ return { launched: true, handle: base };
449
+ }
450
+ finally {
451
+ // Guaranteed cleanup of the secret-bearing launcher: on the happy path
452
+ // the script self-deletes (`rm -f "$0"` before exec), so unlinking here
453
+ // could race the pane shell's open — only remove the orphan on the
454
+ // FAILURE path (where the launcher never exec'd).
455
+ if (!launched)
456
+ unlink(launcherPath);
457
+ }
458
+ },
459
+ };
460
+ }
461
+ /**
462
+ * Resolve the canonical git-remote-derived `owner/repo` VCS identity for a repo
463
+ * root at spawn time. This is the ONLY identity the repo allowlist authorizes
464
+ * against — derived from the runtime's actual `origin` remote, never from the
465
+ * filesystem directory name. Returns null when no remote is resolvable (the
466
+ * fail-closed authorization gate then refuses).
467
+ */
468
+ export async function resolveVcsIdentity(repoRoot, runner = createExecFileRunner()) {
469
+ const result = await runner.run("git", ["-C", repoRoot, "remote", "get-url", "origin"]);
470
+ if (result.code !== 0)
471
+ return null;
472
+ return deriveVcsIdentity(result.stdout.trim());
473
+ }
@@ -0,0 +1,146 @@
1
+ import type { AgentRuntimeSpecInput } from "@pinet/broker-core/types";
2
+ /** Single-quote a value for safe embedding in a POSIX shell launcher script. */
3
+ export declare function shellQuote(value: string): string;
4
+ /**
5
+ * Derive a canonical `owner/repo` VCS identity from a git remote URL. Supports
6
+ * scp-style (`git@github.com:owner/repo.git`), URL (`https://host/owner/repo`,
7
+ * `ssh://git@host/owner/repo`, `git://host/owner/repo`), and bare `owner/repo`
8
+ * forms. Returns null when no `owner/repo` can be derived.
9
+ *
10
+ * This is the ONLY identity the repo allowlist authorizes against, and it is
11
+ * derived from the runtime's actual git REMOTE — never from filesystem directory
12
+ * names — so distinct roots that merely share their final path segments (or a
13
+ * worktree vs. its clone) never collapse onto, or diverge from, one authorization
14
+ * identity.
15
+ */
16
+ export declare function deriveVcsIdentity(remoteUrl: string | null | undefined): string | null;
17
+ /**
18
+ * Parse resident set size (bytes) from `ps -o rss= -p <pid>` output. macOS/Linux
19
+ * `ps` reports RSS in kibibytes; returns null when no numeric value is present
20
+ * (e.g. the process already exited).
21
+ */
22
+ export declare function parseRssBytesFromPs(psOutput: string | null | undefined): number | null;
23
+ /**
24
+ * Build the broker-resolvable, redaction-safe session resume reference for a
25
+ * worker from its stable id. A worker's stable id embeds the absolute path to
26
+ * its Pi session `.jsonl` (`<host>:session:<path>`); the resume ref is stored as
27
+ * `session:<path>` so `redactRuntimeSpec` digests it to `session:#<fingerprint>`
28
+ * on every operator/JSON surface while the broker can still recover the path to
29
+ * respawn `pi --session <path>`. Returns null for stable ids without a session
30
+ * path (e.g. `cwd:`/`leaf:` kinds), which are not resumable.
31
+ */
32
+ export declare function sessionResumeRefFromStableId(stableId: string | null | undefined): string | null;
33
+ /** Recover the absolute session `.jsonl` path from a `session:<path>` resume ref. */
34
+ export declare function resumePathFromSessionRef(sessionResumeRef: string | null | undefined): string | null;
35
+ /** The broker-issued wake fence a respawned runtime must echo back to register. */
36
+ export interface WakeFence {
37
+ wakeLeaseId: string;
38
+ fenceToken: number;
39
+ reservedGeneration: number;
40
+ reservationNonce: string;
41
+ }
42
+ export interface WakeFenceEnvInput extends WakeFence {
43
+ correlationId: string;
44
+ }
45
+ /**
46
+ * Environment variables the respawn launcher exports so the woken runtime can
47
+ * present its single-use wake fence on registration. Ordinary (non-wake) spawns
48
+ * never set these, so ordinary registration stays fence-free and backward
49
+ * compatible.
50
+ */
51
+ export declare function buildWakeFenceEnv(input: WakeFenceEnvInput): Record<string, string>;
52
+ /**
53
+ * Parse a wake fence from a woken worker's environment (the boundary between the
54
+ * respawn launcher and the follower's register RPC). Returns null unless ALL
55
+ * required fields are present and well-formed — the numeric fence token and
56
+ * reserved generation must be canonical positive decimal safe integers — so a
57
+ * partial/garbled environment fails closed to an ordinary (fence-free)
58
+ * registration rather than a malformed fenced one.
59
+ */
60
+ export declare function parseWakeFenceEnv(env: Record<string, string | undefined>): WakeFence | null;
61
+ /**
62
+ * The broker-KNOWN, spawn-authored facts that compose a durable runtime spec.
63
+ *
64
+ * Every operational field here (the tmux socket/session/target the broker will
65
+ * later kill and respawn into, the repo/cwd it runs `ps`/`kill`/`git` against)
66
+ * MUST come from the broker's own spawn record — NEVER from worker-reported
67
+ * registration metadata. A worker that could name another worker's tmux pane
68
+ * would otherwise get the broker to checkpoint/kill/respawn THAT pane on its
69
+ * behalf. The only field the worker's identity contributes is its stable id
70
+ * (which embeds its own session path); authorization uses solely the
71
+ * broker-derived {@link vcsIdentity}.
72
+ */
73
+ interface SpawnAuthoredRuntimeFactsBase {
74
+ agentId: string;
75
+ stableId: string;
76
+ brokerOwnerId: string;
77
+ cwd: string;
78
+ repoRoot: string;
79
+ worktreePath: string;
80
+ extensionEntryPath: string;
81
+ /** Environment variable NAMES (never values) the launcher exports. */
82
+ envAllowlist: string[];
83
+ configFingerprint: string;
84
+ expectedUser: string;
85
+ launchSource: string;
86
+ /** Broker-derived from the runtime's git remote — the ONLY authz identity. */
87
+ vcsIdentity: string | null;
88
+ }
89
+ export type SpawnAuthoredRuntimeFacts = SpawnAuthoredRuntimeFactsBase & ({
90
+ /** Optional only for compatibility with existing tmux-authored callers. */
91
+ runtimeKind?: "tmux";
92
+ tmuxSocket: string;
93
+ tmuxSession: string;
94
+ tmuxTarget: string;
95
+ } | {
96
+ runtimeKind: "herdr";
97
+ herdrSession: string;
98
+ herdrConfigDir: string;
99
+ herdrPaneId: string;
100
+ herdrShellPid: number;
101
+ });
102
+ /**
103
+ * Compose a durable {@link AgentRuntimeSpecInput} from broker-known spawn facts.
104
+ *
105
+ * Fails closed (returns null) when the identity is not a resumable Pi session
106
+ * (`sessionResumeRef` cannot be derived) or any operational locator the broker
107
+ * must act on (tmux socket/session/target, repo root) is missing — a spec that
108
+ * could not be safely hibernated/woken is never recorded. `expectedHost` is
109
+ * taken from the stable id's host prefix; `argv` mirrors the resume launch
110
+ * (`pi -e <entry> --session <path>`) for provenance; the resume path itself is
111
+ * only ever recovered from the redaction-safe `sessionResumeRef`.
112
+ */
113
+ export declare function buildRuntimeSpecInput(facts: SpawnAuthoredRuntimeFacts): AgentRuntimeSpecInput | null;
114
+ export interface ResumeLauncherInput {
115
+ repoPath: string;
116
+ /** Absolute path to the Pi session `.jsonl` to resume. */
117
+ sessionPath: string;
118
+ extensionEntryPath: string;
119
+ /** Inherited env keys to re-export if present in the broker environment. */
120
+ inheritedEnv: Record<string, string | undefined>;
121
+ /** PINET_* launch env plus the wake-fence env for this attempt. */
122
+ pinetEnv: Record<string, string>;
123
+ nickname: string;
124
+ }
125
+ /**
126
+ * Build the launcher script a wake attempt runs via `tmux respawn-pane` to bring
127
+ * back the fenced runtime. It re-establishes the repo cwd and launch environment,
128
+ * exports the wake fence, and resumes the exact session with `pi --session
129
+ * <path>` (no startup prompt — the session already carries the worker's context;
130
+ * injecting a prompt would append a spurious user turn). The woken Pi therefore
131
+ * re-registers under the SAME stable id (same session path) and presents the
132
+ * fence for atomic generation acceptance.
133
+ *
134
+ * The script is secret-bearing (it exports mesh/Slack credential VALUES), so it
135
+ * removes its own file (`rm -f -- "$0"`) as its FIRST executable action — before
136
+ * `cd` and before exporting ANY secret. Under `set -euo pipefail` a later failure
137
+ * (e.g. a missing/unmounted repo making `cd` exit) therefore cannot leave a
138
+ * secret-bearing file behind, closing the “tmux accepted launch, launcher began,
139
+ * pre-delete setup failed” retention window. The launcher fd stays open across the
140
+ * unlink (POSIX keeps the inode until the fd closes), so `exec pi` still runs from
141
+ * the now-unnamed inode even though the secrets are exported after the unlink. The
142
+ * broker separately unlinks on the failure path (where the script never ran) and
143
+ * materializes launchers only in a private, owner-only directory.
144
+ */
145
+ export declare function buildResumeLauncherScript(input: ResumeLauncherInput): string;
146
+ export {};