Commit Graph
70 Commits
Author SHA1 Message Date
Daniel Kim cec874e47c Simplify project files as artifacts (#145) 2026-07-31 16:51:56 -07:00
Daniel Kim 07d3221f3e Increase dashboard typography (#143) 2026-07-31 10:28:57 -07:00
3e934765e1 Add Ray Jobs compute backend (v0.1.88) (#141)
* Add Ray Jobs as a first-class local compute backend.

Submit via the Ray Jobs API with Settings → Compute → Ray, optional
cpu/gpu/mem flavors, and the same supervise/log/cancel path as other backends.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Review fixes: sync cloud skill, reject inapplicable flags, panic-safe log tail, fmt/clippy, dist rebuild (v0.1.87)

* Round-1 review fixes: synced env for ray jobs, server-plane ray arm, help text, honest configured flag, resolver/preflight simplification, NaN guard, address validation

* Round-2 review fixes: GONE debounce for purged ray jobs, cancel re-issue, prefix-guarded log tailer, drop SubmitResponse/Explicit, single URL normalization point

* Round-3 nits: dirty-flag log tailer invariant, slurm-shaped cancel gate on transitions, doc accuracy

* Rebase on v0.1.87; bump to v0.1.88 and rebuild ui/dist

* Use official Ray and OpenResearch marks in BackendLogos

---------

Co-authored-by: Sébastien Fabbro <sebfabbro@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:43:33 -07:00
Daniel Kim 5e25afaf98 Surface silently-dropped chat turns (v0.1.87) (#139)
* Surface silently-dropped chat turns (v0.1.86)

A sent message could vanish with no response and no error. Evidence from
the live DB: user messages persisted with no assistant reply at all —
the claude turn loop waits on child events with no watchdog, so a wedged
resident child (seen: expired-OAuth refresh hang) spins forever; the
user's only recourse, Stop/Escape, aborted the turn task without leaving
any trace; and a failed send POST was swallowed by the UI catch.

- claude adapter: watchdog the event wait (120s to first event, 30min
  between events, matching the codex TURN_WATCHDOG), exempting turns
  held on a pending mcp-gate approval; on trip, kill the child and
  surface a visible error (next message resumes via --resume).
- ChatHost::interrupt_by_user: the Stop/Escape path now persists an
  'Interrupted' marker row, so a stopped turn no longer reads as 'orx
  did nothing'. Internal interrupts (plan resume, deletes) stay silent.
- UI: a failed send now renders a local error row instead of silently
  clearing busy; chat SSE reconnects re-seed sessions/busy and the
  active transcript (chat frames are edge-only, gap frames are lost).

* Address review: verified send-failure surfacing, watchdog guard arm, shared consts

- send() catch: restore draft/attachments even when session creation
  failed; an explicit 'session is busy' rejection always surfaces (that
  busy is someone else's turn); a network failure probes server-side
  busy first and undoes the restore when the turn is real.
- claude watchdog: permission re-arm is a guard arm that no longer
  promotes the first-event deadline; error reports minutes on the
  mid-turn path and the login hint only on the first-event path.
- TURN_WATCHDOG hoisted to harness/mod.rs, shared with codex.
- interrupt_by_user: timestamp captured before the abort; WirePart::tool
  constructor (also used by push_error).
- Reconnect repair: shared syncSessionList (deletedIds tombstones,
  contextUsage merge, seenTitles baseline), seedBusy scoped by known
  ids, transcript reseed retries once via msgGen when a flush raced it.

* Guard claude turns against --resume replay output (echo boundary)

Folds in (and supersedes) the unmerged daniel/fix-swallowed-messages
branch: a --resume respawn can replay prior-session/startup output
before touching the new message — fatally a stale result event, which
ended the turn instantly with zero parts while the real reply streamed
into a dropped channel. --replay-user-messages makes the child echo the
submitted message; the turn loop now skips everything before the echo.

Review round (2x empirical against claude 2.1.212): echo is byte-exact
and stdin-scoped (no history replay, so no same-text false positive);
pre-echo lines count as watchdog proof-of-life (API backoff after boot
must not be killed at 120s; only total silence keeps the fast wedge
budget + login hint); pre-echo system/init harvests the session id for
first-turn interrupts; module docs pin the echo contract.

* Reconcile deleted sessions on SSE reconnect; repair after a failed first connect

Addresses review findings 2 and 3 on PR #139:

- A session deleted while SSE was down lost its chat.session.deleted
  frame for good: syncSessionList removed the sidebar row but left the
  cached transcript, loadedSessions, activeId, and — via the known-
  scoped seedBusy merge — a stale busy flag. Now the pre-fetch snapshot
  of shown sessions is diffed against the authoritative list and every
  missing id gets the full forgetSession cleanup, before seedBusy so
  the ghost's flag can't be carried forward. The snapshot is taken
  before the fetch (a session created mid-flight can't be tombstoned),
  and the project-change reset clears the snapshot ref synchronously
  (the old project's rows must not read as deleted under the new one).

- 'reconnected' now also fires when the first successful EventSource
  open follows a FAILED first connect (page loaded while the backend
  was briefly down) — the initial fetches likely failed too, so the
  panel repaired itself only on later drops, never on that first
  recovery. A clean first open still emits nothing.

Verified live on the slot-8 dev stack: session row deleted from the
DB while the backend was down; on reconnect (same page, no reload)
the row, ghost transcript, active selection, and busy flag all clear.
2026-07-29 16:41:13 -07:00
Daniel Kim bed1b31aaf Resolve chat file links via the files dir; fix \boxed math rendering (v0.1.86) (#140)
- FileViewer: a checkout-sourced tab whose path misses the worktree/clone now
  falls back to the project's files-dir endpoints (agents link files-dir
  reports with checkout-relative paths); renders via ReportMd with a note
  naming the checkout that missed. Branch-ref tabs never fall back.
- Pin katex to ^0.16.22 so the imported stylesheet matches the katex version
  rehype-katex actually renders with (0.16.47); the 0.18 stylesheet lacked
  rules for 0.16 class names, collapsing \boxed{} to a 1px vertical line.
2026-07-29 13:20:27 -07:00
Daniel Kim 33feb2e1ac Don't mint empty reasoning parts; fix tool-group rendering artifacts (v0.1.85) (#136)
* Don't mint empty reasoning parts; fix tool-group rendering artifacts (v0.1.85)

Fable's encrypted thinking streams empty thinking_delta events (payload in
signature_delta) and per-block assistant events with thinking:"" — orx stored
these as invisible empty reasoning parts, which split consecutive tool calls
into single-row groups; an expanded singleton group then painted the identical
tool line twice (summary + row).

- claude.rs: skip empty stream deltas and empty thinking blocks (main loop and
  sub-agent routing) so empty parts are never created
- ChatPanel renderParts: parts that render nothing no longer break a tool run
  (repairs already-stored transcripts)
- ToolGroup: a single-tool group renders its ToolRow directly — no duplicate
  summary line; has-error color parity via styles.css
- test: encrypted_thinking_mints_no_empty_reasoning_parts (captured live from
  the claude CLI)

* Multi-tool group summary is always the count

A running group is always expanded, so a summary echoing the running tool's
line always sat directly above the identical row. 'Used N tools' never
duplicates. Rebuilt ui/dist.

* Review round: shared visibility predicate, sub-agent per-block offset fix, nits

- ChatPanel: extract partIsVisible as the single invisible-part predicate,
  shared by messageHasVisibleContent and renderParts; renderParts now also
  skips resolved permission cards (Codex inlines them into the turn's parts,
  where they split tool runs the same way empty reasoning parts did)
- SubagentTranscript: gate the empty state on rendered output, not raw part
  count, so stored all-invisible transcripts read as empty
- claude.rs: guard empty text blocks like thinking blocks (both loops);
  comment accuracy passes (delta guard scope, cross-layer back-reference,
  ToolGroup doc, CSS child-combinator note)
- apply_subagent_blocks: fix per-block offset semantics — .skip(start) treated
  the cumulative message offset as an index into this event's (single-element)
  array, silently dropping every sub-agent block after a message's first;
  now mirrors the main loop's start+n id shift. Caught by the new
  subagent_empty_thinking_mints_no_child_parts test; also added offset
  assertion comment
2026-07-27 19:56:16 -07:00
Daniel Kim 5228228b2d Auto-generate and animate chat session titles (v0.1.84) (#135)
* Auto-generate chat session titles from the first message

Chat sessions were titled with a 64-char truncation of the first user
message. Now the first message also kicks off a short-lived headless
harness child that names the session, in parallel with the turn.

- store: new chat_sessions.title_source column ('fallback' / 'generated'
  / 'user'; NULL on legacy rows) via the best-effort ALTER pattern.
  set_chat_session_title takes a source; set_chat_session_title_if_empty
  becomes set_chat_session_title_if_placeholder — one conditional UPDATE
  that lets a generated title replace the placeholder while a user
  Rename, a legacy title, and an already-generated title all stand.
- harness: defaulted Harness::generate_title, implemented for Claude
  (claude -p --model haiku --max-turns 1) and Codex (codex exec --json
  at low reasoning effort). Both children run tool-less and MCP-less,
  hermetically in a temp cwd, under a shared TITLE_TIMEOUT. Prompt and
  output sanitizer live in the new harness/title.rs so both halves are
  unit-testable; opencode and cursor keep the default.
- opencode fix: TurnCtx::set_title's if-empty check could never fire —
  the placeholder write always beat it — so native opencode titles were
  silently dropped. The provenance check revives them, and the seed
  title opencode stamps on every new session ("New session - <ts>") is
  now skipped so it can't latch and block the real one.
- chat: one emit_session helper for the re-read/resolve-busy/broadcast
  sequence shared by set_archived, set_title and auto-titling; the
  placeholder now takes the first non-empty line.

No UI changes: the existing chat.session SSE upsert re-renders Recents.

* Animate the reveal of auto-generated session titles

An auto-title used to pop into the sidebar with no acknowledgement that
anything happened. Now it materializes character by character.

- session_json gains "titleSource" so the UI can tell a harness-generated
  title from a placeholder or a user rename.
- New TitleReveal component, used by both the Recents row and the chat
  header so the effect is identical in each. Characters are aria-hidden
  behind an aria-label — a screen reader hears one title, not forty
  single-letter spans — and render as a plain string when not animating.
- The reveal plays only on a live chat.session event that changes a title
  we already had on screen, and only for titleSource "generated": renames
  and page loads swap silently. A per-session nonce remounts the spans so
  a second retitle replays rather than sitting on a finished animation.
- CSS: 240ms per character, 14ms stagger capped at 500ms, fading in from
  blurred and slightly below. No color, no scale, no layout shift, and
  @media (prefers-reduced-motion: reduce) turns it off entirely.

* Trim the skills catalog from codex title requests

The plugin/skills catalog injected ~6k prompt tokens (24.3k -> 18k input
measured) into a one-line title request that ignores it.

* Strip the agent system prompt from claude title requests

--system-prompt replaces the ~8.5k-token Claude Code scaffolding with one
sentence (measured 8.5k -> 1.9k input tokens; wall time unchanged at ~3s,
which is node boot + one API round trip).
2026-07-27 18:22:18 -07:00
Daniel Kim 1b9b62328f Agent | Project scope toggle for the Experiments pane (#134)
* Agent | Project scope toggle for the Experiments pane (v0.1.83)

Attribute experiments to the chat session that created them
(local_experiments.chat_session_id, stamped from ORX_CHAT_SESSION_ID like
runs, immutable after creation) and add an Agent | Project toggle to the
experiments pane. Agent scope filters the tree to the open session's
experiments, collapsing foreign lineage regions into dashed "…" pills
with counts, and scopes the runs table to the same experiments.

* Show the Agent | Project toggle only in projects with attributed experiments

Legacy projects (created before chat_session_id existed) have no attributed
nodes, so the toggle would only ever show an empty Agent view there. Gate it
on the project having at least one attributed experiment — no backfill
needed, and pre-existing projects keep today's UI untouched.

* Require full attribution before showing the Agent | Project toggle

Any unattributed experiment (legacy or pre-feature) makes Agent scope
misrepresent the tree, so the toggle only renders when every experiment
in the project carries a chatSessionId.
2026-07-27 18:04:42 -07:00
Daniel Kim 397dd48815 Stream Codex sub-agent activity into the chat transcript (#117)
* Stream Codex sub-agent activity into the chat transcript

Sub-agents spawned via Codex collaboration now stream their own transcript
live, nested under the spawn row, instead of showing an opaque
collabAgentToolCall/subAgentActivity row.

- WirePart gains a recursive children list (serde-default, no migration)
- apply_item split into a pure item_to_part + thin streaming wrapper
- turn loop classifies foreign-turn events: parent / known sub-agent / stale,
  routing sub-agent items+deltas into the spawn part's children; the
  aborted-predecessor drop is preserved
- UI renders the nested transcript inline (recursive, auto-expands while
  running) plus a right-pane view tab

* Address review: settle orphans on all exits, re-point sendInput, share tool-group CSS, trim spawn fields

* Nits from review round 2 + version bump to 0.1.77

- drop dead .subagent-block class token
- re-point grandchild threads on sendInput (symmetry with top-level)
- fix stale doc comment on subagent_spawn_part
- rebuild ui/dist, bump Cargo version

* Fix un-collapsible running sub-agent row; add live header to view tab

- SubagentBlock: expanded is now a user-toggleable state seeded from running,
  instead of `open || running` which made a running row impossible to collapse
- SubagentTranscript: show a header (spawn label + status dot + "live" badge)
  so the right-pane tab reads as the sub-agent's own live view, not a
  duplicate of the inline block

* Stream Claude and OpenCode sub-agent activity too

Extends live sub-agent streaming (shipped for Codex) to the other two harnesses,
so a Task/task sub-agent's transcript nests under its spawn row in all three.

- Promote find_part_mut / upsert_preserving_children to chat/mod.rs and add
  TurnCtx::upsert_child / append_child_text — shared routing primitives
- Claude: route stream_event/assistant/user events carrying parent_tool_use_id
  (= the Task tool_use's part id) into that Task part's children, instead of
  dropping the deltas and leaking the blocks flat
- OpenCode: link a task-spawned child session (session.created carrying
  parentID) to its task row, and route that child session's parts/deltas into
  the row's children instead of filtering them out by sessionID
- UI: a tool part with children renders as a spawn block regardless of tool
  name (Codex subagent / Claude Task / OpenCode task)
- Tests: per-harness nesting tests; rebuilt ui/dist

* Review fixes: preserve spawn-row children on re-upsert; filter sub-agent user parts

- OpenCode final-message merge and Claude Task tool_use now use
  upsert_part_preserving_children so completing/re-emitting a spawn row doesn't
  wipe the sub-agent transcript that streamed into its children (blocker)
- OpenCode: gate sub-session parts on owned_by_assistant so a child session's
  user prompt echo doesn't nest as sub-agent output; track sub-session assistant
  message ids
- tests: assert Task/task row completes AND keeps children through the merge

* Nits: use imported HashSet; document one-level nesting scope in OpenCode

* Sub-agent row opens transcript in the right panel instead of expanding inline

Per design feedback: the inline sub-agent spawn row is now a single clickable
line (status dot + label + chevron) that opens its full transcript in the
right-side panel — matching the Claude/Codex desktop apps. No inline expansion,
no auto-expand while running (the row just pulses). Nested sub-agents in the
panel are themselves click-to-open rows.

* Size the sub-agent row like the transcript body text

Match the spawn row's font-size (--fs-base) and color (--text) to the
surrounding transcript prose, keeping the trailing chevron.

* Sub-agent row: block-level flex so long labels truncate with ellipsis

Review nit: inline-flex + flex:initial let a long label grow the row and push
the chevron instead of truncating. Use block-level flex + width:100% and keep
the base .tool-line flex:1/min-width:0 so the label ellipsizes and the chevron
pins right (matching .tool-group-summary). Also refresh a stale doc comment.
2026-07-27 17:46:42 -07:00
Daniel Kim 17633be723 Add a hover detail card to experiment tree nodes (v0.1.83) (#133)
* Add a hover detail card to experiment tree nodes (v0.1.83)

Hovering a node in the experiment tree (350ms intent delay) opens a
portal-anchored card with the full title/description, run stats
(counts, backend, duration), a lazily fetched diffstat vs the parent
branch, branch lineage, and the run command. Portaled to <body> so it
neither scales with canvas zoom nor disturbs the fixed tree layout;
dismissed on mouse-out or canvas wheel.

* Review round 1: shared diff parser, viewport-change dismissal, honest counts

- Parse the diffstat with react-diff-view's parseDiff + the Changes tab's
  countChanges/formatDiffFilePath (now exported) instead of a hand-rolled
  scanner; truncated diffs render as lower bounds.
- Replace the global wheel listener with useOnViewportChange dismissal
  (covers drag-pan, pinch, fitView) and re-measure the anchor when tree
  data changes; position via the existing useMeasure/usePopoverPosition.
- Fix duplicate failure text, 'passed'→'done' wording, reconciled run
  counts, live-run duration fallback, mono font-size leak, z-index tier,
  and reuse diff-stat-add/del classes.

* Review round 2: reliable pan/zoom dismissal, truncation-safe parsing, side fallback

- useOnViewportChange stores a single global callback, so per-node
  registrations overwrote each other; dismissal now flows from ReactFlow's
  onMoveStart through a module-level EventTarget every open card subscribes to.
- Byte-capped diffs can cut mid-hunk and crash parseDiff; trim to the last
  complete file (or line) first, try/catch as a backstop, and hide the row
  for empty diffs.
- On windows too narrow for either side placement, anchor above/below
  instead of letting the clamp sit the card on top of its own node.
- Skip rect churn on SSE ticks that don't move the node.

* Review round 3: reset diffstat on run change; label truncated file count

The diffstat effect never cleared state, so when SSE flipped latestRun
while a card was open the old run's counts lingered beside the new run's
status (and a failed refetch kept them forever). Reset at the top of the
effect. Also mark the file count as a lower bound on truncated diffs and
align the cancellation-flag name with the codebase idiom.

* Skip header-only diffstat remnant after truncation trim

* Hover card feedback: expandable description, darker text, drop file names

- Clamped descriptions get a Show more / Show less toggle; expanded text
  scrolls within the card instead of growing past the viewport.
- Title, stats and git rows read in --text instead of --muted (the card
  was mostly grey); the foot row stays muted as tertiary metadata.
- Diffstat keeps branch and +/− counts, drops the changed-file list.

* Drop the description's recessed background for hairline rules

The filled box was the only non-hairline surface on the card and read as
a heavy slab on long results; the description is now plain text between
the same hairline rules the rest of the app uses.

* Post-feedback review nits: toggle hygiene and hairline polish

Reset the expanded state when SSE swaps the description; type=button on
the toggle; trim CSS the global button reset already provides; bottom
padding on the expanded scroll body; comment accuracy.
2026-07-27 17:37:48 -07:00
Daniel Kim 04cae09219 Auto-grow the chat composer with multi-line input (v0.1.82) (#132)
* Auto-grow the chat composer with multi-line input (v0.1.82)

The composer textarea was fixed at two rows (rows={2}, resize: none), so
multi-line drafts scrolled inside a tiny box instead of expanding it
(issue #130). Grow it to fit content up to the existing 180px max-height
cap, past which it scrolls internally; an empty draft clears the inline
height so the resting two-row look is unchanged (and Chrome's
placeholder-in-scrollHeight quirk can't inflate an unsettled layout). A
width ResizeObserver re-measures on pane resizes since wrapping changes
the needed height.

* Rebind the autosize observer on composer remount; review nits

The composer textarea unmounts when a settings view opens (mainView !=
chat renders a different tree); the effect-based observer kept watching
the detached node and a remounted draft came back at two rows. Replace
the effect with a stable ref callback that rebinds the ResizeObserver
and re-measures on every mount. Also: skip measuring at zero width
(hidden/mid-layout scrollHeight is nonsense), drop the circular
useCallback around autosizeComposer, and document the width-only guard,
the CSS min/max-height coupling, and the field-sizing tradeoff.

* Floor the composer at its rows=2 resting height; comment polish

A one-line draft measured ~33px and clamped to the old 42px min-height,
so the box dipped ~10px on the first keystroke and jumped back when
cleared. Raise the floor to the rows=2 resting size (52px) so empty and
one-line drafts sit at the same height. Also soften the ref-callback
comment (an effect could work with a mount flag) and note the plan-strip
textarea sharing the CSS rule.

* Replace the JS autosize with CSS field-sizing: content

Drops the whole autogrow block from ChatPanel (measure function, layout
effect, ref callback, ResizeObserver) for one CSS declaration. Firefox
doesn't ship field-sizing yet and falls back to the previous fixed
two-row box; min-height stays 52px so the resting look is unchanged, and
the plan strip's revise textarea picks up the same growth for free.
2026-07-27 15:58:28 -07:00
Daniel Kim 62609c020b Don't treat single dollars as inline math in chat/report markdown (v0.1.81) (#131)
Prose like "cost $8–18 across both nodes ($1.45 each)" was pairing its
dollar signs into a KaTeX inline-math region, italicizing the span and
collapsing spaces. remark-math's default singleDollarTextMath treats any
$...$ as math; disable it and normalize agent-emitted \(...\) to $$...$$
so real math keeps rendering.
2026-07-27 13:05:04 -07:00
Daniel Kim 30e2ab18f4 Make reasoning/effort choices model-aware (#123) (#127)
* Make reasoning/effort choices model-aware (#123)

The composer exposed one static reasoning list per harness, which hid valid
choices and silently overrode the CLI's own configured effort.

Reasoning choices now ride on each ModelInfo, with the harness-wide list kept
only as the fallback for a session with no model selected:

* Codex — per-model tiers from a curated table (codex exposes no queryable
  effort metadata), kept in the same table as the model ids so a model can't be
  added without stating its tiers. Verified live against codex-cli 0.144: all
  three 5.6 models accept `ultra`, and 5.5 rejects `max` with a 400 — issue
  #123's table claims Luna stops at `max`, which did not reproduce. This matters
  because codex forwards the value unvalidated, so a wrong tier kills the turn.
* OpenCode — real per-model `variants` parsed from `opencode models --verbose`,
  sent as a top-level `variant`. A model with an empty variants map hides the
  picker entirely, which is why absent and empty are distinct states. Unknown
  variants are forwarded rather than filtered: the catalog is the authority and
  already ships ids outside our vocabulary (`thinking` on minimax-m3).
* Claude Code — `ultracode` gated on CLI >= 2.1.217. The installed 2.1.197 does
  not error on an unknown effort; it warns and silently runs at the default, so
  advertising it unconditionally would offer a choice that does nothing.

Every list leads with a `default` sentinel that sends no override at all, and
it is the default selection — so a configured `model_reasoning_effort = "max"`
is no longer clobbered by an implicit per-turn `high`. Switching models
reconciles a stored effort the new model doesn't support back to `default`,
both in the composer and again in each harness mapper before it reaches a CLI.

Deliberately no store migration for existing rows: every value older builds
wrote is still a value the picker offers, so a blanket reset would be
indistinguishable from a level the user just chose — and Store::open() is a
per-request constructor, so it would re-run and clobber fresh picks.

* Lower the ultracode gate to 2.1.203, the measured boundary

The constant was 2.1.217 — the version issue #123 happened to report — with a
comment admitting the true cutoff was somewhere in (197, 217] and that lowering
it needed a version actually tested. It has now been tested: every published
release in 2.1.197..=2.1.212 was installed in isolation and probed.

2.1.198-2.1.202 reject (warn on stderr, run at default effort); 2.1.203 and
every version above accept. So a 2.1.212 install was being denied a choice its
CLI supports.

The acceptance is specific to this value rather than newer CLIs dropping the
warning: on 2.1.206, `ultra`, `ultrathink`, and `ultracode123` all still warn
while `ultracode` does not. `--help` lists only the five ordinary tiers on every
version tested, so it can't be used to detect support.

The tests now pin the 2.1.202/2.1.203 pair, so moving the constant without
re-probing fails loudly.

* Discover the Codex catalog from the CLI instead of curating it

The model list and per-model effort tiers were hard-coded. That was wrong on
the facts, not just the principle: the installed codex actually serves seven
models (gpt-5.4, gpt-5.4-mini, gpt-5.3-codex-spark were missing), and the
curated Luna entry advertised `ultra`, which the catalog does not offer —
codex's own picker doesn't show it, so ours shouldn't either. (An earlier live
`codex exec` turn on Luna tolerated `ultra`, which is what the table was built
from; the catalog is authoritative for what to offer.)

Detection now spawns `codex app-server`, does the initialize handshake, and
calls `model/list` — the same data codex's TUI picker renders, with each
model's `supportedReasoningEfforts`. This is the codex analogue of the
opencode `models --verbose` path. The static table survives only as the
fallback for a codex too old to answer (legacy exec era), with Luna corrected
to the catalog's tiers.

Send-time validation follows the opencode precedent for catalog-discovered
models: a model outside the frozen fallback table forwards its level rather
than clamping to the conservative intersection, because the composer only
offered what `model/list` reported — clamping would drop genuinely supported
tiers. A bad value still surfaces as codex's 400 in the chat.

Claude stays gated rather than discovered, now with evidence there is nothing
to query: no models command, a static `--help`, and the "Valid values" warning
on 2.1.212 still omits `ultracode` even though that version accepts it — the
CLI's only enumeration surface is inconsistent with its own parser.

* Discover the Claude catalog and ultracode support from the CLI

Claude Code was the last harness with hard-coded models and effort tiers. It
turns out the CLI can be asked for both, via two surfaces:

* `list_models` — a control request over `--print` stream-json (the channel
  run_turn already speaks for set_model), returning the same catalog the CLI's
  own /model menu renders: each model's alias, and its per-model
  `supportedEffortLevels`. This immediately corrected the static list a third
  time: Haiku supports no effort control at all, yet the table had been
  offering all five tiers on it. Its picker now hides, like a variant-less
  opencode model.

* `--effort ultracode --version` — the CLI's own argument parser as a
  capability probe (~0.2s, no network). `--version` still runs the parser,
  which warns `Unknown --effort value` for a value it doesn't know; absence of
  the warning is acceptance. This replaces the bisected version constant
  entirely: the 2.1.202/2.1.203 boundary is now discovered per install rather
  than pinned. The parser is the only truthful surface — --help, the warning's
  own "Valid values" list, and list_models all omit ultracode on versions that
  accept it, because the CLI models it as a session mode, not an effort level.
  It is appended to models that reach xhigh (the mode is documented as xhigh +
  dynamic workflows), only when the probe passes.

claude_effort now forwards any non-sentinel value, matching the catalog policy
in codex_reasoning and opencode_variant: the composer only offers what the CLI
reported, an allowlist would drop a future catalog tier, and Claude only
warns-and-defaults on an unknown value rather than failing the turn.

The static model/effort tables survive only as the fallback for a CLI too old
to answer list_models, mirroring the codex and opencode fallbacks.

* Carry the catalogs' display names and blurbs into the picker

The Claude aliases the CLI's picker submits (`opus[1m]`, `sonnet`) are
unversioned, so the model menu couldn't say which Opus or Sonnet you'd get.
The catalogs already answer this: list_models has displayName + description
(the description is where the resolved version lives — "Opus 4.8 with 1M
context · …"), codex model/list has the same pair, and opencode's verbose
output has name. ModelInfo now carries display_name/description, and the
picker renders the catalog's own strings — the same text each CLI's native
picker shows — with the raw id demoted to a hover title. Statically-listed
fallback models keep deriving a label from the id.

* Lead with versioned names, drop the Default row, allow free-form model ids

Three picker fixes from using it:

* The Claude rows read "Fable / Fable 5 · Most capable…" — the name repeated
  because the catalog's displayName is unversioned and the version lives in
  the description's first `·` segment. That segment is now promoted to the
  display name ("Fable 5") with the rest as the blurb, so rows lead with the
  resolved version, exactly once.

* The per-harness "Default model · CLI configuration" row is gone wherever the
  CLI advertises a catalog — it was redundant noise next to a discovered list
  that already leads with the CLI's default. It survives only for custom
  providers, which advertise no models and where "let the CLI decide" is the
  only sensible row. New selections seed the catalog's first model instead of
  null.

* Typing an id the catalog doesn't list offers "Use "<id>"". The catalogs are
  curated menus, not the set of accepted ids — `--model claude-opus-5` works
  today on a CLI whose menu still resolves `opus` to 4.8 — so the menu can't
  be the ceiling. A custom id gets no per-model effort list (unknowable), so
  the reasoning picker falls back to the conservative harness-wide tiers.

* Preselect the effort that actually runs instead of a Default sentinel

The reasoning menu led every list with "Default · CLI configuration" — a
pseudo-value that told the user nothing about what would run. Replace it with
the real answer wherever the CLI can give one:

* Codex reports each model's defaultReasoningEffort, and config.toml's
  model_reasoning_effort outranks it (codex's own resolution order — verified:
  this machine's config sets "high" and the picker now preselects High on
  every model, not the catalog's low/medium). The sentinel row is gone for
  catalog models; the resolved tier is preselected and marked "· Default".
  Sending it explicitly is equivalent to sending nothing, by construction.

* Claude reports no default tier because its unset default isn't one: the
  catalog advertises supportsAdaptiveThinking, and with no --effort the CLI
  scales effort per request. Preselecting a fixed tier would pin behavior the
  user never chose, so the sentinel stays — now labeled "Adaptive", which is
  what actually runs, instead of "Default".

* OpenCode reports no per-model default, so its sentinel row stays as is.

reasoningFor resolves in that order: the model's reported concrete default,
then the sentinel when offered, then the harness-wide default. Stale stored
"default" sentinels on codex sessions reconcile to the resolved tier.

* Render a concrete default tier inline instead of pinning it

Pinning above a separator now applies only to the `default` sentinel, which
isn't a point on the tier ramp (Claude's Adaptive, opencode's model default).
A concrete default — codex's resolved tier, a permission mode — stays in its
natural ramp position with just the "· Default" marker, so the codex menu
reads Low → Ultra in order. Side effect on the shared mode menu: Auto renders
inline with the marker and the 1/2/3 number hints cover the full list again.

* Make every picker change update the sticky new-session preference

The mode/model/reasoning selection saved for new sessions only updated when
the pickers were changed with no session open. A change made inside a session
landed as that session's unsent tweak and the global preference never learned
about it — so configuring auto/sonnet/high mid-session and clicking New
session showed the old config.

selectModel now always writes the merged selection to the sticky preference
(state + localStorage), and additionally layers it onto the open session's
override as before. New session therefore opens with whatever was chosen
last, wherever it was chosen — verified across a full page reload.
2026-07-27 13:00:34 -07:00
Daniel Kim 07a7e209ff Detect Codex and Claude Code behind custom API endpoints (#125)
Follow the active provider in $CODEX_HOME/config.toml so a provider with requires_openai_auth = false authenticates off its declared env_key, and offer the model it configures instead of the first-party catalog. Accept ANTHROPIC_AUTH_TOKEN for Claude Code, letting the env credential win over a stale ~/.claude.json only when ANTHROPIC_BASE_URL is set. Add a "Default model" picker entry that sends model: null so the CLI uses its own configured default.

Closes #121
2026-07-26 22:13:09 -07:00
Daniel Kim 979d8e9362 Rework first-run onboarding and the New project modal (v0.1.79) (#126)
* Tighten the onboarding + new-project flows

Onboarding:
- Gate steps 1 and 2 (a signed-in agent, GitHub access) — both are hard
  requirements for chat and project creation, so Continue is disabled
  until they're met.
- Drop the data-dir step: the default suits ~everyone, it's write-once,
  and Settings > Storage already exposes it with a move flow.
- Split harness notes into not-installed vs not-signed-in, each with the
  real command (claude auth login / codex login / opencode auth login).
- Fix OpenCode reporting Connected while unauthenticated (agent_ready
  ignored authentication).

New project modal:
- Auto-open on the onboarding hand-off.
- Only show the fork choice when the user can actually push (new
  /api/github/repo-access), and say what happens when they can't.
- Name the real GitHub account in repo previews (new /api/github/account).
- Drop the branch picker; resolve the repo's real default branch server
  side instead of assuming "main".
- Describe the copy honestly: it's a single-branch, history-less snapshot,
  not a GitHub fork.
- Top-anchor the modal so the tab row doesn't move between tabs.

* Review round 1: fix onboarding lockouts, trapped name field, stale fork mode

- opencode: treat a non-empty `opencode models` list as authenticated, so
  env-key installs (no auth.json) aren't locked out by the new step-1 gate.
  Claude and Codex already had the equivalent fallback.
- Onboarding: surface a load error. A rejected probe left harnesses/git at
  null forever, which the gates read as not-ready — disabling Continue with
  no hint and no way out, on the screen that is the whole app at first boot.
- NewProjectForm: hoist the project-name field out of repoFields. A stalled
  access check used to unmount it, and `valid` needs a name, so the form
  became unsubmittable with no escape.
- NewProjectForm: reset canPush on every repo change and sync repoMode to
  the answer, so a forced "fork" can't linger and silently copy a repo the
  user can push to.
- github.rs: share one authed_get helper (with a 10s timeout — there was
  none), URL-encode owner/repo, drop the now-dead has_push_access wrapper.
- Drop the unread `known`/`defaultBranch` fields from the repo-access
  response and its TS type.
- ProjectsHome: consume the post-onboarding hand-off so a later remount
  can't re-open the modal.
- Deduplicate the paper-repo hint and the two identical code-chip CSS rules;
  tie the modal offset and max-height together with a --modal-top token.

* Review round 2: correct the opencode fallback, unblock SSH-only users

The round-1 opencode fix rested on a false premise — I claimed `opencode
models` only lists authenticated providers. Tested: under a signed-out HOME
it returns nothing, so the fallback never fired for the users it was meant to
rescue, and had the catalog ever been listed it would have marked signed-out
installs Connected. Replaced with an explicit provider env-key check, matching
claude.rs. Verified both directions: env key set -> ready, none -> gated.

- Onboarding: step 2 gets a "Skip — I use SSH keys" escape. ensure_clone
  tries ssh first, so a token isn't actually required for the use-existing
  path, and git_settings_json can't see SSH keys at all — the hard gate was
  locking out a setup the CLI's own startup note calls supported. Also covers
  the git-not-installed dead end, where the token form is unreachable.
- Onboarding: split the shared loadError into per-probe flags. A telemetry
  failure was putting a connectivity error on unrelated gates and hiding the
  actionable hint behind it.
- NewProjectForm: only force "fork" on denied access; the symmetric sync was
  undoing selectPaper's deliberate copy default for writable paper repos.
  Reset repoMode in onRepoChange instead, which is where the repo changes.
- github.rs: drop the deleted has_push_access's doc comment, left welded onto
  authed_get and describing the wrong thing.
- Bump to v0.1.79 (+ Cargo.lock): ui/dist changes don't ship without it.

* Review round 3: detect synced-env keys, unstick the branch fallback

- Harness detection: resolve API keys through orx's synced env file as well
  as the process env (new detect::api_key, used by claude.rs and opencode.rs).
  A key pasted into Settings > Environment is handed to the harness child, so
  chat works — but detect() couldn't see it, and step 1 is a hard gate.
  Verified: key only in ~/.openresearch/env now reports ready for both.
- opencode: widen the provider key list (Gemini/Groq/xAI/DeepSeek). Measured
  rather than assumed that `opencode models` can't stand in for a credential:
  signed out with no keys it still lists 7 free/bundled models, so keying off
  it would report signed-out installs as Connected.
- baseline_branch: fall back to `git ls-remote --symref` when the GitHub API
  can't answer. Without a token the branch resolved to a hardcoded "main" and
  the clone died on any master/dev repo — and the form no longer has a branch
  field to recover with. git's own ssh/https credentials need no token.
- Step 1 no longer gates on a failed probe: detection fans out to per-binary
  version/model commands that can stall, and there's no way out of onboarding.
- Step 2's skip is always offered and reworded "Skip for now": a stale gh
  token still reads as connected, hiding the escape from the users who need
  it most. Promoted to a real button rather than a muted link.

* Rebuild ui/dist after rebase

* Review round 4: stop 404 forcing a history-losing copy

- repo_meta: a 404 means "invisible to this token", which is also how a
  private repo looks to an SSH-only user who can push to it fine. It was
  reported as can_push=false, which force-forks — and this branch removed the
  "Use this repo" button in that state, so the override was gone too. The
  user would silently get a new repo with their history dropped. Report
  unknown instead, matching the fallback used everywhere else.
- remote_default_branch: bound it with a 15s timeout. ls-remote tries ssh then
  https, and a black-holed host blocks on TCP connect ~75s each — the exact
  hazard authed_get got a timeout for last round, reintroduced on the git path
  of the same request. A stall now degrades to "main" instead of hanging.
- git.rs: the new function had been inserted between ensure_clone's doc
  comment and ensure_clone, so the comment described the wrong function and
  ensure_clone was left undocumented.
- Never show a "Detecting…" spinner next to a fetch error: on a failed probe
  both steps now render the retry copy in place of the card, instead of a
  spinner that claims work is in progress beside an enabled Continue.
- onRepoChange no longer resets repoMode in paper mode, where editing the
  auto-filled repo would quietly retarget pushes to the upstream paper repo.

* Review round 5: make step 2 an honest soft gate, fix stale-state contradictions

- Step 2 no longer pretends to gate. Cloning and pushing work over SSH keys,
  so a disabled Continue beside an always-enabled "Skip for now" was two
  buttons doing the same thing — one greyed out, which reads as a bug. One
  Continue, always enabled, and the hint states what's missing instead of
  blocking on it.
- A rejected probe now clears its state as well as setting the error flag.
  Otherwise a failed *re-check* kept the old cards on screen: stale "Not
  signed in" badges and a "sign in to continue" hint next to a Continue that
  harnessError had just un-gated, with no indication the check had failed.
- Mode switches reset repoMode via a chooseMode helper. Leaving paper mode
  carries the paper's repo into the field but not its copy default — and
  because the field is pre-filled, onRepoChange never fired to reset it, so
  "Private copy" stayed selected on a repo the user never chose it for.
- Deduplicate the project-name field and blank-repo hint, both of which were
  inlined a third time in "New blank repo" (flagged since round 2).

* Review round 6: restore the Existing repo name field, kill the footgun

Round 5's nameField refactor turned it into a function but updated only two of
three call sites. `{parsed && nameField}` passes the function itself as a
React child, which renders nothing — so "Existing repo" had no Project name
input and Create stayed disabled. Both tsc and a grep of the call sites missed
it; three reviewers caught it.

Made the class of bug impossible rather than just fixing the call site: the
autoFocus parameter was the only reason nameField was a function, so it's a
plain element again like blankRepoHint. Verified in a browser this time —
name field present and submit enabled in every mode.

- Onboarding: the docstring still called step 2 a hard gate, which round 5
  made false, and it sat below the RETRY_COPY const so it documented the wrong
  symbol. Rewritten and moved.
- Onboarding: sequence-guard load(). A token save landing mid-refresh was
  overwritten by the pre-save snapshot, leaving the card reading "Not
  connected" until the next check; the save now supersedes in-flight probes.

* Review round 7: fix a Re-check lockout, share renderNote, trim comments

- load()'s .finally was sequence-guarded, so a token save (which bumps loadSeq
  to supersede the in-flight probe) left `checking` true forever and both
  Re-check buttons dead for the session. `checking` is "a load is running",
  not data a stale response can corrupt — it shouldn't be guarded. Introduced
  by round 6's guard; verified fixed by superseding a slow load in a browser.
- renderNote moved to its own module: ModelPicker, a core chat control, was
  importing it from the first-run walkthrough.
- Drop the redundant {parsed && …} guard (repoFields already returns null),
  a doubled CSS comment block, and two stacked rationale comments.
- Document the opencode agent_ready tightening where the behaviour changes:
  installs with no auth.json and no provider key now read "Not signed in".
2026-07-26 21:48:33 -07:00
Daniel Kim f84bdf3b75 Catch "Permission denied (publickey)" before it costs a box (v0.1.78) (#119)
* feat: catch "Permission denied (publickey)" before it costs a box

orx shells out to ssh with no -i, so it authenticates with whatever the
local agent and ~/.ssh offer, while boxes authorize the keys registered
on the account. A key registered from another computer leaves the box
online but unreachable, and the user only finds out after paying to
provision it.

Compare the registered public keys against the locally-available ones at
login, before launch, and on a failed launch, and add `orx ssh-key
add|list` — the command the dashboard has always pointed at but which
never existed.

- login offers to register this computer's key ([Y/n], TTY-gated)
- preflight blocks when nothing is registered, warns when we can't find
  a local match (ssh can still succeed via an IdentityFile elsewhere)
- a publickey denial becomes one actionable line in `orx runs`
- the settings badge distinguishes "on this computer" from "registered
  elsewhere", which the old boolean could not

* fix: don't call the openresearch backend "Configured" without a usable key

The compute row keyed "configured" off being signed in, so it showed a
green badge on a backend that would refuse every connection — the same
gap the launch failure exposed, one screen earlier.

Gate it on holding a key the account has registered, and say which of
the two things is missing. Also run the check when openresearch is made
the default, since that's the moment the user is committing to it.

Unknown (api unreachable, ~/.ssh unreadable) counts as ready: we
couldn't tell, so don't cry wolf.

* fix: say "Unknown" when the readiness check couldn't run

Folding a failed check into "configured" put a green badge on a backend
we never verified; folding it into "not configured" would show alarm
whenever the api hiccups. Neither is true, so stop making the boolean
answer a question it has no data for.

Adds an optional `unverified` flag — absent for backends whose state is
decidable locally, so no other row changes.

* fix: name the key file we found instead of guessing one

The compute row told everyone to run `orx ssh-key add
~/.ssh/id_ed25519.pub` — a path that doesn't exist on a machine whose
key is named anything else, so the fix led to a second error.

The readiness check already resolves the real file via preferred_local;
carry it through instead of discarding it. Split the keyless case out,
since `ssh-key add` alone can't help there, and surface the Unknown
reason so that state stops being a dead end.

* chore: v0.1.78

* fix: stop guessing ~/.ssh/id_ed25519.pub in the skill, notes and run failure

Same bug as the compute row, in three more places. The skill was the
worst of them: an agent reads it and runs the literal command, so on any
machine whose key is named differently it fails with nothing to go on.

- skill: tell the agent to read the path from `orx ssh-key list` rather
  than handing it one, and cover the no-key-at-all case
- settings notes: use the .pub the check already found (new sshKeyPath
  in the payload), with separate copy when there's nothing to register
- run failure: point at `orx ssh-key list`; the supervisor can't see
  ~/.ssh, so it has no path to name
2026-07-24 16:58:21 -07:00
Daniel Kim fcba6a25d1 Show a loading state instead of the empty state while chat history loads (v0.1.77) (#118)
* Show a loading state instead of the empty state while chat history loads (v0.1.77)

Switching between agent conversations briefly flashed the empty state
(OpenResearch logo + 'Ask the agent to explore your codebase...') before
the conversation's messages finished fetching. The empty vs transcript
branch was gated purely on messages.length, which can't distinguish a
not-yet-loaded session from a genuinely empty one.

Add a distinct 'Loading conversation…' state, driven by key presence in
messagesBySession (absent = still fetching). A failed fetch now seeds an
empty transcript so it recovers to the usable empty state instead of a
stuck spinner, and drops the loadedSessions guard so a switch-back retries.

* Enlarge the chat loading spinner and text for legibility

The 13px muted text + 13px wheel read as too small and hidden. Bump the
wheel to 22px (thicker stroke), the label to 16px, and use --subtext for
better contrast.

* Guard failed-fetch recovery against clobbering streamed messages

Review nit: the .catch seeded [] unconditionally, which could overwrite a
message that streamed in via SSE during a failed history fetch (self-heals
on the next re-broadcast, but briefly drops it). Add an onlyIfAbsent flag to
the seed action so recovery no-ops when the transcript key already exists.
2026-07-23 18:15:19 -07:00
Daniel Kim 7940344d44 Open agent-inlined files-dir report links from chat (v0.1.76) (#116)
Clicking a report.md link an agent inlined into the chat transcript failed
with "path must be repo-relative": the report lives in the files dir
(<data dir>/files/<slug>/…), but every chat file-open routed through
parseFilePath → FileViewer → the repo /file endpoint, which rejects absolute
paths. parseFilePath had no files-dir case and FileViewer carried no source
discriminator, so files-dir artifacts were unroutable.

- Backend surfaces a (non-canonical) absolute filesDir on every project
  payload via a shared project_json helper, routed through all six emission
  sites including the SSE project.updated diff — enriching only list_projects
  would let a bare project clobber filesDir on the next visit.
- parseFilePath gains a files-dir branch: exact-prefix match against filesDir
  (byte-for-byte with the path the agent inlines), plus a /files/<slug>/
  suffix-regex fallback for symlink-divergent data dirs. Tabs it produces
  carry source:"files".
- FileViewer branches its fetch by source: files-dir reports via /files/report,
  other files via /files/file, else the repo endpoint. Files-dir markdown
  renders through FilesTab's ReportMd (now exported) so report-relative image
  paths resolve; a missing file shows a friendly not-found; repo-only notes are
  suppressed.

Verified end-to-end: the failing report.md chip now opens the report from the
files endpoint with no error.
2026-07-22 20:16:39 -07:00
Daniel Kim 7956d54af2 Per-session context-window usage meter (#114)
* Per-session context-window usage meter

Surface how much of the model's context window each chat session has used —
a compact percent pill beside the model picker, with a popover breaking it
down into a progress bar and token caption (amber >=80%, red >=95%).

Backend: harness adapters now parse the token usage the CLIs already emit and
discard today, normalize it into a ContextUsage (used = latest request's
input + cache + output, not cumulative), stream it live over a chat.usage SSE
event, and persist the final value to a new chat_sessions.context_usage_json
column so it survives restarts. session_json carries contextUsage thereafter.

  - Claude: assistant message.usage (live) + result modelUsage.contextWindow
    (window), falling back to the last usage.iterations entry.
  - Codex: app-server turn/completed usage and legacy exec token_count
    (input + output; cached_input is a subset). Window from model_context_window
    when reported; static fallback intentionally empty (no invented numbers).
  - OpenCode: assistant message.updated tokens (input+output+reasoning+cache);
    window left unknown (provider config only), so the pill shows the count.

Frontend: ChatSession.contextUsage + fmtTokens helper; a chat.usage listener
that updates the session in place; a ContextMeter pill/popover; and a shared
ProgressBar extracted from SettingsPage and reused in both.

ui/dist rebuilt. A release version bump is still needed to ship.

* Restyle context meter: ring trigger + desktop-style popover

* Context meter: darker value text, more gap before send
2026-07-22 16:50:30 -07:00
Daniel Kim cef4881032 Stop composer keystrokes from re-rendering the whole transcript (v0.1.74) (#113)
* Stop composer keystrokes from re-rendering the whole transcript

Typing in the composer was laggy on long conversations: the draft state
lives in ChatPanel, which also rendered the transcript inline, so every
keystroke re-ran the full component body, re-filtered every message, and
allocated + memo-compared all N Message elements (doubled by StrictMode
in dev). PR #111 fixed the streaming path but left the typing path.

- Extract the transcript map into a module-level memoized Transcript
  component; all its props are referentially stable across keystrokes,
  so typing now costs one shallow compare instead of O(messages) work.
- Memoize the waiting-session scan (ran per session row per render,
  iterating messages x parts) into a useMemo'd Set keyed on reducer
  state, which only changes on SSE dispatches.

* Review nits: mirror Message's onRespond type, state memo constraint

* Ship rebuilt ui/dist; bump to v0.1.74
2026-07-22 13:59:37 -07:00
Daniel Kim 9fe3579c2d Stop re-rendering the whole transcript on every chat streaming tick (v0.1.73) (#111)
* Stop re-rendering the whole transcript on every streaming tick (v0.1.73)

Long chat sessions crawled during streaming because each 150ms flush
re-broadcast the full assistant message (with uncapped tool outputs) and
the UI re-rendered — and re-parsed markdown/KaTeX for — every message in
the session on every tick.

Server: cap tool part output/error at 16k chars in TurnCtx::flush (one
choke point covering all three harness adapters), with a visible
truncation marker; the UI never rendered more than 20k chars anyway.

UI: wrap Message, Md, ToolGroup and ToolRow in React.memo, and make the
callback props (respond, openPlan, per-session file opens) referentially
stable so the memoization holds. upsertMessage already preserves object
identity for untouched messages, so only the actively streaming message
re-renders now.

* Review nits: drop inert memos, document the 16k/20k cap coupling

ToolRow/ToolGroup get fresh part objects every streaming tick (the server
re-broadcasts the whole message), so memo on them never skips — Message's
memo is what short-circuits untouched messages. Keep memo only where it
works (Message, Md). Also note on ToolRow's 20k slice that the backend
caps at 16k so the slice must stay above it, and drop the render-site
comment that moved to openFileInSession.
2026-07-22 11:57:41 -07:00
Daniel Kim 54d0be7521 Live session-worktree viewer (Worktree tab) (#110)
* Add live session-worktree viewer ("Worktree" tab)

A new right-pane "Worktree" tab shows, for the active chat session, what the
agent is changing in its private git worktree before any run/commit exists:

- Changes view: status-badged changed-file list (complete even when the diff
  truncates) plus the unified diff of the worktree vs merge-base(origin/<baseline>,
  HEAD), untracked files rendered as new-file diffs.
- Files view: the full live worktree tree (shared CodeTab tree components),
  clicks opening the existing FileViewer against the session's worktree.

Backend: new read-only GET /api/chat/sessions/{id}/worktree endpoint and a
sessionId param on the code-tree endpoint (threaded through resolve_checkout_root
instead of a hard-coded None; mutually exclusive with ref). New git.rs helpers
merge_base, working_tree_diff_against, and changed_files are all read-only.

UI polls every 5s only while the session is busy (chat.busy SSE), refreshes once
on busy->idle, manual refresh always; keeps last-good data on transient errors.
Entry point is a FolderGit2 button in the chat header when a session is active.

* Review fixes: clear worktree tab on project switch/session delete, seed busy poll on mount, changed-files dedup

* Worktree tab feedback: diff-only Changes view matching the experiment drawer, styled tooltip on the header button

* Tooltip: View session worktree
2026-07-22 11:28:34 -07:00
Daniel Kim 0329d50e3a Keep markdown table cells from breaking words mid-word (v0.1.72) (#109)
* Keep markdown table cells from breaking words mid-word

.md's word-break: break-word shrinks each cell's min-content width to a
single character, so auto table layout squeezed short columns (e.g. a
"Round" header) into vertical letter stacks. Restore normal word-breaking
in cells with overflow-wrap: break-word for genuinely overflowing tokens,
and give .md tables the same scroll-in-place treatment as .report-md
tables so whole-word column minimums can't clip in narrow panes.

* Rebuild ui/dist

* Bump version to 0.1.72
2026-07-21 17:28:53 -07:00
Daniel Kim e1b6c9c608 Render LaTeX math in chat and report markdown via KaTeX (v0.1.71) (#108)
* Render LaTeX math in chat and report markdown via KaTeX

Agents emit \(...\) / \[...\] LaTeX which plain markdown mangled
(backslash escapes stripped, underscores italicized). Add remark-math +
rehype-katex to the shared Md renderer and the Files-tab report renderer,
with a fence-aware normalizer that rewrites backslash delimiters to the
dollar forms remark-math understands. Applies to all harnesses since
every transcript text part renders through Md.

* Bump version to 0.1.71
2026-07-21 16:50:30 -07:00
Daniel Kim 3661374314 Composer command chips, optional paper args, drop /hf (v0.1.70) (#107)
* Composer command chips: picked slash-skills render as a removable unit

- Picking a skill (menu, typing /name<space>, or the empty-state suggest
  button) lifts the command out of the textarea into a chip; the textarea
  holds only the args, with the skill's arg hint as its placeholder.
- Backspace at position 0 (or clicking the chip) pops it back into
  editable /name args text; send() reassembles the plain wire form, so
  server-side expansion is unchanged.
- User bubbles render a leading known /command with the same chip.
- /reproduce-paper args are now optional: bare invocation tells the agent
  to infer the paper from the current repo and only ask if unidentifiable;
  arg hint is bracketed to signal optionality.
- Drop the /hf skill.
- Fix a latent IME bug: Enter during composition no longer sends.

* Review nits: rename activeSkill to pickedSkill (avoids collision with the menu's activeSkillIdx), fix stale pending-question comment

* Chip inline with the input line; paper-aware optional placeholder

- The chip and textarea now share a flex row (.composer-input), so the
  command and its arg hint read as one line, Claude-desktop style.
- When the project has an attached paper, the /reproduce-paper placeholder
  says the args are optional and names the paper it defaults to.

* Chip overlays the textarea with first-line indent so args wrap full-width

The flex-row layout squeezed wrapped args into a column beside the chip.
Instead the chip now sits absolutely on the textarea's first line, the
first line is text-indented past its measured width, and the chip rides
the textarea's scroll (clipped at the wrapper) so scrolled text is never
hidden behind it.

* Claude-style arg hints and chip semantics

- Bracket every skill's arg hint ([topic], [paper on compute], ...) to
  match the Claude-desktop placeholder convention.
- The chip is inert like inline text: pointer-events none so clicks land
  in the textarea beneath, and Backspace at the start deletes the command
  outright (args stay) instead of dissolving it into /name text.

* Hint reads [paper] on [compute] — bracket each supplied part, not the phrase

* Paper-attached hint keeps compute; extend to /paper-to-marimo

- Placeholder on paper-attached projects now reads
  '[paper — optional, defaults to <id>] on [compute]' for both
  paper-reproduction skills — only the paper part is optional; compute is
  still expected.
- Both templates and /paper-to-marimo's no_args now tell the agent to
  infer the paper from the repo when the args name none, so a
  compute-only invocation works too.

* v0.1.70
2026-07-21 15:09:26 -07:00
Daniel Kim a7cc9947a7 Codex: render every item type in the transcript; orx-lit-first routing rule (#105)
* Render every Codex item type in the transcript; route orx-lit first

The Codex chat adapter's apply_item whitelisted five ThreadItem variants
(agentMessage, reasoning, commandExecution, fileChange, plan) and dropped
everything else through a `_ => {}` catch-all. Codex reports its web
searches, MCP tool calls, and dynamic tool calls over the wire, so those —
and any future protocol addition — vanished from the transcript.

Replace the catch-all with explicit arms for webSearch, mcpToolCall, and
dynamicToolCall (rendered as tool parts, parity with the Claude harness), an
explicit skip for userMessage/hookPrompt (input echoes that would duplicate
the user bubble), and a generic fallback that renders any other item type as
a tool part named after its raw type — nothing is silently swallowed. The UI
toolLine now shows the WebSearch query and the WebFetch url. Rebuild ui/dist.

Also rewrite the orx-lit skill description as a routing rule: use it FIRST,
before any web search, for anything academic or research-flavored (paper,
author, blog post, model release). GPT skipped orx-lit on research queries
because the old description read as experiment-loop-only. Mirrored across the
code const, the skill frontmatter + body, SYSTEM_PROMPT.md, and root SKILL.md.

* Render codex web-tool actions (openPage/findInPage), not just search queries

The webSearch item's query is legitimately empty for openPage/findInPage
actions; the action object carries the url/pattern. Merge action fields
into the tool part input and label each variant in the UI.

* Label opaque codex web-tool actions as Browsed the web

codex 0.144 reports page visits as action {type:"other"} with no url —
the openPage/findInPage variants exist in the protocol but aren't emitted
by the hosted web tool yet. Stop calling those rows searches.

* orx-lit description: preferred literature tool, not a web-search sequencing rule

The 'use FIRST, then web-search what the corpus lacks' phrasing implied a
web search always follows. Intent: orx-lit is the preferred method for
literature search on academic/research work; web search is a fallback and
often unnecessary.
2026-07-20 18:50:13 -07:00
Daniel Kim 7cfef22bf6 Tour copy and sizing polish (v0.1.69) (#104)
* Tour copy and sizing polish

Rewritten step descriptions (workspace pitch, prompt examples, worktree
framing) with no em dashes and a single colon across the deck. Card
sized between the original and the first enlargement: 390px wide,
fs-lg title, fs-base body, full-size footer buttons.

* Bump version to 0.1.69
2026-07-20 15:49:13 -07:00
Daniel Kim 2dd67bc2ca UI design-consistency pass: token scales, sentence case, sans-first, rounded everywhere (v0.1.68) (#103)
* ui: add type/weight/radius token scales, drop dead CSS rules

* ui: drop all uppercase transforms and tracking, sentence-case status strings

* ui: reserve mono for code, paths, ids and terminal output

* ui: primary content uses --text; grays reserved for true secondary text

* ui: normalize font weights onto the --fw-* scale

* ui: normalize radii and type sizes onto token scales, round callouts and inputs

* ui: pin badges to sans in mono contexts, un-mono instance durations; rebuild dist

* ui: sentence-case remaining status strings, fix stale ModelPicker comment

* ui: promote composer reasoning pill to --text

* ui: shared Wordmark component — logo mark + all-black OpenResearch text

* ui: match home wordmark weight to the landing page (800 -> 700)

* chore: bump version to 0.1.68
2026-07-20 14:59:21 -07:00
Daniel Kim 0a42fb8b28 Onboarding tour: spotlight walkthrough of the workspace (#102)
* Onboarding tour: spotlight walkthrough of the workspace

Seven-step tour (welcome, composer, model picker, Files, Compute,
experiments panel, new session) shown once after first-run setup and
replayable from a help button in the chat header. Ported from the
openresearch.sh onboarding system: data-onboarding attribute targeting,
ResizeObserver/MutationObserver bounds tracking, box-shadow spotlight
dim, viewport-clamped card with pointer arrow. Completion persists to
localStorage (orx:tour-done). Escape is captured so it can't interrupt
a running agent turn.

* Tour polish: exact-primary ring, data-tip tooltip on replay button

The ring's 45%-alpha glow plus a 1.5px border read as a lighter red than
the theme primary; a crisp 2px border with no glow matches it exactly.
The header help button now uses the app's data-tip tooltip instead of a
native title.
2026-07-20 14:24:24 -07:00
Daniel Kim aba90b6298 Files tab redesign: split explorer with inline preview (#100)
Replace the pill-header list + full-page report swap with a master-detail
split: a resizable file tree on the left (folders open by default, report
folders titled by their report, dir path demoted to a copyable footer) and
a persistent preview pane that renders everything inline — reports and
markdown as GFM docs with a view-source toggle, images and PDFs directly,
other text as highlighted code via the new shared CodeView (also adopted
by FileViewer), with an Open-raw fallback for binary/oversize files.

Icon-only controls get a CSS data-tip tooltip primitive; empty states for
no-files and nothing-selected; FilesTab keyed by project and PreviewPane
keyed by path so state can't leak across switches.
2026-07-20 10:36:50 -07:00
Daniel Kim a72c4cdc2b feat(codex): native plan mode as a selectable permission mode (v0.1.66) (#98)
* feat(codex): native plan mode as a selectable permission mode

Codex ≥ 0.144 supports plan mode as an app-server collaboration mode.
Advertise Plan + Auto + Bypass for codex and wire the full flow:

- initialize sends capabilities.experimentalApi (unlocks collaborationMode)
- conditional per-turn collaborationMode mask: plan on plan turns; default
  on a non-plan turn only when the thread may be sticky-planned (DB
  prev_permission_mode threaded via TurnCtx + in-memory last-sent tracking);
  otherwise no mask (a mask always injects a template)
- render streamed plan items (item/plan/delta + completed plan item) and
  synthesize an end-turn plan card / PlanStrip
- requestUserInput -> question card, answerable inline or via composer;
  per-kind server-request settle shapes (approval/userInput/other)
- resume_from_prompt: plan (send-message), permission/question (inline reply)
- store: keep 'plan' off the codex retire-list; reuse claude.rs
  synthesize_resume / should_synthesize_plan via harness re-export
- UI: PlanStrip agentLabel; ChatPanel pendingQuestion gate includes codex

Legacy exec path (< 0.144) keeps Plan->read-only, with an updated agent note.

* fix(chat): keep prompt-resume scaffolding out of the transcript

Plan/permission card resumes send synthesized text the user never typed
("Implement the plan.", "The user approved that action. Continue.").
Persisting it as a user message misattributes it in the UI. Split the
transcript text from the harness text in send_message (the slash-skills
precedent): plan/permission resumes now record only the user's own note
— nothing at all when there isn't one; the resolved card tells the rest.
Question resumes still record the answer, which the user did choose.

* chore: bump version to 0.1.66
2026-07-19 20:22:35 -07:00
Daniel Kim 66c8159511 Match the web app's favicon and retitle the tab to OpenResearch CLI (#96)
Ports favicon.svg from openresearch.sh so the embedded UI's browser tab
shows the same brand mark, and retitles the tab "OpenResearch CLI" to
distinguish it from the hosted web app. favicon.svg joins index.html in
the no-cache branch since it's served under a fixed (unhashed) name.
2026-07-19 17:29:38 -07:00
Rehaan Ahmad 67190285ab chore: support automated reproductions (#95) 2026-07-19 13:56:43 -07:00
Rehaan AhmadandClaude Fable 5 87df17ce0f chore: Report md heading styles (#91)
* Report pane: restore Markdown heading hierarchy in report-md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bump version to 0.1.63

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:24:55 -07:00
Daniel Kim 012672958d Plan-mode chat: AskUserQuestion double-card/hang fix + desktop-parity plan/question cards (v0.1.61) (#84)
* Fix harness prompt bugs: AskUserQuestion double-card + hang, resolved-card rendering

- plan_auto_policy auto-allows AskUserQuestion: the bridge no longer fronts
  the question card with a redundant permission card whose held request also
  deadlocked the end-turn resume (answer rejected as busy until interrupt).
- respond() resolves a card only after its answer is delivered, so a rejected
  send leaves the card actionable instead of resolved-with-the-answer-dropped.
- Resolved cards mirror Claude Code: permission cards vanish, plan/question
  cards collapse to compact inline expandable rows with an answer echo
  (answers/approved/note stamped on resolve, carried through turn flushes).

* Review round 1 fixes: doc split, echo-preserving adopt, neutral outcome, read-only class rename

* Review round 2 fixes: PendingGuard doc, best-effort resolve folded into helper, neutral question echo

* Post-review nits: doc wording, dedupe question text in collapsed body, word-break on collapsed rows

* Plan-mode AskUserQuestion truly blocks: bridge holds the call as the question card

Auto-allowing AskUserQuestion was wrong: headless the tool returns no
answer, so the model guessed and kept planning instead of waiting. The
bridge now surfaces the QUESTION itself as a held mid-turn card (like
ExitPlanMode's plan card); the answer settles the held call as a denial
whose message is the answer, so the model continues the same turn with
the user's actual choice. The turn-status line says 'Waiting for your
input…' instead of 'Working…' while a card holds the turn open.

* Post-review nits: deny-message hardening, question cards don't suppress plan-synth, empty-options fallback, held-card status via nativeId, comment accuracy

* Polish: no tool row for bridge-held calls (deny-with-answer painted them red), plan pane gets padding + plain background

The bridged AskUserQuestion/ExitPlanMode tool_use now renders nothing —
the held card is the representation, and the denial that carries the
user's answer back made the CLI report the tool as errored, painting a
successful Q&A as a red error row. The right-pane plan tab had no CSS
at all: flush text on the panel tint; it's a markdown document, so it
gets a padded, plain reading surface.

* PlanStrip: Claude-desktop shape — title line, real buttons in a row below

The strip's buttons used .btn-primary/.btn-ghost, which are only styled
inside .prompt-actions — in the strip they rendered as bare text crammed
next to the title. The actions row now reuses .prompt-actions (same
buttons as every card) and sits on its own line under the title.

* PlanStrip buttons: monochrome — black text/borders, no brand red

* PlanStrip split button: single internal divider (caret drops its own left border)

* PlanStrip split button: flat inner corners (radius reset must out-rank .prompt-actions shorthand)

* Composer: attention bell instead of Stop while a held card awaits the user

A blocked-on-input turn is busy, so the composer showed the Stop X — a
loading/cancel affordance while the agent is actually waiting on an
answer. When the session is busy AND a held card (nativeId) is
unresolved, show a pulsing bell that scrolls to the pending card; Esc
still interrupts. The transcript status line shares the same
awaitingInput signal.

* Composer: two states only — Send by default, Stop X only while actively streaming

Drop the bell experiment. A turn held on an unanswered card shows the
idle Send button; the card and the status line are the waiting
affordance. Esc still interrupts a held turn.

* Composer answers the pending card (Claude-desktop): typed text = plan revision feedback / custom question answer

- send() routes plain typed text to the newest unresolved plan/question
  card (respond with note) instead of racing the held turn with a new
  message the busy guard would drop; placeholder says so. Questions
  route only on Claude sessions (opencode rejects note-only replies).
- Rail busy-dot stops pulsing when the turn is held waiting on the user
  (steady dot; same awaitingInput signal as the status line).
- Note-only question answers echo the note in the collapsed row.

* PlanStrip: Claude-desktop parity actions + popovers above the strip

Reject / Revise… / Accept / Accept-and-auto-mode (+ bypass in the caret),
Open plan as a title-row link. Reject is a true rejection: the note-less
plan denial now tells the model to stop and wait (was 'keep refining',
which made it improvise revisions/questions); collapsed rows echo
'Rejected' vs 'Revision requested' by note presence. Accept resumes
under accept-edits (guarded), Accept-and-auto under Auto — the primary
is the solid dark button, not brand red. The strip moved inside
.composer so the mode/model picker popovers (z 50 in that stacking
context) layer above it — as a sibling, .composer's z-index:4 capped
them below the strip.

* PlanStrip: single Accept-and-auto-mode primary, drop the plan excerpt

Matches the desktop reference exactly: [Reject] [Revise…] on the left,
one primary button on the right whose caret menu holds the two guarded
tiers (Accept = accept-edits, Accept and bypass all). The separate
outline Accept button and the plan-title excerpt next to the header
are gone — plan.excerpt/plan prop was fully unused after removal.

* PlanStrip: self-contained Revise mode (Claude-desktop parity)

Swaps the strip into its own textarea + [Back] [Revise ↵] footer
instead of routing through the main composer — matches the desktop
reference exactly. Enter submits, Shift+Enter newlines, Esc cancels
back to the action row. The main-composer typed-text routing
(pendingPrompt) is now question-only; plans have their own dedicated
UI and no longer need it.

* PlanStrip: drop the plain accept-edits Accept option

We have no partial-approval story (edits-only, everything else still
gated) — the caret now holds only Accept and bypass all.

* Plan revision: visible feedback + never silently dead-end

- Submitting a revision now swaps the strip to a 'Revising the plan…'
  placeholder until the revised card arrives (or the turn ends) —
  previously the strip snapped back to an identical-looking action row
  and the submit read as a no-op.
- An empty revise note maps to a generic 'Please revise the plan.'
  instead of riding the note-less wire shape, which the backend reads
  as a hard rejection ('stop and wait') — the '(optional)' field was
  accidentally a Reject.
- respond() reconciles messages AND busy state from the server after
  every answer: a stale card (e.g. resolved by the bridge timeout while
  the tab's SSE was dropped) previously stayed actionable forever and
  swallowed every answer.

* PlanStrip: drop the 'Revising the plan…' placeholder

The transcript's Working… spinner already communicates it. The strip
still hides while a submitted revision is in flight (the outgoing card
must not look actionable) and the revised card swaps in on arrival.

* Review round 1 fixes: zombie-card recovery, honest composer button, reject-loop fix

Blockers:
- A held card orphaned by a process restart (PendingGuard never ran)
  permanently captured typed input: the composer routed every message to
  it and errors were swallowed. Now the backend collapses the zombie on
  the first answer attempt, and the composer only routes typed text to a
  question card that is actually answerable (live turn for native cards).
- The Send/Stop swap keyed on awaitingInput while send() could only
  service question cards — a held plan/permission card showed a lying
  Send (silent no-op) with no Stop control. Now keyed on pendingQuestion.

Also: note-less Reject on an end-turn/synthesized plan card resolves
with no resume (resuming just to say 'stop' re-synthesized a card — an
un-dismissable strip); the bridge plan-deny arm reuses synthesize_resume
verbatim; respond() reconciles busy for its own session only and reports
failure so the question route can restore a consumed draft; revising
clear gated to its session; un-docked plan card fallback matches the
strip's semantics; Rejected gets its own outcome class; note-only
answers echo once; split-button CSS consolidated; dead CSS/union
removed; test pins the reject wording.

* Post-clean-round nits: keep optimistic busy on failed reconcile fetch, sweep zombie held cards at claude turn entry

- respond()'s busy reconcile no longer clears busy when the sessions
  fetch fails — during a Handled resume nothing re-asserts busy
  mid-stream, so a transient fetch failure would hide Working…/Stop for
  the rest of the turn.
- claude run_turn sweeps unresolved native_id cards at turn entry
  (resolve_stale_prompts gains a native_only mode; codex keeps sweeping
  everything). A held card can never outlive its process, so one left
  unresolved is a crash artifact — previously it could capture the
  composer's typed-text routing once the new turn made the session busy.

* Bump version to 0.1.61
2026-07-17 00:15:45 -07:00
Daniel Kim 7450249cec Compute tab redesign: unified target list + default compute target (#86)
* Settings: persist default compute target (backend + flavor) with pure resolver

* Launch paths: resolve the default compute target; widen HTTP run to all 7 backends

* API: compute-targets summary, default-target POST, local hardware + openresearch status endpoints

* Agent guidance: reveal the default compute target in the playbook, SKILL.md, and skill templates

* Compute tab: unified target accordion with This-machine + OpenResearch rows and default picker

* cargo fmt

* Polish: summary casing, a11y-clean row controls, instances subtitle

* Review round 1: coherent no-flavor default prompt, 500 on settings io errors, UI dedup + summary refresh, doc fixes

* Clean-round nits: refetch race guard, aligned token-source labels, flavor-required const, doc accuracy

* Quiet the agent-disclosure footnote: muted text instead of amber callout

* Info icon on the agent-disclosure footnote
2026-07-16 23:38:04 -07:00
Daniel Kim 866ccd4ec0 Session code browser + GitHub branch links (#85)
* Session code browser + GitHub branch links

Backend: GET /api/projects/{id}/code-tree?sessionId= lists the chat
session's worktree via git ls-files (branch name included); factor the
worktree/clone root resolution shared with project_file into
resolve_checkout_root. Add current_branch + list_worktree_files git helpers.

UI: per-session Code right-panel tab (CodeTab) with a collapsible file
tree that opens files in the existing FileViewer; a Code button in the
chat header; GitHub tree/<branch> links (per-segment URL-encoded) on
experiment cards (TreeView), the Changes header (DetailDrawer), and the
Code tab header. Rebuild committed ui/dist.

* Code tab: browse experiment branches, not just the live worktree

Sessions usually sit detached on the baseline tip, so the worktree-only
view showed baseline code instead of the experiment's. The tab now has a
source select: live session worktree or any experiment branch's committed
tree (default: the branch of the latest run's experiment).

Backend: code-tree and file endpoints take an optional ref= param —
resolve_branch_commit (local ref, then origin; option-injection guarded)
+ list_tree_files (ls-tree) / file_at (git show); root:"branch" in
responses; 404 when the branch doesn't exist. Live mode unchanged.

UI: getCodeTree/getProjectFile take {sessionId?, ref?}; FileViewDef
carries the ref through FileViewer (branch badge, ref-aware not-found
copy); polling only in live mode; GitHub link follows the selected ref.
Rebuild committed ui/dist.

* Code tab review round 2: lift tab state, stream branch-file reads

- CodeTab source + expansion state move onto the App-side tab def
  (CodeTabDef.sel/selPicked/toggled): the component unmounts whenever a
  file tab fronts it, and remount must not rederive the default against
  newer runs or lose expansion. Default computed once in openCodeTab.
- file_at_capped: cat-file -e existence check (exit code, no stderr
  parsing) + streamed cat-file blob read capped at limit+1 bytes — a
  committed multi-GB blob no longer means a full-blob allocation, and
  content is byte-exact (no trim). Used by project_file's ref arm;
  file_at kept for its small-file callers.
- resolve_branch_commit rejects rev-suffix expressions (@{, ^, ~, :,
  whitespace); doc comments cross-reference branch_head_sha's opposite
  local/remote preference.
- CodeTab load lifecycle: request-id guard covers stale sources,
  superseded polls and post-unmount responses; manual Refresh surfaces
  errors (inline note when a tree is showing); a seeded default branch
  that doesn't exist yet falls back to live once, user picks keep the
  error. split_nul helper dedups the NUL-split decode. Copy/comment
  alignment. Rebuild committed ui/dist.

* Code tab post-review polish

- file_at_capped: reap the child before propagating read errors (no
  zombies); after a complete read, a failing cat-file exit (tree path
  via crafted request, vanished object) is an error, not a fake empty
  file. Kill only on read error/truncation — after EOF git exits on its
  own, and killing then could race a bogus signal-death status.
- list_worktree_files drops dir entries ('nested/' from a nested git
  repo under --others) server-side.
- openCodeTab's updater self-guards against duplicate defs.
- Seeded default branch selection falls back to live on any fetch
  failure (best-effort), not just 'branch not found'; user picks keep
  errors. Doc/copy alignment; drop unreferenced tree row classNames.
  Rebuild committed ui/dist.

* Tree cards + file viewer UX: Code shortcut, labels, line numbers

- Cards: labeled Changes/Logs/Code actions (Terminal renamed), GitHub-mark
  icon link at the card edge; width 240->264 so the row fits; no phantom
  selection outline or pointer cursor (selection state was write-only and
  is removed; RF nodes stay selectable so pointer-events keep the buttons
  alive, visuals suppressed in CSS)
- Card Code button opens the project-level Code tab pinned to that
  experiment's branch (CodeTabDef.sessionId nullable); chat-header Code
  button removed along with the session-tab opener it fed
- Changes/Code headers: BRANCH/COMMIT/SOURCE micro-labels; shared GitHubMark
  (lucide has no brand icons) used by pill, cards, and code-tab link
- File viewer: sticky line-number gutter (1:1 with unwrapped rows) and a
  slimmer header
- ui/dist rebuilt

* Code tab: collapse the dead session path

The chat-header opener was removed deliberately (45f0b11), leaving the
code tab's session plumbing writable by no one. Delete it end-to-end:

- CodeTabDef is { code, sel, toggled }; codeTabs array + null-keyed
  update/close machinery replaced by a single codeTab state. Card
  shortcut (openCodeTabForBranch) uses a functional updater — stable
  identity, so TreeView's layout memo no longer churns per tab change.
- CodeTab drops sessionId/selPicked/seeded-fallback/poll/inFlight; the
  "" source is the project clone; fetch on source change + Refresh.
- Backend: CodeTreeQuery loses session_id (endpoint is new in this PR);
  code-tree always resolves the hub clone. project_file's sessionId
  stays (chat file tabs use it). Docs rewritten as current truth.
- GitHubMark moves to BackendLogos (brand-SVG home); CodeTab header uses
  it instead of ExternalLink. TreeView: 'Open logs' title, comment fix,
  nodesFocusable=false (redundant focus/selected CSS suppression rules
  dropped — RF's own CSS covers focus, .selected only styles built-in
  node types). Code tab header matches file-view-header padding; gutter/
  code shared font metrics grouped into one rule; empty files skip the
  gutter. Rebuild committed ui/dist.
2026-07-16 23:15:07 -07:00
Daniel Kim 874e84080c Plan UI: docked approval strip + right-pane plan tab (#83)
* Plan gate: read-only git verbs + pipelines into pure consumers + 2>&1

Planning agents read experiment code that lives only on git branches
(git show <ref>:<file>, git ls-tree) and habitually pipe output through
head/grep with a stderr-merge — all of which the gate rejected outright
via its whole-command metachar blacklist. Rework classification to be
per-pipeline-stage:

- segments (;, &&) split into pipe stages; stage 0 must be a read-only
  producer (orx read, git read, glue, or pure consumer), later stages
  pure consumers (head/tail/grep/wc/cat/sort/uniq/cut/tr/nl/column —
  tee/xargs/sed/awk deliberately excluded)
- standalone 2>&1 tokens dropped; any other token carrying > or & (or
  backticks/$(/</newlines anywhere) still gates the whole line
- read-only git allowlist: whole-verb reads (status/log/show/diff/grep/
  ls-tree/...) with --output and -O/--open-files-in-pager escape-hatch
  guards; conditional forms for branch/tag (list-query only), stash
  (list/show), remote (bare/show/get-url), worktree (list), reflog;
  global flags limited to -P/--no-pager/-C (rejecting -c config
  injection); git config excluded wholesale
- command_is_readonly exported for reuse by the upcoming MCP permission
  bridge auto-policy

Fail-safe allowlist property preserved: unknown programs, verbs, or
unparseable forms gate the entire command.

* Plan mode: synthesize a plan card when ExitPlanMode never fires + accept-edits option

Observed failure: a plan-mode session ended with the plan as plain chat
text and no ExitPlanMode call — and since only a plan-card answer
switches the resume mode, the user's chat 'yes' resumed still in plan
mode, stranding the session read-only. Root cause of the missing call:
the shared playbook still said 'Never call an interactive
question/elicitation tool — it will hang', guidance that predates the
prompt-card work in #37/#40.

- run_turn tracks (plan_mode, saw_prompt, turn_errored, last_text) and
  synthesizes a WirePrompt{kind:plan, synthesized:true} from the final
  text when a plan-mode turn ends card-less and error-free; the pure
  condition fn is unit-tested
- WirePrompt/ChatPrompt gain ; the UI softens the header to
  'Plan mode — ready to proceed?' for synthesized cards
- playbook: interactive tools surface as cards now; in plan mode, always
  present the finished plan via ExitPlanMode
- plan card gains 'Approve & accept edits' (resumeMode accept-edits —
  synthesize_resume already honors it)

* chore: drop stray npm lockfile (ui uses pnpm)

* Plan mode: MCP permission bridge — desktop-style mid-turn approvals

Headless plan mode had no approval loop: gray-area tools hard-denied
with nobody to ask, WebFetch was denied (verified on claude 2.1.197),
and — worst — ExitPlanMode SELF-APPROVED headless ("User has approved
exiting plan mode", nobody asked), letting the model exit plan mode and
edit files without the user's say.

The fix is a permission bridge on Claude Code's (hidden but functional)
--permission-prompt-tool flag:

  claude ── --mcp-config ──▶ orx mcp-gate (stdio MCP server, hidden cmd)
                                 │ HTTP long-poll, 127.0.0.1:<up port>
                                 ▼
  orx up: POST /api/internal/permissions ─ oneshot ─▶ card (SSE)
          user answers /respond ─▶ ResumeAction::Handled ─▶ claude unblocks

- src/commands/mcp_gate.rs: newline JSON-RPC stdio server (initialize /
  tools/list / tools/call), concurrent calls, single stdout writer; any
  transport failure answers deny — never hangs claude
- ChatHost: request_permission (token gate -> tier-1 auto-policy ->
  card + held oneshot, 55-min timeout under MCP_TOOL_TIMEOUT=60min),
  settle_permission, per-session drain on turn end/interrupt, drop-guard
  so an abandoned request always resolves its card
- auto-policy (pure fn, tested): read-only Bash allow (shared
  plan_gate classifier), WebFetch/WebSearch allow, file edits DENY —
  with a permission tool configured the CLI delegates plan mode's edit
  block to it (verified: an allow creates files mid-plan), so that deny
  IS the safety
- plan_gate hook now also matches ExitPlanMode and answers "ask",
  routing plan approval to the bridge card instead of self-approval;
  without a bridge, ask denies headless — still better than self-approve
- claude.rs: write_mcp_config + flags wired per plan-mode turn
  (best-effort; older CLIs silently ignore the flag and degrade to
  today's gating), ExitPlanMode tool_use renders as a plain tool row
  while the bridge card is authoritative, bridge cards count as
  "saw a prompt" for the synthesized-card fallback
- resume_from_prompt: native (mid-turn) branch — permission allow/deny
  and plan keep-planning settle the held request in place (Handled);
  plan approve interrupts the paused plan turn and resumes via the
  proven --resume + mode-flip machinery (respond()'s SendMessage arm
  now interrupts first for native cards only)

Empirical grounding (claude 2.1.197 spikes): denials don't fail headless
runs (permission_denials + subtype success); WebFetch denied natively in
plan mode; hook allow bypasses the permission tool (no card spam);
bridge tool timeout = tool error, turn continues; hook "ask" on
ExitPlanMode routes it through the bridge and an allow resumes the same
turn.

* Plan UI: docked approval strip + right-pane plan tab

The plan card scrolls away with the transcript and renders the whole
plan inline. Desktop-style flow instead:

- PlanStrip (new): docked between the thread and the composer while a
  plan awaits a decision — "View plan" / "Keep planning" / "Approve &
  run" with a split-menu for accept-edits / bypass. Keep-planning
  consumes the composer draft as the refinement note, so typing
  feedback then clicking the button is the natural gesture. Solid card
  floated over the composer's gradient fade; disappears on resolve via
  the SSE re-emit.
- Plan tab: "View plan" opens the markdown as a right-pane ClosableTab
  (PlanViewDef in the rightTab union, one tab per prompt id, no fetch —
  the text is already client-side); file links inside the plan resolve
  against the plan's session worktree.
- The inline plan card goes compact while a strip host exists: clamped
  to ~9.5em with a fade, "View full plan" link, no buttons (the strip
  owns the actions; single source of truth). Without a strip host the
  card keeps its full button row.

* Store: stop normalizing claude-code plan sessions to auto

The retired-modes migration runs on every Store::open and still listed
'plan' for claude-code — written when plan mode was dropped (pre-#75).
Re-adding plan to options() without updating it meant picking Plan
silently degraded to Auto on the next request; the composer masked it by
re-sending the mode with every message, but any turn without an override
(API callers, resumes) ran in auto. Caught live in the plan-mode E2E.

* chore: bump version to 0.1.60
2026-07-16 11:17:35 -07:00
Daniel Kim 7d1aed6f7d Plan mode: synthesized plan card fallback + accept-edits option (#81)
* Plan mode: synthesize a plan card when ExitPlanMode never fires + accept-edits option

Observed failure: a plan-mode session ended with the plan as plain chat
text and no ExitPlanMode call — and since only a plan-card answer
switches the resume mode, the user's chat 'yes' resumed still in plan
mode, stranding the session read-only. Root cause of the missing call:
the shared playbook still said 'Never call an interactive
question/elicitation tool — it will hang', guidance that predates the
prompt-card work in #37/#40.

- run_turn tracks (plan_mode, saw_prompt, turn_errored, last_text) and
  synthesizes a WirePrompt{kind:plan, synthesized:true} from the final
  text when a plan-mode turn ends card-less and error-free; the pure
  condition fn is unit-tested
- WirePrompt/ChatPrompt gain ; the UI softens the header to
  'Plan mode — ready to proceed?' for synthesized cards
- playbook: interactive tools surface as cards now; in plan mode, always
  present the finished plan via ExitPlanMode
- plan card gains 'Approve & accept edits' (resumeMode accept-edits —
  synthesize_resume already honors it)

* chore: drop stray npm lockfile (ui uses pnpm)
2026-07-16 11:05:40 -07:00
Daniel Kim 203f63b900 Configurable data directory: choose, change, and move with progress (#80)
Let users pick where orx stores everything (orx.db + WAL, run-logs/,
artifacts/, chat-attachments/, agent-*.log) instead of assuming
~/.local/share/openresearch. The default is unchanged; the location is now
choosable from the product.

Resolution order in store::data_dir(): $ORX_DATA_DIR (forced override) →
persisted user choice → $XDG_DATA_HOME → default. An explicit UI choice beats
the ambient XDG base; the env var stays authoritative for sandboxes/tests.
Empty env vars are treated as unset.

- Persisted path lives in config_dir()/settings.json, OWNED by telemetry's
  locked+atomic mutate_settings (in-process mutex + cross-process flock + temp
  -and-rename + corrupt-file refusal). data_dir is a field on telemetry::Settings
  so a write can't clobber installId/telemetryDisabled and vice-versa.
- Move engine (src/local/datadir.rs): validate → WAL checkpoint → same-fs rename
  (atomic, consumes old) or cross-fs copy-with-progress (verify, then KEEP the
  old dir, reported as oldPathLeft — never deletes data a stray writer might
  still be using) → swap the persisted path. Free-space pre-check via statvfs;
  EXDEV falls back to copy.
- Endpoints (up.rs): GET/POST /api/settings/data-dir, /validate, /move. Move
  streams datadir.move.progress/done/error over the existing SSE broadcast and
  returns 202. A move-in-progress flag (claimed via compare_exchange) makes every
  store-mutating handler back off with 409 for the duration.
- After a move, harness children are restarted so any that pinned the old
  $ORX_DATA_DIR (Codex) respawn on the new path.

UI:
- Settings → Storage card: current path + source, validate, move with a new
  byte-level progress bar, env-forced read-only state, in-flight 409 handling.
- Onboarding gains a "Storage" step (default pre-filled, skippable).
- Files view links "Change storage location →" to Settings → Storage.
- Chat send surfaces the move-in-progress 409 and restores the draft instead of
  dropping it.

human_bytes shared via store::human_bytes; fmtBytes shared via api.ts.
2026-07-16 11:04:49 -07:00
Daniel Kim 08d30a8e61 Add anonymous, opt-out usage telemetry (PostHog) (#70)
* Add anonymous, opt-out usage telemetry (PostHog)

`orx` shipped with no telemetry, so there was no way to measure installs,
DAU/WAU, retention, or command usage. This adds anonymous, opt-out analytics
sent to a dedicated PostHog project.

What's collected: a per-invocation `cli_command` event and explicit
`experiment_started` events (create + run, with a coarse compute target),
carrying only a random per-install UUID, CLI version, os/arch, and CI flag.
Never any code, prompts, file contents, ids, names, paths, or tokens.

Design:
- New `src/telemetry.rs`: settings (install id + opt-out) at
  `config_dir()/settings.json`, opt-out policy, event send, and a
  fire-and-forget session modeled on `updates::UpdateWarning`.
- Opt-out: `DO_NOT_TRACK`, `ORX_TELEMETRY=off`, `CI`, a `--no-telemetry`
  global flag, and a persistent `orx telemetry on|off|status` subcommand.
  A one-time disclosure prints after the first `orx login` (shown even when
  disabled, so it's never permanently suppressed).
- Never blocks or crashes the CLI: events are spawned and flushed once at
  exit within a bounded window, so success output is never delayed; all
  errors are swallowed.
- Cross-process safe: settings writes use an `fd_lock` advisory lock plus
  atomic temp+rename, so a concurrent write can't revert a persisted opt-out.
- musl-safe: reuses the rustls `reqwest` client; no new TLS/C dependency.

Events land at PostHog `/i/v0/e/` with a public write-only `phc_` key and a
client-side ISO-8601 timestamp. `$process_person_profile:false` keeps events
anonymous. Unit tests cover opt-out precedence, id stability, payload shape
(PII-free), timestamp math, corrupt-file fail-safe, and the non-blocking
flush path.

* Prefix all CLI events with `cli_` to separate from website analytics

This PostHog project is shared with the website/cloud-agent analytics, so CLI
events must be distinguishable by event name alone — relying only on the
`source: "cli"` property is fragile (one forgotten dashboard filter leaks CLI
events into website metrics).

`build_payload` now stamps a `cli_` prefix onto every event name centrally, so
every current and future CLI event is `cli_*` by construction. Wire names:
- `cli_command`  (was `cli_command`)
- `cli_experiment_started`  (was `experiment_started`)

Added `every_event_name_is_cli_prefixed` to lock the guarantee.

* Telemetry: trim opt-out surface to --no-telemetry + `orx telemetry off`

Drop the `DO_NOT_TRACK`, `ORX_TELEMETRY`, and `CI` environment-variable
opt-out/disable checks. They were redundant with the two mechanisms we keep:
the `--no-telemetry` per-run flag and the persistent `orx telemetry off`.

The `CI` auto-disable in particular guarded a scenario that's mostly
hypothetical for an interactive research CLI, and it only ever fired for
hosted CI providers that set `CI` (missing Jenkins, agent boxes, and ad-hoc
scripts — the automation `orx` actually runs under). Every event still carries
a `ci` property, so automated runs can be filtered at query time in analytics
without being dropped up front.

Updates the opt-out policy, first-login notice text, `--no-telemetry` help,
README, and tests accordingly.

* orx up: add usage-analytics step to onboarding

Adds a third onboarding step ("Usage analytics") to the `orx up` dashboard
first-run walkthrough, after the coding-agents and git/GitHub steps. It shows
what is and isn't collected and lets the user toggle anonymous analytics On/Off
before creating their first project. On by default (matches the CLI's opt-out
model); the choice persists via the same `telemetry_disabled` setting as
`orx telemetry off`.

- Backend: `GET/POST /api/settings/telemetry` in up.rs, reusing
  telemetry::disabled_reason / set_persisted_disabled (mirrors the git-settings
  handler pattern). Returns `{ enabled, reason }`.
- UI: getTelemetry/setTelemetry in api.ts; Onboarding.tsx grows to a 3-step
  flow with a TelemetryCard (On/Off segmented control).

Verified end-to-end in orx up: toggling Off in the wizard persists
`telemetryDisabled: true` to settings.json; toggling On clears it.

* Telemetry: drop the CLI login stderr disclosure

The one-time `orx login` stderr notice (show_notice_once) is removed now that
the disclosure lives in the `orx up` onboarding step, which is the right place
for it. Also removes the now-unused `notice_shown` settings field and updates
the module doc + README to point at the onboarding step instead of a login
notice.

* Telemetry: record consent decisions (cli_telemetry_consent)

Adds one event, `cli_telemetry_consent { agreed: bool }`, that records whether
the user opts into or out of analytics — so we can count agrees vs rejects.

Unlike every other telemetry event, this one is UNCONDITIONAL: it bypasses the
opt-out check and the persisted install id, and uses a fresh anonymous
distinct_id per call. If it honored the opt-out, every rejection would be
invisible and the ratio would be meaningless.

Fired once when the user leaves the analytics choice:
- `orx up` onboarding: on "Create your first project" (records the final On/Off
  state, so users who accept the default On are counted too) via a dedicated
  POST /api/settings/telemetry/consent endpoint.
- CLI: on `orx telemetry on` / `orx telemetry off`.

Tests: consent payload shape + name prefix, and that record_consent sends even
when persisted-off without generating an install id.

* Bump version to 0.1.57

Ships the anonymous opt-out telemetry subsystem, the orx up onboarding
usage-analytics step, and the cli_telemetry_consent event. Merging this PR
cuts release v0.1.57.
2026-07-15 16:35:44 -07:00
Daniel Kim 36b7058f98 Chat: Escape stops the streaming turn (all harnesses) (#69)
Press Escape to interrupt an in-flight chat turn and drop focus back
into the composer, mirroring the Claude Code desktop app. Works for
every harness — a chat-view-scoped, busy-only document keydown listener
calls the existing stop() -> interruptChat, which the backend routes to
the active session's harness (Claude, Codex, OpenCode, ...).

Also hardens the shared usePopover() hook to own Escape while open
(capture phase + stopPropagation) — a pre-existing gap the new listener
surfaced: without it, closing a composer picker with Escape would also
interrupt the turn. The slash menu keeps its bubble-phase preventDefault
path, gated by the listener's defaultPrevented check.

Rebuilt ui/dist (tracked; debug reads from disk, release embeds it).
2026-07-15 01:12:01 -07:00
Daniel Kim bd9f438501 Chat: rename sessions from the Recents kebab menu (#68)
Add a "Rename" item to the per-row three-dot menu in the chat Recents
list (alongside Archive/Delete). Selecting it turns the row title into an
inline input — Enter/blur saves, Esc cancels — with an optimistic update
that reconciles with the server's chat.session broadcast.

Backend: extend the existing PATCH /api/chat/sessions/{id} handler to
accept a `title` field (trimmed, empty rejected) and add ChatHost::set_title,
mirroring set_archived (re-read after write + SSE emit).

Also guard harness auto-titling: TurnCtx::set_title (opencode's
session.updated path) now sets the title only when the session has none yet,
via an atomic conditional UPDATE, so it can't clobber a title the user set
via Rename.

Incidental: make the vite dev proxy target overridable with ORX_BACKEND
(defaults to the standard 4791) for testing against a non-default backend.
2026-07-15 00:55:13 -07:00
Daniel Kim 2c08a60fed Add OpenResearch compute as a backend: --backend openresearch (#67)
* Add OpenResearch compute as a backend: --backend openresearch

An ephemeral org-billed platform box per run, symmetric with the other
external backends. Submit validates flags, resolves the org, preflights the
user's registered SSH keys, pushes the branch, provisions via POST /sandboxes
(402 -> top-up message), records the run as starting, and detaches the
supervisor. The supervisor polls GET /sandboxes/{id} until the box is online,
records the SSH endpoint on the descriptor (restart-safe), launches the
clone-and-run payload over ssh wrapped in a wall-clock timeout guard, runs
the shared ssh watch loop, and deletes the box on every exit path — logs are
drained off the box before teardown, and a failed teardown appends an
'orx instance delete' hint to the run instead of changing its status.

Supporting changes:
- jobs/ssh.rs: SshTarget {dest, extra_opts} so the transport reaches
  explicit user@host:port endpoints (openresearch) as well as config
  aliases (ssh/slurm); ControlPath hash folds in the opts.
- supervise.rs: run_ssh's two-half loop extracted into watch_ssh_job,
  shared by ssh and openresearch.
- Descriptor: sshHost/sshPort/sshUser + timeoutSecs optional fields
  (fixed field list — unknown keys do not survive a round-trip).
- store: set_backend_json (narrow setter; upsert would clobber
  status/markdown and race the cancel flag).
- client: get_sandbox / delete_sandbox / list_sandboxes / list_ssh_keys.
- orx instance list/delete: the manual cleanup path for a leaked box.
- UI: OpenResearch badge in BackendLogos; startRun type widened.

Requires the platform's companion PR (GET /sandboxes/{id} + PAT auth on
sandbox list/delete and ssh-keys list); against an older API the ssh-key
preflight degrades to a warning and provisioning-wait fails the run with
the API error after teardown retries.

* Bump version to 0.1.56 — releases --backend openresearch on merge
2026-07-14 17:48:05 -07:00
Rehaan Ahmad 18102cf0a9 message default (#66) 2026-07-14 00:08:53 -07:00
Rehaan Ahmad b1cab32b48 chore: local runs (#64)
* open runs

* open runs

* ci
2026-07-13 23:52:03 -07:00
Daniel Kim f1df93d4a3 Codex Auto: sandbox escalations become permission cards (PR2 re-land, v0.1.52) (#63)
* Codex auto mode: grant the orx data dir as a sandbox writable root

workspace-write denies writes outside the workspace, and the orx SQLite
store lives under ~/.local/share (WAL + read-write open), so every
store-touching orx command died with 'unable to open database file'
under Auto mode. Add store::data_dir() to the turn's writable_roots
alongside the shared git dir.

* Review fixes: pin ORX_DATA_DIR to the granted root, ensure_ naming

- Pin ORX_DATA_DIR on the codex child (after prepare_env) so the store
  the sandboxed orx resolves is the exact path granted as a writable
  root — synced env or a relative override could otherwise diverge.
- Rename orx_data_dir -> ensure_orx_data_dir (codebase ensure_ prefix
  for create-if-missing helpers); note the grant is dir-wide.
- Dedent the '-c replaces user roots' note: it covers both roots.

* Review round 2: match-expression pin, stale options() doc, asymmetry note

* Codex chat: migrate to app-server protocol with exec fallback

One long-lived 'codex app-server' child per session (CodexHost, the
AgentHost pattern), JSON-RPC over stdio. Threads persist as the
session's native id and resume across orx up restarts; the playbook
rides developerInstructions; the sandbox policy travels per turn as a
first-class sandboxPolicy param (writable roots + network) instead of
-c config overrides that clobbered user config.

approvalPolicy stays 'never' on every mode — this is a pure transport
swap; the follow-up flips Auto to on-request and surfaces approval
requests as permission cards (validated live against codex-cli 0.144:
sandbox escalations DO arrive as item/commandExecution/requestApproval,
upstream #21982 does not reproduce).

codex < 0.144 keeps the legacy exec path for one release
(ORX_CODEX_EXEC=1 forces it); detect() notes the upgrade.

* Review round 1 fixes: turn-id filter, drop race, bounded interrupt

Majors:
- Filter turn events by the turn/start response's turn id so an aborted
  predecessor's tail (fatally, its turn/completed) can't leak into a
  successor turn's transcript.
- TurnRoute::drop only detaches its own channel (same_channel guard) —
  an aborted task's late drop can no longer clobber a successor's
  registration.
- interrupt_active_turn bounded to 5s so a wedged child can't hold the
  user-facing interrupt/delete paths hostage; reader EOF now also kills
  a half-dead child instead of leaving a zombie in the registry.
- thread/resume falls back to a fresh thread only on a server-reported
  error (try_request); transport failures propagate so a resumable
  thread is never discarded over a timeout.
- delete_project now kills codex children too (was opencode-only).

Nits: options()/sandbox_policy_json doc rewrites, tool_part/tool_status/
part_exists/find_codex_required/respond_decline helpers, HashSet for
unanswered ids, REQUEST_TIMEOUT const, outputDelta creates its part,
turn/completed(inProgress) guard, double-error dedup, truthy
ORX_CODEX_EXEC, softer agent_note, import cleanups.

* Review round 2 fixes: abort-proof child bring-up, shared-helpers section

Major: spawn + register + handshake now run in a detached task, so an
aborted turn task (interrupt/delete during the first message) can no
longer orphan an unregistered app-server child; a no-displace insert
keeps a raced successor's live client intact, and handshake failure
kills + deregisters only its own entry.

Nits: shared helpers moved out of the legacy section under their own
banner; set_active_turn armed from the turn/start response (deletes the
turn/started special case and closes the early-interrupt no-op window);
parse_codex_version takes the first parseable token; resume-rejection
logged; HANDSHAKE_TIMEOUT const; TurnEnd/apply_notification docs;
has_error_part test; fold-test guard tightened; comment accuracy.

* Round 3 polish: doc word order, eprintln prefix, mid-handshake reuse note

* fmt

* Codex Auto: on-request approvals surfaced as permission cards

Auto now runs approvalPolicy on-request: the writable roots keep
routine orx traffic prompt-free, and anything escalating past the
sandbox arrives as item/*/requestApproval, surfaced as a permission
card (native_id = JSON-RPC request id) and answered inline via
resume_from_prompt -> {"decision": accept|decline}.

Invariant: no unresolved codex card outlives its turn — every turn
exit sweeps open cards (resolve + decline with codex); interrupt paths
cancel pending approvals natively before turn/interrupt;
serverRequest/resolved reconciles codex-side settlement (its ~5min
approval deadline). Stale-turn requests are declined, never surfaced;
Bypass auto-accepts; unknown request types get a JSON-RPC error
instead of blocking codex. A 10-minute no-event watchdog (suspended
while a card is pending) interrupts wedged turns.

* PR2 review round 1 fixes: stale-card defense, Bypass reply schema, card content

- Consent integrity: codex request ids restart per child, so a stale
  unresolved card could deliver a click to a different live request.
  Turn-entry resolve_stale_prompts sweep (store-side, codex-only —
  end-turn harnesses keep their cross-turn cards) + turn-scoped part
  ids (appr-{turn}-{id}) close it from both ends.
- Bypass now auto-accepts only card-able approval methods; unknown
  request types get the JSON-RPC error reply in every mode (a
  {decision} reply to item/tool/requestUserInput would fail to parse).
- Permission cards show the request's human-readable reason (fileChange
  cards carry nothing else); UI dist rebuilt.
- serverRequest/resolved also clears the id from the client's pending
  set (stale-answer guard stays truthful after codex self-settles).
- Reconciliation computes the part id directly (approval_part_id)
  instead of a reverse scan; resolve_card returns (); sweep declines
  unconditionally (respond's pending set is the single arbiter);
  watchdog bumped to 30min with an honest false-positive note;
  resume_from_prompt gains a kind guard.

* PR2 round 2 polish: schema-aware fallback replies, immediate card flush

Non-approval request methods now get the JSON-RPC error reply in every
fallback path (read_loop unrouted, stale-turn arm) via a shared
is_approval_request; codex-settled cards flush immediately instead of
waiting for the next event; message_json reuse; Handled doc drift;
watchdog comment dedup.

* Bump version to 0.1.52
2026-07-13 15:27:04 -07:00
Daniel Kim 6636e8e8cc Files tab redesign: minimalist tree + proper report rendering (v0.1.51) (#58)
* Files tab redesign: minimalist tree, centered column, GFM report tables

* Bump version to 0.1.51

* Review round 1 fixes: GFM link/table edge cases, shared code blocks, class rename sweep

* Rebuild ui/dist
2026-07-12 23:14:29 -07:00
Daniel Kim a0e67cd210 File viewer: open files from the chat session's worktree (#57)
Clicking a file chip in chat failed with "path must be repo-relative":
agents work in per-session worktrees, and the viewer only knew how to
strip the hub-clone prefix — and only served files from the hub clone,
where agent-created files don't exist.

- App.tsx parseFilePath maps agent-reported paths (relative, repoPath-
  prefixed, worktrees/ and repos/ cache layouts) to a repo-relative path
  plus the session id; relative paths inherit the click context's
  session (chat's active session, or the viewed file's).
- project_file gains a sessionId query param: the session is validated
  against the store (must belong to the project), its worktree is
  served when present, and a missing worktree degrades to the hub
  clone. The response's root field says which checkout answered so the
  viewer can flag the fallback.
- File tabs are keyed by (path, sessionId) so the same path from
  different sessions opens distinct tabs.
2026-07-12 22:15:50 -07:00
Daniel Kim 10eaa5a25e Recents: hover kebab menu (archive/delete) + Active/Archived/All filter (#56)
* Recents: hover kebab menu (archive/delete) + Active/Archived/All filter

Claude-desktop-style session management in the chat rail:
- three-dot menu on session-row hover with Archive/Unarchive and Delete
  (confirm-guarded; delete already existed server-side)
- filter button beside the Recents label: Active (default) / Archived / All
- new archived column on chat_sessions + PATCH /api/chat/sessions/{id};
  archive keeps updated_at so sessions keep their recency slot

* Review round 1: delete broadcast, keyboard access, rollback + polish

- delete_session now emits chat.session.deleted; dashboards remove the row
  (and its cached transcript/busy state) instead of keeping ghosts
- set_archived re-reads after the write so the broadcast can't clobber a
  concurrent title change or resurrect a deleted session; sending into an
  archived session unarchives it (activity unarchives)
- kebab reveal uses :focus-within (row-only :focus-visible dropped focus to
  body mid-Tab); row keydown ignores events from descendant buttons
- archive rollback restores the pre-request snapshot, not the negation;
  removeSession alerts on failure and uses functional setActiveId
- Escape closes all popovers; dead .rail-filter-btn.active rule removed;
  danger color uses --accent-red; copy/labels tidied

* Review round 2 polish: delete tombstones, keyboard menu dismiss, error surfacing

- deletedIds tombstone set ignores chat.session upserts racing a delete
  (a naturally-finishing turn could resurrect a ghost row)
- row Enter/Space mirrors the click branch: dismiss an open menu instead of
  switching sessions underneath it
- deleteChatSession goes through the shared json helper so the alert shows
  the real server error; alert copy names the session
- one filter reset in send() covers new sessions and unarchive-on-activity
- SESSION_FILTERS carries railLabel (single table); comment placement fix
2026-07-12 22:07:02 -07:00