pi-tmux-session-map 0.4.0 → 0.5.0

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.
package/src/index.ts CHANGED
@@ -4,19 +4,22 @@ import { open, utimes } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
- import { cleanupStaleSessionMappings, cleanupStaleTempFiles, cleanupSupersededStatusFiles, ensureDirectory, writeAtomic } from "./domain/atomic-write.ts";
7
+ import { cleanupStaleSessionMappings, cleanupStaleTempFiles, cleanupSupersededStatusFiles, ensureDirectory, writeAtomic, writeAtomicExclusive } from "./domain/atomic-write.ts";
8
8
  import { createAsyncQueue } from "./domain/async-queue.ts";
9
9
  import { createThrottledErrorReporter } from "./domain/error-reporter.ts";
10
10
  import { publishSessionMapping } from "./domain/mapping-publisher.ts";
11
11
  import { PANE_KEY_FORMAT, defaultStateDir, statusFileName } from "./domain/pane-key.ts";
12
12
  import { parsePaneInfoOutput, parseZellijPaneInfo, mexPaneInfo } from "./domain/pane-info.ts";
13
+ import { ownsInteractivePane, paneOwnershipEvidence } from "./domain/pane-ownership.ts";
13
14
  import { buildRestoreAck, restoreAckPath, restoreExpectationFromEnvironment, serializeRestoreAck } from "./domain/restore-ack.ts";
14
15
  import { createRuntimeState, nextStatusTransition, refreshStatusTransition, type FinalWorkState, type RuntimeState, type WorkState } from "./domain/state.ts";
15
- import { buildTwsStatusPayload, serializeStatusPayload, type PaneInfo, type SessionInfo } from "./domain/status.ts";
16
+ import { ContinuationExecutor } from "./continuation-executor.ts";
17
+ import { createSignalEvidenceListener, recordedTerminationSignal } from "./domain/signal-evidence.ts";
18
+ import { buildTwsStatusPayload, serializeStatusPayload, type ContinueStatus, type PaneInfo, type RecoveryEvidence, type RunAnchor, type SessionInfo, type ShutdownCause } from "./domain/status.ts";
16
19
 
