pi-tmux-session-map 0.3.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, cleanupSupersededSessionMappings, 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
- import { PANE_KEY_FORMAT, defaultStateDir, mappingFileName, statusFileName } from "./domain/pane-key.ts";
10
+ import { publishSessionMapping } from "./domain/mapping-publisher.ts";
11
+ import { PANE_KEY_FORMAT, defaultStateDir, statusFileName } from "./domain/pane-key.ts";
11
12
  import { parsePaneInfoOutput, parseZellijPaneInfo, mexPaneInfo } from "./domain/pane-info.ts";
13
+ import { ownsInteractivePane, paneOwnershipEvidence } from "./domain/pane-ownership.ts";
12
14
  import { buildRestoreAck, restoreAckPath, restoreExpectationFromEnvironment, serializeRestoreAck } from "./domain/restore-ack.ts";
13
- import { buildSessionMapping, serializeSessionMapping } from "./domain/session-mapping.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,8 +35,22 @@ 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
- const PANE_INFO_FORMAT = `${PANE_KEY_FORMAT}\t#{session_name}\t#{window_index}\t#{pane_index}`;
49
+ const reconcileIntervalMs = Math.max(
50
+ 10_000,
51
+ Number.parseInt(process.env.PI_TMUX_SESSION_MAP_RECONCILE_INTERVAL_MS ?? "60000", 10) || 60_000,
52
+ );
53
+ const PANE_INFO_FORMAT = `${PANE_KEY_FORMAT}\t#{session_name}\t#{window_index}\t#{pane_index}\t#{pid}`;
37
54
 
38
55
  type StatusAction = WorkState | "refresh" | null;
39
56
 
@@ -43,6 +60,10 @@ type EventAction = {
43
60
  cleanup?: boolean;
44
61
  restoreReason?: string;
45
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;
46
67
  };
47
68
 
