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 +13 -7
- package/package.json +1 -1
- package/src/domain/atomic-write.ts +17 -8
- 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/session-file.ts +55 -0
- package/src/domain/session-mapping.ts +89 -24
- package/src/domain/status.ts +1 -0
- package/src/index.ts +67 -23
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
|
|
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
|
-
|
|
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`
|
|
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.
|
|
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
|
|
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,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
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
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,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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
+
}
|
package/src/domain/status.ts
CHANGED
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,
|
|
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 {
|
|
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
|
|
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([
|
|
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<
|
|
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);
|
|
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;
|