@aiwayds/dsh-tui-pi 0.28.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,114 @@
1
+ /**
2
+ * Clipboard helper for the ask-user "Type something." sentinel editor.
3
+ *
4
+ * The editor only ever needs to push free-text into the system clipboard
5
+ * (Ctrl+Shift+C) and read it back (right-click → paste). Terminal-side
6
+ * paste is already handled by the bracketed-paste path in `handleCustomInput`
7
+ * (see ask-user.ts); this module is the bridge to a real OS clipboard.
8
+ *
9
+ * Strategy:
10
+ * - `writeClipboard` writes the OSC 52 "set primary clipboard" sequence so a
11
+ * host terminal that follows the convention picks the text up without
12
+ * touching the OS. The sequence is capped at 64 KiB to keep the terminal
13
+ * from being spammed with a multi-MB escape run for a pathological paste;
14
+ * a >64 KiB OSC 52 is skipped and the local command alone is relied on.
15
+ * In parallel, the best-matching local command for the platform is tried
16
+ * (`pbcopy` / `wl-copy` / `xclip` / `xsel` / `clip`). Whichever path lands
17
+ * first resolves `true`; the local failure is swallowed (the OSC 52 path
18
+ * may still succeed, the next call may not, we never want a thrown error
19
+ * to interrupt the user's keystroke).
20
+ * - `readClipboard` is a fall-back ladder. WAYLAND-aware first
21
+ * (`wl-paste` when `WAYLAND_DISPLAY` is set), then the X11 ladder
22
+ * (`xclip` → `xsel`), then macOS `pbpaste`, then Windows PowerShell.
23
+ * The same command is not retried within a single process: when a binary
24
+ * is missing (`ENOENT`) the platform is cached as "unavailable" and the
25
+ * read is short-circuited to `null` for the remainder of the run.
26
+ *
27
+ * Every command runs through `execFile` (no shell, parameter-array argv,
28
+ * bounded timeout, large-enough buffer). The text path itself is raw —
29
+ * the ask-user side (which is the only in-tree caller) sanitizes the
30
+ * payload through `sanitizePastedText` before it lands in the editor
31
+ * buffer. Splitting the sanitize to the call site means a future caller
32
+ * of `readClipboard` (e.g. a feature that streams the clipboard into
33
+ * the chat composer) gets a faithful copy of what the OS gave us, and
34
+ * the editor gets its one sanitization pass.
35
+ *
36
+ * Everything is injectable through `ClipboardImpl` so the module is fully
37
+ * unit-testable without spawning real clipboard processes.
38
+ */
39
+ /**
40
+ * execFile envelope. The result is `{ stdout }` on success; for write
41
+ * commands the caller passes a non-null `stdinPayload` that is piped in
42
+ * before the process exits. A `null` payload skips the stdin pipe (read
43
+ * commands).
44
+ */
45
+ export type ExecFileFn = (file: string, args: readonly string[], options: {
46
+ timeout: number;
47
+ maxBuffer: number;
48
+ windowsHide: boolean;
49
+ }, stdinPayload: string | null) => Promise<{
50
+ stdout: string;
51
+ }>;
52
+ /**
53
+ * Subset of the platform the resolver reads. Pulled out so tests can fake
54
+ * darwin/linux/win32 without touching `process.platform` / `process.env`.
55
+ */
56
+ export interface ClipboardEnv {
57
+ platform: NodeJS.Platform;
58
+ waylandDisplay: string | undefined;
59
+ display: string | undefined;
60
+ }
61
+ /** All I/O the helper needs: command execution + stdout writing. */
62
+ export interface ClipboardImpl {
63
+ execFile: ExecFileFn;
64
+ write: (chunk: string) => void;
65
+ env: ClipboardEnv;
66
+ }
67
+ /** Default impl: real process, real env. */
68
+ export declare const defaultImpl: ClipboardImpl;
69
+ /**
70
+ * Pick the write ladder for a platform. The ladder is tried SERIALLY in
71
+ * order — the helper short-circuits on the first rung that exits cleanly
72
+ * and treats a missing binary (`ENOENT`) as "try the next rung". This
73
+ * mirrors the read-side ladder and avoids two problems the old parallel
74
+ * shape had on a real Wayland + XWayland desktop:
75
+ * 1. `wl-copy` (Wayland) and `xclip` (X11) write the same `CLIPBOARD`
76
+ * selection through different protocols; spawning them concurrently
77
+ * races the two ends and the loser's payload can win.
78
+ * 2. `wl-copy` forks a background helper that lingers after the
79
+ * `execFile` resolves; running it in parallel with every other
80
+ * rung accumulated helpers with each Ctrl+Shift+C.
81
+ */
82
+ export declare function writeCommandsForPlatform(env: ClipboardEnv): readonly {
83
+ cmd: string;
84
+ args: readonly string[];
85
+ }[];
86
+ /** Read-ladder resolver: returns the commands to try in order. */
87
+ export declare function readCommandsForPlatform(env: ClipboardEnv): readonly {
88
+ cmd: string;
89
+ args: readonly string[];
90
+ }[];
91
+ /**
92
+ * Copy `text` to the system clipboard.
93
+ *
94
+ * Returns `true` when either the OSC 52 path succeeded AND we wrote a small
95
+ * enough buffer, OR any local command exited cleanly. Returns `false`
96
+ * when every path failed. Errors are swallowed; the helper must never
97
+ * throw from a key handler.
98
+ */
99
+ export declare function writeClipboard(text: string, impl?: ClipboardImpl): Promise<boolean>;
100
+ /**
101
+ * Read the system clipboard and return its text. The payload is the raw
102
+ * stdout of whichever rung landed first — the ask-user side sanitizes
103
+ * it through `sanitizePastedText` before the editor buffer sees it, so
104
+ * a future caller of this helper gets a faithful copy of what the OS
105
+ * gave us. Returns `null` when no read path succeeds or the platform
106
+ * has been cached as "unavailable" (no command found on the ladder).
107
+ */
108
+ export declare function readClipboard(impl?: ClipboardImpl): Promise<string | null>;
109
+ /**
110
+ * Test seam: drop the cached "platform has no read command" state. Tests
111
+ * inject a different `ClipboardImpl` per case, so the cache can be left
112
+ * full from a previous run; the seam keeps the test order independent.
113
+ */
114
+ export declare function __resetClipboardUnavailableForTest(): void;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Clipboard helper for the ask-user "Type something." sentinel editor.
3
+ *
4
+ * The editor only ever needs to push free-text into the system clipboard
5
+ * (Ctrl+Shift+C) and read it back (right-click → paste). Terminal-side
6
+ * paste is already handled by the bracketed-paste path in `handleCustomInput`
7
+ * (see ask-user.ts); this module is the bridge to a real OS clipboard.
8
+ *
9
+ * Strategy:
10
+ * - `writeClipboard` writes the OSC 52 "set primary clipboard" sequence so a
11
+ * host terminal that follows the convention picks the text up without
12
+ * touching the OS. The sequence is capped at 64 KiB to keep the terminal
13
+ * from being spammed with a multi-MB escape run for a pathological paste;
14
+ * a >64 KiB OSC 52 is skipped and the local command alone is relied on.
15
+ * In parallel, the best-matching local command for the platform is tried
16
+ * (`pbcopy` / `wl-copy` / `xclip` / `xsel` / `clip`). Whichever path lands
17
+ * first resolves `true`; the local failure is swallowed (the OSC 52 path
18
+ * may still succeed, the next call may not, we never want a thrown error
19
+ * to interrupt the user's keystroke).
20
+ * - `readClipboard` is a fall-back ladder. WAYLAND-aware first
21
+ * (`wl-paste` when `WAYLAND_DISPLAY` is set), then the X11 ladder
22
+ * (`xclip` → `xsel`), then macOS `pbpaste`, then Windows PowerShell.
23
+ * The same command is not retried within a single process: when a binary
24
+ * is missing (`ENOENT`) the platform is cached as "unavailable" and the
25
+ * read is short-circuited to `null` for the remainder of the run.
26
+ *
27
+ * Every command runs through `execFile` (no shell, parameter-array argv,
28
+ * bounded timeout, large-enough buffer). The text path itself is raw —
29
+ * the ask-user side (which is the only in-tree caller) sanitizes the
30
+ * payload through `sanitizePastedText` before it lands in the editor
31
+ * buffer. Splitting the sanitize to the call site means a future caller
32
+ * of `readClipboard` (e.g. a feature that streams the clipboard into
33
+ * the chat composer) gets a faithful copy of what the OS gave us, and
34
+ * the editor gets its one sanitization pass.
35
+ *
36
+ * Everything is injectable through `ClipboardImpl` so the module is fully
37
+ * unit-testable without spawning real clipboard processes.
38
+ */
39
+ import { execFile as execFileCb } from 'node:child_process';
40
+ import { Buffer } from 'node:buffer';
41
+ /** Max UTF-8 byte length we accept into the OSC 52 write sequence. */
42
+ const OSC52_MAX_BYTES = 65536;
43
+ /**
44
+ * Default `execFile` wrapper built on the real `node:child_process`. The
45
+ * callback form gives us stdin for write commands (pbcopy / wl-copy / xclip
46
+ * / xsel / clip all consume the text on stdin); a `null` payload skips the
47
+ * stdin pipe entirely (read commands) and returns the collected stdout.
48
+ */
49
+ function makeDefaultExecFile() {
50
+ return (file, args, options, stdinPayload) => new Promise((resolve, reject) => {
51
+ const child = execFileCb(file, [...args], options, (error, stdout) => {
52
+ if (error === null)
53
+ resolve({ stdout });
54
+ else
55
+ reject(error);
56
+ });
57
+ if (stdinPayload !== null) {
58
+ // A child that exists but exits before draining stdin (e.g. xclip
59
+ // printing "Can't open display" on Linux without an X server) raises
60
+ // an uncaught EPIPE on the stream; swallow it here so the helper
61
+ // keeps its best-effort "never throw" contract.
62
+ child.stdin?.on('error', () => { });
63
+ child.stdin?.end(stdinPayload);
64
+ }
65
+ });
66
+ }
67
+ /** Default impl: real process, real env. */
68
+ export const defaultImpl = {
69
+ execFile: makeDefaultExecFile(),
70
+ write: chunk => { process.stdout.write(chunk); },
71
+ env: {
72
+ platform: process.platform,
73
+ waylandDisplay: process.env.WAYLAND_DISPLAY,
74
+ display: process.env.DISPLAY,
75
+ },
76
+ };
77
+ /**
78
+ * Cache of platforms whose read-binary ladder is fully missing: we still
79
+ * retry the write path every call (a copy without a way to read it back is
80
+ * still useful), but a read that would just re-spawn a missing ENOENT
81
+ * short-circuits to `null` so the right-click path stays snappy.
82
+ */
83
+ const readUnavailable = new Set();
84
+ /**
85
+ * Pick the write ladder for a platform. The ladder is tried SERIALLY in
86
+ * order — the helper short-circuits on the first rung that exits cleanly
87
+ * and treats a missing binary (`ENOENT`) as "try the next rung". This
88
+ * mirrors the read-side ladder and avoids two problems the old parallel
89
+ * shape had on a real Wayland + XWayland desktop:
90
+ * 1. `wl-copy` (Wayland) and `xclip` (X11) write the same `CLIPBOARD`
91
+ * selection through different protocols; spawning them concurrently
92
+ * races the two ends and the loser's payload can win.
93
+ * 2. `wl-copy` forks a background helper that lingers after the
94
+ * `execFile` resolves; running it in parallel with every other
95
+ * rung accumulated helpers with each Ctrl+Shift+C.
96
+ */
97
+ export function writeCommandsForPlatform(env) {
98
+ if (env.platform === 'darwin') {
99
+ return [{ cmd: 'pbcopy', args: [] }];
100
+ }
101
+ if (env.platform === 'win32') {
102
+ return [{ cmd: 'clip', args: [] }];
103
+ }
104
+ // Linux and friends: ONE rung per session, picked from the env. WAYLAND
105
+ // wins over X11 outright (a Wayland session that also exposes $DISPLAY
106
+ // for XWayland apps still has its native clipboard on `wl-copy`; the
107
+ // X11 rung would be the XWayland clipboard, a different selection).
108
+ // X11 falls back from xclip to xsel when xclip is not installed.
109
+ if (env.waylandDisplay !== undefined && env.waylandDisplay !== '') {
110
+ return [{ cmd: 'wl-copy', args: [] }];
111
+ }
112
+ if (env.display !== undefined && env.display !== '') {
113
+ return [
114
+ { cmd: 'xclip', args: ['-selection', 'clipboard'] },
115
+ { cmd: 'xsel', args: ['--clipboard', '--input'] },
116
+ ];
117
+ }
118
+ return [];
119
+ }
120
+ /** Read-ladder resolver: returns the commands to try in order. */
121
+ export function readCommandsForPlatform(env) {
122
+ if (env.platform === 'darwin') {
123
+ return [{ cmd: 'pbpaste', args: [] }];
124
+ }
125
+ if (env.platform === 'win32') {
126
+ return [{ cmd: 'powershell.exe', args: ['-NoProfile', '-command', 'Get-Clipboard'] }];
127
+ }
128
+ const ladder = [];
129
+ if (env.waylandDisplay !== undefined && env.waylandDisplay !== '') {
130
+ ladder.push({ cmd: 'wl-paste', args: ['--no-newline'] });
131
+ }
132
+ if (env.display !== undefined && env.display !== '') {
133
+ ladder.push({ cmd: 'xclip', args: ['-o', '-selection', 'clipboard'] });
134
+ }
135
+ ladder.push({ cmd: 'xsel', args: ['--clipboard', '--output'] });
136
+ return ladder;
137
+ }
138
+ /**
139
+ * Copy `text` to the system clipboard.
140
+ *
141
+ * Returns `true` when either the OSC 52 path succeeded AND we wrote a small
142
+ * enough buffer, OR any local command exited cleanly. Returns `false`
143
+ * when every path failed. Errors are swallowed; the helper must never
144
+ * throw from a key handler.
145
+ */
146
+ export async function writeClipboard(text, impl = defaultImpl) {
147
+ let osc52Ok = false;
148
+ // Cap is on UTF-8 byte length, not on the JS string length (which is
149
+ // a UTF-16 code-unit count): 65 536 CJK characters are ~192 KiB in
150
+ // UTF-8, and the terminal would still be spammed with a multi-MB
151
+ // escape run even though `text.length` looked in-budget. The base64
152
+ // step then doubles that again, so `Buffer.byteLength` is the right
153
+ // pre-encode gate.
154
+ if (Buffer.byteLength(text, 'utf8') <= OSC52_MAX_BYTES) {
155
+ const payload = Buffer.from(text, 'utf8').toString('base64');
156
+ try {
157
+ impl.write(`\x1b]52;c;${payload}\x07`);
158
+ osc52Ok = true;
159
+ }
160
+ catch { /* terminal may have closed — give up on this path */ }
161
+ }
162
+ const ladder = writeCommandsForPlatform(impl.env);
163
+ const localOk = await runWriteLadder(ladder, text, impl);
164
+ return osc52Ok || localOk;
165
+ }
166
+ /**
167
+ * Read the system clipboard and return its text. The payload is the raw
168
+ * stdout of whichever rung landed first — the ask-user side sanitizes
169
+ * it through `sanitizePastedText` before the editor buffer sees it, so
170
+ * a future caller of this helper gets a faithful copy of what the OS
171
+ * gave us. Returns `null` when no read path succeeds or the platform
172
+ * has been cached as "unavailable" (no command found on the ladder).
173
+ */
174
+ export async function readClipboard(impl = defaultImpl) {
175
+ if (readUnavailable.has(impl.env.platform))
176
+ return null;
177
+ const ladder = readCommandsForPlatform(impl.env);
178
+ for (const { cmd, args } of ladder) {
179
+ try {
180
+ const { stdout } = await impl.execFile(cmd, args, {
181
+ timeout: 5000,
182
+ maxBuffer: 1 << 20,
183
+ windowsHide: true,
184
+ }, null);
185
+ return stdout;
186
+ }
187
+ catch (error) {
188
+ const code = error?.code;
189
+ if (code === 'ENOENT') {
190
+ // Binary missing on this ladder rung; try the next.
191
+ continue;
192
+ }
193
+ // Any other failure (nonzero exit, timeout, signal) — give up.
194
+ return null;
195
+ }
196
+ }
197
+ // The whole ladder was missing — cache the platform and bail.
198
+ readUnavailable.add(impl.env.platform);
199
+ return null;
200
+ }
201
+ /**
202
+ * Run the write ladder SERIALLY against the same text payload. Resolve
203
+ * `true` as soon as one rung exits cleanly; treat a missing binary
204
+ * (`ENOENT`) as "try the next rung" and any other failure as
205
+ * terminal. The serial order keeps Wayland and X11 from racing on the
206
+ * same `CLIPBOARD` selection, and avoids the wl-copy helper-process
207
+ * leak that parallel `Promise.all` caused.
208
+ */
209
+ async function runWriteLadder(ladder, text, impl) {
210
+ for (const { cmd, args } of ladder) {
211
+ try {
212
+ await impl.execFile(cmd, args, {
213
+ timeout: 5000,
214
+ maxBuffer: 1 << 20,
215
+ windowsHide: true,
216
+ }, text);
217
+ return true;
218
+ }
219
+ catch (error) {
220
+ const code = error?.code;
221
+ if (code === 'ENOENT')
222
+ continue; // binary missing on this rung; try the next.
223
+ return false;
224
+ }
225
+ }
226
+ return false;
227
+ }
228
+ /**
229
+ * Test seam: drop the cached "platform has no read command" state. Tests
230
+ * inject a different `ClipboardImpl` per case, so the cache can be left
231
+ * full from a previous run; the seam keeps the test order independent.
232
+ */
233
+ export function __resetClipboardUnavailableForTest() {
234
+ readUnavailable.clear();
235
+ }
236
+ //# sourceMappingURL=clipboard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clipboard.js","sourceRoot":"","sources":["../src/clipboard.ts"],"names":[],"mappings":"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"}
package/lib/index.js CHANGED
@@ -35,7 +35,11 @@ import { reloadPlugin } from "./reload.js";
35
35
  import { runSessionRetentionOnce } from "./retention.js";
36
36
  import { setNoticeSink } from "./notice-bridge.js";
37
37
  import { collectStartupSummary, formatResumeCommand, parseResumeArg, resolveProfileName, } from "./startup-info.js";
38
- import { inspectPersistedSession, pickPersistedSession, showSessionInfo } from "./sessions.js";
38
+ import { inspectPersistedSession, pickPersistedSession, sessionLogRoot, showSessionInfo } from "./sessions.js";
39
+ import { isCorruptLogError, locateSessionLog, repairFailureNotice, repairSessionLog, } from "./log-repair.js";
40
+ import { openRepairConfirmDialog } from "./repair-dialog.js";
41
+ import { WriterLockedError } from "./writer-lock.js";
42
+ import { emitNotice } from "./notice-bridge.js";
39
43
  import { applySubagentPolicy } from "./subagent-policy.js";
40
44
  import { openSubagentViewer } from "./subagent-viewer.js";
41
45
  import { commandUsagePath, CommandUsageTracker } from "./usage.js";
@@ -601,7 +605,26 @@ export function apply(ctx) {
601
605
  liveWidgets.renderAgents(agents);
602
606
  },
603
607
  };
