Skip to content

Release v2.40.0 - #3629

Merged
atomantic merged 61 commits into
releasefrom
main
Aug 8, 2026
Merged

Release v2.40.0#3629
atomantic merged 61 commits into
releasefrom
main

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Release v2.40.0

Released: 2026-08-08

Highlights

Coding agents

  • Cursor's agent CLI ships as a first-class provider in both headless and interactive flavors, disabled by default and selectable anywhere a process provider is. "Refresh Models" pulls your account's real catalog from the binary rather than the shipped 27-model slice.
  • Refreshing a provider's model list is now the server's decision instead of a client-side guess at it, so the button appears exactly where it works — and when a refresh fails, the toast finally says why.
  • A provider whose display name mentions another vendor is no longer refreshed against that vendor's catalog; the binary it actually runs decides.
  • Adding a new coding-agent vendor is one registry row instead of eight coordinated edits across five files, with a test that walks every shipped provider through each launch step so a missed one fails in CI rather than in a dead agent session.

Agent reliability

  • A provider that goes down mid-queue no longer takes every waiting task with it — provider-attributable failures now sideline that provider for a cooldown scaled to the cause, so retries land on a fallback instead of repeating the same three-second death.
  • Grok and Kimi agents actually start now, and an agent that fails to spawn reports the real reason immediately instead of sitting in "initializing" until a sweeper calls it a crash.
  • Pull requests opened under a "merge when CI is green" policy get merged again; the watch no longer depends on an optional scheduled task that ships disabled.

Code review

  • Every reviewer row can pin a reasoning-effort tier alongside its model, offering only the tiers that reviewer's CLI actually accepts.
  • A configured reviewer whose CLI isn't installed on this machine is flagged before the run instead of after the PR is already open — and the Antigravity reviewer, which was being invoked under a command name that exists nowhere, no longer silently degrades into a self-review.

Brain & Comms

  • Pasting a GitHub repo into Brain capture can hand the clone to an agent: opt in to a read-only malware scan, a clean-room "study this for ideas" run that files what it finds, or both. Queued scans are now visible on the link they're scanning.
  • Comms gains an X page with visibility and reach diagnostics plus review-gated compose handoffs — no stored credentials, no automatic posting.
  • Stacker News triage is newest-first, capped per sync, and every item carries explicit reply / zap / moderation / analysis actions behind the existing human review gate.

Fit and finish

  • The claim-issue author gate gains a "Me + collaborators" setting between "Filed by me only" and "Any author".
  • The series list is readable on a phone again, and LoRA training dataset image labels no longer wash out over bright photos or collide with each other.
  • The Antigravity Usage card parses quota again after agy 1.1.x renamed its limit rows.

Code Review Defaults

  • Each code reviewer can now be pinned to a reasoning-effort tier. The reviewer table already let you pick which model a reviewer runs; how hard that model thinks was stuck at whatever its CLI defaults to, so the only way to buy a more careful review (or a cheaper, faster one) was to change the model. Every reviewer row now has an Effort column alongside Model, on the AI Providers → Code Review Defaults panel, the task form, the CoS schedule config, and the /do:next run drawer. Each row offers only the tiers that reviewer actually accepts — Codex's minimalultra, Claude's lowmax, Antigravity's low/medium/high, and low/medium/high for the local LM Studio / Ollama reviewers — so the picker can't offer a level the CLI rejects, and Copilot, Grok, and @username reviewers show no control at all because they have no such knob. Because slashdo's --review-with grammar has no effort suffix, the pinned tier travels on the invocation instead: as codex -c model_reasoning_effort=high / claude --effort high / agy --effort low when an agent drives the reviewer CLI, and as an effort field on the local review request when PortOS runs it. Leaving a row unset keeps that reviewer's own default, exactly as before.
  • [issue-3606] A Review Loop reviewer whose CLI isn't installed is now flagged up front. Previously the only way to find out a configured reviewer (Claude, Antigravity, Codex, or Grok) couldn't actually run was to wait for the review loop itself to discover its command was missing — after PortOS had already opened the PR and spent an agent run. The Code Review Defaults panel, task form, schedule config, and /do:next run drawer now show a "not installed" badge next to any reviewer whose CLI binary isn't found on this machine. It's informational only — the reviewer stays selectable, since a federated peer machine (or a later install) may still be able to run it.

