Skip to content

Docs: F47 designs Nová karta, the holders of a remote tab and the Environment's overview - #314

Merged
immakermatty merged 6 commits into
mainfrom
agent/DEV-6656-917-new-tab-design
Oct 11, 2026
Merged

immakermatty merged 6 commits into
mainfrom
agent/DEV-6656-917-new-tab-design

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Status: ready for review (design only; the steps it lists follow as their own PRs)

Why

Root decision 0195, addendum points 9–12 (HumanAndMachines/Lazurio#523, 2026-10-11), and the final wireframe Matěj approved (HumanAndMachine-ai/prototypes-lazurio #27) change the right panel of the Environment browser:

  • the Launchpad's strip shows every tab of the Environment, T3 Code's the tabs of one thread;
  • "+" opens the same Nová karta in every app: an address bar, the app's tools, Přehled (memory, all tabs, running Modules) and suggestions;
  • a tab taken from Přehled into a thread stays one shared remote tab; it ends when the last place holding it lets go and no agent works in it, after a short grace with Vrátit.

None of this exists in the Platform today. The view's hub only closes tabs it opened, after their viewers leave. No list of tabs, no per-tab facts, no notion of a holder, and no way for T3 Code's origin to write to the Launchpad. This PR is the design note that plan DEV-6656 task 917 (Platform) and plan DEV-6646 task 2026-10-11-501 (T3 Code) implement; I took 917 over from the DEV-6656 thread.

What the design decides (F47 in docs/decisions.md)

  1. Nová karta is a shell element, <lazurio-new-tab>. Apps embed it from /.lazurio/shell.js as they embed <lazurio-rail>, as 0195 point 10 says. In its shadow root it frames one Launchpad page, /new-tab, on the Launchpad's origin. That is the key choice: the page's writes (open, close, restore, stop) are then same-origin and pass the hosted admission unchanged. Drawn directly in T3 Code's page, they would hit the gateway's 405 for non-GET under /.lazurio on the forks' origins and need a Machines release and fleet rollout. The page limits frame-ancestors to the Environment's own app origins.
  2. The element's interface: strip, tools ({id, label, key?}), focus-address, and one lazurio-new-tab event that says whether the tab became a page, a chosen tab or a tool. The element and the page talk over origin-checked postMessage, internal to one Launchpad.
  3. Holders are files their apps' servers write: $XDG_RUNTIME_DIR/lazurio-browser/holders/<strip>.json (0600, atomic rename). T3 Code's server writes one per thread from the threads' stored tabs, so the strip is the same on every device. The Launchpad writes launchpad.json.
    • Holds stay true to the browser (added after Pablo's reviews). The hub writes the ids of the live page targets to $XDG_RUNTIME_DIR/lazurio-browser/tabs.json. Every list is a fresh Target.getTargets snapshot. Snapshots are taken after each discovery, on every change and every 30 s while connected, one at a time; with no connection nothing is written.
    • Times are on the boot clock (boot_id + /proc/uptime), never the wall clock, which can step back. Each snapshot is stamped with the boot-clock time it began (takenAt).
    • Each app's server reconciles its stored tabs and holder file with that list at start, on change and before writing. It drops an id only when a list of the current boot, taken after its record, lacks it. A snapshot lists every target that exists when it begins, also one whose event reaches the hub late, so a live tab is never dropped. A record from an earlier boot is older than every list of this one.
    • Record times survive restarts: T3 Code keeps them in its thread state, the Launchpad in its holder file's recordedAt.
    • A tab that ended before the app recorded it is dropped by the next periodic snapshot, within about 30 s.
    • So a hidden tab closed for everyone, a browser restart and stale ids found at a server's start are cleaned up with no view open. The view's lazurio-browser:closed stays only as the immediate signal for a visible tab.
  4. The view's hub alone ends a tab. "Held" means a holder lists it, or an agent-browser session is bound to it and its daemon is alive. The binding file alone does not count: agent-browser keeps it past the daemon on purpose. The grace is 8 s when the last hold goes, the wireframe's Vrátit window; F39's 30 s and 10 minutes stand for viewer-only tabs. The hub keeps one blank window of its own, because Chrome ends with its last window.
  5. A loopback-only API on the hub for the Launchpad: list, create, close for everyone, restore. It refuses any request carrying Origin or Sec-Fetch-Site, because pages in the Environment's own browser can reach the loopback too.
  6. The Launchpad's overview and actions: memory from /proc/meminfo and the units' memory.current, tabs with opener and holders, running Modules with the existing stop. Read only while Nová karta is visible.
  7. T3 Code (task 501): "+" embeds the element; its server keeps the threads' tabs and their holder files; the strip shows that a tab is shared.

What it changes in earlier decisions

F39 point 9, twice:

  • an agent's window now ends once no agent works in it and nothing holds or views it (before, the view never closed it);
  • after a view restart, unheld tabs get 10 minutes instead of living until a reboot.

What does not change

  • No code, no Folder text, no template revision, no release in this PR.
  • No change to Machines or the gateway; that is a design goal.
  • Uspat (sleeping tabs) stays with DEV-6656 spike 916; until then Přehled offers Zavřít.

Verification

Every mechanism the note relies on was checked in the code before writing it:

  • the shell relay's 405 and listed paths (Machines workloads/workspace-vm/ingress.ts);
  • no framing headers on the Remote Environment gateway;
  • shell.json's apps origin (src/shell/contract.ts), T3 Code embedding <lazurio-rail> from /.lazurio/shell.js;
  • agent-browser's binding file outliving the daemon and the .pid removed on exit (cli/src/native/tab_binding.rs, daemon.rs, 1 h idle default);
  • T3 Code's session name t3-<thread> (its agentBrowserSession.ts);
  • the unit restarting Chrome when its last window closes (src/browser/units.ts);
  • the view's loopback Host rule (src/browser/people/service.ts);
  • the Launchpad knowing the view's loopback port from entry.browser;
  • the wireframe's 8 s grace (GRACE_MS).

bun run check:public passes. The note is docs only.

Risks and open questions

Next

Steps 1–3 (hub, Launchpad overview and actions, page + element + panel) as Platform PRs. Then step 4 in Lazurio/t3code, then a Platform release and Matěj's check in a browser (root decision 0178).

🤖 Generated with Claude Code

…rview

Root decision 0195 points 9-12 and the approved wireframe give every app's right
panel the same Nová karta and say when a shared remote tab ends. F47 designs it
before the code: a shell element, <lazurio-new-tab>, that apps embed like the
rail and that frames one Launchpad page, so its writes stay same-origin and pass
the hosted admission with no gateway or Machines change; holder files that the
apps' servers write; and the view's hub as the only place that ends a tab, with
the 8 s grace of Vrátit and a loopback API that refuses browser requests.

It changes F39 point 9 twice: an agent's window ends once no agent works in it
and nothing holds or views it, and after a view restart unheld tabs get the
10 minutes instead of living for good. Plans DEV-6656 task 917 and DEV-6646 task
2026-10-11-501 implement it in the five steps the entry lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — CHANGES_REQUESTED on bb3efab (F47 design only).

Blocking: F47 points 6 and “Failure modes” (docs/decisions.md:6907, 6948–6951) say that a closed tab disappears from every strip via lazurio-browser:closed, including after a browser restart. But the proposed notification is emitted by the view page to its embedding app; only a currently viewed tab has a view page/socket. A T3 thread can hold a tab in its server-side stored list while another tab/thread is displayed, or have no panel open at all. When the hub explicitly closes that hidden tab for everyone, or Chrome restarts and all target IDs disappear, that app receives no event. Its stored tabs and holder file keep dead IDs; on T3 server restart, the design rewrites the same stale IDs from storage. The hub ignores them, but the thread strip and shared-holder labels remain stale, contradicting the all-tabs and close-for-everyone contract.

Please specify an authoritative reconciliation of each app server’s persisted tabs and holder files against the hub’s live target IDs, including startup/reconnect, explicit close and browser restart while no tab is viewed. Clarify how a server learns a closure and removes the dead ID without requiring an active view, and add that failure mode/verification to the implementation steps. This can be polling, a server-to-server signal or another bounded mechanism; an event from an individual view alone is insufficient.

Verified: exact detached head and base, F36/F38/F39 and 0195(5,9–12), Machines shell relay and hosted admission, agent-browser binding/daemon lifecycle, clean git diff --check, bun run check:public PASS (660 files). No code or merge performed.

Pablo's review of bb3efab: a view page's lazurio-browser:closed only reaches an
app while someone views that tab. A hidden tab closed for everyone, or a browser
restart, would leave dead ids in a T3 Code thread's stored tabs and holder file,
and a restarted server would write them back.

The hub now writes the ids of the browser's live page targets to its own file,
$XDG_RUNTIME_DIR/lazurio-browser/tabs.json, after each discovery and whenever a
target appears or ends, never while it does not know them. Each app's server
reconciles its stored tabs and holder file with it at start, on change and
before writing, dropping only ids recorded before the list was written. The
view's message stays as the immediate signal for a visible tab. Failure modes,
rejected variants and the steps' tests say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

Fixed in 526b8e3 (re-review requested on that head). You were right: lazurio-browser:closed reaches an app only while someone views that tab, so a hidden tab closed for everyone or a browser restart would leave dead ids in a thread's stored tabs and holder file, and a restarted server would write them back.

F47 point 4 now has one authoritative list. The hub writes the ids of the browser's live page targets to its own file, $XDG_RUNTIME_DIR/lazurio-browser/tabs.json:

  • after each discovery and whenever a target appears or ends;
  • never while it does not know the browser's targets.

Each app's server reconciles its stored tabs and holder file with it at start, on change (watched, plus a 30 s re-read) and before writing. It drops only ids it recorded before the list was written, so a just-recorded tab is never dropped. The view's message stays only as the immediate signal for a visible tab. Point 6 says why it is a file and not the loopback request: T3 Code's server knows neither the view's port nor whether the view runs.

Also changed:

  • failure modes: hidden close or end, browser restart, a server's start with stale ids, a tab recorded before the list has it, the hub down or not knowing the targets;
  • two rejected variants;
  • the steps: steps 1, 2 and 4 must prove those cases with tests.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — CHANGES_REQUESTED on exact 526b8e3 (F47 design only).

The new tabs.json reconciliation addresses the original hidden-close/restart case when a hold predates the updated list, but its timestamp guard leaves a late-hold race unresolved (docs/decisions.md:6875–6887, 6971). Example: Nová karta reports an id to T3 Code, but before its server records the hold the target ends and the hub atomically writes a list without that id. If the app request/event is delayed and the server records the id after that write, the only authoritative list is older than the hold. The 30 s re-read, startup reconciliation and pre-write check all read the same file and refuse to remove the id; with no further target/discovery event, the dead id remains in the thread strip and holder file indefinitely, even across T3 server restarts. The same can happen when selecting an overview tab that ends before its hold is recorded.

Please make late records converge too (for example a bounded periodic fresh discovery/list generation while the hub knows the browser, or an explicit causal freshness protocol), while preserving the guard that a genuinely new target is not deleted before the hub observes it. Specify how the app timestamps survive server restart and test the ended-before-record case alongside the four listed tests. Do not rely on a viewed tab or a future unrelated target event.

Verified in isolated detached checkout: head/base pinned, docs-only diff, git diff --check clean, bun run check:public PASS (660 files) under the heavy lock. Live CI pass/skipped, zero unresolved threads. No merge performed.

Pablo's review of 526b8e3: if a tab ends before an app records it, the list
the hub wrote at the end is older than the record, so the guard keeps the dead
id, and without another target event no newer list ever comes.

The hub now also writes the list every 30 s while it knows the browser's
targets, so every record meets a newer list. An app drops an id only when a list
written at least 5 s after its record lacks it: the hub sees a target within
milliseconds of Chrome creating it, before any app can learn the id. Apps keep
each record's time in their own store (T3 Code's thread state, the Launchpad's
holder file's recordedAt), so it survives a restart. The failure modes and the
steps' tests name the ended-before-record case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

Fixed in df2c45f (re-review requested on that head). Right again: if the tab ends before the app records it, the list written at its end is older than the record, so the guard keeps the dead id, and no newer list ever comes without another target event.

Point 4 now:

  • Periodic list. The hub also writes the list every 30 s while it knows the browser's targets, so every record meets a newer list. It still never writes while it does not know them.
  • Causal margin. An app drops an id only when a list written at least 5 s after its record lacks it. The hub sees a target within milliseconds of Chrome creating it, and an app only learns an id after that (from the hub, the view or the browser). So a genuinely new tab is never dropped, and an ended-before-record id is dropped by the next periodic list, within about 35 s.
  • Times survive restarts. Each app keeps its record times in its own store: T3 Code's thread state, the Launchpad's holder file's recordedAt (the hub ignores it). A stored tab without a time counts as recorded long ago.

The failure modes have an ended-before-record row, and the steps' tests list that case.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — CHANGES_REQUESTED on exact df2c45f (F47 design only).

The periodic rewrite closes the previous ended-before-record liveness race when the hub already has an accurate target set, but the new 5 s margin does not establish the claimed causal ordering (docs/decisions.md:6878–6886, 6979–6980). A tab can be created through agent-browser/direct CDP (src/browser/window.ts:204–215) or learned from the browser on a connection separate from the hub’s Target.targetCreated stream (src/browser/people/hub.ts:579–586). If the app records this live id at t=0 while that event is delayed, the hub can periodically rewrite its cached target set without the id at t=6. The app then drops the genuinely live id, removes its hold, and the hub can end the tab. “Within milliseconds” is an expectation, not a cross-connection ordering guarantee; waiting 5 seconds cannot prove the hub observed the creation. Please make periodic lists authoritative by fresh browser discovery/snapshot after the record (with the timestamp tied to snapshot initiation), or specify an equivalent positive observation/causal protocol, and include the delayed-discovery live-tab case in the steps’ tests. Preserve the ended-before-record convergence.

Separately, main advanced to 00862d2 (F46/#307) after the stated base 3643e1a; the current head now has a docs/decisions.md merge conflict. Please integrate current main without losing F46 or F47 and rerun exact-head gates on the new SHA.

Verified in disposable detached checkout: docs-only diff, git diff --check clean, bun run check:public PASS (660 files) under heavy lock. Live checks pass/skipped and zero unresolved threads at review time. No merge performed.

immakermatty and others added 2 commits October 11, 2026 17:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pablo's review of df2c45f: a 5 s margin is an expectation, not an order. A tab
created through agent-browser or plain DevTools reaches the hub on its own
connection, so a list written from the hub's cache could miss a live tab and an
app would drop it.

Each list is now a fresh snapshot: the hub notes the time, asks the browser for
its targets (Target.getTargets) and writes the page targets with that time as
takenAt, after each discovery, on every change and every 30 s while connected.
An app drops an id only when a snapshot begun more than 1 s after its record
lacks it; the snapshot lists every target that exists when it begins, also one
whose event reaches the hub late, and the second guards a small clock step.
Failure modes, a rejected variant and the steps' tests (a live tab whose event
comes late) say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

Fixed in d8f69c0, after merging main (9d61c4e: F46 from #307 kept byte for byte, F47 after it; the diff against main is F47 only). Re-review requested on that head.

You were right that the 5 s margin was an expectation, not an order across two DevTools connections. Every list is now a fresh snapshot:

  • The hub notes the time, calls Target.getTargets and writes the page targets with that time as takenAt.
  • Snapshots are taken after each discovery, on every change and every 30 s while connected. With no connection nothing is written.
  • An app drops an id only when a snapshot begun more than 1 s after its record lacks it.
  • A snapshot lists every target that exists when it begins, also one whose targetCreated reaches the hub late. So a live tab is never dropped, and an ended-before-record id goes with the next periodic snapshot (≤ ~31 s).
  • The 1 s guards a small step of the Environment's clock, which both times come from.

Also changed:

  • failure modes: a late event row, and "not connected" instead of "does not know the targets";
  • a rejected variant: cached list plus margin;
  • the steps' tests: the delayed-event live tab.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — CHANGES_REQUESTED on exact d8f69c0 (F47 design only).

The fresh Target.getTargets snapshot fixes the delayed targetCreated event and the periodic snapshot fixes the ended-before-record case under a stable clock. One safety premise is still not guaranteed: point 4 compares persisted wall-clock takenAt and recordedAt and says the 1 s margin guards a small Environment clock step (docs/decisions.md:7033–7048). A Remote Environment's wall clock can jump back by more than 1 s. Concrete ordering: the hub starts a snapshot at clock 100 and receives a target list without a new tab; the clock steps back 10 s; the app creates and records that live tab at clock 91 on another CDP connection; the earlier snapshot is written after that record, with takenAt=100 and without the id, before the delayed targetCreated event. The app sees 100 > 91+1 and deletes a live tab from its stored strip and holder file. A later snapshot containing it cannot restore that app hold; the hub may then close it when unviewed. This contradicts “a live tab is never dropped” and the delayed-event test would miss it.

Please specify a cross-process, restart-safe causal comparison that does not rely on a bounded wall-clock step (e.g. a shared boot-scoped monotonic timebase with its boot identity in the persisted records, or a protocol that positively confirms a missing id after the record), and cover a backwards-clock-step fixture alongside both late-event and ended-before-record cases. Linux CLOCK_REALTIME is subject to discontinuous jumps; CLOCK_MONOTONIC is not. No code or merge performed.

Verified in disposable detached checkout: current main 00862d2 contains F46 unchanged, PR diff is F47 only; git diff --check clean; bun run check:public PASS (664 files) under heavy lock. Live checks pass/skipped, zero unresolved threads at submission gate.

…l clock

Pablo's review of d8f69c0: the wall clock can step back by more than the 1 s
margin, so a snapshot begun before an app's record could carry a later time
and the app would drop a live tab.

Times are now the Environment's boot (/proc/sys/kernel/random/boot_id) and the
seconds since it (/proc/uptime), which never step back and which the hub and
the apps read alike. An app drops an id only when a list of the current boot,
taken after its record, lacks it. A record from an earlier boot is older than
every list of this one, and a list of another boot is ignored. The margin is
gone. A wall-clock row in the failure modes, a rejected variant, and two more
cases in the steps' tests (an earlier boot, a step of the wall clock) say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

Fixed in 1013da8; re-review requested on that head. Right: the wall clock can step back by more than any margin. The fix removes the wall clock from the comparison rather than adding a protocol:

  • every time is the Environment's boot clock, {boot, at} = /proc/sys/kernel/random/boot_id and the seconds of /proc/uptime. CLOCK_BOOTTIME never steps back, and the hub and the apps read the same one;
  • an app drops an id only when a list of the current boot, taken after its record, lacks it. There is no margin any more;
  • a record from an earlier boot is older than every list of this boot, and a list of another boot is ignored.

Your ordering (snapshot at 100, wall clock back 10 s, record at 91) cannot happen on the boot clock: the record reads a later boot time than the snapshot's start, so it is kept.

Also changed:

  • failure modes: a wall-clock step row;
  • a rejected variant: wall-clock times with a margin;
  • the steps' tests: an earlier boot and a wall-clock step, next to the late-event and ended-before-record cases.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — APPROVED on exact 1013da8 (F47 design only). The boot-scoped monotonic clock removes the prior wall-clock regression: a snapshot stamped before a later app record cannot appear newer after a wall-clock step. Fresh, serial Target.getTargets snapshots and strict later-than comparison retain the delayed-discovery live tab while the periodic snapshot converges an ended-before-record id. Earlier-boot records and wall-clock-step cases are now explicitly in the implementation steps. Verified isolated detached head against main 00862d2, docs-only delta, git diff --check clean, and bun run check:public PASS (664 files) under the heavy lock. Live CI success/skipped, zero unresolved review threads at submission gate. This is design approval, not implementation or release QA; no merge performed.

@immakermatty
immakermatty merged commit 342db2f into main Oct 11, 2026
6 checks passed
@immakermatty
immakermatty deleted the agent/DEV-6656-917-new-tab-design branch October 11, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants