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/README.md +33 -4
- package/package.json +1 -1
- package/src/continuation-executor.ts +323 -0
- package/src/domain/atomic-write.ts +23 -1
- package/src/domain/continuation.ts +374 -0
- package/src/domain/pane-ownership.ts +23 -0
- package/src/domain/signal-evidence.ts +56 -0
- package/src/domain/status.ts +54 -0
- package/src/index.ts +198 -20
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 {
|
|
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:
|
|
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<
|
|
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
|
|
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.
|
|
281
|
-
artifactCache.
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
610
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
441
611
|
stopReconciliation();
|
|
442
|
-
|
|
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
|
}
|