hop2 0.11.0 → 0.11.2
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 +130 -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 +525 -34
- package/hay/apps/server/test/rooms.test.ts +411 -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 +2492 -281
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- 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 +2525 -0
- package/hay/apps/web/src/setupTests.ts +23 -0
- package/hay/apps/web/src/styles.css +2460 -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 +314 -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 +324 -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 +377 -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-BohAWpcL.css +1 -0
- package/hay-web/assets/index-DfGT3DuO.js +130 -0
- package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
- package/hay-web/index.html +4 -2
- package/hop +4348 -302
- 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 +227 -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,139 @@
|
|
|
1
|
+
# Session restore: failure taxonomy and the design that closes it
|
|
2
|
+
|
|
3
|
+
Status: living document · Last updated 2026-07-26
|
|
4
|
+
|
|
5
|
+
Every failure below was observed on the real fleet, not imagined. Each entry
|
|
6
|
+
names the mechanism, the symptom the user saw, and where it is closed.
|
|
7
|
+
|
|
8
|
+
## The two things restore must get right
|
|
9
|
+
|
|
10
|
+
Restore answers exactly two questions per session, and every bug so far has
|
|
11
|
+
been one of them getting a wrong answer confidently:
|
|
12
|
+
|
|
13
|
+
1. **Identity** — *which conversation belongs to this session?*
|
|
14
|
+
2. **Execution** — *did the relaunch actually happen?*
|
|
15
|
+
|
|
16
|
+
Everything else (buffers, cwd, flags, config roots) is detail hanging off
|
|
17
|
+
those two.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## A. Identity failures
|
|
22
|
+
|
|
23
|
+
### A1. A helper claude steals the session's record
|
|
24
|
+
**Mechanism.** The SessionStart hook keys its record on `HOP_SESSION`. Any
|
|
25
|
+
claude started *inside* a session inherits that variable — including hop's
|
|
26
|
+
own `hop ai tagline` helper, which runs `claude -p --model haiku` in a temp
|
|
27
|
+
dir. It recorded itself as the session's conversation.
|
|
28
|
+
**Symptom.** "Solstice didn't recover — I had to start claude manually."
|
|
29
|
+
Restore refused a record whose cwd was a temp dir and opened a plain shell.
|
|
30
|
+
**Closed by.** `aiChildEnv()` deletes `HOP_SESSION`/`HOP_SESSION_INTERNAL`, so
|
|
31
|
+
hop's own helpers are invisible to the hook.
|
|
32
|
+
|
|
33
|
+
### A2. A nested/side claude clobbers the record
|
|
34
|
+
**Mechanism.** Same hook, different intruder: an MCP/agent workspace claude
|
|
35
|
+
launched by a tool inside the session. The hook's original guard keyed on
|
|
36
|
+
`CLAUDE_CODE_SESSION_ID`, which hop deliberately scrubs — so the guard never
|
|
37
|
+
fired.
|
|
38
|
+
**Symptom.** A `~/Code/nebula-slides` session came back in the slides tool's
|
|
39
|
+
`agent-home/claude-workspace`; the room "became" a different session.
|
|
40
|
+
**Closed by.** The hook refuses to overwrite an existing record when the new
|
|
41
|
+
claude's cwd differs, parking the intruder at `<session>.other.json`.
|
|
42
|
+
|
|
43
|
+
### A3. The record disagrees with where the session actually is
|
|
44
|
+
**Mechanism.** Even with the guards, a record can predate a `cd`, or belong
|
|
45
|
+
to a foreign tree.
|
|
46
|
+
**Symptom.** Resuming resurrects the wrong conversation in the wrong place.
|
|
47
|
+
**Closed by.** The room's **live cwd** — persisted to the durable meta from
|
|
48
|
+
the host, which no nested process can forge — is the arbiter.
|
|
49
|
+
`restorePlanForSession` refuses a record whose cwd disagrees, and promotes a
|
|
50
|
+
parked `.other.json` whose cwd *matches*. Otherwise it opens a shell in the
|
|
51
|
+
right directory with a visible warning.
|
|
52
|
+
|
|
53
|
+
### A4. The recorded conversation doesn't exist on this host
|
|
54
|
+
**Symptom.** `⚠ recorded claude conversation 673a1060… has no transcript on
|
|
55
|
+
this host` (Hubble).
|
|
56
|
+
**Closed by.** Detected before relaunch; falls back to a plain shell in the
|
|
57
|
+
right cwd rather than running a command that would fail.
|
|
58
|
+
|
|
59
|
+
### A5. Two sessions wearing one name
|
|
60
|
+
**Mechanism.** A session renamed to a name that a *later* session owns as its
|
|
61
|
+
internal name. Display names are not unique, so every name-addressed
|
|
62
|
+
operation picked an arbitrary one of the two.
|
|
63
|
+
**Symptom.** One tile flickering between a shell and a claude session;
|
|
64
|
+
rename hitting the wrong session.
|
|
65
|
+
**Closed by.** Rename addresses sessions by internal name; hydration detects
|
|
66
|
+
a display name owned by another live session and gives it back.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## B. Execution failures
|
|
71
|
+
|
|
72
|
+
### B1. Typing on a timer
|
|
73
|
+
**Mechanism.** Restore typed the relaunch 800ms after attaching, then walked
|
|
74
|
+
away. A shell still booting swallowed the keystrokes.
|
|
75
|
+
**Symptom.** Orion: a bare shell holding **62 copies** of an un-run resume
|
|
76
|
+
command, while restore reported success.
|
|
77
|
+
**Closed by.** A per-session state machine (`wait-ready → type → verify →
|
|
78
|
+
one retry → fail`) driven by the host's own view of the room.
|
|
79
|
+
|
|
80
|
+
### B2. No idempotence
|
|
81
|
+
**Mechanism.** Nothing checked whether the session was already running before
|
|
82
|
+
typing, so every restore added another copy.
|
|
83
|
+
**Closed by.** A room whose foreground process is not a shell is left alone.
|
|
84
|
+
|
|
85
|
+
### B3. Silent failure
|
|
86
|
+
**Mechanism.** Restore's report was its *plan*, not its result.
|
|
87
|
+
**Closed by.** Per-session outcomes (`resumed` / `already-running` /
|
|
88
|
+
`never-started` / `timeout`), printed for failures and returned to callers.
|
|
89
|
+
|
|
90
|
+
### B4. Flags accumulating
|
|
91
|
+
**Mechanism.** The hook records the argv claude actually ran with — which
|
|
92
|
+
already contains the flags the previous restore added.
|
|
93
|
+
**Symptom.** `--dangerously-skip-permissions` five times in one command.
|
|
94
|
+
**Closed by.** Valueless flags deduped when rebuilding the launch.
|
|
95
|
+
|
|
96
|
+
### B5. Restart races
|
|
97
|
+
**Mechanism.** A daemon restart during the salvage script's restore phase
|
|
98
|
+
cycled the host; PTYs died mid-restore.
|
|
99
|
+
**Symptom.** 17 sessions lost at once (recovered via `hop restore`).
|
|
100
|
+
**Open.** See below.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## C. What was open — now closed
|
|
105
|
+
|
|
106
|
+
1. **Restart races (B5).** A pid-stamped lifecycle lock
|
|
107
|
+
(`~/.hop2/.lifecycle.lock`) serializes `hop start`, `hop stop`,
|
|
108
|
+
`hop restore` and the salvage script. Stale holders (dead pid, or held
|
|
109
|
+
>10min) are taken over, so a crash cannot wedge restarts.
|
|
110
|
+
2. **The resume-choice prompt.** Sessions parked on claude's
|
|
111
|
+
"resume from summary / full session" question are marked **NEEDS YOU** on
|
|
112
|
+
the wall, detected from preview text already fetched. hop still never
|
|
113
|
+
answers it — that choice can consume a large share of a usage limit.
|
|
114
|
+
3. **Restore is no longer keystrokes.** The relaunch is the room's own argv:
|
|
115
|
+
`shell -lc "<cmd>; exec shell -l"`, threaded from the daemon through
|
|
116
|
+
`POST /rooms` to `createPty`. No prompt to race, nothing half-typed, no
|
|
117
|
+
command echo in the scrollback; the session still drops to an interactive
|
|
118
|
+
shell when the app exits. Typing survives only as the fallback for a host
|
|
119
|
+
that predates this.
|
|
120
|
+
|
|
121
|
+
## Remaining risk
|
|
122
|
+
|
|
123
|
+
The failure classes above are structurally closed, but two things still
|
|
124
|
+
depend on the environment rather than on hop:
|
|
125
|
+
|
|
126
|
+
- A conversation whose transcript is missing on this host (A4) can only ever
|
|
127
|
+
become a plain shell in the right directory.
|
|
128
|
+
- Restore cannot tell whether the app itself will succeed once launched (a
|
|
129
|
+
bad API key, an exhausted usage limit). Verification confirms the process
|
|
130
|
+
started, not that it is happy.
|
|
131
|
+
|
|
132
|
+
## The invariant to hold on to
|
|
133
|
+
|
|
134
|
+
> Never infer what can be observed, and never assume what can be verified.
|
|
135
|
+
|
|
136
|
+
Identity comes from the room's live state (which the host owns), not from a
|
|
137
|
+
file anything can overwrite. Execution is confirmed by watching the room's
|
|
138
|
+
process, not by a timer. Every failure above is one of those two rules being
|
|
139
|
+
broken.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Session identity: names, layers, and the rules that keep them honest
|
|
2
|
+
|
|
3
|
+
Renaming a session has broken, in a different way, four times. Each fix
|
|
4
|
+
addressed the symptom in front of it (persistence, then a false 409, then
|
|
5
|
+
duplicate display names) and the next failure arrived from the same
|
|
6
|
+
direction. This document names the underlying structure so the class stays
|
|
7
|
+
closed.
|
|
8
|
+
|
|
9
|
+
## The layers
|
|
10
|
+
|
|
11
|
+
A session's metadata lives in three places, and every one of them can answer
|
|
12
|
+
"what is this session called?":
|
|
13
|
+
|
|
14
|
+
| Layer | Where | Lifetime | Written by |
|
|
15
|
+
|---|---|---|---|
|
|
16
|
+
| Store config | `sessionStore.sessions[internal]` (in memory; a workspace file only when a workspace exists) | until daemon exit | `setSessionConfig`, restore (`saveDefinition: true`), session creation |
|
|
17
|
+
| Runtime metadata | `runtimeSessionMetadata` map | until daemon exit | `rememberRuntimeSessionMetadata`, every reconcile tick |
|
|
18
|
+
| Durable meta | `~/.hop2/claude-sessions/<internal>.meta` | forever | rename, park/archive, tagline, hook records |
|
|
19
|
+
|
|
20
|
+
A session is addressed by its **internal name** — the room id, immutable for
|
|
21
|
+
the session's life. The **display name** is a label, and the alias map
|
|
22
|
+
(`sessionAliases`) redirects old labels to current ones.
|
|
23
|
+
|
|
24
|
+
## The rules
|
|
25
|
+
|
|
26
|
+
1. **Write through every layer a reader consults.** Not "the right one" —
|
|
27
|
+
all of them. The recurring rename bug was a write/read asymmetry:
|
|
28
|
+
`renameSessionDisplayName` chose its target via the workspace-GATED
|
|
29
|
+
`getSavedSessionConfig`, while `getEffectiveSessionConfig`,
|
|
30
|
+
`reconcileExternalRuntimeSessions`, and `getSessionDisplayName` all read
|
|
31
|
+
the UNGATED store first. Any session with a store entry and no workspace —
|
|
32
|
+
which is every restored session — took the runtime branch, and the next
|
|
33
|
+
reconcile copied the stale store name back over it. The rename reverted
|
|
34
|
+
seconds later, which is why it kept looking like "rename doesn't persist."
|
|
35
|
+
|
|
36
|
+
2. **Durable meta is the authority for a name.** It is the only layer written
|
|
37
|
+
by every rename and the only one that survives a restart. Reconcile
|
|
38
|
+
resolves `displayName` from meta first, then store, then runtime.
|
|
39
|
+
|
|
40
|
+
3. **Never invent a session.** `resolveSessionDisplayName` returns `null`
|
|
41
|
+
when nothing owns a name, and callers refuse. It used to return the
|
|
42
|
+
requested name either way (both ternary branches were identical), so
|
|
43
|
+
`/ws?room=<name>` CREATED an empty session wearing that name. Combined
|
|
44
|
+
with rule 1's revert, one rename produced a phantom shell holding the new
|
|
45
|
+
name while the real session ran on, unreachable, behind the old one — and
|
|
46
|
+
because the alias pointed the old name at the new one, *both* names
|
|
47
|
+
resolved to the phantom. Any typo'd room name in a URL did the same thing.
|
|
48
|
+
|
|
49
|
+
4. **Address by internal name whenever the caller knows it.** Display names
|
|
50
|
+
collide; the UI passes `internalName` so a rename always lands on the
|
|
51
|
+
session the user clicked.
|
|
52
|
+
|
|
53
|
+
5. **A name conflict resolves in favour of the room that owns it.** If a
|
|
54
|
+
session's claimed display name is another live room's internal name, the
|
|
55
|
+
claimant falls back to its own name and the collision is logged. Two
|
|
56
|
+
identically-named sessions are indistinguishable in the UI and ambiguous
|
|
57
|
+
to address.
|
|
58
|
+
|
|
59
|
+
## Diagnosing the next one
|
|
60
|
+
|
|
61
|
+
- `[ws] refused attach to unknown session "X"` — a client is asking for a
|
|
62
|
+
name nothing owns. Before rule 3, this line was a new session instead.
|
|
63
|
+
- `[rename] "X" is another live session's name` — rule 5 fired.
|
|
64
|
+
- Disagreement between `<name>.meta` on disk and `/api/sessions` output means
|
|
65
|
+
a layer is stale: rule 1 or 2 has been violated by a new code path.
|
|
66
|
+
|
|
67
|
+
## Covered by
|
|
68
|
+
|
|
69
|
+
`npm run test:identity` — CI runs unit suites only (the integration tests
|
|
70
|
+
need a real daemon and PTYs), so these guards do NOT run on push. Run them
|
|
71
|
+
locally before shipping anything that touches naming, aliases, or attach.
|
|
72
|
+
|
|
73
|
+
`tests/terminal-api.integration.test.cjs`:
|
|
74
|
+
- *websocket attach to an unknown session refuses instead of creating one*
|
|
75
|
+
(fails without rule 3)
|
|
76
|
+
- *rename sticks across reconcile ticks and never manufactures a session*
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Switcher-first hop: design direction
|
|
2
|
+
|
|
3
|
+
Status: proposal · Author: drafted with Claude · Date: 2026-07-25
|
|
4
|
+
|
|
5
|
+
## The shift
|
|
6
|
+
|
|
7
|
+
hop was built terminal-first: you land in a session, and the switcher is a
|
|
8
|
+
palette you summon (⌘K) to leave it. The way hop is actually used — supervising
|
|
9
|
+
many concurrent agent sessions, often from a phone — inverts that. The wall of
|
|
10
|
+
sessions is the workspace; a single full-screen terminal is one mode of looking
|
|
11
|
+
at it.
|
|
12
|
+
|
|
13
|
+
This document proposes making the switcher the primary surface, with full-screen
|
|
14
|
+
as a first-class mode, and **the default a user choice**. Both must remain
|
|
15
|
+
one gesture apart.
|
|
16
|
+
|
|
17
|
+
## What already supports this
|
|
18
|
+
|
|
19
|
+
The switcher has quietly grown most of the machinery:
|
|
20
|
+
|
|
21
|
+
- **Zoom ladder** (9 levels, +/−, viewport-capped) — the wall scales from a
|
|
22
|
+
dense overview to near-readable panes.
|
|
23
|
+
- **Interactive tiles** above zoom level 6 — click a tile and type in it in
|
|
24
|
+
place, with a reconnect lifecycle of its own.
|
|
25
|
+
- **Grid previews** — every tile shows the session's true current screen,
|
|
26
|
+
cheaply, because the daemon keeps a real parsed grid per room.
|
|
27
|
+
- **Frozen ordering + activity glow** — the wall stops moving while you scan
|
|
28
|
+
it; activity signals in place.
|
|
29
|
+
- **Taglines** — each session says what it is *for*, generated automatically.
|
|
30
|
+
- **Search over content** — filter matches session names, working directories,
|
|
31
|
+
and terminal/transcript content, with previews retained in results.
|
|
32
|
+
|
|
33
|
+
The gap is not capability. It is that the switcher still behaves like a
|
|
34
|
+
transient palette: it resets on open, it has no durable identity of its own,
|
|
35
|
+
and full-screen is where hop assumes you live.
|
|
36
|
+
|
|
37
|
+
## Principles
|
|
38
|
+
|
|
39
|
+
1. **The wall is a place, not a popup.** State the user establishes (zoom,
|
|
40
|
+
sort, scope, scroll position) persists across opens.
|
|
41
|
+
2. **One gesture between modes, in both directions.** ⌘⏎ from a tile opens it
|
|
42
|
+
full screen; ⌘. returns. Escape never destroys work.
|
|
43
|
+
3. **The default surface is a setting, not an opinion.** Some sessions are a
|
|
44
|
+
long single conversation; some are a fleet.
|
|
45
|
+
4. **Nothing on the wall requires a round trip to be useful.** Tagline,
|
|
46
|
+
activity state, and last screen are all readable at a glance.
|
|
47
|
+
5. **Phone-first ergonomics.** Thumb reach, no hover dependencies, and no
|
|
48
|
+
layout that assumes a mouse.
|
|
49
|
+
|
|
50
|
+
## Proposed changes
|
|
51
|
+
|
|
52
|
+
### 1. Default surface setting
|
|
53
|
+
`Settings → Start on:` **Session wall** | **Last session** (current behavior).
|
|
54
|
+
The daemon's landing redirect honors it, so opening hop goes where the user
|
|
55
|
+
wants without a flash of the wrong surface.
|
|
56
|
+
|
|
57
|
+
### 2. The wall gets an identity
|
|
58
|
+
- A stable URL (`/wall`) so it can be bookmarked and returned to.
|
|
59
|
+
- Zoom, sort mode, origin scope, and scroll offset persist per device.
|
|
60
|
+
- Opening it does not reset scope to `user` (today it does).
|
|
61
|
+
|
|
62
|
+
### 3. Mode switching becomes symmetric
|
|
63
|
+
| From | Gesture | To |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| Wall | Enter / tap | Session, full screen |
|
|
66
|
+
| Wall | ⌘⏎ on a tile | Interactive tile, stay on the wall |
|
|
67
|
+
| Session | ⌘K / swipe down | Wall |
|
|
68
|
+
| Interactive tile | ⌘⏎ | Session, full screen |
|
|
69
|
+
| Interactive tile | ⌘. | Release, stay on the wall |
|
|
70
|
+
|
|
71
|
+
Escape from the wall returns to the previous session when there is one;
|
|
72
|
+
otherwise it is a no-op (never a dead end).
|
|
73
|
+
|
|
74
|
+
### 4. Wall-native actions
|
|
75
|
+
Rename, kill, and "new session here" already exist per tile. Add: pin a
|
|
76
|
+
session to the top, and a compact keyboard path for each (the wall should be
|
|
77
|
+
fully drivable without a pointer).
|
|
78
|
+
|
|
79
|
+
### 5. Attention model
|
|
80
|
+
Bell and unread dots stay. With ordering frozen, attention is communicated
|
|
81
|
+
by the in-place glow plus an optional "N sessions want you" affordance that
|
|
82
|
+
jumps to the next one — supervising twenty sessions should not require
|
|
83
|
+
scanning twenty tiles.
|
|
84
|
+
|
|
85
|
+
## Open questions
|
|
86
|
+
|
|
87
|
+
- Does the wall replace the hub landing page, or are they the same surface in
|
|
88
|
+
two states? (Leaning: same surface; the hub is simply the wall with no
|
|
89
|
+
current session.)
|
|
90
|
+
- On a phone, is the interactive tile worth the complexity versus always going
|
|
91
|
+
full screen for input? Real usage should decide.
|
|
92
|
+
- Should panes and the wall converge — is a split pane just two pinned tiles?
|
|
93
|
+
|
|
94
|
+
## Sequencing
|
|
95
|
+
|
|
96
|
+
1. Default-surface setting + stable `/wall` URL (small, unblocks the rest).
|
|
97
|
+
2. Persist wall state across opens; stop resetting scope.
|
|
98
|
+
3. Symmetric mode gestures + Escape semantics.
|
|
99
|
+
4. Attention jump affordance.
|
|
100
|
+
5. Revisit panes vs. wall convergence.
|
|
101
|
+
|
|
102
|
+
Each step is independently shippable and leaves hop usable if the next never
|
|
103
|
+
lands.
|
package/hay/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
#### Tooling
|
|
8
|
+
- **Deterministic turn-completion signal for agent driving**: the Claude hook installed by `hop claude-hook install` now also registers a `Stop` hook that bumps a per-turn counter at `<HOP_HOME>/claude-sessions/<HOP_SESSION>.turn` (`{ sessionId, count, at }`) each time Claude finishes a turn. Re-run `hop claude-hook install` to add it to an existing SessionStart-only install; it's a no-op outside hop and never disrupts Claude.
|
|
9
|
+
- **MCP `until_agent_done` uses that counter when available**: `hopx_agent_turn` captures the session's turn count before sending and treats a counter advance as the authoritative "turn done" — exact completion instead of screen-scraping for a busy footer (which can read a paused agent as idle). When no marker is present (hook not installed, a non-Claude agent, or no local filesystem access) it falls back to the existing busy-line heuristic unchanged, so there's no behavior change without the hook.
|
|
10
|
+
- **Verified submit catches a swallowed Enter**: in `mode:"ui"`, after `hopx_agent_turn` sends your text and presses Enter it re-reads the agent's input box and, if your prompt is still sitting there un-submitted (a known TUI race when the app isn't ready for input), re-sends Enter — up to twice — so a driver never waits forever on a turn that never started. The box is read with a dim-ghost/border-aware scrape (below), so an empty box showing only placeholder text counts as cleared. Reported under `submit` in the result (`verified`, `reason`, `resends`). On by default for UI turns; pass `verify_submit:false` to disable, or tune `verify_submit_retries` / `verify_submit_delay_ms`. No-op (and safe) when no input box is on screen, so it never double-submits to a plain shell.
|
|
11
|
+
- **Composer scrape distinguishes typed text from ghost placeholder**: the MCP now reads a TUI input box straight from the virtual screen, locating it by its box-drawing frame and dropping the dim placeholder text using the per-cell SGR *dim* attribute (rather than stripping `\e[2m` from a string, which is ambiguous when color payloads follow). This is what lets verified-submit tell "my prompt is still in the box" from "the box is empty".
|
|
12
|
+
- **Capture any TUI's scrollback through Hop with `hopx_capture_scrollback`**: the Hop-native counterpart to `hop_read_trajectory` — obtain an alternate-screen TUI's history *the way a user would*, by driving it to scroll up and stitching the rendered frames. It sends a scroll key (default PageUp), waits for the screen to actually redraw (`screenRevision` change), and stitches newly-revealed rows, detecting and excluding the app's fixed footer/composer chrome (so Claude's input box isn't duplicated through the transcript). Restores the live view afterward (PageDown per captured page) and caps output (`max_chars`). Works for any scrollable TUI — including non-Claude agents (codex/gemini) and when no transcript file is reachable. Verified end-to-end: full lossless reconstruction of a 300-line file under `less` (0 gaps, restored to bottom), and a live Claude session (PageUp scrolls, fixed composer not duplicated, view restored). Best-effort and lossy for wrapped/redrawn content — prefer `hop_read_trajectory` when a Claude transcript is available.
|
|
13
|
+
- **Read a Claude session's real history with `hop_read_trajectory`**: the terminal only shows the current frame for an alternate-screen TUI, so the scrolled-off conversation is invisible to a driver. This MCP tool resolves a hop session name to its Claude transcript (`~/.claude/projects/<cwd-encoded>/<sessionId>.jsonl`, via the SessionStart-hook record, falling back to the newest transcript in the project dir — which it flags as ambiguous when several sessions share a cwd) and returns the **actual** turn history. It is **context-safe by design**: it stream-parses the file (a 21 MB / 6900-line transcript reduces in ~70 ms at ~150 MB RSS) and never returns the raw transcript — output is capped (`max_chars`, default 8000) with a truncation hint. The default `digest` mode is a **reduced conversation view** (ported self-contained from the agent-migration tool): per-turn User text + Assistant text + one-line tool-call summaries (`[Bash] …`, `[Read] path`, `[WebSearch] query=…`), with transcript noise dropped (tool-result bodies, thinking, system/hook lines, `<system-reminder>`/`<command-name>`-style injected text) and the most-recent turns kept within budget. Other modes: `summary` (metadata + per-type counts + token totals), `list` (paginated compact records), `get` (one turn by index/uuid), `tail` (last N turns' full text). **Gated by the same per-session agent permission as attaching** — reading a session's history requires `hop_set_agent_permission(name, allowed=true)` (or `hop session permit <name>`); unknown/unverifiable sessions are denied. Reads on the MCP host today, behind a seam so a daemon endpoint can serve it for agent-remote-from-sessions setups later.
|
|
14
|
+
- **Busy-line completion settles before declaring done, and reports a three-state verdict**: on the heuristic path (no Stop-hook marker), the `hopx_agent_turn` busy guard now requires N consecutive idle reads (`settle_checks`, default 2) before calling a turn done — a single idle read can be a lull between an agent's bursts (thinking → tool call), and the settle avoids that stale-idle false "done". The guard's `uiBusyGuard` summary now carries `state: "done" | "busy" | "unknown"` (plus `settledIdle` and a compact `composer` snapshot) so a driver can tell a confirmed completion from a still-working turn from an ambiguous timeout. The turn-counter marker stays authoritative and needs no settle.
|
|
15
|
+
- **`hop session list` shows attached client counts**: each live session now reports how many clients are connected (e.g. `Solstice [TERMINAL, LIVE] · 1 client`, or `· 0 clients` for a live-but-detached session) instead of only whether it's alive. Counts come from the host's per-room client tally; non-live sessions still only appear with `--all`.
|
|
16
|
+
- **`hop` starts a new session by default; `hop attach` reuses one.** Bare `hop` (and `hop new` / `hop --new`) now always start a fresh session instead of reattaching to this directory's session. `hop attach` with no name attaches to an **unattached** session (one with no client connected) — preferring the current directory, else the most recently active — and if every session is already attached it reports "No unattached session" and lists them. `hop attach <name>` and `hop attach all` are unchanged.
|
|
17
|
+
- **Clearer "couldn't reserve a session" note**: when the daemon can't hand out a session atomically, the fallback message now explains it's non-fatal and that a bare `status 200` typically means the running daemon is older than the CLI — with the fix (`hop stop && hop start`, which keeps sessions running). Previously it read as a cryptic "Could not claim session atomically (status 200)".
|
|
18
|
+
- **`hop math '<latex>'`** — render a LaTeX formula in the terminal as a **2D Unicode layout**, dependency-free. The box-layout engine handles stacked fractions, `√` with an overline, big-operator limits stacked over/under, super/subscripts (Unicode glyphs with `^`/`_` fallback), Greek + operator symbols, and combining-mark accents like `\vec`/`\hat`/`\bar`. Reads from an argument or stdin. Inline-image rendering (Kitty/iTerm/Sixel) is deferred until graphics support lands in the web client (xterm.js), since the real payoff is images that render *inside* a shared hop session — until then math is Unicode everywhere.
|
|
19
|
+
|
|
20
|
+
#### CLI Client
|
|
21
|
+
- **Rename the session from inside it (`Opt+R`)**: type a new name in the status bar (`Enter` to save, `Esc` to cancel). The new name persists to the daemon, so it shows everywhere — the session manager, `hop session list`, and on reattach. (Posts to the local hop daemon's rename API; available wherever a local daemon is reachable.)
|
|
22
|
+
- **You're told when another client reshapes the shared terminal**: with autofit on (the default), a peer's window can resize the shared PTY for everyone. When that happens the status bar shows a `⇄ <name>` indicator (who's driving the current size), and a one-off notice — `<name> resized the terminal to <cols>×<rows>` — explains the reshape so a sudden change of shape (including on connect, when a peer already sized it) isn't a surprise.
|
|
23
|
+
- **Status bar / hints toggles are session-only**: `Opt+B` (status bar) and `Opt+T` (hints) no longer persist to `.hop.json`, so an in-session toggle can't silently become your permanent default. The default is taken from `.hop.json` (`showStatusBar`/`showHints`, both default `true`); toggles affect only the current session and the notice now says so. (Other toggles like mouse capture and sync-size still persist.)
|
|
24
|
+
|
|
25
|
+
#### Web Client
|
|
26
|
+
- **Restyle to the hop identity**: the web client now shares the CLI bars' palette and language — accent session chip, semantic state dot, and keycap-style find control in a real status-bar footer; mono-led chrome (brand, presence names, session labels); refined light/dark themes; dot-grid join page. The hop session picker got the same treatment, including dark-mode support.
|
|
27
|
+
- **Drawer polish**: quick actions split into two clusters — utility icons (keyboard / share / find) then named actions (Fit / Manage) — and the drawer FAB now defaults to the top-right (clear of the status bar, mirroring the close button) instead of bottom-left. It stays draggable.
|
|
28
|
+
- **Autofit by default + fit on every session load**: the terminal fits the viewport on connect and on each session switch, on every platform. Autofit resizes the shared PTY, so other viewers follow this client; switch to Manual in the drawer to opt out.
|
|
29
|
+
|
|
30
|
+
#### Server
|
|
31
|
+
- **Plain-shell restore replays the last screen**: on a graceful shutdown the host now persists each room's tail buffer to `<HOP_HOME>/session-buffers/<id>.raw`; `hop restore` seeds a recreated plain-shell session with that buffer plus a dim `──── session restored ────` separator, then starts a fresh shell in the same dir — so reopening a shell session picks up where it left off (claude sessions still resume via `claude --resume`, so they aren't seeded). The buffer is consumed (deleted) on restore so a later normal recreate never replays a stale screen. The first restart after upgrading has no persisted buffers yet (nothing to replay).
|
|
32
|
+
|
|
33
|
+
### Bug Fixes
|
|
34
|
+
|
|
35
|
+
#### Session restore
|
|
36
|
+
- **`hop restore` actually restores now (Claude conversations included), from the SessionStart-hook records**: restore built its candidate list from the workspace-scoped session store, which is only populated when a workspace is active — and no workspace is ever activated in normal use, so `restore` found nothing and silently restored zero sessions. It now sources candidates directly from the durable per-session records the SessionStart hook writes (`~/.hop2/claude-sessions/<name>.json` = `{ sessionId, cwd }`), which survive a crash/reboot and carry the exact conversation id. So after a restart, `hop restore` recreates each session in its directory and resumes its Claude conversation with `claude --resume <id>` — no manual capture step. Sessions already live are skipped (idempotent), and `hop restore` now starts the daemon itself if it isn't running, so post-reboot recovery is a single command. `--dry-run` previews the plan. Records are pruned when a session is deleted, so restore never resurrects a session you intentionally closed (and if an unwanted one does come back, deleting it once keeps it gone).
|
|
37
|
+
- **`hopx_agent_turn` no longer runs non-UI input twice**: in `readable_raw` mode (and `auto` resolving to it — i.e. a plain shell or any non-alternate-screen agent), a synchronous turn pre-sent the input and *then* sent it again via the combined send+wait, so a command driven through `hopx_agent_turn` executed twice. The pre-send is now done only for the paths that need it (the `ui` branch and async turns, which return before the combined call); non-UI synchronous turns let the single send+wait do the send. UI and async turns are unchanged.
|
|
38
|
+
|
|
39
|
+
#### Session manager
|
|
40
|
+
- **Workdir now shows the live directory, not the creation directory**: a session that's both running and saved had its working dir clobbered by the saved definition's cwd when the session list was assembled — so a session you'd `cd`'d into a subdir of (e.g. `~/Code/hop2`) showed its original dir (`~`) in the web/mobile session manager, even though the CLI status bar showed the right one. The live runtime cwd (the room's tracked `liveCwd`) now wins over the saved config cwd.
|
|
41
|
+
|
|
42
|
+
#### CLI Client
|
|
43
|
+
- **See every shortcut when the hint bar is too narrow (`Opt+?`)**: a full-screen help overlay lists all shortcuts grouped by purpose (`↑↓` scroll, `Esc` close), so keys aren't lost when the window can't show the whole hint bar. The bar now keeps a `⌥? help` entry near the front (so it survives right-edge truncation) and ends with a `…` when there are more hints than fit.
|
|
44
|
+
- **Scroll wheel reaches mouse-aware remotes as a real wheel event (not arrow keys)**: when the remote program had mouse tracking on, clicks/drags were forwarded as mouse events but the wheel was converted to `↑`/`↓` arrow keys — so scrolling in a mouse-aware TUI behaved wrong even in mouse mode. The wheel is now passed through (like clicks) whenever the remote is tracking the mouse; arrow-key emulation is reserved for fullscreen apps that *don't* grab the mouse (a pager, `man`, etc.), and the normal screen still scrolls the local viewport.
|
|
45
|
+
- **Duplicated / lost text during scrolling output (e.g. Claude Code)**: the dirty-line render diff compared screen rows positionally but only forced a full repaint on resize — so when the buffer scrolled (or you panned), rows that shifted could be skipped (lost text) or left behind (duplicated). The renderer now forces a full repaint whenever the buffer scrolls (xterm `onScroll`) or the viewport offset changes, and clears its frame cache on snapshot replay. The in-place diff still applies for typing (no scroll), keeping that fast.
|
|
46
|
+
|
|
47
|
+
#### Web Client (mobile)
|
|
48
|
+
- **Touch scrolling works again in fullscreen TUIs (Claude Code, vim, less)**: an alternate-screen app has no local scrollback, so the mobile touch handler's `terminal.scrollLines()` was a silent no-op — dragging did nothing once Claude entered fullscreen (and recent Claude versions scroll their own transcript rather than the terminal's buffer). The web client now tracks the remote's alternate-screen state (seeded from the server snapshot, kept live via xterm `?h/?l` CSI handlers for modes 47/1047/1049) and, while it's active, translates a touch-drag into the app's own paging keys — PageUp/PageDown (`\e[5~`/`\e[6~`), which Claude Code, `less`, and `man` all scroll on — instead of the dead local scroll. Drag up pages toward newer output, drag down toward older; momentum is disabled on the alternate screen so a flick can't fire a burst of page keys. Normal-screen scrolling (local viewport + momentum) is unchanged. Verified the alt-screen detection against the real xterm parser (modes flip the flag and the buffer still switches; unrelated modes like cursor-hide/bracketed-paste don't) and the drag→key direction/threshold mapping.
|
|
49
|
+
- **Drawer groups no longer collapse with a long session list**: the drawer is a flex column with a fixed viewport height on mobile, so a long "Switch session" list overflowed and the flex algorithm shrank the Control and Theme/Font/Copy groups (which have `overflow:hidden`) to empty slivers. Children are now pinned to their natural height, so the drawer scrolls instead of squeezing — the settings groups stay visible no matter how many sessions are live.
|
|
50
|
+
- **Mobile controls no longer disappear at narrow widths**: `isMobile` now tracks the viewport instead of being fixed at page load, so resizing a window narrow (or any narrow load) shows the full mobile drawer — keyboard toggle, Find, Touch mode, and the virtual keyboard — matching the CSS breakpoint. Previously a wide-then-narrow window stayed in "desktop mode" and dropped those controls.
|
|
51
|
+
- **Find no longer triggers Safari's auto-zoom**: every focusable field is floored at 16px on mobile, so focusing the find box (or any input) never zooms the page with no way back. The drawer's Find action is now an icon.
|
|
52
|
+
- **Drawer quick actions no longer clip**: buttons are content-width and wrap, so labels like "Manage" are never cut off; keyboard/share/find are compact icon buttons.
|
|
53
|
+
- **Copy is one tidy control** (`Screen | All`) instead of three loose buttons, matching the other drawer rows.
|
|
54
|
+
- **Calmer disconnect**: connecting/reconnecting/disconnected now read in a muted slate instead of alarm orange/red — a dropped connection should feel recoverable. Only a truly ended session shows red.
|
|
55
|
+
- **Haptics**: the key-tap vibration uses the Vibration API on Android/Chromium. (iOS Safari has no web haptic API — Apple removed the `<input switch>` trick in iOS 17.4 — so haptics are a no-op there.)
|
|
56
|
+
|
|
3
57
|
## [0.9.0] - 2026-06-12
|
|
4
58
|
|
|
5
59
|
### New Features
|
|
6
60
|
|
|
7
61
|
#### CLI Client
|
|
62
|
+
- **Session panel (Opt+S)**: a file-manager-style overlay — session list on the left, a live screen preview of the highlighted session on the right. `↑`/`↓` (or `j`/`k`) fly through sessions with no perceived latency (previews are fetched from the host and cached), `Enter` switches the live terminal to the selected session, `Esc` closes. The footer shows the selected session's working dir, running program, and viewer count.
|
|
8
63
|
- **Scrollback search (Opt+F)**: incremental search over the buffer; all matches are highlighted (current match bright), `Enter`/`↓` jump to the next match and `↑` to the previous one.
|
|
9
64
|
- **Presence names + take/release control (Opt+C)**: peer names (with typing markers) in the status bar and exclusive-control parity with the web client.
|
|
10
65
|
- **Persistent reconnect indicator**: a banner with the failure reason, countdown to the next retry, and attempt count stays visible while disconnected (CLI and web); the CLI now exits with a clear error if the first ~5 connection attempts never succeed.
|
|
@@ -14,12 +69,15 @@
|
|
|
14
69
|
- **Hints toggle moved to Opt+T** (was `Ctrl+T`), matching the other Opt toggles; `Ctrl+T` now passes through to the remote terminal (readline transpose-chars, fzf, etc.).
|
|
15
70
|
- **Adaptive bar themes**: status/hint bars auto-detect light vs dark terminal backgrounds (OSC 11 query, `$COLORFGBG` fallback) with truecolor palettes; pin with `--theme light|dark|auto` or `{"hay-cli": {"theme": "dark"}}` in `.hop.json`.
|
|
16
71
|
- **Status bar redesign**: semantic state dot at the right edge (green connected / amber reconnecting / red disconnected / purple locked), session name as an accent chip at the left edge, dim cwd, `·` separators, `manual` shown only when autofit is off.
|
|
17
|
-
- **
|
|
72
|
+
- **Much bigger scroll buffer**: client scrollback is now 50000 lines (CLI and web), and the server retains ~20MB of raw output per room for reattach snapshots (override with `HAY_SNAPSHOT_BUFFER_BYTES`). The full snapshot is replayed and parsed on reattach, so this trades memory + reattach-parse time for deeper restored history.
|
|
18
73
|
- **Hint bar redesign**: keycap-style hints (`⌃G detach · ⌥F find …` on macOS) with keys at full strength and labels dim; notices render as toasts (`✓` confirmations, `!` warnings, accent edge for info); the hint line surface is one step fainter than the status line.
|
|
19
74
|
- **Clearer session feedback**: "Created new session" vs "Attached to (N participants)" notice on connect, named control-handoff notices ("alice took control…"), friendlier control-locked rejection with the controller's name, and a reattach hint on detach.
|
|
20
75
|
- **CLI argument validation**: unknown flags and extra positional arguments are rejected with an error instead of silently becoming the room name.
|
|
21
76
|
|
|
22
77
|
#### Server
|
|
78
|
+
- Every session's PTY env now carries `HOP_SESSION=<room id>`, so a Claude Code SessionStart hook can map a running conversation back to its hop session (enables `hop restore` to resume the exact conversation per session).
|
|
79
|
+
- Room summaries now report the foreground process name (node-pty's process getter, read fresh per summary), live cwd, and last-activity timestamp, so the session manager can show what each session is running, where, and when it was last active.
|
|
80
|
+
- Rooms expose a bounded preview source (size + output tail) for on-demand screen previews in the session manager (`GET /rooms/:id/preview`).
|
|
23
81
|
- Optional `created` flag on `hello` and `by` attribution on `session_ended` (both backwards compatible).
|
|
24
82
|
- Invalid client messages are rejected with the message type and offending field (e.g. `Invalid resize message: rows …`) instead of a bare "Invalid message".
|
|
25
83
|
|