@zhuxixi/pi-agent-board 0.5.1 → 0.6.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +41 -3
  3. package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +284 -0
  4. package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +722 -0
  5. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
  6. package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +146 -0
  7. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
  8. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
  9. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
  10. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
  11. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
  12. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
  13. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
  14. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
  15. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
  16. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
  17. package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +78 -0
  18. package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +120 -0
  19. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
  20. package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +99 -0
  21. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
  22. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
  23. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
  24. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
  25. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
  26. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
  27. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
  28. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
  29. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
  30. package/package.json +83 -81
  31. package/runner/job-runner.mjs +2 -2
  32. package/runner/pty-runner.mjs +585 -4
  33. package/runner/state-runner.mjs +3 -0
  34. package/runner/title-runner.mjs +1 -1
  35. package/scripts/release_helper.mjs +277 -0
  36. package/src/commands/agent-board.ts +38 -35
  37. package/src/commands/attach-decision.mjs +66 -0
  38. package/src/commands/attach-flow.ts +45 -39
  39. package/src/core/code-refs.mjs +85 -33
  40. package/src/core/editor-state-reporter.mjs +102 -0
  41. package/src/core/evidence.mjs +2 -2
  42. package/src/core/heuristics.mjs +40 -2
  43. package/src/core/host-coordination.mjs +159 -0
  44. package/src/core/host-crash.mjs +43 -3
  45. package/src/core/host-probe.mjs +196 -0
  46. package/src/core/launch.mjs +3 -1
  47. package/src/core/locks.mjs +196 -1
  48. package/src/core/paths.mjs +24 -0
  49. package/src/core/pty-input.mjs +32 -0
  50. package/src/core/store.mjs +164 -5
  51. package/src/core/types.mjs +17 -1
  52. package/src/core/warm-host-sweeper.mjs +150 -0
  53. package/src/index.ts +41 -2
  54. package/src/runtime/service.mjs +967 -109
  55. package/src/ui/dashboard-decisions.mjs +55 -0
  56. package/src/ui/dashboard.ts +26 -12
  57. package/src/ui/pty-attach.ts +55 -7