604
- const bridge = new DshSessionBridge(ctx, bridgeCallbacks);
608
+ const bridgeCallbacksWithTakeover = {
609
+ ...bridgeCallbacks,
610
+ onRemotePromotable: async (idRaw) => {
611
+ // Queued follow-ups won the writer-lock race at an idle boundary:
612
+ // take over EXACTLY like a manual /resume, then flush the queue.
613
+ const resumed = await bridge.resume(SessionId(idRaw));
614
+ refreshPermissionPreset();
615
+ renderer.clear();
616
+ liveWidgets.clear();
617
+ const session = resumed.agent.session;
618
+ const adopted = 'adopted' in resumed && resumed.adopted === true;
619
+ bridge.replay(adopted ? session.events : session.events.filter(event => event.seq < session.firstLiveSeq));
620
+ for (const text of bridge.takePendingRemoteFollowups()) {
621
+ await bridge.prompt(text);
622
+ }
623
+ emitNotice('Write lock acquired — follow-ups sent.');
624
+ ui.requestRender();
625
+ },
626
+ };
627
+ const bridge = new DshSessionBridge(ctx, bridgeCallbacksWithTakeover);
605
628
  bridgeRef = bridge;
606
629
  // Subagent fine-grained control, all in-process (see subagent-policy.ts):