Cursor Agent provider

  • [issue-3615] "Refresh Models" now works for Cursor, and pulls your account's real catalog. The Cursor providers shipped with a hand-curated 27-model slice, and the Refresh button was hidden for them because nothing on the server could answer — so the only way to reach a model Cursor offers you but PortOS didn't list was to type the id into the provider's model list yourself. Refresh now asks the binary directly (cursor-agent models) and stores everything it reports: 177 ids on a current account, including the -fast priority-compute twin of most tiers, and Cursor's own auto router at the top. Because that catalog varies by account, plan, and binary version, it's the only authoritative answer — which is also why a refresh that can't reach cursor-agent (not installed, or not on the server's PATH) reports the failure and leaves your existing list untouched, rather than quietly re-saving the shipped 27 and telling you it refreshed. The button appears for both the CLI and TUI providers, since --model applies to interactive sessions too.
  • A failed "Refresh Models" now tells you what actually went wrong. Every refresh failure — any provider, not just Cursor — used to surface as "Provider not found or not an API type", which is simply false when the provider exists and its type is fine; the real reason (the binary isn't on the server's PATH, the endpoint timed out, that CLI has no catalog to fetch) only ever reached the server log. The toast now carries the actual message. A model list that comes back legitimately empty still saves as empty — an empty answer and a failed one stay distinguishable, which is the whole reason a failed probe refuses to fall back to the shipped list.
  • A provider whose name mentions another vendor is no longer refreshed against the wrong catalog. Refresh picked its fetcher by checking the display name for vendor words before checking which binary the provider actually runs. So naming a Cursor provider "Cursor Claude Opus", or an Antigravity provider "Antigravity Claude Sonnet 4.6" — the natural name for a second provider pinned to that model, and Antigravity's own catalog does list Claude models — silently replaced its model list with Anthropic's, ids those tools reject. In the Antigravity case it also dropped the "use agy's own configured default" entry while the provider was still set to it, which blanked the model dropdown and read as though nothing was selected. The binary a provider runs now decides, and only falls back to the name when no binary matches.

Series Pipeline

  • The series list is readable on a phone again. Each row put the series' logline and the row's controls — the sync badge, share, sync-to-peer, and delete — side by side in one line. Those controls won't compress below their own width, so on a narrow screen they took the row and left the logline a column about one word wide, turning a two-line premise into a thirty-line ribbon that pushed the next series off the screen entirely. The controls now drop to their own line beneath the text on small screens, so the logline gets the card's full width; on a tablet or desktop the row is unchanged.

Added

  • Cursor's agent CLI ships as a provider, in both CLI and TUI flavors. cursor-agent is a harness-capable coding agent, but PortOS had no entry for it — using it meant hand-rolling a custom provider and guessing at its flags. It now ships as cursor-cli (headless one-shot) and cursor-tui (interactive PTY), mirroring the Codex/Antigravity/Grok/Kimi pairs. Both ship disabled, so nothing changes until you enable them, and once enabled they're selectable anywhere a process provider is — CoS agents, runners, pipeline stages, TUI sessions. The headless path runs cursor-agent --print with the prompt on stdin; the TUI path runs the interactive agent. Both inject --force, which is doing more work than the name suggests: besides auto-approving tool calls, it clears Cursor's workspace-trust gate, and without it cursor-agent prints "Workspace Trust Required" and exits — which in a fresh CoS worktree is every single run. The model list is a 27-entry slice of the account catalog (Composer, Claude, GPT, Gemini families), each id verified against the live binary; the default is Cursor's own auto router, so unlike Grok/Kimi/Antigravity no configured-default sentinel was needed. Cursor bakes reasoning effort into the model id (…-thinking-high, …-xhigh, …-max) rather than exposing a flag, so no Effort control is offered for it. Existing installs pick the pair up on restart via migration 231-cursor-providers. If your account offers a model outside the shipped 27 (the full catalog runs to 177 ids, most of them -fast twins), "Refresh Models" pulls the whole list from the binary — see above.

  • X account diagnostics and review-gated compose handoffs. Comms now has an X page that stores bounded public snapshots, checks profile/search visibility and post reach through the managed browser, captures visible metrics, and routes drafts through Review Hub before opening X for a final manual submission. It does not store X credentials, post automatically, or claim that ordinary low reach proves a shadowban.

  • Pasting a GitHub repo into Brain capture can now hand it to an agent. A bare repo URL captured in the Brain has always been saved as a link and cloned in the background, but nothing said so and nothing followed. Both capture boxes (the Quick Capture dashboard widget and the Brain → Inbox form) now recognize a repo URL, name the owner/repo that will be cloned, and offer two opt-in checkboxes: Scan for malware runs the same read-only static audit as the Links tab's Scan button, ending in a CLEAN / CAUTION / DANGEROUS report you open from the link; Study for PortOS ideas queues an agent that reads the clone for implementation ideas worth adopting and files the good ones as issues (or PLAN.md items, whichever tracker this install is configured for). Both preferences stick, so "always scan what I clone" is a one-time tick. The agents only start once the clone actually lands — a failed clone queues nothing — and the study run is clean-room by contract: it never executes anything from the clone, never edits it, reads its LICENSE before proposing anything, and describes techniques in its own words rather than copying code. Re-pasting a repo you already saved re-queues nothing, since there's no new clone to read.

  • Stacker News triage is newest-first and action-oriented. Each community sync is capped to a configurable newest-item count (30 by default), preserves source timestamps for stable ordering, captures direct image posts for Ollama vision analysis, and gives every newest item explicit reply, zap-handoff, moderation-handoff, and analysis actions behind the existing human review gate.

  • The claim-issue author gate has a "Me + collaborators" setting. Until now the only way to let an agent work on a teammate's issue was "Any author", which also opens the queue to drive-by issues from strangers — so a repo with any real collaborators had to choose between ignoring their issues and claiming everyone's. The new option trusts you plus every account the forge already grants access to (GitHub collaborators, GitLab project members including group-inherited ones), which is the same set that could push the code anyway. It sits between "Filed by me only" (still the default) and "Any author" in the CoS → Schedule author-filter dropdown, in the per-app override, and in the /do:next run drawer, and applies to claim-issue and claim-work alike. Neither CLI's --author flag accepts more than one account, so the gate is applied to the issue listing rather than pushed into the query — both the work detector and the agent prompt resolve the trusted set first, then filter. If that lookup fails (a token without push access can't read the collaborator list), PortOS reports it and retries rather than quietly narrowing back to your own issues or widening to everybody — and because that failure repeats identically on every retry, both the run drawer and the "Run now" toast name the filter and the way out instead of the generic "try again shortly".

Changed

  • [issue-3618] A newly added coding-agent provider can no longer silently miss one of the steps that launch its interactive session. Adding a vendor means teaching several independent launch steps about it, and forgetting one is invisible from the outside: the provider appears in the picker and spawns, then either runs the wrong binary or comes up without the flags that let it work unattended and stalls on its first approval prompt with nobody there to answer. A test now walks every provider in the shipped catalog through those steps, so a vendor any of them doesn't recognize fails in CI instead of in a dead agent session. No shipped provider's behavior changed.

Documentation

  • Updated documentation index and feature references. Added missing feature deep dive links for Quota-burn automation (QUOTA-BURN.md), Three.js procedural 3D models (THREEJS_MODELS.md), Stacker News stewardship (stacker-news.md), and the PortDeck native companion app API contract (COMPANION_APP_API.md) across README.md and docs/README.md. Improved inline JSDoc comments for client and server utility modules.