package/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here, generated from
4
+ conventional commits by `scripts/release_helper.mjs`. Entries are
5
+ forward-only: they begin with the first release after this file landed —
6
+ for earlier history, see the git log and the pull-request list.
7
+
8
+ ## [0.6.0] - 2026-09-07
9
+
10
+ ### Features
11
+
12
+ - conventional-commit driven CHANGELOG via release_helper.mjs (issue #64) (#83)
13
+
14
+ ### Fixes
15
+
16
+ - pty host stacking and control.sock cross-deletion via per-instance ownership protocol (issue #70) (#84)
17
+ - guard mention fallback against placeholders, code spans, and pr-context (issue #61) (#82)
18
+ - truncate never splits surrogate pairs or emits lone surrogates (issue #39) (#81)
19
+ - extract evidence outputPreview from AgentToolResult content (issue #41) (#77)
20
+ - reclaim idle warm PTY hosts via periodic sweep + lifecycle cleanup (issue #75) (#76)
21
+ - narrow code-refs PR back-link extraction by evidence context (issue #65) (#73)
22
+
23
+ ### Performance
24
+
25
+ - use cached PTY probe on the reconcile drain path (issue #13) (#79)
26
+
27
+ ### Changes
28
+
29
+ - Windows WezTerm IME needs a visible hardware cursor (issue #38) (#80)
30
+ - poll markCompleted to success instead of racing the persist window (issue #63) (#78)
31
+
32
+ [0.6.0]: https://github.com/zhuxixi/pi-agent-board/compare/v0.5.2...v0.6.0
package/README.md CHANGED
@@ -236,6 +236,26 @@ steer:awaiting-approval
236
236
 
237
237
  Evidence is collected locally from session events. Agent Board can extract issue and pull-request references from that evidence and show badges such as `#40` or `▸#45` on rows; Peek includes the provider, confidence, source, and URL when available. Built-in GitHub/GitLab-style providers are available, and an optional per-store `providers.json` can extend the provider rules. The `AGENT_BOARD_CODE_REFS=off` setting disables extraction.
238
238
 
239
+ For internal code platforms (non-github/gitlab hosts) or custom CLIs, add a per-store `providers.json` so claim/action rules exist and sessions stop depending on the low-confidence mention fallback. Example — an internal CLI (`acli`) with claim-strength issue rules:
240
+
241
+ ```json
242
+ {
243
+ "providers": [
244
+ {
245
+ "name": "acode",
246
+ "hosts": ["acode.internal.example.com"],
247
+ "rules": [
248
+ { "pattern": "acli\\s+issue\\s+update\\s+#?(\\d+)(?=[\\s\\S]*--assignee)", "kind": "issue", "strength": "claim" },
249
+ { "pattern": "acli\\s+issue\\s+(?:note|comment|close)\\s+#?(\\d+)", "kind": "issue", "strength": "action" },
250
+ { "pattern": "acli\\s+issue\\s+(?:show|view)\\s+#?(\\d+)", "kind": "issue", "strength": "view" }
251
+ ]
252
+ }
253
+ ]
254
+ }
255
+ ```
256
+
257
+ `hosts` matches the repo's remote host; rules follow the same `pattern`/`kind`/`strength` shape as the built-in `gh`/`glab` tables (`strength`: `claim` > `action` > `view`), and the `#N` capture group supplies the number. Validation errors from a broken file surface in the diagnostics panel and never break extraction — an invalid file is simply ignored.
258
+
239
259
  ## Attach and Fallback Behavior
240
260
 
241
261
  When PTY support is healthy, Agent Board uses an interactive PTY host for attach and start-and-attach. If PTY support is unavailable, eligible managed sessions can still run in the background through the JSON runner; start-and-attach falls back to background launch with a warning. Adopted external foreground sessions require PTY to continue safely. Press `!` in the dashboard for diagnosis and repair hints.
@@ -319,6 +339,20 @@ Press `!` in the dashboard to open the diagnostic panel and follow its repair hi
319
339
 
320
340
  A cold PTY host may briefly show a loading or reconnecting surface while it starts. Check the PTY status in the dashboard with `!`; stale hosts are diagnosed separately from active task workers. If the host never becomes healthy, repair `node-pty` or use background mode for eligible managed sessions.
321
341
 
342
+ ### IME candidate window is stuck at the window edge (Windows WezTerm)
343
+
344
+ On Windows WezTerm with a WSL2 backend, the IME candidate window may stay pinned to the right edge instead of following the text cursor in an attached session. Windows WezTerm only tracks the IME candidate position from the visible hardware cursor, and Pi hides the hardware cursor by default — the block cursor you see in the editor is drawn content, not the hardware cursor. Linux terminals are not affected.
345
+
346
+ Make the hardware cursor visible, either way:
347
+
348
+ ```bash
349
+ export PI_HARDWARE_CURSOR=1 # machine-local, e.g. ~/.zshrc.local
350
+ ```
351
+
352
+ or set `"showHardwareCursor": true` in Pi's `settings.json` (syncs across machines if the config is version-controlled; harmless on Linux).
353
+
354
+ Trade-off: the real terminal cursor becomes visible inside the TUI. This is cosmetic only.
355
+
322
356
  ### Start & attach falls back to background
323
357
 
324
358
  Start & attach requires PTY support. When PTY is unavailable, the task is still dispatched in the background and the dashboard displays a warning. Repair PTY and retry attach from the normal `/agent-board` command path.
@@ -346,15 +380,19 @@ npm run verify
346
380
 
347
381
  ## Publishing
348
382
 
349
- Before publishing a release, verify the package, bump the version, and publish it:
383
+ Before publishing a release, verify the package, generate the changelog, then bump, and publish — the changelog must be generated **before** `npm version`, because `npm version` commits and tags the bump, which would empty the generation range:
350
384
 
351
385
  ```bash
352
386
  npm run verify
353
- npm version patch
387
+ npm run changelog -- --dry-run # preview the next section (version = current + patch)
388
+ npm run changelog -- patch # inserts the section into CHANGELOG.md
389
+ node scripts/release_helper.mjs verify # must exit 0: no functional PR missing from the top section
390
+ git add CHANGELOG.md && git commit -m "docs(changelog): <version>"
391
+ npm version patch # bumps package.json, commits, and tags vX.Y.Z
354
392
  npm publish
355
393
  ```
356
394
 
357
- Use `npm version minor` or `npm version major` when appropriate. If the version is already bumped, skip `npm version patch`. After publishing, users install the scoped package with:
395
+ Use `minor`/`major` in both the changelog and `npm version` steps when appropriate. The changelog is generated from conventional commits since the last `vX.Y.Z` tag (`scripts/release_helper.mjs`, ported from the jfox release flow); review the preview before committing. The `verify` step guards against PRs merged after the changelog was generated — rerun it after any late merge and, after removing the stale top section, re-run `npm run changelog -- <bump>` if it reports missing PRs (apply refuses when the section already exists — remove the stale section first). Release notes for the GitHub Release are the top CHANGELOG section. After publishing, users install the scoped package with:
358
396
 
359
397
  ```bash
360
398
  pi install npm:@zhuxixi/pi-agent-board
@@ -0,0 +1,284 @@
1
+ # ← Detach Gate Cursor-Anchor Fix Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Fix issue #66 — `←` must detach when the Pi editor line is empty even while the terminal cursor rests on working/output lines (attach / streaming).
6
+
7
+ **Architecture:** Replace the cursor-line anchor in `PtyAttachComponent.childInputLooksEmpty()` with a bottom-up scan for Pi's inverse-video fake-cursor cell (`ESC[7m`, persists in the xterm buffer across differential frames), with a prompt-glyph fallback and a "treat as empty" escape fallback. Add a pure helper `isProbablyPiInputLine` for glyph detection.
8
+
9
+ **Tech Stack:** TypeScript (pty-attach.ts, run via `node --experimental-transform-types` in tests), plain ESM (pty-input.mjs), `node:test` runner, @xterm/headless.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Coverage gates: lines 85 / funcs 80 / branches 70 (c8, `npm run test:coverage`); Node 22/24 both green (CI).
16
+ - `isProbablyPiInputLine` glyph set must stay identical to `isProbablyEmptyPiInputLine`'s trim charset: `›>┃│|┆╎╏:`.
17
+ - Do not change `ctrl+]` semantics (passes through to Pi since v0.5.1) and do not make `←` unconditionally detach (keeps the edit-protection gate — spec §3).
18
+ - Do not modify `src/core/pty-input.mjs`'s existing `isProbablyEmptyPiInputLine` behavior.
19
+ - All edits inside worktree `WT=.pi/worktrees/issue-66-attach-detach-gate-cursor-anchor`; git ops via `git -C $WT`.
20
+
21
+ ---
22
+
23
+ ### Task 1: Add `isProbablyPiInputLine` pure helper + unit tests
24
+
25
+ **Files:**
26
+ - Modify: `src/core/pty-input.mjs` (append glyph const + function after existing helper)
27
+ - Test: `test/pty-input.test.mjs`
28
+
29
+ **Interfaces:**
30
+ - Produces: `export function isProbablyPiInputLine(line: string): boolean` — true iff the line, after trimming leading whitespace, starts with a prompt/continuation glyph.
31
+
32
+ - [ ] **Step 1: Write the failing test**
33
+
34
+ Append to `test/pty-input.test.mjs` (keep existing tests untouched):
35
+
36
+ ```js
37
+ import { isProbablyEmptyPiInputLine, isProbablyPiInputLine } from "../src/core/pty-input.mjs";
38
+
39
+ test("isProbablyPiInputLine recognizes Pi prompt / continuation lines", () => {
40
+ assert.equal(isProbablyPiInputLine("> "), true);
41
+ assert.equal(isProbablyPiInputLine(" ┃ edit me"), true);
42
+ assert.equal(isProbablyPiInputLine(" │ second line"), true);
43
+ assert.equal(isProbablyPiInputLine("› draft"), true);
44
+ });
45
+
46
+ test("isProbablyPiInputLine rejects content lines and empty lines", () => {
47
+ assert.equal(isProbablyPiInputLine("chat content"), false);
48
+ assert.equal(isProbablyPiInputLine("────── ◊◊ ──────"), false);
49
+ assert.equal(isProbablyPiInputLine(""), false);
50
+ assert.equal(isProbablyPiInputLine(" "), false);
51
+ });
52
+ ```
53
+
54
+ - [ ] **Step 2: Run test to verify it fails**
55
+
56
+ Run: `node --test test/pty-input.test.mjs`
57
+ Expected: FAIL — `isProbablyPiInputLine is not a function` (import error).
58
+
59
+ - [ ] **Step 3: Write minimal implementation**
60
+
61
+ Append to `src/core/pty-input.mjs` (before `isProbablyEmptyPiInputLine` or after — any top-level position):
62
+
63
+ ```js
64
+ /** Glyphs Pi uses to render editor prompt / continuation lines (`>` main prompt,
65
+ * `›`/`┃`/`│` and variants in older releases). Must stay in sync with the
66
+ * trim charset of isProbablyEmptyPiInputLine below. */
67
+ const PROMPT_GLYPHS = "›>┃│|┆╎╏:";
68
+
69
+ /**
70
+ * Whether the given terminal line looks like a Pi editor input line: leading
71
+ * whitespace followed by a prompt/continuation glyph. The attach surface uses
72
+ * this to locate the editor line inside the buffer instead of trusting the
73
+ * terminal cursor, which wanders onto output/working lines while Pi streams
74
+ * (issue #66).
75
+ * @param {string} line
76
+ * @returns {boolean}
77
+ */
78
+ export function isProbablyPiInputLine(line) {
79
+ const withoutLeftPadding = String(line || "").replace(/^[\s\u00a0]+/u, "");
80
+ return withoutLeftPadding.length > 0 && PROMPT_GLYPHS.includes(withoutLeftPadding[0]);
81
+ }
82
+ ```
83
+
84
+ - [ ] **Step 4: Run test to verify it passes**
85
+
86
+ Run: `node --test test/pty-input.test.mjs`
87
+ Expected: PASS (4 tests: 2 existing + 2 new).
88
+
89
+ - [ ] **Step 5: Commit**
90
+
91
+ ```bash
92
+ git add test/pty-input.test.mjs src/core/pty-input.mjs
93
+ git commit -m "feat: add isProbablyPiInputLine helper (issue #66)"
94
+ ```
95
+
96
+ ---
97
+
98
+ ### Task 2: Rework `childInputLooksEmpty()` anchor + smoke scenarios
99
+
100
+ **Files:**
101
+ - Modify: `src/ui/pty-attach.ts` (import line ~8; `childInputLooksEmpty()` ~line 319; add private helper near it)
102
+ - Modify: `test-support/detach-gate-smoke.ts` (scenarios B/B1/B2/B3 + new E)
103
+ - Modify: `test/pty-attach-detach-gate.test.mjs` (assertions)
104
+
105
+ **Interfaces:**
106
+ - Consumes: `isProbablyPiInputLine` from Task 1; existing `isProbablyEmptyPiInputLine`.
107
+ - Produces: private `findLastInverseCellLine(active): number | null` — bottom-most line index containing an `isInverse()` cell; `null` when none.
108
+
109
+ - [ ] **Step 1: Write the failing smoke scenarios**
110
+
111
+ In `test-support/detach-gate-smoke.ts`:
112
+
113
+ 1. Change scenario **B** (line ~76) so the draft line carries a fake cursor, then add B3 (cursor off the empty input line) and E (empty input line without fake cursor). Replace the current B block and append:
114
+
115
+ ```ts
116
+ // B. ← must NOT detach while attached with a draft in the editor line (child
117
+ // is mid-draft and ← is also the editor's cursor-left key). The draft line
118
+ // carries Pi's inverse-video fake cursor (ESC[7m).
119
+ {
120
+ const { attach, sent, didDetach } = makeAttach();
121
+ await writeToTerm(attach, "chat content\r\n> \x1b[7m草\x1b[27m稿");
122
+ (attach as unknown as { connected: boolean }).connected = true;
123
+ attach.handleInput("\x1b[D");
124
+ out.leftStaysGatedOnNonEmptyLine = !didDetach() && sent.length === 1 && sent[0].type === "input" && sent[0].data === "\x1b[D";
125
+ attach.dispose();
126
+ }
127
+
128
+ // B3. The streaming case from issue #66: the editor line is empty (bottom of
129
+ // the buffer, with its fake cursor) but the terminal cursor rests on the
130
+ // working line because Pi's differential frames only repaint the changed
131
+ // line. The gate must be judged from the fake-cursor line, not the cursor.
132
+ {
133
+ const { attach, sent, didDetach } = makeAttach();
134
+ await writeToTerm(attach, "chat content\r\n> \x1b[7m \x1b[27m");
135
+ await writeToTerm(attach, "\x1b[2;1H⠙ Working...");
136
+ (attach as unknown as { connected: boolean }).connected = true;
137
+ attach.handleInput("\x1b[D");
138
+ out.leftDetachesWhenCursorOffEmptyInputLine = didDetach() && sent.length === 0;
139
+ attach.dispose();
140
+ }
141
+
142
+ // E. Empty input line rendered WITHOUT a fake cursor: no inverse cell and no
143
+ // glyph anywhere, and the terminal cursor sits on a non-empty output line.
144
+ // Falls through to the escape fallback — treat as empty, detach.
145
+ {
146
+ const { attach, sent, didDetach } = makeAttach();
147
+ await writeToTerm(attach, "chat content\r\n");
148
+ await writeToTerm(attach, "\x1b[1;1H"); // park the cursor on the non-empty line
149
+ (attach as unknown as { connected: boolean }).connected = true;
150
+ attach.handleInput("\x1b[D");
151
+ out.leftDetachesOnEmptyInputWithoutFakeCursor = didDetach() && sent.length === 0;
152
+ attach.dispose();
153
+ }
154
+ ```
155
+
156
+ 2. Update the B1 header comment (scenario text already fits) and ensure B1 (`────── ◊◊ ──────` garbled buffer) stays as-is — it now asserts escape-on-garbled-buffer.
157
+
158
+ - [ ] **Step 2: Run smoke to verify new scenarios fail**
159
+
160
+ Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
161
+ Expected: `leftDetachesWhenCursorOffEmptyInputLine` is `false` (old cursor-line anchor reads the Working line) and `leftDetachesOnEmptyInputWithoutFakeCursor` is `false` (old anchor reads last non-empty line `chat content`? — verify output); `leftStaysGatedOnNonEmptyLine` true.
162
+
163
+ - [ ] **Step 3: Write the implementation**
164
+
165
+ In `src/ui/pty-attach.ts`:
166
+
167
+ 1. Update import (line ~8):
168
+
169
+ ```ts
170
+ import { isProbablyEmptyPiInputLine, isProbablyPiInputLine } from "../core/pty-input.mjs";
171
+ ```
172
+
173
+ 2. Replace the `childInputLooksEmpty()` method (currently ~line 319) and add the helper above it:
174
+
175
+ ```ts
176
+ /** Bottom-most line whose cells include an inverse-video cell — Pi renders
177
+ * its editor cursor as an inverse "fake cursor" (`ESC[7m`), and the cell
178
+ * persists in the buffer even while streaming differential frames skip
179
+ * repainting the editor line. */
180
+ private findLastInverseCellLine(active: {
181
+ baseY: number;
182
+ length: number;
183
+ getLine(index: number): BufferLineLike | undefined;
184
+ }): number | null {
185
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
186
+ const line = active.getLine(y);
187
+ if (!line) continue;
188
+ for (let x = 0; x < line.length; x++) {
189
+ if (line.getCell(x)?.isInverse()) return y;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+
195
+ private childInputLooksEmpty(): boolean {
196
+ if (!this.receivedOutput) return true;
197
+ const active = this.term.buffer.active;
198
+ // The terminal cursor is not a reliable anchor for the editor line:
199
+ // while Pi streams output (or right after attach) the cursor rests on
200
+ // working/output lines, never the input line, so a genuinely empty
201
+ // editor was misread as non-empty and ← stopped detaching (issue #66).
202
+ // Pi's editor line always carries an inverse-video fake-cursor cell,
203
+ // so anchor on that instead.
204
+ const fakeCursorLine = this.findLastInverseCellLine(active);
205
+ if (fakeCursorLine !== null) {
206
+ const line = active.getLine(fakeCursorLine)?.translateToString(true) ?? "";
207
+ return isProbablyEmptyPiInputLine(line);
208
+ }
209
+ // Fallback: Pi variants that render no fake cursor — look for a
210
+ // prompt-glyph line.
211
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
212
+ const line = active.getLine(y)?.translateToString(true) ?? "";
213
+ if (isProbablyPiInputLine(line)) return isProbablyEmptyPiInputLine(line);
214
+ }
215
+ // No editor line recoverable (e.g. a garbled replay buffer): treat the
216
+ // input as empty — ← is the only detach key left on the attach surface,
217
+ // so it must always escape rather than trap the user.
218
+ return true;
219
+ }
220
+ ```
221
+
222
+ - [ ] **Step 4: Update gate assertions**
223
+
224
+ In `test/pty-attach-detach-gate.test.mjs`, add to the existing test (after `leftDetachesOnEmptyInput`):
225
+
226
+ ```js
227
+ assert.equal(parsed.leftDetachesWhenCursorOffEmptyInputLine, true, "← must detach when the editor line is empty even if the cursor sits on a working line");
228
+ assert.equal(parsed.leftDetachesOnEmptyInputWithoutFakeCursor, true, "← must detach when an empty editor line renders no fake cursor");
229
+ ```
230
+
231
+ - [ ] **Step 5: Run tests to verify pass**
232
+
233
+ Run: `node --test test/pty-attach-detach-gate.test.mjs test/pty-input.test.mjs`
234
+ Expected: PASS — all gate assertions true, including the three new ones.
235
+
236
+ - [ ] **Step 6: Commit**
237
+
238
+ ```bash
239
+ git add src/ui/pty-attach.ts test-support/detach-gate-smoke.ts test/pty-attach-detach-gate.test.mjs
240
+ git commit -m "fix: anchor ← detach gate on Pi's fake-cursor line, not terminal cursor (issue #66)"
241
+ ```
242
+
243
+ ---
244
+
245
+ ### Task 3: Full regression (A6)
246
+
247
+ **Files:** none (verification only).
248
+
249
+ - [ ] **Step 1: Run verify**
250
+
251
+ Run (in worktree): `npm run verify`
252
+ Expected: typecheck clean; all `node --test test/*.test.mjs` pass (325+ tests, none of the pre-existing ones changed semantics); c8 coverage above gates (lines ≥85 / funcs ≥80 / branches ≥70); `npm pack --dry-run` succeeds.
253
+
254
+ - [ ] **Step 2: Commit any incidental fixes**
255
+
256
+ If verify surfaced issues, fix and commit with a message referencing issue #66. Otherwise nothing to commit.
257
+
258
+ ---
259
+
260
+ ### Task 4: Post-implementation manual verification (U1, U2)
261
+
262
+ **Files:** none (user verification; report back into the PR).
263
+
264
+ - [ ] **Step 1: U1 — attach-then-←**
265
+
266
+ 1. Start agent-board, attach into an existing pi session (or create a new one).
267
+ 2. As soon as the attach surface paints, press `←`.
268
+ Expected: returns to the dashboard immediately, no ↑/↓ or type-then-delete needed.
269
+
270
+ - [ ] **Step 2: U2 — ← while Pi is thinking**
271
+
272
+ 1. Attach into a session and send a prompt that triggers Pi streaming (`⠹ Working...` animation visible).
273
+ 2. While the animation is running, press `←`.
274
+ Expected: returns to the dashboard. (Fails before this fix — the exact reported symptom.)
275
+
276
+ - [ ] **Step 3: Sanity — ← does not steal the editor's cursor-left**
277
+
278
+ 1. Attach, type a draft in the input box.
279
+ 2. Press `←`.
280
+ Expected: cursor moves left inside the draft (key forwarded), NOT detach.
281
+
282
+ - [ ] **Step 4: Record results**
283
+
284
+ Write the U1/U2/U3 outcomes into the PR description (or a PR comment) before merge.