607
630
  // a tools.guard denies spawn-tool calls once `maxAgents` children run
@@ -963,6 +986,112 @@ export function apply(ctx) {
963
986
  }
964
987
  if (picked.kind === 'cancelled')
965
988
  return { kind: 'success', text: 'Resume cancelled.' };
989
+ // Narrowed shared handle for the closures below (const narrowing
990
+ // propagates into async closures; `picked`'s does not).
991
+ const target = picked;
992
+ // The selected row's resume path: swap the live agent for the target
993
+ // and rebuild transcript + stats from the stored events. Shared by the
994
+ // direct hit and the post-repair re-entry so both behave identically
995
+ // (including the WriterLockedError read-only fallback).
996
+ const resumeAndReplay = async () => {
997
+ let resumed;
998
+ try {
999
+ resumed = await bridge.resume(target.id);
1000
+ }
1001
+ catch (error) {
1002
+ const message = error instanceof Error ? error.message : String(error);
1003
+ if (error instanceof WriterLockedError) {
1004
+ // Single-writer guard fired: another process drives this session.
1005
+ // Rather than a dead end, degrade to a READ-ONLY view synced from
1006
+ // its persisted log — final replies arrive (poll-delayed, without
1007
+ // streaming detail); input is refused until /resume or /new.
1008
+ try {
1009
+ await bridge.watchRemote(target.id);
1010
+ refreshPermissionPreset();
1011
+ renderer.clear();
1012
+ liveWidgets.clear();
1013
+ ui.requestRender();
1014
+ return {
1015
+ kind: 'success',
1016
+ text: `Watching ${clipToWidth(String(target.id), 8)} (read-only · driven by pid ${error.holder.pid}). /resume or /new to switch.`,
1017
+ };
1018
+ }
1019
+ catch (watchError) {
1020
+ return {
1021
+ kind: 'error',
1022
+ text: `Cannot watch ${clipToWidth(String(target.id), 8)} read-only: ${watchError instanceof Error ? watchError.message : String(watchError)}`,
1023
+ };
1024
+ }
1025
+ }
1026
+ return {
1027
+ kind: 'error',
1028
+ text: `Resume failed: ${message} — the previous session was closed; the next prompt starts a new one.`,
1029
+ };
1030
+ }
1031
+ // Seed the badge cache for the resumed session (its pin event may have
1032
+ // been emitted before the bridge's session-id filter re-bound).
1033
+ refreshPermissionPreset();
1034
+ // Clear BEFORE replay: the renderer's local-echo dedupe must not see
1035
+ // replayed user messages next to a stale prompt echo. The live widget
1036
+ // drops the previous session's todos too (its agents already went via
1037
+ // the bridge's onLive([]) on resume reset).
1038
+ renderer.clear();
1039
+ liveWidgets.clear();
1040
+ // Replay seed history only: events at or above firstLiveSeq were
1041
+ // published in-process and arrive again through the session/event
1042
+ // subscription (replaying them would double-count stats and echo).
1043
+ // Seeds entered through construction never published — replaying them
1044
+ // exactly once covers the stored log with zero overlap, zero gap.
1045
+ const session = resumed.agent.session;
1046
+ // Adopted live sessions (attach arm): EVERY event in the log was
1047
+ // published before this surface started tracking it — the firehose
1048
+ // dropped all of them, so replay unfiltered or the transcript misses
1049
+ // everything the other surface did. Cold resumes keep the firstLiveSeq
1050
+ // filter (seeded history replays once; live events re-arrive).
1051
+ const adopted = 'adopted' in resumed && resumed.adopted === true;
1052
+ bridge.replay(adopted ? session.events : session.events.filter(event => event.seq < session.firstLiveSeq));
1053
+ ui.requestRender();
1054
+ return {
1055
+ kind: 'success',
1056
+ text: `Resumed ${clipToWidth(String(target.id), 8)} · ${session.events.length} events.`,
1057
+ };
1058
+ };
1059
+ // Corrupt-log branch: the repair rewrites user data on disk, so it is
1060
+ // gated behind an explicit confirmation dialog — it never runs
1061
+ // silently, and Cancel falls back to the plain failure text.
1062
+ const offerCorruptedLogRepair = async (message) => {
1063
+ // Locate BEFORE asking (feishu-surface order): a log that cannot be
1064
+ // grounded on disk gives the dialog nothing to confirm.
1065
+ const persistence = ctx.get('sessionPersistence');
1066
+ const logPath = await locateSessionLog(persistence, String(target.id), sessionLogRoot());
1067
+ if (logPath === undefined) {
1068
+ return {
1069
+ kind: 'error',
1070
+ text: `repair failed: cannot locate the log of ${clipToWidth(String(target.id), 8)} on disk — log untouched`,
1071
+ };
1072
+ }
1073
+ if (await openRepairConfirmDialog(ui.tui, ui.theme, refocusEditor) !== 'repair') {
1074
+ return {
1075
+ kind: 'error',
1076
+ text: `Cannot resume ${clipToWidth(String(target.id), 8)}: ${message}`,
1077
+ };
1078
+ }
1079
+ // repairSessionLog maps its own failures to results; the catch is a
1080
+ // defensive floor so the dispatch never sees an exception.
1081
+ let notice;
1082
+ try {
1083
+ notice = repairFailureNotice(await repairSessionLog(logPath));
1084
+ }
1085
+ catch (error) {
1086
+ const detail = error instanceof Error ? error.message : String(error);
1087
+ notice = `repair failed: ${detail} — log untouched`;
1088
+ }
1089
+ if (notice !== undefined)
1090
+ return { kind: 'error', text: notice };
1091
+ // A verified-clean log now sits under the canonical name — re-enter
1092
+ // the selected row's resume path.
1093
+ return resumeAndReplay();
1094
+ };
966
1095
  // Validate the target log before tearing down the current agent: a
