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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Wozniak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # pi-tmux-session-map
2
+
3
+ A [pi](https://pi.dev) extension that records which Pi session runs in which tmux pane, so tmux-resurrect can restore the **exact** session after a tmux server restart — instead of blindly continuing the newest session for the pane's working directory.
4
+
5
+ It also writes [tws](https://github.com/ytaskiran/tws)-compatible Pi work-status sidecar files for both tmux and Zellij, so each TWS edition can distinguish working, retrying, successful, cancelled, incomplete, and failed Pi runs.
6
+
7
+ ## Problem
8
+
9
+ `pi --continue` resumes the most recent session for the current directory. When tmux-resurrect restores panes with a blanket `pi --continue`, every pane in the same directory gets the same (newest) session — the wrong one for all but one pane.
10
+
11
+ ## How it works
12
+
13
+ The extension resolves a stable tmux pane key:
14
+
15
+ ```
16
+ #{session_name}:#{window_index}.#{pane_index}
17
+ ```
18
+
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
+
21
+ 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
+
23
+ ```text
24
+ ~/.local/state/pi/tmux-sessions/<sanitized-pane-key>-<sha256-12>.session
25
+ ~/.config/tws/pi-status/<sanitized-pane-key>-<sha256-12>.json
26
+ ~/.config/tws-zellij/pi-status/<sanitized-pane-key>-<sha256-12>.json
27
+ ```
28
+
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.
30
+
31
+ 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
+
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.
34
+
35
+ ## Install
36
+
37
+ Requires Pi `0.80.5` or newer (`agent_settled` lifecycle event).
38
+
39
+ ```sh
40
+ pi install npm:pi-tmux-session-map
41
+ ```
42
+
43
+ Then reload pi:
44
+
45
+ ```txt
46
+ /reload
47
+ ```
48
+
49
+ ## Pairing with tmux-resurrect
50
+
51
+ Add a `pi-tmux-resume` wrapper on your `PATH`:
52
+
53
+ ```sh
54
+ #!/usr/bin/env bash
55
+ set -euo pipefail
56
+
57
+ state_dir="$HOME/.local/state/pi/tmux-sessions"
58
+
59
+ fallback() {
60
+ exec pi --continue
61
+ }
62
+
63
+ if [ -z "${TMUX:-}" ] || [ -z "${TMUX_PANE:-}" ]; then
64
+ fallback
65
+ fi
66
+
67
+ key="$(tmux display-message -p -t "$TMUX_PANE" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true)"
68
+ if [ -z "$key" ]; then
69
+ fallback
70
+ fi
71
+
72
+ # Keep in sync with src/domain/pane-key.ts.
73
+ sanitized="$(printf '%s' "$key" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_' | cut -c 1-120)"
74
+ if [ -z "$sanitized" ] || printf '%s' "$sanitized" | grep -Eq '^\.+$'; then
75
+ sanitized="pane"
76
+ fi
77
+ hash="$(printf '%s' "$key" | shasum -a 256 | awk '{print substr($1, 1, 12)}')"
78
+ map="$state_dir/$sanitized-$hash.session"
79
+
80
+ if [ -f "$map" ]; then
81
+ session_file="$(head -n 1 "$map" 2>/dev/null || true)"
82
+ if [ -n "$session_file" ] && [ -f "$session_file" ]; then
83
+ exec pi --session "$session_file"
84
+ fi
85
+ fi
86
+
87
+ fallback
88
+ ```
89
+
90
+ Then tell tmux-resurrect to restore Pi panes with it:
91
+
92
+ ```tmux
93
+ set -g @resurrect-processes '"~pi-coding-agent/dist/cli.js->pi-tmux-resume"'
94
+ ```
95
+
96
+ ## Behavior
97
+
98
+ The exact session mapping is written only when Pi runs inside tmux (`$TMUX` and `$TMUX_PANE` set) and the current session has a backing session file. The tws status file is written inside either tmux or Zellij (`$ZELLIJ`, `$ZELLIJ_SESSION_NAME`, and `$ZELLIJ_PANE_ID`), even when the Pi session file is not available yet; `session_file` is `null` until Pi creates one. Final failure-like outcomes are delayed until `agent_settled`, so automatic retries or compaction recovery do not appear as permanent failures.
99
+
100
+ If the recorded session file no longer exists, the wrapper falls back to `pi --continue` for the pane's working directory.
101
+
102
+ ## Caveats
103
+
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.
105
+
106
+ ## License
107
+
108
+ MIT
109
+
110
+ ## Configuration
111
+
112
+ Optional environment variables for hardening or local integration:
113
+
114
+ - `PI_TMUX_SESSION_MAP_STATE_DIR` — override the mapping directory.
115
+ - `PI_TMUX_SESSION_MAP_TWS_CONFIG_DIR` — override the tws config directory.
116
+ - `PI_TMUX_SESSION_MAP_TWS_STATUS_DIR` — override only the tws status directory.
117
+ - `PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE` — override the tws trigger file.
118
+ - `PI_TMUX_SESSION_MAP_TMUX_BIN` — override the `tmux` binary path.
119
+ - `PI_TMUX_SESSION_MAP_ZELLIJ_BIN` — override the `zellij` binary path.
120
+ - `PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR` — override the Zellij TWS status directory.
121
+ - `PI_TMUX_SESSION_MAP_ZELLIJ_TRIGGER_FILE` — override the Zellij TWS trigger file.
122
+
123
+ Lifecycle sidecar updates are serialized in-process, mapping and status files are written atomically, and `tmux display-message` is bounded by a short timeout so Pi lifecycle hooks do not hang indefinitely.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-tmux-session-map",
3
+ "version": "0.3.0",
4
+ "description": "Map Pi sessions to tmux panes and publish TWS work status for tmux and Zellij.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi-extension",
12
+ "pi",
13
+ "tmux",
14
+ "zellij",
15
+ "tws",
16
+ "tmux-resurrect"
17
+ ],
18
+ "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/patlux/pi-tmux-session-map.git"
25
+ },
26
+ "homepage": "https://github.com/patlux/pi-tmux-session-map#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/patlux/pi-tmux-session-map/issues"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "scripts",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "test": "node --experimental-strip-types --test \"test/**/*.test.ts\"",
38
+ "typecheck": "tsc --noEmit",
39
+ "benchmark": "node --experimental-strip-types scripts/benchmark-overhead.mjs",
40
+ "check:installed": "npm run check && node scripts/check-installed-cache.mjs",
41
+ "check": "npm run typecheck && npm test && npm pack --dry-run"
42
+ },
43
+ "pi": {
44
+ "extensions": [
45
+ "./src/index.ts"
46
+ ]
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": ">=0.80.5"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-coding-agent": "^0.80.5",
53
+ "@types/node": "^24.0.0",
54
+ "typescript": "^5.9.3"
55
+ }
56
+ }
@@ -0,0 +1,165 @@
1
+ import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const root = await mkdtemp(join(tmpdir(), "pi-tmux-session-map-bench-"));
6
+ const stateDir = join(root, "state");
7
+ const twsDir = join(root, "tws");
8
+ const statusDir = join(twsDir, "pi-status");
9
+ const ackDir = join(root, "ack");
10
+ const backend = process.env.BENCH_BACKEND === "zellij" ? "zellij" : "tmux";
11
+ const callsFile = join(root, "mux-calls.jsonl");
12
+ const fakeMux = join(root, backend);
13
+ const sessionFile = join(root, "session.jsonl");
14
+
15
+ await writeFile(sessionFile, "{}\n");
16
+ await writeFile(
17
+ fakeMux,
18
+ backend === "tmux"
19
+ ? `#!/bin/sh\nprintf '%s\\n' "$*" >> "$PI_TMUX_SESSION_MAP_BENCH_CALLS"\nprintf 'bench:1.0\\tbench\\t1\\t0\\n'\n`
20
+ : `#!/bin/sh\nprintf '%s\\n' "$*" >> "$PI_TMUX_SESSION_MAP_BENCH_CALLS"\nprintf '[{"id":1,"is_plugin":false,"tab_position":1}]\\n'\n`,
21
+ );
22
+ await chmod(fakeMux, 0o755);
23
+
24
+ Object.assign(process.env, {
25
+ PI_TMUX_SESSION_MAP_TMUX_BIN: fakeMux,
26
+ PI_TMUX_SESSION_MAP_ZELLIJ_BIN: fakeMux,
27
+ PI_TMUX_SESSION_MAP_STATE_DIR: stateDir,
28
+ PI_TMUX_SESSION_MAP_TWS_CONFIG_DIR: twsDir,
29
+ PI_TMUX_SESSION_MAP_TWS_STATUS_DIR: statusDir,
30
+ PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE: join(twsDir, "agent.trigger"),
31
+ PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR: statusDir,
32
+ PI_TMUX_SESSION_MAP_ZELLIJ_TRIGGER_FILE: join(twsDir, "agent.trigger"),
33
+ PI_TMUX_SESSION_MAP_BENCH_CALLS: callsFile,
34
+ PI_TMUX_RESTORE_TOKEN: "restore-token",
35
+ PI_TMUX_EXPECTED_PANE: "bench:1.0",
36
+ PI_TMUX_EXPECTED_SESSION_ID: "session-id",
37
+ PI_TMUX_EXPECTED_SESSION_FILE: sessionFile,
38
+ PI_TMUX_RESTORE_ACK_DIR: ackDir,
39
+ });
40
+ if (backend === "tmux") {
41
+ process.env.TMUX = join(root, "tmux.sock");
42
+ process.env.TMUX_PANE = "%1";
43
+ delete process.env.ZELLIJ;
44
+ delete process.env.ZELLIJ_SESSION_NAME;
45
+ delete process.env.ZELLIJ_PANE_ID;
46
+ } else {
47
+ delete process.env.TMUX;
48
+ delete process.env.TMUX_PANE;
49
+ process.env.ZELLIJ = "1";
50
+ process.env.ZELLIJ_SESSION_NAME = "bench";
51
+ process.env.ZELLIJ_PANE_ID = "1";
52
+ }
53
+
54
+ const beforeImport = process.memoryUsage();
55
+ const importStarted = process.hrtime.bigint();
56
+ const { default: extension } = await import("../src/index.ts");
57
+ const importElapsedMs = Number(process.hrtime.bigint() - importStarted) / 1e6;
58
+ const afterImport = process.memoryUsage();
59
+
60
+ const handlers = new Map();
61
+ extension({
62
+ on(event, handler) {
63
+ handlers.set(event, handler);
64
+ },
65
+ });
66
+
67
+ const context = {
68
+ cwd: root,
69
+ sessionManager: {
70
+ getSessionFile: () => sessionFile,
71
+ getSessionName: () => "Optimize overhead",
72
+ getSessionId: () => "session-id",
73
+ },
74
+ };
75
+
76
+ async function snapshot() {
77
+ const result = new Map();
78
+ for (const dir of [stateDir, statusDir, twsDir, ackDir]) {
79
+ let entries;
80
+ try {
81
+ entries = await readdir(dir);
82
+ } catch {
83
+ continue;
84
+ }
85
+ for (const entry of entries) {
86
+ const path = join(dir, entry);
87
+ const info = await stat(path, { bigint: true });
88
+ if (!info.isFile()) {
89
+ continue;
90
+ }
91
+ result.set(path, `${info.ino}:${info.mtimeNs}:${info.size}:${await readFile(path, "utf8")}`);
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ function mutations(before, after) {
98
+ let count = 0;
99
+ for (const [path, value] of after) {
100
+ if (before.get(path) !== value) {
101
+ count += 1;
102
+ }
103
+ }
104
+ return count;
105
+ }
106
+
107
+ async function invoke(event, payload) {
108
+ const handler = handlers.get(event);
109
+ if (!handler) {
110
+ throw new Error(`missing ${event} handler`);
111
+ }
112
+ const before = await snapshot();
113
+ await handler(payload, context);
114
+ const after = await snapshot();
115
+ return mutations(before, after);
116
+ }
117
+
118
+ const phases = {};
119
+ phases.startup = await invoke("session_start", { reason: "resume" });
120
+ phases.namingUnchanged = await invoke("session_info_changed", { name: "Optimize overhead" });
121
+ phases.agentStart = await invoke("agent_start", {});
122
+ phases.agentEnd = await invoke("agent_end", {
123
+ messages: [{ role: "assistant", stopReason: "stop" }],
124
+ });
125
+ phases.settlement = await invoke("agent_settled", {});
126
+ phases.shutdown = await invoke("session_shutdown", {});
127
+
128
+ const muxCalls = (await readFile(callsFile, "utf8")).trim().split("\n").filter(Boolean);
129
+ const baseline = backend === "tmux"
130
+ ? {
131
+ muxSubprocesses: 7,
132
+ filesystemArtifactMutations: {
133
+ startup: 4,
134
+ namingUnchanged: 3,
135
+ agentStart: 3,
136
+ agentEnd: 3,
137
+ settlement: 3,
138
+ shutdown: 0,
139
+ },
140
+ }
141
+ : {
142
+ muxSubprocesses: 6,
143
+ filesystemArtifactMutations: {
144
+ startup: 2,
145
+ namingUnchanged: 2,
146
+ agentStart: 2,
147
+ agentEnd: 2,
148
+ settlement: 2,
149
+ shutdown: 0,
150
+ },
151
+ };
152
+
153
+ console.log(JSON.stringify({
154
+ backend,
155
+ baselineWithoutCaching: baseline,
156
+ import: {
157
+ elapsedMs: importElapsedMs,
158
+ rssDelta: afterImport.rss - beforeImport.rss,
159
+ heapUsedDelta: afterImport.heapUsed - beforeImport.heapUsed,
160
+ moduleLoadList: process.moduleLoadList.length,
161
+ },
162
+ muxSubprocesses: muxCalls.length,
163
+ filesystemArtifactMutations: phases,
164
+ totalFilesystemArtifactMutations: Object.values(phases).reduce((sum, value) => sum + value, 0),
165
+ }, null, 2));
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const files = [
8
+ "package.json",
9
+ "README.md",
10
+ "scripts/check-installed-cache.mjs",
11
+ "src/index.ts",
12
+ "src/domain/async-queue.ts",
13
+ "src/domain/atomic-write.ts",
14
+ "src/domain/error-reporter.ts",
15
+ "src/domain/pane-info.ts",
16
+ "src/domain/pane-key.ts",
17
+ "src/domain/state.ts",
18
+ "src/domain/status.ts",
19
+ ];
20
+
21
+ const installedRoot = join(homedir(), ".pi", "agent", "git", "github.com", "patlux", "pi-tmux-session-map");
22
+
23
+ function hash(path) {
24
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
25
+ }
26
+
27
+ if (!existsSync(installedRoot)) {
28
+ console.log(`installed cache not present: ${installedRoot}`);
29
+ process.exit(0);
30
+ }
31
+
32
+ const mismatches = [];
33
+ for (const file of files) {
34
+ const source = join(process.cwd(), file);
35
+ const installed = join(installedRoot, file);
36
+ if (!existsSync(installed)) {
37
+ mismatches.push(`${file}: missing from installed cache`);
38
+ continue;
39
+ }
40
+ if (hash(source) !== hash(installed)) {
41
+ mismatches.push(`${file}: differs from installed cache`);
42
+ }
43
+ }
44
+
45
+ if (mismatches.length > 0) {
46
+ console.error(`installed cache is stale at ${installedRoot}`);
47
+ for (const mismatch of mismatches) {
48
+ console.error(`- ${mismatch}`);
49
+ }
50
+ process.exit(1);
51
+ }
52
+
53
+ console.log(`installed cache matches source: ${installedRoot}`);
@@ -0,0 +1,18 @@
1
+ export type AsyncQueue = {
2
+ enqueue<T>(operation: () => Promise<T>): Promise<T>;
3
+ };
4
+
5
+ export function createAsyncQueue(): AsyncQueue {
6
+ let tail = Promise.resolve();
7
+
8
+ return {
9
+ enqueue<T>(operation: () => Promise<T>): Promise<T> {
10
+ const run = tail.then(operation, operation);
11
+ tail = run.then(
12
+ () => undefined,
13
+ () => undefined,
14
+ );
15
+ return run;
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,193 @@
1
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+
4
+ import { mappedSessionFile } from "./session-mapping.ts";
5
+
6
+ let atomicWriteCounter = 0;
7
+
8
+ export type CleanupResult = {
9
+ scanned: number;
10
+ removed: number;
11
+ };
12
+
13
+ export async function ensureDirectory(dir: string, mode = 0o700): Promise<void> {
14
+ await mkdir(dir, { recursive: true, mode });
15
+ }
16
+
17
+ function nextTempPath(file: string): string {
18
+ atomicWriteCounter += 1;
19
+ return join(dirname(file), `${basename(file)}.tmp-${process.pid}-${atomicWriteCounter}`);
20
+ }
21
+
22
+ export async function writeAtomic(file: string, data: string, mode: number): Promise<void> {
23
+ const tmp = nextTempPath(file);
24
+ let renamed = false;
25
+ try {
26
+ await writeFile(tmp, data, { mode });
27
+ await rename(tmp, file);
28
+ renamed = true;
29
+ } finally {
30
+ if (!renamed) {
31
+ await rm(tmp, { force: true });
32
+ }
33
+ }
34
+ }
35
+
36
+ export async function cleanupStaleTempFiles(
37
+ dir: string,
38
+ maxAgeMs: number,
39
+ nowMs = Date.now(),
40
+ ): Promise<CleanupResult> {
41
+ if (maxAgeMs <= 0) {
42
+ return { scanned: 0, removed: 0 };
43
+ }
44
+
45
+ let entries: string[];
46
+ try {
47
+ entries = await readdir(dir);
48
+ } catch {
49
+ return { scanned: 0, removed: 0 };
50
+ }
51
+
52
+ let scanned = 0;
53
+ let removed = 0;
54
+ for (const entry of entries) {
55
+ if (!entry.includes(".tmp-")) {
56
+ continue;
57
+ }
58
+ scanned += 1;
59
+ const path = join(dir, entry);
60
+ try {
61
+ const info = await stat(path);
62
+ if (nowMs - info.mtimeMs <= maxAgeMs) {
63
+ continue;
64
+ }
65
+ await rm(path, { force: true });
66
+ removed += 1;
67
+ } catch {
68
+ // Best-effort cleanup only.
69
+ }
70
+ }
71
+ return { scanned, removed };
72
+ }
73
+
74
+ export async function cleanupSupersededStatusFiles(
75
+ dir: string,
76
+ currentFileName: string,
77
+ paneId: string,
78
+ ): Promise<CleanupResult> {
79
+ let entries: string[];
80
+ try {
81
+ entries = await readdir(dir);
82
+ } catch {
83
+ return { scanned: 0, removed: 0 };
84
+ }
85
+
86
+ let scanned = 0;
87
+ let removed = 0;
88
+ for (const entry of entries) {
89
+ if (!entry.endsWith(".json") || entry === currentFileName) {
90
+ continue;
91
+ }
92
+ scanned += 1;
93
+ const path = join(dir, entry);
94
+ try {
95
+ const payload = JSON.parse(await readFile(path, "utf8"));
96
+ if (payload?.pane_id !== paneId) {
97
+ continue;
98
+ }
99
+ await rm(path, { force: true });
100
+ removed += 1;
101
+ } catch {
102
+ // Best-effort cleanup only; malformed files are ignored here and skipped
103
+ // by tws when read.
104
+ }
105
+ }
106
+ return { scanned, removed };
107
+ }
108
+
109
+ export async function cleanupSupersededSessionMappings(
110
+ dir: string,
111
+ currentFileName: string,
112
+ sessionFile: string,
113
+ ): Promise<CleanupResult> {
114
+ let entries: string[];
115
+ try {
116
+ entries = await readdir(dir);
117
+ } catch {
118
+ return { scanned: 0, removed: 0 };
119
+ }
120
+
121
+ let scanned = 0;
122
+ let removed = 0;
123
+ for (const entry of entries) {
124
+ if (!entry.endsWith(".session") || entry === currentFileName) {
125
+ continue;
126
+ }
127
+ scanned += 1;
128
+ const path = join(dir, entry);
129
+ 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) {
135
+ continue;
136
+ }
137
+ await rm(path, { force: true });
138
+ removed += 1;
139
+ } catch {
140
+ // Best-effort cleanup only.
141
+ }
142
+ }
143
+ return { scanned, removed };
144
+ }
145
+
146
+ export async function cleanupStaleSessionMappings(
147
+ dir: string,
148
+ maxAgeMs: number,
149
+ nowMs = Date.now(),
150
+ ): Promise<CleanupResult> {
151
+ if (maxAgeMs <= 0) {
152
+ return { scanned: 0, removed: 0 };
153
+ }
154
+
155
+ let entries: string[];
156
+ try {
157
+ entries = await readdir(dir);
158
+ } catch {
159
+ return { scanned: 0, removed: 0 };
160
+ }
161
+
162
+ let scanned = 0;
163
+ let removed = 0;
164
+ for (const entry of entries) {
165
+ if (!entry.endsWith(".session")) {
166
+ continue;
167
+ }
168
+ scanned += 1;
169
+ const path = join(dir, entry);
170
+ try {
171
+ const info = await stat(path);
172
+ if (nowMs - info.mtimeMs <= maxAgeMs) {
173
+ continue;
174
+ }
175
+
176
+ const sessionFile = mappedSessionFile(await readFile(path, "utf8")) ?? "";
177
+ if (sessionFile !== "") {
178
+ try {
179
+ await stat(sessionFile);
180
+ continue;
181
+ } catch {
182
+ // Missing target session file means the mapping is stale.
183
+ }
184
+ }
185
+
186
+ await rm(path, { force: true });
187
+ removed += 1;
188
+ } catch {
189
+ // Best-effort cleanup only.
190
+ }
191
+ }
192
+ return { scanned, removed };
193
+ }
@@ -0,0 +1,39 @@
1
+ export type LogError = (message: string) => void;
2
+
3
+ export type ErrorReporter = {
4
+ report(prefix: string, error: unknown, nowMs?: number): void;
5
+ };
6
+
7
+ type ErrorBucket = {
8
+ lastLoggedAtMs: number;
9
+ suppressed: number;
10
+ };
11
+
12
+ export function formatError(error: unknown): string {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+
16
+ export function createThrottledErrorReporter(
17
+ windowMs = 60_000,
18
+ log: LogError = console.error,
19
+ ): ErrorReporter {
20
+ const buckets = new Map<string, ErrorBucket>();
21
+
22
+ return {
23
+ report(prefix: string, error: unknown, nowMs = Date.now()): void {
24
+ const message = formatError(error);
25
+ const key = `${prefix}\n${message}`;
26
+ const bucket = buckets.get(key);
27
+
28
+ if (bucket && nowMs - bucket.lastLoggedAtMs < windowMs) {
29
+ bucket.suppressed += 1;
30
+ return;
31
+ }
32
+
33
+ const suppressed = bucket?.suppressed ?? 0;
34
+ buckets.set(key, { lastLoggedAtMs: nowMs, suppressed: 0 });
35
+ const suffix = suppressed > 0 ? ` (suppressed ${suppressed} similar error${suppressed === 1 ? "" : "s"})` : "";
36
+ log(`${prefix}: ${message}${suffix}`);
37
+ },
38
+ };
39
+ }