pi-tmux-session-map 0.3.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 ADDED
@@ -0,0 +1,405 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { execFile } from "node:child_process";
3
+ import { open, utimes } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { cleanupStaleSessionMappings, cleanupStaleTempFiles, cleanupSupersededSessionMappings, cleanupSupersededStatusFiles, ensureDirectory, writeAtomic } from "./domain/atomic-write.ts";
8
+ import { createAsyncQueue } from "./domain/async-queue.ts";
9
+ import { createThrottledErrorReporter } from "./domain/error-reporter.ts";
10
+ import { PANE_KEY_FORMAT, defaultStateDir, mappingFileName, statusFileName } from "./domain/pane-key.ts";
11
+ import { parsePaneInfoOutput, parseZellijPaneInfo, mexPaneInfo } from "./domain/pane-info.ts";
12
+ import { buildRestoreAck, restoreAckPath, restoreExpectationFromEnvironment, serializeRestoreAck } from "./domain/restore-ack.ts";
13
+ import { buildSessionMapping, serializeSessionMapping } from "./domain/session-mapping.ts";
14
+ 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
+
17
+ function execFileAsync(file: string, args: readonly string[]): Promise<{ stdout: string }> {
18
+ return new Promise((resolve, reject) => {
19
+ execFile(file, args, { timeout: muxTimeoutMs }, (error, stdout) => {
20
+ if (error) {
21
+ reject(error);
22
+ return;
23
+ }
24
+ resolve({ stdout });
25
+ });
26
+ });
27
+ }
28
+ const stateDir = process.env.PI_TMUX_SESSION_MAP_STATE_DIR ?? defaultStateDir();
29
+ const twsConfigDir = process.env.PI_TMUX_SESSION_MAP_TWS_CONFIG_DIR ?? join(homedir(), ".config", "tws");
30
+ const twsStatusDir = process.env.PI_TMUX_SESSION_MAP_TWS_STATUS_DIR ?? join(twsConfigDir, "pi-status");
31
+ const twsTriggerFile = process.env.PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE ?? join(twsConfigDir, "agent.trigger");
32
+ const tmuxBin = process.env.PI_TMUX_SESSION_MAP_TMUX_BIN ?? "tmux";
33
+ const zellijBin = process.env.PI_TMUX_SESSION_MAP_ZELLIJ_BIN ?? "zellij";
34
+ const muxTimeoutMs = 1000;
35
+ const cleanupMaxAgeMs = 24 * 60 * 60 * 1000;
36
+ const PANE_INFO_FORMAT = `${PANE_KEY_FORMAT}\t#{session_name}\t#{window_index}\t#{pane_index}`;
37
+
38
+ type StatusAction = WorkState | "refresh" | null;
39
+
40
+ type EventAction = {
41
+ mapping: boolean;
42
+ status: StatusAction;
43
+ cleanup?: boolean;
44
+ restoreReason?: string;
45
+ refreshPane?: boolean;
46
+ };
47
+
48
+ type ArtifactCache = {
49
+ paneInfo: PaneInfo | undefined;
50
+ paneInfoResolved: boolean;
51
+ mappingSignature: string | null;
52
+ statusSignature: string | null;
53
+ };
54
+
55
+ type AgentEndMessage = {
56
+ role: string;
57
+ stopReason?: string;
58
+ };
59
+
60
+ export function finalWorkStateForMessages(messages: readonly AgentEndMessage[]): FinalWorkState {
61
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
62
+ const message = messages[index];
63
+ if (message?.role !== "assistant") {
64
+ continue;
65
+ }
66
+ switch (message.stopReason) {
67
+ case "error":
68
+ return "failed";
69
+ case "aborted":
70
+ return "cancelled";
71
+ case "length":
72
+ return "incomplete";
73
+ default:
74
+ return "done";
75
+ }
76
+ }
77
+ return "done";
78
+ }
79
+
80
+ export function provisionalWorkState(finalState: FinalWorkState): WorkState {
81
+ return finalState === "failed" ? "retrying" : finalState;
82
+ }
83
+
84
+ export async function resolvePaneInfo(): Promise<PaneInfo | undefined> {
85
+ const tmuxPane = process.env.TMUX_PANE;
86
+ if (process.env.TMUX && tmuxPane) {
87
+ try {
88
+ const { stdout } = await execFileAsync(
89
+ tmuxBin,
90
+ ["display-message", "-p", "-t", tmuxPane, PANE_INFO_FORMAT],
91
+ );
92
+ return parsePaneInfoOutput(stdout, tmuxPane);
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ const zellijSession = process.env.ZELLIJ_SESSION_NAME;
99
+ const zellijPane = process.env.ZELLIJ_PANE_ID;
100
+ if (process.env.ZELLIJ && zellijSession && zellijPane) {
101
+ try {
102
+ const { stdout } = await execFileAsync(
103
+ zellijBin,
104
+ ["--session", zellijSession, "action", "list-panes", "--json", "--all"],
105
+ );
106
+ return parseZellijPaneInfo(stdout, zellijSession, zellijPane);
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ const mexSession = process.env.MEX_SESSION;
113
+ if (mexSession && !process.env.TMUX_PANE) {
114
+ return mexPaneInfo(mexSession);
115
+ }
116
+
117
+ return undefined;
118
+ }
119
+
120
+ async function touchTwsTrigger(path = twsTriggerFile): Promise<void> {
121
+ const now = new Date();
122
+ try {
123
+ await utimes(path, now, now);
124
+ } catch {
125
+ try {
126
+ const handle = await open(path, "w", 0o600);
127
+ await handle.close();
128
+ } catch {
129
+ // Status files are still picked up by tws on its periodic scan.
130
+ }
131
+ }
132
+ }
133
+
134
+ export function sessionMappingSignature(
135
+ info: PaneInfo,
136
+ session: SessionInfo,
137
+ sessionId: string,
138
+ ): string | null {
139
+ if (session.sessionFile === null || info.backend !== "tmux") {
140
+ return null;
141
+ }
142
+ return JSON.stringify([info.paneKey, session.sessionFile, sessionId, session.cwd, session.sessionName]);
143
+ }
144
+
145
+ export function twsStatusSignature(
146
+ info: PaneInfo,
147
+ session: SessionInfo,
148
+ state: WorkState,
149
+ startedAtMs: number | null,
150
+ finishedAtMs: number | null,
151
+ ): string {
152
+ return JSON.stringify([
153
+ info.backend,
154
+ info.paneKey,
155
+ info.paneId,
156
+ info.sessionName,
157
+ info.windowIndex,
158
+ info.paneIndex,
159
+ session.cwd,
160
+ session.sessionFile,
161
+ session.sessionName,
162
+ state,
163
+ startedAtMs,
164
+ finishedAtMs,
165
+ ]);
166
+ }
167
+
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);
184
+ }
185
+
186
+ async function writeRestoreAcknowledgement(
187
+ info: PaneInfo,
188
+ session: SessionInfo,
189
+ sessionId: string,
190
+ reason: string,
191
+ ): Promise<void> {
192
+ const expectation = restoreExpectationFromEnvironment();
193
+ if (!expectation) {
194
+ return;
195
+ }
196
+ const ack = buildRestoreAck(expectation, info, session, sessionId, reason, Date.now());
197
+ await ensureDirectory(expectation.ackDir);
198
+ await writeAtomic(restoreAckPath(expectation), serializeRestoreAck(ack), 0o600);
199
+ }
200
+
201
+ function getSessionInfo(ctx: ExtensionContext): SessionInfo {
202
+ return {
203
+ cwd: ctx.cwd,
204
+ sessionFile: ctx.sessionManager.getSessionFile() ?? null,
205
+ sessionName: ctx.sessionManager.getSessionName() ?? null,
206
+ };
207
+ }
208
+
209
+ function statusDirectory(info: PaneInfo): string {
210
+ switch (info.backend) {
211
+ case "zellij":
212
+ return process.env.PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR
213
+ ?? join(homedir(), ".config", "tws-zellij", "pi-status");
214
+ case "tmux":
215
+ case "mex":
216
+ // mex shares the default tws pi-status dir + trigger with tmux.
217
+ return twsStatusDir;
218
+ }
219
+ }
220
+
221
+ function triggerFile(info: PaneInfo): string {
222
+ switch (info.backend) {
223
+ case "zellij":
224
+ return process.env.PI_TMUX_SESSION_MAP_ZELLIJ_TRIGGER_FILE
225
+ ?? join(homedir(), ".config", "tws-zellij", "agent.trigger");
226
+ case "tmux":
227
+ case "mex":
228
+ return twsTriggerFile;
229
+ }
230
+ }
231
+
232
+ async function writeTwsStatus(
233
+ info: PaneInfo,
234
+ session: SessionInfo,
235
+ state: WorkState,
236
+ updatedAtMs: number,
237
+ startedAtMs: number | null,
238
+ finishedAtMs: number | null,
239
+ ): Promise<void> {
240
+ const payload = buildTwsStatusPayload(info, session, state, updatedAtMs, startedAtMs, finishedAtMs);
241
+
242
+ const directory = statusDirectory(info);
243
+ const fileName = statusFileName(info.paneKey);
244
+ await ensureDirectory(directory);
245
+ await writeAtomic(join(directory, fileName), serializeStatusPayload(payload), 0o600);
246
+ await cleanupSupersededStatusFiles(directory, fileName, info.paneId);
247
+
248
+ await touchTwsTrigger(triggerFile(info));
249
+ }
250
+
251
+ async function cleanupStaleFiles(): Promise<void> {
252
+ const zellijStatusDir = process.env.PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR
253
+ ?? join(homedir(), ".config", "tws-zellij", "pi-status");
254
+ await cleanupStaleTempFiles(stateDir, cleanupMaxAgeMs);
255
+ await cleanupStaleTempFiles(twsStatusDir, cleanupMaxAgeMs);
256
+ await cleanupStaleTempFiles(zellijStatusDir, cleanupMaxAgeMs);
257
+ await cleanupStaleSessionMappings(stateDir, 30 * cleanupMaxAgeMs);
258
+ }
259
+
260
+ async function applyEventAction(
261
+ ctx: ExtensionContext,
262
+ action: EventAction,
263
+ runtimeState: RuntimeState,
264
+ artifactCache: ArtifactCache,
265
+ ): Promise<RuntimeState> {
266
+ const session = getSessionInfo(ctx);
267
+ const mappingSessionFile = action.mapping ? session.sessionFile : null;
268
+ if (mappingSessionFile === null && action.status === null) {
269
+ return runtimeState;
270
+ }
271
+
272
+ if (action.refreshPane === true || !artifactCache.paneInfoResolved) {
273
+ artifactCache.paneInfo = await resolvePaneInfo();
274
+ artifactCache.paneInfoResolved = true;
275
+ }
276
+ const info = artifactCache.paneInfo;
277
+ if (!info) {
278
+ return runtimeState;
279
+ }
280
+
281
+ 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;
286
+ }
287
+
288
+ if (action.restoreReason !== undefined && info.backend === "tmux") {
289
+ await writeRestoreAcknowledgement(info, session, sessionId, action.restoreReason);
290
+ }
291
+
292
+ if (action.status === null) {
293
+ return runtimeState;
294
+ }
295
+
296
+ const now = Date.now();
297
+ const transition =
298
+ action.status === "refresh"
299
+ ? refreshStatusTransition(runtimeState, now)
300
+ : action.status === runtimeState.lastState
301
+ ? refreshStatusTransition(runtimeState, now)
302
+ : nextStatusTransition(runtimeState, action.status, now);
303
+ if (!transition.shouldWrite) {
304
+ return transition;
305
+ }
306
+
307
+ const statusSignature = twsStatusSignature(
308
+ info,
309
+ session,
310
+ transition.state,
311
+ transition.startedAtMs,
312
+ transition.finishedAtMs,
313
+ );
314
+ if (statusSignature === artifactCache.statusSignature) {
315
+ return transition;
316
+ }
317
+
318
+ await writeTwsStatus(info, session, transition.state, now, transition.startedAtMs, transition.finishedAtMs);
319
+ artifactCache.statusSignature = statusSignature;
320
+ return transition;
321
+ }
322
+
323
+ /**
324
+ * Maps each tmux pane (session:window.pane) to the Pi session file running in
325
+ * it, so tmux-resurrect can restore the exact session (e.g. via a
326
+ * `pi-tmux-resume` wrapper) instead of blindly continuing the newest session
327
+ * of the pane's working directory.
328
+ *
329
+ * Also writes tws-compatible Pi work-status sidecar JSON files so tws can show
330
+ * active/retrying state and distinguish successful, cancelled, incomplete, and
331
+ * failed outcomes.
332
+ */
333
+ export default function tmuxSessionMap(pi: ExtensionAPI) {
334
+ const queue = createAsyncQueue();
335
+ const errorReporter = createThrottledErrorReporter();
336
+ let runtimeState = createRuntimeState();
337
+ let pendingFinalState: FinalWorkState = "done";
338
+ const artifactCache: ArtifactCache = {
339
+ paneInfo: undefined,
340
+ paneInfoResolved: false,
341
+ mappingSignature: null,
342
+ statusSignature: null,
343
+ };
344
+
345
+ const enqueue = async (ctx: ExtensionContext, action: EventAction) => {
346
+ await queue.enqueue(async () => {
347
+ try {
348
+ if (action.cleanup === true) {
349
+ await cleanupStaleFiles();
350
+ }
351
+ runtimeState = await applyEventAction(ctx, action, runtimeState, artifactCache);
352
+ } catch (error) {
353
+ errorReporter.report("pi-tmux-session-map: failed to update sidecars", error);
354
+ }
355
+ });
356
+ };
357
+
358
+ // Intentionally no cleanup on session_shutdown: the mapping must survive a
359
+ // tmux server restart so resurrect can restore the exact session.
360
+ pi.on("session_start", async (event, ctx) => {
361
+ artifactCache.paneInfo = undefined;
362
+ artifactCache.paneInfoResolved = false;
363
+ artifactCache.mappingSignature = null;
364
+ artifactCache.statusSignature = null;
365
+ runtimeState = createRuntimeState();
366
+ await enqueue(ctx, {
367
+ mapping: true,
368
+ status: "idle",
369
+ cleanup: true,
370
+ restoreReason: event.reason,
371
+ refreshPane: true,
372
+ });
373
+ });
374
+ pi.on("session_info_changed", async (_event, ctx) => {
375
+ await enqueue(ctx, { mapping: true, status: "refresh", refreshPane: true });
376
+ });
377
+ pi.on("agent_start", async (_event, ctx) => {
378
+ pendingFinalState = "done";
379
+ await enqueue(ctx, { mapping: true, status: "working", refreshPane: true });
380
+ });
381
+ // agent_end may be followed by automatic retry/compaction. Keep failures
382
+ // provisional until agent_settled confirms that no continuation will run.
383
+ pi.on("agent_end", async (event, ctx) => {
384
+ pendingFinalState = finalWorkStateForMessages(event.messages);
385
+ await enqueue(ctx, {
386
+ mapping: true,
387
+ refreshPane: true,
388
+ // Technical errors may still recover through Pi's automatic retry or
389
+ // compaction flow. User aborts and token-limit responses are already
390
+ // definitive and should not briefly look like retries.
391
+ status: provisionalWorkState(pendingFinalState),
392
+ });
393
+ });
394
+ pi.on("agent_settled", async (_event, ctx) => {
395
+ await enqueue(ctx, { mapping: true, status: pendingFinalState });
396
+ });
397
+ pi.on("session_shutdown", async (_event, ctx) => {
398
+ await enqueue(ctx, { mapping: false, status: "shutdown" });
399
+ artifactCache.paneInfo = undefined;
400
+ artifactCache.paneInfoResolved = false;
401
+ artifactCache.mappingSignature = null;
402
+ artifactCache.statusSignature = null;
403
+ runtimeState = createRuntimeState();
404
+ });
405
+ }