48
69
  type ArtifactCache = {
@@ -50,8 +71,27 @@ type ArtifactCache = {
50
71
  paneInfoResolved: boolean;
51
72
  mappingSignature: string | null;
52
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;
53
82
  };
54
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
+
55
95
  type AgentEndMessage = {
56
96
  role: string;
57
97
  stopReason?: string;
@@ -81,13 +121,14 @@ export function provisionalWorkState(finalState: FinalWorkState): WorkState {
81
121
  return finalState === "failed" ? "retrying" : finalState;
82
122
  }
83
123
 
84
- export async function resolvePaneInfo(): Promise<PaneInfo | undefined> {
124
+ export async function resolvePaneInfo(timeoutMs = muxTimeoutMs): Promise<PaneInfo | undefined> {
85
125
  const tmuxPane = process.env.TMUX_PANE;
86
126
  if (process.env.TMUX && tmuxPane) {
87
127
  try {
88
128
  const { stdout } = await execFileAsync(
89
129
  tmuxBin,
90
130
  ["display-message", "-p", "-t", tmuxPane, PANE_INFO_FORMAT],
131
+ timeoutMs,
91
132
  );
92
133
  return parsePaneInfoOutput(stdout, tmuxPane);
93
134
  } catch {
@@ -139,7 +180,24 @@ export function sessionMappingSignature(
139
180
  if (session.sessionFile === null || info.backend !== "tmux") {
140
181
  return null;
141
182
  }
142
- return JSON.stringify([info.paneKey, session.sessionFile, sessionId, session.cwd, session.sessionName]);
183
+ return JSON.stringify([
184
+ info.paneKey,
185
+ info.paneId,
186
+ info.tmuxServerPid,
187
+ session.sessionFile,
188
+ sessionId,
189
+ session.cwd,
190
+ session.sessionName,
191
+ ]);
192
+ }
193
+
194
+ export function reconciliationDelayMs(identity: string, intervalMs = reconcileIntervalMs): number {
195
+ let hash = 2166136261;
196
+ for (const byte of Buffer.from(identity, "utf8")) {
197
+ hash ^= byte;
198
+ hash = Math.imul(hash, 16777619) >>> 0;
199
+ }
200
+ return hash % intervalMs;
143
201
  }
144
202
 
145
203
  export function twsStatusSignature(
@@ -148,54 +206,67 @@ export function twsStatusSignature(
148
206
  state: WorkState,
149
207
  startedAtMs: number | null,
150
208
  finishedAtMs: number | null,
209
+ sessionId: string,
210
+ evidence: RecoveryEvidence = { runAnchor: null, shutdownCause: null },
151
211
  ): string {
152
212
  return JSON.stringify([
213
+ evidence.runAnchor,
214
+ evidence.shutdownCause,
215
+ evidence.continueState ?? null,
153
216
  info.backend,
154
217
  info.paneKey,
155
218
  info.paneId,
219
+ info.tmuxServerPid,
156
220
  info.sessionName,
157
221
  info.windowIndex,
158
222
  info.paneIndex,
159
223
  session.cwd,
160
224
  session.sessionFile,
161
225
  session.sessionName,
226
+ sessionId,
162
227
  state,
163
228
  startedAtMs,
164
229
  finishedAtMs,
165
230
  ]);
166
231
  }
167
232
 
168
- async function writeSessionMapping(info: PaneInfo, session: SessionInfo, sessionId: string): Promise<void> {
169
- if (session.sessionFile === null) {
170
- return;
171
- }
172
- const fileName = mappingFileName(info.paneKey);
173
- const mapping = buildSessionMapping(
174
- info.paneKey,
175
- session.sessionFile,
176
- sessionId,
177
- session.cwd,
178
- session.sessionName,
179
- Date.now(),
180
- );
181
- await ensureDirectory(stateDir);
182
- await writeAtomic(join(stateDir, fileName), serializeSessionMapping(mapping), 0o600);
183
- await cleanupSupersededSessionMappings(stateDir, fileName, session.sessionFile);
233
+ async function writeSessionMapping(info: PaneInfo, session: SessionInfo, sessionId: string): Promise<boolean> {
234
+ return (await publishSessionMapping(stateDir, info, session, sessionId)) !== undefined;
184
235
  }
185
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
+ */
186
242
  async function writeRestoreAcknowledgement(
187
243
  info: PaneInfo,
188
244
  session: SessionInfo,
189
245
  sessionId: string,
190
246
  reason: string,
191
- ): Promise<void> {
247
+ ): Promise<"resumed" | "mismatch" | undefined> {
192
248
  const expectation = restoreExpectationFromEnvironment();
193
249
  if (!expectation) {
194
- return;
250
+ return undefined;
195
251
  }
196
252
  const ack = buildRestoreAck(expectation, info, session, sessionId, reason, Date.now());
197
253
  await ensureDirectory(expectation.ackDir);
198
- 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;
199
270
  }
200
271
 
201
272
  function getSessionInfo(ctx: ExtensionContext): SessionInfo {
@@ -236,8 +307,11 @@ async function writeTwsStatus(
236
307
  updatedAtMs: number,
237
308
  startedAtMs: number | null,
238
309
  finishedAtMs: number | null,
310
+ sessionId: string,
311
+ evidence: RecoveryEvidence,
239
312
  ): Promise<void> {
240
- 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);
241
315
 
242
316
  const directory = statusDirectory(info);
243
317
  const fileName = statusFileName(info.paneKey);
@@ -263,30 +337,46 @@ async function applyEventAction(
263
337
  runtimeState: RuntimeState,
264
338
  artifactCache: ArtifactCache,
265
339
  ): Promise<RuntimeState> {
340
+ if (!ownsInteractivePane(paneOwnershipEvidence(ctx.mode, process.stdin.isTTY))) {
341
+ return runtimeState;
342
+ }
343
+
266
344
  const session = getSessionInfo(ctx);
267
345
  const mappingSessionFile = action.mapping ? session.sessionFile : null;
268
346
  if (mappingSessionFile === null && action.status === null) {
269
347
  return runtimeState;
270
348
  }
271
349
 
272
- if (action.refreshPane === true || !artifactCache.paneInfoResolved) {
273
- 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;
274
359
  artifactCache.paneInfoResolved = true;
275
360
  }
276
361
  const info = artifactCache.paneInfo;
277
362
  if (!info) {
363
+ // A pending restore acknowledgement stays pending; the next event or the
364
+ // periodic reconciliation retries it.
278
365
  return runtimeState;
279
366
  }
280
367
 
281
368
  const sessionId = ctx.sessionManager.getSessionId();
282
- const mappingSignature = sessionMappingSignature(info, session, sessionId);
283
- if (mappingSessionFile !== null && mappingSignature !== null && mappingSignature !== artifactCache.mappingSignature) {
284
- await writeSessionMapping(info, session, sessionId);
285
- artifactCache.mappingSignature = mappingSignature;
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;
286
374
  }
287
375
 
288
- if (action.restoreReason !== undefined && info.backend === "tmux") {
289
- await writeRestoreAcknowledgement(info, session, sessionId, action.restoreReason);
376
+ const mappingSignature = sessionMappingSignature(info, session, sessionId);
377
+ if (mappingSessionFile !== null && mappingSignature !== null && mappingSignature !== artifactCache.mappingSignature) {
378
+ const published = await writeSessionMapping(info, session, sessionId);
379
+ artifactCache.mappingSignature = published ? mappingSignature : null;
290
380
  }
291
381
 
292
382
  if (action.status === null) {
@@ -304,18 +394,38 @@ async function applyEventAction(
304
394
  return transition;
305
395
  }
306
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
+
307
415
  const statusSignature = twsStatusSignature(
308
416
  info,
309
417
  session,
310
418
  transition.state,
311
419
  transition.startedAtMs,
312
420
  transition.finishedAtMs,
421
+ sessionId,
422
+ evidence,
313
423
  );
314
424
  if (statusSignature === artifactCache.statusSignature) {
315
425
  return transition;
316
426
  }
317
427
 
318
- 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);
319
429
  artifactCache.statusSignature = statusSignature;
320
430
  return transition;
321
431
  }
@@ -340,7 +450,13 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
340
450
  paneInfoResolved: false,
341
451
  mappingSignature: null,
342
452
  statusSignature: null,
453
+ pendingRestoreReason: null,
454
+ runAnchor: null,
455
+ restoreAckStatus: undefined,
456
+ continueState: () => executor?.status() ?? null,
343
457
  };
458
+ const signalEvidence = createSignalEvidenceListener();
459
+ let executor: ContinuationExecutor | undefined;
344
460
 
345
461
  const enqueue = async (ctx: ExtensionContext, action: EventAction) => {
346
462
  await queue.enqueue(async () => {
@@ -355,6 +471,44 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
355
471
  });
356
472
  };
357
473
 
474
+ let reconciliationTimer: ReturnType<typeof setTimeout> | undefined;
475
+ let reconciliationActive = false;
476
+
477
+ const stopReconciliation = () => {
478
+ reconciliationActive = false;
479
+ if (reconciliationTimer !== undefined) {
480
+ clearTimeout(reconciliationTimer);
481
+ reconciliationTimer = undefined;
482
+ }
483
+ };
484
+
485
+ const scheduleReconciliation = (ctx: ExtensionContext, delayMs: number) => {
486
+ reconciliationTimer = setTimeout(async () => {
487
+ reconciliationTimer = undefined;
488
+ if (!reconciliationActive) {
489
+ return;
490
+ }
491
+ await enqueue(ctx, { mapping: true, status: "refresh", refreshPane: true });
492
+ if (reconciliationActive) {
493
+ scheduleReconciliation(ctx, reconcileIntervalMs);
494
+ }
495
+ }, delayMs);
496
+ reconciliationTimer.unref();
497
+ };
498
+
499
+ const startReconciliation = (ctx: ExtensionContext) => {
500
+ stopReconciliation();
501
+ if (
502
+ !process.env.TMUX
503
+ || !process.env.TMUX_PANE
504
+ || !ownsInteractivePane(paneOwnershipEvidence(ctx.mode, process.stdin.isTTY))
505
+ ) {
506
+ return;
507
+ }
508
+ reconciliationActive = true;
509
+ scheduleReconciliation(ctx, reconciliationDelayMs(process.env.TMUX_PANE));
510
+ };
511
+
358
512
  // Intentionally no cleanup on session_shutdown: the mapping must survive a
359
513
  // tmux server restart so resurrect can restore the exact session.
360
514
  pi.on("session_start", async (event, ctx) => {
@@ -362,6 +516,8 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
362
516
  artifactCache.paneInfoResolved = false;
363
517
  artifactCache.mappingSignature = null;
364
518
  artifactCache.statusSignature = null;
519
+ artifactCache.pendingRestoreReason = null;
520
+ artifactCache.runAnchor = null;
365
521
  runtimeState = createRuntimeState();
366
522
  await enqueue(ctx, {
367
523
  mapping: true,
@@ -370,13 +526,69 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
370
526
  restoreReason: event.reason,
371
527
  refreshPane: true,
372
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
+ }
564
+ startReconciliation(ctx);
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;
373
583
  });
374
584
  pi.on("session_info_changed", async (_event, ctx) => {
375
585
  await enqueue(ctx, { mapping: true, status: "refresh", refreshPane: true });
376
586
  });
377
587
  pi.on("agent_start", async (_event, ctx) => {
378
588
  pendingFinalState = "done";
379
- 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 });
380
592
  });
381
593
  // agent_end may be followed by automatic retry/compaction. Keep failures
382
594
  // provisional until agent_settled confirms that no continuation will run.
@@ -392,14 +604,24 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
392
604
  });
393
605
  });
394
606
  pi.on("agent_settled", async (_event, ctx) => {
607
+ executor?.agentSettled(pendingFinalState);
395
608
  await enqueue(ctx, { mapping: true, status: pendingFinalState });
396
609
  });
397
- pi.on("session_shutdown", async (_event, ctx) => {
398
- await enqueue(ctx, { mapping: false, status: "shutdown" });
610
+ pi.on("session_shutdown", async (event, ctx) => {
611
+ stopReconciliation();
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 });
399
619
  artifactCache.paneInfo = undefined;
400
620
  artifactCache.paneInfoResolved = false;
401
621
  artifactCache.mappingSignature = null;
402
622
  artifactCache.statusSignature = null;
623
+ artifactCache.pendingRestoreReason = null;
624
+ artifactCache.runAnchor = null;
403
625
  runtimeState = createRuntimeState();
404
626
  });
405
627
  }