@xmoon76/dsh-pi-tui 0.3.1 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoon76/dsh-pi-tui",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "The dsh-pi-tui bundle: a TUI surface for DeepSeek Harness profiles (dsh --profile pi-tui)",
5
5
  "license": "MIT",
6
6
  "author": "XMoon <xmoonox@gmail.com>",
@@ -142,6 +142,7 @@
142
142
  "@types/node": "^22.0.0",
143
143
  "@xmoon76/pi-tui": "^0.84.3",
144
144
  "@xterm/headless": "5.5.0",
145
+ "husky": "^9.1.7",
145
146
  "tsdown": "0.22.0",
146
147
  "tsx": "^4.23.12",
147
148
  "typescript": "^5.9.0"
@@ -170,6 +171,8 @@
170
171
  "smoke:vim": "node scripts/vim-plugin-smoke.mjs",
171
172
  "smoke:examples": "node scripts/examples-plugin-smoke.mjs",
172
173
  "pack:release": "rm -f xmoon76-dsh-pi-tui-*.tgz && pnpm pack --pack-destination .",
173
- "smoke:pack": "node scripts/tarball-smoke.mjs"
174
+ "smoke:pack": "node scripts/tarball-smoke.mjs",
175
+ "verify:prepush": "pnpm typecheck:fork && pnpm test:fork && pnpm test:docs && node scripts/naming-gate.mjs && pnpm audit --prod --audit-level high && pnpm pack:release",
176
+ "verify:prepush:nofork": "pnpm test:docs && node scripts/naming-gate.mjs && pnpm audit --prod --audit-level high && pnpm pack:release"
174
177
  }
175
178
  }
package/scripts/bench.mts CHANGED
@@ -52,6 +52,12 @@ class BenchTerminal {
52
52
  }
53
53
 
54
54
  // --- synthetic session content ---------------------------------------------
55
+ // NOTE: the CJK strings below are FUNCTIONAL benchmark fixtures, not
56
+ // user-facing copy: the wide-grapheme samples exercise the CJK width
57
+ // paths (get-east-asian-width, wrap + emoji) whose cost this script
58
+ // measures. The English-only rule applies to user-facing strings and
59
+ // comments; these samples are deliberately CJK — an English-only fixture
60
+ // would silently drop the wide-character code path from the benchmark.
55
61
 
