pi-tmux-session-map 0.3.0 → 0.4.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 CHANGED
@@ -10,27 +10,31 @@ It also writes [tws](https://github.com/ytaskiran/tws)-compatible Pi work-status
10
10
 
11
11
  ## How it works
12
12
 
13
- The extension resolves a stable tmux pane key:
13
+ The extension records two complementary tmux identities:
14
14
 
15
+ ```text
16
+ restart identity: #{session_name}:#{window_index}.#{pane_index}
17
+ live identity: (#{pid}, #{pane_id})
15
18
  ```
16
- #{session_name}:#{window_index}.#{pane_index}
17
- ```
18
19
 
19
- That key is stable across tmux server restarts (unlike pane ids like `%42`), which is exactly what tmux-resurrect restores. File names use a readable sanitized prefix plus a 12-character SHA-256 suffix so different pane keys cannot collide and very long tmux session names stay below filesystem filename limits.
20
+ The pane key is what tmux-resurrect recreates across server restarts. The server-PID/pane-ID pair remains stable while one tmux server is running, even when windows or panes are renumbered. A periodic, jittered reconciliation resolves the current key for the live pane ID and atomically republishes the mapping under the new key. Pane IDs are never trusted across a tmux server restart because tmux can reuse them.
21
+
22
+ File names use a readable sanitized prefix plus a 12-character SHA-256 suffix so different identities cannot collide and very long tmux session names stay below filesystem filename limits.
20
23
 
21
24
  For tmux it writes the exact-resume mapping and status sidecar. For Zellij it writes only the status sidecar because Zellij owns session resurrection itself:
22
25
 
23
26
  ```text
24
27
  ~/.local/state/pi/tmux-sessions/<sanitized-pane-key>-<sha256-12>.session
28
+ ~/.local/state/pi/tmux-sessions/pane-id-<sanitized-pane-id>-<sha256-12>.session
25
29
  ~/.config/tws/pi-status/<sanitized-pane-key>-<sha256-12>.json
26
30
  ~/.config/tws-zellij/pi-status/<sanitized-pane-key>-<sha256-12>.json
27
31
  ```
28
32
 
29
- The `.session` file contains the active Pi session file path. It is written on `session_start`, `session_info_changed`, `agent_start`, and `agent_end` when the current Pi session has a backing session file.
33
+ The `.session` files contain schema-3 JSON with pane key, pane ID, tmux server PID, exact Pi session path and ID, cwd, and session name. They are written only after the JSONL exists and its session header matches Pi's current session ID. Writes occur on Pi lifecycle events and periodic reconciliation. A replacement key and pane-ID alias are published atomically before stale names are removed, so interruption can leave duplicates but never a mapping gap.
30
34
 
31
35
  The `.json` file contains the multiplexer backend, tws work state (`idle`, `working`, `retrying`, `done`, `cancelled`, `incomplete`, `failed`, `shutdown`), and pane/session metadata. A technical-error `agent_end` is provisional `retrying`; aborts and token-limit responses become `cancelled`/`incomplete` immediately. `agent_settled` confirms the final outcome (`failed` if recovery did not succeed). The extension touches the matching `agent.trigger` file after status writes so that edition refreshes within one poll tick.
32
36
 
33
- Mappings are intentionally **not** removed on shutdown — they must survive `tmux kill-server` so resurrect can use them. Every Pi start in a pane overwrites that pane's mapping, so stale entries self-heal. Very old mapping files and interrupted atomic-write temp files are cleaned up opportunistically on session start.
37
+ Mappings are intentionally **not** removed on shutdown — they must survive `tmux kill-server` so resurrect can use them. During one live tmux server, reconciliation self-heals pane renumbering through the stable pane-ID alias. After a server restart, consumers ignore old aliases and use the restart-stable pane key. Very old missing-target mappings and interrupted atomic-write temp files are cleaned up opportunistically on session start.
34
38
 
35
39
  ## Install
36
40
 
@@ -101,7 +105,8 @@ If the recorded session file no longer exists, the wrapper falls back to `pi --c
101
105
 
102
106
  ## Caveats
103
107
 
104
- - Pane keys use window/pane *indexes*: if you reorder windows or panes between save and restore, a mapping can point at a sibling pane's session. It self-heals on the next Pi start.
108
+ - Pane IDs are scoped to one tmux server and may be reused after restart. Schema 3 therefore includes the tmux server PID and consumers require both values before using a live alias.
109
+ - A Pi process whose current session has no valid on-disk JSONL cannot be mapped exactly. The extension fails closed and waits for a later lifecycle event or reconciliation instead of publishing a speculative path.
105
110
 
106
111
  ## License
107
112
 
@@ -116,6 +121,7 @@ Optional environment variables for hardening or local integration:
116
121
  - `PI_TMUX_SESSION_MAP_TWS_STATUS_DIR` — override only the tws status directory.
117
122
  - `PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE` — override the tws trigger file.
118
123
  - `PI_TMUX_SESSION_MAP_TMUX_BIN` — override the `tmux` binary path.
124
+ - `PI_TMUX_SESSION_MAP_RECONCILE_INTERVAL_MS` — periodic mapping reconciliation interval; minimum 10 seconds, default 60 seconds, deterministically jittered per pane.
119
125
  - `PI_TMUX_SESSION_MAP_ZELLIJ_BIN` — override the `zellij` binary path.
120
126
  - `PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR` — override the Zellij TWS status directory.
121
127
  - `PI_TMUX_SESSION_MAP_ZELLIJ_TRIGGER_FILE` — override the Zellij TWS trigger file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tmux-session-map",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Map Pi sessions to tmux panes and publish TWS work status for tmux and Zellij.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join } from "node:path";
3
3
 
4
- import { mappedSessionFile } from "./session-mapping.ts";
4
+ import { mappedPaneIdentity, mappedSessionFile } from "./session-mapping.ts";
5
5
 
6
6
  let atomicWriteCounter = 0;
7
7
 
@@ -108,8 +108,9 @@ export async function cleanupSupersededStatusFiles(
108
108
 
109
109
  export async function cleanupSupersededSessionMappings(
110
110
  dir: string,
111
- currentFileName: string,
111
+ currentFileNames: string | readonly string[],
112
112
  sessionFile: string,
113
+ paneIdentity?: { paneId: string; tmuxServerPid: string },
113
114
  ): Promise<CleanupResult> {
114
115
  let entries: string[];
115
116
  try {
@@ -118,20 +119,28 @@ export async function cleanupSupersededSessionMappings(
118
119
  return { scanned: 0, removed: 0 };
119
120
  }
120
121
 
122
+ const retained = new Set(
123
+ typeof currentFileNames === "string" ? [currentFileNames] : currentFileNames,
124
+ );
121
125
  let scanned = 0;
122
126
  let removed = 0;
123
127
  for (const entry of entries) {
124
- if (!entry.endsWith(".session") || entry === currentFileName) {
128
+ if (!entry.endsWith(".session") || retained.has(entry)) {
125
129
  continue;
126
130
  }
127
131
  scanned += 1;
128
132
  const path = join(dir, entry);
129
133
  try {
130
- // A Pi session file is owned by exactly one live pane, so any other
131
- // mapping pointing at it is a stale key (window renamed / pane moved) or
132
- // a legacy-named duplicate of the current key.
133
- const target = mappedSessionFile(await readFile(path, "utf8")) ?? "";
134
- if (target !== sessionFile) {
134
+ const content = await readFile(path, "utf8");
135
+ // A Pi session and a live tmux pane id each have one current owner. A
136
+ // match on either identity proves this is an old key or alias, but only
137
+ // after the caller has atomically published the replacement files.
138
+ const sameSession = mappedSessionFile(content) === sessionFile;
139
+ const mappedIdentity = mappedPaneIdentity(content);
140
+ const samePane = paneIdentity !== undefined
141
+ && mappedIdentity?.paneId === paneIdentity.paneId
142
+ && mappedIdentity.tmuxServerPid === paneIdentity.tmuxServerPid;
143
+ if (!sameSession && !samePane) {
135
144
  continue;
136
145
  }
137
146
  await rm(path, { force: true });
@@ -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
+ }
@@ -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,
@@ -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,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
+ }
@@ -1,4 +1,4 @@
1
- export type SessionMapping = {
1
+ export type SessionMappingV2 = {
2
2
  schema: 2;
3
3
  paneKey: string;
4
4
  sessionFile: string;
@@ -8,16 +8,52 @@ export type SessionMapping = {
8
8
  updatedAtMs: number;
9
9
  };
10
10
 
11
+ export type SessionMapping = {
12
+ schema: 3;
13
+ paneKey: string;
14
+ paneId: string;
15
+ tmuxServerPid: string;
16
+ sessionFile: string;
17
+ sessionId: string;
18
+ cwd: string;
19
+ sessionName: string | null;
20
+ updatedAtMs: number;
21
+ };
22
+
23
+ export type StructuredSessionMapping = SessionMappingV2 | SessionMapping;
24
+
11
25
  export type ParsedSessionMapping =
12
- | { format: "structured"; mapping: SessionMapping }
26
+ | { format: "structured"; mapping: StructuredSessionMapping }
13
27
  | { format: "legacy"; sessionFile: string };
14
28
 
15
29
  function isRecord(value: unknown): value is Record<string, unknown> {
16
30
  return typeof value === "object" && value !== null && !Array.isArray(value);
17
31
  }
18
32
 
33
+ type CommonMappingFields = {
34
+ paneKey: string;
35
+ sessionFile: string;
36
+ sessionId: string;
37
+ cwd: string;
38
+ sessionName: string | null;
39
+ updatedAtMs: number;
40
+ };
41
+
42
+ function hasCommonFields(value: Record<string, unknown>): value is Record<string, unknown> & CommonMappingFields {
43
+ return (
44
+ typeof value.paneKey === "string"
45
+ && typeof value.sessionFile === "string"
46
+ && typeof value.sessionId === "string"
47
+ && typeof value.cwd === "string"
48
+ && (typeof value.sessionName === "string" || value.sessionName === null)
49
+ && typeof value.updatedAtMs === "number"
50
+ );
51
+ }
52
+
19
53
  export function buildSessionMapping(
20
54
  paneKey: string,
55
+ paneId: string,
56
+ tmuxServerPid: string,
21
57
  sessionFile: string,
22
58
  sessionId: string,
23
59
  cwd: string,
@@ -25,8 +61,10 @@ export function buildSessionMapping(
25
61
  updatedAtMs: number,
26
62
  ): SessionMapping {
27
63
  return {
28
- schema: 2,
64
+ schema: 3,
29
65
  paneKey,
66
+ paneId,
67
+ tmuxServerPid,
30
68
  sessionFile,
31
69
  sessionId,
32
70
  cwd,
@@ -54,30 +92,46 @@ export function parseSessionMapping(content: string): ParsedSessionMapping | und
54
92
  } catch {
55
93
  return undefined;
56
94
  }
95
+ if (!isRecord(value) || !hasCommonFields(value)) {
96
+ return undefined;
97
+ }
98
+ if (value.schema === 2) {
99
+ return {
100
+ format: "structured",
101
+ mapping: {
102
+ schema: 2,
103
+ paneKey: value.paneKey,
104
+ sessionFile: value.sessionFile,
105
+ sessionId: value.sessionId,
106
+ cwd: value.cwd,
107
+ sessionName: value.sessionName,
108
+ updatedAtMs: value.updatedAtMs,
109
+ },
110
+ };
111
+ }
57
112
  if (
58
- !isRecord(value)
59
- || value.schema !== 2
60
- || typeof value.paneKey !== "string"
61
- || typeof value.sessionFile !== "string"
62
- || typeof value.sessionId !== "string"
63
- || typeof value.cwd !== "string"
64
- || !(typeof value.sessionName === "string" || value.sessionName === null)
65
- || typeof value.updatedAtMs !== "number"
113
+ value.schema === 3
114
+ && typeof value.paneId === "string"
115
+ && value.paneId !== ""
116
+ && typeof value.tmuxServerPid === "string"
117
+ && value.tmuxServerPid !== ""
66
118
  ) {
67
- return undefined;
119
+ return {
120
+ format: "structured",
121
+ mapping: {
122
+ schema: 3,
123
+ paneKey: value.paneKey,
124
+ paneId: value.paneId,
125
+ tmuxServerPid: value.tmuxServerPid,
126
+ sessionFile: value.sessionFile,
127
+ sessionId: value.sessionId,
128
+ cwd: value.cwd,
129
+ sessionName: value.sessionName,
130
+ updatedAtMs: value.updatedAtMs,
131
+ },
132
+ };
68
133
  }
69
- return {
70
- format: "structured",
71
- mapping: {
72
- schema: 2,
73
- paneKey: value.paneKey,
74
- sessionFile: value.sessionFile,
75
- sessionId: value.sessionId,
76
- cwd: value.cwd,
77
- sessionName: value.sessionName,
78
- updatedAtMs: value.updatedAtMs,
79
- },
80
- };
134
+ return undefined;
81
135
  }
82
136
 
83
137
  export function mappedSessionFile(content: string): string | undefined {
@@ -87,3 +141,14 @@ export function mappedSessionFile(content: string): string | undefined {
87
141
  }
88
142
  return parsed.format === "structured" ? parsed.mapping.sessionFile : parsed.sessionFile;
89
143
  }
144
+
145
+ export function mappedPaneIdentity(content: string): { paneId: string; tmuxServerPid: string } | undefined {
146
+ const parsed = parseSessionMapping(content);
147
+ if (!parsed || parsed.format !== "structured" || parsed.mapping.schema !== 3) {
148
+ return undefined;
149
+ }
150
+ return {
151
+ paneId: parsed.mapping.paneId,
152
+ tmuxServerPid: parsed.mapping.tmuxServerPid,
153
+ };
154
+ }
@@ -4,6 +4,7 @@ export type PaneInfo = {
4
4
  backend: "tmux" | "zellij" | "mex";
5
5
  paneKey: string;
6
6
  paneId: string;
7
+ tmuxServerPid: string | null;
7
8
  sessionName: string;
8
9
  windowIndex: number;
9
10
  paneIndex: number;
package/src/index.ts CHANGED
@@ -4,13 +4,13 @@ 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 } 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";
12
13
  import { buildRestoreAck, restoreAckPath, restoreExpectationFromEnvironment, serializeRestoreAck } from "./domain/restore-ack.ts";
13
- import { buildSessionMapping, serializeSessionMapping } from "./domain/session-mapping.ts";
14
14
  import { createRuntimeState, nextStatusTransition, refreshStatusTransition, type FinalWorkState, type RuntimeState, type WorkState } from "./domain/state.ts";
15
15
  import { buildTwsStatusPayload, serializeStatusPayload, type PaneInfo, type SessionInfo } from "./domain/status.ts";
16
16
 
@@ -33,7 +33,11 @@ const tmuxBin = process.env.PI_TMUX_SESSION_MAP_TMUX_BIN ?? "tmux";
33
33
  const zellijBin = process.env.PI_TMUX_SESSION_MAP_ZELLIJ_BIN ?? "zellij";
34
34
  const muxTimeoutMs = 1000;
35
35
  const cleanupMaxAgeMs = 24 * 60 * 60 * 1000;
36
- const PANE_INFO_FORMAT = `${PANE_KEY_FORMAT}\t#{session_name}\t#{window_index}\t#{pane_index}`;
36
+ const reconcileIntervalMs = Math.max(
37
+ 10_000,
38
+ Number.parseInt(process.env.PI_TMUX_SESSION_MAP_RECONCILE_INTERVAL_MS ?? "60000", 10) || 60_000,
39
+ );
40
+ const PANE_INFO_FORMAT = `${PANE_KEY_FORMAT}\t#{session_name}\t#{window_index}\t#{pane_index}\t#{pid}`;
37
41
 
38
42
  type StatusAction = WorkState | "refresh" | null;
39
43
 
@@ -139,7 +143,24 @@ export function sessionMappingSignature(
139
143
  if (session.sessionFile === null || info.backend !== "tmux") {
140
144
  return null;
141
145
  }
142
- return JSON.stringify([info.paneKey, session.sessionFile, sessionId, session.cwd, session.sessionName]);
146
+ return JSON.stringify([
147
+ info.paneKey,
148
+ info.paneId,
149
+ info.tmuxServerPid,
150
+ session.sessionFile,
151
+ sessionId,
152
+ session.cwd,
153
+ session.sessionName,
154
+ ]);
155
+ }
156
+
157
+ export function reconciliationDelayMs(identity: string, intervalMs = reconcileIntervalMs): number {
158
+ let hash = 2166136261;
159
+ for (const byte of Buffer.from(identity, "utf8")) {
160
+ hash ^= byte;
161
+ hash = Math.imul(hash, 16777619) >>> 0;
162
+ }
163
+ return hash % intervalMs;
143
164
  }
144
165
 
145
166
  export function twsStatusSignature(
@@ -153,6 +174,7 @@ export function twsStatusSignature(
153
174
  info.backend,
154
175
  info.paneKey,
155
176
  info.paneId,
177
+ info.tmuxServerPid,
156
178
  info.sessionName,
157
179
  info.windowIndex,
158
180
  info.paneIndex,
@@ -165,22 +187,8 @@ export function twsStatusSignature(
165
187
  ]);
166
188
  }
167
189
 
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);
190
+ async function writeSessionMapping(info: PaneInfo, session: SessionInfo, sessionId: string): Promise<boolean> {
191
+ return (await publishSessionMapping(stateDir, info, session, sessionId)) !== undefined;
184
192
  }
185
193
 
186
194
  async function writeRestoreAcknowledgement(
@@ -281,8 +289,8 @@ async function applyEventAction(
281
289
  const sessionId = ctx.sessionManager.getSessionId();
282
290
  const mappingSignature = sessionMappingSignature(info, session, sessionId);
283
291
  if (mappingSessionFile !== null && mappingSignature !== null && mappingSignature !== artifactCache.mappingSignature) {
284
- await writeSessionMapping(info, session, sessionId);
285
- artifactCache.mappingSignature = mappingSignature;
292
+ const published = await writeSessionMapping(info, session, sessionId);
293
+ artifactCache.mappingSignature = published ? mappingSignature : null;
286
294
  }
287
295
 
288
296
  if (action.restoreReason !== undefined && info.backend === "tmux") {
@@ -355,6 +363,40 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
355
363
  });
356
364
  };
357
365
 
366
+ let reconciliationTimer: ReturnType<typeof setTimeout> | undefined;
367
+ let reconciliationActive = false;
368
+
369
+ const stopReconciliation = () => {
370
+ reconciliationActive = false;
371
+ if (reconciliationTimer !== undefined) {
372
+ clearTimeout(reconciliationTimer);
373
+ reconciliationTimer = undefined;
374
+ }
375
+ };
376
+
377
+ const scheduleReconciliation = (ctx: ExtensionContext, delayMs: number) => {
378
+ reconciliationTimer = setTimeout(async () => {
379
+ reconciliationTimer = undefined;
380
+ if (!reconciliationActive) {
381
+ return;
382
+ }
383
+ await enqueue(ctx, { mapping: true, status: "refresh", refreshPane: true });
384
+ if (reconciliationActive) {
385
+ scheduleReconciliation(ctx, reconcileIntervalMs);
386
+ }
387
+ }, delayMs);
388
+ reconciliationTimer.unref();
389
+ };
390
+
391
+ const startReconciliation = (ctx: ExtensionContext) => {
392
+ stopReconciliation();
393
+ if (!process.env.TMUX || !process.env.TMUX_PANE) {
394
+ return;
395
+ }
396
+ reconciliationActive = true;
397
+ scheduleReconciliation(ctx, reconciliationDelayMs(process.env.TMUX_PANE));
398
+ };
399
+
358
400
  // Intentionally no cleanup on session_shutdown: the mapping must survive a
359
401
  // tmux server restart so resurrect can restore the exact session.
360
402
  pi.on("session_start", async (event, ctx) => {
@@ -370,6 +412,7 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
370
412
  restoreReason: event.reason,
371
413
  refreshPane: true,
372
414
  });
415
+ startReconciliation(ctx);
373
416
  });
374
417
  pi.on("session_info_changed", async (_event, ctx) => {
375
418
  await enqueue(ctx, { mapping: true, status: "refresh", refreshPane: true });
@@ -395,6 +438,7 @@ export default function tmuxSessionMap(pi: ExtensionAPI) {
395
438
  await enqueue(ctx, { mapping: true, status: pendingFinalState });
396
439
  });
397
440
  pi.on("session_shutdown", async (_event, ctx) => {
441
+ stopReconciliation();
398
442
  await enqueue(ctx, { mapping: false, status: "shutdown" });
399
443
  artifactCache.paneInfo = undefined;
400
444
  artifactCache.paneInfoResolved = false;