Fixed

  • A provider that goes down mid-queue no longer takes every waiting task with it. When an agent run failed because the provider failed, PortOS sidelined that provider only if the failure was a usage or rate limit. Every other provider-side failure — an expired credential, a refused account, an Antigravity install still waiting on Google to verify eligibility — left it marked healthy, so the next task off the queue picked the same provider and died the same way, and the next, and the next. The most visible version: agy reporting "We're finishing verifying your account eligibility… please try again shortly" would burn down a whole queue of tasks in about a minute, each in three seconds, with nothing to show for it. A failure the run can attribute to the provider itself now sidelines it for a cooldown scaled to the cause (minutes for a transient block, longer for something needing a human), which is what makes the retries land on a fallback provider instead of repeating the failure; the sideline expires on its own, so the provider comes back without anyone visiting settings. Failures that describe the request rather than the provider — a bad model id, a content refusal, an off-shape response — still never sideline anything, since the provider's other models are fine. And a failure diagnosed only by keyword-matching the agent's own transcript can't sideline a provider either; that takes a structured signal from the provider itself. One shared cooldown policy now answers this for both agent runs and one-shot prompts, which previously disagreed about how long the same failure was worth.
  • The Antigravity account-verification banner no longer blocks the task it interrupts. PortOS recognized the banner and failed the run promptly rather than waiting out the idle reaper, but filed it as something a human had to fix — which blocked the task outright, over a condition whose own error message says it clears itself. It now takes the ordinary retry path, which (with the provider sidelined per the entry above) lands on a fallback rather than re-reading the same banner three seconds later.

atomantic and others added 30 commits August 5, 2026 22:21
…atcher task

`queuePendingMerge` is written from the agent-completion path whenever a
merge-on-green PR is opened, but the only drain was inside
`resolvePrWatcherBlock` — which runs exclusively when an app's `pr-watcher`
scheduled task fires. That task ships disabled (its default prompt is a
review-and-comment agent), so on the common setup PortOS queued PRs into a
list nothing ever read: green PRs sat open forever at `ticks: 0`, and even
the bounded MAX_PENDING_MERGE_TICKS escape hatch never fired. Observed with
PortOS #3270 (queued Aug 1) and #3602 both stranded fully green.

Adds `sweepPendingMergePrs()` and calls it from `evaluateTasks`, before the
agent-slot gate (a deterministic merge claims no lane, so a full roster must
not wedge the merge queue) and gated on `!paused` to match the autonomous
tiers. The pr-watcher path now owns PR discovery only.
…irst match

The guard located the pause gate with a plain `.search(/if (!paused)/)`, which
assumes `evaluateTasks` has exactly one. The pending-merge sweep legitimately
adds an earlier one — it claims no agent lane, so it must run before the slot
gate — and the guard then read that as "the tier gate moved to the top" and
failed on the very shape it was meant to permit.

Anchor on the last pause gate preceding spawnPriority1UserTasks instead, which
is what the assertion actually means. Verified by probe: stripping the tier
gate's `!paused` still fails the guard.
The author filter previously jumped straight from "only issues I filed" to
"any author", so a repo with real collaborators had to choose between
ignoring teammates' issues and claiming drive-by issues from strangers. The
new value trusts the accounts the forge already grants repo/project access —
the same set that could push the code anyway.

Neither `gh issue list --author` nor glab's accepts more than one account, so
the gate is applied to the listing rather than pushed into the query: the
detector resolves the trusted set (self + GitHub collaborators / GitLab
members/all, so group-inherited access counts) and filters the normalized
issues; the agent prompt spells out the same two-step recipe. An unresolvable
author is dropped — the gate is a security boundary, so "can't tell who filed
it" resolves to "not trusted".

A failed member lookup goes transient with a remedy rather than silently
narrowing to self or widening to everyone. Since that failure (a token without
push access) repeats identically forever, the remedy rides the existing
transient-verdict channel so both the run drawer and the on-demand toast print
the way out instead of "try again shortly" — checkGhHealth can't see a
per-repo permission gap, only global auth.
add a "me + collaborators" option to the claim-issue author gate
…its slug

The Review Loop stores the reviewer as `antigravity`, but the shipped
executable is `agy` — no `antigravity` command exists on any PATH. Every
prompt that told an agent to invoke it named the slug, so a review-loop
follow-up ran `command -v antigravity`, found nothing, reported "the only
configured reviewer is antigravity … isn't available in this environment",
substituted its own self-review, and merged the PR.

Adds REVIEWER_CLI_BINARIES / reviewerCliBinary() / describeReviewerCli() to
cosValidation as the one slug -> command mapping, and resolves through it
everywhere a prompt names a reviewer as something to run. The multi-reviewer
bullet and the CLI Reviewer Procedure heading now list the reviewers this
loop actually configured (they were a fixed "codex / antigravity / claude /
grok" roster) named by binary, with an explicit slug -> command line for any
reviewer whose command differs from its slug.

Also closes the half that did the damage: a reviewer whose binary is missing
is UNSATISFIED, not clean. Both the dynamic follow-up prompt and the four
versioned claim/plan prompts now forbid substituting a self-review and
forbid the merge, directing the agent to comment on the PR and stop.

The versioned prompts carry the cross-install contract: PROMPT_VERSIONS
bumped for plan-task/claim-issue/claim-issue-gitlab/claim-issue-jira with
each outgoing body preserved verbatim in PREVIOUS_DEFAULT_PROMPTS and the
integrity snapshot regenerated, so other installs auto-upgrade.

Follow-up filed as #3606 (detect a missing reviewer binary server-side at
configuration time, rather than leaving it to the spawned agent).
name agent prompts the antigravity reviewer's real binary (agy), not its slug
The source chip and view label were 20%-opacity tints over an arbitrary
photo, and pinned to opposite corners so a long view label collided with
the source chip in a narrow column. Both now sit on their own near-opaque
plate in one wrapping row.
make LoRA dataset image labels readable over any training image
A Review Loop reviewer whose CLI isn't installed was only discovered by the
spawned follow-up agent at review time, after the PR was already open. Adds a
shared commandExists() helper (consolidating two private copies in
localLlm.js/ollamaManager.js), a TTL-cached getReviewerCliInstalled() probe
in codeReview.js, and an `installed` map on GET /api/code-review/defaults.
ReviewerPicker surfaces a "not installed" badge, warn-only per the issue's
decision — a missing binary never filters or disables a reviewer.
agy renamed Weekly/Five Hour Limit to … Limit Remaining, so the
Usage page Antigravity card scraped the panel but returned zero meters.
fix: parse agy 1.1.x Limit Remaining usage rows
Selecting the grok-tui provider produced an agent that never opened a
shell. The runner's command allowlist carries only the CLIs it may
spawn, and `grok`/`kimi` were never added even though both ship as
providers in data.reference/providers.json — so /spawn-tui 400'd, no PTY
was ever created, and the run died without a diagnosis.

