Repository navigation
Android app - #1124
Open
CPlusPlus17 wants to merge 286 commits into
Open
Android app#1124CPlusPlus17 wants to merge 286 commits into
CPlusPlus17 wants to merge 286 commits into
Conversation
…n a question (A38) Quick approve required the node to show exactly BLOCKED before it would answer an approval. The desktop publishes a concurrent (subagent) approval with its hook-reply ticket while the node keeps the parent's WAITING badge for a held AskUserQuestion (recordAgentEvent in src/core/agent-status-mirror.ts, pinned by src/core/pending-question.test.ts), so the phone answered "Already handled" for a request nobody had answered, and the subagent sat waiting until the hold timed out. QuickActions now splits the gate by how the answer is delivered: - Ticketed approval (pendingId != null): the node-state requirement is dropped. The CARD decides: a fresh card that is resolved is "already handled", an unresolved one is answered through answerApproval, and the host itself reports "gone" once the hook's hold ended. The post-"gone" EXPIRED/ALREADY_HANDLED choice uses the same card gate. - Ticketless approval (claude, keys `1`/Esc): unchanged, still requires BLOCKED. WAITING is not accepted: `1` on a node whose question picker is on screen would pick option 1 of the question. - Question (digits): unchanged, still requires WAITING, never BLOCKED. Departure from the audit's proposed fix, per the verifier: the relaxation is limited to the ticketed path; the keys paths keep their exact-state gates. One refinement the verifier did not cover: when the fresh feed no longer lists the card (the host trims its feed at 50 events, keeping only each node's newest unresolved ask), there is no card to consult, so the ticket gate falls back to the node being in a needs-you state (BLOCKED or WAITING) rather than answering blind or refusing outright. Nothing is typed on this path and the host still refuses an ended hold. This keeps the existing relay interop EXPIRED test (an event id absent from the feed) behaving as before. Verified: new QuickActionsTest (fake HostConnection, 10 cases: ticketed approval on a WAITING node is answered; its gone hold while WAITING is EXPIRED; a gone ticket settled meanwhile is already handled; a resolved ticketed card is not answered on BLOCKED or WAITING; card absent falls back to needs-you state; a host without the answer verb opens the session, no keys; a ticketless approval on WAITING is not typed; on BLOCKED it is; a question on BLOCKED is not typed; on WAITING it is). Mutation checks: against the unfixed QuickActions 4 tests fail; accepting either needs-you state on the keys gate fails the ticketless- WAITING and question-BLOCKED tests; ignoring `resolved` on the ticket gate fails the two resolved-card tests; routing ticketless approvals through the ticket gate fails the ticketless-WAITING test. Full protocol suite (89 tests, 0 skipped) and the offline app type-check pass. docs/android.md notes the new coverage. Not run on a device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The audit asked for an iOS-style "Always allow" on ticketless Claude
approval cards: re-check the node is BLOCKED and the card unresolved, then
type `2`. This commit does not build it, and says why in the three places a
future reader would look.
The digit is not established by this repo. The only statement of it is
docs/hook-reply-approvals.md ("Digit `2`/Always allow keeps using send-keys
in v1"), which describes iOS. No desktop code types `2`, and no captured
prompt pins the option order. Reading the bundle of the Claude Code CLI
installed here (2.1.283; a static read, not a live prompt) shows option 2
is conditional. The Bash prompt pushes `Yes`, then a "don't ask again" row
only when Claude offers one for this ask (withheld when
`suppressAlwaysAllowRule` is set, and behind a feature gate), then an
optional "Yes, and switch to auto mode", then `No`. Other prompts differ
again (the directory prompt is "Yes, for this session" / "Yes, and
remember this directory" / No). So a blind `2` can deny the request or
switch the whole session to auto mode, and the phone has no way to see
which prompt is on screen. A button labelled "Always allow" that can do
either breaks the rule that a guess degrades to nothing.
- QuickActions.kt: the KDoc says there is deliberately no keyed "Always
allow", with the evidence.
- docs/android.md: a Known gaps entry (the verifier asked for one if the
button is not built), naming the layout-independent route: answer
`allow` with `updatedPermissions` from the request's
`permission_suggestions`. Both fields exist in that CLI's hook schema.
That changes the ~/.nodeterm/pending answer file and approvals.answer,
so the desktop, iOS and Android have to change together.
- docs/hook-reply-approvals.md: a caveat on the iOS `2` line.
Departure from the audit: no button and no keyed `2` (the verifier's own
alternative was a Known gaps entry). No behaviour changes, so there is no
new test. Verified: `gradle test --offline` in android/protocol (89 tests,
0 failures) and `gradle compileKotlin --offline` in android/tools/typecheck.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The Usages tab showed only a limit's percentage and reset time, and the
Inbox's approval, question and done cards never showed the node's context
fill (only the live working cards did). docs/mobile-usage-inbox.md asks
for both, and iOS has both.
What changed:
- android/protocol: new UsagePace (pure). The pace is computed only when
resetsAt is known; the window is the limit's own windowMinutes, else
300 min for the session kind and 10080 for the weekly kinds
(weekly_all, weekly_scoped), falling back to group session/weekly.
Elapsed share vs usedPercent gives "<window> usage pace faster/slower",
or "<window> usage on pace" within 5 points (inclusive). The window
label ("5h", "7d", "7h") is derived from the minutes actually used.
The clock is a parameter. Arithmetic is in doubles so hostile values
from the hand-editable mirror are refused rather than wrapped.
- android/protocol: ContextFill, one definition of the context label
(clamped, rounded like the desktop's percentText) and the desktop
context meter's colour bands (>85 red, >=60 yellow, else green).
- InboxTab: UsageBar shows the pace line (warning colour when faster);
EventCard takes inbox.nodes[nodeId].contextPercent and shows a small
ring + "N% context" beside the agent chip; the live working card uses
the same indicator. SessionsTab's row routes through ContextFill too,
so the same node reads the same number everywhere (it truncated before).
Departures from the audit / verifier:
- An unknown kind with no windowMinutes gets NO pace line, instead of
the verifier's "else 10080": a future provider's kind is not assumed
to be weekly (a guess degrades to nothing, never to a wrong verdict).
group == "session"/"weekly" is used as a second hint.
- No verdict when resetsAt has passed (the percentage is the old
window's) or when more time is left than the assumed window (the
length is wrong for this limit, e.g. a plan whose session window is
not five hours).
- "on pace" band added; the doc names only slower/faster.
Verified: UsagePaceTest (12 tests: null resetsAt, kind defaults for a
mirror limit with windowMinutes null parsed through ProjectsParser, a
reported window winning, unknown kinds, band boundaries, reset/overlong
windows, injected clock, hostile values, window labels, context
rounding/bands, context lookup per event node). Mutation-checked by
temporarily removing the kind defaults (7 failures), the overlong-window
refusal (2), making the band exclusive (1), truncating the context
percent (1), switching to Long arithmetic (1) and ignoring a reported
windowMinutes (2); all restored. Full protocol suite and the offline app
type-check pass. No wire or host-service change. Not run on a device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…e, under a lock (A48) The phone remembered the Inbox events it had announced or read as a bare id set, trimmed with `(set + ids).toList().takeLast(500)`. SharedPreferences hands the set back as a HashSet, so past 500 entries every new id evicted an ARBITRARY old one, and an evicted event that was still unresolved and younger than the 6 h announce window was notified again. The read-modify-write was also unlocked while the Inbox worker, the terminal screen and the Inbox tab all called it, so concurrent updates could lose each other. What changed: - New pure `SeenLog` in android/protocol (model/SeenLog.kt) with an injected storage and clock. It keeps id -> anchor, the latest of when the phone saw the event and the event's own ts, as one JSON object. An entry is pruned only when its anchor is older than the announce window plus an 18 h margin (24 h). An event stops being announceable 6 h after its ts on the same phone clock, and the anchor is never earlier than ts, so nothing still eligible is ever pruned, even when the desktop's clock runs ahead. - Every entry point is @synchronized across the whole read-modify-write. `claimAnnounceable` decides and records in one locked step, so the worker's check-then-mark no longer has a window either. - The pre-A48 id set (pref `seenEvents`) is migrated on first use as "seen now": the upgrade re-announces nothing, and those ids age out one retention period later. The same prefs edit that writes `seenEvents.v2` removes the old key. - The 6 h window moved from InboxNotifier into `SeenLog.ANNOUNCE_WINDOW_MS`, so the retention is derived from it. The check is written as `ts > now - window`, which cannot overflow on a garbage ts. - HostStore delegates `markSeen(events)` / `claimAnnounceable(events)` to it. The callers pass InboxEvents instead of ids, so the anchor can carry the ts. InboxNotifier.announce uses the claim. - android/README.md: one paragraph on the once-per-event rule. Departures from the audit's proposal: - The lock is on SeenLog (a @synchronized core, like SecretStoreCore and RelayApprovalGate), not a @synchronized HostStore.markSeen. HostStore only delegates, and all the state it guards lives in SeenLog. - A count bound remains, but only as a memory backstop (5000 entries, far beyond what a real Inbox reaches: the desktop caps its feed at 50 events). It exists for a desktop that keeps minting future-dated events, which would otherwise be kept forever. When it applies, it drops the OLDEST anchors, never hash order. - apply() is kept, as the verifier said. Verified: - New SeenLogTest (11 tests): claim-once, the announce window and resolved events, no eviction of an eligible event after 1000 more ids, retention boundary, desktop clock ahead of the phone, anchor never moves back, legacy migration (by claim and by read), tolerant decode, the backstop drops the oldest, and a deterministic lost-update test (the first read waits for a second reader). - Mutation checks, each reverted afterwards: - removing @synchronized from markSeen fails the concurrency test; - a hash-order takeLast(500) instead of the age prune fails the eviction, retention and migration tests; - an anchor that ignores the event ts fails the clock-ahead and anchor tests; - ignoring the legacy set fails both migration tests. - `gradle test --offline` in android/protocol: 112 tests, 0 failures, 0 skipped. - Offline app type-check (android/tools/typecheck `gradle compileKotlin --offline`) passes. Not verified on a device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…pboard write (A53) The terminal page forwarded any OSC 52 payload to the bridge with no size limit (xterm.js accepts OSC payloads up to 10,000,000 characters), the Kotlin side decoded it leniently (android.util.Base64 skips characters it does not know) and called setPrimaryClip with no try/catch. The clipboard write is a binder transaction into the system server, whose buffer is about 1 MB and shared, and ClipData parcels text as UTF-16; a failed transaction is rethrown from setPrimaryClip as a RuntimeException, so pane output could crash the app. - android/protocol: new Osc52.parse, mirroring the desktop's parseOsc52 (src/renderer/terminal/osc52.ts): the ';' separator is required, a '?' read query is refused, base64 is decoded exactly as atob does (WHATWG forgiving-base64) and UTF-8 strictly (CodingErrorAction.REPORT, one leading BOM dropped as TextDecoder drops it). Results are Copy / Ignored / Invalid / TooLarge. - Caps: MAX_TEXT_CHARS = 100,000 UTF-16 units (a parcel of about 200 KB) is the real one; MAX_BASE64 = 400,000 is the most base64 such a text can take (3 UTF-8 bytes per unit), checked on the raw payload first so an oversized sequence is refused before anything is decoded. - terminal.js reads the cap over the bridge (copyLimit(), so there is one definition), refuses an oversized payload before it crosses (onCopyTooLarge), and otherwise hands the WHOLE sequence to onCopy so Kotlin applies the desktop's rules. - TerminalController parses on the bridge thread, wraps setPrimaryClip in try/catch, and toasts when a copy was over the cap, rejected for size (TransactionTooLargeException in the cause chain) or failed otherwise. Departures from the audit's proposal: the verifier's correction is followed (the desktop's 1,000,000 cap is not enough on Android). The task suggested about 350k base64 characters; I used 400,000 paired with a 100,000-character text cap, so the base64 cap never refuses a text the text cap would take (pinned by a test with 100,000 three-byte characters landing exactly on it). An empty decode writes nothing, where the desktop writes an empty string, so the phone never says "Copied" over an emptied clipboard. Invalid payloads stay silent, as on the desktop. Verified: Osc52Test (12 cases: cap incl. the 986,675-character payload tmux forwards, missing ';', '?', invalid base64, invalid UTF-8, BOM, normal and multibyte payloads) and TerminalJsOsc52Test, which runs the app's real terminal.js in node against stub xterm/bridge objects (src/test/interop/terminal-js-driver.cjs). Mutation-checked: the HEAD terminal.js fails all 3 JS tests; removing either cap, making ';' optional, REPLACE instead of REPORT for UTF-8, skipping bad base64 characters or accepting '?' each fail at least one Osc52Test case. Full protocol suite (127 tests, none skipped) and the offline app type-check pass. The try/catch and toasts in the app are not tested (no Android runtime here), and nothing has run on a device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
PairingClient read the pairing answer from whatever host:pairPort the scanned code (or a nodeterm://pair link from any web page) names, and trusted it: `ByteArray(contentLength)` with no sign or size check, an unbounded read-to-EOF when no length was sent, and only a per-read timeout, which a server trickling one byte at a time never trips. A huge length was an OutOfMemoryError that took the app down, a length too large for an Int silently became the unbounded EOF read, and a negative one reached PairScreen as NegativeArraySizeException's bare "-1". Now: - A Content-Length is checked before anything is allocated: negative, non-numeric, or above MAX_RESPONSE_BYTES (64 KiB) is a PairingException with a sentence. The header cap is the same 64 KiB and now also a sentence instead of "Couldn't reach ...". - With no Content-Length the body is read to EOF but never past 64 KiB. - The whole exchange, connect included, has a deadline (45 s default, constructor parameter). A watchdog coroutine closes the socket when it runs out, and also when the caller is cancelled, because cancellation alone never unblocks a thread parked in a socket read. It starts UNDISPATCHED so a cancellation that lands before it would have been scheduled still closes the socket. - PairingException.userMessage frames anything that is not a PairingException as a sentence; PairScreen shows that and rethrows CancellationException. The desktop's largest real answer (sealed, relay leg + pin) measures 606 bytes including headers through a counting proxy against the real createPairingService; a new interop test pins it under 4 KiB, so 64 KiB (also the desktop's own cap on the request, MAX_BODY_BYTES) leaves wide room. No desktop code or wire contract changed. Verification: PairingClientBoundsTest drives scripted local servers: Content-Length 2147483647 / 99999999999 / 26 digits / 65537, -1, "abc", an endless body without a length, answers exactly at the cap (with and without a length), a trickle past a 1.5 s deadline with a 5 s read timeout, and caller cancellation against a silent server. Mutation checks: with the HEAD read logic (constructor param kept so it compiles) 5 of 7 fail (OOM, bare -1, read timeouts, "not JSON" after reading everything, the cancel test waiting out the read timeout); dropping the close-on-cancel fails the cancel test (5009 ms); `>=` instead of `>` at either cap fails the at-cap test. Full protocol suite (135 tests, interop included, none skipped) and the offline app type-check pass. Nothing ran on a device. Departures from the audit: the deadline is a socket-closing watchdog rather than a bare withTimeout (withTimeout cannot interrupt the blocking read); a non-numeric Content-Length is refused rather than read as "no length"; the header-size refusal became a sentence too; PairScreen frames any non-PairingException as a sentence rather than relying only on the protocol layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…ls through to the relay in Auto (A49, A74) What changed: - SshHostConnection: the host-key verifier no longer persists the TOFU pin during key exchange. It records the presented fingerprint and calls HostKeyPin.pin only after authPublickey succeeds, so an sshd that answers at the paired address and refuses the phone's key (another machine holding the old DHCP lease, or the same private range on another network) never becomes the pin. Within one connection a re-key must present the same key as the first exchange, pinned or not. The HostKeyPin KDoc no longer claims the pin is null only "before the first successful connect"; it now says what is true (null until a connect has authenticated). - New pure SshFallback.afterFailure (protocol): what a connect does after the SSH leg failed. A HostKeyChangedException is never used over SSH; in Auto (relay allowed and configured) it goes on to the relay leg with a warning, on "Only on my network" it stops, and with no relay leg at all it stops and says how to get one. Ordinary SSH failures keep their previous behaviour and wording. - HostSession (app) applies it. The relay dial still goes through RelayApprovalGate.decide exactly as before, so a background check still never makes a first relay handshake. A new HostSession.sshWarning keeps the warning visible on the host screen while connected through the relay; it is cleared by an SSH connect and on a failed connect (whose message already carries it). - Error text: the exception states the fact and the likely causes (another machine has the address, a reinstall) before interception; the advice points at Settings -> How to reach each computer -> "Only through the relay", and mentions pairing again only as the way to trust a reinstalled computer's new key, instead of "remove and re-pair" as the only way out. - docs/android.md and android/README.md no longer say a changed key is a hard stop in Auto; the known-gaps list records that the pin is not anchored in pairing and the LAN address is frozen. Why: an auth-failed key exchange pinned whatever answered (A49), and after a correct pin a private-IP collision on another network made Auto fail with an interception warning and never try the independently authenticated relay (A74; the verifiers' refinement). Verified: - New SshTransportTest case: a server that completes key exchange and then refuses our key (and a refused user) leaves the pin null; the first authenticated connect pins exactly the server's key. Mutation check: pinning inside the verifier again makes it fail at the first assertNull. - New SshFallbackTest (5 cases). Mutation check: making the host-key branch always Stop fails two. - Full protocol suite: gradle test --offline, 141 tests, 0 skipped, 0 failures. - Offline app type-check: gradle compileKotlin --offline (--rerun-tasks) passes. - The app wiring (HostSession, HostScreen banner) is type-checked only; nothing ran on a device. Departures from the audit's proposal: anchoring the pin in the sealed /pair answer (desktop returning its SSH host key fingerprints) is left as a follow-up, as scoped; "do not background-dial a host whose SSH never authenticated" is not done (the verifier showed the unpinned window is narrow). The pin is still not cleared or replaced automatically after a relay connect: that needs the relay-authenticated computer to vouch for its new SSH key, which is desktop work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
Correct a false claim written in three places by the A39+A75 fix: that only the desktop shell publishes the mirror's `usage` block. The Server Edition publishes it too: registerCoreHandlers (src/server/handlers/ index.ts) calls setMirrorUsageProvider, and src/server/index.ts boots it and runs initAgentStatusMirror. The claim contradicted docs/mobile-usage-inbox.md's own "A host that runs nodeterm itself (server edition) writes its own mirror with its own usage." The design decision stands on its two remaining reasons, which are now the only ones stated: `usage` is dropped from SSH slices, and it is empty until the first usage poll. - src/core/agent-status-mirror.ts (MirrorClaudeAccount doc comment) - android/protocol/.../model/AccountNames.kt (resolver source order); also reworded source (1) from "every desktop shell" to "both shells (desktop and Server Edition) and the desktop's per-host SSH slice", which is what mirrorClaudeAccount's callers are - docs/mobile-usage-inbox.md (settings.claudeAccounts paragraph) Comment and doc text only, so there is no behaviour to regression-test. Verified: android/protocol gradle test --offline (141 tests, 0 failures), android/tools/typecheck gradle compileKotlin --offline, npm run typecheck, and npx vitest run src/core/agent-status-mirror (198 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
Raise the iOS side of the A56 finding instead of framing it as an Android-only rationale. The earlier caveat on the iOS `2` line ended "Android therefore has no 'Always allow'", so a reader of the desktop PR had no signal that the iOS button is exposed to the same prompt-layout hazard. CLAUDE.md's three-surfaces rule says a mobile implication is raised in the desktop PR with @eneskirca mentioned. Re-verified against the Claude Code 2.1.283 binary installed here (a static read of MYe(), not a live prompt): the Bash prompt pushes `Yes`, then a suggestion / "don't ask again" row only under the XB() gate and only when there is a suggestion, then "Yes, and switch to auto mode" when showEnableAutoModeOption is set, then `No`. So option 2 can be `No` or the auto-mode switch. - docs/hook-reply-approvals.md: the caveat now spells out the full option order, states that the iOS "Always allow" path carries the hazard and is an iOS follow-up for nodeterm-ios (@eneskirca), says this repo cannot see whether the iOS code guards the digit, and names the shared fix (hook `updatedPermissions`, which changes the answer file and approvals.answer on all three surfaces). - docs/android.md, Known gaps: the same iOS follow-up line. Not done here: the reviewer also asked to add A56 to the WP7 list of @eneskirca mobile implications in docs/android-handover.md. That file is maintained by the orchestrator, so the entry is left to it. Docs only, no behaviour change, so there is no test to add. Verified: android/protocol `gradle test --offline` (141 tests, 0 failures) and android/tools/typecheck `gradle compileKotlin --offline`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The Usages pace line compared a usedPercent measured at the desktop's last fetch with the share of the window elapsed at the phone's CURRENT time. Usage only grows inside a window, so any lag between measurement and display could only push the verdict toward "slower". The desktop polls Claude usage every 15 minutes (POLL_MS, usage-service.ts), which is five points of a 5h window, the whole on-pace band; a mirror read over SSH while the desktop app is not running is older still. Someone burning through the window could therefore be told they were on pace. What changed: - UsagePace.of now takes measuredAt (the account's updatedAt, which buildMirrorUsage stamps with the fetch time, and which an error snapshot never pairs with old limits) and now, as required parameters so a caller cannot forget the measurement time. The elapsed share is taken at measuredAt; now still decides the "already reset" refusal. measurementTime() falls back to now when updatedAt is 0 (the parser's default for a missing field), negative, or later than now (a desktop clock ahead of the phone's). A measurement more than one window before the reset is refused like any other length that does not fit. - UsageBar passes account.updatedAt. - UsagePaceTest: a stale snapshot one poll old stays "on pace" where the draw-time reading would say "slower"; a fast burn stays "faster" for as long as a fossil snapshot is drawn and gives no verdict once it has reset; the fallbacks; a pre-window measurement; the parser test now reads the pace at the parsed account's updatedAt. - docs/android.md: the UsagePaceTest sentence says what is measured. Verified: gradle test --offline in android/protocol (144 tests, all pass) and the offline app type-check. Mutation checks: measuring elapsed at now again fails 3 of the new/updated tests, and dropping the updatedAt fallback fails the fallback test. The type-check also rejects the old two-argument call. Not run on a device. The reviewer's alternative (refuse a verdict past a staleness limit) was not taken: judged at its own measurement time, a stale snapshot's line describes the same moment as the bar above it, and the tab already prints its age. docs/mobile-usage-inbox.md describes the iOS app and was left alone; iOS may carry the same draw-time bias. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
… attaches carry no sshRemote Since A09 the relay host attaches an SSH-project node over its ControlMaster, so "the relay host's detached callers pass no sshRemote at all" was false. That attach does not need the probe here: its conn comes from SshProjectManager.sshRemoteFor, which the manager annotated with the same probe at connect. Comment only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…s text Review follow-ups for A53, A54 and A49: - OSC 52: only the payload after the ';' was capped, so a sequence with a multi-megabyte selection field and a tiny payload still crossed the WebView bridge whole. terminal.js and Osc52.parse now ignore a selection field longer than 16 characters (xterm's own are a few letters). - /pair: a non-2xx body was shown verbatim on the pairing screen. The endpoint is whatever the payload names, so its text is untrusted: control characters are dropped, whitespace collapsed, and it is cut at 300 characters. The desktop's own one-sentence refusals are unchanged. - SshFallbackTest: an ordering assertion passed when the expected text was missing (indexOf answers -1); it now asserts the text is present first. Tests: Osc52Test, TerminalJsOsc52Test (the real terminal.js under node), PairingClientBoundsTest; both new guards mutation-checked. Full protocol suite and the offline type-check pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
… A49/A74, A53, A54, A56, A58) Handover: a progress-log table for the batch, and "What is still open" rewritten (A13 for direct-SSH phones, A56's hook-level design, A49 anchoring at pairing, A72 project overrides, the per-host seen log). Audit index: the batch's findings marked, A56 marked as deliberately not built, and the status note explains the 📝 marker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…a phone launch after it leaves (A40) The terminal screen handed a new stream to the main thread by a posted runnable after checking `disposed`/`stopped` once on the background thread. A screen that left in between got the stream installed anyway, and nothing ever detached it. A session the phone had just started ran its launch (900 ms settle, launch line, `projects.registerNode`) inside the attach job with no check at all: leaving the screen detached the stream first, so the line was dropped while the node was still registered with a bare shell; going to the background cancelled the job, so the launch was dropped entirely. The request had already been consumed (`PendingLaunches.take`), so nothing could retry it. The rules now live in android/protocol (`host/TerminalHandoff.kt`), where they are JVM-tested: - `StreamLease`: a stream with holders (the screen, and a launch); the last release detaches it, exactly once. - `ViewerSlot`: every attach takes a ticket; leaving (ON_STOP), closing (dispose) and a newer attach invalidate older tickets, and the hand-off installs a stream only for the current ticket. Any other hand-off releases the screen's hold at once, so the stream is detached instead of shown. - `PhoneLaunch.start`: takes its own hold BEFORE the stream is handed to the screen, runs in the app scope rather than the attach job, types the line after the settle delay, registers, then releases. TerminalController uses them: one sink per attach, gated by its ticket, so a superseded stream, or one only a launch still holds, neither paints into the terminal nor reports its exit as this screen's. The failure/NeedsRelay posts of a stale attach are ignored, and an attach's `finally` clears `attachJob` only if it is still its own. The A13 exit-0 reattach, A36 auto-reattach and A08/A09 relay offer are unchanged (the exit of the current stream still drives them). Departures from the audit's proposal: - (b) Of the verifier's two options, the launch finishes regardless of the viewer instead of ending the session on abort: the user asked for a session, leaving the screen is not cancelling it, and `endSession` would destroy exactly what they asked for. - (a) Instead of @volatile `disposed`, the background thread no longer reads `disposed`/`stopped` at all: the main-thread hand-off decides by ticket, which also covers a superseded attach (ON_STOP then ON_START before the first attach landed installed the old stream, and the new one replaced it without a detach). dispose() still does not cancel the attach job: an attach that lands afterwards is let go of by the hand-off, and a session it created still gets its launch. - The pending launch is taken only when the attach sent the create hint, i.e. only for the session that attach asked the host to create. - A launch whose write throws still registers (neither transport throws from write today): a visible node with a bare shell is better than an invisible one. Verified: `TerminalHandoffTest` (13 tests), mutation-checked: without the launch's own hold 6 fail, a hand-off that ignores the ticket fails 4, `ended`/`leave` that keep the ticket fail 1 each. Full protocol suite (`gradle test --offline`, 160 tests) and the offline app type-check pass. The controller itself is type-checked only; nothing was run on a device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
… (A41) Send cleared the composed prompt unconditionally. With no stream attached (connecting, "Disconnected.", an ended session) the text went to JS, then to Bridge.onInput and a null stream, so it was dropped and the draft was gone. The key-row chips, ^C/^D/^R/^L and the Resume offer had the same silent drop: they looked sent and reached nothing, and Resume also spent the offer. - protocol: InputBar.plan decides what Send does from (attached, Ctrl armed, text, enter): NotAttached, a lone control byte (A34), or a paste plus Enter. NotAttached leaves Ctrl armed. - TerminalController: `attached` gates on state == TermState.Attached, not on reading `stream` (per the verifier: both change in one main-thread post, and all callers are on the main thread). submit() follows the plan and returns whether it was delivered; key(), raw() and acceptResume() do nothing while not attached, and acceptResume keeps the offer. - TerminalScreen: the draft is cleared only when submit() returns true. The Send icon, the Resume button and the sending key chips are disabled while not attached. The keyboard's Send keeps the text in place; the terminal overlay already says why (connecting / disconnected). The Ctrl and keyboard chips stay enabled (they send nothing themselves), and a draft can still be typed while reconnecting. Verified: InputBarTest (5 tests) passes; with the `!attached` gate removed from InputBar.plan, 2 of them fail (mutation check done). Full protocol suite (gradle test --offline, 165 tests) and the offline app type-check (gradle compileKotlin --offline) pass. The screen wiring is only type-checked; nothing has run on a device. Departures from the audit: the decision moved into a pure protocol function so it is testable, and acceptResume/Resume were gated too (same silent drop, not named in the finding). No notice was added for the keyboard's Send; the kept draft plus the existing state overlay say it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The terminal's WebViewClient overrode only shouldOverrideUrlLoading. When
the renderer process was killed (memory) or crashed, the default
onRenderProcessGone (false) took the whole app down with it.
- protocol: `host/TerminalPage.kt`, JVM-tested.
- `TerminalPage`: the page by generation. `build()` per WebView,
`ready(gen)` hands over queued JavaScript only to the current page,
`lost()` retires the generation and drops what was queued for the
dead page, and JavaScript offered while no page exists is kept for
the next one (a reattach can paint before the new WebView is built).
`isReplacing` holds an attach back until a replacement page has
reported its size.
- `RendererRecovery`: who reattaches. A kill (didCrash false) is
reattached automatically; a crash offers "Reopen terminal", since the
reattach repaints the same screen and could crash it again in a loop;
more than two automatic reattaches within a minute fall back to the
offer too.
- TerminalController: onRenderProcessGone returns true and, for the
current view, retires the page, cancels the attach, detaches the stream
(`slot.leave()`, so a phone launch still holding it finishes, A40),
drops buffered output, resets cols/rows and sizedElsewhere, removes the
WebView from its parent and destroys it, and bumps `webViewKey`. Then
state is Connecting (the new page's first resize or ready reattaches it,
or onStart when in the background) or the new `TermState.ViewLost`. The
bridge carries its page's generation; callbacks of a dead page are
ignored. `pageReady`/`pendingJs` are replaced by `TerminalPage`.
`setRendererPriorityPolicy(RENDERER_PRIORITY_BOUND, true)` lets Android
reclaim the renderer before the app while the screen is not visible
(the stream is detached then anyway). A delayed auto-reattach (A36)
does not override the ViewLost offer.
- TerminalScreen: the AndroidView is wrapped in key(webViewKey) so a new
WebView is built; a ViewLost overlay with "Reopen terminal".
Decision (the verifier left it open): reattach automatically for a kill,
once the new page has reported its size (attaching earlier would claim
the pty at 80x24 on a pty the desktop may share); offer the button for a
crash or a kill loop. As from every other state, leaving the app and
coming back reattaches, the ViewLost offer included.
Beyond the proposal: dispose() now also removes the WebView from its
parent before destroy() (the same helper; WebView.destroy expects a
detached view), a replacement WebView built while the screen is stopped
is paused like its predecessor, and copy callbacks are gated by page
generation too. No typecheck stubs were needed: RenderProcessGoneDetail,
onRenderProcessGone and setRendererPriorityPolicy are in android-all
(API 35), and minSdk 26 covers them.
Verified: TerminalPageTest (10 tests). Mutation-checked: dropping the
generation check in ready(), keeping the queue in lost(), clearing it in
build(), keeping the generation in lost(), not marking replacing,
reattaching on a crash, and removing the kill bound each fail 1-2 tests.
Full protocol suite (gradle test --offline, 175 tests) and the offline
app type-check (gradle compileKotlin --offline) pass. The controller and
screen are only type-checked; nothing has run on a device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The chip ran only `term.focus()` in the terminal page. That cannot bring up
the keyboard: Blink returns early from focus() on the element that already
has focus (xterm's textarea, once the terminal was tapped), nothing asked
Android for the keyboard, and Android's focus (which the keyboard follows)
stayed with the input bar's text field or nowhere.
Following the verifier's recipe, the chip now:
- releases Compose's focus (LocalFocusManager.clearFocus() in the key row),
so the draft field lets go of the keyboard;
- gives the WebView Android's focus (webView.requestFocus());
- moves the page's focus onto the textarea with the new
`nt.focusForKeyboard()`, which blurs before focusing so Blink's
already-focused early return cannot swallow it;
- asks InputMethodManager.showSoftInput(webView, SHOW_IMPLICIT), which does
not depend on the page having seen a touch since it loaded.
TerminalController.focusTerminal() is renamed showKeyboard().
Departure from the audit: the showSoftInput call is deferred by a
Choreographer frame callback AND webView.post {}, not by webView.post {}
alone. Compose's text input has queued its keyboard commands (including the
hide for a field that lost focus) to the next frame; a bare post can run
before that frame and have the keyboard hidden again. The request is
dropped if the WebView was replaced meanwhile (renderer loss, A45).
Tests: TerminalKeyboardChipTest. The page half runs the real terminal.js
under node (terminal-js-driver.cjs gains a textarea stub that follows
Blink's focus rule and a way to call window.nt functions); the Android half
cannot run on a JVM, so its order (clearFocus before showKeyboard;
requestFocus, then the page call, then one deferred showSoftInput with
SHOW_IMPLICIT) is pinned in the source. The node runner is shared with
TerminalJsOsc52Test (TerminalJsDriver). The protocol test task now declares
src/test/interop, the terminal assets and the app's ui sources as inputs,
so editing terminal.js re-runs these tests instead of reporting them up to
date (checked: unchanged -> UP-TO-DATE, edited -> re-run).
Mutation checks: with focusForKeyboard reduced to term.focus() the
already-focused case fails; with the app files at HEAD all four tests fail;
without clearFocus, or with a synchronous showSoftInput, the two source pins
fail. Protocol suite (179 tests, 0 skipped) and the offline app type-check
pass. Nothing has run on a device: whether the keyboard comes up and stays
up is a device check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
… (A76) Opening an Eco-hibernated (Sleeping) session over direct SSH landed on the bare shell Eco left behind with nothing offered: the phone's resume banner fired only when the attach reported `fresh`, which an SSH attach never is, and nothing tells the desktop about an SSH attach (unlike the relay, whose attach fires `agent:wake`). The decision now lives in a pure `ResumeOffer.afterAttach` in android/protocol, which TerminalController delegates to: - fresh (a reboot; only the relay creates a session): the cold-restore resume line as before (cd, managed account, permission mode; A15/A16); - Sleeping (mirror `hibernated`) over SSH: a "Wake <agent>" offer carrying the desktop's own wake line, `Launch.wakeLine` = the agent's resume plus the permission mode and no cd or CLAUDE_CONFIG_DIR, since the pane's shell is the one the CLI exited back to and its tmux env carries the account; - Sleeping over the relay: nothing, because the attach already asked the desktop to wake it and a second `--resume` would land in that CLI as a prompt. It stays an offer the user taps. Accepting a wake writes Ctrl-U first (the desktop's KILL_LINE, so a half-typed line at the prompt is not spliced in) and re-checks the current snapshot: a node no longer Sleeping gets a notice and nothing is typed. The offer is also only installed for the attach the screen still shows (the slot ticket). `paused` is not in the mirror; a shallow pause reads as Sleeping and gets only the tapped offer, which is the explicit Resume the desktop's PAUSED chip gives. A deep pause leaves `hibernated` unset and gets nothing. Verified: ResumeOfferTest (fresh, Sleeping over SSH, Sleeping over relay, paused shallow/deep, fallback ids, unsafe id, withdrawal at the tap, the wake line) and a SshTransportTest case that attaches to the real sandboxed tmux with a Sleeping mirror entry, types the offer after a half-typed line, and sees a stand-in CLI start in the node's folder with the right args. Mutation-checked: dropping the wake branch, the kill-line, the relay exclusion, the tap re-check, or building the wake like the cold restore each fails at least one test. Full protocol suite and the offline app type-check pass. Not run on a device. Departures from the audit's proposal: no cwd/account (per the verifier), plus the kill-line and the tap-time re-check it did not ask for. docs/android.md gains the protocol row, the test description and a known gap: through the relay a Sleeping node the desktop has not mounted stays asleep (the desktop's wake no-ops there) and the phone cannot tell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…ross a terminal (A43) AppContent composed only the top back-stack entry and had no SaveableStateHolder, so a screen's rememberSaveable state died the moment another was pushed on it. Back from a terminal, the Host screen was on its first tab, scrolled to the top, and the Board was on the first project. - protocol: BackStack, a value back stack whose entries each carry a key unique to the ENTRY (a random UUID; never reused while it still names saved state), not derived from the route, which can repeat on the stack. pop/replaceAll/retain RETIRE the keys of entries that leave; retain keeps surviving entries under their own keys. encode/decode save the keys and the retired list with the stack (A22's Saver), drop an entry that no longer decodes (the single-use pairing code) and retire its key, read the A22 format (a bare array of routes) with new keys, and refuse a corrupted save's duplicate key. - Navigator is now an observable holder of a BackStack<Route>; the forgotten- host filter uses retain() instead of clearing the list (so kept entries keep their state). AppContent wraps the top entry in rememberSaveableStateHolder().SaveableStateProvider(entry.key) and, in a SideEffect, calls removeState() for every retired key. The old key(r) around HostScreen is subsumed by the per-entry key (a notification's Host entry is a new entry, so it still starts on its own tab). - BoardTab's selected projectId and InboxTab's showArchive are now rememberSaveable (they were plain remember, so the holder alone would not have kept them). - HostScreen also wraps the tab content in its own SaveableStateHolder keyed by tab, so a tab's scroll/project/archive state also survives switching to another tab and back. Verified: BackStackTest (12 tests) passes; mutation checks done: pop not retiring, decode ignoring saved keys, keys derived from the route, and retired keys being reusable each fail 1-3 of its tests. Full protocol suite (gradle test --offline --rerun, 202 tests) and the offline app type-check (gradle compileKotlin --offline) pass; SaveableStateHolder is the real runtime-saveable artifact there, no stub was needed. The Compose wiring was also exercised in a throwaway headless composition (Compose Multiplatform runtime 1.5.12 desktop, not committed): back from a pushed entry the saved tab comes back, the popped entry's state is not saved, and both survive a save/restore; without the holder the tab resets, without the removeState drain the popped state leaks into the saved bundle. The app screens are only type-checked; nothing has run on a device. Departures from the audit: the key bookkeeping moved into a pure protocol class so it is testable; retired keys are persisted with the stack so an entry that left just before process death is still cleaned up; the per-tab holder in HostScreen goes beyond the finding (tab switches, not only a terminal round trip). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The phone name and the relay API address were stored only by the
top-bar back arrow. The system back (gesture or button) reached
AppContent's BackHandler, which popped the screen and threw the edits
away. An address that was not https was also dropped by the arrow
without a word.
SettingsScreen now has one leave(): store the name, decide the address,
pop. The arrow calls it, and so does a BackHandler inside the screen
(enabled like AppContent's, nav.size > 1, and registered after it, so it
runs instead of the plain pop while Settings shows). Nothing is stored
per keystroke. A refused address is not stored; the field shows an
error while it holds one, and leaving shows a Toast saying it was not
saved.
The address rule moved to the protocol module (ApiBaseSetting, pure
and unit-tested). Departure from the audit: the rule is tightened
beyond startsWith("https://"). It still requires the https:// prefix
(now case-insensitive, since a keyboard may capitalize the first
letter), and the address must also parse as a URL with a host and
without a query or fragment. Measured with okhttp 4.12.0: "https://"
alone was stored as "https:", which OkHttp reads with the appended
/v1/... path as a host named "v1"; "https://?x", "https://:443" and
"https://a b" made Request.Builder.url throw IllegalArgumentException,
which RelayApi does not turn into a RelayApiException. A refused value
that equals the stored one (only an older build could have stored it)
is left alone without a message.
Tests: SettingsLeaveTest. The address rule and the leave decision are
unit tests; the wiring (the BackHandler and the arrow both call leave(),
leave stores before it pops and toasts a refusal, and the file has one
nav.pop() and one write each of apiBase and deviceName) is pinned in
the source, as for A46. The source-pin helpers moved from
TerminalKeyboardChipTest to a shared AppSourcePins. docs/android.md
describes the test; the protocol build's comment on its ui inputs now
names it.
Mutation checks: with SettingsScreen.kt at HEAD, the three source-pin
tests fail; with only the BackHandler line removed, the back test fails;
with accept() reduced to the old prefix check, the four rule tests fail.
Protocol suite: 210 tests, 0 skipped, 0 failed. The offline app
type-check passes. Nothing has run on a device: whether the back gesture
reaches leave() and the Toast shows is a device check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…ng (A42)
The New-session dialog remembered the user's taps while HostScreen
re-listed the host under it every 8 s. Start looked the remembered
project up with `projects.first { it.id == projectId }`, which threw
NoSuchElementException inside the click handler when the desktop had
closed or removed that project meanwhile. The account picker had the
same shape: a managed account removed on the desktop stayed remembered,
no row was drawn as selected (or, with every account gone, no picker
was drawn at all), and Start still sent the stale id to the desktop in
the LaunchRequest.
What changed:
- New protocol helper NewSessionChoice (pure Kotlin):
offeredProjects (the A14 rule: open, not SSH, with a folder),
project(offered, pickedId) = the pick while it is still offered,
else the first offered project, else null, and
account(settings, project, pickedId) = the pick while the host still
has it (null = System), else the project's default via
Launch.defaultAccount, else System.
- The dialog computes `selected` and `accountId` from those on every
recomposition and uses the same values for the radios it draws and
for Start. Start is enabled only when `selected != null` and uses
`selected` (no first {}). The account pick is re-seeded from the
selected project's default whenever the selection moves to another
project, and `accountId` is now a val no tap can overwrite.
- With no project left to start in, Start is disabled and the dialog
says so instead of showing an empty Project list.
- HostScreen's canCreate uses the same offeredProjects rule instead of
its own copy of the filter.
- The agent picker was checked: its list is the static Agent.entries,
so it cannot lose its selection.
Verified: NewSessionChoiceTest (9 cases: listings that change between
taps, vanished and returning projects, removed accounts, empty lists,
plus AppSourcePins pins of the dialog's wiring and HostScreen's rule).
Mutation-checked: reverting SessionsTab.kt/HostScreen.kt fails 3 pin
tests; drawing the project radio from the raw pick fails 1; project()
with first {} throws NoSuchElementException in 3 tests; project()
returning null for a vanished pick fails 1; account() returning the
raw pick, or skipping the project default, fails 1 each. Full protocol
suite (219 tests) and the offline app type-check pass. Nothing has run
on a device.
Departures from the audit: the verifier's derived-selection fix is
followed; on "dismiss or disable" the dialog disables Start and shows
a line rather than vanishing under the user's finger. The pick is not
overwritten on fallback, so a project that reappears in a later
listing is selected again. The account picker got the same treatment
(the task asked to check it), falling back to the project default
before System.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…d (A57) A multi-select AskUserQuestion card showed only "Open session": InboxTab drew option buttons only for `!ev.multiSelect`, so the card did not say what was being asked, although the desktop publishes the options and the multiSelect flag precisely so the phone can show them. The card now lists them, numbered and read-only (plain text, not buttons), under "Choose several — answer in the session." and beside "Open session". A single-select question keeps its answer buttons. What the card shows is one pure rule in android/protocol, QuestionChoices.of(event): Answer (single-select, numbered rows that are quick answers), ReadOnly (multi-select) or None (no options, or not a question). QuickActions.answerQuestion now asks the same rule instead of its own `multiSelect` check, so the card can never offer an answer the action refuses. There is deliberately no answer path for multi-select: nothing in this repo measures how Claude Code's multi-select picker toggles an option or submits, and the phone cannot see the picker. Tests: QuestionChoicesTest covers the rule, and pins in InboxTab's source that the card draws by it, that single-select rows answer through QuickActions, and that the multi-select branch shows the note, the rows as text and "Open session" with no answerQuestion, OutlinedButton or clickable. QuickActionsTest adds that a multi-select question on a WAITING node is never typed. Mutation checks: mapping multi-select to None fails the ReadOnly rule test; letting answerQuestion accept a ReadOnly question fails the new QuickActions test; InboxTab reverted to HEAD fails the three source pins; making the read-only rows answer buttons fails the multi-select pin. Full protocol suite (226 tests) and the offline app type-check pass. Nothing ran on a device. Departures from the audit: none from the verifier's refinement, which replaced the proposed toggleable chips and key sequence (unmeasured) with the read-only listing. docs/android.md (tests and Known gaps) and the android/README.md parity row now say multi-select is answered in the session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…n the host list (A47) The host list labelled each computer "From anywhere" by decrypting its relay device token inside the LazyColumn row: graph.secure.getString(relayTokenKey(id)) != null. That is an AES-GCM open under the Android Keystore key (a binder round trip to keystore2) on the main thread, once per row per recomposition (every ConnState change), and it went through SecretStoreCore's @synchronized read, so composition also waited behind any connection decrypting at the same moment. It ran even for hosts with no relay block, where routeSummary ignores the answer. The list only needs to know a token is STORED: - SecretStoreCore.contains(name) answers from the storage alone: no sealer.open, and not synchronized, so it never waits on the store's lock. A value that cannot be opened right now still counts (the old check read "no relay" during a keystore hiccup); anything that uses the value still reads it with getBytes. - SecretStoreCore.revision, a StateFlow bumped by every putBytes and remove (never by a read), so a screen can re-ask when a secret comes or goes. - SecureStore exposes contains, hasRelayToken(hostId) and revision. - HostsScreen collects graph.secure.revision and each row computes remember(host.id, secretsRevision) { graph.secure.hasRelayToken(host.id) }. Departure from the audit: the verifier said either a hasRelayToken flag on PairedHost or prefs.contains would update through the graph.hosts.update that follows putString in adoptRelayIfAdvertised. That holds only when the host record changes. When the host already has a relay block but no token (paired with remote access on but no token minted), the adoption's update produces an equal PairedHost, the hosts StateFlow does not emit, and the row would keep its stale label. The revision signal covers that and the forget path without a second copy of the fact: the preferences file stays the only source of truth, so there is no PairedHost field to migrate or keep in sync at the four put/remove sites. The dial paths (relayConfigured, hasRelay, the relay leg of connectLocked, adoptRelayIfAdvertised's early return) still decrypt: they need the token itself, or a readable one, and run once per action rather than per recomposition. Verified: new SecretPresenceTest (protocol, JVM): contains never opens the value; stays true while the keystore fails transiently; answers within 2 s while another thread holds the store's lock inside a blocked decrypt; revision moves on put/remove/identity creation and never on reads; and a source pin on HostsScreen (no secure.getString / getBytes, the revision collect, the remembered hasRelayToken feeding routeSummary). Mutation-checked: contains decrypting fails three tests, contains taking the lock fails the lock test, dropping the revision bumps fails the revision test, and HEAD's HostsScreen fails the pin. Full protocol suite (231 tests) and the offline app type-check pass. Not run on a device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…otifications by default (A52)
An approval notification carried the desktop's tool summary (the command's first line, a file path
with its diff size, a fetched URL; without a summary, the agent's last line), a question the
question itself, and a finished turn the agent's last message. Nothing set a public version, and
Android shows a notification's full content on a secure lock screen under its default setting.
What a notification says is now the pure InboxNotificationText (android/protocol). By default it
carries none of the event's own text: the title keeps the session and whether it needs you or
completed ("Needs you — <session>" / "Completed — <session>"), the text names the kind ("Needs
approval", "Has a question", "Finished", "Interrupted"), and there is no expanded text. The
event's title and detail are added only when the user turns on the new Settings switch "Show
details in notifications" (HostStore.notificationDetails, off by default). A public version with
only the title and the computer's name is always set, with VISIBILITY_PRIVATE made explicit.
Following the verifier, the fix does not rely on VISIBILITY_PRIVATE plus setPublicVersion alone:
that hides nothing under Android's default lock-screen setting, so the detail is left out of the
private content itself. The audit proposed calling the switch "Show details on lock screen"; it is
"Show details in notifications", because it controls the notification, which the lock screen then
shows or hides by Android's own setting. The event's title is also hidden by default, not only its
detail: for an approval without a tool summary, and for a question, the title is itself the agent's
text. docs/android.md and android/README.md now note that the iOS app gets the same detail through
the desktop's APNs push body (src/core/push-notify.ts), a difference in platform defaults.
Verified: InboxNotificationTextTest (9 tests) pins the words for each kind with details off and on,
the public version, and the session fallback, and pins in the app's source that InboxNotifier reads
every word from InboxNotificationText (never ev.title/ev.detail), sets the public version, and that
the setting defaults to off and is stored only from its switch. Mutation check: with the old
InboxNotifier, the detail always included, and the default flipped to on, 5 of the 9 tests fail.
Full protocol suite green (240 tests); offline app type-check green (NotificationCompat stub gained
setVisibility, setPublicVersion and the VISIBILITY_* constants, as in androidx.core). Not run on a
device: what a lock screen shows is a device check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The Settings switch, the README and InboxNotifier's comment all promised notifications "live every 8 seconds while a computer is open", but InboxNotifier.announce had one caller: the 15-minute WorkManager job. The in-app 8 s poll only updated the listing. What changed: - HostSession.refreshNow now runs the announce path on every listing that arrives (the 8 s poll of the computer on screen, a change the computer pushed, the background check). The call sits outside the listing's try, so a notification the system refuses is never read as a transport failure (which would drop a working connection). No network is added: the listing already arrived. - What the user is looking at is left out and recorded as seen: every event while that computer's Inbox tab is on screen, and the events of the session open in a terminal. A new OnScreenTracker on HostSession counts those screens (InboxTab and TerminalScreen register while STARTED; the terminal registers before its watch starts, so the first listing knows). - The decision is in protocol: OnScreen.split (pure) and SeenLog.claimLive, which records the on-screen events and claims the rest in one locked step. It records on-screen events even with notifications off or not allowed (A21's canPost + the 13+ permission), so turning them on later does not announce what the user already looked at. It writes only what is new and skips the read entirely when nothing in the feed is announceable, so the 8 s path stays cheap. - The worker no longer announces a second time from session.snapshot.value: a listing announces itself, with the same on-screen view, and a failed one has nothing new. - Copy fixed in Settings, the InboxNotifier KDoc and android/README.md: "Checked about every 15 minutes in the background, and live for the computer whose screen is open"; the README now says other computers are not polled. docs/android.md gets a paragraph on the test. Verification: new LiveNotificationsTest (16 tests: the split, claimLive against the background check, shown-then-left, terminal node, notifications off, no-write on repeat, tracker counting and idempotent close, plus source pins for the refresh, announce, worker, both screens and the three copy sites). Mutation-checked: restoring the pre-fix app wiring and copy from HEAD fails 5 tests; dropping the inbox branch of OnScreen.shows, ignoring onScreen in claimLive, returning early when notify is false, re-recording known ids, making close non-idempotent, removing the refresh's announce, restoring the worker's announce, dropping the Inbox tab's registration and reverting the Settings copy each fail at least one test. Full protocol suite (256 tests) and the offline app type-check pass. Nothing has run on a device. Departures from the audit's proposal: announce runs from refreshNow for every successful listing rather than only from the poll loop, so push-triggered refreshes and the worker share one path and the worker also skips what is on screen; the worker's stale-snapshot announce after a failed refresh is dropped. HostStore.claimAnnounceable is replaced by claimLive (SeenLog.claimAnnounceable stays, as the nothing-on-screen case). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
…he relay deviceId to its key (A51) allowBackup="false" stops cloud backup, but for an app targeting Android 12+ (targetSdk 35 here) it does not stop a device-to-device transfer; only the <device-transfer> section of the data extraction rules does. The transfer copied nodeterm.hosts (every paired computer, its SSH pin and the relay deviceId) to the new phone, where the Keystore-sealed secrets could not follow, so the new phone regenerated its box key and kept presenting the OLD deviceId beside it. What changed: - The manifest names res/xml/data_extraction_rules.xml, which excludes every domain (root, file, database, sharedpref, external and the device_* variants, path ".") from both <cloud-backup> and <device-transfer>. Each domain is backed up as its own tree, so each is excluded. allowBackup stays false; no fullBackupContent is added, because on Android 11 and lower allowBackup="false" already disables device transfer too. - New PhoneIdentity (protocol): a deviceId never outlives the box secret it was minted with. When the box secret has to be created (absent, or provably lost per SecretStoreCore), the stored deviceId is removed first, durably, and only then is the new key written; deviceId() resolves the box secret before it reads or mints an id. SecretStoreCore.getOrCreate32 gained a beforeCreate hook that runs under its lock only when a value is about to be created (nothing is created if it throws). The app reads the deviceId only through AppGraph.identity; HostStore's deviceId getter is gone and HostStore supplies the storage (same "deviceId" key, so existing installs keep their id while their key still opens). Durable writes use commit() and throw when it fails. Effect on the relay backend: it keys the device row on deviceId and the mint upserts on it. Per the desktop's note on priorDeviceToken (src/main/pairing-service.ts; the backend is not in this repo), a free-tier re-registration of a known deviceId asks for the previous device token and can refuse without it, leaving the pairing LAN-only (a 502 on a relay-only Windows desktop). A phone whose key was lost now registers under a new id instead, and no longer shares a row with the phone it was copied from, so removing one pairing on an entitled desktop cannot revoke the other phone. The old row is left unused, as after an uninstall. Verified: PhoneIdentityTest (9 tests: fresh phone order, restart, legacy id kept, transferred phone with a replaced Keystore key, preferences without the sealed file, remove-before-create ordering across both files, deviceId-before-key, transient keystore error, failed removal) and BackupRulesTest (2 tests parsing the manifest and rules, and checking every getSharedPreferences file is covered). Mutation checks: dropping the beforeCreate hook fails 5 PhoneIdentityTest cases, not resolving the key in deviceId() fails 7, running the hook after the write fails 3; removing dataExtractionRules from the manifest, removing <device-transfer>, or narrowing the sharedpref exclusion each fail both BackupRulesTest cases. Full protocol suite (267 tests) and the offline app type-check pass. No desktop code changed. Not run on a device: what a real transfer copies is a device check. Departures from the audit: excludes everything rather than naming the two preference files (the test still accepts either form); the deviceId stays in nodeterm.hosts coupled by rule rather than moving into SecureStore, so existing installs keep their id without a migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The app targets API 35, so on Android 15 its window is edge-to-edge whether it asks or not: adjustResize no longer resizes it and the keyboard's inset reaches Compose. The terminal screen applied the Scaffold's padding (which already holds the navigation bar) and then imePadding(), whose inset also includes the navigation bar, so with the keyboard up the navigation bar was counted twice. Pair and Settings, whose text fields sit in a scrolling column, had no keyboard padding at all. One helper, Modifier.aboveKeyboard(padding) in ui/Insets.kt, now does padding(p).consumeWindowInsets(p).imePadding(), and the terminal, Pair and Settings Scaffold bodies use it (before the verticalScroll, so the keyboard shrinks the scrolled viewport). consumeWindowInsets and imePadding are in the Compose foundation-layout jar the type-check compiles against, with no opt-in marker, so no stub was needed. Departures from the audit's proposal, per the verifier: enableEdgeToEdge() is not called, so API 26-34 stays off the inset path (there the decor consumes the insets and the helper is the Scaffold padding alone). The dialogs were checked and left alone: NewSessionDialog has no text field, and the Board labels / session rename text fields live in AlertDialogs, which are floating windows. Android 15's PhoneWindow (android-all 15 bytecode) clears fit-insets types only for non-floating windows, so a dialog stays framed by the system; an imePadding() inside one could add the keyboard twice. A shared helper instead of three inline chains keeps the rule in one place. Verified: KeyboardInsetsTest (android/protocol, source pins through AppSourcePins) pins the helper's order, that nothing else in the app calls imePadding()/WindowInsets.ime, that every Scaffold body holding a text field uses the helper before its scroll, and that nothing calls enableEdgeToEdge()/setDecorFitsSystemWindows. Mutation-checked: with the three screens reverted 2 tests fail; with only PairScreen reverted, with the helper missing consumeWindowInsets, or with Settings' helper after the scroll, 1 test fails each. Full protocol suite (271 tests) and the offline app type-check pass. Nothing has run on a device; how the screens and dialogs look with the keyboard up is a device check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The release build type had minification off, and proguard-rules.pro kept
sshj/BouncyCastle/EdDSA but lacked the -dontwarn rules R8 in AGP 8 needs.
Turning minification on as it stood would have failed on missing classes
(sshj's GSSAPI org.ietf.jgss.* and javax.security.auth.login.LoginContext,
eddsa's sun.security.x509.X509Key). It also had no keep for the two app
classes reached by name.
What changed:
- proguard-rules.pro: added the three missing -dontwarn rules and kept
javax.naming (BouncyCastle's LDAP/DANE code). Dropped -dontwarn
org.slf4j.**, because slf4j-api 2.0.13 resolves completely. Added the
runtime keeps R8 cannot infer:
- the WebView bridge's @JavascriptInterface methods, which terminal.js
calls by name (plus -keepattributes RuntimeVisibleAnnotations);
- InboxWorker with its (Context, WorkerParameters) constructor, because
WorkManager stores the class name and the periodic work is enqueued
with KEEP;
- com.hierynomus.sshj.** next to net.schmizz.sshj.**, since sshj is
split over both package roots;
- -keepnames for Throwable subclasses, because error text falls back to
e.javaClass.simpleName.
Comments now say what is and is not verified.
- app/build.gradle.kts: release is minified. Debug stays unminified and is
still the distributed APK. There is still no release signing config.
- CI (android.yml): a separate "App release (R8, unsigned)" job runs
:app:assembleRelease, then tools/check-r8-output.sh. The script checks
R8's seeds.txt and mapping.txt: every bridge method and the worker
constructor are seeds, and the worker, BouncyCastleProvider,
AES$Mappings, the X25519 KeyAgreementSpi and HostException keep their
names. The job uploads the mapping directory (including R8's
missing_rules.txt on failure). It is separate so an R8 failure never
withholds the debug APK. No signing secrets are involved.
- R8RulesTest (android/protocol, JVM): reads the constant pools of the
jars this module ships to the app (passed from build.gradle.kts as
nodeterm.runtimeClasspath). It collects every reference Android cannot
resolve and requires each to be covered by the app's -dontwarn or by
the jar's own consumer rules, matching the missing class or its
referrer as R8 does. It also flags stale app -dontwarn rules. It pins
the bridge keep, the worker keep, the Throwable keepnames, a minified
release, an unminified debug, and the CI leg.
How it was verified:
- jdeps over sshj 0.39.0, eddsa 0.3.0, bcprov/bcpkix/bcutil 1.78.1,
OkHttp 4.12.0, okio, slf4j-api 2.0.13, kotlinx and kotlin-stdlib. The
scan finds exactly jdeps' list.
- javap: the reflective call sites are in sshj SecurityUtils and
BouncyCastle's provider tables. OkHttp only reflects on platform
classes. zxing-android-embedded 4.3.0 and zxing core 3.4.1 (fetched
from Maven Central for inspection) neither reflect nor reference
anything Android lacks.
- Full protocol suite (277 tests) and the offline app type-check pass.
- Mutation checks, each making the new test fail:
- running R8RulesTest on the pre-fix rules file (5 of 6 fail; the
missing-class list is exactly the audit's);
- deleting the new -dontwarn lines;
- dropping the JS keep;
- adding allowobfuscation to the worker keep;
- setting release isMinifyEnabled=false;
- removing assembleRelease from CI;
- adding a stale -dontwarn.
- check-r8-output.sh was exercised against synthetic seeds/mapping files:
all good, a missing seed, a renamed class, a missing directory.
Not verified: R8 has not run. AGP needs Google Maven, which is blocked
here, so the first real R8 run is CI. Some points are inferred, not read
from bytes:
- the seeds.txt/mapping.txt formats and location the script greps;
- R8 honouring a -dontwarn that matches the referrer;
- androidx's consumer rules (WorkManager, androidx.startup, Compose).
A green run proves the build and that the keeps matched. It does not
prove that reflection-loaded code works on a device; no release APK has
run on one.
Departures from the audit: the proposal only added -dontwarn lines. This
also enables R8 for release and gates it in CI, as the task asked. It
names LoginContext and X509Key exactly rather than .** wildcards. It
drops the slf4j rule, which the verifier called optional.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
Going to the background while `conn.attach(...)` was in flight still dropped a stream and a phone launch. onStop() cancels the attach job, and a cancelled attach threw away a stream the transport had already opened, with no detach, before PendingLaunches.take ever ran: - Relay: the attach's continuation had no cancellation handling, so the late reply registered the stream and resumed a cancelled continuation (ignored). The host had already reserved it: a phone viewer on the node (an Eco shield and a size ceiling on the desktop) until the relay closed. - SSH: withContext(Dispatchers.IO) drops the finished block's result once the caller is cancelled, leaving the reader thread and the tmux client attached for the life of the connection. The session the host created from the create hint then had no launch line and no registerNode until the next ON_START, and none at all if the process died in the background (PendingLaunches is in memory only). What changed: - Both transports keep a contract now written on HostConnection.attach: a cancelled attach leaves nothing attached. The relay resumes with an onCancellation handler that forgets the stream and sends `pty.kill` (non-suspending `abandon()`; it also covers a cancel after the resume but before dispatch). SSH detaches, under NonCancellable, a stream whose open finished after the cancel (the A20 dial's shape). This also covers the other caller, SessionsTab's "End session" attach. - TerminalController.attach(): ON_STOP (and a renderer loss) still cancel the connect or relay approval wait, but not an attach already sent. After `ensureActive()`, the attach, the launch hand-off and the screen hand-off run in withContext(NonCancellable), so the stream always arrives, is let go of by the ticket, and a created session gets its launch at once. - The relay approval code a dial reports is shown only while its attach is current, so a superseded dial cannot put its code over the current screen. Departure from the review's first suggestion: onStop keeps cancelling, rather than not cancelling at all. Not cancelling would keep a connect or an approval wait running behind a screen nobody sees, and the next attach would wait on that dial's lock without its own approval code (which the new ticket gate now also hides). The boundary is the one the review offered as the alternative: stop cancelling once the request is on the wire. Verified: RelayInteropTest (the desktop's real host through host-fixture.ts, which now has a slow `term-slow-*` probe and emits remoteViewer attach/detach) cancels an attach after the host received it and checks the viewer is released; SshTransportTest (MINA sshd + sandboxed tmux) cancels the attach exactly while the server starts `attach-session` and checks the channel's process exits and no tmux client stays; TerminalHandoffTest pins the controller's attach order and the approval gate in source (only a device runs it). Mutation checks: without the relay handler the relay test fails (no viewer-detached), without the SSH cleanup the SSH test fails (the process never exits), and removing NonCancellable or the ticket gate fails the source pin. Full protocol suite (280 tests) and the offline app type-check pass; the fixture also type-checks with tsc. Nothing was run on a device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
The A41 fix claimed Resume "keeps the offer" while the stream is down, but every way back to Attached goes through attach(), which cleared the offer first, and the reattach is always warm (the phone's own cold attach created the session; an SSH stream is never fresh), so afterAttach never offered it again. The disabled Resume button promised to come back and never did. The unanswered offer is now carried through a reattach of the same screen: - protocol: ResumeOffer.afterAttach takes the offer the screen still shows (`carried`). On a warm reattach a carried RESUME is kept only when the current snapshot builds exactly that offer again for a cold pane: same line, and the node's mirror entry `updatedAt` unchanged (new field `statusAt`). The mirror moves `updatedAt` only on a hook event from the node, so a CLI started in the pane while the phone was away (where the line would arrive as a prompt), another conversation, or a removed node drops it. A carried resume still wins over a stale Sleeping flag, as the first offer did. A fresh reattach builds the line anew. A wake is not carried; every attach re-derives it, as before. - TerminalController: attach() no longer clears the offer; it marks it unsettled. afterAttach re-lists first when it is carrying a resume over a warm attach (the pre-drop listing cannot show what happened meanwhile), then settles the offer on main with the offer still on screen as `carried` (so one the user dismissed is not revived). acceptResume and the button (`canResume`) require attached AND settled, so a carried offer cannot be typed before the reattach has checked it. An attach that carries a launch clears the offer (it types its own line). - TerminalScreen: the Resume button is enabled on `canResume`. - docs/android.md: says what is carried and when it is dropped. Verified: ResumeOfferTest (15 tests, 5 new) passes. Mutation checks: disabling the carry in afterAttach fails 2 of the new tests; building offers without `statusAt` fails the "heard from the node since" case. Full protocol suite (gradle test --offline) and the offline app type-check (gradle compileKotlin --offline) pass. The controller wiring is only type-checked; nothing has run on a device. Departures from the review's suggestion: the carry is re-validated against a fresh listing rather than re-installed blindly, and the offer cannot be tapped until that check is done. Leaving the screen and reopening the node is a new controller and still offers nothing (out of scope; noted in docs/android.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa
Capture bounded retained rows behind the actual output and geometry queues when mouse reporting is off. Use the requested mouse encoder when it is on, and bind session-host requests to the original generation, subscriber, and socket without replay. Add snapshot paging, ownership, queue, encoder, uncertainty, and real-host regressions with passing mutation controls. Co-authored-by: Codex <codex@openai.com>
Advertise scrollV1 only on a safe attached route and return bounded immutable history pages owned by that viewer. Keep cached gestures read-only, fence late replies after explicit input or viewer retirement, and preserve drag, momentum, live parsing, copying, and links. Update the Android client and actual native/session-host interop producers in the same change. Cover stale results, requested mouse encoders, negotiation, cleanup, and CI registration with regression and mutation checks. iOS adoption is recorded in the companion documentation. Co-authored-by: Codex <codex@openai.com>
Record the fixed A129 behavior, bounds, negotiation, immutable paging, Android invalidation, and iOS adoption requirements. Keep the regression proof layers separate and preserve pending full publication and physical acceptance, the installed beta, and the original checklist. Co-authored-by: Codex <codex@openai.com>
Cancel the old scroll and native pending movement on touch-down, preserving the captured history until the click. Exercise child event bubbling with three regressions for held, clicked and cancelled presses. Focused control and restored runs pass 48 tests across 7 suites; old-handler, premature-close and parent-gesture mutants fail by behavior. Actual WebView acceptance remains separate. Co-authored-by: Codex <codex@openai.com>
Record A129 source gates, exact-head Android CI and the signed beta 18 artifact. Add the A130 historical failure and focused source fix with separate control and mutation evidence. Keep real WebView, physical phone, provider and hosted-backend acceptance pending, with the original 64-row checklist unchanged. Co-authored-by: Codex <codex@openai.com>
Extend item 20 with held, moved and cancelled Live-button touches and late-page refusal. Preserve all 64 physical ledger rows and their existing results. DeviceChecklistDocsTest passes all seven checks; removing only the new A130 item ID produces the intended coverage assertion, and the restored checks pass. Co-authored-by: Codex <codex@openai.com>
Document measured Pixel 7a Send, Find, draft and reconnect results, publication provenance and complete owned-fixture cleanup. Keep native history, held Live touch, provider application and broader release acceptance pending. Co-authored-by: Codex <codex@openai.com>
Verify one original localSettings rule through the shipped Desktop approval consumer: the CLI saves the rule and executes two commands with one hold. Keep Android approval, full questions and wider persistence pending. Co-authored-by: Codex <codex@openai.com>
Verify beta 19 remembered rules and complete question answers through actual phone input and real Claude application. Record saved-profile reuse, changed-host-key refusal and exact disposable fixture cleanup across the Android documentation. Preserve remaining lifecycle and platform checks. Co-authored-by: Codex <codex@openai.com>
Keep buffered bytes and unready-page commands with their original viewer. Preserve snapshot ordering, binary batching and the current ready page's final output before exit. Add deterministic lifecycle tests and record scoped A131 evidence. Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Codex <codex@openai.com>
Capture the visible tmux pane before its shutdown erase and retain it as an inert same-page display with Copy and links after the current viewer ends. Preserve live xterm parsing and clear retired captures; keep settled rows through font and layout changes. Add a shipped-xterm regression with exact native byte fixtures and 26 cases, plus three app lifecycle source pins. Focused tests and twelve renderer / three source-pin mutation checks pass; full gates and device checks follow. Co-authored-by: Codex <codex@openai.com>
Advance the submission epoch only when a viewer or display clears. Font, fit and resize still invalidate pending EOF captures through their revision, without cancelling a valid same-view delayed Enter. Retain the existing swipe-barrier test and add an actual-xterm regression. All 16 focused methods / 27 component cases pass; reinstating the previous epoch placement fails the intended assertion with passing restored control. Co-authored-by: Codex <codex@openai.com>
Record the retained-signer update, active background output and bounded closed SSH display checks. Preserve the original output finding and distinguish clipboard, native history and wider lifecycle checks that remain unverified. Co-authored-by: Codex <codex@openai.com>
Record exact final-marker and Unicode clipboard pastes, post-font OSC8/plain link offers and a fresh viewer check. Preserve the bounded physical scope, remaining device checks and reconciled cleanup evidence. Co-authored-by: Codex <codex@openai.com>
Record the fresh Fedora output discriminator, bounded native Desktop readiness observations, exact live-terminal browser and Share checks, and reconciled device cleanup. Preserve A132 attribution, unverified browser Back, and the original checklist outcomes. Co-authored-by: Codex <codex@openai.com>
Record the Pixel 7a Pair/Back check, unchanged terminal owners, qualified phone-client reattachment, and completed test cleanup. Keep cursor, IME and pending-Send checks unverified.
Keep pending admission and outcome guidance with the live terminal entry. Share the button and IME guard, and retain earlier uncertainty through a later refused attempt while preserving viewer and revision clearing fences. Add twelve behavior and three wiring regressions; verify thirteen mutants, full 1069-method protocol tests and offline app compilation in the private candidate. Keep controlled phone acceptance pending in the Android docs. Assisted-by: Codex
Record the published A137 source, successful Android CI and reviewed private beta 23 APK. Keep installation and the controlled pending-Send phone comparison unverified while the intended device is offline. Preserve the original device checklist and archived beta 22 evidence. Assisted-by: Codex
Reconcile the Android host, terminal and answer contracts with upstream v0.4.2 chat, role-scoped pairing and session backends. Preserve managed launch recovery in the machine-local execution overlay, distinguish the combined revision-6 structured answer contract from either revision-5 parent, and retain disconnected-phone revocation markers. Update real Android interop bundles for the optional SSH native dependency, keep regression fixtures aligned with both contracts, and record the merged-source device verification boundary.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WIP, more details are following.
Works as MVP already.