hop2 0.10.0 → 0.11.1

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 (87) hide show
  1. package/.github/workflows/ci.yml +25 -0
  2. package/.github/workflows/publish.yml +126 -0
  3. package/DESIGN-input-acks.md +349 -0
  4. package/MULTI-AGENT-CHECKLIST.md +5 -5
  5. package/README-MCP.md +149 -36
  6. package/README.md +76 -10
  7. package/demo/README.md +52 -0
  8. package/demo/capture/capture-env.mjs +108 -0
  9. package/demo/capture/capture.mjs +634 -0
  10. package/demo/capture/dispatch-task.mjs +44 -0
  11. package/demo/capture/mcp-client.mjs +61 -0
  12. package/demo/capture/native-css.mjs +49 -0
  13. package/demo/capture/setup-04.mjs +67 -0
  14. package/demo/capture/setup-sessions.mjs +40 -0
  15. package/demo/capture/spawn-aurora.mjs +53 -0
  16. package/demo/capture/tools/agent.sh +30 -0
  17. package/demo/capture/tools/build.sh +18 -0
  18. package/demo/capture/tools/metrics.sh +17 -0
  19. package/docs/hero-overview.svg +119 -0
  20. package/docs/hero.svg +118 -0
  21. package/docs/restore-reliability.md +139 -0
  22. package/docs/session-identity.md +76 -0
  23. package/docs/switcher-first-ux.md +103 -0
  24. package/hay/CHANGELOG.md +88 -0
  25. package/hay/README.md +53 -8
  26. package/hay/apps/cli/package.json +4 -3
  27. package/hay/apps/cli/src/index.ts +1787 -148
  28. package/hay/apps/server/package.json +1 -1
  29. package/hay/apps/server/src/pty.ts +36 -2
  30. package/hay/apps/server/src/rooms.ts +577 -39
  31. package/hay/apps/server/test/rooms.test.ts +465 -1
  32. package/hay/apps/web/index.html +2 -0
  33. package/hay/apps/web/package.json +15 -13
  34. package/hay/apps/web/scripts/run-e2e.mjs +42 -5
  35. package/hay/apps/web/src/App.tsx +2782 -263
  36. package/hay/apps/web/src/components/MobileKeyboard.tsx +167 -25
  37. package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
  38. package/hay/apps/web/src/components/SessionSwitcher.tsx +2288 -0
  39. package/hay/apps/web/src/setupTests.ts +23 -0
  40. package/hay/apps/web/src/styles.css +2509 -220
  41. package/hay/apps/web/src/utils/fileDrop.ts +15 -0
  42. package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
  43. package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
  44. package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
  45. package/hay/apps/web/src/utils/switcherModel.ts +277 -0
  46. package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
  47. package/hay/apps/web/src/vite-env.d.ts +3 -0
  48. package/hay/apps/web/test/SessionSwitcher.test.tsx +273 -0
  49. package/hay/apps/web/test/fileDrop.test.ts +22 -0
  50. package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
  51. package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
  52. package/hay/apps/web/test/switcherModel.test.ts +312 -0
  53. package/hay/apps/web/test/terminalSearch.test.ts +37 -0
  54. package/hay/apps/web/vite.config.ts +20 -0
  55. package/hay/package-lock.json +1695 -1171
  56. package/hay/packages/shared/package.json +1 -1
  57. package/hay/packages/shared/src/protocol.ts +33 -4
  58. package/hay/packages/shared/test/protocol.test.ts +32 -0
  59. package/hay-web/assets/index--cf1ILsi.css +1 -0
  60. package/hay-web/assets/index-_wMnETjm.js +130 -0
  61. package/hay-web/assets/index-_wMnETjm.js.map +1 -0
  62. package/hay-web/index.html +4 -2
  63. package/hop +5254 -508
  64. package/hopa +465 -0
  65. package/lib/naming-themes.js +28 -14
  66. package/mcp/hop-mcp.js +3331 -285
  67. package/package.json +7 -4
  68. package/scripts/claude-session-hook.js +152 -0
  69. package/scripts/hay-host.js +232 -4
  70. package/scripts/hop-math.js +316 -0
  71. package/scripts/salvage-and-restart-host.js +217 -0
  72. package/setup-mcp.js +37 -19
  73. package/skills/hop-manager/SKILL.md +62 -0
  74. package/skills/hop-session/SKILL.md +42 -0
  75. package/skills/hopa/SKILL.md +75 -0
  76. package/tests/agent-skill.unit.test.cjs +60 -0
  77. package/tests/composer-state.unit.test.cjs +102 -0
  78. package/tests/hop-mcp.integration.test.cjs +476 -94
  79. package/tests/hopa-parity.unit.test.cjs +188 -0
  80. package/tests/readable-echo.unit.test.cjs +94 -0
  81. package/tests/readable-flush.unit.test.cjs +93 -0
  82. package/tests/submit-verification.unit.test.cjs +331 -0
  83. package/tests/terminal-api.integration.test.cjs +301 -13
  84. package/hay-web/assets/index-BmWjq_Nh.css +0 -32
  85. package/hay-web/assets/index-D9aOC5Zt.js +0 -63
  86. package/hay-web/assets/index-D9aOC5Zt.js.map +0 -1
  87. package/sessions.html +0 -595
