pi-tmux-session-map 0.4.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.
@@ -0,0 +1,374 @@
1
+ // Executor side of tmux-recovery's auto-continue (tmux-recovery
2
+ // docs/pi-auto-continue.md §3.4, §3.5, §4.2). The restore engine plans and
3
+ // releases a ledger entry; this module decides, immediately before dispatch,
4
+ // whether the one visible continuation turn may start, and records it.
5
+ //
6
+ // Exclusivity: O_EXCL files only. `K.claim` is created with "wx" and never
7
+ // removed; `K.skip` is first-writer-wins, so a veto is never downgraded; the
8
+ // global dispatch lock is a hard link of a fully written file and is only
9
+ // removed by its owner (inode check) or by the restore engine's reconciler.
10
+ // Everything here is synchronous so a dispatch decision cannot interleave with
11
+ // user input or another turn in this process.
12
+
13
+ import {
14
+ closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync,
15
+ writeFileSync, writeSync,
16
+ } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ export const CONTINUE_CUSTOM_TYPE = "tmux-recovery-continue";
21
+
22
+ export type ContinuePlan = {
23
+ schema: number;
24
+ key: string;
25
+ state: string;
26
+ chain: number;
27
+ token: string;
28
+ paneKey: string;
29
+ sessionId: string;
30
+ sessionFile: string;
31
+ frozenLeafId: string;
32
+ interruptedAtMs: number | null;
33
+ cause: "signal" | "hard" | null;
34
+ toolCalls: { id: string; name: string }[];
35
+ ledgerDir: string;
36
+ readyPath: string;
37
+ configPath: string;
38
+ holdFile: string;
39
+ spacingMs: number;
40
+ slotMs: number | null;
41
+ deadlineMs: number | null;
42
+ };
43
+
44
+ export function readPlan(path: string): ContinuePlan | undefined {
45
+ try {
46
+ const value = JSON.parse(readFileSync(path, "utf8"));
47
+ if (typeof value?.key !== "string" || typeof value?.token !== "string" || typeof value?.ledgerDir !== "string") {
48
+ return undefined;
49
+ }
50
+ return value as ContinuePlan;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ export type ReadyState = "waiting" | "released" | "not-released";
57
+
58
+ export function readyState(plan: ContinuePlan): ReadyState {
59
+ if (!existsSync(plan.readyPath)) {
60
+ return "waiting";
61
+ }
62
+ try {
63
+ const ready = JSON.parse(readFileSync(plan.readyPath, "utf8"));
64
+ return ready?.tokens?.[plan.token] ? "released" : "not-released";
65
+ } catch {
66
+ return "waiting";
67
+ }
68
+ }
69
+
70
+ function fsyncDirectory(dir: string): void {
71
+ const fd = openSync(dir, "r");
72
+ try {
73
+ fsyncSync(fd);
74
+ } finally {
75
+ closeSync(fd);
76
+ }
77
+ }
78
+
79
+ /** Create `path` with `content` only if it does not exist (O_EXCL). */
80
+ export function createExclusive(path: string, content: string): boolean {
81
+ let fd: number;
82
+ try {
83
+ fd = openSync(path, "wx", 0o600);
84
+ } catch (error) {
85
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
86
+ return false;
87
+ }
88
+ throw error;
89
+ }
90
+ try {
91
+ writeSync(fd, content);
92
+ fsyncSync(fd);
93
+ } finally {
94
+ closeSync(fd);
95
+ }
96
+ fsyncDirectory(dirname(path));
97
+ return true;
98
+ }
99
+
100
+ export function writeAtomicSync(path: string, content: string): void {
101
+ const tmp = `${path}.tmp-${process.pid}`;
102
+ writeFileSync(tmp, content, { mode: 0o600 });
103
+ const fd = openSync(tmp, "r");
104
+ try {
105
+ fsyncSync(fd);
106
+ } finally {
107
+ closeSync(fd);
108
+ }
109
+ renameSync(tmp, path);
110
+ fsyncDirectory(dirname(path));
111
+ }
112
+
113
+ export type SkipClass = "process-end" | "veto";
114
+
115
+ /** First writer wins: a veto written earlier is kept. */
116
+ export function writeSkip(plan: ContinuePlan, skipClass: SkipClass, reason: string, nowMs = Date.now()): boolean {
117
+ if (existsSync(join(plan.ledgerDir, `${plan.key}.claim`))) {
118
+ return false;
119
+ }
120
+ return createExclusive(
121
+ join(plan.ledgerDir, `${plan.key}.skip`),
122
+ `${JSON.stringify({ class: skipClass, reason, token: plan.token, pid: process.pid, atMs: nowMs })}\n`,
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Reason of an existing `K.skip` from any writer, for example a veto from TWS
128
+ * (`{"class": "veto", "reason": "skipped-in-tws", …}`); null without one. A
129
+ * file that cannot be read or parsed still counts as a veto (fail closed).
130
+ */
131
+ export function existingSkipReason(plan: ContinuePlan): string | null {
132
+ const path = join(plan.ledgerDir, `${plan.key}.skip`);
133
+ let text: string;
134
+ try {
135
+ text = readFileSync(path, "utf8");
136
+ } catch (error) {
137
+ return isErrno(error, "ENOENT") ? null : "vetoed";
138
+ }
139
+ try {
140
+ const value: unknown = JSON.parse(text);
141
+ if (typeof value === "object" && value !== null && "reason" in value && typeof value.reason === "string" && value.reason !== "") {
142
+ return value.reason;
143
+ }
144
+ } catch {
145
+ // a skip that is still being written or is damaged is a veto without a reason
146
+ }
147
+ return "vetoed";
148
+ }
149
+
150
+ function isErrno(error: unknown, code: string): boolean {
151
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
152
+ }
153
+
154
+ export function writeOutcome(plan: ContinuePlan, outcome: string, continuationId: string, extra: Record<string, unknown> = {}): void {
155
+ writeAtomicSync(
156
+ join(plan.ledgerDir, `${plan.key}.outcome`),
157
+ `${JSON.stringify({ outcome, continuationId, atMs: Date.now(), pid: process.pid, ...extra })}\n`,
158
+ );
159
+ }
160
+
161
+ // -- dispatch lock -----------------------------------------------------------
162
+
163
+ export type DispatchLock = { path: string; inode: number };
164
+
165
+ export function acquireDispatchLock(ledgerDir: string, continuationId: string): DispatchLock | undefined {
166
+ const path = join(ledgerDir, "dispatch.lock");
167
+ const tmp = join(ledgerDir, `.dispatch.lock.${process.pid}`);
168
+ writeFileSync(tmp, `${JSON.stringify({ pid: process.pid, continuationId, atMs: Date.now() })}\n`, { mode: 0o600 });
169
+ const fd = openSync(tmp, "r");
170
+ try {
171
+ fsyncSync(fd);
172
+ } finally {
173
+ closeSync(fd);
174
+ }
175
+ try {
176
+ linkSync(tmp, path);
177
+ } catch (error) {
178
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
179
+ return undefined;
180
+ }
181
+ throw error;
182
+ } finally {
183
+ unlinkSync(tmp);
184
+ }
185
+ return { path, inode: statSync(path).ino };
186
+ }
187
+
188
+ export function releaseDispatchLock(lock: DispatchLock): void {
189
+ try {
190
+ if (statSync(lock.path).ino === lock.inode) {
191
+ unlinkSync(lock.path);
192
+ }
193
+ } catch {
194
+ // Already reclaimed; never touch a successor's lock.
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Test-only pause points for crash drills (tmux-recovery docs/pi-auto-continue.md §6.2). A point
200
+ * is active when `PI_TMUX_CONTINUE_TEST_PAUSE_AT` names it or the file `<ledger>/.test-pause-at`
201
+ * contains its name; the ledger directory is private to the user (0700). The executor then
202
+ * records `<ledger>/.test-reached-<name>` with its pid and blocks synchronously, so the drill can
203
+ * kill the process exactly there, until `<ledger>/.test-release-<name>` exists, at most 120 s.
204
+ */
205
+ export function testCheckpoint(ledgerDir: string, name: string, env: NodeJS.ProcessEnv = process.env): void {
206
+ let active = env.PI_TMUX_CONTINUE_TEST_PAUSE_AT === name;
207
+ if (!active) {
208
+ try {
209
+ active = readFileSync(join(ledgerDir, ".test-pause-at"), "utf8").trim() === name;
210
+ } catch {
211
+ return;
212
+ }
213
+ }
214
+ if (!active) return;
215
+ writeFileSync(join(ledgerDir, `.test-reached-${name}`), `${JSON.stringify({ pid: process.pid, atMs: Date.now() })}\n`);
216
+ const release = join(ledgerDir, `.test-release-${name}`);
217
+ const deadline = Date.now() + 120_000;
218
+ const cell = new Int32Array(new SharedArrayBuffer(4));
219
+ while (!existsSync(release) && Date.now() < deadline) {
220
+ try {
221
+ Atomics.wait(cell, 0, 0, 100);
222
+ } catch {
223
+ execFileSync("sleep", ["0.1"]);
224
+ }
225
+ }
226
+ }
227
+
228
+ export function lastDispatchMs(ledgerDir: string): number | null {
229
+ try {
230
+ const value = JSON.parse(readFileSync(join(ledgerDir, ".last-dispatch"), "utf8"));
231
+ return typeof value?.atMs === "number" ? value.atMs : null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ // -- executor rules E1-E7 ----------------------------------------------------
238
+
239
+ export type LiveFacts = {
240
+ nowMs: number;
241
+ ackResumed: boolean;
242
+ sessionId: string;
243
+ sessionFile: string | null;
244
+ paneKey: string | null;
245
+ lastMessageEntryId: string | null;
246
+ idle: boolean;
247
+ pendingMessages: boolean;
248
+ editorText: string;
249
+ userActive: boolean;
250
+ promptOpen: boolean;
251
+ watchdogAlive: boolean;
252
+ mode: string;
253
+ holdPresent: boolean;
254
+ /** Reason of a `K.skip` that another writer (TWS) created; null without one. */
255
+ skipReason: string | null;
256
+ };
257
+
258
+ export type Verdict =
259
+ | { kind: "dispatch" }
260
+ | { kind: "wait"; untilMs: number }
261
+ | { kind: "skip"; skipClass: SkipClass; reason: string }
262
+ | { kind: "stop"; reason: string }
263
+ /** An existing `K.skip` decided it already; nothing more is written. */
264
+ | { kind: "vetoed"; reason: string };
265
+
266
+ /** E1-E7 against the plan as currently on disk. Caller holds the dispatch lock (E7). */
267
+ export function decide(plan: ContinuePlan, onDisk: ContinuePlan | undefined, facts: LiveFacts, ledgerHasClaim: boolean, lastDispatch: number | null): Verdict {
268
+ // E1
269
+ if (!facts.ackResumed) return { kind: "skip", skipClass: "process-end", reason: "ack" };
270
+ // E7: plan state, claim, deadline, spacing
271
+ if (!onDisk || onDisk.token !== plan.token || onDisk.state !== "released") return { kind: "stop", reason: "not-released" };
272
+ if (ledgerHasClaim) return { kind: "stop", reason: "claimed" };
273
+ // A veto from outside (TWS) counts until the claim; after the claim it has no effect.
274
+ if (facts.skipReason !== null) return { kind: "vetoed", reason: facts.skipReason };
275
+ if (onDisk.deadlineMs !== null && facts.nowMs > onDisk.deadlineMs) return { kind: "skip", skipClass: "process-end", reason: "timeout" };
276
+ if (onDisk.slotMs !== null && facts.nowMs < onDisk.slotMs) return { kind: "wait", untilMs: onDisk.slotMs };
277
+ if (lastDispatch !== null && facts.nowMs < lastDispatch + plan.spacingMs) return { kind: "wait", untilMs: lastDispatch + plan.spacingMs };
278
+ // E6
279
+ if (facts.mode !== "on") return { kind: "skip", skipClass: "veto", reason: "disabled" };
280
+ if (facts.holdPresent) return { kind: "skip", skipClass: "veto", reason: "hold" };
281
+ // E2
282
+ if (facts.sessionId !== plan.sessionId || facts.sessionFile !== plan.sessionFile || facts.paneKey !== plan.paneKey) {
283
+ return { kind: "skip", skipClass: "veto", reason: "identity" };
284
+ }
285
+ // E3
286
+ if (facts.lastMessageEntryId !== plan.frozenLeafId) return { kind: "skip", skipClass: "veto", reason: "new-entry" };
287
+ // E4
288
+ if (facts.userActive || facts.editorText.trim() !== "" || facts.promptOpen) {
289
+ return { kind: "skip", skipClass: "veto", reason: "user-active" };
290
+ }
291
+ if (!facts.idle || facts.pendingMessages) return { kind: "skip", skipClass: "veto", reason: "busy" };
292
+ // E5
293
+ if (facts.watchdogAlive) return { kind: "skip", skipClass: "veto", reason: "watchdog" };
294
+ return { kind: "dispatch" };
295
+ }
296
+
297
+ export function readMode(configPath: string): string {
298
+ try {
299
+ const value = JSON.parse(readFileSync(configPath, "utf8"));
300
+ return typeof value?.mode === "string" ? value.mode : "off";
301
+ } catch {
302
+ return "off";
303
+ }
304
+ }
305
+
306
+ /** pi-session-watchdog keeps its pid file in /tmp/pi-session-watchdog-<session id>/. */
307
+ export function watchdogAlive(sessionId: string, root = "/tmp"): boolean {
308
+ const pidFile = join(root, `pi-session-watchdog-${sessionId}`, "watchdog.pid");
309
+ let pid: number;
310
+ try {
311
+ pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
312
+ } catch {
313
+ return false;
314
+ }
315
+ if (!Number.isInteger(pid) || pid <= 0) return false;
316
+ try {
317
+ process.kill(pid, 0);
318
+ } catch {
319
+ return false;
320
+ }
321
+ try {
322
+ return execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).includes("pi-session-watchdog");
323
+ } catch {
324
+ return true; // cannot tell: fail closed
325
+ }
326
+ }
327
+
328
+ type BranchEntry = { id?: string; type?: string; message?: { role?: string } };
329
+
330
+ export function lastMessageEntryId(branch: readonly BranchEntry[]): string | null {
331
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
332
+ const entry = branch[index]!;
333
+ if (entry.type === "custom_message" || (entry.type === "message" && entry.message?.role !== "system")) {
334
+ return entry.id ?? null;
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+
340
+ // -- the visible message -----------------------------------------------------
341
+
342
+ function elapsedGerman(ms: number): string {
343
+ const seconds = Math.max(0, Math.round(ms / 1000));
344
+ const unit = (value: number, one: string, many: string) => `${value} ${value === 1 ? one : many}`;
345
+ if (seconds < 90) return unit(seconds, "Sekunde", "Sekunden");
346
+ const minutes = Math.round(seconds / 60);
347
+ if (minutes < 90) return unit(minutes, "Minute", "Minuten");
348
+ const hours = Math.round(minutes / 60);
349
+ if (hours < 36) return unit(hours, "Stunde", "Stunden");
350
+ return unit(Math.round(hours / 24), "Tag", "Tagen");
351
+ }
352
+
353
+ function dateGerman(ms: number): string {
354
+ const date = new Date(ms);
355
+ const pad = (value: number) => String(value).padStart(2, "0");
356
+ return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} um ${pad(date.getHours())}:${pad(date.getMinutes())}`;
357
+ }
358
+
359
+ export function continuationMessage(plan: ContinuePlan, nowMs: number, continuationId: string): string {
360
+ const stopped = plan.interruptedAtMs ?? nowMs;
361
+ const how = plan.cause === "hard" ? "abrupt beendet (Absturz oder Stromausfall)" : "beendet (Terminal geschlossen, tmux beendet oder Neustart)";
362
+ const tools = plan.toolCalls.length > 0
363
+ ? `Ohne Ergebnis unterbrochen: ${plan.toolCalls.map((call) => `\`${call.name}\` (${call.id})`).join(", ")}.`
364
+ : "Es war kein Werkzeugaufruf offen.";
365
+ return [
366
+ `[tmux-recovery] Diese Session lief, als sie am ${dateGerman(stopped)} ${how} wurde, vor ${elapsedGerman(nowMs - stopped)}.`,
367
+ "Pi wurde mit genau dieser Session wiederhergestellt.",
368
+ tools,
369
+ "Prüfe zuerst den tatsächlichen Stand (Dateien, Git, Prozesse, Remote-Effekte), bevor du eine Aktion wiederholst, die schon gelaufen sein kann.",
370
+ "Ob Hintergrundprozesse, Subagents oder Remote-Jobs von vorher noch laufen und was sie getan haben, ist unbekannt: prüfe das, bevor du sie neu startest.",
371
+ "Setze danach die ursprüngliche Aufgabe fort.",
372
+ `continuationId=${continuationId}`,
373
+ ].join("\n");
374
+ }
@@ -0,0 +1,23 @@
1
+ export type PaneOwnershipEvidence = {
2
+ mode: string;
3
+ stdinIsTty: boolean;
4
+ subagentChild: boolean;
5
+ };
6
+
7
+ export function paneOwnershipEvidence(
8
+ mode: string,
9
+ stdinIsTty: boolean | undefined,
10
+ env: NodeJS.ProcessEnv = process.env,
11
+ ): PaneOwnershipEvidence {
12
+ return {
13
+ mode,
14
+ stdinIsTty: stdinIsTty === true,
15
+ subagentChild: env.PI_SUBAGENT_CHILD === "1",
16
+ };
17
+ }
18
+
19
+ export function ownsInteractivePane(evidence: PaneOwnershipEvidence): boolean {
20
+ return evidence.mode === "tui"
21
+ && evidence.stdinIsTty
22
+ && !evidence.subagentChild;
23
+ }
@@ -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
+ }
@@ -16,6 +16,44 @@ export type SessionInfo = {
16
16
  sessionName: string | null;
17
17
  };
18
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
+
19
57
  export type TwsStatusPayload = {
20
58
  schema: 1;
21
59
  agent: "pi";
@@ -28,10 +66,18 @@ export type TwsStatusPayload = {
28
66
  cwd: string;
29
67
  session_file: string | null;
30
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;
31
73
  state: WorkState;
32
74
  updated_at_ms: number;
33
75
  started_at_ms: number | null;
34
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;
35
81
  };
36
82
 
37
83
  export function buildTwsStatusPayload(
@@ -41,6 +87,8 @@ export function buildTwsStatusPayload(
41
87
  updatedAtMs: number,
42
88
  startedAtMs: number | null,
43
89
  finishedAtMs: number | null,
90
+ identity: LiveSessionIdentity,
91
+ evidence: RecoveryEvidence = { runAnchor: null, shutdownCause: null },
44
92
  ): TwsStatusPayload {
45
93
  return {
46
94
  schema: 1,
@@ -54,10 +102,16 @@ export function buildTwsStatusPayload(
54
102
  cwd: session.cwd,
55
103
  session_file: session.sessionFile,
56
104
  session_name: session.sessionName,
105
+ session_id: identity.sessionId,
106
+ pi_pid: identity.piPid,
107
+ tmux_server_pid: pane.tmuxServerPid,
57
108
  state,
58
109
  updated_at_ms: updatedAtMs,
59
110
  started_at_ms: startedAtMs,
60
111
  finished_at_ms: finishedAtMs,
112
+ run_anchor: evidence.runAnchor,
113
+ shutdown_cause: state === "shutdown" ? evidence.shutdownCause : null,
114
+ ...(evidence.continueState ? { continue: evidence.continueState } : {}),
61
115
  };
62
116
  }
63
117