The reporting around it was the worse half. A rejected spawn threw out
of the spawner into subAgentSpawner's task:ready listener, which only
logs, so the error never reached the agent record:

- TUI: the record sat `initializing` until the zombie reaper finalized
  it ~1min later as "Agent process terminated unexpectedly".
- runner-CLI: worse — the stale `runnerAgents` entry keeps
  isAgentOwnedLocally() true, so every sweep skips the record while the
  3s init timer flips it to `working`. It stays there until the server
  restarts.

Both arms now finalize with the runner's actual error. The runner names
the offending command instead of one opaque "missing or invalid fields",
and an agent reaped with no pid ever recorded is reported as never
having started rather than as a crash.

A parity test pins the allowlist against both shipped provider catalogs
so the next provider added without an entry fails in CI, not in a silent
agent. Note the allowlist stays hand-curated on purpose: data/providers.json
is user-writable at runtime with an unconstrained command string, so
deriving it would let a config write choose the exec target. PLAN.md
captures the remaining gap for user-created providers.

The runner runs as its own pm2 process (portos-cos) — it must restart to
pick up the new allowlist.
allowlist grok/kimi in the CoS runner and stop losing spawn errors
The reviewer table already chose which model each reviewer runs; how hard
that model thinks was stuck at its CLI default, so buying a more careful
(or cheaper) review meant swapping the model. Every reviewer row now has
an Effort column next to Model, on the AI Providers panel, the task form,
the CoS schedule config, and the /do:next run drawer.

Each row offers only the tiers that reviewer accepts — the ladders derive
from effortLevelsForProvider rather than being restated, so `agy` never
shows `max` and copilot/grok/@username rows show no control at all. An
out-of-ladder value is dropped rather than clamped: reviewing at a
different effort than the row displays is worse than falling back.

slashdo's --review-with grammar has no effort suffix, so the pin travels
on the invocation instead: `codex -c model_reasoning_effort=high` /
`claude --effort high` / `agy --effort low` when an agent drives the
reviewer CLI, and an `effort` field on the local review request when
PortOS runs it.

Also collapses the three token-keyed reviewer-pin normalizers and their
resolvers (max-rounds, models, efforts) onto shared factories, and fixes
a case-folding divergence where pickCodeReviewDefaults and
resolveReviewLoopOptions disagreed on a stored "High".
- The challenge auto-recheck ran its re-review at the model's default
  effort while threading the model from the same defaults, so the verdict
  deciding upheld-vs-escalated came from a weaker pass than configured.
- reviewerEffortArgs delegated straight to buildEffortArgs, which clamps
  an out-of-ladder value. It is reached with raw task metadata, so a
  stale `antigravity: max` emitted `agy --effort high` while the picker
  rendered the same pin as unsupported. Normalize first: drop, don't clamp.
- The local-reviewer jq hint hardcoded `model: "…"`, so an effort-only run
  told the agent to send a literal ellipsis — and the route prefers a
  truthy body.model over the configured default, turning the review into a
  model-not-found error. Build the example from the pinned keys.
- Cover the effort roster at the cleanup→follow-up hop: it differs from
  the model roster (agy takes an effort but no pinnable model), and the
  wrong one would drop every agy pin with the suite still green.
Adding the effort pin made three existing seams visible as copy-paste, and
this pulls each up to the mechanism the pins already share.

- The three token-keyed pins (caps / model / effort) were hand-copied as a
  guard-normalize-assign block per pin in BOTH persist paths, six blocks
  across two files. A missed block is silent — the pin is configured,
  persisted and displayed but never reaches the reviewer, with nothing
  failing. They now iterate one exported KEYED_REVIEWER_PINS table.
- The prompt builder resolved the same six reviewer values at three spawn
  paths. Bundled into resolveReviewerConfig(), so a fourth pin kind reaches
  all three at once instead of being a three-site edit.
- The client's scalar<->map adapters were two hand-copied loop pairs
  differing only in roster, key suffix, and single-value validator — the
  exact three axes the server's keyedReviewerPinNormalizer already
  parameterizes. Now one pinScalarAdapters factory.
- ReviewerPicker's Effort and Model cells each open-coded the same closed-list
  select, including the non-obvious "keep a stored out-of-list value as an
  <option> or the select reads as unset" contract. One renderPinSelect, with
  the accent passed as a COMPLETE class string (an interpolated
  `text-${accent}` would never reach the Tailwind build).

Two fixes fall out of the same pass:

- POST /api/code-review/local validated `effort` against a flat union of
  every local level rather than the ladder for the requested backend. The two
  backends are separate identities in REVIEWER_EFFORT_LEVELS, so once their
  ladders diverge the route would accept a level valid only for the other
  one and the service's own normalizer would drop it — a 200 with the effort
  silently ignored instead of a 400. Now checked through the same normalizer
  the service uses, with tests for both directions.
- The local-LLM jq hint read a Set that the pin note's .map() filled as a
  side effect, so hoisting either line past the other would quietly emit a
  body with no pinned keys. Both strings now derive from one array.

Also drops a redundant re-normalize in the challenge re-check
(pickCodeReviewDefaults already validated the effort scalars), and renames
reviewerModelEntries/Note to reviewerPin* — they carry effort flags too.
The parity test that pins the client's effort ladders against the server's
imported `components/cos/constants.js`, which imports `lucide-react` for its
tab and agent-state icons. That module isn't installed in the server CI job,
so the drift gate failed with ERR_MODULE_NOT_FOUND — it only passed locally
because a dev worktree has the client's node_modules on hand.

The icons have nothing to do with reviewer pins, so the pin vocabularies move
to `client/src/lib/reviewerPins.js`, a leaf with no client-only dependency
(its only import chain is utils/providers -> utils/formatters, both
dependency-free). `constants.js` re-exports every name, so no client import
changes. Verified the real CI condition by running the suite with
client/node_modules moved aside: 24/24 pass.

