Merge pull request #6175 from Hmbown/v0914-chunk2: v0.9.14 slice run 2

51 commits. Lands the v0.9.14 slice-2 work plus overnight CI triage.

Fixed to get here:
- RUSTSEC-2026-0285 — rustls 0.23.45 (#6105's 0.23.44 was not the patched release)
- web fact staleness, in both the generated and the hand-maintained copy
- seven clippy errors, five only reachable via --all-targets
- Test (windows-latest) compile break: ShellKind's dead_code expectations were unix-first
- ACP session/new minting ids session/load could not resolve (#6174)
- PTY harness sealed against a live local Ollama
- workspace instruction relative_path made platform-independent
- pet_watch Windows test racing a 500ms audio freshness window (#6224)
- pet conformance: one whale body across all five copies (#6227) — 380 checkpoints, first time green

Reverted in-branch: the #6207 store-adoption fix, whose predicate sampled emptiness with no liveness check.

Merging with two non-required checks red, neither from this branch:
- CodeQL: 34 pre-existing high alerts on main
- Codewhale review: a provider-spend gate requiring 5 passes, not a code failure

All three required checks green: Lint, Test (windows-latest), npm wrapper smoke (ubuntu-latest).
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-15 08:29:12 -07:00
commit 9cdfa92bc0
205 files changed
+12555 -6156

No files matched your search

+7
View File
@@ -428,6 +428,13 @@ jobs:
# pushes to main, where the number is actually actionable.
continue-on-error: ${{ github.event_name == 'pull_request' }}
run: python3 scripts/check-dead-code-budget.py
# Ratchet for blocking calls that could park Tokio workers: any new
# thread::sleep/std::fs site outside spawn_blocking, dedicated-thread,
# or test scopes must be isolated or budgeted (#6149).
- name: Check blocking-calls budget
if: needs.changes.outputs.heavy == 'true'
continue-on-error: ${{ github.event_name == 'pull_request' }}
run: python3 scripts/check-blocking-calls-budget.py
- name: Test runtime-contract measurement harness
if: needs.changes.outputs.heavy == 'true'
run: |
+13
View File
@@ -35,6 +35,19 @@ jobs:
- run: npm --prefix pet run sync
- name: Embedded sources match canonical core
run: git diff --exit-code -- crates/tui/src/tui/pet_watch/pet-native.js pet/ios/Resources
# The authored body is checked in five times and nothing kept the copies
# in step, so the Rust core silently carried a different whale from every
# other surface and conformance never passed. Compare them directly: no
# sync step produces these, so `git diff` cannot catch the drift.
- name: One whale body across every surface
run: |
canonical=crates/tui/src/tui/ambient_life/whale-points.tsv
for copy in pet/whale-points.tsv pet/public/whale-points.tsv pet/rs/whale-points.tsv; do
diff -q "$canonical" "$copy" || {
echo "::error file=$copy::diverged from $canonical; the Rust core is canonical"
exit 1
}
done
- run: cargo fetch --locked --manifest-path pet/tui/Cargo.toml
- name: TypeScript and Rust parity, including reduced motion
run: ./pet/verify.sh --no-swift
+31 -3
View File
@@ -56,9 +56,11 @@ base prompt". Two more corollaries earned here:
lane is obsolete, preserve its intent and evidence rather than merging stale
code mechanically.
- A small coherent change may be committed directly to `main` when that checkout
is current, clean, and owns the affected files. A worktree remains the right
safety boundary for conflicting, dirty, stale, or independent work. Local
commit permission never implies push, merge, tag, release, or deploy permission.
is current, clean, and owns the affected files. Do not create worktrees: work
in the checkout that already exists, and when several agents share it,
partition by file, stage only the paths your slice touched, and retry a commit
that fails on `index.lock`. Local commit permission never implies push, merge,
tag, release, or deploy permission.
- When the task is local-only, stay fully offline: no browsing, GitHub or remote
Git operations, downloads, dependency installation, provider calls, or
source/diff transmission. Record the missing external receipt and keep working
@@ -69,6 +71,16 @@ base prompt". Two more corollaries earned here:
- Keep providers and models first-class and provider-neutral.
- Never rewrite published history, retag a release, force-push a shared ref, or
publish without explicit authorization. Preserve human contributor credit.
- **Model-visible means logged.** Anything that reaches a model request must be
reconstructable from the session log, and a new model-visible input needs a
session event. Live presentation and the persisted record must agree; when they
disagree the record is right.
- **Misconfiguration fails loud**, at load when it is self-contained, otherwise
at the earliest point it can be resolved. Never silently skip a missing
referent.
- **Write down what a design does not do**, beside the behaviour it owns — a
short known-limitations note in the owning module. A stated limit stops the
next reader from assuming a capability that was never built.
## Landing other people's work
@@ -120,6 +132,10 @@ because they did anything wrong. Treat their time as more expensive than ours.
- Audit any harness before trusting its score. `ok = ok and X or True` parses
as `(ok and X) or True` and silently reported twelve unevaluated rows as
passing.
- Match the evidence to the surface. Run the tests that cover the change, not the
whole suite, and do not repeat a check that already passed in order to commit.
CI owns exhaustive coverage; a full local run is for CI diagnosis or for an
irreducibly repository-wide change.
## Current contracts
@@ -143,6 +159,18 @@ because they did anything wrong. Treat their time as more expensive than ours.
`config/src/route/`. Native memory lives in `tui/src/native_memory.rs`;
`tools/remember.rs` is its capture path.
- Environment-specific behavior belongs in `docs/ENVIRONMENTS.md`, not here.
- Blocking-call convention (#6149): code on the Tokio runtime — tool
handlers, engine tasks, the UI event loop, anything reached through an
`async` call chain — must not run blocking operations inline.
`std::fs`/`std::process` calls inside `async` code use `tokio::fs`/
`tokio::process`, or move the synchronous work into
`tokio::task::spawn_blocking` (`utils::spawn_blocking_supervised` for
fire-and-forget). `thread::sleep` is for dedicated `std::thread`s and
bounded contention retries in synchronous APIs that are only reachable
from blocking scopes — an async-path wait uses `tokio::time`. A sync
helper containing blocking calls must only be called under
`spawn_blocking` or from a dedicated thread; `scripts/
check-blocking-calls-budget.py` ratchets the unprotected-site count.
## Code, migrations, and evidence
Generated
+7 -5
View File
@@ -881,7 +881,7 @@ name = "codewhale-config"
version = "0.9.13"
dependencies = [
"anyhow",
"base64 0.22.1",
"base64 0.23.1",
"chrono",
"codewhale-execpolicy",
"codewhale-paths",
@@ -931,8 +931,10 @@ version = "0.9.13"
dependencies = [
"anyhow",
"codewhale-protocol",
"regex",
"serde",
"shlex 1.3.0",
"shlex 2.0.1",
"toml 1.1.4+spec-1.1.0",
]
[[package]]
@@ -1188,7 +1190,7 @@ dependencies = [
"sha2 0.11.0",
"shell-words",
"shellexpand",
"shlex 1.3.0",
"shlex 2.0.1",
"similar",
"syntect",
"tar",
@@ -4772,9 +4774,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.44"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
+25 -17
View File
@@ -258,7 +258,7 @@ fn unsupported_platform() -> DaemonSocketError {
#[cfg(unix)]
mod platform {
use std::collections::HashMap;
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt};
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -409,7 +409,8 @@ mod platform {
shutdown,
} = self;
let _socket_file = SocketFileGuard(path.clone());
let uid = std::fs::metadata(&path)
let uid = tokio::fs::metadata(&path)
.await
.map_err(|source| DaemonSocketError::Io {
context: "failed to stat the daemon socket",
path: path.clone(),
@@ -468,7 +469,7 @@ mod platform {
options: DaemonSocketOptions,
) -> Result<DaemonSocket, DaemonSocketError> {
let path = resolve_socket_path(&SocketPathInputs::from_environment(options.socket_path)?)?;
ensure_private_parent_dir(&path)?;
ensure_private_parent_dir(&path).await?;
clear_stale_socket(&path).await?;
let listener = UnixListener::bind(&path).map_err(|source| DaemonSocketError::Io {
@@ -476,13 +477,13 @@ mod platform {
path: path.clone(),
source,
})?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).map_err(
|source| DaemonSocketError::Io {
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|source| DaemonSocketError::Io {
context: "failed to restrict daemon socket permissions to 0600",
path: path.clone(),
source,
},
)?;
})?;
let state = build_state_with_transport(options.config_path, None, AppTransport::Socket)
.map_err(DaemonSocketError::State)?;
@@ -496,21 +497,26 @@ mod platform {
}
/// Create the socket's directory as `0700` when it does not exist. An
/// existing directory is left as the operator made it.
fn ensure_private_parent_dir(path: &Path) -> Result<(), DaemonSocketError> {
/// existing directory is left as the operator made it. Async because the
/// caller runs on the Tokio runtime (blocking-call convention, #6149).
async fn ensure_private_parent_dir(path: &Path) -> Result<(), DaemonSocketError> {
let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return Ok(());
};
if parent.is_dir() {
if tokio::fs::metadata(parent)
.await
.is_ok_and(|metadata| metadata.is_dir())
{
return Ok(());
}
std::fs::DirBuilder::new()
tokio::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)
.await
.map_err(|source| DaemonSocketError::Io {
context: "failed to create the daemon runtime directory",
path: parent.to_path_buf(),
@@ -520,7 +526,7 @@ mod platform {
/// Remove a socket file nobody answers on; refuse to touch anything else.
async fn clear_stale_socket(path: &Path) -> Result<(), DaemonSocketError> {
let metadata = match std::fs::symlink_metadata(path) {
let metadata = match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
@@ -541,11 +547,13 @@ mod platform {
path: path.to_path_buf(),
}),
Ok(Err(_refused)) => {
std::fs::remove_file(path).map_err(|source| DaemonSocketError::Io {
context: "failed to remove a stale daemon socket",
path: path.to_path_buf(),
source,
})
tokio::fs::remove_file(path)
.await
.map_err(|source| DaemonSocketError::Io {
context: "failed to remove a stale daemon socket",
path: path.to_path_buf(),
source,
})
}
Err(_elapsed) => Err(DaemonSocketError::ProbeTimedOut {
path: path.to_path_buf(),
+18
View File
@@ -229,6 +229,7 @@ Common forwarded flags:
--session-id <SESSION_ID> Resume a previous session by ID or prefix
--continue Continue the most recent session for this workspace
--output-format <FORMAT> Output format: text or stream-json
--hooks Opt in to configured hooks (tool_call_before, shell_env)
Plain `codewhale exec` is a one-shot model response. Use `--auto` for
non-interactive filesystem/shell tool use, matching the supported automation
@@ -296,6 +297,10 @@ lifecycle generation you observed.
Eval(TuiPassthroughArgs),
/// Manage MCP servers.
Mcp(TuiPassthroughArgs),
/// Run the shared ambient pet owner (`pet serve`). Internal: spawned
/// lazily by clients when no owner is running.
#[command(name = "pet", hide = true)]
Pet(TuiPassthroughArgs),
/// Inspect feature flags.
Features(TuiPassthroughArgs),
/// Connect third-party harnesses through Codewhale (e.g. `integrations dsh status`).
@@ -2114,6 +2119,19 @@ fn run() -> Result<()> {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("mcp", args))
}
Some(Commands::Pet(args)) => {
// `pet` must reach run_with_args at argv[1]; the TUI passthrough
// builder would inject global flags ahead of it and the trailing
// PROMPT positional would otherwise swallow `pet serve`.
let mut argv = vec!["codewhale".to_string(), "pet".to_string()];
argv.extend(args.args);
let code = codewhale_tui::run(argv);
std::process::exit(if code == std::process::ExitCode::SUCCESS {
0
} else {
1
});
}
Some(Commands::Integrations(args)) => {
// Integrations only need route *identity*. Do not recover or
// export a stored credential just to plan/launch a third-party
+1 -1
View File
@@ -12,7 +12,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
base64 = "0.22.1"
base64 = "0.23.1"
chrono.workspace = true
ring = "0.17.14"
semver.workspace = true
+7 -1
View File
@@ -2475,6 +2475,8 @@ pub struct WorkflowConfigToml {
#[serde(default = "default_workflow_max_depth")]
pub max_depth: u32,
/// Default shared token budget for a Workflow run and its children.
/// `0` applies no shared cap — the run is advisory-only like the parent
/// turn loop — while any positive value is enforced across the run.
#[serde(default = "default_workflow_default_token_budget")]
pub default_token_budget: u64,
/// How many parallel write children may share the parent worktree without
@@ -2521,7 +2523,11 @@ fn default_workflow_max_depth() -> u32 {
}
fn default_workflow_default_token_budget() -> u64 {
120_000
// Off by default: a cap the caller never asked for must not throttle a
// run — a 120k default silently killed real fan-outs mid-task (#6189).
// Spend discipline stays available as an explicit opt-in (tool
// `token_budget`, spec `budget.max_tokens`, or a configured value here).
0
}
fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
+3 -1
View File
@@ -8252,7 +8252,9 @@ fn workflow_config_defaults_match_product_surface() {
assert_eq!(defaults.max_children, 1000);
assert_eq!(defaults.max_concurrent, 16);
assert_eq!(defaults.max_depth, 5);
assert_eq!(defaults.default_token_budget, 120_000);
// 0 = no shared cap; budgets are opt-in, matching the parent turn loop's
// advisory policy (#6189).
assert_eq!(defaults.default_token_budget, 0);
assert_eq!(defaults.max_parallel_writes_without_worktree, 0);
assert!(defaults.persist_completed_activity);
assert!(defaults.persist_completed_across_restarts);
+3 -1
View File
@@ -13,5 +13,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
codewhale-protocol = { path = "../protocol", version = "0.9.13" }
regex = "1.11"
serde.workspace = true
shlex = "1.3.0"
shlex = "2.0.1"
toml.workspace = true
+1 -1
View File
@@ -316,7 +316,7 @@ pub fn classify_command(tokens: &[&str]) -> String {
///
/// For allow rules that contain wildcards (`*`) or regex metacharacters, the
/// caller should additionally invoke the pattern-matching path from
/// `crate::execpolicy::matcher::pattern_matches`.
/// `crate::matcher::pattern_matches`.
///
/// # Examples
///
+2
View File
@@ -1,7 +1,9 @@
pub mod approval_mode;
pub mod bash_arity;
pub mod command_safety;
pub mod matcher;
pub mod shell_expand;
pub mod toml_rules;
pub use approval_mode::ApprovalMode;
File renamed without changes.
@@ -1,16 +1,30 @@
//! Execpolicy rules loaded from TOML configuration.
//!
//! The legacy Starlark policy engine (`PolicyParser`, `Policy`, `Rule`, the
//! `execpolicy check` CLI verb) was deleted for v0.9.4: the runtime never
//! enforced it, so a green `check` meant nothing. The live policy surface is
//! the TOML `execpolicy.toml` rules here plus this crate's permission engine.
//!
//! Where the file lives is the caller's business — the TUI resolves
//! `~/.deepseek/execpolicy.toml` and hands the parsed [`ExecPolicyConfig`]
//! to its shell tool.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
use super::matcher::pattern_matches;
use codewhale_execpolicy::command_safety::prefix_allow_matches;
use crate::command_safety::prefix_allow_matches;
use crate::matcher::pattern_matches;
/// Verdict of evaluating a command against the TOML rule sets.
///
/// Distinct from [`crate::ExecPolicyDecision`], which is the permission
/// engine's structured output; this is the file-rules verdict consumed by
/// the shell tool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecPolicyDecision {
pub enum RuleDecision {
Allow,
Deny(String),
AskUser(String),
@@ -31,17 +45,17 @@ pub struct RuleSet {
}
impl ExecPolicyConfig {
pub fn from_str(contents: &str) -> Result<Self> {
pub fn parse(contents: &str) -> Result<Self> {
toml::from_str(contents).context("failed to parse execpolicy.toml")
}
pub fn from_path(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read execpolicy file {}", path.display()))?;
Self::from_str(&contents)
Self::parse(&contents)
}
pub fn evaluate(&self, command: &str) -> ExecPolicyDecision {
pub fn evaluate(&self, command: &str) -> RuleDecision {
// #security: a deny pattern has to be matched against the commands the
// shell would actually run, not against the text as written. Quoting,
// command substitution (`` `cmd` ``, `$(cmd)`), grouping, chaining and
@@ -54,16 +68,14 @@ impl ExecPolicyConfig {
// Only the deny loop is widened. The allow loop below still matches the
// command as written, so a broader expansion can never turn into a
// broader auto-approval.
let deny_targets = codewhale_execpolicy::shell_expand::expanded_commands(command);
let deny_targets = crate::shell_expand::expanded_commands(command);
for (group, rules) in &self.rules {
for pattern in &rules.deny {
if deny_targets
.iter()
.any(|target| pattern_matches(pattern, target))
{
return ExecPolicyDecision::Deny(format!(
"execpolicy denied by {group}: {pattern}"
));
return RuleDecision::Deny(format!("execpolicy denied by {group}: {pattern}"));
}
}
}
@@ -76,29 +88,15 @@ impl ExecPolicyConfig {
// `pattern_matches` for wildcard patterns (e.g. `cargo *`).
if prefix_allow_matches(pattern, command) || pattern_matches(pattern, command) {
let _ = group;
return ExecPolicyDecision::Allow;
return RuleDecision::Allow;
}
}
}
ExecPolicyDecision::AskUser("execpolicy: no matching allow rule".to_string())
RuleDecision::AskUser("execpolicy: no matching allow rule".to_string())
}
}
pub fn default_execpolicy_path() -> Option<PathBuf> {
crate::config::effective_home_dir().map(|home| home.join(".deepseek").join("execpolicy.toml"))
}
pub fn load_default_policy() -> Result<Option<ExecPolicyConfig>> {
let Some(path) = default_execpolicy_path() else {
return Ok(None);
};
if !path.exists() {
return Ok(None);
}
ExecPolicyConfig::from_path(&path).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -124,21 +122,18 @@ mod tests {
]),
};
assert!(matches!(
config.evaluate("git status"),
ExecPolicyDecision::Allow
));
assert!(matches!(config.evaluate("git status"), RuleDecision::Allow));
assert!(matches!(
config.evaluate("git log --oneline"),
ExecPolicyDecision::Allow
RuleDecision::Allow
));
assert!(matches!(
config.evaluate("git push --force"),
ExecPolicyDecision::Deny(_)
RuleDecision::Deny(_)
));
assert!(matches!(
config.evaluate("unknown command"),
ExecPolicyDecision::AskUser(_)
RuleDecision::AskUser(_)
));
}
@@ -157,16 +152,16 @@ mod tests {
assert!(matches!(
config.evaluate("git status -s"),
ExecPolicyDecision::Allow
RuleDecision::Allow
));
assert!(matches!(
config.evaluate("git status --porcelain"),
ExecPolicyDecision::Allow
RuleDecision::Allow
));
// Push must NOT match the "git status" allow rule.
assert!(matches!(
config.evaluate("git push origin main"),
ExecPolicyDecision::AskUser(_)
RuleDecision::AskUser(_)
));
}
@@ -214,7 +209,7 @@ mod tests {
"timeout 5 rm -rf /",
"xargs rm -rf /",
] {
if !matches!(config.evaluate(command), ExecPolicyDecision::Deny(_)) {
if !matches!(config.evaluate(command), RuleDecision::Deny(_)) {
evaded.push(command);
}
}
@@ -234,7 +229,7 @@ mod tests {
"echo 'rm -rf /'",
] {
assert!(
!matches!(config.evaluate(command), ExecPolicyDecision::Deny(_)),
!matches!(config.evaluate(command), RuleDecision::Deny(_)),
"harmless command wrongly denied: {command:?}"
);
}
@@ -254,15 +249,15 @@ mod tests {
assert!(matches!(
config.evaluate("cargo check"),
ExecPolicyDecision::Allow
RuleDecision::Allow
));
assert!(matches!(
config.evaluate("cargo check --workspace"),
ExecPolicyDecision::Allow
RuleDecision::Allow
));
assert!(matches!(
config.evaluate("cargo build --release"),
ExecPolicyDecision::AskUser(_)
RuleDecision::AskUser(_)
));
}
}
+7 -2
View File
@@ -1359,8 +1359,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "Què vols aconseguir?",
"SessionsSurfaceTitle": "sessions",
"SessionsPaneTitle": " sessions (1-9) ",
"SessionsHistoryPaneTitle": " historial (PgUp/PgDn) ",
"SessionsPaneTitle": " sessions (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " historial (Shift+PgUp/PgDn) ",
"SessionsActionResume": "reprèn",
"SessionsActionSearch": "cerca",
"SessionsActionSort": "ordena",
@@ -1399,6 +1399,8 @@
"SessionsShowingRange": "Mostrant {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} msjs",
"SessionsForkCompact": "bifurcació",
"SessionsCurrentCompact": "actual",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "desconegut",
"SessionsPreviewTitle": "Títol: {title}",
"SessionsPreviewUpdated": "Actualitzada: {updated}",
@@ -1449,6 +1451,7 @@
"FleetRoutePickUnavailable": "L’equip ha canviat o ja no està disponible. Torna’l a obrir abans de triar un model.",
"FleetRouteSaved": "Desat {fleet} · {route} · {path}",
"FleetRouteInherited": "model de la sessió",
"FleetRouteNotInCatalog": " · absent del catàleg actual",
"PickerActionSetStartupDefault": "defineix com a predeterminat d'inici",
"PickerActionPin": "fixa",
"PickerActionFleet": "flota",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Mode",
"StatusLabelSafety": "Seguretat",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Finestra de context",
"StatusLabelWindowSource": "Origen de la finestra",
"StatusLabelWindowOverride": "Ajust de la finestra",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "accés total",
"StatusApprovalNever": "mai",
"StatusMcpConfigured": "{count} configurats",
"StatusFleetDrifted": "{fleet} · {count} rutes desades que no són al catàleg actual: {ids}",
"StatusContextUsage": "{percent}% utilitzat ({used} / {max} tokens)",
"StatusContextSourceConfigured": "configurada",
"StatusContextSourceConfiguredModel": "configurada (per model)",
+7 -2
View File
@@ -1359,8 +1359,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "Was möchtest du erreichen?",
"SessionsSurfaceTitle": "Sitzungen",
"SessionsPaneTitle": " Sitzungen (1-9) ",
"SessionsHistoryPaneTitle": " Verlauf (PgUp/PgDn) ",
"SessionsPaneTitle": " Sitzungen (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " Verlauf (Shift+PgUp/PgDn) ",
"SessionsActionResume": "fortsetzen",
"SessionsActionSearch": "suchen",
"SessionsActionSort": "sortieren",
@@ -1399,6 +1399,8 @@
"SessionsShowingRange": "Zeige {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} Nachr.",
"SessionsForkCompact": "Fork",
"SessionsCurrentCompact": "aktuell",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "unbekannt",
"SessionsPreviewTitle": "Titel: {title}",
"SessionsPreviewUpdated": "Aktualisiert: {updated}",
@@ -1449,6 +1451,7 @@
"FleetRoutePickUnavailable": "Das Team wurde geändert oder ist nicht mehr verfügbar. Öffne es erneut, bevor du ein Modell auswählst.",
"FleetRouteSaved": "Gespeichert: {fleet} · {route} · {path}",
"FleetRouteInherited": "Sitzungsmodell",
"FleetRouteNotInCatalog": " · nicht im aktuellen Katalog",
"PickerActionSetStartupDefault": "als Startstandard festlegen",
"PickerActionPin": "anheften",
"PickerActionFleet": "Flotte",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Modus",
"StatusLabelSafety": "Sicherheit",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Kontextfenster",
"StatusLabelWindowSource": "Fensterquelle",
"StatusLabelWindowOverride": "Fensteranpassung",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "Vollzugriff",
"StatusApprovalNever": "nie",
"StatusMcpConfigured": "{count} konfiguriert",
"StatusFleetDrifted": "{fleet} · {count} gespeicherte Routen nicht im aktuellen Katalog: {ids}",
"StatusContextUsage": "{percent}% belegt ({used} / {max} Token)",
"StatusContextSourceConfigured": "konfiguriert",
"StatusContextSourceConfiguredModel": "konfiguriert (pro Modell)",
+7 -2
View File
@@ -1382,8 +1382,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "What do you want to accomplish?",
"SessionsSurfaceTitle": "sessions",
"SessionsPaneTitle": " sessions (1-9) ",
"SessionsHistoryPaneTitle": " history (PgUp/PgDn) ",
"SessionsPaneTitle": " sessions (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " history (Shift+PgUp/PgDn) ",
"SessionsActionResume": "resume",
"SessionsActionSearch": "search",
"SessionsActionSort": "sort",
@@ -1422,6 +1422,8 @@
"SessionsShowingRange": "Showing {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} msgs",
"SessionsForkCompact": "fork",
"SessionsCurrentCompact": "current",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "unknown",
"SessionsPreviewTitle": "Title: {title}",
"SessionsPreviewUpdated": "Updated: {updated}",
@@ -1472,6 +1474,7 @@
"FleetRoutePickUnavailable": "The team changed or is no longer available. Reopen it before choosing a model.",
"FleetRouteSaved": "Saved {fleet} · {route} · {path}",
"FleetRouteInherited": "session model",
"FleetRouteNotInCatalog": " · not in current catalog",
"PickerActionSetStartupDefault": "set startup default",
"PickerActionPin": "pin",
"PickerActionFleet": "fleet",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Mode",
"StatusLabelSafety": "Safety",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Context window",
"StatusLabelWindowSource": "Window source",
"StatusLabelWindowOverride": "Window override",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "full access",
"StatusApprovalNever": "never",
"StatusMcpConfigured": "{count} configured",
"StatusFleetDrifted": "{fleet} · {count} saved routes not in the current catalog: {ids}",
"StatusContextUsage": "{percent}% used ({used} / {max} tokens)",
"StatusContextSourceConfigured": "configured",
"StatusContextSourceConfiguredModel": "configured (per-model)",
+7 -2
View File
@@ -1382,8 +1382,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "¿Qué quieres lograr?",
"SessionsSurfaceTitle": "sesiones",
"SessionsPaneTitle": " sesiones (1-9) ",
"SessionsHistoryPaneTitle": " historial (PgUp/PgDn) ",
"SessionsPaneTitle": " sesiones (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " historial (Shift+PgUp/PgDn) ",
"SessionsActionResume": "reanudar",
"SessionsActionSearch": "buscar",
"SessionsActionSort": "ordenar",
@@ -1422,6 +1422,8 @@
"SessionsShowingRange": "Mostrando {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} msgs",
"SessionsForkCompact": "bifurcar",
"SessionsCurrentCompact": "actual",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "desconocido",
"SessionsPreviewTitle": "Título: {title}",
"SessionsPreviewUpdated": "Actualizado: {updated}",
@@ -1472,6 +1474,7 @@
"FleetRoutePickUnavailable": "El equipo cambió o ya no está disponible. Vuelve a abrirlo antes de elegir un modelo.",
"FleetRouteSaved": "Guardado: {fleet} · {route} · {path}",
"FleetRouteInherited": "modelo de la sesión",
"FleetRouteNotInCatalog": " · no está en el catálogo actual",
"PickerActionSetStartupDefault": "establecer como predeterminado de inicio",
"PickerActionPin": "fijar",
"PickerActionFleet": "flota",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Modo",
"StatusLabelSafety": "Seguridad",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Ventana de contexto",
"StatusLabelWindowSource": "Origen de la ventana",
"StatusLabelWindowOverride": "Ajuste de ventana",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "acceso total",
"StatusApprovalNever": "nunca",
"StatusMcpConfigured": "{count} configurados",
"StatusFleetDrifted": "{fleet} · {count} rutas guardadas que no están en el catálogo actual: {ids}",
"StatusContextUsage": "{percent}% usado ({used} / {max} tokens)",
"StatusContextSourceConfigured": "configurada",
"StatusContextSourceConfiguredModel": "configurada (por modelo)",
+7 -2
View File
@@ -1359,8 +1359,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "Que voulez-vous accomplir ?",
"SessionsSurfaceTitle": "sessions",
"SessionsPaneTitle": " sessions (1-9) ",
"SessionsHistoryPaneTitle": " historique (PgUp/PgDn) ",
"SessionsPaneTitle": " sessions (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " historique (Shift+PgUp/PgDn) ",
"SessionsActionResume": "reprendre",
"SessionsActionSearch": "rechercher",
"SessionsActionSort": "trier",
@@ -1399,6 +1399,8 @@
"SessionsShowingRange": "Affichage de {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} msgs",
"SessionsForkCompact": "fork",
"SessionsCurrentCompact": "actuelle",
"SessionsPreviewId": "ID : {id}",
"SessionsUnknownMode": "inconnu",
"SessionsPreviewTitle": "Titre : {title}",
"SessionsPreviewUpdated": "Mis à jour : {updated}",
@@ -1449,6 +1451,7 @@
"FleetRoutePickUnavailable": "L’équipe a changé ou n’est plus disponible. Rouvrez-la avant de choisir un modèle.",
"FleetRouteSaved": "Enregistré : {fleet} · {route} · {path}",
"FleetRouteInherited": "modèle de la session",
"FleetRouteNotInCatalog": " · absent du catalogue actuel",
"PickerActionSetStartupDefault": "définir comme valeur par défaut au démarrage",
"PickerActionPin": "épingler",
"PickerActionFleet": "flotte",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Mode",
"StatusLabelSafety": "Sécurité",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Fenêtre de contexte",
"StatusLabelWindowSource": "Source de la fenêtre",
"StatusLabelWindowOverride": "Réglage de la fenêtre",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "accès total",
"StatusApprovalNever": "jamais",
"StatusMcpConfigured": "{count} configurés",
"StatusFleetDrifted": "{fleet} · {count} itinéraires enregistrés absents du catalogue actuel : {ids}",
"StatusContextUsage": "{percent}% utilisé ({used} / {max} jetons)",
"StatusContextSourceConfigured": "configurée",
"StatusContextSourceConfiguredModel": "configurée (par modèle)",
+7 -2
View File
@@ -1359,8 +1359,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "आप क्या हासिल करना चाहते हैं?",
"SessionsSurfaceTitle": "सत्र",
"SessionsPaneTitle": " सत्र (1-9) ",
"SessionsHistoryPaneTitle": " इतिहास (PgUp/PgDn) ",
"SessionsPaneTitle": " सत्र (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " इतिहास (Shift+PgUp/PgDn) ",
"SessionsActionResume": "फिर शुरू",
"SessionsActionSearch": "खोजें",
"SessionsActionSort": "क्रमबद्ध",
@@ -1399,6 +1399,8 @@
"SessionsShowingRange": "{start}-{end} / {total} दिख रहे",
"SessionsMessageCountCompact": "{count} संदेश",
"SessionsForkCompact": "फ़ोर्क",
"SessionsCurrentCompact": "वर्तमान",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "अज्ञात",
"SessionsPreviewTitle": "शीर्षक: {title}",
"SessionsPreviewUpdated": "अपडेट: {updated}",
@@ -1449,6 +1451,7 @@
"FleetRoutePickUnavailable": "टीम बदल गई है या अब उपलब्ध नहीं है। मॉडल चुनने से पहले इसे फिर से खोलें।",
"FleetRouteSaved": "सहेजा गया: {fleet} · {route} · {path}",
"FleetRouteInherited": "सत्र का मॉडल",
"FleetRouteNotInCatalog": " · मौजूदा कैटलॉग में नहीं",
"PickerActionSetStartupDefault": "आरंभिक डिफ़ॉल्ट के रूप में सेट करें",
"PickerActionPin": "पिन करें",
"PickerActionFleet": "फ़्लीट",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "मोड",
"StatusLabelSafety": "सुरक्षा",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "कॉन्टेक्स्ट विंडो",
"StatusLabelWindowSource": "विंडो स्रोत",
"StatusLabelWindowOverride": "विंडो ओवरराइड",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "पूर्ण एक्सेस",
"StatusApprovalNever": "कभी नहीं",
"StatusMcpConfigured": "{count} कॉन्फ़िगर",
"StatusFleetDrifted": "{fleet} · {count} सहेजे गए रूट मौजूदा कैटलॉग में नहीं: {ids}",
"StatusContextUsage": "{percent}% उपयोग ({used} / {max} टोकन)",
"StatusContextSourceConfigured": "कॉन्फ़िगर किया गया",
"StatusContextSourceConfiguredModel": "कॉन्फ़िगर किया गया (प्रति मॉडल)",
+7 -2
View File
@@ -1359,8 +1359,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "Apa yang ingin Anda capai?",
"SessionsSurfaceTitle": "sesi",
"SessionsPaneTitle": " sesi (1-9) ",
"SessionsHistoryPaneTitle": " riwayat (PgUp/PgDn) ",
"SessionsPaneTitle": " sesi (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " riwayat (Shift+PgUp/PgDn) ",
"SessionsActionResume": "lanjut",
"SessionsActionSearch": "cari",
"SessionsActionSort": "urut",
@@ -1399,6 +1399,8 @@
"SessionsShowingRange": "Menampilkan {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} pesan",
"SessionsForkCompact": "fork",
"SessionsCurrentCompact": "saat ini",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "tidak diketahui",
"SessionsPreviewTitle": "Judul: {title}",
"SessionsPreviewUpdated": "Diperbarui: {updated}",
@@ -1449,6 +1451,7 @@
"FleetRoutePickUnavailable": "Tim telah berubah atau tidak lagi tersedia. Buka kembali sebelum memilih model.",
"FleetRouteSaved": "Tersimpan: {fleet} · {route} · {path}",
"FleetRouteInherited": "model sesi",
"FleetRouteNotInCatalog": " · tidak ada di katalog saat ini",
"PickerActionSetStartupDefault": "tetapkan sebagai default saat mulai",
"PickerActionPin": "sematkan",
"PickerActionFleet": "armada",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Mode",
"StatusLabelSafety": "Keamanan",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Jendela konteks",
"StatusLabelWindowSource": "Sumber jendela",
"StatusLabelWindowOverride": "Penyesuaian jendela",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "akses penuh",
"StatusApprovalNever": "jangan pernah",
"StatusMcpConfigured": "{count} dikonfigurasi",
"StatusFleetDrifted": "{fleet} · {count} rute tersimpan tidak ada di katalog saat ini: {ids}",
"StatusContextUsage": "{percent}% terpakai ({used} / {max} token)",
"StatusContextSourceConfigured": "dikonfigurasi",
"StatusContextSourceConfiguredModel": "dikonfigurasi (per model)",
+7 -2
View File
@@ -1382,8 +1382,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "何を達成したいですか?",
"SessionsSurfaceTitle": "セッション",
"SessionsPaneTitle": " セッション (1-9) ",
"SessionsHistoryPaneTitle": " 履歴 (PgUp/PgDn) ",
"SessionsPaneTitle": " セッション (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " 履歴 (Shift+PgUp/PgDn) ",
"SessionsActionResume": "再開",
"SessionsActionSearch": "検索",
"SessionsActionSort": "並べ替え",
@@ -1422,6 +1422,8 @@
"SessionsShowingRange": "{start}-{end} / {total} 件を表示",
"SessionsMessageCountCompact": "{count} 件",
"SessionsForkCompact": "分岐",
"SessionsCurrentCompact": "現在",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "不明",
"SessionsPreviewTitle": "タイトル: {title}",
"SessionsPreviewUpdated": "更新: {updated}",
@@ -1472,6 +1474,7 @@
"FleetRoutePickUnavailable": "チームが変更されたか、利用できなくなりました。モデルを選ぶ前に開き直してください。",
"FleetRouteSaved": "保存済み: {fleet} · {route} · {path}",
"FleetRouteInherited": "セッションのモデル",
"FleetRouteNotInCatalog": " · 現在のカタログにありません",
"PickerActionSetStartupDefault": "起動時の既定値に設定",
"PickerActionPin": "ピン留め",
"PickerActionFleet": "フリート",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "モード",
"StatusLabelSafety": "安全性",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "コンテキストウィンドウ",
"StatusLabelWindowSource": "ウィンドウの根拠",
"StatusLabelWindowOverride": "ウィンドウ設定",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "フルアクセス",
"StatusApprovalNever": "常に拒否",
"StatusMcpConfigured": "{count} 件設定済み",
"StatusFleetDrifted": "{fleet} · 保存済みルート {count} 件が現在のカタログにありません: {ids}",
"StatusContextUsage": "{percent}% 使用中({used} / {max} トークン)",
"StatusContextSourceConfigured": "設定値",
"StatusContextSourceConfiguredModel": "設定値(モデル別)",
+7 -2
View File
@@ -1382,8 +1382,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "무엇을 이루고 싶으신가요?",
"SessionsSurfaceTitle": "세션",
"SessionsPaneTitle": " 세션 (1-9) ",
"SessionsHistoryPaneTitle": " 기록 (PgUp/PgDn) ",
"SessionsPaneTitle": " 세션 (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " 기록 (Shift+PgUp/PgDn) ",
"SessionsActionResume": "재개",
"SessionsActionSearch": "검색",
"SessionsActionSort": "정렬",
@@ -1422,6 +1422,8 @@
"SessionsShowingRange": "{start}-{end} / {total} 표시",
"SessionsMessageCountCompact": "메시지 {count}개",
"SessionsForkCompact": "포크",
"SessionsCurrentCompact": "현재",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "알 수 없음",
"SessionsPreviewTitle": "제목: {title}",
"SessionsPreviewUpdated": "업데이트: {updated}",
@@ -1472,6 +1474,7 @@
"FleetRoutePickUnavailable": "팀이 변경되었거나 더 이상 사용할 수 없습니다. 모델을 선택하기 전에 다시 여세요.",
"FleetRouteSaved": "저장됨: {fleet} · {route} · {path}",
"FleetRouteInherited": "세션 모델",
"FleetRouteNotInCatalog": " · 현재 카탈로그에 없음",
"PickerActionSetStartupDefault": "시작 기본값으로 설정",
"PickerActionPin": "고정",
"PickerActionFleet": "플릿",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "모드",
"StatusLabelSafety": "안전",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "컨텍스트 창",
"StatusLabelWindowSource": "창 출처",
"StatusLabelWindowOverride": "창 재정의",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "전체 접근",
"StatusApprovalNever": "허용 안 함",
"StatusMcpConfigured": "{count}개 구성됨",
"StatusFleetDrifted": "{fleet} · 저장된 경로 {count}개가 현재 카탈로그에 없습니다: {ids}",
"StatusContextUsage": "{percent}% 사용 중 ({used} / {max} 토큰)",
"StatusContextSourceConfigured": "구성 값",
"StatusContextSourceConfiguredModel": "구성 값(모델별)",
+7 -2
View File
@@ -1382,8 +1382,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "O que você quer realizar?",
"SessionsSurfaceTitle": "sessões",
"SessionsPaneTitle": " sessões (1-9) ",
"SessionsHistoryPaneTitle": " histórico (PgUp/PgDn) ",
"SessionsPaneTitle": " sessões (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " histórico (Shift+PgUp/PgDn) ",
"SessionsActionResume": "retomar",
"SessionsActionSearch": "buscar",
"SessionsActionSort": "ordenar",
@@ -1422,6 +1422,8 @@
"SessionsShowingRange": "Mostrando {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} msgs",
"SessionsForkCompact": "bifurcar",
"SessionsCurrentCompact": "atual",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "desconhecido",
"SessionsPreviewTitle": "Título: {title}",
"SessionsPreviewUpdated": "Atualizado: {updated}",
@@ -1472,6 +1474,7 @@
"FleetRoutePickUnavailable": "A equipe mudou ou não está mais disponível. Abra-a novamente antes de escolher um modelo.",
"FleetRouteSaved": "Salvo: {fleet} · {route} · {path}",
"FleetRouteInherited": "modelo da sessão",
"FleetRouteNotInCatalog": " · fora do catálogo atual",
"PickerActionSetStartupDefault": "definir como padrão de inicialização",
"PickerActionPin": "fixar",
"PickerActionFleet": "frota",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Modo",
"StatusLabelSafety": "Segurança",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Janela de contexto",
"StatusLabelWindowSource": "Origem da janela",
"StatusLabelWindowOverride": "Ajuste da janela",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "acesso total",
"StatusApprovalNever": "nunca",
"StatusMcpConfigured": "{count} configurados",
"StatusFleetDrifted": "{fleet} · {count} rotas salvas fora do catálogo atual: {ids}",
"StatusContextUsage": "{percent}% usado ({used} / {max} tokens)",
"StatusContextSourceConfigured": "configurada",
"StatusContextSourceConfiguredModel": "configurada (por modelo)",
+7 -2
View File
@@ -1359,8 +1359,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "Чего вы хотите достичь?",
"SessionsSurfaceTitle": "сессии",
"SessionsPaneTitle": " сессии (1-9) ",
"SessionsHistoryPaneTitle": " история (PgUp/PgDn) ",
"SessionsPaneTitle": " сессии (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " история (Shift+PgUp/PgDn) ",
"SessionsActionResume": "возобновить",
"SessionsActionSearch": "поиск",
"SessionsActionSort": "сортировка",
@@ -1399,6 +1399,8 @@
"SessionsShowingRange": "Показано {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} сообщ.",
"SessionsForkCompact": "fork",
"SessionsCurrentCompact": "текущая",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "неизвестно",
"SessionsPreviewTitle": "Заголовок: {title}",
"SessionsPreviewUpdated": "Обновлено: {updated}",
@@ -1449,6 +1451,7 @@
"FleetRoutePickUnavailable": "Команда изменилась или больше недоступна. Откройте её заново перед выбором модели.",
"FleetRouteSaved": "Сохранено: {fleet} · {route} · {path}",
"FleetRouteInherited": "модель сеанса",
"FleetRouteNotInCatalog": " · нет в текущем каталоге",
"PickerActionSetStartupDefault": "установить как значение по умолчанию при запуске",
"PickerActionPin": "закрепить",
"PickerActionFleet": "флот",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Режим",
"StatusLabelSafety": "Безопасность",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Окно контекста",
"StatusLabelWindowSource": "Источник окна",
"StatusLabelWindowOverride": "Настройка окна",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "полный доступ",
"StatusApprovalNever": "никогда",
"StatusMcpConfigured": "настроено: {count}",
"StatusFleetDrifted": "{fleet} · {count} сохранённых маршрутов нет в текущем каталоге: {ids}",
"StatusContextUsage": "использовано {percent}% ({used} / {max} токенов)",
"StatusContextSourceConfigured": "настроено",
"StatusContextSourceConfiguredModel": "настроено (для модели)",
+7 -2
View File
@@ -1359,8 +1359,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "Чого ви хочете досягти?",
"SessionsSurfaceTitle": "сесії",
"SessionsPaneTitle": " сесії (1-9) ",
"SessionsHistoryPaneTitle": " історія (PgUp/PgDn) ",
"SessionsPaneTitle": " сесії (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " історія (Shift+PgUp/PgDn) ",
"SessionsActionResume": "відновити",
"SessionsActionSearch": "пошук",
"SessionsActionSort": "сортувати",
@@ -1399,6 +1399,8 @@
"SessionsShowingRange": "Показано {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} повід.",
"SessionsForkCompact": "форк",
"SessionsCurrentCompact": "поточна",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "невідомо",
"SessionsPreviewTitle": "Назва: {title}",
"SessionsPreviewUpdated": "Оновлено: {updated}",
@@ -1449,6 +1451,7 @@
"FleetRoutePickUnavailable": "Команда змінилася або більше недоступна. Відкрийте її знову, перш ніж вибирати модель.",
"FleetRouteSaved": "Збережено: {fleet} · {route} · {path}",
"FleetRouteInherited": "модель сеансу",
"FleetRouteNotInCatalog": " · немає в поточному каталозі",
"PickerActionSetStartupDefault": "встановити як значення за замовчуванням під час запуску",
"PickerActionPin": "закріпити",
"PickerActionFleet": "флот",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Режим",
"StatusLabelSafety": "Безпека",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Вікно контексту",
"StatusLabelWindowSource": "Джерело вікна",
"StatusLabelWindowOverride": "Налаштування вікна",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "повний доступ",
"StatusApprovalNever": "ніколи",
"StatusMcpConfigured": "налаштовано: {count}",
"StatusFleetDrifted": "{fleet} · {count} збережених маршрутів немає в поточному каталозі: {ids}",
"StatusContextUsage": "використано {percent}% ({used} / {max} токенів)",
"StatusContextSourceConfigured": "налаштовано",
"StatusContextSourceConfiguredModel": "налаштовано (для моделі)",
+7 -2
View File
@@ -1382,8 +1382,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "Bạn muốn hoàn thành điều gì?",
"SessionsSurfaceTitle": "phiên",
"SessionsPaneTitle": " phiên (1-9) ",
"SessionsHistoryPaneTitle": " lịch sử (PgUp/PgDn) ",
"SessionsPaneTitle": " phiên (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " lịch sử (Shift+PgUp/PgDn) ",
"SessionsActionResume": "tiếp tục",
"SessionsActionSearch": "tìm kiếm",
"SessionsActionSort": "sắp xếp",
@@ -1422,6 +1422,8 @@
"SessionsShowingRange": "Hiển thị {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} tin",
"SessionsForkCompact": "nhánh",
"SessionsCurrentCompact": "hiện tại",
"SessionsPreviewId": "ID: {id}",
"SessionsUnknownMode": "không rõ",
"SessionsPreviewTitle": "Tiêu đề: {title}",
"SessionsPreviewUpdated": "Cập nhật: {updated}",
@@ -1472,6 +1474,7 @@
"FleetRoutePickUnavailable": "Nhóm đã thay đổi hoặc không còn khả dụng. Hãy mở lại trước khi chọn mô hình.",
"FleetRouteSaved": "Đã lưu: {fleet} · {route} · {path}",
"FleetRouteInherited": "mô hình của phiên",
"FleetRouteNotInCatalog": " · không có trong danh mục hiện tại",
"PickerActionSetStartupDefault": "đặt làm mặc định khi khởi động",
"PickerActionPin": "ghim",
"PickerActionFleet": "đội hình",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "Chế độ",
"StatusLabelSafety": "An toàn",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "Cửa sổ ngữ cảnh",
"StatusLabelWindowSource": "Nguồn cửa sổ",
"StatusLabelWindowOverride": "Ghi đè cửa sổ",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "toàn quyền",
"StatusApprovalNever": "không bao giờ",
"StatusMcpConfigured": "đã cấu hình {count}",
"StatusFleetDrifted": "{fleet} · {count} tuyến đã lưu không có trong danh mục hiện tại: {ids}",
"StatusContextUsage": "đã dùng {percent}% ({used} / {max} token)",
"StatusContextSourceConfigured": "đã cấu hình",
"StatusContextSourceConfiguredModel": "đã cấu hình (theo mô hình)",
+7 -2
View File
@@ -1382,8 +1382,8 @@
"EmptyStateMcpLabel": "mcp",
"EmptyStatePrompt": "你想完成什么?",
"SessionsSurfaceTitle": "会话",
"SessionsPaneTitle": " 会话 (1-9) ",
"SessionsHistoryPaneTitle": " 历史 (PgUp/PgDn) ",
"SessionsPaneTitle": " 会话 (1-9, PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " 历史 (Shift+PgUp/PgDn) ",
"SessionsActionResume": "恢复",
"SessionsActionSearch": "搜索",
"SessionsActionSort": "排序",
@@ -1422,6 +1422,8 @@
"SessionsShowingRange": "显示 {start}-{end} / {total}",
"SessionsMessageCountCompact": "{count} 条消息",
"SessionsForkCompact": "分叉",
"SessionsCurrentCompact": "当前",
"SessionsPreviewId": "ID:{id}",
"SessionsUnknownMode": "未知",
"SessionsPreviewTitle": "标题:{title}",
"SessionsPreviewUpdated": "更新:{updated}",
@@ -1472,6 +1474,7 @@
"FleetRoutePickUnavailable": "团队已更改或不再可用。请重新打开后再选择模型。",
"FleetRouteSaved": "已保存:{fleet} · {route} · {path}",
"FleetRouteInherited": "会话模型",
"FleetRouteNotInCatalog": " · 不在当前目录中",
"PickerActionSetStartupDefault": "设为启动默认值",
"PickerActionPin": "固定",
"PickerActionFleet": "船队",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "模式",
"StatusLabelSafety": "安全",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "上下文窗口",
"StatusLabelWindowSource": "窗口来源",
"StatusLabelWindowOverride": "窗口覆盖",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "完全访问",
"StatusApprovalNever": "从不允许",
"StatusMcpConfigured": "已配置 {count} 个",
"StatusFleetDrifted": "{fleet} · {count} 条已保存路由不在当前目录中:{ids}",
"StatusContextUsage": "已使用 {percent}%({used} / {max} 令牌)",
"StatusContextSourceConfigured": "配置值",
"StatusContextSourceConfiguredModel": "配置值(按模型)",
+7 -2
View File
@@ -1290,6 +1290,7 @@
"FleetRoutePickUnavailable": "團隊已變更或不再可用。請重新開啟後再選擇模型。",
"FleetRouteSaved": "已儲存:{fleet} · {route} · {path}",
"FleetRouteInherited": "工作階段模型",
"FleetRouteNotInCatalog": " · 不在目前目錄中",
"PickerActionSetStartupDefault": "設為啟動預設值",
"PickerActionPin": "釘選",
"PickerActionFleet": "船隊",
@@ -1389,8 +1390,10 @@
"SessionsEmptyHint": "傳送一條訊息即可開始;工作階段會自動儲存。",
"SessionsEmptyTitle": "尚無已儲存的工作階段。",
"SessionsForkCompact": "分叉",
"SessionsCurrentCompact": "目前",
"SessionsPreviewId": "ID:{id}",
"SessionsHidingArchived": "已隱藏封存工作階段",
"SessionsHistoryPaneTitle": " 歷史 (PgUp/PgDn) ",
"SessionsHistoryPaneTitle": " 歷史 (Shift+PgUp/PgDn) ",
"SessionsImage": "[圖片]",
"SessionsLoadFailed": "無法載入工作階段:{error}",
"SessionsMessageCountCompact": "{count} 條訊息",
@@ -1400,7 +1403,7 @@
"SessionsNoSelection": "未選擇工作階段",
"SessionsOpenFailed": "無法開啟工作階段:{error}",
"SessionsOpenedHistory": "已開啟 {id} 的歷史紀錄",
"SessionsPaneTitle": " 工作階段 (1-9) ",
"SessionsPaneTitle": " 工作階段 (1-9, PgUp/PgDn) ",
"SessionsPreviewFailed": "無法載入工作階段預覽。",
"SessionsPreviewMessagesModel": "訊息:{count} | 模型:{model}",
"SessionsPreviewMode": "模式:{mode}",
@@ -1827,6 +1830,7 @@
"StatusLabelMode": "模式",
"StatusLabelSafety": "安全性",
"StatusLabelMcp": "MCP",
"StatusLabelFleet": "Fleet",
"StatusLabelContextWindow": "上下文視窗",
"StatusLabelWindowSource": "視窗來源",
"StatusLabelWindowOverride": "視窗覆寫",
@@ -1848,6 +1852,7 @@
"StatusApprovalFullAccess": "完整存取",
"StatusApprovalNever": "永不允許",
"StatusMcpConfigured": "已設定 {count} 個",
"StatusFleetDrifted": "{fleet} · {count} 條已儲存路由不在目前目錄中:{ids}",
"StatusContextUsage": "已使用 {percent}%({used} / {max} 權杖)",
"StatusContextSourceConfigured": "設定值",
"StatusContextSourceConfiguredModel": "設定值(依模型)",
+12
View File
@@ -1676,6 +1676,7 @@ pub enum MessageId {
StatusLabelMode,
StatusLabelSafety,
StatusLabelMcp,
StatusLabelFleet,
StatusLabelContextWindow,
StatusLabelWindowSource,
StatusLabelWindowOverride,
@@ -1697,6 +1698,7 @@ pub enum MessageId {
StatusApprovalFullAccess,
StatusApprovalNever,
StatusMcpConfigured,
StatusFleetDrifted,
StatusContextUsage,
StatusContextSourceConfigured,
StatusContextSourceConfiguredModel,
@@ -1786,8 +1788,10 @@ pub enum MessageId {
SessionsShowingRange,
SessionsMessageCountCompact,
SessionsForkCompact,
SessionsCurrentCompact,
SessionsUnknownMode,
SessionsPreviewTitle,
SessionsPreviewId,
SessionsPreviewUpdated,
SessionsPreviewMessagesModel,
SessionsPreviewMode,
@@ -1838,6 +1842,7 @@ pub enum MessageId {
FleetRoutePickUnavailable,
FleetRouteSaved,
FleetRouteInherited,
FleetRouteNotInCatalog,
PickerActionSetStartupDefault,
PickerActionPin,
PickerActionFleet,
@@ -3966,6 +3971,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::StatusLabelMode,
MessageId::StatusLabelSafety,
MessageId::StatusLabelMcp,
MessageId::StatusLabelFleet,
MessageId::StatusLabelContextWindow,
MessageId::StatusLabelWindowSource,
MessageId::StatusLabelWindowOverride,
@@ -3987,6 +3993,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::StatusApprovalFullAccess,
MessageId::StatusApprovalNever,
MessageId::StatusMcpConfigured,
MessageId::StatusFleetDrifted,
MessageId::StatusContextUsage,
MessageId::StatusContextSourceConfigured,
MessageId::StatusContextSourceConfiguredModel,
@@ -4074,8 +4081,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::SessionsShowingRange,
MessageId::SessionsMessageCountCompact,
MessageId::SessionsForkCompact,
MessageId::SessionsCurrentCompact,
MessageId::SessionsUnknownMode,
MessageId::SessionsPreviewTitle,
MessageId::SessionsPreviewId,
MessageId::SessionsPreviewUpdated,
MessageId::SessionsPreviewMessagesModel,
MessageId::SessionsPreviewMode,
@@ -4124,6 +4133,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::FleetRoutePickUnavailable,
MessageId::FleetRouteSaved,
MessageId::FleetRouteInherited,
MessageId::FleetRouteNotInCatalog,
MessageId::PickerActionSetStartupDefault,
MessageId::PickerActionPin,
MessageId::PickerActionFleet,
@@ -5922,6 +5932,8 @@ mod tests {
MessageId::FleetRoutePickUnavailable,
MessageId::FleetRouteSaved,
MessageId::FleetRouteInherited,
MessageId::FleetRouteNotInCatalog,
MessageId::StatusFleetDrifted,
MessageId::PickerActionSetStartupDefault,
MessageId::PickerActionPin,
MessageId::PickerActionFleet,
+1 -1
View File
@@ -1117,7 +1117,7 @@ pub fn theme_label_for_mode(mode: PaletteMode) -> &'static str {
}
#[must_use]
#[cfg_attr(not(test), allow(dead_code))]
#[cfg(test)]
pub fn ui_theme_from_settings(theme: &str, background_color: Option<&str>) -> UiTheme {
super::resolve_theme_setting(theme, background_color)
.map(|(_, _, theme)| theme)
+77 -69
View File
@@ -102,6 +102,76 @@ fn default_capability_state() -> String {
"unknown".to_string()
}
/// Per-turn authority payload carried by [`Op::SendMessage`]. Serializable
/// twin of `crates/tui/src/core/ops::TurnSpec`; extracted from the enum arm so
/// new per-turn fields accrete here instead of widening the variant. The
/// `SendMessage(TurnSpec)` newtype keeps the internally-tagged wire shape
/// byte-identical: `{"kind":"send_message", ...fields}`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnSpec {
#[serde(
default,
rename = "maxOutputTokens",
alias = "max_output_tokens",
skip_serializing_if = "Option::is_none"
)]
pub max_output_tokens: Option<std::num::NonZeroU32>,
pub content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<crate::runtime::RuntimeImageInput>,
/// Effective mode for this turn (`"plan" | "agent" | "operate"` etc).
#[serde(default = "default_mode")]
pub mode: String,
/// Optional explicit route/model the caller resolved already (mirrors
/// `ResolvedRuntimeRoute` in `crates_tui::route_runtime`). `None` means
/// "use the thread's current route".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
/// Tool restriction from slash-command frontmatter.
#[serde(default)]
pub allowed_tools: Option<Vec<String>>,
/// Runtime-supplied dynamic tools for this turn only.
#[serde(default)]
pub dynamic_tools: Vec<DynamicToolSpec>,
/// Structural input provenance — only `external_user` may inherit
/// YOLO/auto-approval authority (mirrors `UserInputProvenance`).
#[serde(default = "default_provenance")]
pub provenance: String,
/// Compaction policy carried atomically with the route receipt.
/// Boxed only to keep the enum small; the wire shape is unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction: Option<Box<CompactionPolicy>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_objective: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_token_budget: Option<u32>,
/// `active | paused | complete | blocked`.
#[serde(default = "default_goal_status")]
pub goal_status: String,
/// `"off" | "low" | "medium" | "high" | "max"`; `None` = provider default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub reasoning_effort_auto: bool,
#[serde(default)]
pub auto_model: bool,
#[serde(default)]
pub allow_shell: bool,
#[serde(default)]
pub trust_mode: bool,
#[serde(default)]
pub auto_approve: bool,
/// `auto | bypass | suggest | never`.
#[serde(default = "default_approval_mode")]
pub approval_mode: String,
#[serde(default)]
pub translation_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbosity: Option<String>,
}
/// Operations that can be submitted to the core engine. This is the
/// protocol view of `crates/tui/src/core/ops::Op` — same lifecycle,
/// same provenance gate — but serializable and free of `mpsc` / `oneshot`
@@ -114,69 +184,7 @@ pub enum Op {
/// receipt the engine will freeze at the client-freeze boundary. Headless
/// and TUI must produce byte-identical `MessageRequest`s for identical
/// `Op::SendMessage` payloads.
SendMessage {
#[serde(
default,
rename = "maxOutputTokens",
alias = "max_output_tokens",
skip_serializing_if = "Option::is_none"
)]
max_output_tokens: Option<std::num::NonZeroU32>,
content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<crate::runtime::RuntimeImageInput>,
/// Effective mode for this turn (`"plan" | "agent" | "operate"` etc).
#[serde(default = "default_mode")]
mode: String,
/// Optional explicit route/model the caller resolved already (mirrors
/// `ResolvedRuntimeRoute` in `crates_tui::route_runtime`). `None` means
/// "use the thread's current route".
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model_provider: Option<String>,
/// Tool restriction from slash-command frontmatter.
#[serde(default)]
allowed_tools: Option<Vec<String>>,
/// Runtime-supplied dynamic tools for this turn only.
#[serde(default)]
dynamic_tools: Vec<DynamicToolSpec>,
/// Structural input provenance — only `external_user` may inherit
/// YOLO/auto-approval authority (mirrors `UserInputProvenance`).
#[serde(default = "default_provenance")]
provenance: String,
/// Compaction policy carried atomically with the route receipt.
/// Boxed only to keep the enum small; the wire shape is unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
compaction: Option<Box<CompactionPolicy>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
goal_objective: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
goal_token_budget: Option<u32>,
/// `active | paused | complete | blocked`.
#[serde(default = "default_goal_status")]
goal_status: String,
/// `"off" | "low" | "medium" | "high" | "max"`; `None` = provider default.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(default)]
reasoning_effort_auto: bool,
#[serde(default)]
auto_model: bool,
#[serde(default)]
allow_shell: bool,
#[serde(default)]
trust_mode: bool,
#[serde(default)]
auto_approve: bool,
/// `auto | bypass | suggest | never`.
#[serde(default = "default_approval_mode")]
approval_mode: String,
#[serde(default)]
translation_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
verbosity: Option<String>,
},
SendMessage(TurnSpec),
/// Steer an in-flight turn with additional user content (drains into
/// the turn loop's `rx_steer` channel).
@@ -463,13 +471,13 @@ pub const OP_KINDS: &[&str] = &[
impl Op {
#[must_use]
pub fn is_send_message(&self) -> bool {
matches!(self, Self::SendMessage { .. })
matches!(self, Self::SendMessage(_))
}
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::SendMessage { .. } => "send_message",
Self::SendMessage(_) => "send_message",
Self::Steer { .. } => "steer",
Self::ContinueGoal { .. } => "continue_goal",
Self::RunShellCommand { .. } => "run_shell_command",
@@ -514,7 +522,7 @@ pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into<String>)
op_id: format!("op-{}", uuid::Uuid::new_v4()),
thread_id: thread_id.clone(),
session_id: SessionId::new(),
op: Op::SendMessage {
op: Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: content.into(),
images: Vec::new(),
@@ -537,7 +545,7 @@ pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into<String>)
approval_mode: default_approval_mode(),
translation_enabled: false,
verbosity: None,
},
}),
}
}
@@ -745,13 +753,13 @@ mod tests {
"content": "hello"
}))
.unwrap();
let Op::SendMessage {
let Op::SendMessage(TurnSpec {
mode,
provenance,
goal_status,
approval_mode,
..
} = op
}) = op
else {
panic!("expected send_message");
};
+1 -1
View File
@@ -101,7 +101,7 @@ tracing-subscriber.workspace = true
tower-http.workspace = true
wait-timeout = "0.2"
webbrowser = "1.0"
shlex = "1.3.0"
shlex = "2.0.1"
globset = "0.4"
ignore = "0.4"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
+1 -1
View File
@@ -1 +1 @@
249ae77fad9162c2af11d5460d91b2b5b909c06c
8a7b7dda19f44a2dc97fce864bab37a0203eb3be
+41 -10
View File
@@ -10,17 +10,48 @@ function reply(obj) {
}
const argv = process.argv.slice(2);
if (!argv[0]) reply({ ok: false, error: { code: "missing_payload", message: "usage: node agent.mjs <base64 payload>" } });
if (!argv[0]) reply({ ok: false, error: { code: "missing_payload", message: "usage: node agent.mjs <base64 payload> | node agent.mjs --serve" } });
let req;
try {
req = JSON.parse(Buffer.from(argv[0], "base64").toString("utf8"));
} catch {
reply({ ok: false, error: { code: "bad_payload", message: "payload is not base64 JSON" } });
if (argv[0] === "--serve") {
// Persistent mode: each stdin line is base64 {id, tool, args}; each stdout
// line is the JSON receipt {id, ok, ...}. The process stays alive, so its
// backend retains the open_application binding and owned input between
// calls. The allow-list still applies — the channel cannot become a shell.
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (line) void serve(line);
}
});
process.stdin.on("end", () => process.exit(0));
} else {
let req;
try {
req = JSON.parse(Buffer.from(argv[0], "base64").toString("utf8"));
} catch {
reply({ ok: false, error: { code: "bad_payload", message: "payload is not base64 JSON" } });
}
if (["left_mouse_down", "recordingStart"].includes(req?.tool)) {
reply({ ok: false, error: { code: "persistent_session_required", message: "This operation needs a persistent computer session; the one-shot SSH agent exits after each request. Upgrade the plugin so it serves the agent over one connection, or use a complete drag gesture." } });
}
reply(await handle(req, { computerId: "remote" }));
}
if (["left_mouse_down", "recordingStart"].includes(req?.tool)) {
reply({ ok: false, error: { code: "persistent_session_required", message: "This operation needs a persistent computer session; the SSH agent exits after each request. Use a complete drag gesture or the local helper instead." } });
async function serve(line) {
let req;
try {
req = JSON.parse(Buffer.from(line, "base64").toString("utf8"));
} catch {
process.stdout.write(JSON.stringify({ id: null, ok: false, error: { code: "bad_payload", message: "line is not base64 JSON" } }) + "\n");
return;
}
const receipt = await handle(req, { computerId: "remote", sessionId: "ssh-serve", persistentInputOwner: true });
process.stdout.write(JSON.stringify({ id: req?.id ?? null, ...receipt }) + "\n");
}
reply(await handle(req, { computerId: "remote" }));
+115 -8
View File
@@ -5,7 +5,7 @@
// computer id switches the sticky active computer.
import fs from "node:fs";
import * as registry from "../src/registry.mjs";
import { backendFor, installRemoteAgent, executorFor, closeAppSession, routeFingerprint } from "../src/transport.mjs";
import { backendFor, installRemoteAgent, executorFor, closeAppSession, routeFingerprint, closeSshChannel } from "../src/transport.mjs";
import { TOOLS, TOOL_NAMES, READ_ONLY_TOOLS, REMOTE_TOOLS, BACKEND_METHOD } from "../src/tools.mjs";
import { tryJson, withSignal, throwIfAborted, wait } from "../src/exec.mjs";
import { APP_VERSION } from "../src/app-socket.mjs";
@@ -32,7 +32,7 @@ const backendCache = new Map();
const ROUTE_INSPECTION_TOOLS = new Set([
"request_access", "list_displays", "list_apps", "list_windows", "get_app_state", "screenshot",
"cursor_position", "read_clipboard", "recording_list", "recording_status",
"find_elements", "get_value",
"find_elements", "get_value", "wait_for",
]);
const STATE_CHAR_BUDGET = Number(process.env.CODEWHALE_CU_MAX_STATE_CHARS) > 0
? Number(process.env.CODEWHALE_CU_MAX_STATE_CHARS)
@@ -73,6 +73,7 @@ async function retireBinding(id) {
const binding = backendCache.get(id);
invalidateObservations(id);
if (!binding) return;
closeSshChannel(binding);
// Mark unusable before awaiting cleanup. A failure, or a catalog rollback,
// must never resurrect this backend or its observations.
binding.retired = true;
@@ -326,7 +327,10 @@ function observeState(computer, app_ref, result, args = {}) {
// Cache the complete backend records before making the model-facing view.
// Public indices still address those records, including their private AX
// paths; a compact response must never weaken live target revalidation.
const state_id = rememberState(computer, app_ref, result);
// Ephemeral polls (wait_for) share the filter math without churning the
// state cache: only the observation a caller can act on earns a state_id.
const ephemeral = args.ephemeral === true;
const state_id = ephemeral ? null : rememberState(computer, app_ref, result);
const compact = args.detail === "compact" || args.compact === true;
const detail = args.detail === "full" ? "full" : compact ? "compact" : "summary";
const filtered = filterElements(result.elements, {
@@ -346,19 +350,84 @@ function observeState(computer, app_ref, result, args = {}) {
offset: filtered.offset,
returned: filtered.returned,
truncated: filtered.truncated,
note: "Target observed elements with {type:'element', state_id, index}. Indices address the cached tree, including rows omitted from this page. Filter with query/role/limit/offset instead of requesting a larger dump. Missing labels or values are unknown; do not guess.",
note: ephemeral
? "Ephemeral poll: elements are not bound to a state_id."
: "Target observed elements with {type:'element', state_id, index}. Indices address the cached tree, including rows omitted from this page. Filter with query/role/limit/offset instead of requesting a larger dump. Missing labels or values are unknown; do not guess.",
};
if (compact && data.ocr && args.include_ocr !== true) delete data.ocr;
return fitStatePayload(data, STATE_CHAR_BUDGET);
}
/**
* Poll get_app_state until the query/role predicate holds or the deadline
* passes. Intermediate polls are ephemeral — they share the filter math but
* never churn the state cache; the observation that satisfies the predicate
* is read once more, bound, and its state_id is what the caller targets.
* Errors that can resolve themselves (app not launched yet) count as "no
* match yet"; errors that cannot (stopped, route changed) abort the wait.
*/
async function waitFor(computer, args, switched) {
const { query, role } = args;
if (query == null && role == null) throw new ServerError("bad_args", "wait_for needs a query and/or role to watch for");
if (query != null && typeof query !== "string") throw new ServerError("bad_args", "query must be a string");
if (role != null && typeof role !== "string") throw new ServerError("bad_args", "role must be a string");
const state = args.state ?? "present";
if (state !== "present" && state !== "absent") throw new ServerError("bad_args", 'state must be "present" or "absent"');
const timeoutSec = Number(args.timeout ?? 10);
if (!Number.isFinite(timeoutSec) || timeoutSec < 0.5 || timeoutSec > 60) throw new ServerError("bad_args", "timeout must be 0.5..60 seconds");
const intervalMs = Number(args.interval ?? 400);
if (!Number.isInteger(intervalMs) || intervalMs < 100 || intervalMs > 5000) throw new ServerError("bad_args", "interval must be an integer 100..5000 ms");
const limit = args.limit ?? 20;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new ServerError("bad_args", "limit must be an integer 1..100");
const FATAL = new Set(["cancelled", "control_stopped", "computer_route_changed", "app_upgrade_required"]);
const observe = (ephemeral) => callTool({ name: "get_app_state", arguments: {
app_ref: args.app_ref, window_id: args.window_id, query, role,
limit, detail: "compact", ephemeral, computer: computer.id,
}});
const started = Date.now();
const deadline = started + timeoutSec * 1000;
let polls = 0, lastError = null, everObserved = false;
while (true) {
const res = await observe(true);
polls++;
const body = JSON.parse(res.content[0].text);
let usable = false, matchedCount = 0;
if (!res.isError && body.ok !== false) { usable = true; matchedCount = body.matched ?? 0; }
else if (FATAL.has(body?.error?.code)) {
return { content: [{ type: "text", text: JSON.stringify(fail(computer, body.error.code, body.error.message, { tool: "wait_for", switched, polls })) }], isError: true };
} else if (body?.found === false || /application not found/.test(body?.error?.message ?? "")) {
usable = true; // not running yet, or gone: zero matches either way
} else {
lastError = body?.error ?? { code: "observe_failed", message: "observation failed" };
}
if (usable) { everObserved = true; lastError = null; }
if (usable && (state === "absent" ? matchedCount === 0 : matchedCount > 0)) {
const bound = await observe(false);
polls++;
const b = JSON.parse(bound.content[0].text);
if (bound.isError || b.ok === false) {
return { content: [{ type: "text", text: JSON.stringify(receipt(computer, { ok: true, tool: "wait_for", switched, matched: true, state, polls, elapsed_ms: Date.now() - started, note: "Condition held but the follow-up observation failed — call get_app_state before targeting." })) }] };
}
return { content: [{ type: "text", text: JSON.stringify(receipt(computer, { ok: true, tool: "wait_for", switched, matched: true, state, polls, elapsed_ms: Date.now() - started, state_id: b.state_id, matched_count: b.matched ?? 0, elements: b.elements, app: { name: b.name ?? null, pid: b.pid ?? null, bundle_id: b.bundle_id ?? null }, note: "Elements are bound to state_id — target them with {type:'element', state_id, index}. Re-observe if the UI changes again." })) }] };
}
if (Date.now() >= deadline) break;
await wait(Math.min(intervalMs, Math.max(1, deadline - Date.now())));
throwIfAborted();
}
if (!everObserved && lastError) {
return { content: [{ type: "text", text: JSON.stringify(fail(computer, lastError.code ?? "observe_failed", lastError.message ?? "observation failed", { tool: "wait_for", switched, polls, elapsed_ms: Date.now() - started })) }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(receipt(computer, { ok: true, tool: "wait_for", switched, matched: false, timed_out: true, state, polls, elapsed_ms: Date.now() - started, ...(lastError ? { last_error: lastError } : {}), note: state === "absent" ? "Matches remained until the deadline." : "No match appeared before the deadline. Observe the app or widen the query." })) }] };
}
// ---------- tool dispatch ----------
async function callTool(params) {
const name = params.name;
if (!TOOL_NAMES.has(name)) {
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: { code: "unknown_tool", message: `unknown tool "${name}"` } }) }], isError: true };
}
const args = params.arguments ?? {};
let args = params.arguments ?? {};
if (name === "stop_computer_control") {
controlStopped = true;
@@ -488,11 +557,29 @@ async function callTool(params) {
}
return callTool({ name: "get_app_state", arguments: { ...args, detail: "compact", limit: args.limit ?? 20, computer: computer.id } });
}
if (name === "wait_for") {
return waitFor(computer, args, switched);
}
// type/key with an element target run the documented focus-then-act idiom
// in one call: the element is revalidated and accessibility-focused first,
// through the same routed path a separate focus call would take.
if ((name === "type" || name === "key") && args.target != null) {
if (args.target.type !== "element") {
throw new ServerError("bad_target", `${name} accepts element targets only — use left_click for a coordinate, then ${name}`);
}
const focused = await callTool({ name: "focus", arguments: { target: args.target, computer: computer.id } });
const focusBody = JSON.parse(focused.content[0].text);
if (focused.isError || focusBody.ok === false) {
return { content: [{ type: "text", text: JSON.stringify(fail(computer, focusBody.error?.code ?? "focus_failed", focusBody.error?.message ?? "element could not be focused", { tool: name, stage: "focus" })) }], isError: true };
}
args = { ...args };
delete args.target;
}
// Out-of-process runners (the desktop app for the local computer, the
// remote agent for ssh computers) get the request over the wire.
const backendMethod = BACKEND_METHOD[name] === "request_access" ? "probe" : BACKEND_METHOD[name];
let data;
const ex = computer.transport === "local" || computer.transport === "ssh" ? await executorFor(computer) : null;
const ex = computer.transport === "local" || computer.transport === "ssh" ? await executorFor(computer, binding) : null;
if (ex?.kind === "app") binding.usedApp = true;
// Zoom needs the bound parent raster up front (server-side check too, not
// only the backend) so it can bind the child raster after success.
@@ -505,8 +592,27 @@ async function callTool(params) {
const sink = { reacquired: false };
if (typeof ex?.remote === "function" && REMOTE_TOOLS.has(backendMethod)) {
// ssh rides the persistent agent channel when the remote supports
// --serve; a channel that never produced a reply means an old agent,
// so fall back to one-shot for that binding rather than failing.
const remoteCall = async (request, opts = {}) => {
if (typeof ex.persistent === "function" && binding.sshServe !== false) {
try {
return await ex.persistent(request, opts);
} catch (err) {
const ch = binding.sshChannel;
if (err?.code === "remote_session_lost" && ch && !ch.everReplied) {
binding.sshServe = false;
ex.closeChannel?.();
return ex.remote(request, opts);
}
throw err;
}
}
return ex.remote(request, opts);
};
const resolve = async (req) => {
const rep = await ex.remote({ tool: "resolve_element", args: req }, { timeoutMs: 30_000 });
const rep = await remoteCall({ tool: "resolve_element", args: req }, { timeoutMs: 30_000 });
if (!rep?.ok) return { found: false, element: null, reason: rep?.error?.code ?? "remote_error" };
return rep.data;
};
@@ -521,7 +627,7 @@ async function callTool(params) {
dispatched = true;
const timeoutMs = backendMethod.startsWith("recording") || backendMethod === "get_app_state" ? 60_000 : 30_000;
const invoke = async (tool, a) => {
const r = await ex.remote({ tool, args: a }, { timeoutMs });
const r = await remoteCall({ tool, args: a }, { timeoutMs });
if (!r.ok) throw new ServerError(r.error?.code ?? "remote_error", r.error?.message ?? "remote agent failed");
return r.data;
};
@@ -644,6 +750,7 @@ async function callTool(params) {
async function prepareArgs(computer, name, args, resolve, sink) {
const out = { ...args };
delete out.computer;
delete out.ephemeral; // server-internal: never reaches a backend
const semantic = new Set(["set_value", "select_text", "perform_action", "focus", "get_value"]);
for (const key of ["target", "from_target", "to"]) {
const given = out[key];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codewhale-cu",
"version": "0.4.0",
"version": "0.5.0",
"description": "Codewhale's included Computer Use plugin: accessibility, screenshots, keyboard and pointer control, and recording through the Engine's reviewed plugin authority.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/plugin.json",
"name": "computer-use",
"version": "0.4.0",
"version": "0.5.0",
"description": "Control apps with Codewhale. macOS beta; Windows and Linux backends are experimental and source-only.",
"author": {
"name": "Codewhale"
@@ -57,10 +57,12 @@ Observe once, act once, then verify.
labels or values mean unknown content, not something to guess. `get_value`
reads one field live.
4. If the tree contains the target, act on the element: `focus` then `type`
or `key` for composers, `set_value` for ordinary fields, `perform_action`
(AXPress/Invoke/click…) for advertised actions, element click. Newlines in
`type` are Return/Enter; `press_enter:true` sends after the text. Never
expect `\\n` to send a chat message. `run_actions` batches up to 8 steps
or `key` for composers (or pass the element `target` straight to
`type`/`key` — it focuses first, in the same call), `set_value` for
ordinary fields, `perform_action` (AXPress/Invoke/click…) for advertised
actions, element click. Newlines in `type` are Return/Enter;
`press_enter:true` sends after the text. Never expect `\\n` to send a
chat message. `run_actions` batches up to 8 steps
(click → type → key return → get_value).
macOS provides background element actions; Linux AT-SPI support depends
on the control. Windows currently refuses scoped semantic mutations.
@@ -84,6 +86,14 @@ Observe once, act once, then verify.
If the host reports an omitted or oversized image, capture a smaller app
window/region or zoom, then use that returned raster. Do not guess from a
file path or reuse coordinates from an image the model never received.
5b. When the UI needs time — a page loading, a dialog appearing or
dismissing, a spinner finishing — call `wait_for` instead of looping
`get_app_state` + `wait` by hand: it polls the accessibility tree until
a `query`/`role` match appears (`state:"present"`) or disappears
(`state:"absent"`), then returns the matched elements bound to a fresh
`state_id` you can target immediately. A `timed_out:true` receipt means
the condition never held — observe and reconsider rather than repeating
the same wait.
6. Verify with a fresh observation or a task oracle before claiming success.
`action_sent: true` means it may already have happened — never replay.
On macOS `type` also reports `verified`: `false` (with
@@ -437,13 +437,29 @@ export function create({ exec }) {
} finally { clearTimeout(timer); }
}
async function recordingStart({ display, durationSec, region } = {}) {
async function recordingStart({ display, durationSec, region, app_ref, window_id } = {}) {
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const id = crypto.randomBytes(4).toString("hex");
const file = path.join(dir, `rec-${id}.mov`);
const displays = await displayInfo();
const disp = display ?? state.activeDisplay;
// app_ref scopes the recording to the app's window rect: resolved once at
// start through the same window_info the AX path uses, so a background
// window records behind the user's work. The rect is fixed at start —
// it does not track later moves or resizes.
let window = null;
if (app_ref !== undefined || window_id != null) {
window = await native("window_info", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref, window_id });
if (!window?.points || !(window.points.w > 0) || !(window.points.h > 0)) throw new ExecError("the selected application has no capturable window — call list_windows");
if (region) throw new ExecError("choose app_ref or region, not both");
region = [window.points.x, window.points.y, window.points.w, window.points.h];
}
let disp = display ?? state.activeDisplay;
if (window && display == null) {
const cx = region[0] + region[2] / 2, cy = region[1] + region[3] / 2;
const host = displays.find(d => d.points && cx >= d.points.x && cx < d.points.x + d.points.w && cy >= d.points.y && cy < d.points.y + d.points.h);
if (host) disp = host.index;
}
const selected = displays.find(d => d.index === disp);
if (!selected) throw new ExecError("choose one available display for recording");
if (durationSec != null && (!Number.isFinite(durationSec) || durationSec <= 0)) throw new ExecError("durationSec must be positive");
@@ -481,7 +497,8 @@ export function create({ exec }) {
completion.then(result => { if (!ready) reject(new ExecError(result.error || "screen recorder exited before capture started")); });
});
throwIfAborted();
return { id, pid: child.pid, file, display: disp, durationSec: durationSec ?? null, region: region ?? null, fps: 30, mode: "ScreenCaptureKit", startedAt };
return { id, pid: child.pid, file, display: disp, durationSec: durationSec ?? null, region: region ?? null, fps: 30, mode: "ScreenCaptureKit", startedAt,
...(window ? { window: { id: window.window_id ?? null, name: window.name ?? null }, note: "Recording the window's rect as it was at start; it does not track moves or resizes." } : {}) };
} catch (error) {
requestRecordingStop(recording);
const result = await waitForRecordingStop(recording, 2_000);
@@ -530,7 +547,7 @@ export function create({ exec }) {
for (const f of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
const full = path.join(dir, f);
const st = fs.statSync(full);
if (st.isFile() && /\.(mov|mp4|png)$/i.test(f)) out.push({ file: full, bytes: st.size, modifiedAt: st.mtime.toISOString() });
if (st.isFile() && /\.(mov|mp4|png|jpe?g)$/i.test(f)) out.push({ file: full, bytes: st.size, modifiedAt: st.mtime.toISOString() });
}
out.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
return { dir, recordings: out.slice(0, 50), running: [...rec.keys()] };
+28 -7
View File
@@ -115,6 +115,25 @@ export const TOOLS = [
additionalProperties: false,
},
},
{
name: "wait_for",
description: "Poll this computer's accessibility state until elements matching query/role appear (state:\"present\", default) or until none remain (state:\"absent\"). Returns the matched elements bound to a fresh state_id, ready to target. Prefer this over a get_app_state/wait loop after actions that load, animate or dismiss UI.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Case-insensitive substring over label, value and role. At least one of query/role is required." },
role: { type: "string", description: "Exact accessibility role, e.g. AXButton, AXTextField." },
state: { enum: ["present", "absent"], default: "present", description: "present: wait until a match exists. absent: wait until no match remains (dialogs dismissed, loading finished)." },
timeout: { type: "number", minimum: 0.5, maximum: 60, description: "Seconds to poll before giving up; default 10." },
interval: { type: "integer", minimum: 100, maximum: 5000, description: "Milliseconds between observations; default 400." },
limit: { type: "integer", minimum: 1, maximum: 100, description: "Max matched elements to return; default 20." },
app_ref: { type: "object", properties: { pid: { type: "integer" }, name: { type: "string" }, bundle_id: { type: "string" } }, additionalProperties: false, description: "Same selector rules as get_app_state; omission follows the app selected by open_application." },
window_id: { type: "integer", description: "macOS only: zero-based window index within the app." },
computer: computerParam,
},
additionalProperties: false,
},
},
{
name: "get_app_state",
description: "Read an application's text, controls, actions and layout without requiring vision. The default summary keeps app content and top-level menus; full adds nested menus and tree structure. Act using observed state_id/index targets and refresh after UI changes. Missing labels or values are unknown, not an invitation to guess; request a screenshot only when useful.",
@@ -226,12 +245,12 @@ export const TOOLS = [
},
// ---- text & keyboard ----
{
name: "type", description: "Type unicode text into the focused control. Newlines in `text` are Return/Enter key presses, not literal characters — never put \\n in a composer by hoping it will send. Focus the field first (click, focus, or set_value). On macOS the receipt carries `verified:true` only when the focused control's value actually reflects the typed text; on `verified:false` the text may have gone nowhere — observe again before relying on it.",
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, press_enter: { type: "boolean", description: "After typing, press Return/Enter once. Prefer this to putting a newline in `text` when you want to send." }, computer: computerParam }, additionalProperties: false },
name: "type", description: "Type unicode text into the focused control. Newlines in `text` are Return/Enter key presses, not literal characters — never put \\n in a composer by hoping it will send. Focus the field first (click, focus, or set_value), or pass an element `target` to focus it in the same call. On macOS the receipt carries `verified:true` only when the focused control's value actually reflects the typed text; on `verified:false` the text may have gone nowhere — observe again before relying on it.",
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, press_enter: { type: "boolean", description: "After typing, press Return/Enter once. Prefer this to putting a newline in `text` when you want to send." }, target: { type: "object", description: "Element target from get_app_state; it is accessibility-focused first, then the text is typed. Element targets only.", required: ["type", "state_id", "index"], properties: { type: { const: "element" }, state_id: { type: "string" }, index: { type: "integer", minimum: 0 } }, additionalProperties: false }, computer: computerParam }, additionalProperties: false },
},
{
name: "key", description: "Press a named key or chord. Examples: return, enter, backspace, tab, escape, cmd+c (macOS), ctrl+c (Linux/Windows). This is the key-press tool; type() cannot send modifiers or Return by itself except via newlines/press_enter. Repeat with `repeat`.",
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, repeat: { type: "integer", minimum: 1, maximum: 100 }, computer: computerParam }, additionalProperties: false },
name: "key", description: "Press a named key or chord. Examples: return, enter, backspace, tab, escape, cmd+c (macOS), ctrl+c (Linux/Windows). This is the key-press tool; type() cannot send modifiers or Return by itself except via newlines/press_enter. Repeat with `repeat`. Pass an element `target` to accessibility-focus it first.",
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, repeat: { type: "integer", minimum: 1, maximum: 100 }, target: { type: "object", description: "Element target from get_app_state; it is accessibility-focused first, then the key is sent. Element targets only.", required: ["type", "state_id", "index"], properties: { type: { const: "element" }, state_id: { type: "string" }, index: { type: "integer", minimum: 0 } }, additionalProperties: false }, computer: computerParam }, additionalProperties: false },
},
{
name: "hold_key", description: "Hold a key for `duration` seconds (0.05..30).",
@@ -309,13 +328,15 @@ export const TOOLS = [
// ---- recording ----
{
name: "recording_start",
description: "Start screen recording on a computer (mp4/mov). Darwin: ScreenCaptureKit via the native helper (timed or until recording_stop; honors region, no recorder overlay, stops on session exit). Linux and Windows: unavailable pending session-owned recorder cleanup; use screenshots. HarmonyOS: snapshot-series muxed with ffmpeg.",
description: "Start screen recording on a computer (mp4/mov). Darwin: ScreenCaptureKit via the native helper (timed or until recording_stop; honors region, no recorder overlay, stops on session exit). Pass app_ref to record only the selected app's window rect — captured at start and not tracked across moves. Linux and Windows: unavailable pending session-owned recorder cleanup; use screenshots. HarmonyOS: snapshot-series muxed with ffmpeg.",
inputSchema: {
type: "object",
properties: {
display: { type: ["integer", "string"] },
fps: { type: "integer", minimum: 1, maximum: 60, description: "Linux/Windows/harmony-series only" },
region: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "Linux/Windows only" },
region: { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4, description: "[x, y, w, h] in screen points" },
app_ref: { type: "object", properties: { pid: { type: "integer" }, name: { type: "string" }, bundle_id: { type: "string" } }, additionalProperties: false, description: "macOS only: record the rect this app's window occupies at start. Omission follows the app selected by open_application." },
window_id: { type: "integer", description: "macOS only: zero-based window index within the app; requires or implies app_ref." },
durationSec: { type: "number", minimum: 1, maximum: 7200, description: "macOS only: auto-stop after N seconds" },
intervalMs: { type: "integer", minimum: 150, maximum: 5000, description: "harmony snapshot-series frame interval" },
computer: computerParam,
@@ -372,7 +393,7 @@ export const REMOTE_TOOLS = new Set([
/** Map public tool name -> backend method name. */
export const BACKEND_METHOD = Object.fromEntries(
TOOLS.filter((t) => !["computer_list", "computer_switch", "computer_register", "computer_remove", "stop_computer_control", "wait", "find_elements", "run_actions"].includes(t.name))
TOOLS.filter((t) => !["computer_list", "computer_switch", "computer_register", "computer_remove", "stop_computer_control", "wait", "wait_for", "find_elements", "run_actions"].includes(t.name))
.map((t) => [t.name, {
recording_start: "recordingStart",
recording_stop: "recordingStop",
@@ -79,13 +79,102 @@ export function appExec(app, sessionId = SESSION_ID) {
};
}
/**
* A persistent ssh agent channel: one `ssh host node agent.mjs --serve`
* process carrying base64-JSON request lines in and JSON receipt lines out.
* Unlike the one-shot agent it keeps its backend alive between calls, so an
* open_application binding survives into later raw-input calls and held
* input/recording can be owned by the session. Requests written before the
* channel dies may already have run remotely — their failures are marked
* requestDispatched so the server reports outcome_unknown instead of
* inviting a blind retry.
*/
export function ensureSshChannel(binding, argv) {
let ch = binding.sshChannel;
if (ch?.alive) return ch;
const next = { alive: false, restarted: !!ch, everReplied: false, pending: new Map(), seq: 1, buf: "", proc: null };
binding.sshChannel = next;
const failAll = (err) => {
for (const [, p] of next.pending) { clearTimeout(p.timer); p.reject(err); }
next.pending.clear();
};
let proc;
try {
proc = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
} catch (err) {
next.spawnError = err;
return next;
}
next.proc = proc;
next.alive = true;
proc.stdin.on("error", () => {});
proc.stderr.on("data", () => {}); // drain; stderr is never parsed
proc.stdout.setEncoding("utf8");
proc.stdout.on("data", (d) => {
next.buf += d;
let i;
while ((i = next.buf.indexOf("\n")) !== -1) {
const line = next.buf.slice(0, i).trim();
next.buf = next.buf.slice(i + 1);
if (!line.startsWith("{")) continue; // MOTD/banner noise
let msg;
try { msg = JSON.parse(line); } catch { continue; }
next.everReplied = true;
const p = next.pending.get(msg.id);
if (!p) continue; // timed-out or unknown request: drop the late reply
next.pending.delete(msg.id);
clearTimeout(p.timer);
p.resolve(msg);
}
});
const dead = (why) => {
if (!next.alive) return;
next.alive = false;
failAll(Object.assign(new ExecError(`ssh agent channel closed${why ? `: ${why}` : ""}`), { code: "remote_session_lost", requestDispatched: true }));
};
proc.on("error", (err) => dead(String(err?.message ?? err)));
proc.on("close", (code, sig) => dead(code != null ? `exited ${code}` : `signal ${sig}`));
return next;
}
export function closeSshChannel(binding) {
const ch = binding.sshChannel;
if (!ch) return;
binding.sshChannel = null;
ch.alive = false;
try { ch.proc?.stdin.end(); } catch {}
try { ch.proc?.kill("SIGTERM"); } catch {}
for (const [, p] of ch.pending ?? []) {
clearTimeout(p.timer);
p.reject(Object.assign(new ExecError("ssh agent channel closed"), { code: "remote_session_lost", requestDispatched: true }));
}
ch.pending?.clear();
}
export function channelRequest(ch, request, timeoutMs) {
return new Promise((resolve, reject) => {
if (!ch.alive) {
reject(Object.assign(new ExecError("ssh agent channel is closed"), { code: "remote_session_lost" }));
return;
}
const id = ch.seq++;
const timer = setTimeout(() => {
ch.pending.delete(id);
// The request was written; the remote may still be executing it.
reject(Object.assign(new ExecError(`ssh agent timed out after ${timeoutMs}ms`), { code: "remote_timeout", requestDispatched: true }));
}, timeoutMs);
ch.pending.set(id, { resolve, reject, timer });
ch.proc.stdin.write(b64({ id, tool: request.tool, args: request.args ?? {} }) + "\n");
});
}
/** ssh executor: speaks to the remote agent installed by installRemoteAgent(). */
export function sshExec(computer) {
export function sshExec(computer, binding) {
const userHost = computer.user ? `${computer.user}@${computer.host}` : computer.host;
const portArgs = computer.port ? ["-p", String(computer.port)] : [];
const remoteAgent = safeRemotePath(computer.agentPath ?? ".codewhale-cu/agent/agent.mjs");
const base = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=accept-new", ...portArgs, userHost];
return {
const ex = {
kind: "ssh",
base,
userHost,
@@ -108,6 +197,32 @@ export function sshExec(computer) {
return reply;
},
};
if (binding) {
// Read-only/identity requests may run on a restarted channel; input tools
// may not, because the fresh remote agent no longer holds this session's
// open_application binding.
const SAFE_AFTER_RESTART = new Set([
"platform", "probe", "list_displays", "switch_display", "list_apps", "list_windows",
"get_app_state", "resolve_element", "screenshot", "zoom", "cursor_position",
"read_clipboard", "recordingList", "recordingStatus", "open_application", "preview",
]);
ex.persistent = (request, opts = {}) => {
const ch = ensureSshChannel(binding, ["ssh", ...base, "node", remoteAgent, "--serve"]);
if (ch.spawnError) {
return Promise.reject(Object.assign(new ExecError(`ssh agent channel failed to start: ${ch.spawnError.message}`), { code: "remote_session_lost" }));
}
if (ch.restarted) {
ch.restarted = false;
binding.needsObservation = true;
if (!SAFE_AFTER_RESTART.has(request.tool)) {
return Promise.reject(Object.assign(new ExecError("the remote agent session restarted — rebind with open_application and observe before acting"), { code: "remote_session_restarted" }));
}
}
return channelRequest(ch, request, opts.timeoutMs ?? 25_000);
};
ex.closeChannel = () => closeSshChannel(binding);
}
return ex;
}
/** Push the self-contained remote agent + src tree to an ssh computer. */
@@ -170,7 +285,7 @@ export function hdcExec(computer) {
};
}
export async function executorFor(computer) {
export async function executorFor(computer, binding) {
if (computer.transport === "local") {
// Test hook: exercise the out-of-process wire path (desktop app / ssh
// agent) in-process, so wire argument preparation is covered by tests.
@@ -186,7 +301,7 @@ export async function executorFor(computer) {
}
return { ...localExec(), appReason: status.reason };
}
if (computer.transport === "ssh") return sshExec(computer);
if (computer.transport === "ssh") return sshExec(computer, binding);
if (computer.transport === "hdc") return hdcExec(computer);
throw new ExecError(`unknown transport ${computer.transport}`);
}
@@ -25,6 +25,7 @@ const {args} = JSON.parse(process.argv[2]);
const stop = () => { if(args.durationSec === 8) return; fs.writeFileSync(args.file, 'finalized'); process.exit(0); };
process.on('SIGINT', stop); process.stdin.resume(); process.stdin.on('end', stop);
fs.writeFileSync(path.join(path.dirname(args.file), '..', 'handler-installed'), '');
fs.writeFileSync(path.join(path.dirname(args.file), '..', 'last-request.json'), process.argv[2]);
fs.writeFileSync(args.file, 'partial');
if(args.durationSec !== 7) console.log(JSON.stringify({ready:true}));
setInterval(()=>{},1000);
@@ -100,6 +101,39 @@ test('session close bounds a stubborn recorder and retains its partial file', {s
assert.equal((await owner.recordingList()).running.length, 0);
});
test('app_ref recording crops to the window rect on its hosting display', {skip:process.platform==='win32'}, async t => {
const { root } = fixture(t);
const requests = [];
const b = create({ exec: { async run(_cmd, args) {
const req = JSON.parse(args[0]);
requests.push(req.tool);
if (req.tool === 'input_capabilities') return {code:0,stdout:JSON.stringify({record_owner_pipe:1}),stderr:''};
if (req.tool === 'window_info') return {code:0,stdout:JSON.stringify({window_id:77,name:'FakeWin',points:{x:100,y:50,w:800,h:600}}),stderr:''};
assert.equal(req.tool, 'displays');
return { code: 0, stdout: JSON.stringify([{index:1,id:1,points:{x:0,y:0,w:2880,h:1620}},{index:2,id:2,points:{x:2880,y:0,w:1920,h:1080}}]), stderr: '' };
} } });
const rec = await b.recordingStart({ app_ref: { name: 'FakeApp' } });
t.after(() => b.closeSession().catch(() => {}));
assert.equal(rec.window.name, 'FakeWin');
assert.equal(rec.display, 1, 'window center selects the hosting display');
const sent = JSON.parse(fs.readFileSync(path.join(root, 'last-request.json'), 'utf8'));
assert.equal(sent.tool, 'record');
assert.deepEqual(sent.args.region, [100, 50, 800, 600]);
assert.equal(sent.args.displayID, 1);
await assert.rejects(b.recordingStart({ app_ref: { name: 'FakeApp' }, region: [0, 0, 10, 10] }), /not both/);
});
test('recording_list returns jpeg screenshots alongside recordings', {skip:process.platform==='win32'}, async t => {
const { make, root } = fixture(t);
const dir = path.join(root, 'recordings');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'shot-a.jpg'), 'x');
fs.writeFileSync(path.join(dir, 'shot-b.png'), 'x');
fs.writeFileSync(path.join(dir, 'notes.txt'), 'x');
const list = await make().recordingList();
assert.deepEqual(list.recordings.map((r) => path.basename(r.file)).sort(), ['shot-a.jpg', 'shot-b.png']);
});
test('native recording startup notices owner pipe EOF without capturing a screen', {skip:process.platform!=='darwin'}, async t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-recording-pipe-'));
t.after(() => fs.rmSync(root, {recursive:true, force:true}));
@@ -39,9 +39,23 @@ export function create() {
const record = (method, args) => {
try { fs.appendFileSync(callsFile, JSON.stringify({ method, args }) + "\n"); } catch {}
};
// One backend instance owns this binding; serve-mode tests read it back to
// prove state survives between requests on the same agent process.
let boundApp = null;
return {
platform: "fake",
async open_application(args = {}) {
record("open_application", args);
boundApp = { name: args.name ?? "FakeApp", pid: args.pid ?? 4242, bundle_id: args.bundle_id ?? "com.fake.app" };
return { launched: true, activate: !!args.activate, resolved: boundApp, keyboard_delivery: args.activate ? "foreground-guarded" : "process", input_scope: args.activate ? "shared-desktop" : "application", shared_pointer: !!args.activate };
},
async list_apps() {
record("list_apps", {});
return { items: [{ name: boundApp?.name ?? "FakeApp", pid: boundApp?.pid ?? 4242, bundle_id: "com.fake.app" }] };
},
async left_mouse_down(args = {}) { record("left_mouse_down", args); return { action_sent: true, at: { x: args.target?.x, y: args.target?.y } }; },
async left_mouse_up(args = {}) { record("left_mouse_up", args); return { action_sent: true }; },
async screenshot({ region } = {}) {
record("screenshot", { region });
const file = tmpPng("cu-fake-shot-");
@@ -74,7 +88,7 @@ export function create() {
async mouse_move({ target } = {}) { record("mouse_move", { target }); return { action_sent: true, at: { x: target?.x, y: target?.y } }; },
async perform_action(args) { record("perform_action", args); return { action_sent: true, strategy: "a11y" }; },
async set_value(args) { record("set_value", args); return { action_sent: true, strategy: "a11y", verified: true, after: args.value }; },
async type(args) { record("type", args); return { action_sent: true, text: args.text, verified: true }; },
async type(args) { record("type", args); return { action_sent: true, text: args.text, verified: true, bound_app: boundApp?.name ?? null }; },
async key(args) { record("key", args); return { action_sent: true, key: args.text ?? "return" }; },
async focus(args) { record("focus", args); return { action_sent: true, focused: true, strategy: "a11y" }; },
async get_value(args) { record("get_value", args); return { value: "Fixture text", strategy: "a11y" }; },
@@ -0,0 +1,256 @@
// wait_for polling, element-targeted type/key, and the persistent agent
// channel: real MCP server over stdio with the injected fake backend, plus
// the remote agent's --serve mode driven as a local child process.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import url from "node:url";
import { ensureSshChannel, channelRequest, b64 } from "../src/transport.mjs";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const FAKE = path.join(__dirname, "fixtures", "fake-backend.mjs");
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-wait-state-"));
const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-wait-rec-"));
const callsFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cu-wait-")), "calls.jsonl");
const controlFile = callsFile + ".control.json";
let server;
let buf = "";
const pending = new Map();
let nextId = 1;
function rpc(method, params, timeoutMs = 30_000) {
const id = nextId++;
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
async function tool(name, args = {}) {
const res = await rpc("tools/call", { name, arguments: args });
assert.ok(res.result, `${name}: protocol error ${JSON.stringify(res.error ?? {})}`);
return JSON.parse(res.result.content[0].text);
}
function calls(method) {
if (!fs.existsSync(callsFile)) return [];
return fs.readFileSync(callsFile, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)).filter((c) => c.method === method);
}
function setControl(obj) {
if (obj == null) fs.rmSync(controlFile, { force: true });
else fs.writeFileSync(controlFile, JSON.stringify(obj));
}
before(async () => {
server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
env: {
...process.env,
CODEWHALE_CU_APP: "off",
CODEWHALE_CU_STATE_DIR: stateDir,
CODEWHALE_CU_RECORDINGS_DIR: recDir,
CODEWHALE_CU_TEST_BACKEND: FAKE,
FAKE_BACKEND_CALLS: callsFile,
FAKE_BACKEND_CONTROL: controlFile,
},
stdio: ["pipe", "pipe", "pipe"],
});
server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
server.stdout.setEncoding("utf8");
server.stdout.on("data", (d) => {
buf += d;
let i;
while ((i = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
try {
const msg = JSON.parse(line);
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
} catch {}
}
});
const init = await rpc("initialize", { protocolVersion: "2025-06-18" });
assert.equal(init.result.serverInfo.name, "codewhale-cu");
});
after(() => {
server?.kill("SIGTERM");
for (const d of [stateDir, recDir, path.dirname(callsFile)]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
});
test("wait_for returns matched elements bound to a fresh targetable state_id", async () => {
const r = await tool("wait_for", { query: "OK", role: "AXButton", timeout: 5 });
assert.equal(r.ok, true, JSON.stringify(r));
assert.equal(r.matched, true);
assert.equal(r.matched_count, 1);
assert.equal(r.elements[0].label, "OK");
assert.ok(r.state_id, "the satisfying observation is bound");
// The returned state_id is targetable: the button resolves and presses.
const press = await tool("perform_action", { target: { type: "element", state_id: r.state_id, index: r.elements[0].index }, action: "AXPress" });
assert.equal(press.ok, true, JSON.stringify(press.error));
});
test("wait_for absent is satisfied immediately when nothing matches", async () => {
const r = await tool("wait_for", { query: "no such element anywhere", state: "absent", timeout: 5 });
assert.equal(r.ok, true, JSON.stringify(r));
assert.equal(r.matched, true);
assert.equal(r.matched_count, 0);
assert.equal(r.timed_out, undefined);
});
test("wait_for times out honestly when the predicate never holds", async () => {
const before = calls("get_app_state").length;
const r = await tool("wait_for", { query: "never-present-label", timeout: 0.6, interval: 150 });
assert.equal(r.ok, true, JSON.stringify(r));
assert.equal(r.matched, false);
assert.equal(r.timed_out, true);
assert.ok(r.polls >= 2, `expected several polls, got ${r.polls}`);
assert.equal(r.state_id, undefined, "a timed-out wait binds nothing");
assert.ok(calls("get_app_state").length - before >= r.polls - 1, "ephemeral polls reached the backend");
});
test("ephemeral wait_for polls do not evict earlier states", async () => {
const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } });
assert.ok(st.state_id);
// ~30 polls: more than the 24-state cache cap. If polls were cached, st
// would be evicted; ephemeral polling keeps it targetable.
const w = await tool("wait_for", { query: "never-present-label", timeout: 3, interval: 100 });
assert.equal(w.timed_out, true);
assert.ok(w.polls > 24, `expected >24 polls to prove non-caching, got ${w.polls}`);
const focus = await tool("focus", { target: { type: "element", state_id: st.state_id, index: 1 } });
assert.equal(focus.ok, true, JSON.stringify(focus.error));
});
test("wait_for validates its arguments", async () => {
const bad = async (args, match) => {
const res = await rpc("tools/call", { name: "wait_for", arguments: args });
assert.match(res.error?.message ?? "", match, JSON.stringify(res));
};
await bad({}, /needs a query and\/or role/);
await bad({ query: "x", state: "bogus" }, /state must be "present" or "absent"/);
await bad({ query: "x", timeout: 999 }, /timeout/);
await bad({ query: "x", interval: 5 }, /interval/);
});
test("type with an element target focuses first, then types", async () => {
const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } });
const beforeFocus = calls("focus").length;
const beforeType = calls("type").length;
setControl({ found: true, element: { role: "AXTextField", position: { x: 10, y: 60 }, size: { w: 150, h: 25 } }, reason: null });
try {
const r = await tool("type", { text: "hello", target: { type: "element", state_id: st.state_id, index: 8 } });
assert.equal(r.ok, true, JSON.stringify(r.error));
const focusCalls = calls("focus");
const typeCalls = calls("type");
assert.equal(focusCalls.length, beforeFocus + 1);
assert.equal(typeCalls.length, beforeType + 1);
assert.deepEqual(focusCalls.at(-1).args.target.path, [0, 2], "focus received the semantic element target");
assert.equal(typeCalls.at(-1).args.text, "hello");
} finally { setControl(null); }
});
test("key with an element target focuses first; a coordinate target is refused", async () => {
const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } });
setControl({ found: true, element: { role: "AXTextField", position: { x: 10, y: 60 }, size: { w: 150, h: 25 } }, reason: null });
try {
const r = await tool("key", { text: "return", target: { type: "element", state_id: st.state_id, index: 8 } });
assert.equal(r.ok, true, JSON.stringify(r.error));
} finally { setControl(null); }
const refused = await tool("type", { text: "x", target: { type: "coordinate", x: 1, y: 1 } });
assert.equal(refused.ok, false);
assert.equal(refused.error.code, "bad_target");
});
test("type target fails closed when the element went stale before any text is sent", async () => {
const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } });
setControl({ found: false, element: null, reason: "element_gone" });
const before = calls("type").length;
try {
const r = await tool("type", { text: "should never land", target: { type: "element", state_id: st.state_id, index: 1 } });
assert.equal(r.ok, false);
assert.equal(r.error.code, "element_stale");
assert.equal(r.stage, "focus");
assert.equal(calls("type").length, before, "no keystrokes after the failed focus");
} finally {
setControl(null);
}
});
// ---------- persistent remote agent ----------
test("agent --serve keeps its backend binding across requests", async t => {
const child = spawn("node", [path.join(ROOT, "agent.mjs"), "--serve"], {
env: { ...process.env, CODEWHALE_CU_TEST_BACKEND: FAKE, FAKE_BACKEND_CALLS: callsFile },
stdio: ["pipe", "pipe", "pipe"],
});
t.after(() => child.kill("SIGKILL"));
const replies = new Map();
let rbuf = "";
child.stdout.setEncoding("utf8").on("data", (d) => {
rbuf += d;
let i;
while ((i = rbuf.indexOf("\n")) !== -1) {
const msg = JSON.parse(rbuf.slice(0, i));
rbuf = rbuf.slice(i + 1);
replies.set(msg.id, msg);
}
});
const send = async (id, toolName, args = {}) => {
child.stdin.write(b64({ id, tool: toolName, args }) + "\n");
const deadline = Date.now() + 10_000;
while (!replies.has(id) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10));
assert.ok(replies.has(id), `no reply for ${toolName}`);
return replies.get(id);
};
assert.equal((await send(1, "platform")).ok, true);
const open = await send(2, "open_application", { name: "FakeApp" });
assert.equal(open.ok, true, JSON.stringify(open));
const typed = await send(3, "type", { text: "hi" });
assert.equal(typed.ok, true);
assert.equal(typed.data.bound_app, "FakeApp", "the second request reused the first request's binding");
// Held input is no longer blanket-refused in persistent mode: it reaches
// the backend, which the fixture answers.
const held = await send(4, "left_mouse_down", { target: { type: "coordinate", x: 5, y: 5 } });
assert.equal(held.ok, true, JSON.stringify(held));
const refused = await send(5, "shell_escape");
assert.equal(refused.ok, false);
assert.equal(refused.error.code, "tool_not_allowed");
});
test("one-shot agent still refuses operations that need a persistent session", async () => {
const out = spawn("node", [path.join(ROOT, "agent.mjs"), b64({ tool: "left_mouse_down", args: {} })], {
env: { ...process.env, CODEWHALE_CU_TEST_BACKEND: FAKE },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
out.stdout.setEncoding("utf8").on("data", (d) => { stdout += d; });
await new Promise((resolve) => out.once("close", resolve));
const reply = JSON.parse(stdout.trim().split("\n").pop());
assert.equal(reply.ok, false);
assert.equal(reply.error.code, "persistent_session_required");
});
test("channelRequest correlates replies and survives out-of-order completion", async t => {
const binding = {};
const ch = ensureSshChannel(binding, ["node", path.join(ROOT, "agent.mjs"), "--serve"]);
t.after(() => { try { ch.proc?.kill("SIGKILL"); } catch {} });
// Override spawn is not needed here: the channel factory takes argv, so a
// local agent process stands in for `ssh host node agent --serve`.
assert.equal(ch.alive, true);
const p1 = channelRequest(ch, { tool: "platform" }, 10_000);
const p2 = channelRequest(ch, { tool: "not_a_tool" }, 10_000);
const [r1, r2] = await Promise.all([p1, p2]);
assert.equal(r1.ok, true);
assert.equal(r2.ok, false);
assert.equal(r2.error.code, "tool_not_allowed");
ch.proc.kill("SIGKILL");
await assert.rejects(channelRequest(ch, { tool: "platform" }, 500), (e) => e.code === "remote_session_lost");
});
+72 -3
View File
@@ -1498,7 +1498,12 @@ impl AcpServer {
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| self.default_cwd.clone());
let session_id = format!("codewhale-{}", uuid::Uuid::new_v4());
// A bare uuid, the same shape `create_saved_session` produces and the
// same shape `session/list` advertises. The old `codewhale-` prefix put
// this id in a namespace no other method understood, so a client that
// replayed it into `session/load` — the normal thing to do — got
// `-32602` for an id we had just handed it (#6174).
let session_id = uuid::Uuid::new_v4().to_string();
let tool_registry = Arc::new(build_acp_tool_registry(
&self.config,
&cwd,
@@ -1583,13 +1588,22 @@ impl AcpServer {
.and_then(Value::as_str)
.ok_or_else(|| AcpError::invalid_params("session/load requires sessionId"))?
.to_string();
// Sessions this connection already holds resolve from memory. `session/new`
// sessions live only here — nothing on the ACP path writes them to the
// durable store — so consulting the store first would fail every id we
// minted ourselves. This also makes reloading an already-loaded durable
// session cheap and free of store side effects.
if self.sessions.contains_key(&session_id) {
return Ok(self.session_configuration(&session_id));
}
let manager = Self::session_manager()
.ok_or_else(|| AcpError::internal("no Codewhale session store is available"))?;
let saved = manager
.load_session_by_prefix(&session_id)
.resume_session_by_prefix(&session_id)
.map_err(|error| {
AcpError::invalid_params(format!("could not load session {session_id}: {error}"))
})?;
})?
.session;
let cwd = saved.metadata.workspace.clone();
let tool_registry = Arc::new(build_acp_tool_registry(
@@ -2056,6 +2070,7 @@ fn build_acp_system_prompt(
verbosity: config.verbosity.as_deref(),
skills_scan_codewhale_only: config.skills_config().scan_codewhale_only(),
plugin_registry: None,
recovery_hint: None,
mode: acp_mode(config),
},
crate::prompts::PromptHost::Headless,
@@ -3131,6 +3146,60 @@ mod tests {
assert!(session.messages.is_empty());
}
/// #6174: an ACP client has no id for a session it just created other than
/// the one `session/new` returned, so that id must be loadable. It used to
/// come back `codewhale-<uuid>` — a namespace `session/load` did not
/// understand and the durable store never held — and replaying it, which is
/// the normal client behaviour, failed with `-32602`.
#[test]
fn session_new_returns_an_id_that_session_load_resolves() {
let mut server = AcpServer::new(
Config::default(),
"test-model".to_string(),
PathBuf::from("/tmp"),
);
let created = server
.new_session(json!({ "cwd": "/tmp" }))
.expect("new session");
let session_id = created["sessionId"]
.as_str()
.expect("session id")
.to_string();
// The id is in the one namespace every method understands: the bare
// uuid shape `session/list` advertises for durable sessions.
assert!(
!session_id.starts_with("codewhale-"),
"session/new must not mint a prefixed id, got {session_id}"
);
uuid::Uuid::parse_str(&session_id)
.unwrap_or_else(|e| panic!("session/new must mint a bare uuid, got {session_id}: {e}"));
// Replaying that exact id resolves, and resolves to the same session.
let loaded = server
.load_session(json!({ "sessionId": session_id }))
.expect("session/load must resolve an id session/new returned");
assert_eq!(loaded["sessionId"].as_str(), Some(session_id.as_str()));
}
/// The memory hit must not paper over a genuinely unknown id: that still
/// has to reach the durable store and fail there.
#[test]
fn session_load_still_rejects_an_id_no_one_minted() {
let mut server = AcpServer::new(
Config::default(),
"test-model".to_string(),
PathBuf::from("/tmp"),
);
let unknown = uuid::Uuid::new_v4().to_string();
assert!(
server
.load_session(json!({ "sessionId": unknown }))
.is_err(),
"an id from no namespace must not resolve"
);
}
#[test]
fn prompt_appends_user_and_assistant_messages_to_history() {
let mut server = AcpServer::new(
+1
View File
@@ -255,6 +255,7 @@ impl ApprovalReceiptStore {
self.load_unlocked(session_id)
}
#[cfg_attr(not(test), expect(dead_code))]
pub(crate) fn replay(&self, session_id: &str) -> io::Result<ApprovalReplay> {
let receipts = self.load(session_id)?;
ApprovalReplay::from_receipts(&receipts)
+3 -3
View File
@@ -3812,11 +3812,11 @@ struct OpenRouterModelItem {
id: String,
// Captured from OpenRouter for future display/deprecation surfaces. The
// current CatalogOffering shape has no honest fields for these yet.
#[allow(dead_code)]
#[serde(default)]
#[expect(dead_code)]
name: Option<String>,
#[allow(dead_code)]
#[serde(default)]
#[expect(dead_code)]
created: Option<u64>,
#[serde(default)]
context_length: Option<u32>,
@@ -3828,8 +3828,8 @@ struct OpenRouterModelItem {
supported_parameters: Option<Vec<String>>,
#[serde(default)]
architecture: Option<OpenRouterArchitecture>,
#[allow(dead_code)]
#[serde(default)]
#[expect(dead_code)]
expiration_date: Option<String>,
}
+20 -12
View File
@@ -86,7 +86,7 @@ use codewhale_localization::{MessageId, tr};
/// Not referenced by production dispatch code — the fail-closed Python gate
/// (`scripts/check-command-migration-manifest.py`) reads this exact
/// declaration by source regex and the Rust frontier tests assert it.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub(crate) const PENDING_GROUPS: &[&str] = &["config", "core", "debug", "session"];
// ---------------------------------------------------------------------------
@@ -764,6 +764,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
// `RefCell` borrow of `App` is not simultaneously mutable and shared.
let workspace = app.workspace.clone();
let ui_locale = app.ui_locale;
let current_id = app.current_session_id.clone();
match preselected {
Some(session_id) => {
app.view_stack.push(
@@ -771,14 +772,15 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
&workspace,
ui_locale,
&session_id,
),
)
.with_current_session(current_id.as_deref()),
);
}
None => {
app.view_stack
.push(crate::tui::session_picker::SessionPickerView::new(
&workspace, ui_locale,
));
app.view_stack.push(
crate::tui::session_picker::SessionPickerView::new(&workspace, ui_locale)
.with_current_session(current_id.as_deref()),
);
}
}
}
@@ -1069,7 +1071,8 @@ impl CommandSessionControlContext for SessionControlAdapter<'_> {
fn open_resume_picker(&mut self) {
let mut app = self.host.app.borrow_mut();
let picker =
crate::tui::session_picker::SessionPickerView::new(&app.workspace, app.ui_locale);
crate::tui::session_picker::SessionPickerView::new(&app.workspace, app.ui_locale)
.with_current_session(app.current_session_id.as_deref());
app.view_stack.push(picker);
}
@@ -1092,10 +1095,14 @@ impl CommandSessionControlContext for SessionControlAdapter<'_> {
Ok(m) => m,
Err(e) => return Err(format!("could not open sessions directory: {e}")),
};
match manager
.load_session(raw)
.or_else(|_| manager.load_session_by_prefix(raw))
{
// Resolution only needs durable identity — the resume that follows
// runs and persists the repair, so probe the snapshot instead of
// running (and logging) an in-memory repair here.
match manager.load_session_snapshot(raw).or_else(|_| {
manager
.resolve_session_id_prefix(raw)
.and_then(|id| manager.load_session_snapshot(&id))
}) {
Ok(sess) => {
let path = manager
.sessions_dir()
@@ -1464,7 +1471,8 @@ fn import_session_container(
&app.workspace,
app.ui_locale,
&new_id,
);
)
.with_current_session(app.current_session_id.as_deref());
app.view_stack.push(picker);
Ok(ResumeImportReceipt {
truncated_id: crate::session_manager::truncate_id(&new_id).to_string(),
+5 -1
View File
@@ -275,7 +275,7 @@ pub(in crate::commands) fn workflow_settings(app: &App) -> CommandResult {
cfg.max_children, cfg.max_concurrent, cfg.max_depth
),
format!(
"default_token_budget = {} · shared admission hint for a run and its children",
"default_token_budget = {} · shared admission cap for a run and its children (0 = none)",
cfg.default_token_budget
),
format!(
@@ -293,6 +293,10 @@ pub(in crate::commands) fn workflow_settings(app: &App) -> CommandResult {
"max_continuations = {} · automatic continuation passes before a goal pauses; 0 = unlimited (completion, blocked, or you stop it)",
app.goal_max_continuations
),
format!(
"enforce_token_budget = {} · true = a goal's token budget is a hard stop; false = advisory telemetry",
on(app.goal_enforce_token_budget)
),
];
CommandResult::message(lines.join("\n"))
}
@@ -115,6 +115,9 @@ fn format_status(app: &App) -> String {
&[("{count}", &app.mcp_configured_count.to_string())],
),
);
if let Some(drift) = fleet_drift_summary(app, locale) {
push_row(&mut out, locale, MessageId::StatusLabelFleet, &drift);
}
if let Some(notice) = crate::core::turn::snapshots_disabled_status(
&app.workspace,
app.current_session_id.as_deref(),
@@ -341,6 +344,56 @@ fn push_row(out: &mut String, locale: Locale, label: MessageId, value: &str) {
let _ = writeln!(out, " {label:<LABEL_WIDTH$} {value}");
}
/// Selected-Fleet pin drift: saved `(provider, model)` pairs that are no
/// longer among the routes the Fleet picker can offer — the provider table
/// was removed, or the model dropped out of the provider's roster. A pin may
/// still serve upstream, so this reports and never rewrites. `None` when no
/// Fleet is selected or nothing drifted.
fn fleet_drift_summary(app: &App, locale: Locale) -> Option<String> {
let selected = crate::fleet::store::selected_fleet(&app.workspace)?;
let (fleet, _scope) = crate::fleet::store::load_fleet_at(&selected.path).ok()?;
let config =
crate::config::Config::load(app.config_path.clone(), app.config_profile.as_deref()).ok()?;
let active = config
.provider
.as_deref()
.and_then(crate::config::ApiProvider::parse)
.unwrap_or(crate::config::ApiProvider::Deepseek);
let health = crate::provider_readiness::ProviderReadinessSnapshot::default();
let routes =
crate::tui::views::fleet_setup::cross_provider_model_routes(&config, active, &health);
let offered = |provider: &str, model: &str| {
routes
.iter()
.any(|(p, m, _)| p.eq_ignore_ascii_case(provider) && m.eq_ignore_ascii_case(model))
};
let mut drifted: Vec<String> = Vec::new();
if let Some(operator) = &fleet.operator
&& !offered(&operator.provider, &operator.model)
{
drifted.push("operator".to_string());
}
for member in &fleet.members {
if let (Some(provider), Some(model)) = (&member.provider, &member.model)
&& !offered(provider, model)
{
drifted.push(member.id.clone());
}
}
if drifted.is_empty() {
return None;
}
Some(localized(
locale,
MessageId::StatusFleetDrifted,
&[
("{fleet}", &fleet.name),
("{count}", &drifted.len().to_string()),
("{ids}", &drifted.join(", ")),
],
))
}
fn safety_summary(app: &App) -> Cow<'static, str> {
let policy = crate::core::authority::sandbox_policy_for_turn(
app.mode,
+11 -1
View File
@@ -479,10 +479,20 @@ fn detect_free_asr() -> &'static str {
}
/// Transcribe via local whisper.cpp (free, offline, cross-platform).
///
/// The whole body is synchronous — temp-file I/O plus `Command::output()`,
/// which blocks for the entire subprocess run — so it runs on the blocking
/// pool rather than a Tokio worker (blocking-call convention, #6149).
async fn transcribe_local_whisper(audio_samples: &[i16]) -> Result<String, String> {
let wav = encode_wav(audio_samples);
tokio::task::spawn_blocking(move || transcribe_local_whisper_blocking(&wav))
.await
.map_err(|e| e.to_string())?
}
fn transcribe_local_whisper_blocking(wav: &[u8]) -> Result<String, String> {
let tmp = std::env::temp_dir().join(format!("cw-voice-{}.wav", std::process::id()));
std::fs::write(&tmp, &wav).map_err(|e| e.to_string())?;
std::fs::write(&tmp, wav).map_err(|e| e.to_string())?;
// Try each local binary until one succeeds; whisper.cpp outputs to stdout or file.
for bin in LOCAL_WHISPER_BINS {
let output = Command::new(bin)
@@ -339,7 +339,6 @@ pub(crate) fn escape_review_text(value: &str) -> String {
escaped
}
#[allow(dead_code)]
fn _diagnostic_level_label(level: PluginDiagnosticLevel) -> &'static str {
match level {
PluginDiagnosticLevel::Warning => "warning",
@@ -347,7 +346,6 @@ fn _diagnostic_level_label(level: PluginDiagnosticLevel) -> &'static str {
}
}
#[allow(dead_code)]
fn _diagnostic_path(diagnostic: &PluginDiagnostic) -> Option<String> {
diagnostic
.path
+1 -1
View File
@@ -524,7 +524,7 @@ impl CommandRegistry {
/// Used by test assertions under `#[cfg(test)]`; production builds have
/// no contextual entries, so the method is dead there until a group
/// migrates (FEAT-018+).
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub(crate) fn has_contextual_handler(&self, name: &str) -> bool {
self.get(name)
.is_some_and(|command| command.contextual_handler().is_some())
+7 -4
View File
@@ -7,7 +7,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{OnceLock, RwLock};
use std::time::{Duration, SystemTime};
use std::time::SystemTime;
use crate::tools::goal::GoalStatus;
use crate::tui::app::{App, AppAction};
@@ -512,7 +512,7 @@ fn normalize_configured_name(name: &str) -> Option<String> {
.then(|| name.to_ascii_lowercase())
}
fn usage_describes_arguments(name: &str, usage: &str) -> bool {
pub(crate) fn usage_describes_arguments(name: &str, usage: &str) -> bool {
let usage = usage.trim();
if usage.is_empty() {
return false;
@@ -735,6 +735,9 @@ pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
app.pausable = false;
app.paused = false;
app.paused_goal_objective = None;
// These command paths run on the async UI task, so the contention retry
// yields instead of parking a worker with `thread::sleep`. The critical
// sections are microsecond-scale; a still-contended lock logs below.
let mut todos_cleared = false;
for _ in 0..10 {
if let Ok(mut todos) = app.todos.try_lock() {
@@ -742,7 +745,7 @@ pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
todos_cleared = true;
break;
}
std::thread::sleep(Duration::from_millis(1));
std::thread::yield_now();
}
if !todos_cleared {
tracing::warn!(target: "commands", "todos lock contended or poisoned — previous todos not cleared");
@@ -755,7 +758,7 @@ pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
plan_cleared = true;
break;
}
std::thread::sleep(Duration::from_millis(1));
std::thread::yield_now();
}
if !plan_cleared {
tracing::warn!(target: "commands", "plan_state lock contended or poisoned — previous plan not cleared");
+18
View File
@@ -2091,6 +2091,14 @@ pub struct GoalConfig {
/// inside a provider turn.
#[serde(default)]
pub continuation_delay_seconds: Option<u64>,
/// Make a goal's `token_budget` a hard stop instead of advisory telemetry
/// (#6013). `false`/`None` preserves current behavior: crossing the budget
/// logs and continues. `true` stops the run with `BudgetLimit` once
/// `tokens_used >= token_budget`; goals created without a token budget
/// stay unbounded either way.
#[serde(default)]
pub enforce_token_budget: Option<bool>,
}
/// Reasoning-only recovery controls (`[reasoning_only]` table in config.toml).
@@ -7550,6 +7558,16 @@ impl Config {
}
}
/// Whether a goal's `token_budget` is a hard stop (#6013). Default `false`
/// keeps the advisory/telemetry behavior.
#[must_use]
pub fn goal_enforce_token_budget(&self) -> bool {
self.goal
.as_ref()
.and_then(|goal| goal.enforce_token_budget)
.unwrap_or(false)
}
/// Quiet period between successful interactive goal turns (#5508).
/// Absent/zero keeps the existing immediate-continuation behavior.
#[must_use]
+11
View File
@@ -618,6 +618,8 @@ fn goal_max_continuations_loads_from_goal_table() -> Result<()> {
);
assert_eq!(config.goal_max_continuations(), 0);
assert_eq!(config.goal_continuation_delay_seconds(), 0);
// enforce_token_budget defaults off: budgets stay advisory (#6013).
assert!(!config.goal_enforce_token_budget());
// Explicit backstop override.
let config: Config = toml::from_str(
@@ -638,6 +640,15 @@ max_continuations = 0
"#,
)?;
assert_eq!(config.goal_max_continuations(), 0);
// Opt a set token budget into a hard stop (#6013).
let config: Config = toml::from_str(
r#"
[goal]
enforce_token_budget = true
"#,
)?;
assert!(config.goal_enforce_token_budget());
assert_eq!(config.goal_continuation_delay_seconds(), 0);
// Bound accidental giant cadences; this remains a turn loop, not a
+1 -1
View File
@@ -31,7 +31,7 @@ pub(crate) struct ModeSessionPrefs {
/// The permission policy a given [`AppMode`] resolves to (#3386).
#[derive(Debug, Clone, Copy)]
pub(crate) struct EffectiveModePolicy {
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub(crate) mode: AppMode,
pub(crate) allow_shell: bool,
pub(crate) trust_mode: bool,
+464 -240
View File
@@ -79,8 +79,8 @@ use super::authority::{
};
use super::events::{Event, TurnOutcomeStatus, TurnRoute};
use super::ops::{
McpManagerUpdate, Op, ProviderRuntimeStatus, SessionSnapshot, USER_SHELL_TOOL_ID_PREFIX,
UserInputProvenance,
McpManagerUpdate, Op, ProviderRuntimeStatus, SessionSnapshot, TurnSpec,
USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance,
};
use super::session::Session;
use super::tool_parser;
@@ -409,6 +409,10 @@ pub struct EngineConfig {
/// immediately; positive values opt coordinator goals into a cancellable
/// quiet period (#5508).
pub goal_continuation_delay_seconds: u64,
/// Whether a goal's `token_budget` is a hard stop (`BudgetLimit`) instead
/// of advisory telemetry. Resolved from `[goal] enforce_token_budget`;
/// default `false` (#6013).
pub goal_enforce_token_budget: bool,
/// Maximum number of automatic re-requests when the model returns only
/// reasoning without any answer or tool call. Defaults to 2.
/// Resolved from `[reasoning_only] max_reprompts` in config.toml.
@@ -587,6 +591,7 @@ impl Default for EngineConfig {
goal_status: GoalStatus::Active,
goal_max_continuations: crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS,
goal_continuation_delay_seconds: 0,
goal_enforce_token_budget: false,
reasoning_only_max_reprompts: crate::config::DEFAULT_REASONING_ONLY_REPROMPTS,
// `None` means "use the built-in nudge". Storing the default text
// here instead would make an operator's explicit empty string
@@ -643,7 +648,6 @@ impl Default for EngineConfig {
/// `External`, `Preempted`, and `Internal` are reserved for the
/// remaining direct cancellation paths tracked in #1541.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum CancelReason {
/// User-initiated cancel (Esc, `/cancel`, click cancel on modal).
User,
@@ -653,6 +657,7 @@ pub enum CancelReason {
/// Cancel triggered when a new turn starts before the previous one
/// finished — e.g. plain Enter while busy after the queueing path
/// pre-empts the running turn.
#[expect(dead_code)]
Preempted,
/// Engine internals tore down the turn (drop, channel close,
/// shutdown). Rare — surfaced as an internal error.
@@ -796,6 +801,19 @@ enum McpBootUpdate {
},
}
type ExplicitConnectJoin =
Result<(String, Result<crate::mcp::McpConnection, anyhow::Error>), tokio::task::JoinError>;
/// In-flight connects a turn started because its explicit tool selection
/// named them (#6033). `names` tracks the still-unresolved servers so an
/// abort can clear their `connecting` marks in the pool; the catalog
/// generation pins the authority the batch was spawned under.
struct ExplicitMcpConnects {
connects: tokio::task::JoinSet<(String, Result<crate::mcp::McpConnection, anyhow::Error>)>,
names: HashSet<String>,
catalog_generation: u64,
}
/// The core engine that processes operations and emits events
pub struct Engine {
config: EngineConfig,
@@ -1516,6 +1534,13 @@ impl Engine {
);
let prompt_goal_objective =
goal_objective_for_prompt(config.goal_objective.as_deref(), &config.goal_state);
// #5715: name a prior workspace session that ended mid-turn so the
// model can offer recovery without being asked. Frozen-prefix
// contributor — computed once here, identical for every turn.
let recovery_hint = crate::session_manager::session_recovery_hint(
&config.workspace,
Some(session.id.as_str()),
);
let prompt_host = if config.terminal_chrome_enabled {
prompts::PromptHost::Interactive
} else {
@@ -1544,6 +1569,7 @@ impl Engine {
),
),
verbosity: config.verbosity.as_deref(),
recovery_hint: recovery_hint.as_deref(),
skills_scan_codewhale_only: config.skills_scan_codewhale_only,
plugin_registry: Some(plugin_registry.as_ref()),
// Matches `current_mode`'s initial value below; a later
@@ -2495,32 +2521,33 @@ impl Engine {
))
.await;
let _ = self
.handle_send_message(
"[runtime] A background shell task finished; its completion evidence follows."
.to_string(),
self.current_mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
self.config.goal_status,
self.session.reasoning_effort.clone(),
self.session.reasoning_effort_auto,
self.session.auto_model,
self.session.allow_shell,
self.session.trust_mode,
self.session.auto_approve,
self.session.approval_mode,
self.config.translation_enabled,
self.config.allowed_tools.clone(),
Vec::new(),
self.config.hook_executor.clone(),
self.config.verbosity.clone(),
UserInputProvenance::Runtime,
Vec::new(),
None,
)
.handle_send_message(TurnSpec {
content:
"[runtime] A background shell task finished; its completion evidence follows."
.to_string(),
mode: self.current_mode,
route: Box::new(route),
compaction: Box::new(self.config.compaction.clone()),
initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()),
goal_objective: self.config.goal_objective.clone(),
goal_token_budget: self.config.goal_token_budget,
goal_status: self.config.goal_status,
reasoning_effort: self.session.reasoning_effort.clone(),
reasoning_effort_auto: self.session.reasoning_effort_auto,
auto_model: self.session.auto_model,
allow_shell: self.session.allow_shell,
trust_mode: self.session.trust_mode,
auto_approve: self.session.auto_approve,
approval_mode: self.session.approval_mode,
translation_enabled: self.config.translation_enabled,
allowed_tools: self.config.allowed_tools.clone(),
dynamic_tools: Vec::new(),
hook_executor: self.config.hook_executor.clone(),
verbosity: self.config.verbosity.clone(),
provenance: UserInputProvenance::Runtime,
images: Vec::new(),
max_output_tokens: None,
})
.await;
}
@@ -2568,31 +2595,7 @@ impl Engine {
self.handle_idle_shell_completion_wake().await;
}
EngineRunInput::Operation(op) => match *op {
Op::SendMessage {
max_output_tokens,
content,
images,
mode,
route,
compaction,
initial_routed_usage,
goal_objective,
goal_token_budget,
goal_status,
reasoning_effort,
reasoning_effort_auto,
auto_model,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
translation_enabled,
allowed_tools,
dynamic_tools,
hook_executor,
verbosity,
provenance,
} => {
Op::SendMessage(spec) => {
self.admitted_turn_control = {
let mut controls = self
.turn_controls
@@ -2604,32 +2607,7 @@ impl Engine {
};
// Keep the send-message state machine out of this
// event-loop future's stack frame.
Box::pin(self.handle_send_message(
content,
mode,
*route,
*compaction,
*initial_routed_usage,
goal_objective,
goal_token_budget,
goal_status,
reasoning_effort,
reasoning_effort_auto,
auto_model,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
translation_enabled,
allowed_tools,
dynamic_tools,
hook_executor,
verbosity,
provenance,
images,
max_output_tokens,
))
.await;
Box::pin(self.handle_send_message(spec)).await;
}
Op::ContinueGoal {
dynamic_tools,
@@ -2711,31 +2689,33 @@ impl Engine {
};
let _ = self
.handle_send_message(
.handle_send_message(TurnSpec {
content,
self.current_mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
goal_snapshot.objective,
goal_snapshot.token_budget,
GoalStatus::Active,
self.session.reasoning_effort.clone(),
self.session.reasoning_effort_auto,
self.session.auto_model,
self.session.allow_shell,
self.session.trust_mode,
self.session.auto_approve,
self.session.approval_mode,
self.config.translation_enabled,
self.config.allowed_tools.clone(),
mode: self.current_mode,
route: Box::new(route),
compaction: Box::new(self.config.compaction.clone()),
initial_routed_usage: Box::new(
crate::cost_status::RuntimeUsageBatch::default(),
),
goal_objective: goal_snapshot.objective,
goal_token_budget: goal_snapshot.token_budget,
goal_status: GoalStatus::Active,
reasoning_effort: self.session.reasoning_effort.clone(),
reasoning_effort_auto: self.session.reasoning_effort_auto,
auto_model: self.session.auto_model,
allow_shell: self.session.allow_shell,
trust_mode: self.session.trust_mode,
auto_approve: self.session.auto_approve,
approval_mode: self.session.approval_mode,
translation_enabled: self.config.translation_enabled,
allowed_tools: self.config.allowed_tools.clone(),
dynamic_tools,
self.config.hook_executor.clone(),
self.config.verbosity.clone(),
UserInputProvenance::Runtime,
Vec::new(),
None,
)
hook_executor: self.config.hook_executor.clone(),
verbosity: self.config.verbosity.clone(),
provenance: UserInputProvenance::Runtime,
images: Vec::new(),
max_output_tokens: None,
})
.await;
}
Op::RunShellCommand {
@@ -3268,31 +3248,33 @@ impl Engine {
// Now dispatch the new message as a normal send,
// reusing the engine's stored mode/model config.
let mode = self.current_mode;
self.handle_send_message(
new_message.clone(),
self.handle_send_message(TurnSpec {
content: new_message.clone(),
mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
self.config.goal_status,
self.session.reasoning_effort.clone(),
self.session.reasoning_effort_auto,
self.session.auto_model,
self.session.allow_shell,
self.session.trust_mode,
self.session.auto_approve,
self.session.approval_mode,
self.config.translation_enabled,
self.config.allowed_tools.clone(),
Vec::new(),
self.config.hook_executor.clone(),
self.config.verbosity.clone(),
UserInputProvenance::ExternalUser,
Vec::new(),
None,
)
route: Box::new(route),
compaction: Box::new(self.config.compaction.clone()),
initial_routed_usage: Box::new(
crate::cost_status::RuntimeUsageBatch::default(),
),
goal_objective: self.config.goal_objective.clone(),
goal_token_budget: self.config.goal_token_budget,
goal_status: self.config.goal_status,
reasoning_effort: self.session.reasoning_effort.clone(),
reasoning_effort_auto: self.session.reasoning_effort_auto,
auto_model: self.session.auto_model,
allow_shell: self.session.allow_shell,
trust_mode: self.session.trust_mode,
auto_approve: self.session.auto_approve,
approval_mode: self.session.approval_mode,
translation_enabled: self.config.translation_enabled,
allowed_tools: self.config.allowed_tools.clone(),
dynamic_tools: Vec::new(),
hook_executor: self.config.hook_executor.clone(),
verbosity: self.config.verbosity.clone(),
provenance: UserInputProvenance::ExternalUser,
images: Vec::new(),
max_output_tokens: None,
})
.await;
}
Op::SetAdvisorEnabled { enabled } => {
@@ -3928,31 +3910,31 @@ impl Engine {
.await;
let outcome = self
.handle_send_message(
.handle_send_message(TurnSpec {
content,
self.current_mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
self.config.goal_status,
self.session.reasoning_effort.clone(),
self.session.reasoning_effort_auto,
self.session.auto_model,
self.session.allow_shell,
self.session.trust_mode,
self.session.auto_approve,
self.session.approval_mode,
self.config.translation_enabled,
self.config.allowed_tools.clone(),
Vec::new(),
self.config.hook_executor.clone(),
self.config.verbosity.clone(),
UserInputProvenance::SubAgentHandoff,
Vec::new(),
None,
)
mode: self.current_mode,
route: Box::new(route),
compaction: Box::new(self.config.compaction.clone()),
initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()),
goal_objective: self.config.goal_objective.clone(),
goal_token_budget: self.config.goal_token_budget,
goal_status: self.config.goal_status,
reasoning_effort: self.session.reasoning_effort.clone(),
reasoning_effort_auto: self.session.reasoning_effort_auto,
auto_model: self.session.auto_model,
allow_shell: self.session.allow_shell,
trust_mode: self.session.trust_mode,
auto_approve: self.session.auto_approve,
approval_mode: self.session.approval_mode,
translation_enabled: self.config.translation_enabled,
allowed_tools: self.config.allowed_tools.clone(),
dynamic_tools: Vec::new(),
hook_executor: self.config.hook_executor.clone(),
verbosity: self.config.verbosity.clone(),
provenance: UserInputProvenance::SubAgentHandoff,
images: Vec::new(),
max_output_tokens: None,
})
.await;
if !outcome.started() {
for agent_id in claimed_ids {
@@ -4009,9 +3991,12 @@ impl Engine {
continuations: snapshot.continuation_count,
},
// Unbounded like grokbuild (agent-call cap) and kimicode swarm
// (turnBudget per-task, resumable): token/time are telemetry only,
// only Completed/Blocked/ContinuationLimit pause the loop.
// (turnBudget per-task, resumable): token/time are telemetry only
// unless `[goal] enforce_token_budget` opts a set budget into a
// hard stop (#6013); otherwise only Completed/Blocked/
// ContinuationLimit pause the loop.
crate::goal_loop::GoalBudget::unbounded()
.with_enforced_token_budget(self.config.goal_enforce_token_budget)
.with_max_continuations(self.config.goal_max_continuations),
);
@@ -4041,6 +4026,13 @@ impl Engine {
),
GoalPauseReason::Backoff,
),
crate::goal_loop::StopReason::BudgetLimit => (
"Goal paused: the goal's token budget was reached and \
[goal] enforce_token_budget makes that a hard stop; \
raise the budget or resume to continue."
.to_string(),
GoalPauseReason::BudgetLimit,
),
crate::goal_loop::StopReason::Completed
| crate::goal_loop::StopReason::Blocked => {
return GoalContinuationAction::Inactive;
@@ -4529,6 +4521,7 @@ impl Engine {
foreground_children,
flush_tx,
drain_handle,
settle_grace: FOREGROUND_CHILD_SETTLE_GRACE,
})
} else {
None
@@ -4716,33 +4709,35 @@ impl Engine {
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_send_message(
&mut self,
content: String,
mode: AppMode,
route: ResolvedRuntimeRoute,
compaction: CompactionConfig,
initial_routed_usage: crate::cost_status::RuntimeUsageBatch,
goal_objective: Option<String>,
goal_token_budget: Option<u32>,
goal_status: GoalStatus,
reasoning_effort: Option<String>,
reasoning_effort_auto: bool,
auto_model: bool,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
approval_mode: ApprovalMode,
translation_enabled: bool,
allowed_tools: Option<Vec<String>>,
dynamic_tools: Vec<DynamicToolSpec>,
hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
verbosity: Option<String>,
provenance: UserInputProvenance,
images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
max_output_tokens: Option<std::num::NonZeroU32>,
) -> SendMessageOutcome {
async fn handle_send_message(&mut self, spec: TurnSpec) -> SendMessageOutcome {
let TurnSpec {
max_output_tokens,
content,
images,
mode,
route,
compaction,
initial_routed_usage,
goal_objective,
goal_token_budget,
goal_status,
reasoning_effort,
reasoning_effort_auto,
auto_model,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
translation_enabled,
allowed_tools,
dynamic_tools,
hook_executor,
verbosity,
provenance,
} = spec;
let route = *route;
let compaction = *compaction;
let initial_routed_usage = *initial_routed_usage;
// All surfaces reuse the same bounded validator. Runtime already checks
// before admission; this also protects direct in-process operations.
let images = match crate::image_attach::prepare_stored_images(&images) {
@@ -5397,7 +5392,19 @@ impl Engine {
if status == TurnOutcomeStatus::Completed && !turn.budget_exhausted_final_report {
barrier.continue_and_flush().await;
} else {
barrier.cancel_and_flush().await;
// The join is deadline-bounded: a child that never observes
// its cancel token is named and left shutting down rather
// than withholding `TurnComplete` forever (#6184).
let unsettled = barrier.cancel_and_flush().await;
if !unsettled.is_empty() {
let _ = self
.tx_event
.send(Event::status(format!(
"Turn ended while sub-agent(s) were still shutting down: {}. Their late receipts were dropped.",
unsettled.join(", ")
)))
.await;
}
}
}
// The advisor is dispatched after TurnComplete, but its usage still
@@ -6155,10 +6162,12 @@ impl Engine {
}
fn mcp_connecting_names(pool: &McpPool, errors: &HashMap<String, String>) -> Vec<String> {
let connected = pool.connected_servers();
pool.enabled_server_names()
// The pool tracks spawned-but-unresolved connects (#6033). Inferring
// "connecting" from enabled-minus-connected mislabels every lazy —
// configured but never-started — server as mid-handshake.
pool.connecting_servers()
.into_iter()
.filter(|name| !connected.contains(&name.as_str()) && !errors.contains_key(name))
.filter(|name| !errors.contains_key(name))
.collect()
}
@@ -6349,18 +6358,24 @@ impl Engine {
self.drain_mcp_boot_updates().await;
}
/// Explicit MCP tool selections need their schemas on the first request.
/// Keep unrelated optional servers in the background, using the existing
/// bounded connection pass and its authority-checked progress updates.
async fn wait_for_explicit_mcp_boot(&mut self, allowed_tools: Option<&[String]>) {
let requested = self
.config
/// `tools_always_load` plus a turn's `allowed_tools`, normalized to the
/// lowercase `mcp_*` names the selection grammar uses.
fn explicit_mcp_tool_names(&self, allowed_tools: Option<&[String]>) -> Vec<String> {
self.config
.tools_always_load
.iter()
.chain(allowed_tools.into_iter().flatten())
.map(|name| name.trim().to_ascii_lowercase())
.filter(|name| name.starts_with("mcp_"))
.collect::<Vec<_>>();
.collect()
}
/// Explicit MCP tool selections need their schemas on the first request.
/// Under lazy boot a selected server may never have been started, so this
/// begins those connects itself — off the mailbox — and then waits on the
/// boot pass and the explicit connects together under the one deadline.
async fn wait_for_explicit_mcp_boot(&mut self, allowed_tools: Option<&[String]>) {
let requested = self.explicit_mcp_tool_names(allowed_tools);
if requested.is_empty() {
return;
}
@@ -6368,10 +6383,19 @@ impl Engine {
// unreachable or un-authenticated MCP server is an ordinary state, not
// an exceptional one, so waiting without a deadline here turned one bad
// row in the config into an unresponsive session. Past the deadline the
// turn proceeds with the tools that are ready; the boot keeps running,
// and the missing server's tools become available on a later turn.
// turn proceeds with the tools that are ready; the connects keep
// running, and the missing server's tools become available on a later
// turn.
let deadline = tokio::time::Instant::now() + Self::MCP_BOOT_UI_WAIT;
while self.mcp_boot_in_flight {
let mut explicit = self.start_explicit_mcp_connects(&requested).await;
let started_explicit = !explicit.names.is_empty();
if started_explicit {
// The connects are in flight now — surfaces should show the
// selected servers as connecting, not configured.
let generation = self.next_mcp_event_generation();
self.emit_mcp_session_boot(generation, false).await;
}
while self.mcp_boot_in_flight || !explicit.connects.is_empty() {
if tokio::time::Instant::now() >= deadline {
tracing::info!(
waited_secs = Self::MCP_BOOT_UI_WAIT.as_secs(),
@@ -6383,35 +6407,158 @@ impl Engine {
let Some(pool) = self.mcp_pool.as_ref() else {
break;
};
let pending =
let connecting =
Self::mcp_connecting_names(&*pool.lock().await, &self.mcp_connection_errors);
let needs_schema = pending.iter().any(|server| {
let prefix = format!("mcp_{}_", server.to_ascii_lowercase());
requested.iter().any(|name| {
name.starts_with(&prefix)
|| name
.strip_suffix('*')
.is_some_and(|rule| prefix.starts_with(rule))
})
});
let needs_schema = connecting
.iter()
.any(|server| crate::mcp::tool_selection_covers_server(&requested, server));
if !needs_schema {
break;
}
let Some(rx) = self.mcp_boot_rx.as_mut() else {
break;
};
// The deadline has to cover this await too: a server that accepts
// the connection and then goes quiet sends no progress update at
// all, so checking only at the top of the loop would still park the
// turn here indefinitely.
let update = tokio::select! {
_ = self.cancel_token.cancelled() => None,
() = tokio::time::sleep_until(deadline) => None,
update = rx.recv() => update,
enum WaitOutcome {
Cancel,
Deadline,
Boot(Option<McpBootUpdate>),
Connect(Option<Box<ExplicitConnectJoin>>),
}
let outcome = tokio::select! {
_ = self.cancel_token.cancelled() => WaitOutcome::Cancel,
() = tokio::time::sleep_until(deadline) => WaitOutcome::Deadline,
update = async {
match self.mcp_boot_rx.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => WaitOutcome::Boot(update),
joined = explicit.connects.join_next(), if !explicit.connects.is_empty() => {
WaitOutcome::Connect(joined.map(Box::new))
}
};
let Some(update) = update else { break };
self.apply_mcp_boot_update(update).await;
match outcome {
WaitOutcome::Cancel | WaitOutcome::Deadline => break,
WaitOutcome::Boot(Some(update)) => self.apply_mcp_boot_update(update).await,
// The boot channel closing means the pass ended without a
// Finished update; explicit connects may still be running.
WaitOutcome::Boot(None) => {
if let Some(generation) = self.mcp_boot_generation {
self.finish_mcp_boot_generation(generation);
}
}
WaitOutcome::Connect(Some(joined)) => {
self.store_explicit_connect_result(&mut explicit, *joined)
.await;
}
WaitOutcome::Connect(None) => {}
}
}
if !explicit.connects.is_empty() {
explicit.connects.abort_all();
if let Some(pool) = self.mcp_pool.as_ref() {
pool.lock().await.cancel_connecting(&explicit.names);
}
}
if started_explicit {
// Close out the in-flight marks: a deadline-aborted server must
// stop reading "connecting" on the next paint.
let generation = self.next_mcp_event_generation();
self.emit_mcp_session_boot(generation, !self.mcp_boot_in_flight)
.await;
}
}
/// Start connects for servers an explicit tool selection covers but the
/// boot pass left lazy (#6033). Selection is intent: cooldowns do not
/// apply, but enabled/allowed/plugin authority checks do.
async fn start_explicit_mcp_connects(&mut self, requested: &[String]) -> ExplicitMcpConnects {
let mut state = ExplicitMcpConnects {
connects: tokio::task::JoinSet::new(),
names: HashSet::new(),
catalog_generation: 0,
};
let Some(pool) = self.mcp_pool.as_ref() else {
return state;
};
let (pending, errors, timeouts, network_policy, generation) = {
let mut pool = pool.lock().await;
let names = pool.explicitly_selected_server_names(requested);
let (pending, errors) = pool.take_pending_connects_for(&names);
(
pending,
errors,
pool.connect_timeouts(),
pool.cloned_network_policy(),
pool.current_catalog_generation(),
)
};
for (name, error) in errors {
self.mcp_connection_errors
.insert(name, crate::mcp::format_mcp_error_for_display(&error));
}
state.catalog_generation = generation;
state.names = pending.iter().map(|(name, _)| name.clone()).collect();
state.connects =
McpPool::spawn_pending_connects(pending, timeouts, network_policy, generation);
state
}
/// Store one resolved explicit connect under the same authority
/// discipline as the boot pass: a config reload mid-handshake invalidates
/// the rest of the batch instead of letting old-authority results land.
async fn store_explicit_connect_result(
&mut self,
explicit: &mut ExplicitMcpConnects,
joined: ExplicitConnectJoin,
) {
let (name, result) =
joined.unwrap_or_else(|error| ("connection task".to_string(), Err(error.into())));
explicit.names.remove(&name);
let Some(pool) = self.mcp_pool.as_ref() else {
return;
};
{
let mut pool = pool.lock().await;
let reload = pool.reload_if_config_changed().await;
if reload.is_err() || pool.current_catalog_generation() != explicit.catalog_generation {
explicit.connects.abort_all();
// `name` already left `names` above — its mark dies with the
// aborted batch too.
explicit.names.insert(name);
pool.cancel_connecting(&explicit.names);
explicit.names.clear();
if let Err(error) = reload {
self.mcp_connection_errors.insert(
"configuration".to_string(),
crate::mcp::format_mcp_error_for_display(&error),
);
}
return;
}
let result =
result.and_then(|connection| pool.store_ready_connection(name.clone(), connection));
match result {
Ok(()) => {
self.mcp_connection_errors.remove(&name);
}
Err(error) => {
pool.note_connect_failure(&name, &error);
self.mcp_connection_errors
.insert(name, crate::mcp::format_mcp_error_for_display(&error));
}
}
}
self.session.pending_prefix_change_reason = Some("mcp-session-boot".to_string());
// A resolved selection connect refreshes the server surfaces the
// same way a `/mcp` retry does, without waiting for the boot pass.
let generation = self.next_mcp_event_generation();
self.emit_mcp_session_boot(
generation,
!self.mcp_boot_in_flight && explicit.connects.is_empty(),
)
.await;
}
/// Start the concurrent connect pass without occupying the engine mailbox.
@@ -6441,6 +6588,12 @@ impl Engine {
}
};
// Boot is lazy (#6033): a configured server nobody asked for is not
// spawned at session start. The eager set is `required` servers plus
// whatever the session's explicit tool selections cover; everything
// else connects on demand — a selected turn, a `/mcp` connect, or a
// lazy tool-name resolution.
let requested = self.explicit_mcp_tool_names(self.config.allowed_tools.as_deref());
let (pending, auth_errors, timeouts, network_policy, catalog_generation) = {
let mut pool = pool.lock().await;
match refresh {
@@ -6456,7 +6609,8 @@ impl Engine {
// so a failed explicit reload leaves the live pool intact.
McpConnectRefresh::Force => pool.force_reload_config_sources()?,
}
let (pending, auth_errors) = pool.collect_pending_connects();
let eager = pool.eager_boot_server_names(&requested);
let (pending, auth_errors) = pool.collect_pending_connects(Some(&eager));
(
pending,
auth_errors,
@@ -6495,7 +6649,7 @@ impl Engine {
"mcp-session-boot",
std::panic::Location::caller(),
async move {
let mut remaining: Vec<String> =
let mut remaining: HashSet<String> =
pending.iter().map(|(name, _)| name.clone()).collect();
let mut connects = McpPool::spawn_pending_connects(
pending,
@@ -6507,8 +6661,8 @@ impl Engine {
while let Some(joined) = connects.join_next().await {
let (name, result) = joined
.unwrap_or_else(|error| ("connection task".to_string(), Err(error.into())));
remaining.retain(|pending_name| pending_name != &name);
{
remaining.remove(&name);
let connecting = {
let mut pool = pool_for_task.lock().await;
// A turn may have reloaded the pool while these handshakes
// were in flight. Never let their old authority or failures
@@ -6518,6 +6672,10 @@ impl Engine {
|| pool.current_catalog_generation() != catalog_generation
{
connects.abort_all();
// `name` already left `remaining` above — its
// mark dies with the aborted pass too.
remaining.insert(name.clone());
pool.cancel_connecting(&remaining);
connection_errors.clear();
if let Err(error) = reload {
connection_errors.insert(
@@ -6535,12 +6693,16 @@ impl Engine {
connection_errors
.insert(name, crate::mcp::format_mcp_error_for_display(&error));
}
}
// The pool's in-flight set also names connects a turn
// started on an explicit selection while this pass was
// running — report what is actually connecting.
pool.connecting_servers()
};
let _ = progress_tx.try_send(McpBootUpdate::Progress {
generation,
authority_errors: Arc::clone(&authority_errors),
connection_errors: connection_errors.clone(),
connecting: remaining.clone(),
connecting,
});
}
{
@@ -6660,28 +6822,35 @@ impl Engine {
return pool.lock().await.to_api_tools();
}
let mut pool = pool.lock().await;
let errors = pool.connect_all().await;
self.mcp_connection_errors = errors
.into_iter()
.map(|(server, error)| (server, crate::mcp::format_mcp_error_for_display(&error)))
.collect();
// Failures stay on the session-boot snapshot, not as Status toasts.
drop(pool);
let generation = self.next_mcp_event_generation();
self.emit_mcp_session_boot(generation, true).await;
self.mcp_pool
.as_ref()
.expect("pool exists")
.lock()
.await
.to_api_tools()
// Boot is lazy (#6033): unselected servers stay unconnected on
// purpose, so there is no per-turn sweep here. A `required` server
// that never got an attempt still owes the session an honest error
// row — `push_required_server_errors` only fills gaps a real
// diagnosis did not already cover.
let mut gaps = Vec::new();
{
let pool = pool.lock().await;
pool.push_required_server_errors(&mut gaps);
}
let mut inserted = false;
for (name, error) in gaps {
self.mcp_connection_errors.entry(name).or_insert_with(|| {
inserted = true;
crate::mcp::format_mcp_error_for_display(&error)
});
}
if inserted {
// Failures stay on the session-boot snapshot, not as Status toasts.
let generation = self.next_mcp_event_generation();
self.emit_mcp_session_boot(generation, true).await;
}
pool.lock().await.to_api_tools()
}
/// Handle a turn using the DeepSeek API.
#[allow(clippy::too_many_lines)]
/// Refresh the stable system prompt based on current non-mode context.
#[cfg_attr(not(test), allow(dead_code))]
#[cfg_attr(not(test), expect(dead_code))]
fn refresh_system_prompt(&mut self) {
self.refresh_system_prompt_with_reason("system");
}
@@ -6801,6 +6970,12 @@ impl Engine {
} else {
prompts::PromptHost::Headless
};
// Recomputed on each refresh (#5715): the prior session's checkpoint
// may have settled or been resumed since construction.
let recovery_hint = crate::session_manager::session_recovery_hint(
&self.config.workspace,
Some(self.session.id.as_str()),
);
let base =
prompts::system_prompt_for_mode_with_context_skills_session_and_approval_for_host(
&self.config.workspace,
@@ -6822,6 +6997,7 @@ impl Engine {
),
),
verbosity: context.verbosity.as_deref(),
recovery_hint: recovery_hint.as_deref(),
skills_scan_codewhale_only: self.config.skills_scan_codewhale_only,
plugin_registry: Some(self.plugin_registry.as_ref()),
mode: context.mode,
@@ -7488,7 +7664,14 @@ impl NextTurnPromptContext {
}
}
/// Result of one turn tool-catalog build.
/// Grace period for cancelled turn-owned children to release their barrier
/// registration before the terminal turn event is emitted anyway (#6184).
/// Cooperative children settle in milliseconds; the bound exists so a child
/// parked on an await that never observes its cancel token cannot withhold
/// `TurnComplete` — a child still shutting down is strictly less harmful
/// than a turn that silently never finishes.
const FOREGROUND_CHILD_SETTLE_GRACE: Duration = Duration::from_secs(5);
/// Turn-scoped mailbox handle plus the machinery needed to close it exactly
/// once. Held by the engine (never by the child runtime) so the flush barrier
/// is owned by the same code that emits the terminal turn event.
@@ -7498,6 +7681,8 @@ pub(crate) struct TurnMailboxBarrier {
pub(crate) foreground_children: Arc<ForegroundChildRegistry>,
pub(crate) flush_tx: tokio::sync::oneshot::Sender<()>,
pub(crate) drain_handle: tokio::task::JoinHandle<()>,
/// Bound on the cancelled-child join inside `cancel_and_flush` (#6184).
pub(crate) settle_grace: Duration,
}
fn terminal_turn_status_at_settlement(
@@ -7515,9 +7700,16 @@ impl TurnMailboxBarrier {
/// Settle the foreground subtree before closing the turn's mailbox. The
/// ordering is intentional: a terminal turn event must never be emitted
/// while an owned child can still publish into this turn's shared state.
pub(crate) async fn cancel_and_flush(self) {
self.foreground_children.cancel_and_wait().await;
///
/// The join is best-effort and bounded by `settle_grace` (#6184): a
/// child parked on an await that never observes its cancel token must
/// not withhold `TurnComplete`. Returns the labels of any children
/// still registered when the join gave up — empty on a clean settle —
/// so the caller can name them in the terminal turn event.
pub(crate) async fn cancel_and_flush(self) -> Vec<String> {
let unsettled = self.join_foreground_children().await;
self.flush().await;
unsettled
}
/// A normal answer closes this turn's UI mailbox without cancelling
@@ -7528,6 +7720,33 @@ impl TurnMailboxBarrier {
self.flush().await;
}
/// Wait for cancelled foreground children to release their
/// registration, giving up at `settle_grace` or when a *new*
/// cancellation lands mid-wait. The Esc path reaches this barrier with
/// the turn token already cancelled, so the early-exit arm is only
/// armed when it is not — otherwise every interrupted turn's receipt
/// window would collapse to zero instead of merely being bounded.
async fn join_foreground_children(&self) -> Vec<String> {
let join = self.foreground_children.cancel_and_wait();
tokio::pin!(join);
let fresh_cancel = !self.cancel_token.is_cancelled();
let gave_up = tokio::select! {
biased;
() = &mut join => false,
() = self.cancel_token.cancelled(), if fresh_cancel => true,
() = tokio::time::sleep(self.settle_grace) => true,
};
if !gave_up {
return Vec::new();
}
let labels = self.foreground_children.unsettled_labels();
tracing::warn!(
unsettled_children = ?labels,
"foreground child join exceeded its bound; sealing the turn mailbox with children still registered"
);
labels
}
async fn flush(self) {
self.mailbox.seal();
let _ = self.flush_tx.send(());
@@ -7535,6 +7754,7 @@ impl TurnMailboxBarrier {
}
}
/// Result of one turn tool-catalog build.
struct TurnToolBuild {
/// One authority for executable, searchable, and initially active tools.
surface: ToolSurfacePolicy,
@@ -7544,7 +7764,7 @@ struct TurnToolBuild {
mcp: McpToolState,
/// Route model installed into the child runtime, when sub-agent tools were
/// available. This is an internal receipt, not a manifest field.
#[cfg_attr(not(test), allow(dead_code))]
#[cfg_attr(not(test), expect(dead_code))]
subagent_runtime_model: Option<String>,
/// Turn-scoped sub-agent mailbox and its flush barrier, when sub-agent
/// wiring was live. The engine must seal, flush, and await this before it
@@ -7725,6 +7945,10 @@ mod tool_preparation;
mod tool_setup;
pub(crate) mod turn_budget;
pub(crate) mod turn_loop;
pub(crate) use dispatch::{
FLEET_FINAL_REPORT_NOTICE, FLEET_NO_PROGRESS_STOP, FLEET_STRATEGY_SWITCH_NOTICE,
FleetDenialAction, FleetDenialBatch, FleetDenialGuard,
};
pub(crate) use token_estimate_cache::TokenEstimateCache;
pub(super) const MAX_PARALLEL_SHELL_EXEC: usize = 4;
+22 -10
View File
@@ -41,16 +41,27 @@ pub(super) struct ToolExecOutcome {
pub(super) original_content_digest: Option<[u8; 32]>,
}
/// Notice appended as a user-role message when the guard first asks the worker
/// to change strategy after repeated no-progress denials (#6015).
pub(crate) const FLEET_STRATEGY_SWITCH_NOTICE: &str = "Fleet strategy switch required: repeated permission denials produced no new evidence. The rejected action is held. Use another permitted tool from the current catalog to make progress, or report completed work and the blocker. Do not work around permissions or request the same approval again.";
/// Notice appended when denials continue past the strategy switch: the next
/// response is report-only and its tool calls are admission-held (#6015).
pub(crate) const FLEET_FINAL_REPORT_NOTICE: &str = "Fleet no-progress final report: permission denials continued after the strategy switch without new evidence. Your next response is report-only; no tools will execute. Report what you completed, exact evidence, the permission blocker and remaining work. This is the last response unless the user changes direction or authority.";
/// Terminal reason once the report-only response has been recorded (#6015).
pub(crate) const FLEET_NO_PROGRESS_STOP: &str = "Fleet worker stopped after repeated permission denials without new evidence. Work and tool results are retained in the transcript; review the blocker before resuming.";
/// Progress observations for one provider response, independent of tool finish
/// order. Only typed permission denials contribute to the retry guard (#6015).
#[derive(Default)]
pub(super) struct FleetDenialBatch {
pub(crate) struct FleetDenialBatch {
denied: std::collections::HashSet<String>,
made_progress: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FleetDenialAction {
pub(crate) enum FleetDenialAction {
Continue,
SwitchStrategy,
FinalReport,
@@ -60,8 +71,9 @@ pub(super) enum FleetDenialAction {
/// admission predicate and result accumulator, not another execution loop.
/// Three responses give the model two opportunities to use denial feedback;
/// after one strategy notice, three more denied responses request a report.
/// Fleet sub-agent workers run the same guard in their own loop (#6015).
#[derive(Default)]
pub(super) struct FleetDenialGuard {
pub(crate) struct FleetDenialGuard {
denied_rounds: std::collections::HashMap<String, u8>,
switch_requested: bool,
recovery_denied_rounds: u8,
@@ -78,15 +90,15 @@ impl FleetDenialGuard {
const REPEATED_DENIAL_ROUNDS: u8 = 3;
const MAX_OBSERVATIONS: usize = 32;
pub(super) fn reset(&mut self) {
pub(crate) fn reset(&mut self) {
*self = Self::default();
}
pub(super) fn report_only(&self) -> bool {
pub(crate) fn report_only(&self) -> bool {
self.report_only
}
pub(super) fn awaiting_strategy_change(&self) -> bool {
pub(crate) fn awaiting_strategy_change(&self) -> bool {
self.switch_requested
}
@@ -94,7 +106,7 @@ impl FleetDenialGuard {
self.denial_rounds_without_progress
}
pub(super) fn original_content_digest(
pub(crate) fn original_content_digest(
name: &str,
input: &serde_json::Value,
output: &ToolResult,
@@ -109,7 +121,7 @@ impl FleetDenialGuard {
.then(|| Sha256::digest(output.content.as_bytes()).into())
}
pub(super) fn admission_error(
pub(crate) fn admission_error(
&self,
name: &str,
input: &serde_json::Value,
@@ -128,7 +140,7 @@ impl FleetDenialGuard {
}
}
pub(super) fn observe(
pub(crate) fn observe(
&mut self,
batch: &mut FleetDenialBatch,
name: &str,
@@ -214,7 +226,7 @@ impl FleetDenialGuard {
}
}
pub(super) fn finish_batch(&mut self, batch: FleetDenialBatch) -> FleetDenialAction {
pub(crate) fn finish_batch(&mut self, batch: FleetDenialBatch) -> FleetDenialAction {
if self.report_only {
return FleetDenialAction::Continue;
}
+10 -2
View File
@@ -140,6 +140,15 @@ impl EngineHandle {
}
/// Send an operation to the engine
///
/// This awaits channel capacity, and the engine drains `rx_op` only
/// between turns — so on the UI event loop an awaited send into a
/// saturated mailbox freezes input for the rest of the turn (#6150).
/// Input-path callers instead either `try_send` a droppable op (report
/// the rejection) or `try_reserve_owned` before committing UI state and
/// hand off with `send_reserved_op`. An awaited `send` remains correct
/// only where the operation is part of a committed, ordered transition
/// (session/provider reload) whose drop would desync engine and UI.
pub async fn send(&self, op: Op) -> Result<()> {
let authority = Self::change_mode_authority(&op);
let permit = self.tx_op.clone().reserve_owned().await?;
@@ -192,7 +201,7 @@ impl EngineHandle {
.turn_controls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if matches!(&op, Op::SendMessage { .. }) {
if matches!(&op, Op::SendMessage(_)) {
let control = controls.fresh();
controls.pending.push_back(control);
}
@@ -310,7 +319,6 @@ impl EngineHandle {
/// Check if a request is currently cancelled
#[must_use]
#[allow(dead_code)]
pub fn is_cancelled(&self) -> bool {
if let Some(control) = self
.turn_controls
+1
View File
@@ -201,6 +201,7 @@ impl Engine {
crate::goal_loop::GoalBudget {
token_budget: snapshot.token_budget.map(u64::from),
time_budget_seconds: None,
enforce_token_budget: self.config.goal_enforce_token_budget,
max_continuations: self.config.goal_max_continuations,
},
))
+43 -42
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::core::ops::TurnSpec;
fn tool(name: &str, deferred: bool) -> Tool {
Tool {
@@ -957,31 +958,31 @@ async fn assert_preview_matches_first_wire_body(
.clone();
let _ = engine
.handle_send_message(
prompt.to_string(),
AppMode::Agent,
production_route,
compaction,
crate::cost_status::RuntimeUsageBatch::default(),
.handle_send_message(TurnSpec {
content: prompt.to_string(),
mode: AppMode::Agent,
route: Box::new(production_route),
compaction: Box::new(compaction),
initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()),
goal_objective,
None,
goal_token_budget: None,
goal_status,
reasoning_effort,
reasoning_effort_auto,
false,
false,
false,
false,
ApprovalMode::Suggest,
auto_model: false,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
translation_enabled,
None,
Vec::new(),
None,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity,
UserInputProvenance::ExternalUser,
Vec::new(),
None,
)
provenance: UserInputProvenance::ExternalUser,
images: Vec::new(),
max_output_tokens: None,
})
.await;
let requests = server
@@ -1924,31 +1925,31 @@ async fn provider_reported_usage_is_unavailable_until_a_response_reports_it() {
assert_eq!(engine.session.total_usage.output_tokens, 0);
let _ = engine
.handle_send_message(
prompt.to_string(),
AppMode::Agent,
production_route,
compaction,
crate::cost_status::RuntimeUsageBatch::default(),
None,
None,
GoalStatus::Active,
.handle_send_message(TurnSpec {
content: prompt.to_string(),
mode: AppMode::Agent,
route: Box::new(production_route),
compaction: Box::new(compaction),
initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()),
goal_objective: None,
goal_token_budget: None,
goal_status: GoalStatus::Active,
reasoning_effort,
reasoning_effort_auto,
false,
false,
false,
false,
ApprovalMode::Suggest,
false,
None,
Vec::new(),
None,
None,
UserInputProvenance::ExternalUser,
Vec::new(),
None,
)
auto_model: false,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
translation_enabled: false,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
images: Vec::new(),
max_output_tokens: None,
})
.await;
// The completed turn's counts are exactly what `parse_usage` reads off
+313 -115
View File
@@ -211,7 +211,9 @@ async fn terminal_barrier_keeps_healthy_child_and_late_completion_alive() {
let (mailbox, _receiver) = Mailbox::new(turn_token.clone());
let children = Arc::new(ForegroundChildRegistry::new());
let child_token = turn_token.child_token();
let registration = children.register(child_token.clone()).unwrap();
let registration = children
.register(child_token.clone(), "agent_healthy")
.unwrap();
let parking = registration.parking_signal();
let (complete_tx, mut complete_rx) = tokio::sync::mpsc::unbounded_channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
@@ -231,6 +233,7 @@ async fn terminal_barrier_keeps_healthy_child_and_late_completion_alive() {
foreground_children: Arc::clone(&children),
flush_tx,
drain_handle,
settle_grace: Duration::from_secs(1),
};
tokio::time::timeout(Duration::from_secs(1), barrier.continue_and_flush())
.await
@@ -250,7 +253,9 @@ async fn terminal_barrier_explicit_cancel_still_joins_owned_child() {
let (mailbox, _receiver) = Mailbox::new(turn_token.clone());
let children = Arc::new(ForegroundChildRegistry::new());
let child_token = turn_token.child_token();
let registration = children.register(child_token.clone()).unwrap();
let registration = children
.register(child_token.clone(), "agent_owned")
.unwrap();
let child = tokio::spawn(async move {
child_token.cancelled().await;
drop(registration);
@@ -265,14 +270,98 @@ async fn terminal_barrier_explicit_cancel_still_joins_owned_child() {
foreground_children: Arc::clone(&children),
flush_tx,
drain_handle,
settle_grace: Duration::from_secs(1),
};
tokio::time::timeout(Duration::from_secs(1), barrier.cancel_and_flush())
let unsettled = tokio::time::timeout(Duration::from_secs(1), barrier.cancel_and_flush())
.await
.unwrap();
assert!(unsettled.is_empty(), "cooperative children join cleanly");
child.await.unwrap();
assert_eq!(children.active_count(), 0);
}
/// Regression for #6184: a foreground child parked on an await that never
/// observes its cancel token must not withhold the terminal turn event. The
/// join gives up at `settle_grace` and names the child it left behind.
#[tokio::test]
async fn terminal_barrier_cancel_names_child_that_ignores_cancellation() {
let turn_token = CancellationToken::new();
let (mailbox, _receiver) = Mailbox::new(turn_token.clone());
let children = Arc::new(ForegroundChildRegistry::new());
let child_token = turn_token.child_token();
// The fake child keeps its registration for the whole test — it never
// observes the cancel token, like a task parked on a blocking await.
let registration = children
.register(child_token.clone(), "agent_stuck")
.unwrap();
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
let drain_handle = tokio::spawn(async {
let _ = flush_rx.await;
});
let barrier = TurnMailboxBarrier {
mailbox,
cancel_token: turn_token.clone(),
foreground_children: Arc::clone(&children),
flush_tx,
drain_handle,
settle_grace: Duration::from_millis(50),
};
// Esc latches the turn token before the barrier runs, so the grace
// window — not the token — is the bound under test.
turn_token.cancel();
let unsettled = tokio::time::timeout(Duration::from_secs(1), barrier.cancel_and_flush())
.await
.expect("the bounded join must not wait on a parked child");
assert_eq!(unsettled, vec!["agent_stuck".to_string()]);
assert!(
child_token.is_cancelled(),
"the bounded join still cancels the child's token"
);
assert_eq!(
children.active_count(),
1,
"the stuck child is left registered — leaked, not awaited"
);
drop(registration);
assert_eq!(children.active_count(), 0);
}
/// A turn that fails without user cancellation runs the same barrier with a
/// live turn token: Esc during the join must still break it rather than sit
/// out the whole grace period (#6184).
#[tokio::test]
async fn terminal_barrier_cancel_join_breaks_on_fresh_esc() {
let turn_token = CancellationToken::new();
let (mailbox, _receiver) = Mailbox::new(turn_token.clone());
let children = Arc::new(ForegroundChildRegistry::new());
let registration = children
.register(turn_token.child_token(), "agent_stuck")
.unwrap();
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
let drain_handle = tokio::spawn(async {
let _ = flush_rx.await;
});
let barrier = TurnMailboxBarrier {
mailbox,
cancel_token: turn_token.clone(),
foreground_children: Arc::clone(&children),
flush_tx,
drain_handle,
settle_grace: Duration::from_secs(30),
};
let esc = turn_token.clone();
let esc_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
esc.cancel();
});
let unsettled = tokio::time::timeout(Duration::from_secs(1), barrier.cancel_and_flush())
.await
.expect("a fresh Esc must break the join before its grace expires");
assert_eq!(unsettled, vec!["agent_stuck".to_string()]);
esc_task.await.unwrap();
drop(registration);
}
mod compaction;
const REPRESENTATIVE_HANDOFF_RELAY: &str = "REPRESENTATIVE_HANDOFF_RELAY";
@@ -599,7 +688,7 @@ async fn exact_turn_snapshot_restores_custom_endpoint_and_turn_receipt_after_bui
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "verify exact route".to_string(),
images: Vec::new(),
@@ -626,7 +715,7 @@ async fn exact_turn_snapshot_restores_custom_endpoint_and_turn_receipt_after_bui
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send exact custom turn");
@@ -975,7 +1064,7 @@ async fn goal_continuation_preserves_goal_and_resolves_updated_authoritative_rou
let goal_state = engine.config.goal_state.clone();
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "first turn".to_string(),
images: Vec::new(),
@@ -999,7 +1088,7 @@ async fn goal_continuation_preserves_goal_and_resolves_updated_authoritative_rou
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send first goal turn");
@@ -1257,7 +1346,7 @@ async fn saturated_mailbox_does_not_deadlock_goal_continuation_self_dispatch() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "start the saturated goal turn".to_string(),
images: Vec::new(),
@@ -1281,7 +1370,7 @@ async fn saturated_mailbox_does_not_deadlock_goal_continuation_self_dispatch() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send saturated goal turn");
tokio::time::timeout(model_turn_event_timeout(), request_entered.notified())
@@ -1388,30 +1477,32 @@ async fn queued_ordinary_turn_does_not_multiply_engine_goal_continuations() {
let (engine, handle) = Engine::new_with_model_client(engine_config, &config, client);
let goal_state = engine.config.goal_state.clone();
let run_task = tokio::spawn(engine.run());
let send_message = |content: &str| Op::SendMessage {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some("coalesce queued goal turns".to_string()),
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
translation_enabled: false,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
let send_message = |content: &str| {
Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some("coalesce queued goal turns".to_string()),
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
translation_enabled: false,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
};
handle
@@ -2588,7 +2679,7 @@ async fn cross_turn_token_budget_exhaustion_does_not_pause_goal() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "start budgeted goal".to_string(),
images: Vec::new(),
@@ -2612,7 +2703,7 @@ async fn cross_turn_token_budget_exhaustion_does_not_pause_goal() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send budgeted goal turn");
@@ -3048,7 +3139,7 @@ async fn explicit_natural_goal_activates_before_provider_request() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "hello - take over and make it your /goal to solve navier stokes".to_string(),
images: Vec::new(),
@@ -3072,7 +3163,7 @@ async fn explicit_natural_goal_activates_before_provider_request() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send explicit natural goal turn");
@@ -3161,7 +3252,7 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option<String>, boo
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: prompt.to_string(),
images: Vec::new(),
@@ -3185,7 +3276,7 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option<String>, boo
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send probe turn");
@@ -3337,30 +3428,32 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
let goal_state = engine.config.goal_state.clone();
let run_task = tokio::spawn(engine.run());
let send = |content: &str, goal_objective: Option<String>, goal_status| Op::SendMessage {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
mode: AppMode::Operate,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective,
goal_token_budget: None,
goal_status,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
translation_enabled: false,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
let send = |content: &str, goal_objective: Option<String>, goal_status| {
Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
mode: AppMode::Operate,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective,
goal_token_budget: None,
goal_status,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
translation_enabled: false,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
};
let first =
@@ -4108,7 +4201,7 @@ async fn host_managed_engine_does_not_self_dispatch_goal_continuation() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "one host-owned turn".to_string(),
images: Vec::new(),
@@ -4132,7 +4225,7 @@ async fn host_managed_engine_does_not_self_dispatch_goal_continuation() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send host-owned goal turn");
@@ -4384,7 +4477,7 @@ async fn host_managed_engine_defers_idle_subagent_completion_to_explicit_turn()
);
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "claim the next turn".to_string(),
images: Vec::new(),
@@ -4408,7 +4501,7 @@ async fn host_managed_engine_defers_idle_subagent_completion_to_explicit_turn()
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send explicit host turn");
@@ -5075,7 +5168,7 @@ async fn healthy_owned_children_do_not_force_another_parent_model_turn() {
let children = Arc::new(ForegroundChildRegistry::new());
let child_cancel = CancellationToken::new();
let registration = children
.register(child_cancel.clone())
.register(child_cancel.clone(), "agent_child")
.expect("child registered");
let mut turn = crate::core::turn::TurnContext::new(max_steps);
@@ -5145,7 +5238,7 @@ async fn user_steer_during_parent_answer_still_gets_a_reply_with_healthy_childre
let children = Arc::new(ForegroundChildRegistry::new());
let child_cancel = CancellationToken::new();
let registration = children
.register(child_cancel.clone())
.register(child_cancel.clone(), "agent_child")
.expect("child registered");
let mut turn = crate::core::turn::TurnContext::new(8);
@@ -5806,7 +5899,7 @@ fn active_goal_message_op(
objective: &str,
token_budget: Option<u32>,
) -> Op {
Op::SendMessage {
Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -5830,7 +5923,7 @@ fn active_goal_message_op(
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
}
})
}
fn system_prompt_text(prompt: SystemPrompt) -> String {
@@ -5845,7 +5938,7 @@ fn system_prompt_text(prompt: SystemPrompt) -> String {
}
fn external_user_message_op(content: &str, mode: AppMode, config: &Config) -> Op {
Op::SendMessage {
Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -5869,11 +5962,11 @@ fn external_user_message_op(content: &str, mode: AppMode, config: &Config) -> Op
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
}
})
}
fn auto_review_message_op(content: &str, config: &Config) -> Op {
Op::SendMessage {
Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -5897,7 +5990,7 @@ fn auto_review_message_op(content: &str, config: &Config) -> Op {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
}
})
}
struct DropSignal(std::sync::Arc<std::sync::atomic::AtomicBool>);
@@ -6706,11 +6799,11 @@ async fn automatic_compaction_continues_one_task_and_suppresses_failed_passes()
AppMode::Agent,
&config,
);
if let Op::SendMessage {
if let Op::SendMessage(TurnSpec {
compaction,
auto_approve,
..
} = &mut op
}) = &mut op
{
compaction.token_threshold = 40_000;
*auto_approve = true;
@@ -6828,10 +6921,10 @@ async fn initial_routed_usage_is_total_only_emitted_once_and_keeps_parent_route_
let task = tokio::spawn(engine.run());
let mut op = external_user_message_op("account for classifier", AppMode::Agent, &api_config);
let Op::SendMessage {
let Op::SendMessage(TurnSpec {
initial_routed_usage,
..
} = &mut op
}) = &mut op
else {
unreachable!("external_user_message_op always builds SendMessage");
};
@@ -7258,11 +7351,11 @@ async fn sandbox_escalation_fails_closed_when_the_posture_cannot_prompt() {
AppMode::Agent,
&config,
);
let Op::SendMessage {
let Op::SendMessage(TurnSpec {
approval_mode: op_approval_mode,
auto_approve: op_auto_approve,
..
} = &mut op
}) = &mut op
else {
panic!("user message op")
};
@@ -12296,7 +12389,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "write the requested local fixture".to_string(),
images: Vec::new(),
@@ -12320,7 +12413,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send Operate model turn");
@@ -12465,7 +12558,7 @@ async fn full_access_subagent_handoff_keeps_model_shell_free_of_approval_prompts
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "continue from the completed child".to_string(),
images: Vec::new(),
@@ -12491,7 +12584,7 @@ async fn full_access_subagent_handoff_keeps_model_shell_free_of_approval_prompts
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::SubAgentHandoff,
})
}))
.await
.expect("send model turn");
@@ -12604,7 +12697,7 @@ async fn assert_full_access_model_tool_batch_is_blocked(
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "exercise the Full Access execution boundary".to_string(),
images: Vec::new(),
@@ -12628,7 +12721,7 @@ async fn assert_full_access_model_tool_batch_is_blocked(
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send Full Access model turn");
@@ -12812,7 +12905,7 @@ async fn assert_full_access_model_tool_batch_runs(
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "exercise the Full Access auto-approval boundary".to_string(),
images: Vec::new(),
@@ -12836,7 +12929,7 @@ async fn assert_full_access_model_tool_batch_runs(
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send Full Access model turn");
@@ -13091,7 +13184,7 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "continue autonomously".to_string(),
images: Vec::new(),
@@ -13115,7 +13208,7 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send Auto-Review model turn");
@@ -13280,7 +13373,7 @@ async fn full_access_permission_allow_cannot_bypass_background_catastrophic_floo
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "please run a background shell".to_string(),
images: Vec::new(),
@@ -13304,7 +13397,7 @@ async fn full_access_permission_allow_cannot_bypass_background_catastrophic_floo
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send model turn");
@@ -13423,7 +13516,7 @@ async fn yolo_mode_does_not_prompt_for_background_shell() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "please run a background shell".to_string(),
images: Vec::new(),
@@ -13447,7 +13540,7 @@ async fn yolo_mode_does_not_prompt_for_background_shell() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send model turn");
@@ -13562,7 +13655,7 @@ async fn yolo_mode_executes_publish_like_shell_without_prompt() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "please publish this crate".to_string(),
images: Vec::new(),
@@ -13586,7 +13679,7 @@ async fn yolo_mode_executes_publish_like_shell_without_prompt() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send model turn");
@@ -13705,7 +13798,7 @@ async fn yolo_mode_does_not_prompt_for_mcp_action() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "please open the PR".to_string(),
images: Vec::new(),
@@ -13729,7 +13822,7 @@ async fn yolo_mode_does_not_prompt_for_mcp_action() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send model turn");
@@ -20150,7 +20243,7 @@ async fn run_headless_turn_with_flaky_network(
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -20174,7 +20267,7 @@ async fn run_headless_turn_with_flaky_network(
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send flaky-network turn");
@@ -20331,7 +20424,7 @@ async fn terminal_output_limit_followed_by_stream_error_is_charged_and_not_retri
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -20355,7 +20448,7 @@ async fn terminal_output_limit_followed_by_stream_error_is_charged_and_not_retri
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send terminal-then-drop turn");
@@ -20438,7 +20531,7 @@ async fn midstream_error_frame_stops_the_stream_and_drops_trailing_deltas() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -20462,7 +20555,7 @@ async fn midstream_error_frame_stops_the_stream_and_drops_trailing_deltas() {
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send midstream-error turn");
@@ -20689,7 +20782,7 @@ async fn run_interactive_turn_with_flaky_network(
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -20713,7 +20806,7 @@ async fn run_interactive_turn_with_flaky_network(
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send interactive flaky-network turn");
@@ -20920,7 +21013,7 @@ async fn interactive_thinking_only_drop_preserves_nothing_and_never_claims_it_di
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -20944,7 +21037,7 @@ async fn interactive_thinking_only_drop_preserves_nothing_and_never_claims_it_di
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send thinking-only drop turn");
@@ -21150,7 +21243,7 @@ async fn run_reasoning_only_turn_with_reprompts(
let (engine, handle) = Engine::new_with_model_client(engine_config, &config, client);
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -21174,7 +21267,7 @@ async fn run_reasoning_only_turn_with_reprompts(
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
}))
.await
.expect("send reasoning-only turn");
let mut events = Vec::new();
@@ -21933,7 +22026,10 @@ readline.createInterface({ input: process.stdin }).on('line', async line => {
"timeouts": { "connect_timeout": 120 },
"servers": {
"fast": { "command": node, "args": [server, "fast", tmp.path()] },
"slow": { "command": node, "args": [server, "slow", tmp.path()] },
// `slow` is deliberately unselected: `required` keeps it in
// the eager boot set under lazy boot (#6033) so it can stand
// in for "an unrelated server still connecting".
"slow": { "command": node, "args": [server, "slow", tmp.path()], "required": true },
"failed": { "command": "codewhale-missing-mcp-fixture-38911" }
}
}))
@@ -22170,8 +22266,11 @@ lines.on('line', async (line) => {
serde_json::to_vec(&serde_json::json!({
"timeouts": { "connect_timeout": 30 },
"servers": {
"fast": { "command": "node", "args": [server, "fast", release] },
"slow": { "command": "node", "args": [server, "slow", release] }
// Both marked `required` so lazy boot (#6033) still starts
// them eagerly — this test proves progress ordering, not the
// lazy/eligible split.
"fast": { "command": "node", "args": [server, "fast", release], "required": true },
"slow": { "command": "node", "args": [server, "slow", release], "required": true }
}
}))
.expect("config JSON"),
@@ -22263,6 +22362,103 @@ lines.on('line', async (line) => {
}
}
/// Lazy boot (#6033): a configured server nobody selected and nobody marked
/// `required` must not be spawned at session start. The fixture writes a
/// `started-<name>` marker when it receives `initialize`, so the lazy
/// server's absence is proven by the file that never appears — not by a
/// timeout on "it would have started by now".
#[tokio::test]
async fn lazy_boot_leaves_unselected_servers_unspawned() {
let Some(node) = crate::dependencies::resolve_node() else {
return;
};
let tmp = tempdir().expect("tempdir");
let server = tmp.path().join("server.mjs");
fs::write(
&server,
r#"import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
readline.createInterface({ input: process.stdin }).on('line', line => {
const request = JSON.parse(line);
if (request.id === undefined) return;
if (request.method === 'initialize') {
fs.writeFileSync(path.join(process.argv[3], 'started-' + process.argv[2]), 'ready');
}
const result = request.method === 'initialize'
? { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: process.argv[2], version: '1' } }
: { tools: [{ name: 'ready', inputSchema: { type: 'object' } }] };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
});"#,
)
.expect("fixture");
let config_path = tmp.path().join("mcp.json");
fs::write(
&config_path,
serde_json::to_vec(&serde_json::json!({
"servers": {
"eager": { "command": node, "args": [server, "eager", tmp.path()], "required": true },
"lazy": { "command": node, "args": [server, "lazy", tmp.path()] }
}
}))
.unwrap(),
)
.expect("MCP config");
let (engine, handle) = Engine::new(
EngineConfig {
workspace: tmp.path().to_path_buf(),
mcp_config_path: config_path,
..Default::default()
},
&Config::default(),
);
let task = tokio::spawn(async move { engine.run().await });
let mut events = handle.rx_event.write().await;
let finished = tokio::time::timeout(Duration::from_secs(30), async {
while let Some(event) = events.recv().await {
if let Event::McpSessionBoot {
snapshot,
connecting,
finished: true,
..
} = event
{
return (snapshot, connecting);
}
}
panic!("engine event channel closed");
})
.await
.expect("boot must finish");
drop(events);
handle.send(Op::Shutdown).await.expect("shutdown");
task.await.expect("engine task");
let (snapshot, connecting) = finished;
assert!(
tmp.path().join("started-eager").exists(),
"a required server is still eager"
);
assert!(
!tmp.path().join("started-lazy").exists(),
"an unselected, unrequired server must not be spawned at boot"
);
assert!(connecting.is_empty());
let lazy = snapshot
.servers
.iter()
.find(|server| server.name == "lazy")
.expect("configured lazy server still appears in the snapshot");
assert!(!lazy.connected);
assert!(lazy.error.is_none(), "lazy is a state, not a failure");
let eager = snapshot
.servers
.iter()
.find(|server| server.name == "eager")
.expect("required server in snapshot");
assert!(eager.connected);
}
#[tokio::test]
async fn mcp_boot_updates_preserve_authority_errors_and_replace_ordinary_errors() {
let tmp = tempdir().expect("tempdir");
@@ -22449,7 +22645,9 @@ async fn bootstrap_and_retry_mcp_use_the_engine_owned_pool() {
let config_path = tmp.path().join("mcp.json");
std::fs::write(
&config_path,
r#"{"servers":{"disabled":{"command":"node","disabled":true},"alpha":{"command":"codewhale-mcp-missing-alpha-9f8e7d6c"},"beta":{"command":"codewhale-mcp-missing-beta-9f8e7d6c"}}}"#,
// `required` keeps alpha/beta in the eager boot set under lazy boot
// (#6033) so they carry the connection diagnoses this test checks.
r#"{"servers":{"disabled":{"command":"node","disabled":true},"alpha":{"command":"codewhale-mcp-missing-alpha-9f8e7d6c","required":true},"beta":{"command":"codewhale-mcp-missing-beta-9f8e7d6c","required":true}}}"#,
)
.expect("MCP config");
let engine_config = EngineConfig {
@@ -184,7 +184,7 @@ async fn finish_turn(handle: &EngineHandle, events: &mut Vec<Event>) {
async fn send_user(handle: &EngineHandle, config: &Config, content: &str) {
let mut op = external_user_message_op(content, AppMode::Agent, config);
if let Op::SendMessage { allow_shell, .. } = &mut op {
if let Op::SendMessage(TurnSpec { allow_shell, .. }) = &mut op {
*allow_shell = false;
}
tokio::time::timeout(CONTROL_TIMEOUT, handle.send(op))
@@ -55,12 +55,12 @@ async fn engine_cancel_stops_started_foreground_descendants_and_preserves_backgr
.unwrap();
let task = tokio::spawn(engine.run());
let mut op = external_user_message_op("Run the foreground fixture.", AppMode::Agent, &config);
if let Op::SendMessage {
if let Op::SendMessage(TurnSpec {
trust_mode,
auto_approve,
approval_mode,
..
} = &mut op
}) = &mut op
{
*trust_mode = true;
*auto_approve = true;
+5 -7
View File
@@ -6,6 +6,7 @@
//! checkpoints, and loop termination.
use super::dispatch::{
FLEET_FINAL_REPORT_NOTICE, FLEET_NO_PROGRESS_STOP, FLEET_STRATEGY_SWITCH_NOTICE,
FleetDenialAction, FleetDenialBatch, FleetDenialGuard, normalize_schema_json_containers,
};
use super::*;
@@ -2712,7 +2713,7 @@ impl Engine {
)
} else {
turn.stop_diagnostics.reason = Some(TurnStopReason::NoProgress);
"Fleet worker stopped after repeated permission denials without new evidence. Work and tool results are retained in the transcript; review the blocker before resuming.".to_string()
FLEET_NO_PROGRESS_STOP.to_string()
};
let _ = self.tx_event.send(Event::status(error.clone())).await;
return (TurnOutcomeStatus::Failed, Some(error));
@@ -2724,15 +2725,11 @@ impl Engine {
.stop_diagnostics
.permission_strategy_switches
.saturating_add(1);
Some(
"Fleet strategy switch required: repeated permission denials produced no new evidence. The rejected action is held. Use another permitted tool from the current catalog to make progress, or report completed work and the blocker. Do not work around permissions or request the same approval again.",
)
Some(FLEET_STRATEGY_SWITCH_NOTICE)
}
FleetDenialAction::FinalReport => {
turn.stop_diagnostics.final_report_requested = true;
Some(
"Fleet no-progress final report: permission denials continued after the strategy switch without new evidence. Your next response is report-only; no tools will execute. Report what you completed, exact evidence, the permission blocker and remaining work. This is the last response unless the user changes direction or authority.",
)
Some(FLEET_FINAL_REPORT_NOTICE)
}
};
if let Some(notice) = notice {
@@ -5365,6 +5362,7 @@ impl Engine {
crate::goal_loop::GoalBudget {
token_budget: snapshot.token_budget.map(u64::from),
time_budget_seconds: None,
enforce_token_budget: self.config.goal_enforce_token_budget,
max_continuations: self.config.goal_max_continuations,
},
);
+55 -51
View File
@@ -121,58 +121,64 @@ impl UserInputProvenance {
}
}
/// Per-turn authority payload carried by [`Op::SendMessage`]. Extracted from
/// the enum arm so new per-turn fields accrete here instead of widening the
/// variant; the serializable twin is `codewhale_protocol::op::TurnSpec`.
#[derive(Debug)]
pub struct TurnSpec {
/// Admitted allowance for this turn only; never changes session settings.
pub max_output_tokens: Option<std::num::NonZeroU32>,
pub content: String,
/// Inline image bytes validated by Runtime admission; no file references.
pub images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
pub mode: AppMode,
/// Exact, structurally resolved route authority for this turn. The
/// engine activates its client before mutating turn state; injected
/// engines may use their already-supplied client with the same receipt.
pub route: Box<ResolvedRuntimeRoute>,
/// Compaction policy derived from the same provider route. Carrying it
/// atomically avoids a model/limit mismatch before `SendMessage`.
pub compaction: Box<CompactionConfig>,
/// Auxiliary provider calls completed while planning this exact turn
/// (currently Auto's classifier), bounded and paired with their own
/// immutable routes. The engine folds their tokens into total usage
/// only; they never enter the parent route's billing aggregate.
pub initial_routed_usage: Box<crate::cost_status::RuntimeUsageBatch>,
pub goal_objective: Option<String>,
pub goal_token_budget: Option<u32>,
pub goal_status: GoalStatus,
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// `None` lets the provider apply its default.
pub reasoning_effort: Option<String>,
/// True when the user selected auto thinking, even though the UI sends
/// a concrete per-turn value to the model API.
pub reasoning_effort_auto: bool,
/// True when the user selected auto model routing.
pub auto_model: bool,
pub allow_shell: bool,
pub trust_mode: bool,
pub auto_approve: bool,
pub approval_mode: ApprovalMode,
pub translation_enabled: bool,
/// Tool restriction from custom slash command frontmatter.
/// `None` means the current turn may use the normal tool set.
pub allowed_tools: Option<Vec<String>>,
/// Runtime-supplied tools available only for this turn.
pub dynamic_tools: Vec<DynamicToolSpec>,
/// Hook executor for control-plane hooks.
/// `ToolCallBefore` hooks may deny a tool call with exit code 2.
pub hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
pub verbosity: Option<String>,
/// Structural input origin. This gates whether the turn may inherit
/// YOLO/auto-approval authority; user-shaped text is not enough.
pub provenance: UserInputProvenance,
}
/// Operations that can be submitted to the engine.
#[derive(Debug)]
pub enum Op {
/// Send a message to the AI
SendMessage {
/// Admitted allowance for this turn only; never changes session settings.
max_output_tokens: Option<std::num::NonZeroU32>,
content: String,
/// Inline image bytes validated by Runtime admission; no file references.
images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
mode: AppMode,
/// Exact, structurally resolved route authority for this turn. The
/// engine activates its client before mutating turn state; injected
/// engines may use their already-supplied client with the same receipt.
route: Box<ResolvedRuntimeRoute>,
/// Compaction policy derived from the same provider route. Carrying it
/// atomically avoids a model/limit mismatch before `SendMessage`.
compaction: Box<CompactionConfig>,
/// Auxiliary provider calls completed while planning this exact turn
/// (currently Auto's classifier), bounded and paired with their own
/// immutable routes. The engine folds their tokens into total usage
/// only; they never enter the parent route's billing aggregate.
initial_routed_usage: Box<crate::cost_status::RuntimeUsageBatch>,
goal_objective: Option<String>,
goal_token_budget: Option<u32>,
goal_status: GoalStatus,
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// `None` lets the provider apply its default.
reasoning_effort: Option<String>,
/// True when the user selected auto thinking, even though the UI sends
/// a concrete per-turn value to the model API.
reasoning_effort_auto: bool,
/// True when the user selected auto model routing.
auto_model: bool,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
approval_mode: ApprovalMode,
translation_enabled: bool,
/// Tool restriction from custom slash command frontmatter.
/// `None` means the current turn may use the normal tool set.
allowed_tools: Option<Vec<String>>,
/// Runtime-supplied tools available only for this turn.
dynamic_tools: Vec<DynamicToolSpec>,
/// Hook executor for control-plane hooks.
/// `ToolCallBefore` hooks may deny a tool call with exit code 2.
hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
verbosity: Option<String>,
/// Structural input origin. This gates whether the turn may inherit
/// YOLO/auto-approval authority; user-shaped text is not enough.
provenance: UserInputProvenance,
},
SendMessage(TurnSpec),
/// Re-check and dispatch an interactive goal continuation when this
/// operation reaches the front of the engine queue. Keeping this distinct
@@ -258,7 +264,6 @@ pub enum Op {
FollowUpSubAgent { agent_id: String, text: String },
/// Change the operating mode
#[allow(dead_code)]
ChangeMode {
mode: AppMode,
allow_shell: bool,
@@ -269,7 +274,6 @@ pub enum Op {
},
/// Update the model being used and refresh stable prompt context.
#[allow(dead_code)]
SetModel {
model: String,
mode: AppMode,
@@ -374,7 +378,7 @@ pub enum Op {
/// Edit the last user message: remove the last user+assistant exchange
/// from the session, then re-send with the new content.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
EditLastTurn { new_message: String },
/// Enable or disable the background advisor watcher for this session.
+31 -49
View File
@@ -381,6 +381,36 @@ fn compaction_to_wire(config: &CompactionConfig) -> wire_op::CompactionPolicy {
}
}
/// Project the engine's per-turn authority onto the wire `TurnSpec`. Host-only
/// fields (`initial_routed_usage`, `hook_executor`) and resolved routes are
/// stripped; only their non-secret receipts cross.
fn turn_spec_to_wire(spec: &crate::core::ops::TurnSpec) -> wire_op::TurnSpec {
wire_op::TurnSpec {
max_output_tokens: spec.max_output_tokens,
content: spec.content.clone(),
images: spec.images.clone(),
mode: app_mode_str(spec.mode).to_string(),
model: Some(spec.route.model.clone()),
model_provider: Some(spec.route.identity.key.clone()),
allowed_tools: spec.allowed_tools.clone(),
dynamic_tools: spec.dynamic_tools.clone(),
provenance: spec.provenance.as_str().to_string(),
compaction: Some(Box::new(compaction_to_wire(&spec.compaction))),
goal_objective: spec.goal_objective.clone(),
goal_token_budget: spec.goal_token_budget,
goal_status: spec.goal_status.as_str().to_string(),
reasoning_effort: spec.reasoning_effort.clone(),
reasoning_effort_auto: spec.reasoning_effort_auto,
auto_model: spec.auto_model,
allow_shell: spec.allow_shell,
trust_mode: spec.trust_mode,
auto_approve: spec.auto_approve,
approval_mode: approval_mode_str(spec.approval_mode).to_string(),
translation_enabled: spec.translation_enabled,
verbosity: spec.verbosity.clone(),
}
}
fn preview_unresolved_str(unresolved: &PreviewUnresolved) -> String {
match unresolved {
PreviewUnresolved::AutoRouteNeedsPrompt => "auto_route_needs_prompt".to_string(),
@@ -942,55 +972,7 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
#[must_use]
pub fn op_to_protocol(op: &Op) -> wire_op::Op {
match op {
Op::SendMessage {
max_output_tokens,
content,
images,
mode,
route,
compaction,
initial_routed_usage: _, // Host-owned accounting, never model input.
goal_objective,
goal_token_budget,
goal_status,
reasoning_effort,
reasoning_effort_auto,
auto_model,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
translation_enabled,
allowed_tools,
dynamic_tools,
// Host configuration, never turn input.
hook_executor: _,
verbosity,
provenance,
} => wire_op::Op::SendMessage {
max_output_tokens: *max_output_tokens,
content: content.clone(),
images: images.clone(),
mode: app_mode_str(*mode).to_string(),
model: Some(route.model.clone()),
model_provider: Some(route.identity.key.clone()),
allowed_tools: allowed_tools.clone(),
dynamic_tools: dynamic_tools.clone(),
provenance: provenance.as_str().to_string(),
compaction: Some(Box::new(compaction_to_wire(compaction))),
goal_objective: goal_objective.clone(),
goal_token_budget: *goal_token_budget,
goal_status: goal_status.as_str().to_string(),
reasoning_effort: reasoning_effort.clone(),
reasoning_effort_auto: *reasoning_effort_auto,
auto_model: *auto_model,
allow_shell: *allow_shell,
trust_mode: *trust_mode,
auto_approve: *auto_approve,
approval_mode: approval_mode_str(*approval_mode).to_string(),
translation_enabled: *translation_enabled,
verbosity: verbosity.clone(),
},
Op::SendMessage(spec) => wire_op::Op::SendMessage(turn_spec_to_wire(spec)),
Op::ContinueGoal {
dynamic_tools,
engine_schedule_id,
@@ -14,7 +14,6 @@ use codewhale_models::{MessageRequest, MessageResponse};
/// adapter preserves that provider trait while giving deterministic Engine
/// tests and alternate adapters one injectable boundary.
#[async_trait]
#[allow(dead_code)]
pub trait ModelClient: Send + Sync {
fn provider_name(&self) -> &str;
fn model(&self) -> &str;
@@ -56,6 +55,7 @@ pub trait ModelClient: Send + Sync {
self.create_message(request).await
}
async fn create_message_stream(&self, request: MessageRequest) -> Result<StreamEventBox>;
#[expect(dead_code)]
async fn health_check(&self) -> Result<bool>;
}
-1
View File
@@ -350,7 +350,6 @@ impl Session {
/// compaction. Bumps `messages_revision` exactly once even when the new
/// history has a different length, so downstream caches invalidate
/// atomically.
#[allow(dead_code)]
pub fn replace_messages(&mut self, messages: Vec<Message>) {
self.messages = messages.into();
self.messages_revision = self.messages_revision.saturating_add(1);
+2 -4
View File
@@ -47,7 +47,6 @@ pub struct TurnContext {
pub id: String,
/// When the turn started
#[allow(dead_code)]
pub started_at: Instant,
/// Current step in the turn (tool call iteration)
@@ -71,7 +70,7 @@ pub struct TurnContext {
/// Number of tool calls made in this turn.
/// Whether the turn has been cancelled
#[allow(dead_code)]
#[expect(dead_code)]
pub cancelled: bool,
/// Usage for this turn
@@ -170,13 +169,12 @@ impl TurnContext {
}
/// Cancel the turn
#[allow(dead_code)]
#[expect(dead_code)]
pub fn cancel(&mut self) {
self.cancelled = true;
}
/// Get the elapsed time
#[allow(dead_code)]
pub fn elapsed(&self) -> Duration {
self.started_at.elapsed()
}
+1 -3
View File
@@ -84,7 +84,7 @@ pub(crate) enum Credential {
/// production path mints one yet. The variant is kept because it is half
/// of the ported contract and the store's serialization guarantee exists
/// precisely for it.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
OAuth {
access: String,
expires_at_unix_secs: Option<i64>,
@@ -94,7 +94,6 @@ pub(crate) enum Credential {
impl Credential {
/// Only [`store::InMemoryCredentialStore::list`] needs this today; the
/// secret-store adapter knows every slot it holds is an api key.
#[allow(dead_code)]
pub(crate) fn kind(&self) -> CredentialKind {
match self {
Self::ApiKey { .. } => CredentialKind::ApiKey,
@@ -135,6 +134,5 @@ pub(crate) enum CredentialKind {
ApiKey,
/// See the note on [`Credential::OAuth`]: no production store mints one
/// yet.
#[allow(dead_code)]
OAuth,
}
+2 -3
View File
@@ -68,7 +68,7 @@ pub(crate) trait CredentialStore: Send + Sync {
/// sequence instead. Collapsing them onto `modify` would change their
/// error messages and rollback shape, which is deliberately out of this
/// change's scope.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
fn modify(
&self,
provider_id: &str,
@@ -144,14 +144,13 @@ pub(crate) fn with_provider_write_locks<T>(
/// Default in-memory store. Real stores are injected; this one backs tests and
/// keeps the trait honest about its own contract — including the serialization
/// guarantee, which is only observable through `modify`.
#[allow(dead_code)]
#[derive(Debug, Default)]
pub(crate) struct InMemoryCredentialStore {
entries: Mutex<HashMap<String, Credential>>,
}
#[allow(dead_code)]
impl InMemoryCredentialStore {
#[cfg_attr(not(test), expect(dead_code))]
pub(crate) fn new() -> Self {
Self::default()
}
+1 -1
View File
@@ -361,7 +361,7 @@ pub trait ExternalTool {
/// Convenience: run the tool with arguments and return only the
/// exit status (discards stdout/stderr).
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
fn status(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::ExitStatus> {
let mut cmd = Self::command().ok_or_else(|| {
std::io::Error::new(
+27 -4
View File
@@ -8,6 +8,7 @@
//! keeps the dispatch site and tests resolving unchanged.
use super::*;
use crate::core::ops::TurnSpec;
/// Resolve the headless `exec` model-step ceiling.
///
@@ -274,6 +275,7 @@ pub(crate) async fn run_exec_agent(
disallowed_tools: Option<Vec<String>>,
append_system_prompt: Option<String>,
tool_authority_json: Option<String>,
exec_hooks_enabled: bool,
plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
) -> Result<()> {
use crate::compaction::CompactionConfig;
@@ -438,6 +440,25 @@ pub(crate) async fn run_exec_agent(
} else {
plugin_registry
};
// `exec --hooks` (#6099) is the operator's explicit opt-in: headless runs
// fire no hooks by default. When armed, the executor is the same one the
// TUI builds — global config, reviewed plugin snapshots, then trusted
// project `.codewhale/hooks.toml` — so `tool_call_before` can still deny
// and `shell_env` still applies. It is shared with the engine config, the
// turn's SendMessage op (which re-installs it into the engine), and the
// tool runtime services. Fleet workers never opt in: the narrowed
// authority envelope does not carry the operator's hook set into a child.
let exec_hook_executor = (exec_hooks_enabled && !fleet_authority_active).then(|| {
let hooks_config = crate::hooks::HooksConfig::load_with_project_and_plugins(
execution_config.hooks_config(),
&workspace,
Some(engine_plugin_registry.as_ref()),
);
std::sync::Arc::new(crate::hooks::HookExecutor::new(
hooks_config,
workspace.clone(),
))
});
let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
fleet_authority_active,
outer_shell_authority,
@@ -456,6 +477,7 @@ pub(crate) async fn run_exec_agent(
persist_services_enabled,
automations: exec_automations,
media_originals_dir: crate::media_originals::default_store_dir(),
hook_executor: exec_hook_executor.clone(),
..crate::tools::spec::RuntimeToolServices::default()
};
@@ -555,6 +577,7 @@ pub(crate) async fn run_exec_agent(
goal_status: crate::tools::goal::GoalStatus::Active,
goal_max_continuations: execution_config.goal_max_continuations(),
goal_continuation_delay_seconds: execution_config.goal_continuation_delay_seconds(),
goal_enforce_token_budget: execution_config.goal_enforce_token_budget(),
reasoning_only_max_reprompts: execution_config.reasoning_only_max_reprompts(),
reasoning_only_reprompt_message: Some(
execution_config
@@ -564,7 +587,7 @@ pub(crate) async fn run_exec_agent(
allowed_tools: allowed_tools.clone(),
disallowed_tools: disallowed_tools.clone(),
max_tool_calls,
hook_executor: None,
hook_executor: exec_hook_executor.clone(),
locale_tag: codewhale_localization::resolve_locale(&settings.locale)
.tag()
.to_string(),
@@ -661,7 +684,7 @@ pub(crate) async fn run_exec_agent(
let exec_turn_started_at = Instant::now();
engine_handle
.send(Op::SendMessage {
.send(Op::SendMessage(TurnSpec {
max_output_tokens: None,
content: prompt.to_string(),
images: Vec::new(),
@@ -674,7 +697,7 @@ pub(crate) async fn run_exec_agent(
goal_status: crate::tools::goal::GoalStatus::Active,
allowed_tools: allowed_tools.clone(),
dynamic_tools: Vec::new(),
hook_executor: None,
hook_executor: exec_hook_executor.clone(),
reasoning_effort: effective_reasoning_effort,
reasoning_effort_auto,
auto_model,
@@ -693,7 +716,7 @@ pub(crate) async fn run_exec_agent(
},
verbosity: execution_config.verbosity.clone(),
provenance: crate::core::ops::UserInputProvenance::ExternalUser,
})
}))
.await?;
// Lifecycle outbox: the clean headless turn-start boundary. `exec` has
-12
View File
@@ -1,12 +0,0 @@
//! TOML execpolicy rules consumed by the shell tool.
//!
//! The legacy Starlark policy engine (`PolicyParser`, `Policy`, `Rule`, the
//! `execpolicy check` CLI verb) was deleted for v0.9.4: the runtime never
//! enforced it, so a green `check` meant nothing. The live policy surface is
//! the TOML `execpolicy.toml` rules here plus the `codewhale-execpolicy`
//! crate's permission engine.
pub mod matcher;
pub mod rules;
pub use rules::{ExecPolicyDecision, load_default_policy};
+22 -13
View File
@@ -732,19 +732,28 @@ impl FleetManager {
) -> Result<FleetStatusSnapshot> {
let max_workers = max_workers.clamp(1, 128);
let manager_lock_path = self.manager_lock_path(run_id);
if let Some(parent) = manager_lock_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating Fleet manager lock dir {}", parent.display()))?;
}
let lock_file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&manager_lock_path)
.with_context(|| {
format!("opening Fleet manager lock {}", manager_lock_path.display())
})?;
// Directory creation and the lock-file open are blocking filesystem
// calls; this fn runs on the Tokio runtime, so they go through the
// blocking pool (blocking-call convention, #6149).
let lock_file = {
let path = manager_lock_path.clone();
tokio::task::spawn_blocking(move || -> Result<std::fs::File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("creating Fleet manager lock dir {}", parent.display())
})?;
}
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.with_context(|| format!("opening Fleet manager lock {}", path.display()))
})
.await
.context("Fleet manager lock setup task failed to join")??
};
let mut manager_lock = fd_lock::RwLock::new(lock_file);
let standby_interval = tick_interval
.min(Duration::from_millis(100))
+119 -37
View File
@@ -12,7 +12,7 @@ use std::path::Path;
use super::role::public_role_label;
use super::store::{
FleetFile, FleetMember, FleetScope, FleetStoreError, load_fleet_at, load_fleet_in_scope,
resolve_selected_fleet, save_fleet, set_selected, slugify,
member_pins, provider_ids_match, resolve_selected_fleet, save_fleet, set_selected, slugify,
};
use codewhale_localization::{Locale, MessageId, tr};
@@ -143,7 +143,9 @@ impl std::error::Error for FleetModelError {}
/// The selected fleet's models, operator first, then members in file order.
/// Members that inherit the session route (no pin) are not models of their
/// own and are skipped.
/// own: when the fleet has an operator their role is attributed to that
/// route — the route they actually resolve to (#6037) — and without an
/// operator they are skipped.
///
/// `Ok(empty)` means no fleet is selected (or the selected one has no pinned
/// route): the caller states "your fleet is the session model only". `Err`
@@ -182,34 +184,29 @@ pub fn models_of(fleet: &FleetFile) -> Vec<FleetModel> {
push(&operator.provider, &operator.model, Some("operator"));
}
for member in &fleet.members {
let (Some(provider), Some(model)) = (member.provider.as_deref(), member.model.as_deref())
else {
continue;
};
push(provider, model, Some(member.role_label()));
match (member.provider.as_deref(), member.model.as_deref()) {
(Some(provider), Some(model)) => {
push(provider, model, Some(member.role_label()));
}
// Unpinned = inherit the operator route; the member's role is
// covered by that route and follows it when the operator moves.
// With no operator the route is the live session's, which this
// static projection cannot name — the member stays unlisted.
(None, None) if !member.shortlist => {
if let Some(operator) = fleet.operator.as_ref() {
push(
&operator.provider,
&operator.model,
Some(member.role_label()),
);
}
}
_ => {}
}
}
models
}
/// Provider kinds have documented aliases; named custom routes have exact
/// keys. Treating every provider name as case-insensitive merges distinct
/// endpoints before the configured route binder can resolve them.
fn provider_ids_match(saved: &str, requested: &str) -> bool {
use crate::config::ApiProvider;
saved.trim() == requested.trim()
|| ApiProvider::parse(saved)
.filter(|provider| *provider != ApiProvider::Custom)
.is_some_and(|provider| Some(provider) == ApiProvider::parse(requested))
}
fn member_pins(member: &FleetMember, provider: &str, model: &str) -> bool {
member
.provider
.as_deref()
.is_some_and(|p| provider_ids_match(p, provider))
&& member.model.as_deref().is_some_and(|id| id == model)
}
/// Add `provider/model` to the selected fleet, one member row per role, or
/// one explicitly marked shortlist row when no role was requested.
///
@@ -218,6 +215,10 @@ fn member_pins(member: &FleetMember, provider: &str, model: &str) -> bool {
/// the add has been written. Roles are deduplicated; when every requested
/// role already pins the route nothing is rewritten and the change is
/// [`FleetModelChange::Unchanged`].
///
/// A role member asked to run the fleet's own operator route inherits that
/// route instead of pinning it (#6037): the pin would resolve identically
/// today and then hold the member on a retired id when the operator moves.
pub fn add_fleet_model(
workspace: &Path,
provider: &str,
@@ -250,13 +251,20 @@ pub fn add_fleet_model(
}
Ok(())
};
if shortlist && is_operator_route(&fleet, provider, model) {
let on_operator_route = is_operator_route(&fleet, provider, model);
if shortlist && on_operator_route {
select(needs_select)?;
return Ok(FleetModelChange::Unchanged {
fleet: fleet.name,
reason: UnchangedReason::OperatorRoute,
});
}
// #6037: the pin is the deliberate opt-out, not the default. A role
// member asked to run the fleet's own operator route inherits instead —
// it resolves to that route today and still follows when the operator
// moves. A role asked to run a different route keeps the explicit pin;
// a shortlist row always pins (the pin is its entire content).
let pins_route = shortlist || !on_operator_route;
let model_slug = slugify(model);
let mut added_any = false;
for role in roles
@@ -265,8 +273,12 @@ pub fn add_fleet_model(
.chain(shortlist.then_some(""))
{
let already = fleet.members.iter().any(|m| {
member_pins(m, provider, model)
&& (shortlist || (!m.shortlist && m.role_label().eq_ignore_ascii_case(role)))
if shortlist {
return member_pins(m, provider, model);
}
!m.shortlist
&& m.role_label().eq_ignore_ascii_case(role)
&& (member_pins(m, provider, model) || (!pins_route && m.model.is_none()))
});
if already {
continue;
@@ -282,8 +294,8 @@ pub fn add_fleet_model(
display_name: None,
shortlist,
role: role.to_string(),
model: Some(model.to_string()),
provider: Some(provider.to_string()),
model: pins_route.then(|| model.to_string()),
provider: pins_route.then(|| provider.to_string()),
reasoning: None,
instructions: None,
requires: Vec::new(),
@@ -373,17 +385,19 @@ pub fn toggle_fleet_model(
if removed_shortlist {
save_fleet(&fleet, scope, workspace)?;
}
if fleet
.members
.iter()
.any(|m| member_pins(m, provider, model))
{
let on_operator_route = is_operator_route(&fleet, provider, model);
if fleet.members.iter().any(|m| {
member_pins(m, provider, model)
// #6037: an unpinned role member inherits the operator route, so
// it still covers this route even though nothing pins it.
|| (on_operator_route && !m.shortlist && m.model.is_none())
}) {
return Ok(FleetModelChange::Unchanged {
fleet: fleet.name,
reason: UnchangedReason::AlreadyPresent,
});
}
if is_operator_route(&fleet, provider, model) {
if on_operator_route {
return Ok(FleetModelChange::Unchanged {
fleet: fleet.name,
reason: UnchangedReason::OperatorRoute,
@@ -1337,6 +1351,74 @@ provider = "custom-a"
);
}
#[test]
fn add_on_the_operator_route_inherits_and_follows_it_when_it_moves() {
let _lock = crate::test_support::lock_test_env();
let (_temp, _home, workspace) = isolated_workspace();
let mut fleet = fleet_with(Some(("openrouter", "z-ai/glm-5.3")), &[]);
fleet.name = "Ops".to_string();
save_fleet(&fleet, FleetScope::Personal, &workspace).expect("save");
set_selected("Ops", FleetScope::Personal, &workspace).expect("select");
// #6037: a role asked to run the operator route inherits it — no pin.
let change = add_fleet_model(
&workspace,
"openrouter",
"z-ai/glm-5.3",
&["planner".to_string()],
)
.expect("add role");
assert!(
matches!(change, FleetModelChange::Added { .. }),
"{change:?}"
);
let (on_disk, _) = selected_file(&workspace);
let planner = on_disk.member("planner").expect("planner member");
assert_eq!(planner.provider, None, "inherited route writes no pin");
assert_eq!(planner.model, None, "inherited route writes no pin");
// Coverage still counts: a second add of the same role is a no-op.
let change = add_fleet_model(
&workspace,
"openrouter",
"z-ai/glm-5.3",
&["planner".to_string()],
)
.expect("re-add");
assert!(matches!(
change,
FleetModelChange::Unchanged {
reason: UnchangedReason::AlreadyPresent,
..
}
));
// The inherited role follows the operator when it moves — the exact
// property a pin would have defeated.
let (mut moved, _) = selected_file(&workspace);
moved.operator = Some(FleetOperator {
provider: "deepseek".to_string(),
model: "deepseek-v4-flash".to_string(),
reasoning: None,
});
let models = models_of(&moved);
assert_eq!(models[0].model, "deepseek-v4-flash");
assert_eq!(models[0].roles, ["operator", "planner"]);
// A role asked to run a different route keeps the deliberate pin.
add_fleet_model(
&workspace,
"openrouter",
"z-ai/glm-5.3-flash",
&["scout".to_string()],
)
.expect("add pinned role");
let (on_disk, _) = selected_file(&workspace);
let scout = on_disk.member("scout").expect("scout member");
assert_eq!(scout.provider.as_deref(), Some("openrouter"));
assert_eq!(scout.model.as_deref(), Some("z-ai/glm-5.3-flash"));
}
#[test]
fn member_ids_stay_unique_when_a_role_is_reused_on_two_models() {
let _lock = crate::test_support::lock_test_env();
+1 -1
View File
@@ -390,7 +390,7 @@ pub(crate) const RAW_SHELL_DENYLIST: &[&str] = &[
/// deliberately *not* what the execution envelope consults for shell
/// authority — see [`SHELL_AUTHORITY_SENTINEL`] for why those are two
/// different questions.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub(crate) const RAW_SHELL_SENTINEL: &str = "exec_shell";
/// The built-in verification surface: the workspace's own configured checks.
+95 -2
View File
@@ -28,6 +28,8 @@ use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::ApiProvider;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -190,6 +192,25 @@ impl FleetMember {
}
}
/// Provider kinds have documented aliases; named custom routes have exact
/// keys. Treating every provider name as case-insensitive merges distinct
/// endpoints before the configured route binder can resolve them.
pub(crate) fn provider_ids_match(saved: &str, requested: &str) -> bool {
saved.trim() == requested.trim()
|| ApiProvider::parse(saved)
.filter(|provider| *provider != ApiProvider::Custom)
.is_some_and(|provider| Some(provider) == ApiProvider::parse(requested))
}
/// The member pins exactly `provider`/`model`.
pub(crate) fn member_pins(member: &FleetMember, provider: &str, model: &str) -> bool {
member
.provider
.as_deref()
.is_some_and(|p| provider_ids_match(p, provider))
&& member.model.as_deref().is_some_and(|id| id == model)
}
/// The saved named Fleet document (compatibility `schema = "fleet"`, revision 2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -374,6 +395,19 @@ impl FleetFile {
// those rows on read so the roster reads as roles again. The next
// save writes the clean document.
fleet.members.retain(|member| !member.is_bare_model_pin());
// #6037: a member pinned to the fleet's own operator route resolves
// to that route either way; the pin only stops it following when the
// operator moves (a vendor retiring the id, an operator switching
// models). Read the redundant pin as the inheritance it always meant.
// Shortlist rows keep their pin — it is their entire content.
if let Some(operator) = &fleet.operator {
for member in &mut fleet.members {
if !member.shortlist && member_pins(member, &operator.provider, &operator.model) {
member.provider = None;
member.model = None;
}
}
}
fleet.validate()?;
Ok(fleet)
}
@@ -549,7 +583,7 @@ fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec<FleetEntry>) {
/// names both origins — the caller (UI) resolves it by asking for a scope.
/// (Kept for the qualified-name flow and the ambiguity tests; the list/detail
/// UI resolves by scope via load_fleet_in_scope.)
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub fn load_fleet(
name: &str,
workspace: &Path,
@@ -624,7 +658,6 @@ pub fn load_fleet_in_scope(
/// Load a v2 Fleet from a specific path (used by the editor on the currently
/// open entry, so the saved scope is exact). API surface for the path-based
/// editor flows; currently exercised by tests.
#[allow(dead_code)]
pub fn load_fleet_at(path: &Path) -> Result<(FleetFile, FleetScope), FleetStoreError> {
let text = fs::read_to_string(path).map_err(|e| FleetStoreError::Io {
path: path.display().to_string(),
@@ -1172,6 +1205,66 @@ mod tests {
assert!(err.to_string().contains("vision"), "{err}");
}
#[test]
fn member_pin_matching_the_operator_route_reads_as_inheritance() {
// #6037: a role member pinned to the fleet's own operator route
// resolves to that route either way — the pin only stops it
// following when the operator moves. Parse drops the redundant pin;
// a different-route pin and a shortlist row keep theirs.
let fleet = FleetFile::parse(
r#"schema = "fleet"
schema_revision = 2
name = "Inherit"
[operator]
provider = "openrouter"
model = "z-ai/glm-5.3"
[[members]]
id = "planner"
role = "planner"
provider = "openrouter"
model = "z-ai/glm-5.3"
[[members]]
id = "builder"
role = "builder"
provider = "openrouter"
model = "z-ai/glm-5.3-pro"
[[members]]
id = "choice"
shortlist = true
provider = "openrouter"
model = "z-ai/glm-5.3"
"#,
)
.expect("parse");
let planner = fleet.member("planner").expect("planner member");
assert_eq!(planner.provider, None);
assert_eq!(planner.model, None);
let builder = fleet.member("builder").expect("builder member");
assert_eq!(builder.provider.as_deref(), Some("openrouter"));
assert_eq!(builder.model.as_deref(), Some("z-ai/glm-5.3-pro"));
let choice = fleet
.members
.iter()
.find(|member| member.shortlist)
.expect("shortlist row");
assert_eq!(choice.provider.as_deref(), Some("openrouter"));
assert_eq!(choice.model.as_deref(), Some("z-ai/glm-5.3"));
// The listing still attributes the inherited role to the route it runs.
let models = crate::fleet::members::models_of(&fleet);
assert_eq!(models[0].model, "z-ai/glm-5.3");
assert_eq!(models[0].roles, ["operator", "planner"]);
assert_eq!(models[1].roles, ["builder"]);
// The cleaned document round-trips: inherit stays inherit.
assert_eq!(
FleetFile::parse(&fleet.render_toml().expect("render")).expect("reparse"),
fleet
);
}
#[test]
fn render_parse_round_trip_preserves_every_field() {
let fleet = sample_fleet();
+79 -10
View File
@@ -74,7 +74,7 @@ pub enum GoalRunStatus {
/// verifier confirmed — see `GoalGate`).
Completed,
/// The model reported it is blocked and needs the user.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
Blocked,
}
@@ -84,11 +84,14 @@ pub enum StopReason {
/// Objective achieved.
Completed,
/// Model reported blocked.
#[allow(dead_code)]
Blocked,
/// Continuation circuit-breaker tripped (too many continuations without a
/// terminal signal).
ContinuationLimit,
/// The goal's token budget was reached while `[goal] enforce_token_budget`
/// opted the budget into a hard stop (#6013). Default-off: without the
/// opt-in the budget stays advisory telemetry and never produces this.
BudgetLimit,
}
/// Accumulated, durable progress for a goal run. Mirrors the fields wired by
@@ -108,6 +111,11 @@ pub struct GoalProgress {
pub struct GoalBudget {
pub token_budget: Option<u64>,
pub time_budget_seconds: Option<u64>,
/// Whether `token_budget` stops the run when reached (#6013). `false`
/// keeps the default advisory behavior: crossing the budget logs and
/// continues. There is no time-budget enforcement because goals carry no
/// `time_budget` field — only `token_budget` can gate.
pub enforce_token_budget: bool,
/// Safety backstop on automatic continuation passes (#5052). `0` disables
/// the backstop: only terminal status stops the run.
pub max_continuations: u32,
@@ -116,28 +124,37 @@ pub struct GoalBudget {
impl GoalBudget {
/// No token or time cap. Terminal status, user control, and the default
/// continuation backstop still stop the run.
#[allow(dead_code)]
pub const fn unbounded() -> Self {
Self {
token_budget: None,
time_budget_seconds: None,
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
}
}
/// A token budget for telemetry/UI. It never pauses an unbounded goal.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub const fn with_token_budget(token_budget: u64) -> Self {
Self {
token_budget: Some(token_budget),
time_budget_seconds: None,
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
}
}
/// Opt the token budget into a hard stop (`[goal] enforce_token_budget`).
/// With no `token_budget` set this is a no-op by construction — there is
/// no ceiling to reach.
#[must_use]
pub const fn with_enforced_token_budget(mut self, enforce: bool) -> Self {
self.enforce_token_budget = enforce;
self
}
/// Override the continuation backstop (`0` = unlimited until terminal
/// status).
#[allow(dead_code)]
#[must_use]
pub const fn with_max_continuations(mut self, max_continuations: u32) -> Self {
self.max_continuations = max_continuations;
@@ -176,14 +193,21 @@ pub fn decide_continuation(
GoalRunStatus::Active => {}
}
// 2. Token/time budgets are advisory only (unbounded). They are
// visible in the Goal chip + /cost but never pause the loop — like
// grokbuild's agent-call budget and kimicode swarm's per-task
// turnBudget with resume. Log if we are over budget, then continue.
// 2. Token budget: advisory telemetry by default, or a hard stop when
// `[goal] enforce_token_budget` opts in (#6013). Time stays advisory —
// goals carry no time budget to enforce against.
if budget
.token_budget
.is_some_and(|limit| progress.tokens_used >= limit)
{
if budget.enforce_token_budget {
tracing::info!(
tokens_used = progress.tokens_used,
token_budget = ?budget.token_budget,
"goal token budget reached; stopping ([goal] enforce_token_budget)"
);
return ContinuationDecision::Stop(StopReason::BudgetLimit);
}
tracing::debug!(
tokens_used = progress.tokens_used,
token_budget = ?budget.token_budget,
@@ -280,7 +304,7 @@ pub const fn token_budget_exhausted(_progress: GoalProgress, _budget: GoalBudget
/// Whether a stop reason represents success (Completed) vs. an early/forced exit.
/// Useful for the UI/status projection (#2666 token/time visibility).
#[must_use]
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub fn is_success(reason: StopReason) -> bool {
matches!(reason, StopReason::Completed)
}
@@ -321,6 +345,7 @@ mod tests {
let budget = GoalBudget {
token_budget: Some(1000),
time_budget_seconds: Some(600),
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
};
assert_eq!(
@@ -416,6 +441,48 @@ mod tests {
);
}
#[test]
fn enforced_token_budget_stops_the_run() {
let progress = GoalProgress {
tokens_used: 1000,
continuations: 1,
..GoalProgress::default()
};
let budget = GoalBudget::with_token_budget(1000).with_enforced_token_budget(true);
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, budget),
ContinuationDecision::Stop(StopReason::BudgetLimit),
"enforce_token_budget makes an exhausted token budget terminal"
);
assert!(!is_success(StopReason::BudgetLimit));
}
#[test]
fn enforce_flag_without_a_token_budget_cannot_stop() {
// A goal created without `token_budget` has no ceiling to reach — the
// flag must not invent one.
let progress = GoalProgress {
tokens_used: u64::MAX / 2,
continuations: 1,
..GoalProgress::default()
};
let budget = GoalBudget::unbounded().with_enforced_token_budget(true);
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, budget),
ContinuationDecision::Continue
);
}
#[test]
fn enforced_budget_still_yields_to_a_terminal_status() {
let budget = GoalBudget::with_token_budget(1000).with_enforced_token_budget(true);
assert_eq!(
decide_continuation(GoalRunStatus::Completed, GoalProgress::default(), budget),
ContinuationDecision::Stop(StopReason::Completed),
"a clean completion outranks the budget gate"
);
}
#[test]
fn token_budget_is_advisory_not_terminal() {
let progress = GoalProgress {
@@ -441,6 +508,7 @@ mod tests {
let budget = GoalBudget {
token_budget: None,
time_budget_seconds: Some(600),
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
};
assert_eq!(
@@ -457,6 +525,7 @@ mod tests {
let budget = GoalBudget {
token_budget: Some(1_000_000),
time_budget_seconds: Some(86_400),
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
};
assert_eq!(
+200 -8
View File
@@ -49,7 +49,6 @@ mod dsh_credentials;
mod elapsed;
mod error_taxonomy;
mod eval;
mod execpolicy;
mod external_credentials;
mod fast_hash;
mod features;
@@ -114,7 +113,6 @@ mod runtime_threads;
mod safe_label;
mod sandbox;
mod scorecard;
#[allow(dead_code)]
mod session_diagnostics;
// Acceptance matrix for #2934 / #4397. Test-only: the table documents the
// contract for reviewers and is enforced by the tests beside it, so it does
@@ -125,7 +123,6 @@ mod doctor_loader_tests;
#[cfg(test)]
mod session_control_acceptance;
mod session_export;
#[allow(dead_code)]
mod session_manager;
mod session_peek;
mod session_projection;
@@ -496,6 +493,13 @@ struct ExecArgs {
/// Internal Fleet worker authority envelope. Non-secret, versioned JSON.
#[arg(long, value_name = "JSON", hide = true)]
tool_authority_json: Option<String>,
/// Fire the configured hooks in this run (opt-in). On the headless path
/// the engine-side events are `tool_call_before` — which may still deny
/// a call — and `shell_env`. A hook `ask` resolves fail-closed because
/// nothing can prompt headlessly. Fleet worker subprocesses never fire
/// operator hooks.
#[arg(long, default_value_t = false)]
hooks: bool,
/// Prompt to send to the model
#[arg(
value_name = "PROMPT",
@@ -970,7 +974,8 @@ fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSe
let session_ref = exec_stream_session_ref(session_id);
SessionManager::default_location()
.context("could not open session manager for resume")?
.load_session_by_prefix(session_id)
.resume_session_by_prefix(session_id)
.map(|recovery| recovery.session)
.with_context(|| format!("could not load session {session_ref}"))
}
@@ -2429,6 +2434,7 @@ async fn run_async_main_dispatch(
|| args.disallowed_tools.is_some()
|| args.append_system_prompt.is_some()
|| args.tool_authority_json.is_some()
|| args.hooks
|| args.sandbox.is_some()
|| args.allow_sandbox_elevation
|| env_tool_surface.is_some();
@@ -2469,6 +2475,7 @@ async fn run_async_main_dispatch(
disallowed_tools,
args.append_system_prompt.clone(),
args.tool_authority_json.clone(),
args.hooks,
std::sync::Arc::clone(&plugin_registry),
)
.await
@@ -2539,7 +2546,7 @@ async fn run_async_main_dispatch(
args.acp,
)?;
if args.mcp {
tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
mcp_server::run_mcp_server(workspace).await
} else if http_selected {
let (mut config, config_profile) =
load_config_from_cli_with_effective_profile(&cli)?;
@@ -4515,7 +4522,7 @@ async fn run_doctor(
);
// Secret hygiene: name the keys, never the values. Plain-text config
// is not a secret store.
if let Ok(raw) = std::fs::read_to_string(config_path) {
if let Ok(raw) = tokio::fs::read_to_string(config_path).await {
let flagged = crate::doctor::config_credential_shaped_keys(&raw);
if !flagged.is_empty() {
println!(
@@ -6476,8 +6483,11 @@ fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_
})
.collect();
let model_pin_drift = doctor_model_pin_drift(config, workspace, &roster);
json!({
"ready": has_credentials_or_local && runtime_ready && roster_ready,
"model_pin_drift": model_pin_drift,
"provider": {
"id": config.provider_identity_for(provider),
"auth": {
@@ -6518,6 +6528,105 @@ fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_
})
}
/// Warning-only model-pin drift surfacing (#6035). A pin is flagged only
/// when a FRESH cached live roster for that provider route exists and does
/// not list the pinned wire id — stale, failed, or absent rosters cannot
/// prove drift, and bundled catalog rows say nothing about what the account
/// currently serves. The id may still answer (soft deprecation) or be served
/// by other providers on their own routes, so this never rewrites the pin.
fn doctor_model_pin_drift(
config: &Config,
workspace: &Path,
roster: &crate::fleet::roster::FleetRoster,
) -> serde_json::Value {
use serde_json::json;
use std::collections::BTreeMap;
// One row per affected route: (provider, model) -> pin owners.
let mut pins: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
for entry in crate::fleet::store::list_fleets(workspace) {
if entry.parse_error.is_some() {
continue;
}
let Ok((fleet, scope)) = crate::fleet::store::load_fleet_at(&entry.path) else {
continue;
};
let owner = format!("fleet:{} ({})", fleet.name, scope.label());
if let Some(operator) = fleet.operator.as_ref() {
pins.entry((operator.provider.clone(), operator.model.clone()))
.or_default()
.push(format!("{owner} operator"));
}
for member in &fleet.members {
if let (Some(provider), Some(model)) = (member.provider.as_ref(), member.model.as_ref())
{
pins.entry((provider.clone(), model.clone()))
.or_default()
.push(format!("{owner} member:{}", member.id));
}
}
}
for member in roster.members() {
if let (Some(provider), Some(model)) = (
member.profile.provider.as_ref(),
member.profile.model.as_ref(),
) {
pins.entry((provider.clone(), model.clone()))
.or_default()
.push(format!("agent:{}", member.id));
}
}
let mut unverifiable = 0usize;
let drifted = pins
.iter()
.filter_map(|((provider, model), owners)| {
let kind = crate::config::ApiProvider::parse(provider)
.unwrap_or(crate::config::ApiProvider::Custom);
let identity = match kind {
crate::config::ApiProvider::Custom => provider.clone(),
_ => kind.as_str().to_string(),
};
let base_url = config.base_url_for_route_identity(kind, &identity);
if crate::provider_catalog_live::status_for_route(kind, &identity, &base_url)
!= codewhale_config::catalog::CatalogStatus::Fresh
{
unverifiable += 1;
return None;
}
let listed =
crate::provider_catalog_live::cached_entry_for_route(kind, &identity, &base_url)
.ok()
.flatten()
.is_some_and(|entry| {
entry.offerings.iter().any(|offering| {
offering.wire_model_id == *model
|| offering.canonical_model.as_deref() == Some(model.as_str())
})
});
(!listed).then(|| {
json!({
"provider": provider,
"model": model,
"owners": owners,
"message": format!(
"pinned id `{model}` is absent from {provider}'s current live roster; \
the id may still answer (soft deprecation) or be served by other \
providers on their own routes — the pin is left unchanged"
),
})
})
})
.collect::<Vec<_>>();
json!({
"checked": pins.len(),
"unverifiable": unverifiable,
"drifted": drifted,
})
}
fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
use serde_json::json;
@@ -7756,10 +7865,12 @@ async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
.await?;
if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create output directory {}", parent.display()))?;
}
std::fs::write(&output, &response.audio_bytes)
tokio::fs::write(&output, &response.audio_bytes)
.await
.with_context(|| format!("Failed to write audio file {}", output.display()))?;
if json_output {
@@ -14888,6 +14999,84 @@ mod terminal_mode_tests {
);
}
#[test]
fn doctor_fleet_report_flags_pins_absent_from_fresh_live_roster() {
// #6035: a pin that vanished from the provider's current live roster
// is drift the report must name — warning only, never a rewrite.
let _env_lock = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let tmp = tempfile::TempDir::new().expect("tempdir");
let home = tmp.path().join("home");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
crate::provider_catalog_live::reset_cache_for_test();
let fleets = home.join("fleets");
std::fs::create_dir_all(&fleets).expect("fleets dir");
std::fs::write(
fleets.join("default.toml"),
"schema = \"fleet\"\nschema_revision = 2\nname = \"default\"\n\
[operator]\nprovider = \"deepseek\"\nmodel = \"deepseek-flash\"\n\
[[members]]\nid = \"builder\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n",
)
.expect("fleet file");
let config = Config {
provider: Some("deepseek".to_string()),
..Default::default()
};
let kind = crate::config::ApiProvider::Deepseek;
let base_url = config.base_url_for_route_identity(kind, "deepseek");
let fetched_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs();
let fingerprint = codewhale_config::catalog::base_url_fingerprint(&base_url);
assert_eq!(
crate::provider_catalog_live::record_success(
codewhale_config::catalog::ProviderCatalogDelta {
provider: "deepseek".to_string(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: vec![codewhale_config::catalog::CatalogOffering {
provider: "deepseek".to_string(),
wire_model_id: "deepseek-flash".to_string(),
endpoint_key: "chat".to_string(),
source: codewhale_config::catalog::CatalogSource::Live {
base_url_fingerprint: fingerprint,
fetched_at,
},
..Default::default()
}],
}
),
codewhale_config::catalog::CatalogStatus::Fresh
);
let operate = doctor_operate_fleet_report_json(&config, &workspace);
let drift = &operate["model_pin_drift"];
let drifted = drift["drifted"].as_array().expect("drifted array");
let row = drifted
.iter()
.find(|row| row["model"] == "deepseek-v4-flash")
.expect("member pin flagged: {drift}");
assert_eq!(row["provider"], "deepseek");
assert!(
row["owners"]
.as_array()
.expect("owners")
.iter()
.any(|owner| owner.as_str().is_some_and(|o| o.contains("member:builder"))),
"the fleet member pin is named: {row}"
);
// The operator pin moved to the listed id — not drift.
assert!(
!drifted.iter().any(|row| row["model"] == "deepseek-flash"),
"listed pin must not be flagged: {drifted:?}"
);
}
fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
let mut saved = session_manager::create_saved_session_with_mode(
&[],
@@ -17106,6 +17295,7 @@ api_key = "test-only-key"
assert_eq!(args.resume.as_deref(), Some("abc123"));
assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
assert_eq!(args.prompt, vec!["follow up"]);
assert!(!args.hooks, "headless hooks stay opt-in by default");
}
#[test]
@@ -17137,6 +17327,7 @@ api_key = "test-only-key"
"extra rules",
"--tool-authority-json",
envelope,
"--hooks",
"do the thing",
]);
let Some(Commands::Exec(args)) = cli.command else {
@@ -17155,6 +17346,7 @@ api_key = "test-only-key"
assert_eq!(args.max_tool_calls, Some(9));
assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
assert!(args.hooks);
assert_eq!(args.prompt, vec!["do the thing"]);
}
+16 -1
View File
@@ -205,12 +205,27 @@ pub(crate) async fn probe_live_local_ollama_catalog(
Some(LiveLocalOllamaCatalog { endpoint_v1, tags })
}
/// Env opt-out for harnesses that must not see the developer's machine.
///
/// `spawn_local_ollama_adoption_probe` is already inert under `cfg(test)`, but
/// the PTY suites spawn the real binary, so that guard never reaches them. A
/// developer running Ollama on :11434 therefore gets the launch screen replaced
/// by a "Provider switched: deepseek -> ollama" notice, and the PTY tests that
/// wait for launch text fail on their machine while CI stays green. Sealing the
/// HOME is not enough, because this leak arrives over the loopback network
/// rather than through the filesystem.
pub(crate) const DISABLE_LOCAL_OLLAMA_PROBE_ENV: &str = "CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE";
fn local_ollama_probe_disabled() -> bool {
std::env::var_os(DISABLE_LOCAL_OLLAMA_PROBE_ENV).is_some_and(|value| !value.is_empty())
}
/// Background probe used by the event loop (mirrors `spawn_startup_version_check`).
pub(crate) fn spawn_local_ollama_adoption_probe(
config: &Config,
should_probe: bool,
) -> Option<tokio::task::JoinHandle<Option<LiveLocalOllamaCatalog>>> {
if !should_probe {
if !should_probe || local_ollama_probe_disabled() {
return None;
}
#[cfg(test)]
+2 -2
View File
@@ -72,7 +72,7 @@ pub trait LspTransport: Send + Sync {
}
/// Best-effort shutdown. Called via `LspManager::shutdown_all`.
#[allow(dead_code)]
#[expect(dead_code)]
async fn shutdown(&self);
}
@@ -82,7 +82,7 @@ pub trait LspTransport: Send + Sync {
pub struct StdioLspTransport {
/// JoinHandle for the running server. Held so the child stays alive for
/// the transport's lifetime; consumed during `shutdown`.
#[allow(dead_code)]
#[expect(dead_code)]
child: AsyncMutex<Option<Child>>,
/// Outgoing message sender to the writer task.
tx_outbound: mpsc::Sender<Vec<u8>>,
+1 -1
View File
@@ -633,7 +633,7 @@ impl LspManager {
/// Best-effort shutdown of every spawned transport. Called when the
/// session ends.
#[allow(dead_code)]
#[expect(dead_code)]
pub async fn shutdown_all(&self) {
let transports: Vec<Arc<dyn LspTransport>> =
self.transports.lock().await.values().cloned().collect();
+127 -5
View File
@@ -2570,6 +2570,13 @@ pub struct McpPool {
/// healthy. Explicit intent (`retry_connection`, `get_or_connect`, a
/// config reload) ignores the cooldown.
connect_backoff: HashMap<String, ConnectBackoff>,
/// Servers with a spawned connect in flight right now. `connect_all`,
/// the session boot pass, and explicit tool-selection connects all mark
/// names here and clear them on resolution, so status surfaces never
/// have to infer "connecting" from "enabled but not connected yet"
/// (#6033): under lazy boot an unconnected server is one nobody has
/// asked for, not one mid-handshake.
connecting: HashSet<String>,
}
/// One server's cooldown: when to try again, and what to say until then.
@@ -2595,6 +2602,21 @@ fn connect_backoff_delay(failures: u32) -> std::time::Duration {
type McpPendingConnect = (String, McpServerConfig);
type McpConnectError = (String, anyhow::Error);
/// Whether an explicit tool selection (`tools_always_load`, a turn's
/// `allowed_tools`) covers `server`: either an exact `mcp_<server>_<tool>`
/// name or an `mcp_<prefix>*` glob whose prefix reaches the server name.
/// One definition shared by the lazy boot pass and the per-turn
/// explicit-connect wait so both agree on what a selection starts (#6033).
pub(crate) fn tool_selection_covers_server(requested: &[String], server: &str) -> bool {
let prefix = format!("mcp_{}_", server.to_ascii_lowercase());
requested.iter().any(|name| {
name.starts_with(&prefix)
|| name
.strip_suffix('*')
.is_some_and(|rule| prefix.starts_with(rule))
})
}
impl McpPool {
/// Create a new pool with the given configuration
pub fn new(config: McpConfig) -> Self {
@@ -2612,6 +2634,7 @@ impl McpPool {
config_hash,
catalog_generation: AtomicU64::new(1),
connect_backoff: HashMap::new(),
connecting: HashSet::new(),
last_mtimes: Vec::new(),
dynamic_servers: Arc::new(RwLock::new(HashMap::new())),
needs_auth_servers: BTreeSet::new(),
@@ -3098,6 +3121,7 @@ impl McpPool {
}
// A successful connect settles the auth question for this server,
// and the cooldown with it.
self.connecting.remove(&name);
self.connect_backoff.remove(&name);
if self.needs_auth_servers.remove(&name) {
self.needs_auth_generation = self.needs_auth_generation.wrapping_add(1);
@@ -3113,6 +3137,7 @@ impl McpPool {
/// failure replaces the verdict — the state is "the most recent connect
/// failed auth-required", not "some connect once did".
pub(crate) fn note_connect_failure(&mut self, name: &str, error: &anyhow::Error) {
self.connecting.remove(name);
if !self.server_allowed(name) {
return;
}
@@ -3180,17 +3205,22 @@ impl McpPool {
/// while bounding peak memory.
const CONNECT_CONCURRENCY: usize = 8;
/// Decide which enabled configured servers still need a handshake.
/// Dynamic runtime servers stay registered and connect via
/// [`Self::get_or_connect`]; `connect_all` has never spawned them.
/// Collect the configured servers a connect pass should start. `only`
/// scopes the pass to the given names; `None` connects every enabled,
/// allowed server (`connect_all`). Dynamic runtime servers stay
/// registered and connect via [`Self::get_or_connect`]; connect passes
/// have never spawned them. Every emitted name is marked
/// [`Self::connecting`] until its spawn resolves.
pub(crate) fn collect_pending_connects(
&mut self,
only: Option<&HashSet<String>>,
) -> (Vec<McpPendingConnect>, Vec<McpConnectError>) {
let names: Vec<String> = self
.config
.servers
.iter()
.filter(|(name, server)| server.is_enabled() && self.server_allowed(name))
.filter(|(name, _)| only.is_none_or(|set| set.contains(*name)))
.map(|(name, _)| name.clone())
.collect();
let mut pending = Vec::new();
@@ -3230,12 +3260,105 @@ impl McpPool {
errors.push((name, anyhow::anyhow!(backoff.last_error.clone())));
continue;
}
if self.connecting.contains(&name) {
// An earlier pass spawned this connect and it has not
// resolved; a second pass must not spawn a duplicate.
continue;
}
self.drop_connection(&name, "reconnect");
self.connecting.insert(name.clone());
pending.push((name, server_config));
}
(pending, errors)
}
/// Start connects for servers an explicit tool selection named. Unlike a
/// boot pass the selection is the intent — cooldowns do not apply — but
/// servers already ready or already in flight are left alone, and plugin
/// authority is re-validated exactly as in
/// [`Self::collect_pending_connects`]. Covers dynamic servers too: a
/// selection can name one.
pub(crate) fn take_pending_connects_for(
&mut self,
names: &[String],
) -> (Vec<McpPendingConnect>, Vec<McpConnectError>) {
let mut pending = Vec::new();
let mut errors = Vec::new();
for name in names {
let Some(server_config) = self.server_config(name) else {
continue;
};
if !server_config.is_enabled() || !self.server_allowed(name) {
continue;
}
let plugin_source = self
.connections
.get(name)
.and_then(|connection| connection.config().reviewed_plugin.clone())
.or_else(|| server_config.reviewed_plugin.clone());
if let Some(source) = plugin_source
&& let Err(error) = source.validate_before_use(name, "use")
{
self.drop_connection(name, "plugin authority revoked or changed");
errors.push((name.clone(), error));
continue;
}
if self
.connections
.get(name)
.is_some_and(McpConnection::is_ready)
|| !self.connecting.insert(name.clone())
{
continue;
}
self.drop_connection(name, "reconnect");
pending.push((name.clone(), server_config));
}
(pending, errors)
}
/// Forget in-flight marks for connects whose spawns were aborted before
/// resolution (boot-pass abort on config change, deadline expiry).
pub(crate) fn cancel_connecting(&mut self, names: &HashSet<String>) {
self.connecting.retain(|name| !names.contains(name));
}
/// Servers with a connect in flight right now — the one honest answer to
/// "which servers are connecting" (#6033).
pub(crate) fn connecting_servers(&self) -> Vec<String> {
self.connecting.iter().cloned().collect()
}
/// Enabled, allowed configured servers the boot pass must still start
/// eagerly under lazy boot (#6033): servers marked `required`, plus any
/// server the session's explicit tool selections cover.
pub(crate) fn eager_boot_server_names(&self, requested: &[String]) -> HashSet<String> {
self.config
.servers
.iter()
.filter(|(name, server)| server.is_enabled() && self.server_allowed(name))
.filter(|(name, server)| {
server.required || tool_selection_covers_server(requested, name)
})
.map(|(name, _)| name.clone())
.collect()
}
/// Enabled, allowed servers — configured or dynamic — covered by an
/// explicit tool selection (`mcp_<server>_*` names or `mcp_<prefix>*`
/// globs). These are the names a turn is allowed to start on demand.
pub(crate) fn explicitly_selected_server_names(&self, requested: &[String]) -> Vec<String> {
let dynamic = self.dynamic_servers.read();
self.config
.servers
.iter()
.chain(dynamic.iter())
.filter(|(name, server)| server.is_enabled() && self.server_allowed(name))
.filter(|(name, _)| tool_selection_covers_server(requested, name))
.map(|(name, _)| name.clone())
.collect()
}
pub(crate) fn push_required_server_errors(&self, errors: &mut Vec<McpConnectError>) {
for (name, server_cfg) in &self.config.servers {
// Only stand in for a missing diagnosis. When the connect attempt
@@ -3349,7 +3472,7 @@ impl McpPool {
}
for _pass in 0..2 {
let (pending, auth_errors) = self.collect_pending_connects();
let (pending, auth_errors) = self.collect_pending_connects(None);
errors.extend(auth_errors);
if pending.is_empty() {
break;
@@ -4534,7 +4657,6 @@ impl McpPool {
}
/// Get list of connected server names
#[allow(dead_code)] // Public API; the HTTP list endpoint no longer spawns a pool to call it (#3532)
pub fn connected_servers(&self) -> Vec<&str> {
self.connections
.iter()
-1
View File
@@ -19,7 +19,6 @@ pub(super) struct SseTransport {
pub(super) auth: McpHttpAuth,
pub(super) endpoint_url: Option<String>,
pub(super) receiver: tokio::sync::mpsc::Receiver<SseInbound>,
#[allow(dead_code)]
pub(super) sse_task: tokio::task::JoinHandle<()>,
}
+83 -3
View File
@@ -4490,7 +4490,7 @@ async fn a_failed_server_waits_out_a_cooldown_instead_of_redialing_every_turn()
assert_eq!(first.len(), 1, "first pass should dial and fail once");
// Second pass: still reported as failing, but nothing is queued to dial.
let (pending, errors) = pool.collect_pending_connects();
let (pending, errors) = pool.collect_pending_connects(None);
assert!(
pending.is_empty(),
"a server inside its cooldown must not be re-dialed: {:?}",
@@ -4508,13 +4508,93 @@ async fn a_failed_server_waits_out_a_cooldown_instead_of_redialing_every_turn()
// Asking for that server by name is explicit intent and lifts the wait.
assert!(pool.retry_connection("broken").await.is_err());
let (pending, _) = pool.collect_pending_connects();
let (pending, _) = pool.collect_pending_connects(None);
assert!(
pending.is_empty(),
"the failed retry restarts the ladder rather than clearing it"
);
}
/// Lazy boot (#6033): the scoped collect starts only the eager set —
/// `required` servers plus ones an explicit tool selection covers — and the
/// pool tracks exactly those names as in-flight, so "connecting" never has
/// to be inferred from "enabled but unconnected".
#[test]
fn lazy_boot_scopes_pending_connects_and_tracks_in_flight() {
let mut required_cfg = test_server_config();
required_cfg.required = true;
let mut pool = McpPool::new(McpConfig {
timeouts: McpTimeouts::default(),
servers: HashMap::from([
("needed".to_string(), required_cfg),
("selected".to_string(), test_server_config()),
("lazy".to_string(), test_server_config()),
]),
});
let requested = vec!["mcp_selected_read".to_string()];
let eager = pool.eager_boot_server_names(&requested);
assert_eq!(
eager,
HashSet::from(["needed".to_string(), "selected".to_string()]),
"the eager set is required servers plus selection-covered ones"
);
let (pending, errors) = pool.collect_pending_connects(Some(&eager));
assert!(errors.is_empty());
let pending_names: HashSet<String> = pending.iter().map(|(name, _)| name.clone()).collect();
assert_eq!(pending_names, eager);
assert_eq!(
pool.connecting_servers()
.into_iter()
.collect::<HashSet<_>>(),
pending_names,
"in-flight marks must name exactly the spawned connects"
);
// A lazy server is neither spawned nor reported connecting.
let (pending, errors) = pool.collect_pending_connects(Some(&eager));
assert!(
pending.is_empty() && errors.is_empty(),
"an in-flight name is not re-queued by a second scoped pass"
);
// Explicit selection is intent: the lazy server starts on demand and is
// marked in-flight while it does.
let (pending, errors) = pool.take_pending_connects_for(&["lazy".to_string()]);
assert!(errors.is_empty());
assert_eq!(pending.len(), 1);
assert!(pool.connecting_servers().contains(&"lazy".to_string()));
// Aborting clears the marks without touching connection state.
pool.cancel_connecting(&HashSet::from([
"needed".to_string(),
"selected".to_string(),
"lazy".to_string(),
]));
assert!(pool.connecting_servers().is_empty());
}
/// Selection coverage shared by lazy boot and the per-turn wait: exact
/// `mcp_<server>_<tool>` names and `mcp_<prefix>*` globs both count.
#[test]
fn tool_selection_covers_exact_names_and_globs() {
let selected = vec![
"mcp_fs_read".to_string(),
"mcp_git_*".to_string(),
"shell".to_string(),
];
assert!(tool_selection_covers_server(&selected, "fs"));
assert!(tool_selection_covers_server(&selected, "git_status"));
assert!(!tool_selection_covers_server(&selected, "slack"));
// A prefix glob reaches every server whose `mcp_<server>_` namespace
// starts with it: `mcp_gi*` covers `git` and `gitea` alike.
let glob = vec!["mcp_gi*".to_string()];
assert!(tool_selection_covers_server(&glob, "git"));
assert!(tool_selection_covers_server(&glob, "gitea"));
assert!(!tool_selection_covers_server(&glob, "fs"));
}
#[test]
fn connect_backoff_doubles_then_holds_at_the_ceiling() {
use std::time::Duration;
@@ -7692,7 +7772,7 @@ async fn mcp_ceiling_denied_server_is_absent_across_cached_boot_meta_auth_and_ru
pool.authenticate_tool_target("mcp_private_a_authenticate")
.is_none()
);
let (pending, errors) = pool.collect_pending_connects();
let (pending, errors) = pool.collect_pending_connects(None);
assert!(pending.is_empty() && errors.is_empty());
assert!(
pool.connect_all().await.is_empty(),
+86 -396
View File
@@ -1,24 +1,16 @@
//! MCP server implementation for exposing Codewhale tools over stdio.
use std::collections::{HashMap, HashSet};
use std::io::{self, BufRead, Write};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::runtime::Runtime;
use uuid::Uuid;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use crate::client::DeepSeekClient;
use crate::config::Config;
use crate::llm_client::LlmClient;
use crate::session_manager::SessionManager;
use crate::tools::spec::{ToolError, ToolResult};
use crate::tools::{ToolContext, ToolRegistryBuilder};
use codewhale_models::Role;
use codewhale_models::{ContentBlock, Message, MessageRequest};
#[derive(Debug, Default, Deserialize)]
struct McpServerConfigFile {
@@ -71,20 +63,14 @@ struct ExposedTool {
internal: String,
}
fn mcp_request_model(arguments: &Value, default_model: &str) -> String {
arguments
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
.unwrap_or(default_model)
.to_string()
}
pub fn run_mcp_server(workspace: PathBuf) -> Result<()> {
let settings = McpServerSettings::load()?;
pub async fn run_mcp_server(workspace: PathBuf) -> Result<()> {
// Settings load is a synchronous config read; keep it off the async
// worker per the blocking-call convention.
let settings = tokio::task::spawn_blocking(McpServerSettings::load)
.await
.context("MCP server settings task failed")??;
let mut server = McpServer::new(workspace, settings)?;
server.run()
server.run().await
}
struct McpServer {
@@ -92,11 +78,6 @@ struct McpServer {
registry: crate::tools::ToolRegistry,
exposed_tools: Vec<ExposedTool>,
require_approval: bool,
/// Thread-based conversation state for deepseek/deepseek-reply tools.
/// Maps thread_id -> ordered list of messages in the conversation.
threads: Arc<Mutex<HashMap<String, Vec<Message>>>>,
/// Monotonic request counter for notification correlation.
next_notification_id: u64,
}
impl McpServer {
@@ -126,18 +107,18 @@ impl McpServer {
registry,
exposed_tools,
require_approval: settings.require_approval,
threads: Arc::new(Mutex::new(HashMap::new())),
next_notification_id: 0,
})
}
fn run(&mut self) -> Result<()> {
let runtime = Runtime::new().context("Failed to start MCP runtime")?;
let stdin = io::stdin();
let mut stdout = io::stdout();
/// The serialized stdio loop runs on the caller's runtime: a JSON-RPC
/// stdio server answers one request at a time by definition, so it
/// needs no private `Runtime` and no `block_on` (#6140).
async fn run(&mut self) -> Result<()> {
let stdin = tokio::io::BufReader::new(tokio::io::stdin());
let mut stdout = tokio::io::stdout();
let mut lines = stdin.lines();
for line in stdin.lock().lines() {
let line = line?;
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
@@ -146,17 +127,18 @@ impl McpServer {
continue;
};
if let Some(response) = self.handle_message(&runtime, message) {
if let Some(response) = self.handle_message(message).await {
let payload = serde_json::to_string(&response)?;
writeln!(stdout, "{payload}")?;
stdout.flush()?;
stdout.write_all(payload.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
Ok(())
}
fn handle_message(&mut self, runtime: &Runtime, message: Value) -> Option<Value> {
async fn handle_message(&mut self, message: Value) -> Option<Value> {
let method = message.get("method").and_then(Value::as_str)?;
let id = message.get("id").cloned();
@@ -165,12 +147,12 @@ impl McpServer {
"tools/list" => respond(id.as_ref(), self.list_tools_response()),
"tools/call" => {
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
match self.call_tool(runtime, params, id.clone()) {
match self.call_tool(params).await {
Ok(result) => respond(id.as_ref(), result),
Err(err) => respond_error(id.as_ref(), err.code, err.message),
}
}
"resources/list" => respond(id.as_ref(), self.list_resources_response()),
"resources/list" => respond(id.as_ref(), self.list_resources_response().await),
"ping" => respond(id.as_ref(), json!({})),
"notifications/initialized" => None,
_ => respond_error(id.as_ref(), -32601, format!("Method not found: {method}")),
@@ -184,64 +166,12 @@ impl McpServer {
if !seen.insert(entry.public.clone()) {
continue;
}
match entry.internal.as_str() {
"deepseek" => {
tools.push(json!({
"name": "deepseek",
"description": "Send a prompt to Codewhale and get a response. Creates a new conversation thread.",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The user prompt to send to Codewhale"
},
"model": {
"type": "string",
"description": "Optional model identifier. When omitted, uses the active provider's default model."
},
"cwd": {
"type": "string",
"description": "Optional working directory context"
}
},
"required": ["prompt"]
}
}));
}
"deepseek-reply" => {
tools.push(json!({
"name": "deepseek-reply",
"description": "Continue an existing conversation thread with Codewhale. Requires a thread_id from a previous deepseek call.",
"inputSchema": {
"type": "object",
"properties": {
"thread_id": {
"type": "string",
"description": "Thread ID from a previous deepseek call"
},
"prompt": {
"type": "string",
"description": "The follow-up prompt"
},
"model": {
"type": "string",
"description": "Optional model override"
}
},
"required": ["thread_id", "prompt"]
}
}));
}
_ => {
if let Some(tool) = self.registry.get(&entry.internal) {
tools.push(json!({
"name": entry.public,
"description": tool.description(),
"inputSchema": tool.input_schema(),
}));
}
}
if let Some(tool) = self.registry.get(&entry.internal) {
tools.push(json!({
"name": entry.public,
"description": tool.description(),
"inputSchema": tool.input_schema(),
}));
}
}
// MCP spec: `nextCursor` must be omitted (or be a string) when there
@@ -250,7 +180,7 @@ impl McpServer {
json!({ "tools": tools })
}
fn list_resources_response(&self) -> Value {
async fn list_resources_response(&self) -> Value {
let mut resources = Vec::new();
resources.push(json!({
"uri": format!("file://{}", self.workspace.display()),
@@ -259,17 +189,22 @@ impl McpServer {
"mimeType": "inode/directory",
}));
if let Ok(manager) = SessionManager::default_location()
&& let Ok(sessions) = manager.list_sessions()
{
for session in sessions {
resources.push(json!({
"uri": format!("deepseek://session/{}", session.id),
"name": session.title,
"description": format!("{} messages", session.message_count),
"mimeType": "application/json",
}));
}
// `SessionManager` does synchronous filesystem work; the listing is
// a borrow-free unit so it can run on the blocking pool.
let sessions = tokio::task::spawn_blocking(|| {
SessionManager::default_location().and_then(|manager| manager.list_sessions())
})
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default();
for session in sessions {
resources.push(json!({
"uri": format!("codewhale://session/{}", session.id),
"name": session.title,
"description": format!("{} messages", session.message_count),
"mimeType": "application/json",
}));
}
// Same spec point as `list_tools_response`: omit `nextCursor` when
@@ -277,12 +212,7 @@ impl McpServer {
json!({ "resources": resources })
}
fn call_tool(
&mut self,
runtime: &Runtime,
params: Value,
request_id: Option<Value>,
) -> Result<Value, RpcError> {
async fn call_tool(&mut self, params: Value) -> Result<Value, RpcError> {
let params = params.as_object().ok_or_else(|| RpcError {
code: -32602,
message: "Invalid params for tools/call".to_string(),
@@ -317,217 +247,13 @@ impl McpServer {
message: format!("Tool not exposed: {name}"),
})?;
// Handle deepseek and deepseek-reply natively
if internal == "deepseek" || internal == "deepseek-reply" {
let arguments = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
return self.handle_deepseek_call(runtime, &internal, &arguments, request_id);
}
let arguments = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let result = runtime.block_on(self.registry.execute_full(&internal, arguments));
let result = self.registry.execute_full(&internal, arguments).await;
Ok(tool_result_to_mcp(result))
}
/// Handle a `deepseek` or `deepseek-reply` tool call.
///
/// Uses `DeepSeekClient` directly (not the full engine) to send a prompt
/// and return the response. For `deepseek` a new thread is created; for
/// `deepseek-reply` the caller supplies a `thread_id` to continue an
/// existing conversation.
fn handle_deepseek_call(
&mut self,
runtime: &Runtime,
internal_name: &str,
arguments: &Value,
request_id: Option<Value>,
) -> Result<Value, RpcError> {
let prompt = arguments
.get("prompt")
.and_then(Value::as_str)
.ok_or_else(|| RpcError {
code: -32602,
message: "Missing required argument: prompt".to_string(),
})?;
// Load config first so an omitted model follows the active provider
// default instead of a hardcoded DeepSeek id.
let config = Config::load(None, None).map_err(|e| RpcError {
code: -32000,
message: format!("Failed to load config: {e}"),
})?;
let default_model = config.default_model();
let model = mcp_request_model(arguments, &default_model);
// Resolve thread_id
let thread_id = if internal_name == "deepseek" {
// New thread
Uuid::new_v4().to_string()
} else {
arguments
.get("thread_id")
.and_then(Value::as_str)
.ok_or_else(|| RpcError {
code: -32602,
message: "Missing required argument: thread_id for deepseek-reply".to_string(),
})?
.to_string()
};
let client = DeepSeekClient::new(&config).map_err(model_client_init_error)?;
// Build message list
let user_message = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: prompt.to_string(),
cache_control: None,
}],
};
let messages = if internal_name == "deepseek" {
vec![user_message]
} else {
let thread = self.threads.lock().unwrap_or_else(|e| e.into_inner());
let mut existing = thread.get(&thread_id).cloned().ok_or_else(|| RpcError {
code: -32602,
message: format!("Thread not found: {thread_id}"),
})?;
existing.push(user_message);
existing
};
// Send the API request (non-streaming for the basic version). Internal
// chat uses the same resolved output policy as an ordinary turn.
let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
let request = MessageRequest {
model: model.clone(),
messages: messages.clone(),
max_tokens: client.effective_max_output_tokens(&request_route.model),
system: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: None,
stream: None,
temperature: None,
top_p: None,
};
let response = runtime
.block_on(client.create_message(request))
.map_err(model_provider_call_error)?;
// A provider-declared incomplete reply must not enter the stored
// thread or be returned as a successful answer. The billed usage is
// still reported in the error payload.
if codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
let error = format!(
"Model response incomplete: provider stop reason `{}`; the partial reply was not accepted.",
codewhale_models::stop_reason_detail(response.stop_reason.as_deref())
);
return Ok(json!({
"content": [{ "type": "text", "text": &error }],
"isError": true,
"structuredContent": {
"threadId": thread_id,
"error": error,
"usage": {
"inputTokens": response.usage.input_tokens,
"outputTokens": response.usage.output_tokens,
}
}
}));
}
// Extract response text from content blocks
let response_text = response
.content
.iter()
.filter_map(|block| {
if let ContentBlock::Text { text, .. } = block {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
let usage = &response.usage;
// Store the assistant response in the thread
{
let mut thread = self.threads.lock().unwrap_or_else(|e| e.into_inner());
let convo = thread.entry(thread_id.clone()).or_default();
// If deepseek, we already have just the user message; if deepseek-reply,
// the user message was appended to the cloned messages above but we need
// to also append it to the stored thread and then the assistant response.
if internal_name == "deepseek" {
convo.push(Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: prompt.to_string(),
cache_control: None,
}],
});
}
convo.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::Text {
text: response_text.clone(),
cache_control: None,
}],
});
}
// Emit a notification/message so the client can correlate the response
let notification_id = {
let nid = self.next_notification_id;
self.next_notification_id += 1;
nid
};
// Write notification to stdout
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/message",
"params": {
"notificationId": notification_id,
"requestId": request_id,
"threadId": thread_id,
"content": response_text,
"usage": {
"inputTokens": usage.input_tokens,
"outputTokens": usage.output_tokens,
}
}
});
if let Ok(payload) = serde_json::to_string(&notification) {
let mut stdout = io::stdout();
let _ = writeln!(stdout, "{payload}");
let _ = stdout.flush();
}
Ok(json!({
"content": [{ "type": "text", "text": &response_text }],
"isError": false,
"structuredContent": {
"threadId": thread_id,
"content": response_text,
"usage": {
"inputTokens": usage.input_tokens,
"outputTokens": usage.output_tokens,
}
}
}))
}
}
fn default_config_path() -> Option<PathBuf> {
@@ -541,8 +267,6 @@ fn default_expose_tools() -> Vec<String> {
"search".to_string(),
"apply_patch".to_string(),
"shell".to_string(),
"deepseek".to_string(),
"deepseek-reply".to_string(),
]
}
@@ -561,8 +285,6 @@ fn build_exposed_tools(names: &[String]) -> Vec<ExposedTool> {
"shell" => "exec_shell",
"search" => "grep_files",
"file_search" => "file_search",
// deepseek and deepseek-reply are handled natively in call_tool
"deepseek" | "deepseek-reply" => trimmed,
other => other,
}
.to_string();
@@ -624,20 +346,6 @@ struct RpcError {
message: String,
}
fn model_client_init_error(error: impl std::fmt::Display) -> RpcError {
RpcError {
code: -32000,
message: format!("Failed to create Codewhale model client: {error}"),
}
}
fn model_provider_call_error(error: impl std::fmt::Display) -> RpcError {
RpcError {
code: -32000,
message: format!("Model provider call failed: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -670,13 +378,13 @@ mod tests {
assert_eq!(map.get("shell").map(String::as_str), Some("exec_shell"));
}
#[test]
fn list_responses_omit_null_next_cursor() {
#[tokio::test]
async fn list_responses_omit_null_next_cursor() {
// MCP spec: `nextCursor` must be omitted (or be a string) when there
// are no further pages. Emitting `null` breaks strict clients such as
// Claude Code, which validate the response shape.
let settings = McpServerSettings {
expose_tools: vec!["deepseek".to_string(), "apply_patch".to_string()],
expose_tools: vec!["file_read".to_string(), "apply_patch".to_string()],
require_approval: false,
};
let server = McpServer::new(PathBuf::from("."), settings).expect("build server");
@@ -691,7 +399,7 @@ mod tests {
"tools/list must omit nextCursor when there are no more pages"
);
let resources_value = server.list_resources_response();
let resources_value = server.list_resources_response().await;
let resources = resources_value
.as_object()
.expect("resources/list response is an object");
@@ -702,6 +410,39 @@ mod tests {
);
}
#[tokio::test]
async fn retired_deepseek_tools_are_not_exposed() {
// #6140: the `deepseek`/`deepseek-reply` tools called a provider
// client directly — a second model authority beside the engine.
// Configs still naming them degrade to "tool not exposed" rather
// than silently running.
let settings = McpServerSettings {
expose_tools: vec!["deepseek".to_string(), "deepseek-reply".to_string()],
require_approval: false,
};
let mut server = McpServer::new(PathBuf::from("."), settings).expect("build server");
let tools = server.list_tools_response();
assert_eq!(
tools["tools"].as_array().map(Vec::len),
Some(0),
"retired tools must not be advertised: {tools}"
);
let response = server
.handle_message(json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "deepseek", "arguments": {"prompt": "hi"}}
}))
.await;
// The name resolves through `exposed_tools` but no registry tool
// backs it, so the call answers with an isError result.
let response = response.expect("tools/call responds");
assert_eq!(response["result"]["isError"], json!(true), "{response}");
}
#[test]
fn initialize_uses_standard_mcp_shape_and_codewhale_identity() {
let response = initialize_response();
@@ -710,55 +451,4 @@ mod tests {
assert_eq!(response["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
assert!(response["capabilities"]["tools"].is_object());
}
#[test]
fn model_failures_use_codewhale_provider_neutral_language() {
let init = model_client_init_error("missing credential");
assert_eq!(init.code, -32000);
assert_eq!(
init.message,
"Failed to create Codewhale model client: missing credential"
);
let request = model_provider_call_error("route unavailable");
assert_eq!(request.code, -32000);
assert_eq!(
request.message,
"Model provider call failed: route unavailable"
);
assert!(!init.message.contains("DeepSeek"));
assert!(!request.message.contains("DeepSeek"));
}
#[test]
fn omitted_mcp_model_follows_the_active_provider_default() {
assert_eq!(mcp_request_model(&json!({}), "gpt-5.6"), "gpt-5.6");
assert_eq!(
mcp_request_model(&json!({ "model": " " }), "GLM-5.3"),
"GLM-5.3"
);
assert_eq!(
mcp_request_model(&json!({ "model": "kimi-k2.5" }), "gpt-5.6"),
"kimi-k2.5"
);
}
#[test]
fn list_tools_does_not_hardcode_a_deepseek_default_model() {
let settings = McpServerSettings {
expose_tools: vec!["deepseek".to_string()],
require_approval: false,
};
let server = McpServer::new(PathBuf::from("."), settings).expect("build server");
let tools = server.list_tools_response();
let description = tools["tools"][0]["inputSchema"]["properties"]["model"]["description"]
.as_str()
.expect("model description");
assert!(
!description.to_ascii_lowercase().contains("deepseek"),
"{description}"
);
assert!(description.contains("active provider"), "{description}");
}
}
+1 -1
View File
@@ -1679,7 +1679,7 @@ pub struct OwnedOAuthCredentials {
/// Receipt for a committed Codewhale-owned OAuth generation.
pub struct OAuthActivation {
#[allow(dead_code)]
#[expect(dead_code)]
pub credentials: OwnedOAuthCredentials,
pub config_path: PathBuf,
pub auth_path: PathBuf,
+1 -2
View File
@@ -50,7 +50,6 @@ pub struct CostEstimate {
}
impl CostEstimate {
#[allow(dead_code)]
pub fn usd_only(usd: f64) -> Self {
Self { usd, cny: 0.0 }
}
@@ -113,7 +112,7 @@ impl CostEstimate {
/// mapped onto [`BalanceInfo`] at the fetch seam.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceResponse {
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub is_available: bool,
pub balance_infos: Vec<BalanceInfo>,
}
+514 -1
View File
@@ -180,7 +180,6 @@ impl ForeignInstructionImports {
}
/// Enabled format keys, for provenance and diagnostics.
#[cfg(test)]
#[must_use]
pub fn keys(&self) -> Vec<&'static str> {
self.enabled.iter().copied().collect()
@@ -1296,6 +1295,417 @@ Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore
Ok(agents_path)
}
// === Effective instruction-source listing (#6168) ===
/// Which layer of the instruction stack a source belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstructionSourceKind {
/// Chain candidate (`AGENTS.md`, `.codewhale/instructions.md`, opted-in
/// foreign files) in a repository-root → workspace scope directory.
Project,
/// `.md` file inside a rules directory (`.codewhale/rules`, opted-in
/// foreign rules dirs) at workspace scope.
Rule,
/// User-level fallback (`~/.codewhale/AGENTS.md` and friends).
Global,
/// File selected by the bounded foreign-fragment loader
/// (`codewhale_core::fragments`) for an opted-in format.
Fragment,
/// Configured `instructions = [...]` file (#454).
Configured,
/// `.codewhale/constitution.json` authority policy.
Constitution,
/// Present but deliberately never loaded (deprecated `WHALE.md`).
Ignored,
}
/// Whether the prompt assembly actually consumed the file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstructionSourceStatus {
/// Read and merged into the assembled instructions.
Loaded,
/// Exists, but a higher-priority candidate in the same scope won.
Shadowed,
/// Exists but the loader refused, could not read, or skipped it
/// (empty, oversized, over the per-directory rules cap).
Skipped,
/// Candidate path the loader checks; nothing is there.
Missing,
}
/// One instruction-source candidate or loaded file, for the
/// workspace-instructions listing route.
#[derive(Debug, Clone)]
pub struct InstructionSourceInfo {
pub kind: InstructionSourceKind,
/// Scope directory the candidate belongs to: the chain directory for
/// `Project`, the workspace for `Rule`/`Fragment`/`Configured`, the home
/// directory for `Global`.
pub scope_dir: PathBuf,
/// Candidate or actual file path, as the loader resolves it.
pub path: PathBuf,
/// `symlink_metadata` saw a file or symlink (same check the loader runs).
pub exists: bool,
pub status: InstructionSourceStatus,
/// File size in bytes when it could be stat'd.
pub bytes: Option<u64>,
/// Loader warning produced for this path, when one exists.
pub warning: Option<String>,
}
fn file_len(path: &Path) -> Option<u64> {
fs::metadata(path).ok().map(|m| m.len())
}
fn push_candidate_entry(
sources: &mut Vec<InstructionSourceInfo>,
kind: InstructionSourceKind,
scope_dir: &Path,
path: PathBuf,
scope_loaded: &mut bool,
load: impl FnOnce(&Path) -> Result<String, ProjectContextError>,
) {
let exists = context_candidate_exists(&path);
let (status, warning) = if !exists {
(InstructionSourceStatus::Missing, None)
} else if *scope_loaded {
(InstructionSourceStatus::Shadowed, None)
} else {
match load(&path) {
Ok(_) => {
*scope_loaded = true;
(InstructionSourceStatus::Loaded, None)
}
Err(error) => (InstructionSourceStatus::Skipped, Some(error.to_string())),
}
};
sources.push(InstructionSourceInfo {
kind,
scope_dir: scope_dir.to_path_buf(),
exists,
bytes: file_len(&path),
path,
status,
warning,
});
}
/// Enumerate the effective instruction sources for `workspace` (#6168).
///
/// Mirrors the loaders above — same candidate order, same existence and
/// readability checks — so the listing reports what prompt assembly actually
/// picks up: precedence, shadowing, opt-in imports, and refusal warnings.
/// Read-only; it never creates or modifies files. `configured` is the
/// resolved `instructions = [...]` array (`Config::instructions_paths`);
/// `home_dir` gates the global layer exactly as
/// `load_project_context_with_parents_and_home` does.
///
/// Known limit: statuses describe file selection, not the aggregate byte
/// budget — `enforce_project_instruction_budget` can still trim loaded
/// content at render time without changing a source's `Loaded` status.
#[must_use]
pub fn project_instruction_sources(
workspace: &Path,
home_dir: Option<&Path>,
configured: &[PathBuf],
) -> Vec<InstructionSourceInfo> {
let imports = &foreign_instruction_imports();
let workspace = canonicalize_workspace_or_keep(workspace);
let mut sources = Vec::new();
// Repository-root → workspace instruction chain. Same dir order the
// loader assembles, so wider scopes list first.
for dir in context_chain_dirs(&workspace, home_dir) {
let whale = dir.join(DEPRECATED_WHALE_FILENAME);
if context_candidate_exists(&whale) {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Ignored,
scope_dir: dir.clone(),
path: whale.clone(),
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: file_len(&whale),
warning: Some(WHALE_IGNORED_WARNING.to_string()),
});
}
let mut scope_loaded = false;
for filename in context_files_for(imports) {
push_candidate_entry(
&mut sources,
InstructionSourceKind::Project,
&dir,
dir.join(filename),
&mut scope_loaded,
load_context_file,
);
}
}
// Workspace-scope rules directories — the chain loader never reads
// ancestor rules, so only the workspace contributes them.
for rules_dir_name in rules_dirs_for(imports) {
let rules_dir = workspace.join(rules_dir_name);
if fs::symlink_metadata(&rules_dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
// Same refusal as `load_rules_from_dir`, surfaced rather than
// logged-and-dropped.
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Rule,
scope_dir: workspace.clone(),
path: rules_dir,
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: None,
warning: Some("refusing symlinked rules directory".to_string()),
});
continue;
}
let Ok(dir_entries) = fs::read_dir(&rules_dir) else {
continue;
};
let mut file_paths: Vec<PathBuf> = dir_entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(path)
})
.collect();
file_paths.sort_by(|a, b| {
a.file_name()
.unwrap_or_default()
.cmp(b.file_name().unwrap_or_default())
});
for (index, path) in file_paths.into_iter().enumerate() {
let (status, warning) = if index >= MAX_RULES_FILES {
(
InstructionSourceStatus::Skipped,
Some(format!(
"beyond the per-directory rules cap of {MAX_RULES_FILES} files"
)),
)
} else {
match load_context_file(&path) {
Ok(_) => (InstructionSourceStatus::Loaded, None),
Err(error) => (InstructionSourceStatus::Skipped, Some(error.to_string())),
}
};
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Rule,
scope_dir: workspace.clone(),
path: path.clone(),
exists: true,
status,
bytes: file_len(&path),
warning,
});
}
}
// User-level fallback layer.
if let Some(home) = home_dir {
for relative in legacy_global_whale_relative_paths() {
let path = join_relative_components(home, relative);
if context_candidate_exists(&path) {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Ignored,
scope_dir: home.to_path_buf(),
path: path.clone(),
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: file_len(&path),
warning: Some(WHALE_IGNORED_WARNING.to_string()),
});
}
}
let mut scope_loaded = false;
for relative in global_context_relative_paths() {
push_candidate_entry(
&mut sources,
InstructionSourceKind::Global,
home,
join_relative_components(home, relative),
&mut scope_loaded,
load_global_context_file,
);
}
}
// Opted-in foreign fragments — enumerate the declared candidates, then
// mark the files the bounded loader's own selection walk picks.
let fragment_candidates = fragment_candidates_for(imports);
let selected: std::collections::BTreeSet<PathBuf> =
codewhale_core::fragments::selected_project_instruction_candidate_files(
&workspace,
&fragment_candidates,
)
.into_iter()
.collect();
let mut emitted = std::collections::BTreeSet::new();
for candidate in &fragment_candidates {
let path = workspace.join(candidate);
if !path.exists() {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path,
exists: false,
status: InstructionSourceStatus::Missing,
bytes: None,
warning: None,
});
continue;
}
// A directory candidate's loadable files are the selected entries
// underneath it; report those rather than the directory itself.
let members: Vec<PathBuf> = selected
.iter()
.filter(|file| file.starts_with(&path))
.cloned()
.collect();
if path.is_dir() {
if members.is_empty() {
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path,
exists: true,
status: InstructionSourceStatus::Skipped,
bytes: None,
warning: Some(
"no loadable .md files selected under this directory".to_string(),
),
});
}
for file in members {
emitted.insert(file.clone());
let loaded = fs::read_to_string(&file)
.map(|content| !content.trim().is_empty())
.unwrap_or(false);
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path: file.clone(),
exists: true,
status: if loaded {
InstructionSourceStatus::Loaded
} else {
InstructionSourceStatus::Skipped
},
bytes: file_len(&file),
warning: None,
});
}
} else {
emitted.insert(path.clone());
let selected_file = selected.contains(&path);
let loaded = selected_file
&& fs::read_to_string(&path)
.map(|content| !content.trim().is_empty())
.unwrap_or(false);
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path: path.clone(),
exists: true,
status: if loaded {
InstructionSourceStatus::Loaded
} else if selected_file {
InstructionSourceStatus::Skipped
} else {
// Exists but not selected — e.g. a symlink the walk refuses.
InstructionSourceStatus::Skipped
},
bytes: file_len(&path),
warning: if selected_file && !loaded {
Some("selected but empty or unreadable".to_string())
} else {
None
},
});
}
}
// Selected files not attributed to a listed candidate (nested rules dir
// members) still get reported.
for file in selected.iter().filter(|file| !emitted.contains(*file)) {
let loaded = fs::read_to_string(file)
.map(|content| !content.trim().is_empty())
.unwrap_or(false);
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Fragment,
scope_dir: workspace.clone(),
path: file.clone(),
exists: true,
status: if loaded {
InstructionSourceStatus::Loaded
} else {
InstructionSourceStatus::Skipped
},
bytes: file_len(file),
warning: None,
});
}
// Configured `instructions = [...]` files (#454) — resolved paths, in
// declared order. The renderer skips missing/empty files with a warning.
for path in configured {
let exists = context_candidate_exists(path);
let (status, warning) = match fs::read_to_string(path) {
Ok(content) if !content.trim().is_empty() => (InstructionSourceStatus::Loaded, None),
Ok(_) => (
InstructionSourceStatus::Skipped,
Some("empty instructions file".to_string()),
),
Err(error) => (
InstructionSourceStatus::Skipped,
Some(format!("unreadable instructions file: {error}")),
),
};
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Configured,
scope_dir: workspace.clone(),
path: path.clone(),
exists,
status,
bytes: file_len(path),
warning,
});
}
// `.codewhale/constitution.json`, workspace → repository root. The loader
// stops at the first existing candidate whether it parses or not, so
// later candidates — even ones that exist — are never evaluated.
let (_block, constitution_source, _warnings) = load_repo_constitution_block(&workspace);
let mut seen_existing = false;
for path in repo_constitution_candidate_paths(&workspace) {
let exists = context_candidate_exists(&path);
let status = if !exists {
InstructionSourceStatus::Missing
} else if seen_existing {
InstructionSourceStatus::Shadowed
} else {
seen_existing = true;
if constitution_source.as_deref() == Some(path.as_path()) {
InstructionSourceStatus::Loaded
} else {
InstructionSourceStatus::Skipped
}
};
sources.push(InstructionSourceInfo {
kind: InstructionSourceKind::Constitution,
scope_dir: workspace.clone(),
path: path.clone(),
exists,
status,
bytes: file_len(&path),
warning: None,
});
}
sources
}
// === Unit Tests ===
#[cfg(test)]
@@ -2852,4 +3262,107 @@ mod tests {
instructions.len()
);
}
#[test]
fn instruction_sources_report_precedence_shadowing_and_ignored_files() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join(".git")).expect("mkdir git");
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
fs::write(root.join("AGENTS.md"), "primary\n").expect("agents");
fs::create_dir_all(root.join(".codewhale")).expect("mkdir codewhale");
fs::write(
root.join(".codewhale").join("instructions.md"),
"secondary\n",
)
.expect("workspace instructions");
fs::write(root.join("WHALE.md"), "deprecated\n").expect("whale");
fs::create_dir_all(root.join(".codewhale").join("rules")).expect("mkdir rules");
fs::write(
root.join(".codewhale").join("rules").join("style.md"),
"keep it small\n",
)
.expect("rule");
let home = tmp.path().join("home");
fs::create_dir_all(&home).expect("mkdir home");
let configured = vec![root.join("team").join("extra.md")];
fs::create_dir_all(root.join("team")).expect("mkdir team");
fs::write(root.join("team").join("extra.md"), "configured\n").expect("configured");
let sources = project_instruction_sources(root, Some(&home), &configured);
let find = |path_suffix: &str| {
sources
.iter()
.find(|source| source.path.ends_with(path_suffix))
.unwrap_or_else(|| panic!("missing entry for {path_suffix}: {sources:?}"))
};
let agents = find("AGENTS.md");
assert_eq!(agents.kind, InstructionSourceKind::Project);
assert_eq!(agents.status, InstructionSourceStatus::Loaded);
assert_eq!(agents.bytes, Some(8));
// The lower-priority candidate in the same scope exists but is
// shadowed, not loaded.
let secondary = find(".codewhale/instructions.md");
assert_eq!(secondary.status, InstructionSourceStatus::Shadowed);
let whale = find("WHALE.md");
assert_eq!(whale.kind, InstructionSourceKind::Ignored);
assert_eq!(whale.status, InstructionSourceStatus::Skipped);
assert!(whale.warning.is_some());
let rule = find("style.md");
assert_eq!(rule.kind, InstructionSourceKind::Rule);
assert_eq!(rule.status, InstructionSourceStatus::Loaded);
let configured_entry = find("extra.md");
assert_eq!(configured_entry.kind, InstructionSourceKind::Configured);
assert_eq!(configured_entry.status, InstructionSourceStatus::Loaded);
// Unchecked-but-real candidates are enumerated so clients can render
// the full precedence map.
assert!(
sources
.iter()
.any(|s| s.status == InstructionSourceStatus::Missing),
"missing candidates must appear: {sources:?}"
);
// Global candidates under the (empty) home are reported as missing.
assert!(
sources
.iter()
.any(|s| s.kind == InstructionSourceKind::Global
&& s.status == InstructionSourceStatus::Missing),
"global candidates must appear: {sources:?}"
);
}
#[test]
fn instruction_sources_mark_unreadable_winner_as_skipped_not_loaded() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join(".git")).expect("mkdir git");
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
// An empty AGENTS.md fails the load check, so the next candidate wins.
fs::write(root.join("AGENTS.md"), " \n").expect("empty agents");
fs::create_dir_all(root.join(".codewhale")).expect("mkdir codewhale");
fs::write(root.join(".codewhale").join("instructions.md"), "wins\n")
.expect("workspace instructions");
let sources = project_instruction_sources(root, None, &[]);
let agents = sources
.iter()
.find(|s| s.path.ends_with("AGENTS.md"))
.expect("agents entry");
assert_eq!(agents.status, InstructionSourceStatus::Skipped);
assert!(agents.warning.is_some(), "refusal must carry a warning");
let winner = sources
.iter()
.find(|s| s.path.ends_with(".codewhale/instructions.md"))
.expect("instructions entry");
assert_eq!(winner.status, InstructionSourceStatus::Loaded);
}
}
+2 -2
View File
@@ -286,14 +286,14 @@ impl std::ops::Deref for AppendLog {
/// Per-turn ephemeral data. Cleared at every turn boundary.
///
/// **Phase 1 scaffolding** — not yet wired into the engine request path.
#[cfg_attr(not(test), allow(dead_code))]
#[cfg_attr(not(test), expect(dead_code))]
#[derive(Debug, Clone, Default)]
pub struct TurnScratch {
pub working_set: Vec<String>,
pub user_message: Option<Message>,
}
#[cfg_attr(not(test), allow(dead_code))]
#[cfg_attr(not(test), expect(dead_code))]
impl TurnScratch {
pub fn new() -> Self {
Self::default()
+87
View File
@@ -45,6 +45,12 @@ pub struct PromptSessionContext<'a> {
/// Optional output-verbosity mode. `concise` appends a short output
/// discipline block; unset keeps the normal conversational prompt.
pub verbosity: Option<&'a str>,
/// One-line notice that a prior session in this workspace left a
/// recovery checkpoint (#5715). KV effect: frozen-prefix contributor —
/// computed at engine construction and stable for the session; absent
/// entirely when no interrupted session exists, so clean sessions share
/// the same prefix bytes.
pub recovery_hint: Option<&'a str>,
/// Restrict skill discovery to Codewhale-owned roots plus explicit
/// `skills_dir` configuration.
pub skills_scan_codewhale_only: bool,
@@ -68,6 +74,7 @@ impl Default for PromptSessionContext<'_> {
model_id: "codewhale",
context_window_override: None,
verbosity: None,
recovery_hint: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
mode: AppMode::Agent,
@@ -1019,6 +1026,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
model_id: "codewhale",
context_window_override: None,
verbosity: None,
recovery_hint: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
mode: AppMode::Agent,
@@ -1233,6 +1241,17 @@ pub(crate) fn system_prompt_for_mode_with_context_skills_session_and_approval_fo
goal_objective.trim()
));
}
// #5715: name an interrupted prior workspace session so the model can
// offer recovery. Session-pinned: absent entirely on clean sessions so
// they share identical prefix bytes.
if let Some(hint) = session_context.recovery_hint
&& !hint.trim().is_empty()
{
workspace_parts.push(format!(
"## Prior Session\n\n<session_recovery>\n{}\n</session_recovery>",
hint.trim()
));
}
let workspace_body = workspace_parts.join("\n\n");
// Permissions fragment: configured `instructions = [...]` files (#454).
@@ -1716,6 +1735,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
),
@@ -2139,6 +2159,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
),
@@ -2264,6 +2285,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
),
@@ -2311,6 +2333,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
),
@@ -2404,6 +2427,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -2585,6 +2609,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -2615,6 +2640,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -2659,6 +2685,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -2789,6 +2816,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -2820,6 +2848,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -3042,6 +3071,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -3076,6 +3106,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -3084,6 +3115,58 @@ mod tests {
assert!(!prompt.contains("## Current Goal"));
}
#[test]
fn recovery_hint_renders_only_when_present() {
// Prompt assembly reads env-dependent paths (skills, memory, session
// state); the byte-equality check must serialize against env-guard
// tests in the same binary.
let _env_guard = crate::test_support::lock_test_env();
let tmp = tempdir().expect("tempdir");
let build = |recovery_hint: Option<&str>| {
system_prompt_flat_text(&system_prompt_for_mode_with_context_skills_and_session(
tmp.path(),
None,
None,
None,
PromptSessionContext {
user_memory_block: None,
goal_objective: None,
project_context_pack_enabled: false,
locale_tag: "en",
translation_enabled: false,
model_id: "codewhale",
context_window_override: None,
verbosity: None,
recovery_hint,
skills_scan_codewhale_only: false,
plugin_registry: None,
mode: AppMode::Agent,
},
))
};
let hinted = build(Some(
"A previous session (\"fix\", id abc12345) has a recovery checkpoint",
));
assert!(hinted.contains("## Prior Session"));
assert!(hinted.contains("<session_recovery>"));
assert!(hinted.contains("recovery checkpoint"));
// Clean sessions share identical prefix bytes: no block, no heading.
let clean = build(None);
assert!(!clean.contains("## Prior Session"));
assert!(!clean.contains("session_recovery"));
let blank = build(Some(" "));
for (i, (a, b)) in clean.lines().zip(blank.lines()).enumerate() {
assert_eq!(a, b, "line {i} differs");
}
assert_eq!(
clean.lines().count(),
blank.lines().count(),
"line counts differ"
);
}
#[test]
fn universal_prompt_leaves_tool_selection_to_the_catalog() {
let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale");
@@ -3167,6 +3250,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
));
@@ -3690,6 +3774,7 @@ mod tests {
verbosity: Some(" Concise "),
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
),
@@ -3734,6 +3819,7 @@ mod tests {
verbosity: Some("concise"),
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
},
);
@@ -3787,6 +3873,7 @@ mod tests {
verbosity: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
recovery_hint: None,
mode: AppMode::Agent,
};
let first = system_prompt_for_mode_with_context_skills_session_and_approval(
+1 -1
View File
@@ -287,7 +287,7 @@ fn replace_provider_live_snapshot_for_owner(owner: LivePartitionOwner, snapshot:
/// Clear all live snapshots (both Models.dev and per-provider partitions).
/// Used by tests and shutdown paths that need a full reset.
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub fn clear_live_snapshot() {
if let Ok(mut guard) = LIVE_SNAPSHOT.write() {
guard.models_dev = None;
+2 -2
View File
@@ -52,9 +52,9 @@ pub enum RetryState {
/// pass can age it out automatically; today the engine clears it on
/// `TurnStarted`.
Failed {
#[allow(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
reason: String,
#[allow(dead_code)]
#[expect(dead_code)]
since: Instant,
},
}
+119 -1
View File
@@ -112,7 +112,9 @@ use self::sessions::{
use self::sessions::{messages_from_thread_detail, session_to_detail};
#[cfg(test)]
use self::workspace::collect_workspace_status;
use self::workspace::{collect_workspace_git_metadata, workspace_file_search, workspace_status};
use self::workspace::{
collect_workspace_git_metadata, workspace_file_search, workspace_instructions, workspace_status,
};
const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN";
const LEGACY_RUNTIME_TOKEN_ENV: &str = "DEEPSEEK_RUNTIME_TOKEN";
@@ -1091,6 +1093,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
)
.route("/v1/workspace/status", get(workspace_status))
.route("/v1/workspace/files/search", get(workspace_file_search))
.route("/v1/workspace/instructions", get(workspace_instructions))
.route("/v1/agent-runs", get(list_agent_runs))
.route("/v1/agent-runs/{run_id}", get(get_agent_run))
.route("/v1/fleet/profiles", get(list_fleet_profiles))
@@ -1202,6 +1205,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/tasks/{id}", get(get_task))
.route("/v1/tasks/{id}/cancel", post(cancel_task))
.route("/v1/skills", get(list_skills))
.route("/v1/commands", get(list_commands))
.route(
"/v1/skills/{name}",
post(set_skill_enabled).delete(uninstall_skill_api),
@@ -2855,6 +2859,120 @@ fn fleet_event_label(payload: &FleetWorkerEventPayload) -> String {
}
}
/// One entry in the served slash-command catalog (`GET /v1/commands`, #6178).
///
/// Clients use this to complete and validate input without duplicating the
/// registry: a `binding: "host"` row must never be submitted as a model
/// prompt, and a user command shadowing a builtin name wins that spelling.
#[derive(Debug, Serialize)]
struct CommandCatalogEntry {
name: String,
aliases: Vec<String>,
/// English source text; localizing is the client's surface.
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
usage: Option<String>,
/// Literal verbs declared by the usage line (`/goal <block|complete|…>`).
subcommands: Vec<String>,
takes_arguments: bool,
/// `builtin` is registered code; `user` expands a stored template.
kind: &'static str,
/// `host` runs locally and never reaches the model; `prompt` expands into
/// the request the model sees.
binding: &'static str,
/// `primary` | `advanced` | `compatibility` — builtins only; `hidden`
/// covers rows the product does not advertise anywhere.
#[serde(skip_serializing_if = "Option::is_none")]
discovery: Option<&'static str>,
hidden: bool,
/// User command holding this builtin's canonical name.
#[serde(skip_serializing_if = "Option::is_none")]
shadowed_by: Option<String>,
/// Alias spellings of this builtin taken by user commands.
#[serde(skip_serializing_if = "Vec::is_empty")]
shadowed_aliases: Vec<String>,
}
#[derive(Debug, Serialize)]
struct CommandsResponse {
commands: Vec<CommandCatalogEntry>,
}
fn command_catalog(
user_commands: &crate::commands::user_registry::UserCommandRegistry,
) -> Vec<CommandCatalogEntry> {
let mut commands = Vec::new();
for info in crate::commands::command_infos() {
let shadowed_by = user_commands
.get(info.name)
.map(|command| command.name.clone());
let shadowed_aliases = info
.aliases
.iter()
.filter(|alias| user_commands.get(alias).is_some())
.map(|alias| (*alias).to_string())
.collect();
commands.push(CommandCatalogEntry {
name: info.name.to_string(),
aliases: info
.aliases
.iter()
.map(|alias| (*alias).to_string())
.collect(),
summary: Some(
info.description_for(codewhale_localization::Locale::En)
.into_owned(),
),
usage: Some(info.usage.to_string()),
subcommands: crate::commands::traits::usage_subcommands(info.usage)
.iter()
.map(|token| (*token).to_string())
.collect(),
takes_arguments: crate::commands::user_registry::usage_describes_arguments(
info.name, info.usage,
),
kind: "builtin",
binding: "host",
discovery: Some(match info.discovery() {
crate::commands::traits::CommandDiscovery::Primary => "primary",
crate::commands::traits::CommandDiscovery::Advanced => "advanced",
crate::commands::traits::CommandDiscovery::Compatibility => "compatibility",
}),
hidden: crate::commands::traits::UNLISTED_COMMANDS.contains(&info.name),
shadowed_by,
shadowed_aliases,
});
}
for command in user_commands.iter() {
commands.push(CommandCatalogEntry {
name: command.name.clone(),
aliases: command.aliases.clone(),
summary: command.description.clone(),
usage: command.display_usage().map(str::to_string),
subcommands: Vec::new(),
takes_arguments: command.takes_arguments(),
kind: "user",
binding: "prompt",
discovery: None,
hidden: command.hidden,
shadowed_by: None,
shadowed_aliases: Vec::new(),
});
}
commands
}
async fn list_commands(
State(state): State<RuntimeApiState>,
) -> Result<Json<CommandsResponse>, ApiError> {
let commands = crate::commands::user_registry::with_registry_for_workspace(
Some(state.workspace.as_path()),
command_catalog,
);
Ok(Json(CommandsResponse { commands }))
}
async fn list_skills(
State(state): State<RuntimeApiState>,
) -> Result<Json<SkillsResponse>, ApiError> {
+3 -2
View File
@@ -294,8 +294,9 @@ pub(super) async fn resume_session_thread(
let manager = SessionManager::new(state.sessions_dir.clone())
.map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
let session = manager
.load_session(&id)
.map_err(|e| map_session_err(&id, e, "read"))?;
.resume_session(&id)
.map_err(|e| map_session_err(&id, e, "read"))?
.session;
// Validate imported image bytes before allocating a Runtime thread. This
// retains local history's existing bounds; invalid content cannot leave an
+16 -11
View File
@@ -1,6 +1,6 @@
use super::*;
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
use crate::core::ops::Op;
use crate::core::ops::{Op, TurnSpec};
use crate::runtime_threads::RuntimeEventRecord;
use crate::test_support::{EnvVarGuard, lock_test_env};
use anyhow::{Context, bail};
@@ -14,7 +14,9 @@ use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::sleep;
use uuid::Uuid;
mod command_catalog;
mod headless_catalog;
mod workspace_instructions;
/// Scale a wait budget for shared CI runners.
///
@@ -2856,7 +2858,7 @@ async fn compatibility_stream_closes_losslessly_across_replay_live_handoff() ->
let (release_overlap, wait_for_overlap_release) = oneshot::channel();
let (release_terminal, wait_for_terminal_release) = oneshot::channel();
let engine_task = tokio::spawn(async move {
if !matches!(rx_op.recv().await, Some(Op::SendMessage { .. })) {
if !matches!(rx_op.recv().await, Some(Op::SendMessage(TurnSpec { .. }))) {
return;
}
let _ = wait_for_overlap_release.await;
@@ -3059,7 +3061,10 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
let (submission_tx, submission_rx) = oneshot::channel();
let (release_completion, wait_for_completion_release) = oneshot::channel();
let engine_task = tokio::spawn(async move {
if !matches!(harness.rx_op.recv().await, Some(Op::SendMessage { .. })) {
if !matches!(
harness.rx_op.recv().await,
Some(Op::SendMessage(TurnSpec { .. }))
) {
bail!("compatibility interaction engine did not receive a prompt");
}
harness
@@ -3479,7 +3484,7 @@ async fn thread_endpoints_expose_lifecycle_contract() -> Result<()> {
tokio::spawn(async move {
while let Some(op) = rx_op.recv().await {
match op {
Op::SendMessage { .. } => {
Op::SendMessage(TurnSpec { .. }) => {
let _ = tx_event
.send(EngineEvent::TurnStarted {
turn_id: "mock_lifecycle".to_string(),
@@ -3658,7 +3663,7 @@ async fn turn_endpoint_operation_key_returns_original_and_conflicts_on_mismatch(
let tx_event = harness.tx_event.clone();
tokio::spawn(async move {
while let Some(op) = harness.rx_op.recv().await {
if !matches!(op, Op::SendMessage { .. }) {
if !matches!(op, Op::SendMessage(TurnSpec { .. })) {
continue;
}
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@@ -3797,7 +3802,7 @@ async fn turn_operation_lookup_is_authenticated_read_only_and_survives_restart()
tokio::time::timeout(ci_scaled(Duration::from_secs(2)), engine.rx_op.recv())
.await?
.context("accepted mock Engine operation")?,
Op::SendMessage { .. }
Op::SendMessage(TurnSpec { .. })
));
let endpoint = format!(
@@ -3975,7 +3980,7 @@ async fn events_endpoint_respects_since_seq_cursor() -> Result<()> {
let mut rx_op = harness.rx_op;
let tx_event = harness.tx_event;
tokio::spawn(async move {
if !matches!(rx_op.recv().await, Some(Op::SendMessage { .. })) {
if !matches!(rx_op.recv().await, Some(Op::SendMessage(TurnSpec { .. }))) {
return;
}
let _ = tx_event
@@ -4388,7 +4393,7 @@ async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> {
let tx_event = harness.tx_event;
let cancel_token = harness.cancel_token;
tokio::spawn(async move {
if !matches!(rx_op.recv().await, Some(Op::SendMessage { .. })) {
if !matches!(rx_op.recv().await, Some(Op::SendMessage(TurnSpec { .. }))) {
return;
}
let _ = tx_event
@@ -4851,7 +4856,7 @@ async fn stream_endpoint_remains_backward_compatible() -> Result<()> {
let mut rx_op = harness.rx_op;
let tx_event = harness.tx_event;
tokio::spawn(async move {
if !matches!(rx_op.recv().await, Some(Op::SendMessage { .. })) {
if !matches!(rx_op.recv().await, Some(Op::SendMessage(TurnSpec { .. }))) {
return;
}
let _ = tx_event
@@ -6066,7 +6071,7 @@ async fn session_create_from_thread_rejects_active_turn() -> Result<()> {
let (active_tx, active_rx) = oneshot::channel();
let (finish_tx, finish_rx) = oneshot::channel();
tokio::spawn(async move {
if !matches!(rx_op.recv().await, Some(Op::SendMessage { .. })) {
if !matches!(rx_op.recv().await, Some(Op::SendMessage(TurnSpec { .. }))) {
return;
}
let _ = tx_event
@@ -14453,7 +14458,7 @@ async fn runtime_image_http_rejects_before_dispatch_and_accepts_large_canonical_
let response = client.post(&url).json(&body).send().await?;
assert_eq!(response.status(), StatusCode::CREATED);
let accepted: Value = response.json().await?;
let Op::SendMessage { images, .. } =
let Op::SendMessage(TurnSpec { images, .. }) =
harness.rx_op.recv().await.context("accepted Engine op")?
else {
bail!("expected SendMessage");
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more