Skip to main content

tau_ext_shell/
lib.rs

1//! Filesystem and shell tool extension.
2//!
3//! Provides `read`, `edit`, `replace`, `apply_patch`, `dir_lock`, `grep`,
4//! `find`, `ls`, `workdir`, `shell`, and `gpt_shell` tools.
5//!
6//! The `echo` tool is available under `cfg(test)` or the
7//! `echo-agent` cargo feature for harness-side echo-agent tests.
8
9use std::collections::HashMap;
10use std::error::Error;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, mpsc};
14use std::time::{Duration, Instant};
15
16use tau_proto::{
17    ActionError, ActionInvoke, ActionOutput, ActionResult, AgentContextKey, AgentContextValue,
18    CborValue, DiscoveryModifiedMicros, DiscoverySkillCandidate, Event, ExtAgentContextPublish,
19    ExtensionAgentDiscoverySnapshotDeclared, ExtensionContextReady, ExtensionSessionContextReady,
20    ExtensionSessionDiscoverySnapshotDeclared, HarnessInputMessage, PromptContent, PromptFragment,
21    PromptPriority, SessionAgentLoaded, SessionStarted, ToolCancelled, ToolExample,
22    ToolExampleSelector, ToolResult, ToolResultKind, ToolSpec, ToolTag,
23};
24use tracing::{debug, trace};
25
26#[cfg(test)]
27static DETACHED_OUTPUT_OVERLOAD_NOTIFY: Mutex<Option<mpsc::Sender<()>>> = Mutex::new(None);
28
29use crate::tools::{shell as path_crate_tools_shell, world as path_crate_tools_world};
30use crate::{
31    dir_lock as path_crate_dir_lock, display as path_crate_display, tools as path_crate_tools,
32};
33
34mod agents;
35mod argument;
36mod artifact_transfer;
37mod config;
38mod cwd_state;
39mod diff;
40mod dir_lock;
41mod discovery_source;
42mod display;
43mod isolation;
44#[cfg(any(target_os = "android", target_os = "linux", target_os = "macos"))]
45mod pty_stdio;
46mod runtime;
47mod scheduler;
48mod shell_output_spool;
49mod shell_process;
50mod terminal_frame;
51mod tool_lifecycle;
52mod tool_started_identity;
53mod tools;
54mod truncate;
55mod ui_shell_shutdown_generation;
56
57#[cfg(test)]
58mod tests;
59
60use crate::agents::ancestor_dirs;
61use crate::artifact_transfer::{ArtifactTransferControl, ArtifactTransferManager};
62use crate::config::{ExtConfig, ShellConfig};
63use crate::cwd_state::{CwdState, WorkdirSnapshot};
64use crate::dir_lock::{DIR_LOCK_TOOL_NAME, DirLockManager};
65use crate::runtime::ShellRuntime;
66use crate::scheduler::{WorkMeta, WorkPriority, WorkScheduler};
67use crate::tool_lifecycle::{ToolCancellationState, ToolLifecycle};
68#[cfg(any(test, feature = "echo-agent"))]
69use crate::tools::ECHO_TOOL_NAME;
70use crate::tools::shell::{ShellAccessMode, ShellCommandMode};
71use crate::tools::{
72    APPLY_PATCH_TOOL_NAME, EDIT_TOOL_NAME, EXPORT_TOOL_NAME, FIND_TOOL_NAME, GPT_SHELL_TOOL_NAME,
73    GREP_TOOL_NAME, IMPORT_TOOL_NAME, LS_TOOL_NAME, READ_TOOL_NAME, REPLACE_TOOL_NAME,
74    SHELL_TOOL_NAME, WORKDIR_TOOL_NAME, execute_tool,
75};
76use crate::ui_shell_shutdown_generation::{
77    UiShellShutdownGeneration, UiShellShutdownGenerationCounter,
78};
79
80/// Cloneable shell output adapter.
81///
82/// Production optional worker, progress, and diagnostic output uses
83/// tau-client's detached enqueue path so shell workers do not block on protocol
84/// flush. Sole terminals, user-shell completion, discovery, context,
85/// prerequisite metadata, and readiness use checked writes instead. The shared
86/// sticky failure state wakes the manual loop and prevents worker cleanup from
87/// releasing ownership after a failed terminal. Tests can use an mpsc-backed
88/// adapter for direct state-machine coverage.
89#[derive(Clone)]
90pub(crate) struct Output {
91    /// Production client or test channel receiving output frames.
92    inner: OutputInner,
93    /// Optional local-to-wire tool-name mapping for one scoped invocation.
94    tool_name_scope: Option<(tau_proto::ToolName, tau_proto::ToolName)>,
95    /// First mandatory-output failure shared with the manual policy loop.
96    failure: Arc<Mutex<MandatoryOutputFailure>>,
97}
98
99/// Cross-thread notification that a checked protocol write failed.
100#[derive(Default)]
101struct MandatoryOutputFailure {
102    /// First failure retained until the policy loop observes it.
103    message: Option<String>,
104    /// Sticky marker preserving worker ownership after loop observation.
105    failed: bool,
106    /// Manual-loop wake handle installed after tau-client startup.
107    waker: Option<tau_client::ManualRuntimeWaker>,
108}
109
110/// Backend used by [`Output`] to publish protocol frames.
111#[derive(Clone)]
112enum OutputInner {
113    /// Production tau-client writer handle.
114    Client(tau_client::ClientHandle),
115    #[cfg(test)]
116    /// Direct unit-test channel.
117    Channel(mpsc::Sender<HarnessInputMessage>),
118}
119
120impl Output {
121    fn client(handle: tau_client::ClientHandle) -> Self {
122        Self {
123            inner: OutputInner::Client(handle),
124            tool_name_scope: None,
125            failure: Arc::default(),
126        }
127    }
128
129    #[cfg(test)]
130    fn channel(tx: mpsc::Sender<HarnessInputMessage>) -> Self {
131        Self {
132            inner: OutputInner::Channel(tx),
133            tool_name_scope: None,
134            failure: Arc::default(),
135        }
136    }
137
138    fn scoped_tool(&self, local: tau_proto::ToolName, wire: tau_proto::ToolName) -> Self {
139        Self {
140            inner: self.inner.clone(),
141            tool_name_scope: Some((local, wire)),
142            failure: Arc::clone(&self.failure),
143        }
144    }
145
146    fn scope_tool_name(&self, tool_name: &mut tau_proto::ToolName) {
147        if let Some((local, wire)) = &self.tool_name_scope
148            && tool_name == local
149        {
150            *tool_name = wire.clone();
151        }
152    }
153
154    fn send(&self, mut message: HarnessInputMessage) -> tau_client::ClientResult<()> {
155        self.scope_message(&mut message);
156        let result = match &self.inner {
157            OutputInner::Client(handle) => handle.send_detached(message),
158            #[cfg(test)]
159            OutputInner::Channel(tx) => tx
160                .send(message)
161                .map_err(|_| tau_client::ClientError::WriterClosed),
162        };
163        #[cfg(test)]
164        if matches!(result, Err(tau_client::ClientError::Overloaded))
165            && let Some(notify) = DETACHED_OUTPUT_OVERLOAD_NOTIFY
166                .lock()
167                .expect("detached overload notification")
168                .as_ref()
169        {
170            let _ = notify.send(());
171        }
172        result
173    }
174
175    /// Sends mandatory lifecycle traffic in order and waits for writer flush.
176    fn send_checked(&self, mut message: HarnessInputMessage) -> tau_client::ClientResult<()> {
177        self.scope_message(&mut message);
178        let result = match &self.inner {
179            OutputInner::Client(handle) => handle.send(message),
180            #[cfg(test)]
181            OutputInner::Channel(tx) => tx
182                .send(message)
183                .map_err(|_| tau_client::ClientError::WriterClosed),
184        };
185        self.retain_mandatory_failure(result)
186    }
187
188    fn scope_message(&self, message: &mut HarnessInputMessage) {
189        if let HarnessInputMessage::Emit(emit) = message {
190            let tool_name = match emit.event.as_mut() {
191                Event::ToolProgressReported(event) => Some(&mut event.tool_name),
192                Event::ToolResultReported(event) => Some(&mut event.tool_name),
193                Event::ToolResult(event) => Some(&mut event.tool_name),
194                Event::ToolErrorReported(event) => Some(&mut event.tool_name),
195                Event::ToolError(event) => Some(&mut event.tool_name),
196                Event::ToolCancelledReported(event) => Some(&mut event.tool_name),
197                Event::ToolCancelled(event) => Some(&mut event.tool_name),
198                _ => None,
199            };
200            if let Some(tool_name) = tool_name {
201                self.scope_tool_name(tool_name);
202            }
203        }
204    }
205
206    /// Submit one transient tool progress observation.
207    fn report_tool_progress(
208        &self,
209        progress: tau_proto::ToolProgress,
210    ) -> tau_client::ClientResult<()> {
211        self.send(HarnessInputMessage::emit_with_persist(
212            Event::ToolProgressReported(progress),
213            false,
214        ))
215    }
216
217    /// Submit one terminal tool outcome through the typed client report helper.
218    fn report_tool_terminal(&self, event: Event) -> tau_client::ClientResult<()> {
219        let result = (|| {
220            let mut outcome =
221                tau_client::ToolTerminalOutcome::try_from(event).map_err(|event| {
222                    tau_client::ClientError::handler(format!(
223                        "terminal report helper received {}",
224                        event.name()
225                    ))
226                })?;
227            self.scope_tool_name(outcome.tool_name_mut());
228            let message = terminal_frame::budget_terminal_report(outcome.into_reported_event())?;
229            match &self.inner {
230                OutputInner::Client(handle) => handle.send(message),
231                #[cfg(test)]
232                OutputInner::Channel(tx) => tx
233                    .send(message)
234                    .map_err(|_| tau_client::ClientError::WriterClosed),
235            }
236        })();
237        self.retain_mandatory_failure(result)
238    }
239
240    /// Installs the wake handle used when a worker observes checked-output
241    /// failure.
242    fn install_waker(&self, waker: tau_client::ManualRuntimeWaker) {
243        self.failure
244            .lock()
245            .expect("mandatory output failure lock poisoned")
246            .waker = Some(waker);
247    }
248
249    /// Returns the first worker-side mandatory-output failure.
250    fn take_mandatory_failure(&self) -> tau_client::ClientResult<()> {
251        let message = self
252            .failure
253            .lock()
254            .expect("mandatory output failure lock poisoned")
255            .message
256            .take();
257        match message {
258            Some(message) => Err(tau_client::ClientError::handler(message)),
259            None => Ok(()),
260        }
261    }
262
263    /// Returns whether any checked output has failed before loop teardown.
264    fn mandatory_output_failed(&self) -> bool {
265        self.failure
266            .lock()
267            .expect("mandatory output failure lock poisoned")
268            .failed
269    }
270
271    /// Records a checked-output failure and wakes the policy loop.
272    fn retain_mandatory_failure(
273        &self,
274        result: tau_client::ClientResult<()>,
275    ) -> tau_client::ClientResult<()> {
276        if let Err(error) = &result {
277            let waker = {
278                let mut failure = self
279                    .failure
280                    .lock()
281                    .expect("mandatory output failure lock poisoned");
282                failure.message.get_or_insert_with(|| error.to_string());
283                failure.failed = true;
284                failure.waker.clone()
285            };
286            if let Some(waker) = waker {
287                waker.wake();
288            }
289        }
290        result
291    }
292
293    fn register_local_tool(
294        &self,
295        registration: tau_proto::ToolRegistrationDeclared,
296    ) -> tau_client::ClientResult<()> {
297        match &self.inner {
298            OutputInner::Client(handle) => handle.register_local_tool(registration),
299            #[cfg(test)]
300            OutputInner::Channel(tx) => tx
301                .send(HarnessInputMessage::emit_with_persist(
302                    Event::ToolRegistrationDeclared(registration),
303                    false,
304                ))
305                .map_err(|_| tau_client::ClientError::WriterClosed),
306        }
307    }
308}
309
310fn tool_tags(tags: &[&str]) -> Vec<ToolTag> {
311    tags.iter().map(|tag| ToolTag::new(*tag)).collect()
312}
313
314fn example_field(name: &str, value: CborValue) -> (CborValue, CborValue) {
315    (CborValue::Text(name.to_owned()), value)
316}
317
318fn example_text(value: &str) -> CborValue {
319    CborValue::Text(value.to_owned())
320}
321
322fn example_int(value: i64) -> CborValue {
323    CborValue::Integer(value.into())
324}
325
326const SHELL_DIR_FORCE_UNLOCK_ACTION_ID: &str = "shell.dir.force_unlock";
327
328const SLOW_LOCK_WAIT_THRESHOLD_SECS: u64 = 5;
329const LOCK_WAIT_DURATION_SECONDS_HEADER: &str = "lock_wait_duration_seconds";
330const XDG_USER_SKILL_SOURCE_PRECEDENCE: u32 = 0;
331const LEGACY_USER_SKILL_SOURCE_PRECEDENCE: u32 = 1;
332
333#[derive(Clone, Copy)]
334enum DiscoverySourcePolicy {
335    Environment,
336    #[cfg(any(test, feature = "echo-agent"))]
337    EmptyFixture,
338}
339
340impl DiscoverySourcePolicy {
341    const fn reads_environment(self) -> bool {
342        matches!(self, Self::Environment)
343    }
344}
345
346enum RuntimeCwdSource {
347    Process,
348    #[cfg(any(test, feature = "echo-agent"))]
349    Fixture(PathBuf),
350}
351
352/// One discovery pass split into mandatory state and optional session-only
353/// diagnostics.
354struct DiscoveryScan {
355    /// Complete discovery state published for the session or one agent.
356    snapshot: ExtensionSessionDiscoverySnapshotDeclared,
357    /// Best-effort diagnostic notices published only during session discovery.
358    diagnostics: Vec<HarnessInputMessage>,
359}
360
361/// Runs the extension on stdin/stdout.
362pub fn run_stdio() -> Result<(), Box<dyn Error>> {
363    tau_client::init_logging_for("tau_ext_shell");
364    tracing::info!(
365        target: "tau_ext_shell",
366        package = env!("CARGO_PKG_NAME"),
367        version = env!("CARGO_PKG_VERSION"),
368        revision = tau_client::diagnostic_build_revision(),
369        "extension startup identity"
370    );
371    run_impl(
372        std::io::stdin(),
373        std::io::stdout(),
374        DiscoverySourcePolicy::Environment,
375        RuntimeCwdSource::Process,
376    )
377}
378
379/// Runs the extension over arbitrary reader/writer streams.
380///
381/// The test-only `echo` tool is registered when built with
382/// `cfg(test)` or the `echo-agent` cargo feature.
383pub fn run<R, W>(reader: R, writer: W) -> Result<(), Box<dyn Error>>
384where
385    R: Read + Send + 'static,
386    W: Write + Send + 'static,
387{
388    run_impl(
389        reader,
390        writer,
391        DiscoverySourcePolicy::Environment,
392        RuntimeCwdSource::Process,
393    )
394}
395
396/// Runs an in-process harness test extension without discovering caller-owned
397/// skills or `AGENTS.md` files.
398///
399/// Harness unit tests share one process, so they cannot safely rewrite
400/// process-wide HOME, XDG, or working-directory state. This entrypoint keeps
401/// their synthetic extension from reading those ambient discovery inputs while
402/// preserving the ordinary protocol and tool behavior.
403#[cfg(any(test, feature = "echo-agent"))]
404pub fn run_for_test_harness<R, W>(
405    reader: R,
406    writer: W,
407    fixture_cwd: PathBuf,
408) -> Result<(), Box<dyn Error>>
409where
410    R: Read + Send + 'static,
411    W: Write + Send + 'static,
412{
413    run_impl(
414        reader,
415        writer,
416        DiscoverySourcePolicy::EmptyFixture,
417        RuntimeCwdSource::Fixture(fixture_cwd),
418    )
419}
420
421fn registered_tool_specs(dir_lock_enabled: bool) -> Vec<ToolSpec> {
422    #[cfg(any(test, feature = "echo-agent"))]
423    let echo_tool = Some(ToolSpec {
424        provider_scope: None,
425        name: tau_proto::ToolName::new(ECHO_TOOL_NAME),
426        model_visible_name: None,
427        description: Some("Echo the provided payload unchanged".to_owned()),
428        tool_type: tau_proto::ToolType::Function,
429        parameters: None,
430        format: None,
431        tags: tool_tags(&["test:echo"]),
432        enabled_by_default: false,
433        background_support: None,
434        examples: Vec::new(),
435    });
436    #[cfg(not(any(test, feature = "echo-agent")))]
437    let echo_tool: Option<ToolSpec> = None;
438    let mut tools = Vec::new();
439    if let Some(echo_tool) = echo_tool {
440        tools.push(echo_tool);
441    }
442    let read_tool = ToolSpec {
443        provider_scope: None,
444        name: tau_proto::ToolName::new(READ_TOOL_NAME),
445        model_visible_name: None,
446        description: Some(
447            "Reads a file. Defaults to reading the whole file in one call — \
448             output is capped at 2000 lines / 10 KiB. Truncated output keeps \
449             the first 1000 and last 1000 lines separated by a literal `...` line. \
450             Files over 10 MiB are rejected by an input safety cap before output truncation. \
451             Prefer one full read. Pass inclusive `start_line`/`end_line` only to \
452             fetch one specific known slice, or `ranges` for up to 100 slices; \
453             range chunks are separated by one empty line and may overlap, but large overlapping \
454             multi-range expansions can be rejected before rendering to keep memory bounded. `start_line` past EOF errors, \
455             while `end_line` past EOF returns available lines. Returned content lines are prefixed \
456             by their 1-based line number and a space; \
457             CRLF, CR, and missing final line endings are marked after the number, e.g. \
458             `2(crlf)`, `3(cr)`, or `4(no_nl)`. Invalid UTF-8 is shown with \
459             Unicode replacement characters and an `invalid-utf8` line flag. Lines that would exceed \
460             the 10 KiB visible output budget are marker-only, e.g. `1(truncated)`. Truncated results include `truncated: true`, `total_lines`, \
461             and `total_bytes`, plus a private path to bounded saved output (or `saved_output_unavailable: true` when private storage fails); `valid_utf8: false` is included only when applicable."
462                .to_owned(),
463        ),
464        tool_type: tau_proto::ToolType::Function,
465        parameters: Some(serde_json::json!({
466            "type": "object",
467            "properties": {
468                "path": {
469                    "type": "string",
470                    "description": "Path to the file"
471                },
472                "start_line": {
473                    "type": "integer",
474                    "minimum": 1,
475                    "description": "Optional, 1-based inclusive. Omit to start at line 1 (the default)."
476                },
477                "end_line": {
478                    "type": "integer",
479                    "minimum": 1,
480                    "description": "Optional, 1-based inclusive. Omit to read to end of file (the default and preferred mode). Set this only to continue past a previous truncation, or to fetch a known specific slice of a large file — do NOT pre-slice an ordinary file you haven't already established is large."
481                },
482                "ranges": {
483                    "type": "array",
484                    "description": "Optional list of inclusive line ranges to read. Cannot be combined with top-level start_line or end_line. Each chunk is separated by one empty line in the output, and overlapping ranges are returned redundantly. Requests whose overlapping ranges would expand into too much rendered content are rejected before rendering.",
485                    "minItems": 1,
486                    "maxItems": 100,
487                    "items": {
488                        "type": "object",
489                        "properties": {
490                            "start_line": {
491                                "type": "integer",
492                                "minimum": 1,
493                                "description": "1-based inclusive start line to read."
494                            },
495                            "end_line": {
496                                "type": "integer",
497                                "minimum": 1,
498                                "description": "1-based inclusive end line to read."
499                            }
500                        },
501                        "required": ["start_line", "end_line"],
502                        "additionalProperties": false
503                    }
504                }
505            },
506            "required": ["path"],
507            "additionalProperties": false
508        })),
509        format: None,
510        tags: tool_tags(&["shell:read", tau_proto::TURN_DATA_FETCH_TOOL_TAG]),
511        enabled_by_default: true,
512        background_support: None,
513        examples: vec![ToolExample {
514            id: "read-file".to_owned(),
515            title: Some("Read a file".to_owned()),
516            arguments: CborValue::Map(vec![example_field("path", example_text("src/main.rs"))]),
517            note: Some("Use only the path field for a full-file read.".to_owned()),
518            subcommand: None,
519        }],
520    };
521    let export_tool = ToolSpec {
522        provider_scope: None,
523        name: tau_proto::ToolName::new(EXPORT_TOOL_NAME),
524        model_visible_name: None,
525        description: Some(
526            "Export one local regular file to the shared content-addressed artifact store. \
527             Originals are limited to 16 MiB. A successful export returns a canonical \
528             <tau-artifact:FULL_KEY> reference and byte size as output headers and renews shared \
529             artifact age, including for duplicate bytes. Original bytes persist independently \
530             of ephemeral session transcripts."
531                .to_owned(),
532        ),
533        tool_type: tau_proto::ToolType::Function,
534        parameters: Some(serde_json::json!({
535            "type": "object",
536            "properties": {
537                "path": {"type": "string", "description": "Path to one local regular file"}
538            },
539            "required": ["path"],
540            "additionalProperties": false
541        })),
542        format: None,
543        tags: tool_tags(&[
544            "shell:read",
545            "artifact:write",
546            tau_proto::TURN_DATA_FETCH_TOOL_TAG,
547        ]),
548        enabled_by_default: true,
549        background_support: None,
550        examples: vec![ToolExample {
551            id: "export-artifact".to_owned(),
552            title: Some("Export an original".to_owned()),
553            arguments: CborValue::Map(vec![example_field("path", example_text("output.png"))]),
554            note: Some(
555                "Pass the returned artifact reference directly to import or read_image.".to_owned(),
556            ),
557            subcommand: None,
558        }],
559    };
560    let import_tool = ToolSpec {
561        provider_scope: None,
562        name: tau_proto::ToolName::new(IMPORT_TOOL_NAME),
563        model_visible_name: None,
564        description: Some(
565            "Import one <tau-artifact:FULL_KEY> reference to a private, unpredictable, \
566             non-executable temporary file on this shell host. A bare internal key is also \
567             accepted. Size and digest are verified before success. The local path and byte size \
568             are returned as output headers. Import does not renew retention age; pass the \
569             returned local path to filesystem tools."
570                .to_owned(),
571        ),
572        tool_type: tau_proto::ToolType::Function,
573        parameters: Some(serde_json::json!({
574            "type": "object",
575            "properties": {
576                "key": {
577                    "type": "string",
578                    "pattern": "^(?:blake3:[0-9a-f]{64}|<tau-artifact:blake3:[0-9a-f]{64}>)$",
579                    "description": "Canonical <tau-artifact:FULL_KEY> reference returned by export; a bare key is also accepted"
580                }
581            },
582            "required": ["key"],
583            "additionalProperties": false
584        })),
585        format: None,
586        tags: tool_tags(&[
587            "shell:read",
588            "artifact:read",
589            tau_proto::TURN_DATA_FETCH_TOOL_TAG,
590        ]),
591        enabled_by_default: true,
592        background_support: None,
593        examples: Vec::new(),
594    };
595    let edit_tool = ToolSpec {
596        provider_scope: None,
597        name: tau_proto::ToolName::new(EDIT_TOOL_NAME),
598        model_visible_name: None,
599        description: Some(
600            "Edit a file using line-oriented replacements. Each edit fully replaces \
601             the 1-based half-open `start_line`..`end_line_exclusive` range \
602             with `newText`. `start_line` is included and `end_line_exclusive` \
603             is excluded. Empty insertion ranges use \
604             `start_line == end_line_exclusive`; for example, `1..<1` inserts \
605             at the start of the file and `total_lines + 1 ..< total_lines + 1` \
606             appends at EOF. All ranges use the original file numbering as if \
607             applied simultaneously. Non-empty replacements are kept as whole \
608             lines. Ranges must be non-overlapping. Missing files are treated as \
609             empty and missing parent directories are created. Per-edit `context_line` \
610             must exactly match the original content of `start_line`. Use an empty \
611             context_line when `start_line` is the append slot past the end of the \
612             file."
613                .to_owned(),
614        ),
615        tool_type: tau_proto::ToolType::Function,
616        parameters: Some(serde_json::json!({
617            "type": "object",
618            "properties": {
619                "path": {
620                    "type": "string",
621                    "description": "Path to the file"
622                },
623                "edits": {
624                    "type": "array",
625                    "description": "One or more line ranges to replace in the original file",
626                    "minItems": 1,
627                    "maxItems": 100,
628                    "items": {
629                        "type": "object",
630                        "properties": {
631                            "start_line": {
632                                "type": "integer",
633                                "minimum": 1,
634                                "description": "1-based included start line or insertion slot. Use 1 for the start of the file. To append at EOF, use total_lines + 1. Use together with end_line_exclusive."
635                            },
636                            "end_line_exclusive": {
637                                "type": "integer",
638                                "minimum": 1,
639                                "description": "1-based excluded end line or insertion slot. Empty insertion ranges have end_line_exclusive == start_line. To replace read output lines A through B, use start_line A and end_line_exclusive B + 1. Use together with start_line."
640                            },
641                            "newText": {
642                                "type": "string",
643                                "description": "Replacement text. Non-empty replacements stay whole-line."
644                            },
645                            "context_line": {
646                                "type": "string",
647                                "description": "Exact expected content of the original start_line, including spaces and tabs. Use an empty context_line when start_line is the append slot past the end of the file. If it does not match, the edit fails and returns current line-numbered context around the expected context line."
648                            }
649                        },
650                        "required": ["start_line", "end_line_exclusive", "newText", "context_line"],
651                        "additionalProperties": false
652                    }
653                }
654            },
655            "required": ["path", "edits"],
656            "additionalProperties": false
657        })),
658        format: None,
659        tags: tool_tags(&[
660            "shell:edit",
661            "shell:edit:line",
662            "shell:mutates-files",
663            tau_proto::TURN_MANIPULATOR_TOOL_TAG,
664        ]),
665        enabled_by_default: true,
666        background_support: None,
667        examples: vec![ToolExample {
668            id: "replace-lines".to_owned(),
669            title: Some("Replace one line".to_owned()),
670            arguments: CborValue::Map(vec![
671                example_field("path", example_text("src/main.rs")),
672                (
673                    CborValue::Text("edits".to_owned()),
674                    CborValue::Array(vec![CborValue::Map(vec![
675                        example_field("start_line", example_int(10)),
676                        example_field("end_line_exclusive", example_int(11)),
677                        example_field("newText", example_text("replacement line")),
678                        example_field("context_line", example_text("line being replaced")),
679                    ])]),
680                ),
681            ]),
682            note: Some("end_line_exclusive is one past the last line replaced.".to_owned()),
683            subcommand: None,
684        }],
685    };
686    let apply_patch_tool = ToolSpec {
687        provider_scope: None,
688        name: tau_proto::ToolName::new(APPLY_PATCH_TOOL_NAME),
689        model_visible_name: None,
690        description: Some("Use the `apply_patch` tool to edit files.".to_owned()),
691        tool_type: tau_proto::ToolType::Custom,
692        parameters: None,
693        format: Some(tau_proto::ToolFormat::Text),
694        tags: tool_tags(&[
695            "shell:edit",
696            "shell:edit:apply_patch",
697            "shell:mutates-files",
698            tau_proto::TURN_MANIPULATOR_TOOL_TAG,
699        ]),
700        enabled_by_default: false,
701        background_support: None,
702        examples: Vec::new(),
703    };
704    let replace_tool = ToolSpec {
705        provider_scope: None,
706        name: tau_proto::ToolName::new(REPLACE_TOOL_NAME),
707        model_visible_name: Some(tau_proto::ToolName::new(EDIT_TOOL_NAME)),
708        description: Some(
709            "Replace exact text in one existing UTF-8 file. Each oldText must occur exactly \
710             once in the same original file snapshot; all edits apply atomically. Matching \
711             ignores only an initial UTF-8 BOM and normalizes CRLF/CR to LF. Use newText \
712             as an empty string to delete text."
713                .to_owned(),
714        ),
715        tool_type: tau_proto::ToolType::Function,
716        parameters: Some(serde_json::json!({
717            "type": "object",
718            "properties": {
719                "path": { "type": "string", "minLength": 1 },
720                "edits": {
721                    "type": "array",
722                    "minItems": 1,
723                    "maxItems": 100,
724                    "items": {
725                        "type": "object",
726                        "properties": {
727                            "oldText": { "type": "string", "minLength": 1 },
728                            "newText": { "type": "string" }
729                        },
730                        "required": ["oldText", "newText"],
731                        "additionalProperties": false
732                    }
733                }
734            },
735            "required": ["path", "edits"],
736            "additionalProperties": false
737        })),
738        format: None,
739        tags: tool_tags(&[
740            "shell:edit",
741            "shell:edit:replace",
742            "shell:mutates-files",
743            tau_proto::TURN_MANIPULATOR_TOOL_TAG,
744        ]),
745        enabled_by_default: false,
746        background_support: None,
747        examples: Vec::new(),
748    };
749    let dir_lock_tool = dir_lock_tool_spec(dir_lock_enabled);
750    let grep_tool = ToolSpec {
751        provider_scope: None,
752        name: tau_proto::ToolName::new(GREP_TOOL_NAME),
753        model_visible_name: None,
754        description: Some(
755            "Search file contents for a pattern using in-process ripgrep libraries. Patterns are literal by default; \
756             regex metacharacters like `|` require `regex: true`. Returns matching lines \
757             with file paths and line numbers. Respects .gitignore. Output is truncated at \
758             `limit` matches or 10 KiB of visible output. Visible-cap truncation provides a private saved-output path, or explicit unavailable metadata when storage fails; limit-only and per-line truncation retain native metadata. Long lines are truncated to 500 chars."
759                .to_owned(),
760        ),
761        tool_type: tau_proto::ToolType::Function,
762        parameters: Some(serde_json::json!({
763            "type": "object",
764            "properties": {
765                "pattern": {
766                    "type": "string",
767                    "description": "Search pattern. Treated as a literal string by default. Set `regex: true` to interpret as a regex."
768                },
769                "path": {
770                    "type": "string",
771                    "description": "Directory or file to search (default: current directory)"
772                },
773                "glob": {
774                    "type": "string",
775                    "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.rs'"
776                },
777                "ignoreCase": {
778                    "type": "boolean",
779                    "description": "Case-insensitive search (default: false)"
780                },
781                "regex": {
782                    "type": "boolean",
783                    "description": "Interpret `pattern` as a regex instead of a literal string (default: false)"
784                },
785                "context": {
786                    "type": "integer",
787                    "description": "Number of lines to show before and after each match (default: 0, max: 20)"
788                },
789                "limit": {
790                    "type": "integer",
791                    "description": "Maximum number of matches to return (default: 100, max: 2000)"
792                }
793            },
794            "required": ["pattern"],
795            "additionalProperties": false
796        })),
797        format: None,
798        tags: tool_tags(&[
799            "shell:read",
800            "shell:search",
801            tau_proto::TURN_DATA_FETCH_TOOL_TAG,
802        ]),
803        enabled_by_default: true,
804        background_support: None,
805        examples: vec![ToolExample {
806            id: "search-literal".to_owned(),
807            title: Some("Search literal text".to_owned()),
808            arguments: CborValue::Map(vec![
809                example_field("pattern", example_text("TODO")),
810                example_field("path", example_text("src")),
811                example_field("glob", example_text("**/*.rs")),
812            ]),
813            note: Some("Set regex=true only when pattern is a regular expression.".to_owned()),
814            subcommand: None,
815        }],
816    };
817    let find_tool = ToolSpec {
818        provider_scope: None,
819        name: tau_proto::ToolName::new(FIND_TOOL_NAME),
820        model_visible_name: None,
821        description: Some(
822            "Search for files by glob pattern. Returns only file paths (directories are \
823             never included, even with '**/*') relative to the search directory. Respects \
824             .gitignore. Output is truncated at `limit` results or 10 KiB of visible output. Visible-cap truncation provides a private saved-output path, or explicit unavailable metadata when storage fails; limit-only truncation retains native metadata. Use the ls tool \
825             if you want to see directory entries."
826                .to_owned(),
827        ),
828        tool_type: tau_proto::ToolType::Function,
829        parameters: Some(serde_json::json!({
830            "type": "object",
831            "properties": {
832                "pattern": {
833                    "type": "string",
834                    "description": "Glob pattern matched against file paths relative to `path`. `**` matches any number of intermediate directories, including zero — so `**/*.rs` finds both top-level `a.rs` and nested `src/a.rs`. Directories are not returned, even with `**/*`."
835                },
836                "path": {
837                    "type": "string",
838                    "description": "Directory to search (default: current directory)"
839                },
840                "limit": {
841                    "type": "integer",
842                    "description": "Maximum number of results to return (default: 1000, max: 2000)"
843                }
844            },
845            "required": ["pattern"],
846            "additionalProperties": false
847        })),
848        format: None,
849        tags: tool_tags(&[
850            "shell:read",
851            "shell:search",
852            tau_proto::TURN_DATA_FETCH_TOOL_TAG,
853        ]),
854        enabled_by_default: true,
855        background_support: None,
856        examples: vec![ToolExample {
857            id: "find-rust-files".to_owned(),
858            title: Some("Find files by glob".to_owned()),
859            arguments: CborValue::Map(vec![
860                example_field("pattern", example_text("**/*.rs")),
861                example_field("path", example_text("crates")),
862            ]),
863            note: None,
864            subcommand: None,
865        }],
866    };
867    let ls_tool = ToolSpec {
868        provider_scope: None,
869        name: tau_proto::ToolName::new(LS_TOOL_NAME),
870        model_visible_name: None,
871        description: Some(
872            "List directory contents. Returns entries sorted alphabetically, with '/' suffix \
873             for directories. Includes dotfiles. Output lines are prefixed with 1-based \
874             entry numbers plus flags such as `escaped`, `invalid-utf8`, or `truncated`; \
875             output is capped at `limit` entries, 2000 lines, or 10 KiB of visible output with saved-output metadata and standard truncation headers. \
876             When `limit_reached` is true, entries are a bounded filesystem-order sample sorted \
877             for display, not a complete alphabetic prefix."
878                .to_owned(),
879        ),
880        tool_type: tau_proto::ToolType::Function,
881        parameters: Some(serde_json::json!({
882            "type": "object",
883            "properties": {
884                "path": {
885                    "type": "string",
886                    "description": "Directory to list (default: current directory)"
887                },
888                "limit": {
889                    "type": "integer",
890                    "minimum": 1,
891                    "description": "Maximum number of entries to return (default: 500, max: 2001)"
892                }
893            },
894            "additionalProperties": false
895        })),
896        format: None,
897        tags: tool_tags(&[
898            "shell:read",
899            "shell:list",
900            tau_proto::TURN_DATA_FETCH_TOOL_TAG,
901        ]),
902        enabled_by_default: true,
903        background_support: None,
904        examples: vec![ToolExample {
905            id: "list-directory".to_owned(),
906            title: Some("List a directory".to_owned()),
907            arguments: CborValue::Map(vec![example_field("path", example_text("src"))]),
908            note: None,
909            subcommand: None,
910        }],
911    };
912    let workdir_tool = ToolSpec {
913        provider_scope: None,
914        name: tau_proto::ToolName::new(WORKDIR_TOOL_NAME),
915        model_visible_name: None,
916        description: Some(
917            "Read or change your durable workdir. Omit `path` \
918             to read the current path and availability. A provided path is resolved from the \
919             last committed workdir, validated, canonicalized, and persisted. Do not combine a \
920             workdir change with shell or filesystem calls that rely on the new directory."
921                .to_owned(),
922        ),
923        tool_type: tau_proto::ToolType::Function,
924        parameters: Some(serde_json::json!({
925            "type": "object",
926            "properties": { "path": { "type": "string", "minLength": 1, "description": "Optional directory to persist as this instance's workdir" } },
927            "additionalProperties": false
928        })),
929        format: None,
930        tags: tool_tags(&["shell:workdir", tau_proto::TURN_MANIPULATOR_TOOL_TAG]),
931        enabled_by_default: true,
932        background_support: None,
933        examples: vec![ToolExample {
934            id: "change-directory".to_owned(),
935            title: Some("Change directory".to_owned()),
936            arguments: CborValue::Map(vec![example_field("path", example_text("crates/tau"))]),
937            note: None,
938            subcommand: None,
939        }],
940    };
941    let shell_tool = ToolSpec {
942        provider_scope: None,
943        name: tau_proto::ToolName::new(SHELL_TOOL_NAME),
944        model_visible_name: None,
945        description: Some(
946            "Execute a shell command via `sh -c`. When directory locking is enabled, commands \
947             are inferred read-write only while the agent holds a matching `dir_lock`; otherwise \
948             they are read-only. When directory locking is disabled, shell commands run read-write. \
949             Non-zero exits and timeouts are returned as structured command results with output details. \
950             The native `output` body is capped at 2000 lines / 15 KiB; small result \
951             metadata is deliberately outside that budget, which does not cap fully \
952             rendered provider text. Truncated output keeps the first 1000 and last 1000 lines \
953             separated by a literal `...` line. Output lines are prefixed with `out ` \
954             for stdout or `err ` for stderr; missing trailing newlines are marked, e.g. \
955             `out(no_nl)`; CRLF and CR line endings are marked as `out(crlf)` \
956             or `out(cr)`. Invalid UTF-8 is shown with Unicode replacement characters and \
957             an `invalid-utf8` line flag. Lines that would exceed the 15 KiB output budget \
958             are marker-only, e.g. `err(truncated)`. Truncated results include complete totals, a warning, and normally an exact temporary path to up to 16 MiB of rendered output; output beyond that saved cap is explicitly marked incomplete, while platforms or filesystems that cannot enforce private storage report `saved_output_unavailable: true`. Byte totals and artifacts count the complete rendered UTF-8 records, including stream prefixes, flags, and separators, rather than raw process bytes. \
959             Stdin is closed and commands cannot receive interactive input. Stdout and stderr may be TTY-backed even though no controlling terminal exists. Use explicit noninteractive flags/messages; do not launch prompts, pagers, or editors. \
960             Commands taking longer than 5 seconds include duration metadata. Prefer dedicated \
961             tools like `read`, `grep`, and `find` when they fit."
962                .to_owned(),
963        ),
964        tool_type: tau_proto::ToolType::Function,
965        parameters: Some(serde_json::json!({
966            "type": "object",
967            "properties": {
968                "command": {
969                    "type": "string",
970                    "description": "The shell command to execute"
971                },
972                "timeout": {
973                    "type": "integer",
974                    "minimum": 0,
975                    "description": "Timeout in seconds. The command is killed if it exceeds this. Default: 300"
976                },
977                "cwd": {
978                    "type": "string",
979                    "description": "Working directory for this invocation only. Relative paths resolve from this shell instance's remembered workdir; omission uses the remembered workdir. This does not change later calls; use workdir in an earlier turn to change later calls."
980                }
981            },
982            "required": ["command"],
983            "additionalProperties": false
984        })),
985        format: None,
986        tags: tool_tags(&[
987            "shell:exec",
988            "shell:exec:generic",
989            tau_proto::TURN_MANIPULATOR_TOOL_TAG,
990        ]),
991        enabled_by_default: true,
992        background_support: None,
993        examples: vec![ToolExample {
994            id: "run-command".to_owned(),
995            title: Some("Run a command".to_owned()),
996            arguments: CborValue::Map(vec![
997                example_field("command", example_text("cargo test -p dpc-tau-core")),
998                example_field("timeout", example_int(300)),
999            ]),
1000            note: Some("For file edits, prefer apply_patch when available.".to_owned()),
1001            subcommand: None,
1002        }],
1003    };
1004    let gpt_shell_tool = ToolSpec {
1005        provider_scope: None,
1006        name: tau_proto::ToolName::new(GPT_SHELL_TOOL_NAME),
1007        model_visible_name: Some(tau_proto::ToolName::new("shell_command")),
1008        description: Some(
1009            "Run a shell command. The native `output` body is capped at 2000 lines / 15 KiB; \
1010             small result metadata is deliberately outside that budget, which does not cap fully \
1011             rendered provider text. \
1012             truncated results normally provide an exact temporary path to up to 16 MiB of rendered output and mark an incomplete saved artifact honestly; private-storage failures instead report `saved_output_unavailable: true`. \
1013             Output lines are prefixed with `out ` for stdout or `err ` for stderr; missing \
1014             trailing newlines are marked with `(no_nl)`. Byte totals and artifacts count the complete rendered UTF-8 records, including stream prefixes, flags, and separators, rather than raw process bytes. Stdin is closed and commands cannot receive interactive input. Stdout and stderr may be TTY-backed even though no controlling terminal exists. Use explicit noninteractive flags/messages; do not launch prompts, pagers, or editors. For file changes, prefer apply_patch."
1015                .to_owned(),
1016        ),
1017        tool_type: tau_proto::ToolType::Function,
1018        parameters: Some(serde_json::json!({
1019            "type": "object",
1020            "properties": {
1021                "command": {
1022                    "type": "string",
1023                    "description": "The shell command to execute"
1024                },
1025                "timeout": {
1026                    "type": "integer",
1027                    "description": "Timeout in seconds. The command is killed if it exceeds this. Default: 300"
1028                },
1029                "workdir": {
1030                    "type": "string",
1031                    "description": "Optional working directory for this shell_command invocation only. Relative paths resolve from this shell instance's remembered persistent workdir; omission uses that remembered workdir. This does not change later calls; use the separate top-level workdir(path) tool in an earlier turn to change later calls."
1032                }
1033            },
1034            "required": ["command"],
1035            "additionalProperties": false
1036        })),
1037        format: None,
1038        tags: tool_tags(&[
1039            "shell:exec",
1040            "shell:exec:shell_command",
1041            tau_proto::TURN_MANIPULATOR_TOOL_TAG,
1042        ]),
1043        enabled_by_default: false,
1044        background_support: None,
1045        examples: vec![ToolExample {
1046            id: "run-command".to_owned(),
1047            title: Some("Run a command".to_owned()),
1048            arguments: CborValue::Map(vec![
1049                example_field("command", example_text("cargo test -p dpc-tau-core")),
1050                example_field("timeout", example_int(300)),
1051            ]),
1052            note: Some("For file edits, prefer apply_patch when available.".to_owned()),
1053            subcommand: None,
1054        }],
1055    };
1056    let builtin_tools = [
1057        read_tool,
1058        export_tool,
1059        import_tool,
1060        edit_tool,
1061        replace_tool,
1062        apply_patch_tool,
1063        dir_lock_tool,
1064        grep_tool,
1065        find_tool,
1066        ls_tool,
1067        workdir_tool,
1068        shell_tool,
1069        gpt_shell_tool,
1070    ];
1071    tools.extend(builtin_tools);
1072    tools
1073}
1074
1075fn run_impl<R, W>(
1076    reader: R,
1077    writer: W,
1078    discovery_policy: DiscoverySourcePolicy,
1079    runtime_cwd_source: RuntimeCwdSource,
1080) -> Result<(), Box<dyn Error>>
1081where
1082    R: Read + Send + 'static,
1083    W: Write + Send + 'static,
1084{
1085    let initial_config = ExtConfig::default();
1086    let mut runtime = tau_client::TauExtensionRunner::new(ShellExtension {
1087        initial_config: initial_config.clone(),
1088    })
1089    .start_manual_loop_with_state(reader, writer, |handle| match runtime_cwd_source {
1090        RuntimeCwdSource::Process => ShellRuntime::new_with_artifacts(
1091            Output::client(handle.clone()),
1092            ArtifactTransferManager::new(tau_client::ArtifactClient::new(handle)),
1093            initial_config,
1094            discovery_policy,
1095        ),
1096        #[cfg(any(test, feature = "echo-agent"))]
1097        RuntimeCwdSource::Fixture(fixture_cwd) => ShellRuntime::new_for_test_harness(
1098            Output::client(handle),
1099            initial_config,
1100            discovery_policy,
1101            fixture_cwd,
1102        ),
1103    })?;
1104
1105    let waker = runtime.waker();
1106    runtime.state().install_waker(waker);
1107    let loop_result = run_shell_manual_loop(&mut runtime);
1108
1109    // EOF/disconnect/errors may arrive without a committed SessionShutdown
1110    // event. Wake lock waiters and drop scheduler workers before tau-client
1111    // shuts down its writer, because active worker jobs may be blocked
1112    // inside DirLockManager and worker-held output handles must not enqueue
1113    // after writer shutdown.
1114    runtime.state_mut().final_shutdown();
1115    let finish_result = runtime.finish().map(|_| ());
1116    match (loop_result, finish_result) {
1117        (Ok(()), Ok(())) => Ok(()),
1118        (_, Err(error)) => Err(Box::new(error)),
1119        (Err(error), _) => Err(Box::new(error)),
1120    }
1121}
1122
1123fn run_shell_manual_loop(
1124    runtime: &mut tau_client::ManualExtensionRuntime<ShellRuntime>,
1125) -> tau_client::ClientResult<()> {
1126    loop {
1127        runtime.state_mut().drain_artifact_commands()?;
1128        runtime.state().take_mandatory_output_failure()?;
1129        match runtime.try_recv()? {
1130            tau_client::ManualRuntimePoll::Message(message) => {
1131                if let tau_proto::HarnessOutputMessage::ArtifactResult(result) = message {
1132                    runtime.state_mut().handle_artifact_result(*result)?;
1133                    continue;
1134                }
1135                match runtime.dispatch_one(message)? {
1136                    tau_client::DispatchOutcome::Continue => {}
1137                    tau_client::DispatchOutcome::StopRequested
1138                    | tau_client::DispatchOutcome::Disconnect(_) => return Ok(()),
1139                }
1140            }
1141            tau_client::ManualRuntimePoll::InputClosed => return Ok(()),
1142            tau_client::ManualRuntimePoll::Empty => runtime.wait_for_wake(),
1143        }
1144    }
1145}
1146
1147struct ShellExtension {
1148    initial_config: ExtConfig,
1149}
1150
1151impl tau_client::TauExtension for ShellExtension {
1152    type State = ShellRuntime;
1153
1154    fn name(&self) -> &'static str {
1155        "tau-ext-shell"
1156    }
1157
1158    fn register(self, builder: &mut tau_client::ExtensionBuilder<Self::State>) {
1159        let tools = registered_tool_specs(self.initial_config.dir_lock.enable);
1160
1161        // Replay policy is declared per handler below: historical cwd metadata
1162        // is folded, while effectful tools/actions/cancellation/UI commands and
1163        // session lifecycle publications stay live-only.
1164        let shell_tool_group = tau_proto::ToolGroup {
1165            name: tau_proto::ToolGroupName::new("shell"),
1166            prompt_fragment: None,
1167        };
1168        let test_tool_group = tau_proto::ToolGroup {
1169            name: tau_proto::ToolGroupName::new("test"),
1170            prompt_fragment: None,
1171        };
1172
1173        for tool in tools {
1174            let tool_group = if tool.name.as_str() == "echo" {
1175                test_tool_group.clone()
1176            } else {
1177                shell_tool_group.clone()
1178            };
1179            builder.tool_with_group_and_prompt_fragment(tool, Some(tool_group), None, |cx| {
1180                let local_tool_name = cx.local_tool_name().clone();
1181                cx.state
1182                    .handle_scoped_tool_started(cx.invoke.clone(), &local_tool_name)
1183            });
1184        }
1185        builder
1186            .register_context_provider()
1187            .register_session_context_provider()
1188            .publish_prompt_fragment(shell_workdir_prompt_fragment(&self.initial_config.shell))
1189            .publish_actions(shell_action_schema())
1190            .on_live::<tau_proto::ToolCancelRequest>(|cx| {
1191                cx.state
1192                    .handle_event(Event::ToolCancelRequest(cx.event.clone()), false)
1193            })
1194            .on_raw_live(
1195                tau_proto::EventSelector::Exact(tau_proto::EventName::ACTION_INVOKE),
1196                |cx| cx.state.handle_event(cx.event().clone(), false),
1197            )
1198            .on_restore::<tau_proto::SessionStarted>(|cx| {
1199                cx.state
1200                    .handle_event(Event::SessionStarted(cx.event.clone()), true)
1201            })
1202            .on_live::<tau_proto::SessionStarted>(|cx| {
1203                cx.state
1204                    .handle_event(Event::SessionStarted(cx.event.clone()), false)
1205            })
1206            .on_restore::<tau_proto::SessionAgentLoaded>(|cx| {
1207                cx.state
1208                    .handle_event(Event::SessionAgentLoaded(cx.event.clone()), true)
1209            })
1210            .on_live::<tau_proto::SessionAgentLoaded>(|cx| {
1211                cx.state
1212                    .handle_event(Event::SessionAgentLoaded(cx.event.clone()), false)
1213            })
1214            .on_restore::<tau_proto::SessionAgentUnloaded>(|cx| {
1215                cx.state
1216                    .handle_event(Event::SessionAgentUnloaded(cx.event.clone()), true)
1217            })
1218            .on_live::<tau_proto::SessionAgentUnloaded>(|cx| {
1219                cx.state
1220                    .handle_event(Event::SessionAgentUnloaded(cx.event.clone()), false)
1221            })
1222            .on_live::<tau_proto::AgentReplayComplete>(|cx| {
1223                cx.state
1224                    .handle_event(Event::AgentReplayComplete(cx.event.clone()), false)
1225            })
1226            .on_live::<tau_proto::HarnessAgentDiscoveryRefreshRequested>(|cx| {
1227                cx.state.handle_event(
1228                    Event::HarnessAgentDiscoveryRefreshRequested(cx.event.clone()),
1229                    false,
1230                )
1231            })
1232            .on_live::<tau_proto::HarnessAgentContextInitialized>(|cx| {
1233                cx.state.handle_event(
1234                    Event::HarnessAgentContextInitialized(cx.event.clone()),
1235                    false,
1236                )
1237            })
1238            .on_restore::<tau_proto::AgentMetadataSet>(|cx| {
1239                cx.state
1240                    .handle_event(Event::AgentMetadataSet(cx.event.clone()), true)
1241            })
1242            .on_live::<tau_proto::AgentMetadataSet>(|cx| {
1243                cx.state
1244                    .handle_event(Event::AgentMetadataSet(cx.event.clone()), false)
1245            })
1246            .on_restore::<tau_proto::AgentMetadataUnset>(|cx| {
1247                cx.state
1248                    .handle_event(Event::AgentMetadataUnset(cx.event.clone()), true)
1249            })
1250            .on_live::<tau_proto::AgentMetadataUnset>(|cx| {
1251                cx.state
1252                    .handle_event(Event::AgentMetadataUnset(cx.event.clone()), false)
1253            })
1254            .on_live::<tau_proto::SessionShutdown>(|cx| {
1255                cx.state
1256                    .handle_event(Event::SessionShutdown(cx.event.clone()), false)
1257            })
1258            .on_live::<tau_proto::StartAgentAccepted>(|cx| {
1259                cx.state
1260                    .handle_event(Event::StartAgentAccepted(cx.event.clone()), false)
1261            })
1262            .on_live::<tau_proto::StartAgentResult>(|cx| {
1263                cx.state
1264                    .handle_event(Event::StartAgentResult(cx.event.clone()), false)
1265            })
1266            .on_live::<tau_proto::UiShellCommand>(|cx| {
1267                cx.state
1268                    .handle_event(Event::UiShellCommand(cx.event.clone()), false)
1269            })
1270            .configure_raw(|cx| {
1271                let cfg = cx.parse_config::<ExtConfig>()?;
1272                cx.state.apply_config(
1273                    cx.configure.instance_name.clone(),
1274                    cx.configure.tool_prefix.clone(),
1275                    cfg,
1276                )
1277            })
1278            .ready_message("filesystem and shell tools ready");
1279    }
1280}
1281
1282fn apply_working_directory(
1283    current: &ExtConfig,
1284    next: &ExtConfig,
1285    runtime_started: bool,
1286) -> Result<(), String> {
1287    match (&current.working_directory, &next.working_directory) {
1288        (None, Some(_)) if runtime_started => Err(
1289            "ext-shell working_directory cannot be set after runtime events have started"
1290                .to_owned(),
1291        ),
1292        (None, Some(working_directory)) => set_process_working_directory(working_directory),
1293        (Some(current), Some(next)) if current == next => Ok(()),
1294        (Some(current), Some(next)) => Err(format!(
1295            "ext-shell working_directory cannot be changed after startup (current: {}, requested: {})",
1296            current.display(),
1297            next.display()
1298        )),
1299        _ => Ok(()),
1300    }
1301}
1302
1303fn set_process_working_directory(working_directory: &Path) -> Result<(), String> {
1304    std::env::set_current_dir(working_directory).map_err(|err| {
1305        format!(
1306            "failed to set ext-shell working_directory to {}: {err}",
1307            working_directory.display()
1308        )
1309    })
1310}
1311
1312fn dir_lock_tool_spec(enabled_by_default: bool) -> ToolSpec {
1313    let tags = if enabled_by_default {
1314        tool_tags(&["shell:lock", tau_proto::TURN_WAIT_TOOL_TAG])
1315    } else {
1316        tool_tags(&[tau_proto::TURN_WAIT_TOOL_TAG])
1317    };
1318    ToolSpec {
1319        provider_scope: None,
1320        name: tau_proto::ToolName::new(DIR_LOCK_TOOL_NAME),
1321        model_visible_name: None,
1322        description: Some(
1323            "Lock or unlock a directory and its contents for updates. Waits for the lock when \
1324             necessary."
1325                .to_owned(),
1326        ),
1327        tool_type: tau_proto::ToolType::Function,
1328        parameters: Some(serde_json::json!({
1329            "type": "object",
1330            "properties": {
1331                "command": {
1332                    "type": "string",
1333                    "enum": ["update", "unlock"],
1334                    "description": "Lock or unlock the directory for updates"
1335                },
1336                "directory": {
1337                    "type": "string",
1338                    "description": "Existing directory to canonicalize before locking"
1339                },
1340                "owner_agent_id": {
1341                    "type": "string",
1342                    "description": "Optional owner agent id for force-unlocking a manual lock held by another agent"
1343                }
1344            },
1345            "required": ["command", "directory"],
1346            "additionalProperties": false
1347        })),
1348        format: None,
1349        tags,
1350        enabled_by_default,
1351        background_support: None,
1352        examples: vec![
1353            ToolExample {
1354                id: "update-lock".to_owned(),
1355                title: Some("Acquire update lock".to_owned()),
1356                arguments: CborValue::Map(vec![
1357                    example_field("command", example_text("update")),
1358                    example_field("directory", example_text(".")),
1359                ]),
1360                note: Some(
1361                    "Acquire before making file changes when directory locking is enabled."
1362                        .to_owned(),
1363                ),
1364                subcommand: Some(ToolExampleSelector {
1365                    path: vec!["command".to_owned()],
1366                    value: example_text("update"),
1367                }),
1368            },
1369            ToolExample {
1370                id: "unlock".to_owned(),
1371                title: Some("Release update lock".to_owned()),
1372                arguments: CborValue::Map(vec![
1373                    example_field("command", example_text("unlock")),
1374                    example_field("directory", example_text(".")),
1375                ]),
1376                note: None,
1377                subcommand: Some(ToolExampleSelector {
1378                    path: vec!["command".to_owned()],
1379                    value: example_text("unlock"),
1380                }),
1381            },
1382        ],
1383    }
1384}
1385
1386fn shell_action_schema() -> tau_actions::ActionSchema {
1387    tau_actions::ActionSchema {
1388        version: tau_actions::ACTION_SCHEMA_VERSION,
1389        roots: vec![tau_actions::ActionCommand {
1390            name: ":shell-dir-force-unlock".to_owned(),
1391            description: "Force-release ext-shell manual directory locks overlapping a directory"
1392                .to_owned(),
1393            action_id: Some(SHELL_DIR_FORCE_UNLOCK_ACTION_ID.to_owned()),
1394            args: vec![tau_actions::ActionArg {
1395                name: "directory".to_owned(),
1396                description: "Existing directory whose overlapping manual locks should be released"
1397                    .to_owned(),
1398                required: true,
1399                suggestions: Vec::new(),
1400                kind: tau_actions::ActionArgKind::RestString,
1401            }],
1402            children: Vec::new(),
1403        }],
1404    }
1405}
1406
1407fn dispatch_action_invoke(invoke: ActionInvoke, lock_manager: &DirLockManager) -> Event {
1408    if invoke.action_id != SHELL_DIR_FORCE_UNLOCK_ACTION_ID {
1409        return action_error(invoke, "unknown shell action".to_owned());
1410    }
1411    let Some(directory) = invoke.argv.first().map(String::as_str) else {
1412        return action_error(invoke, "missing directory argument".to_owned());
1413    };
1414    let dir = match crate::dir_lock::canonical_existing_dir(Path::new(directory)) {
1415        Ok(dir) => dir,
1416        Err(message) => return action_error(invoke, message),
1417    };
1418    let removed = match lock_manager.force_unlock_overlapping(&dir) {
1419        Ok(removed) => removed,
1420        Err(message) => {
1421            return action_error(invoke, format!("dir_lock backend error: {message}"));
1422        }
1423    };
1424    if removed.is_empty() {
1425        return action_error(
1426            invoke,
1427            format!("no manual directory locks overlap {}", dir.display()),
1428        );
1429    }
1430
1431    let mut lines = vec![format!(
1432        "Force-unlocked {} manual directory lock(s) overlapping {}.",
1433        removed.len(),
1434        dir.display()
1435    )];
1436    for entry in removed {
1437        lines.push(format!("{} owner={}", entry.dir.display(), entry.owner));
1438    }
1439    Event::ActionResultReported(ActionResult {
1440        invocation_id: invoke.invocation_id,
1441        action_id: invoke.action_id,
1442        output: ActionOutput::Text {
1443            text: lines.join("\n"),
1444        },
1445    })
1446}
1447
1448fn action_error(invoke: ActionInvoke, message: String) -> Event {
1449    Event::ActionErrorReported(ActionError {
1450        invocation_id: invoke.invocation_id,
1451        action_id: invoke.action_id,
1452        message,
1453        details: None,
1454    })
1455}
1456
1457fn rewrite_invoke_for_cwd(
1458    mut invoke: tau_proto::ToolStarted,
1459    base: &Path,
1460) -> tau_proto::ToolStarted {
1461    if invoke.tool_name == WORKDIR_TOOL_NAME {
1462        return invoke;
1463    }
1464    let field = match invoke.tool_name.as_str() {
1465        SHELL_TOOL_NAME => path_crate_tools::ShellSurface::Generic.directory_argument(),
1466        GPT_SHELL_TOOL_NAME => path_crate_tools::ShellSurface::ChatGpt.directory_argument(),
1467        READ_TOOL_NAME | EXPORT_TOOL_NAME | EDIT_TOOL_NAME | REPLACE_TOOL_NAME | FIND_TOOL_NAME
1468        | GREP_TOOL_NAME | LS_TOOL_NAME => "path",
1469        DIR_LOCK_TOOL_NAME => "directory",
1470        _ => return invoke,
1471    };
1472    let explicit_path = cbor_optional_text(&invoke.arguments, field);
1473    if explicit_path.is_none() && cbor_has_field(&invoke.arguments, field) {
1474        // Preserve malformed present values for the surface parser to reject.
1475        return invoke;
1476    }
1477    let Some(path) = explicit_path
1478        .clone()
1479        .or_else(|| matches!(field, "path").then(|| ".".to_owned()))
1480        .or_else(|| {
1481            matches!(
1482                invoke.tool_name.as_str(),
1483                SHELL_TOOL_NAME | GPT_SHELL_TOOL_NAME
1484            )
1485            .then(|| base.display().to_string())
1486        })
1487    else {
1488        return invoke;
1489    };
1490    let path = PathBuf::from(path);
1491    let absolute = if path.is_absolute() {
1492        path
1493    } else {
1494        base.join(path)
1495    };
1496    if let Some(canonical) = canonicalize_existing_dir_for_cwd_field(&absolute, field) {
1497        set_cbor_text_field(
1498            &mut invoke.arguments,
1499            field,
1500            canonical.display().to_string(),
1501        );
1502    } else {
1503        set_cbor_text_field(&mut invoke.arguments, field, absolute.display().to_string());
1504    }
1505    invoke
1506}
1507
1508fn canonicalize_existing_dir_for_cwd_field(path: &Path, field: &str) -> Option<PathBuf> {
1509    (field == "cwd" || field == "workdir" || field == "directory" || field == "path")
1510        .then(|| path.canonicalize().ok())
1511        .flatten()
1512        .filter(|path| path.is_dir())
1513}
1514
1515fn cbor_optional_text(arguments: &CborValue, field: &str) -> Option<String> {
1516    let CborValue::Map(entries) = arguments else {
1517        return None;
1518    };
1519    entries.iter().find_map(|(key, value)| match (key, value) {
1520        (CborValue::Text(key), CborValue::Text(value)) if key == field => Some(value.clone()),
1521        _ => None,
1522    })
1523}
1524
1525fn cbor_has_field(arguments: &CborValue, field: &str) -> bool {
1526    let CborValue::Map(entries) = arguments else {
1527        return false;
1528    };
1529    entries
1530        .iter()
1531        .any(|(key, _)| matches!(key, CborValue::Text(key) if key == field))
1532}
1533
1534fn set_cbor_text_field(arguments: &mut CborValue, field: &str, value: String) {
1535    let CborValue::Map(entries) = arguments else {
1536        return;
1537    };
1538    if let Some((_, existing)) = entries
1539        .iter_mut()
1540        .find(|(key, _)| matches!(key, CborValue::Text(key) if key == field))
1541    {
1542        *existing = CborValue::Text(value);
1543    } else {
1544        entries.push((CborValue::Text(field.to_owned()), CborValue::Text(value)));
1545    }
1546}
1547
1548#[expect(
1549    clippy::too_many_arguments,
1550    reason = "admission receives independently owned scheduler, policy, lifecycle, cwd, and Artifact routes"
1551)]
1552fn schedule_tool_started(
1553    (invoke, local_tool_name): (tau_proto::ToolStarted, &tau_proto::ToolName),
1554    scheduler: &WorkScheduler,
1555    tx: &Output,
1556    config: ExtConfig,
1557    lock_manager: DirLockManager,
1558    cancellation: ToolCancellationState,
1559    cwd_state: CwdState,
1560    artifact_control: ArtifactTransferControl,
1561) -> Result<
1562    (),
1563    Box<(
1564        tool_started_identity::ToolStartedIdentity,
1565        crate::display::ToolFailure,
1566    )>,
1567> {
1568    let (identity, arguments) =
1569        tool_started_identity::ToolStartedIdentity::split(invoke, local_tool_name.clone());
1570    let tx = tx.scoped_tool(
1571        identity.local_tool_name.clone(),
1572        identity.wire_tool_name.clone(),
1573    );
1574    let workdir_snapshot = cwd_state.snapshot(&identity.agent_id).map_err(|message| {
1575        Box::new((
1576            identity.clone(),
1577            path_crate_display::ToolFailure::new(message),
1578        ))
1579    })?;
1580    if matches!(workdir_snapshot, WorkdirSnapshot::Invalid)
1581        && identity.local_tool_name != WORKDIR_TOOL_NAME
1582    {
1583        return Err(Box::new((
1584            identity,
1585            path_crate_display::ToolFailure::new(
1586                "remembered workdir metadata is invalid; repair it with an absolute workdir path",
1587            ),
1588        )));
1589    }
1590    if matches!(workdir_snapshot, WorkdirSnapshot::ReplayFailed) {
1591        return Err(Box::new((
1592            identity,
1593            path_crate_display::ToolFailure::new(
1594                "workdir replay failed for this agent; reload the agent before retrying",
1595            ),
1596        )));
1597    }
1598    if matches!(workdir_snapshot, WorkdirSnapshot::Invalid) {
1599        let requested = cbor_optional_text(&arguments, "path");
1600        if !requested
1601            .as_deref()
1602            .is_none_or(|path| Path::new(path).is_absolute())
1603        {
1604            return Err(Box::new((
1605                identity,
1606                path_crate_display::ToolFailure::new(
1607                    "remembered workdir metadata is invalid; repair it with an absolute workdir path",
1608                ),
1609            )));
1610        }
1611    }
1612    let mut invoke = identity.clone().into_local_started(arguments);
1613    invoke = match &workdir_snapshot {
1614        WorkdirSnapshot::Valid(cwd) => rewrite_invoke_for_cwd(invoke, cwd),
1615        WorkdirSnapshot::Invalid => invoke,
1616        WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
1617    };
1618    if invoke.tool_name == WORKDIR_TOOL_NAME
1619        && cbor_optional_text(&invoke.arguments, "path").is_some()
1620    {
1621        let base = match &workdir_snapshot {
1622            WorkdirSnapshot::Valid(path) => Some(path.as_path()),
1623            WorkdirSnapshot::Invalid => None,
1624            WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
1625        };
1626        let path = path_crate_tools::workdir::target_dir(&invoke.arguments, base)
1627            .map_err(|failure| Box::new((identity.clone(), failure)))?;
1628        cwd_state
1629            .start_pending_workdir_result(
1630                invoke.agent_id.clone(),
1631                path,
1632                identity.clone(),
1633                None,
1634            )
1635            .map_err(|_| {
1636                Box::new((
1637                    identity.clone(),
1638                    path_crate_display::ToolFailure::new(
1639                        "another workdir change is already pending for this agent and shell instance",
1640                    ),
1641                ))
1642            })?;
1643        cwd_state.mark_pending_workdir_awaiting_echo(&invoke.agent_id, &identity.call_id);
1644        let path = cwd_state
1645            .pending_workdir_target(&invoke.agent_id, &identity.call_id)
1646            .expect("newly reserved workdir target");
1647        let mutation_id =
1648            cwd_state.pending_workdir_mutation_id(&invoke.agent_id, &identity.call_id);
1649        if tx
1650            .send_checked(HarnessInputMessage::emit_transient(
1651                Event::AgentMetadataSetRequest(tau_proto::AgentMetadataSet {
1652                    agent_id: invoke.agent_id,
1653                    key: cwd_state.key(),
1654                    value: CborValue::Text(path.display().to_string()),
1655                    mutation_id,
1656                    inheritable: true,
1657                }),
1658            ))
1659            .is_err()
1660        {
1661            let failure =
1662                path_crate_display::ToolFailure::new("failed to request workdir metadata commit");
1663            if send_identity_failure(identity.clone(), failure, &tx).is_ok() {
1664                cwd_state.take_pending_workdir_by_call(&identity.call_id);
1665            }
1666            return Ok(());
1667        }
1668        return Ok(());
1669    }
1670    let priority = priority_for_tool(&invoke, &config);
1671    let meta = WorkMeta {
1672        call_id: Some(invoke.call_id.clone()),
1673        agent_id: Some(invoke.agent_id.clone()),
1674        queued_bytes: approximate_tool_bytes(&invoke, scheduler.queued_bytes_limit()),
1675    };
1676    #[cfg(test)]
1677    tool_started_identity::ownership_probe::record_queued_bytes(
1678        &identity.call_id,
1679        meta.queued_bytes,
1680    );
1681    let tx_for_job = tx.clone();
1682    let lifecycle = cancellation.lifecycles.admit(
1683        invoke.call_id.clone(),
1684        invoke.tool_name.clone(),
1685        invoke.agent_id.clone(),
1686        tx_for_job.clone(),
1687    );
1688    let lifecycle_for_error = lifecycle.clone();
1689    let identity_for_error = identity;
1690    let cwd_state_for_error = cwd_state.clone();
1691    scheduler
1692        .enqueue(priority, meta, move || {
1693            #[cfg(test)]
1694            lifecycle.test_pause_after_dequeue();
1695            if invoke.tool_name == DIR_LOCK_TOOL_NAME {
1696                if lifecycle.start_effect() {
1697                    crate::dir_lock::dispatch_dir_lock_tool(
1698                        invoke,
1699                        &lock_manager,
1700                        config.dir_lock.enable,
1701                        &tx_for_job,
1702                        lifecycle.clone(),
1703                    );
1704                }
1705            } else if config.dir_lock.enable && is_dir_lock_update_tool(invoke.tool_name.as_str()) {
1706                dispatch_locked_tool_invoke(
1707                    invoke,
1708                    ToolDispatchContext {
1709                        shell_config: config.shell,
1710                        tx: tx_for_job.clone(),
1711                        running_calls: Arc::clone(&cancellation.running_calls),
1712                        enforce_ro_bind: config.dir_lock.enforce_ro_bind,
1713                        cwd_state: cwd_state.clone(),
1714                        lifecycle: lifecycle.clone(),
1715                    },
1716                    &lock_manager,
1717                    match &workdir_snapshot {
1718                        WorkdirSnapshot::Valid(cwd) => cwd.clone(),
1719                        WorkdirSnapshot::Invalid => {
1720                            unreachable!("only workdir admits invalid state")
1721                        }
1722                        WorkdirSnapshot::ReplayFailed => {
1723                            unreachable!("replay failures return above")
1724                        }
1725                    },
1726                );
1727            } else if invoke.tool_name == EXPORT_TOOL_NAME || invoke.tool_name == IMPORT_TOOL_NAME {
1728                if lifecycle.start_effect()
1729                    && artifact_control.prepare(
1730                        invoke,
1731                        lifecycle.clone(),
1732                        match &workdir_snapshot {
1733                            WorkdirSnapshot::Valid(cwd) => cwd,
1734                            WorkdirSnapshot::Invalid | WorkdirSnapshot::ReplayFailed => {
1735                                unreachable!("artifact tools require a valid workdir")
1736                            }
1737                        },
1738                        &tx_for_job,
1739                    )
1740                {
1741                    return;
1742                }
1743            } else {
1744                if lifecycle.start_effect() {
1745                    dispatch_tool_invoke(
1746                        invoke,
1747                        ToolDispatchContext {
1748                            shell_config: config.shell,
1749                            tx: tx_for_job.clone(),
1750                            running_calls: Arc::clone(&cancellation.running_calls),
1751                            enforce_ro_bind: config.dir_lock.enforce_ro_bind,
1752                            cwd_state: cwd_state.clone(),
1753                            lifecycle: lifecycle.clone(),
1754                        },
1755                        None,
1756                        config
1757                            .dir_lock
1758                            .enable
1759                            .then_some(ShellCommandMode::visible(ShellAccessMode::ReadOnly)),
1760                        workdir_snapshot.clone(),
1761                    );
1762                }
1763            }
1764            if !tx_for_job.mandatory_output_failed() {
1765                lifecycle.finish();
1766            }
1767        })
1768        .map_err(|error| {
1769            lifecycle_for_error.finish();
1770            cwd_state_for_error.take_pending_workdir_by_call(&identity_for_error.call_id);
1771            Box::new((
1772                identity_for_error,
1773                path_crate_display::ToolFailure::new(error.message),
1774            ))
1775        })
1776}
1777
1778/// Frozen resources needed to enqueue one UI shell command.
1779struct UiShellScheduleContext<'a> {
1780    /// Scheduler that owns the queued command.
1781    scheduler: &'a WorkScheduler,
1782    /// Extension output used to publish command events.
1783    tx: &'a Output,
1784    /// Shell execution policy captured at admission.
1785    shell_config: ShellConfig,
1786    /// Cancellation senders for commands currently executing.
1787    running_ui_commands: Arc<Mutex<HashMap<tau_proto::ShellCommandId, mpsc::Sender<()>>>>,
1788    /// Shutdown authority shared with the runtime teardown handler.
1789    shutdown_generation_counter: Arc<UiShellShutdownGenerationCounter>,
1790    /// Lifecycle generation captured when the command was admitted.
1791    scheduled_generation: UiShellShutdownGeneration,
1792    /// Canonical workdir captured when the command was admitted.
1793    cwd: PathBuf,
1794}
1795
1796fn schedule_ui_shell_command(
1797    cmd: tau_proto::UiShellCommand,
1798    context: UiShellScheduleContext<'_>,
1799) -> Result<(), Box<(tau_proto::UiShellCommand, String)>> {
1800    let UiShellScheduleContext {
1801        scheduler,
1802        tx,
1803        shell_config,
1804        running_ui_commands,
1805        shutdown_generation_counter,
1806        scheduled_generation,
1807        cwd,
1808    } = context;
1809    let meta = WorkMeta {
1810        call_id: None,
1811        agent_id: cmd.target_agent_id.clone(),
1812        queued_bytes: cmd.command.len(),
1813    };
1814    let tx_for_job = tx.clone();
1815    let cmd_for_error = cmd.clone();
1816    let command_id = cmd.command_id.clone();
1817    scheduler
1818        .enqueue(WorkPriority::User, meta, move || {
1819            let (cancel_tx, cancel_rx) = mpsc::channel();
1820            running_ui_commands
1821                .lock()
1822                .expect("running ui shell registry lock poisoned")
1823                .insert(command_id.clone(), cancel_tx.clone());
1824            if shutdown_generation_counter.current() != scheduled_generation {
1825                let _ = cancel_tx.send(());
1826            }
1827            path_crate_tools::shell::dispatch_user_shell_command(
1828                cmd,
1829                shell_config,
1830                &tx_for_job,
1831                cancel_rx,
1832                cwd,
1833            );
1834            running_ui_commands
1835                .lock()
1836                .expect("running ui shell registry lock poisoned")
1837                .remove(&command_id);
1838        })
1839        .map_err(|error| Box::new((cmd_for_error, error.message)))
1840}
1841
1842fn priority_for_tool(invoke: &tau_proto::ToolStarted, config: &ExtConfig) -> WorkPriority {
1843    if invoke.tool_name == DIR_LOCK_TOOL_NAME {
1844        if is_dir_lock_update_invocation(&invoke.arguments) {
1845            return WorkPriority::Bulk;
1846        }
1847        return WorkPriority::Control;
1848    }
1849    if matches!(
1850        invoke.tool_name.as_str(),
1851        READ_TOOL_NAME
1852            | EXPORT_TOOL_NAME
1853            | IMPORT_TOOL_NAME
1854            | GREP_TOOL_NAME
1855            | FIND_TOOL_NAME
1856            | LS_TOOL_NAME
1857    ) {
1858        return WorkPriority::Cheap;
1859    }
1860    if config.dir_lock.enable && is_dir_lock_update_tool(invoke.tool_name.as_str()) {
1861        return WorkPriority::Bulk;
1862    }
1863    WorkPriority::Bulk
1864}
1865
1866fn approximate_tool_bytes(invoke: &tau_proto::ToolStarted, queued_bytes_limit: usize) -> usize {
1867    let cap = queued_bytes_limit.saturating_add(1);
1868    let base = invoke
1869        .call_id
1870        .as_str()
1871        .len()
1872        .saturating_add(invoke.tool_name.as_str().len())
1873        .saturating_add(invoke.agent_id.as_str().len());
1874    saturating_add_capped(base, estimate_cbor_bytes(&invoke.arguments, cap), cap)
1875}
1876
1877fn estimate_cbor_bytes(value: &CborValue, cap: usize) -> usize {
1878    if cap == 0 {
1879        return 0;
1880    }
1881    match value {
1882        CborValue::Integer(_) | CborValue::Float(_) | CborValue::Bool(_) | CborValue::Null => {
1883            8.min(cap)
1884        }
1885        CborValue::Bytes(bytes) => bytes.len().min(cap),
1886        CborValue::Text(text) => text.len().min(cap),
1887        CborValue::Tag(_, inner) => saturating_add_capped(8, estimate_cbor_bytes(inner, cap), cap),
1888        CborValue::Array(values) => estimate_cbor_sequence(values.iter(), cap),
1889        CborValue::Map(entries) => {
1890            let mut total = 1usize;
1891            for (key, value) in entries {
1892                total = saturating_add_capped(total, estimate_cbor_bytes(key, cap - total), cap);
1893                if cap <= total {
1894                    return cap;
1895                }
1896                total = saturating_add_capped(total, estimate_cbor_bytes(value, cap - total), cap);
1897                if cap <= total {
1898                    return cap;
1899                }
1900            }
1901            total
1902        }
1903        _ => 8.min(cap),
1904    }
1905}
1906
1907fn estimate_cbor_sequence<'a>(values: impl Iterator<Item = &'a CborValue>, cap: usize) -> usize {
1908    let mut total = 1usize;
1909    for value in values {
1910        total = saturating_add_capped(total, estimate_cbor_bytes(value, cap - total), cap);
1911        if cap <= total {
1912            return cap;
1913        }
1914    }
1915    total
1916}
1917
1918fn saturating_add_capped(lhs: usize, rhs: usize, cap: usize) -> usize {
1919    lhs.saturating_add(rhs).min(cap)
1920}
1921
1922/// Frozen resources shared by one model tool dispatch.
1923struct ToolDispatchContext {
1924    /// Shell execution policy captured at admission.
1925    shell_config: ShellConfig,
1926    /// Extension output used to publish tool events.
1927    tx: Output,
1928    /// Cancellation senders for tool calls currently executing.
1929    running_calls: Arc<Mutex<HashMap<tau_proto::ToolCallId, mpsc::Sender<()>>>>,
1930    /// Whether read-only shell workdirs must be bind-mounted.
1931    enforce_ro_bind: bool,
1932    /// Per-instance workdir state used by the persistent workdir tool.
1933    cwd_state: CwdState,
1934    /// Shared effect-start and cancellation authority for this admitted call.
1935    lifecycle: ToolLifecycle,
1936}
1937
1938fn dispatch_locked_tool_invoke(
1939    invoke: tau_proto::ToolStarted,
1940    context: ToolDispatchContext,
1941    lock_manager: &DirLockManager,
1942    cwd: PathBuf,
1943) {
1944    let ToolDispatchContext {
1945        shell_config,
1946        tx,
1947        running_calls,
1948        enforce_ro_bind,
1949        cwd_state,
1950        lifecycle,
1951    } = context;
1952    let dirs = match crate::dir_lock::automatic_lock_dirs_for_tool_in_dir(
1953        invoke.tool_name.as_str(),
1954        &invoke.arguments,
1955        &cwd,
1956    ) {
1957        Ok(dirs) => crate::dir_lock::normalize_lock_dirs(dirs),
1958        Err(error) => {
1959            if lifecycle.claim_terminal_before_effect() {
1960                let _ = send_tool_failure(invoke, error, &tx);
1961            }
1962            return;
1963        }
1964    };
1965    let shell_command_mode = is_shell_command_tool(invoke.tool_name.as_str())
1966        .then_some(ShellCommandMode::visible(ShellAccessMode::ReadWrite));
1967
1968    let lock_wait_started = Instant::now();
1969    let wait_progress = crate::dir_lock::waiting_progress(&invoke, &dirs, shell_command_mode);
1970    let wait_tx = tx.clone();
1971    let on_wait = move || {
1972        let _ = wait_tx.report_tool_progress(wait_progress);
1973    };
1974    let guard = match if shell_command_mode.is_some() {
1975        lock_manager.acquire_auto_if_manual_covers(
1976            invoke.call_id.clone(),
1977            invoke.agent_id.clone(),
1978            dirs,
1979            on_wait,
1980        )
1981    } else {
1982        lock_manager.acquire_auto(
1983            invoke.call_id.clone(),
1984            invoke.agent_id.clone(),
1985            dirs,
1986            on_wait,
1987        )
1988    } {
1989        Ok(guard) => guard,
1990        Err(path_crate_dir_lock::LockAcquireError::NotCovered) => {
1991            if lifecycle.start_effect() {
1992                dispatch_tool_invoke(
1993                    invoke,
1994                    ToolDispatchContext {
1995                        shell_config,
1996                        tx,
1997                        running_calls,
1998                        enforce_ro_bind,
1999                        cwd_state,
2000                        lifecycle: lifecycle.clone(),
2001                    },
2002                    None,
2003                    Some(ShellCommandMode::visible(ShellAccessMode::ReadOnly)),
2004                    WorkdirSnapshot::Valid(cwd),
2005                );
2006            }
2007            return;
2008        }
2009        Err(path_crate_dir_lock::LockAcquireError::Cancelled) => {
2010            lifecycle.report_cancelled_before_effect();
2011            return;
2012        }
2013        Err(path_crate_dir_lock::LockAcquireError::Abandoned(lock)) => {
2014            if lifecycle.claim_terminal_before_effect() {
2015                let _ = send_tool_failure(invoke, lock.tool_failure(), &tx);
2016            }
2017            return;
2018        }
2019        Err(path_crate_dir_lock::LockAcquireError::SelfConflict {
2020            uncovered_dir,
2021            held_dir,
2022        }) => {
2023            if lifecycle.claim_terminal_before_effect() {
2024                let _ = send_tool_failure(
2025                    invoke,
2026                    path_crate_display::ToolFailure::new(format!(
2027                        "automatic directory lock is outside your manual lock coverage: requested {}; held {}",
2028                        uncovered_dir.display(),
2029                        held_dir.display()
2030                    )),
2031                    &tx,
2032                );
2033            }
2034            return;
2035        }
2036        Err(path_crate_dir_lock::LockAcquireError::Backend(message)) => {
2037            if lifecycle.claim_terminal_before_effect() {
2038                let _ = send_tool_failure(
2039                    invoke,
2040                    path_crate_display::ToolFailure::new(format!(
2041                        "dir_lock backend error: {message}"
2042                    )),
2043                    &tx,
2044                );
2045            }
2046            return;
2047        }
2048    };
2049
2050    let lock_wait_duration_seconds =
2051        reported_lock_wait_duration_seconds(lock_wait_started.elapsed());
2052    #[cfg(test)]
2053    lifecycle.test_pause_after_lock();
2054    if lifecycle.start_effect() {
2055        dispatch_tool_invoke(
2056            invoke,
2057            ToolDispatchContext {
2058                shell_config,
2059                tx,
2060                running_calls,
2061                enforce_ro_bind,
2062                cwd_state,
2063                lifecycle: lifecycle.clone(),
2064            },
2065            lock_wait_duration_seconds,
2066            shell_command_mode,
2067            WorkdirSnapshot::Valid(cwd),
2068        );
2069    }
2070    drop(guard);
2071}
2072
2073fn send_ui_shell_saturated_failure(cmd: tau_proto::UiShellCommand, message: String, tx: &Output) {
2074    let _ = tx.send_checked(HarnessInputMessage::emit(
2075        Event::ShellCommandFinishedReported(tau_proto::ShellCommandFinished {
2076            command_id: cmd.command_id,
2077            session_id: cmd.session_id,
2078            command: cmd.command,
2079            include_in_context: cmd.include_in_context,
2080            target_agent_id: cmd.target_agent_id,
2081            output: message,
2082            exit_code: None,
2083            cancelled: false,
2084        }),
2085    ));
2086}
2087
2088fn send_identity_failure(
2089    identity: tool_started_identity::ToolStartedIdentity,
2090    failure: crate::display::ToolFailure,
2091    tx: &Output,
2092) -> tau_client::ClientResult<()> {
2093    let crate::display::ToolFailure {
2094        message,
2095        details,
2096        display,
2097    } = failure;
2098    tx.report_tool_terminal(Event::ToolError(tau_proto::ToolError {
2099        presentation: Default::default(),
2100        call_id: identity.call_id,
2101        tool_name: identity.wire_tool_name,
2102        tool_type: tau_proto::ToolType::Function,
2103        message,
2104        details: details.map(|details| *details),
2105        display: Some(*display),
2106        originator: identity.originator,
2107    }))
2108}
2109
2110fn send_tool_failure(
2111    invoke: tau_proto::ToolStarted,
2112    failure: crate::display::ToolFailure,
2113    tx: &Output,
2114) -> tau_client::ClientResult<()> {
2115    send_identity_failure(invoke.into(), failure, tx)
2116}
2117
2118fn reported_lock_wait_duration_seconds(elapsed: Duration) -> Option<u64> {
2119    if elapsed <= Duration::from_secs(SLOW_LOCK_WAIT_THRESHOLD_SECS) {
2120        return None;
2121    }
2122
2123    let whole_seconds = elapsed.as_secs();
2124    if Duration::from_secs(whole_seconds) < elapsed {
2125        Some(whole_seconds.saturating_add(1))
2126    } else {
2127        Some(whole_seconds)
2128    }
2129}
2130
2131fn with_lock_wait_duration(event: Event, lock_wait_duration_seconds: Option<u64>) -> Event {
2132    let Some(seconds) = lock_wait_duration_seconds else {
2133        return event;
2134    };
2135
2136    match event {
2137        Event::ToolResult(mut result) => {
2138            result.result = cbor_value_with_lock_wait_duration(result.result, seconds, "output");
2139            Event::ToolResult(result)
2140        }
2141        Event::ToolError(mut error) => {
2142            error.details = Some(match error.details {
2143                Some(details) => cbor_value_with_lock_wait_duration(details, seconds, "details"),
2144                None => CborValue::Map(vec![lock_wait_duration_entry(seconds)]),
2145            });
2146            Event::ToolError(error)
2147        }
2148        event => event,
2149    }
2150}
2151
2152fn cbor_value_with_lock_wait_duration(
2153    value: CborValue,
2154    seconds: u64,
2155    non_map_payload_key: &str,
2156) -> CborValue {
2157    match value {
2158        CborValue::Map(mut entries) => {
2159            prepend_lock_wait_duration(&mut entries, seconds);
2160            CborValue::Map(entries)
2161        }
2162        value => CborValue::Map(vec![
2163            lock_wait_duration_entry(seconds),
2164            (CborValue::Text(non_map_payload_key.to_owned()), value),
2165        ]),
2166    }
2167}
2168
2169fn prepend_lock_wait_duration(entries: &mut Vec<(CborValue, CborValue)>, seconds: u64) {
2170    entries.retain(|(key, _)| match key {
2171        CborValue::Text(key) => key != LOCK_WAIT_DURATION_SECONDS_HEADER,
2172        _ => true,
2173    });
2174    entries.insert(0, lock_wait_duration_entry(seconds));
2175}
2176
2177fn lock_wait_duration_entry(seconds: u64) -> (CborValue, CborValue) {
2178    let seconds = i64::try_from(seconds).unwrap_or(i64::MAX);
2179    (
2180        CborValue::Text(LOCK_WAIT_DURATION_SECONDS_HEADER.to_owned()),
2181        CborValue::Integer(seconds.into()),
2182    )
2183}
2184
2185/// Execute a single tool invocation and send the response event(s).
2186fn dispatch_tool_invoke(
2187    mut invoke: tau_proto::ToolStarted,
2188    context: ToolDispatchContext,
2189    lock_wait_duration_seconds: Option<u64>,
2190    shell_command_mode: Option<ShellCommandMode>,
2191    workdir_snapshot: WorkdirSnapshot,
2192) {
2193    let ToolDispatchContext {
2194        shell_config,
2195        tx,
2196        running_calls,
2197        enforce_ro_bind,
2198        cwd_state,
2199        lifecycle,
2200    } = context;
2201    if invoke.tool_name == WORKDIR_TOOL_NAME {
2202        if cbor_optional_text(&invoke.arguments, "path").is_none() {
2203            let output = path_crate_tools::workdir::status_output(match &workdir_snapshot {
2204                WorkdirSnapshot::Valid(path) => Some(path.as_path()),
2205                WorkdirSnapshot::Invalid => None,
2206                WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
2207            });
2208            let _ = tx.report_tool_terminal(Event::ToolResult(ToolResult {
2209                presentation: Default::default(),
2210                call_id: invoke.call_id,
2211                tool_name: invoke.tool_name,
2212                tool_type: tau_proto::ToolType::Function,
2213                result: output.result,
2214                provider_content: output.provider_content,
2215                kind: ToolResultKind::Final,
2216                display: Some(output.display),
2217                originator: invoke.originator,
2218            }));
2219            return;
2220        }
2221        let agent_id = invoke.agent_id.clone();
2222        if let Some(path) = cwd_state.pending_workdir_target(&agent_id, &invoke.call_id) {
2223            if cwd_state.mark_pending_workdir_awaiting_echo(&agent_id, &invoke.call_id) {
2224                let metadata = Event::AgentMetadataSetRequest(tau_proto::AgentMetadataSet {
2225                    agent_id,
2226                    key: cwd_state.key(),
2227                    value: CborValue::Text(path.display().to_string()),
2228                    mutation_id: None,
2229                    inheritable: true,
2230                });
2231                let _ = tx.send_checked(HarnessInputMessage::emit_transient(metadata));
2232            }
2233            return;
2234        }
2235        // Every setter is reserved and validated at admission. A missing
2236        // reservation means cancellation or lifecycle cleanup won the race.
2237        return;
2238    }
2239    let tool_cwd = match workdir_snapshot {
2240        WorkdirSnapshot::Valid(cwd) => cwd,
2241        WorkdirSnapshot::Invalid => unreachable!("non-workdir calls reject invalid state"),
2242        WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
2243    };
2244    if matches!(
2245        invoke.tool_name.as_str(),
2246        READ_TOOL_NAME
2247            | EDIT_TOOL_NAME
2248            | GREP_TOOL_NAME
2249            | FIND_TOOL_NAME
2250            | LS_TOOL_NAME
2251            | SHELL_TOOL_NAME
2252            | GPT_SHELL_TOOL_NAME
2253    ) {
2254        crate::shell_output_spool::note_call();
2255    }
2256    let (world, authorized_cwd) = match world_after_shell_authorization(
2257        &mut invoke,
2258        &shell_config,
2259        tau_vcr::VcrConfig::from_env(),
2260        tool_cwd,
2261    ) {
2262        Ok(world) => world,
2263        Err(crate::display::ToolFailure {
2264            message,
2265            details,
2266            display,
2267        }) => {
2268            let event = Event::ToolError(tau_proto::ToolError {
2269                presentation: Default::default(),
2270                call_id: invoke.call_id.clone(),
2271                tool_name: invoke.tool_name.clone(),
2272                tool_type: tau_proto::ToolType::Function,
2273                message,
2274                details: details.map(|details| *details),
2275                display: Some(*display),
2276                originator: invoke.originator.clone(),
2277            });
2278            let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2279            let _ = tx.report_tool_terminal(event);
2280            return;
2281        }
2282    };
2283
2284    if invoke.tool_name == SHELL_TOOL_NAME || invoke.tool_name == GPT_SHELL_TOOL_NAME {
2285        dispatch_cancellable_shell_tool(CancellableShellDispatch {
2286            invoke,
2287            shell_config,
2288            tx: &tx,
2289            running_calls: &running_calls,
2290            lifecycle: &lifecycle,
2291            lock_wait_duration_seconds,
2292            shell_command_mode: shell_command_mode.unwrap_or(ShellCommandMode::READ_WRITE_HIDDEN),
2293            enforce_ro_bind,
2294            world,
2295            authorized_cwd,
2296        });
2297        return;
2298    }
2299
2300    if invoke.tool_name == GREP_TOOL_NAME || invoke.tool_name == FIND_TOOL_NAME {
2301        dispatch_cancellable_non_shell_tool(
2302            invoke,
2303            &tx,
2304            &running_calls,
2305            &lifecycle,
2306            lock_wait_duration_seconds,
2307            world,
2308        );
2309        return;
2310    }
2311
2312    if let Some(display) = crate::tools::initial_display(&invoke) {
2313        let _ = tx.report_tool_progress(tau_proto::ToolProgress {
2314            call_id: invoke.call_id.clone(),
2315            tool_name: invoke.tool_name.clone(),
2316            message: None,
2317            progress: None,
2318            display: Some(display),
2319        });
2320    }
2321
2322    let events = execute_tool(invoke, world);
2323    for event in events {
2324        let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2325        let _ = tx.report_tool_terminal(event);
2326    }
2327}
2328
2329/// Authorize shell invocations before opening VCR state, then construct the
2330/// execution world shared by shell and non-shell tools.
2331fn world_after_shell_authorization(
2332    invoke: &mut tau_proto::ToolStarted,
2333    shell_config: &ShellConfig,
2334    vcr_config: Option<tau_vcr::VcrConfig>,
2335    tool_cwd: PathBuf,
2336) -> Result<(path_crate_tools_world::ShellWorld, Option<PathBuf>), crate::display::ToolFailure> {
2337    let authorized_cwd = if let Some(surface) =
2338        path_crate_tools::ShellSurface::for_tool_name(invoke.tool_name.as_str())
2339    {
2340        path_crate_tools::shell::prepare_tool_invocation(surface, &invoke.arguments, shell_config)?
2341    } else {
2342        None
2343    };
2344    if let Some(surface) = path_crate_tools::ShellSurface::for_tool_name(invoke.tool_name.as_str())
2345        && let Some(canonical_cwd) = authorized_cwd.as_ref()
2346    {
2347        set_cbor_text_field(
2348            &mut invoke.arguments,
2349            surface.directory_argument(),
2350            canonical_cwd.display().to_string(),
2351        );
2352    }
2353    let world = path_crate_tools_world::ShellWorld::for_tool_in_dir(
2354        invoke.tool_name.as_str(),
2355        invoke.call_id.as_str(),
2356        &invoke.arguments,
2357        vcr_config,
2358        tool_cwd,
2359    )?;
2360    Ok((world, authorized_cwd))
2361}
2362
2363fn dispatch_cancellable_non_shell_tool(
2364    invoke: tau_proto::ToolStarted,
2365    tx: &Output,
2366    running_calls: &Arc<Mutex<HashMap<tau_proto::ToolCallId, mpsc::Sender<()>>>>,
2367    lifecycle: &ToolLifecycle,
2368    lock_wait_duration_seconds: Option<u64>,
2369    world: path_crate_tools::world::ShellWorld,
2370) {
2371    #[cfg(test)]
2372    lifecycle.test_pause_before_active_registration();
2373    let (cancel_tx, cancel_rx) = mpsc::channel();
2374    running_calls
2375        .lock()
2376        .expect("running call registry lock poisoned")
2377        .insert(invoke.call_id.clone(), cancel_tx.clone());
2378    if lifecycle.effect_cancel_requested() {
2379        let _ = cancel_tx.send(());
2380    }
2381
2382    if let Some(display) = crate::tools::initial_display(&invoke) {
2383        let _ = tx.report_tool_progress(tau_proto::ToolProgress {
2384            call_id: invoke.call_id.clone(),
2385            tool_name: invoke.tool_name.clone(),
2386            message: None,
2387            progress: None,
2388            display: Some(display),
2389        });
2390    }
2391
2392    let call_id = invoke.call_id.clone();
2393    let tool_name = invoke.tool_name.clone();
2394    let outcome = crate::tools::execute_cancellable_tool(invoke, world, cancel_rx);
2395
2396    running_calls
2397        .lock()
2398        .expect("running call registry lock poisoned")
2399        .remove(&call_id);
2400
2401    match outcome {
2402        path_crate_tools::CancellableToolOutcome::Finished(events) => {
2403            for event in events {
2404                let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2405                let _ = tx.report_tool_terminal(event);
2406            }
2407        }
2408        path_crate_tools::CancellableToolOutcome::Cancelled => {
2409            let event = Event::ToolCancelled(ToolCancelled {
2410                presentation: Default::default(),
2411                call_id,
2412                tool_name,
2413                tool_type: tau_proto::ToolType::Function,
2414                display: None,
2415            });
2416            let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2417            let _ = tx.report_tool_terminal(event);
2418        }
2419    }
2420}
2421
2422/// Parameters needed to run a cancellable shell-like tool invocation.
2423struct CancellableShellDispatch<'a> {
2424    /// Tool invocation emitted by the harness.
2425    invoke: tau_proto::ToolStarted,
2426    /// Effective shell execution configuration for this invocation.
2427    shell_config: ShellConfig,
2428    /// Channel used to send progress and terminal events back to the harness.
2429    tx: &'a Output,
2430    /// Shared registry used by cancel requests to signal running shell
2431    /// processes.
2432    running_calls: &'a Arc<Mutex<HashMap<tau_proto::ToolCallId, mpsc::Sender<()>>>>,
2433    /// Lifecycle authority that bridges effect start to sender registration.
2434    lifecycle: &'a ToolLifecycle,
2435    /// Seconds spent waiting on a directory lock before this invocation ran.
2436    lock_wait_duration_seconds: Option<u64>,
2437    /// Display and access mode chosen for the shell command.
2438    shell_command_mode: ShellCommandMode,
2439    /// Whether read-only commands should run under the native read-only bind
2440    /// guard.
2441    enforce_ro_bind: bool,
2442    /// Tool execution world carrying the cwd and recorded side effects.
2443    world: path_crate_tools::world::ShellWorld,
2444    /// Canonical operational cwd retained from pre-VCR authorization.
2445    authorized_cwd: Option<PathBuf>,
2446}
2447
2448fn dispatch_cancellable_shell_tool(params: CancellableShellDispatch<'_>) {
2449    let CancellableShellDispatch {
2450        invoke,
2451        shell_config,
2452        tx,
2453        running_calls,
2454        lifecycle,
2455        lock_wait_duration_seconds,
2456        shell_command_mode,
2457        enforce_ro_bind,
2458        mut world,
2459        authorized_cwd,
2460    } = params;
2461    #[cfg(test)]
2462    lifecycle.test_pause_before_active_registration();
2463    let (cancel_tx, cancel_rx) = mpsc::channel();
2464    debug!(
2465        call_id = %invoke.call_id,
2466        tool_name = %invoke.tool_name,
2467        "registering cancellable shell call"
2468    );
2469    running_calls
2470        .lock()
2471        .expect("running call registry lock poisoned")
2472        .insert(invoke.call_id.clone(), cancel_tx.clone());
2473    if lifecycle.effect_cancel_requested() {
2474        let _ = cancel_tx.send(());
2475    }
2476
2477    let _ = tx.report_tool_progress(tau_proto::ToolProgress {
2478        call_id: invoke.call_id.clone(),
2479        tool_name: invoke.tool_name.clone(),
2480        message: None,
2481        progress: None,
2482        display: Some(path_crate_tools::shell::initial_display(
2483            &invoke.arguments,
2484            shell_command_mode,
2485        )),
2486    });
2487    let result = path_crate_tools::shell::run_command_cancellable_for_tool(
2488        path_crate_tools::shell::ShellInvocation {
2489            surface: path_crate_tools::ShellSurface::for_tool_name(invoke.tool_name.as_str())
2490                .expect("shell dispatch accepts only known shell tools"),
2491            call_id: invoke.call_id.as_str(),
2492            arguments: &invoke.arguments,
2493            authorized_cwd: authorized_cwd.as_deref(),
2494        },
2495        &shell_config,
2496        shell_command_mode,
2497        enforce_ro_bind,
2498        Some(cancel_rx),
2499        &mut world,
2500    );
2501    let outcome = match (result, world.finish()) {
2502        (Ok(outcome), Ok(())) => Ok(outcome),
2503        (Ok(_), Err(failure)) | (Err(failure), Ok(())) | (Err(failure), Err(_)) => Err(failure),
2504    };
2505    let event = match outcome {
2506        Ok(path_crate_tools_shell::CommandOutcome::Finished(output)) => {
2507            debug!(call_id = %invoke.call_id, tool_name = %invoke.tool_name, "cancellable shell call finished");
2508            Event::ToolResult(ToolResult {
2509                presentation: Default::default(),
2510                call_id: invoke.call_id.clone(),
2511                tool_name: invoke.tool_name.clone(),
2512                tool_type: tau_proto::ToolType::Function,
2513                result: output.result,
2514                provider_content: Vec::new(),
2515                kind: ToolResultKind::Final,
2516                display: Some(output.display),
2517                originator: invoke.originator.clone(),
2518            })
2519        }
2520        Ok(path_crate_tools_shell::CommandOutcome::Cancelled) => {
2521            debug!(call_id = %invoke.call_id, tool_name = %invoke.tool_name, "cancellable shell call cancelled");
2522            Event::ToolCancelled(ToolCancelled {
2523                presentation: Default::default(),
2524                call_id: invoke.call_id.clone(),
2525                tool_name: invoke.tool_name.clone(),
2526                tool_type: tau_proto::ToolType::Function,
2527                display: None,
2528            })
2529        }
2530        Err(crate::display::ToolFailure {
2531            message,
2532            details,
2533            display,
2534        }) => {
2535            debug!(
2536                call_id = %invoke.call_id,
2537                tool_name = %invoke.tool_name,
2538                message,
2539                "cancellable shell call failed"
2540            );
2541            Event::ToolError(tau_proto::ToolError {
2542                presentation: Default::default(),
2543                call_id: invoke.call_id.clone(),
2544                tool_name: invoke.tool_name.clone(),
2545                tool_type: tau_proto::ToolType::Function,
2546                message,
2547                details: details.map(|details| *details),
2548                display: Some(*display),
2549                originator: invoke.originator.clone(),
2550            })
2551        }
2552    };
2553
2554    running_calls
2555        .lock()
2556        .expect("running call registry lock poisoned")
2557        .remove(&invoke.call_id);
2558    trace!(call_id = %invoke.call_id, "removed shell call from cancellation registry");
2559    let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2560    if tx.report_tool_terminal(event).is_err() {
2561        debug!(call_id = %invoke.call_id, "failed to send terminal shell event to harness");
2562    }
2563}
2564
2565fn dispatch_session_started(
2566    started: SessionStarted,
2567    tx: &Output,
2568    discovery_policy: DiscoverySourcePolicy,
2569) -> tau_client::ClientResult<()> {
2570    let session_id = started.session_id.clone();
2571    let scan = build_discovery_snapshot(started, discovery_policy);
2572    for diagnostic in scan.diagnostics {
2573        let _ = tx.send(diagnostic);
2574    }
2575    dispatch_session_discovery_messages(
2576        session_id,
2577        vec![HarnessInputMessage::emit_transient(
2578            Event::ExtensionSessionDiscoverySnapshotDeclared(scan.snapshot),
2579        )],
2580        tx,
2581    )
2582}
2583
2584/// Publish one ordered session-discovery batch followed by its readiness
2585/// acknowledgement.
2586fn dispatch_session_discovery_messages(
2587    session_id: tau_proto::SessionId,
2588    messages: Vec<HarnessInputMessage>,
2589    tx: &Output,
2590) -> tau_client::ClientResult<()> {
2591    for message in messages {
2592        tx.send_checked(message)?;
2593    }
2594    tx.send_checked(HarnessInputMessage::emit_transient(
2595        Event::ExtensionSessionContextReady(ExtensionSessionContextReady { session_id }),
2596    ))
2597}
2598
2599fn apply_started_cwd_metadata(
2600    started: tau_proto::AgentStarted,
2601    tx: &Output,
2602    cwd_state: &CwdState,
2603    is_replay: bool,
2604) -> tau_client::ClientResult<()> {
2605    for item in started.metadata {
2606        if item.key == cwd_state.key() {
2607            if let CborValue::Text(path) = item.value {
2608                let cwd = PathBuf::from(path);
2609                if cwd_state.set_metadata_text(started.agent_id.clone(), cwd.clone())
2610                    && !is_replay
2611                    && let Some((session_id, initialization_id)) =
2612                        cwd_state.initialization(&started.agent_id)
2613                {
2614                    tx.send_checked(HarnessInputMessage::emit_transient(cwd_context_event(
2615                        session_id,
2616                        started.agent_id.clone(),
2617                        initialization_id,
2618                        &cwd,
2619                        cwd_state,
2620                    )))?;
2621                }
2622            } else {
2623                cwd_state.set_invalid(started.agent_id.clone());
2624            }
2625        }
2626    }
2627    Ok(())
2628}
2629
2630fn dispatch_session_agent_loaded(
2631    loaded: SessionAgentLoaded,
2632    tx: &Output,
2633    cwd_state: &CwdState,
2634    defer_default_until_replay_complete: bool,
2635    discovery_policy: DiscoverySourcePolicy,
2636) -> tau_client::ClientResult<()> {
2637    if defer_default_until_replay_complete {
2638        cwd_state.set_pending_ready(
2639            loaded.agent_id,
2640            loaded.session_id,
2641            loaded.agent_initialization_id,
2642        );
2643        return Ok(());
2644    }
2645    publish_agent_discovery_snapshot(&loaded, tx, discovery_policy)?;
2646    if let Some(cwd) = cwd_state.get(&loaded.agent_id) {
2647        tx.send_checked(HarnessInputMessage::emit_transient(cwd_context_event(
2648            loaded.session_id.clone(),
2649            loaded.agent_id.clone(),
2650            loaded.agent_initialization_id.clone(),
2651            &cwd,
2652            cwd_state,
2653        )))?;
2654        tx.send_checked(HarnessInputMessage::emit_transient(
2655            Event::ExtensionContextReady(ExtensionContextReady {
2656                session_id: loaded.session_id,
2657                agent_id: loaded.agent_id,
2658                agent_initialization_id: loaded.agent_initialization_id,
2659            }),
2660        ))?;
2661        return Ok(());
2662    }
2663
2664    cwd_state.set_pending_ready(
2665        loaded.agent_id.clone(),
2666        loaded.session_id,
2667        loaded.agent_initialization_id,
2668    );
2669    let Ok(cwd) = cwd_state.process_default() else {
2670        return Ok(());
2671    };
2672    tx.send_checked(HarnessInputMessage::emit_transient(
2673        Event::AgentMetadataSetRequest(tau_proto::AgentMetadataSet {
2674            agent_id: loaded.agent_id,
2675            key: cwd_state.key(),
2676            value: CborValue::Text(cwd.display().to_string()),
2677            mutation_id: None,
2678            inheritable: true,
2679        }),
2680    ))
2681}
2682
2683fn cwd_context_event(
2684    session_id: tau_proto::SessionId,
2685    agent_id: tau_proto::AgentId,
2686    agent_initialization_id: tau_proto::AgentInitializationId,
2687    cwd: &Path,
2688    cwd_state: &CwdState,
2689) -> Event {
2690    let status = if cwd.is_dir() {
2691        "available"
2692    } else {
2693        "unavailable"
2694    };
2695    Event::ExtAgentContextPublish(ExtAgentContextPublish {
2696        session_id,
2697        agent_id,
2698        agent_initialization_id,
2699        key: AgentContextKey::new("workdir"),
2700        value: AgentContextValue(serde_json::json!({
2701            "label": cwd_state.context_label(),
2702            "path": cwd.display().to_string(),
2703            "status": status,
2704        })),
2705    })
2706}
2707
2708fn invalid_cwd_context_event(
2709    session_id: tau_proto::SessionId,
2710    agent_id: tau_proto::AgentId,
2711    agent_initialization_id: tau_proto::AgentInitializationId,
2712    cwd_state: &CwdState,
2713) -> Event {
2714    Event::ExtAgentContextPublish(ExtAgentContextPublish {
2715        session_id,
2716        agent_id,
2717        agent_initialization_id,
2718        key: AgentContextKey::new("workdir"),
2719        value: AgentContextValue(serde_json::json!({
2720            "label": cwd_state.context_label(),
2721            "path": "<invalid>",
2722            "status": "invalid",
2723        })),
2724    })
2725}
2726
2727fn cwd_notice_event(agent_id: tau_proto::AgentId, cwd: &Path) -> Event {
2728    Event::AgentUserMessageInjected(tau_proto::AgentUserMessageInjected {
2729        inference_activation: false,
2730        agent_id,
2731        text: format!("Your working directory changed to {}.", cwd.display()),
2732        message_class: tau_proto::PromptMessageClass::Internal,
2733    })
2734}
2735
2736fn is_shell_tool(name: &str) -> bool {
2737    matches!(
2738        name,
2739        READ_TOOL_NAME
2740            | EXPORT_TOOL_NAME
2741            | IMPORT_TOOL_NAME
2742            | EDIT_TOOL_NAME
2743            | REPLACE_TOOL_NAME
2744            | APPLY_PATCH_TOOL_NAME
2745            | GREP_TOOL_NAME
2746            | FIND_TOOL_NAME
2747            | LS_TOOL_NAME
2748            | WORKDIR_TOOL_NAME
2749            | SHELL_TOOL_NAME
2750            | GPT_SHELL_TOOL_NAME
2751            | DIR_LOCK_TOOL_NAME
2752    ) || is_echo_tool(name)
2753}
2754
2755fn is_dir_lock_update_invocation(arguments: &CborValue) -> bool {
2756    crate::argument::optional_argument_text(arguments, "command")
2757        .ok()
2758        .flatten()
2759        .as_deref()
2760        == Some("update")
2761}
2762
2763fn is_dir_lock_update_tool(name: &str) -> bool {
2764    matches!(
2765        name,
2766        EDIT_TOOL_NAME
2767            | REPLACE_TOOL_NAME
2768            | APPLY_PATCH_TOOL_NAME
2769            | SHELL_TOOL_NAME
2770            | GPT_SHELL_TOOL_NAME
2771    )
2772}
2773
2774fn is_shell_command_tool(name: &str) -> bool {
2775    matches!(name, SHELL_TOOL_NAME | GPT_SHELL_TOOL_NAME)
2776}
2777
2778#[cfg(any(test, feature = "echo-agent"))]
2779fn is_echo_tool(name: &str) -> bool {
2780    name == ECHO_TOOL_NAME
2781}
2782
2783#[cfg(not(any(test, feature = "echo-agent")))]
2784fn is_echo_tool(_name: &str) -> bool {
2785    false
2786}
2787
2788fn build_discovery_snapshot(
2789    _started: SessionStarted,
2790    discovery_policy: DiscoverySourcePolicy,
2791) -> DiscoveryScan {
2792    if discovery_policy.reads_environment() {
2793        return discovery_source::DiscoverySource::new(dirs::home_dir())
2794            .scan_project(_started.session_id, std::env::current_dir().ok().as_deref());
2795    }
2796    DiscoveryScan {
2797        snapshot: ExtensionSessionDiscoverySnapshotDeclared {
2798            frontmatter_diagnostics: Vec::new(),
2799            session_id: _started.session_id,
2800            skills: Vec::new(),
2801            agents_files: Vec::new(),
2802        },
2803        diagnostics: Vec::new(),
2804    }
2805}
2806
2807fn discovery_skill_candidate(
2808    skill: tau_skills::Skill,
2809    modified: Option<std::time::SystemTime>,
2810) -> DiscoverySkillCandidate {
2811    let file_path = skill.file_path.canonicalize().unwrap_or(skill.file_path);
2812    let sampled_modified = modified.and_then(system_time_to_discovery_micros);
2813    DiscoverySkillCandidate {
2814        visibility: skill.visibility,
2815        name: skill.name,
2816        description: skill.description,
2817        file_path,
2818        add_to_prompt: skill.add_to_prompt,
2819        user_invocable: skill.user_invocable,
2820        disable_model_invocation: skill.disable_model_invocation,
2821        argument_hint: skill.argument_hint,
2822        sampled_modified,
2823    }
2824}
2825
2826fn system_time_to_discovery_micros(time: std::time::SystemTime) -> Option<DiscoveryModifiedMicros> {
2827    match time.duration_since(std::time::UNIX_EPOCH) {
2828        Ok(duration) => i64::try_from(duration.as_micros())
2829            .ok()
2830            .map(DiscoveryModifiedMicros::new),
2831        Err(error) => i64::try_from(error.duration().as_micros())
2832            .ok()
2833            .and_then(i64::checked_neg)
2834            .map(DiscoveryModifiedMicros::new),
2835    }
2836}
2837
2838fn publish_agent_discovery_snapshot(
2839    loaded: &SessionAgentLoaded,
2840    tx: &Output,
2841    discovery_policy: DiscoverySourcePolicy,
2842) -> tau_client::ClientResult<()> {
2843    publish_agent_discovery_snapshot_for(
2844        loaded.session_id.clone(),
2845        loaded.agent_id.clone(),
2846        loaded.agent_initialization_id.clone(),
2847        tx,
2848        discovery_policy,
2849    )
2850}
2851
2852fn publish_agent_discovery_snapshot_for(
2853    session_id: tau_proto::SessionId,
2854    agent_id: tau_proto::AgentId,
2855    agent_initialization_id: tau_proto::AgentInitializationId,
2856    tx: &Output,
2857    discovery_policy: DiscoverySourcePolicy,
2858) -> tau_client::ClientResult<()> {
2859    let session = SessionStarted {
2860        session_id: session_id.clone(),
2861        reason: tau_proto::SessionStartReason::Resume,
2862    };
2863    publish_agent_discovery_scan(
2864        build_discovery_snapshot(session, discovery_policy),
2865        agent_id,
2866        agent_initialization_id,
2867        tx,
2868    )
2869}
2870
2871/// Publishes only the mandatory snapshot from one per-agent discovery scan.
2872fn publish_agent_discovery_scan(
2873    scan: DiscoveryScan,
2874    agent_id: tau_proto::AgentId,
2875    agent_initialization_id: tau_proto::AgentInitializationId,
2876    tx: &Output,
2877) -> tau_client::ClientResult<()> {
2878    tx.send_checked(agent_discovery_message(
2879        scan.snapshot,
2880        agent_id,
2881        agent_initialization_id,
2882    ))
2883}
2884
2885/// Converts session discovery data into one correlated per-agent declaration.
2886fn agent_discovery_message(
2887    snapshot: ExtensionSessionDiscoverySnapshotDeclared,
2888    agent_id: tau_proto::AgentId,
2889    agent_initialization_id: tau_proto::AgentInitializationId,
2890) -> HarnessInputMessage {
2891    HarnessInputMessage::emit_transient(Event::ExtensionAgentDiscoverySnapshotDeclared(
2892        ExtensionAgentDiscoverySnapshotDeclared {
2893            workdir_binding: None,
2894            refresh_id: None,
2895            discovery_error: None,
2896            frontmatter_diagnostics: snapshot.frontmatter_diagnostics,
2897            session_id: snapshot.session_id,
2898            agent_id,
2899            agent_initialization_id,
2900            skills: snapshot.skills,
2901            agents_files: snapshot.agents_files,
2902        },
2903    ))
2904}
2905
2906fn shell_workdir_prompt_fragment(shell: &config::ShellConfig) -> PromptFragment {
2907    let mut template = String::from(
2908        "{{#if agent_context.workdir}}### Shell workdirs\n\nEach shell extension instance \
2909         has its own persistent workdir; there is no global shell cwd.\n\
2910         {{#each agent_context.workdir}}- {{#if (eq value.label \"default\")}}default shell \
2911         tools (`workdir`){{else}}`{{value.label}}_*` shell tools \
2912         (`{{value.label}}_workdir`){{/if}}: `{{value.path}}` \
2913         [{{value.status}}]\n{{/each}}\nNormally set the matching workdir tool to the project \
2914         root before project work. It sets the cwd/base for later shell and filesystem calls \
2915         in that same instance. The cwd can select configured directory-scoped wrappers, \
2916         notably `direnv exec .`, and affect other cwd-sensitive wrappers/tools. After \
2917         changing it, make dependent calls only in a later tool turn after success; sibling \
2918         calls have no workdir-first ordering.{{/if}}",
2919    );
2920    if let Some(allowlist) = shell.allowlist_prompt_fragment() {
2921        template.push_str(&allowlist);
2922    }
2923    PromptFragment::new(
2924        "shell.workdir",
2925        PromptPriority::new(900),
2926        PromptContent::new(template),
2927    )
2928}
2929
2930fn push_skill_diagnostic_requests(
2931    messages: &mut Vec<HarnessInputMessage>,
2932    diagnostics: Vec<tau_skills::SkillDiagnostic>,
2933) {
2934    for diagnostic in diagnostics {
2935        let (kind, level) = match diagnostic.kind {
2936            // These travel atomically in the mandatory discovery snapshot.
2937            tau_skills::DiagnosticKind::Frontmatter => continue,
2938            tau_skills::DiagnosticKind::Warning => ("warning", tau_proto::NoticeLevel::Info),
2939            tau_skills::DiagnosticKind::Collision => ("collision", tau_proto::NoticeLevel::Trace),
2940            tau_skills::DiagnosticKind::Skipped => ("skipped", tau_proto::NoticeLevel::Warning),
2941        };
2942        messages.push(HarnessInputMessage::ExtensionNoticeRequest(
2943            tau_proto::ExtensionNoticeRequest {
2944                message: format!(
2945                    "skill {kind}: {}\n{}",
2946                    diagnostic.path.display(),
2947                    diagnostic.message
2948                ),
2949                level,
2950            },
2951        ));
2952    }
2953}
2954
2955fn session_skill_dirs(
2956    cwd: Option<std::path::PathBuf>,
2957    home: Option<std::path::PathBuf>,
2958) -> Vec<tau_skills::SkillDir> {
2959    let mut skill_dirs = project_skill_dirs(cwd.as_deref(), home.as_deref());
2960    if let Some(home) = home {
2961        skill_dirs.push(user_skill_dir_precedence(
2962            home.join(".config").join("agents").join("skills"),
2963            XDG_USER_SKILL_SOURCE_PRECEDENCE,
2964        ));
2965        skill_dirs.push(user_skill_dir_precedence(
2966            home.join(".config").join("agents.local").join("skills"),
2967            XDG_USER_SKILL_SOURCE_PRECEDENCE,
2968        ));
2969        skill_dirs.push(user_skill_dir_precedence(
2970            home.join(".agents").join("skills"),
2971            LEGACY_USER_SKILL_SOURCE_PRECEDENCE,
2972        ));
2973        skill_dirs.push(user_skill_dir_precedence(
2974            home.join(".agents.local").join("skills"),
2975            LEGACY_USER_SKILL_SOURCE_PRECEDENCE,
2976        ));
2977    }
2978    skill_dirs
2979}
2980
2981fn project_skill_dirs(cwd: Option<&Path>, home: Option<&Path>) -> Vec<tau_skills::SkillDir> {
2982    let mut skill_dirs = Vec::new();
2983    if let Some(cwd) = cwd {
2984        for project_dir in project_skill_ancestor_dirs(cwd, home) {
2985            push_existing_project_skill_dir(
2986                &mut skill_dirs,
2987                project_dir.join(".agents").join("skills"),
2988            );
2989            push_existing_project_skill_dir(
2990                &mut skill_dirs,
2991                project_dir.join(".agents.local").join("skills"),
2992            );
2993        }
2994    }
2995    skill_dirs
2996}
2997
2998fn project_skill_ancestor_dirs(
2999    cwd: &std::path::Path,
3000    home: Option<&std::path::Path>,
3001) -> Vec<std::path::PathBuf> {
3002    ancestor_dirs(cwd)
3003        .into_iter()
3004        .filter(|dir| dir.parent().is_some())
3005        .filter(|dir| {
3006            let Some(home) = home else {
3007                return true;
3008            };
3009            !cwd.starts_with(home) || (dir.starts_with(home) && dir != home)
3010        })
3011        .collect()
3012}
3013
3014fn push_existing_project_skill_dir(
3015    skill_dirs: &mut Vec<tau_skills::SkillDir>,
3016    path: std::path::PathBuf,
3017) {
3018    if path.is_dir() {
3019        skill_dirs.push(project_skill_dir(path));
3020    }
3021}
3022
3023fn project_skill_dir(path: std::path::PathBuf) -> tau_skills::SkillDir {
3024    tau_skills::SkillDir {
3025        path,
3026        add_to_prompt_by_default: true,
3027        source_precedence: None,
3028    }
3029}
3030
3031fn user_skill_dir_precedence(
3032    path: std::path::PathBuf,
3033    source_precedence: u32,
3034) -> tau_skills::SkillDir {
3035    tau_skills::SkillDir {
3036        path,
3037        add_to_prompt_by_default: false,
3038        source_precedence: Some(source_precedence),
3039    }
3040}