A 19MB video download traveled as one ~26MB base64 socket ack. On a remote
runner behind a slow tunnel (measured ~0.35MB/s k2lab->mini) that exceeds
the hub's RPC budget and Bun's HTTP idle cutoff, so the request died with
an empty reply at ~50s (and at ~240s after the first timeout bump).
The route now probes with a 1MB read that reports total size + mime, then
streams the rest chunk by chunk with Content-Length, honours Range/206 for
players and download tools (416 on unsatisfiable ranges), and falls back
to the legacy full payload when the session process predates this change
(it ignores offset/length and reports no size).
Also gives media/files/upload RPCs a 300s budget and raises the hub HTTP
idle timeout to 240s so a slow transfer is not killed while waiting on the
CLI round-trip.
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.
The runner kills a runner-spawned child whose "session started" webhook
misses its bounded wait (15s by default), so a cold resume whose thread
has a large rollout always failed with "Session webhook timeout for PID
...": the codex shared runtime only reported after reconciling the full
native history (projection.history + refresh + refreshChildren), and
replaying tens of thousands of items took longer than that wait.
Measured on this machine before the fix: two ~800MB rollouts (36k/43k
lines) needed 17-20s to bind; one attempt was killed 2s before its late
webhook landed and was reaped as an orphan.
Split SharedCodexRoot.bind() into the local claim (queue, projection,
metadata, settings) and a separate syncHistory(); runSharedRuntime.bind()
now activates the session and reports it to the runner before the replay.
The session is controllable when the signal fires (controls registered),
and the replay continues in the background; re-sent history is idempotent
because projection uses stable message ids that the hub dedupes by
local_id.
Covered by root.test.ts: bind performs no native history reads, syncHistory
does the reconciliation.
The live pipeline records only the final model step of an OpenCode turn,
so the usage dashboard showed roughly 42% of OpenCode's own totals. Add
an offline reconciliation ledger instead of touching the streaming path
(a previous attempt that fed cumulative counters into the live pipeline
made the chat/status UI numbers jump):
- shared: read and aggregate `~/.local/share/opencode/opencode.db` per
(day, model), exposed as `@hapi/protocol/opencodeUsage`
- hub: additive `usage_reconciliation` table (no schema bump, safe to
roll back), local scan at startup and every 30 minutes, and the usage
summary prefers snapshots over live events for reconciled sessions
- cli: each runner scans its machine's store every 30 minutes and reports
snapshots over the new `opencode-usage-report` socket event, covering
remote machines and sessions whose process already exited
- the hub-local scan skips sessions whose store lives on another machine,
so it can never wipe remotely reported rows
Cursor renamed their CLI to cursor-agent; the old agent name is easily
shadowed by unrelated tools (on one machine the Grok TUI owns agent), which
made the cursor model probe run the wrong binary - four seconds of clap usage
errors and a broken ACP backend. Resolve cursor commands via PATH candidates
(cursor-agent first, then agent); HAPI_CURSOR_PATH overrides both.
- 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.
- 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
teamPrompt tests ran with the real cwd and wrote .hapi/team memory into the
checkout (one charter.md was accidentally committed in ef33332d). Tests now
pass an explicit temp cwd; getTeamPromptBlock/withTeamInstruction accept a cwd.
Drops the stray artifact and ignores .hapi/ in this repo.
- team memory moves to <repo>/.hapi/teams/<slug>-<short-id>/ so several teams
on one repository no longer share (and clobber) a single charter
- the pre-teams/ shared .hapi/team dir is moved into the first team dir that
starts after the upgrade
- .hapi/ is appended to the repo .gitignore once (idempotent, best effort)
Team memory now lives with the code, like CLAUDE.md: <main repo>/.hapi/team/
on the machine where the agent runs. Worktrees resolve to the main repo so
every member shares one directory. The CLI creates charter.md + handoffs/ at
session start (team env only), points the agent at it in the system prompt,
and reports the path in session metadata; the web reads it through the
session file API, so remote runners work too.
Hub keeps only coordination data (teams.db): members/tasks/messages/status.
Its memory-dir management and the /memory API are removed.
Root cause of 'lead never answers': sessions created through the web form
never received HAPI_TEAM_* env, so their system prompts had no team rules
(only spawn_peer-created members did). Now:
- /api/machines/:id/spawn accepts a team descriptor; the runner exports the
env as before
- team mode creates the team first, spawns the lead with the team context,
then attaches it as lead; member mode spawns with the team context before
joining
- OpenCode system prompts (title + native tool instruction) carry the team
block too
- human ping copy is action-first: reply directly (auto-synced to the
group); do not run shell commands or explore the team
Members replied in their own session and the group chat stayed empty,
which read as 'no response'. The hub now records a pending ping when a
human message is pushed to a member, and on the member's next
thinking->idle transition mirrors its last assistant text into the team
log (deduped: skipped if the member answered via team_send).
Prompts updated: normal replies sync to the group automatically; team_send
is for member collaboration and explicit reporting.
Hub
- independent teams.db (own user_version ladder); feature gate default OFF,
so a disabled hub never creates the file and team routes simply do not exist
- /api/teams: list/detail/by-session/memory/messages/spawn/tasks,
human message channel, team rename/archive/delete
- team runtime: spawn members via the existing runner RPC (worktree/yolo
options), peer message routing (mention/dm push, broadcast pull-only),
budgets (max members, message rate, reply chain depth), member-offline
announcements that wake the lead or escalate to the human
- team-attention notifications: in-app toast when visible, web push otherwise
- team memory dir {HAPI_HOME}/teams/<id>/ with charter.md template; read-only
browse API with path-traversal guards
CLI
- MCP tools: team_status / team_read / team_send / spawn_peer (claude, codex
bridge and ACP flavors); spawn_peer stays behind manual approval
- HAPI_TEAM_* env for spawned members; system-prompt block for Claude/Codex;
assignment brief carries team rules for flavors without prompt injection
- RUNNER_CAPABILITIES.agentTeam advertised to the hub
Web
- team group chat route with folded peer threads, key/task filters,
human composer, member/decision/system message cards
- side panel: live member status, interactive task board (create/status/
assignee), team memory browser, budget
- team create / add-member / settings dialogs; sidebar team section;
hub settings toggle for the feature (restart to apply)
- SSE team-updated invalidation
Zero impact when disabled: no teams.db, no routes, no tool or prompt changes.
Revert 8357da0a9d and its later shared-runtime integration.
Remove automatic model fallback, account usage polling, and the related UI, protocol fields, tests, and documentation without adding replacement quota handling.
Keep generic model pagination and method probing required by the current shared-session architecture. Cover idle sessions staying online without usage polling or agent-state churn.
Separate top-level help and version flags from agent arguments. Require explicit agents in scripts and preserve command argument boundaries.
Validation: bun typecheck, bun run test, targeted runner integration tests, and PTY/source/compiled argv smoke checks.
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.
Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.
Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
Vitest's 5s default flakes under GHA Defender/cold-start on the only
unit test that real-spawns on Windows (#1823). Keep the global unit
default unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
Bridge main-session PermissionRequest hooks without suppressing the native
terminal dialog. Reconcile replies against native results and clean up on
timeout, cancellation, mode switches, and session changes.
Keep reply IDs distinct from native tool IDs across web and native clients;
add protocol fixtures and regression tests.
Refs #1796
* fix(agy): say what the model picker is actually waiting on
The spinner in the New Session AGY picker read "Checking Antigravity
authentication…", but nothing at that point checks authentication — the
machine is running `agy models`, and the sign-in prompt is a separate
branch below it, shown only when agy reports the failure.
Name the wait after the work: "Fetching available models…", the same
words agy prints while it fetches.
* refactor(agy): describe a probe by its outcome, not by its response
The probe function returned a finished `AgyModelsResponse`, so "agy could
not be reached" and "agy listed no models" both arrived as a successful
response carrying the hardcoded mirror, and the caller could no longer
tell which had happened. Every policy decision about that answer has to
live inside the probe as a result.
Hand back what the probe observed — a live catalog, an auth failure, or
nothing usable — and let the caller turn it into a response. Same
behaviour: the mirror still stands in for both failure modes, and the
60s cache still holds whatever came out.
* fix(agy): serve the model catalog stale-while-revalidate
The `agy models` probe is a whole agy invocation — around 3s on a good
day, 15s when it times out — and the 60s window meant the New Session
picker paid that again a minute after the last look.
Keep the last listing agy actually returned and answer from it: fresh for
ten minutes, then still answered while a probe refreshes behind it, until
the entry is a day old and stops standing in for the machine at all. A
probe that times out or loses auth leaves that entry alone, so a blip no
longer empties a working picker, and the hardcoded mirror is no longer
recorded as if the machine had reported it.
Three things fall out of that and are handled here. A machine whose
sign-in has actually gone bad would otherwise look healthy for a day, so
an auth failure rides along with the catalog it can still serve — and,
because nothing else would re-probe a catalog that is still fresh, a
warning riding on the answer is itself a reason to look again. A failed
probe is not repeated on the very next request either, or a machine where
agy hangs would spawn it once per poll.
An explicit refresh always costs a probe, and never rides one that was
already running when it was asked for.
* fix(agy): let Retry force a fresh model catalog probe
With the catalog held for ten minutes, Retry would otherwise hand back the
answer it was pressed to replace, so the intent travels to the machine:
`?refresh=true` on the machine route, an optional RPC param, and a
one-shot flag on the query so ordinary mount and focus refetches stay
cheap. Every hop is optional, so a hub and a runner on different versions
still talk — the older side ignores it and answers from its cache.
Retry also has to be reachable, and honest, in the state that needs it.
The machine now answers with both a usable catalog and the sign-in failure
behind it, so the picker keeps the list and says why it may be out of
date, with the button right there rather than only once there is nothing
left to show. Pressing it runs agy, which can take tens of seconds, so the
button says so while it does.
The client contract covers both: `agy-models` is the one catalog route
that can carry an `error` on a successful response, and the one that
takes a refresh parameter.
* fix(agy): use the machine catalog in the in-session model picker
New Session already asks the machine what `agy models` lists, but a
session that is already open offered the built-in list in
`shared/src/models.ts`. That list is a hand-maintained mirror, so a model
agy started offering after the last release could be picked for a new
session and not for the one already running.
Point the composer at the same machine catalog. The mirror stays as the
fallback for the moment before the machine answers, and a model the
session is already on is kept selectable — and readable, when it is one
of the known presets — even after the catalog moves on without it.
* fix(agy): announce a model catalog re-check that changed the answer
Serving the last known catalog answers the picker instantly, but a picker
that was already open kept showing that answer until the user closed and
reopened it — the machine had no way to say it had found something newer.
Say it on the stream that already carries machine changes. The machine
daemon — the only process that answers `<machineId>:listAgyModels` —
emits it, and the hub forwards it as `machine-agy-models-updated` with
nothing but the machineId. Namespace resolution, per-machine delivery and
reconnect replay all come from the existing path.
What counts as a change is what the route would answer, not what sits in
the cache. That distinction carries the cases: a sign-in that lapsed or
came back changes no models yet changes what the user is told; a machine
whose agy was signed out has been answering from the hardcoded mirror, and
its first real listing is the largest change there is, for every client
except the one awaiting it.
* fix(agy): re-read the announced machine's model catalog
On `machine-agy-models-updated`, cancel and refetch that one machine's
catalog query — the app's global connection is always subscribed, so an
open picker redraws wherever it is.
Cancelling first is what makes it correct rather than merely likely.
query-core cancels an in-flight fetch only when the query already holds
data, so a picker opening for the first time would otherwise join the
request already on its way and settle on the listing the announcement
replaced. The refetch is answered from the machine's cache, so it starts
no probe and cannot bounce another announcement back.
A reconnect the hub could not replay takes the resync path, which clears
the agy catalogs the same way — that path has no announcement to fall back
on, so it is the one that can least afford to join a stale request.
* fix(agy): keep a model the user picked when the catalog moves under them
The catalog can now change while the New Session form is open, and the
form dropped any selection the machine no longer advertised — including
one the user had just made.
Keep that one, and list it as no longer listed so the form does not imply
agy is still offering it. A model restored from a draft or a saved
preference is still dropped: it may never have been runnable here.
* fix(agy): announce uncached authentication changes