Also stops rejecting a blank `effort` on POST /api/code-review/local. The
refine only short-circuited on `undefined`, so `""` fell through to the
ladder check and 400'd the whole review — contradicting the route's own
header ("when omitted or empty we fall back") and leaving the `body.effort ||
configured` fallback on the next line unreachable. It was also asymmetric
with the sibling `model` field, where `""` has always fallen back. Any caller
templating the body with a possibly-blank effort slot hit it.

Drops three now-unused imports left behind by the resolveReviewerConfig
bundling (they survive only in comments).
Pin a reasoning-effort tier per code reviewer
Adds `cursor-cli` (headless `cursor-agent --print`, prompt on stdin) and
`cursor-tui` (interactive PTY), both disabled by default. Ships a 27-model
catalog curated from the live binary; `auto` is Cursor's own router, so no
configured-default sentinel is needed. `--force` clears Cursor's workspace-trust
gate as well as auto-approving tools — without it a headless run exits before
doing any work.

Also inverts the Bedrock model-id rewrite from opt-out to opt-in on the launch
command, and collapses the two divergent TUI model-injection ladders into a
shared resolveInjectedTuiModel, so a non-Claude-Code TUI provider can no longer
have its model id rewritten on a Bedrock box.
- README catalog listed four cursor.js exports that no longer exist
- appendModelArgs honored a user-baked --model pin only for opencode, so a
  pinned claude/codex/cursor TUI spawned a duplicate flag and lost the pin
- the shared-helper refactor defeated agentTuiSpawning.test's Bedrock mock
  (internal call bypasses vi.mock); pin the env instead and drop dead stubs
- --trust is not an approval posture; suppressing --force on it stalled an
  unattended run on the first tool prompt
- replace two vacuous migration "deep-copy" tests with catalog/tier assertions
- move supportsModelRefresh into utils/providers.js so it is testable
…mports

- --auto-review grants approval but does NOT clear cursor's trust gate, so
  pinning it suppressed --force and every run exited on "Workspace Trust
  Required". Trust and approval are now checked as the two independent gates
  they are; --force covers both, --trust covers only the gate left uncovered.
- supportsModelRefresh claimed to mirror the server dispatch but reported true
  for codex and kimi-cli (both shipped), whose Refresh button 404'd on click,
  and matched the exact command string so a path-configured gemini/claude was
  wrongly hidden. Rewritten as the allowlist the server actually implements.
- drop imports and a vi.mock stub left inert by the shared-helper extraction
- changelog the pin-wins and refresh-visibility behavior changes
The previous fix over-corrected: it matched claude/gemini on a command
BASENAME while the server compares the raw string, so a renamed,
path-configured binary would show a Refresh button the server still refuses
— the same 404 in a new place — and a test asserted that wrong contract.

Mirror the server's actual two-level shape instead: route on provider.type
first, then apply the per-vendor tests exactly as it does, including that its
tui arm (unlike its cli arm) never consults the provider name.

Adds a seed-driven parity test that walks every shipped provider and compares
against a transcription of the server dispatch, so the next provider added
without a matching client branch fails here rather than in the UI.
feat: ship Cursor Agent as a CLI and TUI provider
atomantic and others added 29 commits August 7, 2026 11:52
…olding

/simplify pass over the cursor fetcher, applying findings the reuse and
altitude reviews both raised:

- _fetchCursorModels was a near-verbatim clone of _fetchAntigravityModels;
  everything but the parse step was identical, down to both error strings.
  Extracted _execCliModelList(provider, defaultBin, parse), which now owns the
  spawn conventions (Windows-safe invoke, 15s cap, envVars merge, stdin close)
  and the throw-on-failed/empty posture. Both fetchers delegate; antigravity
  keeps its sentinel prepend, cursor prepends nothing. No behavior change.
- Moved the agy stdout filter to internal/antigravity.js as
  parseAntigravityModelList, next to parseCursorModelList, so it is directly
  unit-testable instead of reachable only by spawning a fake binary.
- Added internal/commandBasename.js: the basename normalizer was inlined twice
  inside internal/. The toolkit still may not import server/lib/providerModels
  (self-containment), but one copy inside the boundary is enough.
- Dropped CURSOR_CLI_ID, a dead export whose only consumer was a test asserting
  it equalled its own literal.
- Replaced that tautological constants test with a real drift guard: the test
  (which may cross the vendoring boundary, unlike the source) now compares the
  toolkit copy against server/lib/cursor.js on the binary name and every
  command-matching case.
- Test cruft: unused exitCode param on writeFakeCursor, dynamic fs/promises
  import for chmod when the file already imports from it at the top.
- Trimmed the duplicated throw-posture rationale from both fetcher JSDocs; it
  lives once on the shared helper.

Filed #3620 for the deeper altitude finding both reviews raised and agreed was
out of scope here: collapsing the vendor dispatch into one table and surfacing
it to the client as a canRefreshModels payload field, replacing the
hand-transcribed client mirror.
…ing arms

Found by the pre-PR review gate. The command-keyed cursor branch sat below
_refreshCLIProviderModels' name-substring branches, so a cursor provider the
user renamed "Cursor Claude Opus" (or "Cursor Antigravity") matched
name.includes('claude') first and had that vendor's static catalog persisted
onto it -- 15 Anthropic ids cursor-agent rejects, written silently, because
the client's gate is command-keyed and happily showed the Refresh button.

An exact command match is a stronger identity signal than a display-name
substring, so it must win; this is the same reasoning that already puts the
ollama check ahead of the generic claude one. Moved the cursor arm above
claude/antigravity/gemini, mirrored the order on the client, and pinned it
with a regression test that refreshes cursor providers named for three other
vendors.
…l refresh errors

Four findings from the pre-PR review pass.