967
1096
  // corrupt log must leave the live session untouched.
968
1097
  try {
@@ -970,46 +1099,12 @@ export function apply(ctx) {
970
1099
  }
971
1100
  catch (error) {
972
1101
  const message = error instanceof Error ? error.message : String(error);
1102
+ if (isCorruptLogError(message)) {
1103
+ return offerCorruptedLogRepair(message);
1104
+ }
973
1105
  return { kind: 'error', text: `Cannot resume ${clipToWidth(String(picked.id), 8)}: ${message}` };
974
1106
  }
975
- let resumed;
976
- try {
977
- resumed = await bridge.resume(picked.id);
978
- }
979
- catch (error) {
980
- const message = error instanceof Error ? error.message : String(error);
981
- return {
982
- kind: 'error',
983
- text: `Resume failed: ${message} — the previous session was closed; the next prompt starts a new one.`,
984
- };
985
- }
986
- // Seed the badge cache for the resumed session (its pin event may have
987
- // been emitted before the bridge's session-id filter re-bound).
988
- refreshPermissionPreset();
989
- // Clear BEFORE replay: the renderer's local-echo dedupe must not see
990
- // replayed user messages next to a stale prompt echo. The live widget
991
- // drops the previous session's todos too (its agents already went via
992
- // the bridge's onLive([]) on resume reset).
993
- renderer.clear();
994
- liveWidgets.clear();
995
- // Replay seed history only: events at or above firstLiveSeq were
996
- // published in-process and arrive again through the session/event
997
- // subscription (replaying them would double-count stats and echo).
998
- // Seeds entered through construction never published — replaying them
999
- // exactly once covers the stored log with zero overlap, zero gap.
1000
- const session = resumed.agent.session;
1001
- // Adopted live sessions (attach arm): EVERY event in the log was
1002
- // published before this surface started tracking it — the firehose
1003
- // dropped all of them, so replay unfiltered or the transcript misses
1004
- // everything the other surface did. Cold resumes keep the firstLiveSeq
1005
- // filter (seeded history replays once; live events re-arrive).
1006
- const adopted = 'adopted' in resumed && resumed.adopted === true;
1007
- bridge.replay(adopted ? session.events : session.events.filter(event => event.seq < session.firstLiveSeq));
1008
- ui.requestRender();
1009
- return {
1010
- kind: 'success',
1011
- text: `Resumed ${clipToWidth(String(picked.id), 8)} · ${session.events.length} events.`,
1012
- };
1107
+ return resumeAndReplay();
1013
1108
  };
