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/README.md +44 -9
- package/package.json +1 -1
- package/src/continuation-executor.ts +323 -0
- package/src/domain/atomic-write.ts +40 -9
- package/src/domain/continuation.ts +374 -0
- package/src/domain/mapping-publisher.ts +54 -0
- package/src/domain/pane-info.ts +4 -1
- package/src/domain/pane-key.ts +5 -0
- package/src/domain/pane-ownership.ts +23 -0
- package/src/domain/session-file.ts +55 -0
- package/src/domain/session-mapping.ts +89 -24
- package/src/domain/signal-evidence.ts +56 -0
- package/src/domain/status.ts +55 -0
- package/src/index.ts +263 -41
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,
|
|
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 {
|
|
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 {
|
|
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,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
|
|
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([
|
|
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<
|
|
169
|
-
|
|
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<
|
|
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
|
|
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.
|
|
273
|
-
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;
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
artifactCache.
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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 (
|
|
398
|
-
|
|
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
|
}
|