# LOG

## 2026-03-23

**User asked:** Implement custom commands (`!command`) for the REPL per the plan in `plans/custom-commands.md`. Commands are looked up in `~/.config/swival/commands/`, executed with `base_dir` as the first argument (like reviewers) and `cwd=base_dir`, and their stdout is injected into the conversation. Added `_repl_run_custom_command()` and `_truncate_for_context()` helpers, a dedicated branch in `repl_loop()` with full interrupt/history/exhaustion parity, updated `/help`, and wrote 29 tests covering happy path, errors, truncation, cwd, env vars, and REPL integration.

**User asked:** Enhance `/init` to generate AGENTS.md with build/test/lint/post-edit workflow info, not just conventions. Created a plan, iterated through 5 rounds of review to nail down: CI-vs-local precedence for the post-edit command, validation predicate (file exists + `## Workflow` is the first section), retry logic with cause-specific `INIT_RETRY_PROMPT` template, output budget (3000 chars advisory), and deterministic test coverage.

**What I did:**
- Rewrote `INIT_PROMPT`, `INIT_ENRICH_PROMPT`, `INIT_WRITE_PROMPT` in `agent.py` — now agent-optimized, covering workflow discovery + conventions
- Added `INIT_RETRY_PROMPT` (format string with `{reason}` placeholder) and `_INIT_AGENTS_MD_BUDGET` constant
- Added `validate_agents_md()` predicate: checks file exists, `^## Workflow\s*$` line present, and it's the first `## ` heading
- Modified `/init` handler: 3 primary passes + conditional retry on validation failure + advisory size warning
- Updated 2 existing tests, added 16 new tests: prompt-contract assertions, table-driven reason-string checks, retry/warn paths, `validate_agents_md` unit tests
- All 2190 tests pass, `make check` clean

## 2026-02-28

**User asked:** Implement the snapshot feature from `plans/final.md`.

**What I did:** Full implementation of the proactive context collapse feature:

- Created `swival/snapshot.py` — SnapshotState class with save/restore/cancel/status actions, implicit checkpoint resolution (backward scan for user messages or previous restore boundaries), dirty scope tracking (READ_ONLY_TOOLS allowlist), history with prompt injection, and collapse semantics (synthetic recap messages with no orphaned tool_call_ids)
- Added snapshot tool schema to `tools.py` (TOOLS list + dispatch routing)
- Integrated into `agent.py` — dirty tracking after non-readonly tool calls, snapshot nudge after 5+ consecutive read-only turns, recap message scoring (+5) in compaction heuristics, snapshot_state passed through handle_tool_call/run_agent_loop/repl_loop/_repl_clear, summary display at loop end
- Wired `snapshot_state` in `session.py` (_make_per_run_state, _build_loop_kwargs)
- Added snapshot metrics to `report.py` (build_report/finalize accept snapshot_stats)
- Added few-shot guidance to `system_prompt.txt`
- Documented tool API in `docs.md/tools.md`
- Created 55 tests in `tests/test_snapshot.py` covering: save/restore/cancel/status, implicit vs explicit checkpoints, dirty tracking, force override, history cap, prompt injection budget, token savings, dispatch integration, compaction scoring

- All 1226 tests pass, lint clean, format clean

**User asked:** Fix four issues found in review.

**What I did:**

1. **Critical — orphaned tool results after restore.** `_restore()` was collapsing the current turn's assistant message (which contains the snapshot tool_call), then the loop appended the tool result creating an orphan. Fixed by scanning backwards for the last assistant message with tool_calls and excluding it and everything after from the collapse scope. Added 2 integration tests that simulate the actual loop flow (single and mixed tool_call batches).

2. **High — snapshot history not injected into prompts.** `inject_into_prompt()` existed but was never called. Added dynamic injection into the system message at the start of each loop iteration in `run_agent_loop()`. Previous injection is stripped before re-injecting to avoid accumulation. Added 2 tests.

3. **Medium — dirty state bypass on `/continue`.** `run_agent_loop()` unconditionally reset dirty at entry. Fixed by checking whether the last message is a user message (new scope boundary). On `/continue`, the last message is assistant/tool, so dirty state is preserved. Added 2 tests.

4. **Medium — nudge one-shot per run instead of per streak.** `snapshot_nudge_fired` was never reset. Fixed by resetting it when a mutation breaks the read streak, making nudges fire once per streak. Also removed duplicate nudge logic. Added 2 tests.

- All 1234 tests pass, lint clean, format clean

**User asked:** Fix history injection duplication across `/continue` / repeated `run_agent_loop()` calls.

**What I did:** The `_snapshot_history_injected` flag was local to `run_agent_loop()`, resetting to `False` on each invocation. On re-entry the old `[Snapshot history` block in the system message wasn't stripped, causing accumulation. Fixed by removing the flag entirely and always stripping any existing `[Snapshot history` block from the system message before injecting. Added 1 test covering the re-entry scenario.

- All 1235 tests pass, lint clean, format clean

**User asked:** Fix low-risk issue — history-strip marker is plain text that could collide with natural prompt content.

**What I did:** Replaced the plain-text marker `"\n\n[Snapshot history"` with a dedicated sentinel token `<!-- swival:snapshot-history-39a7c -->` (HTML comment with random suffix). The sentinel was already added to `snapshot.py` and `inject_into_prompt()` in the previous step. Completed the fix by:
- Updated `agent.py` to import `SNAPSHOT_HISTORY_SENTINEL` from `snapshot.py` and use it for stripping (replaced `_SNAPSHOT_HISTORY_MARKER` local variable)
- Updated `tests/test_snapshot.py` to import and use `SNAPSHOT_HISTORY_SENTINEL` in all 6 marker references
- All 1235 tests pass, lint clean

**User asked:** Implement the REPL `/save`, `/restore`, `/unsave` commands from `plans/repl-snapshot-commands.md`.

