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 +44 -9
- package/package.json +1 -1
- package/src/continuation-executor.ts +323 -0
- package/src/domain/atomic-write.ts +40 -9
- package/src/domain/continuation.ts +374 -0
- 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/pane-ownership.ts +23 -0
- package/src/domain/session-file.ts +55 -0
- package/src/domain/session-mapping.ts +89 -24
- package/src/domain/signal-evidence.ts +56 -0
- package/src/domain/status.ts +55 -0
- package/src/index.ts +263 -41
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Records whether this process received SIGHUP or SIGTERM, so a session_shutdown
|
|
2
|
+
// caused by a closed terminal, a dead tmux server or a reboot can be told apart
|
|
3
|
+
// from an interactive quit (both reach extensions as reason "quit").
|
|
4
|
+
//
|
|
5
|
+
// Pi's own handler starts the shutdown from the same signal dispatch. The
|
|
6
|
+
// listener here only sets a flag; consumers read it later, when the queued
|
|
7
|
+
// status write builds its payload: Node runs every signal listener
|
|
8
|
+
// synchronously before any promise continuation, so the flag is set by then
|
|
9
|
+
// regardless of listener order.
|
|
10
|
+
|
|
11
|
+
export type TerminationSignal = "SIGHUP" | "SIGTERM";
|
|
12
|
+
|
|
13
|
+
type SignalTarget = {
|
|
14
|
+
prependListener(event: TerminationSignal, listener: () => void): unknown;
|
|
15
|
+
removeListener(event: TerminationSignal, listener: () => void): unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let recorded: TerminationSignal | null = null;
|
|
19
|
+
|
|
20
|
+
export function recordedTerminationSignal(): TerminationSignal | null {
|
|
21
|
+
return recorded;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Test hook: forget a recorded signal. */
|
|
25
|
+
export function resetRecordedTerminationSignal(): void {
|
|
26
|
+
recorded = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SignalEvidenceListener = {
|
|
30
|
+
install(): void;
|
|
31
|
+
uninstall(): void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function createSignalEvidenceListener(target: SignalTarget = process): SignalEvidenceListener {
|
|
35
|
+
const listeners = new Map<TerminationSignal, () => void>();
|
|
36
|
+
return {
|
|
37
|
+
install() {
|
|
38
|
+
if (listeners.size > 0) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const signal of ["SIGHUP", "SIGTERM"] as const) {
|
|
42
|
+
const listener = () => {
|
|
43
|
+
recorded ??= signal;
|
|
44
|
+
};
|
|
45
|
+
listeners.set(signal, listener);
|
|
46
|
+
target.prependListener(signal, listener);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
uninstall() {
|
|
50
|
+
for (const [signal, listener] of listeners) {
|
|
51
|
+
target.removeListener(signal, listener);
|
|
52
|
+
}
|
|
53
|
+
listeners.clear();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/domain/status.ts
CHANGED
|
@@ -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;
|
|
@@ -15,6 +16,44 @@ export type SessionInfo = {
|
|
|
15
16
|
sessionName: string | null;
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
export type LiveSessionIdentity = {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
piPid: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Leaf before the run's prompt entry and the run start (agent_start). */
|
|
25
|
+
export type RunAnchor = {
|
|
26
|
+
leaf_id: string | null;
|
|
27
|
+
started_at_ms: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Why a session_shutdown happened: a SIGHUP/SIGTERM (terminal closed, tmux server
|
|
32
|
+
* died, reboot), an interactive quit, a reload, or a session replacement.
|
|
33
|
+
*/
|
|
34
|
+
export type ShutdownCause = "signal" | "quit" | "reload" | "replace";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Auto-continue state of this Pi for displays (TWS). `state`: planned, dispatched, admitted, an
|
|
38
|
+
* outcome (done, failed, cancelled, …), uncertain, skipped or stopped; `reason` for skipped and
|
|
39
|
+
* stopped. Slot and deadline come from the released plan; `key` and `ledger_dir` name the ledger
|
|
40
|
+
* entry, where TWS may veto a planned continuation with `<key>.skip`.
|
|
41
|
+
*/
|
|
42
|
+
export type ContinueStatus = {
|
|
43
|
+
state: string;
|
|
44
|
+
reason: string | null;
|
|
45
|
+
slot_ms: number | null;
|
|
46
|
+
deadline_ms: number | null;
|
|
47
|
+
key: string;
|
|
48
|
+
ledger_dir: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type RecoveryEvidence = {
|
|
52
|
+
runAnchor: RunAnchor | null;
|
|
53
|
+
shutdownCause: ShutdownCause | null;
|
|
54
|
+
continueState?: ContinueStatus | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
18
57
|
export type TwsStatusPayload = {
|
|
19
58
|
schema: 1;
|
|
20
59
|
agent: "pi";
|
|
@@ -27,10 +66,18 @@ export type TwsStatusPayload = {
|
|
|
27
66
|
cwd: string;
|
|
28
67
|
session_file: string | null;
|
|
29
68
|
session_name: string | null;
|
|
69
|
+
// Additive live identity: valid even before the session JSONL exists.
|
|
70
|
+
session_id: string;
|
|
71
|
+
pi_pid: number;
|
|
72
|
+
tmux_server_pid: string | null;
|
|
30
73
|
state: WorkState;
|
|
31
74
|
updated_at_ms: number;
|
|
32
75
|
started_at_ms: number | null;
|
|
33
76
|
finished_at_ms: number | null;
|
|
77
|
+
// Additive recovery evidence for tmux-recovery's auto-continue decision.
|
|
78
|
+
run_anchor: RunAnchor | null;
|
|
79
|
+
shutdown_cause: ShutdownCause | null;
|
|
80
|
+
continue?: ContinueStatus;
|
|
34
81
|
};
|
|
35
82
|
|
|
36
83
|
export function buildTwsStatusPayload(
|
|
@@ -40,6 +87,8 @@ export function buildTwsStatusPayload(
|
|
|
40
87
|
updatedAtMs: number,
|
|
41
88
|
startedAtMs: number | null,
|
|
42
89
|
finishedAtMs: number | null,
|
|
90
|
+
identity: LiveSessionIdentity,
|
|
91
|
+
evidence: RecoveryEvidence = { runAnchor: null, shutdownCause: null },
|
|
43
92
|
): TwsStatusPayload {
|
|
44
93
|
return {
|
|
45
94
|
schema: 1,
|
|
@@ -53,10 +102,16 @@ export function buildTwsStatusPayload(
|
|
|
53
102
|
cwd: session.cwd,
|
|
54
103
|
session_file: session.sessionFile,
|
|
55
104
|
session_name: session.sessionName,
|
|
105
|
+
session_id: identity.sessionId,
|
|
106
|
+
pi_pid: identity.piPid,
|
|
107
|
+
tmux_server_pid: pane.tmuxServerPid,
|
|
56
108
|
state,
|
|
57
109
|
updated_at_ms: updatedAtMs,
|
|
58
110
|
started_at_ms: startedAtMs,
|
|
59
111
|
finished_at_ms: finishedAtMs,
|
|
112
|
+
run_anchor: evidence.runAnchor,
|
|
113
|
+
shutdown_cause: state === "shutdown" ? evidence.shutdownCause : null,
|
|
114
|
+
...(evidence.continueState ? { continue: evidence.continueState } : {}),
|
|
60
115
|
};
|
|
61
116
|
}
|
|
62
117
|
|