* feat(config): add agent config descriptor protocol and advertise via runner capability
Introduce shared agent configuration descriptors covering model, effort,
permission, and secondary settings per agent flavor, plus the canonical
HAPI YOLO -> native permission mode mapping. Runners advertise the
builtin descriptors through the runner-state capability so hubs and web
can render configuration without hardcoded flavor branches.
Migrate the OpenCode create-session model picker from a bespoke radio
list to the shared SelectControl combobox.
* feat(web): render create-session permission from agent config descriptor
Replace the flavor-branched Grok/Codex-family/YOLO permission block with
a descriptor-driven PermissionField. Pi now reports permission as managed
instead of silently ignoring the YOLO toggle, and YOLO-only flavors show
the native permission mode the preference maps to.
Removes the superseded GrokPermissionModeSelector and
CodexFamilyPermissionModeSelector components.
* ci: retry flaky claudeRemote 5s-timeout failure
* fix(web): persist explicit OpenCode Default selection instead of restoring a concrete model
The parent initialization effect treated every null selected model as
'uninitialized' and auto-picked a concrete advertised model, clobbering
the user's explicit Default choice (and a restored Default preference).
null now means explicit Default and is preserved; only undefined (no
choice made yet) triggers probe-based initialization. Add parent-level
regression tests for Default persistence and remembered-model restore.
* fix(web): accept undefined selected model in OpencodeModelSelector props
* feat(web+cli+hub): unify create-session model/effort fields and add Pi model/effort support
Pi's agent config descriptor now advertises model (machine) and effort
(static thinking levels) for create AND session availability:
- cli: ListPiModelsForMachine RPC runs 'pi --list-models' (cached, inflight
deduped) and parses the provider/model table; startup model match accepts
provider-qualified ids
- hub: GET /api/machines/:id/pi-models route + rpcGateway/syncEngine passthrough
- web: NewSession renders Pi models grouped by provider through the generic
ModelSelector and a new descriptor-driven EffortField (replaces the
per-flavor LaunchEffortSelector/ReasoningEffortSelector pair); launch payload
forwards Pi model + thinking-level effort (runner already supported --model/
--effort for pi)
* fix(web): render Pi provider groups in ModelSelector and scope Grok availability warning
- ModelSelector now renders grouped options as <optgroup> (Pi models are
provider-grouped; identical modelIds from different providers stay distinct)
- PermissionField only receives autoPermissionModeSupported for Grok — a
cached Grok probe result no longer leaks the Grok warning onto other agents
Addresses HAPI Bot Minor findings on #1469.
* fix(web): drop Object.groupBy from ModelSelector; revalidate restored Pi models against the catalog
- ModelSelector buckets options with a reduce instead of Object.groupBy
(Safari < 17.4 has no polyfill — New Session would throw on those clients)
- Pi restored model/effort are cleared when the value is absent from the live
machine catalog, and Create waits for the catalog while a non-default Pi
choice is being validated (mirrors Codex/Grok/Copilot handling)
Addresses HAPI Bot findings on #1469.
* fix(pi+web): serialize startup model before thinking level; hide Pi launch controls during history import
- PiSession gains startupModelSettled; the startup set_thinking_level waits for
the requested model's set_model attempt to settle first, so a level the
default model rejects is not lost before the requested model is confirmed
(set_model and set_thinking_level were already serialized by the runtime
mutation lock; this pins the model-first ordering)
- Create Session hides Pi model/effort controls while a Pi history import is
selected — the import reopens the native session as-is and would silently
ignore launch-only model/effort values
Addresses HAPI Bot findings on #1469.
* fix(pi): settle startup-model gate when model discovery fails or returns no models
A failed or empty get_available_models response would leave the
startupModelSettled gate unresolved, stranding a requested startup effort
indefinitely. Resolve the gate on the error path and the empty-models path;
adds regression tests for both.
* feat(web): composer model/effort value buttons and settings order
Wide composers now show [model] and [effort] value buttons for non-Pi
flavors (labels from the current session values), opening the settings
sheet on click. Narrow viewports collapse to the settings button only
via a new useNarrowViewport hook. The settings sheet reorders to
Model -> Effort -> Permission -> other settings (Fast mode,
collaboration, Copilot agent mode) so permission is first-class.
Toolbar customization gains 'model'/'effort' items with settings labels.
Pi keeps its dedicated model/thinking panels unchanged (unified
descriptor-driven sheet is a follow-up).
* fix(web): satisfy strict types in composer value-button test harness
* fix(web): address review findings on composer model/effort value buttons
- Normalize null/'auto'/'default' model wire values onto the value:null
option so default-model sessions keep a localized label button (Major)
- Exempt model/effort value buttons from the settings outside-click
dismissal so a second click closes the sheet instead of reopening it
- Read matchMedia synchronously in useNarrowViewport so narrow first
paints never flash the wide toolbar
- Add regression tests: model=null/'auto' labels, toggle-close behavior,
and initial narrow-viewport render
* feat(web): fold Pi into the generic composer model/effort value buttons
Pi sessions previously exposed model/effort twice: dedicated 'Pi model' /
'Pi thinking level' toolbar buttons (PiModelPanel/PiThinkingLevelPanel)
AND the settings sheet's generic Model/Effort sections. Consolidate so Pi
looks exactly like every other flavor:
- Pi now uses the generic model/effort value buttons; labels resolve from
the provider-qualified piModels catalog (name -> modelId -> session id).
- The settings sheet's Model section already renders provider-grouped Pi
rows and Effort renders Pi thinking levels, so the dedicated panels and
their toolbar slots are deleted.
- Keep Pi's mid-turn control affordance (#1442): Pi turns hold
thread.isDisabled for minutes, so Pi model/effort controls stay enabled
while a turn is running (configurationControlsDisabled instead of
controlsDisabled), including the sheet rows.
- Drop 'piModel'/'piThinking' toolbar layout items; persisted layouts
normalize them away automatically.
Tests: pi value-button label/sheet tests, mid-turn model selection via the
unified sheet, toolbar layout defaults.
* fix(web): keep a session-settings trigger on narrow viewports and mid-turn Pi
Address the HAPI review bot's two Minor findings on the Pi consolidation:
- Narrow viewports collapse the model/effort value buttons into the
settings sheet, so a persisted toolbar layout hiding the gear left no
session-settings trigger at all. ComposerButtons now forces the gear
back into the rendered layout on narrow viewports (wide layouts keep
honoring the user's hidden choice).
- The Pi mid-turn live-control rule only reached the value buttons and
sheet rows; with those buttons gone on narrow, the gear was still
disabled by controlsDisabled for the whole (minutes-long) Pi turn.
HappyComposer now passes settingsDisabled={modelEffortControlsDisabled}
so the gear stays clickable mid-turn for Pi exactly like the buttons.
Tests: narrow + hidden-gear layout keeps Settings; narrow Pi mid-turn
gear stays enabled and opens the provider-grouped sheet.
* fix(web): address HAPI Bot Pi settings-sheet findings
Three Minor findings from the review bot on the unified Pi sheet:
- Provider-qualified selection: rows compared only modelId, so duplicate
model IDs across providers all looked selected. Compare against
piSelectedModel's provider+modelId when available.
- Thinking-level reset: the removed Pi panel toggled the current level
back to null; the unified effort rows only submitted concrete values.
Re-clicking the selected effort row now clears it for Pi.
- Memo staleness: the settings-sheet memo did not depend on
modelEffortControlsDisabled, so a Pi disabled-state transition while
the sheet was open left rows enabled from the prior render.
Tests: colliding model IDs highlight only the matching provider row,
re-clicking the selected effort row sends null, and a rerender with
active=false disables the open sheet's rows.
* fix(web): include piSelectedModel in settings-sheet memo deps
The overlays memo reads piSelectedModel for provider-qualified row
highlighting but only declared the derived selectedPiModel. When
piSelectedModel hydrates from absent to a qualifier that resolves to
the same catalog object, the memo is reused and duplicate model IDs
stay highlighted across providers. Add the raw prop to the dep array.
* fix(web): gate Pi model rows on catalog; reset drill-down via value button
Address the HAPI Bot review on the unified composer settings sheet:
- Pi no longer falls back to the generic synthesized modelOptions rows
when its provider catalog is empty/loading. Selecting one of those
would post a bare model id that runPi cannot resolve to a provider
(first cached match or 409). The Model section now only renders for
Pi when piModelGroups exists, and renders grouped rows exclusively.
- Closing the sheet through the model/effort value button now goes
through handleSettingsToggle, so a Cursor variant drill-down resets
to the base model list on reopen (previously only the gear and
outside-click paths cleared it).
Tests: empty Pi catalog hides Model section; value-button close resets
Cursor drill-down.
* fix(web): hide Pi effort controls until the selected model resolves
Address the HAPI Bot review: with the catalog still loading/failed there
is no selectedPiModel to derive a capability map from, but the unified
effort control stayed enabled (mid-turn Pi controls are intentionally
live). Selecting a level would send set_thinking_level for a model that
may not support reasoning, and the RPC can be rejected after the sheet
closed. The old dedicated panel guarded this state.
showEffortSettings now requires a resolved, reasoning-capable Pi model;
the effort value button hides the same way. With an empty catalog Pi
exposes no settings trigger at all, matching the old control states.
Tests: unresolved Pi catalog mid-turn exposes no effort action.
* fix(web): hide the Pi model trigger until the catalog resolves
Address the HAPI Bot Minor finding: with an empty/loading Pi catalog the
model value button fell back to the bare session model id and rendered
an enabled trigger that opens no Model section. Show the button only
once the provider-qualified catalog entry resolves.
* fix(web): prevent replaying historical assistant output
* fix(web): handle history pagination during typing handoff
* fix(web): preserve streaming handoff across history windows
* fix(web): distinguish hydrated active runs from new output
* fix(web): preserve handoff across tail hydration
* fix(web): preserve first output in user-only turns
* fix(pi): settle autonomous agent lifecycles instead of swallowing them
When Pi starts an agent lifecycle on its own — a subagent completion
wake-up, scheduled work — no HAPI prompt is in flight, so the previous
prompt lifecycle has already delivered its settlement and
deliveredSettlement is still true. agent_start unconditionally set
thinking=true, but every settlement path (agent_settled delivery, the
legacy agent_end grace, the prompt-lifecycle fallback) was gated shut by
that stale flag, so the autonomous turn's completion was swallowed:
thinking stayed true forever, the FIFO pump stayed blocked
(piIsStreaming), new messages queued without ever being sent, and abort
waited on a settlement that could never arrive. The only escape was
killing the session.
Open a fresh settlement cycle when agent_start/turn_start arrives with
deliveredSettlement still true: reset deliveredSettlement,
agentEndObserved and activeAgentSettledSeen so the existing settlement
paths (direct agent_settled, legacy agent_end grace) apply to the
autonomous lifecycle. Prompt-driven lifecycles are unaffected because
beginPromptLifecycle has already reset the flag before their agent_start
arrives; mid-cycle retries are unaffected because their cycle has not
settled yet.
Evidence: hapi log 2026-08-13-18-39-10-pid-85758.log — 19:24:07
agent_start (no prompt accepted) → 19:25:40 agent_end + agent_settled
both swallowed → session stuck thinking=true for 27+ minutes until
killed.
* fix(pi): generation-scope the settled callback against autonomous lifecycle races
Review follow-up (HAPI Bot, Major): deliverSettlement() notifies
onAgentSettled only after an async conversationHistory.syncEntries().
An autonomous lifecycle can begin in that window; the stale finally
callback would then mark the new lifecycle's abort boundary as settled
before it emits agent_settled.
Capture lifecycleGeneration at settlement time and skip the
notification when it no longer matches, and advance the generation when
agent_start reopens a settlement cycle for an autonomous lifecycle so
the in-flight callback turns stale.
New regression test holds syncEntries open across the autonomous
agent_start and asserts the stale callback does not settle the new
boundary (fails without the fix).
* fix(codex): cache codex model list to avoid spawning app-server per request
listCodexModels() spawned a fresh `codex app-server` subprocess on every
request: exec a version probe, boot the app-server, validate the ChatGPT
session (token refresh over the network when needed), list models, then
kill the process. Fleet measurement showed 0.5-4.4s per call on healthy
machines and 33s (initialize timeout) on a machine with a slow OpenAI
network path, and the web refetches on every session open / dialog mount
(staleTime 30s).
Mirror the opencode model cache: cache successful non-empty lists for 5
minutes and coalesce concurrent requests into a single app-server spawn.
Failures and empty results are never cached, so a broken machine retries
on the next request.
Fixes#1533
* test(codex): cover TTL expiration of the model list cache
* fix(hub): retry Cursor SQLite cleanup on Windows
* test(hub): keep cleanup retry regression Windows-specific
* test(hub): exercise Windows cleanup retry in CI
* fix(codex): preserve native instructions
Keep HAPI guidance in the developer layer so app-server retains Codex persistence rules. Align long-running goal states with the Codex 0.147 protocol.
* fix(codex): filter new goal statuses
* fix(web): preserve current Fork action while reading history
* fix(web): invalidate stale Fork boundary during history updates
* fix(web): invalidate Fork boundary after message consumption
* feat(web): keep quiet active sessions in the pinned top section (#1589)
When pin-in-progress is on, any connected session now floats above the
project folders instead of dropping down the moment it finishes
executing. The sidebar reads as three tiers:
1. In progress - running and pending work (unchanged)
2. Active sessions - quiet but connected (finished executing, operator
usually continues the conversation)
3. Inactive - everything else, in directory groups
Also remove the duplicate showSessionSummaryInChat fixture property in
markdown-a.test.tsx that fails web typecheck on upstream main.
* test(web): cover Active section collapse, keyboard toggle, and search expansion
* fix(web): keep action-only project headers when every row floated
A directory whose sessions all floated to the pinned sections lost its
header entirely, taking copy-path and new-session-in-directory with it.
Render an action-only header for fully-floated projects so the directory
actions stay available.
* fix(web): restore Ctrl+A select-all on the chat page
Chromium's SelectAll collapses to an empty caret when the page contains
a contenteditable (the rich composer) but focus is outside it, so
Ctrl+A + Ctrl+C on the Happy page copied nothing. Take over Ctrl/Cmd+A
at window scope when focus is outside the composer/inputs and select
the message thread manually.
Also removes a duplicate property in markdown-a.test.tsx that broke
`bun typecheck` on upstream/main.
* fix(web): restrict select-all takeover to unshifted Ctrl/Cmd+A; wire e2e spec into CI
Address review findings:
- leave Ctrl+Shift+A to the browser (matches native Chromium, where
the shift variant is unbound)
- run the composer-copy Chromium regression spec in CI alongside
terminal-wrap-fidelity.spec.ts
- move the spec to the root e2e/ dir so the root playwright config
picks it up
* fix(web): map codex-enveloped compact-summary to the chat block
The merged #1570 renders Pi compaction summaries as a dedicated chat
block via the event envelope. A compact-summary arriving in the codex
payload envelope (older import paths, future producers) would still be
silently dropped by the codex-content filter; map it to the same
agent-event the live pi wrapper emits, mirroring the existing
context_compacted handling. Adds a normalizeAgent regression test and
tightens the /compact thinking-state test to assert the last keepAlive
flips true (RPC outstanding) then false (settled).
Verified: bun typecheck clean; web normalizeAgent 12/12, cli runPi
57/57 in an isolated TMPDIR.
* fix(web): remove duplicate showSessionSummaryInChat in markdown-a fixture
Regression from #1530: the fixture object literal sets the key twice,
which breaks `bun typecheck` on upstream/main (Test workflow failing on
push). One-line cleanup; no behavior change.
Verified: bun typecheck exit 0 across cli/web/hub.
* feat(pi): support Pi slash commands from HAPI web (compact/session/model/help)
Pi runs as 'pi --mode rpc' over piped stdio, so TUI slash commands typed in
web chat previously fell through to the LLM as plain text and silently did
nothing (notably /compact).
- shared: add Pi builtin slash command list (help/compact/session/model) so
the web / menu exposes them; web test updated to match
- cli: intercept Pi builtin commands in runPi's user-message path
* /compact [instructions] -> Pi compact RPC (120s timeout, works while
streaming; summary + token delta reported back as chat messages)
* /session -> get_session_stats formatted stats
* /model [modelId] -> list/switch via set_model
* /help -> supported-commands list
* other Pi TUI builtins (/tree, /export, /reload, ...) -> explicit
terminal-only notice instead of silent LLM pass-through
* unknown slash text still passes through (extension commands, skills,
templates keep working)
- gate the prompt pump with piCompactInFlight so queued prompts are not
rejected by Pi mid-compaction; buffer commands until ready like prompts
- ListSlashCommands RPC merges HAPI builtins with Pi extension commands
- tests: parser unit tests + runPi integration tests (compact execution,
streaming steer interception, failure reporting, FIFO blocking, model
switch, unsupported commands, slash list merge)
- docs: document Pi slash command support in docs/guide/agents.md
* fix(pi): address review findings on slash command lifecycle
- compact timeout: fail the session (indeterminate outcome, runtime lease
poisoned) instead of reopening the prompt FIFO into a possibly-compacting
Pi; pump only when cleanup has not been initiated
- special commands: release the cancellation reservation before executing so
a cancel landing mid-command is not acknowledged (hub would delete the
queued row while the command still runs)
- tests: drop the duplicated slash-command describe block; add focused tests
for compaction timeout with a queued prompt and cancellation during an
in-flight special command
* fix(pi): route slash commands through the prompt FIFO and reject ambiguous models
- slash commands now share the prompt FIFO with ordinary messages: a
/compact or /model typed after a queued prompt dispatches only after it
(and after the active turn settles), instead of jumping the queue from
the preparation chain
- the pump dispatches special entries out-of-band while piSpecialCommandInFlight
keeps the FIFO blocked; steer promotion refuses slash commands
- /model <id> prefers an exact provider/modelId match and reports bare IDs
shared by multiple providers as ambiguous instead of picking the first
- tests: FIFO ordering (queued prompt before /compact), steer-delivered
/compact queued until settle, ambiguous/qualified model selection
* fix(pi): keep /compact interruptible, honor extension precedence, require token boundary
- head-of-line /compact dispatches even while Pi is streaming (Pi's
compact() aborts the active generation itself); every other queued item
still waits for the stream to settle, preserving FIFO order
- discovered extension commands / prompt templates override same-name
builtins at message time, matching the slash-list merge precedence
- parsePiSpecialCommand requires a command-token boundary, so path-like
text such as /compact.md or /model/config stays an ordinary prompt
- tests: interrupt rule, extension collision, reserved-name path prefixes,
non-compact commands waiting for stream settle
* fix(pi): honor cancellation acknowledged during slash-command discovery
A cancel arriving while the chain awaits get_commands (cold cache) was
acknowledged via the preparing reservation but never re-checked, so a
canceled /compact could still execute. Re-check the cancellation marker
after discovery and drop the message before dispatch.
* fix(pi): qualify /model selectors and report failed slash RPCs once
- /model lists provider-qualified selectors (openai/gpt-5.2) so duplicate
bare IDs remain usable and copy-pasteable; current model is qualified too
- compact/set_model failures are owned by the awaited slash/config handlers:
the common response handler no longer emits the raw Pi error a second time
- tests: qualified listing with duplicate providers, single-message failure
reporting for rejected /compact and /model
* fix(pi): consume slash-command queue row at dispatch
Special commands (/compact, /session, /model, /help) are executed by HAPI
itself and never delivered to Pi as prompts. Consumption was deferred until
the command finished, so a /compact run — an LLM summarization pass that can
take minutes — left the row stuck in the web queued bar for its whole
duration, then surfaced as a sent message. Consume the row the moment
dispatch starts; failures still surface via the explicit event message.
* fix(pi): guard special-command dispatch against unexpected rejections
* ci: retry Codex PR Review after infra failure (proxy 503)
* fix(pi): keep session queued-thinking grace during /compact dispatch
The queued-thinking grace is session-scoped, so clearing it while
acknowledging a dispatch-time /compact row also drops the grace for any
prompt queued behind it. /compact keeps running for minutes without
toggling Pi thinking state, which would leave the web session looking idle
while compaction and the following prompt are still pending. Only the
fast, synchronous commands (/session, /model, /help) clear the grace.
* fix(pi): render compaction summary as a dedicated chat block
The manual /compact RPC result was reported as two plain message
events ("📦 Compaction completed (tokens: …)" + "📦 Compaction
summary: …"), which the web chat renders as tiny centered status
lines — unusable for a real summary payload. Emit a structured
compact-summary event instead (summary + token delta) and render
it as an independent block: header with the delta and the summary
markdown in a scrollable panel.
Also emit the same structured event when importing Pi session
files (compaction entries), and queue the event lossless like
other user-visible messages so a disconnect cannot drop it.
Verified: bun typecheck clean; bun run test exit 0 (cli 2481
passed, web 2451 passed, hub/shared clean); runPi/loop/apiSession/
piSessions/presentation suites green.
* fix(pi): address HAPI Bot findings on compact dispatch and import
- Track compaction as thinking for its whole duration: /compact runs for
minutes without a Pi streaming event, so the 15s queued-thinking grace
alone left the web session looking idle while compaction and any queued
prompts were still pending (updateThinkingState around the compact RPC).
- Imported Pi compaction summaries must use the event envelope
(content.type: 'event') like the live wrapper's compact RPC result; the
codex payload envelope is dropped by the web normalizer. Extend
CodexImportedMessageSchema with the event variant.
* fix(pi): /model retries discovery when the model cache is empty
Startup model discovery can be late or fail once; using only the cached
catalog made /model report valid models as unknown. getPiModels() falls
back to the get_available_models RPC on an empty cache, used for both
listing and switching.
* fix(pi): interrupt in-flight /compact on Abort; surface startup model rejection
- The Abort action no longer waits on the runtime-mutation lease when a
manual /compact is in flight (compaction can hold it for up to 120s,
blowing the 25s abort deadline and failing closed). It sends the abort
RPC directly so Pi cancels its compaction AbortController; the compact
RPC's 'Compaction cancelled' error is not double-reported as a failure
since Pi already emits the compaction_end(aborted) lifecycle event.
- A rejected detached startup set_model now emits a visible ⚠️ event into
chat instead of only a debug log, restoring the pre-existing behavior.
* fix(pi): close the Abort race when /compact is queued on the mutation lock
Abort previously assumed an in-flight /compact always had its RPC issued;
the command is marked active at queue dispatch, but the compact RPC is sent
only after the runtime-mutation lock is acquired. An Abort landing in that
gap acknowledged success while the compact RPC still ran afterwards.
Track the compact's rpcStarted/cancelled state: Abort cancels a not-yet-
started compact in place (the queued callback skips it), and interrupts a
started one via the abort RPC as before.
* fix(pi): persist provider-qualified selection after /model switch
The success path updated currentModel/currentProvider and keepalive with a
bare model ID, leaving metadata.piSelectedModel on the previous provider.
The web picker prefers that metadata for selection, context-window
resolution, and effort options, so a switch like openai/gpt-5.2 ->
azure/gpt-5.2 was invisible. Persist piSelectedModel with the full
provider/modelId pair on every confirmed switch.
* fix(pi): retire pending extension UI requests when /compact interrupts a turn
The streaming-interrupt path sent the compact RPC without cancelling
pending extension UI requests first, unlike the Abort path. Editor
requests have no timeout, so the web could stay stuck on a stale
input/permission card and a later answer could be routed to the aborted
turn. Cancel all pending requests (with a response) before compacting.
* fix(pi): fail closed when the direct compact-abort RPC times out
The in-flight /compact abort branch awaited the abort RPC without the
ordinary Abort path's timeout handling: an unanswered abort left the
compaction outcome indeterminate (the compact RPC keeps the mutation
lease for up to 120s) while the wrapper still looked live. Fail the
session on PiRpcTimeoutError, mirroring the standard abort fail-closed
path.
---------
Co-authored-by: swear01 <swear01@users.noreply.github.com>
A suspended mobile tab can lose its SSE connection without the browser
ever noticing (no FIN/RST arrives). On resume the client used to wait
for the 90s heartbeat watchdog or lean on the native EventSource retry,
which can hang on a dead pooled socket - leaving a persistent
"Reconnecting... (stream error)" banner while the UI keeps working.
- useSSE: on visibility resume, distrust the connection after one missed
heartbeat interval (45s) instead of 90s
- useSSE: abandon connection attempts that don't open within 10s (hung
on a dead pooled socket after resume) instead of waiting for the
watchdog; force bypasses the one-shot reconnect guard for the new
attempt cycle
- useSSE: first reconnect attempt is immediate; exponential backoff
starts from the second attempt
- ReconnectingBanner: localize connect-timeout and transport-error
reasons (transport-error is already emitted by current code but fell
through unlabeled)
Fixes#1559. Reimplements the reconnect half of #989 (closed unmerged)
on top of the current scheduler (hidden-tab deferral, slow backoff,
replay cursor).
* feat(sessions): add on-demand AI title suggestions
* fix(sessions): address title suggestion review feedback
* fix(web): ignore stale title generation results