Bring in the post-0.30.7 upstream work while keeping local team, macaron,
requirement, DSH and deployment features:
- runner/hub: cross-platform orphan reap on archive and tracking loss,
machine-spawn preallocated stubs, keepalive-only sessions reconcile to idle
- hub: prepared-statement cache, stale/canceled permission request handling
- cli: Codex compaction announcements, dynamic Kimi models, MCP bridge reap
- web: session toolbar icon shedding, mark-all-read, unread/active sections,
Russian locale, external-link preference, 48px search chrome
- ios/android: locale and font-size updates
Conflict resolution keeps local-only behavior (agent team spawns, macaron
skins, collapsible sidebar, workspace markdown images) layered on the new
upstream toolbar and session lifecycle plumbing.
Test infrastructure: skip the Docker-only pwsh integration test quickly when
Docker is unavailable, and exclude the Bun-only usageScan test from vitest.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Local Markdown image paths now resolve through the session file API and
render from a data URL, with loading/failure/retry states and a portal-based
viewer. Out-of-workspace paths show a resend hint; the display_image prompt
tells agents not to emit local-path Markdown images.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Paste only accepted image/* files, silently dropping PDFs, archives,
etc. copied from the file manager, while drag-and-drop and the attach
button accept any file. Remove the image-only filter from both paste
handlers so paste matches drag-and-drop. The scheduled-send guard is
unchanged.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): stop session toolbar search/clear overlapping neighbors
Collapsed search used min-w-0 and crushed under shrink-0 icons, so clear
painted over filter/BlockedJumpPill on narrow widths. Keep search chrome
shrink-0 and wrap the header actions group instead (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size session toolbar actions to content so they wrap
flex-1 min-w-0 kept the action group beside a long search chip in a 280px
sidebar, so Browse/Settings overflowed onto the calendar. Use w-max shrink-0
and keep the router header-action cluster from shrinking (#2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep session toolbar icons in one slab when the row wraps
max-w-full plus inner flex-wrap let the action group split, so some icons
stayed beside search and the rest dropped underneath. The group is nowrap
and content-sized, so a tight row moves every icon together.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): shed secondary session toolbar icons instead of wrapping
Quest was burning a full toolbar line when the icon slab did not fit
beside collapsed search. Keep one nowrap row; hide browse/settings (and
similar headerActions) below a container-query width, with a higher bar
when the query chip is present (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): satisfy typecheck for session header-row test
CI failed because optional chaining left headerRow possibly null when
reading className. Use getByTestId instead.
* fix(web): put shed session toolbar actions in a More menu
Hiding headerActions on narrow headers removed Settings with no
alternative. Keep one toolbar row, but surface secondary actions in an
accessible ⋯ popover below the container-query threshold (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fold primary toolbar into More when query chip is active
At ~320px the collapsed chip plus unread/filter/New still overflowed past
More/Settings. When searchQuery is non-empty, put primary controls in the
same overflow menu so the row is [chip][⋯] (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pack session toolbar actions beside search
ml-auto shoved the action slab to the right edge and left a Quest-width
gap between the search icon and BlockedJumpPill (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): widen machine menu clamp and width-gate toolbar More
MachineFilterMenu near the left edge (inside More) got a tiny right-only
maxWidth. Clamp to the wider horizontal pocket and left-align when that
side wins. Fold primaries into More only when a query chip is present and
the header is under 32rem (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fold toolbar into More on narrow headers without a query
Empty query still forced toolbarCrowded=false, so at 320px / 130% font
with machines+unread the More control went offscreen. Crowd from header
width in both query states (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): stop Quest-width empty query collapsing to search+More
A flat 32rem crowd gate folded every primary into More on typical Quest
sidebars (~400px), leaving [search][⋯] and a desert of empty space. Empty
query now crowds only below ~22rem (still covers 320px/130% font); query
chip keeps the higher 32rem bar (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pin toolbar More to the trailing edge on Quest widths
Left-packing every action left a dead zone after New while Settings hid
in More. Primaries stay beside search; secondary/More get ml-auto on a
flex-1 actions row so the chrome reads [search][primaries] … [⋯]
(#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): shed session toolbar icons by keep rank
A single width gate either hid the whole row or left a hole before More.
Drop the lowest-priority controls first (browse, mark-all, settings) and
keep search, new, and the unread lens on the row while they fit (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size toolbar shed from actions box, not a search guess
Active query reserved 200px for a max-w-[9rem] chip and collapsed the
row to search+More. Measure the actions flex box and each control;
keep daily icons until width is known (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): remeasure toolbar shed after search collapse
The actions box unmounts while search is expanded, so a one-shot observer
left the next layout on a stale width. Fit math now reattaches on the
new node, subtracts a visible machine menu, and uses the measured More
width and column gap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): let query chip yield so toolbar More stays on-screen
At 320px and 130% type a long chip plus calendar left too little room
for the machine menu and More. An active query now uses min-w-0 on
search and shrink-0 on actions so only the chip text truncates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): stop desktop toolbar using an infinite shed budget
Empty-query first paint treated the actions gutter as infinite, so every
shrink-0 icon stayed inline and painted past the split. Use a finite
fallback, cap the budget by the real row gutter, and give the sidebar
min-w-0 so --sidebar-w actually constrains the flex item.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): use one-gap slack instead of leaving an icon-sized hole
The shed refused the next icon when the gutter was only a column-gap
short, so the row kept an empty slot the size of that icon.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fill the toolbar gutter before opening More
The shed was budgeting a guessed remainder, so More stayed open while
a wide empty stretch sat before the split. The budget is now the pixels
from the actions' left edge to the row's right edge. If every icon fits,
overflow is empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size the toolbar budget from the flex leftover, not the icons
shrink-0 made the actions box as wide as whatever was already showing, so
search lost its clear and calendar and a collapsed query could hide every
action. The actions box is flex-1 again, and a query chip cannot shrink
below its icon, clear, and calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): reserve machine menu and More before search width
A long 9rem query chip at 320px / 130% left the actions box under 50px
while machine menu + More need ~94px, so More slid off-screen. Cap the
collapsed search group to the row minus those trailing controls.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Add layered forest clearings, a responsive chat scene strip, larger companions and transparent nine-slice wooden frames. Export artwork for phone pixel density and preserve compact navigation, drafts, default appearance and existing session actions.
Add an opt-in forest skin with original transparent artwork, session companions, matching mobile surfaces, and on-demand offline art caching. Preserve existing theme customization and session actions. Include reproducible art slicing, browser coverage, and baseline test evidence.
Active non-worktree sessions fetch the branch of their working
directory with a single lightweight git-status RPC and render it in the
header metadata row (live, so mid-session branch switches follow).
Worktree sessions keep their existing worktree chip and skip the extra
RPC; a new display preference toggles the branch item.
With custom Claude models configured (e.g. a custom ANTHROPIC_BASE_URL),
the composer and new-session pickers now offer exactly those plus the
Default entry instead of merging the built-in Anthropic alias presets,
and fall back to the first configured model when there is no auto
option.
The composer status bar surfaces an in-flight Codex context compaction
with the native start time and an elapsed timer; completion,
interruption, reconnect, and shutdown all clear it. The CLI follows the
contextCompaction item on the app-server stream and publishes
contextCompactionStartedAt through the shared agent state.
* fix(web): 48px session search clear with calendar left of it (#2019)
Quest/touch: expanded search uses a min 48x48 clear end-cap as the
furthest-right control, with the calendar embedded immediately to its
left inside the field. Collapsed search stays shrink-0 with a larger
clear chip so it does not steal the toolbar row.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): anchor date picker to search group; let collapsed chip shrink (#2019)
HAPI Bot: mount the calendar popup on the search control (not the 48px
button) so left/right align stays on-screen at narrow widths. Drop
shrink-0 on the collapsed group so the query chip can yield space while
calendar/clear stay fixed hit targets.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(voice): configurable ElevenLabs max conversation duration
Auto-created ConvAI agents never set conversation.max_duration_seconds, so
ElevenLabs applied its 600s default and cut voice conversations off.
Add ELEVENLABS_MAX_DURATION_SECONDS (default 1800, validated 60-7200). The cap
is applied when the hub creates an agent and reconciled onto existing
hub-managed agents (found by name) once per process. Operator-supplied agents
(ELEVENLABS_AGENT_ID / voice map / customAgentId) keep their own cap. Invalid
values fail the token request with a clear message instead of an API 422.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* feat(voice): Settings UI for ElevenLabs max conversation duration
Extend the hub-owner credentials panel (Settings -> Voice -> manage
providers) with a numeric field for ELEVENLABS_MAX_DURATION_SECONDS,
reusing the same env-wins-over-Settings overlay that already backs the
ElevenLabs API key field. A Settings save takes effect on the very next
token request in the same process; the env var still requires a restart.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): reconcile max duration on cached agents, not just once per process
Codex review on heavygee/hapi#230 caught this: getOrCreateAgentIdForVoice's
agentIdCache short-circuited before ever reaching ensureAgentOverrides, and
ensureAgentOverrides's overridesEnsuredAgents gate was a one-time-forever
marker that didn't account for the configured duration changing after the
first reconciliation. Together, any voice already used once in the hub
process would keep its old cap until a restart, contradicting the Settings
UI's 'takes effect immediately' claim.
Track the last-applied max duration per agent and compare it against the
live setting on every call, including agentIdCache hits, so a Settings
change reaches an already-cached agent on its very next token request.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): address tiann/hapi#2017 review findings
- Fix TS2769 typecheck failure: test assertions casting the credentials
response narrowed the type to omit 'editable', which upstream CI's
bun typecheck catches and this fork's (currently broken) CI did not.
- An invalid ELEVENLABS_MAX_DURATION_SECONDS env value threw out of
buildMaxDurationStatus(), which is shared by every provider's
GET/PUT credentials status — breaking visibility into OpenAI/Deepgram/
Groq settings too, not just ElevenLabs. The token-issuance hot path
(getConfiguredElevenLabsMaxDurationSeconds) still throws there by
design; only the status/update endpoints now degrade gracefully.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): interpolate duration range in the Settings hint
Nit from tiann/hapi#2017 follow-up review: the hint under the max-duration
field rendered the literal '{min}-{max}' placeholders instead of '60-7200'.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Persist paged Codex history checkpoints and bounded, acknowledged delivery.
Keep usage sources and accounting across deleted or merged sessions.
Include native Goal controls, faster peer/session queries, and release guards.
Track the complete deployed source so isolated builds cannot omit local fixes.
Validation: all typechecks pass. The full suite reports 7,997 passing tests
and 46 existing failures, each reproduced against the prior commit.
* feat(web): add Russian locale
- Add web/src/lib/locales/ru.ts
- Register 'ru' in the locale registry, i18n context, language switcher and settings
- Format outline timestamps with the day.month convention for 'ru'
* feat(web): detect initial locale from Telegram and browser
The Telegram Mini App runs in its own web view, so a language chosen in
the browser does not carry over and the app always opened in English.
Pick the first session locale from an explicit stored choice, then the
Telegram user's language_code, then navigator.language, then English.
* feat(web): translate the newly localized strings
Russian values for the keys added by the hardcoded-string fix (see the
separate web i18n PR). Keeping them on this branch so the locale PR is
complete when both land.
* feat(web): add Russian for the newly localized strings
* fix(web): recheck the Telegram language after a slow SDK load
* fix(web): keep a manual language choice over late Telegram detection
---------
Co-authored-by: mrudy <mrudy@local>
Large media downloads (a 19MB video takes ~60s over the slow runner
tunnel) previously showed only a pulsing placeholder, so the load looked
dead and users gave up. Read the response stream against Content-Length
and render a percentage bar while the blob downloads; fall back to an
indeterminate "Loading …" label when the header is absent, and keep the
instant path for cache hits.
Loaded video/audio/file blobs stayed in memory for the whole session
(object URLs were only revoked on unmount), so opening several tens-of-MB
videos in one chat grew memory without bound. Explicitly loaded media now
goes through a shared cache that keeps at most three entries; the least
recently used object URL is revoked on overflow and its card falls back to
the Load button (a sticky eviction flag prevents a slow load from
re-publishing an already-revoked URL). Auto-loaded images keep their
existing per-card lifetime.
Pasted images were stored inline in the message JSON as full-size base64
data URLs. Every conversation load (open, paginate, mobile reconnect) had
to download and parse those megabytes before anything rendered, with no
lazy loading and no per-image caching. Measured on this hub: 115 messages
holding 138MB of base64, the largest single message 18.3MB.
New messages now carry only a small thumbnail (browser-generated, 768px
webp/jpeg data URL); the original is uploaded as before and the hub keeps
a durable copy under ~/.hapi/attachments (table chat_attachments, schema
v27). The chat renders the thumbnail instantly and lazily swaps in the
original from GET /api/sessions/:id/attachments/:id when the viewer opens;
that endpoint answers sha256 ETag + immutable Cache-Control so repeated
opens are free. Sessions scope the URL, deletes remove the copy, and
hub/scripts/attachments.ts covers stats/verify/gc/export.
Old messages keep their inline previews (migration deliberately deferred).
Verified end-to-end against the local hub: upload returns the attachment
id/url, original 200 with ETag, revalidation 304, cross-session 404, delete
removes file+row (404 after). Typecheck clean; suite status matches the
pre-change baseline; fixtures unchanged.
Six text conflicts, all resolved:
- cli/apiMachine + runner/run: adopt upstream's MachinePathPolicy; keep the
local policy that browsing stays disabled until --workspace-root is set
(banner text + upstream's browse test adapted to assert the local gate).
- cli/codex root.test.ts: keep both the syncHistory split test and upstream's
change_title/name test; fixture return merges metadata + syncHistory.
- web/SessionList: combine the local personal/global pinned split with
upstream's search-relevance ranking (deps union).
- web/AskUserQuestionFooter: take upstream's draft-restore effect and thread
the local skippedByQuestion through the draft store; the local DSH skip test
moves to AskUserQuestionFooter.skip.test.tsx so upstream's vi.mock setup in
the original file cannot shadow it.
Also: bun install for the assistant-ui 0.15.21 bump, regenerated
shared/fixtures from the merged pipeline.
Test status vs the pre-merge baseline (d6cf54d7), identical pre-existing
failures only: cli 7, hub 2 (opencodeClear), web 35, shared 2 (DSH); relay
clean; typecheck clean across all packages.
* feat(web): add Ctrl/Cmd+Shift+D dictate keyboard shortcut
Toggle composer dictation or voice assistant via the same path as the
mic buttons, with dialog/input guards and tooltip documentation.
Fixestiann/hapi#1900
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): address dictate hotkey bot findings on PR 1902
Ignore key repeat on the session listener and align assistant-mode
invoke guards with UnifiedButton send mode (canSend).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): change_title updates metadata.name for visible renames
Agent change_title was writing metadata.summary only, so spawn --name
sessions kept showing the old title. Route explicit renames through
metadata.name (web rename semantics) across MCP, Codex, and Pi.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): voice context prefers metadata.name for session title
Voice bootstrap/full formatters only read summary.text, so after
change_title sets name the spoken context kept a stale title. Use the
same name→summary precedence as getSessionTitle.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: polish title rename tests after cold review
Rename the Codex root title test to match last-writer-wins behavior,
and skip the voice full-context summary header when no title signal
exists (avoid literal undefined).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(kimi): discover configured models dynamically
* fix(kimi): address dynamic model review findings
Probe settlement (Major): parse `kimi provider list --json` on the child's
'close' event instead of 'exit', so stdio is fully drained first, and run the
parse inside a guarded settle() helper. A parser throw previously escaped the
promise from an async event callback and reached the runner's uncaughtException
shutdown handler instead of listKimiModelsForCwd's catch. The helper also
settles the promise exactly once across timeout, spawn error and close, names
the terminating signal when there is no exit code, and tolerates a kill() that
throws.
Running-session discovery (Minor): add a session-scoped Kimi catalog. The
shared RPC method `listKimiModels`, a CLI handler registered on the session
connection with the probe cwd taken from the session metadata (never from RPC
arguments), hub rpcGateway/syncEngine pass-throughs, and
GET /api/sessions/:id/kimi-models gated on an active session of flavor `kimi`.
SessionChat now reads the catalog from that endpoint, so an active session
advertises its models even when no background runner holds the machine socket.
New Session keeps the machine-scoped discovery unchanged, and model switching
still goes through the existing ACP set_model path.
* fix(kimi): register session model discovery before the local/remote loop
The session catalog handler was registered inside the remote launcher, after
ACP initialization. A session started with `hapi kimi` in local mode therefore
answered the web picker's request with an unregistered-handler error, and the
failed query was not retried or invalidated when remote control was taken over.
The catalog is backend independent, so it is now registered in runKimi session
setup alongside the other session-level handlers, using the session's working
directory, and the late registration in the remote launcher is removed. This
covers local, remote and resumed sessions on the same session connection.
Adds cli/src/kimi/runKimi.test.ts asserting the handler is registered after
bootstrap and before the loop, and that the probe cwd is the session's working
directory. The launcher wiring assertion moves out of
kimiRemoteLauncher.test.ts with the registration.
Links in a session open via window.open(_blank). For an installed
standalone-display PWA, navigating to a URL outside the manifest's scope
is a platform behavior (Chromium/Edge show it as a minimal overlay with a
close affordance), not custom code here — but closing it can mean the
underlying page had a real top-level teardown, not just an in-app remount.
useComposerDraft only ever persisted the draft in its effect's cleanup —
reliable for a React-managed unmount (switching sessions), but browsers
don't guarantee that cleanup runs to completion before a genuine
top-level navigation completes, so the save could simply never happen.
Extracts the existing save logic into persistNow() and calls it both from
the unmount cleanup (unchanged) and a new visibilitychange listener (when
document.visibilityState becomes 'hidden'), mirroring the pattern already
used elsewhere in this codebase (useSSE.ts, useAuth.ts, useSyncingState.ts,
usePwaUpdate.ts, useVisibilityReporter.ts) for the same class of problem.
Fixes#1882
Co-authored-by: Ian Stevenson <is@lockhouse.systems>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(web): setting to open external links in a new tab
Left-clicking an external http(s) link renders it with no explicit
target, defaulting to _self. In an installed/standalone PWA this gets
intercepted by the browser and shown as a captive in-app overlay
instead of leaving the app; in a normal tab it navigates away from
HAPI entirely. Right-click already opens in a new tab, but that's not
a fair ask for every link.
Add an opt-in Settings > Display > Links toggle that forces
target="_blank" rel="noopener noreferrer" on genuine external http(s)
links only (relative/SPA hrefs and other schemes like mailto are left
untouched). Defaults to off, preserving today's behaviour, since the
overlay may be intentional/liked by some users.
Vibe Coding disclosure: implemented with Claude Sonnet 5 (Claude Code).
* fix(web): simplify link-toggle setting description per review
The description mentioned the target attribute (an implementation
detail, not user-facing) and asserted the in-app overlay is what every
installed PWA shows, which was only actually confirmed on Chromium/
Edge. Describe the user-visible behaviour only, without promising a
specific rendering across all installed-app environments.
* fix(web): stop promising specific behaviour when installed as an app
Per follow-up review: describing the setting as "always" opening links
in a new tab "even when installed as an app" still guarantees a
specific outcome across every browser's PWA handling, which isn't
something this change can verify or promise. State only what the
setting does (open in a separate tab), not how every installed-app
environment will render that.
---------
Co-authored-by: Ian Stevenson <is@lockhouse.systems>
Pressing Enter to confirm a Chinese IME candidate sent the message because the
team composer had no composition guard; it also ignored the shared
'hapi-composer-enter-behavior' setting. Now it never sends while composing and
follows the setting (send on Enter, or newline with Cmd/Ctrl+Enter).
Grouping every message into collapsed requirement cards made the team page read
as a wall of summaries. The default is now a chronological timeline (with the
AI chatter folding) and the requirement cards moved behind a 'By requirement'
filter; 'Key' and 'Task' views are unchanged.
Cards kept showing 待开始 for requirements whose tasks were all finished,
because the status field is only written when the lead explicitly closes a
requirement. The card now falls back to the task states (blocked > done >
doing) and treats teammate activity without tasks as in progress; an explicit
status still wins.
The card showed the same sentence up to four times: as the title, in the
'your ask' block, in a separate 'original ask' block and again as the opening
human message in the timeline. Now the ask appears once at the top (clamped to
three lines while collapsed, full text when expanded) and the opening human
message is hidden inside the process view.
- cards show 'your ask' and a result block at a glance: the lead's conclusion
when present, otherwise a result derived from task states (n/m done plus the
latest deliverable); the original ask stays one click away
- the process opens to key milestones only (human messages, decisions, task
hand-offs, done/blocked tasks, system notices) with a 'show full process (+n)'
switch for the raw stream
- hub: one-time idempotent backfill files pre-requirement history under
requirements (fresh human asks become requirements; messages/tasks follow by
time window; short acknowledgements do not open requirements)
- task update broadcasts now carry the status in meta so the UI can pick
milestones without parsing text
- requirement cards keep the conclusion (result) and task progress visible while
collapsed, show the original ask when expanded, and offer a 补充说明 shortcut
that threads the next message under the requirement
- composer gains a requirement selector (auto-file / new / a specific one) and
the human message API accepts requirementId + newRequirement
- closing a requirement (or writing its conclusion) now notifies the human
out-of-band via a team-attention event
- AI messages inside a thread lose their heavy card styling (left rule only);
decisions keep the emphasised card
- requirements: a fresh human ask opens one automatically (replies inherit);
tasks and messages carry requirementId (spawn_peer / team_task / team_send
taskId); the lead closes one with team_requirement (status + conclusion)
- web: the team timeline groups messages into collapsible requirement cards
(title, status, conclusion, message count; a card with a pending decision
opens automatically); unassigned messages fall into an 'Other' card
- web: entering a team scrolls to the newest message, follows new messages only
while pinned to the bottom, and offers a 'jump to latest' button otherwise
- web: AI-to-AI chatter (status/chat/question/task-update, not addressed to the
human) folds into a one-line summary by default (runs of >= 2), with the
participants and latest line preview
- tests: requirement store/service coverage and grouping helper tests
to=human alone is an out-of-band notification, not a request for an answer;
the inbox (待你确认) now only contains kind=decision messages (legacy rows
with awaitingHuman still count). Prompts and tool descriptions now tell agents
to use kind=decision when the human must decide and plain broadcasts for
progress they merely need to see.
- team settings can edit the budget (max members / messages per minute / chain
depth); changes merge into the existing config and apply immediately
- default member cap 5 -> 8 (lead included)
- new DELETE /api/teams/:id/members/:sessionId (optional ?stopSession=1),
lead cannot be removed; removal is announced in the team log, the removed
session is notified, and its session is archived only when requested
- member list gets a human-confirmed remove action with a stop-session checkbox
- tests: service config merge + route budget/removal coverage
* fix(cursor): pin Auto to CLI --model auto instead of account default
Selecting Auto was treated as unset/ACP default[] after the Default→Auto rename. Persist and spawn `auto` so the picker matches `agent --list-models`.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): keep live ACP model until Auto actually applies
Pin session.model to auto for the next `--model auto` spawn, but do not claim the live backend switched when ACP only advertises default[].
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): reject live Auto unless ACP auto or spawn confirmed it
Do not persist Auto when the running catalog has no auto option. Clear the spawn-auto flag after a concrete model apply so Auto → concrete → Auto cannot fake a live switch.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): keep ACP wire ids spawn-safe and apply effort
Cursor's parameterized picker (`clientCapabilities._meta.parameterizedModelPicker`)
advertises bare model bases plus separate `fast` / `thought_level` config options.
`buildCursorModelsSnapshotFromAcp` synthesized every base as
`${base}[fast=${fast}]`, and Cursor rejects any bracket id whose parameter set is
not that model's complete set, so 74 of the 76 published picker ids failed at ACP
`session/new` with `Cannot use this model` (#1818).
- Stop synthesizing wire ids: keep the advertised values verbatim (bare bases are
accepted by `--model`, defaults applied). Parameter sets differ per model, so a
synthesized id cannot be validated from the options alone.
- Flag such catalogs as `parameterized` so variant CLI skus still attach to their
base rows — the ACP apply path expresses them through config options.
- Apply effort in `applyParameterizedCursorModel` (ids `effort`, `reasoning`,
`reasoning_effort`), applied against the config options Cursor re-advertises for
the newly selected model, and skipped on models without that axis.
- Match CLI sku bases across Cursor's legacy `cursor-` family prefix
(`cursor-grok-4.6-high` → ACP base `grok-4.6`) so those variant rows are not
dropped.
- Version the shared on-disk catalog cache (v2) so runners that already cached the
rejected synthesized ids re-probe instead of serving them.
Verified with live `cursor-agent 2026.09.10-fd3934a` ACP runs: the catalog no
longer contains bracket ids, every advertised base and sampled variant sku spawns,
and in-session `claude-opus-4-8-low` / `cursor-grok-4.6-low` set the matching
`effort`/`reasoning` option.
* test(cursor): cover aliased effort ids and thinking coexistence
Covers the branches the HAPI bot review called out for #1818: the
`xhigh` → `extra-high` alias on `gpt-5.5` (`reasoning`), the
`reasoning_effort` id used by `gemini-3.8-flash`, and switching effort on a
model that also advertises Claude's separate `thought_level` `thinking`
toggle.
* test(cursor): cover legacy sku base and post-switch effort option
Adds the two apply-path cases the HAPI bot review called out for #1818:
resolving `cursor-grok-4.6-high` onto an advertised `grok-4.6` base, and
reading the effort option Cursor advertises for the newly selected model
(`reasoning` for gpt-5.5 → `effort` for claude-opus-4-8) instead of the
previous model's options.
* test(cursor): create the cache dir in the migration test
The version-envelope test wrote $HAPI_HOME/cache/cursor-models.json without
creating the directory, so running it alone (or filtering with -t) failed with
ENOENT before the assertion; it only passed because sibling tests in the same
file had already created the path. Reported in the HAPI bot review of #1822.
Verified: `vitest run ... -t 'ignores pre-versioned'` → 1 passed, whole file → 4 passed,
and every other changed test file passes when run standalone.
* Clarify Cursor Auto live-switch limitation in session pickers
* Reconcile partial Cursor model changes before acknowledging Auto
* Serialize Cursor model changes across clients with the existing async lock
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): reconcile keepalive-only sessions to an idle lifecycle
`sessionCache.expireInactive` only expires a session when `activeAt` goes
stale, and `session-alive` refreshes `activeAt` every ~2s for as long as the
CLI socket lives. A session whose agent has done nothing for days therefore
stays `active: true` / `lifecycleState: running` forever — socket pinging was
being read as agent health.
Add the missing second signal. `active` keeps its meaning (the CLI is
reachable); `metadata.lifecycleState` moves `running` -> `idle` once the hub
has seen nothing but keepalives for `HAPI_SESSION_IDLE_TIMEOUT_MS` (default
12h, `0` disables), and back to `running` on the next real progress.
`active` is deliberately not flipped: the socket really is up, and a false
`active: false` would let `resumeSession` spawn a second agent against a live
process and would unlock dedup-merge / delete on it — the #1705 class of bug.
- `sessionCache.recordAgentProgress` tracks a progress clock that keepalives
can never touch: messages in either direction, queued prompts, background
tasks. Seeded from `updatedAt` so a hub restart re-derives it from disk.
- `reconcileKeepaliveIdle` runs on the existing 5s inactivity tick. Sessions
that are thinking, running background tasks or holding a pending permission
request are never marked; `metadata.idleReconcileExempt` opts out entirely.
- Only real progress clears `idle`, never `thinking` — Cursor ACP emits
ambient idle/running churn (#1553) that would otherwise flap the lifecycle
every tick.
- `isLiveLifecycleState` replaces the `=== 'running'` literals so an `idle`
row does not read as dead to the archive route or the Cursor migrator.
- Web splits keepalive-only sessions into their own session-list bucket.
Refs #1820. Distinct from #842 (`active=false` with a stale `running` badge);
this is the inverse.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(hub): seed the idle clock from stored messages across hub restart
PR review on #1821: a cold `SessionCache` had no observed progress and fell
back to `updatedAt` alone. Assistant messages deliberately do not move
`updatedAt`, so a session that was streaming output a minute before a hub
restart read as hours idle and got marked on the very next tick — a false
`idle` that persisted until the next progress event.
Seed the clock once from `getNewestMessagePosition`, which is the durable
record of that output. `updatedAt` stays a floor on top of the seed: todos /
teamState / agentState writes bump it without routing through
`recordAgentProgress`.
Regression test recreates a SessionCache over the same store after recent
assistant output and confirms keepalives no longer cause premature idle
classification — and that the same session is still marked once the output
itself goes stale.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: cover the socket wake-up path and the idle session-list bucket
Follow-up review on #1821 reported no findings but flagged two uncovered
paths. Both have bad failure modes, so close them.
- Hub: an assistant message arriving through `registerSessionHandlers` must
wake an idle session. Assistant messages never bump `updatedAt`, so the
`onAgentProgress` hook is the only thing that can clear the mark — a
refactor dropping that wiring would silently false-idle every actively
working session, and nothing would have caught it.
- Web: extract the inline bucketing from the `runningSessions` useMemo into a
pure `bucketRunningSessions`, matching how the rest of SessionList's logic
is exported and tested. Covers idle vs quiet-but-ready separation, no
double-bucketing, the transition back out of idle on thinking/background
task/pending request, and the disconnected/pinned skips.
No behaviour change.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: cover the idle lifecycle at both isLiveLifecycleState call sites
Third review round reported no findings but named the two guards this PR
changed as uncovered. Both protect against reading a keepalive-idle row as a
dead one:
- Cursor migrator: an idle-marked row still has a live CLI behind it, so
preflight must refuse it without `forceArchiveRunning`. A regression here
would let the migrator transplant the ACP session directory out from under
a running Cursor runner.
- Archive route: once a keepalive-idle session finally loses its socket it
must stay archivable, rather than being stranded behind a 409.
Both tests were confirmed non-vacuous by reverting each guard to the
`=== 'running'` literal; each then fails.
No behaviour change.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: stop the restart regression leaking its temp DB on failure
The file-backed store's `rmSync` was a trailing call, so any failing
assertion above it skipped cleanup and left a temp DB behind — one per failed
run. Noticed while verifying the test was non-vacuous, which deliberately
fails it.
Wrap in try/finally. Verified: a forced failure now leaves zero
`/tmp/hapi-1820-*` directories.
Test-only.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
- spawned members inherit the caller's tool/model/thinking level/permission
(spawn request schema + hub runtime + spawn_peer tool); explicit overrides win
- replies from turns triggered by teammates (not by the human) no longer vanish:
system prompt and ACP briefs now require an explicit team_send(to=human) for
human-facing conclusions produced on those turns
- task loop: team_task tool (list/update), dependsOn gating, deliverable evidence
required when a member marks done, auto broadcast on change, web shows deps and
deliverable on the task board
- team-scoped agent tokens (hapi_team_*): issued per team, injected as
HAPI_TEAM_TOKEN at spawn, restricted by the auth middleware to this team's
messages/tasks/status and audited via [TeamToken] hub log lines
- Team chat now talks to the lead by default; drop the 'everyone' recipient
(broadcast never woke anyone, which read as 'no response')
- Member decisions (kind=decision / to:human) are flagged awaitingHuman; the
human's reply carries inReplyTo and clears the decision, and a new dismiss
endpoint waves one off without replying
- Web: 'Needs your reply / Replied / Dismissed' chips on decisions, an inline
Reply button that binds the asking member, and a 'Needs you' inbox in the
side panel with a badge on the panel toggle