Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ OVERVIEW for the design.
## Command surface (deliberately tiny)

`scay` opens the interactive session; **everything else lives inside it** as
slash commands (`/auth`, `/model`, `/mcp`, `/compact`, `/clear`, `/exit`).
slash commands (`/auth`, `/model`, `/effort`, `/mcp`, `/compact`, `/clear`, `/exit`).
The only subcommands are `scay yolo` (session with the approval gate bypassed)
and `scay update` (self-update from GitHub releases), plus `version`/`help`.
Don't add new top-level subcommands — extend the slash commands instead.
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ First time, scay walks you through setup right there:
1. **`/auth`** — pick a provider and authenticate. OpenAI accepts an API key or
an experimental ChatGPT subscription login; Ollama needs neither. Credentials
are stored with `0600` permissions under `~/.config/scay`.
2. **`/model`** — pick the provider, model, and thinking effort. With Ollama,
the menu lists whatever models you actually have installed.
3. **Type what you want.** Anything that isn't a `/command` goes to the model.
2. **`/model`** — pick the provider and model. OpenAI lists
the models available to the current account; Ollama lists whatever is
installed locally. Both fall back to built-in suggestions when offline.
3. **`/effort`** — optionally choose the thinking effort. Use `/effort high`
to set one directly.
4. **Type what you want.** Anything that isn't a `/command` goes to the model.

<p align="center">
<img src="docs/img/setup.svg" alt="First run: scay guides you through /model, listing installed Ollama models" width="700">
Expand All @@ -67,7 +70,8 @@ Slash commands, inside the session:

| Command | What it does |
|---|---|
| `/model` | Pick provider, model, and thinking effort (applies live) |
| `/model` | Pick provider and model (applies live) |
| `/effort [level]` | Pick thinking effort, or set `off`, `low`, `medium`, `high`, or `max` directly |
| `/auth [provider]` | Authenticate; OpenAI offers API key or experimental ChatGPT login |
| `/mcp` | Manage MCP servers — `add`, `list`, `remove`, `get`, `tools` |
| `/compact` | Squash a long history into a summary, freeing context |
Expand Down Expand Up @@ -101,6 +105,10 @@ without touching your saved setup. ChatGPT login relies on the Codex protocol
and is explicitly experimental. Responses stream on all four backends, and
transient API failures retry automatically with backoff.

For protocol troubleshooting, set `SCAY_OPENAI_DEBUG=1`. The opt-in diagnostic
output includes HTTP status, request ID, model, refresh attempts, and SSE event
types, but never credentials, prompts, or encrypted reasoning content.

## MCP servers (optional)

scay is an [MCP](https://modelcontextprotocol.io) client: plug in external
Expand Down
28 changes: 22 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# scay roadmap

> **Current state: v0.2.0 pre-release audit complete (2026-07-29).** A full review
> **Current state: OpenAI Subscription stabilization + dynamic model discovery
> implemented and mock-tested (2026-08-09).** The Responses adapter now has
> secret-free opt-in diagnostics, classified errors, complete partial-stream/item
> replay, hardened refresh recovery, account-aware `/model` discovery, a GPT-5.6
> fallback catalog, and capability-aware reasoning. `go test ./...`, vet, and build
> are clean. **Still required before calling the milestone fully live-validated:**
> five consecutive real Subscription sessions, real refresh-token rotation and
> restart, `/compact` on both auth modes, and the versionless `client_version`
> catalog spike. Earlier release history follows. v0.2.0's full review
> pass (three parallel reviewers over provider/cli+config/agent+tools+mcp) found and
> fixed ~20 defects before going public — see M9 below for the list. gofmt/vet/
> build/test clean, windows cross-build clean, release.sh dry run clean; live
Expand All @@ -12,10 +20,8 @@
> banner is now the four-row block-art octopus from the website
> (`internal/cli/mascot.go`, tested) with version, provider · model, and effort
> beside it — red on a failed startup build; docs screenshots updated to match.
> **Next:** live
> checks vs Anthropic/OpenAI/Gemini when real keys are available — including the
> M8 items and the new Responses fixes (incomplete/refusal handling,
> `strict:false`, Codex `include: reasoning.encrypted_content`).
> **Next:** run the credential-backed M10 matrix, then live checks vs
> Anthropic/Gemini when real keys are available.
>
> Update this header every session so work resumes instantly.

Expand Down Expand Up @@ -89,9 +95,19 @@ Legend: `[x]` done · `[~]` in progress · `[ ]` todo
- [x] **CLI/auth hardening:** Ctrl-C during hidden key entry restores terminal echo before exiting; the OAuth callback validates `state` in the handler so a stray localhost request can't dead-end the login; `scay update` verifies the release's published sha256 checksums before swapping the binary; browser-launcher zombie reaped; plaintext-credential warning when `/mcp add --project` stores headers.
- [x] Known accepted limitation: Ctrl-C during a y/N approval prompt takes effect only after Enter (the blocking stdin read is shared with the REPL); the default answer is deny, so nothing runs.

## M10 — OpenAI Subscription stabilization + model discovery (2026-08-09)
- [x] **Safe diagnostics and errors:** `SCAY_OPENAI_DEBUG=1` reports status, request ID, model, refresh state, and SSE event types without tokens, prompts, tool arguments, or encrypted reasoning. HTTP/model/auth/rate-limit/protocol/stream failures are distinguishable; cross-origin request redirects and OAuth token redirects are blocked.
- [x] **OAuth hardening:** proactive expiry and forced-401 refresh share one serialized path; missing refresh state and refresh failures direct the user to `/auth openai`; rotated access/refresh/account/expiry state is atomically persisted and reload-tested. The documented cross-process lock limitation remains.
- [x] **Streaming/replay:** completed message, reasoning, and multiple function-call items are merged across `response.output_item.done` and empty terminal output; partial text, `response.incomplete`, refusal deltas/items, malformed SSE, and premature stream close are regression-tested.
- [x] **Dynamic `/model`:** Platform uses `GET /v1/models` with conservative GPT/Responses filtering; Subscription uses `GET /backend-api/codex/models` with account header, picker visibility, priority, subscription-only model support, and capability parsing. Both use a three-second bound, one OAuth refresh after 401, validate remote identifiers before display/use, preserve the configured model, retain free-form input, and fall back cleanly.
- [x] **GPT-5.6 + capability-aware effort:** fallback includes `gpt-5.6`, Sol, Terra, Luna, 5.5, and 5.2. Subscription catalog efforts drive request mapping; GPT-5.6 statically maps `off`→`none` and `max`→`max`, while earlier Platform fallback remains `minimal`→`xhigh`.
- [x] **Separate model/effort UX:** `/model` now changes only provider/model; `/effort` shows the neutral ladder with the effective OpenAI mapping, and `/effort high` applies a level directly. Both reload live without clearing same-provider history.
- [x] **No runtime expansion:** standard library only, no app-server process, no persistent model cache, and no fabricated Codex `client_version`.
- [ ] **Live acceptance (credentials required):** five consecutive Subscription text/tool sessions; preventive expiry plus forced 401 and persisted token rotation across restart; `/compact`→turn on Platform and Subscription; incomplete/interrupted recovery; effort `none`/`max` on visible GPT-5.6 models; versionless Subscription catalog request and fallback behavior.

## M7 — Debloat: one binary, everything in-session (2026-07-04)
- [x] Removed `cmd/scay-tui` (the nested Charm Go module) and all references (CI, release script, gitignore, docs); the block-art octopus moved into the CLI banner
- [x] Collapsed the command surface: `auth`/`model`/`mcp` subcommands and `-p` one-shot removed; everything runs inside the interactive session as `/auth`, `/model`, `/mcp`, `/compact`, `/clear`, `/help`, `/exit`
- [x] Collapsed the command surface: `auth`/`model`/`mcp` subcommands and `-p` one-shot removed; everything runs inside the interactive session as `/auth`, `/model`, `/effort`, `/mcp`, `/compact`, `/clear`, `/help`, `/exit`
- [x] Session works unconfigured (guides through /auth → /model) and rebuilds the agent live on config changes (MCP sessions closed + reconnected; history survives model/effort changes, cleared on provider switch since reasoning blobs are provider-specific)
- [x] `scay yolo`: the session with the approval gate bypassed (replaces `-p --yes`)
- [x] `scay update`: stdlib self-update — latest GitHub release tag → download `scay_<os>_<arch>.tar.gz` → atomic rename over the running executable (darwin/linux)
Expand Down
72 changes: 55 additions & 17 deletions docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ Every dependency and abstraction must earn its weight. Concretely:
API key or an experimental browser OAuth flow compatible with Codex. Credentials
sit behind a renewable `Credential` interface; the ChatGPT route is isolated
because OpenAI does not yet publish third-party OAuth client registration.
3. **Model selection → curated list + free-form.** A short built-in list of current
models per provider, plus "type any model id". Effort from a unified ladder.
3. **Model selection → account-aware + fallback + free-form.** OpenAI and Ollama
discover usable models with a three-second bound; a short built-in list covers
offline/error cases, and users can always type any model id. Other providers
use curated suggestions. Effort stays on a unified ladder.
4. **Key storage → `0600` file + env.** Stored under `os.UserConfigDir()`
(`~/.config/scay/`) with `0600` perms; env vars override the file.

Expand Down Expand Up @@ -100,9 +102,11 @@ replays next request — is identical on both paths. That matters most for:
`input_json_delta` fragments concatenate into tool-call args; `redacted_thinking`
arrives whole in `content_block_start` and is kept raw.
- **OpenAI** (`stream: true`, Responses SSE): `response.output_text.delta` and
reasoning deltas render immediately; `response.completed` carries the complete
output-item list and usage. Function calls and reasoning items are replayed
verbatim on the next request.
reasoning deltas render immediately. Completed message, reasoning, and function
items are retained from `response.output_item.done` and merged with the terminal
`response.completed` or `response.incomplete` payload. This covers Codex streams
whose terminal `output` is empty, partial text, refusals, and multiple tool calls;
opaque items are replayed verbatim on the next request.
- **Gemini** (`:streamGenerateContent?alt=sse`): each frame is a chunk of parts.
Parts carrying a `thoughtSignature` (and all thought/functionCall parts) are kept
raw for replay; unsigned plain-text fragments are merged into one block.
Expand All @@ -121,7 +125,31 @@ Ctrl-C aborts a turn.
| `low` | `low` | effort `low` | `low` | `"low"` |
| `medium` | `medium` | effort `medium`| `medium`/dynamic | `"medium"` |
| `high` | `high` | effort `high` | `high` | `"high"` |
| `max` | `xhigh` | effort `max` | `high` + budget -1 | `"max"` |
| `max` | `xhigh`/`max` | effort `max` | `high` + budget -1 | `"max"` |

For OpenAI, the adapter maps the neutral endpoints to the actual model
capabilities when the Codex catalog provides them: `off` chooses `none` or
`minimal`, while `max` chooses `max`, `xhigh`, or the highest advertised level.
GPT-5.6 has a static `none`…`max` fallback; earlier Platform models retain the
`minimal`…`xhigh` fallback because `/v1/models` does not expose capabilities.

### OpenAI model discovery

`/model` selects only provider/model and calls `GET /v1/models` for Platform
credentials and `GET /backend-api/codex/models` for ChatGPT credentials, using
the same bearer, account header, and one-refresh-after-401 path as Responses.
Platform results are conservatively filtered to GPT text models suitable for
Responses; Codex results respect picker `visibility`, `priority`, and advertised
reasoning levels. Remote model IDs and effort values are validated before display
or use so catalog data cannot inject terminal controls or arbitrary effort values.
`supported_in_api` is intentionally not used to hide subscription-only models;
the official client applies that flag only outside ChatGPT auth. The configured
model is always retained even if temporarily absent.

Discovery has no persistent cache and falls back to the curated GPT-5.6/5.5/5.2
list on timeout, auth failure, empty data, or malformed JSON. scay deliberately
omits the Codex-specific `client_version` rather than claiming to be an official
Codex release; compatibility of the versionless request remains a live-test item.

## The agent loop

Expand Down Expand Up @@ -150,6 +178,15 @@ attempt's response is handed back untouched so each adapter keeps its own error
formatting. Streams retry only up to the first byte; once output is flowing, a
drop surfaces as an error rather than a silent re-request.

OpenAI errors add stable categories (`authentication`, `model_unavailable`,
`rate_limit`, `protocol`, or `stream_interrupted`) and a request ID when one is
returned. Setting `SCAY_OPENAI_DEBUG=1` adds secret-free protocol diagnostics:
status, request ID, model, refresh state, and SSE event type only. Tokens,
authorization codes, prompts, tool arguments, and encrypted reasoning are never
logged. OpenAI requests follow redirects only within the original origin, while
OAuth token POSTs do not follow redirects, preventing sensitive bodies or account
headers from being replayed to another host.

Related hardening: the Anthropic adapter merges adjacent same-role wire
messages, because a legal scay history can produce them (tool results followed
by fresh user input after an interrupted turn, or a compaction summary) while
Expand All @@ -175,9 +212,10 @@ the loop by holding the output pipe.
file. OpenAI supports `api_key` and experimental `chatgpt` credentials; the latter
stores access token, rotating refresh token, account ID, and expiry in that protected
file. `Credential` resolves a current token at request time, refreshes once after a
401, and persists the rotated token atomically. The browser login uses Authorization
Code + PKCE on a localhost callback; it has no third-party client-registration
guarantee from OpenAI and is deliberately marked experimental.
401, and persists the rotated token atomically. Missing/revoked/incomplete refresh
state reports a direct `/auth openai` recovery path. The browser login uses
Authorization Code + PKCE on a localhost callback; it has no third-party
client-registration guarantee from OpenAI and is deliberately marked experimental.

## MCP

Expand Down Expand Up @@ -208,14 +246,14 @@ servers are actually configured.
## The interactive session

`scay` drops straight into the session; there is nothing else to launch or
compile. The session works even before any model is configured — `/auth` and
`/model` run inside it and the agent is rebuilt live (MCP sessions closed and
reconnected) when they change the config. History survives a model or effort
change but is cleared on a provider switch, because replayed reasoning blocks
are provider-specific opaque blobs another backend would reject.

Slash commands: `/help`, `/auth`, `/model`, `/mcp …`, `/compact`, `/clear`,
`/exit`. Lines are tokenized with quote support so things like
compile. The session works even before any model is configured — `/auth`,
`/model`, and `/effort` run inside it and the agent is rebuilt live (MCP sessions
closed and reconnected) when they change the config. History survives a model or
effort change but is cleared on a provider switch, because replayed reasoning
blocks are provider-specific opaque blobs another backend would reject.

Slash commands: `/help`, `/auth`, `/model`, `/effort [level]`, `/mcp …`,
`/compact`, `/clear`, `/exit`. Lines are tokenized with quote support so things like
`/mcp add --header 'Authorization: Bearer x' srv https://…` work. Rendering is
deliberately plain: assistant text streams verbatim, tool calls print as dim
`· tool args` lines (a red `✗` on failure), reasoning pauses show a single dim
Expand Down
22 changes: 20 additions & 2 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ func (a *Agent) Run(ctx context.Context, s *Session, input string) error {
return nil // the model is done
}

for _, call := range calls {
for callIndex, call := range calls {
// Stop dispatching once the user interrupts — mutating tools must
// not keep running (or keep prompting) after a Ctrl-C.
// not keep running (or keep prompting) after a Ctrl-C. Every queued
// call still needs a result so the provider history remains valid.
if err := ctx.Err(); err != nil {
appendSkippedToolResults(s, calls[callIndex:], err)
return err
}
p.ToolUse(call.Name, briefArgs(call.Args))
Expand All @@ -101,11 +103,27 @@ func (a *Agent) Run(ctx context.Context, s *Session, input string) error {
Text: text,
IsError: isErr,
})
if err := ctx.Err(); err != nil {
appendSkippedToolResults(s, calls[callIndex+1:], err)
return err
}
}
}
return ErrMaxIterations
}

func appendSkippedToolResults(s *Session, calls []provider.ToolCall, cause error) {
for _, call := range calls {
s.Append(provider.Message{
Role: provider.RoleTool,
ToolCallID: call.ID,
ToolName: call.Name,
Text: "Error: tool call skipped after cancellation: " + cause.Error(),
IsError: true,
})
}
}

// callModel performs one model call, forwarding assistant text to p as it
// arrives. Providers without streaming fall back to Chat transparently; either
// way the returned Response is the complete turn and the emitted text ends
Expand Down
Loading
Loading