@aiwayds/dsh-tui-pi 0.29.0 → 1.0.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,273 @@
1
+ /**
2
+ * Corrupted-resume-log repair orchestration — the /resume safety net.
3
+ *
4
+ * When a persisted session's log was forked by a historical double-writer
5
+ * (two dsh processes appending to one jsonl), the loader refuses it with
6
+ * "corrupt session log: seq gap …" and /resume dead-ends. The offline
7
+ * surgery itself lives in scripts/repair-session-log.mjs (deterministic
8
+ * dedupe + dense renumbering; the original is NEVER modified there). This
9
+ * module is the TUI-free bridge around it: it runs the script, verifies the
10
+ * result, and — only after an explicit user confirmation upstream — swaps
11
+ * the repaired copy in beside a kept backup, under the single-writer lock.
12
+ *
13
+ * Contract of the script (its own header is authoritative):
14
+ * exit 0 → the log is CLEAN (nothing written), or — with --apply — a
15
+ * `<stem>.repaired.jsonl[.zstd]` artifact was written BESIDE the
16
+ * original (the CLEAN verdict line distinguishes the two);
17
+ * exit 3 → corrupt diagnosed, nothing written (dry-run only by contract);
18
+ * exit 2 → usage/environment error (missing zstd, torn lines without
19
+ * --skip-bad-lines, …).
20
+ *
21
+ * Safety invariants (all enforced here, testable without a terminal):
22
+ * - The original log is never edited in place — swapping moves it aside to
23
+ * `<name>.corrupt-bak` first and only then renames the verified repaired
24
+ * copy over the canonical name.
25
+ * - A repaired copy that does not itself verify CLEAN is never swapped in.
26
+ * - The writer lock beside the log (<sessionDir>/writer.lock, same file the
27
+ * cold arms and the feishu guard compete on) is claimed for the whole
28
+ * apply→verify→swap window and always released, success or not.
29
+ */
30
+ import { spawnSync } from 'node:child_process';
31
+ import { chmodSync, existsSync, renameSync, statSync } from 'node:fs';
32
+ import { basename, dirname, join } from 'node:path';
33
+ import { SESSION_LOG_FILE_NAMES } from "./retention.js";
34
+ import { acquireWriterLock, projectKeyFor, releaseOwnedWriterLock, } from "./writer-lock.js";
35
+ /** The repair script rides the published package (`files` includes scripts/). */
36
+ export function repairScriptPath() {
37
+ return new URL('../scripts/repair-session-log.mjs', import.meta.url).pathname;
38
+ }
39
+ /** Hard wall-clock budget for one script invocation (big logs still finish; a hung zstd must not freeze /resume). */
40
+ export const REPAIR_TIMEOUT_MS = 120_000;
41
+ /**
42
+ * The path the repair script writes its artifact to (mirrors the script's
43
+ * own `<stem>.repaired.jsonl[.zstd]` naming, stem = name minus the jsonl[.zstd]
44
+ * suffix). Exported so tests and callers can predict the artifact location.
45
+ */
46
+ export function repairedArtifactPath(logPath) {
47
+ const isZstd = /\.jsonl\.zstd$/.test(logPath);
48
+ const stem = basename(logPath).replace(/\.jsonl(\.zstd)?$/, '');
49
+ return join(dirname(logPath), `${stem}.repaired.jsonl${isZstd ? '.zstd' : ''}`);
50
+ }
51
+ function firstLine(text) {
52
+ const line = text.split('\n').map(l => l.trim()).find(l => l !== '');
53
+ return line ?? '';
54
+ }
55
+ /**
56
+ * Run scripts/repair-session-log.mjs against one log. Never touches the log
57
+ * itself (the script cannot); classifies the outcome per the exit contract
58
+ * above. A missing zstd binary surfaces as the script's exit 2 — or, should
59
+ * the spawn itself fail with ENOENT, as a failed result with an install hint.
60
+ */
61
+ export function runRepair(logPath, options = {}) {
62
+ const apply = options.apply ?? false;
63
+ const spawn = options.spawnSyncFn ?? spawnSync;
64
+ let proc;
65
+ try {
66
+ proc = spawn(process.execPath, [repairScriptPath(), logPath, ...(apply ? ['--apply'] : [])], { encoding: 'utf8', timeout: REPAIR_TIMEOUT_MS, maxBuffer: 1 << 26 });
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ return { status: 'failed', detail: `cannot run the repair script: ${message}` };
71
+ }
72
+ if (proc.error !== undefined) {
73
+ const code = proc.error.code;
74
+ const hint = code === 'ENOENT' ? ' — is zstd installed and on PATH?' : '';
75
+ return {
76
+ status: 'failed',
77
+ detail: `cannot run the repair script: ${proc.error.message}${hint}`,
78
+ };
79
+ }
80
+ const out = proc.stdout ?? '';
81
+ const err = proc.stderr ?? '';
82
+ if (proc.status === 0) {
83
+ // Exit 0 is ambiguous by contract (CLEAN vs. repaired+written); the
84
+ // CLEAN verdict line disambiguates without guessing at files.
85
+ if (out.includes('verdict: CLEAN'))
86
+ return { status: 'clean' };
87
+ const repairedPath = repairedArtifactPath(logPath);
88
+ if (existsSync(repairedPath)) {
89
+ return { status: 'repaired', repairedPath, detail: firstLine(out) };
90
+ }
91
+ return {
92
+ status: 'failed',
93
+ detail: 'repair script exited 0 but wrote no repaired artifact beside the log',
94
+ };
95
+ }
96
+ if (proc.status === 3) {
97
+ // By the script's contract exit 3 means "corrupt, dry-run, nothing
98
+ // written" — with --apply this is a contract violation, but either way
99
+ // there is nothing to swap in, so it is a failure for our caller.
100
+ return {
101
+ status: 'failed',
102
+ detail: firstLine(out) || 'log is corrupt — the script diagnosed it without writing a repair',
103
+ };
104
+ }
105
+ return {
106
+ status: 'failed',
107
+ detail: firstLine(err) || `repair script exited ${proc.status ?? 'by signal'}`,
108
+ };
109
+ }
110
+ /**
111
+ * Verify one log loads clean (dry-run diagnosis, never writes). Used on the
112
+ * repaired artifact BEFORE it replaces the canonical log.
113
+ */
114
+ export function verifyClean(logPath, options = {}) {
115
+ const run = runRepair(logPath, { ...options, apply: false });
116
+ return run.status === 'clean' ? { ok: true } : { ok: false, detail: run.detail };
117
+ }
118
+ /**
119
+ * Swap the verified repaired copy in over the canonical log name: the
120
+ * original moves aside to `<name>.corrupt-bak` (suffixed with the epoch
121
+ * millisecond when that name is taken — never overwritten), the repaired
122
+ * copy takes its place with owner-only permissions, matching the store's
123
+ * private-file hygiene. Synchronous on purpose: the swap window sits under
124
+ * the writer lock and must be a tight rename-rename pair. A failed swap-in
125
+ * restores the original best-effort (mirroring the feishu surface) before
126
+ * rethrowing, so the session is never stranded without its canonical log.
127
+ */
128
+ export function swapRepaired(logPath, repairedPath, options = {}) {
129
+ let backupPath = `${logPath}.corrupt-bak`;
130
+ try {
131
+ statSync(backupPath);
132
+ backupPath = `${backupPath}.${Date.now()}`;
133
+ }
134
+ catch {
135
+ // Free — the plain .corrupt-bak name is ours.
136
+ }
137
+ renameSync(logPath, backupPath);
138
+ try {
139
+ renameSync(repairedPath, logPath);
140
+ }
141
+ catch (error) {
142
+ // Best-effort restore first — the canonical name must not stay missing
143
+ // with the original parked in the backup; the rethrow is the signal.
144
+ try {
145
+ renameSync(backupPath, logPath);
146
+ }
147
+ catch {
148
+ // Nothing further is recoverable here; the caller reports the swap
149
+ // failure and the .corrupt-bak copy still holds the original.
150
+ }
151
+ throw error;
152
+ }
153
+ try {
154
+ ;
155
+ (options.chmodFn ?? chmodSync)(logPath, 0o600);
156
+ }
157
+ catch {
158
+ // Permissions are store hygiene, not log integrity: the swap already
159
+ // succeeded, so a chmod failure never flips the outcome. A later
160
+ // successful repair (or any manual chmod) fixes the mode.
161
+ }
162
+ return { backupPath };
163
+ }
164
+ /**
165
+ * End-to-end repair of one session log, under the single-writer lock beside
166
+ * it: claim → apply → verify the repaired copy → swap → release. ANY failure
167
+ * leaves the canonical log untouched, releases the lock, and reports
168
+ * {kind:'failed'}; only a verified artifact is ever swapped in. The lock dir
169
+ * is the log's own directory — the same `<sessionDir>/writer.lock` the cold
170
+ * arms and the feishu guard compete on.
171
+ */
172
+ export async function repairSessionLog(logPath, options = {}) {
173
+ const dir = dirname(logPath);
174
+ let claim;
175
+ try {
176
+ claim = await acquireWriterLock(dir);
177
+ }
178
+ catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ return { kind: 'failed', detail: `cannot claim the writer lock: ${message}` };
181
+ }
182
+ if (!claim.ok)
183
+ return { kind: 'locked', holder: claim.holder };
184
+ try {
185
+ const run = runRepair(logPath, { apply: true, spawnSyncFn: options.spawnSyncFn });
186
+ if (run.status === 'failed')
187
+ return { kind: 'failed', detail: run.detail };
188
+ if (run.status === 'clean')
189
+ return { kind: 'clean' };
190
+ const verdict = verifyClean(run.repairedPath, options);
191
+ if (!verdict.ok) {
192
+ return {
193
+ kind: 'failed',
194
+ detail: `the repaired copy still fails verification: ${verdict.detail}`,
195
+ };
196
+ }
197
+ return {
198
+ kind: 'repaired',
199
+ backupPath: swapRepaired(logPath, run.repairedPath, options).backupPath,
200
+ };
201
+ }
202
+ catch (error) {
203
+ // swapRepaired restored the original best-effort before rethrowing; map
204
+ // the residue to a plain failure so no exception ever escapes toward the
205
+ // command dispatch — this module's contract is results, not throws.
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ return { kind: 'failed', detail: `swap failed: ${message}` };
208
+ }
209
+ finally {
210
+ await releaseOwnedWriterLock(dir);
211
+ }
212
+ }
213
+ /**
214
+ * The /resume corrupt-log fingerprint: the loader's "corrupt session log:
215
+ * seq gap …" refusal, plus the raw-decompression twin ("corrupt zstandard
216
+ * log"). Case-insensitive; anything else (locked session, missing
217
+ * persistence, network) must NOT route into the repair flow.
218
+ */
219
+ const CORRUPT_LOG_PATTERN = /corrupt .*(session|zstandard) log/i;
220
+ export function isCorruptLogError(message) {
221
+ return CORRUPT_LOG_PATTERN.test(message);
222
+ }
223
+ /**
224
+ * User-facing notice for a repair attempt that did NOT swap anything;
225
+ * undefined when the flow should proceed to resume ('repaired' | 'clean').
226
+ * Pure so the wording is testable without a terminal (English-only).
227
+ */
228
+ export function repairFailureNotice(result) {
229
+ if (result.kind === 'locked') {
230
+ return `session is driven by pid ${result.holder.pid} — close it on the other side first`;
231
+ }
232
+ if (result.kind === 'failed') {
233
+ return `repair failed: ${result.detail} — log untouched`;
234
+ }
235
+ return undefined;
236
+ }
237
+ /**
238
+ * Locate one session's canonical log file on disk the same way the
239
+ * single-writer guard derives it: `<root>/<projectKeyFor(cwd)>/<sessionId>/<name>`,
240
+ * with the cwd taken from the persisted header list (the log lives under
241
+ * THAT project key). Best-effort: no header, no cwd, or no log file under
242
+ * the derived dir yields undefined — the caller reports a failure instead
243
+ * of repairing a decoy. When both suffixes coexist (a kept raw copy beside
244
+ * the compressed log) the compressed name wins: it is what the writer
245
+ * appends to and what the loader reads.
246
+ */
247
+ export async function locateSessionLog(persistence, sessionId, root) {
248
+ let cwd;
249
+ try {
250
+ const stored = (await persistence?.list?.().catch(() => [])) ?? [];
251
+ const header = stored.find(candidate => String(candidate.id) === sessionId);
252
+ if (typeof header?.cwd === 'string' && header.cwd !== '')
253
+ cwd = header.cwd;
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ if (cwd === undefined)
259
+ return undefined;
260
+ const dir = join(root, projectKeyFor(cwd), sessionId);
261
+ for (const name of SESSION_LOG_FILE_NAMES.slice().reverse()) {
262
+ const file = join(dir, name);
263
+ try {
264
+ statSync(file);
265
+ return file;
266
+ }
267
+ catch {
268
+ // Not this suffix — try the next one.
269
+ }
270
+ }
271
+ return undefined;
272
+ }
273
+ //# sourceMappingURL=log-repair.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-repair.js","sourceRoot":"","sources":["../src/log-repair.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The /resume repair confirmation dialog — shown when a picked session's
3
+ * log fails to load with a corrupt-log error (src/log-repair.ts
4
+ * `isCorruptLogError`). Repairing rewrites user data on disk, so it is
5
+ * NEVER done implicitly: this dialog is the explicit confirmation gate.
6
+ *
7
+ * Two options, in the spec's button order:
8
+ * 1. Repair & resume — run the guarded in-place repair, then re-enter the
9
+ * selected row's resume path.
10
+ * 2. Cancel — leave the log untouched; /resume reports the original error.
11
+ * ↑↓ moves the selection, `1`/`2` select directly, Enter confirms, Esc
12
+ * cancels — the exact keymap of the submit routing dialog (route-dialog.ts),
13
+ * whose pure-reducer split this file mirrors so the decision matrix stays
14
+ * unit-testable without a terminal. Framing/focus follow the shared overlay
15
+ * contract: PanelHost framing, close re-focuses the CURRENT editor instance
16
+ * through `restoreFocus`.
17
+ */
18
+ import { type Component, type TUI } from '@earendil-works/pi-tui';
19
+ import { type TuiTheme } from './theme/index.ts';
20
+ /** The two choices in display order; index 0 is preselected. */
21
+ export declare const REPAIR_CONFIRM_OPTIONS: ReadonlyArray<{
22
+ id: 'repair' | 'cancel';
23
+ title: string;
24
+ hint: string;
25
+ }>;
26
+ /** The fixed body copy (spec wording): what happened, what repair does, what is kept. */
27
+ export declare const REPAIR_CONFIRM_MESSAGE: string;
28
+ /** Footer hint — hardcoded like every other panel footer (English-only). */
29
+ export declare const REPAIR_CONFIRM_FOOTER = "\u2191\u2193 select \u00B7 1/2 pick \u00B7 Enter confirm \u00B7 Esc cancel";
30
+ /** Pure dialog state: which row is highlighted, and the terminal outcome. */
31
+ export interface RepairConfirmState {
32
+ selected: number;
33
+ /**
34
+ * Set once by a terminal key: `'confirm'` (Enter on a selection) or
35
+ * `'cancel'` (Esc). Further input is ignored afterwards.
36
+ */
37
+ settled?: 'confirm' | 'cancel';
38
+ }
39
+ export declare function initialRepairConfirmState(): RepairConfirmState;
40
+ /**
41
+ * Apply one raw key sequence to the dialog state. Unknown keys are no-ops;
42
+ * anything after a settle is ignored (single terminal outcome guard).
43
+ */
44
+ export declare function updateRepairConfirm(state: RepairConfirmState, data: string): RepairConfirmState;
45
+ /** Resolved dialog outcome: the chosen action, or undefined on cancel. */
46
+ export declare function repairConfirmOutcome(state: RepairConfirmState): 'repair' | 'cancel' | undefined;
47
+ /**
48
+ * The framed overlay component. Renders the fixed body copy plus the two
49
+ * option rows; every key goes through {@link updateRepairConfirm}, and the
50
+ * first terminal key fires `onFinish` exactly once.
51
+ */
52
+ export declare class RepairConfirmPanel implements Component {
53
+ private readonly theme;
54
+ private readonly onFinish;
55
+ private readonly requestRenderFn;
56
+ private state;
57
+ constructor(theme: TuiTheme, onFinish: (outcome: 'repair' | 'cancel' | undefined) => void, requestRender: () => void);
58
+ invalidate(): void;
59
+ render(width: number): string[];
60
+ handleInput(data: string): void;
61
+ }
62
+ /**
63
+ * Open the repair confirmation dialog for one corrupt resume target.
64
+ * Resolves `'repair'` when the user explicitly confirmed, `'cancelled'`
65
+ * otherwise (Esc, or an overlay that failed to mount — treated as cancel so
66
+ * a half-mounted dialog can never imply consent). Closing always hands
67
+ * focus back through `restoreFocus` before the promise settles.
68
+ */
69
+ export declare function openRepairConfirmDialog(tui: TUI, theme: TuiTheme, restoreFocus: () => void): Promise<'repair' | 'cancelled'>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The /resume repair confirmation dialog — shown when a picked session's
3
+ * log fails to load with a corrupt-log error (src/log-repair.ts
4
+ * `isCorruptLogError`). Repairing rewrites user data on disk, so it is
5
+ * NEVER done implicitly: this dialog is the explicit confirmation gate.
6
+ *
7
+ * Two options, in the spec's button order:
8
+ * 1. Repair & resume — run the guarded in-place repair, then re-enter the
9
+ * selected row's resume path.
10
+ * 2. Cancel — leave the log untouched; /resume reports the original error.
11
+ * ↑↓ moves the selection, `1`/`2` select directly, Enter confirms, Esc
12
+ * cancels — the exact keymap of the submit routing dialog (route-dialog.ts),
13
+ * whose pure-reducer split this file mirrors so the decision matrix stays
14
+ * unit-testable without a terminal. Framing/focus follow the shared overlay
15
+ * contract: PanelHost framing, close re-focuses the CURRENT editor instance
16
+ * through `restoreFocus`.
17
+ */
18
+ import { getKeybindings } from '@earendil-works/pi-tui';
19
+ import { PanelHost, panelThemeFns } from "./panels.js";
20
+ import { BOLD, RESET, ansiFg } from "./theme/index.js";
21
+ import { clipToWidth, wrapText } from "./text.js";
22
+ /** The two choices in display order; index 0 is preselected. */
23
+ export const REPAIR_CONFIRM_OPTIONS = [
24
+ { id: 'repair', title: 'Repair & resume', hint: 'rebuild the log in place, then enter the session' },
25
+ { id: 'cancel', title: 'Cancel', hint: 'leave the log untouched' },
26
+ ];
27
+ /** The fixed body copy (spec wording): what happened, what repair does, what is kept. */
28
+ export const REPAIR_CONFIRM_MESSAGE = 'Resume log is corrupted (likely from a historical double-writer). '
29
+ + 'Repair it in place? The original is kept as .corrupt-bak.';
30
+ /** Footer hint — hardcoded like every other panel footer (English-only). */
31
+ export const REPAIR_CONFIRM_FOOTER = '↑↓ select · 1/2 pick · Enter confirm · Esc cancel';
32
+ export function initialRepairConfirmState() {
33
+ return { selected: 0 };
34
+ }
35
+ /**
36
+ * Apply one raw key sequence to the dialog state. Unknown keys are no-ops;
37
+ * anything after a settle is ignored (single terminal outcome guard).
38
+ */
39
+ export function updateRepairConfirm(state, data) {
40
+ if (state.settled !== undefined)
41
+ return state;
42
+ const kb = getKeybindings();
43
+ if (kb.matches(data, 'tui.select.cancel'))
44
+ return { ...state, settled: 'cancel' };
45
+ if (kb.matches(data, 'tui.input.submit'))
46
+ return { ...state, settled: 'confirm' };
47
+ if (kb.matches(data, 'tui.select.up')) {
48
+ return { ...state, selected: Math.max(0, state.selected - 1) };
49
+ }
50
+ if (kb.matches(data, 'tui.select.down')) {
51
+ return { ...state, selected: Math.min(REPAIR_CONFIRM_OPTIONS.length - 1, state.selected + 1) };
52
+ }
53
+ // Digit direct-select (1-based): selects the row, Enter still confirms —
54
+ // same select-then-confirm split as the routing dialog.
55
+ const digit = /^([1-9])$/.exec(data);
56
+ if (digit !== null) {
57
+ const index = Number(digit[1]) - 1;
58
+ if (index < REPAIR_CONFIRM_OPTIONS.length)
59
+ return { ...state, selected: index };
60
+ }
61
+ return state;
62
+ }
63
+ /** Resolved dialog outcome: the chosen action, or undefined on cancel. */
64
+ export function repairConfirmOutcome(state) {
65
+ if (state.settled !== 'confirm')
66
+ return undefined;
67
+ return REPAIR_CONFIRM_OPTIONS[state.selected]?.id;
68
+ }
69
+ /**
70
+ * The framed overlay component. Renders the fixed body copy plus the two
71
+ * option rows; every key goes through {@link updateRepairConfirm}, and the
72
+ * first terminal key fires `onFinish` exactly once.
73
+ */
74
+ export class RepairConfirmPanel {
75
+ theme;
76
+ onFinish;
77
+ requestRenderFn;
78
+ state = initialRepairConfirmState();
79
+ constructor(theme, onFinish, requestRender) {
80
+ this.theme = theme;
81
+ this.onFinish = onFinish;
82
+ this.requestRenderFn = requestRender;
83
+ }
84
+ invalidate() { }
85
+ render(width) {
86
+ const fns = panelThemeFns(this.theme);
87
+ const wrap = Math.max(2, width - 2);
88
+ // Word-wrap the body FIRST, then paint (iron rule: width math runs on
89
+ // plain text, ANSI goes on after clipping).
90
+ const lines = [
91
+ fns.accent(BOLD + clipToWidth('● Corrupted resume log', wrap) + RESET),
92
+ ...wrapText(REPAIR_CONFIRM_MESSAGE, wrap).map(segment => fns.muted(clipToWidth(segment, wrap))),
93
+ '',
94
+ ];
95
+ for (let i = 0; i < REPAIR_CONFIRM_OPTIONS.length; i++) {
96
+ const option = REPAIR_CONFIRM_OPTIONS[i];
97
+ const marker = i === this.state.selected ? '▸' : ' ';
98
+ const row = clipToWidth(`${marker} ${i + 1}. ${option.title} — ${option.hint}`, wrap);
99
+ lines.push(i === this.state.selected
100
+ ? ansiFg(this.theme.palette.accent) + BOLD + row + RESET
101
+ : fns.muted(row));
102
+ }
103
+ lines.push('');
104
+ lines.push(fns.subtle(clipToWidth(REPAIR_CONFIRM_FOOTER, wrap)));
105
+ return lines;
106
+ }
107
+ handleInput(data) {
108
+ const previous = this.state;
109
+ this.state = updateRepairConfirm(this.state, data);
110
+ if (this.state === previous)
111
+ return;
112
+ if (this.state.settled === undefined) {
113
+ this.requestRenderFn();
114
+ return;
115
+ }
116
+ this.onFinish(repairConfirmOutcome(this.state));
117
+ }
118
+ }
119
+ /**
120
+ * Open the repair confirmation dialog for one corrupt resume target.
121
+ * Resolves `'repair'` when the user explicitly confirmed, `'cancelled'`
122
+ * otherwise (Esc, or an overlay that failed to mount — treated as cancel so
123
+ * a half-mounted dialog can never imply consent). Closing always hands
124
+ * focus back through `restoreFocus` before the promise settles.
125
+ */
126
+ export function openRepairConfirmDialog(tui, theme, restoreFocus) {
127
+ return new Promise(resolve => {
128
+ let settled = false;
129
+ const settle = (outcome) => {
130
+ if (settled)
131
+ return;
132
+ settled = true;
133
+ resolve(outcome);
134
+ };
135
+ // A half-mounted overlay must not strand the keyboard: PanelHost's
136
+ // onError closes + calls restoreFocus, then we settle as cancelled.
137
+ const host = new PanelHost(tui, theme, () => {
138
+ restoreFocus();
139
+ settle('cancelled');
140
+ });
141
+ const finish = (outcome) => {
142
+ host.close();
143
+ restoreFocus();
144
+ settle(outcome === 'repair' ? 'repair' : 'cancelled');
145
+ };
146
+ const panel = new RepairConfirmPanel(theme, outcome => finish(outcome), () => tui.requestRender());
147
+ // maxHeight is a hard slice in pi-tui: ~9 content rows + 5 frame rows
148
+ // must fit or the bottom border (and footer) is cut. 75% of the 24-row
149
+ // e2e floor is 18 — the same headroom the /resume picker reserves.
150
+ host.open(panel, '70%', '75%');
151
+ });
152
+ }
153
+ //# sourceMappingURL=repair-dialog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repair-dialog.js","sourceRoot":"","sources":["../src/repair-dialog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,cAAc,EAA4B,MAAM,wBAAwB,CAAA;AACjF,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AACtD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAiB,MAAM,kBAAkB,CAAA;AACrE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAEjD,gEAAgE;AAChE,MAAM,CAAC,MAAM,sBAAsB,GAA4E;IAC7G,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,iBAAiB,EAAE,IAAI,EAAE,kDAAkD,EAAE;IACpG,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,yBAAyB,EAAE;CACnE,CAAA;AAED,yFAAyF;AACzF,MAAM,CAAC,MAAM,sBAAsB,GACjC,oEAAoE;MAClE,2DAA2D,CAAA;AAE/D,4EAA4E;AAC5E,MAAM,CAAC,MAAM,qBAAqB,GAAG,mDAAmD,CAAA;AAYxF,MAAM,UAAU,yBAAyB;IACvC,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAA;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAyB,EAAE,IAAY;IACzE,IAAI,KAAK,CAAC,OAAO,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA;IAC7C,MAAM,EAAE,GAAG,cAAc,EAAE,CAAA;IAC3B,IAAI,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,mBAAmB,CAAC;QAAE,OAAO,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAA;IACjF,IAAI,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,kBAAkB,CAAC;QAAE,OAAO,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,CAAA;IACjF,IAAI,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,eAAe,CAAC,EAAE,CAAC;QACtC,OAAO,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,EAAE,CAAA;IAChE,CAAC;IACD,IAAI,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,iBAAiB,CAAC,EAAE,CAAC;QACxC,OAAO,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,sBAAsB,CAAC,MAAM,GAAG,CAAC,EAAE,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,EAAE,CAAA;IAChG,CAAC;IACD,yEAAyE;IACzE,wDAAwD;IACxD,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpC,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACnB,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;QAClC,IAAI,KAAK,GAAG,sBAAsB,CAAC,MAAM;YAAE,OAAO,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAA;IACjF,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,oBAAoB,CAAC,KAAyB;IAC5D,IAAI,KAAK,CAAC,OAAO,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACjD,OAAO,sBAAsB,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAA;AACnD,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,kBAAkB;IACZ,KAAK,CAAU;IACf,QAAQ,CAAoD;IAC5D,eAAe,CAAY;IACpC,KAAK,GAAuB,yBAAyB,EAAE,CAAA;IAE/D,YACE,KAAe,EACf,QAA4D,EAC5D,aAAyB;QAEzB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAA;QAClB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,IAAI,CAAC,eAAe,GAAG,aAAa,CAAA;IACtC,CAAC;IAED,UAAU,KAAU,CAAC;IAErB,MAAM,CAAC,KAAa;QAClB,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACrC,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,CAAA;QACnC,sEAAsE;QACtE,4CAA4C;QAC5C,MAAM,KAAK,GAAa;YACtB,GAAG,CAAC,MAAM,CAAC,IAAI,GAAG,WAAW,CAAC,wBAAwB,EAAE,IAAI,CAAC,GAAG,KAAK,CAAC;YACtE,GAAG,QAAQ,CAAC,sBAAsB,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;YAC/F,EAAE;SACH,CAAA;QACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,sBAAsB,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACvD,MAAM,MAAM,GAAG,sBAAsB,CAAC,CAAC,CAAE,CAAA;YACzC,MAAM,MAAM,GAAG,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAA;YACpD,MAAM,GAAG,GAAG,WAAW,CAAC,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,MAAM,CAAC,KAAK,MAAM,MAAM,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,CAAA;YACrF,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,QAAQ;gBAClC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,GAAG,GAAG,KAAK;gBACxD,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAA;QACrB,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACd,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,qBAAqB,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;QAChE,OAAO,KAAK,CAAA;IACd,CAAC;IAED,WAAW,CAAC,IAAY;QACtB,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAA;QAC3B,IAAI,CAAC,KAAK,GAAG,mBAAmB,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;QAClD,IAAI,IAAI,CAAC,KAAK,KAAK,QAAQ;YAAE,OAAM;QACnC,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;YACrC,IAAI,CAAC,eAAe,EAAE,CAAA;YACtB,OAAM;QACR,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,oBAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAA;IACjD,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,UAAU,uBAAuB,CACrC,GAAQ,EACR,KAAe,EACf,YAAwB;IAExB,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE;QAC3B,IAAI,OAAO,GAAG,KAAK,CAAA;QACnB,MAAM,MAAM,GAAG,CAAC,OAA+B,EAAQ,EAAE;YACvD,IAAI,OAAO;gBAAE,OAAM;YACnB,OAAO,GAAG,IAAI,CAAA;YACd,OAAO,CAAC,OAAO,CAAC,CAAA;QAClB,CAAC,CAAA;QACD,mEAAmE;QACnE,oEAAoE;QACpE,MAAM,IAAI,GAAG,IAAI,SAAS,CAAC,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE;YAC1C,YAAY,EAAE,CAAA;YACd,MAAM,CAAC,WAAW,CAAC,CAAA;QACrB,CAAC,CAAC,CAAA;QACF,MAAM,MAAM,GAAG,CAAC,OAAwC,EAAQ,EAAE;YAChE,IAAI,CAAC,KAAK,EAAE,CAAA;YACZ,YAAY,EAAE,CAAA;YACd,MAAM,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAA;QACvD,CAAC,CAAA;QACD,MAAM,KAAK,GAAG,IAAI,kBAAkB,CAClC,KAAK,EACL,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,EAC1B,GAAG,EAAE,CAAC,GAAG,CAAC,aAAa,EAAE,CAC1B,CAAA;QACD,sEAAsE;QACtE,uEAAuE;QACvE,mEAAmE;QACnE,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,CAAA;IAChC,CAAC,CAAC,CAAA;AACJ,CAAC"}
package/lib/sessions.d.ts CHANGED
@@ -195,6 +195,15 @@ export declare function filterSessionsByLastActivity(headers: readonly SessionHe
195
195
  export declare function previewOfEvents(events: readonly SessionEvent[]): string | undefined;
196
196
  /** Collapse control chars/whitespace and clip a raw message into a one-line preview. */
197
197
  export declare function normalizePreview(text: string, maxChars?: number): string;
198
+ /**
199
+ * The picker row's SESSION-column label: the first-message preview when one
200
+ * was read, else the header label (cwd basename · short id). `corrupt`
201
+ * prepends the ⚠ marker — the same pre-read `inspect` that harvests previews
202
+ * already diagnosed this log as corrupt, and selecting the row will offer an
203
+ * in-place repair instead of a dead end. Pure so the marker contract is
204
+ * unit-testable without a store.
205
+ */
206
+ export declare function resumeRowTitle(header: SessionHeader, preview: string | undefined, corrupt: boolean): string;
198
207
  /**
199
208
  * Validate one persisted session's log without publishing it — the /resume
200
209
  * pre-check: a corrupt target must be rejected BEFORE the current live agent
package/lib/sessions.js CHANGED
@@ -11,6 +11,7 @@ import { readdir, stat } from 'node:fs/promises';
11
11
  import { homedir } from 'node:os';
12
12
  import { basename, join } from 'node:path';
13
13
  import { SESSION_LOG_FILE_NAMES } from "./retention.js";
14
+ import { isCorruptLogError } from "./log-repair.js";
14
15
  import { emitNotice } from "./notice-bridge.js";
15
16
  import { wrapFramedOverlay } from "./frame.js";
16
17
  import { autoColumns, columnWidths, MARKER_W, padCell, panelThemeFns, TablePanel, tableHeaderLine, tableRuleLine, TABLE_SEP, } from "./panels.js";
@@ -405,9 +406,27 @@ export function normalizePreview(text, maxChars = PREVIEW_MAX_CHARS) {
405
406
  const oneLine = text.replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim();
406
407
  return oneLine.length <= maxChars ? oneLine : `${oneLine.slice(0, maxChars)}…`;
407
408
  }
408
- /** Best-effort first-message preview per session id; failures map to undefined. */
409
+ /**
410
+ * The picker row's SESSION-column label: the first-message preview when one
411
+ * was read, else the header label (cwd basename · short id). `corrupt`
412
+ * prepends the ⚠ marker — the same pre-read `inspect` that harvests previews
413
+ * already diagnosed this log as corrupt, and selecting the row will offer an
414
+ * in-place repair instead of a dead end. Pure so the marker contract is
415
+ * unit-testable without a store.
416
+ */
417
+ export function resumeRowTitle(header, preview, corrupt) {
418
+ const base = preview ?? `${basename(header.cwd ?? '?')} · ${clipToWidth(String(header.id), 8)}`;
419
+ return corrupt ? `⚠ ${base}` : base;
420
+ }
421
+ /**
422
+ * Best-effort first-message preview per session id; failures map to
423
+ * undefined, and failures matching the corrupt-log fingerprint additionally
424
+ * record the id in `corruptIds` — zero extra IO (the inspect calls already
425
+ * ran for the previews), so the ⚠ rows stay a free byproduct of enrichment.
426
+ */
409
427
  async function loadSessionPreviews(persistence, ids) {
410
428
  const previews = new Map();
429
+ const corruptIds = new Set();
411
430
  let cursor = 0;
412
431
  const workers = Array.from({ length: Math.min(PREVIEW_CONCURRENCY, ids.length) }, async () => {
413
432
  while (cursor < ids.length) {
@@ -420,14 +439,17 @@ async function loadSessionPreviews(persistence, ids) {
420
439
  if (text)
421
440
  preview = normalizePreview(text);
422
441
  }
423
- catch {
442
+ catch (error) {
424
443
  preview = undefined;
444
+ const message = error instanceof Error ? error.message : String(error);
445
+ if (isCorruptLogError(message))
446
+ corruptIds.add(String(id));
425
447
  }
426
448
  previews.set(String(id), preview);
427
449
  }
428
450
  });
429
451
  await Promise.all(workers);
430
- return previews;
452
+ return { previews, corruptIds };
431
453
  }
432
454
  /**
433
455
  * Validate one persisted session's log without publishing it — the /resume
@@ -518,16 +540,16 @@ export async function pickPersistedSession(ctx, tui, theme, excludeSessionId, re
518
540
  }
519
541
  // Enrich the most recent candidates with their first-message preview so the
520
542
  // rows are distinguishable at a glance; older ones fall back to the header
521
- // label (cwd + short id).
522
- const previews = await loadSessionPreviews(persistence, ordered.slice(0, PREVIEW_SESSION_CAP).map(header => header.id));
543
+ // label (cwd + short id). Logs the enrichment inspect diagnosed as corrupt
544
+ // get the ⚠ marker — a free byproduct of the same calls, never a new scan.
545
+ const { previews, corruptIds } = await loadSessionPreviews(persistence, ordered.slice(0, PREVIEW_SESSION_CAP).map(header => header.id));
523
546
  const rows = ordered.map(header => {
524
547
  const id = String(header.id);
525
- const preview = previews.get(id);
526
548
  const updated = lastUpdates.get(id)?.mtimeMs ?? header.createdAt;
527
549
  return {
528
550
  value: id,
529
551
  header,
530
- session: preview ?? `${basename(header.cwd ?? '?')} · ${clipToWidth(id, 8)}`,
552
+ session: resumeRowTitle(header, previews.get(id), corruptIds.has(id)),
531
553
  when: new Date(updated).toLocaleString(),
532
554
  dir: header.cwd ?? 'no cwd',
533
555
  };