Open Harness Server subcommands - #1853
Merged
Merged
Conversation
anup-deka
reviewed
May 29, 2026
logwolvy
approved these changes
Jun 1, 2026
logwolvy
left a comment
Contributor
There was a problem hiding this comment.
Assuming, relevant godo changes for sending agent spec yaml are done.
Please address the comments and we should be good to merge.
doctl agent attach connected but printed nothing: the harness-api SSE
stream emits the SPI canonical event envelope (type/data/timestamp/
tenant_id, dot-separated event names) while vendored godo decoded
kind/payload/at/team_id, so every event fell through renderEvent.
- godo HostedAgentEvent: decode the SPI wire via UnmarshalJSON and switch
the HostedAgentEventKind constants to the dot-separated SPI names.
- godo ErrorResponse: also read the nested {"error":{code,message}}
envelope so failed calls show the server's reason, not a bare status.
- agents: align event payload structs/rendering and HITL tracking to the
SPI data shapes, and acknowledge each submit with its run id so the
agent's startup latency isn't mistaken for a hang (which caused
duplicate submits).
- tests for the wire decode, rendering, nested-error surfacing, and ack.
The vendored godo edits mirror the fix being upstreamed to
digitalocean/godo and are in-tree until godo is re-vendored.
Co-authored-by: Cursor <cursoragent@cursor.com>
logwolvy
previously approved these changes
Jun 8, 2026
logwolvy
left a comment
Contributor
There was a problem hiding this comment.
I will be raising a PR for stream related fixes on doctl and godo.
…endlier attach/start errors
…1866) * displayers: drop SandboxID column from hosted-agent session output * add vendor files --------- Co-authored-by: SSharma-10 <shivanisharma@digitalocean.com>
Mahki0412
reviewed
Jul 1, 2026
| @@ -0,0 +1,1215 @@ | |||
| /* | |||
| Copyright 2026 The Doctl Authors All rights reserved. | |||
| Licensed under the Apache License, Version 2.0 (the "License"); | |||
Vendors glamour and its transitive deps (chroma, goldmark, bluemonday, etc.) and bumps lipgloss/termenv so `doctl agents` can render the agent's markdown replies with syntax-highlighted code blocks. Also re-syncs the pinned godo vendor tree (adds Session.SandboxID). Co-authored-by: Cursor <cursoragent@cursor.com>
command_execution-style approvals (bash, GitHub writes) already worked as a
plain approve/reject/defer verdict. Agents now also emit mcp_elicitation HITLs
via harness-api: an OAuth url elicitation, an empty-schema yes/no tool-call
approval, and a schema-bearing data form — none of which this CLI could
render or answer, and the resolve request had nowhere to carry the form's
answer content (only a verdict).
- commands/agents_hitl_elicitation.go: classify a HITLRequested payload's
shape (command / url / approval / form), parse requestedSchema into
validated fields (the MCP spec restricts these to flat primitives), and
render the message/URL/schema card wherever a command's summary line
showed before.
- Interactive attach TTY: url/approval reuse the existing y/n/d menu
(content stays empty); a schema-bearing form gets its own field-by-field
prompt (attachState.form), reusing the plain line editor; the ticket's
{approve: boolean} special case gets a two-key Approve/Deny prompt where
both keys resolve Outcome: Approve and differ only in Content.
- Headless paths (--on-hitl, `prompt`) fail loudly on a data form instead of
hanging or submitting an empty answer — there's no value to synthesize.
- `approve` (out-of-band resolve) gains --content, a JSON object forwarded
as-is, following the --user-simulator-config pattern.
## Paired with digitalocean/godo#1115 — review together
The godo half is digitalocean/godo#1115, open for review in parallel with
this one. It targets OHS_endpoints rather than main, since hosted_agents.go
only exists on that branch.
The vendor/github.com/digitalocean/godo/hosted_agents.go change here is
byte-identical to what godo#1115 ships (just the Content field on
HostedAgentResolveHITLRequest) — it's here only so doctl compiles and can be
reviewed now, not as a permanent fork of the vendored library. go.mod is
untouched; it still points at the released v1.208.0-beta.1.
Merge order: godo#1115 first, then a release, then this PR swaps the
hand-edit for a bump (go get github.com/digitalocean/godo@<new-version> &&
go mod vendor), which should regenerate the same file and collapse the vendor
diff to nothing but the version string in go.mod/go.sum/vendor/modules.txt.
Follows the precedent set by digitalocean/godo#1112 / #1969.
Swaps the hand-patched vendor/hosted_agents.go for a real dependency bump now that HostedAgentResolveHITLRequest.Content shipped in a tagged godo beta. The vendored file's diff collapses to nothing but the version metadata, as expected — the hand-patch was byte-identical to the released field.
harness-runtime: render & answer MCP elicitation HITLs (MARSOHS-1443)
added hermes-base to template base allowList
Harness Runtime sessions are paid from a team-wide prepayment balance. When it runs out, harness-api returns 402 on every spend route, but doctl rendered that as a generic "Request failed" with no mention of billing -- and an attached session simply went quiet mid-sentence. Adds a `balance` command, turns the gate's 402 into an add-funds card enriched with the live balance, and makes an unasked-for pause explain itself. - do/prepayment.go reads GET /v2/customers/my/prepayment_config, which returns config and status together so one request covers every messaging branch. A 403 becomes ErrNoBillingPermission: the token lacks billing:read, which is a state to report rather than a failure. - The 402 card is built centrally in beautifyAgentErrorFor, so all twelve gated routes are covered without call sites opting in. port-forward is wired directly because it sits outside the pretty-error wrapper. Billing is read only on the 402 path and by `balance`, so the non-spend commands stay ungated and keep working at $0. - The balance read that enriches the card is bounded to 5s, which is why PrepaymentService.Get takes a context rather than using the do/* package-wide context.TODO(). The user has already earned the 402, so a degraded billing API must not hold it back on doctl's usual retry schedule; on expiry the card falls back to its plain wording. - 503 gets its own card. The gate fails closed when it cannot resolve a team's mirrored state, and telling a solvent user to add funds because a lookup timed out would be actively misleading. - Mid-session, the card prints inline and the session stays attached: the work is intact and the next prompt succeeds once funds land. The typed message is restored, and the card is latched to once per gate episode so a retry loop does not wallpaper the terminal. Messaging leads with the team-level cause rather than the failed verb, since the same card appears on twelve commands, and says the work is saved. Zero-balance messaging ships the depleted wording only: the never-funded variant would key off config.created_at, but that marks whether a team configured auto top-off, not whether it ever funded, so a one-time top-up that drained would be misread as a brand new account.
A session paused by the prepay gate looked identical to one paused for being idle: status PAUSED, no explanation. harness-api has told us the difference for a while via pause_reason, but godo's HostedAgentSession had no such field, so the value was discarded during its unmarshal and never reached doctl. The field is added to godo rather than worked around here, as digitalocean/godo#1113. The vendored hosted_agents.go change in this commit is byte-identical to what that PR ships -- it is here only so doctl compiles and can be reviewed now, not as a permanent fork of the vendored library. Once godo#1113 merges and releases, the hand-edit is replaced by a pin bump and the vendor diff collapses to the version strings. - `show` and `list` name the reason inline, passed through verbatim so a reason the API adds later is displayed rather than flattened to "unknown". godo types it as a string for the same reason, with constants only for the three values documented today. The table column is only carried when some session actually has a reason, so an ordinary list does not grow a blank column. - A low_balance pause points at the balance before `launch`, since launching would just reproduce the 402 until funds land. - `--paused-by` filters on the reason. There is no server-side pause-reason filter, so it implies status=paused on the request and filters the page locally; the help text says that interacts with pagination. Pairing it with a non-paused status is rejected rather than silently matching nothing. Because the field is on godo's type, the session read paths stay on godo's client and -o json keeps unwrapping to the godo struct, so pause_reason reaches scripted output the same way sandbox_id and resume_on_topoff already do. Nothing in do/ changes.
`--harness codex` built a manifest with `agent: codex-agentapi`, so a user
asking for the Codex CLI silently got OpenAI's sandbox-provider adapter
instead. They are two different adapters, not two spellings of one: `codex`
is the Codex CLI supervised by OHR in the coding-codex template, where
DigitalOcean runs the agent loop, while `codex-agentapi` keeps the loop and
session state at OpenAI and DigitalOcean supplies only the sandbox. The two
also differ in what they support -- codex-agentapi is excluded from
checkpoint, fork, and rollback.
`--harness codex` now yields `agent: codex` with an OPENAI_API_KEY secret
slot, no `config` block, and no CODEX_* env. The prompt goes out post-ready
via sendInitialPrompt, as it does for opencode and claude-code, rather than
riding in `config.input`. `codex-agentapi` and `openai-codex` keep producing
the full OpenAI body unchanged.
The OPENAI_API_KEY slot matches the coding-codex template, which runs
brightstaff with `credentials: backend: env` and regenerates .codex/auth.json
per run from the guest inference env; OPENAI_API_KEY is the value that set
takes first. A manifest can use the HARNESS_INFERENCE_* set instead, which is
why this is only what --harness writes, not the only thing that works.
Both `create` and `launch` share addAgentCreationFlags, so both are fixed
here. knownAgentAdapters already accepted both slugs, and
prepareOpenAISandboxStart already gates on the adapter, so `agent: codex`
creates no OpenAI session and expands no ${ENV_ID} / ${REMOTE_URL}; tests now
pin that.
agents: prepay billing awareness for harness-runtime
Resolve prepayment conflicts by keeping main's godo-backed GetConfig/GetStatus and adding harness-runtime's context-aware Get (with ErrNoBillingPermission). Pin godo to v1.210.0-beta.1 for pause_reason and related hosted-agents APIs. Co-authored-by: Cursor <cursoragent@cursor.com>
Create/launch unit tests were failing because the T&C notice called Account.Get (and would ListSessions) without gomock expectations. Co-authored-by: Cursor <cursoragent@cursor.com>
The prepay gate announces a pause on session.updated as well as run.paused, and often only there: run.paused needs a run to pause, so a session stopped between turns -- or one sitting idle waiting for a prompt -- has no run event to carry the reason. doctl rendered that event as a bare "session updated" and, during warm-up, folded it into the banner and dropped it altogether. The balance card added in 299856d was reachable from only one of the two paths the gate actually uses. What the user saw was a half-finished sentence, one muted line, four "Reconnecting...", and "Failed to reconnect to agent activity stream." -- a network diagnosis for a billing problem, with no mention of the balance that would fix it. - sessionUpdatedPayload parses status/pause_reason/changed_fields, accepting both the stream's short "paused" and the model's SESSION_STATUS_PAUSED. changed_fields is what keeps the card from reprinting: every update while paused still carries status=paused, so only one that changed status or pause_reason is news. The field is optional, and a paused status is then all we have -- announcing is the safer default there, since a repeated card is noise but a silent pause is the bug this fixes. - The card is identical whichever event carried the reason. The user is looking at one stalled agent, and the run/session distinction that matters to the API is not one they can act on differently. - drainStream now reports what the connection learned about pausing, so the reconnect loop can explain the silence instead of blaming the connection. Knowing nothing is kept distinct from knowing the session is not paused: a reconnect that drops before delivering an event carries no news, and collapsing the two would make a paused session look healthy again after one quiet retry. Reconnecting continues either way, because a top-off in another window brings the session back and the stream with it. - The give-up message stays a fixed string rather than the balance-enriched card. It runs on the stream goroutine, where a billing round-trip would stall the one message that explains the silence; `balance` has the number. Two smaller fixes fall out of routing a pause through one path: it now stops the spinner, which was left turning over a session that had stopped, and a gate pause reported on both run.paused and session.updated prints one card rather than two.
attachToSession previously opened a single connection whose catching_up
(history) and live phases shared the wire, spliced apart server-side, with
stdin read from the moment the goroutine was launched -- before any of that
history had rendered.
ui-aquarium's /events consumer (useSessionHistoryPager + useStreamSession)
instead does this as two sequential calls: a finite replay_only history read
first, then a live attach anchored strictly after that read's newest event,
with input held until both settle. Give doctl the same shape:
replayHistoryBeforeAttach reads history to completion synchronously, on the
same goroutine that calls streamWithReconnect/runAttach right after, so:
- stdin is never read until history has fully rendered, closing off
MARSOHS-1026's user-visible symptom (a resent/duplicate turn rendering
live, indistinguishable from new output, interleaved with whatever the
user just typed) as a client-side defense independent of OHP's own
ingest-side dedupe.
- the live stream opens with ReplayFrom already seeded to the last history
event's id (drainStream's existing cursor.set(ev.EventID) does this for
free), so there is no overlap window left for OHP to splice.
Best-effort: a terminal error is reported once and returns, matching
streamWithReconnect's own classifyStreamError handling; a transient error
falls through silently to the live stream's own catching_up window, which is
the pre-existing behavior for that failure mode.
…split agents: stop mapping --harness codex onto the codex-agentapi adapter
…stream harness-runtime: explain a stream that stops on a prepay pause
…seam harness-runtime: replay history before opening the live attach
Every harness-runtime verb that registers a displayer advertises --format and --no-header in its own --help, but the runners only consulted Output == "json" before choosing a renderer. In text mode the styled card was printed regardless, so `checkpoint list --format CheckpointID,Label` returned the card and `SID=$(... show <session> --format SessionID --no-header)` captured the whole rendered box, borders and newlines included. The command still exited 0, so the first sign of trouble was a malformed URL or a mis-parsed field downstream. Route the choice through one predicate, agentStructuredOutput, which is true for -o json and for an explicit --format / --no-header. The default output of every verb is unchanged; only a caller who named columns gets them, and a bad column name now fails at the doctl call instead of being ignored. Decoration that shared stdout with the card follows the same rule: the page token, config create advisories, the discovered-manifest notice, and create's progress spinner move aside (to stderr, or off) when stdout is a table someone is about to parse. exec, files cat, and prompt keep passing their payload through byte for byte by default, and reach for the displayer only when asked. The one verb that cannot answer with columns alone is triggers create, whose one-time webhook secret is not one of the trigger's columns: under --format the row goes to stdout and the secret to stderr, so it is neither captured nor lost.
Session update landed on feat/agents-subcommands after this change and still keyed off Output == "json", so --format/--no-header would have kept printing the card. Co-authored-by: Cursor <cursoragent@cursor.com>
…-flags harness-runtime: honor --format and --no-header, not just -o json
Co-authored-by: Cursor <cursoragent@cursor.com>
anup-deka
approved these changes
Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.