diff --git a/.github/scripts/generate_star_history.py b/.github/scripts/generate_star_history.py
new file mode 100644
index 0000000000..19ec09ae52
--- /dev/null
+++ b/.github/scripts/generate_star_history.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Generate a self-hosted GitHub star-history chart for the README."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import urllib.request
+from pathlib import Path
+
+
+def fetch_stars(repository: str, token: str) -> list[dt.date]:
+ url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100"
+ dates: list[dt.date] = []
+ while url:
+ request = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/vnd.github.star+json",
+ "Authorization": f"Bearer {token}",
+ "User-Agent": "jcode-star-history",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ )
+ with urllib.request.urlopen(request) as response:
+ for star in json.load(response):
+ dates.append(dt.datetime.fromisoformat(star["starred_at"].replace("Z", "+00:00")).date())
+ links = response.headers.get("Link", "")
+ url = ""
+ for link in links.split(","):
+ if 'rel="next"' in link:
+ url = link[link.index("<") + 1 : link.index(">")]
+ break
+ return dates
+
+
+def render_svg(repository: str, dates: list[dt.date]) -> str:
+ if not dates:
+ raise RuntimeError("GitHub returned no stargazers")
+ dates.sort()
+ start, end = dates[0], max(dates[-1], dt.date.today())
+ span = max((end - start).days, 1)
+ width, height = 800, 420
+ left, right, top, bottom = 72, 24, 38, 58
+ plot_w, plot_h = width - left - right, height - top - bottom
+
+ points: list[tuple[dt.date, int]] = []
+ for index, day in enumerate(dates, 1):
+ if index == len(dates) or dates[index] != day:
+ points.append((day, index))
+ if points[-1][0] < end:
+ points.append((end, len(dates)))
+
+ def x(day: dt.date) -> float:
+ return left + (day - start).days / span * plot_w
+
+ max_stars = len(dates)
+ grid_max = ((max_stars + 4999) // 5000) * 5000 or 1
+
+ def y(value: int) -> float:
+ return top + (1 - value / grid_max) * plot_h
+
+ path = " ".join(
+ ("M" if index == 0 else "L") + f" {x(day):.1f} {y(count):.1f}"
+ for index, (day, count) in enumerate(points)
+ )
+ area = f"{path} L {x(end):.1f} {top + plot_h:.1f} L {x(start):.1f} {top + plot_h:.1f} Z"
+
+ y_ticks = []
+ for index in range(5):
+ value = round(grid_max * index / 4)
+ yy = y(value)
+ label = f"{value / 1000:g}k" if value >= 1000 else str(value)
+ y_ticks.append(f'{label}')
+
+ x_ticks = []
+ years = range(start.year, end.year + 1)
+ for year in years:
+ day = max(start, dt.date(year, 1, 1))
+ if day > end:
+ continue
+ xx = x(day)
+ x_ticks.append(f'{day.year}')
+
+ return f'''
+'''
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo", default="1jehuang/jcode")
+ parser.add_argument("--output", type=Path, default=Path("docs/images/star-history.svg"))
+ args = parser.parse_args()
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ if not token:
+ raise SystemExit("GITHUB_TOKEN or GH_TOKEN is required")
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(render_svg(args.repo, fetch_stars(args.repo, token)))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 633e26f115..a99581aa2d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,6 +19,8 @@ jobs:
name: Quality Guardrails
runs-on: ubuntu-latest
timeout-minutes: 45
+ env:
+ DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
steps:
- uses: actions/checkout@v4
with:
@@ -26,9 +28,10 @@ jobs:
submodules: recursive
- name: Configure SSH for cargo git dependencies
+ if: ${{ env.DEPLOY_KEY != '' }}
uses: webfactory/ssh-agent@v0.9.0
with:
- ssh-private-key: ${{ secrets.DEPLOY_KEY }}
+ ssh-private-key: ${{ env.DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
with:
@@ -137,6 +140,9 @@ jobs:
- name: Compile release automation scripts
run: python3 -m py_compile scripts/post_discord_release.py scripts/test_post_discord_release.py
+ - name: Test fork-compatible workflow contracts
+ run: python3 -m unittest -v scripts/test_fork_ci_workflow.py
+
build:
name: Build & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
@@ -158,6 +164,7 @@ jobs:
# pinning it here makes every step use this job's installed stable toolchain.
env:
RUSTUP_TOOLCHAIN: stable
+ DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
strategy:
fail-fast: false
matrix:
@@ -175,9 +182,10 @@ jobs:
submodules: recursive
- name: Configure SSH for cargo git dependencies
+ if: ${{ env.DEPLOY_KEY != '' }}
uses: webfactory/ssh-agent@v0.9.0
with:
- ssh-private-key: ${{ secrets.DEPLOY_KEY }}
+ ssh-private-key: ${{ env.DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
with:
@@ -406,6 +414,8 @@ jobs:
name: Build & Test (windows-latest)
runs-on: windows-latest
timeout-minutes: 150
+ env:
+ DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
steps:
- uses: actions/checkout@v4
with:
@@ -413,9 +423,10 @@ jobs:
submodules: recursive
- name: Configure SSH for cargo git dependencies
+ if: ${{ env.DEPLOY_KEY != '' }}
uses: webfactory/ssh-agent@v0.9.0
with:
- ssh-private-key: ${{ secrets.DEPLOY_KEY }}
+ ssh-private-key: ${{ env.DEPLOY_KEY }}
- uses: ilammy/msvc-dev-cmd@v1
with:
@@ -659,6 +670,8 @@ jobs:
name: Windows Cross-Target Check (Linux)
runs-on: ubuntu-latest
timeout-minutes: 35
+ env:
+ DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
steps:
- uses: actions/checkout@v4
with:
@@ -666,9 +679,10 @@ jobs:
submodules: recursive
- name: Configure SSH for cargo git dependencies
+ if: ${{ env.DEPLOY_KEY != '' }}
uses: webfactory/ssh-agent@v0.9.0
with:
- ssh-private-key: ${{ secrets.DEPLOY_KEY }}
+ ssh-private-key: ${{ env.DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
with:
diff --git a/.github/workflows/require-issue.yml b/.github/workflows/require-issue.yml
index e2a02fd0ce..84865e7b5f 100644
--- a/.github/workflows/require-issue.yml
+++ b/.github/workflows/require-issue.yml
@@ -39,6 +39,20 @@ jobs:
const owner = context.repo.owner;
const repo = context.repo.repo;
+
+ try {
+ const repositoryQuery = `query($owner:String!, $repo:String!) {
+ repository(owner:$owner, name:$repo) { hasIssuesEnabled }
+ }`;
+ const repositoryResult = await github.graphql(repositoryQuery, { owner, repo });
+ if (repositoryResult?.repository?.hasIssuesEnabled === false) {
+ core.info('Repository issues are disabled; skipping linked-issue requirement.');
+ return;
+ }
+ } catch (err) {
+ core.warning(`Could not query repository issue capability: ${err}`);
+ }
+
const text = `${pr.title || ''}\n\n${pr.body || ''}`;
// Collect candidate issue numbers that belong to THIS repo.
diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml
new file mode 100644
index 0000000000..e251d9acdb
--- /dev/null
+++ b/.github/workflows/update-star-history.yml
@@ -0,0 +1,34 @@
+name: Update star history
+
+on:
+ schedule:
+ - cron: "17 3 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: update-star-history
+ cancel-in-progress: true
+
+jobs:
+ update:
+ if: github.repository == '1jehuang/jcode'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Generate chart using repository-authorized star data
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python3 .github/scripts/generate_star_history.py
+ - name: Commit updated chart
+ run: |
+ if git diff --quiet -- docs/images/star-history.svg; then
+ exit 0
+ fi
+ git config user.name github-actions[bot]
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ git add docs/images/star-history.svg
+ git commit -m "docs: update star history chart"
+ git push
diff --git a/Cargo.lock b/Cargo.lock
index 0d6fe824e3..ec7b736f87 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2099,6 +2099,18 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -2753,6 +2765,15 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2786,6 +2807,15 @@ dependencies = [
"foldhash 0.2.0",
]
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
[[package]]
name = "heck"
version = "0.4.1"
@@ -3355,7 +3385,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jcode"
-version = "0.75.5"
+version = "0.78.0"
dependencies = [
"anyhow",
"async-stream",
@@ -3568,6 +3598,7 @@ dependencies = [
"rand 0.9.3",
"regex",
"reqwest 0.12.28",
+ "rusqlite",
"serde",
"serde_json",
"serde_yaml",
@@ -4869,6 +4900,17 @@ dependencies = [
"redox_syscall 0.7.0",
]
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "line-clipping"
version = "0.3.5"
@@ -7280,6 +7322,20 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags 2.10.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
[[package]]
name = "rustc-hash"
version = "1.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index f7f0da8c87..bc6fbd7da8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "jcode"
-version = "0.75.5"
+version = "0.78.0"
description = "Possibly the greatest coding agent ever built โ blazing-fast TUI, multi-model, swarm coordination, 30+ tools"
edition = "2024"
autobins = false
diff --git a/README.md b/README.md
index ef8191ed67..a25e696591 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ The most intelligent harness
-
+
@@ -365,13 +365,14 @@ There are two ways to set one up:
jcode login --provider
# for example:
jcode login --provider openrouter
+ jcode login --provider orcarouter
jcode login --provider deepseek
jcode login --provider opencode # OpenCode Zen
jcode login --provider moonshotai
jcode login --provider meta-muse # Meta Model API / Muse Spark
```
- Built-in OpenAI-compatible profile ids include: `openrouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list.
+ Built-in OpenAI-compatible profile ids include: `openrouter`, `orcarouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list.
- **Any other endpoint** โ point jcode at an arbitrary OpenAI-compatible API (hosted or local) with `jcode login --provider openai-compatible` or the scriptable `jcode provider add` command described below.
@@ -456,6 +457,34 @@ id = "my-model-id"
context_window = 128000
```
+Anthropic Messages-compatible gateways use the same named-profile surface with
+`type = "anthropic-compatible"`. The profile can select bearer, custom-header,
+or no authentication and attach gateway-specific headers to every request:
+
+```toml
+[provider]
+default_provider = "corp-claude"
+default_model = "claude-sonnet-4-6"
+
+[providers.corp-claude]
+type = "anthropic-compatible"
+base_url = "https://gateway.example.com/anthropic/v1"
+auth = "bearer"
+api_key_env = "CORP_CLAUDE_TOKEN"
+default_model = "claude-sonnet-4-6"
+
+[providers.corp-claude.headers]
+x-tenant-id = "tenant-42"
+
+[[providers.corp-claude.models]]
+id = "claude-sonnet-4-6"
+context_window = 200000
+```
+
+For direct environment-based configuration, `ANTHROPIC_BASE_URL` overrides the
+non-OAuth Messages endpoint and `ANTHROPIC_AUTH_TOKEN` is sent as a bearer token.
+Claude OAuth traffic always continues to use Anthropic's official endpoints.
+
##### Extra request-body fields (`extra_body`)
Some OpenAI-compatible backends require non-standard top-level request fields. For example, NVIDIA NIM DeepSeek-V4 reasoning models (`deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro`) only enable thinking when the request includes `chat_template_kwargs`; without it they reply without reasoning (or, for some deployments, hang). jcode lets you inject arbitrary top-level fields two ways.
@@ -580,7 +609,7 @@ The above image is the first page of provider logins
### Supported provider
- **Native / first-party style providers:** `claude`, `openai`, `copilot`, `gemini`, `azure`, `alibaba-coding-plan`
-- **Aggregator / compatibility providers:** `openrouter`, `openai-compatible`
+- **Aggregator / compatibility providers:** `openrouter`, `orcarouter`, `openai-compatible`
- **Additional provider integrations:** `opencode`, `opencode-go`, `zai` / `kimi`, `302ai`, `baseten`, `cortecs`, `deepseek`, `firmware`, `huggingface`, `moonshotai`, `nebius`, `scaleway`, `stackit`, `groq`, `mistral`, `perplexity`, `togetherai`, `deepinfra`, `fireworks`, `minimax`, `xai`, `lmstudio`, `ollama`, `chutes`, `cerebras`, `cursor`, `antigravity`, `google`
Jcode also supports easy multi-account switching. Ran out of tokens on your first ChatGPT Pro subscription? /account and quickly switch to your second.
diff --git a/TELEMETRY.md b/TELEMETRY.md
index 85d7e667f9..f85e421d06 100644
--- a/TELEMETRY.md
+++ b/TELEMETRY.md
@@ -95,10 +95,18 @@ Recent telemetry additions also include: coarse onboarding steps, explicit thumb
| Field | Example | Purpose |
|-------|---------|----------|
| `event` | `"feedback"` | Event type |
-| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback explicitly submitted with `/feedback ...` |
+| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback submitted with `/feedback ...` or the `maintainer_feedback` agent tool |
| `feedback_rating` | `"up"` / `"down"` | Legacy explicit product sentiment, if present |
| `feedback_reason` | `"slow"` | Legacy optional coarse reason bucket, if present |
+The `maintainer_feedback` tool is available only as another explicit telemetry
+path: it obeys the same telemetry opt-out as `/feedback` and sends no event when
+telemetry is disabled. Its schema tells the agent to paraphrase, omit secrets and
+private data, and label whether the report originated with the user, the agent,
+or both. User-originated and mixed reports are rejected unless the user explicitly
+approved sharing them; agent-only technical observations do not need per-report
+approval. Jcode does not attach transcript content, repository files, or paths.
+
### Sponsored Discovery Event
One event is sent after each `discover_tools` attempt. A random per-request ID
diff --git a/changelog/index.json b/changelog/index.json
index ce60244021..b00f25c0e6 100644
--- a/changelog/index.json
+++ b/changelog/index.json
@@ -1,5 +1,25 @@
{
"entries": [
+ {
+ "version": "0.78.0",
+ "date": "2026-08-18"
+ },
+ {
+ "version": "0.77.2",
+ "date": "2026-08-18"
+ },
+ {
+ "version": "0.77.1",
+ "date": "2026-08-17"
+ },
+ {
+ "version": "0.77.0",
+ "date": "2026-08-17"
+ },
+ {
+ "version": "0.76.0",
+ "date": "2026-08-14"
+ },
{
"version": "0.75.5",
"date": "2026-08-12"
diff --git a/changelog/v0.76.0.json b/changelog/v0.76.0.json
new file mode 100644
index 0000000000..6eddfa64dd
--- /dev/null
+++ b/changelog/v0.76.0.json
@@ -0,0 +1,22 @@
+{
+ "version": "0.76.0",
+ "date": "2026-08-14",
+ "title": "Transcript privacy and broader provider support",
+ "highlights": [
+ "Opt-in transcript telemetry now supports privacy-preserving collection with automatic secret redaction",
+ "Anthropic-compatible provider profiles can now connect to additional compatible services",
+ "Grok Build login can now be completed directly inside the TUI"
+ ],
+ "improvements": [
+ "Startup update checks can now be disabled in configuration",
+ "Transient provider retries are more resilient and configurable",
+ "Z.AI Coding Plan supports reasoning effort controls and safely handles text-only models"
+ ],
+ "fixes": [
+ "OpenRouter responses preserve tool outputs even when the corresponding tool call is unavailable",
+ "Prompt files are deduplicated and oversized skill context is clipped",
+ "Repeated paste placeholders expand correctly",
+ "Self-development builds promote artifacts to the correct paths",
+ "Grok model lists refresh after login and use the current managed backend"
+ ]
+}
diff --git a/changelog/v0.77.0.json b/changelog/v0.77.0.json
new file mode 100644
index 0000000000..dbeabd6ff7
--- /dev/null
+++ b/changelog/v0.77.0.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.77.0",
+ "date": "2026-08-17",
+ "title": "Background visibility and expanded authentication",
+ "highlights": [
+ "Background tasks now appear in the pinned status band, report intermediate progress reliably, and wake stalled agents automatically",
+ "Cursor and Grok Build now support native authentication flows, with OrcaRouter available as a provider profile",
+ "Duplicate provider accounts now receive memorable animal names in the account picker"
+ ],
+ "improvements": [
+ "Todo intent and understanding are shown inline with clearer status colors and expandable pinned details",
+ "Pinned todo, copy, and edit-expand controls can now be activated with the mouse",
+ "Session search finds late transcript content and preserves longer search prefixes",
+ "The SDK now exposes persisted session titles and reasoning-effort updates",
+ "Bash output is collapsed by default with a cleaner presentation",
+ "Users can submit privacy-conscious maintainer feedback after explicit consent"
+ ],
+ "fixes": [
+ "Pasted content remains visible after sending",
+ "Hyphenated MCP tool names dispatch correctly",
+ "Repeated OpenRouter tool outputs are preserved",
+ "Provider selection honors an explicitly requested CLI provider",
+ "Setup hotkey uninstall actions are honored",
+ "macOS swarm Option shortcuts work correctly"
+ ]
+}
diff --git a/changelog/v0.77.1.json b/changelog/v0.77.1.json
new file mode 100644
index 0000000000..6a6743ff96
--- /dev/null
+++ b/changelog/v0.77.1.json
@@ -0,0 +1,7 @@
+{
+ "version": "0.77.1",
+ "date": "2026-08-17",
+ "fixes": [
+ "The remote release command now detects the current repository and follows its own release conventions instead of assuming Jcode-specific tooling"
+ ]
+}
diff --git a/changelog/v0.77.2.json b/changelog/v0.77.2.json
new file mode 100644
index 0000000000..cd51675abc
--- /dev/null
+++ b/changelog/v0.77.2.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.77.2",
+ "date": "2026-08-18",
+ "improvements": [
+ "Inline diff previews now show the affected file path for clearer review context"
+ ],
+ "fixes": [
+ "One-shot sessions now close automatically after completing their response",
+ "Ambient launches now fall back gracefully when opening a visible terminal fails",
+ "Spawned agents keep their prompt when an explicitly blank initial message is supplied",
+ "MiniMax authentication now uses the correct API key variable while preserving existing credentials"
+ ]
+}
diff --git a/changelog/v0.78.0.json b/changelog/v0.78.0.json
new file mode 100644
index 0000000000..05e7b5a9fa
--- /dev/null
+++ b/changelog/v0.78.0.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.78.0",
+ "date": "2026-08-18",
+ "highlights": [
+ "Harness API and SDK clients can now receive images embedded in transcript messages"
+ ],
+ "improvements": [
+ "Todo quality checks now give shorter, clearer guidance and avoid repeatedly blocking final responses"
+ ],
+ "fixes": [
+ "Completed hook observers are now cleaned up reliably instead of accumulating over time"
+ ]
+}
diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs
index 0f7ea142a7..b872bdecd9 100644
--- a/crates/jcode-app-core/src/agent.rs
+++ b/crates/jcode-app-core/src/agent.rs
@@ -233,6 +233,9 @@ pub struct Agent {
mcp_late_register_resolved: bool,
/// Override system prompt (used by ambient mode to inject a custom prompt)
system_prompt_override: Option,
+ /// AGENTS.md is session bootstrap input. Keep the captured text stable so
+ /// tool writes do not mutate the provider's cacheable prefix mid-session.
+ agents_md_snapshot: (Option, crate::prompt::ContextInfo),
/// Whether memory features are enabled for this session
memory_enabled: bool,
/// One-step undo snapshot captured before the most recent rewind.
@@ -255,6 +258,15 @@ pub struct Agent {
}
impl Agent {
+ fn refresh_agents_md_snapshot(&mut self) {
+ let working_dir = self
+ .session
+ .working_dir
+ .as_deref()
+ .map(std::path::Path::new);
+ self.agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir);
+ }
+
fn should_track_client_cache(&self) -> bool {
match std::env::var("JCODE_TRACK_CLIENT_CACHE") {
Ok(value) => {
@@ -273,6 +285,8 @@ impl Agent {
disabled_tools: HashSet,
) -> Self {
let skills = SkillRegistry::shared_snapshot();
+ let working_dir = session.working_dir.as_deref().map(std::path::Path::new);
+ let agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir);
let initial_provider_model = provider.model();
let agent = Self {
provider,
@@ -299,6 +313,7 @@ impl Agent {
locked_tools: None,
mcp_late_register_resolved: false,
system_prompt_override: None,
+ agents_md_snapshot,
memory_enabled: crate::config::config().features.memory,
rewind_undo_snapshot: None,
stdin_request_tx: None,
diff --git a/crates/jcode-app-core/src/agent/prompting.rs b/crates/jcode-app-core/src/agent/prompting.rs
index c96536450c..f4b0f91639 100644
--- a/crates/jcode-app-core/src/agent/prompting.rs
+++ b/crates/jcode-app-core/src/agent/prompting.rs
@@ -107,12 +107,13 @@ impl Agent {
.as_ref()
.map(std::path::PathBuf::from);
- let (mut split, _context_info) = crate::prompt::build_system_prompt_split(
+ let (mut split, _context_info) = crate::prompt::build_system_prompt_split_with_agents_md(
skill_prompt.as_deref(),
&available_skills,
self.session.is_canary,
memory_prompt,
working_dir.as_deref(),
+ self.agents_md_snapshot.clone(),
);
self.append_current_turn_system_reminder(&mut split);
diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs
index 7f5aea34b8..d2c54a6447 100644
--- a/crates/jcode-app-core/src/agent/provider.rs
+++ b/crates/jcode-app-core/src/agent/provider.rs
@@ -242,6 +242,7 @@ impl Agent {
return;
}
self.session.working_dir = Some(dir.to_string());
+ self.refresh_agents_md_snapshot();
self.session.refresh_initial_session_context_message();
self.log_env_snapshot("working_dir");
}
diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs
index 0c3168a32f..1f0dd756c7 100644
--- a/crates/jcode-app-core/src/agent/turn_execution.rs
+++ b/crates/jcode-app-core/src/agent/turn_execution.rs
@@ -210,6 +210,7 @@ impl Agent {
new_session.ensure_initial_session_context_message();
self.session = new_session;
+ self.refresh_agents_md_snapshot();
self.reconcile_explicit_provider_pin_route();
self.reset_runtime_state_for_session_change();
self.provider_session_id = None;
@@ -627,6 +628,7 @@ impl Agent {
// Restore provider_session_id for Claude CLI session resume
self.provider_session_id = session.provider_session_id.clone();
self.session = session;
+ self.refresh_agents_md_snapshot();
crate::tool::clear_session_tool_policy(&previous_session_id);
crate::tool::set_session_tool_policy(
&self.session.id,
diff --git a/crates/jcode-app-core/src/ambient/runner.rs b/crates/jcode-app-core/src/ambient/runner.rs
index 4ad30adeb7..eb1e9e10b0 100644
--- a/crates/jcode-app-core/src/ambient/runner.rs
+++ b/crates/jcode-app-core/src/ambient/runner.rs
@@ -887,17 +887,55 @@ impl AmbientRunnerHandle {
/// Run a single ambient cycle. Returns the cycle result.
async fn run_cycle(&self, provider: &Arc) -> anyhow::Result {
+ self.run_cycle_with_visible_launcher(provider, config().ambient.visible, || {
+ let jcode_bin =
+ std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode"));
+
+ std::process::Command::new("kitty")
+ .args([
+ "--title",
+ "๐ค jcode ambient cycle",
+ "-e",
+ &jcode_bin.to_string_lossy(),
+ "ambient",
+ "run-visible",
+ ])
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ })
+ .await
+ }
+
+ async fn run_cycle_with_visible_launcher(
+ &self,
+ provider: &Arc,
+ visible: bool,
+ launch_visible: F,
+ ) -> anyhow::Result
+ where
+ F: FnOnce() -> std::io::Result + Send,
+ {
let started_at = Utc::now();
- let visible = config().ambient.visible;
self.set_running_detail("gathering context").await;
let (system_prompt, initial_message) = self.build_cycle_context(provider).await?;
// Visible mode: spawn a full TUI instead of running headlessly
if visible {
- return self
- .run_cycle_visible(started_at, system_prompt, initial_message)
- .await;
+ match self
+ .run_cycle_visible(
+ started_at,
+ system_prompt.clone(),
+ initial_message.clone(),
+ launch_visible,
+ )
+ .await?
+ {
+ VisibleCycleOutcome::Completed(result) => return Ok(*result),
+ VisibleCycleOutcome::FallBackHeadless => {}
+ }
}
// Headless mode: run agent directly
@@ -986,12 +1024,16 @@ impl AmbientRunnerHandle {
}
/// Run a visible ambient cycle by spawning a full TUI in a kitty window.
- async fn run_cycle_visible(
+ async fn run_cycle_visible(
&self,
started_at: chrono::DateTime,
system_prompt: String,
initial_message: String,
- ) -> anyhow::Result {
+ launch_visible: F,
+ ) -> anyhow::Result
+ where
+ F: FnOnce() -> std::io::Result + Send,
+ {
use crate::ambient::VisibleCycleContext;
self.set_running_detail("launching visible TUI").await;
@@ -1008,25 +1050,9 @@ impl AmbientRunnerHandle {
let _ = std::fs::remove_file(&result_path);
}
- // Find the jcode binary
- let jcode_bin =
- std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode"));
-
// Spawn kitty with `jcode ambient run-visible`
logging::info("Ambient visible: spawning kitty with jcode TUI");
- let child = std::process::Command::new("kitty")
- .args([
- "--title",
- "๐ค jcode ambient cycle",
- "-e",
- &jcode_bin.to_string_lossy(),
- "ambient",
- "run-visible",
- ])
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .spawn();
+ let child = launch_visible();
match child {
Ok(mut child) => {
@@ -1046,25 +1072,29 @@ impl AmbientRunnerHandle {
crate::storage::read_json::(&result_path)
{
let _ = std::fs::remove_file(&result_path);
- return Ok(AmbientCycleResult {
- started_at,
- ended_at: Utc::now(),
- ..result
- });
+ return Ok(VisibleCycleOutcome::Completed(Box::new(
+ AmbientCycleResult {
+ started_at,
+ ended_at: Utc::now(),
+ ..result
+ },
+ )));
}
// No result file โ user closed the window without end_ambient_cycle
- Ok(AmbientCycleResult {
- summary: "Visible cycle ended (user closed window)".to_string(),
- memories_modified: 0,
- compactions: 0,
- proactive_work: None,
- next_schedule: None,
- started_at,
- ended_at: Utc::now(),
- status: CycleStatus::Incomplete,
- conversation: None,
- })
+ Ok(VisibleCycleOutcome::Completed(Box::new(
+ AmbientCycleResult {
+ summary: "Visible cycle ended (user closed window)".to_string(),
+ memories_modified: 0,
+ compactions: 0,
+ proactive_work: None,
+ next_schedule: None,
+ started_at,
+ ended_at: Utc::now(),
+ status: CycleStatus::Incomplete,
+ conversation: None,
+ },
+ )))
}
Err(e) => {
logging::warn(&format!(
@@ -1072,12 +1102,17 @@ impl AmbientRunnerHandle {
e
));
// Fall back to headless mode
- Err(anyhow::anyhow!("Failed to spawn visible TUI: {}", e))
+ Ok(VisibleCycleOutcome::FallBackHeadless)
}
}
}
}
+enum VisibleCycleOutcome {
+ Completed(Box),
+ FallBackHeadless,
+}
+
// ---------------------------------------------------------------------------
#[cfg(test)]
diff --git a/crates/jcode-app-core/src/ambient/runner_tests.rs b/crates/jcode-app-core/src/ambient/runner_tests.rs
index 94146aaa0d..867e77b99c 100644
--- a/crates/jcode-app-core/src/ambient/runner_tests.rs
+++ b/crates/jcode-app-core/src/ambient/runner_tests.rs
@@ -7,6 +7,7 @@ use anyhow::Result;
use async_stream::stream;
use async_trait::async_trait;
use std::collections::VecDeque;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
@@ -121,6 +122,45 @@ async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() {
let _ = task.await;
}
+async fn assert_visible_launch_error_falls_back(error_kind: std::io::ErrorKind) {
+ let _guard = crate::storage::lock_test_env();
+ let temp = tempfile::tempdir().expect("tempdir");
+ let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
+
+ let provider: Arc = Arc::new(StreamingTestProvider::default());
+ let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new()));
+ let launch_attempted = Arc::new(AtomicBool::new(false));
+ let launch_attempted_in_callback = launch_attempted.clone();
+
+ let result = runner
+ .run_cycle_with_visible_launcher(&provider, true, move || {
+ launch_attempted_in_callback.store(true, Ordering::SeqCst);
+ Err(std::io::Error::from(error_kind))
+ })
+ .await
+ .expect("failed visible launch should continue as a headless cycle");
+
+ assert!(launch_attempted.load(Ordering::SeqCst));
+ assert!(
+ result.conversation.is_some(),
+ "headless fallback should capture an agent conversation"
+ );
+ assert!(
+ result.summary.contains("forced end after 2 attempts"),
+ "headless fallback should return the headless agent result"
+ );
+}
+
+#[tokio::test]
+async fn unsupported_visible_launch_falls_back_to_headless() {
+ assert_visible_launch_error_falls_back(std::io::ErrorKind::Unsupported).await;
+}
+
+#[tokio::test]
+async fn missing_visible_launcher_falls_back_to_headless() {
+ assert_visible_launch_error_falls_back(std::io::ErrorKind::NotFound).await;
+}
+
#[tokio::test]
async fn spawn_target_creates_one_child_session_and_runs_task() {
let _guard = crate::storage::lock_test_env();
diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs
index 36fec332ba..44d2468db5 100644
--- a/crates/jcode-app-core/src/server.rs
+++ b/crates/jcode-app-core/src/server.rs
@@ -52,9 +52,9 @@ mod util;
pub(super) use self::await_members_state::AwaitMembersRuntime;
use self::background_tasks::{
dispatch_background_task_completion, dispatch_background_task_progress,
- dispatch_swarm_await_completion, dispatch_swarm_batch_progress, dispatch_swarm_output_tail,
- dispatch_swarm_runtime_status, dispatch_swarm_todo_progress, dispatch_swarm_tool_activity,
- dispatch_ui_activity,
+ dispatch_background_task_stalled, dispatch_swarm_await_completion,
+ dispatch_swarm_batch_progress, dispatch_swarm_output_tail, dispatch_swarm_runtime_status,
+ dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, dispatch_ui_activity,
};
use self::debug::{ClientConnectionInfo, ClientDebugState};
use self::debug_jobs::DebugJob;
@@ -2178,6 +2178,19 @@ impl Server {
Ok(BusEvent::BackgroundTaskProgress(task)) => {
dispatch_background_task_progress(&task, &swarm_members).await;
}
+ Ok(BusEvent::BackgroundTaskStalled(task)) => {
+ dispatch_background_task_stalled(
+ &task,
+ &sessions,
+ &soft_interrupt_queues,
+ &swarm_members,
+ &swarms_by_id,
+ &event_history,
+ &event_counter,
+ &swarm_event_tx,
+ )
+ .await;
+ }
Ok(BusEvent::SwarmAwaitCompleted(event)) => {
dispatch_swarm_await_completion(
&event,
diff --git a/crates/jcode-app-core/src/server/background_tasks.rs b/crates/jcode-app-core/src/server/background_tasks.rs
index 6b68c8a86a..b39bb03e7f 100644
--- a/crates/jcode-app-core/src/server/background_tasks.rs
+++ b/crates/jcode-app-core/src/server/background_tasks.rs
@@ -89,6 +89,86 @@ pub(super) async fn dispatch_background_task_completion(
}
}
+/// Deliver a stall-watchdog wake for a background task that has gone quiet.
+///
+/// Mirrors completion delivery: optionally notify attached clients, then wake
+/// an idle agent or queue a soft interrupt for a busy one. The task is still
+/// running; the message tells the agent to inspect and decide.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "background task stall delivery needs session, interrupt, and swarm status state"
+)]
+pub(super) async fn dispatch_background_task_stalled(
+ task: &crate::bus::BackgroundTaskStalled,
+ sessions: &SessionAgents,
+ soft_interrupt_queues: &SessionInterruptQueues,
+ swarm_members: &Arc>>,
+ swarms_by_id: &Arc>>>,
+ event_history: &Arc>>,
+ event_counter: &Arc,
+ swarm_event_tx: &broadcast::Sender,
+) {
+ let notification = crate::message::format_background_task_stalled_markdown(task);
+
+ if task.notify
+ && fanout_session_event(
+ swarm_members,
+ &task.session_id,
+ ServerEvent::Notification {
+ from_session: "background_task".to_string(),
+ from_name: Some("background task".to_string()),
+ notification_type: NotificationType::Message {
+ scope: Some("background_task".to_string()),
+ channel: None,
+ tldr: None,
+ },
+ message: notification.clone(),
+ },
+ )
+ .await
+ == 0
+ {
+ crate::logging::warn(&format!(
+ "Failed to notify attached clients for background task stall on session {}",
+ task.session_id
+ ));
+ }
+
+ if task.wake
+ && !run_live_turn_if_idle(
+ &task.session_id,
+ ¬ification,
+ Some(
+ "A background task for this session has produced no output or progress for its stall window. Inspect it and decide whether to keep waiting, fix it, or cancel it."
+ .to_string(),
+ ),
+ sessions,
+ LiveTurnSwarmContext::new(
+ swarm_members,
+ swarms_by_id,
+ event_history,
+ event_counter,
+ swarm_event_tx,
+ ),
+ )
+ .await
+ && !queue_soft_interrupt_for_session(
+ &task.session_id,
+ notification.clone(),
+ false,
+ SoftInterruptSource::BackgroundTask,
+ soft_interrupt_queues,
+ sessions,
+ )
+ .await
+ {
+ crate::logging::warn(&format!(
+ "Failed to deliver background task stall to session {}",
+ task.session_id
+ ));
+ }
+}
+
/// Deliver the result of a backgrounded `swarm await_members` watcher to the
/// requesting session. Mirrors background-task completion delivery: optionally
/// notify attached clients, then wake an idle agent or queue a soft interrupt
diff --git a/crates/jcode-app-core/src/tool/bash.rs b/crates/jcode-app-core/src/tool/bash.rs
index 3cdbd2b5fc..c12688d6d2 100644
--- a/crates/jcode-app-core/src/tool/bash.rs
+++ b/crates/jcode-app-core/src/tool/bash.rs
@@ -696,6 +696,10 @@ struct BashInput {
notify: bool,
#[serde(default)]
wake: bool,
+ /// For background runs: wake the agent after this many seconds with no
+ /// new output and no progress events. Resets on activity.
+ #[serde(default)]
+ stall_wake_seconds: Option,
/// Set only when re-issuing a call the gate refused (#604).
#[serde(default)]
justification: Option,
@@ -1289,6 +1293,23 @@ impl BashTool {
} else {
"Notifications disabled. Use `bg` tool to check status."
};
+
+ let stall_msg = match params.stall_wake_seconds {
+ Some(requested) => {
+ match crate::background::global()
+ .arm_stall_watchdog(&info.task_id, requested)
+ .await
+ {
+ Some(effective) => format!(
+ "Stall watchdog armed: you will be woken after {}s with no output or progress (resets on activity).\n",
+ effective
+ ),
+ None => String::new(),
+ }
+ }
+ None => String::new(),
+ };
+
let output = format!(
"Command started in background.\n\n\
Task ID: {}\n\
@@ -1296,7 +1317,7 @@ impl BashTool {
Output file: {}\n\
Status file: {}\n\n\
{}\n\
- To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\
+ {}To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\
To check progress immediately: use the `bg` tool with action=\"status\" and task_id=\"{}\"\n\
To see output: use the `read` tool on the output file, or `bg` with action=\"output\"\n\n\
{}",
@@ -1305,6 +1326,7 @@ impl BashTool {
info.output_file.display(),
info.status_file.display(),
notify_msg,
+ stall_msg,
info.task_id,
info.task_id,
BACKGROUND_PROGRESS_GUIDANCE,
diff --git a/crates/jcode-app-core/src/tool/bash_destructive_gate.rs b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs
index 699be85b1a..b9c0f551e1 100644
--- a/crates/jcode-app-core/src/tool/bash_destructive_gate.rs
+++ b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs
@@ -75,6 +75,10 @@ pub(super) fn bash_parameters_schema() -> serde_json::Value {
"type": "boolean",
"description": "Wake on completion."
},
+ "stall_wake_seconds": {
+ "type": "integer",
+ "description": "With run_in_background: wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently."
+ },
"justification": {
"type": "string",
"description": "Only when re-issuing a command the destructive gate refused; explain which user request it serves."
diff --git a/crates/jcode-app-core/src/tool/bg.rs b/crates/jcode-app-core/src/tool/bg.rs
index 22a519f4c2..db0e7ea615 100644
--- a/crates/jcode-app-core/src/tool/bg.rs
+++ b/crates/jcode-app-core/src/tool/bg.rs
@@ -74,6 +74,10 @@ struct BgInput {
/// Whether to wake on completion when using watch/delivery (default: true)
#[serde(default)]
wake: Option,
+ /// For watch/delivery: also arm a stall watchdog that wakes the agent after
+ /// this many seconds with no new output or progress (resets on activity)
+ #[serde(default)]
+ stall_wake_seconds: Option,
/// Max seconds to block when using wait (default: 60, capped at 3600)
#[serde(default)]
max_wait_seconds: Option,
@@ -333,6 +337,7 @@ fn wait_reason_label(reason: background::BackgroundTaskWaitReason) -> &'static s
background::BackgroundTaskWaitReason::Finished => "finished",
background::BackgroundTaskWaitReason::Progress => "progress",
background::BackgroundTaskWaitReason::Checkpoint => "checkpoint",
+ background::BackgroundTaskWaitReason::Stalled => "stalled",
background::BackgroundTaskWaitReason::Timeout => "timeout",
}
}
@@ -491,6 +496,7 @@ impl Tool for BgTool {
"dry_run": { "type": "boolean", "description": "For cleanup, report what would be removed without deleting." },
"notify": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to notify on completion. Defaults to true." },
"wake": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to wake on completion. Defaults to true." },
+ "stall_wake_seconds": { "type": "integer", "description": "For delivery/watch: also wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently." },
"max_wait_seconds": { "type": "integer", "description": "For wait: max seconds to block. Default 60, cap 3600, 0 = immediate check." },
"return_on_progress": { "type": "boolean", "description": "For wait: return on the first progress/checkpoint event too. Defaults to true." },
"wait_mode": { "type": "string", "enum": ["any", "all", "first_failure"], "description": "For multi-task wait, return on any completion, all completions, or first failure. Defaults to any." },
@@ -663,22 +669,37 @@ impl Tool for BgTool {
.remove(0);
let notify = params.notify.unwrap_or_else(default_watch_notify);
let wake = params.wake.unwrap_or_else(default_watch_wake);
+ let stall_armed = match params.stall_wake_seconds {
+ Some(requested) => manager.arm_stall_watchdog(&task_id, requested).await,
+ None => None,
+ };
match manager.update_delivery(&task_id, notify, wake).await? {
- Some(task) => Ok(ToolOutput::new(format!(
- "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}",
- task_id,
- status_label(&task.status),
- task.notify,
- task.wake
- ))
- .with_title(format!("bg delivery {}", task_id))
- .with_metadata(json!({
- "task_id": task.task_id,
- "task": task_metadata(manager, &task),
- "status": status_label(&task.status),
- "notify": task.notify,
- "wake": task.wake,
- }))),
+ Some(task) => {
+ let stall_line = match stall_armed {
+ Some(effective) => format!(
+ "\nStall watchdog: wake after {}s of no output/progress (resets on activity)",
+ effective
+ ),
+ None => String::new(),
+ };
+ Ok(ToolOutput::new(format!(
+ "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}{}",
+ task_id,
+ status_label(&task.status),
+ task.notify,
+ task.wake,
+ stall_line
+ ))
+ .with_title(format!("bg delivery {}", task_id))
+ .with_metadata(json!({
+ "task_id": task.task_id,
+ "task": task_metadata(manager, &task),
+ "status": status_label(&task.status),
+ "notify": task.notify,
+ "wake": task.wake,
+ "stall_wake_seconds": stall_armed,
+ })))
+ }
None => Err(anyhow::anyhow!("Task not found: {}", task_id)),
}
}
@@ -760,6 +781,9 @@ impl Tool for BgTool {
background::BackgroundTaskWaitReason::Checkpoint => {
"Background task emitted a checkpoint event.\n\n".to_string()
}
+ background::BackgroundTaskWaitReason::Stalled => {
+ "Background task stall watchdog fired: no output or progress for its stall window. The task is still running; inspect it and decide whether to keep waiting or cancel.\n\n".to_string()
+ }
background::BackgroundTaskWaitReason::Timeout => format!(
"No terminal event before max wait of {}s. Check again with `bg action=\"wait\" task_id=\"{}\"` or inspect status/output.\n\n",
capped_wait, task_id
diff --git a/crates/jcode-app-core/src/tool/communicate.rs b/crates/jcode-app-core/src/tool/communicate.rs
index 0235f6f66f..548a88449b 100644
--- a/crates/jcode-app-core/src/tool/communicate.rs
+++ b/crates/jcode-app-core/src/tool/communicate.rs
@@ -1902,7 +1902,16 @@ struct CommunicateInput {
impl CommunicateInput {
fn spawn_initial_message(&self) -> Option {
- self.initial_message.clone().or_else(|| self.prompt.clone())
+ self.initial_message
+ .as_ref()
+ .filter(|message| !message.trim().is_empty())
+ .cloned()
+ .or_else(|| {
+ self.prompt
+ .as_ref()
+ .filter(|prompt| !prompt.trim().is_empty())
+ .cloned()
+ })
}
fn required_spawn_label(&self) -> anyhow::Result {
diff --git a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs
index b20f536147..e636fdfc64 100644
--- a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs
+++ b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs
@@ -20,6 +20,27 @@ fn spawn_initial_message_accepts_prompt_alias_and_prefers_explicit_initial_messa
preferred.spawn_initial_message().as_deref(),
Some("preferred")
);
+
+ for blank_initial_message in ["", " \t\n"] {
+ let from_prompt: CommunicateInput = serde_json::from_value(serde_json::json!({
+ "action": "spawn",
+ "initial_message": blank_initial_message,
+ "prompt": "fallback"
+ }))
+ .expect("spawn payload should deserialize");
+ assert_eq!(
+ from_prompt.spawn_initial_message().as_deref(),
+ Some("fallback")
+ );
+ }
+
+ let blank_messages: CommunicateInput = serde_json::from_value(serde_json::json!({
+ "action": "spawn",
+ "initial_message": "",
+ "prompt": " "
+ }))
+ .expect("spawn payload should deserialize");
+ assert_eq!(blank_messages.spawn_initial_message(), None);
}
#[test]
diff --git a/crates/jcode-app-core/src/tool/feedback.rs b/crates/jcode-app-core/src/tool/feedback.rs
new file mode 100644
index 0000000000..b34ebd9851
--- /dev/null
+++ b/crates/jcode-app-core/src/tool/feedback.rs
@@ -0,0 +1,230 @@
+use super::{Tool, ToolContext, ToolOutput};
+use anyhow::{Result, bail};
+use async_trait::async_trait;
+use serde::Deserialize;
+use serde_json::{Value, json};
+
+const MAX_SUMMARY_CHARS: usize = 240;
+const MAX_DETAILS_CHARS: usize = 1500;
+
+pub struct MaintainerFeedbackTool;
+
+impl MaintainerFeedbackTool {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "snake_case")]
+struct FeedbackInput {
+ category: FeedbackCategory,
+ origin: FeedbackOrigin,
+ user_confirmed: bool,
+ summary: String,
+ #[serde(default)]
+ details: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum FeedbackCategory {
+ Bug,
+ Praise,
+ Suggestion,
+ Usability,
+ Other,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum FeedbackOrigin {
+ User,
+ Agent,
+ Mixed,
+}
+
+fn limited(value: &str, label: &str, max: usize) -> Result {
+ let value = value.trim();
+ if value.is_empty() {
+ bail!("{label} must not be empty");
+ }
+ if value.chars().count() > max {
+ bail!("{label} must be at most {max} characters");
+ }
+ Ok(value.to_string())
+}
+
+fn payload(input: FeedbackInput) -> Result {
+ if matches!(input.origin, FeedbackOrigin::User | FeedbackOrigin::Mixed) && !input.user_confirmed
+ {
+ bail!("user_confirmed must be true for user or mixed-origin feedback");
+ }
+ let summary = limited(&input.summary, "summary", MAX_SUMMARY_CHARS)?;
+ let details = input
+ .details
+ .as_deref()
+ .map(|value| limited(value, "details", MAX_DETAILS_CHARS))
+ .transpose()?;
+ let category = format!("{:?}", input.category).to_ascii_lowercase();
+ let origin = format!("{:?}", input.origin).to_ascii_lowercase();
+ let mut text = format!("[agent feedback; category={category}; origin={origin}] {summary}");
+ if let Some(details) = details {
+ text.push_str("\n\n");
+ text.push_str(&details);
+ }
+ Ok(text)
+}
+
+#[async_trait]
+impl Tool for MaintainerFeedbackTool {
+ fn name(&self) -> &str {
+ "maintainer_feedback"
+ }
+
+ fn description(&self) -> &str {
+ "Send product feedback to Jcode's maintainer. Respects telemetry settings."
+ }
+
+ fn parameters_schema(&self) -> Value {
+ json!({
+ "type": "object",
+ "required": ["category", "origin", "user_confirmed", "summary"],
+ "properties": {
+ "intent": super::intent_schema_property(),
+ "category": {
+ "type": "string",
+ "enum": ["bug", "praise", "suggestion", "usability", "other"],
+ "description": "Kind of feedback."
+ },
+ "origin": {
+ "type": "string",
+ "enum": ["user", "agent", "mixed"],
+ "description": "Whether this reflects the user's words, the agent's observation, or both."
+ },
+ "user_confirmed": {
+ "type": "boolean",
+ "description": "True only if the user approved sharing user-originated feedback. Agent observations may use false."
+ },
+ "summary": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": MAX_SUMMARY_CHARS,
+ "description": "Self-contained maintainer-facing summary. Paraphrase rather than quoting the user."
+ },
+ "details": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": MAX_DETAILS_CHARS,
+ "description": "Optional reproduction steps or expected versus actual behavior. Never include private data."
+ }
+ }
+ })
+ }
+
+ async fn execute(&self, input: Value, _ctx: ToolContext) -> Result {
+ let text = payload(serde_json::from_value(input)?)?;
+ if !crate::telemetry::is_enabled() {
+ return Ok(ToolOutput::new(
+ "Feedback was not sent because telemetry is disabled. The user can use /telemetry to change that setting.",
+ ));
+ }
+ crate::telemetry::record_feedback(&text);
+ Ok(ToolOutput::new(
+ "Feedback queued for the Jcode maintainer. Thank you.",
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn payload_labels_and_formats_feedback() {
+ let text = payload(FeedbackInput {
+ category: FeedbackCategory::Praise,
+ origin: FeedbackOrigin::Mixed,
+ user_confirmed: true,
+ summary: "The new picker is much easier to use".into(),
+ details: Some("The labels make model differences clear.".into()),
+ })
+ .unwrap();
+ assert_eq!(
+ text,
+ "[agent feedback; category=praise; origin=mixed] The new picker is much easier to use\n\nThe labels make model differences clear."
+ );
+ }
+
+ #[test]
+ fn payload_rejects_empty_and_oversized_fields() {
+ let empty = payload(FeedbackInput {
+ category: FeedbackCategory::Bug,
+ origin: FeedbackOrigin::Agent,
+ user_confirmed: false,
+ summary: " ".into(),
+ details: None,
+ });
+ assert!(empty.unwrap_err().to_string().contains("must not be empty"));
+
+ let oversized = payload(FeedbackInput {
+ category: FeedbackCategory::Other,
+ origin: FeedbackOrigin::User,
+ user_confirmed: true,
+ summary: "x".repeat(MAX_SUMMARY_CHARS + 1),
+ details: None,
+ });
+ assert!(oversized.unwrap_err().to_string().contains("at most 240"));
+ }
+
+ #[test]
+ fn schema_requires_provenance_and_carries_privacy_guidance() {
+ let tool = MaintainerFeedbackTool::new();
+ let schema = tool.parameters_schema();
+ assert_eq!(
+ schema["required"],
+ json!(["category", "origin", "user_confirmed", "summary"])
+ );
+ assert_eq!(
+ schema["properties"]["origin"]["enum"],
+ json!(["user", "agent", "mixed"])
+ );
+ assert!(
+ schema["properties"]["summary"]["description"]
+ .as_str()
+ .unwrap()
+ .contains("Paraphrase")
+ );
+ assert!(
+ schema["properties"]["details"]["description"]
+ .as_str()
+ .unwrap()
+ .contains("Never include private data")
+ );
+ assert!(tool.description().contains("telemetry settings"));
+ }
+
+ #[test]
+ fn user_origin_requires_explicit_confirmation() {
+ let error = payload(FeedbackInput {
+ category: FeedbackCategory::Praise,
+ origin: FeedbackOrigin::User,
+ user_confirmed: false,
+ summary: "The user likes the new workflow".into(),
+ details: None,
+ })
+ .unwrap_err();
+ assert!(error.to_string().contains("user_confirmed must be true"));
+
+ assert!(
+ payload(FeedbackInput {
+ category: FeedbackCategory::Bug,
+ origin: FeedbackOrigin::Agent,
+ user_confirmed: false,
+ summary: "The agent observed a reproducible tool error".into(),
+ details: None,
+ })
+ .is_ok()
+ );
+ }
+}
diff --git a/crates/jcode-app-core/src/tool/mcp.rs b/crates/jcode-app-core/src/tool/mcp.rs
index e296d63740..ed62cfd7ba 100644
--- a/crates/jcode-app-core/src/tool/mcp.rs
+++ b/crates/jcode-app-core/src/tool/mcp.rs
@@ -189,9 +189,8 @@ impl McpManagementTool {
} else {
for (_, tool) in server_tools {
output.push_str(&format!(
- " - mcp__{}__{}: {}\n",
- server,
- tool.name,
+ " - {}: {}\n",
+ crate::mcp::dispatch_name(server, &tool.name),
tool.description.as_deref().unwrap_or("(no description)")
));
}
@@ -280,9 +279,8 @@ impl McpManagementTool {
);
for (_, tool) in &server_tools {
output.push_str(&format!(
- " - mcp__{}__{}: {}\n",
- server_name,
- tool.name,
+ " - {}: {}\n",
+ crate::mcp::dispatch_name(&server_name, &tool.name),
tool.description.as_deref().unwrap_or("(no description)")
));
}
@@ -291,8 +289,9 @@ impl McpManagementTool {
// Register the new tools in the registry
if let Some(ref registry) = self.registry {
let mcp_tools = crate::mcp::create_mcp_tools(Arc::clone(&self.manager)).await;
+ let server_prefix = crate::mcp::dispatch_name(&server_name, "");
for (name, tool) in mcp_tools {
- if name.starts_with(&format!("mcp__{}__", server_name)) {
+ if name.starts_with(&server_prefix) {
registry.register(name, tool).await;
}
}
@@ -347,7 +346,7 @@ impl McpManagementTool {
// Unregister tools for this server
if let Some(ref registry) = self.registry {
let removed = registry
- .unregister_prefix(&format!("mcp__{}__", server_name))
+ .unregister_prefix(&crate::mcp::dispatch_name(&server_name, ""))
.await;
crate::logging::event_info(
"MCP_LIFECYCLE",
diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs
index a7411246e0..68d59693a2 100644
--- a/crates/jcode-app-core/src/tool/mod.rs
+++ b/crates/jcode-app-core/src/tool/mod.rs
@@ -14,6 +14,7 @@ mod debug_socket;
mod discover;
mod discover_secrets;
mod edit;
+mod feedback;
mod gmail;
mod goal;
pub mod inflight;
@@ -246,6 +247,12 @@ impl Registry {
websearch::WebSearchTool::new,
);
Self::insert_tool_timed(&mut m, &mut timings, "invalid", invalid::InvalidTool::new);
+ Self::insert_tool_timed(
+ &mut m,
+ &mut timings,
+ "maintainer_feedback",
+ feedback::MaintainerFeedbackTool::new,
+ );
Self::insert_tool_timed(
&mut m,
&mut timings,
diff --git a/crates/jcode-app-core/src/tool/selfdev/tests.rs b/crates/jcode-app-core/src/tool/selfdev/tests.rs
index 3b014b9be0..bee0ec475b 100644
--- a/crates/jcode-app-core/src/tool/selfdev/tests.rs
+++ b/crates/jcode-app-core/src/tool/selfdev/tests.rs
@@ -1200,6 +1200,7 @@ async fn build_ignores_stale_pending_requests_when_computing_queue_position() {
wake: true,
progress: None,
event_history: Vec::new(),
+ stall_wake_seconds: None,
},
)
.expect("write stale status file");
@@ -1300,6 +1301,7 @@ fn reconcile_pending_state_maps_superseded_background_status() {
wake: true,
progress: None,
event_history: Vec::new(),
+ stall_wake_seconds: None,
},
)
.expect("write superseded status file");
@@ -1389,6 +1391,7 @@ fn reconcile_keeps_running_request_not_yet_registered_in_live_task_map() {
wake: true,
progress: None,
event_history: Vec::new(),
+ stall_wake_seconds: None,
},
)
.expect("write running status file");
diff --git a/crates/jcode-app-core/src/tool/tests.rs b/crates/jcode-app-core/src/tool/tests.rs
index d2ce304c67..a9585d7eeb 100644
--- a/crates/jcode-app-core/src/tool/tests.rs
+++ b/crates/jcode-app-core/src/tool/tests.rs
@@ -1,6 +1,7 @@
#![cfg_attr(test, allow(clippy::await_holding_lock))]
use super::*;
+
use crate::message::{Message, ToolDefinition};
use crate::provider::{EventStream, Provider};
use async_trait::async_trait;
@@ -31,6 +32,19 @@ impl Provider for MockProvider {
}
}
+#[tokio::test]
+async fn maintainer_feedback_tool_is_registered() {
+ let provider: Arc = Arc::new(MockProvider);
+ let registry = Registry::new(provider).await;
+ assert!(
+ registry
+ .tool_names()
+ .await
+ .iter()
+ .any(|name| name == "maintainer_feedback")
+ );
+}
+
#[tokio::test]
async fn test_tool_definitions_are_sorted() {
// Create registry with mock provider
diff --git a/crates/jcode-background-types/src/lib.rs b/crates/jcode-background-types/src/lib.rs
index 4971e0bc06..3d34c56de3 100644
--- a/crates/jcode-background-types/src/lib.rs
+++ b/crates/jcode-background-types/src/lib.rs
@@ -43,8 +43,10 @@ impl BackgroundTaskProgress {
pub fn normalize(mut self) -> Self {
if let (Some(current), Some(total)) = (self.current, self.total)
&& total > 0
- && self.percent.is_none()
{
+ // Counts are the directly measurable work units, so they are the
+ // source of truth when a producer also supplies a percentage. This
+ // prevents contradictory updates such as `2/10` paired with 80%.
let computed = (current as f64 / total as f64) * 100.0;
self.percent = Some(((computed * 100.0).round() / 100.0) as f32);
}
@@ -64,6 +66,29 @@ impl BackgroundTaskProgress {
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn measurable_counts_override_a_conflicting_reported_percent() {
+ let progress = BackgroundTaskProgress {
+ kind: BackgroundTaskProgressKind::Determinate,
+ percent: Some(80.0),
+ message: None,
+ current: Some(2),
+ total: Some(10),
+ unit: Some("tests".into()),
+ eta_seconds: None,
+ updated_at: "now".into(),
+ source: BackgroundTaskProgressSource::Reported,
+ }
+ .normalize();
+
+ assert_eq!(progress.percent, Some(20.0));
+ }
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BackgroundTaskProgressEvent {
pub task_id: String,
@@ -89,6 +114,29 @@ pub struct BackgroundTaskCompleted {
pub wake: bool,
}
+/// Event sent when a stall watchdog notices a background task has produced no
+/// output bytes and no progress events for its configured stall window.
+///
+/// This is a "check on me" signal, not a terminal event: the task is still
+/// running as far as the manager can tell. The watchdog fires at most once per
+/// silence episode; new output or progress re-arms it.
+#[derive(Debug, Clone)]
+pub struct BackgroundTaskStalled {
+ pub task_id: String,
+ pub tool_name: String,
+ pub display_name: Option,
+ pub session_id: String,
+ /// Configured stall window in seconds.
+ pub stall_wake_seconds: u64,
+ /// How long the task has been running when the watchdog fired.
+ pub running_secs: f64,
+ /// Tail of the output file at fire time (already truncated).
+ pub output_tail: String,
+ pub output_file: PathBuf,
+ pub notify: bool,
+ pub wake: bool,
+}
+
/// Render a one-line human-readable progress display: an optional progress bar,
/// a textual summary, and the source label (for example `[##--] 50% (reported)`).
pub fn format_progress_display(progress: &BackgroundTaskProgress, width: usize) -> String {
diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml
index c66d20fced..128247ee4f 100644
--- a/crates/jcode-base/Cargo.toml
+++ b/crates/jcode-base/Cargo.toml
@@ -43,6 +43,7 @@ anyhow = "1"
libc = "0.2" # Unix system calls (flock)
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
+rusqlite = { version = "0.32", features = ["bundled"] }
urlencoding = "2" # URL encoding for web search
uuid = { version = "1", features = ["v4", "v5"] }
proctitle = "0.1"
@@ -151,3 +152,5 @@ global-hotkey = "0.7"
# MultiProvider routing tests exercise the real OpenRouter/OpenAI-compatible
# runtime through the composition-root registry, exactly like the binary.
jcode-provider-openrouter-runtime = { path = "../jcode-provider-openrouter-runtime" }
+# Paused-time (auto-advancing virtual clock) tests for the background stall watchdog.
+tokio = { version = "1", features = ["test-util"] }
diff --git a/crates/jcode-base/src/auth/account_store.rs b/crates/jcode-base/src/auth/account_store.rs
index d33269dc6a..0956a7ade5 100644
--- a/crates/jcode-base/src/auth/account_store.rs
+++ b/crates/jcode-base/src/auth/account_store.rs
@@ -31,8 +31,26 @@ pub fn runtime_active_override(prefix: &str) -> Option {
.and_then(|overrides| overrides.get(prefix).cloned())
}
+/// Memorable, provider-independent account names. Keeping this list fixed makes
+/// labels stable across restarts and gives the same ordinal account the same
+/// animal for every provider (for example `claude-otter` and `openai-otter`).
+const ACCOUNT_ANIMALS: &[&str] = &[
+ "otter", "fox", "panda", "wolf", "owl", "lynx", "badger", "raven", "tiger", "koala", "falcon",
+ "gecko", "bison", "heron", "moose", "orca", "rabbit", "yak", "zebra", "beaver", "cougar",
+ "dolphin", "ibis", "jaguar", "lemur", "marten", "newt", "quail", "seal", "wombat", "alpaca",
+ "penguin",
+];
+
pub fn canonical_account_label(prefix: &str, index: usize) -> String {
- format!("{prefix}-{index}")
+ let animal = index
+ .checked_sub(1)
+ .and_then(|index| ACCOUNT_ANIMALS.get(index).copied());
+ match animal {
+ Some(animal) => format!("{prefix}-{animal}"),
+ // Extremely large account sets remain unique without making the common
+ // case less friendly.
+ None => format!("{prefix}-animal-{index}"),
+ }
}
pub fn next_account_label(prefix: &str, account_count: usize) -> String {
@@ -240,12 +258,12 @@ mod tests {
);
assert!(outcome.changed);
- assert_eq!(accounts[0].label, "openai-1");
- assert_eq!(accounts[1].label, "openai-2");
- assert_eq!(active.as_deref(), Some("openai-2"));
+ assert_eq!(accounts[0].label, "openai-otter");
+ assert_eq!(accounts[1].label, "openai-fox");
+ assert_eq!(active.as_deref(), Some("openai-fox"));
assert_eq!(
outcome.canonical_override_label.as_deref(),
- Some("openai-1")
+ Some("openai-otter")
);
}
@@ -265,8 +283,16 @@ mod tests {
|account, label| account.label = label,
);
- assert_eq!(label, "claude-1");
- assert_eq!(accounts[0].label, "claude-1");
- assert_eq!(active.as_deref(), Some("claude-1"));
+ assert_eq!(label, "claude-otter");
+ assert_eq!(accounts[0].label, "claude-otter");
+ assert_eq!(active.as_deref(), Some("claude-otter"));
+ }
+
+ #[test]
+ fn account_labels_use_animals_and_stay_unique_after_the_named_pool() {
+ assert_eq!(canonical_account_label("claude", 1), "claude-otter");
+ assert_eq!(canonical_account_label("claude", 2), "claude-fox");
+ assert_eq!(canonical_account_label("openai", 32), "openai-penguin");
+ assert_eq!(canonical_account_label("openai", 33), "openai-animal-33");
}
}
diff --git a/crates/jcode-base/src/auth/claude.rs b/crates/jcode-base/src/auth/claude.rs
index 7c1fc5517c..7c01e31b35 100644
--- a/crates/jcode-base/src/auth/claude.rs
+++ b/crates/jcode-base/src/auth/claude.rs
@@ -327,7 +327,7 @@ pub fn load_auth_file() -> Result {
if relabel_accounts(&mut auth) {
crate::logging::info(
- "Renaming Claude accounts to numbered labels (claude-1, claude-2, ...)",
+ "Renaming Claude accounts to animal labels (claude-otter, claude-fox, ...)",
);
save_auth_file(&auth)?;
}
diff --git a/crates/jcode-base/src/auth/claude_tests.rs b/crates/jcode-base/src/auth/claude_tests.rs
index eeed2d188a..ae0a6a7ed1 100644
--- a/crates/jcode-base/src/auth/claude_tests.rs
+++ b/crates/jcode-base/src/auth/claude_tests.rs
@@ -82,7 +82,7 @@ fn jcode_path_respects_jcode_home() {
}
#[test]
-fn load_auth_file_renames_existing_labels_to_numbered_scheme() {
+fn load_auth_file_renames_existing_labels_to_animal_scheme() {
let _lock = crate::storage::lock_test_env();
let temp = tempfile::TempDir::new().unwrap();
let _home = EnvVarGuard::set("JCODE_HOME", temp.path());
@@ -117,9 +117,9 @@ fn load_auth_file_renames_existing_labels_to_numbered_scheme() {
.iter()
.map(|account| account.label.as_str())
.collect::>(),
- vec!["claude-1", "claude-2"]
+ vec!["claude-otter", "claude-fox"]
);
- assert_eq!(auth.active_anthropic_account.as_deref(), Some("claude-2"));
+ assert_eq!(auth.active_anthropic_account.as_deref(), Some("claude-fox"));
}
#[test]
diff --git a/crates/jcode-base/src/auth/codex.rs b/crates/jcode-base/src/auth/codex.rs
index e0d9f37592..25c53cea9d 100644
--- a/crates/jcode-base/src/auth/codex.rs
+++ b/crates/jcode-base/src/auth/codex.rs
@@ -169,7 +169,7 @@ pub fn load_auth_file() -> Result {
if relabel_accounts(&mut auth) {
crate::logging::info(
- "Renaming OpenAI accounts to numbered labels (openai-1, openai-2, ...)",
+ "Renaming OpenAI accounts to animal labels (openai-otter, openai-fox, ...)",
);
save_auth_file(&auth)?;
}
diff --git a/crates/jcode-base/src/auth/codex_tests.rs b/crates/jcode-base/src/auth/codex_tests.rs
index a9c69b81f8..a235351baa 100644
--- a/crates/jcode-base/src/auth/codex_tests.rs
+++ b/crates/jcode-base/src/auth/codex_tests.rs
@@ -396,7 +396,7 @@ fn load_credentials_reads_legacy_oauth_without_changing_external_permissions() {
}
#[test]
-fn load_auth_file_renames_existing_labels_to_numbered_scheme() {
+fn load_auth_file_renames_existing_labels_to_animal_scheme() {
let _lock = crate::storage::lock_test_env();
let temp = tempfile::TempDir::new().unwrap();
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
@@ -429,7 +429,7 @@ fn load_auth_file_renames_existing_labels_to_numbered_scheme() {
.iter()
.map(|account| account.label.as_str())
.collect::>(),
- vec!["openai-1", "openai-2"]
+ vec!["openai-otter", "openai-fox"]
);
- assert_eq!(auth.active_openai_account.as_deref(), Some("openai-2"));
+ assert_eq!(auth.active_openai_account.as_deref(), Some("openai-fox"));
}
diff --git a/crates/jcode-base/src/auth/cursor.rs b/crates/jcode-base/src/auth/cursor.rs
index e21fb65eed..c58438d669 100644
--- a/crates/jcode-base/src/auth/cursor.rs
+++ b/crates/jcode-base/src/auth/cursor.rs
@@ -4,8 +4,6 @@ use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
-use std::process::{Command, Output, Stdio};
-use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
const CURSOR_API_BASE: &str = "https://api2.cursor.sh";
@@ -15,7 +13,6 @@ const CURSOR_API_BASE: &str = "https://api2.cursor.sh";
// `JCODE_CURSOR_CLIENT_VERSION` if Cursor moves the floor again.
const CURSOR_DIRECT_CLIENT_VERSION_DEFAULT: &str = "3.8.24";
const CURSOR_OAUTH_CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
-const CURSOR_EXTERNAL_COMMAND_TIMEOUT: Duration = Duration::from_secs(3);
pub const CURSOR_AUTH_FILE_SOURCE_ID: &str = "cursor_auth_json";
pub const CURSOR_VSCDB_SOURCE_ID: &str = "cursor_vscdb";
@@ -101,29 +98,6 @@ pub fn has_cursor_native_auth() -> bool {
load_access_token_from_env_or_file().is_ok() || has_cursor_vscdb_token() || has_cursor_api_key()
}
-/// Check whether the local Cursor Agent CLI reports an authenticated session.
-///
-/// Full auth status may spend a little time probing external commands, while
-/// `AuthStatus::check_fast()` intentionally skips this path for UI responsiveness.
-pub fn has_authenticated_cli_session() -> bool {
- let command = std::env::var_os("JCODE_CURSOR_CLI_PATH")
- .unwrap_or_else(|| std::ffi::OsString::from("cursor-agent"));
- let command_label = command.to_string_lossy();
- if !super::command_exists(&command_label) {
- return false;
- }
-
- let mut status_command = Command::new(&command);
- status_command.arg("status");
- let Ok(Some(output)) =
- command_output_with_timeout(&mut status_command, CURSOR_EXTERNAL_COMMAND_TIMEOUT)
- else {
- return false;
- };
-
- status_output_indicates_authenticated(output.status.success(), &output.stdout, &output.stderr)
-}
-
/// Check whether a trusted Cursor auth.json contains a usable direct access token.
pub fn has_cursor_auth_file_token() -> bool {
let Ok(file_path) = cursor_auth_file_path() else {
@@ -262,49 +236,28 @@ fn cursor_vscdb_paths() -> Vec {
.collect()
}
-/// Read a key from a vscdb file using the sqlite3 CLI.
+/// Read a key from Cursor's SQLite state directly. Authentication must not
+/// depend on an external `sqlite3` executable being installed.
fn read_vscdb_key(db_path: &PathBuf, key: &str) -> Result {
- let mut command = Command::new("sqlite3");
- command.arg(db_path).arg(format!(
- "SELECT value FROM ItemTable WHERE key = '{}';",
- key
- ));
- let output = command_output_with_timeout(&mut command, CURSOR_EXTERNAL_COMMAND_TIMEOUT)
- .context("Failed to run sqlite3 (is it installed?)")?
- .ok_or_else(|| anyhow::anyhow!("sqlite3 timed out reading {}", db_path.display()))?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("sqlite3 failed: {}", stderr.trim());
- }
-
- let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ let connection = rusqlite::Connection::open_with_flags(
+ db_path,
+ rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
+ | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
+ | rusqlite::OpenFlags::SQLITE_OPEN_URI,
+ )
+ .with_context(|| format!("Failed to open Cursor state at {}", db_path.display()))?;
+ let value: String = connection
+ .query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |row| {
+ row.get(0)
+ })
+ .with_context(|| format!("Key '{key}' not found in {}", db_path.display()))?;
+ let value = value.trim().to_string();
if value.is_empty() {
anyhow::bail!("Key '{}' not found or empty in {}", key, db_path.display());
}
Ok(value)
}
-fn command_output_with_timeout(command: &mut Command, timeout: Duration) -> Result