17
- function execFileAsync(file: string, args: readonly string[]): Promise<{ stdout: string }> {
20
+ function execFileAsync(file: string, args: readonly string[], timeoutMs = muxTimeoutMs): Promise<{ stdout: string }> {
18
21
  return new Promise((resolve, reject) => {
19
- execFile(file, args, { timeout: muxTimeoutMs }, (error, stdout) => {
22
+ execFile(file, args, { timeout: timeoutMs }, (error, stdout) => {
20
23
  if (error) {
21
24
  reject(error);
22
25
  return;
@@ -32,6 +35,16 @@ const twsTriggerFile = process.env.PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE ?? join(
32
35
  const tmuxBin = process.env.PI_TMUX_SESSION_MAP_TMUX_BIN ?? "tmux";
33
36
  const zellijBin = process.env.PI_TMUX_SESSION_MAP_ZELLIJ_BIN ?? "zellij";
34
37
  const muxTimeoutMs = 1000;
38
+ // A restore recreates many panes at once and keeps the tmux server busy, so a
39
+ // 1 s `display-message` can time out exactly when a restored Pi starts. Without
40
+ // pane info the one-shot restore acknowledgement was never written (observed in
41
+ // a 106-session Tart drill on 2026-11-06). While that acknowledgement is
42
+ // pending, retry with growing timeouts; about 31 s in total, below the restore
43
+ // engine's minimum acknowledgement window of 15 s + 2 s per resumed pane.
44
+ const restorePaneInfoTimeoutsMs = (process.env.PI_TMUX_SESSION_MAP_RESTORE_PANE_TIMEOUTS_MS ?? "1000,2000,4000,8000,16000")
45
+ .split(",")
46
+ .map((value) => Number.parseInt(value, 10))
47
+ .filter((value) => Number.isFinite(value) && value > 0);
35
48
  const cleanupMaxAgeMs = 24 * 60 * 60 * 1000;
36
49
  const reconcileIntervalMs = Math.max(
37
50
  10_000,
@@ -47,6 +60,10 @@ type EventAction = {
47
60
  cleanup?: boolean;
48
61
  restoreReason?: string;
49
62
  refreshPane?: boolean;
63
+ /** Leaf captured synchronously at agent_start, before the prompt is persisted. */
64
+ runStartLeafId?: string | null;
65
+ /** session_shutdown reason; the cause is resolved when the payload is built. */
66
+ shutdownReason?: string;
50
67
  };
51
68
 
52
69
  type ArtifactCache = {
@@ -54,8 +71,27 @@ type ArtifactCache = {
54
71
  paneInfoResolved: boolean;
55
72
  mappingSignature: string | null;
56
73
  statusSignature: string | null;
74
+ /** session_start reason of a restore acknowledgement not yet written. */
75
+ pendingRestoreReason: string | null;
76
+ /** Anchor of the current agent run; null outside a run. */
77
+ runAnchor: RunAnchor | null;
78
+ /** In-process status of the restore acknowledgement, once known. */
79
+ restoreAckStatus: "resumed" | "mismatch" | undefined;
80
+ /** Auto-continue executor state for the status sidecar. */
81
+ continueState: () => ContinueStatus | null;
57
82
  };
58
83
 
84
+ export function shutdownCauseFor(reason: string, signal: string | null): ShutdownCause {
85
+ switch (reason) {
86
+ case "quit":
87
+ return signal === null ? "quit" : "signal";
88
+ case "reload":
89
+ return "reload";
90
+ default:
91
+ return "replace";
92
+ }
93
+ }
94
+
59
95
  type AgentEndMessage = {
60
96
  role: string;
61
97
  stopReason?: string;
@@ -85,13 +121,14 @@ export function provisionalWorkState(finalState: FinalWorkState): WorkState {
85
121
  return finalState === "failed" ? "retrying" : finalState;
86
122
  }
87
123
 
88
- export async function resolvePaneInfo(): Promise<PaneInfo | undefined> {
124
+ export async function resolvePaneInfo(timeoutMs = muxTimeoutMs): Promise<PaneInfo | undefined> {
89
125
  const tmuxPane = process.env.TMUX_PANE;
90
126
  if (process.env.TMUX && tmuxPane) {
91
127
  try {
92
128
  const { stdout } = await execFileAsync(
93
129
  tmuxBin,
94
130
  ["display-message", "-p", "-t", tmuxPane, PANE_INFO_FORMAT],
131
+ timeoutMs,
95
132
  );
96
133
  return parsePaneInfoOutput(stdout, tmuxPane);
97
134
  } catch {
@@ -169,8 +206,13 @@ export function twsStatusSignature(
169
206
  state: WorkState,
170
207
  startedAtMs: number | null,
171
208
  finishedAtMs: number | null,
209
+ sessionId: string,
210
+ evidence: RecoveryEvidence = { runAnchor: null, shutdownCause: null },
172
211
  ): string {
173
212
  return JSON.stringify([
213
+ evidence.runAnchor,
214
+ evidence.shutdownCause,
215
+ evidence.continueState ?? null,
174
216
  info.backend,
175
217
  info.paneKey,
176
218
  info.paneId,
@@ -181,6 +223,7 @@ export function twsStatusSignature(
181
223
  session.cwd,
182
224
  session.sessionFile,
183
225
  session.sessionName,
226
+ sessionId,
184
227
  state,
185
228
  startedAtMs,
186
229
  finishedAtMs,
@@ -191,19 +234,39 @@ async function writeSessionMapping(info: PaneInfo, session: SessionInfo, session
191
234
  return (await publishSessionMapping(stateDir, info, session, sessionId)) !== undefined;
192
235
  }
193
236
 
237
+ /**
238
+ * Publish the one-shot restore acknowledgement. The first acknowledgement for a
239
+ * token wins: a later session_start in the same process (for example after
240
+ * /new or /reload) must not replace the proof of what the restore resumed.
241
+ */
194
242
  async function writeRestoreAcknowledgement(
195
243
  info: PaneInfo,
196
244
  session: SessionInfo,
197
245
  sessionId: string,
198
246
  reason: string,
199
- ): Promise<void> {
247
+ ): Promise<"resumed" | "mismatch" | undefined> {
200
248
  const expectation = restoreExpectationFromEnvironment();
201
249
  if (!expectation) {
202
- return;
250
+ return undefined;
203
251
  }
204
252
  const ack = buildRestoreAck(expectation, info, session, sessionId, reason, Date.now());
205
253
  await ensureDirectory(expectation.ackDir);
206
- await writeAtomic(restoreAckPath(expectation), serializeRestoreAck(ack), 0o600);
254
+ await writeAtomicExclusive(restoreAckPath(expectation), serializeRestoreAck(ack), 0o600);
255
+ return ack.status;
256
+ }
257
+
258
+ /** Resolve pane info for a pending restore acknowledgement, with growing timeouts. */
259
+ async function resolvePaneInfoForRestore(): Promise<PaneInfo | undefined> {
260
+ for (const [attempt, timeoutMs] of restorePaneInfoTimeoutsMs.entries()) {
261
+ const info = await resolvePaneInfo(timeoutMs);
262
+ if (info) {
263
+ return info;
264
+ }
265
+ if (attempt + 1 < restorePaneInfoTimeoutsMs.length) {
266
+ await new Promise((resolve) => setTimeout(resolve, 100));
267
+ }
268
+ }
269
+ return undefined;
207
270
  }
208
271
 
209
272
  function getSessionInfo(ctx: ExtensionContext): SessionInfo {
@@ -244,8 +307,11 @@ async function writeTwsStatus(
244
307
  updatedAtMs: number,
245
308
  startedAtMs: number | null,
246
309
  finishedAtMs: number | null,
310
+ sessionId: string,
311
+ evidence: RecoveryEvidence,
247
312
  ): Promise<void> {
248
- const payload = buildTwsStatusPayload(info, session, state, updatedAtMs, startedAtMs, finishedAtMs);
313
+ const payload = buildTwsStatusPayload(info, session, state, updatedAtMs, startedAtMs, finishedAtMs,
314
+ { sessionId, piPid: process.pid }, evidence);
249
315
 
250
316
  const directory = statusDirectory(info);
251
317
  const fileName = statusFileName(info.paneKey);
@@ -271,32 +337,48 @@ async function applyEventAction(
271
337
  runtimeState: RuntimeState,
272
338
  artifactCache: ArtifactCache,
273
339
  ): Promise<RuntimeState> {
340
+ if (!ownsInteractivePane(paneOwnershipEvidence(ctx.mode, process.stdin.isTTY))) {
341
+ return runtimeState;
342
+ }
343
+
274
344
  const session = getSessionInfo(ctx);
275
345
  const mappingSessionFile = action.mapping ? session.sessionFile : null;
276
346
  if (mappingSessionFile === null && action.status === null) {
277
347
  return runtimeState;
278
348
  }
279
349
 
280
- if (action.refreshPane === true || !artifactCache.paneInfoResolved) {
281
- artifactCache.paneInfo = await resolvePaneInfo();
350
+ if (action.restoreReason !== undefined && restoreExpectationFromEnvironment() !== undefined) {
351
+ artifactCache.pendingRestoreReason = action.restoreReason;
352
+ }
353
+ const restorePending = artifactCache.pendingRestoreReason !== null;
354
+ if (action.refreshPane === true || !artifactCache.paneInfoResolved || (restorePending && !artifactCache.paneInfo)) {
355
+ const resolved = restorePending ? await resolvePaneInfoForRestore() : await resolvePaneInfo();
356
+ // A refresh that fails (tmux busy past its timeout) keeps the last known pane of this
357
+ // process. Dropping it skipped this event's status write, e.g. `working` at agent_start.
358
+ artifactCache.paneInfo = resolved ?? artifactCache.paneInfo;
282
359
  artifactCache.paneInfoResolved = true;
283
360
  }
284
361
  const info = artifactCache.paneInfo;
285
362
  if (!info) {
363
+ // A pending restore acknowledgement stays pending; the next event or the
364
+ // periodic reconciliation retries it.
286
365
  return runtimeState;
287
366
  }
288
367
 
289
368
  const sessionId = ctx.sessionManager.getSessionId();
369
+ if (artifactCache.pendingRestoreReason !== null && info.backend === "tmux") {
370
+ // Acknowledge before the mapping: the restore only waits for this file,
371
+ // and a mapping failure must not suppress it.
372
+ artifactCache.restoreAckStatus = await writeRestoreAcknowledgement(info, session, sessionId, artifactCache.pendingRestoreReason);
373
+ artifactCache.pendingRestoreReason = null;
374
+ }
375
+
290
376
  const mappingSignature = sessionMappingSignature(info, session, sessionId);
291
377
  if (mappingSessionFile !== null && mappingSignature !== null && mappingSignature !== artifactCache.mappingSignature) {
292
378
  const published = await writeSessionMapping(info, session, sessionId);
293
379
  artifactCache.mappingSignature = published ? mappingSignature : null;
294
380
  }
295
381
 
296
- if (action.restoreReason !== undefined && info.backend === "tmux") {
297
- await writeRestoreAcknowledgement(info, session, sessionId, action.restoreReason);
298
- }
299
-
300
382
  if (action.status === null) {
301
383
  return runtimeState;
302
384
  }
@@ -312,18 +394,38 @@ async function applyEventAction(
312
394
  return transition;
313
395
  }
314
396
 
397
+ // A run starts on the first `working` after a non-running state; retries keep
398
+ // the anchor. Final states and idle end the run.
399
+ if (transition.state === "working" && runtimeState.lastState !== "working" && runtimeState.lastState !== "retrying") {
400
+ artifactCache.runAnchor = {
401
+ leaf_id: action.runStartLeafId ?? null,
402
+ started_at_ms: transition.startedAtMs ?? now,
403
+ };
404
+ } else if (transition.state !== "working" && transition.state !== "retrying" && transition.state !== "shutdown") {
405
+ artifactCache.runAnchor = null;
406
+ }
407
+ const evidence: RecoveryEvidence = {
408
+ continueState: artifactCache.continueState(),
409
+ runAnchor: artifactCache.runAnchor,
410
+ shutdownCause: action.shutdownReason === undefined
411
+ ? null
412
+ : shutdownCauseFor(action.shutdownReason, recordedTerminationSignal()),
413
+ };
414
+
315
415
  const statusSignature = twsStatusSignature(
316
416
  info,
317
417
  session,
318
418
  transition.state,
319
419
  transition.startedAtMs,
320
420
  transition.finishedAtMs,
421
+ sessionId,
422
+ evidence,
321
423
  );
322
424
  if (statusSignature === artifactCache.statusSignature) {
323
425
  return transition;
324
426
  }
325
427
 
326
- await writeTwsStatus(info, session, transition.state, now, transition.startedAtMs, transition.finishedAtMs);
428
+ await writeTwsStatus(info, session, transition.state, now, transition.startedAtMs, transition.finishedAtMs, sessionId, evidence);
327
429
  artifactCache.statusSignature = statusSignature;
328
430
  return transition;
329
431
  }
@@ -348,7 +450,13 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
348
450
  paneInfoResolved: false,
349
451
  mappingSignature: null,
350
452
  statusSignature: null,
453
+ pendingRestoreReason: null,
454
+ runAnchor: null,
455
+ restoreAckStatus: undefined,
456
+ continueState: () => executor?.status() ?? null,
351
457
  };
458
+ const signalEvidence = createSignalEvidenceListener();
459
+ let executor: ContinuationExecutor | undefined;
352
460
 
353
461
  const enqueue = async (ctx: ExtensionContext, action: EventAction) => {
354
462
  await queue.enqueue(async () => {
@@ -390,7 +498,11 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
390
498
 
391
499
  const startReconciliation = (ctx: ExtensionContext) => {
392
500
  stopReconciliation();
393
- if (!process.env.TMUX || !process.env.TMUX_PANE) {
501
+ if (
502
+ !process.env.TMUX
503
+ || !process.env.TMUX_PANE
504
+ || !ownsInteractivePane(paneOwnershipEvidence(ctx.mode, process.stdin.isTTY))
505
+ ) {
394
506
  return;
395
507
  }
396
508
  reconciliationActive = true;
@@ -404,6 +516,8 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
404
516
  artifactCache.paneInfoResolved = false;
405
517
  artifactCache.mappingSignature = null;
406
518
  artifactCache.statusSignature = null;
519
+ artifactCache.pendingRestoreReason = null;
520
+ artifactCache.runAnchor = null;
407
521
  runtimeState = createRuntimeState();
408
522
  await enqueue(ctx, {
409
523
  mapping: true,
@@ -412,14 +526,69 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
412
526
  restoreReason: event.reason,
413
527
  refreshPane: true,
414
528
  });
529
+ // Only the visible interactive Pi owns the pane; print, RPC and headless
530
+ // children never install a signal listener.
531
+ const ownsPane = ownsInteractivePane(paneOwnershipEvidence(ctx.mode, process.stdin.isTTY));
532
+ if (ownsPane) {
533
+ signalEvidence.install();
534
+ }
535
+ // Auto-continue executor: only the restored Pi that the restore engine
536
+ // planned (PI_TMUX_CONTINUE_PLAN), once, at its startup.
537
+ if (ownsPane && event.reason === "startup" && executor === undefined && process.env.TMUX) {
538
+ executor = ContinuationExecutor.fromEnvironment(
539
+ {
540
+ ackResumed: () => (artifactCache.restoreAckStatus === undefined ? undefined : artifactCache.restoreAckStatus === "resumed"),
541
+ paneKey: () => artifactCache.paneInfo?.paneKey ?? null,
542
+ sessionId: () => ctx.sessionManager.getSessionId(),
543
+ sessionFile: () => ctx.sessionManager.getSessionFile() ?? null,
544
+ branch: () => ctx.sessionManager.getBranch() as never,
545
+ idle: () => ctx.isIdle(),
546
+ pendingMessages: () => ctx.hasPendingMessages(),
547
+ editorText: () => ctx.ui.getEditorText() ?? "",
548
+ },
549
+ (message, options) => pi.sendMessage(message, options),
550
+ () => {
551
+ void enqueue(ctx, { mapping: false, status: "refresh" });
552
+ },
553
+ );
554
+ if (executor) {
555
+ // Any keystroke before the dispatch vetoes it (terminal replies do not
556
+ // reach this listener inside tmux; measured in tmux-recovery phase 0).
557
+ ctx.ui.onTerminalInput(() => {
558
+ executor?.noteUserActivity();
559
+ return undefined;
560
+ });
561
+ executor.start();
562
+ }
563
+ }
415
564
  startReconciliation(ctx);
416
565
  });
566
+ pi.on("input", (event) => {
567
+ if (event.source !== "extension") {
568
+ executor?.noteUserActivity();
569
+ }
570
+ return undefined;
571
+ });
572
+ // Pi 1.5 emits ui_prompt_start/end around extension dialogs; older Pi never does.
573
+ const onUiPrompt = pi.on as unknown as (event: string, handler: () => void) => void;
574
+ onUiPrompt("ui_prompt_start", () => {
575
+ executor?.notePrompt(true);
576
+ });
577
+ onUiPrompt("ui_prompt_end", () => {
578
+ executor?.notePrompt(false);
579
+ });
580
+ pi.on("before_agent_start", (event) => {
581
+ executor?.beforeAgentStart(event as never);
582
+ return undefined;
583
+ });
417
584
  pi.on("session_info_changed", async (_event, ctx) => {
418
585
  await enqueue(ctx, { mapping: true, status: "refresh", refreshPane: true });
419
586
  });
420
587
  pi.on("agent_start", async (_event, ctx) => {
421
588
  pendingFinalState = "done";
422
- await enqueue(ctx, { mapping: true, status: "working", refreshPane: true });
589
+ // Read the leaf now: the run's prompt entry is persisted after agent_start.
590
+ const runStartLeafId = ctx.sessionManager.getLeafId() ?? null;
591
+ await enqueue(ctx, { mapping: true, status: "working", refreshPane: true, runStartLeafId });
423
592
  });
424
593
  // agent_end may be followed by automatic retry/compaction. Keep failures
425
594
  // provisional until agent_settled confirms that no continuation will run.
@@ -435,15 +604,24 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
435
604
  });
436
605
  });
437
606
  pi.on("agent_settled", async (_event, ctx) => {
607
+ executor?.agentSettled(pendingFinalState);
438
608
  await enqueue(ctx, { mapping: true, status: pendingFinalState });
439
609
  });
440
- pi.on("session_shutdown", async (_event, ctx) => {
610
+ pi.on("session_shutdown", async (event, ctx) => {
441
611
  stopReconciliation();
442
- await enqueue(ctx, { mapping: false, status: "shutdown" });
612
+ executor?.shutdown();
613
+ if (event.reason !== "quit") {
614
+ // A replacement instance installs its own listener; a quit keeps it so a
615
+ // signal that arrives during the shutdown is still recorded.
616
+ signalEvidence.uninstall();
617
+ }
618
+ await enqueue(ctx, { mapping: false, status: "shutdown", shutdownReason: event.reason });
443
619
  artifactCache.paneInfo = undefined;
444
620
  artifactCache.paneInfoResolved = false;
445
621
  artifactCache.mappingSignature = null;
446
622
  artifactCache.statusSignature = null;
623
+ artifactCache.pendingRestoreReason = null;
624
+ artifactCache.runAnchor = null;
447
625
  runtimeState = createRuntimeState();
448
626
  });
449
627
  }