pi-tmux-session-map 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,27 +10,57 @@ 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
- ```
16
- #{session_name}:#{window_index}.#{pane_index}
15
+ ```text
16
+ restart identity: #{session_name}:#{window_index}.#{pane_index}
17
+ live identity: (#{pid}, #{pane_id})
17
18
  ```
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 by an interactive Pi TUI with a real stdin TTY, never by a headless `pi-subagents` child that merely inherited the parent's tmux environment. Publication also requires that 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.
34
+
35
+ The status `.json` also publishes `session_id`, `pi_pid` and `tmux_server_pid`.
36
+ This live identity comes directly from Pi at session start, even before its JSONL
37
+ exists. Consumers must match the foreground Pi PID, tmux server and pane, reject
38
+ shutdown/stale status, and revalidate before copying. The session ID is therefore
39
+ available before the first message; a resume command still requires a matching
40
+ on-disk header. The durable `.session` mappings retain their persistence checks.
41
+
42
+ For tmux-recovery's auto-continue decision the status also carries recovery evidence. `run_anchor` (`{ leaf_id, started_at_ms }`) is set at the `agent_start` that begins a run: `leaf_id` is the session leaf before the run's prompt entry, captured synchronously because Pi persists the prompt after `agent_start`. Retries keep the anchor; final states and `idle` clear it. On `shutdown`, `shutdown_cause` is `signal` when the process received SIGHUP or SIGTERM (closed terminal, dead tmux server, reboot), `quit` for an interactive quit, `reload`, or `replace` for `/new`, `/resume` and fork. Only the interactive pane owner installs the signal listener, and the cause is resolved when the queued write builds its payload, after the signal dispatch has finished. A graceful signal shutdown can still leave `working` or `retrying` when Pi exits on a dead terminal before the write lands.
43
+
44
+ ### Auto-continue executor
45
+
46
+ When tmux-recovery restores a Pi that was interrupted inside a run, its resume wrapper sets `PI_TMUX_CONTINUE_PLAN` to a plan file in the recovery ledger. Only that restored process runs the executor, once, at its `startup`, and only as the interactive pane owner for the planned session. The executor waits for the restore acknowledgement (`resumed`; a mismatch skips) and for the plan's slot, then checks the final conditions in one section under the ledger's dispatch lock: plan still released and listed in `ready.json`, mode `on`, no hold file, same session, pane and last message entry, Pi idle without pending messages, empty editor, no open dialog, no live `pi-session-watchdog` for the session. It then claims the plan with an exclusive create (`<key>.claim`, never removed) and sends one visible `tmux-recovery-continue` message with `triggerTurn`. The German text names the stop time, the elapsed time and open tool calls and asks Pi to check the actual state first; its `continuationId` matches the message details.
47
+
48
+ Any real keystroke (`ctx.ui.onTerminalInput`), interactive or RPC input, extension dialog or other run before the dispatch writes a durable veto (`<key>.skip`, first writer wins). A session end before the claim writes a `process-end` skip. After the dispatch, `before_agent_start` with the matching `continuationId` writes the outcome `admitted`, and the turn's `agent_settled` writes its final state; without admission within 120 s the outcome is `uncertain`. Nothing is ever re-sent.
49
+
50
+ A `<key>.skip` that another program wrote before the claim is a veto too: TWS writes `{"class": "veto", "reason": "skipped-in-tws", …}` with an exclusive create when you skip a planned continuation. The executor checks for it at least once a second while it waits for the slot or the spacing, and again inside the dispatch section; it then stops without a claim and keeps the file as written. A file it cannot read or parse also counts as a veto. A skip written after the claim has no effect, so a writer should check `<key>.claim` after its own create to know whether it came too late.
51
+
52
+ The status sidecar shows the executor in `continue`, from its start on:
53
+
54
+ ```json
55
+ "continue": {"state": "planned", "reason": null, "slot_ms": 1794000014000, "deadline_ms": 1794000614000,
56
+ "key": "<64 hex>", "ledger_dir": "/Users/…/resurrect/pi-continue/ledger"}
57
+ ```
58
+
59
+ `state` is `planned`, `dispatched`, `admitted`, the turn's outcome (`done`, `failed`, `cancelled`, …), `uncertain`, `skipped` or `stopped`; `reason` is set for `skipped` and `stopped` (for example `user-active`, `skipped-in-tws`, `claimed`). `slot_ms` and `deadline_ms` come from the released plan and stay `null` until the release.
30
60
 
31
61
  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
62
 
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.
63
+ 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
64
 
35
65
  ## Install
36
66
 
@@ -95,13 +125,14 @@ set -g @resurrect-processes '"~pi-coding-agent/dist/cli.js->pi-tmux-resume"'
95
125
 
96
126
  ## Behavior
97
127
 
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.
128
+ The exact session mapping and pane-scoped tws status are written only when Pi is an interactive TUI with a real stdin TTY and is not marked as a `PI_SUBAGENT_CHILD`. Tmux additionally requires `$TMUX` and `$TMUX_PANE`; Zellij requires `$ZELLIJ`, `$ZELLIJ_SESSION_NAME`, and `$ZELLIJ_PANE_ID`. This ownership gate prevents invisible headless children from replacing the visible parent's resume identity or work status. When an eligible interactive session has no Pi session path, the status `session_file` is `null`. Pi can also supply a planned path before the file exists; that path alone is not proof of resumability. The live `session_id` remains available in either case. Final failure-like outcomes are delayed until `agent_settled`, so automatic retries or compaction recovery do not appear as permanent failures.
99
129
 
100
130
  If the recorded session file no longer exists, the wrapper falls back to `pi --continue` for the pane's working directory.
101
131
 
102
132
  ## Caveats
103
133
 
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.
134
+ - 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.
135
+ - A Pi process without a valid on-disk JSONL has a live identity but no durable resume mapping. The extension waits for persistence before publishing a resume alias; it never creates session history merely to make a command available.
105
136
 
106
137
  ## License
107
138
 
@@ -116,8 +147,12 @@ Optional environment variables for hardening or local integration:
116
147
  - `PI_TMUX_SESSION_MAP_TWS_STATUS_DIR` — override only the tws status directory.
117
148
  - `PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE` — override the tws trigger file.
118
149
  - `PI_TMUX_SESSION_MAP_TMUX_BIN` — override the `tmux` binary path.
150
+ - `PI_TMUX_SESSION_MAP_RESTORE_PANE_TIMEOUTS_MS` — comma-separated `tmux display-message` timeouts while a restore acknowledgement is pending; default `1000,2000,4000,8000,16000`.
151
+ - `PI_TMUX_SESSION_MAP_RECONCILE_INTERVAL_MS` — periodic mapping reconciliation interval; minimum 10 seconds, default 60 seconds, deterministically jittered per pane.
119
152
  - `PI_TMUX_SESSION_MAP_ZELLIJ_BIN` — override the `zellij` binary path.
120
153
  - `PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR` — override the Zellij TWS status directory.
121
154
  - `PI_TMUX_SESSION_MAP_ZELLIJ_TRIGGER_FILE` — override the Zellij TWS trigger file.
122
155
 
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.
156
+ A restore acknowledgement (`PI_TMUX_RESTORE_*` environment from `pi-tmux-resume`) is written at most once per token: the first writer wins, so a later `/new` or `/reload` cannot replace the proof of what the restore resumed. A restore keeps the tmux server busy, so while the acknowledgement is pending the pane lookup is retried with growing timeouts; if every attempt fails, the next lifecycle event or reconciliation writes it.
157
+
158
+ 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. A refresh that times out keeps the last known pane of the process, so a busy tmux never drops a status write such as `working` at `agent_start`.
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.5.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": {
@@ -0,0 +1,323 @@
1
+ // Timer, veto latch and admission tracking around src/domain/continuation.ts.
2
+ // index.ts forwards the Pi events; this class never registers handlers itself.
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ import {
9
+ CONTINUE_CUSTOM_TYPE, acquireDispatchLock, continuationMessage, createExclusive, decide, existingSkipReason, lastDispatchMs,
10
+ lastMessageEntryId, readMode, readPlan, readyState, releaseDispatchLock, testCheckpoint, watchdogAlive, writeAtomicSync,
11
+ writeOutcome, writeSkip, type ContinuePlan, type LiveFacts,
12
+ } from "./domain/continuation.ts";
13
+ import type { ContinueStatus } from "./domain/status.ts";
14
+
15
+ /** Also the longest wait before the slot, so an outside veto shows within a second. */
16
+ const POLL_MS = 1000;
17
+ const LOCK_RETRY_MS = 1000;
18
+ const ADMISSION_TIMEOUT_MS = 120_000;
19
+ /** Without a ready.json the restore never released this attempt. */
20
+ const READY_WAIT_LIMIT_MS = 30 * 60 * 1000;
21
+
22
+ export type LiveSource = {
23
+ ackResumed(): boolean | undefined;
24
+ paneKey(): string | null;
25
+ sessionId(): string;
26
+ sessionFile(): string | null;
27
+ branch(): readonly { id?: string; type?: string; message?: { role?: string } }[];
28
+ idle(): boolean;
29
+ pendingMessages(): boolean;
30
+ editorText(): string;
31
+ };
32
+
33
+ export type SendMessage = (
34
+ message: { customType: string; content: string; display: boolean; details: Record<string, unknown> },
35
+ options: { triggerTurn: boolean },
36
+ ) => void;
37
+
38
+ export class ContinuationExecutor {
39
+ readonly continuationId = randomUUID();
40
+ private timer: ReturnType<typeof setTimeout> | undefined;
41
+ private admissionTimer: ReturnType<typeof setTimeout> | undefined;
42
+ private finished = false;
43
+ private claimed = false;
44
+ private admitted = false;
45
+ private outcomeWritten = false;
46
+ private userActive = false;
47
+ private promptDepth = 0;
48
+ private startedAtMs = Date.now();
49
+ private stateValue = "planned";
50
+ private slotMs: number | null;
51
+ private deadlineMs: number | null;
52
+ /** End of a spacing wait decided under the dispatch lock. */
53
+ private waitUntilMs = 0;
54
+ readonly planPath: string;
55
+ readonly plan: ContinuePlan;
56
+ private readonly live: LiveSource;
57
+ private readonly send: SendMessage;
58
+ private readonly onChange: () => void;
59
+ private readonly watchdogRoot: string;
60
+
61
+ private constructor(planPath: string, plan: ContinuePlan, live: LiveSource, send: SendMessage, onChange: () => void, watchdogRoot: string) {
62
+ this.planPath = planPath;
63
+ this.plan = plan;
64
+ this.slotMs = plan.slotMs ?? null;
65
+ this.deadlineMs = plan.deadlineMs ?? null;
66
+ this.live = live;
67
+ this.send = send;
68
+ this.onChange = onChange;
69
+ this.watchdogRoot = watchdogRoot;
70
+ }
71
+
72
+ static fromEnvironment(live: LiveSource, send: SendMessage, onChange: () => void, env = process.env): ContinuationExecutor | undefined {
73
+ const planPath = env.PI_TMUX_CONTINUE_PLAN;
74
+ if (!planPath) return undefined;
75
+ const plan = readPlan(planPath);
76
+ if (!plan || plan.sessionId !== live.sessionId()) return undefined;
77
+ return new ContinuationExecutor(planPath, plan, live, send, onChange, env.PI_TMUX_SESSION_WATCHDOG_ROOT ?? "/tmp");
78
+ }
79
+
80
+ /** Executor state as text: planned, dispatched, admitted, an outcome, skipped:<reason>, stopped:<reason>. */
81
+ state(): string {
82
+ return this.stateValue;
83
+ }
84
+
85
+ /** The status sidecar `continue` field. */
86
+ status(): ContinueStatus {
87
+ const separator = this.stateValue.indexOf(":");
88
+ return {
89
+ state: separator === -1 ? this.stateValue : this.stateValue.slice(0, separator),
90
+ reason: separator === -1 ? null : this.stateValue.slice(separator + 1),
91
+ slot_ms: this.slotMs,
92
+ deadline_ms: this.deadlineMs,
93
+ key: this.plan.key,
94
+ ledger_dir: this.plan.ledgerDir,
95
+ };
96
+ }
97
+
98
+ start(): void {
99
+ this.startedAtMs = Date.now();
100
+ // Publish `planned` (with its slot, once known) so a display can show the wait.
101
+ this.onChange();
102
+ this.schedule(0);
103
+ }
104
+
105
+ // -- events forwarded by index.ts -------------------------------------------
106
+
107
+ noteUserActivity(): void {
108
+ if (this.claimed || this.finished) return;
109
+ this.userActive = true;
110
+ this.skip("veto", "user-active");
111
+ }
112
+
113
+ notePrompt(open: boolean): void {
114
+ this.promptDepth = Math.max(0, this.promptDepth + (open ? 1 : -1));
115
+ if (open) this.noteUserActivity();
116
+ }
117
+
118
+ beforeAgentStart(event: { messageType?: string; message?: { customType?: string; details?: { continuationId?: string } } }): void {
119
+ if (event.messageType === "custom" && event.message?.customType === CONTINUE_CUSTOM_TYPE
120
+ && event.message.details?.continuationId === this.continuationId) {
121
+ this.admit();
122
+ return;
123
+ }
124
+ // Any other run before the dispatch is activity in this session.
125
+ if (!this.claimed && !this.finished) {
126
+ this.skip("veto", "busy");
127
+ }
128
+ }
129
+
130
+ agentSettled(outcome: string): void {
131
+ // Only the continuation turn's own outcome; later user runs are not ours.
132
+ if (!this.admitted || this.outcomeWritten) return;
133
+ this.outcomeWritten = true;
134
+ writeOutcome(this.plan, outcome, this.continuationId);
135
+ this.setState(outcome);
136
+ }
137
+
138
+ shutdown(): void {
139
+ this.clearTimers();
140
+ if (!this.claimed && !this.finished) {
141
+ writeSkip(this.plan, "process-end", "session-ended");
142
+ this.finish("skipped:session-ended");
143
+ }
144
+ }
145
+
146
+ // -- internals ----------------------------------------------------------------
147
+
148
+ private setState(value: string): void {
149
+ this.stateValue = value;
150
+ this.onChange();
151
+ }
152
+
153
+ private finish(value: string): void {
154
+ this.finished = true;
155
+ this.clearTimers();
156
+ this.setState(value);
157
+ }
158
+
159
+ private skip(skipClass: "process-end" | "veto", reason: string): void {
160
+ writeSkip(this.plan, skipClass, reason);
161
+ this.finish(`skipped:${reason}`);
162
+ }
163
+
164
+ private clearTimers(): void {
165
+ if (this.timer !== undefined) clearTimeout(this.timer);
166
+ this.timer = undefined;
167
+ }
168
+
169
+ private schedule(ms: number): void {
170
+ this.clearTimers();
171
+ if (this.finished) return;
172
+ this.timer = setTimeout(() => {
173
+ this.timer = undefined;
174
+ try {
175
+ this.tick();
176
+ } catch {
177
+ // A file system error must not crash Pi; give up without dispatching.
178
+ if (!this.claimed) this.finish("stopped:error");
179
+ }
180
+ }, Math.max(0, ms));
181
+ this.timer.unref?.();
182
+ }
183
+
184
+ private tick(): void {
185
+ if (this.finished) return;
186
+ // A veto from outside (TWS `x`) ends the wait at once; the skip file is not ours to write.
187
+ const vetoReason = existingSkipReason(this.plan);
188
+ if (vetoReason !== null) {
189
+ this.finish(`skipped:${vetoReason}`);
190
+ return;
191
+ }
192
+ const now = Date.now();
193
+ switch (readyState(this.plan)) {
194
+ case "waiting":
195
+ if (now - this.startedAtMs > READY_WAIT_LIMIT_MS) {
196
+ this.skip("process-end", "timeout");
197
+ } else {
198
+ this.schedule(POLL_MS);
199
+ }
200
+ return;
201
+ case "not-released":
202
+ this.skip("process-end", "not-released");
203
+ return;
204
+ case "released":
205
+ break;
206
+ }
207
+ const onDisk = readPlan(this.planPath);
208
+ if (onDisk?.token === this.plan.token) {
209
+ this.noteSlot(onDisk.slotMs ?? null, onDisk.deadlineMs ?? null);
210
+ }
211
+ if (onDisk?.slotMs != null && now < onDisk.slotMs && onDisk.token === this.plan.token) {
212
+ this.schedule(Math.min(onDisk.slotMs - now, POLL_MS));
213
+ return;
214
+ }
215
+ if (now < this.waitUntilMs) {
216
+ this.schedule(Math.min(this.waitUntilMs - now, POLL_MS));
217
+ return;
218
+ }
219
+ this.dispatchSection(now);
220
+ }
221
+
222
+ private facts(now: number): LiveFacts {
223
+ return {
224
+ nowMs: now,
225
+ ackResumed: this.live.ackResumed() === true,
226
+ sessionId: this.live.sessionId(),
227
+ sessionFile: this.live.sessionFile(),
228
+ paneKey: this.live.paneKey(),
229
+ lastMessageEntryId: lastMessageEntryId(this.live.branch()),
230
+ idle: this.live.idle(),
231
+ pendingMessages: this.live.pendingMessages(),
232
+ editorText: this.live.editorText(),
233
+ userActive: this.userActive,
234
+ promptOpen: this.promptDepth > 0,
235
+ watchdogAlive: watchdogAlive(this.plan.sessionId, this.watchdogRoot),
236
+ mode: readMode(this.plan.configPath),
237
+ holdPresent: existsSync(this.plan.holdFile),
238
+ skipReason: existingSkipReason(this.plan),
239
+ };
240
+ }
241
+
242
+ private noteSlot(slotMs: number | null, deadlineMs: number | null): void {
243
+ if (slotMs === this.slotMs && deadlineMs === this.deadlineMs) return;
244
+ this.slotMs = slotMs;
245
+ this.deadlineMs = deadlineMs;
246
+ this.onChange();
247
+ }
248
+
249
+ /** One synchronous section: lock, E1-E7, claim, send, timestamp, unlock. */
250
+ private dispatchSection(now: number): void {
251
+ if (this.live.ackResumed() === undefined) {
252
+ this.schedule(POLL_MS); // the acknowledgement is still pending
253
+ return;
254
+ }
255
+ const lock = acquireDispatchLock(this.plan.ledgerDir, this.continuationId);
256
+ if (!lock) {
257
+ this.schedule(LOCK_RETRY_MS);
258
+ return;
259
+ }
260
+ try {
261
+ testCheckpoint(this.plan.ledgerDir, "in-dispatch-lock");
262
+ const claimPath = join(this.plan.ledgerDir, `${this.plan.key}.claim`);
263
+ // The pre-check runs under the dispatch lock; the O_EXCL claim below
264
+ // still decides if another holder claimed in between (reclaimed lock).
265
+ const claimedBefore = existsSync(claimPath);
266
+ const verdict = decide(this.plan, readPlan(this.planPath), this.facts(now), claimedBefore, lastDispatchMs(this.plan.ledgerDir));
267
+ switch (verdict.kind) {
268
+ case "wait":
269
+ this.waitUntilMs = verdict.untilMs;
270
+ this.schedule(Math.min(verdict.untilMs - now, POLL_MS));
271
+ return;
272
+ case "stop":
273
+ this.finish(`stopped:${verdict.reason}`);
274
+ return;
275
+ case "skip":
276
+ this.skip(verdict.skipClass, verdict.reason);
277
+ return;
278
+ case "vetoed":
279
+ this.finish(`skipped:${verdict.reason}`);
280
+ return;
281
+ case "dispatch":
282
+ break;
283
+ }
284
+ const claim = { continuationId: this.continuationId, token: this.plan.token, key: this.plan.key, sessionId: this.plan.sessionId, pid: process.pid, claimedAtMs: now };
285
+ if (!createExclusive(claimPath, `${JSON.stringify(claim)}\n`)) {
286
+ this.finish("stopped:claimed");
287
+ return;
288
+ }
289
+ this.claimed = true;
290
+ testCheckpoint(this.plan.ledgerDir, "after-claim");
291
+ try {
292
+ this.send(
293
+ { customType: CONTINUE_CUSTOM_TYPE, content: continuationMessage(this.plan, now, this.continuationId), display: true, details: { continuationId: this.continuationId } },
294
+ { triggerTurn: true },
295
+ );
296
+ this.setState("dispatched");
297
+ this.admissionTimer = setTimeout(() => {
298
+ if (!this.admitted) {
299
+ writeOutcome(this.plan, "uncertain", this.continuationId, { reason: "no-admission" });
300
+ this.setState("uncertain");
301
+ }
302
+ }, ADMISSION_TIMEOUT_MS);
303
+ this.admissionTimer.unref?.();
304
+ } catch (error) {
305
+ writeOutcome(this.plan, "uncertain", this.continuationId, { reason: `send failed: ${String(error)}` });
306
+ this.setState("uncertain");
307
+ }
308
+ writeAtomicSync(join(this.plan.ledgerDir, ".last-dispatch"), `${JSON.stringify({ atMs: now, pid: process.pid, continuationId: this.continuationId })}\n`);
309
+ this.finished = true;
310
+ } finally {
311
+ releaseDispatchLock(lock);
312
+ }
313
+ }
314
+
315
+ private admit(): void {
316
+ if (this.admitted) return;
317
+ this.admitted = true;
318
+ if (this.admissionTimer !== undefined) clearTimeout(this.admissionTimer);
319
+ // A late admission upgrades an earlier `uncertain` (display only; never re-sent).
320
+ writeOutcome(this.plan, "admitted", this.continuationId);
321
+ this.setState("admitted");
322
+ }
323
+ }
@@ -1,7 +1,7 @@
1
- import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { link, 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
 
@@ -33,6 +33,28 @@ export async function writeAtomic(file: string, data: string, mode: number): Pro
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Publish `file` with its complete content only if it does not exist yet.
38
+ * Returns false when another writer published it first; its content is kept.
39
+ */
40
+ export async function writeAtomicExclusive(file: string, data: string, mode: number): Promise<boolean> {
41
+ const tmp = nextTempPath(file);
42
+ try {
43
+ await writeFile(tmp, data, { mode });
44
+ try {
45
+ await link(tmp, file);
46
+ return true;
47
+ } catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
49
+ return false;
50
+ }
51
+ throw error;
52
+ }
53
+ } finally {
54
+ await rm(tmp, { force: true });
55
+ }
56
+ }
57
+
36
58
  export async function cleanupStaleTempFiles(
37
59
  dir: string,
38
60
  maxAgeMs: number,
@@ -108,8 +130,9 @@ export async function cleanupSupersededStatusFiles(
108
130
 
109
131
  export async function cleanupSupersededSessionMappings(
110
132
  dir: string,
111
- currentFileName: string,
133
+ currentFileNames: string | readonly string[],
112
134
  sessionFile: string,
135
+ paneIdentity?: { paneId: string; tmuxServerPid: string },
113
136
  ): Promise<CleanupResult> {
114
137
  let entries: string[];
115
138
  try {
@@ -118,20 +141,28 @@ export async function cleanupSupersededSessionMappings(
118
141
  return { scanned: 0, removed: 0 };
119
142
  }
120
143
 
144
+ const retained = new Set(
145
+ typeof currentFileNames === "string" ? [currentFileNames] : currentFileNames,
146
+ );
121
147
  let scanned = 0;
122
148
  let removed = 0;
123
149
  for (const entry of entries) {
124
- if (!entry.endsWith(".session") || entry === currentFileName) {
150
+ if (!entry.endsWith(".session") || retained.has(entry)) {
125
151
  continue;
126
152
  }
127
153
  scanned += 1;
128
154
  const path = join(dir, entry);
129
155
  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) {
156
+ const content = await readFile(path, "utf8");
157
+ // A Pi session and a live tmux pane id each have one current owner. A
158
+ // match on either identity proves this is an old key or alias, but only
159
+ // after the caller has atomically published the replacement files.
160
+ const sameSession = mappedSessionFile(content) === sessionFile;
161
+ const mappedIdentity = mappedPaneIdentity(content);
162
+ const samePane = paneIdentity !== undefined
163
+ && mappedIdentity?.paneId === paneIdentity.paneId
164
+ && mappedIdentity.tmuxServerPid === paneIdentity.tmuxServerPid;
165
+ if (!sameSession && !samePane) {
135
166
  continue;
136
167
  }
137
168
  await rm(path, { force: true });