**What I did:** Added three REPL commands for manual snapshot control, leveraging the existing `SnapshotState` machinery:

- Extended `SnapshotState` in `snapshot.py`:
  - Added `explicit_begin_index` field for index-based checkpoints (user-initiated saves happen between turns, not inside tool calls)
  - Added `_generation`/`_save_generation` counter pair for stale-index detection after compaction
  - Refactored `_save` into `_save_common` + `_save`, added `save_at_index(label, index)` method
  - Added `invalidate_index_checkpoint()` method (increments generation)
  - Added `restore_with_autosummary(messages, summarize_fn)` — auto-generates summary via LLM call, falls back to static marker
  - Added `end_idx` kwarg to `_restore` so manual restore can collapse the full tail
  - Extended `_resolve_start` with index branch + generation check
  - Cleared new fields in `_restore`, `_cancel`, `reset`
- Wired into `agent.py`:
  - Added `/save [label]`, `/restore`, `/unsave` command handlers in `repl_loop`
  - Added `_repl_snapshot_save`, `_repl_snapshot_restore`, `_repl_snapshot_unsave` helpers
  - Updated `_repl_compact` to call `invalidate_index_checkpoint()` after compaction
  - Added invalidation call in auto-compaction path (`run_agent_loop` overflow branch)
  - Updated `/help` output with the three new commands
- Added 13 snapshot unit tests and 11 REPL integration tests
- All 1267 tests pass, lint clean, format clean

**User asked:** Fix two review issues: (1) weak summarization prompt in /restore, (2) missing auto-compaction invalidation test.

**What I did:**

1. Replaced the weak prompt in `_repl_snapshot_restore` ("Summarize the following conversation turns concisely.") with the same safety-hardened prompt used by `summarize_turns` — requires factual-only output, preserves key artifacts (file paths, findings, decisions, errors), explicitly forbids instructions/directives. This matters because recap text is injected back into system context via `inject_into_prompt`.

2. Added `test_save_then_autocompact_then_restore_error` — saves an index checkpoint, triggers `ContextOverflowError` in `run_agent_loop` which fires the graduated compaction path, then verifies `_generation` was incremented and `_resolve_start` returns an invalidation error.

- All 1268 tests pass, lint clean, format clean

**User asked:** Make sure all documentation is accurate and up to date including the docs.md directory.

**What I did:**

- `docs.md/usage.md`: Added `/save [label]`, `/restore`, `/unsave` to the REPL Commands section with descriptions of behavior, default label, implicit checkpoint fallback, and compaction invalidation.
- `docs.md/tools.md`: Added a "REPL Commands" subsection under the snapshot tool section, cross-referencing the REPL commands and noting that `/restore` auto-generates the summary via LLM.
- `CLAUDE.md`: Added `snapshot.py` to the Key modules list (was missing).

**User asked:** Explore all context management features and write an extensive report.

**What I did:** Explored the entire codebase in depth — agent.py, snapshot.py, thinking.py, todo.py, mcp_client.py, skills.py, session.py, config.py, tools.py, fetch.py, tracker.py, and system_prompt.txt. Wrote an initial report in `context-management-report.md`.

**User asked:** Merge the two reports (existing `CONTEXT_MANAGEMENT_REPORT.md` and new `context-management-report.md`) into a single definitive report.

**What I did:** Merged both files into `CONTEXT_MANAGEMENT_REPORT.md`, combining the best of each: the code snippets and API-level detail from the original, the concentric rings architecture diagram and lifecycle walkthrough from the new one, plus the output size guards, interventions/nudges, and MCP runtime handling that were only in one or the other. Deleted the duplicate `context-management-report.md`.

**User asked:** Create a new documentation page in docs.md about context management, accurate and high-level (not about code details).

**What I did:** Created `docs.md/context-management.md` — a user-facing documentation page covering Swival's context management in prose without code snippets or line references. Structured around the four-layer architecture (prevention, proactive collapse, reactive compaction, knowledge survival), with sections on output size guards, MCP schema budgets, the snapshot tool, graduated compaction levels, thinking/todo/snapshot persistence, proactive checkpoint summaries, REPL commands, and configuration. Added the page to `build.py` NAV (under "Basics" after "Tools"), cross-referenced from `getting-started.md` ("Where To Go Next") and `tools.md` (snapshot section). Website builds clean, all links OK.

**User asked:** Simplify the code without breaking anything.

**What I did:** Ran three parallel review agents (code reuse, quality, efficiency) on the last 5 commits. Applied 11 safe simplifications:

1. Extracted message accessor helpers (`_msg_get`, `_msg_role`, `_msg_content`, etc.) into `swival/_msg.py` — eliminates duplication between `agent.py` and `snapshot.py` (which had its own copies)
2. Extracted `_SUMMARIZE_SYSTEM_PROMPT` constant in `agent.py` — was duplicated verbatim in `summarize_turns()` and `_repl_snapshot_restore()`
3. Extracted `_clear_explicit()` method in `SnapshotState` — checkpoint clearing code was duplicated in `_restore()`, `cancel()`, and `reset()`
4. Made `SnapshotState.dirty` a `@property` derived from `bool(self.dirty_tools)` — was redundant state always set in tandem
5. Removed double `READ_ONLY_TOOLS` membership check — was checked both in `agent.py` caller and inside `mark_dirty()`
6. Extracted `_show_state_summaries()` helper in `agent.py` — identical 8-line block was copy-pasted at two exit points of `run_agent_loop`
7. Cached `inject_into_prompt()` result — was rebuilding the string from scratch every turn even when history hadn't changed
8. Added `SNAPSHOT_RECAP_PREFIX` constant — replaced magic string `"[snapshot:"` in `score_turn()` with importable constant
9. Replaced list slice with arithmetic for `turns_collapsed` in `_restore()` — avoids allocating a copy of the message list just to get its length
10. Used `_msg_tool_calls()` and `_msg_get()` in `snapshot.py` instead of hand-rolled isinstance/getattr patterns
11. Renamed `_cancel()` to `cancel()` — was being called from `agent.py` as a private method, now properly public

