design phase 9 w9 sender controls - sinrimin/harness-nexus GitHub Wiki
Design: Phase 9 W9 β Sender controls (session config + attachments)
Status: SHIPPED 2026-09-11 (see Β§Post-ship notes). Ground truth:
research-phase-9-w9-composer-controls.md(adapter matrix verified against claude wrapper 0.76.0 / codex-acp 0.16.0 / dsh-acp 0.1.2-rc.1). Predecessor:design-phase-9-w8-sender.mdΒ§"Deferred" β this wave builds the four wire arms that table enumerated.
Problem
The W8 composer card is presentational only: no permission-mode chip, no
model / reasoning-effort selectors, no attachments. All four controls exist as
STANDARD ACP surface (session modes & configuration; prompt content blocks)
that every shipped adapter already implements β our wire drops them on the
floor (establishment responses only read sessionId; current_mode_update /
config_option_update fall to raw and render nothing; promptBlockSchema
has no image variant; the UI has no attach affordance).
Scope
- A β session-config selectors (mode / model / reasoning effort) on the composer toolbar, data-driven from the adapter's advertised options.
- B β image attachments (
+menu, paste, drag-drop; client-side downscale; thumbnails in the stream). - C β file references (
+menu / trailing@β workspace file picker βresource_linkprompt blocks, rendered as chips).
Out of scope: audio attachments, embedded-resource upload, @ caret-aware
mention popover (only the trailing-@ trigger ships), machine-level
permission-mode policy (C6), the claude wrapper's providers/* client-managed
LLM routing, effort auto-max (portal-specific behavior β NOT copied).
A. Session-config wire
Shared schemas (packages/shared/src/realtime.ts)
SessionConfigState rides THREE surfaces β all additive:
1. stream event (daemon β server β browser, and inside history items):
{ kind: 'session_config',
modes?: { currentModeId?: string, availableModes?: [{id,name,description?}] },
configOptions?: [{ id, name, description?, category?, currentValue?,
options?: [{ value, name, description?, group? }] }] }
PATCH semantics: `availableModes` / `configOptions` replace when present,
`currentModeId` alone patches the current mode. The daemon always emits a
FULL merged snapshot; `mapAcpUpdate` (the load-replay capture path, which
has no session state to merge into) emits the adapter's patch verbatim.
2. browser β server β daemon request:
chat:config.set = { sessionId, kind: 'mode', modeId }
| { sessionId, kind: 'option', configId, value }
(`value` may be '' β dsh's provider-default reasoning value.)
3. chat:session.ready += promptCapabilities?: { image, audio?, embeddedContext? }
(from the initialize result's agentCapabilities β the B gating signal).
Option values are OPAQUE keys (dsh model values are JSON.stringify([ provider, model])) β the UI compares by equality, never parses. Only
type:'select' options are surfaced; the daemon filters other kinds
(boolean options from future adapters) at mapping time.
Caps: β€32 modes, β€256 option values, β€64 options, strings β€2k.
Daemon (packages/cli/src/daemon/chat.ts)
DaemonSession.config: { modes?: β¦; options: SessionConfigOption[] }.- Establishment: read
modes/configOptionsoff thesession/new/session/load/session/resumeRESPONSE (load: AFTER the capture window, so the replayed patches settle before the authoritative snapshot) β store βemitConfig(full snapshot; enters the history ring β resync restores the selectors for free). wireSession: interceptcurrent_mode_update/config_option_updatebefore the ordinary mapping β merge intosession.configβemitConfig.mapAcpUpdate(exported; the capture path): map the same two updates to PATCH events verbatim (config_option_updatecarries a full option list;current_mode_updateonlycurrentModeId).chat:config.sethandler: forwardsession/set_mode/session/set_config_optionwith the ACP session id; on success apply the requested value intosession.configandemitConfig(daemon-side optimistic update β codex does not reliably push after set; adapter pushes then confirm/correct); on RPC error ack the message. NOT busy-gated: dsh pins the selection per turn (a mid-turn set applies to the next turn) and the UI disables selectors while a turn runs anyway.AcpAgentConnection.startalso derivespromptCaps: { image: boolean }fromagentCapabilities.promptCapabilitiesβ rides the ready push.
Server (packages/server/src)
ChatService.onConfigSet(ownerId, sessionId, β¦)β owner check + phase ready (no busy gate) β forward over /ctl./apphandler validates the shared schema (same envelope-identity binding as the other chat handlers).chat:session.readyrelay passespromptCapabilitiesthrough; the new stream event kind needs no special handling inonStream.
Web
fold.ts:FoldState.config(patch-merged inapplyEvent); user rows switch from{text}to{blocks: UserBlock[]}(B/C need it β see below).- Composer toolbar-left, data-driven (render NOTHING when the category is
absent β dsh has no mode selector; effort appears per model):
- mode β configOptions
category:'mode'(fallbackmodes.availableModes), - model β
category:'model'(grouped options viagroup), - effort β
category:'thought_level'. Selects disabled whileturnActiveor not ready. Mode values are wire enums (English, per the i18n exception); labels come from the adapter. - Effort ground truth (claude-code, wrapper 0.79, CC 2.1.28x β binary-
verified, user-rig cross-checked): the option exists only when the
current model's catalog entry carries
supportedEffortLevels, and CC decides that with a RUNTIME latch: any request carrying the newoutput_config.effortparameter β including the side-query probe sent during set-model, BEFORE the first message β that the gateway 400s makes CCmarkEffortUnsupported(resolvedModel)(in-memory per CC SDK process, keyed by the BARE model name, so switching between[1m]and bare entries does not escape it), silently retry without effort, and drop the levels from the catalog; wrapper and web then honestly show no selector. In short: when some platforms answeroutput_config.effortincorrectly (a rejection instead of accepting/ignoring it), CC stops showing the thinking-level selector for that model. A probe that only opens a session still latches (the side query fires during set-model). A gateway whose 400s DON'T match CC's effort_unsupported error shape (e.g. a plain subscription-expired error) never latches β the same binary can show full effort levels through one endpoint and none through another. An old session still displaying the selector is the pre-latch snapshot replayed (and is NOT actually live there). Real fix is model/gateway-side (a model that supports thinking-level switching, or the platform toleratingoutput_config.effort);CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1re-shows the selector but cannot make a rejecting gateway honor it (and the latch outranks the env in the catalog check anyway).
- mode β configOptions
- Dangerous modes (id β {bypassPermissions, full-access, auto}) confirm via
window.confirmfirst β same pattern as MachineDetail's actions. - Switch errors surface as toasts; state settles only through
session_configevents (single source of truth β no browser-side optimism).
W3 interplay
Machine-level RuntimeConfig stays the DEFAULT (settings writers); a
session-level switch overrides for that channel's subsequent turns only. A
NEW session starts from the machine default again; a resumed session's
currentValue reflects its pinned model (W7 stale-model annotation logic
unchanged β it reads the transcript, not live config).
B. Image attachments
promptBlockSchema+={ type:'image', data: base64 β€6MB, mimeType: png|jpeg|webp|gif }; send-path schemas refine β€4 images and β€6MB total per turn (the socket buffer is 8MB β headroom for the envelope).- Client-side downscale BEFORE send (canvas, long edge β€1568px, JPEG 0.85 β animated GIFs pass through untouched if β€2MB, else rejected). The wire therefore usually carries hundreds of KB, the caps are the backstop.
- Sources:
+menu β file input (four accept types, multiple), clipboard paste on the textarea, drag-drop onto the composer card. - Gating: the attach control enables only when
promptCapabilities.image(from ready) is true β dsh advertises it dynamically per model route. - Fold user row renders image blocks as thumbnails (click β lightbox
Dialog); the daemon's
hnx/prompt-errorraw event already surfaces dsh's image-admission rejections as a system note. - History: user items keep blocks verbatim (live echo β ring β resync replays thumbnails). Adapter-replayed history (load/transcript) is text-only by construction β placeholder-free, images simply never appear there.
C. File references
- The
resource_linkprompt block already flows verbatim end-to-end (schema + daemon passthrough) β this is UI only, plus a file listing: workspace:listgrows afilesarray ({name, path}, hidden skipped, capped, sorted) next todirectories; the REST route returns it; old daemons simply omit it. DirPicker ignores it.- Picker:
+menu β "reference file" opens a lazy file tree dialog (same listing call as DirPicker; dirs expand, files select) β ALSO triggered by typing a trailing@in the textarea (on pick, the trailing@is stripped from the draft). Picking inserts afileRefchip ({type:'resource_link', name: basename, uri:'file://'+abs}). - User rows render
resource_linkblocks as a mono@namechip. Honest expectation-setting: claude/codex turn the link into agent-readable text; dsh treats it as a plain marker (the agent must be asked to read it).
Plan (implementation order)
- shared: schemas + types + tests (session-config surfaces, image block, workspace files) β build.
- cli: agent-connection caps; daemon capture/merge/emit/set; workspace
files; fixture arms (
FIXTURE_SESSION_CONFIG=1,FIXTURE_IMAGE_CAPS=1,session/set_mode/session/set_config_optionarms); tests. - server:
onConfigSet+/apphandler + ready passthrough + workspace route files arm; tests. - sdk-ts: mirror
fileson the workspace listing type. - web: fold (config + user blocks) β stream rows (image/link chips) β composer (+ menu, three selects, attachments row) β page wiring (send blocks, config.set, ready caps) β FilePicker; strings en/zh.
- Gates:
pnpm -r build+typecheck+test,task verify(Node 20), web build; rig E2E against the real claude adapter. - Docs: this file β SHIPPED; AGENTS.md section; roadmap/README.
Testing matrix
- shared: schema accept/reject (patch event, config.set union incl. empty value, image caps, file listing).
- cli: ready carries caps; establishment snapshot event; push patches;
chat:config.setβ ACP request β optimistic snapshot; workspace files listing; fixture image-block echo. - server: config.set owner gating + forward; session_config relay; ready passthrough.
- web: build gate + rig E2E (selectors render from real wrapper data, mode switch round-trip, image paste β thumbnail β echo, file ref chip).
Post-ship notes (2026-09-11)
- Landed as designed β all three wires (A config selectors, B image
attach, C file references), daemon
0.12.0-p9w9(workspacefilesarm + session-config capture/merge +chat:config.set), schema arms per Β§A/B/C. - Rig E2E against the REAL claude wrapper 0.76.0 (agent-browser through
the live web image): the three selectors render from the adapter's own
data (
Manual/deepseek-v4-flashcustom model row /Default), mode ManualβAuto round-trips (confirm accepted, snapshot re-emitted), model βSonnet 5, effort βHigh; a canvas-synthesized PNG rides the drop handler β compression β chip β user-row thumbnail β the model ANSWERS ABOUT THE IMAGE CONTENT ("ιη»Ώθ²θζ―δΈη½θ²η²δ½ W9 attach test") β vision verified end-to-end; a trailing@opens the picker,NOTES.mdbecomes a chip, and the agent's own Read tool returns the file's content. Resume re-renders the selectors from the load response; replayed history shows the image turn as the adapter's text-only[image]placeholder (by design). One honest upstream finding: resuming an image-bearing session under a TEXT-ONLY gateway model fails on the API side (400 "Model only support text input") β surfaced as a system note viahnx/prompt-error; switching the channel's model to a vision model heals it. - E2E incidental: first rig pass "showed no selectors" because the machine's dsh agent was entered β dsh advertised no configOptions on that channel (data-driven hiding works as designed); the claude agent showed everything.
- Rig hygiene:
pnpm deploynow produces the machine overlay (the old hand-copied dist lostnode_modulesβpnpm --filter @harness-nexus/cli deploy --prod+ strip the one absolute self-symlinknode_modules/.pnpm/node_modules/@harness-nexus/clibeforedocker cp). Daemon restart reaps channels; post-run adapter scan: zero processes.
Post-ship fix β hnx/prompt-error now carries adapter data.message (2026-09-14)
Rig finding (codex): when a turn fails at the TRANSPORT layer (gateway
unreachable, TLS verification failure), codex-acp answers session/prompt
with -32603 "Internal error" and hides the real reason in
error.data.message ("stream disconnected before completion: error sending request for url (β¦)"). The daemon's JSON-RPC error mapping only read
data.details (dsh's dialect β see its "no adapter registeredβ¦" rejections),
so the system note was a bare, useless Internal error. The mapping
(agent-connection.ts) now joins BOTH dialect fields (data.message, then
data.details) onto the top-level message; the fixture gained a
please error with detail arm and chat.test.ts a regression test asserting
the full enriched string. Unrelated-but-same-session rig note: that failure
was the machine container missing ca-certificates (codex-acp is the only
NATIVE adapter β system openssl + system CA store; Node adapters bundle their
own CAs and stayed green, which is what made it look gateway-side).
Post-ship note β the toolbar's dressing and density (2026-09-25)
The three config selects keep their data-driven behavior (render nothing when the category is absent) and their place at the toolbar's left. Their dressing is the shell's now:
- trigger height
--control-h-sminstead of a literal, so BAY's selects are 25px like the rest of its controls, andvariant="bare"β no fill, no border, no window of their own in either theme (a skin's[data-slot='select-trigger']rule is unlayered, so the opt-out has to be a variant it can read); - below
smwidth they are icon buttons (permission / model / effort) with the current value read in the picker (check-marked) and the fingertip supplied by a::afterhit area β a 2.5rem box would put the toolbar back on two rows; - from
smup: icon + value + chevron.
The toolbar itself is items-end (baseline with the send control) and wraps below sm.
See design-skin-system.md Β§The control scale and Β§Devices.
#49 addendum (2026-10-14): the platform's DEFAULT permission mode
Problem: the claude ACP wrapper (0.79.0 β‘ 0.89.x logic) always resolves an
EXPLICIT mode at session/new (creationOpts.permissionMode ?? settings.permissions.defaultMode ?? default) and the session/new wire
params carry no mode field β CC 2.1.285's own headless auto default can
never reach an ACP session, so every platform chat started in Manual (ask).
Writing permissions.defaultMode into the user's ~/.claude/settings.json
would work (the SettingsManager hot-reloads) but mutates the user's own
global CC config; the platform stays out of that file.
Design (machine-scoped, mirrors chatPrewarm):
- Storage:
machines.chat_default_modeJSON{'claude-code': string|null}(migration 18; NULL column on pre-feature rows). The value is the adapter's OPAQUE mode id; explicit null clears it. PATCH replace-semantics. - Relay: the server stamps
defaultModeintochat:session.startat open (claude-code target only β the only adapter advertising permission modes), exactly likemodelOptions/prewarm. Absent/null = omitted. - Daemon: right after establishment and BEFORE the first config snapshot
emits (no defaultβauto flicker), it issues one
session/set_modeβ only when the established session advertises the mode (honest absence = no-op; an adapter refusal keeps its own resolved mode). The optimistic merge updates BOTH surfaces (modes.currentModeIdand themodeoption'scurrentValue) because the wrapper's post-set push is what usually refreshes the option β a push-silent adapter would sit stale otherwise. - Web: a "Default permission mode" select on the claude-code agent card (Adapter default / Manual / Accept edits / Plan / Auto; option names stay English β the adapter's own wire names, same as the W9 dropdown). The W9 dropdown remains the mid-session switch; this only sets the INITIAL mode.
- Rig E2E (2026-10-14): PATCH auto β open β both config surfaces arrive with
auto; cleared β omitted; raw-wrapper control probe resolvesdefault(proving the delta is ours, not the wrapper's).