@@ -0,0 +1,25 @@
1
+ # CI: syntax + unit suites. Integration tests (real daemon + PTYs + fake
2
+ # tunnel) stay local-only for now — they're timing-sensitive on shared
3
+ # runners; promote them once they've proven stable.
4
+ name: CI
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ pull_request:
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: 22
18
+ - name: Install root deps
19
+ run: npm ci
20
+ - name: Install hay workspace deps
21
+ run: npm --prefix hay ci
22
+ - name: Syntax + repo unit tests
23
+ run: npm test
24
+ - name: hay unit tests (shared, server, web)
25
+ run: npm --prefix hay run test:unit
@@ -0,0 +1,126 @@
1
+ # Publish to npm — no tokens, no 2FA prompt. TWO CHANNELS:
2
+ #
3
+ # npm i -g hop2 → last NIGHTLY green main (`latest`)
4
+ # npm i -g hop2@stable → last formal release (fallback)
5
+ #
6
+ # WHY NIGHTLY IS THE DEFAULT. Almost every commit here is a fix, so the newest
7
+ # green build is usually the best one to be running — holding people back on a
8
+ # month-old tarball serves them older bugs. `latest` therefore tracks the
9
+ # nightly, and `@stable` is the fallback for anyone who wants to hold still
10
+ # on a version a human deliberately cut and used.
11
+ #
12
+ # Publishing per-commit was the wrong end of this: a busy day would mint
13
+ # dozens of permanent versions (npm forbids unpublishing after 72h) for
14
+ # changes nobody had run yet. Once a night, from whatever main is green,
15
+ # matches how the fixes are actually used.
16
+ #
17
+ # Publishing a release is one command with zero authentication:
18
+ # gh release create v0.12.0 --generate-notes
19
+ #
20
+ # Auth is npm Trusted Publishing (OIDC): npm verifies this workflow's identity
21
+ # against the trusted publisher configured on the package page, so nothing is
22
+ # stored as a repository secret and nobody authenticates per publish.
23
+ #
24
+ # ONE-TIME SETUP (browser, once ever):
25
+ # npmjs.com → package `hop2` → Settings → Trusted Publisher → GitHub Actions
26
+ # Organization/user: jzthree Repository: hop Workflow: publish.yml
27
+ #
28
+ # ROLLBACK, if a bad release escapes: cut a new release from the good commit.
29
+ # (Repointing with `npm dist-tag add` also works but needs a real token —
30
+ # trusted publishing only authorizes `npm publish`.) npm forbids unpublishing
31
+ # after 72h regardless, so rolling forward is the reliable path.
32
+ name: Publish
33
+
34
+ on:
35
+ schedule: # → @next, nightly at 03:00 America/Chicago (08:00 UTC)
36
+ - cron: "0 8 * * *"
37
+ release: # → latest, the human "I have used this" gate
38
+ types: [published]
39
+ workflow_dispatch:
40
+ inputs:
41
+ version:
42
+ description: "Exact version to publish as latest (blank = nightly @next)"
43
+ required: false
44
+
45
+ permissions:
46
+ contents: write # commit the version bump back to main
47
+ id-token: write # OIDC token for trusted publishing + provenance
48
+
49
+ concurrency: # a burst of pushes publishes once, from the newest commit
50
+ group: publish-npm
51
+ cancel-in-progress: false
52
+
53
+ jobs:
54
+ publish:
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+ with:
59
+ ref: main
60
+ fetch-depth: 0
61
+
62
+ - uses: actions/setup-node@v4
63
+ with:
64
+ node-version: 22
65
+ registry-url: https://registry.npmjs.org
66
+
67
+ # Trusted publishing needs npm >= 11.5.1; the runner's bundled npm is older.
68
+ - run: npm install -g npm@latest
69
+
70
+ # Nothing new since the last nightly? Then there is nothing to publish —
71
+ # quiet days should not mint versions. npm records the commit each
72
+ # tarball was built from, which makes this exact rather than date math.
73
+ - name: Skip if main has not moved
74
+ if: github.event_name == 'schedule'
75
+ run: |
76
+ PUBLISHED=$(npm view hop2@latest gitHead 2>/dev/null || echo "")
77
+ if [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$GITHUB_SHA" ]; then
78
+ echo "main is unchanged since the last nightly ($PUBLISHED) — nothing to publish."
79
+ echo "SKIP=1" >> "$GITHUB_ENV"
80
+ fi
81
+
82
+ - name: Install dependencies
83
+ if: env.SKIP != '1'
84
+ run: npm ci || npm install
85
+
86
+ # Gate: the nightly ships only if main is green. This is the whole
87
+ # safety story for @next, so it runs BEFORE the version is chosen.
88
+ - name: Test
89
+ if: env.SKIP != '1'
90
+ run: npm test
91
+
92
+ - name: Choose the version and channel
93
+ if: env.SKIP != '1'
94
+ run: |
95
+ EXPLICIT="${{ github.event.release.tag_name || inputs.version }}"
96
+ if [ -n "$EXPLICIT" ]; then
97
+ # A formal release names its version and lands on @stable — the
98
+ # deliberate, "I have used this" fallback channel.
99
+ npm version "${EXPLICIT#v}" --no-git-tag-version --allow-same-version
100
+ echo "NPM_TAG=stable" >> "$GITHUB_ENV"
101
+ echo "COMMIT_BUMP=1" >> "$GITHUB_ENV"
102
+ else
103
+ # Nightly → a normal patch version on `latest`, so a plain
104
+ # install gets the newest green build. Not a prerelease: those are
105
+ # skipped by default resolution, which is the opposite of what we
106
+ # want now that nightly IS the default.
107
+ BASE=$(node -p "require('./package.json').version.split('-')[0]")
108
+ npm version patch --no-git-tag-version
109
+ echo "NPM_TAG=latest" >> "$GITHUB_ENV"
110
+ echo "COMMIT_BUMP=1" >> "$GITHUB_ENV"
111
+ fi
112
+ echo "VERSION=$(node -p "require('./package.json').version")" >> "$GITHUB_ENV"
113
+
114
+ - name: Publish
115
+ if: env.SKIP != '1'
116
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
117
+
118
+ - name: Commit the release bump
119
+ if: env.SKIP != '1' && env.COMMIT_BUMP == '1'
120
+ run: |
121
+ git config user.name "github-actions[bot]"
122
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
123
+ git add package.json
124
+ git commit -m "chore(release): $VERSION [skip ci]"
125
+ git pull --rebase --autostash origin main
126
+ git push origin HEAD:main
@@ -0,0 +1,349 @@
1
+ # DESIGN: Sequence-acked input
2
+
3
+ **Status: proposal only — nothing in this document is implemented.**
4
+
5
+ Close the half-dead-socket keystroke-loss window by adding per-message sequence
6
+ numbers to `input` messages, cumulative server acknowledgements, a client-side
7
+ retransmit buffer, and dedup-on-resume across reconnects.
8
+
9
+ ---
10
+
11
+ ## 1. Problem statement
12
+
13
+ ### 1.1 The current input path
14
+
15
+ A keystroke travels client → PTY like this:
16
+
17
+ **Web client** (`hay/apps/web/src/App.tsx`)
18
+
19
+ 1. xterm.js `onData` → `handleUserInput(data)` (App.tsx, ~line 684) strips focus
20
+ sequences, then:
21
+ - if `wsRef.current?.readyState !== WebSocket.OPEN`, the keystroke is pushed
22
+ into `pendingInputRef` (a per-room buffer capped by
23
+ `PENDING_INPUT_MAX_ENTRIES = 200` entries and
24
+ `PENDING_INPUT_MAX_AGE_MS = 15000` ms, App.tsx lines 181–182) and a
25
+ "Reconnecting — input buffered" toast is shown;
26
+ - otherwise it is locally echoed via `optimisticEchoRef.current.onInput`
27
+ (`hay/apps/web/src/utils/optimisticEcho.ts`) and sent with
28
+ `sendMessage({ type: "input", data })`.
29
+ 2. `sendMessage` (App.tsx, ~line 674) is fire-and-forget:
30
+ `wsRef.current.send(JSON.stringify(message))` — no sequence number, no
31
+ delivery confirmation, no `bufferedAmount` check.
32
+ 3. On reconnect (`connect`'s `open` handler, App.tsx ~lines 826–849) the pending
33
+ buffer is filtered to the same room and the 15 s age window, concatenated,
34
+ and replayed as a single `input` message.
35
+
36
+ **CLI client** (`hay/apps/cli/src/index.ts`)
37
+
38
+ 1. `process.stdin.on("data", …)` (line 2974) tokenizes input, handles local
39
+ shortcuts, then calls `sendMessage({ type: "input", data: sanitized })`
40
+ (line 3085). Terminal-generated replies (DSR/CPR etc.) go through
41
+ `terminal.onData` (line 536) the same way.
42
+ 2. `sendMessage` (line 1201) sends only `if (ws?.readyState === WebSocket.OPEN)`
43
+ — when the socket is not OPEN the keystroke is **silently dropped**. The CLI
44
+ has no equivalent of the web client's pending-input buffer.
45
+
46
+ **Server** (`hay/apps/server/`)
47
+
48
+ 1. The WebSocket is wrapped in a `SocketAdapter`
49
+ (`hay/apps/server/src/rooms.ts`, line 9) by `createSocketAdapter` /
50
+ `attachTermshare` in `hay/apps/server/src/lib.ts` (the standalone
51
+ `hay/apps/server/src/index.ts` and the production host `scripts/hay-host.js`
52
+ build the identical adapter).
53
+ 2. `Room.attachClient` (rooms.ts, line 243) registers `socket.onMessage` →
54
+ `safeParseClientMessage` (`hay/packages/shared/src/protocol.ts`) →
55
+ `Room.handleMessage` (rooms.ts, line 311).
56
+ 3. `case "input"` → `Room.handleInput` (rooms.ts, line 637) → `this.pty.write(data)`.
57
+ Nothing is sent back to the client on success; the only input-related reply
58
+ in the protocol is the negative `input_rejected` (control locked).
59
+
60
+ The wire protocol (`clientMessageSchema` / `serverMessageSchema` in
61
+ `hay/packages/shared/src/protocol.ts`) has an app-level `ping`/`pong` pair
62
+ (`{type:"ping", t}` handled in rooms.ts line 346), but **no client currently
63
+ sends it** — grep finds no senders in `hay/apps/web/src/App.tsx` or
64
+ `hay/apps/cli/src/index.ts`. There is also no WebSocket-protocol-level
65
+ heartbeat: neither `lib.ts` nor `scripts/hay-host.js` calls `ws.ping()` or runs
66
+ an `isAlive` sweep, and no client checks `bufferedAmount`.
67
+
68
+ ### 1.2 Where keystrokes are lost: the half-dead-socket window
69
+
70
+ Everything above is gated on `readyState === OPEN`. But `readyState` reflects
71
+ the *local* socket state, not the health of the path. When a phone's wifi
72
+ drops, a NAT mapping expires, a laptop sleeps, or the network path silently
73
+ breaks, the TCP connection stays ESTABLISHED on the client for as long as TCP
74
+ retransmission takes to give up — tens of seconds to many minutes. During that
75
+ whole window:
76
+
77
+ - `ws.send()` succeeds (bytes queue in the kernel/browser send buffer),
78
+ - the web client's optimistic echo paints the keystroke as if delivered,
79
+ - the disconnect buffering in `handleUserInput` never engages because
80
+ `readyState` is still `OPEN`,
81
+ - the server never receives the bytes, and
82
+ - nothing detects the gap until the OS finally errors the socket and the
83
+ `close` handler fires.
84
+
85
+ When the socket does die, everything sitting in the send buffer is discarded.
86
+ The user believes the input was delivered (they saw it echoed, or simply saw no
87
+ error); the PTY never got it. The existing `pendingInputRef` replay only
88
+ protects keystrokes typed *after* the browser noticed the death — precisely the
89
+ easy half of the problem. The CLI is worse: it has no buffer at all, so any
90
+ keystroke during any non-OPEN state is gone.
91
+
92
+ Secondary loss points closed by the same mechanism:
93
+
94
+ - **Close race**: input sent moments before the `close` event fires may or may
95
+ not have arrived; today the client can't tell, so it neither retransmits nor
96
+ warns.
97
+ - **Blind replay**: the web reconnect replay (App.tsx lines 834–849) re-sends
98
+ buffered input with no way for the server to detect duplicates if some of it
99
+ did arrive before the drop.
100
+ - **Server-side accepted-but-dead**: the server's `SocketAdapter.send` also
101
+ silently no-ops when its side isn't OPEN (lib.ts lines 29–33) — harmless for
102
+ output (the snapshot on reattach recovers it) but it means the server can't
103
+ signal anything about input it did or didn't get.
104
+
105
+ ## 2. Proposed protocol: sequence numbers + cumulative acks
106
+
107
+ ### 2.1 Overview
108
+
109
+ - Each client instance numbers its `input` messages with a monotonically
110
+ increasing `seq` that survives reconnects.
111
+ - The server acknowledges input **cumulatively**: `{type:"input_ack", seq: N}`
112
+ means "I have written every input with seq ≤ N from you to the PTY".
113
+ - The client keeps every sent-but-unacked input in a retransmit buffer. Acks
114
+ prune it; reconnect replays it; a stall in acks is the *detection signal* for
115
+ a half-dead socket — far earlier and more precisely than TCP.
116
+ - The server remembers the highest seq it has seen per client *instance* and
117
+ drops anything ≤ that on replay, making retransmission idempotent.
118
+
119
+ ### 2.2 Sequencing
120
+
121
+ - `seq` starts at 1 per client-instance per room and increments per `input`
122
+ message. It is **not** reset on reconnect — the whole point is that the seq
123
+ space spans socket lifetimes.
124
+ - The client identifies its instance with a random `instance` id (UUID
125
+ generated once per attached client process/tab per room), sent as a query
126
+ parameter on the `/ws` URL alongside the existing `room`/`name`/`cols`/`rows`
127
+ params parsed in `attachTermshare` (lib.ts, line 118 onward) and
128
+ `scripts/hay-host.js` (line 192 onward). This is needed because today's
129
+ server-side client id is a fresh `randomUUID()` per *connection* (lib.ts,
130
+ line 125), so it cannot key resume state.
131
+
132
+ ### 2.3 Ack semantics
133
+
134
+ - **What acks**: the `Room`, after `this.pty.write(data)` returns in
135
+ `handleInput` (rooms.ts, line 637). The ack means "accepted into the PTY
136
+ stream", the strongest claim this server can honestly make (the PTY consumes
137
+ writes; the app behind it may still do anything with them).
138
+ - **Cumulative, not per-message**: the server sends the highest contiguous seq
139
+ written. Cumulative acks are self-healing — a lost ack is repaired by the
140
+ next one — and keep the client's pruning logic trivial.
141
+ - **Coalesced**: acking every keystroke doubles message rate for no benefit.
142
+ The server acks at most once per small flush window (~50 ms debounce, plus an
143
+ immediate ack when the socket has been idle), always carrying the latest
144
+ cumulative seq. Worst case this adds ~50 ms to loss *detection*, not to input
145
+ latency.
146
+ - `input_rejected` (control locked, rooms.ts line 638–643) also carries the
147
+ message's `seq` so the client can prune it from the retransmit buffer —
148
+ rejected input is *resolved*, not lost, and must not be replayed later.
149
+
150
+ ### 2.4 Client retransmit buffer
151
+
152
+ Replaces (web) / introduces (CLI) the disconnect-only buffer:
153
+
154
+ - Every sent `input` is appended to `unacked: Array<{seq, data, at}>` **at send
155
+ time**, not at disconnect time. `input_ack` with seq N drops all entries ≤ N.
156
+ - On `readyState !== OPEN`, keystrokes are still appended (with fresh seqs) —
157
+ this subsumes today's `pendingInputRef` behavior in `handleUserInput`.
158
+ - Bounds: cap at 256 entries / 32 KiB. On overflow drop the **oldest** entries
159
+ and record that a gap exists so the user can be told input was lost (§2.6) —
160
+ never silently.
161
+ - Age policy: entries older than a replay-age cap (keep the current 15 s
162
+ `PENDING_INPUT_MAX_AGE_MS` value) are *not retransmitted* on resume — the
163
+ existing judgment in App.tsx (lines 423–426: firing stale keystrokes into a
164
+ shell is worse than losing them) still holds. But unlike today, dropping them
165
+ is reported, not silent.
166
+
167
+ ### 2.5 Reconnect / resume
168
+
169
+ On the new socket's `open` (web: `connect`'s open handler, App.tsx line 826;
170
+ CLI: `ws.on("open")`, index.ts line 2456):
171
+
172
+ 1. The URL carried the same `instance` id, so the server can look up
173
+ `lastSeqSeen` for this instance in the room (kept in `Room`, expiring after
174
+ ~10 minutes without any connection from that instance).
175
+ 2. The server includes `lastInputSeq: lastSeqSeen` in its `hello` message
176
+ (schema in protocol.ts, line 24; already extended with optional fields
177
+ like `created`, so this is additive).
178
+ 3. The client prunes its unacked buffer to entries `> lastInputSeq` (those
179
+ arrived before the drop — the "did my last keystroke make it?" race is
180
+ resolved exactly), drops over-age entries (reporting the count), and
181
+ retransmits the rest **in order, with their original seqs, before accepting
182
+ new keystrokes into the send path** (new input queues behind the replay).
183
+ 4. The server applies its normal dedup rule — ignore seq ≤ lastSeqSeen, write
184
+ the rest — so a replay racing a late-arriving original is harmless.
185
+
186
+ ### 2.6 Surfacing "input not delivered" to the user
187
+
188
+ The ack stream gives a precise health signal; use it in three escalating steps:
189
+
190
+ 1. **Stall detection**: if the oldest unacked entry is older than ~2 s, send an
191
+ app-level `{type:"ping", t}` (already in the protocol, already handled by
192
+ the server at rooms.ts line 346 — currently unused). If neither an ack nor
193
+ the pong arrives within ~2 more seconds, declare the socket suspect.
194
+ 2. **Proactive reconnect**: on a suspect socket, the client closes it and
195
+ reconnects immediately (`scheduleReconnect`, App.tsx line 785, entering at
196
+ attempt 0). The retransmit buffer carries the in-flight keystrokes across;
197
+ resume (§2.5) delivers exactly the ones that were lost. This converts
198
+ "minutes of silent loss" into "~4 s pause, then delivery", with UI:
199
+ web shows the existing toast/notice machinery ("Connection stalled —
200
+ reconnecting, input preserved"); the CLI shows it in the status bar it
201
+ already renders for reconnects (index.ts line 2354).
202
+ 3. **Loss admission**: only when input actually cannot be delivered — buffer
203
+ overflow (§2.4) or over-age drop (§2.5) — the client says so concretely:
204
+ "N keystrokes from HH:MM:SS were not delivered", via `showToast`/`pushNotice`
205
+ on web and the notice line on the CLI. Never claim more certainty than the
206
+ acks provide.
207
+
208
+ ## 3. Wire format
209
+
210
+ All changes are additive to `hay/packages/shared/src/protocol.ts`.
211
+
212
+ ```ts
213
+ // clientMessageSchema — "input" gains an optional seq
214
+ z.object({
215
+ type: z.literal("input"),
216
+ data: z.string().min(1),
217
+ seq: z.number().int().positive().optional(), // absent = legacy client
218
+ })
219
+
220
+ // serverMessageSchema — new message + two extended ones
221
+ z.object({ type: z.literal("input_ack"), seq: z.number().int().positive() })
222
+
223
+ // "hello" gains (all optional):
224
+ // inputAcks: z.boolean().optional() — server speaks this protocol
225
+ // lastInputSeq: z.number().int().optional() — resume point for this instance
226
+
227
+ // "input_rejected" gains:
228
+ // seq: z.number().int().optional() — which input was rejected
229
+ ```
230
+
231
+ Connection URL gains `&instance=<uuid>` (parsed next to `room`/`name` in
232
+ `attachTermshare`, lib.ts, and in `scripts/hay-host.js`).
233
+
234
+ ### Backward compatibility / negotiation
235
+
236
+ - **Old client, new server**: `input` arrives without `seq`; the server writes
237
+ it exactly as today and sends no acks to that client. `hello.inputAcks` is
238
+ ignored by old clients because the schemas are zod discriminated unions with
239
+ optional extra fields — the codebase already uses this pattern for
240
+ `hello.created` and `session_ended.by` (protocol.ts lines 32–34, 59–61).
241
+ - **New client, old server**: the client sends `seq` on `input`. Old servers
242
+ parse with `safeParseClientMessage` and **unknown keys are tolerated** (zod
243
+ objects strip unknown keys by default), so input still flows. The client
244
+ learns the server is old from `hello` lacking `inputAcks: true` and degrades
245
+ to exactly today's behavior (fire-and-forget + disconnect-only buffering),
246
+ disabling stall detection so it never false-alarms.
247
+ - No version handshake beyond `hello.inputAcks` is needed.
248
+
249
+ ## 4. Edge cases
250
+
251
+ - **Reconnect race (old socket half-dead, new socket open)**: the old
252
+ connection's client entry still sits in `Room.clients` until its close fires.
253
+ Resume state is keyed by `instance`, not by connection, so the new socket's
254
+ dedup is correct regardless. If the zombie connection later flushes a
255
+ duplicate `input` (TCP delivered it after all), its seq ≤ lastSeqSeen and the
256
+ server drops it. Optionally, `attachClient` (rooms.ts line 243) can eagerly
257
+ close a prior connection with the same `instance` id.
258
+ - **Duplicate delivery**: only possible path is "server wrote it, ack lost,
259
+ client retransmits" — closed by the per-instance `lastSeqSeen` check before
260
+ `pty.write`.
261
+ - **Ordering**: WebSocket delivery is ordered within a connection; seqs are
262
+ assigned in send order; resume replays in seq order before new input. A gap
263
+ in seqs at the server (seq jumps by >1) can only mean the client dropped
264
+ overflowed/aged entries on purpose; the server accepts the jump and moves
265
+ `lastSeqSeen` forward — it must not wait for a retransmission that will
266
+ never come.
267
+ - **Buffer bounds**: client buffer capped (§2.4) with explicit loss reporting.
268
+ Server per-instance state is one integer + timestamp per instance, expired
269
+ after ~10 minutes disconnected; a room with many transient clients stays
270
+ O(clients).
271
+ - **Multi-client on one session**: each client instance has an independent seq
272
+ space and ack stream; `Room.handleInput` interleaves writes by arrival, same
273
+ as today. Acks go only to the socket that sent the input (`client.socket`,
274
+ as `input_rejected` does today), never broadcast.
275
+ - **System input**: `Room.sendSystemInput` (rooms.ts line 624), used by the
276
+ daemon (`hop`, line 1345) and MCP paths, bypasses client sockets entirely and
277
+ is unaffected.
278
+ - **Web replay concatenation**: today's replay joins buffered entries into one
279
+ `input` message (App.tsx line 843). With seqs, replay must preserve one
280
+ message per seq (or renumber after concatenation); the design keeps
281
+ per-entry messages for exact dedup.
282
+
283
+ ## 5. Alternatives considered
284
+
285
+ - **WebSocket ping/pong tightening (protocol-level heartbeat)**: server
286
+ `ws.ping()` every ~10 s + `isAlive` sweep, clients watching pong gaps. This
287
+ *detects* dead sockets in seconds but does nothing for the keystrokes already
288
+ swallowed by the send buffer — detection without retransmission still loses
289
+ input, and heartbeat intervals short enough to protect fast typists would be
290
+ chatty. Worth doing *in addition* (it cleans up server-side zombie clients),
291
+ but it does not close the loss window.
292
+ - **TCP keepalive**: not configurable from browser JavaScript at all, and
293
+ default kernel timers are minutes long. Doesn't apply to the primary (phone
294
+ browser) case.
295
+ - **Full request/response input** (each keystroke waits for its ack before the
296
+ next is sent): maximal safety, but adds a full RTT of head-of-line blocking
297
+ per keystroke — unusable on the high-latency mobile links that are exactly
298
+ the scenario at issue. Sequence + cumulative ack gives the same delivery
299
+ guarantee with pipelining.
300
+ - **Optimistic-echo reconciliation as detector**: the web client already
301
+ diff-reconciles predicted echo against real output
302
+ (`hay/apps/web/src/utils/optimisticEcho.ts`); "echo never confirmed" could
303
+ flag loss. But it only covers printable characters in echoing contexts — not
304
+ control keys, password prompts, or TUIs (and the CLI has no echo layer). Too
305
+ weak to be the mechanism; remains a nice complement.
306
+
307
+ Sequence-acks win because they make the client's belief about delivery *match
308
+ reality* — detection, retransmission, dedup, and honest failure reporting fall
309
+ out of one small mechanism, and it degrades gracefully to today's behavior
310
+ when either side doesn't speak it.
311
+
312
+ ## 6. Implementation plan (no code in this change)
313
+
314
+ Ordered so each step lands independently and old/new components interoperate
315
+ throughout:
316
+
317
+ 1. **Protocol** — `hay/packages/shared/src/protocol.ts`: optional `seq` on
318
+ `input` and `input_rejected`, new `input_ack` server message, optional
319
+ `inputAcks`/`lastInputSeq` on `hello`. Tests in
320
+ `hay/packages/shared/test/protocol.test.ts` (round-trips, legacy messages
321
+ without the new fields still parse).
322
+ 2. **Server** — `hay/apps/server/src/rooms.ts`: parse `instance` (plumbed
323
+ through `ClientInfo` from `attachTermshare` in `lib.ts`,
324
+ `hay/apps/server/src/index.ts`, and `scripts/hay-host.js`); per-instance
325
+ `lastSeqSeen` map with expiry in `Room`; dedup + coalesced cumulative acks
326
+ in `handleInput`; `lastInputSeq` in the `hello` sent by `attachClient`;
327
+ `seq` echoed on `input_rejected`. Tests in
328
+ `hay/apps/server/test/rooms.test.ts` (ack emission, dedup on replay,
329
+ seq-gap tolerance, legacy no-seq client).
330
+ 3. **Web client** — `hay/apps/web/src/App.tsx`: instance id + seq counter;
331
+ replace `pendingInputRef` with the always-on unacked buffer in
332
+ `handleUserInput`/`sendMessage`; prune on `input_ack`; resume logic in
333
+ `connect`'s open/`hello` handling; stall detection using the existing
334
+ `ping`/`pong`; suspect-socket proactive reconnect via `scheduleReconnect`;
335
+ loss/stall toasts. New unit tests beside `hay/apps/web/test/` for the
336
+ buffer/resume state machine (extracted into a `utils/` module like
337
+ `optimisticEcho.ts` so it's testable headlessly).
338
+ 4. **CLI client** — `hay/apps/cli/src/index.ts`: same buffer/resume module
339
+ shared via `hay/packages/shared` if practical; fix the silent drop in
340
+ `sendMessage` (line 1201) by buffering when not OPEN; status-bar/notice
341
+ surfacing for stall and loss.
342
+ 5. **Optional hardening (separate follow-up)** — server-side WebSocket-level
343
+ heartbeat in `lib.ts`/`scripts/hay-host.js` to reap zombie connections
344
+ (§5, first alternative).
345
+
346
+ Rollout risk is low: every hop deployment runs server and clients from the
347
+ same checkout (the daemon spawns `scripts/hay-host.js` and serves the web
348
+ bundle), so mixed versions occur only transiently, and both mixes degrade to
349
+ current behavior by design.
@@ -11,12 +11,12 @@ Ensure concurrent agent sessions stay isolated, time-bounded, and recoverable.
11
11
 
12
12
  ## Execution Loop
13
13
 
14
- 1. **Create session** — one `hop_create_terminal` per agent.
15
- 2. **Validate prompt** — use `hop_wait_terminal(until_prompt=true, start_from="latest")` before the first command.
16
- 3. **Preferred turn helper** — use `hopx_agent_turn` or `hopx_send_and_wait` for normal multi-turn work.
14
+ 1. **Create session** — `hopx_spawn_agent` (create + launch + ready in one call), or one `hop_create_terminal` per agent for manual control.
15
+ 2. **Validate prompt** — `hopx_spawn_agent` waits for readiness itself; manual path: `hop_wait_terminal(until_prompt=true, start_from="latest")` before the first command.
16
+ 3. **Preferred turn helper** — use `hopx_agent_turn` or `hopx_send_and_wait` for normal multi-turn work; dispatch fleet work with `async=true` and collect with `hopx_wait_any`.
17
17
  4. **Manual fallback** — drop to `hop_write_terminal` + `hop_wait_terminal` only when you need custom wait conditions or lower-level control.
18
- 5. **Assert state** — verify expected output before continuing.
19
- 6. **Repeat** the loop; never write into a terminal that is still busy.
18
+ 5. **Assert state** — verify expected output before continuing; `hopx_agents_overview` shows which terminals are busy/idle across the whole fleet.
19
+ 6. **Repeat** the loop; never write into a terminal that is still busy (overview state `busy`).
20
20
 
21
21
  Recommended defaults:
22
22