1. (medium) The antigravity COMMAND test still sat fused to its name test below
   the claude name test -- the same defect the previous commit fixed for cursor,
   one branch over. An `agy` provider named "Antigravity Claude Sonnet 4.6"
   (a natural name; agy's own catalog carries claude ids) got Anthropic's static
   list persisted onto it AND lost the antigravity-configured-default sentinel
   while defaultModel still pointed at it, blanking the model select. Split the
   command test out and hoisted it; the name test stays below, so
   "Claude via Antigravity" + command claude still reaches Anthropic.

2. (medium) The `id === CURSOR_TUI_ID` clause on the TUI arm was unpinned --
   every test reached that arm via the command, so deleting the clause kept the
   suite green while a shipped cursor-tui repointed at a wrapper silently lost
   its refresh. Added a wrapper-command case per side (antigravity-tui too);
   verified by mutation -- removing either clause now fails a test.

3. (low) parseAntigravityModelList had no success-path coverage, contradicting
   the comment I added claiming it now gets direct unit tests. Added
   internal/antigravity.test.js: real output, prose rejection, sentinel
   exclusion, CRLF, punctuation, and an upstream-parity check.

4. (low) A failed probe surfaced to the user as "Provider not found or not an
   API type" -- false, and it buried the real cause in the server log, defeating
   the point of _execCliModelList refusing to fall back. refreshProviderModels
   now rethrows (502; 400 for "no fetcher for this CLI") instead of collapsing
   to null, so the toast carries the actual message. `null` keeps its one
   meaning: no refreshable branch matched. A legitimately EMPTY list still
   persists, so empty and failed stay distinguishable. localLlm's fan-out
   already caught per provider, so it is unaffected.
…atus codes

Round 2 verified all four round-1 fixes by mutation and raised three low items.

1. Three dispatch clauses were deletable with the suite staying green -- one of
   them (`name.includes('antigravity')`) created by round 1's command/name
   split. Left unpinned, a cleanup deletes it as redundant while the client's
   mirrored name test keeps offering the button, and every click 400s -- the
   exact client/server drift the parity apparatus exists to prevent. Added the
   three missing cases (server: antigravity-NAMED with an unrelated command;
   server: antigravity-tui repointed at a wrapper; client: agy-commanded under
   an unrelated name). Verified by mutation: each now fails exactly one test.
   The round-1 commit message claimed the antigravity-tui case shipped on both
   sides -- it was client-only. It is on both sides now.

2. The unsupported-TUI case still fell out as null and rendered as
   "404 Provider not found or not an API type" -- the same false message the
   rethrow set out to stop showing, just on the arm I did not fix. It now throws
   the same 400 as the CLI fall-through. With that, `null` has exactly ONE
   meaning out of refreshProviderModels (no such provider), so the route's 404
   is narrowed to a plain "Provider not found" instead of guessing at a cause.

3. The SSRF endpoint guard and the missing-Google-key check inherited the 502
   default. Neither is an upstream failure -- both are the user's to fix -- and
   BAD_GATEWAY reads as "vendor is down, retry", so the user retries instead.
   Worse, any status >= 500 makes errorHandler log a full stack, so a plain
   config error produced stack spam pointing at the wrapper rather than the
   cause. Both tagged 400 at the throw; the `||` guard preserves it.

Also added the route-level tests the reviewer noted were missing: refresh-models
502/400/404 asserted at the HTTP boundary through PortOS's real asyncHandler and
errorMiddleware, including that no stack reaches the response body. That seam is
where the whole fix lives and it had only service-layer coverage.

Plus two stale comments the rethrow invalidated (the _execCliModelList docblock
still said it returns null; VoiceTab's said the route 404s on no models).
feat([issue-3615]): fetch Cursor's model catalog live via `cursor-agent models`
…I dispatch points

Adding a coding-agent vendor means teaching ~8 open-coded dispatch branches
about it, and a missed one is silent: the provider spawns, then runs the wrong
binary or comes up without its unattended-run posture and stalls on an approval
prompt. The two model-injection sites had already drifted this way before
resolveInjectedTuiModel collapsed them.

Adds a discovery test that derives its expectations from
data.reference/providers.json rather than a hand-transcribed vendor list:
inferTuiCommand must resolve every seeded provider id to that provider's own
command; every distinct seeded TUI command must reach an applyCommandDefaults
arm that injects a posture, with the two commands whose posture arrives by
another channel (claude via seed args, opencode via OPENCODE_CONFIG_CONTENT)
recorded explicitly and re-checked against the seed so the exemption can't rot;
each arm must be idempotent over the seed args; and a blank-command provider
must build the same invocation as the fully-specified one.

Verified by planting an unknown vendor in the seed: three of the four checks
fail. No divergence found in the currently-shipped catalog.

Refs #3618
…in markers exactly

The catalog walk filtered out providers that declare no `command` — exactly
the shape `inferTuiCommand` exists to resolve, so a new vendor seeded that way
would have skipped every check. Every seeded TUI provider is now walked, with
the binary resolved through the same fallback the spawners use; the
inferTuiCommand parity assert stays scoped to providers that declare a command,
since there is nothing to compare against otherwise.

Also: the `args` posture marker is matched by exact argv membership rather
than a substring of the joined argv (a marker that prefixes a longer flag would
have matched anything), and the exemption map gained a documented channel-less
form so a future vendor that genuinely needs no posture records a reason
instead of inventing a marker string to satisfy the assertion.

Re-verified by planting both an unknown-command and a blank-command vendor in
the seed: 4 checks fail, including the blank-command one that previously
slipped through.

Refs #3618
…mparison

The blank-vs-explicit buildTuiInvocation check compared the seeded provider
as-is, so a provider that already ships a blank command compared blank against
blank. The right-hand side is now pinned to the resolved binary.

Also scopes the inferTuiCommand parity walk to the process-spawning provider
types, so a future non-process type that happens to carry a command field
can't fail it spuriously. CLI providers stay in scope deliberately —
resolveSlashdoStyle asks inferTuiCommand which binary will be spawned for
those too.

Refs #3618
…o a shared makeProviderSeedMigration factory

The up() body that ships a new data/providers.json entry was byte-identical
across 149/152/185/195/201/231, so hardening it meant a six-file sweep — and
none of them ever picked up the RESERVED_MAP_KEYS prototype-pollution guard
_lib.js already applies in its split-migration path. A def id of 'constructor'
reads truthy off any plain object, so the presence probe would treat it as
already installed forever.

Each migration keeps its own frozen defs literal (a migration is the historical
record of what it installed, so nothing reads data.reference at migration time);
only the shell moves. The six duplicated shell-behaviour tests collapse into one
suite against the factory, plus a case for the new guard; every per-migration
payload assertion still runs unchanged.

Closes #3617
…to one table and ship it as canRefreshModels

Adding a vendor to model refresh took four coordinated hand edits: the TUI
`else if` chain in `refreshProviderModels`, the CLI `if` chain in
`_refreshCLIProviderModels`, the client's `supportsModelRefresh` mirror, and a
SECOND transcription of the server dispatch inside the client parity test — so
that test only ever proved the mirror matched its own copy.

Both server arms now resolve from `internal/modelFetchers.js`, one row per
vendor. The three keying conventions that had accumulated as prose above each
branch become columns: `cliMatch` (strong — command/structural), `cliNameMatch`
(weak — display-name substring, consulted only after no command claims the
provider), `tuiMatch` (never consults the name). The route decorates every
provider-shaped response with the derived `canRefreshModels`, so the client
shrinks to `p?.canRefreshModels === true` and the hand-written mirror is gone.

Derived on read and decorated at the route, never in `getAllProviders()`, so it
cannot reach `saveProviders` and go stale against the table; a PUT that echoes
it back is stripped by `providerSchema.partial()`. PortOS's own sanitized GET
handlers shadow the toolkit's, so they decorate too — before redaction, since
the ollama row keys partly on `envVars.ANTHROPIC_BASE_URL`.

No shipped provider's button visibility changes (pinned by a frozen catalog
walk on both sides). The one behavior that does: a provider whose command is
literally `gemini` now refreshes as gemini even under a "Claude …" display
name — the same "exact command beats a name substring" rule the chain had
already grown for cursor and antigravity.

Closes #3620
Closes #3616
…nd re-export canRefreshModels from the service shim

The toolkit's `GET /samples` was the one provider-shaped response left
undecorated, while PortOS's shadowing handler already decorated it — so the
two disagreed about the same sample. And `server/services/providers.js`
re-exported `isOllamaBackedProvider` but not its new sibling, leaving hosts
that classify providers through the shim without the capability predicate.
The row laid the logline and its controls (sync badge, share, sync-to-peer,
delete) out as siblings in one flex line. Flex won't shrink those below their
min-content width, so on a phone they claimed the row and the flex-1 text
column collapsed to roughly one word per line — a two-line logline rendered as
a ~30-line ribbon. Group the controls and stack them under the content below
sm; sm+ keeps the existing side-by-side row.
The 4B/9B side-by-side that gates #2791 was started and paused at step
563/1200 of the 4B arm; the 9B arm never ran. Captures the method, the
step-0 control, the measured 9.4 s/step throughput, and the interim
observation that the shipped rank-32/lr-1e-4 defaults bind identity much
harder than the older 400-step rank-16 adapter that looked cleaner but
encoded the wrong subject.

Explicitly records that neither #2791 change is justified yet, so a later
reader does not mistake the partial data for the verdict.

Refs #2791
Run A (4B) restarted from checkpoint 299 via the resume endpoint —
confirmed training normally from step 300 toward 1200. Run B (9B) still
needs to run before the #2791 gate is satisfied.

Refs #2791
…erdict

Both arms (4B and 9B-8bit, matched params, 1200 steps) finished and were
visually compared at steps 0/300/600/900/1200. Verdict: stay as-is —
neither proposed default change is supported. 4B did not lose to 9B on
identity (the opposite, if anything, though on a single seed/dataset),
and step 600 was weaker on identity than step 900 in both arms, so
cutting steps to ~600 would ship a worse result than the current
default's own best checkpoint. Separately: both arms peaked at step 900
and regressed by step 1200, which doesn't call for a default change but
does point users at the existing checkpoint picker rather than assuming
the final step is best.

Closes #2791
…ROVIDER_VENDORS registry

Adding a coding-agent vendor meant editing ~8 open-coded dispatch branches
across 5 files, and two of them (the TUI model-injection sites in
tuiHandshake.js and agentTuiSpawning.js) had already drifted apart once
before being collapsed into resolveInjectedTuiModel.

Introduces server/lib/providerVendors.js: one PROVIDER_VENDORS row per
vendor (claude/codex/antigravity/opencode/grok/kimi/cursor, plus a
deliberately-incomplete legacy gemini-cli row), consumed by every dispatch
site that used to hand-roll its own vendor if-chain — inferTuiCommand,
applyCommandDefaults, prepareCliPrompt, buildCliArgs, buildCliSpawnConfig,
and allowedCommands.js's allowlist. Also extracts codex.js from
tuiHandshake.js to match the existing one-file-per-vendor shape
(antigravity.js/grok.js/kimi.js/cursor.js), and collapses the
antigravity-vs-everyone-else model+effort injection duplicated between
buildTuiInvocation and buildTuiSpawnConfig into one shared
injectTuiModelAndEffort helper.

No vendor's argv-building logic changed — this only relocates dispatch,
verified by keeping every existing test green (25.6k server tests) plus a
byte-for-byte allowlist-content check against the prior hardcoded Set.
An agy TUI agent blocked on Google's "We're finishing verifying your
account eligibility" banner failed fast (good) but left the provider
marked available, so the next dequeued task picked Antigravity again and
died identically — a queue of tasks could burn down in under a minute.
Worse, the banner was classified actionable, which BLOCKED each task over
a condition whose own message says it clears itself.

Two changes:

- The eligibility signal is now non-actionable, so resolveFailedTaskDecision
  takes the retry path instead of blocking the task, and carries
  origin:'provider' (it is provider chrome, not text an agent could print).
- agentFinalization benches the provider on ANY provider-origin failure,
  not just usage/rate limits. The provenance gate (#2642) is what makes
  this safe: a loose keyword match on a repainted TUI transcript stays
  'output-scan' and never benches.

The cooldown policy moves to server/lib/providerCooldown.js, shared with
promptRunner.js — the two paths previously disagreed on how long the same
category was worth (auth-error: 15m via the prompt cascade, never via the
agent path).
The bench predicate kept a `|| category === 'usage-limit' || category ===
'rate-limit'` fallback alongside the new `origin === 'provider'` check —
preserving the old behavior, but contradicting the rule the surrounding
comment states. Both categories have loose pattern alternatives (a bare
"rate limit" / "quota exceeded"), so an agent's own failing test printing
that phrase would bench a healthy provider for 5 minutes off nothing but
its transcript.

Provenance is now the whole gate. Genuine limits already carry
origin:'provider' via their structuredMarker, and every finalizeAgent
caller analyzes through analyzeAgentFailure or detectImmediateFallbackSignal
— both of which stamp an origin on every branch — so no real provider
failure stops benching.

The bench test now covers output-scan rate-limit and usage-limit, which is
what let this through: it only exercised auth-error. Verified by planting
the old predicate back and watching both new cases fail.
bench a provider when the provider is what failed a CoS agent run
…agents

A bare GitHub repo URL captured in the Brain was already saved as a link and
cloned in the background, but nothing said so and nothing followed. Both capture
boxes now name the owner/repo that will be cloned and offer two sticky opt-ins:

  - Scan for malware — the same read-only /do:scan audit as the Links tab's Scan
    button, now sharing one task shape via services/repoIntake.js
  - Study for PortOS ideas — a repo-study run that reads the clone for ideas
    worth adopting and files them into the configured work tracker

Both dispatch only after the clone succeeds, and the request is persisted on the
link so a Clone/Retry after a failed clone still honors it.

The study prompt is clean-room by contract: never execute anything from the
clone, never edit it, read its LICENSE first, describe techniques rather than
copying code.

Two supporting changes at the shared layer:

  - declaresNoCommitCriterion now also accepts the `workTracker` marker a
    tracker-filing dispatch stamps, so a one-off run reaches the no-commit
    exemption without claiming a scheduled task type — which would have enrolled
    it in taskSchedule's per-type failure ledger and auto-parked a type no
    schedule owns.
  - resolveTrackerFilingBlock moves from cosTaskGenerator.js into
    lib/workTracker.js, next to the wording table it reads, so all three
    tracker-filing dispatch paths share one resolution instead of copying it.

Also fixes the Links-tab Scan button recording nothing on the link, which left
it re-arming into a duplicate-task 409 after a reload.
parseGitHubUrl matched both segments as `[^/]+`, so a crafted URL parsed to a
dot segment: `https://github.com/../evil` yielded owner `..`, and githubCloner's
`join(reposDir, owner, repo)` then resolved OUTSIDE the managed clone root
(`/data/repos/../evil` → `/data/evil`). `github.com/foo/..` collapsed to the
repos root itself. That localPath is persisted on the link and handed to an
agent as the directory to scan or study, so a bad parse aims a run at an
arbitrary directory.

Anchor both segments to the character sets GitHub actually allows (owner: a
login, alphanumeric with internal hyphens; repo: alphanumerics plus _ . -),
reject the `.`/`..` repo names that class still admits, and anchor the host so
`https://evil.example.com/github.com/o/r` is no longer read as a GitHub repo.

Found by the pre-PR review gate. Pre-existing in githubCloner.js, but this
branch promotes that parser to the shared lib/ rule and adds the agent-facing
consumer, so it lands here.
detect GitHub repos in Brain capture and offer post-clone scan/study agents
… auto-run, keep old Stacker News hashes valid

The v2.40.0 release review found five defects in the aggregate diff that each
per-PR review missed, four of which only appear on an upgrading install:

- A genuine Claude usage limit stopped sidelining the provider. The bench gate
  was rewritten to key on failure provenance, but `analyzeAgentFailure` only
  promoted a few phrasings to `origin: 'provider'` — not the banners Claude Code
  actually prints — so the exact case the bench exists for went unbenched and
  every subsequent dequeue re-picked the dead provider. Promote the
  vendor-branded banners; leave the generic phrasings a task's own failing test
  can print deliberately unpromoted.

- The pending-merge drain ran on `!paused` alone, so CoS auto-run set to `off`
  or `dry-run` still merged PRs — including on the boot-time evaluation. It is
  the only tier that writes to a default branch and was the only one not gated
  on the auto-run mode.

- Folding image URLs into the Stacker News content digest changed the value for
  every row already stored, with no compatibility path: the first sync after
  upgrading would re-analyze the whole back catalogue and re-sort it by arrival,
  and any action already in review would fail permanently with "Source content
  changed after review" about content that never changed. Both comparisons now
  accept the previous digest; rows adopt the new one as they are re-ingested.

- `workTracker` and `reviewerEfforts` ride the permissive task-metadata map, so
  a v2.39.x peer accepts a synced task and then mis-runs it — grading a run that
  correctly filed issues as a validation miss and pushing that back. Bump
  `cosTasks` 3 -> 4 so cos-task sync pauses instead.

- A failed malware scan carries a reportId with no file behind it, so the Brain
  "Scan Reports" filter offered a link that 404s; and the X page computed an
  unsaved-form flag it never used, letting a diagnostic run against the saved
  handle while the form showed a different one.

Regression tests drive the real detector rather than hand-stamping the analysis
shape — a hand-stamped `origin` is how the usage-limit regression shipped green.

Deferred findings filed as #3630 (the drain has no real cadence), #3631 (an
agent that prints a banner can bench a healthy provider), #3632, #3633, #3634,
and #3635.
…n a clean tree

The TUI idle reaper scored a run `idle-no-changes` whenever the worktree had
no UNCOMMITTED changes. That fails every job whose deliverable is a commit: a
/do:release or /do:pr agent commits, pushes, and opens its PR, then sits in the
reviewer loop until the reaper takes it — with a clean tree *because it
succeeded*.

Two consecutive release runs on 2026-08-08 each did their whole job (cut the
release commit, opened the release PR, then repaired it against review
findings) and were both recorded as failures, so the task kept retrying while
the release sat unmerged.

Evidence of work is now a dirty tree OR at least one commit inside the run
window (`git rev-list --count --since`, which filters on committer date so
commits merely pulled in from the remote don't count). The #2191 no-op
detection and the #3102 `worktreeChangesExpected` opt-out are both unchanged.

The report-shaped half of the same problem (/do:review, /do:scan,
/do:plan-task, /do:replan, whose deliverable lands outside the repo) is
tracked in #3636, and the unsatisfiable [task-<id>] success criterion one
layer up in #3637.
count commits as evidence of work so release/PR agents stop failing on a clean tree
@atomantic
atomantic merged commit f95dedc into release Aug 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant