mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-13 06:51:27 -10:00
fix/google-model-default
406
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
03455ad915 |
fix(cli): enter setup on a requires-model config and hint zero auth login
Two residual first-run gaps after the catalog model-defaulting fix: - An active provider whose model has NO catalog default to fall back on (custom openai-/anthropic-compatible endpoints) still bricked bare `zero` and `zero setup` — the exact commands that could have repaired the config. Tag that resolve failure with ErrProviderRequiresModel and extend the interactive TUI's fixable-error fallback (previously only ErrNoActiveProvider) to drop into the setup wizard for it. The sentinel is attached via a message-preserving wrapper (multi-target Unwrap), so headless commands (zero config / zero exec) print the exact actionable text unchanged and every other resolve error stays fatal. - `zero login` / `zero logout` don't exist (it's `zero auth login`); first-run users try them and got only "unknown command" (reported in the wild). The unknown-command error now answers: did you mean "zero auth login"? Unrelated unknown commands get no suggestion. Tests: the sentinel comes from a REAL Resolve over a real config file (message-prefix asserted, no key leak); the TUI fallback is red-green (reverting the condition reproduces the exact brick with the old error); the existing no-active-provider fallback and other-errors-stay-fatal tests still pass; login/logout hint with a no-false-positive check. |
||
|
|
0e7a1fc218 |
fix(config): default missing google/anthropic models from the catalog
A hand-written provider entry like {name: "google", provider_kind:
"google", apiKey: ...} with no model bricked Zero: config resolution
failed with "provider google requires model", and since zero config and
bare zero setup both resolve up front, the only commands that could have
fixed the config died with the same error (reported in the wild — the
one working escape, zero setup google --model <m>, is undiscoverable
from that message).
The openai kind has always defaulted a missing model
(modelregistry.DefaultModelID); google and anthropic were missed. Fall
back to their provider-catalog default models (gemini-2.5-pro /
claude-sonnet-4.5) the same way, behind the same gate: provider-command
configs (normalizeProvidersWithoutModelDefaults) still surface exactly
what the external command returned. An explicitly configured model
always wins, and profiles with a catalogID already got this via
applyCatalogDescriptor — this covers the catalog-less hand-written case.
The residual requires-model error (openai-compatible customs, which
have no catalog default) now carries an actionable hint: add "model" in
config.json or re-run zero setup with --model.
Tests: google/anthropic defaulting (red-green: reverting the google
fallback reproduces the exact reported error), explicit model wins,
provider-command gate still errors with the hint, and validation treats
official-kind model-less profiles as clean while still flagging customs.
|
||
|
|
39e7100674 |
fix(gemini): strip unsupported JSON Schema fields from tool declarations (#374)
Every exec against the Google/Gemini provider failed: Zero emits tool parameter schemas in OpenAI's JSON Schema dialect, which always includes `additionalProperties` (schemaToRuntimeMap writes it unconditionally). Google's functionDeclarations schema doesn't recognize that keyword and 400s the whole request — "Unknown name \"additionalProperties\" … Cannot find field" — so no tool-using call ever reached the model. Sanitize tool parameters in the Gemini mapper (only there — the field is valid and wanted for OpenAI strict mode and harmless for Anthropic): - sanitizeGeminiSchema keeps only the keywords Gemini's Schema type supports (type, description, enum, properties, required, items, anyOf, default, nullable, format, and the min/max/length constraints) via an allowlist, so any future OpenAI-ism or MCP-supplied field can't leak. - Recurses through properties, items, and anyOf so nested schemas are cleaned at every depth, not just the top level. Tests: a unit test on the sanitizer (nesting, legit fields preserved, nil-safe) and an end-to-end test asserting the outgoing request body's functionDeclarations[].parameters carry no additionalProperties while the real schema survives. Bypassing the sanitizer reproduces the reported payload, so the coverage is red-green. Fixes #373 |
||
|
|
027a8f2768 |
fix(config): use ~/.config on macOS and enter setup when no provider (#371) (#372)
On macOS, DefaultUserConfigPath used Go's os.UserConfigDir which returns ~/Library/Application Support, while users (and Claude Code) place config at ~/.config/zero/config.json. The user config was never read, leaving activeProvider empty and the launch failing with `active provider "" not found` on a fresh `go run cmd/zero setup`. - Add config.UserConfigDir(): on darwin, resolve to ~/.config (honoring XDG_CONFIG_HOME), matching Linux and Claude Code. DefaultUserConfigPath, the TUI user-commands dir, and specialist manifests all route through it. - Add config.ErrNoActiveProvider sentinel; the interactive TUI now drops into the setup wizard when resolve fails solely due to a missing/ unresolvable active provider, instead of exiting fatally. Other resolve errors (malformed JSON, directory conflicts) remain fatal. Note: `zero setup`/`zero auth` already MkdirAll the config dir before writing, so the directory is auto-created on first write. Co-authored-by: smallnest <smallnest@gmail> |
||
|
|
bd89a1f451 |
feat: require manual approval before npm publish + drop release-as pin (#369)
The publish-npm job now targets the npm-publish environment, whose required-reviewer rule pauses the job for maintainer approval in the Actions UI before anything reaches the registry. Also removes the release-as 0.1.0 pin from release-please-config.json now that v0.1.0 has shipped — leaving it would stamp every future release 0.1.0. |
||
|
|
c807f92b72 |
chore(main): release 0.1.0 (#368)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>v0.1.0 |
||
|
|
8eccc26698 |
feat: publish zero to npm via release-please (#367)
* feat: publish zero to npm via release-please Adds release-please to automate versioning and releases from conventional commits on main: - release-please-config.json / .release-please-manifest.json: node release type (package.json is the version source of truth), plain v-prefixed tags, bootstrap-sha at current HEAD, and release-as 0.1.0 to pin the first release (remove after v0.1.0 ships). - .github/workflows/release-please.yml: maintains the release PR; on merge it tags + creates the GitHub Release, then dispatches release-artifacts.yml at the tag (GITHUB_TOKEN-created tags cannot fire on-push-tags workflows). - .github/workflows/release-artifacts.yml: tag-gated steps now match the ref instead of the push event so the dispatched-at-tag run publishes assets, and a new publish-npm job publishes @gitlawb/zero after verifying every postinstall asset URL is publicly downloadable (also blocks publishing while the repo is private). * Update .github/workflows/release-please.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: publish to npm via OIDC trusted publishing instead of NPM_TOKEN npm's trusted publishing exchanges the workflow's OIDC token for publish auth, so no long-lived NPM_TOKEN secret exists to leak, and provenance is generated automatically. Drops registry-url from setup-node (its .npmrc line hard-fails on the unset NODE_AUTH_TOKEN) and updates npm first (OIDC needs npm >= 11.5.1). One-time caveat: npm only lets you configure a trusted publisher on an existing package, so the first @gitlawb/zero publish is a manual bootstrap. * fix: drop npm@latest from the OIDC publish path Rely on the npm bundled with the pinned Node 24 toolchain (>= 11.5.1, the trusted-publishing minimum) instead of pulling an unpinned npm@latest from the registry inside the publish job, and fail fast with a clear error if a future toolchain change regresses it. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> |
||
|
|
6e0a665118 |
fix(auth): propagate credentials to every provider-build surface and pin children to the live provider (#366)
* fix(auth): propagate credentials to every provider-build surface and pin children to the live provider Sub-agents, swarm members, ACP sessions, and mid-run escalation switches could build providers without the credentials the parent was running with. This consolidates credential resolution so every surface gets the same auth: - Wrap deps.newProvider once in fillAppDeps to fold the stored API key in centrally; ACP, exec escalation, and buildProvider no longer patch it per call site (an apiKeyStored profile previously went out keyless on those paths and 401'd). - Export ZERO_PROVIDER at startup (buildProvider) and keep exporting on every runtime switch (wizard, onboarding, /model, /provider), so spawned children resolve the parent's live provider instead of whatever another process last persisted to config.json. Injected via appDeps so tests never mutate the process environment. - Share one OAuth login selection end to end: ProviderProfile. OAuthLoginCandidates (name, then catalog-ID fallback; nothing for a profile with its own configured credential) + oauth.FirstStored resolve the login ONCE, and the bound store key is threaded through providers.Options.OAuthLoginKey so the Codex account-id header always reads the same login that issued the bearer token. A renamed profile (name != catalog ID) now finds its login; an explicit API key is never silently overridden by a same-name or catalog-shared OAuth login. - Export ProviderProfile.HasConfiguredCredential as the single "profile is key-authed" predicate (cli/tui/config previously had three copies). - applyProviderWizard commits live state (provider, profile, name, env export) only after BOTH the build and the config persist succeed, so a persist failure no longer leaves the chat running on a provider the status line and children disagree with. Covered by red-green tests for each path: stored-key wrap, escalation switch, catalog-ID fallback (incl. case-variant names), explicit-key protection, same-login account selection, wizard persist-failure rollback, onboarding/startup env export. * test: isolate OAuth/config test storage and fix the flaky MCP response-id test Review + CI fixes, all test-only: - factory/cli OAuth tests: pin ZERO_OAUTH_STORAGE=file next to the temp ZERO_OAUTH_TOKENS_PATH — an inherited "keyring" value sends NewStore to the OS keychain and ignores the temp path entirely, so the tests could read (or clobber) the developer's real logins. newOAuthStoreForTest now owns both pins so a future call site can't forget them. - TestApplyProviderWizardExportsActiveProviderEnv: point m.userConfigPath at a temp config and stub newProvider, so the SUCCESS path exercises the full build+persist+commit sequence in isolation (the default empty path silently skipped persist; a changed default must never reach the real user config). - TestClientRequestWaitsForMatchingResponseID (ubuntu smoke flake): the responses were pre-loaded into a bytes.Buffer, so the client's reader goroutine could consume the matching response — and hit EOF — before request() registered its pending id, failing with "request() error = EOF" on fast runners. The fake server now sits on pipes and responds only after reading the outgoing request, matching production ordering; survives -race -count=50. |
||
|
|
142c548c89 |
feat(tui): FILES sidebar panel with click-to-select and file drill-in (#365)
* feat(tui): FILES sidebar panel with click-to-select and file drill-in
Add a FILES section to the right context sidebar showing the files the
session has touched — newest first, with A/M status badges, aggregated
+/− diffstats, and a pulsing row for the file being written right now.
Clicking a row selects the file (accent marker, edit cards tint in the
chat, transcript scrolls to the latest edit); a second click opens a
drill-in view: the chat column swaps to the file's stacked edit cards,
`f` toggles to the syntax-highlighted on-disk file with markers on
session-added lines, `d` back to diff, Esc returns to the chat.
The roster is fed from two sources: changedFiles carried by
write_file/edit_file/apply_patch results (persisted per session, so it
survives /resume), and a git sweep — baseline snapshot at startup,
re-check after command tools and at turn end — so files created through
bash/exec_command scaffolding or subagents show up too.
* fix(tui): address FILES sidebar review findings
Review fixes for the FILES panel / drill-in / git sweep:
- touchedFiles: split multi-file diffs per file (perFileDiffStats) so an
apply_patch spanning several files no longer charges the whole patch's
+/- totals to every file it touched; order by lastRowIndex descending
so a re-touched file lists first (reversing first-seen order kept it in
its original slot).
- sidebarFileLines: filter the live-writing row out before computing the
"+N more" trailer so the skipped entry no longer inflates the count.
- git sweep: run `git diff --numstat -z` and parse the NUL-terminated
rename records (counts + preimage + postimage) — plain --numstat
brace-mangles renamed paths ("src/{old => new}"), so their diffstat
never matched a porcelain path and renamed files stayed at 0/0;
gitTouchedFiles now yields newest detection first to match the
roster's ordering.
- file view: stream the full-mode read and stop at fileViewMaxLines
(os.ReadFile loaded a multi-GB file wholesale before truncating — the
exact freeze the cap exists to prevent); re-opening the file already
being viewed is a no-op so a stray re-click can't bounce full mode
back to diff or reset scroll; skip sub-4-char lines in the changed-line
markers so common braces/returns don't mark unrelated occurrences.
- keys: gate the drill-in's d/f and Esc handling on noBlockingModal so a
permission prompt / ask-user / wizard keeps its own key handling.
- sidebar: a live in-flight write counts as content, so the FILES pulse
shows for the session's first mutation.
- tests: numstat test now feeds real -z record shapes and asserts the
rename postimage key; new coverage for per-file stats, re-touch
ordering, live-row overflow, same-file reopen, truncation, modal
gating, live-write content, and a real-repo rename sweep.
---------
Co-authored-by: Gnanam <[email protected]>
|
||
|
|
02f0c0929b |
fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall (#362)
* fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall Root-caused (via a verified multi-agent audit) the chatgpt/gpt-5.5 "stuck" reports: the ChatGPT Codex backend pauses real generation mid-stream on large single tool-call outputs (a ~1000-line write_file) while continuing SSE keep-alive heartbeats. That is an upstream limitation, not a Zero bug or a connection-pooling issue (the darwin DisableKeepAlives fix is applied and confirmed not the cause). Zero already detected it, but two UX gaps made it feel like a hang: 1. Faster abort. The content-stall watchdog fired at idle*2 = 10 minutes. A heartbeat-pause stall on gpt-5.x rarely recovers, so 10 minutes of a frozen token counter was a terrible wait for a doomed turn. Replaced the StreamContentStallFactor=2 const with ContentStallTimeout(idle)=idle*1.2 (6 minutes at the default). It still only ever fires on a TRUE zero-output freeze — any real data line resets it — so healthy slow-reasoning runs are unaffected. Scales with ZERO_STREAM_IDLE_TIMEOUT; returns 0 (disabled) when idle is disabled. 2. Auto-retry the actual stall. The existing stall-retry only fired when NOTHING had been forwarded — so the common case (model streamed transient reasoning + began the big write_file, then froze mid-arguments) was NOT retried, because the incomplete tool call is collected and transient output was forwarded. Relaxed the gate to retry when no visible PROSE was shown (forwardedVisibleText, marked only by OnText) and no final text was collected — dropping the len(ToolCalls)==0 exclusion, since an incomplete tool call from a timed-out stream is never executed (the turn returns on the error before dispatch) and never appended to messages, so the retry re-sends clean context with no duplication. A visible "[no output — model stalled; retrying N/max]" notice fires per attempt. Forwarded prose still blocks the retry (it would duplicate answer text). Verified: full -race suite green; a 4-dimension root-cause audit and a 3-dimension adversarial review of this diff both passed with no confirmed defects. New tests: TestRunRetriesStallAfterIncompleteToolCall (the exact gpt-5.x case), TestRunRetriesStallAfterOnlyReasoning; preserved TestRunDoesNotRetryStallAfterPartialOutput (prose still blocks retry). * fix(providerio): make ContentStallTimeout overflow-safe Address PR #362 review: idleTimeout*6/5 could overflow int64 for a pathologically large ZERO_STREAM_IDLE_TIMEOUT and wrap to a NEGATIVE duration, which would arm the content-stall timer to fire immediately and abort every stream. Compute 1.2× as idle + idle/5 with a clamp to the max duration on overflow (which just means "effectively no content watchdog", the sane result for an absurd idle timeout). The normal 1.2× values are unchanged (5m->6m, 30s->36s, 100ms->120ms). New test: TestContentStallTimeout covers the scaled values, the zero/disabled case, and the MaxInt64 overflow guard. |
||
|
|
3fde03a900 |
fix(tui): show the queued-message preview above the composer, not below (#361)
A message typed while a run is active is queued for the next turn. Its "[queued] …" preview rendered on the line BELOW the input box; move it to directly ABOVE the box so it reads as "waiting to send," sitting on top of what you're currently typing, and doesn't get visually detached from the composer by the status line beneath it. Footer height is unchanged (the preview line just moves from below to above the box). New test: TestQueuedPromptPreviewSitsAboveComposer asserts the preview line index is above the composer box top border. |
||
|
|
85273ea72d |
fix(providerio): disable HTTP keep-alive reuse on macOS to defeat degraded-connection stalls (#360)
This codebase already has a documented, previously-diagnosed macOS-only bug: "sharedHTTPClient... defeats the stale-pooled-connection hang... reproduced on BOTH chatgpt and ollama... surfaces on macOS far more than Linux because macOS keeps dead pooled connections around longer." The existing mitigation (ResponseHeaderTimeout=120s, IdleConnTimeout=30s) catches a reused connection that never responds at all, or one that sat idle too long. Traced a live report that doesn't fit either case: a session accumulated 301 real completion tokens over ~12.5 minutes (~0.4 tok/s) on chatgpt/gpt-5.5 before being manually cancelled - not silent, just catastrophically slow, on a retry shortly after a prior attempt on the same host. That's a third failure mode: a reused connection that's alive-but-severely-degraded, still delivering real bytes (enough to keep resetting Zero's stream idle/content-stall watchdogs, which only fire on true silence) but never at a usable rate. Basic network diagnostics on the affected machine (DNS, TCP connect, TLS handshake) were all healthy and routed directly with no VPN/proxy involved, pointing at the same connection-reuse class of bug rather than a local network misconfig. DisableKeepAlives removes pooling from the equation entirely rather than just tightening the timeout window, since a degraded-not-dead connection isn't reliably distinguishable from genuine backend slowness once inside the stream. Scoped to darwin only via runtime.GOOS: a fresh TCP+TLS handshake per request costs low tens of milliseconds (measured ~92ms against a real host on the affected machine) against potential minutes-long stalls, and Linux/Windows don't exhibit this OS-level pooled-connection behavior, so they keep full connection reuse. New test: TestHTTPClientReturnsStallHardenedSharedClient extended to assert DisableKeepAlives matches runtime.GOOS == "darwin". |
||
|
|
0914ced9e1 |
test: widen timing margins on two Windows-flaky CI tests (#359)
Both failed on windows-latest today, on merges of otherwise-unrelated PRs (#349, #356) - neither PR touched internal/specialist or internal/agenteval. Windows GitHub Actions runners have coarser default timer granularity and more scheduling contention than Linux/macOS, so tight timing margins that hold on the other two platforms aren't reliable there. - TestOutputToolBlocksUntilTaskCompletes: a 100ms poll timeout against a 5ms background write left only 20x margin. Widened the timeout to 2000ms (a ceiling, not a target - PollInterval keeps the test fast in the success case; it only changes how much CI jitter it tolerates before failing). - TestHarnessTimeoutCancelsBlockedAgent: confirmed there's no actual data race (harness.Agent.Run is a direct synchronous call, not a spawned goroutine racing the assertions) - the failure was workspace materialization alone consuming the full 10s budget on a contended Windows runner, so the agent was never reached. Widened to 30s. This test's own design blocks for the full timeout to observe cancellation, so unlike the other fix this directly costs ~20s more wall-clock time, but that's inherent to what it's testing. |
||
|
|
68669073c2 |
fix(tui): explicitly request the initial window size at startup (#358)
Zero is always in alt-screen mode (useAltScreen() is unconditionally true), and transcriptView's `if m.altScreen && m.height > 0` gate is what pads the rendered frame out to fill the full terminal height (scrollableTranscriptItemsView pads bodyWindow to window.height). If m.height is ever 0 - Bubble Tea documents an initial WindowSizeMsg as delivered automatically on program start, but that's the terminal proactively pushing a size, and nothing else ever asks again if that push is missed (a slow/unusual terminal, a multiplexer, a startup race) - Zero falls back to the unpadded, non-fullscreen render path for the rest of the session: content only occupies as many rows as it needs, leaving the remaining terminal height as plain unfilled background instead of the alt-screen's painted panel color. Fixed by explicitly firing tea.RequestWindowSize in Init(), the same defensive pattern already used for tea.RequestBackgroundColor right below it - Zero doesn't rely solely on an unprompted push for either. |
||
|
|
33410d8730 |
fix(tui): make the stall watchdog visible before it fires, not just after (#357)
Zero already has a content-stall watchdog built specifically for this exact case (providerio.ErrStreamStalled's own doc comment: "observed on chatgpt/gpt-5.x and ollama reasoning models") - a heartbeating-but-silent upstream is aborted after idleTimeout * StreamContentStallFactor (5min * 2 = 10min by default). It works. The gap is that until it fires, the "still generating... Xs" cue is the ONLY signal the user has, and it looks completely identical whether real (if slow) content is still coming or the stream is already dead and just hasn't been caught yet - confirmed against a live report where the token count sat frozen for 7+ minutes with no way to tell from the UI alone. Past half the effective idle timeout, quietGenerationHint() now escalates to name what's actually happening and when Zero will act on its own: "still generating... Xs - unusually quiet, Zero will auto-recover by ~Ys if it doesn't resume". Exported StreamContentStallFactor from providerio (was unexported) so the TUI can compute the same ceiling the watchdog itself uses, instead of duplicating the constant. New test: TestQuietGenerationHintEscalatesPastHalfIdleTimeout, using ZERO_STREAM_IDLE_TIMEOUT to shrink the timeout so the test runs fast while exercising the exact same math the real watchdog uses. |
||
|
|
c0254e3266 |
fix(tui): context-fill gauge for custom/local Ollama models + related fixes (#356)
* fix(tui): show the context-fill gauge for custom/local Ollama models
The colored context gauge (used/window · N%) only renders when Zero knows
a model's context window. It resolves that two ways: a curated remote
catalog (how Grok/hosted models are known), or live /v1/models discovery —
but parseModelsResponse only ever extracts id/description from that
response; OpenAI-compatible model listings don't carry context-window
metadata at all. A custom/local Ollama model tag (e.g. a locally-pulled
":cloud"-tagged model) has no curated-catalog entry to borrow one from
either, so it always resolved to an unknown window and the gauge silently
fell back to a bare token count with no color grading.
Ollama does expose this, just via a different, Ollama-native endpoint
(/api/show, not /v1/models) whose model_info map reports it under an
architecture-prefixed key like "qwen2.context_length". Added
DiscoverOllamaContextWindow (providermodeldiscovery) to probe it, wired
through an async command scoped to the local "ollama" catalog ID only
(not "ollama-cloud", a different hosted service this endpoint isn't
assumed to exist on) — fired from Init() for the provider active at
launch, and from switchProviderModel for a provider switched to
mid-session (which previously warmed no discovery at all for the new
provider, a separate gap this also fixes).
New tests: DiscoverOllamaContextWindow's endpoint construction and
model_info parsing (including a mixed-type model_info and a missing
*.context_length key), the discovery command's scoping to local Ollama
only, the Update() handler populating modelContextWindow's fallback, and
switchProviderModel warming discovery for the newly active provider.
Local only — not pushed. I can't run a live Ollama daemon in this
sandbox, so the actual /api/show request/response shape (verified against
Ollama's documented API, not a live instance) needs confirming against a
real daemon before this goes out.
* fix(tui): stop showing the token count twice next to the context gauge
contextWindowSegment (the gauge) and usageStatusSegment (the plain "N tok"
figure) both read the exact same value (latestUsageTokens) — the code
already suppressed the plain figure when the sidebar shows it ("the token
figure shows on both sides"), but never extended that same dedup to the
gauge. So as soon as a model's context window becomes known (e.g. after
the previous commit's Ollama fix), the footer showed the identical number
twice: once inside the gauge, once again right next to it.
Fixed by falling back to cost-only whenever the gauge is shown, same as
the sidebar-active branch already does.
* fix: check collect()'s deadline even while output keeps draining
The wait deadline in execSession.collect() was only checked in the branch
reached when drainString() returns empty. A background process producing
output continuously enough to keep the buffer perpetually non-empty could
theoretically starve that check forever, turning the caller's requested
timeout into an unbounded block regardless of yield_time_ms.
Investigated as part of a cross-provider "stuck" report (multiple sessions
across different models/providers all hanging on the same prompt, each
with a background terminal running and no forward progress). Could not
empirically reproduce a hang via a synthetic continuous-writer test under
Go's real scheduler — the reader won the race often enough in practice
that this doesn't appear to be the actual cause of those reports. Keeping
the fix regardless since it closes a real, if hard-to-trigger, gap: the
deadline is now checked in both branches, so continuous output can never
prevent collect() from returning within its requested window.
|
||
|
|
c4c14d62d4 |
fix(oauth): make xAI / Hugging Face OAuth actually launch from the wizard (#355)
The provider wizard offered "Sign in with OAuth" for xAI and Hugging Face, but clicking did nothing: the login managers were built without an env, so the preset opt-in (ZERO_OAUTH_ALLOW_PRESETS) read as unset, ResolveConfig errored before any browser step, and the error was never rendered on the provider list. Only ChatGPT worked, because its login path force-enables presets. Add ManagerOptions.AllowPresets, which layers the opt-in onto the manager env (snapshotting the process environment so ZERO_OAUTH_<NAME>_* overrides survive envValue's hermetic-map rule) without the operator exporting the flag. Set it on every user-initiated OAuth manager: the wizard loopback login, the device-code prepare/complete, the post-login token refresh, the runtime resolver that authenticates model calls, and `zero auth login`. This fixes xAI end to end (it ships a public client_id, so the browser now opens). Also surface a failed OAuth attempt on the provider list (renderProviderStep) so the click is no longer a silent no-op, with a Hugging Face hint pointing at app registration + ZERO_OAUTH_HUGGINGFACE_CLIENT_ID (HF ships no public client_id, so it still requires that one env var by design). |
||
|
|
49bec0274f |
feat(tui): interactive theme picker + color theme catalog (#354)
* feat(tui): interactive theme picker + color theme catalog
Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds ten
built-in color themes alongside the dark/light built-ins — dracula, nord, gruvbox,
tokyo-night, catppuccin, one-dark, solarized-dark, rose-pine, everforest, and
solarized-light — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.
Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.
The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.
* fix(tui): address CodeRabbit review on the theme catalog
- CHANGELOG: reconcile the --theme bullet with the named themes (was still
documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
directly instead of a raw substring, so a wrong-key regression fails.
|
||
|
|
bcc65da2a0 |
fix(tui): require a second Esc press to cancel a running turn (#352)
* fix(tui): require a second Esc press to cancel a running turn A single stray Esc during an active run cancelled it instantly with no chance to reconsider - e.g. a press meant to dismiss an overlay that had already closed. Ctrl+C already guards app-exit with a double-press confirmation (exitConfirmActive/exitConfirmSeq); Esc now gets its own independent confirmation (cancelConfirmActive/cancelConfirmSeq) guarding run-cancellation specifically, since the two are different actions with different consequences armed by different keys. Mirrors the existing seq-gated tea.Tick pattern: first Esc arms a 3s window and shows "Press Esc again to cancel" in the footer; a second Esc within the window cancels the run; letting it expire, or the run finishing on its own, disarms without cancelling. Updated the three existing tests that assumed single-press cancellation (TestEscCancelsPendingRun -> TestEscRequiresSecondPressToCancelPendingRun, TestEscCancellationLeavesVisibleMarker, TestEscCancelRecordsSessionError, TestCancelledRunFlushesCheckpointSessionEvents) and added TestEscCancelConfirmationExpires mirroring the Ctrl+C expiry test. * fix: pre-push review findings on the Esc cancel-confirmation gate Ran an adversarial review before opening the PR. Three independent reviewers converged on the same root defect: unlike exitConfirmActive (disarmed by any key other than Ctrl+C), cancelConfirmActive had no "anything else disarms it" safety net, so it behaved as a plain 3s timer rather than a true immediate-second-press gate. Concretely: - Typing, Ctrl+O, or any other non-Esc key left it armed, so a later, unrelated Esc press could silently cancel the run. - A mid-turn ask_user or permission prompt lands asynchronously and is checked earlier in the Esc handler than the cancel-confirm block, so it silently "stole" the confirming press (denying the prompt instead) while leaving cancelConfirmActive armed for a subsequent, unrelated Esc to act on. - Mouse clicks resolving a permission prompt or entering a subchat had the same gap. Fixed by mirroring the Ctrl+C pattern: any non-Esc key disarms it (model.go, next to the existing exitConfirmAction reset), and any left/right mouse click disarms it too (mouse.go — scoped to clicks, not hover/motion, since motion fires continuously and would otherwise defeat the window whenever the mouse sits over the terminal). Within the Esc handler itself, restructured to capture whether this press was really a confirm *before* any of the earlier branches (subchat, MCP wizard, ask-user, permission, picker, suggestions, queued-message, detailed-transcript) can run, then disarm unconditionally — so an Esc consumed by any of those no longer leaves a stale confirmation for a later press to trip. Also updated two now-stale help strings the review flagged: the `?` overlay's Esc row and pickerBusyText()'s "press Esc to cancel" hint, both of which still described the old single-press behavior. Added TestEscCancelConfirmationDisarmsOnInterveningKey (mirrors the existing Ctrl+C equivalent) and TestEscCancelConfirmationDisarmsWhenStolenByAskUser covering the two confirmed gaps. * fix: address CodeRabbit review comments on PR #352 - model.go: the first Esc (arming the cancel confirmation) was clearing the composer draft immediately, even though nothing had actually been cancelled yet — a leftover from the old single-press behavior, where clearing the draft and cancelling the run happened atomically as one action. Now that cancellation is two presses, only the confirming second Esc (or a plain Esc with no run pending) clears the composer; arming preserves whatever the user was typing, mirroring how Ctrl+C already preserves a draft (TestCtrlCClearsComposerBeforeExitConfirmation). - clipboard.go: routePaste (bracketed paste and right-click paste) never disarmed a stale cancelConfirmActive — it isn't a tea.KeyPressMsg, so it bypassed the generic "any non-Esc key disarms it" hook added in the previous commit. A paste is a deliberate action like typing or clicking, so it should disarm the confirmation too; otherwise a later, unrelated Esc could silently cancel the run. New tests: TestEscArmingCancelConfirmationPreservesComposerDraft, TestPasteDisarmsCancelConfirmation. |
||
|
|
2a8802e39c |
fix: cap unbounded exec_command output buffer to prevent OOM kill (#353)
* fix: cap unbounded exec_command output buffer to prevent OOM kill
execOutputBuffer.data accumulated every byte a backgrounded exec_command
session wrote, with no size cap, unlike buffer.recent which is correctly
bounded to recentExecOutputBytes for the /ps preview. drainString() only
clears data when the agent actually polls that session again — so a
long-lived background session nobody polls (e.g. a dev server left running
after the run that started it was cancelled) grows this buffer forever as
long as the process keeps writing, with no ceiling.
Traced this directly to a real crash: a session that had been running for
~5.5 hours (an earlier turn invoking swarm agents got cancelled mid-flight,
per that session's own event log) accumulated to ~42.7GB of compressed
memory, at which point macOS's memorystatus killed the process outright
("killing zero due to low-swap").
Fixed by capping data at maxExecOutputBufferBytes (2MiB), trimming from the
front like recent already does, and surfacing a one-time truncation notice
on the next drain so data loss is visible rather than silent.
* fix: pre-push review findings on the exec_command buffer-cap fix
Ran an adversarial review before opening the PR. It found the truncation
notice added alongside the buffer cap didn't actually work: prefixing
execOutputTruncatedMessage into drainString()'s text meant the notice
always sat ~2MiB before the end of the combined output, but the tool's
own downstream truncateExecOutput() keeps at most a 400KB tail (even at
the maximum allowed max_output_tokens) — so the notice was reliably
swallowed, or chopped into an unreadable fragment at smaller budgets.
Verified with direct reproductions against the real functions.
Fixed by moving the signal out of the text stream entirely:
- execOutputBuffer gains consumeTruncated() (read+reset, called once per
collect()) and peekTruncated() (read-only, for status views) instead of
injecting a marker into the drained bytes.
- collect() now returns (string, bool); both call sites thread the bool
through to execToolResult, which sets meta["output_buffer_truncated"]
and appends the notice to the response body *after* truncateExecOutput
runs, so it can never be re-truncated or land in a discarded region.
- Also flagged: a session torn down without ever being polled again after
truncation loses the signal entirely, since nothing drains/logs it on
teardown. Full audit-trail logging felt like overkill for a soft
transparency signal on top of a hard memory-safety fix, but exposed
peekTruncated() on ExecSessionSnapshot and surfaced it in the /ps row
(background_terminals.go) so it's visible any time a still-running
session is checked — covers the common case (the review's own scenario:
a long-lived dev server nobody's polling but that's still alive) even
though a session reaped before ever being looked at is still a gap.
New tests: reworked TestExecOutputBufferCapsUndrainedData for the
consume/peek API, and TestExecToolResultSurfacesBufferTruncationOutsideByteBudget
reproducing the review's exact failure case (max_output_tokens=1) and
proving the notice now survives it.
|
||
|
|
d53052071a |
revert(local): restore the original bright-lime accent color (#351)
Restores accent to #caff3f (the pre-PR#350 value), undoing the three-round brightness reduction (#ade619 -> #8db620 -> #759523) merged in PR #350. Only the accent token changes - the hover-highlight color (a separate amber token) and every other PR #350 fix (subchat selection, drag-to-edge auto-scroll, the dragging-flag fix) are untouched. Full gate green. Local only - not pushed. |
||
|
|
1c65f59be6 |
fix(tui): subchat mouse selection, hover highlighting, and drag-to-edge auto-scroll (#350)
* fix(local): subchat mouse selection + hidden plan panel + scroll-extend selection Three bugs in the subagent/swarm drill-in (subchat) view and mouse text selection, all from the same root: newer features (subchat's row-source swap, wheel-scroll) were never integrated into the older selection/footer code. 1) Can't copy anything in the subchat view. transcriptLineAtMouse (and selectedTranscriptText, the copy-time text extraction) always hit-tested against m.transcript (the parent), never m.subchat.childRows, even while the screen was showing the child session. New transcriptHitTestSource() mirrors transcriptView's own branching (nav bar + child rows + full width vs pinned title bar + parent rows + sidebar-aware width) and is now the single source both functions use. 2) Plan panel shown above the composer inside the subchat view. footerView unconditionally rendered the PARENT run's pinned plan panel; only pendingAskUser was gated. Added a !m.subchat.active guard (m.plan belongs to the parent run, not whatever subagent/swarm session is being viewed). 3) Selection didn't extend across a scroll (reported as "stopped at the end of the screen"). transcriptSelection.cursor was only ever updated by a mouseMotion event; a wheel event is a distinct message type that never reached that code, so scrolling while selecting left the selection frozen at whatever line the mouse last physically moved over. New scrollChatExtendingSelection scrolls AND re-evaluates the cursor at the mouse's current position against the post-scroll viewport. Uses a new nearestTranscriptLineAtMouse (falls back to the closest visible selectable line) rather than an exact match, since the recomputed position can land exactly on a blank spacer row between messages after an odd-length scroll shifts text/spacer parity - an exact-match miss there must not silently no-op the extension. Tests: subchat selection resolves the CHILD session's text, not the parent's; the plan panel is absent from the footer during subchat; a selection's cursor bodyY moves (extends) after a wheel-scroll instead of staying pinned. Full gate green (73 pkg, -race). * feat(local): highlight clickable rows on mouse hover Moving the cursor over an interactive row now highlights it (accent underline) before you click, across every clickable surface: - specialist cards and collapse/expand toggle headers in the transcript - AGENTS sidebar rows (swarm members) - PLAN sidebar step rows - permission-prompt options (reuses the EXISTING keyboard-cursor highlight by moving pendingPermission.cursor on hover, instead of new styling) Requires switching the mouse-tracking mode from CellMotion (only reports motion while a button is held) to AllMotion (reports idle cursor movement too) - the existing 15ms mouse-event throttle already covers the added motion events. hover.go: hoverTarget + mouseHover predicate + updateHoverTarget, which reuses the SAME hit-testers and priority order as the existing click handlers so hover and click never disagree about what's clickable. Sidebar hover is resolved by STABLE IDENTITY (sessionID / stepIndex), not a cached line offset: hoveredSidebarLineOffset re-matches the identity against a freshly computed hit list on every render, so a row that's disappeared (a swarm member's linger elapsed, a plan step completed) simply doesn't highlight rather than a coincidentally-matching unrelated row lighting up. Transcript hover stays bodyY-keyed (matching the existing selection-highlight convention) with explicit clearHover() calls wherever bodyY numbering can shift without an intervening mouse motion: wheel-scroll, PgUp/PgDn, window resize, and transcript rebuilds (/clear, /resume, /rewind, /compact via resetFlushFrontier). Adversarial review caught two real bugs before ship, both fixed: - BLOCKER: plan-step hover stored the step's own index (0,1,2...) but the sidebar render treated it as a raw line offset into the rendered rows - every plan-step hover highlighted the wrong row (the AGENTS header/body). Fixed by resolving fresh at render time via the stable-identity redesign above, which also closes the related sidebar-drift issue. - MAJOR: hover wasn't cleared on keyboard PgUp/PgDn scroll. Tests: predicate correctness; hover resolves the right target across all 4 surfaces (specialist card, toggle row, sidebar agent, plan step, permission option) with the correct priority order; hover changes the actual render output (transcript + sidebar); hover clears on wheel-scroll and subchat exit; a regression test proving hoveredSidebarLineOffset self-heals (no highlight) when a hovered row's identity no longer resolves - the exact scenario the blocker fix targets. Full gate green (73 pkg, -race). * fix(local): soften hover color + auto-scroll a drag past the transcript edge 1) Hover highlight was brand-lime (bright, glaring against the near-black surface) and re-paints on every mouse movement. Switched to amber (an existing semantic token, "light orange") and dropped the underline per feedback. 2) Dragging a text selection past the top/bottom edge of the visible transcript now auto-scrolls toward that side and extends the selection to follow, matching the browser/editor "drag past the viewport edge" pattern. transcriptEdgeScrollDelta detects the drag is vertically outside the body rect (but still within its horizontal span, so it isn't just "off to the side" over the sidebar); dragToEdgeScroll scrolls one step and anchors the cursor to whichever line now sits at that edge of the NEW viewport - deliberately not a re-resolve of the drag's physical mouse position, which stays outside the body rect after scrolling (scrolling changes what content occupies a screen row, not the mouse's screen position) and would keep finding nothing. Extracted nearestTranscriptSelectableAt as the shared "closest selectable line to a target bodyY" core, now used by both nearestTranscriptLineAtMouse (target derived from a mouse position that may have landed on a spacer row) and dragToEdgeScroll (target derived directly from the viewport bound, no mouse position involved at all). Tests: dragging past the top edge scrolls toward older content and extends the cursor upward; the symmetric bottom-edge case scrolls toward newer content and extends downward. Full gate green (73 pkg, -race). * fix(local): smooth-glide the drag-to-edge auto-scroll instead of jumping The initial edge-scroll implementation (previous commit) applied a single chatWheelScrollLines (5-line) jump per raw mouse-motion event, which reads as sudden/jerky since motion events during a real drag arrive in bursts, and stops dead the instant the physical mouse holds still even while still parked past the edge (a terminal only reports motion on actual cursor movement). Replaced with a self-scheduling tea.Tick chain (dragEdgeScrollTickMsg, gated by edgeScrollSeq mirroring the existing exitConfirmSeq/copyStatusSeq idiom) that steps a small, fixed increment (dragEdgeScrollStep=1 line) on a short cadence (dragEdgeScrollInterval=70ms) for as long as the drag holds past the edge, independent of whether new raw motion events keep arriving. reducedMotion still gets the original single-bigger-step-per-event behavior (no animated glide). Adversarial review of the new self-perpetuating tick chain (the risk being an orphaned/stuck chain) found one real bug: a keypress mid-drag clears m.transcriptSelection.active directly (the KeyPressMsg handler in model.go) without going through stopEdgeScroll, leaving edgeScrollDelta stale. The in-flight tick itself terminates safely via its own liveness check - the bug was that the STALE nonzero edgeScrollDelta then fooled startEdgeScroll's "already running" fast path on the NEXT same-direction drag into silently doing nothing (no immediate step, no scheduled tick) for that whole hold. Fixed at the press site (transcript_selection.go's mouseLeftPress case): every fresh press now resets glide state via stopEdgeScroll, so a new drag always starts clean regardless of how the previous one ended - a single targeted fix rather than chasing every place transcriptSelection can be cleared. Tests: top/bottom edge glide starts and extends the cursor over several ticks (a single small step can land on a blank spacer row between messages, same as real usage); the chain stops when the drag returns to the body and a stale in-flight tick after that is a safe no-op; the keypress-interrupt regression (reproduces the adversarial review's finding, confirms the fix). Full gate green (73 pkg, -race). * fix(local): soften the brand-lime accent color across the whole UI The accent color (#caff3f) was fully saturated at fairly high lightness (HSL: L 62%, S 100%) - the kind of value that reads as neon/glaring everywhere it filled a block or bold-text surface, not just the one spot originally flagged: the composer's "typed" prompt glyph, the permission popup's focused-option badge, and the text-selection highlight all use it, plus the spinner, the brand chip, tool-card prompts, and more. Rather than patch each of those consumers individually, softened the single palette source (dark theme only; the light theme's accent is a separate, already-darkened value for its own contrast needs, untouched) to L 50% / S 80% (#ade619) - still clearly on-brand lime, and contrast against both black (onAccent text on accent fills) and the near-black panel stays far above WCAG AA (~14:1 and ~13:1, both comfortably over the 4.5:1 floor). Updated TestStreamingFadePaletteMonotonic's pinned RGB assertion (it checked the fade ramp's first bucket equals the literal old accent bytes) to the new values. Full gate green (73 pkg, -race; make lint/go vet clean except an unrelated stray file that isn't part of this branch or git history at all). * fix(local): darken the brand-lime accent further per live feedback Two prior passes (#ade619 at L50/S80, #8db620 at L42/S70) were each tested live against the original #caff3f via raw truecolor escape codes and confirmed too subtle in turn. Settled at L36/S62 (#759523) - a noticeably darker, more olive lime, confirmed against the original side by side. Contrast vs black (onAccent) and the near-black panel stays comfortably above WCAG AA (~6.1:1 and ~5.6:1). Updated the streaming-fade test's pinned RGB assertion to match. * fix: address PR #350 review comments and CI smoke test failures Smoke tests (macOS + Ubuntu): the 4 new edge-glide tests implicitly relied on m.reducedMotion being false, but mouseTestModel() inherits it from defaultReducedMotion(), which forces true whenever colorprofile.NoTTY is detected - true in CI's non-TTY test runners, false in a local interactive dev shell. Set reducedMotion = false explicitly in each of the 4 tests so they're deterministic regardless of environment. Verified by re-running locally under ZERO_REDUCED_MOTION=1 (reproduces the CI condition) - passes. CodeRabbit actionable comment: handleTranscriptSelectionMouse's mouseMotion case gated only on transcriptSelection.active, which stays true through the async copy-command grace window after release (a non-empty selection isn't reset until transcriptCopiedMsg lands) - so a genuine hover motion arriving in that window would be misrouted as a drag update. The suggested fix (gate on msg's own Button field) turned out to be wrong in a way only a full test run caught: TestTranscriptSelectionUpdatesOnGenericMotion is a pre-existing, deliberate test - some terminals don't restate the button on every motion event during a real drag, so trusting the per-event Button field breaks those terminals. Fixed properly instead: added a `dragging` field to transcriptSelectionState, tracking the app's own press/release bracket (true at press, false at release) independent of active and independent of any single event's Button field. mouseMotion now gates on dragging. CodeRabbit nitpicks: routed the two remaining raw `m.hover = hoverTarget{}` subchat-exit assignments through the existing clearHover() helper, so hover invalidation stays in one place. Left the third nitpick (hit-test layout recomputed per hover-motion tick) as a documented follow-up per the reviewer's own "trivial/poor tradeoff" framing - a proper per-frame memoized cache needs correct invalidation on scroll/resize/content-change to avoid reintroducing the exact staleness class of bug already fixed twice in this branch, and isn't worth rushing. New tests: TestTranscriptHoverAfterReleaseDoesNotMoveSelection (the actual bug CodeRabbit found - a post-release hover must not alter the just-copied selection). Full gate green (73 pkg, -race); TestTranscriptSelectionUpdatesOnGenericMotion (the test the first fix attempt broke) verified still passing. |
||
|
|
7fb0092de7 |
fix: agent runtime resilience — stream stalls, weak-model tool args, and sub-agent reliability (#349)
* fix(local): defeat the macOS stale-pooled-connection hang (transport + stall-retry)
Root cause (you confirmed it: both chatgpt AND ollama hung, only on macOS): providers
fell back to http.DefaultClient, whose pooled keep-alive connections get reused on a
later turn after the server/NAT silently dropped them. The model call is a POST
(non-idempotent), so Go won't auto-retry it on a fresh conn — it blocks forever.
macOS keeps dead pooled conns around longer than Linux, so it reproduced on macOS only.
1. providerio.HTTPClient now returns a shared, tuned transport (ResponseHeaderTimeout
60s bounds a hung POST that gets no response; IdleConnTimeout 30s stops dead conns
from lingering to be reused). Routed openai/codex through it too (anthropic/gemini
already used HTTPClient). Slow first tokens / long reasoning are unaffected — the
header timeout only bounds time-to-first-response-header, not the stream body.
2. Agent loop now retries a stream idle/stall timeout that produced NO output yet, on
a fresh connection (capped + backoff). A partial-output turn is NOT retried (would
duplicate). Recovers any stall that slips past the transport timeout.
Local only — not pushed.
* fix(local): tolerate concatenated tool-arg JSON + widen response-header timeout
1) Weak models (minimax-m3, glm) pack MULTIPLE concatenated top-level JSON objects
into one tool-call arguments slot ({A}{B}), which json.Unmarshal rejects with
'invalid character {' after top-level value' (~9 failed tool calls/session).
New recoverableToolArguments returns the first JSON value ONLY when the payload is
one value optionally followed by more WHOLE JSON values; it rejects a bad/truncated
first value OR trailing NON-JSON garbage ({"x":1}xyz) so genuine corruption still
errors (adversarial-review fix). decodeToolArguments uses it at the central dispatch
(executeToolCall) so {A}{B} runs the first object; historySafeToolCalls uses the
SAME helper so the replayed transcript records the recovered first object (not {}),
keeping dispatch and history consistent. Truly-malformed JSON still hits the
existing sanitize-to-{} + retry path. Trailing objects are dropped (the model
re-issues on a later turn).
2) Raise the shared transport ResponseHeaderTimeout 60s -> 120s so a slow cloud proxy
(ollama *:cloud) that withholds its 200 header until first token isn't wrongly
aborted; still bounds a truly dead reused connection.
Local only — not pushed.
* feat(local): add /turns command + raise default maxTurns 30->50
Agentic multi-step runs (build + delegate + swarm + MCP + self-verify) routinely hit
the 30-turn ceiling mid-task and never reached the later steps; removing /mode earlier
also left NO interactive way to raise the budget.
- /turns [n]: per-session tool-turn budget setter (commandGroupSession), mirroring
/selfcorrect. Empty/status shows the current budget; a positive int sets
agentOptions.MaxTurns (read by the run at model.go); clamped to maxTurnsCeiling=500
to guard against typos; invalid/non-positive shows usage without changing it.
- defaultMaxTurns 30 -> 50 (matches the old 'deep' preset). Raise per-run with /turns
for long tasks (e.g. /turns 150 for a full acceptance run).
Local only — not pushed.
* fix(local): swarm reports failed members as failed + sub-agents inherit /turns budget
Issue B — a swarm member whose child exited non-zero (e.g. exit 4 / max-turns) was
recorded [done] (swarm_status '0 failed'), so the orchestrator — which can't see the
AGENTS panel — trusted incomplete work. The specialist launcher ignored
res.Result.Status; it now surfaces a StatusError result as a member FAILURE
(errors.New(report)) so the swarm marks it [failed]. Added Coordinator.FailWithSession
and switched the member watch path to it so a failed member keeps its session id
(still drillable) and its report rides along as the failure message.
Issue A — a member hit its OWN turn limit because /turns raised only the parent's
budget; children re-resolved config.json's default. New config.SetMaxTurnsEnv exports
ZERO_MAX_TURNS, read in applyEnv -> cfg.MaxTurns; /turns now exports it so spawned
sub-agents / swarm members (which inherit the env) run with the same budget. A
non-numeric/zero env value never clobbers the configured budget.
Tests: launcher non-zero-exit -> failed (+ session kept); SetMaxTurnsEnv round-trip
+ garbage-env guard; /turns exports + clamps the env. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): corrective error for unknown specialist name + clamp ZERO_MAX_TURNS
1) A model that invents a specialist name (validator-runner, file-creator,
file-writer — none registered) got an opaque 'specialist not found' and kept
spawning doomed sub-agents. loadManifest now lists the available specialists and
notes that 'worker' has shell/edit while explorer/code-review are read-only, so the
model self-corrects to a capable specialist. New availableSpecialistList helper.
2) Defense-in-depth (review nit): applyEnv now clamps an inherited ZERO_MAX_TURNS to
MaxTurnsCeiling, so a raw shell 'export ZERO_MAX_TURNS=999999' can't bypass the
/turns ceiling. Ceiling promoted to config.MaxTurnsCeiling (single source of truth,
referenced by the TUI /turns clamp).
Tests: unknown-name error lists worker/explorer/code-review; applyEnv clamps an
over-ceiling env value. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): surface sub-agent kill signal instead of opaque exit -1
When a sub-agent child is terminated by a signal, command.Wait returns an ExitError
whose ExitCode() is -1, losing the cause. Capture ProcessState.String() (portable,
e.g. "signal: killed") into ChildRunResult.Signal and have BuildFinalResult render
it — "Subagent terminated by a signal (signal: killed) — killed before it finished.
Likely causes: the OS out-of-memory killer ... a timeout, or cancellation." — instead
of an opaque "Subagent failed (exit -1)". The message lists causes rather than
asserting OOM, since timeout/cancellation also SIGKILL the child. A captured signal
always surfaces even if a late run_end reported a clean exit. The background task
path (which can't carry the signal name) emits the same hint for a negative exit code.
* address CodeRabbit review on #349
- loop.go: gate stall-retry on a turn-level forwardedAnything flag (wrapped
callbacks), so a retry never re-streams on top of output already forwarded earlier
in the turn (e.g. across the reactive-compaction retry). New test covers a
reasoning-then-stall (collected.Text empty but output forwarded).
- loop.go: route a reissued stream's non-stall error through the SAME recovery as
the initial stream (image-rejection wrapping / context-limit compaction) via a
shared recoverStreamError closure, instead of returning it raw.
- exec.go: drop the hardcoded worker/explorer/code-review guidance from the
unknown-specialist error; rely on the dynamically-rendered available list (a
custom registry may not have those names).
- streamer.go: kill-signal message lists OOM/timeout/cancellation evenhandedly
instead of asserting OOM (the branch also covers timeouts/cancellations).
- launcher_specialist.go + exec.go: preserve the child SessionID on a post-start
failure too, so FailWithSession keeps those failed members drillable.
- model.go: block /turns while a run is active (mid-run change would make the
inherited ZERO_MAX_TURNS budget inconsistent for later-spawned sub-agents).
|
||
|
|
5aced721b3 |
fix(tui): sub-agents inherit the parent's live provider after a /model switch (#348)
* fix(tui): sub-agents inherit the parent's live provider after a /model switch
Swarm members and Task sub-agents run as child 'zero exec' processes that re-resolve
the provider from config.json themselves — they're passed --model but never the
parent's provider. After switching provider/model in the TUI via /model (an in-memory
change), the child still resolved config.json's stale activeProvider, landed on a
provider whose credentials didn't match the live model, and failed auth in ~3s
(exit 3, 0 tool calls, 'API key missing or invalid / can't reach the provider') — so
the spawned agent appeared then instantly disappeared.
Export ZERO_PROVIDER (config.SetActiveProviderEnv) to the live provider name on a
/model provider switch. Child processes inherit the environment, and config
resolution already maps ZERO_PROVIDER -> activeProvider, so the child resolves the
SAME profile and its credentials (env key / stored key / OAuth) — covering all three
auth methods, for both Task and swarm (both spawn via the specialist executor).
Startup needs no export: the parent and child both read config's default, so they
already match; only an in-memory switch diverges.
* fix(config): clear ZERO_PROVIDER when switching to an unnamed/default profile
SetActiveProviderEnv("") now unsets ZERO_PROVIDER instead of leaving the previous
value in the environment. Switching from a named provider back to an unnamed/default
profile otherwise kept exporting the stale provider into child zero exec processes,
which would resolve the old credentials. Extended the round-trip test with the
stale-env regression case: seed a value, switch to blank, assert both os.Getenv and
applyEnv no longer see it.
Addresses CodeRabbit review on #348.
|
||
|
|
02a3d12f34 |
fix(providerio): bound heartbeat-but-no-output streams so they can't hang forever (#347)
* fix(providerio): bound heartbeat-but-no-output streams (content-stall watchdog) A stalled-but-alive upstream hung the agent indefinitely: SSE keep-alives reset the idle watchdog (intentionally — a heartbeating upstream isn't 'dead', and aborting it killed healthy long requests), so a stream that heartbeats while producing nothing never tripped the idle timeout and there was no other ceiling. Observed on both chatgpt/gpt-5.x (Codex) and ollama minimax-m3 — different provider code, same shared providerio SSE loop — so the fix belongs here, once, for every provider. Add a second 'content' watchdog alongside the idle one: keep-alives still reset idle (no regression for heartbeating connections), but only REAL data lines reset the content watchdog. If no data arrives for streamContentStallFactor (2) × the idle timeout, abort with ErrStreamStalled instead of hanging. A slow-but-producing stream (data between heartbeats) keeps resetting content and is never killed. Contained to internal/providers/providerio — no provider/caller changes. * fix(providerio): surface ErrStreamStalled in callers + widen flaky test margins Address review: - All four provider stream handlers (openai, codex, anthropic, gemini) now treat ErrStreamStalled in the same branch as ErrStreamIdle, via a new providerio.StreamTimeoutMessage helper that gives the stall a distinct, accurate message (it reports the content window and does NOT claim the upstream stopped sending data — keep-alives were still arriving). Previously a stalled stream fell through to the generic provider-error path. - Widen the content-watchdog test timing margins (idle 100ms, data/keepalive every 30ms) so CI/GC jitter can't trip the idle watchdog and mask the content path. - Add a StreamTimeoutMessage unit test. |
||
|
|
a0548d707b |
test(providers): isolate the OAuth store in the chatgpt Codex routing test (#346)
TestNewRoutesChatGPTCatalogToCodexProvider asserts the chatgpt-account-id header is empty when no OAuth login is stored, but never redirected the token store — so on a developer machine with a real chatgpt login it read that login and failed (it still passed in CI, where no token exists). Point ZERO_OAUTH_TOKENS_PATH at an empty temp path, mirroring TestNewRoutesChatGPTCatalogWithStoredAccountID, so the test is deterministic regardless of the developer's stored credentials. |
||
|
|
83a371d669 |
fix: stream Codex reasoning + drop swarm agents on their own completion (#345)
Two agent-visibility fixes surfaced diagnosing a 20-minute gpt-5.5 step and an 'agents never disappear' report. A) Codex Responses path streamed no reasoning, so a long thinking phase produced zero visible output and read as a hang (the activity clock never advanced). Root cause: the request never asked for a reasoning summary and the parser had no case for reasoning deltas. Now request reasoning.summary="auto" and forward response.reasoning_summary_text.delta as StreamEventReasoning — the existing TUI handler renders it live and refreshes the activity clock. B) A finished swarm member stayed in the AGENTS panel until the whole turn ended. Now it drops once ITS OWN task completes (fade over the linger window, then remove), independent of the overall run — matching how finished specialists already behave. Running members and mid-flight collect are unaffected. |
||
|
|
84823d7be1 |
fix(swarm): constrain agent_type to the registered roster via a dynamic enum (#344)
* fix(swarm): constrain spawn/handoff agent_type to the registered roster via enum swarm_spawn and swarm_handoff described agent_type / to_agent_type as a free-form string with only an 'e.g. teammate, subagent' hint, so a model could pass an unregistered type (e.g. 'worker') and only discover it was invalid from the runtime 'unknown agent type' error — wasting tool calls, especially on weaker models. Set the parameter's enum dynamically from the live roster (Registry.AgentTypes()), so the schema advertises the valid set (built-ins plus any user-registered agents) up front and an invalid type can't be sent. Falls back to a free-form string when the roster is unavailable, so the tool never advertises an empty, un-satisfiable enum. The runtime 'available agent types: …' error is kept as a backstop. * test(swarm): also assert handoff to_agent_type enum picks up a custom roster type Address review: the custom-type recheck only re-read spawnTool, so a regression leaving handoffTool on the built-in list would have passed. |
||
|
|
cfd065b565 |
fix(openai): always send message content so strict OpenAI-compatible servers accept it (#343)
Some OpenAI-compatible endpoints (e.g. glm-* on Ollama-cloud) reject a request whose message 'content' is absent or null with HTTP 400 'invalid message content type: <nil>'. The chat-completions mapper relied on 'content,omitempty', so a message with empty text (a tool result, an assistant turn that only makes tool calls, or a sub-agent that failed with no output) serialized with no content field at all. - Drop omitempty on chatMessage.Content and always set it (to "" when there is no text), so contentless messages serialize as "content":"" instead of being dropped. Standard OpenAI/xAI/groq already tolerated the omission; this makes the strict servers work too. - Skip a degenerate assistant turn that has neither text nor tool calls, mirroring the Anthropic/Gemini mappers (which already drop empty turns). Fixes the failure seen on glm-5.2:cloud after a sub-agent failed with empty output. |
||
|
|
fb3a0bff44 |
Document source-build sandbox helpers (#342)
Clarify that Linux source builds need the zero-linux-sandbox helper on PATH for native sandboxing, while macOS does not need an extra helper and Windows can self-dispatch from zero.exe. Also document optional release-style helper builds for Linux and Windows. Tested: git diff --check |
||
|
|
072f3877b5 |
fix(opengateway): correct gateway host, make it the recommended default (#341)
OpenGateway pointed at gateway.gitlawb.com, which does not resolve (NXDOMAIN), so every request failed with "no such host". The live gateway is opengateway.gitlawb.com — a flat OpenAI-compatible endpoint (/v1/chat/completions, Authorization: Bearer ogw_live_…) that smart-routes by model id across its upstreams (xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). - catalog: fix base URL to https://opengateway.gitlawb.com/v1, default model to mimo-v2.5-pro; add a Recommended descriptor field and hoist OpenGateway to the top of the catalog so it is the first/badged pick everywhere. - models: replace the curated model list with the gateway's real upstream models; fix the stale model-discovery fallback host. - providers catalog: sort recommended-first (CLI + snapshot) and render the ★ … (recommended) badge; same badge in the TUI /provider wizard and the setup onboarding list. - providerhealth: OpenGateway's flat /v1/models 404s by design, so probe its public /health endpoint for connectivity instead of reporting a false fail. - tests: update catalog order golden + transport/discovery expectations; add coverage for the recommended provider and the health-endpoint override. Co-authored-by: Kevin Codex <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
1dd884d6e9 |
chore(launch): MIT license, windows-arm64 install guard, TUI TTY guard (#340)
* chore(launch): MIT license field, windows-arm64 install guard, TUI TTY guard Three launch-readiness fixes the docs cleanup doesn't cover: - package.json: set "license": "MIT" (was the placeholder "SEE LICENSE IN LICENSE"), matching the LICENSE file and the README badge. - scripts/postinstall.mjs: skip gracefully on windows-arm64. (win32, arm64) resolves to a valid platform/arch but the release matrix ships no windows-arm64 artifact, so the installer would download a 404 and hard-fail npm install. Treat it like any unsupported target (warnSkip, exit 0) and point to the x64 build (which runs under emulation on Windows on ARM) or source. - internal/tui/run.go: fail fast when stdin is not a terminal. The interactive shell otherwise blocks forever in the Bubble Tea event loop on piped/redirected input (e.g. `echo "" | zero`); now it prints guidance toward `zero exec` and exits non-zero. Dependency-free ModeCharDevice check (no x/term added). Tests: windows-arm64 skips with exit 0; the interactive shell returns non-zero (not a hang) on non-TTY stdin. * fix(tui): use term.IsTerminal for the stdin guard; pin exit code in test Address review on the no-TTY guard: - Replace the os.Stdin.Stat()/ModeCharDevice heuristic with term.IsTerminal (github.com/charmbracelet/x/term, already in the module graph via bubbletea). It is a true TTY check — it rejects pipes, regular files, and non-terminal char devices like /dev/null — and fails closed: anything not a verified terminal blocks the interactive shell. - The test now asserts the documented exit code 2 from the guard, not just a non-zero result, so it proves the stdin guard fired. |
||
|
|
726f1512a3 |
Clean up public release docs (#339)
Rewrite the README around the initial public-release user flow, add the logo asset, and refresh install/update documentation. Remove internal planning, audit, design, and work-split documents from the public docs tree. Update stale documentation references and workspace seed expectations to point at public docs. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/workspaceseed Tested: git diff --check |
||
|
|
8c5898d5db |
feat(reasoning): typed per-provider reasoning capability from a models.dev catalog (#338)
* feat(reasoning): typed per-provider reasoning capability from a models.dev catalog
Providers describe reasoning control with incompatible concepts — OpenAI a
discrete effort enum (split by minor version), Anthropic and Gemini 2.5 a
thinking-token budget, some models only a thinking on/off toggle — which a flat
effort string cannot represent and a model-name heuristic cannot detect.
Add internal/reasoning: a Capability type (a model's reasoning flag plus its
typed Controls — effort/budget_tokens/toggle) and a Catalog that looks capability
up by provider + api model id. The data is an embedded, trimmed snapshot of the
community capability database (first-party providers only; routers excluded since
they carry generic/stale option lists), so detection is offline and exact instead
of guessed.
The package depends only on the standard library, so the model registry and
provider adapters can consume it without an import cycle. This change is purely
additive — nothing is wired into the live resolution path yet.
Tests pin the per-version ground truth (gpt-5 minimal-not-none, gpt-5.1
none-not-minimal, gpt-5.1-codex-max xhigh, gpt-5-pro locked to high; Anthropic
budget-only legacy vs effort-only newer with the budget removed; Gemini 2.5
budget/toggle vs Gemini 3 effort) and that every reasoning model Zero ships today
is covered by the snapshot.
* fix(reasoning): address review — presence-aware budget bounds, deep-copy lookup, first-party-only snapshot
- Control.Min/Max are now *int so an explicit bound is distinct from an omitted
one: Gemini's `min: 0` ("thinking can be disabled") and Anthropic's missing
max (unbounded) are preserved instead of both collapsing to 0. Budget() becomes
BudgetControl() returning the control so callers read the pointers.
- Catalog.Lookup returns a deep copy (clone) of the matched Capability, so a
caller can no longer mutate the shared embedded catalog through the returned
Controls / Values slices or budget pointers.
- Drop the google-vertex section from the snapshot: it re-hosts third-party MaaS
models (Anthropic/Moonshot/OpenAI/DeepSeek/Qwen/Meta), violating first-party
-only. google (AI Studio) already covers first-party Gemini, so the gemini
provider kind resolves there. Snapshot 158 -> 123 models, 16.8 KB.
Tests pin the explicit min: 0 preservation, the nil max on budget-only Anthropic
models, and that a mutation of a Lookup result does not leak into the catalog.
* fix(reasoning): address round-2 review — accessor deep-copy, max tier, provenance
- EffortControl/BudgetControl now return a cloned Control, so a caller holding a
Capability that shares state with the catalog cannot mutate Min/Max pointers or
the Values slice through an accessor result (CodeRabbit). Lookup already deep
-copies; this closes the accessor path too.
- Add modelregistry.ReasoningEffortMax ("max") + accept it in ValidReasoningEffort,
so the snapshot's "max" tier (e.g. gpt-5.1-codex-max, newer Anthropic) has a
constant to map to. No curated model lists it yet.
- Snapshot provenance: add a _fetched date and a regeneration recipe (documented
in catalog.go), so refreshes are reproducible/auditable.
- Document that Lookup takes the API model id (wire name), not the friendly
registry id, and that the match is exact (no case-fold/prefix-strip).
- Record the source-of-truth intent in the package doc: this catalog is the
intended authoritative source; modelregistry's name-pattern inference is demoted
to a fallback when it is wired in (a later change). Rename the `cap` builtin
shadow to `entry`.
Tests pin the accessor deep-copy isolation.
|
||
|
|
7a09a42a17 |
feat(tui): remove the /mode command (superseded by /model + /effort) (#337)
The /mode presets bundled a hardcoded Anthropic/Google model + reasoning effort + turn budget, so on other providers (Ollama-cloud, xAI, GPT-5, custom) /mode would switch to a model the user isn't authenticated to. With /model (any provider's models) and /effort (reasoning effort) covering the same ground flexibly, /mode is redundant in the TUI. Removes the /mode slash command, its handler, the mode picker, and the dispatch + stale comments; updates affected tests. The CLI `--mode` flag and internal/ modelregistry mode presets are kept (still used by headless exec). |
||
|
|
c0a0389b09 |
Polish TUI transcript rendering (#334)
* Polish transcript rendering Tighten transcript spacing, collapse noisy permission/tool rows, and render tool activity with cleaner action-oriented labels. Improve code and diff rendering with markdown highlighting, cleaner command output blocks, and grouped exploration summaries. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: git diff --check * Fix grep exploration rendering on Windows Keep search query text out of path normalization so regex escapes render consistently across platforms. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: git diff --check * Address transcript rendering review Cache highlighted streaming markdown so closed code blocks are not re-lexed on every live render. Tighten bare-code detection to avoid highlighting ordinary prose, restore manual permission audit rows, and make exploration details expandable. Also align diff content/header predicates and use display-width truncation for compact tool output. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: env GOCACHE=/tmp/zero-go-cache go test -race ./internal/tui Tested: env GOCACHE=/tmp/zero-go-cache go vet ./... Tested: git diff --check |
||
|
|
d99ac4d1e3 |
fix(openai): forward reasoning effort to the Codex Responses API (#336)
The Codex provider speaks the Responses API, where reasoning_effort is nested under `reasoning.effort` rather than sent as a top-level field. buildResponsesRequest never set it, so a user's chosen effort was silently dropped for every Codex model — the request always ran at the model default. Add a `reasoning` object to the Responses request and populate it from the request's effort, reusing the chat path's openAIReasoningEffort normalizer so only API-accepted values (minimal/low/medium/high) are sent and an empty or unsupported effort omits the field (no empty object, no 400). |
||
|
|
aa45edacc0 |
fix(modelregistry): one source of truth for a model's reasoning efforts (#335)
The /effort picker and the run-time effort resolver disagreed about which tiers a model supports. Registry.ReasoningEfforts falls back to name-based inference (reasoningEffortsForModelName) when the catalog lists no efforts, so the picker shows controls for proxy-served GPT-5 / Codex / o-series models. EffectiveReasoningEffort read model.ReasoningEfforts directly and never saw that fallback, so it coerced the same request to "none" — the picker advertised a tier the resolver silently dropped. Route both through a single effectiveReasoningEfforts helper so they can never diverge. A model the catalog enumerates with no efforts but whose name is a known reasoning family now has its requested effort honored end to end; non-reasoning models still resolve to "none". Also drop the orphaned, stale NOTE above forwardedReasoningEffort claiming effort "is not yet forwarded" (it is) and restore the reasoningEffortNotice doc comment to its own function. |
||
|
|
ca5e0b9bbb |
feat: context-aware compaction + usage gauge for all models (#332)
* fix(agent): enable auto-compaction for all models, not just registered ones Auto-compaction keys off the model's context window (enabled = ContextWindow > 0), resolved only from the curated registry. Unregistered models — GPT-5/Codex, xAI, Ollama-cloud, custom endpoints — resolved to 0, so both the proactive (~80%) and the reactive post-overflow compaction were silently disabled and long sessions overflowed. - TUI modelContextWindow now also consults live-discovered windows (accurate for proxy/custom models once /model has discovered them); unknown still returns 0 so display gauges show no guessed denominator. - New modelregistry.AgentContextWindow applies a positive FallbackContextWindow (200k) ONLY on the compaction-enable path (TUI run + CLI exec/spec), so compaction is on for every model while display stays accurate-or-empty. Exact windows always win. * feat(tui): show context usage (used / total + % gauge) for all models from launch The sidebar/status-line context gauge only shows a total + fill % when the active model's context window is known. For proxy/custom models (GPT-5/Codex, xAI, Ollama- cloud) the window isn't in the registry, so it showed only a used-token count. Warm model discovery for the active provider in Init() (background, non-blocking) so the active model's window is learned at launch and the gauge renders 'used / total tokens' + the fill % for every model — not just catalogued ones. Builds on the discovered-window resolution from the compaction fix. * fix(tui): address review — agent fallback on model-switch + active-provider window - Apply modelregistry.AgentContextWindow to /model and /mode pre-switch compaction requests (TargetContextWindow), so the 'compaction for all models' behavior also covers undiscovered/custom switch targets, not just the active run. - Scope the live-discovered context-window lookup to the active provider first (then others), so a model ID shared across providers resolves to the provider in use rather than an arbitrary match. - Tighten the resolution test to compare against the registry's actual gpt-4.1 window instead of != FallbackContextWindow. * fix(cli): give provider-discovered context windows precedence in headless exec Address the remaining review point: CLI exec/spec runs collapsed every uncatalogued model to the 200k fallback, ignoring its real window. resolveAgentContextWindow now, on a registry miss, queries the provider's live model list (resolving the credential from inline/stored/env) to get the model's actual context window before falling back. Discovery runs only on a registry miss (catalogued models pay no latency), is bounded by a 5s timeout, and degrades to the fallback on any error so a headless run is never blocked or failed. |
||
|
|
653cee60c1 |
feat(tui): dim the transcript backdrop behind overlays for visibility (#333)
Slash-command/file palettes (and pickers/wizards) were composited directly over the live transcript with no separation, so the chat bled through around the box and the overlay was hard to read. Add a scrim: when an overlay is open, dim the transcript backdrop (strip each line's colors and re-render faint) before compositing the bright overlay on top. Header and composer render separately and stay bright. Text is preserved (just dimmed), so context remains readable behind the palette. |
||
|
|
8aae0be6db |
feat(tui): compact one-line /model switch confirmation (#331)
The model-switch confirmation printed a 6-7 line block (Switched model. / model: / provider: / api model: / effort: / saved:). Collapse it to a single summary line — '<model> · <provider>[ · effort …][ · saved]' — keeping the deprecation/redirect notice line when present. Tests updated to the new format. |
||
|
|
8ea8831c30 |
fix(model): expose reasoning effort for GPT-5 / Codex / o-series (#330)
/effort showed no controls for ChatGPT (GPT-5) because those models aren't in the curated registry and the OpenAI catalog entries carry no reasoning efforts, so Registry.ReasoningEfforts returned nil even though GPT-5 supports reasoning_effort. Add a name-based fallback: when a model isn't enumerated with efforts, infer them for known reasoning families — GPT-5 / gpt-5.x Codex (minimal/low/medium/high) and the o-series (low/medium/high). Non-reasoning models (GPT-4.1/4o, local models) stay empty, so /effort still hides controls where they don't apply. |
||
|
|
a4b2e01fe6 |
feat: encrypted provider credentials + provider-aware /model (#329)
Encrypted credential storage - internal/securefile: AES-256-GCM at-rest crypter (fail-closed, atomic, locked). - internal/credstore: provider API-key store, keyring-first with an encrypted-file fallback; plaintext only via an explicit ZERO_CRED_STORAGE=file opt-out. - Provider API keys no longer live in config.json: captured straight into the store from the wizard/CLI setup, and any existing inline plaintext key is migrated to the store on launch (fail-soft; stripped only after the store write succeeds). - buildProvider and every model/provider switch reload the credential (stored key, env var, or OAuth bearer), so a stored-key provider always authenticates. - `zero auth logout <provider>` now also removes the stored API key and its marker. /model and /provider - /model lists every authenticated provider (filtered to those with a usable credential), grouped per provider under section headers. - Each provider's models are live-discovered (its real served models) rather than a static catalog; local providers show only the models actually pulled. - Selecting a model from another provider switches provider and model together. - /provider offers keep / replace / remove when a provider already has a stored key. Gated throughout: go build, go vet ./..., go test ./... -race, lint. |
||
|
|
fb3277f46e |
feat(tui): tabbed ask_user questionnaire in the composer with suggested answers (#327)
* feat(tui): tabbed ask_user questionnaire rendered in the composer
Redesign the interactive ask_user prompt to match the requested mockup: when a
question is raised the composer text box becomes the questionnaire.
- Multi-question prompts render as a row of TABS (one per question + a trailing
Confirm tab). Tab / Shift+Tab switch questions, answers are given in any order,
and Enter on Confirm submits them all. A single-question prompt submits on
answer (no Confirm step) and shows no tab row.
- Each option can carry a one-line DESCRIPTION (contract: per-option
optionDescriptions, or option objects {label, description}); options render
numbered with the description dimmer underneath and the recommended one tagged.
- Per-question optional `header` gives each tab a short title (falls back to a
trimmed question).
- Rendered in the composer/footer region (footerView), replacing the text box
while active, instead of a scrolling transcript card.
- Keys: Tab move · ↑↓ select · Enter confirm · Esc dismiss; a printable key in
the picker drops into free-text; Esc from "type your own" returns to the picker;
multi-select questions are free-text with the options shown as suggestions.
- Headless unaffected (zero exec never wires OnAskUser).
Removed the old single-question sequential state (cursor/typing/index,
renderFocusedAskUserPrompt, resolveAskUser). Tests rewritten for the tabbed flow
(picker default, type-your-own, multi-question tabs + Confirm, Esc dismiss,
descriptions render). make build / go vet / go test ./... -race / make lint green.
* fix(tui): ask_user questionnaire renders on the black terminal canvas
It was painting a gray panel background (styledBlockFill with zeroTheme.panel +
onPanel-wrapped foregrounds). Since the questionnaire replaces the composer, it
should match it: bare foregrounds on the terminal canvas (black) with the
composer's lineStrong border and no fill. The lime cursor/badge highlights and
the recommended tag stay.
* fix(tui): address PR review — schema/parser alignment, single-question Tab
- ask_user schema: options.items is declared as string, so advertise only the
string form (descriptions go via the parallel optionDescriptions array). The
parser stays object-tolerant defensively, but the schema no longer tells
strict-schema providers to send objects it would reject.
- moveAskUserTab no-ops for single-question prompts (no tab strip / Confirm tab),
so Tab/Shift+Tab can't move the prompt into the hidden Confirm state. Test added.
|
||
|
|
95aaca6055 |
fix(tui): paint transcript selection once, not twice (#328)
Selecting text in the transcript lit up in two places: the real selection plus a copy shifted right by the reading-column gutter (e.g. selecting "help" also highlighted "today"). Root cause: assistant, user, and reasoning rows self-painted the selection highlight in their render functions using the UNSHIFTED textStart, but the selection points (anchor.x/cursor.x) are in the gutter-shifted screen coordinate the mouse maps to. So the self-paint landed gutter cells off, and finalizeTranscriptBodyRow then painted the highlight AGAIN at the correct shifted position — two highlights, one gutter apart. The rendered/toolResult/ specialist rows were already converted to paint-once-at-finalize; these three were left behind. Fix: stop self-painting in renderSelectableUserRow, renderSelectableAssistantRow, and renderSelectableReasoningBlock. They now return the canonical renderRow output (already line-aligned with their selectable spans) and let finalizeTranscriptBodyRow be the single painter, in the same shifted coordinate the mouse uses. Removes the now-dead renderTranscriptSelectableText / renderTranscriptSelectableMarkdownText helpers. Regression test TestTranscriptSelectionPaintsHighlightOnceNotTwice selects a sub-range of an assistant row and asserts the selection style is emitted exactly once; it fails on the pre-fix code (painted 3x, including the gutter-shifted copy). |
||
|
|
1d1fa7c514 |
feat(tui): ask_user suggested answers with a recommended default (#326)
* feat(tui): suggested answers with a recommended default for ask_user When the agent asks a clarifying question mid-task, it can now offer 2-4 suggested answers and mark one recommended; the interactive TUI renders a selector (arrow keys, lime cursor, "(recommended)" tag) with the cursor resting on the recommended option, plus a trailing "✎ Type my own answer…" entry that drops into the existing free-text composer. Whichever the user picks — a suggested option's text or their typed answer — flows through the same answer channel the agent loop already waits on. Optional and backward-compatible: a question with no options renders the plain free-text prompt exactly as before. Headless `zero exec` is unaffected (it never wires OnAskUser; the non-interactive best-assumption fallback stands). - contract: agent.AskUserQuestion / tools.AskUserQuestion gain Recommended; the tool schema advertises optional options + recommended; parser keeps recommended only when it resolves to one of the options (canonical text). - system prompt: tells the model it MAY supply options + a recommended one. - TUI: pendingAskUserPrompt gains cursor/typing; ask_user_prompt.go holds the selector logic mirroring permission_prompt.go; renderFocusedAskUserPrompt renders the picker or the free-text prompt. Tests: selector defaults to recommended and returns its text; "type my own" falls back to free-text and returns the typed text; no-options stays plain free-text. make build / go vet / go test ./... -race / make lint green. * fix(tui): ask_user — single input, Esc steps back from "type my own" Two UX fixes from interactive testing: - No double display: the focused ask card no longer echoes the in-progress free-text answer (it was shown both inside the card AND in the composer text box). The composer is now the single input; the card just shows the question and a hint. renderFocusedAskUserPrompt no longer takes the input string. - Esc from "type my own" returns to the selector for that question instead of cancelling the whole questionnaire (escapeAskUser); a question with no options, or the selector itself, still cancels on Esc as before. Tests: type-my-own asserts the card does not echo the typed text and the hint flips to "type your own answer"; new test covers Esc returning to the selector (no cancel, no answer delivered) then selecting a suggested option. * fix(tui): ask_user review fixes — multi-select, selector typing, dead code Address the three confirmed findings from the code review: - multi-select no longer silently narrowed to one option: a single-pick selector cannot represent MultiSelect, so a multi-select question now renders as free-text with its options surfaced as suggestions; the user types one or more and the answer is returned verbatim (syncQuestionState; escapeAskUser and the renderer updated to match). - typing in selector mode no longer discarded: a printable keystroke while the selector is showing flips into free-text and captures the key (model.go key capture), instead of accumulating in a hidden input that Enter then overrode with the highlighted option. - removed dead submitAskUserAnswer (the Enter handler now routes through confirmAskUser; the orphan had no callers). Tests: multi-select falls back to free-text and returns the typed answer; a printable key in the selector switches to free-text and is captured. Full gate green (build / vet / go test ./... -race / make lint). * fix(tui): address PR review — element-wise option compare, clearer Esc copy - ask_user_test: compare option slices with slices.Equal instead of strings.Join, so the parser test can't be fooled by ["A,B"] vs ["A","B"] or nil vs [""]. - ask_user prompt: the Esc hint said "skip", which reads like "skip this question" on a multi-question prompt even though Esc ends the rest of the questionnaire (remaining answered by best-assumption). Reworded to "skip the rest" in the selector and free-text hints; "type my own" keeps "go back". |
||
|
|
b8b0bb7f65 |
Move deferred tool discovery into tool search (#324)
* Move deferred tool discovery into tool search Keep deferred tool discovery in the tool_search definition instead of appending it as a per-turn user message, so models do not answer or acknowledge internal discovery metadata. Preserve deferred loading behavior: hidden tools remain hidden until loaded, tool_search stays reachable for allowlisted deferred tools, and retry paths keep loaded/deferred state. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools -run 'Test.*Deferred|Test.*ToolSearch|TestRunLoadsDeferredToolThenAdvertisesNextTurn|TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestAllowlistedDeferredToolsKeepToolSearchReachable|TestDisabledToolSearchFallsBackToEager' Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools ./internal/cli Tested: git diff --check * Clarify deferred tool search selection |
||
|
|
f8ac5eceb0 |
fix(agent): gate headless run completion on honesty (no false-success on no-tool-call / self-reported-incomplete turns) (#325)
* fix(agent): don't end a run as success on a no-tool-call turn mid-task
A turn that produced text but no tool call was always accepted as the final answer, so the loop reported success even when the model stopped mid-task (e.g. ended on "...Let me check the SSH configuration:" with plan steps still pending).
Add an opt-in completion gate (Options.RequireCompletionSignal): when a turn has no tool call and work clearly remains -- pending update_plan items, or the message ends on a continuation cue -- re-prompt the model to continue instead of finalizing. Bounded by maxContinueNudges and still by MaxTurns/the deadline; once the budget is spent the run finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default off, so the interactive path is byte-identical.
Genuine single-turn completions (no pending plan, no cue) still finalize as success. Covered by internal/agent/completion_gate_test.go.
* feat(exec): report stalled headless runs as INCOMPLETE (exit 4)
Enable the agent completion gate for headless exec (RequireCompletionSignal) and map Result.Incomplete to run_end status "incomplete" with a new exit code 4, so a run that stalled mid-task (model stopped without a tool call while work remained, continue budget exhausted) is no longer reported as success. Interactive callers are unaffected.
* fix(agent): downgrade self-reported non-completion; advisory task-grounded acceptance
Reduce -- not eliminate -- false-success on headless runs. Two DETERMINISTIC, unit-tested gates (with the plan gate from the prior commit):
(a) self-report downgrade: if the final message admits the model guessed or could not meet the objective, finalize INCOMPLETE (exit 4), never success. Inability is matched by first-person STEMS generalized over verb/tense ("I cannot/can't/could not/am unable to/do not have/unable to ...") plus guess/fallback/uncertainty phrases, with a guard so success-y negations ("could not find any issues", "cannot reproduce") are not misread. (b7bc0b8's plan gate already forces INCOMPLETE on pending/in_progress update_plan items at termination.)
(b) task-grounded acceptance is ADVISORY, not a guarantee. When --self-correct is on it demands one bounded acceptance pass that re-derives the task's stated criterion and runs a concrete check, discouraging three false-success patterns (well-formed==correct, existing-tests-pass==objective-met, result==baseline-it-was-told-to-beat). But it is a prompt: a model that ignores it and confidently claims "PASS, all requirements met" still slips, because ZERO has no general oracle to verify correctness against a task's hidden criterion. Empirically (TB-2, qwen3-coder:480b) this reliably catches admissions and incomplete plans and REDUCES false-success, but a confident false PASS on a model-ceiling task is a residual, fundamental gap -- not a tuning miss.
Default off (RequireCompletionSignal); interactive callers are byte-identical. Covered by internal/agent/{acceptance_gate_test.go,completion_gate_test.go}.
* feat(exec): surface the INCOMPLETE reason in run_end and logs
When a headless run finalizes as INCOMPLETE, include Result.IncompleteReason in the session error event and a stderr warning so an honestly-incomplete run (e.g. "the final message admits the objective was not met") is debuggable rather than an opaque exit 4.
* fix(agent): harden completion gate per PR review
Addresses the correctness findings from the PR review.
self-report (#1): drop behavior-describing phrases ("fall back to",
"placeholder value", bare "best guess", "as a fallback", "without proper")
that also match legitimate final answers; keep first-person/uncertainty
admissions only, since these are matched without a context guard.
self-report (#5): scan every occurrence of each inability stem so an early
success-negation ("could not find any examples") no longer masks a later
genuine admission with the same stem ("could not implement it").
continuation cue (#6): require a trailing colon AND an action lead-in on the
final clause; stop flagging recommendations, plain summary colons, and
sign-offs. Still catches the mid-line "...Let me check the config:".
gate order (#8): check the self-report admission BEFORE pending-plan, so an
admitted-impossible task downgrades immediately with the accurate reason
instead of burning continue-nudges.
pending plan (#3): treat a pending/in_progress update_plan item as a
NUDGE-only weak signal -- it no longer forces INCOMPLETE on its own (a
completed run that left stale plan bookkeeping is trusted). Only a
continuation cue or a self-report admission finalizes INCOMPLETE.
max-turns (#4): a run cut off at the MaxTurns ceiling now finalizes
INCOMPLETE under the gate instead of being reported as success.
exec json/cron (#2, #7): for -o json, emit the terminal done with exit 4 on
an incomplete run (final() pre-emits a success done:0 for json that would
otherwise mask it); emit an error event -- not just a warning -- so the cron
failure extractor can recover the reason.
Deferred (noted on the PR): acceptance-only-when-mutated (#9, cost) and
reusing tools.normalizePlanStatus (#10, would widen scope to internal/tools).
Tests: add TestContinuationCueMatching and TestMaxTurnsCutoffIsIncompleteUnderGate,
extend TestSelfReportedIncompletionMatching with the #1/#5 cases, and replace
the in_progress=>incomplete test with TestPendingPlanAloneDoesNotForceIncomplete.
make build / go vet / make lint / go test ./... -race all green.
|
||
|
|
d29ab98a79 |
Improve sandbox permission handling (#323)
Retry shell commands outside the sandbox when sandboxed output only reflects the sandbox namespace, and keep network retries sandboxed when a network grant is enough. Show user-facing permission reasons for escalated shell commands instead of exposing internal sandbox policy text. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui -run 'TestRunUsesEscalatedJustificationForPermissionPrompt|TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt|TestPermissionPromptMapsEscalatedSandboxReason|TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput' Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./... |
||
|
|
80ab238361 |
Align sandbox command approval behavior (#322)
* Align shell approval sandbox execution Make approved shell command-prefix executions use the escalated sandbox path when denied-read policy allows it, so remembered approvals do not still hit the restrictive sandbox. Allow auto sandbox mode to degrade to a direct command plan when native command wrapping is unavailable, while strict sandbox require continues to fail closed. Update regression tests for degraded execution, prefix-approved shell calls, and unavailable native sandbox metadata. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsWithDegradedUnavailableNativeSandbox|TestBashToolRunsWithDegradedUnavailableNativeSandbox|TestBashRequireEscalatedBypassesNativeSandboxAfterApproval|TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval' Tested: GOCACHE=/tmp/zero-go-cache go test ./... -run '^$' Tested: git diff --check * Align sandbox command approval behavior Evaluate reusable shell approvals across plain command segments so prefix grants can cover commands with safe read-only tails without relying on command-specific sandbox exceptions. Keep process inspection commands sandboxed by default; unsandboxed host access continues to require explicit escalation or a narrow command-prefix approval. Improve shell tool guidance around sandboxed additional permissions, full escalation, justifications, and narrow reusable prefixes. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/agent Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./... * Normalize explicit sandbox backend platforms Infer the platform from a caller-provided sandbox backend name before choosing the manager runtime platform. This keeps Linux, macOS, and Windows backend plans deterministic across host OSes when tests or callers provide a backend without an explicit Platform field. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./... |