diff --git a/codev/experiments/38-multi-client-resize/artifacts/broker-tests.txt b/codev/experiments/38-multi-client-resize/artifacts/broker-tests.txt new file mode 100644 index 000000000..1bbf0a903 --- /dev/null +++ b/codev/experiments/38-multi-client-resize/artifacts/broker-tests.txt @@ -0,0 +1,15 @@ +ok follow-focused: two visible clients, only focused size applies +ok follow-focused: focused disconnect promotes remaining visible viewer +ok follow-focused: both hidden, resize ignored, last size holds +ok follow-focused: iOS reconnect nudge does not steal focused size +ok follow-focused: sole reconnector applies its size +ok ignore-hidden: two visible clients still last-writer-wins (fails FR-38) +ok ignore-hidden: hidden resize is ignored +ok ignore-hidden: both hidden, last size holds +ok ignore-hidden: iOS reconnect nudge wins if visible (fails FR-38) +ok per-viewer-reflow: two visible different sizes is unsupported +ok per-viewer-reflow: matching sizes are allowed +ok per-viewer-reflow: both hidden holds last size +ok per-viewer-reflow: iOS reconnect with other viewer stays unsupported + +13 passed, 0 failed, 13 total diff --git a/codev/experiments/38-multi-client-resize/artifacts/policy-scores.md b/codev/experiments/38-multi-client-resize/artifacts/policy-scores.md new file mode 100644 index 000000000..4efe1afb6 --- /dev/null +++ b/codev/experiments/38-multi-client-resize/artifacts/policy-scores.md @@ -0,0 +1,12 @@ +# Policy scores (written after the run, against the pre-locked cases) + +Cases from notes.md Goal. Pass means the policy meets FR-38 for that case. + +| Case | follow-focused | ignore-hidden | per-viewer-reflow | +|---|---|---|---| +| Two visible, different sizes | PASS. PTY stays at focused 80x24. iPad resize ignored. | FAIL. Second visible attach applies 40x12. Last writer wins. | FAIL for FR-38 as a working policy. Returns `unsupported-divergent`. Additive wrapper cannot reflow. | +| Focused disconnect | PASS. Remaining visible viewer is promoted and applied. | n/a (no focus). Remaining visible can still apply on next resize. | Still unsupported if sizes differ. | +| Both hidden | PASS. No apply. Last negotiated size holds. | PASS. Hidden resize ignored. | PASS. Holds. | +| iOS-style reconnect | PASS. Reattach is unfocused. Nudge ignored while desktop is focused. Sole reconnector applies. | FAIL. Visible reconnector applies immediately and steals size. | FAIL. Divergent sizes stay unsupported. | + +Chosen: **follow-focused**. diff --git a/codev/experiments/38-multi-client-resize/artifacts/repro-last-writer-wins.txt b/codev/experiments/38-multi-client-resize/artifacts/repro-last-writer-wins.txt new file mode 100644 index 000000000..89394d9cd --- /dev/null +++ b/codev/experiments/38-multi-client-resize/artifacts/repro-last-writer-wins.txt @@ -0,0 +1,15 @@ +date: 2026-08-22T19:34:29.564Z +PTY_SESSION_MODULE: /Users/chris/dev/codev-1455/packages/codev/src/terminal/pty-session.ts +method: PtySession.resize (last writer wins) + +spawn: ok (/bin/bash) +clients attached: 2 +initial session.info: cols=80 rows=24 +after client A resize(80, 24): cols=80 rows=24 +after client B resize(40, 12): cols=40 rows=12 +session.info last-writer-wins: YES +stty size after B won (raw): "A:stty size\nB:stty size\nA:\nThe default interactive shell is now zsh.\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\nFor more details, please visit https://support.apple.com/kb/HT208050.\n\u001b[?1034hstty size\n12 40\nB:\nThe default interactive shell is now zsh.\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\nFor more details, please visit https://support.apple.com/kb/HT208050.\n\u001b[?1034hstty size\n12 40\n" +stty parsed: rows=12 cols=40 + +conclusion: +Two sequential resize() calls from two attached clients leave session.info at the later writer. diff --git a/codev/experiments/38-multi-client-resize/notes.md b/codev/experiments/38-multi-client-resize/notes.md new file mode 100644 index 000000000..ec4b70dbc --- /dev/null +++ b/codev/experiments/38-multi-client-resize/notes.md @@ -0,0 +1,134 @@ +# Experiment 38: Multi-client terminal resize policy + +**Status**: Complete ยท **Date**: 2026-08-22 + +Spawn prompt named `codev/specs/0038-consult-pr-mode.md` (an already-shipped 2025 TICK). Issue #38 and porch project `38-spike-v2-ui-multi-client-termi` are the work. The spec-path collision is a template fill on the issue number. + +## Goal + +**Question.** Can FR-38 (two clients on one terminal must not fight over dimensions) be met additively, in a v2-owned attach wrapper that calls the existing `PtySession.resize()` with one agreed size, without editing `pty-session.ts`? + +**Hypothesis, locked before any prototype.** + +1. Last-writer-wins is real on current code. Two attached clients sending different sizes leave `session.info` at the later writer's cols and rows. +2. Policy 2 (ignore resize from hidden panes) fails FR-38 when both clients are visible. That is the product case: iPad and desktop both showing the same builder. +3. Policy 3 (per-viewer local cols with server-side reflow) cannot be met additively. One PTY has one size. Per-viewer reflow needs a second PTY, per-client output rewrite, or a change to what `resize()` means. All three break the fork constraint. +4. Policy 1 (follow the focused client) can be met additively. The wrapper tracks focus and visibility per viewer, picks one size, and calls existing `resize()` once. + +**Success.** All of these, scored against this list, not against whatever the run produces: + +- A reproduction file exists with captured `session.info` (and, if a live PTY starts, process-side size) after two sequential resizes from two clients. +- The wrapper lives under `codev/experiments/38-multi-client-resize/`. No production path is edited to make the prototype work. +- `git diff --stat -- packages/codev/src/terminal/pty-session.ts` is empty. +- Each of the three policies is scored against four cases: two visible clients, focused client disconnects, both hidden, reconnect after an iOS-style drop. +- One policy is named, or FR-38 is flagged as a requirement to renegotiate. +- The named policy's prototype passes those four cases. + +**Failure of the hypothesis.** + +- Last-writer-wins cannot be reproduced against current `PtySession.resize`. The FRD claim is stale. +- Policy 3 can be done without touching `PtySession` and still give two viewers different wrapped output. +- Policy 1 still fights (both claim focus, or the post-connect resize nudge bypasses the wrapper). FR-38 is not additive. + +## Approach + +A v2 resize broker sits in front of `PtySession.resize`. Viewers report size, visibility, and focus. The broker picks at most one size and calls the existing `resize(cols, rows)`. `pty-session.ts` is not imported by the broker. Production would pass `session.resize.bind(session)`. + +**Why this seam.** `tower-websocket.ts:76-80` already forwards every resize control frame straight into `session.resize`. That is the last-writer-wins call. A v2 attach path can intercept the frame before that call. No contract change on `PtySession`. + +**Policy 1, follow focused.** Apply a resize only from the focused viewer. Hidden never applies. Sole remaining viewer becomes focused. No viewers left: hold last size. + +**Policy 2, ignore hidden.** Apply a resize from any visible viewer. Two visible clients still last-writer-wins. Expected to fail the two-visible case. + +**Policy 3, per-viewer reflow.** Record a local size per viewer. Do not call `session.resize` with different sizes. If two visible viewers disagree, return `unsupported`. That is the additive answer: the policy cannot be finished without a second PTY or per-client rewrite. + +**Measurements.** + +| Case | Pass for a policy | +|---|---| +| Two visible, different sizes | PTY size equals the focused (or sole-authority) viewer. Not the last writer. | +| Focused disconnects | PTY size becomes the remaining visible viewer's size, or holds if none remain. | +| Both hidden | No `applySize` call. Last negotiated size stays. | +| iOS-style reconnect | Reconnect attaches visible and unfocused. Its post-connect nudge does not apply while another viewer is focused. Sole reconnector becomes focused. | + +**Not measured here.** Live iPad Safari, WebGL context loss, xterm client-side wrap. Those need a device. This spike answers the server-side policy and the additive question. + +## Environment and reproduction + +Worktree has no `node_modules`. Broker tests need only Node 20: + +``` +node codev/experiments/38-multi-client-resize/src/run-tests.mjs +``` + +Last-writer-wins repro uses main's installed `packages/codev` deps. Worktree `pty-session.ts` is byte-identical to main (`diff -q` reported identical). Command that produced the artifact: + +``` +cd /Users/chris/dev/codev-1455/packages/codev +./node_modules/.bin/tsx \ + /Users/chris/dev/codev-1455/.builders/experiment-38/codev/experiments/38-multi-client-resize/scripts/repro-last-writer-wins.ts +``` + +Untouched check: `git diff --stat -- packages/codev/src/terminal/pty-session.ts` (empty). + +## Code + +| File | What it is | +|---|---| +| `src/v2-resize-broker.mjs` | v2-owned resize broker. Calls an injected `applySize`. Does not import `PtySession`. | +| `src/run-tests.mjs` | 13 node:assert cases for the four locked scenarios across three policies. | +| `scripts/repro-last-writer-wins.ts` | Live `PtySession` plus two attached clients. Writes the capture file. | +| `artifacts/repro-last-writer-wins.txt` | Captured last-writer-wins output. | +| `artifacts/broker-tests.txt` | Test run output. | +| `artifacts/policy-scores.md` | Score table against the pre-locked cases. | + +## Results + +Last-writer-wins is real. Two clients attached, A resized to 80x24, B resized to 40x12. `session.info` ended at 40x12. Live bash `stty size` printed `12 40` (rows, then cols). Source: `artifacts/repro-last-writer-wins.txt`, timestamp `2026-08-22T19:34:29.564Z`. + +Broker tests: 13 passed, 0 failed. Source: `artifacts/broker-tests.txt`. + +| Metric | Value | Source | +|---|---|---| +| `session.info` after A then B | 40x12 | repro artifact | +| `stty size` after B | 12 40 | repro artifact, parsed from PTY output | +| `pty-session.ts` diff | empty | `git diff --stat` | +| follow-focused, two visible | PASS (stays 80x24) | run-tests.mjs | +| ignore-hidden, two visible | FAIL FR-38 (becomes 40x12) | run-tests.mjs | +| per-viewer-reflow, two visible | `unsupported-divergent` | run-tests.mjs | + +**Hypothesis 1, confirmed.** Last-writer-wins is on current code, not just in the FRD. + +**Hypothesis 2, confirmed.** Ignore-hidden still fights when both clients are visible. That is the product case. + +**Hypothesis 3, confirmed for the additive seam.** One `applySize` callback cannot give two viewers different wrapped output. The broker returns `unsupported-divergent`. A second PTY or per-client rewrite was not built. Those would be new infrastructure, not an additive wrapper. + +**Hypothesis 4, confirmed.** Follow-the-focused-client passes all four locked cases. `pty-session.ts` was not edited. + +**Chosen policy: follow the focused client.** Hidden never applies. Sole remaining viewer becomes focused. No viewers: hold last size. Reconnect attaches visible and unfocused, so the post-connect nudge cannot steal size from a still-focused peer. + +**FR-38 is additive for v2-only attach.** Mixed v1+v2 attach to the same session is not. Today's `tower-websocket.ts:76-80` still calls `session.resize` directly. A v2 wrapper cannot stop a v1 client. `POST /api/terminals/:id/resize` is the same hole. That is a scope call, not a reason to edit `pty-session.ts`. + +## What worked / what didn't + +Follow-focused is a small state machine. The four cases fall out of focus plus visibility. No PTY contract change. + +Ignore-hidden is a useful filter and the wrong policy. It only helps tabbed-away panes. Two visible devices still fight. + +Per-viewer reflow is the nice UI and it is not additive. I did not try to fake it with client-side xterm wrap. That would look right until the agent drew a full-screen TUI at the PTY's real width, then the iPad would be wrong in a worse way. + +The worktree has no install. Broker tests stayed zero-dep on purpose. The live PTY repro had to run against main's `packages/codev` so `@xterm/headless` and `node-pty` resolve. The file under test is the same bytes. + +Not tested: real iPad Safari, WebGL context loss, two humans both sending a focus frame. Last explicit focus would still be last-writer-wins at the focus layer. Treating input as implicit focus is the cheap fix and was not coded. + +## Next steps + +Promote `v2-resize-broker.mjs` into a new v2 server module. New file only. + +v2 attach intercepts resize frames and calls `session.resize` once, through the broker. Add a `focus` control frame. Treat `handleUserInput` from a viewer as implicit focus. + +Do not edit `pty-session.ts`. + +Decide mixed v1+v2 attach before any production PR: forbid it, or accept the gap until the old UI is not used on that session. Editing `tower-websocket.ts` to route v1 through the broker is a fork-constraint call, not this spike. + +Issue #38 can stay open for the production follow-up. This experiment answers the policy question. Use `Refs #38` if a PR is opened for the notes alone. diff --git a/codev/experiments/38-multi-client-resize/package.json b/codev/experiments/38-multi-client-resize/package.json new file mode 100644 index 000000000..533a9a1f0 --- /dev/null +++ b/codev/experiments/38-multi-client-resize/package.json @@ -0,0 +1,5 @@ +{ + "name": "exp-38-multi-client-resize", + "private": true, + "type": "module" +} diff --git a/codev/experiments/38-multi-client-resize/scripts/repro-last-writer-wins.ts b/codev/experiments/38-multi-client-resize/scripts/repro-last-writer-wins.ts new file mode 100644 index 000000000..5bbdf9fbf --- /dev/null +++ b/codev/experiments/38-multi-client-resize/scripts/repro-last-writer-wins.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const outPath = path.join(here, '..', 'artifacts', 'repro-last-writer-wins.txt'); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const lines: string[] = []; +function log(line: string): void { + lines.push(line); +} + +async function main(): Promise { + const modulePath = + process.env.PTY_SESSION_MODULE || + path.resolve('/Users/chris/dev/codev-1455/packages/codev/src/terminal/pty-session.ts'); + log(`date: ${new Date().toISOString()}`); + log(`PTY_SESSION_MODULE: ${modulePath}`); + log(`method: PtySession.resize (last writer wins)`); + log(''); + + const { PtySession } = await import(pathToFileURL(modulePath).href); + + const logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'exp38-repro-')); + const session = new PtySession({ + id: 'exp38-repro', + command: '/bin/bash', + args: ['--norc', '--noprofile'], + cols: 80, + rows: 24, + cwd: os.tmpdir(), + env: { ...process.env, TERM: 'xterm-256color', PS1: '' } as Record, + label: 'exp38-repro', + logDir, + diskLogEnabled: false, + }); + + const output: string[] = []; + const clientA = { send: (data: Buffer | string) => output.push(`A:${String(data)}`) }; + const clientB = { send: (data: Buffer | string) => output.push(`B:${String(data)}`) }; + + let spawned = false; + try { + await session.spawn(); + spawned = true; + log('spawn: ok (/bin/bash)'); + } catch (error) { + log(`spawn: failed (${error instanceof Error ? error.message : String(error)})`); + log('continuing with session.info only'); + } + + session.attach(clientA); + session.attach(clientB); + log(`clients attached: ${session.clientCount}`); + log(`initial session.info: cols=${session.info.cols} rows=${session.info.rows}`); + + session.resize(80, 24); + log(`after client A resize(80, 24): cols=${session.info.cols} rows=${session.info.rows}`); + + session.resize(40, 12); + log(`after client B resize(40, 12): cols=${session.info.cols} rows=${session.info.rows}`); + + const infoWins = session.info.cols === 40 && session.info.rows === 12; + log(`session.info last-writer-wins: ${infoWins ? 'YES' : 'NO'}`); + + if (spawned) { + output.length = 0; + session.write('stty size\n'); + await sleep(400); + const stty = output.join('').replace(/\r/g, ''); + log(`stty size after B won (raw): ${JSON.stringify(stty)}`); + const match = stty.match(/(\d+)\s+(\d+)/); + if (match) { + log(`stty parsed: rows=${match[1]} cols=${match[2]}`); + } else { + log('stty: no parseable size in captured output'); + } + session.kill(); + } + + log(''); + log('conclusion:'); + log( + infoWins + ? 'Two sequential resize() calls from two attached clients leave session.info at the later writer.' + : 'Could not reproduce last-writer-wins on session.info.', + ); + + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, lines.join('\n') + '\n', 'utf8'); + process.stdout.write(lines.join('\n') + '\n'); + process.stdout.write(`wrote ${outPath}\n`); + if (!infoWins) process.exitCode = 1; +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/codev/experiments/38-multi-client-resize/src/run-tests.mjs b/codev/experiments/38-multi-client-resize/src/run-tests.mjs new file mode 100644 index 000000000..930924bc8 --- /dev/null +++ b/codev/experiments/38-multi-client-resize/src/run-tests.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import { V2ResizeBroker } from './v2-resize-broker.mjs'; + +const DESKTOP = { cols: 80, rows: 24 }; +const IPAD = { cols: 40, rows: 12 }; +const HUGE = { cols: 100, rows: 30 }; + +function broker(policy) { + const applied = []; + const b = new V2ResizeBroker(policy, (cols, rows) => { + applied.push({ cols, rows }); + return true; + }); + return { b, applied }; +} + +const tests = []; +function test(name, fn) { + tests.push({ name, fn }); +} + +test('follow-focused: two visible clients, only focused size applies', () => { + const { b, applied } = broker('follow-focused'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + const nudge = b.requestResize('ipad', IPAD); + assert.equal(nudge.applied, false); + assert.equal(nudge.reason, 'ignored-unfocused'); + assert.deepEqual(b.negotiated, DESKTOP); + assert.deepEqual(applied, [DESKTOP]); +}); + +test('follow-focused: focused disconnect promotes remaining visible viewer', () => { + const { b, applied } = broker('follow-focused'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + const result = b.detach('desktop'); + assert.equal(result.applied, true); + assert.equal(result.reason, 'applied-focused'); + assert.deepEqual(b.negotiated, IPAD); + assert.deepEqual(applied, [DESKTOP, IPAD]); + assert.equal(b.viewers.get('ipad')?.focused, true); +}); + +test('follow-focused: both hidden, resize ignored, last size holds', () => { + const { b, applied } = broker('follow-focused'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + const hideFocused = b.setVisible('desktop', false); + assert.equal(hideFocused.applied, true); + assert.deepEqual(b.negotiated, IPAD); + b.setVisible('ipad', false); + const result = b.requestResize('desktop', HUGE); + assert.equal(result.applied, false); + assert.equal(result.reason, 'ignored-hidden'); + assert.deepEqual(b.negotiated, IPAD); + assert.deepEqual(applied, [DESKTOP, IPAD]); +}); + +test('follow-focused: iOS reconnect nudge does not steal focused size', () => { + const { b, applied } = broker('follow-focused'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + b.detach('ipad'); + b.attach('ipad', IPAD); + const nudge = b.requestResize('ipad', IPAD); + assert.equal(nudge.applied, false); + assert.equal(nudge.reason, 'ignored-unfocused'); + assert.deepEqual(b.negotiated, DESKTOP); + assert.deepEqual(applied, [DESKTOP]); +}); + +test('follow-focused: sole reconnector applies its size', () => { + const { b, applied } = broker('follow-focused'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + b.detach('desktop'); + b.detach('ipad'); + const result = b.attach('ipad', HUGE); + assert.equal(result.applied, true); + assert.equal(result.reason, 'applied-sole'); + assert.deepEqual(b.negotiated, HUGE); + assert.deepEqual(applied, [DESKTOP, IPAD, HUGE]); +}); + +test('ignore-hidden: two visible clients still last-writer-wins (fails FR-38)', () => { + const { b, applied } = broker('ignore-hidden'); + b.attach('desktop', DESKTOP); + const fight = b.attach('ipad', IPAD); + assert.equal(fight.applied, true); + assert.equal(fight.reason, 'applied-visible'); + assert.deepEqual(b.negotiated, IPAD); + assert.deepEqual(applied, [DESKTOP, IPAD]); +}); + +test('ignore-hidden: hidden resize is ignored', () => { + const { b } = broker('ignore-hidden'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + b.setVisible('ipad', false); + const result = b.requestResize('ipad', HUGE); + assert.equal(result.applied, false); + assert.equal(result.reason, 'ignored-hidden'); + assert.deepEqual(b.negotiated, IPAD); +}); + +test('ignore-hidden: both hidden, last size holds', () => { + const { b, applied } = broker('ignore-hidden'); + b.attach('desktop', DESKTOP); + b.setVisible('desktop', false); + const result = b.requestResize('desktop', HUGE); + assert.equal(result.applied, false); + assert.deepEqual(b.negotiated, DESKTOP); + assert.deepEqual(applied, [DESKTOP]); +}); + +test('ignore-hidden: iOS reconnect nudge wins if visible (fails FR-38)', () => { + const { b, applied } = broker('ignore-hidden'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + b.detach('ipad'); + b.attach('ipad', { cols: 42, rows: 14 }); + assert.deepEqual(b.negotiated, { cols: 42, rows: 14 }); + assert.deepEqual(applied.at(-1), { cols: 42, rows: 14 }); +}); + +test('per-viewer-reflow: two visible different sizes is unsupported', () => { + const { b, applied } = broker('per-viewer-reflow'); + b.attach('desktop', DESKTOP); + const result = b.attach('ipad', IPAD); + assert.equal(result.applied, false); + assert.equal(result.reason, 'unsupported-divergent'); + assert.deepEqual(b.negotiated, DESKTOP); + assert.deepEqual(applied, [DESKTOP]); +}); + +test('per-viewer-reflow: matching sizes are allowed', () => { + const { b } = broker('per-viewer-reflow'); + b.attach('desktop', DESKTOP); + const result = b.attach('ipad', DESKTOP); + assert.equal(result.reason, 'held-unchanged'); + assert.deepEqual(b.negotiated, DESKTOP); +}); + +test('per-viewer-reflow: both hidden holds last size', () => { + const { b } = broker('per-viewer-reflow'); + b.attach('desktop', DESKTOP); + b.setVisible('desktop', false); + const result = b.requestResize('desktop', HUGE); + assert.equal(result.applied, false); + assert.equal(result.reason, 'held-all-hidden'); + assert.deepEqual(b.negotiated, DESKTOP); +}); + +test('per-viewer-reflow: iOS reconnect with other viewer stays unsupported', () => { + const { b } = broker('per-viewer-reflow'); + b.attach('desktop', DESKTOP); + b.attach('ipad', IPAD); + b.detach('ipad'); + const result = b.attach('ipad', IPAD); + assert.equal(result.reason, 'unsupported-divergent'); + assert.deepEqual(b.negotiated, DESKTOP); +}); + +let failed = 0; +for (const { name, fn } of tests) { + try { + fn(); + process.stdout.write(`ok ${name}\n`); + } catch (error) { + failed += 1; + process.stdout.write(`FAIL ${name}\n`); + process.stdout.write(` ${error instanceof Error ? error.message : String(error)}\n`); + } +} +process.stdout.write(`\n${tests.length - failed} passed, ${failed} failed, ${tests.length} total\n`); +if (failed > 0) process.exitCode = 1; diff --git a/codev/experiments/38-multi-client-resize/src/v2-resize-broker.mjs b/codev/experiments/38-multi-client-resize/src/v2-resize-broker.mjs new file mode 100644 index 000000000..c75debbd1 --- /dev/null +++ b/codev/experiments/38-multi-client-resize/src/v2-resize-broker.mjs @@ -0,0 +1,173 @@ +function sameSize(a, b) { + return a !== null && a.cols === b.cols && a.rows === b.rows; +} + +function sizesDiverge(a, b) { + return a.cols !== b.cols || a.rows !== b.rows; +} + +export class V2ResizeBroker { + constructor(policy, applySize) { + this.policy = policy; + this.applySize = applySize; + this.viewers = new Map(); + this.negotiated = null; + this.applyCalls = []; + this.recency = []; + } + + attach(id, size) { + this.viewers.set(id, { id, size, focused: false, visible: true }); + this.touch(id); + if (this.visibleViewers().length === 1) { + this.setFocusedId(id); + return this.commit(size, 'applied-sole'); + } + return this.evaluate(id); + } + + detach(id) { + const wasFocused = this.viewers.get(id)?.focused === true; + this.viewers.delete(id); + this.recency = this.recency.filter((x) => x !== id); + if (this.viewers.size === 0) { + return { applied: false, reason: 'held-all-hidden', size: this.negotiated }; + } + if (this.policy === 'follow-focused' && wasFocused) { + const next = this.mostRecentVisible(); + if (next) { + this.setFocusedId(next.id); + return this.commit(next.size, 'applied-focused'); + } + return { applied: false, reason: 'held-all-hidden', size: this.negotiated }; + } + return this.evaluateRemaining(); + } + + setFocus(id) { + const viewer = this.viewers.get(id); + if (!viewer) { + return { applied: false, reason: 'ignored-unknown', size: this.negotiated }; + } + this.setFocusedId(id); + this.touch(id); + if (this.policy === 'follow-focused') { + if (!viewer.visible) { + return { applied: false, reason: 'ignored-hidden', size: this.negotiated }; + } + return this.commit(viewer.size, 'applied-focused'); + } + return this.evaluate(id); + } + + setVisible(id, visible) { + const viewer = this.viewers.get(id); + if (!viewer) { + return { applied: false, reason: 'ignored-unknown', size: this.negotiated }; + } + viewer.visible = visible; + if (!visible && viewer.focused) { + viewer.focused = false; + if (this.policy === 'follow-focused') { + const next = this.mostRecentVisible(); + if (next) { + this.setFocusedId(next.id); + return this.commit(next.size, 'applied-focused'); + } + return { applied: false, reason: 'held-all-hidden', size: this.negotiated }; + } + } + return this.evaluate(id); + } + + requestResize(id, size) { + const viewer = this.viewers.get(id); + if (!viewer) { + return { applied: false, reason: 'ignored-unknown', size: this.negotiated }; + } + viewer.size = size; + return this.evaluate(id); + } + + evaluate(id) { + const viewer = this.viewers.get(id); + if (!viewer) { + return { applied: false, reason: 'ignored-unknown', size: this.negotiated }; + } + if (this.policy === 'per-viewer-reflow') { + return this.evaluateReflow(viewer); + } + if (!viewer.visible) { + return { applied: false, reason: 'ignored-hidden', size: this.negotiated }; + } + if (this.policy === 'follow-focused') { + if (!viewer.focused) { + return { applied: false, reason: 'ignored-unfocused', size: this.negotiated }; + } + return this.commit(viewer.size, 'applied-focused'); + } + return this.commit(viewer.size, 'applied-visible'); + } + + evaluateRemaining() { + if (this.policy === 'per-viewer-reflow') { + const visible = this.visibleViewers(); + if (visible.length === 0) { + return { applied: false, reason: 'held-all-hidden', size: this.negotiated }; + } + const first = visible[0]; + if (visible.some((v) => sizesDiverge(v.size, first.size))) { + return { applied: false, reason: 'unsupported-divergent', size: this.negotiated }; + } + return this.commit(first.size, 'applied-visible'); + } + return { applied: false, reason: 'held-unchanged', size: this.negotiated }; + } + + evaluateReflow(viewer) { + const visible = this.visibleViewers(); + if (visible.length === 0) { + return { applied: false, reason: 'held-all-hidden', size: this.negotiated }; + } + const first = visible[0]; + if (visible.some((v) => sizesDiverge(v.size, first.size))) { + return { applied: false, reason: 'unsupported-divergent', size: this.negotiated }; + } + if (!viewer.visible) { + return { applied: false, reason: 'ignored-hidden', size: this.negotiated }; + } + return this.commit(viewer.size, 'applied-visible'); + } + + commit(size, reason) { + if (sameSize(this.negotiated, size)) { + return { applied: false, reason: 'held-unchanged', size }; + } + this.applySize(size.cols, size.rows); + this.applyCalls.push({ ...size }); + this.negotiated = { ...size }; + return { applied: true, reason, size: this.negotiated }; + } + + visibleViewers() { + return [...this.viewers.values()].filter((v) => v.visible); + } + + mostRecentVisible() { + for (const id of this.recency) { + const viewer = this.viewers.get(id); + if (viewer?.visible) return viewer; + } + return undefined; + } + + setFocusedId(id) { + for (const viewer of this.viewers.values()) { + viewer.focused = viewer.id === id; + } + } + + touch(id) { + this.recency = [id, ...this.recency.filter((x) => x !== id)]; + } +} diff --git a/codev/projects/38-spike-v2-ui-multi-client-termi/status.yaml b/codev/projects/38-spike-v2-ui-multi-client-termi/status.yaml new file mode 100644 index 000000000..db4254235 --- /dev/null +++ b/codev/projects/38-spike-v2-ui-multi-client-termi/status.yaml @@ -0,0 +1,21 @@ +id: '38' +title: spike-v2-ui-multi-client-termi +protocol: experiment +phase: verified +plan_phases: [] +current_plan_phase: null +gates: + experiment-complete: + status: approved + requested_at: '2026-08-22T19:35:48.445Z' + approved_at: '2026-08-22T19:45:18.658Z' +iteration: 1 +build_complete: false +history: [] +started_at: '2026-08-22T19:27:21.594Z' +updated_at: '2026-08-22T19:46:31.699Z' +pr_history: + - phase: verified + pr_number: 45 + branch: builder/experiment-38 + created_at: '2026-08-22T19:46:31.698Z' diff --git a/codev/state/experiment-38_thread.md b/codev/state/experiment-38_thread.md new file mode 100644 index 000000000..b7a1aa36a --- /dev/null +++ b/codev/state/experiment-38_thread.md @@ -0,0 +1,35 @@ +# experiment-38 thread + +Spawned soft EXPERIMENT for issue #38 (v2 UI multi-client terminal resize). + +The builder prompt named `codev/specs/0038-consult-pr-mode.md`. That spec is a 2025 TICK that already shipped. Porch title and GitHub #38 are the v2 resize spike. Following the issue. + +## Hypothesis + +Locked in `codev/experiments/38-multi-client-resize/notes.md` before any prototype. + +Claim: FR-38 is additive via follow-the-focused-client. Ignore-hidden is not enough (both-visible is the product). Per-viewer reflow is not additive. + +## Design + +Broker in the experiment tree, callback into `session.resize`. Three policies as named functions. Policy 2 expected to fail two-visible. Policy 3 returns `unsupported` when viewers disagree. + +Seam is `tower-websocket.ts:76-80`, not `pty-session.ts:567`. The bug is last-writer-wins because every client frame calls `resize` directly. + +## Execute + +Last-writer-wins reproduced. `session.info` 80x24 then 40x12. Live `stty size` printed `12 40`. Artifact: `codev/experiments/38-multi-client-resize/artifacts/repro-last-writer-wins.txt`. + +Broker: 13/13. follow-focused passes the four locked cases. ignore-hidden fails two-visible and iOS reconnect. per-viewer-reflow returns `unsupported-divergent`. + +`pty-session.ts` untouched (`git diff --stat` empty; worktree byte-identical to main). + +## Analyze + +Chosen policy: follow the focused client. FR-38 is additive for v2-only attach. Mixed v1+v2 on the same session still fights because `tower-websocket.ts:76-80` calls `resize` directly. + +Notes complete at `codev/experiments/38-multi-client-resize/notes.md`. + +Human approved experiment-complete. `porch approve` then `porch done`. Protocol complete. + +#40 already landed on origin/main via PR #42, so the PR base is main. Diff vs main is this experiment only.