All 1268 tests pass, lint clean.

## 2026-03-01

**User asked:** Research macOS sandbox-exec framework for sandboxing shell commands — how it works, SBPL syntax, deprecation status, limitations, and alternatives (chroot, App Sandbox, Linux namespaces, bubblewrap, Docker).

**What I did:** Comprehensive web research covering:
- sandbox-exec man page, SBPL (Sandbox Profile Language) syntax and operations
- Verified sandbox-exec still works on this machine (macOS 26.4 / Tahoe beta)
- Tested deny-by-default profiles, selective file write, network blocking, parameter passing
- Discovered the /tmp vs /private/tmp symlink pitfall through hands-on testing
- Read Anthropic's sandbox-runtime source (macos-sandbox-utils.ts) for a production-grade SBPL template
- Read system sandbox profiles at /usr/share/sandbox/ and /System/Library/Sandbox/Profiles/
- Researched alternatives: bubblewrap (Linux), Docker, Apple Container, chroot, App Sandbox
- Produced a detailed research document with working examples

**User asked:** Create a plan for integrating agentfs to build a better sandbox for swival, allowing arbitrary shell commands with write access restricted to allowed directories.

**What I did:** Explored both codebases thoroughly (swival's current sandboxing in tools.py/agent.py and the full agentfs architecture at /Users/j/src/agentfs). Analyzed agentfs's macOS sandbox implementation (NFS overlay + sandbox-exec + copy-on-write delta layer). Wrote a detailed plan in `plans/agentfs-sandbox.md` covering:
- Current limitations of swival's application-level sandboxing
- Three integration options (shell out to `agentfs run`, long-running sidecar, Python SDK)
- Recommended phased approach: Phase 1 uses sandbox-exec directly (Python-native, zero dependencies) for kernel-enforced write restrictions; Phase 2 adds full agentfs overlay for reversibility
- Implementation details for new `sandbox.py` module, changes to `_run_command()`, CLI flags, config support
- Interaction matrix showing how sandbox mode affects each existing safety feature
- Open questions (defaults, network policy, sandbox-exec deprecation)

**User asked:** Implement the plan in `plans/agentfs-command-sandbox.md` (Phase 1 MVP).

**What I did:** Full implementation of the AgentFS-backed command sandbox:

- Created `swival/sandbox_agentfs.py` — re-exec bootstrap module with `maybe_reexec()`, `is_sandboxed()`, `build_agentfs_argv()`, `_find_agentfs()`. Uses `SWIVAL_AGENTFS_ACTIVE=1` env marker as recursion guard. Builds `agentfs run --no-default-allows --allow <path>... [--session <id>] -- swival ...` argv.
- Updated `swival/config.py` — added `sandbox` (str, enum validated against `SANDBOX_MODES`) and `sandbox_session` (str) to CONFIG_KEYS, _ARGPARSE_DEFAULTS, validation, and generated config template.
- Updated `swival/agent.py` — added `--sandbox builtin|agentfs` and `--sandbox-session` argparse flags, validation (`--sandbox-session` requires `--sandbox agentfs`), re-exec call before agent loop, startup banner in verbose mode, threaded sandbox into `_report_settings` and `_write_report`.
- Updated `swival/session.py` — added `sandbox` and `sandbox_session` as Session attributes, threaded into report `build_report` call.
- Updated `swival/report.py` — added `sandbox_mode` and `sandbox_session` params to `build_report()` and `finalize()`, added `sandbox` field to report output dict.
- Created 34 tests in `tests/test_sandbox_agentfs.py` covering: is_sandboxed env checks, _find_agentfs PATH lookup, build_agentfs_argv (basic, session, add_dirs, no-default-allows), maybe_reexec (noop for builtin, noop when already sandboxed, missing binary error, execvpe capture, add_dirs propagation), config parsing (default, from config, CLI override, session, validation), report metadata (builtin default, agentfs with/without session, finalize passthrough), CLI integration (real build_parser), mock agentfs binary integration.
- Updated docs: `docs.md/usage.md` (--sandbox and --sandbox-session flags), `docs.md/safety-and-sandboxing.md` (new AgentFS Sandbox Mode section), `docs.md/agentfs.md` (Integrated Sandbox Mode section at top).
- All 1302 tests pass, lint clean, format clean, website builds clean

**User asked:** Review findings — fix 4 issues: env-var bypass, CWD/base_dir mismatch, Session API false confidence, wrong URL.

**What I did:** Fixed all four:

1. **Env-var bypass (high):** `is_sandboxed()` now requires both `SWIVAL_AGENTFS_ACTIVE=1` (set by swival during re-exec) AND `AGENTFS=1` (set by agentfs itself). Simply exporting the swival marker without actually being inside agentfs no longer skips re-exec. Added 4 new tests covering the dual-marker logic.

2. **CWD/base_dir mismatch (high):** `maybe_reexec()` now calls `os.chdir(resolved_base)` before `os.execvpe()` so that agentfs overlays the correct directory regardless of CWD. Added `test_chdir_to_base_dir_before_exec`.

3. **Session API false confidence (medium):** `Session._setup()` now calls `check_sandbox_available()` when `sandbox="agentfs"`, which raises `ConfigError` if not already inside an agentfs sandbox. Library users must either use the CLI or wrap their process externally. Added `TestCheckSandboxAvailable` (3 tests) and `TestSessionSandboxFailFast` (2 tests).

4. **Wrong URL (low):** Error message now points to `https://github.com/tursodatabase/agentfs`. Added `test_error_message_has_correct_url`.

- All 1312 tests pass (44 sandbox tests), lint clean, format clean

**User asked:** Fix 2 more review issues: relative CLI paths break after chdir, and external agentfs wrapping rejected by Session.

**What I did:**

1. **Relative paths break after chdir (high):** Added `_absolutize_argv()` — scans sys.argv for known path-bearing flags (`--base-dir`, `--add-dir`, `--add-dir-ro`, `--skills-dir`, `--mcp-config`, `--objective`, `--verify`, `--report`) and resolves their values to absolute paths before forwarding to the child process. Called in `maybe_reexec()` before `os.chdir()`. Added 6 unit tests for `_absolutize_argv` + 1 integration test (`test_relative_paths_absolutized_before_reexec`).

2. **External agentfs wrapping rejected (medium):** Split the env check into two functions: `is_sandboxed()` (requires both markers, used for re-exec recursion guard) and `is_inside_agentfs()` (requires only `AGENTFS=1`, used by `check_sandbox_available()`). External wrapping (`agentfs run -- python script.py`) sets only `AGENTFS=1`, and is now accepted by the Session API. Added `TestIsInsideAgentfs` (4 tests), updated `TestCheckSandboxAvailable` (added `test_ok_when_wrapped_externally`), added `test_session_agentfs_with_external_wrapping_ok`.

- All 1325 tests pass (57 sandbox tests), lint clean, format clean

**User asked:** Fix `_absolutize_argv` missing `--flag=value` syntax.

**What I did:** Extended `_absolutize_argv()` to handle `--flag=value` form by splitting on the first `=`, checking whether the prefix is a known path flag, and resolving the value portion. Added 5 tests covering: equals form basic, multiple flags, absolute preserved, mixed with split form, non-path flag unchanged.

- All 1330 tests pass (62 sandbox tests), lint clean, format clean

**User asked:** Implement Phase 2 — strict read mode + capability negotiation (`--sandbox-strict-read` flag, agentfs probing, version propagation, report integration).

**What I did:**

- `swival/sandbox_agentfs.py`: Added `probe_agentfs()` (runs `agentfs --version`, parses semver from output like `agentfs v0.6.2` or `agentfs 0.6.2-3-gabcdef-dirty`, returns `{version, supports_strict_read}`). Added `_STRICT_READ_MIN_VERSION = None` (no version supports it yet) and `_version_gte()` for future version comparison. Added `get_agentfs_version()` reading `SWIVAL_AGENTFS_VERSION` env var. Extended `maybe_reexec()` with `sandbox_strict_read` param — probes the binary, validates strict read support (fails fast with version in error message), propagates version via env var to child process.
- `swival/config.py`: Added `sandbox_strict_read: bool` to `CONFIG_KEYS`, `_ARGPARSE_DEFAULTS` (default `False`), and `generate_config()` template.
- `swival/agent.py`: Added `--sandbox-strict-read` (store_true, default `_UNSET`), validation requiring `--sandbox agentfs`, threaded into `maybe_reexec()` call, added `agentfs_version=get_agentfs_version()` to report finalization.
- `swival/session.py`: Added `sandbox_strict_read: bool = False` to `Session.__init__`, threaded into report `build_report()`.
- `swival/report.py`: Added `sandbox_strict_read` and `agentfs_version` params to `build_report()` and `finalize()`. Sandbox dict now includes `strict_read` (always for agentfs mode) and `agentfs_version` (when non-None).
- `tests/test_sandbox_agentfs.py`: Added 20 new tests across 6 test classes — `TestProbeAgentfs` (6 tests: standard version, no-v-prefix, dirty version, unparsable, missing binary, always-false strict read), `TestGetAgentfsVersion` (2 tests), `TestStrictReadValidation` (4 tests: unsupported raises, false doesn't raise, error includes version, noop for builtin), `TestVersionPropagation` (2 tests: version env set, unknown propagated), `TestStrictReadReport` (4 tests: includes strict_read, omits for builtin, version omitted when None, finalize passthrough), `TestStrictReadCLI` (4 tests: parsed, default, config default, config sets value). Updated existing report tests for new `strict_read` field in agentfs sandbox dict.
- `docs.md/usage.md`: Documented `--sandbox-strict-read` in the Sandboxing Flags section.
- `docs.md/safety-and-sandboxing.md`: Added Strict Read Mode subsection under AgentFS Sandbox Mode.

- All 1352 tests pass (84 sandbox tests), lint clean, format clean, website builds clean

**User asked:** Implement Phase 3 — UX + operational hardening (auto-generated session IDs, verbose diagnostics with resume/diff hints, report metadata).

**What I did:**

- `swival/sandbox_agentfs.py`: Added `auto_session_id()` (SHA-256 of resolved base_dir, truncated to 12 hex chars, prefixed `swival-`), `get_agentfs_session()` (reads `SWIVAL_AGENTFS_SESSION` env var), `diff_hint()` (returns `agentfs diff <session>`). Extended `maybe_reexec()` with `sandbox_auto_session` param — when True and no explicit session, auto-generates a deterministic session ID. Propagates effective session via `_SESSION_ENV` to child process.
- `swival/config.py`: Added `sandbox_auto_session: bool` to CONFIG_KEYS, `no_sandbox_auto_session: False` to `_ARGPARSE_DEFAULTS`, added to `_INVERT_KEYS` mapping, and `generate_config()` template.
- `swival/agent.py`: Added `--no-sandbox-auto-session` CLI flag, updated `maybe_reexec()` call to pass `sandbox_auto_session`, enhanced verbose banner to show effective session ID (from env) and resume command, added end-of-run diff hint after single-shot and REPL paths, threaded `diff_hint` into `_write_report`.
- `swival/session.py`: Added `sandbox_auto_session: bool = True` to Session.__init__.
- `swival/report.py`: Added `diff_hint` param to `build_report()` and `finalize()`, included in sandbox dict for agentfs mode.
- `swival/fmt.py`: Added `sandbox_hint()` formatting function.
- `tests/test_sandbox_agentfs.py`: Added 21 new tests across 7 test classes — TestAutoSessionId (4), TestGetAgentfsSession (2), TestDiffHint (2), TestAutoSessionReexec (3), TestSessionEnvPropagation (2), TestAutoSessionConfig (3), TestDiffHintReport (3). Updated `_make_args` helper with new flag.
- `docs.md/usage.md`: Documented `--no-sandbox-auto-session`.
- `docs.md/safety-and-sandboxing.md`: Updated session lifecycle with auto-session default behavior, ephemeral overlay opt-out, and diff hint example.

- All 1373 tests pass (105 sandbox tests), lint clean, format clean, website builds clean

**User asked:** Replace `@modelcontextprotocol/server-filesystem` MCP example with something more useful, since swival already has built-in filesystem tools.

**What I did:** Replaced all references across 4 files (`docs.md/mcp.md`, `swival/config.py`, `swival/mcp_client.py`) with `@modelcontextprotocol/server-brave-search` — a more practical example. Rebuilt docs HTML, verified zero remaining references.

**User asked:** Review agentfs.md for outdated/redundant content now that Swival has an integrated sandbox mode.

**What I did:** Edited `docs.md/agentfs.md`:
- Removed the outdated closing claim "Swival does not need special AgentFS integration" — it now does
- Rewrote "Practical Guidance" to reference `--sandbox agentfs --yolo` and deterministic session IDs
- Trimmed "Run Swival Inside A Session Overlay" to note the integrated mode handles this automatically
- Added integrated-mode context to "Iterate Without Starting Over"
- Replaced the mount-based REPL workflow with the simpler `swival --sandbox agentfs --repl --yolo`

## 2026-03-02

**User asked:** LLM agents sometimes hallucinate a tool called `run_shell_command` — how to improve the situation?

**What I did:** Added "did you mean?" suggestions and available-tools listing to the unknown tool error path in `tools.py:dispatch()`. When a model invents a tool name that's a common hallucination (e.g. `run_shell_command`, `execute_command`, `bash`, `search`, `create_file`), the error now says `Did you mean 'run_command'?`. For completely unknown names, it lists all available tools. This lets the model self-correct on the first retry instead of burning 2-3 turns before the guardrail kicks in. Added 3 tests.

**User asked:** Research how to use the ChatGPT API with a ChatGPT Pro subscription using OAuth authentication — endpoints, OAuth flows, token exchange, open-source implementations, and official vs reverse-engineered approaches.

**What I did:** Comprehensive web research across 15+ sources including OpenAI official docs, GitHub issues (openai/codex, RooCodeInc/Roo-Code, anomalyco/opencode), DeepWiki, and open-source implementations. Compiled findings on: the official OAuth PKCE + device code flows via auth.openai.com, the token-exchange grant type that converts OAuth tokens to API keys, the distinction between chatgpt.com/backend-api (unofficial) and api.openai.com (official), concrete client IDs/endpoints/scopes, and implementations in Codex CLI, Roo Code, and opencode plugins.

**User asked:** Create a plan for ChatGPT Pro support with OAuth authentication.

**What I did:** Created `plans/chatgpt-pro-oauth.md`. Initial version covered the OAuth flow, provider integration, and basic implementation plan. User reviewed and raised 9 issues (state validation, call_llm signature, parser impact, client ID risk, config paths, device flow polling, callback port, atomic writes, test coverage). Rewrote the plan addressing all issues:
1. Added CSRF state parameter generation + constant-time validation to browser flow
2. Kept `call_llm()` signature untouched — refresh happens in `_maybe_refresh_token()` called from `run_agent_loop()`, internal `_`-prefixed keys stripped before passing to call_llm
3. Changed from subcommands to `--login`/`--logout` flags with early-exit in `main()` — no parser refactoring
4. Gated entire feature behind `--experimental` flag with explicit risk messaging; client ID configurable via `--oauth-client-id` / env / config
5. Credential storage uses `global_config_dir()` from config.py (XDG-aware)
6. Device flow now uses server-provided `interval`, handles `slow_down`/`expired_token`/`access_denied` responses
7. Callback server binds to `localhost:0` (ephemeral port) instead of fixed 1455
8. Atomic writes via tempfile + os.replace, file locking via fcntl.flock
9. Expanded test plan to cover agent.py, config.py, session.py in addition to oauth.py

Second review raised 6 more issues. Fixed all:
1. `--login`/`--logout`/`--device-auth` flags now use `default=False` (not `_UNSET`), since they're one-shot actions with no config-file equivalent and `_UNSET` is truthy
2. Early exit moved **after** `apply_config_to_args()` so that `experimental`/`oauth_client_id` from config files are available during login; added guard to `question is required` check
3. Added explicit `chatgpt` branch in `call_llm()` routing (`openai/{model_id}` with api_base and api_key) so it doesn't hit the `else: raise AgentError("unknown provider")` path
4. File locking now uses a shared lock file (`chatgpt-auth.json.lock`), not the per-process tempfile
5. Added `experimental` and `oauth_client_id` kwargs to `resolve_provider()` signature, updated call site in `session.py:_setup()`, added Session attrs
6. Fixed internal inconsistencies: `ctx` → `context_length`, `refresh()` return type is `Credentials | None`, `--logout` UX example includes `--experimental`

Third review raised 3 more issues. Fixed all:
1. Replaced `_die(...)` with `parser.error(...)` — `_die` doesn't exist in the codebase; the CLI path uses `parser.error()` for validation failures and `ConfigError` for provider issues
2. Added explicit `_run_main()` callsite (agent.py:2123) to plan — `resolve_provider` is called from both `session.py:_setup()` and `agent.py:_run_main()`, both need the new `experimental`/`oauth_client_id` kwargs
3. Wired `client_id` through the full refresh chain: `load_credentials(client_id=...)` → `refresh(refresh_token, client_id=...)`. `_maybe_refresh_token()` reads `_oauth_client_id` from llm_kwargs (set by resolve_provider, stripped before call_llm). Added 3 tests for client_id plumbing

**User asked:** Implement the plan.

**What I did:** Full implementation of ChatGPT Pro OAuth support following `plans/chatgpt-pro-oauth.md`:

- Created `swival/oauth.py` (~300 lines, stdlib only):
  - `Credentials` dataclass, `OAuthError` exception
  - `_browser_flow()`: PKCE S256 + state parameter, ephemeral port via `localhost:0`, callback HTTP server with CSRF validation (`hmac.compare_digest`), 120s timeout
  - `_device_flow()`: polls with server-provided interval, handles authorization_pending/slow_down/expired_token/access_denied
  - `_exchange_for_api_key()`: token-exchange grant type converting id_token to API key
  - `login()`: orchestrates browser or device flow + token exchange
  - `refresh()`: refresh_token grant + token exchange, returns None on failure
  - `load_credentials()`: reads from swival config dir, falls back to `~/.codex/auth.json`, auto-refreshes near expiry
  - `save_credentials()`: atomic write (tempfile + os.replace), 0o600 permissions, shared file locking via fcntl.flock on `.lock` file
  - `delete_credentials()`: removes credential file
  - `_load_codex_credentials()`: imports Codex CLI credentials (ms→s conversion)

- Updated `swival/config.py`:
  - Added `experimental: bool` and `oauth_client_id: str` to CONFIG_KEYS
  - Added defaults to `_ARGPARSE_DEFAULTS`
  - Added `chatgpt` to provider choices in `generate_config()`

- Updated `swival/agent.py`:
  - Added `--login`, `--logout`, `--device-auth` (all `default=False`), `--experimental` (`default=_UNSET`), `--oauth-client-id` (`default=_UNSET`) to `build_parser()`
  - Added `chatgpt` to `--provider` choices
  - Added `chatgpt` branch in `resolve_provider()` with `experimental`/`oauth_client_id` keyword-only params, loads credentials, sets `_oauth_client_id` in llm_kwargs
  - Added `chatgpt` branch in `call_llm()` (reuses openai/ path)
  - Added `_maybe_refresh_token()` — called before each `call_llm()` in `run_agent_loop()`, reads `_oauth_client_id` from llm_kwargs
  - `_`-prefixed keys stripped from llm_kwargs before passing to `call_llm()`
  - Early exit in `main()` after `apply_config_to_args()` for `--login`/`--logout`
  - Updated `_run_main()` callsite to pass `experimental`/`oauth_client_id`
  - Default model for chatgpt: `gpt-5.2`

- Updated `swival/session.py`:
  - Added `experimental: bool = False` and `oauth_client_id: str | None = None` to `Session.__init__()`
  - Passed both to `resolve_provider()` in `_setup()`

- Created `tests/test_oauth.py` (33 tests): PKCE/state generation, browser flow (token exchange, timeout, error), state validation (match/mismatch), device flow (polling, slow_down, expired, denied), API key exchange, credential storage (save/load, permissions, lock file, atomic write, delete, missing, corrupt), refresh (success, failure, custom client_id, near-expiry, near-expiry failure), Codex import (happy path, ms→s, missing, precedence), login (browser, device, client_id override)

- Added to `tests/test_provider.py` (15 tests): resolve_provider chatgpt (happy path, custom model, no experimental, no creds, client_id passthrough), call_llm chatgpt routing, _maybe_refresh_token (noop, refresh, creds gone, client_id), llm_kwargs stripping, CLI (login requires experimental, question not required, logout without experimental)

- Added to `tests/test_config.py` (5 tests): experimental config key, default, oauth_client_id, chatgpt provider validation, generate_config includes new entries

- Added to `tests/test_session.py` (6 tests): Session experimental/oauth_client_id attrs and defaults, setup passes both to resolve_provider

- All 1435 tests pass, lint clean, format clean

**User asked:** Login with ChatGPT fails — look at /tmp/codex and /tmp/opencode for reference.

**What I did:** Compared swival's OAuth implementation against codex-rs and opencode reference implementations. Found 3 bugs:

1. **Wrong parameter name in token exchange** (`oauth.py:_exchange_for_api_key`): Swival sent `requested_token_type` but the OpenAI token endpoint expects `requested_token`. Fixed.
2. **Missing authorization URL parameters** (`oauth.py:_browser_flow`): Both codex and opencode include `id_token_add_organizations=true` and `codex_cli_simplified_flow=true` in the auth URL. Without these, the OpenAI auth server may reject the flow or return tokens without required claims. Added both params.
3. **Wrong device flow endpoints and format** (`oauth.py:_device_flow`): Swival used `https://auth.openai.com/codex/device` with form-urlencoded body; codex uses `https://auth.openai.com/api/accounts/deviceauth/usercode` (request) and `https://auth.openai.com/api/accounts/deviceauth/token` (poll) with JSON bodies. Fixed endpoints and added `_post_json_body()` helper for JSON POSTs.

Updated all affected tests. All 1437 tests pass.

**User reported:** Login still fails with "Authentication Error (unknown_error)" from OpenAI's auth server.

**What I did:** The OAuth client ID `app_EMoamEEZ73f0CkXaXp7hrann` is registered with OpenAI to only accept `http://localhost:1455/auth/callback` as the redirect URI. Swival was using an ephemeral port (`localhost:0`), which OpenAI's auth server rejects. Both codex-rs and opencode hardcode port 1455. Fixed by binding the callback server to port 1455.

**User asked:** Research how LiteLLM supports ChatGPT as a provider — OAuth flow, token storage, provider string format, environment variables, and Codex-compatible token storage.

**What I did:** Comprehensive research across LiteLLM documentation, GitHub source code, and Codex CLI documentation:
- Fetched and analyzed 4 source files from `litellm/llms/chatgpt/` on GitHub: `authenticator.py`, `common_utils.py`, `chat/transformation.py`, `responses/transformation.py`
- Reviewed PR #19030 that added ChatGPT subscription support (merged Jan 19, 2026)
- Extracted model pricing entries, provider endpoint configuration, and LlmProviders enum
- Cross-referenced Codex CLI auth.json format with LiteLLM's token storage format
- Documented all environment variables, OAuth constants, token refresh logic, and API base URLs

**User asked:** Replace the custom OAuth implementation with LiteLLM's native `chatgpt` provider. Create a plan, then implement.

**What I did:** Wrote `plans/chatgpt-oauth.md`, iterated through 3 rounds of review feedback addressing: docs drift risk, hardcoded context default, version-fragile default model, direct resolve_provider unit tests, and model normalization consistency. Then implemented:

- `swival/agent.py`:
  - Added `"chatgpt"` to `--provider` choices (line ~1512) with updated help text
  - Added `chatgpt` branch in `call_llm()` — `chatgpt/{model_id}` format, `removeprefix` double-prefix guard, optional `api_key`/`api_base` passthrough
  - Added `chatgpt` branch in `resolve_provider()` — requires `--model` (no default, actionable error lists available models), context length resolved from `litellm.get_model_info()` at runtime (no hardcoded value), no API key required (LiteLLM's authenticator handles OAuth)
- `swival/config.py`: Updated `generate_config()` provider comment to include `"chatgpt"`
- `tests/test_provider.py`: Added 17 new tests across 4 classes — `TestChatGPTRouting` (3: basic routing, explicit key passthrough, base_url passthrough), `TestChatGPTModelNormalization` (2: no double prefix, bare model gets prefix), `TestChatGPTCLIValidation` (3: requires model, no key ok, never calls discover/configure), `TestResolveProviderChatGPT` (9: model passthrough, no-model raises, key none/passthrough, api_base none/passthrough, context from litellm metadata, context override, llm_kwargs provider)
- Documentation: Updated README.md, docs.md/usage.md, docs.md/providers.md, docs.md/reports.md, docs.md/getting-started.md, docs.md/customization.md, CLAUDE.md, AGENTS.md — all with gpt-5.3-codex as the primary example model
- All 1394 tests pass, lint clean, format clean

**User asked:** Clarify in docs that the "chatgpt" provider is for ChatGPT Plus/Pro subscriptions, not just "ChatGPT".

**What I did:** Updated all documentation to clarify that the `chatgpt` provider leverages existing ChatGPT Plus and ChatGPT Pro subscriptions:
- `README.md`: Updated link text, quickstart section header/description, feature description, and docs index
- `docs.md/providers.md`: Updated intro paragraph, section header, provider description, model availability note, and backend note
- `docs.md/getting-started.md`: Updated section header, description, and cross-reference
- `docs.md/customization.md`: Updated config example description
- `docs.md/usage.md`: Updated `--provider` flag description
- `swival/agent.py`: Updated argparse help text for `--provider`
- Rebuilt docs HTML

## 2026-03-04

**User asked:** Implement the plan from `plans/edit-diff-output.md` — show colored diff output when `edit_file` succeeds.

**What I did:**

- `swival/fmt.py`: Added `tool_diff(file_path, old, new)` — generates unified diff via `difflib.unified_diff`, renders with Rich `Text` objects (bold headers, cyan hunks, red removals, green additions, dim context). Dual truncation: 50 lines AND 4KB, whichever hits first. All content appended via `Text(line, style=...)` to prevent markup injection.
- `swival/tools.py`: Added `verbose=False` param to `_edit_file()`. Captures `content` before `replace()`, calls `fmt.tool_diff(file_path, content, new_content)` when `verbose=True`. Updated `dispatch()` to forward `verbose` kwarg to `_edit_file`.
- `swival/agent.py`: Added `verbose=verbose` to the `dispatch()` call site.
- `swival/edit.py`: No change needed — `replace()` already returns new content.
- `tests/test_fmt.py`: Added `TestToolDiff` (5 tests: formatting, no-diff-when-identical, line truncation, byte truncation, markup safety).
- `tests/test_tools.py`: Added 4 tests to `TestEditFilePositive` (quiet mode no diff, return value unchanged, dispatch quiet no diff via mock, dispatch verbose calls diff with correct args).
- All 1400 tests pass (5 pre-existing failures in test_provider.py unrelated), lint clean.

## 2026-03-20

**User asked:** Design and implement tool calling for the command provider, so external agents (`claude -p`, `codex`, etc.) can call Swival-exclusive tools (MCP, A2A, skills) using a text-based convention distinct from their own internal tool calling.

**What I did:** Iterated through 6 rounds of plan review, then implemented the final plan from `plans/command-provider-tool-calling.md`:

- **Convention:** External agents emit `<swival:call id="c1" name="tool_name">{"args": ...}</swival:call>` XML blocks in their text output. Swival parses them, executes via `handle_tool_call()`, and re-invokes the command with results in `[swival_result id="c1" name="tool_name"]` transcript sections. Loops until no tool calls remain (max 20 rounds).

- **Scope (v1):** Only MCP (`mcp__*`), A2A (`a2a__*`), and `use_skill` tools exposed. No `think`/`todo`/`snapshot` (state-coupled), no file I/O tools (agent has its own).

- **Implementation:**
  - `agent.py`: Extracted `_post_tool_bookkeeping()` from `run_agent_loop()` — shared helper for post-call event emission, report logging, snapshot dirty tracking, error guardrails. `EVENT_TOOL_START` stays at call site.
  - `agent.py`: Added `_parse_swival_calls()` — order-insensitive XML attribute parser (handles `id`/`name` in any order, extra attrs, malformed JSON → `_parse_error` entries).
  - `agent.py`: Added `_render_swival_tool_catalog()` — renders tool schemas as text for system prompt injection.
  - `agent.py`: Added `_make_tool_call_obj()` — builds synthetic `SimpleNamespace` tool_call objects for `handle_tool_call()`.
  - `agent.py`: Added `_call_command_with_tools()` — inner loop that re-invokes the command subprocess, parses `<swival:call>` blocks, dispatches through `handle_tool_call()` + `_post_tool_bookkeeping()`, feeds results back via transcript. Returns clean 3-tuple `(msg, "stop", activity_list)`.
  - `agent.py`: Updated `_render_transcript()` to render MCP/A2A/skill tool results as `[swival_result id="..." name="..."]` sections instead of `[tool:name]`.
  - `agent.py`: Updated `call_llm()` — returns 3-tuple `(msg, finish_reason, cmd_activity)` with `[]` default for non-command paths. Routes to `_call_command_with_tools()` when `command_tool_kwargs` is provided.
  - `agent.py`: Updated `_COMMAND_PROVIDER_SYSTEM_PROMPT` — conditionally appends tool catalog with `<swival:call>` usage instructions when command-exposable tools exist.
  - `agent.py`: Updated `build_system_prompt()` — new `command_tool_schemas` param, passes to prompt builder for command provider.
  - `agent.py`: Updated `run_agent_loop()` — builds `command_tool_kwargs` dict with `handle_tool_call` kwargs + bookkeeping kwargs when command provider has exposable tools. Unpacks 3-tuple from `call_llm()` (tolerant of 2-tuple for test compat). Injects `[Context for follow-up:` activity summary as separate user message (not in `msg.content`). Updates `outer_turn` before each `call_llm` call.
  - `agent.py`: Added `_COMMAND_TOOL_CONTEXT_PREFIX` to `SYNTHETIC_USER_PREFIXES` (for `continue_here` resume filtering) and `_DROPPABLE_USER_PREFIXES` (for `is_pinned()` compaction).
  - `continue_here.py`, `reviewer.py`: Updated to handle 3-value `call_llm` returns tolerantly.
  - Tests: Updated `test_command_provider.py`, `test_provider.py`, `test_cache.py`, `test_empty_assistant.py`, `test_secrets_integration.py`, `test_a2a_server.py` for 3-tuple returns. Added 20 new tests: parser (9), catalog renderer (2), transcript rendering (4), is_pinned/synthetic prefixes (3), system prompt catalog injection (2).

- All 2131 tests pass, lint clean.

**User asked:** "swival hello" fails with context overflow on a 4K context model — is there a desperate move?

**What I did:** Added a last-resort fallback in `run_agent_loop()`: when all 3 compaction levels fail and tools are still attached, drop all tools and truncate the system prompt, then retry as a plain chat completion. The model loses tool-calling ability but can at least produce a text answer. Changes in `agent.py` only (~50 lines in the compaction exhaustion path). All 2131 tests pass.

## 2026-03-22

**User asked:** Dynamically adjust the system prompt so non-interactive and reviewer modes are fully autonomous, while REPL mode and `Session.ask()` allow asking the user for clarification. Created a plan through 3 review rounds, then implemented.

**What I did:** Implemented interaction-policy substitution:

- `swival/system_prompt.txt`: Replaced the two hardcoded autonomy directives (line 1 and `# Communication` section) with `{{AUTONOMY_DIRECTIVE}}` and `{{AMBIGUITY_DIRECTIVE}}` placeholders.
- `swival/agent.py`: Added `_InteractionPolicy` type alias (`Literal["autonomous", "interactive"]`), two directive dicts (`_AUTONOMY_DIRECTIVES`, `_AMBIGUITY_DIRECTIVES`), and `_apply_interaction_policy()` substitution helper. In `main()`, derives policy from `args.repl` and substitutes after `build_system_prompt()`.
- `swival/session.py`: Refactored `_system_with_memory()` from 4 early returns to single-return flow (result variable → memory injection → policy substitution → return). Added `policy` parameter. `run()` uses default `"autonomous"`, `ask()` passes `"interactive"`.
- `tests/test_interaction_policy.py`: 12 tests — autonomous renders correctly, interactive renders correctly, no placeholders remain (parametrized), custom prompt untouched (parametrized), command provider untouched (parametrized), Session.run() produces autonomous prompt (patches `run_agent_loop`), Session.ask() produces interactive prompt.
- Custom system_prompt and command-provider paths are no-ops — `.replace()` on strings without placeholders does nothing.
- All 1367 tests pass (12 new), lint clean.

## Platform-aware /init workflow discovery

**Asked:** The /init command's build instruction discovery (from ba5ba52) extracted Linux-specific commands when run on macOS (e.g. for libsodium). Fix it.

**Did:** Converted `INIT_PROMPT` constant to `_init_prompt()` function that injects `os.uname()` info (sysname, machine, release) into the prompt. Added explicit instructions to only extract commands for the current platform and prefer build systems that handle platform differences internally. Added `configure.ac`, `configure`, `autogen.sh` to the probe file list. Updated tests accordingly, added a new test verifying platform info is present.

**Follow-up:** Replaced `os.uname()` (Unix-only) with `platform.system()`/`platform.machine()`/`platform.release()` for cross-platform support. Added `_platform_label()` helper that normalizes Darwin→macOS. Expanded prompt text to cover Windows (ignore Unix shell commands unless MSYS2/Cygwin/WSL). Replaced host-only test assertion with parametrized monkeypatched tests for Darwin, Linux, and Windows.
