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

  1. A β€” session-config selectors (mode / model / reasoning effort) on the composer toolbar, data-driven from the adapter's advertised options.
  2. B β€” image attachments (+ menu, paste, drag-drop; client-side downscale; thumbnails in the stream).
  3. C β€” file references (+ menu / trailing @ β†’ workspace file picker β†’ resource_link prompt 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 / configOptions off the session/new / session/load / session/resume RESPONSE (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: intercept current_mode_update / config_option_update before the ordinary mapping β†’ merge into session.config β†’ emitConfig.
  • mapAcpUpdate (exported; the capture path): map the same two updates to PATCH events verbatim (config_option_update carries a full option list; current_mode_update only currentModeId).
  • chat:config.set handler: forward session/set_mode / session/set_config_option with the ACP session id; on success apply the requested value into session.config and emitConfig (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.start also derives promptCaps: { image: boolean } from agentCapabilities.promptCapabilities β†’ rides the ready push.

Server (packages/server/src)

  • ChatService.onConfigSet(ownerId, sessionId, …) β€” owner check + phase ready (no busy gate) β†’ forward over /ctl. /app handler validates the shared schema (same envelope-identity binding as the other chat handlers).
  • chat:session.ready relay passes promptCapabilities through; the new stream event kind needs no special handling in onStream.

Web

  • fold.ts: FoldState.config (patch-merged in applyEvent); 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' (fallback modes.availableModes),
    • model ← category:'model' (grouped options via group),
    • effort ← category:'thought_level'. Selects disabled while turnActive or 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 new output_config.effort parameter β€” including the side-query probe sent during set-model, BEFORE the first message β€” that the gateway 400s makes CC markEffortUnsupported(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 answer output_config.effort incorrectly (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 tolerating output_config.effort); CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 re-shows the selector but cannot make a rejecting gateway honor it (and the latch outranks the env in the catalog check anyway).
  • Dangerous modes (id ∈ {bypassPermissions, full-access, auto}) confirm via window.confirm first β€” same pattern as MachineDetail's actions.
  • Switch errors surface as toasts; state settles only through session_config events (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-error raw 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_link prompt block already flows verbatim end-to-end (schema + daemon passthrough) β€” this is UI only, plus a file listing:
  • workspace:list grows a files array ({name, path}, hidden skipped, capped, sorted) next to directories; 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 a fileRef chip ({type:'resource_link', name: basename, uri:'file://'+abs}).
  • User rows render resource_link blocks as a mono @name chip. 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)

  1. shared: schemas + types + tests (session-config surfaces, image block, workspace files) β†’ build.
  2. 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_option arms); tests.
  3. server: onConfigSet + /app handler + ready passthrough + workspace route files arm; tests.
  4. sdk-ts: mirror files on the workspace listing type.
  5. 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.
  6. Gates: pnpm -r build + typecheck + test, task verify (Node 20), web build; rig E2E against the real claude adapter.
  7. 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 (workspace files arm + 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-flash custom 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.md becomes 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 via hnx/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 deploy now produces the machine overlay (the old hand-copied dist lost node_modules β€” pnpm --filter @harness-nexus/cli deploy --prod + strip the one absolute self-symlink node_modules/.pnpm/node_modules/@harness-nexus/cli before docker 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-sm instead of a literal, so BAY's selects are 25px like the rest of its controls, and variant="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 sm width they are icon buttons (permission / model / effort) with the current value read in the picker (check-marked) and the fingertip supplied by a ::after hit area β€” a 2.5rem box would put the toolbar back on two rows;
  • from sm up: 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_mode JSON {'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 defaultMode into chat:session.start at open (claude-code target only β€” the only adapter advertising permission modes), exactly like modelOptions/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.currentModeId and the mode option's currentValue) 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 resolves default (proving the delta is ours, not the wrapper's).