56
62
  const MARKDOWN_BLOCKS = [
57
63
  '## Findings\n\nThe **cache** now hits for unchanged messages, so `markdown` is not re-parsed every frame.\n\n```ts\nconst hit = cached.text === message.text\n```\n\n- one\n- two\n- three',
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env bash
2
+ # Real two-process E2E for the session lease lifecycle (PR #13).
3
+ #
4
+ # Covers the merge-gate regressions that headless suites cannot:
5
+ #
6
+ # Case 4 (merge blocker) — same-process reopen during COOLING:
7
+ # P1 open A → switch A→B (A enters COOLING) → switch B→A BEFORE the
8
+ # cooling release (~2s window) → wait past the old release time →
9
+ # P2 open A must be REFUSED (A is active again and still owned by P1).
10
+ # PROOF of the reactivation window: A's owner.lock INODE must stay
11
+ # unchanged across the B→A switch. A physical release would unlink the
12
+ # lock and a RELEASED re-acquire would create a NEW file (new inode);
13
+ # an unchanged inode proves the same held lock was never released, so
14
+ # reserveForActivation really took the held-COOLING reactivation path.
15
+ #
16
+ # ABA (recommended) — cooling#1 → reactivate → cooling#2:
17
+ # A→B (cooling#1) → B→A (reactivation) → A→B (cooling#2). The old
18
+ # verifier #1 must be epoch-stale: the lock must NOT disappear early
19
+ # (the current cooling#2 cannot settle before ~1.5s), and only the
20
+ # CURRENT (epoch2) release may hand A back (then P2 opens it).
21
+ #
22
+ # The E2E runs the REAL bundle (this repo's dist/) in TWO real dsh
23
+ # processes under an isolated DSH_HOME with a dedicated profile, driven
24
+ # through tmux. It does NOT need a model connection: the sessions are
25
+ # pre-seeded as minimal durable artifacts, and /resume switches them.
26
+ #
27
+ # Usage:
28
+ # scripts/e2e-session-lease.sh [--keep] [--no-aba]
29
+ #
30
+ # --keep keep the tmux session and DSH_HOME on failure for inspection
31
+ # --no-aba skip the ABA lifecycle case
32
+ #
33
+ # This script is intentionally NOT part of `pnpm test:bundle` (it needs a
34
+ # real TTY via tmux, a pnpm-installable profile, and ~40s of wall time).
35
+ # Run it on demand: scripts/e2e-session-lease.sh
36
+
37
+ set -euo pipefail
38
+
39
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
40
+ PROFILE_NAME="e2e-lease"
41
+ KEEP=0
42
+ RUN_ABA=1
43
+ for arg in "$@"; do
44
+ case "$arg" in
45
+ --keep) KEEP=1 ;;
46
+ --no-aba) RUN_ABA=0 ;;
47
+ *) echo "unknown argument: $arg" >&2; exit 2 ;;
48
+ esac
49
+ done
50
+
51
+ E2E_HOME="$(mktemp -d /tmp/dsh-e2e-lease-XXXXXX)"
52
+ TMUX_SESSION="dsh-e2e-lease-$$"
53
+ WORK_DIR="$E2E_HOME/work"
54
+ PROFILE_DIR="$E2E_HOME/profiles/$PROFILE_NAME"
55
+
56
+ # The session project key for WORK_DIR (see projectKey in
57
+ # session-persistence-jsonl: slashes become "-", unsafe units escape as
58
+ # ~XXXX, leading dashes stripped, wrapped in --...--).
59
+ PROJECT_KEY="$(node -e "
60
+ const cwd = process.argv[1];
61
+ let readable = ''; let sep = false;
62
+ for (const ch of cwd) {
63
+ if (ch === '/' || ch === '\\\\' || ch === ':') { if (!sep) readable += '-'; sep = true; }
64
+ else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; sep = false; }
65
+ else { readable += '~' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0'); sep = false; }
66
+ }
67
+ const slug = readable.replace(/^-+/, '') || 'root';
68
+ console.log('--' + slug.slice(0, 251) + '--');
69
+ " "$WORK_DIR")"
70
+
71
+ PASS=0
72
+ FAIL=0
73
+
74
+ say() { printf '%s\n' "$*"; }
75
+ ok() { PASS=$((PASS+1)); printf ' \033[32mok\033[0m %s\n' "$*"; }
76
+ bad() { FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n' "$*"; }
77
+
78
+ cleanup() {
79
+ if [ "$KEEP" -eq 1 ]; then
80
+ echo "== kept for inspection: tmux session $TMUX_SESSION, DSH_HOME $E2E_HOME =="
81
+ else
82
+ tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
83
+ rm -rf "$E2E_HOME"
84
+ fi
85
+ }
86
+ trap cleanup EXIT
87
+
88
+ # ── 1. profile + isolated DSH_HOME ───────────────────────────────────────
89
+ echo "== setup: isolated DSH_HOME at $E2E_HOME =="
90
+ mkdir -p "$PROFILE_DIR" "$WORK_DIR"
91
+ cat > "$PROFILE_DIR/package.json" <<EOF
92
+ {
93
+ "name": "dsh-profile-e2e-rewind",
94
+ "private": true,
95
+ "dependencies": {
96
+ "@xmoon76/dsh-pi-tui": "link:$REPO_ROOT"
97
+ },
98
+ "dsh": {
99
+ "profile": {
100
+ "bundles": ["@deepseek-ai/dsh-base", "@xmoon76/dsh-pi-tui"]
101
+ }
102
+ }
103
+ }
104
+ EOF
105
+ cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
106
+ packages:
107
+ - .
108
+ nodeLinker: hoisted
109
+ autoInstallPeers: false
110
+ EOF
111
+ if ! (cd "$PROFILE_DIR" && CI=true pnpm install --silent 2>/dev/null); then
112
+ echo "FAIL: pnpm install for the E2E profile failed" >&2
113
+ exit 1
114
+ fi
115
+
116
+ # ── 2. seed two durable session artifacts ───────────────────────────────
117
+ # Hand-seeded minimal artifacts: header frame + one event frame (the JSONL
118
+ # backend's per-frame container). The owner.lock lives next to the log.
119
+ seed_session() {
120
+ local id="$1"
121
+ local dir="$E2E_HOME/sessions/$PROJECT_KEY/$id"
122
+ mkdir -p "$dir"
123
+ printf '%s\n' "{\"type\":\"session\",\"version\":0,\"id\":\"$id\",\"createdAt\":1787409012905,\"cwd\":\"$WORK_DIR\",\"delegationDepth\":0,\"agentPreset\":\"standard\"}" \
124
+ | zstd -q -c > "$dir/f1"
125
+ printf '%s\n' \
126
+ "{\"type\":\"permission/preset\",\"seq\":0,\"time\":1787409012921,\"data\":{\"preset\":\"danger-full-access\"}}" \
127
+ "{\"type\":\"sandbox/mode\",\"seq\":1,\"time\":1787409012925,\"data\":{\"mode\":\"danger-full-access\"}}" \
128
+ | zstd -q -c > "$dir/f2"
129
+ cat "$dir/f1" "$dir/f2" > "$dir/session.jsonl.zstd"
130
+ rm -f "$dir/f1" "$dir/f2"
131
+ }
132
+ SESSION_A="session-e2e-a"
133
+ SESSION_B="session-e2e-b"
134
+ seed_session "$SESSION_A"
135
+ seed_session "$SESSION_B"
136
+
137
+ lock_path() { echo "$E2E_HOME/sessions/$PROJECT_KEY/$1/session.jsonl.zstd.owner.lock"; }
138
+ lock_inode() { stat -c %i "$(lock_path "$1")" 2>/dev/null || echo "MISSING"; }
139
+
140
+ # ── 3. tmux harness ─────────────────────────────────────────────────────
141
+ tmux new-session -d -s "$TMUX_SESSION" -x 120 -y 34
142
+ tmux split-window -t "$TMUX_SESSION" -h
143
+ PANE1="$TMUX_SESSION:0.0"
144
+ PANE2="$TMUX_SESSION:0.1"
145
+
146
+ title_of() { tmux display-message -t "$1" -p '#{pane_title}' 2>/dev/null || true; }
147
+ now_ms() { date +%s%3N; }
148
+ LAST_ENTER_MS=0
149
+ send_cmd() { # send_cmd <pane> <text> — paced to dodge the PasteBurst trap
150
+ tmux send-keys -t "$1" -l "$2"
151
+ sleep 0.2
152
+ tmux send-keys -t "$1" Enter
153
+ LAST_ENTER_MS="$(now_ms)" # the Enter that triggers the command
154
+ }
155
+ wait_title() { # wait_title <pane> <substr> <timeout_s> — 0 on match, 1 on timeout
156
+ local pane="$1" want="$2" timeout="$3" elapsed=0
157
+ while [[ "$(title_of "$pane")" != *"$want"* ]]; do
158
+ sleep 0.2
159
+ elapsed=$((elapsed+1))
160
+ if [ "$elapsed" -ge $((timeout*5)) ]; then return 1; fi
161
+ done
162
+ return 0
163
+ }
164
+ launch_p2() { # launch_p2 — a fresh dsh attempt on session A in pane 2
165
+ tmux send-keys -t "$PANE2" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_A"
166
+ sleep 0.4
167
+ tmux send-keys -t "$PANE2" Enter
168
+ }
169
+ p2_out() {
170
+ tmux capture-pane -t "$PANE2" -p 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ' || true
171
+ }
172
+
173
+ # ── 4. Case 4: same-process reopen during COOLING ───────────────────────
174
+ echo "== Case 4: A → B (cooling) → A (reactivation) → P2 refused =="
175
+ tmux send-keys -t "$PANE1" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_A"
176
+ sleep 0.4
177
+ tmux send-keys -t "$PANE1" Enter
178
+ if ! wait_title "$PANE1" "$SESSION_A" 20; then bad "P1 did not open $SESSION_A"; else ok "P1 opened $SESSION_A"; fi
179
+
180
+ # Switch A → B: A must enter COOLING with its lock still held. Record the
181
+ # authoritative cooling-start LOWER BOUND: the Enter of the A→B command
182
+ # (LAST_ENTER_MS right after this send_cmd). beginCooling cannot happen
183
+ # before that Enter, so the old verifier's earliest release (quiet 1s + 2
184
+ # interval sleeps ≈ 2000ms after ITS beginCooling) is AT OR AFTER that
185
+ # Enter + 2000.
186
+ send_cmd "$PANE1" "/resume $SESSION_B"
187
+ T_A2B_ENTER="$LAST_ENTER_MS" # the A→B Enter (cooling#1 lower bound)
188
+ if ! wait_title "$PANE1" "$SESSION_B" 120; then bad "P1 did not switch to $SESSION_B"; else ok "P1 switched to $SESSION_B"; fi
189
+ A_LOCK="$(lock_path "$SESSION_A")"
190
+ if [ -f "$A_LOCK" ]; then ok "A lock still held during cooling"; else bad "A lock lost during cooling"; fi
191
+ INODE_COOLING="$(lock_inode "$SESSION_A")"
192
+
193
+ # Switch B → A INSIDE the cooling window. The OLD verifier's earliest
194
+ # possible release: quiet 1s, then sampling starts immediately — the 3rd
195
+ # stable sample (release point) is 1000 + 2×500 = 2000ms after ITS
196
+ # beginCooling, which cannot precede the A→B Enter (T_A2B_ENTER). So
197
+ # earliest release >= T_A2B_ENTER + 2000ms. The reactivation COMPLETES
198
+ # no later than the moment wait_title(A) returns (T_REACT_DONE). If
199
+ # T_REACT_DONE is before T_A2B_ENTER + 2000ms, the reactivation finished
200
+ # before the old verifier could possibly release — overlap is GUARANTEED
201
+ # regardless of title-update latency. The unchanged inode is a secondary
202
+ # witness.
203
+ send_cmd "$PANE1" "/resume $SESSION_A"
204
+ if ! wait_title "$PANE1" "$SESSION_A" 120; then bad "P1 did not switch back to $SESSION_A"; else ok "P1 reactivated $SESSION_A during cooling"; fi
205
+ T_REACT_DONE="$(now_ms)"
206
+ REACT_MS=$((T_REACT_DONE - T_A2B_ENTER))
207
+ INODE_REACTIVATED="$(lock_inode "$SESSION_A")"
208
+ if [ "$REACT_MS" -lt 2000 ] && [ "$INODE_COOLING" = "$INODE_REACTIVATED" ] && [ "$INODE_COOLING" != "MISSING" ]; then
209
+ ok "reactivation completed ${REACT_MS}ms after the A→B Enter (<2.0s earliest-release bound), same held lock (inode $INODE_COOLING)"
210
+ else
211
+ bad "reactivation not inside the cooling window: ${REACT_MS}ms (inode $INODE_COOLING → $INODE_REACTIVATED)"
212
+ fi
213
+
214
+ # Wait PAST the original cooling release time: the old verifier must have
215
+ # been stale, so A stays owned by P1 (same lock, same inode).
216
+ sleep 3
217
+ if [ -f "$A_LOCK" ] && [ "$(lock_inode "$SESSION_A")" = "$INODE_REACTIVATED" ]; then
218
+ ok "A still owned by P1 past the old release time (same lock)"
219
+ else
220
+ bad "A lost its lock after reactivation"
221
+ fi
222
+
223
+ # P2 (second real dsh process) tries to open A: must be REFUSED. The
224
+ # assertion matches ONLY the lock-refusal notice (another live process
225
+ # owns the session), never the resume-failure wording ("stays locked by
226
+ # this TUI" would mean P2's own lock check wrongly passed).
227
+ launch_p2
228
+ sleep 3
229
+ if echo "$(p2_out)" | grep -qi "is open in another dsh process\|cannot lock session\|refusing to resume"; then ok "P2 was refused for $SESSION_A"; else bad "P2 was NOT refused: $(p2_out)"; fi
230
+
231
+ # ── 5. ABA: cooling#1 → reactivate → cooling#2 ──────────────────────────
232
+ if [ "$RUN_ABA" -eq 1 ]; then
233
+ echo "== ABA: A→B→A→B — old verifier must be stale =="
234
+ # From A (just reactivated): B → A → B quickly. The OVERLAP proof: the
235
+ # first A→B Enter (T_ABA_ENTER) is the LOWER BOUND of cooling#1's
236
+ # beginCooling, so verifier #1's earliest release is >= that Enter +
237
+ # 2000ms (quiet 1s + 2×0.5s interval sleeps; sampling starts right
238
+ # after the quiet). If cooling#2's start (the third switch's
239
+ # wait_title return, an UPPER bound for its beginCooling) happens
240
+ # before the first Enter + 2000ms, verifier #1 is GUARANTEED to still
241
+ # be running when cooling#2 begins — the ABA race is real. The
242
+ # lock-disappearance timing below then distinguishes a stale-verifier
243
+ # leak (<1.2s, impossible for the current epoch) from the current
244
+ # epoch's normal verified release.
245
+ send_cmd "$PANE1" "/resume $SESSION_B"
246
+ T_ABA_ENTER="$LAST_ENTER_MS" # the first A→B Enter (cooling#1 lower bound)
247
+ wait_title "$PANE1" "$SESSION_B" 120
248
+ send_cmd "$PANE1" "/resume $SESSION_A"
249
+ wait_title "$PANE1" "$SESSION_A" 120
250
+ send_cmd "$PANE1" "/resume $SESSION_B"
251
+ wait_title "$PANE1" "$SESSION_B" 120
252
+ T_ABA2="$(now_ms)" # cooling#2 started (upper bound of its beginCooling)
253
+ ABA_OVERLAP_MS=$((T_ABA2 - T_ABA_ENTER))
254
+ if [ "$ABA_OVERLAP_MS" -lt 2000 ]; then
255
+ ok "cooling#2 began ${ABA_OVERLAP_MS}ms after the cooling#1 Enter — old verifier #1 was still running (real ABA race)"
256
+ else
257
+ bad "cooling#2 began ${ABA_OVERLAP_MS}ms after the cooling#1 Enter — no overlap with the old verifier (race not exercised)"
258
+ fi
259
+ # The CURRENT cooling epoch cannot settle before quiet 1s + 2×0.5s
260
+ # interval sleeps ≈ 2.0s (sampling starts right after the quiet). The
261
+ # OLD verifier #1, if it were NOT epoch-stale, would release A early
262
+ # (its own samples completed around the last switch, ~0.9s into
263
+ # cooling#2 given the measured overlap). Measure when the lock
264
+ # disappears: before 1.5s = stale-verifier leak; after ~2.0s = the
265
+ # CURRENT epoch's release.
266
+ start_ms=$(now_ms)
267
+ elapsed=0
268
+ while [ -f "$(lock_path $SESSION_A)" ] && [ "$elapsed" -lt 60 ]; do
269
+ sleep 0.1
270
+ elapsed=$((elapsed+1))
271
+ done
272
+ if [ -f "$(lock_path $SESSION_A)" ]; then
273
+ bad "A lock never released within 6s of cooling#2"
274
+ else
275
+ gone_ms=$(now_ms)
276
+ gap_ms=$((gone_ms - start_ms))
277
+ if [ "$gap_ms" -lt 1500 ]; then
278
+ bad "A lock released ${gap_ms}ms into cooling#2 — a stale verifier released a later lifecycle"
279
+ else
280
+ ok "lock held through the stale-verifier window; released at ${gap_ms}ms (current epoch)"
281
+ fi
282
+ fi
283
+ # The CURRENT cooling#2 released the lock; P2 may now open A.
284
+ launch_p2
285
+ sleep 4
286
+ if [[ "$(title_of "$PANE2")" == *"$SESSION_A"* ]]; then
287
+ ok "P2 opened $SESSION_A after the epoch2 release"
288
+ else
289
+ bad "P2 could not open A after release: $(p2_out)"
290
+ fi
291
+ fi
292
+
293
+ # ── 6. PINNED sticky quarantine: cooling failure → refused → stale takeover ─
294
+ if [ "$RUN_ABA" -eq 1 ]; then
295
+ echo "== PINNED: cooling failure → quarantine → P1 reopen refused → P2 refused → P1 exit → stale takeover =="
296
+ # Use a THIRD session C (A is owned by P2 after the ABA case). P1 is on
297
+ # B; switch B → C so C becomes live, then C → B so C enters COOLING,
298
+ # and IMMEDIATELY append an extra durable event to C's log (a simulated
299
+ # external writer). The cooling verifier's parity triple (event count,
300
+ # last seq, tail fingerprint) then never matches the final snapshot →
301
+ # the retirement cannot settle → after the 5s max window C is PINNED.
302
+ SESSION_C="session-e2e-c"
303
+ seed_session "$SESSION_C"
304
+ send_cmd "$PANE1" "/resume $SESSION_C"
305
+ wait_title "$PANE1" "$SESSION_C" 120
306
+ send_cmd "$PANE1" "/resume $SESSION_B"
307
+ wait_title "$PANE1" "$SESSION_B" 120
308
+ C_DIR="$E2E_HOME/sessions/$PROJECT_KEY/$SESSION_C"
309
+ # A WELL-FORMED user/message event that the DSH reader ACCEPTS (the
310
+ # artifact must stay resumable): seq CONTINUOUS with the log (max seq + 1,
311
+ # the JSONL backend rejects a gap) and a fresh current time. Its
312
+ # seq/time/content differ from anything P1 wrote, so the cooling
313
+ # verifier's parity triple (event count, last seq, tail fingerprint)
314
+ # never matches the final snapshot → the retirement cannot settle →
315
+ # after the 5s max window C is PINNED.
316
+ C_LAST_SEQ="$(zstd -q -d -c "$C_DIR/session.jsonl.zstd" 2>/dev/null \
317
+ | grep -o '"seq":[0-9]*' | sed 's/"seq"://' | sort -n | tail -1 || true)"
318
+ C_LAST_SEQ="${C_LAST_SEQ:-0}"
319
+ C_NEXT_SEQ=$((C_LAST_SEQ + 1))
320
+ C_NOW_MS="$(date +%s%3N)"
321
+ printf '{"type":"user/message","seq":%s,"time":%s,"data":{"content":[{"type":"text","text":"e2e injected external writer"}],"source":{"kind":"user"},"role":"user","id":"e2e-injected-0001"},"surfaceOp":"append"}\n' \
322
+ "$C_NEXT_SEQ" "$C_NOW_MS" | zstd -q -c >> "$C_DIR/session.jsonl.zstd"
323
+ # Wait past the cooling max (5s) so the verifier pins C.
324
+ sleep 6
325
+ if [ -f "$(lock_path $SESSION_C)" ]; then ok "C lock still held after the failed cooling"; else bad "C lock lost after the failed cooling"; fi
326
+ # P1 attempts to reopen the quarantined C: must be REFUSED with the
327
+ # STICKY-QUARANTINE notice (not a generic failure).
328
+ send_cmd "$PANE1" "/resume $SESSION_C"
329
+ sleep 2
330
+ P1_OUT="$(tmux capture-pane -t "$PANE1" -p 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ')"
331
+ if echo "$P1_OUT" | grep -qi "safety quarantine\|restart this TUI before reopening"; then
332
+ ok "P1 reopen of the quarantined C was refused (sticky quarantine notice)"
333
+ else
334
+ bad "P1 reopen of the quarantined C was NOT refused: $P1_OUT"
335
+ fi
336
+ if [[ "$(title_of "$PANE1")" == *"$SESSION_B"* ]]; then ok "P1 stayed on B (the quarantine did not switch)"; else bad "P1 left B after the refused reopen"; fi
337
+ # P2 (a second real dsh process) tries to open C: must be REFUSED too
338
+ # (the lock is still held by P1's quarantine). Match ONLY the
339
+ # cross-process lock-refusal notice — the same precise wording as Case
340
+ # 4 — never the resume-failure "stays locked by this TUI" (which would
341
+ # mean P2's own lock check wrongly passed). First quit the OLD dsh
342
+ # still running in pane 2 (it opened A in the ABA case) so the pane is
343
+ # a clean shell again.
344
+ tmux send-keys -t "$PANE2" -l "/exit"
345
+ sleep 0.4
346
+ tmux send-keys -t "$PANE2" Enter
347
+ sleep 2
348
+ tmux send-keys -t "$PANE2" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_C"
349
+ sleep 0.4
350
+ tmux send-keys -t "$PANE2" Enter
351
+ sleep 3
352
+ if echo "$(p2_out)" | grep -qi "is open in another dsh process\|cannot lock session\|refusing to resume"; then ok "P2 was refused for the quarantined C"; else bad "P2 was NOT refused for the quarantined C: $(p2_out)"; fi
353
+ # P1 exits: the editor may hold the refused /resume text, so /exit would
354
+ # not be a clean command — kill the OWNER of C's lock precisely (the pid
355
+ # recorded in the lock file IS the dsh process; this is the script's own
356
+ # process, never a user session). The stale lock remains, and P2's next
357
+ # open takes it over (stale takeover).
358
+ C_LOCK="$(lock_path $SESSION_C)"
359
+ if [ -f "$C_LOCK" ]; then
360
+ OWNER_PID="$(node -e "const fs=require('fs');const j=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));console.log(j.pid)" "$C_LOCK" 2>/dev/null || true)"
361
+ if [ -n "$OWNER_PID" ]; then
362
+ kill "$OWNER_PID" 2>/dev/null || true
363
+ fi
364
+ fi
365
+ sleep 3
366
+ tmux send-keys -t "$PANE2" -l "/exit"
367
+ sleep 0.4
368
+ tmux send-keys -t "$PANE2" Enter
369
+ sleep 2
370
+ tmux send-keys -t "$PANE2" -l "env -u NO_COLOR DSH_HOME=$E2E_HOME dsh --profile $PROFILE_NAME --session $SESSION_C"
371
+ sleep 0.4
372
+ tmux send-keys -t "$PANE2" Enter
373
+ sleep 5
374
+ if [[ "$(title_of "$PANE2")" == *"$SESSION_C"* ]]; then
375
+ ok "P2 opened C after P1 exit (stale takeover of the quarantined lock)"
376
+ else
377
+ bad "P2 could not open C after P1 exit: $(p2_out)"
378
+ fi
379
+ fi
380
+
381
+ # ── summary ─────────────────────────────────────────────────────────────
382
+ echo
383
+ echo "== e2e-session-lease: $PASS passed, $FAIL failed =="
384
+ if [ "$FAIL" -ne 0 ]; then
385
+ echo "== FAILURES: keep with --keep for inspection =="
386
+ exit 1
387
+ fi
388
+ exit 0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Visual preview of the editor `❯` prompt + the transcript user-role
3
+ * styling — run inside a real TTY (tmux):
4
+ *
5
+ * env -u NO_COLOR node --import tsx/esm scripts/preview-prompt.mts
6
+ *
7
+ * Shows a transcript mixing user / assistant / thinking / system rows and
8
+ * alternates the editor between an EMPTY draft (the `❯ ` prompt with the
9
+ * cursor block) and a multi-line draft (the prompt leads the first line,
10
+ * continuation lines indent under it, exactly like the transcript rows).
11
+ * `PREVIEW_HOLD_MS` controls how long it stays up (default 30s); Ctrl+C
12
+ * also exits.
13
+ * @module @xmoon76/dsh-pi-tui/preview-prompt
14
+ */
15
+
16
+ import { ProcessTerminal } from '@xmoon76/pi-tui'
17
+ import { TuiApp } from '../src/tui-app.ts'
18
+
19
+ const holdMs = Number(process.env.PREVIEW_HOLD_MS ?? 30000)
20
+
21
+ const terminal = new ProcessTerminal()
22
+ const app = new TuiApp(terminal, {
23
+ onSubmit: () => {},
24
+ onExit: () => {
25
+ app.stop()
26
+ process.exit(0)
27
+ },
28
+ })
29
+ app.start()
30
+
31
+ // Transcript contrast: the user row is a brand-blue bubble (whole-row
32
+ // background + blue ❯), everything else (assistant / thinking / system)
33
+ // keeps its own distinct style.
34
+ app.setTranscript([
35
+ {
36
+ kind: 'user',
37
+ turn: 0,
38
+ // Long enough to wrap: the continuation must indent under the ❯.
39
+ text: 'The startup fails with ENOTFOUND near line 42 of src/app.ts — I suspect an environment variable is missing, so please check the config load order and compare it against the .env.example keys and the docs.',
40
+ },
41
+ {
42
+ kind: 'assistant',
43
+ turn: 0,
44
+ text: 'Let me check. **Config load order**:\n\n1. `.env` file\n2. Environment overrides\n3. Default fallbacks\n\n`ENOTFOUND` usually means DNS resolution failed — confirm whether you reach an internal registry or the public one.',
45
+ },
46
+ { kind: 'thinking', turn: 0, text: 'ENOTFOUND is a DNS issue; verify the config source before concluding.', running: false },
47
+ { kind: 'system', turn: 0, label: 'AGENTS.md', text: 'Repo config conventions live in docs/config.md; env keys come from the preset.', summary: 'context rules' },
48
+ ])
49
+
50
+ // Alternate the draft so both the empty prompt and the multi-line prompt
51
+ // (continuation indent) are visible without touching the keyboard.
52
+ let draftVisible = false
53
+ const flip = setInterval(() => {
54
+ draftVisible = !draftVisible
55
+ app.setDraft(draftVisible ? 'git status\npnpm test -- --filter @xmoon76/pi-tui' : '')
56
+ }, 6000)
57
+
58
+ setTimeout(() => {
59
+ clearInterval(flip)
60
+ app.stop()
61
+ process.exit(0)
62
+ }, holdMs)
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Side-by-side preview of user-role visual schemes — repo code untouched,
3
+ * pure display. Run in a real TTY (tmux):
4
+ *
5
+ * env -u NO_COLOR node --import tsx/esm scripts/preview-role-styles.mts
6
+ *
7
+ * Shows the chosen scheme — background bubble with a brand-blue ❯ (dsh-web
8
+ * parity) — in both the dark palette and the light palette, next to the
9
+ * old kimi-style amber text for contrast, plus the editor-prompt colours.
10
+ * Ctrl+C exits.
11
+ * @module @xmoon76/dsh-pi-tui/preview-role-styles
12
+ */
13
+
14
+ import { Chalk } from 'chalk'
15
+ import {
16
+ ProcessTerminal, Text, TuiMainScreen, matchesKey,
17
+ visibleWidth, wrapTextWithAnsi, type TuiInputListenerResult,
18
+ } from '@xmoon76/pi-tui'
19
+
20
+ const chalk = new Chalk({ level: 3 })
21
+ const dim = (s: string): string => chalk.hex('#888888')(s)
22
+ const darkBlue = (s: string): string => chalk.bold.hex('#679EFE')(s)
23
+ const darkBubbleBg = (s: string): string => chalk.bgHex('#2C2C2F')(s)
24
+ const lightBlue = (s: string): string => chalk.bold.hex('#4177E6')(s)
25
+ const lightBubbleBg = (s: string): string => chalk.bgHex('#E4EDFD')(s)
26
+ const amber = (s: string): string => chalk.bold.hex('#FFCB6B')(s)
27
+ const border = (s: string): string => chalk.hex('#5A5A5A')(s)
28
+
29
+ const W = 100
30
+ const MSG_ONE = 'Check the config load order — ENOTFOUND at src/app.ts:42, likely a missing env var.'
31
+ const MSG_TWO = 'Also verify the .env.example keys against the docs, since the preset loader reads them in sequence.'
32
+
33
+ /**
34
+ * Wrap `text` to `width - 2` cells. The first row carries the plain `❯ `
35
+ * marker, continuation rows indent 2 cells under it; every row is padded
36
+ * to the full inner width so a background paint covers the whole row.
37
+ */
38
+ function wrapped(width: number, text: string): string[] {
39
+ const inner = Math.max(1, width - 2)
40
+ return wrapTextWithAnsi(text, inner).map((line, index) => {
41
+ const prefix = index === 0 ? '❯ ' : ' '
42
+ return prefix + line + ' '.repeat(Math.max(0, inner - visibleWidth(line)))
43
+ })
44
+ }
45
+
46
+ /** Bubble rows: full-row background, coloured ❯, body in the terminal default. */
47
+ function bubble(width: number, text: string, bg: (s: string) => string, bullet: (s: string) => string): string[] {
48
+ return wrapped(width, text).map((row, index) => {
49
+ if (index === 0 && row.startsWith('❯ ')) {
50
+ return bg(bullet('❯') + ' ' + row.slice(2))
51
+ }
52
+ return bg(row)
53
+ })
54
+ }
55
+
56
+ /** Old kimi look: the whole row (❯ + body + padding) in one amber paint. */
57
+ function amberRows(width: number, text: string): string[] {
58
+ return wrapped(width, text).map(amber)
59
+ }
60
+
61
+ const blocks: string[] = [
62
+ dim('dsh-pi-tui · user-role style preview (repo untouched) — Ctrl+C to exit'),
63
+ '',
64
+ dim('dark · bubble + brand-blue ❯ (chosen scheme)'),
65
+ ...bubble(W, MSG_ONE, darkBubbleBg, darkBlue),
66
+ ...bubble(W, MSG_TWO, darkBubbleBg, darkBlue),
67
+ '',
68
+ dim('light · bubble + deep brand-blue ❯ (light palette)'),
69
+ ...bubble(W, MSG_ONE, lightBubbleBg, lightBlue),
70
+ ...bubble(W, MSG_TWO, lightBubbleBg, lightBlue),
71
+ '',
72
+ dim('kimi amber text (rejected — kept for contrast)'),
73
+ ...amberRows(W, MSG_ONE),
74
+ ...amberRows(W, MSG_TWO),
75
+ '',
76
+ dim('editor prompt candidates:'),
77
+ border('─'.repeat(W)),
78
+ ` ${darkBlue('❯')} git status ${lightBlue('❯')} git status ${amber('❯')} git status`,
79
+ border('─'.repeat(W)),
80
+ ]
81
+
82
+ // Pad every line to the exact width so nothing wraps.
83
+ const preview = blocks
84
+ .map(line => line + ' '.repeat(Math.max(0, W - visibleWidth(line))))
85
+ .join('\n')
86
+
87
+ const terminal = new ProcessTerminal()
88
+ const tui = new TuiMainScreen(terminal)
89
+ tui.addChild(new Text(preview, 0, 0))
90
+ tui.addInputListener((data): TuiInputListenerResult => {
91
+ if (matchesKey(data, 'ctrl+c')) {
92
+ tui.stop()
93
+ process.exit(0)
94
+ }
95
+ return undefined
96
+ })
97
+ tui.start()
@@ -254,13 +254,17 @@ function main() {
254
254
  // legitimately inlines the TuiApp implementation (src/tui-app.d.ts)
255
255
  // for the Loader row — it is NOT a third-party SDK entry. Its region
256
256
  // is exempted; everything else (chunks, extensions, builtins) must
257
- // name only the public modules below.
257
+ // name only the public modules below. The image modules
258
+ // (src/image/admission.d.ts, src/image/types.d.ts) are also
259
+ // legitimately inlined into the root entry: the public
260
+ // `commandRejectsImages` signature references `DraftImageStoreLike`,
261
+ // which pulls in the draft vocabulary (and the recalled-ref shape).
258
262
  const isRoot = name === 'dist/index.d.mts'
259
263
  for (const line of lines) {
260
264
  const match = /\/\/#region\s+(\S+)/.exec(line)
261
265
  if (match) {
262
266
  const regionPath = match[1]
263
- const allowed = /^src\/(builtins|commands|diag|extension\/advanced|extension\/advanced-types|extension\/public-types|extension\/service|extension\/slot-map|extension\/unstable|extension\/unstable-types|extensions|index|skill-catalog|startup|surface-catalog)\.d\.ts$/.test(regionPath)
267
+ const allowed = /^src\/(builtins|commands|diag|extension\/advanced|extension\/advanced-types|extension\/public-types|extension\/service|extension\/slot-map|extension\/unstable|extension\/unstable-types|extensions|image\/admission|image\/types|index|skill-catalog|startup|surface-catalog)\.d\.ts$/.test(regionPath)
264
268
  const rootAllowed = isRoot && /^src\/tui-app\.d\.ts$/.test(regionPath)
265
269
  if (!allowed && !rootAllowed) dtsLeaks.push(`${name}: region ${regionPath}`)
266
270
  }