Merge 0089e45724 into 4ea31c1bc9
This commit is contained in:
commit
a1eb5a4f49
|
|
@ -17,5 +17,5 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Working agreement
|
## Working agreement
|
||||||
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
||||||
- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.
|
- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.
|
||||||
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.
|
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,211 @@
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
use api::{
|
||||||
|
detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient,
|
||||||
|
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||||
|
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
||||||
|
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||||
|
ToolResultContentBlock,
|
||||||
|
};
|
||||||
|
use commands::{
|
||||||
|
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||||
|
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||||
|
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||||
|
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
|
||||||
|
slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch,
|
||||||
|
SlashCommand,
|
||||||
|
};
|
||||||
|
use init::initialize_repo;
|
||||||
|
use log::debug;
|
||||||
|
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||||
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
|
use runtime::{
|
||||||
|
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
|
||||||
|
load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base,
|
||||||
|
resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState,
|
||||||
|
CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile,
|
||||||
|
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
|
||||||
|
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
|
||||||
|
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
|
||||||
|
RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
use tools::{
|
||||||
|
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||||
|
RuntimeToolDefinition, ToolSearchOutput,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// #148: Model provenance for `claw status` JSON/text output. Records where
|
||||||
|
/// the resolved model string came from so claws don't have to re-read argv
|
||||||
|
/// to audit whether their `--model` flag was honored vs falling back to env
|
||||||
|
/// or config or default.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ModelSource {
|
||||||
|
/// Explicit `--model` / `--model=` CLI flag.
|
||||||
|
Flag,
|
||||||
|
/// Runtime model environment variable (when no flag was passed).
|
||||||
|
Env,
|
||||||
|
/// `model` key in `.claw.json` / `.claw/settings.json` (when neither
|
||||||
|
/// flag nor env set it).
|
||||||
|
Config,
|
||||||
|
/// Compiled-in `DEFAULT_MODEL` fallback.
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelSource {
|
||||||
|
pub(crate) fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ModelSource::Flag => "flag",
|
||||||
|
ModelSource::Env => "env",
|
||||||
|
ModelSource::Config => "config",
|
||||||
|
ModelSource::Default => "default",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ModelProvenance {
|
||||||
|
/// Resolved model string (after alias expansion).
|
||||||
|
pub(crate) resolved: String,
|
||||||
|
/// Raw user input before alias resolution. None when source is Default.
|
||||||
|
pub(crate) raw: Option<String>,
|
||||||
|
/// Where the resolved model string originated.
|
||||||
|
pub(crate) source: ModelSource,
|
||||||
|
/// Alias-expanded target when `raw` differs from `resolved`.
|
||||||
|
pub(crate) alias_resolved_to: Option<String>,
|
||||||
|
/// Environment variable that supplied the model, when source is Env.
|
||||||
|
pub(crate) env_var: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelProvenance {
|
||||||
|
pub(crate) fn default_fallback() -> Self {
|
||||||
|
Self {
|
||||||
|
resolved: DEFAULT_MODEL.to_string(),
|
||||||
|
raw: None,
|
||||||
|
source: ModelSource::Default,
|
||||||
|
alias_resolved_to: None,
|
||||||
|
env_var: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_flag(raw: &str, resolved: &str) -> Self {
|
||||||
|
Self::from_resolved(raw, resolved, ModelSource::Flag, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_raw(raw: &str, source: ModelSource, env_var: Option<&str>) -> Self {
|
||||||
|
let resolved = resolve_model_alias_with_config(raw);
|
||||||
|
Self::from_resolved(raw, &resolved, source, env_var)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_resolved(
|
||||||
|
raw: &str,
|
||||||
|
resolved: &str,
|
||||||
|
source: ModelSource,
|
||||||
|
env_var: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
let raw_trimmed = raw.trim();
|
||||||
|
let alias_resolved_to = (raw_trimmed != resolved).then(|| resolved.to_string());
|
||||||
|
Self {
|
||||||
|
resolved: resolved.to_string(),
|
||||||
|
raw: Some(raw.to_string()),
|
||||||
|
source,
|
||||||
|
alias_resolved_to,
|
||||||
|
env_var: env_var.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Result<Self, String> {
|
||||||
|
// Only called when no --model flag was passed. Probe env first,
|
||||||
|
// then config, else fall back to default. Mirrors the logic in
|
||||||
|
// resolve_repl_model() but captures the source.
|
||||||
|
if cli_model != DEFAULT_MODEL {
|
||||||
|
let provenance = Self::from_resolved(cli_model, cli_model, ModelSource::Flag, None);
|
||||||
|
provenance.validate()?;
|
||||||
|
return Ok(provenance);
|
||||||
|
}
|
||||||
|
if let Some(env_model) = env_model_for_runtime() {
|
||||||
|
let provenance =
|
||||||
|
Self::from_raw(&env_model.value, ModelSource::Env, Some(env_model.name));
|
||||||
|
provenance.validate()?;
|
||||||
|
return Ok(provenance);
|
||||||
|
}
|
||||||
|
if let Some(config_model) = config_model_for_current_dir() {
|
||||||
|
let provenance = Self::from_raw(&config_model, ModelSource::Config, None);
|
||||||
|
provenance.validate()?;
|
||||||
|
return Ok(provenance);
|
||||||
|
}
|
||||||
|
Ok(Self::default_fallback())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||||
|
validate_model_syntax(&self.resolved).map_err(|error| {
|
||||||
|
let source = match self.source {
|
||||||
|
ModelSource::Flag => "--model",
|
||||||
|
ModelSource::Env => self.env_var.as_deref().unwrap_or("environment"),
|
||||||
|
ModelSource::Config => "config model",
|
||||||
|
ModelSource::Default => "default model",
|
||||||
|
};
|
||||||
|
if let Some(raw) = &self.raw {
|
||||||
|
format!(
|
||||||
|
"invalid_model: {source} model `{raw}` is invalid after alias resolution to `{}`.\n{error}",
|
||||||
|
self.resolved
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_model_alias(model: &str) -> &str {
|
||||||
|
match model {
|
||||||
|
"opus" => "anthropic/claude-opus-4-7",
|
||||||
|
"sonnet" => "anthropic/claude-sonnet-4-6",
|
||||||
|
"haiku" => "anthropic/claude-haiku-4-5-20251213",
|
||||||
|
_ => model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a model name through user-defined config aliases first, then fall
|
||||||
|
/// back to the built-in alias table. This is the entry point used wherever a
|
||||||
|
/// user-supplied model string is about to be dispatched to a provider.
|
||||||
|
pub(crate) fn resolve_model_alias_with_config(model: &str) -> String {
|
||||||
|
let trimmed = model.trim();
|
||||||
|
if let Some(resolved) = config_alias_for_current_dir(trimmed) {
|
||||||
|
return resolve_model_alias(&resolved).to_string();
|
||||||
|
}
|
||||||
|
resolve_model_alias(trimmed).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn config_alias_for_current_dir(alias: &str) -> Option<String> {
|
||||||
|
if alias.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cwd = env::current_dir().ok()?;
|
||||||
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
|
let config = loader.load().ok()?;
|
||||||
|
config.aliases().get(alias).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn config_model_for_current_dir() -> Option<String> {
|
||||||
|
let cwd = env::current_dir().ok()?;
|
||||||
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
|
loader.load().ok()?.model().map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_repl_model(cli_model: String) -> Result<String, String> {
|
||||||
|
Ok(ModelProvenance::from_env_or_config_or_default(&cli_model)?.resolved)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,416 @@
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
use api::{
|
||||||
|
detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient,
|
||||||
|
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||||
|
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
||||||
|
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||||
|
ToolResultContentBlock,
|
||||||
|
};
|
||||||
|
use commands::{
|
||||||
|
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||||
|
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||||
|
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||||
|
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
|
||||||
|
slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch,
|
||||||
|
SlashCommand,
|
||||||
|
};
|
||||||
|
use init::initialize_repo;
|
||||||
|
use log::debug;
|
||||||
|
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||||
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
|
use runtime::{
|
||||||
|
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
|
||||||
|
load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base,
|
||||||
|
resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState,
|
||||||
|
CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile,
|
||||||
|
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
|
||||||
|
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
|
||||||
|
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
|
||||||
|
RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
use tools::{
|
||||||
|
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||||
|
RuntimeToolDefinition, ToolSearchOutput,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) struct HookAbortMonitor {
|
||||||
|
pub(crate) stop_tx: Option<Sender<()>>,
|
||||||
|
pub(crate) join_handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookAbortMonitor {
|
||||||
|
pub(crate) fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
|
||||||
|
Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
|
||||||
|
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let wait_for_stop = tokio::task::spawn_blocking(move || {
|
||||||
|
let _ = stop_rx.recv();
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
result = tokio::signal::ctrl_c() => {
|
||||||
|
if result.is_ok() {
|
||||||
|
abort_signal.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = wait_for_stop => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn spawn_with_waiter<F>(
|
||||||
|
abort_signal: runtime::HookAbortSignal,
|
||||||
|
wait_for_interrupt: F,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static,
|
||||||
|
{
|
||||||
|
let (stop_tx, stop_rx) = mpsc::channel();
|
||||||
|
let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
stop_tx: Some(stop_tx),
|
||||||
|
join_handle: Some(join_handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stop(mut self) {
|
||||||
|
if let Some(stop_tx) = self.stop_tx.take() {
|
||||||
|
let _ = stop_tx.send(());
|
||||||
|
}
|
||||||
|
if let Some(join_handle) = self.join_handle.take() {
|
||||||
|
let _ = join_handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct InternalPromptProgressState {
|
||||||
|
pub(crate) command_label: &'static str,
|
||||||
|
pub(crate) task_label: String,
|
||||||
|
pub(crate) step: usize,
|
||||||
|
pub(crate) phase: String,
|
||||||
|
pub(crate) detail: Option<String>,
|
||||||
|
pub(crate) saw_final_text: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum InternalPromptProgressEvent {
|
||||||
|
Started,
|
||||||
|
Update,
|
||||||
|
Heartbeat,
|
||||||
|
Complete,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct InternalPromptProgressShared {
|
||||||
|
pub(crate) state: Mutex<InternalPromptProgressState>,
|
||||||
|
pub(crate) output_lock: Mutex<()>,
|
||||||
|
pub(crate) started_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct InternalPromptProgressReporter {
|
||||||
|
pub(crate) shared: Arc<InternalPromptProgressShared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct InternalPromptProgressRun {
|
||||||
|
pub(crate) reporter: InternalPromptProgressReporter,
|
||||||
|
pub(crate) heartbeat_stop: Option<mpsc::Sender<()>>,
|
||||||
|
pub(crate) heartbeat_handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalPromptProgressReporter {
|
||||||
|
pub(crate) fn ultraplan(task: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
shared: Arc::new(InternalPromptProgressShared {
|
||||||
|
state: Mutex::new(InternalPromptProgressState {
|
||||||
|
command_label: "Ultraplan",
|
||||||
|
task_label: task.to_string(),
|
||||||
|
step: 0,
|
||||||
|
phase: "planning started".to_string(),
|
||||||
|
detail: Some(format!("task: {task}")),
|
||||||
|
saw_final_text: false,
|
||||||
|
}),
|
||||||
|
output_lock: Mutex::new(()),
|
||||||
|
started_at: Instant::now(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) {
|
||||||
|
let snapshot = self.snapshot();
|
||||||
|
let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error);
|
||||||
|
self.write_line(&line);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mark_model_phase(&self) {
|
||||||
|
let snapshot = {
|
||||||
|
let mut state = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("internal prompt progress state poisoned");
|
||||||
|
state.step += 1;
|
||||||
|
state.phase = if state.step == 1 {
|
||||||
|
"analyzing request".to_string()
|
||||||
|
} else {
|
||||||
|
"reviewing findings".to_string()
|
||||||
|
};
|
||||||
|
state.detail = Some(format!("task: {}", state.task_label));
|
||||||
|
state.clone()
|
||||||
|
};
|
||||||
|
self.write_line(&format_internal_prompt_progress_line(
|
||||||
|
InternalPromptProgressEvent::Update,
|
||||||
|
&snapshot,
|
||||||
|
self.elapsed(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mark_tool_phase(&self, name: &str, input: &str) {
|
||||||
|
let detail = describe_tool_progress(name, input);
|
||||||
|
let snapshot = {
|
||||||
|
let mut state = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("internal prompt progress state poisoned");
|
||||||
|
state.step += 1;
|
||||||
|
state.phase = format!("running {name}");
|
||||||
|
state.detail = Some(detail);
|
||||||
|
state.clone()
|
||||||
|
};
|
||||||
|
self.write_line(&format_internal_prompt_progress_line(
|
||||||
|
InternalPromptProgressEvent::Update,
|
||||||
|
&snapshot,
|
||||||
|
self.elapsed(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mark_text_phase(&self, text: &str) {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let detail = truncate_for_summary(first_visible_line(trimmed), 120);
|
||||||
|
let snapshot = {
|
||||||
|
let mut state = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("internal prompt progress state poisoned");
|
||||||
|
if state.saw_final_text {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.saw_final_text = true;
|
||||||
|
state.step += 1;
|
||||||
|
state.phase = "drafting final plan".to_string();
|
||||||
|
state.detail = (!detail.is_empty()).then_some(detail);
|
||||||
|
state.clone()
|
||||||
|
};
|
||||||
|
self.write_line(&format_internal_prompt_progress_line(
|
||||||
|
InternalPromptProgressEvent::Update,
|
||||||
|
&snapshot,
|
||||||
|
self.elapsed(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn emit_heartbeat(&self) {
|
||||||
|
let snapshot = self.snapshot();
|
||||||
|
self.write_line(&format_internal_prompt_progress_line(
|
||||||
|
InternalPromptProgressEvent::Heartbeat,
|
||||||
|
&snapshot,
|
||||||
|
self.elapsed(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn snapshot(&self) -> InternalPromptProgressState {
|
||||||
|
self.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("internal prompt progress state poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn elapsed(&self) -> Duration {
|
||||||
|
self.shared.started_at.elapsed()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write_line(&self, line: &str) {
|
||||||
|
let _guard = self
|
||||||
|
.shared
|
||||||
|
.output_lock
|
||||||
|
.lock()
|
||||||
|
.expect("internal prompt progress output lock poisoned");
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
let _ = writeln!(stdout, "{line}");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalPromptProgressRun {
|
||||||
|
pub(crate) fn start_ultraplan(task: &str) -> Self {
|
||||||
|
let reporter = InternalPromptProgressReporter::ultraplan(task);
|
||||||
|
reporter.emit(InternalPromptProgressEvent::Started, None);
|
||||||
|
|
||||||
|
let (heartbeat_stop, heartbeat_rx) = mpsc::channel();
|
||||||
|
let heartbeat_reporter = reporter.clone();
|
||||||
|
let heartbeat_handle = thread::spawn(move || loop {
|
||||||
|
match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) {
|
||||||
|
Ok(()) | Err(RecvTimeoutError::Disconnected) => break,
|
||||||
|
Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
reporter,
|
||||||
|
heartbeat_stop: Some(heartbeat_stop),
|
||||||
|
heartbeat_handle: Some(heartbeat_handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reporter(&self) -> InternalPromptProgressReporter {
|
||||||
|
self.reporter.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finish_success(&mut self) {
|
||||||
|
self.stop_heartbeat();
|
||||||
|
self.reporter
|
||||||
|
.emit(InternalPromptProgressEvent::Complete, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finish_failure(&mut self, error: &str) {
|
||||||
|
self.stop_heartbeat();
|
||||||
|
self.reporter
|
||||||
|
.emit(InternalPromptProgressEvent::Failed, Some(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stop_heartbeat(&mut self) {
|
||||||
|
if let Some(sender) = self.heartbeat_stop.take() {
|
||||||
|
let _ = sender.send(());
|
||||||
|
}
|
||||||
|
if let Some(handle) = self.heartbeat_handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InternalPromptProgressRun {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop_heartbeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn format_internal_prompt_progress_line(
|
||||||
|
event: InternalPromptProgressEvent,
|
||||||
|
snapshot: &InternalPromptProgressState,
|
||||||
|
elapsed: Duration,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let elapsed_seconds = elapsed.as_secs();
|
||||||
|
let step_label = if snapshot.step == 0 {
|
||||||
|
"current step pending".to_string()
|
||||||
|
} else {
|
||||||
|
format!("current step {}", snapshot.step)
|
||||||
|
};
|
||||||
|
let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)];
|
||||||
|
if let Some(detail) = snapshot
|
||||||
|
.detail
|
||||||
|
.as_deref()
|
||||||
|
.filter(|detail| !detail.is_empty())
|
||||||
|
{
|
||||||
|
status_bits.push(detail.to_string());
|
||||||
|
}
|
||||||
|
let status = status_bits.join(" · ");
|
||||||
|
match event {
|
||||||
|
InternalPromptProgressEvent::Started => {
|
||||||
|
format!(
|
||||||
|
"🧭 {} status · planning started · {status}",
|
||||||
|
snapshot.command_label
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InternalPromptProgressEvent::Update => {
|
||||||
|
format!("… {} status · {status}", snapshot.command_label)
|
||||||
|
}
|
||||||
|
InternalPromptProgressEvent::Heartbeat => format!(
|
||||||
|
"… {} heartbeat · {elapsed_seconds}s elapsed · {status}",
|
||||||
|
snapshot.command_label
|
||||||
|
),
|
||||||
|
InternalPromptProgressEvent::Complete => format!(
|
||||||
|
"✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total",
|
||||||
|
snapshot.command_label, snapshot.step
|
||||||
|
),
|
||||||
|
InternalPromptProgressEvent::Failed => format!(
|
||||||
|
"✘ {} status · failed · {elapsed_seconds}s elapsed · {}",
|
||||||
|
snapshot.command_label,
|
||||||
|
error.unwrap_or("unknown error")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct CliHookProgressReporter;
|
||||||
|
|
||||||
|
impl runtime::HookProgressReporter for CliHookProgressReporter {
|
||||||
|
fn on_event(&mut self, event: &runtime::HookProgressEvent) {
|
||||||
|
match event {
|
||||||
|
runtime::HookProgressEvent::Started {
|
||||||
|
event,
|
||||||
|
tool_name,
|
||||||
|
command,
|
||||||
|
} => eprintln!(
|
||||||
|
"[hook {event_name}] {tool_name}: {command}",
|
||||||
|
event_name = event.as_str()
|
||||||
|
),
|
||||||
|
runtime::HookProgressEvent::Completed {
|
||||||
|
event,
|
||||||
|
tool_name,
|
||||||
|
command,
|
||||||
|
} => eprintln!(
|
||||||
|
"[hook done {event_name}] {tool_name}: {command}",
|
||||||
|
event_name = event.as_str()
|
||||||
|
),
|
||||||
|
runtime::HookProgressEvent::Cancelled {
|
||||||
|
event,
|
||||||
|
tool_name,
|
||||||
|
command,
|
||||||
|
} => eprintln!(
|
||||||
|
"[hook cancelled {event_name}] {tool_name}: {command}",
|
||||||
|
event_name = event.as_str()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn first_visible_line(text: &str) -> &str {
|
||||||
|
text.lines()
|
||||||
|
.find(|line| !line.trim().is_empty())
|
||||||
|
.unwrap_or(text)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,105 @@
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
use api::{
|
||||||
|
detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient,
|
||||||
|
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||||
|
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
||||||
|
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||||
|
ToolResultContentBlock,
|
||||||
|
};
|
||||||
|
use commands::{
|
||||||
|
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||||
|
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||||
|
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||||
|
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
|
||||||
|
slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch,
|
||||||
|
SlashCommand,
|
||||||
|
};
|
||||||
|
use init::initialize_repo;
|
||||||
|
use log::debug;
|
||||||
|
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||||
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
|
use runtime::{
|
||||||
|
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
|
||||||
|
load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base,
|
||||||
|
resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState,
|
||||||
|
CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile,
|
||||||
|
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
|
||||||
|
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
|
||||||
|
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
|
||||||
|
RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
use tools::{
|
||||||
|
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||||
|
RuntimeToolDefinition, ToolSearchOutput,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option<String> {
|
||||||
|
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
|
||||||
|
let looks_like_skill_name = !bare_first_token.is_empty()
|
||||||
|
&& !bare_first_token.starts_with('/')
|
||||||
|
&& bare_first_token
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
|
||||||
|
if !looks_like_skill_name {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match resolve_skill_invocation(cwd, Some(trimmed)) {
|
||||||
|
Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
// Verify we are inside a git repository before calling `git diff`.
|
||||||
|
// Running `git diff --cached` outside a git tree produces a misleading
|
||||||
|
// "unknown option `cached`" error because git falls back to --no-index mode.
|
||||||
|
let in_git_repo = std::process::Command::new("git")
|
||||||
|
.args(["rev-parse", "--is-inside-work-tree"])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|o| o.status.success());
|
||||||
|
if !in_git_repo {
|
||||||
|
return Ok(format!(
|
||||||
|
"Diff\n Result no git repository\n Detail {} is not inside a git project",
|
||||||
|
cwd.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
||||||
|
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||||
|
if staged.trim().is_empty() && unstaged.trim().is_empty() {
|
||||||
|
return Ok(
|
||||||
|
"Diff\n Result clean working tree\n Detail no current changes"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
if !staged.trim().is_empty() {
|
||||||
|
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
|
||||||
|
}
|
||||||
|
if !unstaged.trim().is_empty() {
|
||||||
|
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
|
||||||
|
resolve_startup_auth_source(|| Ok(None))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue