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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Executor side of tmux-recovery's auto-continue (tmux-recovery
|
|
2
|
+
// docs/pi-auto-continue.md §3.4, §3.5, §4.2). The restore engine plans and
|
|
3
|
+
// releases a ledger entry; this module decides, immediately before dispatch,
|
|
4
|
+
// whether the one visible continuation turn may start, and records it.
|
|
5
|
+
//
|
|
6
|
+
// Exclusivity: O_EXCL files only. `K.claim` is created with "wx" and never
|
|
7
|
+
// removed; `K.skip` is first-writer-wins, so a veto is never downgraded; the
|
|
8
|
+
// global dispatch lock is a hard link of a fully written file and is only
|
|
9
|
+
// removed by its owner (inode check) or by the restore engine's reconciler.
|
|
10
|
+
// Everything here is synchronous so a dispatch decision cannot interleave with
|
|
11
|
+
// user input or another turn in this process.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync,
|
|
15
|
+
writeFileSync, writeSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
export const CONTINUE_CUSTOM_TYPE = "tmux-recovery-continue";
|
|
21
|
+
|
|
22
|
+
export type ContinuePlan = {
|
|
23
|
+
schema: number;
|
|
24
|
+
key: string;
|
|
25
|
+
state: string;
|
|
26
|
+
chain: number;
|
|
27
|
+
token: string;
|
|
28
|
+
paneKey: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
sessionFile: string;
|
|
31
|
+
frozenLeafId: string;
|
|
32
|
+
interruptedAtMs: number | null;
|
|
33
|
+
cause: "signal" | "hard" | null;
|
|
34
|
+
toolCalls: { id: string; name: string }[];
|
|
35
|
+
ledgerDir: string;
|
|
36
|
+
readyPath: string;
|
|
37
|
+
configPath: string;
|
|
38
|
+
holdFile: string;
|
|
39
|
+
spacingMs: number;
|
|
40
|
+
slotMs: number | null;
|
|
41
|
+
deadlineMs: number | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function readPlan(path: string): ContinuePlan | undefined {
|
|
45
|
+
try {
|
|
46
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
47
|
+
if (typeof value?.key !== "string" || typeof value?.token !== "string" || typeof value?.ledgerDir !== "string") {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return value as ContinuePlan;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ReadyState = "waiting" | "released" | "not-released";
|
|
57
|
+
|
|
58
|
+
export function readyState(plan: ContinuePlan): ReadyState {
|
|
59
|
+
if (!existsSync(plan.readyPath)) {
|
|
60
|
+
return "waiting";
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const ready = JSON.parse(readFileSync(plan.readyPath, "utf8"));
|
|
64
|
+
return ready?.tokens?.[plan.token] ? "released" : "not-released";
|
|
65
|
+
} catch {
|
|
66
|
+
return "waiting";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fsyncDirectory(dir: string): void {
|
|
71
|
+
const fd = openSync(dir, "r");
|
|
72
|
+
try {
|
|
73
|
+
fsyncSync(fd);
|
|
74
|
+
} finally {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Create `path` with `content` only if it does not exist (O_EXCL). */
|
|
80
|
+
export function createExclusive(path: string, content: string): boolean {
|
|
81
|
+
let fd: number;
|
|
82
|
+
try {
|
|
83
|
+
fd = openSync(path, "wx", 0o600);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
writeSync(fd, content);
|
|
92
|
+
fsyncSync(fd);
|
|
93
|
+
} finally {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
fsyncDirectory(dirname(path));
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function writeAtomicSync(path: string, content: string): void {
|
|
101
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
102
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
103
|
+
const fd = openSync(tmp, "r");
|
|
104
|
+
try {
|
|
105
|
+
fsyncSync(fd);
|
|
106
|
+
} finally {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
renameSync(tmp, path);
|
|
110
|
+
fsyncDirectory(dirname(path));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type SkipClass = "process-end" | "veto";
|
|
114
|
+
|
|
115
|
+
/** First writer wins: a veto written earlier is kept. */
|
|
116
|
+
export function writeSkip(plan: ContinuePlan, skipClass: SkipClass, reason: string, nowMs = Date.now()): boolean {
|
|
117
|
+
if (existsSync(join(plan.ledgerDir, `${plan.key}.claim`))) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return createExclusive(
|
|
121
|
+
join(plan.ledgerDir, `${plan.key}.skip`),
|
|
122
|
+
`${JSON.stringify({ class: skipClass, reason, token: plan.token, pid: process.pid, atMs: nowMs })}\n`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reason of an existing `K.skip` from any writer, for example a veto from TWS
|
|
128
|
+
* (`{"class": "veto", "reason": "skipped-in-tws", …}`); null without one. A
|
|
129
|
+
* file that cannot be read or parsed still counts as a veto (fail closed).
|
|
130
|
+
*/
|
|
131
|
+
export function existingSkipReason(plan: ContinuePlan): string | null {
|
|
132
|
+
const path = join(plan.ledgerDir, `${plan.key}.skip`);
|
|
133
|
+
let text: string;
|
|
134
|
+
try {
|
|
135
|
+
text = readFileSync(path, "utf8");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return isErrno(error, "ENOENT") ? null : "vetoed";
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const value: unknown = JSON.parse(text);
|
|
141
|
+
if (typeof value === "object" && value !== null && "reason" in value && typeof value.reason === "string" && value.reason !== "") {
|
|
142
|
+
return value.reason;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// a skip that is still being written or is damaged is a veto without a reason
|
|
146
|
+
}
|
|
147
|
+
return "vetoed";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
151
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function writeOutcome(plan: ContinuePlan, outcome: string, continuationId: string, extra: Record<string, unknown> = {}): void {
|
|
155
|
+
writeAtomicSync(
|
|
156
|
+
join(plan.ledgerDir, `${plan.key}.outcome`),
|
|
157
|
+
`${JSON.stringify({ outcome, continuationId, atMs: Date.now(), pid: process.pid, ...extra })}\n`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// -- dispatch lock -----------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export type DispatchLock = { path: string; inode: number };
|
|
164
|
+
|
|
165
|
+
export function acquireDispatchLock(ledgerDir: string, continuationId: string): DispatchLock | undefined {
|
|
166
|
+
const path = join(ledgerDir, "dispatch.lock");
|
|
167
|
+
const tmp = join(ledgerDir, `.dispatch.lock.${process.pid}`);
|
|
168
|
+
writeFileSync(tmp, `${JSON.stringify({ pid: process.pid, continuationId, atMs: Date.now() })}\n`, { mode: 0o600 });
|
|
169
|
+
const fd = openSync(tmp, "r");
|
|
170
|
+
try {
|
|
171
|
+
fsyncSync(fd);
|
|
172
|
+
} finally {
|
|
173
|
+
closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
linkSync(tmp, path);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
} finally {
|
|
183
|
+
unlinkSync(tmp);
|
|
184
|
+
}
|
|
185
|
+
return { path, inode: statSync(path).ino };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function releaseDispatchLock(lock: DispatchLock): void {
|
|
189
|
+
try {
|
|
190
|
+
if (statSync(lock.path).ino === lock.inode) {
|
|
191
|
+
unlinkSync(lock.path);
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Already reclaimed; never touch a successor's lock.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Test-only pause points for crash drills (tmux-recovery docs/pi-auto-continue.md §6.2). A point
|
|
200
|
+
* is active when `PI_TMUX_CONTINUE_TEST_PAUSE_AT` names it or the file `<ledger>/.test-pause-at`
|
|
201
|
+
* contains its name; the ledger directory is private to the user (0700). The executor then
|
|
202
|
+
* records `<ledger>/.test-reached-<name>` with its pid and blocks synchronously, so the drill can
|
|
203
|
+
* kill the process exactly there, until `<ledger>/.test-release-<name>` exists, at most 120 s.
|
|
204
|
+
*/
|
|
205
|
+
export function testCheckpoint(ledgerDir: string, name: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
206
|
+
let active = env.PI_TMUX_CONTINUE_TEST_PAUSE_AT === name;
|
|
207
|
+
if (!active) {
|
|
208
|
+
try {
|
|
209
|
+
active = readFileSync(join(ledgerDir, ".test-pause-at"), "utf8").trim() === name;
|
|
210
|
+
} catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!active) return;
|
|
215
|
+
writeFileSync(join(ledgerDir, `.test-reached-${name}`), `${JSON.stringify({ pid: process.pid, atMs: Date.now() })}\n`);
|
|
216
|
+
const release = join(ledgerDir, `.test-release-${name}`);
|
|
217
|
+
const deadline = Date.now() + 120_000;
|
|
218
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
219
|
+
while (!existsSync(release) && Date.now() < deadline) {
|
|
220
|
+
try {
|
|
221
|
+
Atomics.wait(cell, 0, 0, 100);
|
|
222
|
+
} catch {
|
|
223
|
+
execFileSync("sleep", ["0.1"]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function lastDispatchMs(ledgerDir: string): number | null {
|
|
229
|
+
try {
|
|
230
|
+
const value = JSON.parse(readFileSync(join(ledgerDir, ".last-dispatch"), "utf8"));
|
|
231
|
+
return typeof value?.atMs === "number" ? value.atMs : null;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// -- executor rules E1-E7 ----------------------------------------------------
|
|
238
|
+
|
|
239
|
+
export type LiveFacts = {
|
|
240
|
+
nowMs: number;
|
|
241
|
+
ackResumed: boolean;
|
|
242
|
+
sessionId: string;
|
|
243
|
+
sessionFile: string | null;
|
|
244
|
+
paneKey: string | null;
|
|
245
|
+
lastMessageEntryId: string | null;
|
|
246
|
+
idle: boolean;
|
|
247
|
+
pendingMessages: boolean;
|
|
248
|
+
editorText: string;
|
|
249
|
+
userActive: boolean;
|
|
250
|
+
promptOpen: boolean;
|
|
251
|
+
watchdogAlive: boolean;
|
|
252
|
+
mode: string;
|
|
253
|
+
holdPresent: boolean;
|
|
254
|
+
/** Reason of a `K.skip` that another writer (TWS) created; null without one. */
|
|
255
|
+
skipReason: string | null;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export type Verdict =
|
|
259
|
+
| { kind: "dispatch" }
|
|
260
|
+
| { kind: "wait"; untilMs: number }
|
|
261
|
+
| { kind: "skip"; skipClass: SkipClass; reason: string }
|
|
262
|
+
| { kind: "stop"; reason: string }
|
|
263
|
+
/** An existing `K.skip` decided it already; nothing more is written. */
|
|
264
|
+
| { kind: "vetoed"; reason: string };
|
|
265
|
+
|
|
266
|
+
/** E1-E7 against the plan as currently on disk. Caller holds the dispatch lock (E7). */
|
|
267
|
+
export function decide(plan: ContinuePlan, onDisk: ContinuePlan | undefined, facts: LiveFacts, ledgerHasClaim: boolean, lastDispatch: number | null): Verdict {
|
|
268
|
+
// E1
|
|
269
|
+
if (!facts.ackResumed) return { kind: "skip", skipClass: "process-end", reason: "ack" };
|
|
270
|
+
// E7: plan state, claim, deadline, spacing
|
|
271
|
+
if (!onDisk || onDisk.token !== plan.token || onDisk.state !== "released") return { kind: "stop", reason: "not-released" };
|
|
272
|
+
if (ledgerHasClaim) return { kind: "stop", reason: "claimed" };
|
|
273
|
+
// A veto from outside (TWS) counts until the claim; after the claim it has no effect.
|
|
274
|
+
if (facts.skipReason !== null) return { kind: "vetoed", reason: facts.skipReason };
|
|
275
|
+
if (onDisk.deadlineMs !== null && facts.nowMs > onDisk.deadlineMs) return { kind: "skip", skipClass: "process-end", reason: "timeout" };
|
|
276
|
+
if (onDisk.slotMs !== null && facts.nowMs < onDisk.slotMs) return { kind: "wait", untilMs: onDisk.slotMs };
|
|
277
|
+
if (lastDispatch !== null && facts.nowMs < lastDispatch + plan.spacingMs) return { kind: "wait", untilMs: lastDispatch + plan.spacingMs };
|
|
278
|
+
// E6
|
|
279
|
+
if (facts.mode !== "on") return { kind: "skip", skipClass: "veto", reason: "disabled" };
|
|
280
|
+
if (facts.holdPresent) return { kind: "skip", skipClass: "veto", reason: "hold" };
|
|
281
|
+
// E2
|
|
282
|
+
if (facts.sessionId !== plan.sessionId || facts.sessionFile !== plan.sessionFile || facts.paneKey !== plan.paneKey) {
|
|
283
|
+
return { kind: "skip", skipClass: "veto", reason: "identity" };
|
|
284
|
+
}
|
|
285
|
+
// E3
|
|
286
|
+
if (facts.lastMessageEntryId !== plan.frozenLeafId) return { kind: "skip", skipClass: "veto", reason: "new-entry" };
|
|
287
|
+
// E4
|
|
288
|
+
if (facts.userActive || facts.editorText.trim() !== "" || facts.promptOpen) {
|
|
289
|
+
return { kind: "skip", skipClass: "veto", reason: "user-active" };
|
|
290
|
+
}
|
|
291
|
+
if (!facts.idle || facts.pendingMessages) return { kind: "skip", skipClass: "veto", reason: "busy" };
|
|
292
|
+
// E5
|
|
293
|
+
if (facts.watchdogAlive) return { kind: "skip", skipClass: "veto", reason: "watchdog" };
|
|
294
|
+
return { kind: "dispatch" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function readMode(configPath: string): string {
|
|
298
|
+
try {
|
|
299
|
+
const value = JSON.parse(readFileSync(configPath, "utf8"));
|
|
300
|
+
return typeof value?.mode === "string" ? value.mode : "off";
|
|
301
|
+
} catch {
|
|
302
|
+
return "off";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** pi-session-watchdog keeps its pid file in /tmp/pi-session-watchdog-<session id>/. */
|
|
307
|
+
export function watchdogAlive(sessionId: string, root = "/tmp"): boolean {
|
|
308
|
+
const pidFile = join(root, `pi-session-watchdog-${sessionId}`, "watchdog.pid");
|
|
309
|
+
let pid: number;
|
|
310
|
+
try {
|
|
311
|
+
pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
316
|
+
try {
|
|
317
|
+
process.kill(pid, 0);
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).includes("pi-session-watchdog");
|
|
323
|
+
} catch {
|
|
324
|
+
return true; // cannot tell: fail closed
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
type BranchEntry = { id?: string; type?: string; message?: { role?: string } };
|
|
329
|
+
|
|
330
|
+
export function lastMessageEntryId(branch: readonly BranchEntry[]): string | null {
|
|
331
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
332
|
+
const entry = branch[index]!;
|
|
333
|
+
if (entry.type === "custom_message" || (entry.type === "message" && entry.message?.role !== "system")) {
|
|
334
|
+
return entry.id ?? null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// -- the visible message -----------------------------------------------------
|
|
341
|
+
|
|
342
|
+
function elapsedGerman(ms: number): string {
|
|
343
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
344
|
+
const unit = (value: number, one: string, many: string) => `${value} ${value === 1 ? one : many}`;
|
|
345
|
+
if (seconds < 90) return unit(seconds, "Sekunde", "Sekunden");
|
|
346
|
+
const minutes = Math.round(seconds / 60);
|
|
347
|
+
if (minutes < 90) return unit(minutes, "Minute", "Minuten");
|
|
348
|
+
const hours = Math.round(minutes / 60);
|
|
349
|
+
if (hours < 36) return unit(hours, "Stunde", "Stunden");
|
|
350
|
+
return unit(Math.round(hours / 24), "Tag", "Tagen");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function dateGerman(ms: number): string {
|
|
354
|
+
const date = new Date(ms);
|
|
355
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
356
|
+
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} um ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function continuationMessage(plan: ContinuePlan, nowMs: number, continuationId: string): string {
|
|
360
|
+
const stopped = plan.interruptedAtMs ?? nowMs;
|
|
361
|
+
const how = plan.cause === "hard" ? "abrupt beendet (Absturz oder Stromausfall)" : "beendet (Terminal geschlossen, tmux beendet oder Neustart)";
|
|
362
|
+
const tools = plan.toolCalls.length > 0
|
|
363
|
+
? `Ohne Ergebnis unterbrochen: ${plan.toolCalls.map((call) => `\`${call.name}\` (${call.id})`).join(", ")}.`
|
|
364
|
+
: "Es war kein Werkzeugaufruf offen.";
|
|
365
|
+
return [
|
|
366
|
+
`[tmux-recovery] Diese Session lief, als sie am ${dateGerman(stopped)} ${how} wurde, vor ${elapsedGerman(nowMs - stopped)}.`,
|
|
367
|
+
"Pi wurde mit genau dieser Session wiederhergestellt.",
|
|
368
|
+
tools,
|
|
369
|
+
"Prüfe zuerst den tatsächlichen Stand (Dateien, Git, Prozesse, Remote-Effekte), bevor du eine Aktion wiederholst, die schon gelaufen sein kann.",
|
|
370
|
+
"Ob Hintergrundprozesse, Subagents oder Remote-Jobs von vorher noch laufen und was sie getan haben, ist unbekannt: prüfe das, bevor du sie neu startest.",
|
|
371
|
+
"Setze danach die ursprüngliche Aufgabe fort.",
|
|
372
|
+
`continuationId=${continuationId}`,
|
|
373
|
+
].join("\n");
|
|
374
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { cleanupSupersededSessionMappings, ensureDirectory, writeAtomic } from "./atomic-write.ts";
|
|
4
|
+
import { mappingFileName, paneIdMappingFileName } from "./pane-key.ts";
|
|
5
|
+
import { sessionFileMatches } from "./session-file.ts";
|
|
6
|
+
import { buildSessionMapping, serializeSessionMapping } from "./session-mapping.ts";
|
|
7
|
+
import type { PaneInfo, SessionInfo } from "./status.ts";
|
|
8
|
+
|
|
9
|
+
export type MappingPublication = {
|
|
10
|
+
keyFileName: string;
|
|
11
|
+
paneIdFileName: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function publishSessionMapping(
|
|
15
|
+
stateDir: string,
|
|
16
|
+
info: PaneInfo,
|
|
17
|
+
session: SessionInfo,
|
|
18
|
+
sessionId: string,
|
|
19
|
+
nowMs = Date.now(),
|
|
20
|
+
): Promise<MappingPublication | undefined> {
|
|
21
|
+
if (
|
|
22
|
+
session.sessionFile === null
|
|
23
|
+
|| info.backend !== "tmux"
|
|
24
|
+
|| info.tmuxServerPid === null
|
|
25
|
+
|| !(await sessionFileMatches(session.sessionFile, sessionId))
|
|
26
|
+
) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const keyFileName = mappingFileName(info.paneKey);
|
|
30
|
+
const paneIdFileName = paneIdMappingFileName(info.paneId);
|
|
31
|
+
const mapping = buildSessionMapping(
|
|
32
|
+
info.paneKey,
|
|
33
|
+
info.paneId,
|
|
34
|
+
info.tmuxServerPid,
|
|
35
|
+
session.sessionFile,
|
|
36
|
+
sessionId,
|
|
37
|
+
session.cwd,
|
|
38
|
+
session.sessionName,
|
|
39
|
+
nowMs,
|
|
40
|
+
);
|
|
41
|
+
const serialized = serializeSessionMapping(mapping);
|
|
42
|
+
await ensureDirectory(stateDir);
|
|
43
|
+
// Publish both current identities before removing stale names. A crash can
|
|
44
|
+
// leave duplicates, but never a gap; the next reconciliation removes them.
|
|
45
|
+
await writeAtomic(join(stateDir, keyFileName), serialized, 0o600);
|
|
46
|
+
await writeAtomic(join(stateDir, paneIdFileName), serialized, 0o600);
|
|
47
|
+
await cleanupSupersededSessionMappings(
|
|
48
|
+
stateDir,
|
|
49
|
+
[keyFileName, paneIdFileName],
|
|
50
|
+
session.sessionFile,
|
|
51
|
+
{ paneId: info.paneId, tmuxServerPid: info.tmuxServerPid },
|
|
52
|
+
);
|
|
53
|
+
return { keyFileName, paneIdFileName };
|
|
54
|
+
}
|
package/src/domain/pane-info.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function parsePaneInfoOutput(stdout: string, paneId: string): PaneInfo |
|
|
|
12
12
|
return undefined;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const [rawPaneKey, sessionName, rawWindowIndex, rawPaneIndex] = trimmed.split("\t");
|
|
15
|
+
const [rawPaneKey, sessionName, rawWindowIndex, rawPaneIndex, tmuxServerPid] = trimmed.split("\t");
|
|
16
16
|
const paneKey = normalizePaneKey(rawPaneKey ?? "");
|
|
17
17
|
if (!paneKey || !sessionName) {
|
|
18
18
|
return undefined;
|
|
@@ -22,6 +22,7 @@ export function parsePaneInfoOutput(stdout: string, paneId: string): PaneInfo |
|
|
|
22
22
|
backend: "tmux",
|
|
23
23
|
paneKey,
|
|
24
24
|
paneId,
|
|
25
|
+
tmuxServerPid: tmuxServerPid || null,
|
|
25
26
|
sessionName,
|
|
26
27
|
windowIndex: parseTmuxIndex(rawWindowIndex ?? "0"),
|
|
27
28
|
paneIndex: parseTmuxIndex(rawPaneIndex ?? "0"),
|
|
@@ -46,6 +47,7 @@ export function mexPaneInfo(sessionId: string | undefined): PaneInfo | undefined
|
|
|
46
47
|
backend: "mex",
|
|
47
48
|
paneKey: `mex:${sessionId}`,
|
|
48
49
|
paneId: sessionId,
|
|
50
|
+
tmuxServerPid: null,
|
|
49
51
|
sessionName: sessionId,
|
|
50
52
|
windowIndex: 0,
|
|
51
53
|
paneIndex: 0,
|
|
@@ -85,6 +87,7 @@ export function parseZellijPaneInfo(
|
|
|
85
87
|
backend: "zellij",
|
|
86
88
|
paneKey: `${sessionName}:${tabPosition}.${numericId}`,
|
|
87
89
|
paneId: normalizedPaneId,
|
|
90
|
+
tmuxServerPid: null,
|
|
88
91
|
sessionName,
|
|
89
92
|
windowIndex: tabPosition,
|
|
90
93
|
paneIndex: numericId,
|
package/src/domain/pane-key.ts
CHANGED
|
@@ -48,6 +48,11 @@ export function mappingFileName(key: string): string {
|
|
|
48
48
|
return `${keyFileStem(key)}.session`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Stable live-pane alias used to reconcile index changes while tmux runs. */
|
|
52
|
+
export function paneIdMappingFileName(paneId: string): string {
|
|
53
|
+
return `pane-id-${keyFileStem(paneId)}.session`;
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
/** File name of the tws status file for a raw pane key. */
|
|
52
57
|
export function statusFileName(key: string): string {
|
|
53
58
|
return `${keyFileStem(key)}.json`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type PaneOwnershipEvidence = {
|
|
2
|
+
mode: string;
|
|
3
|
+
stdinIsTty: boolean;
|
|
4
|
+
subagentChild: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function paneOwnershipEvidence(
|
|
8
|
+
mode: string,
|
|
9
|
+
stdinIsTty: boolean | undefined,
|
|
10
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
11
|
+
): PaneOwnershipEvidence {
|
|
12
|
+
return {
|
|
13
|
+
mode,
|
|
14
|
+
stdinIsTty: stdinIsTty === true,
|
|
15
|
+
subagentChild: env.PI_SUBAGENT_CHILD === "1",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ownsInteractivePane(evidence: PaneOwnershipEvidence): boolean {
|
|
20
|
+
return evidence.mode === "tui"
|
|
21
|
+
&& evidence.stdinIsTty
|
|
22
|
+
&& !evidence.subagentChild;
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export type SessionHeader = {
|
|
4
|
+
id: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
version: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseSessionHeader(line: string): SessionHeader | undefined {
|
|
14
|
+
let value: unknown;
|
|
15
|
+
try {
|
|
16
|
+
value = JSON.parse(line);
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (
|
|
21
|
+
!isRecord(value)
|
|
22
|
+
|| value.type !== "session"
|
|
23
|
+
|| typeof value.id !== "string"
|
|
24
|
+
|| value.id === ""
|
|
25
|
+
|| typeof value.cwd !== "string"
|
|
26
|
+
|| value.cwd === ""
|
|
27
|
+
|| typeof value.version !== "number"
|
|
28
|
+
|| !Number.isInteger(value.version)
|
|
29
|
+
|| value.version < 1
|
|
30
|
+
|| value.version > 3
|
|
31
|
+
) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return { id: value.id, cwd: value.cwd, version: value.version };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readSessionHeader(path: string): Promise<SessionHeader | undefined> {
|
|
38
|
+
let handle;
|
|
39
|
+
try {
|
|
40
|
+
handle = await open(path, "r");
|
|
41
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
42
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
43
|
+
const firstLine = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/, 1)[0] ?? "";
|
|
44
|
+
return parseSessionHeader(firstLine);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
} finally {
|
|
48
|
+
await handle?.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function sessionFileMatches(path: string, sessionId: string): Promise<boolean> {
|
|
53
|
+
const header = await readSessionHeader(path);
|
|
54
|
+
return header?.id === sessionId;
|
|
55
|
+
}
|