1014
1109
  commands.registerLocal('resume', resumeHandler);
1015
1110
  ctx.effect(() => ctx.commands.register({
@@ -1527,7 +1622,7 @@ export function apply(ctx) {
1527
1622
  // runs, the dialog decides steer vs follow-up — Esc cancels and the
1528
1623
  // draft goes back into the editor untouched. Idle → direct send (the
1529
1624
  // two primitives are equivalent there: both wake a fresh turn).
1530
- if (decideSubmitPath(bridge.isRunning()) === 'dialog') {
1625
+ if (decideSubmitPath(bridge.isRunning() && !bridge.isReadOnlyView()) === 'dialog') {
1531
1626
  const route = await openSubmitRouteDialog(ui.tui, ui.theme, text, refocusEditor);
1532
1627
  if (route === undefined) {
1533
1628
  // Review S1: restore the RAW submitted text — restoring the trimmed
@@ -1546,8 +1641,11 @@ export function apply(ctx) {
1546
1641
  // editor for text that never ran.
1547
1642
  liveWidgets.setLastRequest(line);
1548
1643
  renderer.renderPromptEcho(line);
1644
+ const wasWatching = bridge.isReadOnlyView();
1549
1645
  try {
1550
1646
  await bridge.prompt(line);
1647
+ if (wasWatching)
1648
+ emitNotice('Queued follow-up — sends automatically once the write lock frees up.');
1551
1649
  }
1552
1650
  catch (error) {
1553
1651
  // Buffered notice: the failure line is the only on-screen record and