Moved VERBATIM out of CLAUDE.md (tier-2 pass).
- All frontend cross-class communication goes through
App(mediator pattern). Classes receiveappin constructor and callapp.methodName(). - One-time WebSocket handler pattern: Register via
ws.onGlobal(), match ontype+sessionId, remove self after first match. Used increateSession(),attachSession(),resumeSession(). - Debounced auto-save:
LayoutManager.scheduleAutoSave()waits 2s after last change. Blocked by_restoringflag for 5s on page load. - Resizer: Reusable component with
inside: truemode for fixed-position elements (sidebar). Don't setpositionorflexon elements that aren't flex children. - Layout restore:
attachSession()returnswinInfosynchronously (the DOM element). Position is applied directly to thiswinInfo, NOT by guessing viawindows.values().pop(). The WebSocket attach is async but the window element already exists. - Proportional bounds tracking:
win.gridBoundsstores position{left, top, width, height}as fractions (0-1) of workspace._reflowWindows()(via ResizeObserver) recalculates pixel positions on workspace resize. Applies universally — grid snap, edge snap, drag-drop, andapplyLayout()all capture bounds. User resize updates proportions on mouseup. Works in both grid and freeform modes. - Title-bar right-click = full window menu (2.212.0): showWindowContextMenu (taskbar.js, shared with taskbar items/group items/window-list rows) with
switchSubmenu:true— "Switch window" submenu (scope viawindow.titlebarSwitchScope: overlap = rect intersection like the classic switcher / desktop / all, cross-desktop entries name their desktop, click → app.goToWinId = tab-chain+stage+desktop aware) + Rename…/Task Groups for SESSION windows (sessionForWin resolves the sidebar session via _openSpec backendSessionId/serverId; same bind semantics as the card menu) + Move/Minimize/Move-to-Desktop/Close. The □ title-bar BUTTON keeps the classic overlap popup (_showOverlapSwitcher,_rectsOverlap). utils showContextMenu submenu children now honordisabled(they silently fired their action before). - WebSocket reconnect re-attach: On WS reconnect, all active sessions are re-attached. Timeout ≠ dead (2.234.1, userL mass false-death incident): the server sends a synchronous
attach-ackat the top of every attach (proof-of-life before any slow await — remote transcript pulls, history rebuilds, MB-scale reply bursts can lawfully exceed 20s on a degraded instance while every session is alive); the client's_reattachno-reply fallback is a RETRY LADDER (re-send while un-acked, wait once acked, flip read-only only after ~2min) and its timeout messages say "may still be running", never "no longer exists" — that certainty is reserved for the explicit not-found error reply. Chat sessions call_reattach()which re-sends attach → server responds with latest normalized messages +isStreamingfrom wrapper metadata.globalHandlersin ws.js are preserved across reconnects. - Layout sync (multi-client): State-based — full workspace state broadcast via
layout-syncWS message after every change. Server saves + rebroadcasts (excluding sender). Receiver does smart diff via_applyRemoteState(): matches windows by unique ID, syncs positions/state/z-order, opens/closes windows as needed. - Atomic openSpec:
createWindow({ openSpec })sets the openSpec before_notify()fires, ensuring the first layout broadcast includes the window creation recipe. OnlycreateSession(resume) sets openSpec async (in WScreatedcallback) because serverId is unknown at creation time — it explicitly re-broadcasts viascheduleAutoSave(). - ChatView module split: ChatView is the controller (virtual scroll, op dispatch). Rendering delegated to
ChatRenderers(returns DOM elements).renderSystemMsgreturns{el, sideEffect}to avoid circular deps — ChatView applies side effects to ChatStatusBar/ChatInput. Search, input, status bar, minimap are standalone classes receiving callbacks for cross-module communication. - Menus are REGISTRATIONS (Plugin Ph1, contributions.js): the sidebar card menu ('session-card'), the window title-bar/taskbar/window-list menu ('window') and the ⚙ gear menu ('gear') are
registerMenuItem({ menu, command | label+run, group, order, when })contributions rendered bymenuItems(menu, ctx)into showContextMenu's item shape — add a row by registering it in the owning module (session-card.js / taskbar.js / gear-menu.js), never by editing a literal; reusable verbs areregisterCommandcommands (session.*shared by the card AND the window menu). Separators are explicit contributions;when/childrenare functions of ctx; an empty submenu drops itself. New shortcuts for plugins go throughregisterKeybinding(strict chords, bubble-phase document dispatcher under the app-lifetime AbortController); core chords keep their capture-phase dispatchers (modifier-lenient by design) and only route their ACTION throughrunCommand. File-explorer menus are still literals (not yet migrated). The ⚙ menu is a TREE (2.369.124, docs/design-gear-menu-hierarchy.md):submenu:trueheads +parenton members (contributions.js resolves lazily and falls to top level — never drops a row). POPOVER PROTOCOL for a nested flyout: the flyout element is appended INSIDE its head (a DOM descendant, somouseleavenever fires when the pointer crosses into it) and carriesdata-popoversoattachPopoverClose's child rule keeps the parent open; Esc is layered with a POPOVER-LOCAL keydown +stopPropagationwhile a flyout is open (the createModalShell pattern) and left to the global[data-popover]handler otherwise; a click on a head OPENS and never toggles (emulated mouseenter on tap may already have opened it — the showContextMenu lesson); the mode (left-opening flyout vs inline accordion) is decided at build time fromapp.isMobile || matchMedia('(hover: none)')— headless chrome answers hover:none by default, so a chrome suite that wants the desktop mode passes--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4(test-gear-menu). Initial focus into a freshly created popover must wait for the rAF in which createPopover clearsvisibility:hidden— nothing inside a hidden subtree is focusable. - A For-you item that is no session's names its DOOR, and a plugin's reminder is PINNED (lane foryou-plugin-notices, 2026-10-19): a producer whose item is answered somewhere other than a conversation declares
openat filing (src/user-todos.jsnormalizeOpen, a closed set of kinds the client owns a verb for) andjump()opens it before any session lookup — never a click that ends in "Session not found". A built-in plugin's reminders (originplugin, gated on the declaredreminderscapability) sit right under urgent: the order is the PUREtrayBand/trayRank(src/lib/user-todos-layout.js), keyed by the record's origin, never by a plugin's name. A ✓ that APPLIES something (the door'sdone) acts on one ✓ by the owner; the store's status event marks 「全部标为已看」 asbatch. - Keyed-row reconciliation replaces innerHTML wherever an editor may live (2.369.169, the For-you panel): a list that can hold a text box (a reply box, an inline editor) is never re-rendered wholesale on a broadcast — rows are keyed (
data-id), patched in place by ONE renderer'spatchRow(a content signature decides whether anything is rebuilt; the LIVE half — enabled/tooltip — is applied separately so a live-fact broadcast rebuilds nothing), appended/removed by key, and moved only when out of place; the box node itself is never detached (detaching a focused textarea blurs it) — the rest of the row is replaced AROUND it. Precedents: the a3 keyed status-bar chips, inc-mtw02kbq-kj96's append-only slots. See src/lib/user-todos-row.js. - Shared utilities:
createPopover(anchor, className, opts)handles popover positioning/dedup/close for all 7+ popover types.showContextMenu(x, y, items)is data-driven. Outside-press close =onOutsidePress(root, close, { exclude, ignore, nested, once, signal })(utils.js, lane M): createPopover / showContextMenu / attachPopoverClose use it; a hand-built floating surface calls it directly — NEVER adocument.addEventListener('mousedown' | 'click', …)closer (an app's picture cancels its pointerdown ⇒ no mousedown ever; test-architecture §59 fails one). Capture-phase passive pointerdown, touch as a tap, armed synchronously (the opening press is recognised by its timestamp);once:falsefor a persistent popup hidden by class;nested:falsefor a surface that should close on a press in another popover.attachPopoverClosealso stamps lane K's OWNERSHIP fields: a popover removed by any other path — a timer, Esc, a row — callspop._closeCtl.abort()in its own cleanup (lane K verify r1: a hover chooser opens and closes with no press), andpop._closeExcludeis the LIVE exclusion list the helper reads at every press — an owner that rebuilds its anchor pushes the new one.fetchJson(url)wraps fetch+json+catch.copyText(text)with execCommand fallback. - A press INTO a frame is an outside press, and an anchor TOGGLES (lane menu-close-residue, B-5bd5, 2.369.332): a press inside an
<iframe>(a Web view, a plugin window, the mail frame, a Design artboard) is dispatched in the frame's document and never reaches the parent's pointerdown —onOutsidePressalso listens towindowblur and closes whendocument.activeElementis a frame outside the surface / its excludes withdocument.hasFocus()still true (PUREshouldCloseOnBlur, outside-press.js; measured: a blur to another window or the devtools leaves activeElement where it was with hasFocus() false, and while a frame holds the focus such a blur never reaches the parent) — every site for free, nothing per site.createPopover's dedup TOGGLES: the same anchor pressed (or Enter) while its popover is open closes it and hands back a DETACHED element the caller fills unseen (_closedByAnchor); a cursor-placed popover andtoggle:falserebuild as before; a caller with its ownisConnectedcheck (the principal picker, the stream pill, the sidebar filters) never gets there. The Web view hides a cross-origin frame as "blocked" — its frame is only ever same-origin; the app's opaque frames are its sandboxed ones. Gates: test-outside-press (PURE + fake DOM + two patched copies), test-popover-frame-close (chrome; a pre-fix bundle as the RED control). - Agent sessions get a SANITIZED env, never raw
process.env(2.227.12,agentEnv()in ws-handler): the container's own runtime vars broke agent work (NODE_ENV=production→ every agentnpm installsilently skipped devDependencies;PORT=3456→ inherited by dev servers the agent started;npm_*→ nested-npm hazard) AND the helm chart injects the instance's SECRETS (VIBESPACE_PASSWORD, S3/CephFS/Drive/frps credentials, telemetry token) which any agent could read with oneenv. Rule: operational vars +npm_*dropped, ALLVIBESPACE_*dropped except a small allowlist (API/session token/task id/remote-transport hints/instance name) — everything an agent legitimately needs is set EXPLICITLY after the strip. Any NEW spawn path must build its env throughagentEnv(). - Server-side settings reads use
serverSetting(key)(server.js; ws-handler gets it via deps) — backed by persistence.js's cachedreadSettingsover data/settings.json, where /api/settings actually persists.getSyncStore('settings')is a dormant EMPTY migration-target store: 9 server reads through it silently returned defaults for every user-configured value until 2.87.0 (onDemandQuotaRefresh modes, activeUsagePolling, shipSubscriptionToRemote, per-turn reminder/stop nudge off-switches, telemetry toggles). Never read settings through the SyncStore server-side. - No native dialogs:
prompt()/alert()/confirm()are banned — useshowInputDialog/showConfirmDialog(Promise-based, reuse.dialogCSS, Enter/Esc handled) andshowToast(msg, {type:'error'})from utils.js. Toasts are CARDS anchored at the inbox button (2.111.0):configureToasts({getSeconds,getAnchor})wired in app.js (duration = settingtaskbar.toastSeconds; anchor falls back to the centered strip on mobile/hidden); every toast is recorded to localStorage history (getToastHistory) which the inbox popup shows as its Notifications tab (window CustomEventvs-toastlive-refreshes it). The pre-2.111.0 invisibility bug wasbackground: var(--bg-panel, var(--bg-secondary))— BOTH undefined tokens → transparent (same class as the no-global-.hidden rule: verify computed styles, not class names). Storage-row gotcha:.mounts-pathkeeps its rtl left-truncation in an inner.mounts-path-textspan — the type chip must stay OUTSIDE the rtl context or bidi reorders it to the end. Popovers/menus created via createPopover/showContextMenu carrydata-popoverso the global Escape handler (app.js_setupDialogs) closes them; Esc then closes#dialog-overlay. Esc is NOT intercepted while focus is inside.xterm(TUI apps need it). createModalShell takeskind(lane dialog-yield, .360):'decision'= the backdrop (a yes / no, a consent, a destructive confirm, the vault unlock, ONE line) ·'form'= a small window that yields (src/lib/form-window.js; the phone and a form over a blocking layer keep the sheet); no default — test-architecture §103 reds a call without it; a decision that should be a form says// form-later: <reason>on its line; round 2: every decision says// decision: <reason>,opener: 'system'for a broadcast / poll / wake open,onEscape(e) → falsefor a form that asks before Escape closes it (never its own Escape listener on the root), and a «did the person open a dialog?» read goes through form-yield.js's DIALOG_ROOTS / isPersonDialog.; round 3: every form call names its OWNidand itskey(the subject — or// one subject: <why>), readsraised(a re-open of the same subject hands back the LIVE shell: draw nothing again), keeps per-open subscriptions on its shell (nevergetElementById(<its id>)), carriesfrom: pressToken()across an await that precedes its open, and floats lists / popovers through createPopover / showContextMenu / ownFloat (data-owner-form) - A dialog closing with unsaved changes ASKS (lane dialog-unsaved-guard, B-bc31): a Save dialog passes
unsaved: { isDirty, save, discard? }to createModalShell — dirtiness = what it would write vs what it drew (drawn() !== base),save= the SAME function its Save button runs (true / closed = landed; a toast or a string = the reason); never a per-dialog confirm. scripts/test-dialog-unsaved-census.mjs keeps every Save dialog on it ⇒ kb-file-structure «src/lib/utils.js» - Drag mousemove is rAF-coalesced everywhere (window.js titlebar drag + resize, tab-group icon drag + tab drag-out): raw mousemove fires at pointer rate (125-1000Hz) and each
processMovedoes elementFromPoint hit-tests (forced style recalc) + rect reads — uncoalesced this visibly stuttered with several live chat windows open. Pattern: store latest event, process once perrequestAnimationFrame, cancel the pending frame in onUp. Keep new drag handlers on this pattern. - Cursor→window positioning MUST convert viewport→workspace coords (2.100.3, trace-diagnosed real report):
e.clientX/Yare VIEWPORT coords; windowstyle.left/topare WORKSPACE-relative (offset by sidebar ~260px + toolbar). Every "center on cursor" re-anchor that wrote raw clientX landed the window a sidebar-width away from the pointer (then tracked parallel — read as "drag drift"; invisible with the sidebar closed, which is why it survived across 7 sites: window.js un-snap/un-maximize/merge-ghost-leave/preview-leave + tab-group detach/follow/merge-leave). Convert viaworkspace.getBoundingClientRect():initL = (e.clientX - wsr.left) - w/2. DELTA-based tracking (initL + (cx - startX)) is space-agnostic and safe — only absolute cursor placement needs the conversion. Also: after any mid-frame re-anchor of initL/startX, derive position from the CURRENT anchors at application time (a delta computed before the re-anchor is stale by the pointer's whole first-frame sweep under rAF coalescing). Diagnosis pattern that cracked it: temporary per-frame drag tracer shipped via/api/telemetryon mouseup — the user reproduces on THEIR machine, the agent reads the ndjson. - UI scale (DPI) + UI font size (2.257.0, per-DEVICE like the language): gs-menu rows writing localStorage
vibespace.uiScale/vibespace.uiFontScale(percent; in CLIENT_PREF_KEYS).applyUiPrefs()(utils.js) runs pre-App in client.js + on change: uiScale = CSSzoomon BODY (desktop only; mobile skipped) +--ui-scale/--ui-font-scaleroot vars — BOTH in themes.js LAYOUT_VARS (theme sweep exemption, the 2.254.0 lesson: without it the theme apply wipes them and every calc() silently reads 1).#main-wrapperheight =calc(100dvh / var(--ui-scale,1))(100dvh resolves in zoomed px). DRAG RULE (do not regress): every drag handler converts pointer deltas viewport→layout by dividing byuiScale()(utils) — window.js titlebar/resize/anchors/move-mode, tab-group ghosts/detach, resizer.js, desktop-manager toolbar+taskbar handles; layout-px ratios must use offsetWidth, never getBoundingClientRect (viewport px differs by the zoom). Terminals refit + clearTextureAtlas on scale change (canvas softness at ≠100% is inherent — CSS zoom does not change devicePixelRatio). uiFontScale = text-only multiplier on CURATED chrome font rules (calc(Npx * var(--ui-font-scale,1)): desktop-preview labels (via their size vars), taskbar title/sub, session-card name/cwd, folder-header, window-title, context/taskbar/gs menu items). REVIEW-HARDENED (10-finding pass — the sweep's blind spots): ① the proportional-bounds engine (capture/apply gridBounds, snap zones, grid presets/lines, snapToHalf, cell ranges) uses{width: workspace.offsetWidth, height: offsetHeight}— NEVER workspace gBCR (viewport px: fractions divided by the zoom poisoned layouts.json for ALL clients; presets hung windows off-screen); hit tests (_getSnapZone/_getGridCell, viewport-vs-viewport) keep gBCR. ② the shared popover primitives (createPopover/showContextMenu/anchorFixedPopup/setupInstantTooltip/toast anchoring + the right-anchoredinnerWidth - rect.rightpattern) divide every write by uiScale() — fixed body children take LAYOUT px, inputs are VIEWPORT px, and past ~80% of the viewport menus rendered off-screen with clamps that could not recover; clamp math runs in viewport space, only final writes divide. ③ ALL vh/vw in the four stylesheets arecalc(Nvh / var(--ui-scale,1))(blanket sweep; a zoomed 80vh dialog = 100% of the screen at 125% clipping its footer). ④ applyUiPrefs re-runs on window resize (debounced) — crossing the 768px mobile boundary otherwise stranded/leaked the zoom. ⑤ theme-editor/customize-mode/explorer-column/PPTX-sidebar drags compensated (column widths COMPOUNDED ×zoomⁿ into localStorage). KNOWN MINOR: at 140% font scale a short taskbar may clip its two text rows — the taskbar is user-resizable, drag it taller. Smoke: scripts/test-ui-scale.mjs (15 asserts incl. 1:1 drag, in-workspace snap + gridBounds round-trip, on-screen corner context menu at 125% + screenshots to /tmp/vs-uiscale-shots). - Sidebar session poll (5s) pauses while
document.hidden(30s heartbeat + immediate catch-up on visibilitychange) — single_pollTimerchain, never double-schedule. - Tab groups (chain model): Windows can be merged into tab groups by dragging one window's icon onto another. Shared chain object
{ tabs: [hostId, ...guestIds], active: index }— all grouped windows hold the same reference viawin._tabChain.tabs[0]is the host (owns the physical.windowelement). Guest content divs are reparented into the host element. Tab bar replaces title bar content with Chrome-style rounded tabs. Drag tab downward (>30px) to pull out — detached window raised to front viafocusWindow, follows cursor with snap; entering another window's icon/tab bar collapses it to a.tab-ghost(same pattern as titleBar drag). Group resize/snap syncs all tabs' gridBounds via_syncChainBounds. Installed as a mixin on WindowManager fromtab-group.js. - Tab merge hit-test: Shared helper
_detectTabMergeTarget(x, y, sourceWinId, hiddenEls)on tab-group mixin. UseselementFromPointafter temporarily disablingpointer-eventson the source and any ghost, so occluded icons never match. Used by (a) window.js titleBar drag, (b)_setupIconDrag, (c)_setupTabDragafter detach. All three share identical hit-test semantics. - Window type icons: Each window type has an inline SVG icon (
TYPE_ICONSin tab-group.js): terminal>_, chat bubble, files folder, viewer document, editor pencil, hex grid, browser globe. Shown in title bar, tab bar, taskbar, and overlap switcher. Icon is also the drag handle for tab merge. For chat/terminal windows with a backend, a composite icon is shown: backend logo (Claude/Codex) with a small mode badge (chat bubble />_prompt) in the bottom-right corner. Taskbar icons usetransform:scale()to uniformly enlarge the composite icon. - Move mode: Right-click taskbar → Move. Full-screen overlay blocks all UI interaction. Window restores from maximized/snapped to original size. Click to place.
- Loading screen: Inline splash in HTML (no CSS dependency). Fades out after
app.readypromise resolves (layout restore complete). - Resume-all boot popup (2.250.0): sessions that come back STOPPED restore as read-only history windows; loadAutoSave arms
_bootStoppedSessions(collected in restoreState's 4 stopped→viewSession branches, deduped by backend:id:host), and after the boot restore offers ONEshowConfirmDialogto bulk-resume if ≥2 — staggered 400ms via app.resumeSession (which already replaces the matching read-only window). Collector is nulled after boot so desktop-switch replays never re-prompt. Never fires on soft reconnect (that path is_reattach, not restoreState). - showContextMenu takes ONE class name (2.369.125): the row class is
className + '-item'over the whole string, so'context-menu mobile-sheet'produces rows classedcontext-menu+mobile-sheet-item— no.context-menu-itemat all (measured: a sheet with zero rows). Call it with the default andclassList.add('<modifier>')on the returned element (mobile-nav.js_sheet, chat-view.js_showMsgMenu). - A chrome suite that TAPS chrome must model a returning user (2.369.125): on a fresh data dir with no sessions
#welcome.onboarding(position:fixed; inset:0; z-index:9600) sits over the nav bar and every hit-test answers it; setlocalStorage vs-onboarded=1throughPage.addScriptToEvaluateOnNewDocumentBEFORE navigating. Touch gestures are realInput.dispatchTouchEventsequences (a long-press = touchStart, hold > 500 ms, touchEnd) so installLongPressContextMenu's own path is exercised. r7 (the Actions mirror on cc89d748): the flag is now ONE shared string —ONBOARDED_SOURCEin scripts/scratch.mjs — passed toPage.addScriptToEvaluateOnNewDocumentbefore EVERYPage.navigateon the same receiver, and scripts/test-architecture.mjs §47 is the census (41 chrome suites; a bare navigate fails it;// onboarding-under-testexempts a suite that means to exercise the wizard). Why the miss was invisible for 40 suites:_checkOnboarding(src/lib/app.js) ALSO skips the wizard when the machine has sessions, so every developer box was green while the runner (empty ~/.claude) showed the modal under the chrome — test-gear-menu's first Esc went to the wizard's own capture-phase handler, and the three suites red on every mirror run since 2.369.75 (toolbar-resize / roster-reset-eta / ghost-host-heal) sat under the same modal. The mirror uploads every/tmp/vs-*-shots-*as an artifact on failure since r6 — read it before guessing. - A long press on SELECTABLE TEXT is a selection, never a menu (lane mobile-select, 2026-10-02): the long-press = contextmenu rule (kb-design-lessons §7a) has ONE exception, decided at its one door — utils.js
installLongPressContextMenuasks PUREsrc/lib/press-select.jsper press (pressFacts→pressTargetClass→longPressVerdict): a glyph under the finger whose effectiveuser-selectis not none ⇒ the timer is never armed and a TRUSTED touch contextmenu there isstopImmediatePropagation'd WITHOUT preventDefault (the platform's selection handles + Copy bar stay); controls (button, summary, [role=button], [data-popover], .context-menu), padding, gutters, pictures anduser-select:nonelabels stay menu presses — and once an app handler TOOK a menu press (a menu is open)selectstartis cancelled while that touch is down — one outcome, never a menu AND a selection; when the platform's long press comes first, our contextmenu fires at itsselectstart; a press no handler takes keeps the platform's behaviour (selection, its own long-press menu). The trusted contextmenu is one PURE table (trustedContextMenu: pass / stop / swallow). A surface whose menu sits on selectable text needs a visible affordance (the chat's per-message … button,addMsgMoreBtn); a new contextmenu handler never re-derives this — and auser-select: textopt-in is a census row (test-press-select §4). A mouse right-click is never judged. - Drag/resize ends at ONE door (lane-drag-release, 2026-10-01; the law line moved here verbatim at the 2.369.200 integration — CLAUDE.md keeps a ≤ 300-character head): every drag door (title bar, resize handle, tab tear-off, icon drag) is fed from the element that CAPTURED the pointer through the ONE feed
src/lib/drag-feed.js(captureOn + attachDragFeed + the shield), ended by the PUREsrc/lib/drag-end.jsverdict (pointerup/cancel/lostcapture/blur/hidden/buttons==0), ONCE;body.wm-draggingshields every OTHER pane's content (a release over an iframe/canvas never reaches the document — lane-drag-release, the lane M class twice; verify r1 found the tab/icon doors still on the document's mouseup). Verify r2 (2026-10-02): the owner's abort ENDS the drag (owner-gone— a window closed mid-drag left the shield up for good; every door CANCELS, nothing dropped); the shield is a holder SET keyed per drag; test-window-drag §5 = THE CENSUS (grep-derived: 16 feed doors in 10 files — every handle-style door is onestartPointerDragcall — and every document/window move-or-end listener named with its reason; an unfed door is red). - A release keeps a title bar, a menu is placed once, a strip counts what it cuts (lane desktop-small, .358): a drop / reflow / default placement of a window ends through drag-end.js
releaseBox(80×32 of the BAR inside — window.js_keepReachablewith_barKeep) and a door that names a window throughinsideBox(_bringBackin revealWindow) — a new placement path asks the same, never its own clamp; a menu is placed by menu-place.jsmenuPlace(showContextMenu{ up }), and its caller never moves it afterwards by hand — an open list that changes under the pointer is placed AGAIN by the same verdict (keep: its top kept, capped at the foot and scrolling — desktop-doors-table r4: a correction by hand lifted the list past the top;scrolled: a list the person scrolled keeps its offset, its cap lifted only as far as its rows stay — r5); a bar that hides overflow counts and lists it (taskbar-fit.jsstripHidden⇒ a «+N» chip) and maps the vertical wheel sideways (wheelScroll). Acceptance r2: a resize's release asks the same keep (resizeRelease— the dragged edge stops); a FOLD keeps every door of what it folds (the compact desktop list's rows carry the preview's items and its drop —_deskItems,_rowDoors, a spring-open on drag hover); a menu's styling class is set BEFORE it is measured (showContextMenu(…, { extraClass })); a mixin's field is named for its owner (window.js_narrowTitleRObeside tab-group's_titleRO— one name for two observers silenced one). Acceptance r3: a spring-loaded target has ONE lifecycle every drag road feeds (dragHover/_springArm/dragDone— the pointer drag's hit test, the HTML5 drag's document dragenter / dragover / drop / dragend); a REAL native HTML5 drag in a raw-CDP suite =Input.setInterceptDrags+ a press and a few moves (Chrome's own drag starts,Input.dragInterceptedcarries its data) +Input.dispatchDragEventwith that data; «another client» in such a suite is a raw /ws connection — a second page target turns the first into a background tab (no rAF ⇒ no rAF-coalesced hit test). - PLACE, never FOCUS, for any window a non-human opens (lane agent-window-no-steal, 2026-10-11): an agent's action may place a window; only a person's action may focus one. A door reached by a broadcast, an auto-bind or an app's own new top-level opens through createWindow's asked
quiet(the active window + caret stay), a free window is restacked under the active one (wm.placeBehind), a desktop is never switched (another desk ⇒moveWindowToDesktop, hidden there), and the person is told once bywm.markArrived(the taskbar entry pulses; no toast, no For-you). A view that focuses its own input on connect asks first whether its window is the active one (xpra-viewmayFocus). The person's opt-in is ONE setting (desktop.agentWindowsFront); the person's own launches keep front + focus. Verdict: PURE src/lib/desktop-app-placement.js (agentWindowPlacement,satelliteFocus). - …and never RE-ARRANGE the desk a person is using (lane agent-window-no-pop, inc-mvhebl4n-rkvr, 2026-10-21): placing is a change too — a non-human's window opens beside its chat only where the person looks at that chat (the active window, or its split frame), on another desk where he is not; everywhere else NOTHING opens: the verdict's
cardleaves a card in that chat and its taskbar badge, and the person's press (Open / the chip) is the only origin that opens + focuses. One PURE verdict for every agent push (agentWindowPlacement), never an inline "is it on screen" test in a door. - ONE autosize helper for every multi-line editor (lane channel-edit-autosize, 2026-10-12 — a fleet user, inc-mv4toyhw-t7ym): a message editor opens at the size of its text and grows as you type —
autosizeEditor(ta, { minRows, maxFrac, pane })in src/lib/channel-editor-size.js (PUREeditorHeightdecides: content clamped betweenminRowsrows andmaxFrac(0.6) of the pane, then it scrolls inside; the binding measures scrollHeight after a one-row reset, once per rAF, puts back the scroll offsets around it, and a drag on the resize handle wins until the next open). Bound today: the proposal card's Edit box (window + Outbox, min 4 rows), the window composer, the agent-group composer, the thread reply box (min 2 rows: an empty composer keeps its look). Never a per-editor copy (test-channel-blocks ㉑ pins it). FOLLOW-UP (not converted here): the webhook compose dialog (channel-webhook.jschan-wh-text), the group dialog's context box (channel-group-dialogs.js), and the textareas of chat-input.js, user-todos-row.js, task-detail.js, task-log.js, manage-agents.js, settings-ui.js, incident-recorder.js, window-share.js, mounts-dialog.js, sidebar-mounts.js, picture-shell.js, chat-status-bar.js — judge each (a terminal/xpra/browser input box is not a message editor). - Every net socket we hand bytes through for a person's picture or input is noDelay (lane picture-path-nodelay, 2026-10-12): Node keeps Nagle on for a
netsocket, so a key's release waited ~40 ms behind its press for the peer's delayed ACK (42.0 → 0.7 ms median through the paired-machine path) — setsetNoDelay(true)at both ends of every hop, or write// nagle: ok — <reason>(test-architecture §89 counts them). - A plugin card's grammar (lane voice-card-polish, 2026-10-13): the header holds the title, ONE status chip and the card's own switch (
role=switch, never a floating checkbox); below, a labelled grid (96-px label column, stacked on a narrow card via an inline-size container), ONE primary action with its alternative folded, every button and field 32 px on--radius-sm— src/lib/voice-plugin-card.js is the worked example. - THE HEAVY TIER ON TWO MACHINES (lane heavy-runner-aidev, 2026-10-13 — the owner: "你可以先扔到AIDev里跑?那边内存还有不少吧"): an integration may split its affected heavy between the hub and the AIDev test box (
ssh aidev, 32 cpus, ~45 GB free). The LOAD RULE has two halves, each read on its own machine and printed: the hub's (free -g≥ 40 GB and no build lane ⇒VIBESPACE_CI_LANES=3, one build lane ⇒ 2, else 1) and the box's (MemAvailable ≥ 40 GB ⇒ 4, ≥ 25 ⇒ 2, else 1 —scripts/ci-remote.mjsreads it there;--lanesoverrides). The split is ONE cut: the hub half runsnode scripts/ci.mjs --heavy --only=$(node scripts/ci.mjs --list-affected --range=<base>..<head> --shard 1/2)(a tree older than 2.369.263 lists withnode /abs/wt/scripts/ci-remote.mjs --snapshot <tree> --range <base>..<head> --shard 1/2 --list— the same names), the box halfnode /abs/wt/scripts/ci-remote.mjs --host aidev --snapshot <tree> --range <base>..<head> --shard 2/2 --log <lane>/heavy-aidev.log --exit <lane>/heavy-aidev.exit. Both cut with the TREE's own affectedSuites + shardPlan (FNV per name; the serial group is shard 1's = the hub's), longest-first by the markers' timings. Each half is a PARTIAL run (never the tier's verdict); the report quotes both verdict lines and both logs. A red on the box is re-run on the hub alone at 1 lane before it is judged a product red — the box is not the hub (Google Chrome 155 vs 154, xpra 6.5.3 vs 6.5.4, no paired device). The integrator brief line: "affected heavy: local shard 1/2 at N lanes + aidev shard 2/2 (ci-remote.mjs) — both logs in the report". The box is (re)provisioned idempotently byssh aidev 'NODE_VERSION=<the hub\'s node --version> bash -s' < scripts/ci-remote-provision.sh; everything it owns lives under /var/tmp/vibespace-ci (kb-file-structure scripts/ci-remote.mjs). - A plugin's configuration is a PURE table derived into Settings; the sidebar lists, toggles, installs and links (lane plugin-settings-p0, 2026-10-13 — the VS Code plugin model): a new configuration value = a row in src/plugin-settings.js (one validator for built-in and manifest rows; a secret or
enabledis refused), read server-side via h.setting — never a control drawn on the plugin's sidebar row and never a field in data/plugins.json. The row only shows status, the switch, install/uninstall and ⚙. - A plugin card's switch = what you get + one example + where it shows up (lane decision-card-words, 2026-10-13 — the owner on the .261 Decision API card): the label says the result in the user's words, a hint under it gives ONE concrete example and names the place it appears; never the engine's vocabulary (judge, records, rows, first screen, route, predicate) on a string a person reads. A shared card takes a row's own words (
setup.title,choiceWords) rather than rewording every card. - Every Enter on a text box goes through
enterSubmits(e)(lane channel-enter-ime, 2026-10-14 — inc-mv7mdv6b-emxq: the Outbox's reject box sent a reason mid-word): a keydown that submits, navigates, searches or picks on Enter in an input, a textarea, a contenteditable — or in a container holding one — asksenterSubmits(e)(chat-enter-keys.js: an Enter the input method does not own), or!imeOwnsKey(e) && (…)where Tab picks too; a script injected as its own source carriesisComposingAND keyCode 229 inline.!e.isComposingalone is not the guard (Safari sends 229). Buttons, rows and menus need nothing — no composition exists there. scripts/test-enter-census.mjs (fast) finds every handler by its target's kind and fails one that skips it; the composer's EnterGuard is its own stricter rule on top. - What an agent is told about the owner is DERIVED, never written down (lane agent-context-owner-facts, B-7960, 2026-10-20 — userW: «23:16» relayed for 16:16 PT; «bottom right» after he moved the tray): a time an agent reads goes through ONE formatter in the owner's zone, named (src/when-words.js whenOf:
16:16 PT, never a bare UTC slice), and the tray is named by its label in the owner's language and its CURRENT place (src/tray-words.js trayWords over the chrome settings + the owner's last page) — a CLI prints the hub's words (when.<field>,tray), never formats a time or names a place itself; test-task-injection's two censuses are RED on a sliced ISO time or a literal «bottom right».
On a phone the text box owns its row: attachments go behind ONE leading door (a sheet), the microphone lives inside the field, and the composer + status bar are one bottom panel (one surface, one top border, the 8-px rhythm). Build the phone face from the SAME nodes the desk uses (re-parent at the 768 px query, restore when it widens) and gate every phone CSS rule; prove the desk is untouched with a desk render whose PNG equals the one with the phone block stripped. A bar that cannot fit folds behind ⋯ in bar order — never a horizontal scroller.
The card grammar (lane api-call-card-words, 2026-10-13 — the owner on the .263 API-call card: 「这什么破 UI 好丑陋」)
A proposal / approval / notice card speaks in the person's words. Seven rules; scripts/test-card-grammar.mjs is the census: every decision-card renderer in src/lib is RENDERED there, and the exception list is EMPTY (lane card-grammar-exceptions, 2026-10-14, rendered its last three: the Outbox proposal's attachment rows, the chat's image card, the For-you row of a raw-API proposal) — a new renderer with a raw-string tell is RED until it is rendered there.
- Title = actor + verb + NAMED object — "杂活 想删除文档《周报》里的 3 个块(第 1–3 个)". The verb comes from a PURE words table (src/api-call-words.js for raw-API calls; one fixture + a RED control per row). An unknown call says "调用 Lark API(POST users)": the vendor + the method + the last meaningful path segment, never the whole path.
- The object is named from what the store or the request itself knows (the channel index's name for a chat, the title in a create call's body), else its id shortened to 8 chars in a chip — never from the agent's
--why(a claim, shown as a quote). Link it when we can open it (app.openChannel). - The policy is a chip ("删除 · 总是询问", the tier word), never a sentence like "敏感操作 —— 总是会问你(delete)".
- ONE fold for raw strings: a collapsed 技术细节 (
data-fold="tech") holds the method + full URL + query, the headers, the body (pretty-printed, ≤ 2 KB then …), the sha256 and the byte count. Nothing raw outside it, buttons included. The always-allow button says the plain phrase 「执行,并以后不再问(在 产品群 发消息)」 (「…(调用 Lark API)」 for an unknown call), and the exact shape it grants is a fold line 「以后放行:POST …/im/*/messages」 (verify r1 H2: the shape is still named, just inside the fold). - Said once: a list row carries the short title + the chip + the outcome. The card that opens under it repeats none of them.
- Actions: Approve is primary and needs no reason. Reject is secondary and asks for its reason in a popover when pressed. The agent's own
--whyis a quoted line under the title. - Phone: the same card, stacked (full-width buttons, the reason box on its own line).
Words for a vendor's long tail are generated from the vendor's catalogue (scripts/gen-api-call-words.mjs); the hand table reads counts; the agent's claim is a quote (lane api-call-words-from-spec).
The census tells: a URL / sha / mime literal / byte count / an id of 16+ chars with a digit outside the fold, a "—— " sentence or a chip holding a dash, a title over 90 chars, a button label holding a URL or a method + path.
A size may stand outside the fold as a SIZE CHIP (
data-chip="size"): a kind or format word + ONE human size — 「图片 · 2.3 MB」 on an attachment row (PUREattachmentKind, src/channel-policy.js), 「PNG · 12 KB」 on the chat's image card; a raw count (「14336 B」) in it is still the bytes tell, and the mime literal + the exact bytes + the sha256 go to the card's ONE 技术细节 fold. A surface that shows a card's subject elsewhere says the CARD's title: the For-you item of a raw-API proposal carriescard.head(src/channel-api-card.jsforYouOf) and every device words it with the same PUREtitleWordsthe chat card and the Outbox row use — never a second, rawer sentence; its raw call ridescard.techinto the row's own fold above Approve.
The CLI call (lane cli-call-timeouts, 2026-10-23 — hub feedback: «vibespace-task progress hung for 30+ min»)
An agent CLI never calls its hub with a bare fetch / http.request: it calls CC.call (src/cli-call.js, loaded by its literal names — ../../src/cli-call.js in a checkout, ./cli-call.js beside it on a host) after CC.cliStart({ tool, flags }) on its first line. Pick the class, not a number: a GET reads (15 s), a write writes (30 s), a call that wakes, dispatches, launches or reaches a vendor is cls: 'wake' (60 s); a route that is KNOWN to hold longer passes its own base ms with the reason beside it (dispatch's compaction 240 s, exit pull 6 h); a long-poll passes the wait it asked for as extraMs. On expiry the helper prints ONE line naming the wait — the connection («nothing was sent»), the answer, or the step the hub named — and exits 124; a poll that must not end the process passes onExpiry: 'throw'. The hub's half: a route that must wait on a slow step of its own names it first with stepAhead(res, '<key>') (a 103; the key from the closed STEP_WORDS table — the hub sends a key, the CLI words it); and a route never holds its answer on a mount at all — it answers after the store write and mirrors the folder after it, bounded, saying when the copy is behind (the TASK.md mirror: _queueMirror / mirrorStateOf). Census: test-architecture §99.
To give a phone's back gesture a meaning inside the app, stand ONE sentinel entry (history.pushState({vs: 1}, '')) above the page's own entry and read every popstate onto the page's entry as "back was pressed": close the topmost layer the way its own closer does (Esc's order), then step FORWARD onto the same sentinel with history.go(1); at home show a toast and stay on the page's entry for the confirm window, so the second back is the browser's own and leaves. Re-arm by traversal, never by a fresh pushState: Chrome's back-button intervention marks the entry below a pushState made without a fresh tap as skippable, so the next back would skip the app and leave (measured on Chrome 154 with the browser's own back). Keep the sentinel you boot on (a reload or a restored tab) instead of pushing a second one. Remember the URL the sentinel stands above: a popstate onto ANOTHER URL is a same-document navigation (a #fragment link), not a back — close nothing, stand a sentinel above it. Gate it to the phone (app.isMobile) and to a setting; a desktop browser's back is never touched. The decisions are PURE (src/lib/back-guard.js); the DOM wiring is separate (src/lib/mobile-back.js).
The path field (lane path-field, 2026-10-23 — L1 of the New Session train, the owner accepted POC v2.2 「没啥问题」)
Every place a person types a folder is pathField(input, {bases, groupsOf, host, kind, onCommit, …}) (src/lib/path-field.js) — never a raw <input> plus a per-door completion (test-architecture §98 fails a raw path input). A door gives it three FUNCTIONS (re-read on every query): its Task Groups (appGroups(selectedIds) — selected ones lead the bases and give one <group folder>/<name> each; none selected ⇒ every group offered), its machine (host), its own context bases; the field computes the rest FOR THAT MACHINE (the hub's slice: /api/dir-complete here, /api/hosts/:id/dir-complete there) and ranks with PURE src/path-candidates.js. What a door reads is the field's commit, never the raw text: await pf.commit() (null = empty or not taken — the field already says why under itself) then await pf.ensure() (makes a folder the person ticked), then info.path (absolute on that machine); a door whose dialog reads the input writes info.path back in onCommit. A value takes effect on blur exactly as on Enter (deferred past pointer-up); one that does not keeps its text and says 「未生效:…」. Words come out of the PURE module as { key, params } and are said through t() in the field. The contract is frozen for the train's other lanes · essay: kb-file-structure.md «THE PATH FIELD».