hop2 0.11.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.
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +126 -0
- package/DESIGN-input-acks.md +349 -0
- package/MULTI-AGENT-CHECKLIST.md +5 -5
- package/README-MCP.md +84 -5
- package/README.md +55 -4
- package/demo/README.md +52 -0
- package/demo/capture/capture-env.mjs +108 -0
- package/demo/capture/capture.mjs +634 -0
- package/demo/capture/dispatch-task.mjs +44 -0
- package/demo/capture/mcp-client.mjs +61 -0
- package/demo/capture/native-css.mjs +49 -0
- package/demo/capture/setup-04.mjs +67 -0
- package/demo/capture/setup-sessions.mjs +40 -0
- package/demo/capture/spawn-aurora.mjs +53 -0
- package/demo/capture/tools/agent.sh +30 -0
- package/demo/capture/tools/build.sh +18 -0
- package/demo/capture/tools/metrics.sh +17 -0
- package/docs/hero-overview.svg +119 -0
- package/docs/hero.svg +118 -0
- package/docs/restore-reliability.md +139 -0
- package/docs/session-identity.md +76 -0
- package/docs/switcher-first-ux.md +103 -0
- package/hay/CHANGELOG.md +59 -1
- package/hay/apps/cli/package.json +3 -2
- package/hay/apps/cli/src/index.ts +705 -28
- package/hay/apps/server/src/pty.ts +36 -2
- package/hay/apps/server/src/rooms.ts +518 -34
- package/hay/apps/server/test/rooms.test.ts +390 -1
- package/hay/apps/web/index.html +2 -0
- package/hay/apps/web/package.json +14 -12
- package/hay/apps/web/scripts/run-e2e.mjs +42 -5
- package/hay/apps/web/src/App.tsx +2399 -281
- package/hay/apps/web/src/components/MobileKeyboard.tsx +135 -14
- package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +2288 -0
- package/hay/apps/web/src/setupTests.ts +23 -0
- package/hay/apps/web/src/styles.css +2283 -180
- package/hay/apps/web/src/utils/fileDrop.ts +15 -0
- package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
- package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
- package/hay/apps/web/src/utils/switcherModel.ts +277 -0
- package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
- package/hay/apps/web/src/vite-env.d.ts +3 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +273 -0
- package/hay/apps/web/test/fileDrop.test.ts +22 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
- package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
- package/hay/apps/web/test/switcherModel.test.ts +312 -0
- package/hay/apps/web/test/terminalSearch.test.ts +37 -0
- package/hay/apps/web/vite.config.ts +20 -0
- package/hay/package-lock.json +1696 -1171
- package/hay/packages/shared/src/protocol.ts +25 -2
- package/hay-web/assets/index--cf1ILsi.css +1 -0
- package/hay-web/assets/index-_wMnETjm.js +130 -0
- package/hay-web/assets/index-_wMnETjm.js.map +1 -0
- package/hay-web/index.html +4 -2
- package/hop +4224 -315
- package/hopa +465 -0
- package/mcp/hop-mcp.js +2878 -122
- package/package.json +7 -4
- package/scripts/claude-session-hook.js +152 -0
- package/scripts/hay-host.js +225 -4
- package/scripts/hop-math.js +316 -0
- package/scripts/salvage-and-restart-host.js +217 -0
- package/skills/hop-manager/SKILL.md +62 -0
- package/skills/hop-session/SKILL.md +42 -0
- package/skills/hopa/SKILL.md +75 -0
- package/tests/agent-skill.unit.test.cjs +60 -0
- package/tests/composer-state.unit.test.cjs +102 -0
- package/tests/hop-mcp.integration.test.cjs +476 -94
- package/tests/hopa-parity.unit.test.cjs +188 -0
- package/tests/readable-echo.unit.test.cjs +94 -0
- package/tests/readable-flush.unit.test.cjs +93 -0
- package/tests/submit-verification.unit.test.cjs +331 -0
- package/tests/terminal-api.integration.test.cjs +301 -13
- package/hay-web/assets/index-BzJB9r_I.js +0 -64
- package/hay-web/assets/index-BzJB9r_I.js.map +0 -1
- package/hay-web/assets/index-DG8Ntznc.css +0 -32
- package/sessions.html +0 -726
|
@@ -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.
|
package/MULTI-AGENT-CHECKLIST.md
CHANGED
|
@@ -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** —
|
|
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
|
|