From 6dab69fec62415c033af5d0f9ddb337eec0574e8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:49:47 +0200 Subject: [PATCH 01/15] mason: add capped every-line async watch delivery (#751) Keep default first-match frames byte-identical. Add opt-in line batches, source cursors, duplicate counts, bounded pending snapshots, rate limits, and immediate one-line terminal drop summaries. Expose the existing byte cursors through both hosts with masked page rendering so drop recovery calls are actionable. Argument names are pending Ufuk's pre-merge approval. Persist the new mode and pending batches in schema 19. The v18 additive-index test now exercises ensure_additive_indexes directly: ordinary open intentionally advances to schema 19. A separate v18-to-v19 test verifies column defaults and the reader floor, preserving rather than reversing the old additive-index claim. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../release-notes/next/watch-every-line.md | 1 + crates/aft/src/bash_background/mod.rs | 1 + crates/aft/src/bash_background/registry.rs | 348 ++++++++++++++++- crates/aft/src/bash_background/watch_lines.rs | 350 ++++++++++++++++++ crates/aft/src/bash_background/watches.rs | 112 ++++++ crates/aft/src/commands/bash_notify.rs | 21 +- crates/aft/src/commands/bash_status.rs | 14 + crates/aft/src/commands/bash_watch.rs | 22 ++ crates/aft/src/db/bash_watches.rs | 22 +- crates/aft/src/db/mod.rs | 65 +++- crates/aft/src/list_surfaces.rs | 6 + crates/aft/src/protocol.rs | 5 + crates/aft/src/subc/mod.rs | 1 + crates/aft/src/subc_format.rs | 48 +++ crates/aft/src/subc_tool_presets.json | 19 + crates/aft/src/subc_tool_schemas.json | 12 + crates/aft/src/subc_translate.rs | 4 + .../aft/tests/integration/bash_watch_test.rs | 35 ++ docs/tools.md | 13 + packages/aft-bridge/src/bash-watch-lines.ts | 78 ++++ packages/aft-bridge/src/bridge.ts | 10 + packages/aft-bridge/src/index.ts | 6 + .../src/__tests__/bash.test.ts | 44 +++ .../src/__tests__/bg-notifications.test.ts | 63 ++++ .../opencode-plugin/src/bg-notifications.ts | 27 +- packages/opencode-plugin/src/index.ts | 1 + .../opencode-plugin/src/subc-tool-schemas.ts | 7 +- packages/opencode-plugin/src/tools/bash.ts | 29 +- .../opencode-plugin/src/tools/bash_watch.ts | 15 + packages/pi-plugin/src/__tests__/bash.test.ts | 57 +++ .../src/__tests__/bg-notifications.test.ts | 69 ++++ packages/pi-plugin/src/bg-notifications.ts | 27 +- packages/pi-plugin/src/index.ts | 1 + packages/pi-plugin/src/tools/bash.ts | 63 +++- 34 files changed, 1566 insertions(+), 30 deletions(-) create mode 100644 .alfonso/release-notes/next/watch-every-line.md create mode 100644 crates/aft/src/bash_background/watch_lines.rs create mode 100644 packages/aft-bridge/src/bash-watch-lines.ts diff --git a/.alfonso/release-notes/next/watch-every-line.md b/.alfonso/release-notes/next/watch-every-line.md new file mode 100644 index 0000000000..df204100da --- /dev/null +++ b/.alfonso/release-notes/next/watch-every-line.md @@ -0,0 +1 @@ +- Async `bash_watch` can opt into `deliver: "each_line"` to batch matching log lines with byte offsets, combining consecutive identical lines with a count. Bounded batches and per-watch rate limits report dropped lines with copyable `bash_status` page requests starting where lines were dropped; default first-match watches are unchanged. Pages display masked output, and oversized lines report an incomplete scan. diff --git a/crates/aft/src/bash_background/mod.rs b/crates/aft/src/bash_background/mod.rs index 3d695479b6..09e0e273b9 100644 --- a/crates/aft/src/bash_background/mod.rs +++ b/crates/aft/src/bash_background/mod.rs @@ -15,6 +15,7 @@ pub mod pty_process; pub mod pty_runtime; pub mod registry; mod temp_cleanup; +pub mod watch_lines; pub mod watchdog; pub mod watches; pub(crate) mod windows_waiter; diff --git a/crates/aft/src/bash_background/registry.rs b/crates/aft/src/bash_background/registry.rs index 1b5203c04a..79ac70718d 100644 --- a/crates/aft/src/bash_background/registry.rs +++ b/crates/aft/src/bash_background/registry.rs @@ -3812,7 +3812,7 @@ impl BgTaskRegistry { return None; }; let context = row.match_context.unwrap_or_else(|| match_text.clone()); - Some(if match_text == WATCH_TARGET_ERASED_TEXT { + let mut frame = if match_text == WATCH_TARGET_ERASED_TEXT { BashPatternMatchFrame::watch_target_erased( row.task_id, session_id, @@ -3832,7 +3832,12 @@ impl BgTaskRegistry { context, row.once, ) - }) + }; + frame.batch = row + .match_batch + .as_deref() + .and_then(|batch| serde_json::from_str(batch).ok()); + Some(frame) }) .collect() } @@ -3983,6 +3988,11 @@ impl BgTaskRegistry { &spec.watch_id, &spec.pattern, spec.once, + self.inner + .watch_registry + .lock() + .map(|registry| registry.is_each_line(&spec.watch_id)) + .unwrap_or(false), stdout_offset, stderr_offset, pty_offset, @@ -3997,6 +4007,7 @@ impl BgTaskRegistry { watch_id: &str, pattern: &WatchPattern, once: bool, + each_line: bool, stdout_offset: u64, stderr_offset: u64, pty_offset: u64, @@ -4015,6 +4026,8 @@ impl BgTaskRegistry { pattern_kind: pattern.kind_name().to_string(), pattern: pattern.pattern_text().to_string(), once, + each_line, + match_batch: None, created_at: unix_millis() as i64, stdout_offset: stdout_offset as i64, stderr_offset: stderr_offset as i64, @@ -4064,13 +4077,15 @@ impl BgTaskRegistry { &self, session_id: &str, task_id: &str, - pattern_match: &PatternMatch, + pattern_match: &mut PatternMatch, stdout_offset: u64, stderr_offset: u64, pty_offset: u64, ) { // The scanner masks the intact capture before constructing this match. // Save that presentation verbatim so replay does not need task secrets. + let (stdout_offset, stderr_offset, pty_offset) = + self.watch_replay_cursors(task_id, (stdout_offset, stderr_offset, pty_offset)); let Some((harness, pool)) = self.db_harness_and_pool_for_session(session_id) else { return; }; @@ -4086,6 +4101,17 @@ impl BgTaskRegistry { ) else { return; }; + if let Some(batch) = pattern_match.batch.as_mut() { + if row.pending_match { + if let Some(mut pending) = row.match_batch.as_deref().and_then(|value| { + serde_json::from_str::(value).ok() + }) { + pending.merge(batch.clone()); + *batch = pending; + } + } + pattern_match.context = crate::subc_format::format_watch_line_batch(task_id, batch); + } row.stdout_offset = stdout_offset as i64; row.stderr_offset = stderr_offset as i64; row.pty_offset = pty_offset as i64; @@ -4093,6 +4119,14 @@ impl BgTaskRegistry { row.match_text = Some(pattern_match.match_text.clone()); row.match_offset = Some(pattern_match.match_offset as i64); row.match_context = Some(pattern_match.context.clone()); + row.match_batch = pattern_match.batch.as_ref().and_then(|batch| { + let mut saved = batch.clone(); + // Replay keeps all pending matching rows; the live notification + // at task exit contains only a one-line summary of dropped lines. + saved.terminal_summary = false; + saved.snapshot = true; + serde_json::to_string(&saved).ok() + }); if pattern_match.once { // Once-watches stop scanning after the first hit but stay durable // until ack so a lost push can be re-delivered after restart. @@ -4115,6 +4149,8 @@ impl BgTaskRegistry { stderr_offset: u64, pty_offset: u64, ) { + let (stdout_offset, stderr_offset, pty_offset) = + self.watch_replay_cursors(task_id, (stdout_offset, stderr_offset, pty_offset)); let offsets = (stdout_offset, stderr_offset, pty_offset); if self .inner @@ -4165,6 +4201,20 @@ impl BgTaskRegistry { } } + fn watch_replay_cursors(&self, task_id: &str, offsets: (u64, u64, u64)) -> (u64, u64, u64) { + self.inner + .watch_registry + .lock() + .map(|registry| { + ( + registry.replay_cursor(task_id, "stdout", offsets.0), + registry.replay_cursor(task_id, "stderr", offsets.1), + registry.replay_cursor(task_id, "pty", offsets.2), + ) + }) + .unwrap_or(offsets) + } + fn watch_stream_cursors(&self, task_id: &str) -> (u64, u64, u64) { let Ok(registry) = self.inner.watch_registry.lock() else { return (0, 0, 0); @@ -7443,6 +7493,16 @@ impl BgTaskRegistry { task_id: String, pattern: WatchPattern, once: bool, + ) -> Result { + self.register_watch_delivery(task_id, pattern, once, false) + } + + pub fn register_watch_delivery( + &self, + task_id: String, + pattern: WatchPattern, + once: bool, + each_line: bool, ) -> Result { let task = self.task(&task_id).ok_or("task_not_found")?; self.record_live_delivery_session(&task.session_id); @@ -7487,6 +7547,9 @@ impl BgTaskRegistry { .lock() .map_err(|_| "watch_registry_poisoned")?; let watch_id = registry.register(task_id.clone(), pattern.clone(), once)?; + if each_line { + registry.enable_each_line(&watch_id); + } match &mode { BgMode::Pipes => { let stdout_key = format!("{task_id}:stdout"); @@ -7539,6 +7602,9 @@ impl BgTaskRegistry { } } + if terminal_at_registration { + terminal_matches.extend(self.take_watch_line_batches(&task_id, true)); + } let (stdout_offset, stderr_offset, pty_offset) = self.watch_stream_cursors(&task_id); // The watch row references the task row. A running task's row can be // missing (spawned before this registry had its aft.db handle, or a @@ -7552,6 +7618,7 @@ impl BgTaskRegistry { &watch_id, &pattern, once, + each_line, stdout_offset, stderr_offset, pty_offset, @@ -7605,6 +7672,9 @@ impl BgTaskRegistry { } } + if !scanned_terminal { + terminal_matches.extend(self.take_watch_line_batches(&task_id, true)); + } let (stdout_offset, stderr_offset, pty_offset) = self.watch_stream_cursors(&task_id); let (watch_controlled, watch_matched) = self.task_watch_state(&task_id); if terminal_matches.is_empty() && (!watch_controlled || watch_matched) { @@ -7622,7 +7692,8 @@ impl BgTaskRegistry { self.record_bash_watch_exit(completion, true); } } else { - for pattern_match in &terminal_matches { + for pattern_match in &mut terminal_matches { + let fresh = pattern_match.clone(); self.persist_watch_match( &task.session_id, &task_id, @@ -7631,7 +7702,7 @@ impl BgTaskRegistry { stderr_offset, pty_offset, ); - self.emit_bash_pattern_match(&task.session_id, pattern_match.clone()); + self.emit_bash_pattern_match(&task.session_id, fresh); } } self.clear_task_watch_state(&task_id); @@ -7781,6 +7852,101 @@ impl BgTaskRegistry { matches } + pub fn masked_status_page( + &self, + task_id: &str, + artifact: TaskArtifact, + from: u64, + bytes: &[u8], + ) -> String { + const MASK_CHUNK_BYTES: usize = 4 * 1024; + let mut text = String::new(); + let mut start = 0; + while start < bytes.len() { + let limit = (start + MASK_CHUNK_BYTES).min(bytes.len()); + let end = bytes[start..limit] + .iter() + .rposition(|byte| *byte == b'\n') + .map(|index| start + index + 1) + .unwrap_or(limit); + // Keep each credential-mask region below the preview cap, while + // every subrange still reads its full surrounding secret halo. + text.push_str(&self.masked_watch_range( + task_id, + artifact, + from + start as u64, + from + end as u64, + )); + start = end; + } + text + } + + pub fn masked_watch_range( + &self, + task_id: &str, + artifact: TaskArtifact, + from: u64, + to: u64, + ) -> String { + let rendered = self.task(task_id).and_then(|task| { + let mut file = open_task_artifact(&task.paths, artifact).ok()?; + let len = file.len().ok()?; + if len >= super::buffer::DISK_LIMIT_BYTES || to > len || from > to { + return None; + } + let source = completion_preview::watch_preview_source_range( + from as usize..to as usize, + len as usize, + )?; + let bytes = file + .read_range(source.start as u64, source.len() as u64) + .ok()?; + if bytes.len() != source.len() { + return None; + } + Some( + self.render_watch_preview_window( + task_id, + &bytes, + from as usize - source.start..to as usize - source.start, + source.start == 0, + source.end == len as usize, + ) + .0, + ) + }); + rendered.unwrap_or_else(|| crate::log_redact::SECRET_PLACEHOLDER.to_string()) + } + + fn take_watch_line_batches(&self, task_id: &str, terminal: bool) -> Vec { + let mut batches = self + .inner + .watch_registry + .lock() + .map(|mut registry| registry.take_line_batches(task_id, terminal)) + .unwrap_or_default(); + for matched in &mut batches { + if let Some(batch) = matched.batch.as_mut() { + for line in &mut batch.lines { + let artifact = match line.stream.as_str() { + "stderr" => TaskArtifact::Stderr, + "pty" => TaskArtifact::Pty, + _ => TaskArtifact::Stdout, + }; + // Render only retained rows, after releasing the scanner lock. The + // preview reader supplies the secret halo before redacting a line. + line.text = self + .masked_watch_range(task_id, artifact, line.offset, line.end_offset) + .trim_end_matches('\n') + .to_string(); + } + matched.context = crate::subc_format::format_watch_line_batch(task_id, batch); + } + } + batches + } + pub(crate) fn scan_task_watch_output(&self, task: &Arc) { if self.active_watch_count(&task.task_id) == 0 { return; @@ -7847,6 +8013,7 @@ impl BgTaskRegistry { break; } } + matches.extend(self.take_watch_line_batches(&task.task_id, drain)); let (stdout_offset, stderr_offset, pty_offset) = self.watch_stream_cursors(&task.task_id); if matches.is_empty() { // Advance durable cursors even when nothing matched so a restart @@ -7862,16 +8029,17 @@ impl BgTaskRegistry { } return; } - for pattern_match in matches { + for mut pattern_match in matches { + let fresh = pattern_match.clone(); self.persist_watch_match( &task.session_id, &task.task_id, - &pattern_match, + &mut pattern_match, stdout_offset, stderr_offset, pty_offset, ); - self.emit_bash_pattern_match(&task.session_id, pattern_match); + self.emit_bash_pattern_match(&task.session_id, fresh); } self.persist_task_watch_cursors( &task.session_id, @@ -11790,6 +11958,9 @@ impl BgTaskRegistry { ); continue; } + if row.each_line && row.scanning { + registry.enable_each_line(&row.watch_id); + } if row.pending_match { if let (Some(match_text), Some(match_offset), Some(context)) = ( row.match_text.clone(), @@ -11803,6 +11974,10 @@ impl BgTaskRegistry { match_offset: match_offset.max(0) as u64, context, once: row.once, + batch: row + .match_batch + .as_deref() + .and_then(|batch| serde_json::from_str(batch).ok()), }); } } @@ -11844,7 +12019,8 @@ impl BgTaskRegistry { } let (stdout_offset, stderr_offset, pty_offset) = self.watch_stream_cursors(&task.task_id); - for pattern_match in &gap_matches { + gap_matches.extend(self.take_watch_line_batches(&task.task_id, terminal)); + for pattern_match in &mut gap_matches { self.persist_watch_match( &task.session_id, &task.task_id, @@ -11864,6 +12040,10 @@ impl BgTaskRegistry { ); } + for fresh in gap_matches.iter().filter(|matched| matched.batch.is_some()) { + pending_to_emit.retain(|pending| pending.watch_id != fresh.watch_id); + pending_to_emit.push(fresh.clone()); + } // Prefer a single delivery: pending re-push first, else fresh gap matches. let emitted_pending = !pending_to_emit.is_empty(); let to_emit = if emitted_pending { @@ -13058,7 +13238,28 @@ impl BgTaskRegistry { return; }; if let Some(sender) = progress_sender.as_ref() { - let frame = if pattern_match.match_text == WATCH_TASK_EXIT_TEXT { + if let Some(batch) = pattern_match + .batch + .as_ref() + .filter(|batch| batch.terminal_summary) + { + let mut summary = batch.clone(); + summary.lines.clear(); + sender(PushFrame::BashPatternMatch( + BashPatternMatchFrame::task_exit( + &pattern_match.task_id, + session_id, + WATCH_TASK_EXIT_TEXT, + crate::subc_format::format_watch_line_batch( + &pattern_match.task_id, + &summary, + ), + ), + )); + return; + } + let batch = pattern_match.batch.clone(); + let mut frame = if pattern_match.match_text == WATCH_TASK_EXIT_TEXT { BashPatternMatchFrame::task_exit( pattern_match.task_id, session_id.to_string(), @@ -13076,6 +13277,7 @@ impl BgTaskRegistry { pattern_match.once, ) }; + frame.batch = batch; sender(PushFrame::BashPatternMatch(frame)); } } @@ -25289,6 +25491,8 @@ mod tests { match_text: Some("(fail)".into()), match_offset: Some(756_237), match_context: Some("release output ... (fail)".into()), + each_line: false, + match_batch: None, }, ) .unwrap(); @@ -26522,6 +26726,128 @@ mod tests { ); } + #[test] + fn each_line_pending_snapshots_merge_and_partial_line_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let storage = dir.path(); + let (registry, db, frames) = registry_with_db_and_frames(storage); + let task_id = registry + .spawn( + SpawnPlan::Unsandboxed, + LONG_RUNNING_COMMAND, + "session".to_string(), + storage.to_path_buf(), + HashMap::new(), + crate::bash_background::HardKill::After(Duration::from_secs(30)), + storage.to_path_buf(), + 10, + true, + false, + Some(storage.to_path_buf()), + ) + .unwrap(); + let watch_id = registry + .register_watch_delivery( + task_id.clone(), + WatchPattern::Substring("ERROR".into()), + false, + true, + ) + .unwrap(); + let task = registry.task_for_session(&task_id, "session").unwrap(); + let mut output = std::fs::OpenOptions::new() + .append(true) + .open(&task.paths.stdout) + .unwrap(); + output.write_all(b"ERROR a\nER").unwrap(); + registry.scan_task_watch_output(&task); + let row = crate::db::bash_watches::get_bash_pattern_watch( + &db.lock().unwrap(), + "opencode", + "session", + &task_id, + &watch_id, + ) + .unwrap() + .unwrap(); + assert!(row.each_line); + assert_eq!(row.stdout_offset, 8, "resume before the unfinished line"); + registry.clear_task_watch_state(&task_id); + registry + .inner + .shutdown + .store(true, std::sync::atomic::Ordering::SeqCst); + output.write_all(b"ROR b\n").unwrap(); + let (replayed, _, replay_frames) = registry_with_db_and_frames(storage); + replayed + .replay_session_inner(storage, "session", None) + .unwrap(); + let matches = pattern_match_frames(&replay_frames); + let latest = matches + .iter() + .rev() + .find(|frame| frame.task_id == task_id && frame.batch.is_some()) + .unwrap(); + let batch = latest.batch.as_ref().unwrap(); + assert_eq!( + batch.lines.len(), + 2, + "{batch:?}; original pushes: {:?}", + pattern_match_frames(&frames) + ); + assert_eq!(batch.lines[0].text, "ERROR a"); + assert_eq!(batch.lines[1].text, "ERROR b"); + assert_eq!(batch.lines[1].offset, 8); + let mut page = b"page text\n".repeat(6000); + page.extend_from_slice(b"page final\n"); + let from = output.metadata().unwrap().len(); + output.write_all(&page).unwrap(); + let masked = registry.masked_status_page(&task_id, TaskArtifact::Stdout, from, &page); + assert_eq!(masked.lines().count(), 6001); + assert!(masked.ends_with("page final\n")); + } + + #[test] + fn each_line_terminal_rate_exemption_is_one_summary_line() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, frames) = registry_with_db_and_frames(dir.path()); + registry.record_live_delivery_session("session"); + let batch = super::super::watch_lines::LineBatch { + terminal_summary: true, + dropped_lines: 7, + first_dropped_stream: Some("stderr".into()), + first_dropped_offset: Some(123), + lines: vec![super::super::watch_lines::WatchLine { + stream: "stdout".into(), + offset: 0, + end_offset: 8, + count: 1, + text: "ERROR a".into(), + }], + ..Default::default() + }; + registry.emit_bash_pattern_match( + "session", + PatternMatch { + watch_id: "watch-a".into(), + task_id: "bash-lines".into(), + match_text: "matching lines".into(), + match_offset: 0, + context: "ignored".into(), + once: false, + batch: Some(batch), + }, + ); + let frames = pattern_match_frames(&frames); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].reason, "task_exit"); + assert!(frames[0].batch.is_none()); + assert_eq!(frames[0].context.lines().count(), 1); + assert!(frames[0].context.contains("7 matching lines dropped")); + assert!(frames[0].context.contains("stderr_offset: 123")); + assert!(!frames[0].context.contains("ERROR a")); + } + #[test] fn pattern_watch_acked_match_does_not_redeliver_after_restart() { let dir = tempfile::tempdir().unwrap(); @@ -26726,6 +27052,8 @@ mod tests { match_text: None, match_offset: None, match_context: None, + each_line: false, + match_batch: None, }, ) .unwrap(); diff --git a/crates/aft/src/bash_background/watch_lines.rs b/crates/aft/src/bash_background/watch_lines.rs new file mode 100644 index 0000000000..d5f0dc6aaa --- /dev/null +++ b/crates/aft/src/bash_background/watch_lines.rs @@ -0,0 +1,350 @@ +//! Bounded, line-oriented delivery for opt-in asynchronous watches. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use super::watches::WatchPattern; + +pub const MAX_LINES_PER_NOTIFICATION: usize = 50; +pub const MAX_NOTIFICATIONS_PER_MINUTE: usize = 12; +pub const NOTIFICATION_WINDOW: Duration = Duration::from_secs(60); +pub const MAX_LINE_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchLine { + pub stream: String, + pub offset: u64, + pub end_offset: u64, + pub text: String, + pub count: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LineBatch { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub snapshot: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub terminal_summary: bool, + pub lines: Vec, + pub dropped_lines: u64, + pub first_dropped_offset: Option, + pub first_dropped_stream: Option, + pub skipped_long_lines: u64, +} + +impl LineBatch { + pub fn merge(&mut self, next: LineBatch) { + self.terminal_summary = next.terminal_summary; + let mut retained = self.lines.iter().map(|line| line.count).sum::(); + for mut line in next.lines { + let keep = line + .count + .min((MAX_LINES_PER_NOTIFICATION as u64).saturating_sub(retained)); + let dropped = line.count - keep; + if dropped > 0 { + let offset = line.offset + keep * (line.end_offset - line.offset); + self.drop_line(&line.stream, offset); + self.dropped_lines = self.dropped_lines.saturating_add(dropped - 1); + } + if keep > 0 { + line.count = keep; + self.lines.push(line); + retained += keep; + } + } + self.dropped_lines = self.dropped_lines.saturating_add(next.dropped_lines); + self.skipped_long_lines = self + .skipped_long_lines + .saturating_add(next.skipped_long_lines); + if self.first_dropped_offset.is_none() { + self.first_dropped_offset = next.first_dropped_offset; + self.first_dropped_stream = next.first_dropped_stream; + } + } + + fn drop_line(&mut self, stream: &str, offset: u64) { + self.dropped_lines = self.dropped_lines.saturating_add(1); + if self.first_dropped_offset.is_none() { + self.first_dropped_offset = Some(offset); + self.first_dropped_stream = Some(stream.to_string()); + } + } + + fn is_empty(&self) -> bool { + self.lines.is_empty() && self.dropped_lines == 0 && self.skipped_long_lines == 0 + } +} + +#[derive(Debug, Default)] +struct PartialLine { + bytes: Vec, + offset: u64, + oversized: bool, +} + +#[derive(Debug, Default)] +pub(super) struct LineDelivery { + streams: HashMap, + batch: LineBatch, + retained: usize, + window: Option, + sent: usize, +} + +impl LineDelivery { + pub fn replay_cursor(&self, stream: &str, cursor: u64) -> u64 { + let partial = self + .streams + .get(stream) + .filter(|line| !line.bytes.is_empty() || line.oversized) + .map(|line| line.offset) + .unwrap_or(cursor); + let dropped = if self.batch.first_dropped_stream.as_deref() == Some(stream) { + self.batch.first_dropped_offset.unwrap_or(cursor) + } else { + cursor + }; + self.batch + .lines + .iter() + .filter(|line| line.stream == stream) + .map(|line| line.offset) + .fold(cursor.min(partial).min(dropped), u64::min) + } + + pub fn scan(&mut self, stream: &str, bytes: &[u8], base: u64, pattern: &WatchPattern) { + let mut partial = self.streams.remove(stream).unwrap_or_else(|| PartialLine { + offset: base, + ..PartialLine::default() + }); + let mut consumed = 0; + for fragment in bytes.split_inclusive(|byte| *byte == b'\n') { + let complete = fragment.last() == Some(&b'\n'); + let content = if complete { + &fragment[..fragment.len() - 1] + } else { + fragment + }; + if !partial.oversized { + if partial.bytes.len().saturating_add(content.len()) > MAX_LINE_BYTES { + partial.bytes.clear(); + partial.oversized = true; + } else { + partial.bytes.extend_from_slice(content); + } + } + consumed += fragment.len(); + if complete { + self.finish_line(stream, &partial, base + consumed as u64, pattern); + partial = PartialLine { + offset: base + consumed as u64, + ..PartialLine::default() + }; + } + } + self.streams.insert(stream.to_string(), partial); + } + + fn finish_line( + &mut self, + stream: &str, + partial: &PartialLine, + end: u64, + pattern: &WatchPattern, + ) { + if partial.oversized { + self.batch.skipped_long_lines = self.batch.skipped_long_lines.saturating_add(1); + if self.batch.first_dropped_offset.is_none() { + self.batch.first_dropped_offset = Some(partial.offset); + self.batch.first_dropped_stream = Some(stream.to_string()); + } + return; + } + let text = String::from_utf8_lossy(&partial.bytes); + let matched = match pattern { + WatchPattern::Substring(needle) => !needle.is_empty() && text.contains(needle), + WatchPattern::Regex(regex) => regex.is_match(&text), + }; + if !matched { + return; + } + // Count source lines, not folded rows, so repetition cannot bypass the cap. + if self.retained >= MAX_LINES_PER_NOTIFICATION { + self.batch.drop_line(stream, partial.offset); + return; + } + self.retained += 1; + if let Some(previous) = self.batch.lines.last_mut() { + if previous.stream == stream + && previous.offset.saturating_add( + previous + .count + .saturating_mul(previous.end_offset - previous.offset), + ) == partial.offset + && previous.text == text + { + previous.count = previous.count.saturating_add(1); + return; + } + } + self.batch.lines.push(WatchLine { + stream: stream.to_string(), + offset: partial.offset, + end_offset: end, + text: text.into_owned(), + count: 1, + }); + } + + pub fn take( + &mut self, + now: Instant, + terminal: bool, + pattern: &WatchPattern, + ) -> Option { + if terminal { + for (stream, partial) in std::mem::take(&mut self.streams) { + if !partial.bytes.is_empty() || partial.oversized { + let end = partial.offset.saturating_add(partial.bytes.len() as u64); + self.finish_line(&stream, &partial, end, pattern); + } + } + } + if self + .window + .is_none_or(|start| now.saturating_duration_since(start) >= NOTIFICATION_WINDOW) + { + self.window = Some(now); + self.sent = 0; + } + if self.sent >= MAX_NOTIFICATIONS_PER_MINUTE { + for line in std::mem::take(&mut self.batch.lines) { + self.batch.drop_line(&line.stream, line.offset); + self.batch.dropped_lines = self.batch.dropped_lines.saturating_add(line.count - 1); + } + self.retained = 0; + if !terminal { + return None; + } + self.batch.terminal_summary = true; + } + if self.batch.is_empty() { + return None; + } + self.sent += 1; + self.retained = 0; + Some(std::mem::take(&mut self.batch)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pattern() -> WatchPattern { + WatchPattern::Substring("ERROR".into()) + } + + #[test] + fn each_line_delivers_all_matches_in_one_block() { + for pattern in [pattern(), WatchPattern::regex("ERROR.*").unwrap()] { + let mut delivery = LineDelivery::default(); + delivery.scan("stdout", b"ERROR a\nok\nERROR b\nERROR c\n", 0, &pattern); + let batch = delivery.take(Instant::now(), false, &pattern).unwrap(); + assert_eq!( + batch + .lines + .iter() + .map(|line| (line.offset, line.text.as_str())) + .collect::>(), + vec![(0, "ERROR a"), (11, "ERROR b"), (19, "ERROR c")] + ); + } + } + + #[test] + fn each_line_split_across_blocks_delivered_once() { + let mut delivery = LineDelivery::default(); + delivery.scan("stdout", b"ER", 40, &pattern()); + assert!(delivery.take(Instant::now(), false, &pattern()).is_none()); + delivery.scan("stdout", b"ROR a\n", 42, &pattern()); + let batch = delivery.take(Instant::now(), false, &pattern()).unwrap(); + assert_eq!(batch.lines[0].text, "ERROR a"); + assert_eq!(batch.lines[0].offset, 40); + assert!(delivery.take(Instant::now(), true, &pattern()).is_none()); + } + + #[test] + fn each_line_cap_reports_exact_count_and_offset() { + let mut delivery = LineDelivery::default(); + for index in 0..53 { + delivery.scan("stderr", b"ERROR\n", index * 6, &pattern()); + } + let batch = delivery.take(Instant::now(), false, &pattern()).unwrap(); + assert_eq!(batch.lines.iter().map(|line| line.count).sum::(), 50); + assert_eq!(batch.dropped_lines, 3); + assert_eq!(batch.first_dropped_offset, Some(300)); + assert_eq!(batch.first_dropped_stream.as_deref(), Some("stderr")); + } + + #[test] + fn each_line_rate_cap_and_terminal_summary() { + let now = Instant::now(); + let mut delivery = LineDelivery::default(); + for index in 0..12 { + delivery.scan("stdout", b"ERROR\n", index * 6, &pattern()); + assert!(delivery.take(now, false, &pattern()).is_some()); + } + delivery.scan("stdout", b"ERROR\nERROR\n", 72, &pattern()); + assert!(delivery.take(now, false, &pattern()).is_none()); + let batch = delivery.take(now, true, &pattern()).unwrap(); + assert_eq!(batch.dropped_lines, 2); + assert_eq!(batch.first_dropped_offset, Some(72)); + assert!(batch.lines.is_empty()); + assert!(delivery + .take(now + NOTIFICATION_WINDOW, false, &pattern()) + .is_none()); + delivery.scan("stdout", b"ERROR\n", 84, &pattern()); + assert_eq!( + delivery + .take(now + NOTIFICATION_WINDOW, false, &pattern()) + .unwrap() + .lines + .len(), + 1 + ); + } + + #[test] + fn each_line_folds_only_consecutive_identical_lines() { + let mut delivery = LineDelivery::default(); + delivery.scan( + "stdout", + b"ERROR a\nERROR a\nignore\nERROR a\n", + 0, + &pattern(), + ); + let batch = delivery.take(Instant::now(), false, &pattern()).unwrap(); + assert_eq!(batch.lines.len(), 2); + assert_eq!(batch.lines[0].count, 2); + assert_eq!(batch.lines[1].count, 1); + } + + #[test] + fn each_line_oversized_line_is_bounded_and_explicit() { + let mut delivery = LineDelivery::default(); + delivery.scan("stdout", &vec![b'x'; MAX_LINE_BYTES], 0, &pattern()); + delivery.scan( + "stdout", + b"x\nERROR final", + MAX_LINE_BYTES as u64, + &pattern(), + ); + let batch = delivery.take(Instant::now(), true, &pattern()).unwrap(); + assert_eq!(batch.skipped_long_lines, 1); + assert_eq!(batch.first_dropped_offset, Some(0)); + assert_eq!(batch.lines[0].text, "ERROR final"); + } +} diff --git a/crates/aft/src/bash_background/watches.rs b/crates/aft/src/bash_background/watches.rs index 8021860d5f..3d8901d697 100644 --- a/crates/aft/src/bash_background/watches.rs +++ b/crates/aft/src/bash_background/watches.rs @@ -1,6 +1,9 @@ use regex::{Regex, RegexBuilder}; use serde::Serialize; use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use super::watch_lines::{LineBatch, LineDelivery}; const MAX_WATCHES_PER_TASK: usize = 8; const CONTEXT_BEFORE: usize = 100; @@ -65,10 +68,13 @@ pub struct PatternMatch { pub match_offset: u64, pub context: String, pub once: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch: Option, } #[derive(Debug, Default)] pub struct WatchRegistry { + line_delivery: HashMap, watches: HashMap>, scan_cursors: HashMap, scan_overlaps: HashMap>, @@ -81,6 +87,63 @@ pub struct WatchRegistry { } impl WatchRegistry { + pub fn enable_each_line(&mut self, watch_id: &str) { + self.line_delivery.entry(watch_id.to_string()).or_default(); + } + + pub fn is_each_line(&self, watch_id: &str) -> bool { + self.line_delivery.contains_key(watch_id) + } + + pub fn replay_cursor(&self, task_id: &str, stream: &str, cursor: u64) -> u64 { + self.watch_ids(task_id) + .iter() + .filter_map(|id| self.line_delivery.get(id)) + .fold(cursor, |cursor, delivery| { + delivery.replay_cursor(stream, cursor) + }) + } + + pub fn take_line_batches(&mut self, task_id: &str, terminal: bool) -> Vec { + self.take_line_batches_at(task_id, terminal, Instant::now()) + } + + fn take_line_batches_at( + &mut self, + task_id: &str, + terminal: bool, + now: Instant, + ) -> Vec { + let mut found = Vec::new(); + for watch in self.watch_specs(task_id) { + let Some(delivery) = self.line_delivery.get_mut(&watch.watch_id) else { + continue; + }; + let Some(batch) = delivery.take(now, terminal, &watch.pattern) else { + continue; + }; + let offset = batch + .lines + .first() + .map(|line| line.offset) + .or(batch.first_dropped_offset) + .unwrap_or(0); + self.matched_tasks.insert(task_id.to_string()); + found.push(PatternMatch { + watch_id: watch.watch_id.clone(), + task_id: task_id.to_string(), + match_text: "matching lines".to_string(), + match_offset: offset, + context: String::new(), + once: watch.once, + batch: Some(batch), + }); + if watch.once { + self.unregister(task_id, &watch.watch_id); + } + } + found + } pub fn register( &mut self, task_id: String, @@ -149,6 +212,7 @@ impl WatchRegistry { } pub fn unregister(&mut self, task_id: &str, watch_id: &str) { + self.line_delivery.remove(watch_id); if let Some(watches) = self.watches.get_mut(task_id) { watches.retain(|watch| watch.watch_id != watch_id); if watches.is_empty() { @@ -183,6 +247,11 @@ impl WatchRegistry { keep: &HashSet, has_pending_match: bool, ) { + for watch_id in self.watch_ids(task_id) { + if !keep.contains(&watch_id) { + self.line_delivery.remove(&watch_id); + } + } let watches_empty = if let Some(watches) = self.watches.get_mut(task_id) { watches.retain(|watch| keep.contains(&watch.watch_id)); watches.is_empty() @@ -199,6 +268,9 @@ impl WatchRegistry { } pub fn clear_task(&mut self, task_id: &str) { + for watch_id in self.watch_ids(task_id) { + self.line_delivery.remove(&watch_id); + } self.watches.remove(task_id); self.controlled_tasks.remove(task_id); self.matched_tasks.remove(task_id); @@ -309,6 +381,11 @@ impl WatchRegistry { let mut matches = Vec::new(); let mut remove_once = Vec::new(); for watch in watches { + if let Some(delivery) = self.line_delivery.get_mut(&watch.watch_id) { + let stream = cursor_key.rsplit(':').next().unwrap_or("stdout"); + delivery.scan(stream, bytes, base_offset, &watch.pattern); + continue; + } if let Some((text_start, text_end, source_start, source_end)) = find_match(&watch.pattern, &text, &scan_bytes, prefix_len) { @@ -327,6 +404,7 @@ impl WatchRegistry { match_offset: scan_base_offset.saturating_add(source_start as u64), context, once: watch.once, + batch: None, }); if watch.once { remove_once.push(watch.watch_id); @@ -430,6 +508,40 @@ pub(super) fn context_range(text: &str, start: usize, end: usize) -> std::ops::R #[cfg(test)] mod tests { + #[test] + fn each_line_default_frame_is_unchanged() { + let mut registry = super::WatchRegistry::default(); + registry + .register( + "task".into(), + super::WatchPattern::Substring("ERROR".into()), + false, + ) + .unwrap(); + let found = registry.scan_new_bytes("task", b"ERROR a\nERROR b\n"); + assert_eq!( + serde_json::to_string(&found).unwrap(), + r#"[{"watch_id":"watch-00000001","task_id":"task","match_text":"ERROR","match_offset":0,"context":"ERROR a\nERROR b\n","once":false}]"# + ); + } + + #[test] + fn each_line_registry_batches_across_pages() { + let mut registry = super::WatchRegistry::default(); + let id = registry + .register( + "task".into(), + super::WatchPattern::Substring("ERROR".into()), + false, + ) + .unwrap(); + registry.enable_each_line(&id); + registry.scan_new_bytes("task", b"ERROR a\nER"); + registry.scan_new_bytes("task", b"ROR b\n"); + let found = registry.take_line_batches("task", false); + assert_eq!(found.len(), 1); + assert_eq!(found[0].batch.as_ref().unwrap().lines.len(), 2); + } use super::*; #[test] diff --git a/crates/aft/src/commands/bash_notify.rs b/crates/aft/src/commands/bash_notify.rs index c58a79b363..4d4fe7a870 100644 --- a/crates/aft/src/commands/bash_notify.rs +++ b/crates/aft/src/commands/bash_notify.rs @@ -13,6 +13,8 @@ struct BashNotifyParams { regex: Option, #[serde(default = "default_once")] once: bool, + #[serde(default)] + deliver: Option, } #[derive(Debug, Deserialize)] @@ -42,6 +44,17 @@ pub fn handle(req: &RawRequest, ctx: &AppContext) -> Response { } }; + if params + .deliver + .as_deref() + .is_some_and(|value| value != "each_line") + { + return Response::error( + &req.id, + "invalid_request", + "bash_watch: deliver must be each_line", + ); + } let pattern = if let Some(regex) = params.regex.as_deref() { match WatchPattern::regex(regex) { Ok(pattern) => pattern, @@ -70,8 +83,12 @@ pub fn handle(req: &RawRequest, ctx: &AppContext) -> Response { .observed_status(¶ms.task_id, req.session(), 0) .is_some() { - ctx.bash_background() - .register_watch(params.task_id.clone(), pattern, params.once) + ctx.bash_background().register_watch_delivery( + params.task_id.clone(), + pattern, + params.once, + params.deliver.is_some(), + ) } else { Err("task_not_found") }; diff --git a/crates/aft/src/commands/bash_status.rs b/crates/aft/src/commands/bash_status.rs index 03e6ad54b2..dd49ef5c16 100644 --- a/crates/aft/src/commands/bash_status.rs +++ b/crates/aft/src/commands/bash_status.rs @@ -299,6 +299,13 @@ pub(crate) fn handle_with_observation_only( offset, ) { Ok(page) => { + data["output_page_text"] = + json!(ctx.bash_background().masked_status_page( + &task_id, + artifact, + offset.min(page.next_offset), + &page.bytes + )); if mode == BgMode::Pty && matches!(output_mode.as_deref(), Some("raw" | "both")) { @@ -329,6 +336,13 @@ pub(crate) fn handle_with_observation_only( offset, ) { Ok(page) => { + data["stderr_page_text"] = + json!(ctx.bash_background().masked_status_page( + &task_id, + crate::bash_background::persistence::TaskArtifact::Stderr, + offset.min(page.next_offset), + &page.bytes + )); data["stderr_chunk_base64"] = json!(base64::engine::general_purpose::STANDARD .encode(page.bytes)); diff --git a/crates/aft/src/commands/bash_watch.rs b/crates/aft/src/commands/bash_watch.rs index 0eaf77ac89..e297eb16b0 100644 --- a/crates/aft/src/commands/bash_watch.rs +++ b/crates/aft/src/commands/bash_watch.rs @@ -52,6 +52,8 @@ const PREVIEW_BYTES: usize = crate::bash_background::output::RUNNING_OUTPUT_PREV #[derive(Debug, Default, Deserialize)] struct BashWatchParams { + #[serde(default)] + deliver: Option, #[serde(default)] task_id: Option, #[serde(default)] @@ -305,6 +307,13 @@ pub(crate) fn handle_deferred_with_observation_only( "bash_watch: missing taskId", )); }; + if params.deliver.is_some() { + return DispatchOutcome::Immediate(Response::error( + &id, + "invalid_request", + "bash_watch: deliver requires background:true; this watch waits synchronously", + )); + } let pattern = match parse_pattern(params.pattern.as_ref()) { Ok(pattern) => pattern, Err(response) => return DispatchOutcome::Immediate(with_id(response, &id)), @@ -894,6 +903,19 @@ fn output_tail(output: &str) -> String { #[cfg(test)] mod tests { use super::*; + #[test] + fn each_line_sync_watch_refuses_delivery() { + let project = tempfile::tempdir().unwrap(); + let ctx = context(project.path(), project.path()); + let req = serde_json::from_value(json!({"id":"sync-delivery", "command":"bash_watch", "task_id":"task", "deliver":"each_line"})).unwrap(); + let DispatchOutcome::Immediate(response) = handle_deferred(&req, ctx) else { + panic!("sync delivery must refuse immediately") + }; + assert!(!response.success); + assert!(serde_json::to_string(&response) + .unwrap() + .contains("deliver requires background:true")); + } use crate::bash_background::persistence::{create_task_layout, write_task_at, PersistedTask}; use crate::config::Config; use crate::harness::Harness; diff --git a/crates/aft/src/db/bash_watches.rs b/crates/aft/src/db/bash_watches.rs index 81905e20fe..fd0db51bb5 100644 --- a/crates/aft/src/db/bash_watches.rs +++ b/crates/aft/src/db/bash_watches.rs @@ -15,6 +15,8 @@ pub struct BashPatternWatchRow { pub pattern_kind: String, pub pattern: String, pub once: bool, + pub each_line: bool, + pub match_batch: Option, pub created_at: i64, pub stdout_offset: i64, pub stderr_offset: i64, @@ -35,11 +37,11 @@ pub fn upsert_bash_pattern_watch( "INSERT INTO bash_pattern_watches ( harness, session_id, task_id, watch_id, pattern_kind, pattern, once, created_at, stdout_offset, stderr_offset, pty_offset, scanning, - pending_match, match_text, match_offset, match_context + pending_match, match_text, match_offset, match_context, each_line, match_batch ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, - ?13, ?14, ?15, ?16 + ?13, ?14, ?15, ?16, ?17, ?18 ) ON CONFLICT(harness, session_id, task_id, watch_id) DO UPDATE SET pattern_kind = excluded.pattern_kind, @@ -53,7 +55,7 @@ pub fn upsert_bash_pattern_watch( pending_match = excluded.pending_match, match_text = excluded.match_text, match_offset = excluded.match_offset, - match_context = excluded.match_context", + match_context = excluded.match_context, each_line = excluded.each_line, match_batch = excluded.match_batch", params![ row.harness, row.session_id, @@ -71,6 +73,8 @@ pub fn upsert_bash_pattern_watch( row.match_text, row.match_offset, row.match_context, + row.each_line, + row.match_batch, ], )?; Ok(()) @@ -112,7 +116,7 @@ pub fn list_bash_pattern_watches_for_task( let mut stmt = conn.prepare( "SELECT harness, session_id, task_id, watch_id, pattern_kind, pattern, once, created_at, stdout_offset, stderr_offset, pty_offset, scanning, - pending_match, match_text, match_offset, match_context + pending_match, match_text, match_offset, match_context, each_line, match_batch FROM bash_pattern_watches WHERE harness = ?1 AND session_id = ?2 AND task_id = ?3 ORDER BY created_at ASC, watch_id ASC", @@ -131,7 +135,7 @@ pub fn list_bash_pattern_watches_for_session( let mut stmt = conn.prepare( "SELECT harness, session_id, task_id, watch_id, pattern_kind, pattern, once, created_at, stdout_offset, stderr_offset, pty_offset, scanning, - pending_match, match_text, match_offset, match_context + pending_match, match_text, match_offset, match_context, each_line, match_batch FROM bash_pattern_watches WHERE harness = ?1 AND session_id = ?2 ORDER BY created_at ASC, task_id ASC, watch_id ASC", @@ -149,7 +153,7 @@ pub fn list_bash_pattern_watches( let mut stmt = conn.prepare( "SELECT harness, session_id, task_id, watch_id, pattern_kind, pattern, once, created_at, stdout_offset, stderr_offset, pty_offset, scanning, - pending_match, match_text, match_offset, match_context + pending_match, match_text, match_offset, match_context, each_line, match_batch FROM bash_pattern_watches WHERE harness = ?1 ORDER BY created_at ASC, task_id ASC, watch_id ASC", @@ -166,7 +170,7 @@ pub fn list_bash_pattern_watches_by_task_id( let mut stmt = conn.prepare( "SELECT harness, session_id, task_id, watch_id, pattern_kind, pattern, once, created_at, stdout_offset, stderr_offset, pty_offset, scanning, - pending_match, match_text, match_offset, match_context + pending_match, match_text, match_offset, match_context, each_line, match_batch FROM bash_pattern_watches WHERE harness = ?1 AND task_id = ?2 ORDER BY created_at ASC, session_id ASC, watch_id ASC", @@ -214,7 +218,7 @@ pub fn get_bash_pattern_watch( conn.query_row( "SELECT harness, session_id, task_id, watch_id, pattern_kind, pattern, once, created_at, stdout_offset, stderr_offset, pty_offset, scanning, - pending_match, match_text, match_offset, match_context + pending_match, match_text, match_offset, match_context, each_line, match_batch FROM bash_pattern_watches WHERE harness = ?1 AND session_id = ?2 AND task_id = ?3 AND watch_id = ?4", params![harness, session_id, task_id, watch_id], @@ -265,5 +269,7 @@ fn map_watch_row(row: &Row<'_>) -> rusqlite::Result { match_text: row.get(13)?, match_offset: row.get(14)?, match_context: row.get(15)?, + each_line: row.get(16)?, + match_batch: row.get(17)?, }) } diff --git a/crates/aft/src/db/mod.rs b/crates/aft/src/db/mod.rs index 00ee03c048..63f3462557 100644 --- a/crates/aft/src/db/mod.rs +++ b/crates/aft/src/db/mod.rs @@ -34,7 +34,7 @@ pub mod state; #[cfg(test)] pub(crate) mod wal_credit_probe; -pub const CURRENT_SCHEMA_VERSION: u32 = rich_ui::SCHEMA_VERSION; +pub const CURRENT_SCHEMA_VERSION: u32 = 21; const MIGRATION_VERSIONS: &[u32] = &[ 1, @@ -57,6 +57,8 @@ const MIGRATION_VERSIONS: &[u32] = &[ 18, 19, rich_ui::SCHEMA_VERSION, + 21, +] ]; // Performance-only indexes do not change the table format or reader floor. @@ -1017,6 +1019,7 @@ fn apply_migration_statements(conn: &Connection, version: u32) -> rusqlite::Resu 18 => conn.execute_batch(access_grants::MODES_MIGRATION), 19 => call_ledger::migrate_result_source(conn), rich_ui::SCHEMA_VERSION => conn.execute_batch(rich_ui::MIGRATION), + 21 => apply_migration_v21(conn), _ => Ok(()), } } @@ -1034,6 +1037,22 @@ fn apply_migration_v4(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } +fn apply_migration_v21(conn: &Connection) -> rusqlite::Result<()> { + let columns = conn + .prepare("PRAGMA table_info(bash_pattern_watches)")? + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?; + if !columns.iter().any(|column| column == "each_line") { + conn.execute_batch( + "ALTER TABLE bash_pattern_watches ADD COLUMN each_line INTEGER NOT NULL DEFAULT 0", + )?; + } + if !columns.iter().any(|column| column == "match_batch") { + conn.execute_batch("ALTER TABLE bash_pattern_watches ADD COLUMN match_batch TEXT")?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1436,6 +1455,50 @@ mod tests { } } + #[test] + fn migration_v20_opens_v19_with_default_first_match_and_new_reader_floor() { + let dir = tempdir().unwrap(); + let path = dir.path().join("aft.db"); + let conn = Connection::open(&path).unwrap(); + for version in 1..=19 { + apply_migration_statements(&conn, version).unwrap(); + } + conn.execute("INSERT INTO schema_version(version) VALUES (19)", []) + .unwrap(); + drop(conn); + crate::reader_floor::raise( + dir.path(), + &[(crate::persisted_format::PersistedStore::AftDb, 19)], + ) + .unwrap(); + assert!(crate::reader_floor::prepare(dir.path()).is_empty()); + let conn = open(&path).unwrap(); + assert_eq!(schema_version(&conn), 20); + let columns = conn + .prepare("PRAGMA table_info(bash_pattern_watches)") + .unwrap() + .query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, bool>(3)?, + row.get::<_, Option>(4)?, + )) + }) + .unwrap() + .collect::>>() + .unwrap(); + assert!(columns.contains(&("each_line".into(), "INTEGER".into(), true, Some("0".into())))); + assert!(columns.contains(&("match_batch".into(), "TEXT".into(), false, None))); + assert_eq!( + crate::reader_floor::read(dir.path()) + .unwrap() + .unwrap() + .get(crate::persisted_format::PersistedStore::AftDb), + Some(20) + ); + } + #[test] fn migration_v2_deduplicates_compression_events_and_adds_unique_index() { let dir = tempdir().unwrap(); diff --git a/crates/aft/src/list_surfaces.rs b/crates/aft/src/list_surfaces.rs index 11c3c1d7c0..e58178a137 100644 --- a/crates/aft/src/list_surfaces.rs +++ b/crates/aft/src/list_surfaces.rs @@ -366,6 +366,12 @@ pub static EXCLUSIONS: &[ExclusionEntry] = &[ location_or_primitive: "MAX_CWD_PATHS", reason: "internal cwd analysis retains at most eight folder/mode states per exit-status branch; exceeding that bound makes the folder unknown rather than removing alternatives, and no response list is shortened", }, + ExclusionEntry { + file: "bash_background/watch_lines.rs", + enclosing_item: "LineDelivery", + location_or_primitive: "MAX_LINES_PER_NOTIFICATION, MAX_NOTIFICATIONS_PER_MINUTE, MAX_LINE_BYTES", + reason: "Async push notifications are not command-response lists. LineBatch reports exact cap drops and the source stream/byte offset; oversized lines explicitly mark scanning incomplete.", + }, ExclusionEntry { file: "standalone_input.rs", enclosing_item: "read_bounded_line, room, push", diff --git a/crates/aft/src/protocol.rs b/crates/aft/src/protocol.rs index 9ab9e12dce..674569e392 100644 --- a/crates/aft/src/protocol.rs +++ b/crates/aft/src/protocol.rs @@ -134,6 +134,8 @@ pub struct BashPatternMatchFrame { pub context: String, pub once: bool, pub reason: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch: Option, } /// Pushed after configure has completed, when the deferred file walk and @@ -409,6 +411,7 @@ impl BashPatternMatchFrame { ) -> Self { Self { frame_type: "bash_pattern_match", + batch: None, task_id: task_id.into(), session_id: session_id.into(), watch_id: watch_id.into(), @@ -428,6 +431,7 @@ impl BashPatternMatchFrame { ) -> Self { Self { frame_type: "bash_pattern_match", + batch: None, task_id: task_id.into(), session_id: session_id.into(), watch_id: "exit".to_string(), @@ -450,6 +454,7 @@ impl BashPatternMatchFrame { // reason because clients only accept pattern_match and task_exit. Self { frame_type: "bash_pattern_match", + batch: None, task_id: task_id.into(), session_id: session_id.into(), watch_id: watch_id.into(), diff --git a/crates/aft/src/subc/mod.rs b/crates/aft/src/subc/mod.rs index a7c3d1bf8a..943f5d7cb3 100644 --- a/crates/aft/src/subc/mod.rs +++ b/crates/aft/src/subc/mod.rs @@ -14039,6 +14039,7 @@ pub(crate) mod test_support { pub(super) fn pattern_match_frame(session_id: &str) -> PushFrame { PushFrame::BashPatternMatch(BashPatternMatchFrame { + batch: None, frame_type: "bash_pattern_match", task_id: "task-pattern".to_string(), session_id: session_id.to_string(), diff --git a/crates/aft/src/subc_format.rs b/crates/aft/src/subc_format.rs index 90ac1585a2..956eb282d2 100644 --- a/crates/aft/src/subc_format.rs +++ b/crates/aft/src/subc_format.rs @@ -1197,6 +1197,23 @@ fn format_bash_status( data.get("exit_code").and_then(Value::as_i64), data.get("status_reason").and_then(Value::as_str), ); + let mut paged = false; + for (stream, cursor) in [("output", "outputOffset"), ("stderr", "stderrOffset")] { + if let Some(page) = data + .get(format!("{stream}_page_text")) + .and_then(Value::as_str) + { + text.push_str(&format!( + "\n{stream} page (next {cursor}: {}, has_more: {}):\n{page}", + data[format!("{stream}_next_offset")], + data[format!("{stream}_has_more")] + )); + paged = true; + } + } + if paged { + return text; + } if let Some(duration_ms) = data.get("duration_ms").and_then(Value::as_u64) { // Rounded to the nearest second, as the plugin displays it. text.push_str(&format!(" {}s", duration_ms.saturating_add(500) / 1000)); @@ -5431,3 +5448,34 @@ mod executed_outline_mode_tests { ); } } + +/// Render a bounded line batch; offsets refer to the original, unmasked stream. +pub fn format_watch_line_batch( + task_id: &str, + batch: &crate::bash_background::watch_lines::LineBatch, +) -> String { + let mut text = String::new(); + for line in &batch.lines { + text.push_str(&format!( + "{} byte {} (x{}): {}\n", + line.stream, line.offset, line.count, line.text + )); + } + if batch.dropped_lines > 0 || batch.skipped_long_lines > 0 { + let incomplete = if batch.skipped_long_lines > 0 { + format!( + " {} lines longer than 64 KiB skipped (scan incomplete).", + batch.skipped_long_lines + ) + } else { + String::new() + }; + let cursor = if batch.first_dropped_stream.as_deref() == Some("stderr") { + "stderr_offset" + } else { + "output_offset" + }; + text.push_str(&format!("{} matching lines dropped.{incomplete} Read bash_status({{ task_id: {}, {cursor}: {} }}).\n", batch.dropped_lines, serde_json::to_string(task_id).unwrap_or_default(), batch.first_dropped_offset.unwrap_or(0))); + } + text.trim_end().to_string() +} diff --git a/crates/aft/src/subc_tool_presets.json b/crates/aft/src/subc_tool_presets.json index 6baada89ae..7137d3ca42 100644 --- a/crates/aft/src/subc_tool_presets.json +++ b/crates/aft/src/subc_tool_presets.json @@ -87,6 +87,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -139,6 +151,13 @@ "minimum": 1, "maximum": 1800000 }, + "deliver": { + "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", + "type": "string", + "enum": [ + "each_line" + ] + }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", "type": "boolean" diff --git a/crates/aft/src/subc_tool_schemas.json b/crates/aft/src/subc_tool_schemas.json index 4046e0c85d..8da197be2b 100644 --- a/crates/aft/src/subc_tool_schemas.json +++ b/crates/aft/src/subc_tool_schemas.json @@ -847,6 +847,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", diff --git a/crates/aft/src/subc_translate.rs b/crates/aft/src/subc_translate.rs index 918cb53e8c..a728ef5ed7 100644 --- a/crates/aft/src/subc_translate.rs +++ b/crates/aft/src/subc_translate.rs @@ -1327,10 +1327,14 @@ fn translate_bash_task_tool(bare_name: &str, args: Value) -> Translated { let mut map = agent_args_map(args); rename(&mut map, "taskId", "task_id"); rename(&mut map, "outputMode", "output_mode"); + rename(&mut map, "outputOffset", "output_offset"); + rename(&mut map, "stderrOffset", "stderr_offset"); rename(&mut map, "timeoutMs", "timeout_ms"); if let Some(Value::Object(params)) = map.get_mut("params") { rename(params, "taskId", "task_id"); rename(params, "outputMode", "output_mode"); + rename(params, "outputOffset", "output_offset"); + rename(params, "stderrOffset", "stderr_offset"); rename(params, "timeoutMs", "timeout_ms"); } Translated { diff --git a/crates/aft/tests/integration/bash_watch_test.rs b/crates/aft/tests/integration/bash_watch_test.rs index 1b79e2f8c1..dabede0664 100644 --- a/crates/aft/tests/integration/bash_watch_test.rs +++ b/crates/aft/tests/integration/bash_watch_test.rs @@ -253,6 +253,41 @@ fn pattern_match_emits_push_frame() { assert!(aft.shutdown().success()); } +#[cfg(unix)] +#[test] +fn each_line_push_batches_all_lines_with_offsets() { + let mut aft = AftProcess::spawn(); + let dir = configure_background(&mut aft); + let release = dir.path().join("each-line-release"); + let payload = dir.path().join("each-line-output"); + fs::write(&payload, b"ERROR a\nERROR b\nERROR b\n").unwrap(); + let release_guard = ReleaseOnDrop::new(release.clone()); + let command = format!( + "while [ ! -f {} ]; do sleep 0.05; done; cat {}", + shell_quote(&release.display().to_string()), + shell_quote(&payload.display().to_string()) + ); + let task_id = spawn(&mut aft, &command); + let response = notify( + &mut aft, + &task_id, + json!({"pattern":"ERROR", "deliver":"each_line", "once":false}), + ); + assert_eq!(response["success"], true, "{response:?}"); + drop(release_guard); + let frame = wait_for_pattern_frame(&mut aft, &task_id); + assert_eq!( + frame["batch"]["lines"].as_array().unwrap().len(), + 2, + "{frame:?}" + ); + assert_eq!(frame["batch"]["lines"][0]["offset"], 0); + assert_eq!(frame["batch"]["lines"][1]["offset"], 8); + assert_eq!(frame["batch"]["lines"][1]["count"], 2); + assert!(frame["context"].as_str().unwrap().contains("ERROR b")); + assert!(aft.shutdown().success()); +} + #[cfg(unix)] #[test] fn pattern_match_offset_counts_original_bytes_before_invalid_utf8() { diff --git a/docs/tools.md b/docs/tools.md index 2661873710..54945adcf8 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -544,6 +544,9 @@ briefly exceed the limit. **`bash_status`** — read-only snapshot of a background or PTY task's current state and output. Never waits. For PTY tasks, `outputMode` selects `screen` (vt100-rendered), `raw` (byte stream), or `both`. +Optional `outputOffset` / `stderrOffset` request at most 64 KiB from the original +stdout (or PTY) / stderr byte offset, returning masked page text and the next offset. +Pi uses `output_offset` / `stderr_offset`, as with its other snake_case arguments. **`bash_watch`** — block on or register for a background task's output. In a main session sync waits are for a short remaining wait on a task (default 30s, max `bash.watch_sync_max_ms`, 120s @@ -557,6 +560,16 @@ but never extends it beyond the configured worker limit. Every session's blockin call (`wait: true`, or any foreground call when `bash.subagent_background` is false) is bounded the same way: at the limit the command moves to the background, not killed, and the reply gives its task id, including on the tool-provider/v1 route. +Async watches can opt into `deliver: "each_line"` (requires `background: true`): each +complete matching line carries its source stream and byte offset, and consecutive identical +lines are folded with a count. Each scan interval sends one batch, retaining at most 50 source +lines, with at most 12 matching-line notifications per 60-second window per watch; omitted +`deliver` keeps first-match delivery unchanged. Drops carry an exact count and a copyable +`bash_status` byte-page call; rate-limited drops are summarized in the next allowed batch. +The final watch notification is exempt from the match-rate cap so it can report final drop counts immediately. +Lines longer than 64 KiB are skipped with an explicit incomplete-scan count; a final unterminated +line is checked on exit. Matching is line-local, including for regexes; use a sync watch for +cross-line patterns. `once` still defaults to true; use `once: false` to keep following a log. The head preset declares a reply deadline for `bash`; the worker preset declares it for `bash` and `bash_watch`. Both use the resolved `bash.worker_wait_max_ms` plus 240 seconds for spawn, terminal handoff and reply delivery. A watch does not spawn a command, but retains the same diff --git a/packages/aft-bridge/src/bash-watch-lines.ts b/packages/aft-bridge/src/bash-watch-lines.ts new file mode 100644 index 0000000000..0eda20404b --- /dev/null +++ b/packages/aft-bridge/src/bash-watch-lines.ts @@ -0,0 +1,78 @@ +import type { BashWatchLineBatch } from "./bridge.js"; + +const MAX_PENDING_WATCH_LINES = 50; + +/** Live pushes are deltas; durable replay is a replacement snapshot. */ +export function mergeWatchLineBatches( + previous: BashWatchLineBatch, + next: BashWatchLineBatch, +): BashWatchLineBatch { + if (next.snapshot) return next; + const merged = { ...previous, lines: previous.lines.map((line) => ({ ...line })) }; + let retained = merged.lines.reduce((sum, line) => sum + line.count, 0); + for (const line of next.lines) { + const keep = Math.min(line.count, MAX_PENDING_WATCH_LINES - retained); + const dropped = line.count - keep; + if (dropped > 0 && merged.first_dropped_offset === null) { + merged.first_dropped_offset = line.offset + keep * (line.end_offset - line.offset); + merged.first_dropped_stream = line.stream; + } + merged.dropped_lines += dropped; + if (keep > 0) { + const last = merged.lines.at(-1); + if ( + last && + last.stream === line.stream && + last.text === line.text && + last.offset + last.count * (last.end_offset - last.offset) === line.offset + ) + last.count += keep; + else merged.lines.push({ ...line, count: keep }); + retained += keep; + } + } + merged.dropped_lines += next.dropped_lines; + merged.skipped_long_lines += next.skipped_long_lines; + if (merged.first_dropped_offset === null) { + merged.first_dropped_offset = next.first_dropped_offset; + merged.first_dropped_stream = next.first_dropped_stream; + } + return merged; +} + +/** Render masked source lines and copyable, host-specific byte-page recovery calls. */ +export function formatWatchLineBatch( + taskId: string, + batch: BashWatchLineBatch, + camelCase: boolean, +): string { + const lines = batch.lines.map( + (line) => `${line.stream} byte ${line.offset} (x${line.count}): ${line.text}`, + ); + if (batch.dropped_lines > 0 || batch.skipped_long_lines > 0) { + const taskKey = camelCase ? "taskId" : "task_id"; + const cursor = batch.first_dropped_stream === "stderr" ? "stderr" : "output"; + const offsetKey = camelCase ? `${cursor}Offset` : `${cursor}_offset`; + const incomplete = + batch.skipped_long_lines > 0 + ? ` ${batch.skipped_long_lines} lines longer than 64 KiB skipped (scan incomplete).` + : ""; + lines.push( + `${batch.dropped_lines} matching lines dropped.${incomplete} Read bash_status({ ${taskKey}: ${JSON.stringify(taskId)}, ${offsetKey}: ${batch.first_dropped_offset ?? 0} }).`, + ); + } + return lines.join("\n"); +} + +/** Page text is masked by the engine; never decode raw transport bytes for display. */ +export function formatBashOutputPages(data: Record, camelCase: boolean): string { + const lines: string[] = []; + for (const stream of ["output", "stderr"]) { + if (typeof data[`${stream}_page_text`] !== "string") continue; + const next = camelCase ? `${stream}Offset` : `${stream}_offset`; + lines.push( + `${stream} page (next ${next}: ${data[`${stream}_next_offset`]}, has_more: ${data[`${stream}_has_more`]}):\n${data[`${stream}_page_text`]}`, + ); + } + return lines.join("\n"); +} diff --git a/packages/aft-bridge/src/bridge.ts b/packages/aft-bridge/src/bridge.ts index 4667964fa7..7c61f562ba 100644 --- a/packages/aft-bridge/src/bridge.ts +++ b/packages/aft-bridge/src/bridge.ts @@ -500,7 +500,17 @@ export interface BashLongRunningPayload { elapsed_ms: number; } +export interface BashWatchLineBatch { + snapshot?: boolean; + lines: { stream: string; offset: number; end_offset: number; text: string; count: number }[]; + dropped_lines: number; + first_dropped_offset: number | null; + first_dropped_stream: string | null; + skipped_long_lines: number; +} + export interface BashPatternMatchFrame { + batch?: BashWatchLineBatch; type: "bash_pattern_match"; task_id: string; session_id: string; diff --git a/packages/aft-bridge/src/index.ts b/packages/aft-bridge/src/index.ts index 6f58daeb2b..65a45e4cdd 100644 --- a/packages/aft-bridge/src/index.ts +++ b/packages/aft-bridge/src/index.ts @@ -89,6 +89,11 @@ export { groupBashPermissionAsks, withBashPermissionLoop, } from "./bash-permissions.js"; +export { + formatBashOutputPages, + formatWatchLineBatch, + mergeWatchLineBatches, +} from "./bash-watch-lines.js"; // --- binary identity (no-exec cache trust) --- export type { BinaryIdentity, BinaryIdentityCheck } from "./binary-identity.js"; export { @@ -106,6 +111,7 @@ export type { BashLongRunningPayload, BashPatternMatchAck, BashPatternMatchFrame, + BashWatchLineBatch, BridgeClock, BridgeOptions, BridgeRequestOptions, diff --git a/packages/opencode-plugin/src/__tests__/bash.test.ts b/packages/opencode-plugin/src/__tests__/bash.test.ts index f6579886c1..3fe8c5831f 100644 --- a/packages/opencode-plugin/src/__tests__/bash.test.ts +++ b/packages/opencode-plugin/src/__tests__/bash.test.ts @@ -1738,6 +1738,50 @@ describe("bash_status tool", () => { expect(sessionBgStates.get("s-watch")?.outstandingTaskIds.has("bash-finished")).toBe(false); }); + test("each_line recovery pages forward byte offsets and show only masked text", async () => { + const { calls, statusTool } = makeCtx(() => ({ + success: true, + status: "running", + output_page_text: "masked output", + stderr_page_text: "masked error", + output_chunk_base64: "RAW-SECRET", + output_next_offset: 48, + stderr_next_offset: 20, + output_has_more: false, + stderr_has_more: false, + })); + const result = await statusTool.execute( + { taskId: "bash-lines", outputOffset: 16, stderrOffset: 10 }, + createMockSdkContext(), + ); + expect(calls.find((call) => call.cmd === "bash_status")?.params).toMatchObject({ + output_offset: 16, + stderr_offset: 10, + }); + expect(result).toContain("masked output"); + expect(result).toContain("next outputOffset: 48"); + expect(result).not.toContain("RAW-SECRET"); + }); + + test("each_line forwards async delivery and refuses sync delivery", async () => { + const { calls, watchTool } = makeCtx(() => ({ + success: true, + watch_id: "watch-lines", + status: "running", + })); + await watchTool.execute( + { taskId: "bash-lines", pattern: "ERROR", background: true, deliver: "each_line" }, + createMockSdkContext(), + ); + expect(calls.find((call) => call.cmd === "bash_notify")?.params.deliver).toBe("each_line"); + await expect( + watchTool.execute( + { taskId: "bash-lines", pattern: "ERROR", deliver: "each_line" }, + createMockSdkContext(), + ), + ).rejects.toThrow("deliver requires background:true"); + }); + test('async bash_watch forwards string once "false" as boolean false', async () => { const { calls, watchTool } = makeCtx((cmd) => cmd === "bash_notify" diff --git a/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts b/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts index 49765add32..5fc884b6b4 100644 --- a/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts @@ -2701,3 +2701,66 @@ function preAdmissionRejection(message: string) { response: { status: 400 }, }; } + +test("each_line reminder keeps bounded rows and drop recovery hint", () => { + const reminder = formatPatternMatchReminder([ + { + task_id: "bash-lines", + session_id: "s1", + watch_id: "watch-lines", + match_text: "matching lines", + match_offset: 0, + context: "stdout byte 0 (x2): ERROR a\n3 matching lines dropped; Read bash_status offset=16.", + once: false, + batch: { + lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 2 }], + dropped_lines: 3, + first_dropped_offset: 16, + first_dropped_stream: "stdout", + skipped_long_lines: 0, + }, + }, + ]); + expect(reminder).toContain("task bash-lines matching lines:"); + expect(reminder).toContain("stdout byte 0 (x2): ERROR a"); + expect(reminder).toContain( + '3 matching lines dropped. Read bash_status({ taskId: "bash-lines", outputOffset: 16 }).', + ); +}); + +test("each_line queue replaces snapshots without discarding another watch", async () => { + markTaskWaiting("s-lines", "bash-lines"); + const { ctx } = harness(() => ({ success: true, acked_task_ids: [] })); + const base = { + task_id: "bash-lines", + session_id: "s-lines", + watch_id: "watch-a", + match_text: "matching lines", + match_offset: 0, + context: "rows", + once: false, + }; + const batch = { + lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 1 }], + dropped_lines: 0, + first_dropped_offset: null, + first_dropped_stream: null, + skipped_long_lines: 0, + }; + const drain = { ctx, directory: "/tmp/project", sessionID: "s-lines", client: {} }; + await handlePushedPatternMatch(drain, { ...base, batch }); + await handlePushedPatternMatch(drain, { + ...base, + batch: { + ...batch, + snapshot: true, + dropped_lines: 3, + first_dropped_offset: 8, + first_dropped_stream: "stdout", + }, + }); + await handlePushedPatternMatch(drain, { ...base, watch_id: "watch-b", batch }); + const pending = sessionBgStates.get("s-lines")?.pendingPatternMatches; + expect(pending).toHaveLength(2); + expect(pending?.find((entry) => entry.watch_id === "watch-a")?.batch?.dropped_lines).toBe(3); +}); diff --git a/packages/opencode-plugin/src/bg-notifications.ts b/packages/opencode-plugin/src/bg-notifications.ts index c80188ebca..60be5335fe 100644 --- a/packages/opencode-plugin/src/bg-notifications.ts +++ b/packages/opencode-plugin/src/bg-notifications.ts @@ -1,7 +1,10 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { BashWatchLineBatch } from "@cortexkit/aft-bridge"; import { type AftProjectTransport, type BgNudgeRef, + formatWatchLineBatch, + mergeWatchLineBatches, resolveBridgeForNudge, } from "@cortexkit/aft-bridge"; import { sessionLog, sessionWarn } from "./logger.js"; @@ -56,6 +59,7 @@ export interface BgCompletion { } export interface PatternMatchEntry { + batch?: BashWatchLineBatch; task_id: string; session_id: string; watch_id: string; @@ -509,11 +513,21 @@ function queuePendingPatternMatch(state: SessionBgState, entry: PatternMatchEntr ? entry : { ...entry, reason: "pattern_match" }; const existingIdx = state.pendingPatternMatches.findIndex( - (match) => match.task_id === normalized.task_id, + (match) => + match.task_id === normalized.task_id && + (normalized.batch || match.batch ? match.watch_id === normalized.watch_id : true), ); if (existingIdx >= 0) { const existing = state.pendingPatternMatches[existingIdx]; - if (existing.reason !== "pattern_match" && normalized.reason === "pattern_match") { + // Live pushes contain only new rows; replay replaces the durable snapshot. + // Bound the queue too, since the host can stay busy across many scan intervals. + if (normalized.batch && existing.batch) { + normalized.batch = mergeWatchLineBatches(existing.batch, normalized.batch); + } + if ( + normalized.batch || + (existing.reason !== "pattern_match" && normalized.reason === "pattern_match") + ) { state.pendingPatternMatches[existingIdx] = normalized; } return; @@ -1620,7 +1634,14 @@ export function formatLongRunningReminder(reminders: readonly BgLongRunningRemin export function formatPatternMatchReminder(matches: readonly PatternMatchEntry[]): string { const bullets = matches .map((match) => { - const context = (match.context || match.match_text).replace(/\n/g, "\n > "); + const context = ( + match.batch + ? formatWatchLineBatch(match.task_id, match.batch, true) + : match.context || match.match_text + ).replace(/\n/g, "\n > "); + if (match.batch) { + return `- task ${match.task_id} matching lines:\n > ${context}`; + } if (match.reason === "task_exit") { return `- task ${match.task_id} exited:\n > ${context}`; } diff --git a/packages/opencode-plugin/src/index.ts b/packages/opencode-plugin/src/index.ts index 028ec36511..e80ff0fae0 100644 --- a/packages/opencode-plugin/src/index.ts +++ b/packages/opencode-plugin/src/index.ts @@ -94,6 +94,7 @@ import type { PluginContext } from "./types.js"; import { appendHintsToSystem, buildHintsForRegisteredTools } from "./workflow-hints.js"; type BashPatternMatchPayload = { + batch?: import("@cortexkit/aft-bridge").BashWatchLineBatch; session_id: string; task_id: string; watch_id: string; diff --git a/packages/opencode-plugin/src/subc-tool-schemas.ts b/packages/opencode-plugin/src/subc-tool-schemas.ts index a12181cfa5..3e2a6f64fd 100644 --- a/packages/opencode-plugin/src/subc-tool-schemas.ts +++ b/packages/opencode-plugin/src/subc-tool-schemas.ts @@ -384,7 +384,7 @@ export function buildSubcToolPresets( * The worker preset's `bash_watch`: the OpenCode tool's arguments with the * worker description. The module serves this tool itself and always waits * synchronously, because a catalog consumer has no channel that would deliver - * an async watch's notification; the two async-only arguments say so. + * an async watch's notification; the async-only arguments say so. */ function workerBashWatchSchema( schema: Record, @@ -401,6 +401,11 @@ function workerBashWatchSchema( ...properties.once, description: "Accepted for compatibility; only an async watch reads it.", }; + properties.deliver = { + ...properties.deliver, + description: + "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", + }; return { ...clone, description }; } diff --git a/packages/opencode-plugin/src/tools/bash.ts b/packages/opencode-plugin/src/tools/bash.ts index 26a6a0b7f6..e9a38065a3 100644 --- a/packages/opencode-plugin/src/tools/bash.ts +++ b/packages/opencode-plugin/src/tools/bash.ts @@ -9,6 +9,7 @@ import { classifyBashHostFallbackError, coerceBoolean, escalationAskText, + formatBashOutputPages, groupBashPermissionAsks, isLiveBackgroundTaskReply, LONGEST_TIMER_DELAY_MS, @@ -801,6 +802,12 @@ export function createBashStatusTool(ctx: PluginContext): ToolDefinition { .describe( "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", ), + outputOffset: optionalInt(0, Number.MAX_SAFE_INTEGER).describe( + "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + ), + stderrOffset: optionalInt(0, Number.MAX_SAFE_INTEGER).describe( + "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + ), outputMode: z .enum(["screen", "raw", "both"]) .optional() @@ -814,7 +821,27 @@ export function createBashStatusTool(ctx: PluginContext): ToolDefinition { // bash_status is snapshot-only as of bash_watch landing. waitFor/exit/ // timeoutMs moved to bash_watch — if the agent passes them here, they're // silently ignored at the Zod schema layer (extra keys stripped). - const data = await bashStatusSnapshot(ctx, context, taskId, outputMode); + const paging = args.outputOffset !== undefined || args.stderrOffset !== undefined; + const data = paging + ? await callBashBridge(ctx, context, "bash_status", { + task_id: taskId, + output_mode: outputMode, + output_offset: coerceOptionalInt( + args.outputOffset, + "outputOffset", + 0, + Number.MAX_SAFE_INTEGER, + ), + stderr_offset: coerceOptionalInt( + args.stderrOffset, + "stderrOffset", + 0, + Number.MAX_SAFE_INTEGER, + ), + }) + : await bashStatusSnapshot(ctx, context, taskId, outputMode); + if (data.success === false) throw new Error(String(data.message ?? "bash_status failed")); + if (paging) return formatBashOutputPages(data, true); const isSubagent = await resolveIsSubagent(ctx.client, context.sessionID, context.directory); return await formatBashStatusText( context, diff --git a/packages/opencode-plugin/src/tools/bash_watch.ts b/packages/opencode-plugin/src/tools/bash_watch.ts index 72214e1e73..243d90bc2e 100644 --- a/packages/opencode-plugin/src/tools/bash_watch.ts +++ b/packages/opencode-plugin/src/tools/bash_watch.ts @@ -145,6 +145,12 @@ export function createBashWatchTool(ctx: PluginContext): ToolDefinition { "When true, register an async watch and return immediately. Defaults to false (sync wait).", ), timeoutMs: optionalInt(1, 1800000).describe(WATCH_TIMEOUT_PARAM_DESCRIPTION), + deliver: z + .enum(["each_line"]) + .optional() + .describe( + "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with bash_status byte offsets.", + ), once: z .boolean() .optional() @@ -168,6 +174,14 @@ export function createBashWatchTool(ctx: PluginContext): ToolDefinition { requestedAsync && subagentStatus !== false && !bashCfg.subagent_background; const isSubagent = subagentStatus === true; const asyncMode = requestedAsync && !subagentForcedSync; + if (args.deliver !== undefined && !asyncMode) { + throw new Error( + "invalid_request: bash_watch deliver requires background:true; this watch waits synchronously", + ); + } + if (args.deliver !== undefined && args.deliver !== "each_line") { + throw new Error("invalid_request: bash_watch deliver must be each_line"); + } if (asyncMode) { if (!waitFor) { @@ -179,6 +193,7 @@ export function createBashWatchTool(ctx: PluginContext): ToolDefinition { task_id: taskId, once: coerceBoolean(args.once, true), }; + if (args.deliver !== undefined) notifyParams.deliver = args.deliver; if (waitFor.kind === "regex") notifyParams.regex = waitFor.source; else notifyParams.pattern = waitFor.value; markExplicitControl(context.sessionID, taskId, false); diff --git a/packages/pi-plugin/src/__tests__/bash.test.ts b/packages/pi-plugin/src/__tests__/bash.test.ts index 280b87f07d..7f35c0375c 100644 --- a/packages/pi-plugin/src/__tests__/bash.test.ts +++ b/packages/pi-plugin/src/__tests__/bash.test.ts @@ -1269,6 +1269,63 @@ describe("bash tool adapter", () => { expect(sessionBgStates.get("s-watch")?.outstandingTaskIds.has("bash-finished")).toBe(false); }); + test("each_line recovery pages forward byte offsets and show only masked text", async () => { + const tools = new Map(); + const { bridge, calls } = makeTrackableMockBridge({ + status: "running", + output_page_text: "masked output", + output_chunk_base64: "RAW-SECRET", + output_next_offset: 48, + output_has_more: false, + }); + registerBashTool(makeMockApi(tools), makeMockContext(bridge)); + const result = (await tools + .get("bash_status")! + .execute( + "call", + { task_id: "bash-lines", output_offset: 16, stderr_offset: 10 }, + undefined, + undefined, + { cwd: projectRoot }, + )) as { content: { text: string }[] }; + const status = calls.find((call) => (call as [string])[0] === "bash_status") as [ + string, + Record, + ]; + expect(status[1]).toMatchObject({ output_offset: 16, stderr_offset: 10 }); + expect(result.content[0].text).toContain("masked output"); + expect(result.content[0].text).toContain("next output_offset: 48"); + expect(result.content[0].text).not.toContain("RAW-SECRET"); + }); + + test("each_line forwards async delivery and refuses sync delivery", async () => { + const tools = new Map(); + const { bridge, calls } = makeTrackableMockBridge({ watch_id: "watch-lines" }); + registerBashTool(makeMockApi(tools), makeMockContext(bridge)); + const tool = tools.get("bash_watch")!; + await tool.execute( + "call", + { task_id: "bash-lines", pattern: "ERROR", background: true, deliver: "each_line" }, + undefined, + undefined, + { cwd: projectRoot }, + ); + const notify = calls.find((call) => (call as [string])[0] === "bash_notify") as [ + string, + Record, + ]; + expect(notify[1].deliver).toBe("each_line"); + await expect( + tool.execute( + "call", + { task_id: "bash-lines", pattern: "ERROR", deliver: "each_line" }, + undefined, + undefined, + { cwd: projectRoot }, + ), + ).rejects.toThrow("deliver requires background:true"); + }); + test('async bash_watch forwards string once "false" as boolean false', async () => { const tools = new Map(); const api = makeMockApi(tools); diff --git a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts index 7e49cd38bd..603483bcc1 100644 --- a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts @@ -30,6 +30,7 @@ import { appendToolResultBgCompletions, cleanupIdleSessionStates, consumeBgCompletion, + formatPatternMatchReminder, formatSystemReminder, handlePushedBgCompletion, handlePushedPatternMatch, @@ -1433,3 +1434,71 @@ async function waitUntil( function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +test("each_line reminder keeps bounded rows and drop recovery hint", () => { + const reminder = formatPatternMatchReminder([ + { + task_id: "bash-lines", + session_id: "s1", + watch_id: "watch-lines", + match_text: "matching lines", + match_offset: 0, + context: "stdout byte 0 (x2): ERROR a\n3 matching lines dropped; Read bash_status offset=16.", + once: false, + batch: { + lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 2 }], + dropped_lines: 3, + first_dropped_offset: 16, + first_dropped_stream: "stdout", + skipped_long_lines: 0, + }, + }, + ]); + expect(reminder).toContain("task bash-lines matching lines:"); + expect(reminder).toContain("stdout byte 0 (x2): ERROR a"); + expect(reminder).toContain( + '3 matching lines dropped. Read bash_status({ task_id: "bash-lines", output_offset: 16 }).', + ); +}); + +test("each_line queue replaces snapshots without discarding another watch", async () => { + markTaskWaiting("s-lines", "bash-lines"); + const { ctx } = harness(() => ({ success: true, acked_task_ids: [] })); + const base = { + task_id: "bash-lines", + session_id: "s-lines", + watch_id: "watch-a", + match_text: "matching lines", + match_offset: 0, + context: "rows", + once: false, + }; + const batch = { + lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 1 }], + dropped_lines: 0, + first_dropped_offset: null, + first_dropped_stream: null, + skipped_long_lines: 0, + }; + const drain = { + ctx, + directory: "/tmp/project", + sessionID: "s-lines", + runtime: { sendUserMessage: () => {} }, + }; + await handlePushedPatternMatch(drain, { ...base, batch }); + await handlePushedPatternMatch(drain, { + ...base, + batch: { + ...batch, + snapshot: true, + dropped_lines: 3, + first_dropped_offset: 8, + first_dropped_stream: "stdout", + }, + }); + await handlePushedPatternMatch(drain, { ...base, watch_id: "watch-b", batch }); + const pending = sessionBgStates.get("s-lines")?.pendingPatternMatches; + expect(pending).toHaveLength(2); + expect(pending?.find((entry) => entry.watch_id === "watch-a")?.batch?.dropped_lines).toBe(3); +}); diff --git a/packages/pi-plugin/src/bg-notifications.ts b/packages/pi-plugin/src/bg-notifications.ts index c744f78fdf..6c5701bb7d 100644 --- a/packages/pi-plugin/src/bg-notifications.ts +++ b/packages/pi-plugin/src/bg-notifications.ts @@ -1,6 +1,9 @@ +import type { BashWatchLineBatch } from "@cortexkit/aft-bridge"; import { type AftProjectTransport, type BgNudgeRef, + formatWatchLineBatch, + mergeWatchLineBatches, resolveBridgeForNudge, } from "@cortexkit/aft-bridge"; import { sessionLog, sessionWarn } from "./logger.js"; @@ -39,6 +42,7 @@ export interface BgCompletion { } export interface PatternMatchEntry { + batch?: BashWatchLineBatch; task_id: string; session_id: string; watch_id: string; @@ -334,11 +338,21 @@ function queuePendingPatternMatch(state: SessionBgState, entry: PatternMatchEntr ? entry : { ...entry, reason: "pattern_match" }; const existingIdx = state.pendingPatternMatches.findIndex( - (match) => match.task_id === normalized.task_id, + (match) => + match.task_id === normalized.task_id && + (normalized.batch || match.batch ? match.watch_id === normalized.watch_id : true), ); if (existingIdx >= 0) { const existing = state.pendingPatternMatches[existingIdx]; - if (existing.reason !== "pattern_match" && normalized.reason === "pattern_match") { + // Live pushes contain only new rows; replay replaces the durable snapshot. + // Bound the queue too, since the host can stay busy across many scan intervals. + if (normalized.batch && existing.batch) { + normalized.batch = mergeWatchLineBatches(existing.batch, normalized.batch); + } + if ( + normalized.batch || + (existing.reason !== "pattern_match" && normalized.reason === "pattern_match") + ) { state.pendingPatternMatches[existingIdx] = normalized; } return; @@ -796,7 +810,14 @@ export function formatLongRunningReminder(reminders: readonly BgLongRunningRemin export function formatPatternMatchReminder(matches: readonly PatternMatchEntry[]): string { const bullets = matches .map((match) => { - const context = (match.context || match.match_text).replace(/\n/g, "\n > "); + const context = ( + match.batch + ? formatWatchLineBatch(match.task_id, match.batch, false) + : match.context || match.match_text + ).replace(/\n/g, "\n > "); + if (match.batch) { + return `- task ${match.task_id} matching lines:\n > ${context}`; + } if (match.reason === "task_exit") { return `- task ${match.task_id} exited:\n > ${context}`; } diff --git a/packages/pi-plugin/src/index.ts b/packages/pi-plugin/src/index.ts index 6d71a84564..6504ceedc5 100644 --- a/packages/pi-plugin/src/index.ts +++ b/packages/pi-plugin/src/index.ts @@ -127,6 +127,7 @@ type BashLongRunningPayload = { }; type BashPatternMatchPayload = { + batch?: import("@cortexkit/aft-bridge").BashWatchLineBatch; session_id: string; task_id: string; watch_id: string; diff --git a/packages/pi-plugin/src/tools/bash.ts b/packages/pi-plugin/src/tools/bash.ts index 668370aad7..40e029a5f4 100644 --- a/packages/pi-plugin/src/tools/bash.ts +++ b/packages/pi-plugin/src/tools/bash.ts @@ -11,6 +11,7 @@ import { classifyBashHostFallbackError, coerceBoolean, escalationAskText, + formatBashOutputPages, formatWatchWaited, interruptedWatchTail, isBridgeTransportTimeout, @@ -412,6 +413,22 @@ const BashTaskParams = Type.Object({ }); const BashStatusParams = Type.Object({ + output_offset: Type.Optional( + Type.Integer({ + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + description: + "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + }), + ), + stderr_offset: Type.Optional( + Type.Integer({ + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + description: + "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + }), + ), task_id: Type.String({ description: "Background bash task id returned by bash({ background: true }).", }), @@ -430,6 +447,12 @@ const BashWatchParams = Type.Object({ pattern: Type.Optional(Type.Union([Type.String(), Type.Object({ regex: Type.String() })])), background: Type.Optional(Type.Boolean()), timeout_ms: optionalInt(1, 1800000, WATCH_TIMEOUT_PARAM_DESCRIPTION), + deliver: Type.Optional( + Type.Literal("each_line", { + description: + "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with bash_status byte offsets.", + }), + ), once: Type.Optional(Type.Boolean()), }); @@ -1126,7 +1149,36 @@ export function createBashStatusTool(ctx: PluginContext) { // bash_status is snapshot-only. wait_for / exit / timeout_ms moved to // bash_watch; if the agent passes them here they're silently ignored // at the TypeBox schema layer. - const data = await bashStatusSnapshot(bridge, extCtx, params.task_id, params.output_mode); + const paging = params.output_offset !== undefined || params.stderr_offset !== undefined; + const data = paging + ? await callBashBridge( + bridge, + "bash_status", + { + task_id: params.task_id, + output_mode: params.output_mode, + output_offset: coerceOptionalInt( + params.output_offset, + "output_offset", + 0, + Number.MAX_SAFE_INTEGER, + ), + stderr_offset: coerceOptionalInt( + params.stderr_offset, + "stderr_offset", + 0, + Number.MAX_SAFE_INTEGER, + ), + }, + extCtx, + ) + : await bashStatusSnapshot(bridge, extCtx, params.task_id, params.output_mode); + if (data.success === false) throw new Error(String(data.message ?? "bash_status failed")); + if (paging) + return bashStatusResult( + formatBashOutputPages(data, false), + data as unknown as BashStatusDetails, + ); const details = data as unknown as BashStatusDetails; return bashStatusResult( await formatBashStatus(extCtx, params.task_id, details, params.output_mode, { @@ -1164,6 +1216,14 @@ export function createBashWatchTool(ctx: PluginContext) { coerceBoolean(params.background) && !bashCfg.subagent_background && isPiWorkerSession(extCtx); + if (params.deliver !== undefined && (!coerceBoolean(params.background) || workerForcedSync)) { + throw new Error( + "invalid_request: bash_watch deliver requires background:true; this watch waits synchronously", + ); + } + if (params.deliver !== undefined && params.deliver !== "each_line") { + throw new Error("invalid_request: bash_watch deliver must be each_line"); + } // Coerce at the boundary: stringified background must enable async mode (coerceBoolean). if (coerceBoolean(params.background) && !workerForcedSync) { if (!waitFor) { @@ -1175,6 +1235,7 @@ export function createBashWatchTool(ctx: PluginContext) { task_id: params.task_id, once: coerceBoolean(params.once, true), }; + if (params.deliver !== undefined) notifyParams.deliver = params.deliver; if (waitFor.kind === "regex") notifyParams.regex = waitFor.source; else notifyParams.pattern = waitFor.value; const sessionId = resolveSessionId(extCtx); From 9c54fdcafce59562ad16cfef5dcc83f8a1812ad0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:23:49 +0200 Subject: [PATCH 02/15] mason: finalize every-line watch delivery and exact acknowledgements (#751) Keep approved wording and default push bytes unchanged. Preserve real terminal identities, persist transport-only generation/version metadata, and serialize/coalesce standalone V2 watches using bounded durable snapshots. SubC V2 uses the same admitted-row/counter watermarks and retires them on exact ack or durable absence. V1 and Pi acknowledge exact watch versions. V1's existing uncertain prompt-admission recovery remains unchanged because its host API can leave admission unproven; V2 has confirmed synthetic admission and can watermark precisely. Add failing-first consumer regressions and collision, partial-flush, lost-ack, restart and SubC stale-ack coverage. The watch migration follows main's stored tool-result reply migration 19 as migration 20; upstream migrations are unchanged. Schemas are regenerated byte-identically, and the release fragment uses bash-watch-each-line.md. Final landing gates and deliberate-break proofs are recorded separately. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../next/bash-watch-each-line.md | 1 + .../release-notes/next/watch-every-line.md | 1 - crates/aft/src/bash_background/registry.rs | 302 ++++++++++-- crates/aft/src/bash_background/watch_lines.rs | 6 + crates/aft/src/bash_background/watches.rs | 10 + crates/aft/src/commands/bash_watch.rs | 10 +- crates/aft/src/db/mod.rs | 5 + crates/aft/src/subc_format.rs | 58 ++- docs/tools.md | 17 +- .../src/__tests__/bash-pattern-ack.test.ts | 76 +++ .../src/__tests__/bash-watch-lines.test.ts | 58 +++ .../src/__tests__/bash-watch-progress.test.ts | 46 ++ packages/aft-bridge/src/bash-pattern-ack.ts | 82 ++++ packages/aft-bridge/src/bash-watch-lines.ts | 13 +- .../aft-bridge/src/bash-watch-progress.ts | 90 ++++ packages/aft-bridge/src/bridge.ts | 3 + packages/aft-bridge/src/index.ts | 11 + .../src/__tests__/bash.test.ts | 2 +- .../src/__tests__/bg-notifications.test.ts | 66 ++- .../src/__tests__/v2-pattern-wakes.test.ts | 295 +++++++++++- .../src/__tests__/v2-subc-wakes.test.ts | 124 ++++- .../opencode-plugin/src/bg-notifications.ts | 128 +++-- .../opencode-plugin/src/tools/bash_watch.ts | 5 +- .../src/wakes/runtime-consumer.ts | 450 ++++++++++++------ packages/pi-plugin/src/__tests__/bash.test.ts | 2 +- .../src/__tests__/bg-notifications.test.ts | 70 ++- packages/pi-plugin/src/bg-notifications.ts | 91 ++-- packages/pi-plugin/src/tools/bash.ts | 5 +- 28 files changed, 1702 insertions(+), 325 deletions(-) create mode 100644 .alfonso/release-notes/next/bash-watch-each-line.md delete mode 100644 .alfonso/release-notes/next/watch-every-line.md create mode 100644 packages/aft-bridge/src/__tests__/bash-pattern-ack.test.ts create mode 100644 packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts create mode 100644 packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts create mode 100644 packages/aft-bridge/src/bash-pattern-ack.ts create mode 100644 packages/aft-bridge/src/bash-watch-progress.ts diff --git a/.alfonso/release-notes/next/bash-watch-each-line.md b/.alfonso/release-notes/next/bash-watch-each-line.md new file mode 100644 index 0000000000..6e24d13312 --- /dev/null +++ b/.alfonso/release-notes/next/bash-watch-each-line.md @@ -0,0 +1 @@ +- Background `bash_watch` can follow a log line by line with `deliver: "each_line"`, delivering matching lines with byte offsets and reporting drops when caps are reached (#751). diff --git a/.alfonso/release-notes/next/watch-every-line.md b/.alfonso/release-notes/next/watch-every-line.md deleted file mode 100644 index df204100da..0000000000 --- a/.alfonso/release-notes/next/watch-every-line.md +++ /dev/null @@ -1 +0,0 @@ -- Async `bash_watch` can opt into `deliver: "each_line"` to batch matching log lines with byte offsets, combining consecutive identical lines with a count. Bounded batches and per-watch rate limits report dropped lines with copyable `bash_status` page requests starting where lines were dropped; default first-match watches are unchanged. Pages display masked output, and oversized lines report an incomplete scan. diff --git a/crates/aft/src/bash_background/registry.rs b/crates/aft/src/bash_background/registry.rs index 79ac70718d..208069ff52 100644 --- a/crates/aft/src/bash_background/registry.rs +++ b/crates/aft/src/bash_background/registry.rs @@ -4102,14 +4102,32 @@ impl BgTaskRegistry { return; }; if let Some(batch) = pattern_match.batch.as_mut() { + let previous = row.match_batch.as_deref().and_then(|value| { + serde_json::from_str::(value).ok() + }); + let last_version = previous + .as_ref() + .and_then(|batch| batch.version) + .or_else(|| row.match_offset.map(|offset| offset.max(0) as u64)); if row.pending_match { - if let Some(mut pending) = row.match_batch.as_deref().and_then(|value| { - serde_json::from_str::(value).ok() - }) { + if let Some(mut pending) = previous { pending.merge(batch.clone()); *batch = pending; } - } + } else { + batch.pending_id = None; + } + // Streams can share a byte offset, and a final partial-line flush + // need not advance a cursor. Source offsets stay in lines; the + // outer offset is a durable, unique acknowledgement version. + let version = last_version + .map(|last| last.saturating_add(1).max(pattern_match.match_offset)) + .unwrap_or(pattern_match.match_offset); + batch.version = Some(version); + if batch.pending_id.is_none() { + batch.pending_id = Some(format!("{}:{version}", pattern_match.watch_id)); + } + pattern_match.match_offset = version; pattern_match.context = crate::subc_format::format_watch_line_batch(task_id, batch); } row.stdout_offset = stdout_offset as i64; @@ -7693,7 +7711,7 @@ impl BgTaskRegistry { } } else { for pattern_match in &mut terminal_matches { - let fresh = pattern_match.clone(); + let mut fresh = pattern_match.clone(); self.persist_watch_match( &task.session_id, &task_id, @@ -7702,6 +7720,7 @@ impl BgTaskRegistry { stderr_offset, pty_offset, ); + fresh.copy_delivery_identity(pattern_match); self.emit_bash_pattern_match(&task.session_id, fresh); } } @@ -8030,7 +8049,7 @@ impl BgTaskRegistry { return; } for mut pattern_match in matches { - let fresh = pattern_match.clone(); + let mut fresh = pattern_match.clone(); self.persist_watch_match( &task.session_id, &task.task_id, @@ -8039,6 +8058,7 @@ impl BgTaskRegistry { stderr_offset, pty_offset, ); + fresh.copy_delivery_identity(&pattern_match); self.emit_bash_pattern_match(&task.session_id, fresh); } self.persist_task_watch_cursors( @@ -13245,17 +13265,17 @@ impl BgTaskRegistry { { let mut summary = batch.clone(); summary.lines.clear(); - sender(PushFrame::BashPatternMatch( - BashPatternMatchFrame::task_exit( - &pattern_match.task_id, - session_id, - WATCH_TASK_EXIT_TEXT, - crate::subc_format::format_watch_line_batch( - &pattern_match.task_id, - &summary, - ), - ), - )); + let mut frame = BashPatternMatchFrame::new( + &pattern_match.task_id, + session_id, + &pattern_match.watch_id, + &pattern_match.match_text, + pattern_match.match_offset, + crate::subc_format::format_watch_line_batch(&pattern_match.task_id, &summary), + pattern_match.once, + ); + frame.batch = Some(summary); + sender(PushFrame::BashPatternMatch(frame)); return; } let batch = pattern_match.batch.clone(); @@ -26739,6 +26759,7 @@ mod tests { storage.to_path_buf(), HashMap::new(), crate::bash_background::HardKill::After(Duration::from_secs(30)), + None, storage.to_path_buf(), 10, true, @@ -26807,11 +26828,214 @@ mod tests { assert!(masked.ends_with("page final\n")); } + fn each_line_running_fixture( + storage: &Path, + ) -> ( + BgTaskRegistry, + Arc, + Arc>>, + String, + ) { + let (registry, _, frames) = registry_with_db_and_frames(storage); + let task_id = registry + .spawn( + SpawnPlan::Unsandboxed, + LONG_RUNNING_COMMAND, + "session".into(), + storage.to_path_buf(), + HashMap::new(), + crate::bash_background::HardKill::After(Duration::from_secs(30)), + None, + storage.to_path_buf(), + 10, + true, + false, + Some(storage.to_path_buf()), + ) + .unwrap(); + registry + .inner + .shutdown + .store(true, std::sync::atomic::Ordering::SeqCst); + let watch = registry + .register_watch_delivery( + task_id.clone(), + WatchPattern::Substring("READY".into()), + false, + true, + ) + .unwrap(); + let task = registry.task_for_session(&task_id, "session").unwrap(); + (registry, task, frames, watch) + } + + fn append_each_line_output(path: &Path, bytes: &[u8]) { + std::fs::OpenOptions::new() + .append(true) + .open(path) + .unwrap() + .write_all(bytes) + .unwrap(); + } + #[test] - fn each_line_terminal_rate_exemption_is_one_summary_line() { + fn each_line_stream_offsets_do_not_collide_as_ack_versions() { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, frames, watch) = each_line_running_fixture(dir.path()); + let _reap = KillTasksOnDrop(registry.clone()); + append_each_line_output(&task.paths.stdout, b"READY a\n"); + registry.scan_task_watch_output(&task); + append_each_line_output(&task.paths.stderr, b"READY b\n"); + registry.scan_task_watch_output(&task); + let frames = pattern_match_frames(&frames); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].batch.as_ref().unwrap().lines[0].offset, 0); + assert_eq!(frames[1].batch.as_ref().unwrap().lines[0].offset, 0); + assert!( + frames[1].match_offset > frames[0].match_offset, + "different streams must not share an acknowledgement version" + ); + assert!(registry + .ack_pattern_match_for_session("session", &task.task_id, &watch, frames[0].match_offset) + .unwrap() + .is_none()); + assert_eq!( + registry + .pending_pattern_matches_for_session("session") + .len(), + 1 + ); + } + + #[test] + fn each_line_final_partial_flush_advances_version_without_cursor_movement() { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, frames, watch) = each_line_running_fixture(dir.path()); + let _reap = KillTasksOnDrop(registry.clone()); + append_each_line_output(&task.paths.stdout, b"READY tail"); + append_each_line_output(&task.paths.stderr, b"READY other\n"); + registry.scan_task_watch_output(&task); + let first = pattern_match_frames(&frames).pop().unwrap(); + let cursors = registry.watch_stream_cursors(&task.task_id); + let mut final_match = registry + .take_watch_line_batches(&task.task_id, true) + .pop() + .unwrap(); + assert_eq!( + final_match.batch.as_ref().unwrap().lines[0].text, + "READY tail" + ); + registry.persist_watch_match( + "session", + &task.task_id, + &mut final_match, + cursors.0, + cursors.1, + cursors.2, + ); + assert_eq!(registry.watch_stream_cursors(&task.task_id), cursors); + assert!( + final_match.match_offset > first.match_offset, + "a final partial line needs a new acknowledgement version even without another read" + ); + assert!(registry + .ack_pattern_match_for_session("session", &task.task_id, &watch, first.match_offset) + .unwrap() + .is_none()); + } + + #[test] + fn each_line_ack_version_survives_daemon_restart_before_and_after_ack() { + for acknowledge_first in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, frames, watch) = each_line_running_fixture(dir.path()); + let _reap = KillTasksOnDrop(registry.clone()); + append_each_line_output(&task.paths.stdout, b"READY a\n"); + registry.scan_task_watch_output(&task); + let first = pattern_match_frames(&frames).pop().unwrap(); + let pending_id = first.batch.as_ref().unwrap().pending_id.clone().unwrap(); + if acknowledge_first { + assert!(registry + .ack_pattern_match_for_session( + "session", + &task.task_id, + &watch, + first.match_offset + ) + .unwrap() + .is_some()); + } + registry.clear_task_watch_state(&task.task_id); + let (replayed, _, replay_frames) = registry_with_db_and_frames(dir.path()); + let _reap_replayed = KillTasksOnDrop(replayed.clone()); + replayed + .replay_session_inner(dir.path(), "session", None) + .unwrap(); + replayed + .inner + .shutdown + .store(true, std::sync::atomic::Ordering::SeqCst); + if !acknowledge_first { + let replay = pattern_match_frames(&replay_frames).pop().unwrap(); + assert_eq!(replay.match_offset, first.match_offset); + } + let replayed_task = replayed.task_for_session(&task.task_id, "session").unwrap(); + append_each_line_output(&task.paths.stderr, b"READY b\n"); + replayed.scan_task_watch_output(&replayed_task); + let newest = pattern_match_frames(&replay_frames).pop().unwrap(); + assert!( + newest.match_offset > first.match_offset, + "acknowledged first: {acknowledge_first}" + ); + assert_eq!( + newest.batch.as_ref().unwrap().lines.last().unwrap().offset, + 0 + ); + if acknowledge_first { + assert_ne!( + newest.batch.as_ref().unwrap().pending_id.as_ref().unwrap(), + &pending_id + ); + } else { + assert_eq!( + newest.batch.as_ref().unwrap().pending_id.as_ref().unwrap(), + &pending_id + ); + } + assert!(replayed + .ack_pattern_match_for_session("session", &task.task_id, &watch, first.match_offset) + .unwrap() + .is_none()); + } + } + + #[test] + fn each_line_default_push_frame_stays_byte_identical() { let dir = tempfile::tempdir().unwrap(); let (registry, _, frames) = registry_with_db_and_frames(dir.path()); - registry.record_live_delivery_session("session"); + registry.record_live_delivery_session("session-v2"); + registry.emit_bash_pattern_match( + "session-v2", + PatternMatch { + watch_id: "watch-ready".into(), + task_id: "bash-running".into(), + match_text: "READY".into(), + match_offset: 42, + context: "READY".into(), + once: false, + batch: None, + }, + ); + assert_eq!( + serde_json::to_string(&pattern_match_frames(&frames)[0]).unwrap(), + r#"{"type":"bash_pattern_match","task_id":"bash-running","session_id":"session-v2","watch_id":"watch-ready","match_text":"READY","match_offset":42,"context":"READY","once":false,"reason":"pattern_match"}"# + ); + } + + fn each_line_terminal_frame() -> BashPatternMatchFrame { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, frames) = registry_with_db_and_frames(dir.path()); + registry.record_live_delivery_session("session-v2"); let batch = super::super::watch_lines::LineBatch { terminal_summary: true, dropped_lines: 7, @@ -26827,25 +27051,43 @@ mod tests { ..Default::default() }; registry.emit_bash_pattern_match( - "session", + "session-v2", PatternMatch { - watch_id: "watch-a".into(), - task_id: "bash-lines".into(), + watch_id: "watch-ready".into(), + task_id: "bash-running".into(), match_text: "matching lines".into(), - match_offset: 0, + match_offset: 123, context: "ignored".into(), once: false, batch: Some(batch), }, ); - let frames = pattern_match_frames(&frames); + let mut frames = pattern_match_frames(&frames); assert_eq!(frames.len(), 1); - assert_eq!(frames[0].reason, "task_exit"); - assert!(frames[0].batch.is_none()); - assert_eq!(frames[0].context.lines().count(), 1); - assert!(frames[0].context.contains("7 matching lines dropped")); - assert!(frames[0].context.contains("stderr_offset: 123")); - assert!(!frames[0].context.contains("ERROR a")); + frames.remove(0) + } + + #[test] + fn each_line_terminal_rate_exemption_is_one_summary_line() { + let frame = each_line_terminal_frame(); + assert_eq!(frame.reason, "pattern_match"); + assert_eq!(frame.watch_id, "watch-ready"); + assert_eq!(frame.match_offset, 123); + assert!(frame.batch.as_ref().unwrap().lines.is_empty()); + assert_eq!(frame.context.lines().count(), 1); + assert!(frame.context.contains("7 matching lines dropped")); + assert!(frame.context.contains("stderr_offset: 123")); + assert!(!frame.context.contains("ERROR a")); + } + + #[test] + fn each_line_terminal_summary_frame_fixture() { + // Host integration tests consume the native emitter's frame, not a + // reconstruction that could hide a disagreement in durable identity. + println!( + "AFT_EACH_LINE_TERMINAL_FRAME={}", + serde_json::to_string(&each_line_terminal_frame()).unwrap() + ); } #[test] diff --git a/crates/aft/src/bash_background/watch_lines.rs b/crates/aft/src/bash_background/watch_lines.rs index d5f0dc6aaa..ac251b2dbc 100644 --- a/crates/aft/src/bash_background/watch_lines.rs +++ b/crates/aft/src/bash_background/watch_lines.rs @@ -11,6 +11,8 @@ pub const MAX_LINES_PER_NOTIFICATION: usize = 50; pub const MAX_NOTIFICATIONS_PER_MINUTE: usize = 12; pub const NOTIFICATION_WINDOW: Duration = Duration::from_secs(60); pub const MAX_LINE_BYTES: usize = 64 * 1024; +pub const EACH_LINE_NEEDS_BACKGROUND: &str = + "bash_watch: deliver: \"each_line\" needs background: true."; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WatchLine { @@ -23,6 +25,10 @@ pub struct WatchLine { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct LineBatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub snapshot: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] diff --git a/crates/aft/src/bash_background/watches.rs b/crates/aft/src/bash_background/watches.rs index 3d8901d697..120b18edd7 100644 --- a/crates/aft/src/bash_background/watches.rs +++ b/crates/aft/src/bash_background/watches.rs @@ -72,6 +72,16 @@ pub struct PatternMatch { pub batch: Option, } +impl PatternMatch { + pub(super) fn copy_delivery_identity(&mut self, persisted: &Self) { + if let (Some(live), Some(saved)) = (self.batch.as_mut(), persisted.batch.as_ref()) { + self.match_offset = persisted.match_offset; + live.pending_id = saved.pending_id.clone(); + live.version = saved.version; + } + } +} + #[derive(Debug, Default)] pub struct WatchRegistry { line_delivery: HashMap, diff --git a/crates/aft/src/commands/bash_watch.rs b/crates/aft/src/commands/bash_watch.rs index e297eb16b0..9280294498 100644 --- a/crates/aft/src/commands/bash_watch.rs +++ b/crates/aft/src/commands/bash_watch.rs @@ -20,6 +20,7 @@ use serde_json::{json, Value}; use crate::bash_background::persistence::{BgMode, TaskArtifact}; use crate::bash_background::registry::BgTaskSnapshot; +use crate::bash_background::watch_lines::EACH_LINE_NEEDS_BACKGROUND; use crate::bash_background::{BgTaskRegistry, BgTaskStatus}; use crate::commands::bash_orchestrate::{ format_wait_limit, kill_deadline_sentence, worker_kill_deadline_within_handoff_margin, @@ -311,7 +312,7 @@ pub(crate) fn handle_deferred_with_observation_only( return DispatchOutcome::Immediate(Response::error( &id, "invalid_request", - "bash_watch: deliver requires background:true; this watch waits synchronously", + EACH_LINE_NEEDS_BACKGROUND, )); } let pattern = match parse_pattern(params.pattern.as_ref()) { @@ -912,9 +913,10 @@ mod tests { panic!("sync delivery must refuse immediately") }; assert!(!response.success); - assert!(serde_json::to_string(&response) - .unwrap() - .contains("deliver requires background:true")); + assert_eq!( + serde_json::to_value(&response).unwrap()["message"], + r#"bash_watch: deliver: "each_line" needs background: true."# + ); } use crate::bash_background::persistence::{create_task_layout, write_task_at, PersistedTask}; use crate::config::Config; diff --git a/crates/aft/src/db/mod.rs b/crates/aft/src/db/mod.rs index 63f3462557..7cb0845bf2 100644 --- a/crates/aft/src/db/mod.rs +++ b/crates/aft/src/db/mod.rs @@ -1490,6 +1490,11 @@ mod tests { .unwrap(); assert!(columns.contains(&("each_line".into(), "INTEGER".into(), true, Some("0".into())))); assert!(columns.contains(&("match_batch".into(), "TEXT".into(), false, None))); + let result_source_type: String = conn.query_row( + "SELECT type FROM pragma_table_info('call_ledger') WHERE name = 'tool_result_source'", + [], |row| row.get(0), + ).unwrap(); + assert_eq!(result_source_type, "BLOB"); assert_eq!( crate::reader_floor::read(dir.path()) .unwrap() diff --git a/crates/aft/src/subc_format.rs b/crates/aft/src/subc_format.rs index 956eb282d2..ea39d95956 100644 --- a/crates/aft/src/subc_format.rs +++ b/crates/aft/src/subc_format.rs @@ -5456,15 +5456,20 @@ pub fn format_watch_line_batch( ) -> String { let mut text = String::new(); for line in &batch.lines { + let repeats = if line.count > 1 { + format!(" ×{}", line.count) + } else { + String::new() + }; text.push_str(&format!( - "{} byte {} (x{}): {}\n", - line.stream, line.offset, line.count, line.text + "{} byte {}{repeats}: {}\n", + line.stream, line.offset, line.text )); } if batch.dropped_lines > 0 || batch.skipped_long_lines > 0 { let incomplete = if batch.skipped_long_lines > 0 { format!( - " {} lines longer than 64 KiB skipped (scan incomplete).", + " {} lines longer than 64 KiB were skipped, so the scan is incomplete.", batch.skipped_long_lines ) } else { @@ -5475,7 +5480,52 @@ pub fn format_watch_line_batch( } else { "output_offset" }; - text.push_str(&format!("{} matching lines dropped.{incomplete} Read bash_status({{ task_id: {}, {cursor}: {} }}).\n", batch.dropped_lines, serde_json::to_string(task_id).unwrap_or_default(), batch.first_dropped_offset.unwrap_or(0))); + text.push_str(&format!("{} matching lines dropped.{incomplete} Read them with bash_status({{ task_id: {}, {cursor}: {} }}).\n", batch.dropped_lines, serde_json::to_string(task_id).unwrap_or_default(), batch.first_dropped_offset.unwrap_or(0))); } text.trim_end().to_string() } + +#[cfg(test)] +mod watch_line_format_tests { + use super::format_watch_line_batch; + use crate::bash_background::watch_lines::{LineBatch, WatchLine}; + + #[test] + fn each_line_row_wording_golden() { + let mut batch = LineBatch { + lines: vec![WatchLine { + stream: "stdout".into(), + offset: 18234, + end_offset: 18257, + text: "ERROR db timeout".into(), + count: 1, + }], + ..Default::default() + }; + assert_eq!( + format_watch_line_batch("task", &batch), + "stdout byte 18234: ERROR db timeout" + ); + batch.lines[0].count = 3; + assert_eq!( + format_watch_line_batch("task", &batch), + "stdout byte 18234 ×3: ERROR db timeout" + ); + } + + #[test] + fn each_line_drop_wording_golden() { + let mut batch = LineBatch { + dropped_lines: 7, + first_dropped_stream: Some("stdout".into()), + first_dropped_offset: Some(18240), + ..Default::default() + }; + assert_eq!(format_watch_line_batch("task", &batch), + "7 matching lines dropped. Read them with bash_status({ task_id: \"task\", output_offset: 18240 })."); + batch.skipped_long_lines = 2; + batch.first_dropped_stream = Some("stderr".into()); + assert_eq!(format_watch_line_batch("task", &batch), + "7 matching lines dropped. 2 lines longer than 64 KiB were skipped, so the scan is incomplete. Read them with bash_status({ task_id: \"task\", stderr_offset: 18240 })."); + } +} diff --git a/docs/tools.md b/docs/tools.md index 54945adcf8..32b2ee03cc 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -544,9 +544,7 @@ briefly exceed the limit. **`bash_status`** — read-only snapshot of a background or PTY task's current state and output. Never waits. For PTY tasks, `outputMode` selects `screen` (vt100-rendered), `raw` (byte stream), or `both`. -Optional `outputOffset` / `stderrOffset` request at most 64 KiB from the original -stdout (or PTY) / stderr byte offset, returning masked page text and the next offset. -Pi uses `output_offset` / `stderr_offset`, as with its other snake_case arguments. +Optional `outputOffset` / `stderrOffset` (`output_offset` / `stderr_offset` in Pi) read masked stdout/PTY or stderr pages from a byte offset and return the next offset. **`bash_watch`** — block on or register for a background task's output. In a main session sync waits are for a short remaining wait on a task (default 30s, max `bash.watch_sync_max_ms`, 120s @@ -560,16 +558,9 @@ but never extends it beyond the configured worker limit. Every session's blockin call (`wait: true`, or any foreground call when `bash.subagent_background` is false) is bounded the same way: at the limit the command moves to the background, not killed, and the reply gives its task id, including on the tool-provider/v1 route. -Async watches can opt into `deliver: "each_line"` (requires `background: true`): each -complete matching line carries its source stream and byte offset, and consecutive identical -lines are folded with a count. Each scan interval sends one batch, retaining at most 50 source -lines, with at most 12 matching-line notifications per 60-second window per watch; omitted -`deliver` keeps first-match delivery unchanged. Drops carry an exact count and a copyable -`bash_status` byte-page call; rate-limited drops are summarized in the next allowed batch. -The final watch notification is exempt from the match-rate cap so it can report final drop counts immediately. -Lines longer than 64 KiB are skipped with an explicit incomplete-scan count; a final unterminated -line is checked on exit. Matching is line-local, including for regexes; use a sync watch for -cross-line patterns. `once` still defaults to true; use `once: false` to keep following a log. +With `background: true`, `deliver: "each_line"` batches matching lines with byte offsets and combines consecutive identical lines with a count. +Notices are capped at 50 lines and 12 a minute per watch (the final drop summary is exempt from the rate cap). +Drops name a `bash_status` offset to read. The head preset declares a reply deadline for `bash`; the worker preset declares it for `bash` and `bash_watch`. Both use the resolved `bash.worker_wait_max_ms` plus 240 seconds for spawn, terminal handoff and reply delivery. A watch does not spawn a command, but retains the same diff --git a/packages/aft-bridge/src/__tests__/bash-pattern-ack.test.ts b/packages/aft-bridge/src/__tests__/bash-pattern-ack.test.ts new file mode 100644 index 0000000000..a536c7301c --- /dev/null +++ b/packages/aft-bridge/src/__tests__/bash-pattern-ack.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { + bashAckKey, + bashAckRequest, + bashAcksConfirmed, + bashAckTargetFromKey, + bashAckTargets, +} from "../bash-pattern-ack.js"; + +test("pattern acknowledgements keep sibling watches and newer offsets distinct", () => { + const targets = bashAckTargets( + [{ task_id: "task" }], + [ + { task_id: "task", watch_id: "one", match_offset: 7, ackCompletionOnDelivery: true }, + { task_id: "task", watch_id: "two", match_offset: 7, ackCompletionOnDelivery: true }, + { task_id: "task", watch_id: "one", match_offset: 8, ackCompletionOnDelivery: true }, + { task_id: "task", watch_id: "one", match_offset: 7, ackCompletionOnDelivery: true }, + ], + ); + expect(bashAckRequest(targets)).toEqual({ + task_ids: ["task"], + matches: [ + { task_id: "task", watch_id: "one", match_offset: 7 }, + { task_id: "task", watch_id: "two", match_offset: 7 }, + { task_id: "task", watch_id: "one", match_offset: 8 }, + ], + }); + for (const target of targets) expect(bashAckTargetFromKey(bashAckKey(target))).toEqual(target); +}); + +test("pattern acknowledgements require the exact returned version", () => { + const targets = [{ task_id: "task", watch_id: "one", match_offset: 7 }]; + expect(bashAcksConfirmed({ success: true, acked_task_ids: ["task"] }, targets)).toBe(false); + expect( + bashAcksConfirmed( + { success: true, acked_matches: [{ task_id: "task", watch_id: "one", match_offset: 8 }] }, + targets, + ), + ).toBe(false); + expect( + bashAcksConfirmed( + { success: true, acked_matches: [{ task_id: "task", watch_id: "two", match_offset: 7 }] }, + targets, + ), + ).toBe(false); + expect(bashAcksConfirmed({ success: true, acked_matches: targets }, targets)).toBe(true); +}); + +test("task exits keep task-level acknowledgement and ordinary matches do not", () => { + expect( + bashAckRequest( + bashAckTargets( + [], + [ + { + task_id: "task-exit", + watch_id: "exit", + match_offset: 0, + reason: "task_exit", + ackCompletionOnDelivery: true, + }, + { + task_id: "task-pattern", + watch_id: "watch", + match_offset: 3, + reason: "pattern_match", + ackCompletionOnDelivery: true, + }, + ], + ), + ), + ).toEqual({ + task_ids: ["task-exit"], + matches: [{ task_id: "task-pattern", watch_id: "watch", match_offset: 3 }], + }); +}); diff --git a/packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts b/packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts new file mode 100644 index 0000000000..87068e2f07 --- /dev/null +++ b/packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { formatWatchLineBatch, WATCH_EACH_LINE_NEEDS_BACKGROUND } from "../bash-watch-lines.js"; +import type { BashWatchLineBatch } from "../bridge.js"; + +function batch(): BashWatchLineBatch { + return { + lines: [], + dropped_lines: 0, + first_dropped_stream: null, + first_dropped_offset: null, + skipped_long_lines: 0, + }; +} + +test("each_line row wording golden omits a single-line count", () => { + const value = batch(); + value.pending_id = "transport-only-generation"; + value.version = 999; + value.lines.push({ + stream: "stdout", + offset: 18234, + end_offset: 18257, + text: "ERROR db timeout", + count: 1, + }); + expect(formatWatchLineBatch("task", value, false)).toBe("stdout byte 18234: ERROR db timeout"); + value.lines[0].count = 3; + expect(formatWatchLineBatch("task", value, false)).toBe("stdout byte 18234 ×3: ERROR db timeout"); +}); + +test("each_line drop wording golden uses each host's cursor names", () => { + const value = { + ...batch(), + dropped_lines: 7, + first_dropped_stream: "stdout", + first_dropped_offset: 18240, + }; + expect(formatWatchLineBatch("task", value, false)).toBe( + '7 matching lines dropped. Read them with bash_status({ task_id: "task", output_offset: 18240 }).', + ); + expect(formatWatchLineBatch("task", value, true)).toBe( + '7 matching lines dropped. Read them with bash_status({ taskId: "task", outputOffset: 18240 }).', + ); + value.skipped_long_lines = 2; + value.first_dropped_stream = "stderr"; + expect(formatWatchLineBatch("task", value, false)).toBe( + '7 matching lines dropped. 2 lines longer than 64 KiB were skipped, so the scan is incomplete. Read them with bash_status({ task_id: "task", stderr_offset: 18240 }).', + ); + expect(formatWatchLineBatch("task", value, true)).toBe( + '7 matching lines dropped. 2 lines longer than 64 KiB were skipped, so the scan is incomplete. Read them with bash_status({ taskId: "task", stderrOffset: 18240 }).', + ); +}); + +test("each_line sync refusal wording golden", () => { + expect(WATCH_EACH_LINE_NEEDS_BACKGROUND).toBe( + 'bash_watch: deliver: "each_line" needs background: true.', + ); +}); diff --git a/packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts b/packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts new file mode 100644 index 0000000000..0820e488c3 --- /dev/null +++ b/packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { WatchBatchProgress, watchBatchHasContent } from "../bash-watch-progress.js"; +import type { BashWatchLineBatch } from "../bridge.js"; + +function snapshot(id = "a"): BashWatchLineBatch { + return { + pending_id: id, + version: 1, + snapshot: true, + lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "READY a", count: 1 }], + dropped_lines: 7, + skipped_long_lines: 2, + first_dropped_offset: 8, + first_dropped_stream: "stdout", + }; +} + +test("watch progress dedups folded lines and cumulative counters after admission", () => { + const progress = new WatchBatchProgress(); + const first = snapshot(); + expect(watchBatchHasContent(progress.unseen(first))).toBe(true); + progress.admit(first); + expect(watchBatchHasContent(progress.unseen(first))).toBe(false); + const next = snapshot(); + next.lines[0].count = 3; + next.dropped_lines = 10; + next.skipped_long_lines = 3; + next.version = 2; + const unseen = progress.unseen(next); + expect(unseen.lines).toEqual([ + { stream: "stdout", offset: 8, end_offset: 16, text: "READY a", count: 2 }, + ]); + expect(unseen.dropped_lines).toBe(3); + expect(unseen.skipped_long_lines).toBe(1); +}); + +test("watch progress does not reserve rows before admission or across pending generations", () => { + const progress = new WatchBatchProgress(); + expect(progress.unseen(snapshot()).lines).toHaveLength(1); + expect(progress.unseen(snapshot()).lines).toHaveLength(1); + progress.admit(snapshot()); + const fresh = snapshot("b"); + fresh.dropped_lines = 10; + expect(progress.unseen(fresh).dropped_lines).toBe(10); + expect(progress.unseen(fresh).lines).toHaveLength(1); +}); diff --git a/packages/aft-bridge/src/bash-pattern-ack.ts b/packages/aft-bridge/src/bash-pattern-ack.ts new file mode 100644 index 0000000000..6b23468191 --- /dev/null +++ b/packages/aft-bridge/src/bash-pattern-ack.ts @@ -0,0 +1,82 @@ +import type { BashPatternMatchAck } from "./bridge.js"; + +export type BashAckTarget = { task_id: string } | BashPatternMatchAck; + +type PatternIdentity = BashPatternMatchAck & { reason?: string; ackCompletionOnDelivery?: boolean }; + +export function bashPatternAckTarget(match: PatternIdentity): BashAckTarget { + return match.reason === "task_exit" + ? { task_id: match.task_id } + : { task_id: match.task_id, watch_id: match.watch_id, match_offset: match.match_offset }; +} + +export function bashAckKey(target: BashAckTarget): string { + return "watch_id" in target + ? `watch:${JSON.stringify([target.task_id, target.watch_id, target.match_offset])}` + : target.task_id; +} + +export function bashAckTargetFromKey(key: string): BashAckTarget { + if (!key.startsWith("watch:")) return { task_id: key }; + const [task_id, watch_id, match_offset] = JSON.parse(key.slice("watch:".length)) as [ + string, + string, + number, + ]; + return { task_id, watch_id, match_offset }; +} + +export function bashAckTargets( + completions: readonly { task_id: string }[], + matches: readonly PatternIdentity[], +): BashAckTarget[] { + const targets = new Map(); + for (const completion of completions) { + const target = { task_id: completion.task_id }; + targets.set(bashAckKey(target), target); + } + for (const match of matches) { + if (!match.ackCompletionOnDelivery) continue; + const target = bashPatternAckTarget(match); + targets.set(bashAckKey(target), target); + } + return [...targets.values()]; +} + +export function bashAckRequest(targets: readonly BashAckTarget[]): { + task_ids?: string[]; + matches?: BashPatternMatchAck[]; +} { + const unique = new Map(targets.map((target) => [bashAckKey(target), target])); + const task_ids: string[] = []; + const matches: BashPatternMatchAck[] = []; + for (const target of unique.values()) { + if ("watch_id" in target) matches.push(target); + else task_ids.push(target.task_id); + } + return { + ...(task_ids.length > 0 ? { task_ids } : {}), + ...(matches.length > 0 ? { matches } : {}), + }; +} + +export function bashAcksConfirmed( + response: Record, + targets: readonly BashAckTarget[], +): boolean { + if (response.success === false) return false; + const matches = Array.isArray(response.acked_matches) ? response.acked_matches : []; + return targets.every( + (target) => + !("watch_id" in target) || + matches.some((entry) => { + if (entry === null || typeof entry !== "object") return false; + const ack = entry as Partial; + return ( + ack.task_id === target.task_id && + ack.watch_id === target.watch_id && + ack.match_offset === target.match_offset + ); + }), + ); +} diff --git a/packages/aft-bridge/src/bash-watch-lines.ts b/packages/aft-bridge/src/bash-watch-lines.ts index 0eda20404b..8d850cbba4 100644 --- a/packages/aft-bridge/src/bash-watch-lines.ts +++ b/packages/aft-bridge/src/bash-watch-lines.ts @@ -1,6 +1,8 @@ import type { BashWatchLineBatch } from "./bridge.js"; const MAX_PENDING_WATCH_LINES = 50; +export const WATCH_EACH_LINE_NEEDS_BACKGROUND = + 'bash_watch: deliver: "each_line" needs background: true.'; /** Live pushes are deltas; durable replay is a replacement snapshot. */ export function mergeWatchLineBatches( @@ -8,7 +10,11 @@ export function mergeWatchLineBatches( next: BashWatchLineBatch, ): BashWatchLineBatch { if (next.snapshot) return next; + if (previous.pending_id && next.pending_id && previous.pending_id !== next.pending_id) + return next; const merged = { ...previous, lines: previous.lines.map((line) => ({ ...line })) }; + merged.pending_id = next.pending_id ?? previous.pending_id; + merged.version = next.version ?? previous.version; let retained = merged.lines.reduce((sum, line) => sum + line.count, 0); for (const line of next.lines) { const keep = Math.min(line.count, MAX_PENDING_WATCH_LINES - retained); @@ -47,7 +53,8 @@ export function formatWatchLineBatch( camelCase: boolean, ): string { const lines = batch.lines.map( - (line) => `${line.stream} byte ${line.offset} (x${line.count}): ${line.text}`, + (line) => + `${line.stream} byte ${line.offset}${line.count > 1 ? ` ×${line.count}` : ""}: ${line.text}`, ); if (batch.dropped_lines > 0 || batch.skipped_long_lines > 0) { const taskKey = camelCase ? "taskId" : "task_id"; @@ -55,10 +62,10 @@ export function formatWatchLineBatch( const offsetKey = camelCase ? `${cursor}Offset` : `${cursor}_offset`; const incomplete = batch.skipped_long_lines > 0 - ? ` ${batch.skipped_long_lines} lines longer than 64 KiB skipped (scan incomplete).` + ? ` ${batch.skipped_long_lines} lines longer than 64 KiB were skipped, so the scan is incomplete.` : ""; lines.push( - `${batch.dropped_lines} matching lines dropped.${incomplete} Read bash_status({ ${taskKey}: ${JSON.stringify(taskId)}, ${offsetKey}: ${batch.first_dropped_offset ?? 0} }).`, + `${batch.dropped_lines} matching lines dropped.${incomplete} Read them with bash_status({ ${taskKey}: ${JSON.stringify(taskId)}, ${offsetKey}: ${batch.first_dropped_offset ?? 0} }).`, ); } return lines.join("\n"); diff --git a/packages/aft-bridge/src/bash-watch-progress.ts b/packages/aft-bridge/src/bash-watch-progress.ts new file mode 100644 index 0000000000..4632d0404b --- /dev/null +++ b/packages/aft-bridge/src/bash-watch-progress.ts @@ -0,0 +1,90 @@ +import type { BashWatchLineBatch } from "./bridge.js"; + +type Stream = "stdout" | "stderr" | "pty"; + +function knownStream(stream: string): stream is Stream { + return stream === "stdout" || stream === "stderr" || stream === "pty"; +} + +/** Admission watermarks for one pending watch generation; no line text is retained. */ +export class WatchBatchProgress { + private pendingID: string | undefined; + private version: number | undefined; + private ends: Record = { stdout: 0, stderr: 0, pty: 0 }; + private dropped = 0; + private skipped = 0; + + get admittedVersion(): number | undefined { + return this.version; + } + + unseen(batch: BashWatchLineBatch): BashWatchLineBatch { + const same = this.pendingID === (batch.pending_id ?? "legacy"); + const obsolete = + same && + !batch.snapshot && + batch.version !== undefined && + this.version !== undefined && + batch.version <= this.version; + const lines: BashWatchLineBatch["lines"] = []; + if (!obsolete) + for (const line of batch.lines) { + const width = Math.max(1, line.end_offset - line.offset); + const end = same && knownStream(line.stream) ? this.ends[line.stream] : 0; + const skip = Math.min(line.count, Math.max(0, Math.ceil((end - line.offset) / width))); + if (skip < line.count) + lines.push({ + ...line, + offset: line.offset + skip * width, + end_offset: line.end_offset + skip * width, + count: line.count - skip, + }); + } + return { + ...batch, + lines, + dropped_lines: obsolete + ? 0 + : Math.max(0, batch.dropped_lines - (same && batch.snapshot ? this.dropped : 0)), + skipped_long_lines: obsolete + ? 0 + : Math.max(0, batch.skipped_long_lines - (same && batch.snapshot ? this.skipped : 0)), + }; + } + + admit(batch: BashWatchLineBatch): void { + const pendingID = batch.pending_id ?? "legacy"; + if (this.pendingID !== pendingID) { + this.pendingID = pendingID; + this.version = undefined; + this.ends = { stdout: 0, stderr: 0, pty: 0 }; + this.dropped = 0; + this.skipped = 0; + } + if ( + !batch.snapshot && + batch.version !== undefined && + this.version !== undefined && + batch.version <= this.version + ) + return; + for (const line of batch.lines) { + if (knownStream(line.stream)) { + const end = line.offset + line.count * Math.max(1, line.end_offset - line.offset); + this.ends[line.stream] = Math.max(this.ends[line.stream], end); + } + } + this.dropped = batch.snapshot + ? Math.max(this.dropped, batch.dropped_lines) + : this.dropped + batch.dropped_lines; + this.skipped = batch.snapshot + ? Math.max(this.skipped, batch.skipped_long_lines) + : this.skipped + batch.skipped_long_lines; + if (batch.version !== undefined) + this.version = Math.max(this.version ?? batch.version, batch.version); + } +} + +export function watchBatchHasContent(batch: BashWatchLineBatch): boolean { + return batch.lines.length > 0 || batch.dropped_lines > 0 || batch.skipped_long_lines > 0; +} diff --git a/packages/aft-bridge/src/bridge.ts b/packages/aft-bridge/src/bridge.ts index 7c61f562ba..8fcd8a6344 100644 --- a/packages/aft-bridge/src/bridge.ts +++ b/packages/aft-bridge/src/bridge.ts @@ -501,6 +501,9 @@ export interface BashLongRunningPayload { } export interface BashWatchLineBatch { + pending_id?: string; + version?: number; + terminal_summary?: boolean; snapshot?: boolean; lines: { stream: string; offset: number; end_offset: number; text: string; count: number }[]; dropped_lines: number; diff --git a/packages/aft-bridge/src/index.ts b/packages/aft-bridge/src/index.ts index 65a45e4cdd..c14451a00d 100644 --- a/packages/aft-bridge/src/index.ts +++ b/packages/aft-bridge/src/index.ts @@ -89,11 +89,22 @@ export { groupBashPermissionAsks, withBashPermissionLoop, } from "./bash-permissions.js"; +export { + type BashAckTarget, + bashAckKey, + bashAckRequest, + bashAcksConfirmed, + bashAckTargetFromKey, + bashAckTargets, + bashPatternAckTarget, +} from "./bash-pattern-ack.js"; export { formatBashOutputPages, formatWatchLineBatch, mergeWatchLineBatches, + WATCH_EACH_LINE_NEEDS_BACKGROUND, } from "./bash-watch-lines.js"; +export { WatchBatchProgress, watchBatchHasContent } from "./bash-watch-progress.js"; // --- binary identity (no-exec cache trust) --- export type { BinaryIdentity, BinaryIdentityCheck } from "./binary-identity.js"; export { diff --git a/packages/opencode-plugin/src/__tests__/bash.test.ts b/packages/opencode-plugin/src/__tests__/bash.test.ts index 3fe8c5831f..00274d16c7 100644 --- a/packages/opencode-plugin/src/__tests__/bash.test.ts +++ b/packages/opencode-plugin/src/__tests__/bash.test.ts @@ -1779,7 +1779,7 @@ describe("bash_status tool", () => { { taskId: "bash-lines", pattern: "ERROR", deliver: "each_line" }, createMockSdkContext(), ), - ).rejects.toThrow("deliver requires background:true"); + ).rejects.toThrow('bash_watch: deliver: "each_line" needs background: true.'); }); test('async bash_watch forwards string once "false" as boolean false', async () => { diff --git a/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts b/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts index 5fc884b6b4..0a7d8bcbd2 100644 --- a/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts @@ -2204,7 +2204,11 @@ describe("subc forced-drain dedup (C-#1 / C-#3)", () => { }, ], } - : { success: true, acked_task_ids: ["task-pattern"] }, + : { + success: true, + acked_task_ids: [], + acked_matches: [{ task_id: "task-pattern", watch_id: "watch-1", match_offset: 42 }], + }, ); const { ctx } = harness(send); const promptAsync = mock(async () => {}); @@ -2224,7 +2228,7 @@ describe("subc forced-drain dedup (C-#1 / C-#3)", () => { expect(prompt.body.parts[0].text).toContain('task task-pattern matched "READY"'); expect(send.mock.calls.find((call) => call[0] === "bash_ack_completions")?.[1]).toEqual({ session_id: "s1", - task_ids: ["task-pattern"], + matches: [{ task_id: "task-pattern", watch_id: "watch-1", match_offset: 42 }], }); }); @@ -2710,7 +2714,8 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { watch_id: "watch-lines", match_text: "matching lines", match_offset: 0, - context: "stdout byte 0 (x2): ERROR a\n3 matching lines dropped; Read bash_status offset=16.", + context: + 'stdout byte 0 ×2: ERROR a\n3 matching lines dropped. Read them with bash_status({ taskId: "bash-lines", outputOffset: 16 }).', once: false, batch: { lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 2 }], @@ -2722,9 +2727,9 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { }, ]); expect(reminder).toContain("task bash-lines matching lines:"); - expect(reminder).toContain("stdout byte 0 (x2): ERROR a"); + expect(reminder).toContain("stdout byte 0 ×2: ERROR a"); expect(reminder).toContain( - '3 matching lines dropped. Read bash_status({ taskId: "bash-lines", outputOffset: 16 }).', + '3 matching lines dropped. Read them with bash_status({ taskId: "bash-lines", outputOffset: 16 }).', ); }); @@ -2764,3 +2769,54 @@ test("each_line queue replaces snapshots without discarding another watch", asyn expect(pending).toHaveLength(2); expect(pending?.find((entry) => entry.watch_id === "watch-a")?.batch?.dropped_lines).toBe(3); }); + +test("exact pattern ack retries keep sibling watches and newer versions deliverable", async () => { + const old = { + task_id: "task-pattern", + session_id: "s1", + watch_id: "watch-one", + match_offset: 40, + match_text: "READY", + context: "READY old", + once: false, + reason: "pattern_match", + }; + let pending = [old]; + let confirm = false; + const calls: Array<{ command: string; params: Record }> = []; + const { ctx } = harness((command, params) => { + calls.push({ command, params }); + if (command === "bash_drain_completions") + return { success: true, bg_completions: [], pending_matches: pending }; + return { success: true, acked_matches: confirm ? (params.matches ?? []) : [] }; + }); + const context = { ctx, directory: "/tmp/project", sessionID: "s1" }; + const deliver = async () => { + const output = { output: "tool" }; + await appendInTurnBgCompletions(context, output); + return output.output; + }; + expect(await deliver()).toContain("READY old"); + expect(await deliver()).not.toContain("READY old"); + pending = [ + { ...old, watch_id: "watch-two", context: "READY sibling" }, + { ...old, match_offset: 41, context: "READY newer" }, + ]; + confirm = true; + const next = await deliver(); + expect(next).toContain("READY sibling"); + expect(next).toContain("READY newer"); + expect(next).not.toContain("READY old"); + const acks = calls.filter((call) => call.command === "bash_ack_completions"); + expect( + acks.some( + (call) => + JSON.stringify(call.params.matches) === + JSON.stringify([{ task_id: "task-pattern", watch_id: "watch-one", match_offset: 40 }]), + ), + ).toBe(true); + expect( + acks.some((call) => Array.isArray(call.params.matches) && call.params.matches.length === 2), + ).toBe(true); + for (const call of acks) expect(call.params.task_ids ?? []).not.toContain("task-pattern"); +}); diff --git a/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts b/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts index 9ed434f7a3..d5d1b1ec8e 100644 --- a/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts +++ b/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { join } from "node:path"; import type { BashPatternMatchFrame } from "@cortexkit/aft-bridge"; import { Effect, Exit, PubSub, Scope, Stream } from "effect"; @@ -37,6 +38,9 @@ async function rig(parentID?: string) { let failAdmission = false; let failAck = false; let ignoreAck = false; + let loseAck = false; + let beforeDrain: (() => Promise) | undefined; + let beforeAdmission: (() => Promise) | undefined; const host = { location: { directory: process.cwd() }, event: { subscribe: () => Stream.fromPubSub(bus) }, @@ -50,10 +54,13 @@ async function rig(parentID?: string) { failAdmission = false; return Effect.fail(new Error("host unavailable")); } - return Effect.sync(() => { + const record = Effect.sync(() => { statusAtWake.push(task.status); return synthetic.push(input); }); + const hook = beforeAdmission; + beforeAdmission = undefined; + return hook ? Effect.andThen(Effect.promise(hook), record) : record; }), }, }; @@ -69,7 +76,10 @@ async function rig(parentID?: string) { watch = params; return { success: true, watch_id: "watch-ready" }; } - if (command === "bash_drain_completions") + if (command === "bash_drain_completions") { + const hook = beforeDrain; + beforeDrain = undefined; + if (hook) await hook(); return { success: true, pending_matches: pending ? [pending] : [], @@ -86,6 +96,7 @@ async function rig(parentID?: string) { ] : [], }; + } if (command === "bash_ack_completions") { if (ignoreAck) { ignoreAck = false; @@ -95,11 +106,30 @@ async function rig(parentID?: string) { failAck = false; return { success: false, message: "ack unavailable" }; } - if (pending?.once) watch = undefined; - pending = undefined; + const matches = ( + (params.matches ?? []) as Array<{ + task_id: string; + watch_id: string; + match_offset: number; + }> + ).filter( + (match) => + pending && + match.task_id === pending.task_id && + match.watch_id === pending.watch_id && + match.match_offset === pending.match_offset, + ); + if (matches.length > 0 || ((params.task_ids ?? []) as string[]).includes(task.id)) { + if (pending?.once) watch = undefined; + pending = undefined; + } + if (loseAck) { + loseAck = false; + return { success: false, message: "ack response lost" }; + } return { success: true, - acked_matches: params.matches ?? [], + acked_matches: matches, acked_task_ids: params.task_ids ?? [], }; } @@ -134,6 +164,17 @@ async function rig(parentID?: string) { sends, bridge, pending: () => pending, + setPending: (frame: BashPatternMatchFrame) => { + pending = frame; + }, + holdNextDrain: (hook: () => Promise) => { + beforeDrain = hook; + }, + holdNextAdmission: (hook: () => Promise) => { + beforeAdmission = hook; + }, + push: (frame: BashPatternMatchFrame) => + consumer.bridgeOptions.onBashPatternMatch?.(frame, bridge as never), watching: () => watch !== undefined, revert: (value: boolean) => { reverted = value; @@ -147,6 +188,9 @@ async function rig(parentID?: string) { ignoreAck: () => { ignoreAck = true; }, + loseAck: () => { + loseAck = true; + }, async stop() { await Effect.runPromise( PubSub.publish(bus, { @@ -160,10 +204,10 @@ async function rig(parentID?: string) { consumer.dispose(); await Effect.runPromise(Scope.close(scope, Exit.succeed(undefined))); }, - async register(once = true) { + async register(once = true, deliver?: "each_line") { await Effect.runPromise( tool.execute( - { taskId: "bash-running", background: true, pattern: "READY", once }, + { taskId: "bash-running", background: true, pattern: "READY", once, deliver }, { sessionID: "session-v2", progress: () => Effect.void }, ), ); @@ -204,6 +248,9 @@ test("V2 async bash_watch wakes on matching output before task completion", asyn }); expect(r.synthetic[0]?.text).toContain("[BG BASH NOTIFY]"); expect(r.synthetic[0]?.text).toContain("READY: server is listening"); + expect(r.synthetic[0]?.text).toBe( + '\n[BG BASH NOTIFY]\n- task bash-running matched "READY" (offset 0):\n > READY: server is listening\n', + ); expect(r.synthetic[0]).not.toHaveProperty("resume"); expect(r.sends.filter((send) => send.command === "bash_ack_completions")).toEqual([ { @@ -557,3 +604,237 @@ test("V2 erased watch delivers its live-only failure notice without a durable ma await r.close(); } }); + +function eachLineFrame(offset: number, text: string): BashPatternMatchFrame { + return { + type: "bash_pattern_match", + session_id: "session-v2", + task_id: "bash-running", + watch_id: "watch-ready", + match_text: "matching lines", + match_offset: offset, + context: "unused: the batch is the rendered source", + once: false, + reason: "pattern_match", + batch: { + lines: [ + { + stream: "stdout", + offset, + end_offset: offset + Buffer.byteLength(text) + 1, + text, + count: 1, + }, + ], + dropped_lines: 0, + first_dropped_offset: null, + first_dropped_stream: null, + skipped_long_lines: 0, + }, + }; +} + +function eachLineSnapshot(id: string, ...frames: BashPatternMatchFrame[]): BashPatternMatchFrame { + const last = frames.at(-1)!; + const batch = { + ...last.batch!, + pending_id: id, + snapshot: true, + lines: frames.flatMap((frame) => frame.batch!.lines.map((line) => ({ ...line }))), + }; + return { ...last, batch }; +} + +function latch() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +function nativeTerminalFrame(): BashPatternMatchFrame { + const fixture = Bun.spawnSync({ + cmd: [ + "cargo", + "test", + "-p", + "agent-file-tools", + "--lib", + "--", + "bash_background::registry::tests::each_line_terminal_summary_frame_fixture", + "--exact", + "--nocapture", + ], + cwd: join(import.meta.dir, "../../../.."), + env: { ...process.env, RUSTC_WRAPPER: "", CARGO_BUILD_RUSTC_WRAPPER: "" }, + stdout: "pipe", + stderr: "pipe", + timeout: 20 * 60 * 1000, + }); + if (fixture.exitCode !== 0) + throw new Error(`native terminal fixture failed: ${fixture.stderr.toString()}`); + const frame = fixture.stdout.toString().match(/AFT_EACH_LINE_TERMINAL_FRAME=(\{[^\n]+\})/)?.[1]; + if (!frame) + throw new Error(`native terminal fixture emitted no frame: ${fixture.stdout.toString()}`); + return JSON.parse(frame) as BashPatternMatchFrame; +} + +test( + "V2 each_line terminal cap summary survives the durable version fence", + async () => { + const native = nativeTerminalFrame(); + const r = await rig(); + try { + await r.register(false, "each_line"); + const pending = eachLineSnapshot("terminal-pending", eachLineFrame(0, "READY first")); + pending.match_offset = 123; + pending.batch!.dropped_lines = 7; + pending.batch!.first_dropped_offset = 123; + pending.batch!.first_dropped_stream = "stderr"; + r.setPending(pending); + await r.push(native); + expect(r.synthetic).toHaveLength(1); + expect(r.synthetic[0]?.text).toContain( + '7 matching lines dropped. Read them with bash_status({ taskId: "bash-running", stderrOffset: 123 }).', + ); + expect(r.synthetic[0]?.text).toContain("stdout byte 0: READY first"); + expect( + r.sends.filter((send) => send.command === "bash_ack_completions").at(-1)?.params, + ).toEqual({ + session_id: "session-v2", + matches: [{ task_id: "bash-running", watch_id: "watch-ready", match_offset: 123 }], + }); + expect(r.pending()).toBeUndefined(); + } finally { + await r.close(); + } + }, + 20 * 60 * 1000, +); + +test("V2 each_line superseded push delivers every durable line once", async () => { + const r = await rig(); + const entered = latch(); + const resume = latch(); + let firstPush: Promise | void; + try { + await r.register(false, "each_line"); + const first = eachLineFrame(0, "READY first"); + const second = eachLineFrame(12, "READY second"); + r.setPending(eachLineSnapshot("pending-a", first)); + r.holdNextDrain(async () => { + entered.release(); + await resume.promise; + }); + firstPush = r.push(first); + await entered.promise; + r.setPending(eachLineSnapshot("pending-a", first, second)); + await r.push(second); + resume.release(); + await firstPush; + expect(r.synthetic).toHaveLength(1); + expect(r.synthetic[0]?.text).toContain("stdout byte 0: READY first"); + expect(r.synthetic[0]?.text).toContain("stdout byte 12: READY second"); + expect(r.pending()).toBeUndefined(); + } finally { + resume.release(); + await firstPush; + await r.close(); + } +}); + +test("V2 each_line concurrent admission dedups folded rows until exact ack", async () => { + const r = await rig(); + const entered = latch(); + const resume = latch(); + let firstPush: Promise | void; + try { + await r.register(false, "each_line"); + const first = eachLineFrame(0, "READY first"); + const second = eachLineFrame(12, "READY first"); + r.setPending(eachLineSnapshot("pending-a", first)); + r.holdNextAdmission(async () => { + entered.release(); + await resume.promise; + }); + firstPush = r.push(first); + await entered.promise; + const folded = eachLineSnapshot("pending-a", first); + folded.match_offset = second.match_offset; + folded.batch!.lines[0].count = 2; + r.setPending(folded); + await r.push(second); + resume.release(); + await firstPush; + expect(r.synthetic).toHaveLength(2); + expect(r.synthetic[0]?.text).toContain("stdout byte 0: READY first"); + expect(r.synthetic[1]?.text).toContain("stdout byte 12: READY first"); + expect(r.synthetic[1]?.text).not.toContain("stdout byte 0"); + expect(r.synthetic[1]?.text).not.toContain("×2"); + expect(r.pending()).toBeUndefined(); + } finally { + resume.release(); + await firstPush; + await r.close(); + } +}); + +test("V2 each_line new pending generation resets admitted drop counters", async () => { + const r = await rig(); + try { + await r.register(false, "each_line"); + const first = eachLineFrame(0, "READY first"); + first.batch!.dropped_lines = 7; + first.batch!.first_dropped_offset = 12; + first.batch!.first_dropped_stream = "stdout"; + r.setPending(eachLineSnapshot("pending-a", first)); + r.failAck(); + await r.push(first); + const second = eachLineFrame(24, "READY second"); + second.batch!.dropped_lines = 10; + second.batch!.first_dropped_offset = 37; + second.batch!.first_dropped_stream = "stdout"; + r.setPending(eachLineSnapshot("pending-b", second)); + await r.push(second); + expect(r.synthetic).toHaveLength(2); + expect(r.synthetic[0]?.text).toContain("7 matching lines dropped."); + expect(r.synthetic[1]?.text).toContain("10 matching lines dropped."); + expect(r.synthetic[1]?.text).not.toContain("3 matching lines dropped."); + } finally { + await r.close(); + } +}); + +test("V2 each_line lost ack redelivery preserves drop totals without duplicate delivery", async () => { + for (const committed of [false, true]) { + const r = await rig(); + try { + await r.register(false, "each_line"); + const first = eachLineFrame(0, "READY first"); + first.batch!.dropped_lines = 7; + first.batch!.first_dropped_offset = 12; + first.batch!.first_dropped_stream = "stdout"; + r.setPending(eachLineSnapshot("pending-a", first)); + if (committed) r.loseAck(); + else r.failAck(); + await r.push(first); + await r.push(first); + expect(r.synthetic).toHaveLength(1); + expect(r.synthetic[0]?.text).toContain("7 matching lines dropped."); + const next = eachLineFrame(24, "READY second"); + next.batch!.dropped_lines = 10; + next.batch!.first_dropped_offset = 37; + next.batch!.first_dropped_stream = "stdout"; + r.setPending(eachLineSnapshot("pending-b", next)); + await r.push(next); + expect(r.synthetic).toHaveLength(2); + expect(r.synthetic[1]?.text).toContain("10 matching lines dropped."); + expect(r.synthetic[1]?.text).not.toContain("READY first"); + expect(r.synthetic[1]?.text).not.toContain("3 matching lines dropped."); + expect(r.pending()).toBeUndefined(); + } finally { + await r.close(); + } + } +}); diff --git a/packages/opencode-plugin/src/__tests__/v2-subc-wakes.test.ts b/packages/opencode-plugin/src/__tests__/v2-subc-wakes.test.ts index 3762ef086a..150b278d0b 100644 --- a/packages/opencode-plugin/src/__tests__/v2-subc-wakes.test.ts +++ b/packages/opencode-plugin/src/__tests__/v2-subc-wakes.test.ts @@ -6,6 +6,7 @@ import { type AftTransportFactoryOptions, type AftTransportPool, acquireBridge, + type BashPatternMatchAck, releaseBridge, SubcTransportPool, } from "@cortexkit/aft-bridge"; @@ -21,7 +22,7 @@ import { import { makeServerEffect } from "../entry/server-runtime.mjs"; import * as logger from "../logger.js"; import { __resetV2WakeRoutesForTests, type V2RuntimeConsumer } from "../wakes/runtime-consumer.js"; -import type { V2SessionSyntheticInput } from "../wakes/session-delivery.js"; +import { V2SessionDelivery, type V2SessionSyntheticInput } from "../wakes/session-delivery.js"; import { recoverV2SubcSessions } from "../wakes/subc-recovery.js"; const roots: string[] = []; @@ -49,6 +50,7 @@ class ModuleDouble { events: string[] = []; clients: ReturnType[] = []; admissionGate: Promise | undefined; + acks: BashPatternMatchAck[][] = []; key(root: string, session: string) { return `${root}\0${session}`; } @@ -72,7 +74,10 @@ class ModuleDouble { request: async (route: RouteHandle, raw: unknown) => { if (closed) throw new Error("client closed"); const identity = routes.get(route.channel)!; - const body = raw as { name: string; arguments: { task_ids?: string[] } }; + const body = raw as { + name: string; + arguments: { task_ids?: string[]; matches?: BashPatternMatchAck[] }; + }; const key = this.key(identity.project_root, identity.session); this.events.push(`${body.name}:${identity.session}`); if (body.name === "bash_drain_completions") { @@ -82,6 +87,16 @@ class ModuleDouble { if (body.name === "bash_ack_completions") { expect(this.events).toContain(`admitted:${identity.session}`); const taskIds = new Set(body.arguments.task_ids); + const exact = (left: BashPatternMatchAck, right: BashPatternMatchAck) => + left.task_id === right.task_id && + left.watch_id === right.watch_id && + left.match_offset === right.match_offset; + const acked = (body.arguments.matches ?? []).filter((target) => + this.pending + .get(key) + ?.pending_matches?.some((item) => exact(item as BashPatternMatchAck, target)), + ); + this.acks.push(body.arguments.matches ?? []); const retained = { bg_completions: this.pending .get(key) @@ -91,14 +106,21 @@ class ModuleDouble { pending_matches: this.pending .get(key) ?.pending_matches?.filter( - (item) => !taskIds.has((item as { task_id: string }).task_id), + (item) => + !taskIds.has((item as { task_id: string }).task_id) && + !acked.some((target) => exact(item as BashPatternMatchAck, target)), ), }; if (retained.bg_completions?.length || retained.pending_matches?.length) this.pending.set(key, retained); else this.pending.delete(key); return { - structuredContent: { text: "", success: true, acked_task_ids: body.arguments.task_ids }, + structuredContent: { + text: "", + success: true, + acked_task_ids: body.arguments.task_ids, + acked_matches: acked, + }, content: [], }; } @@ -799,3 +821,97 @@ test("OC2 subc admission reservation fences a concurrent in-turn completion drai await runtime.stop(); } }); + +test("OC2 subc each_line stale ack at version one does not repeat admitted rows or counters", async () => { + const root = workspace(); + const module = new ModuleDouble(); + const runtime = await start(root, module); + let admit!: () => void; + let firstDrain: Promise | undefined; + const deliveries = new Set(); + const original = V2SessionDelivery.prototype.completion; + V2SessionDelivery.prototype.completion = function (input) { + if (input.notificationID?.startsWith("match\0")) deliveries.add(this); + return original.call(this, input); + }; + try { + runtime.consumer.registerSession?.("s"); + await runtime.pool.getBridge(root).send("status", { session_id: "s" }); + await tick(); + module.events.length = 0; + const firstLine = { + stream: "stdout", + offset: 0, + end_offset: 12, + text: "READY first", + count: 1, + }; + const first = { + ...match(), + once: false, + reason: "pattern_match", + match_offset: 0, + batch: { + pending_id: "subc-pending", + version: 0, + snapshot: true, + lines: [firstLine], + dropped_lines: 7, + skipped_long_lines: 0, + first_dropped_offset: 100, + first_dropped_stream: "stdout", + }, + }; + module.pending.set(module.key(root, "s"), { pending_matches: [first] }); + module.admissionGate = new Promise((resolve) => { + admit = resolve; + }); + const receiver = runtime.consumer.subcWakes(() => runtime.pool, root); + firstDrain = receiver.drainSession("s"); + await until(() => module.events.includes("bash_drain_completions:s")); + module.pending.set(module.key(root, "s"), { + pending_matches: [ + { + ...first, + match_offset: 1, + batch: { + ...first.batch, + version: 1, + dropped_lines: 10, + lines: [ + { ...firstLine }, + { stream: "stderr", offset: 0, end_offset: 13, text: "READY second", count: 1 }, + ], + }, + }, + ], + }); + admit(); + module.admissionGate = undefined; + await firstDrain; + await receiver.drainSession("s"); + expect(runtime.synthetic).toHaveLength(2); + expect(runtime.synthetic[0]?.text).toContain("stdout byte 0: READY first"); + expect(runtime.synthetic[0]?.text).toContain("7 matching lines dropped."); + expect(runtime.synthetic[1]?.text).toContain("stderr byte 0: READY second"); + expect(runtime.synthetic[1]?.text).not.toContain("stdout byte 0: READY first"); + expect(runtime.synthetic[1]?.text).toContain("3 matching lines dropped."); + expect(runtime.synthetic[1]?.text).not.toContain("10 matching lines dropped."); + expect(module.acks).toEqual([ + [{ task_id: "watched", watch_id: "watch", match_offset: 0 }], + [{ task_id: "watched", watch_id: "watch", match_offset: 1 }], + ]); + expect(module.pending.has(module.key(root, "s"))).toBe(false); + expect(deliveries.size).toBe(1); + for (const delivery of deliveries) { + expect((delivery as unknown as { admittedWakes: Set }).admittedWakes.size).toBe(0); + } + await receiver.drainSession("s"); + expect(runtime.synthetic).toHaveLength(2); + } finally { + V2SessionDelivery.prototype.completion = original; + admit?.(); + await firstDrain; + await runtime.stop(); + } +}); diff --git a/packages/opencode-plugin/src/bg-notifications.ts b/packages/opencode-plugin/src/bg-notifications.ts index 60be5335fe..066990ea64 100644 --- a/packages/opencode-plugin/src/bg-notifications.ts +++ b/packages/opencode-plugin/src/bg-notifications.ts @@ -2,7 +2,14 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import type { BashWatchLineBatch } from "@cortexkit/aft-bridge"; import { type AftProjectTransport, + type BashAckTarget, type BgNudgeRef, + bashAckKey, + bashAckRequest, + bashAcksConfirmed, + bashAckTargetFromKey, + bashAckTargets, + bashPatternAckTarget, formatWatchLineBatch, mergeWatchLineBatches, resolveBridgeForNudge, @@ -126,7 +133,7 @@ type SessionBgState = { consumedTaskIds: Set; consumedTaskOrder: string[]; /** - * Task IDs whose delivery is IN FLIGHT (removed from pendingCompletions / + * Task IDs and exact watch-version keys whose delivery is IN FLIGHT (removed from pendingCompletions / * pattern queues, prompt not yet resolved). Ingest skips these so a subc forced * drain in the removal→delivery window cannot re-accept and double-schedule an * already-departing completion. Bounded by the in-flight wake batch (cleared on @@ -134,7 +141,7 @@ type SessionBgState = { */ deliveringTaskIds: Set; /** - * Task IDs DELIVERED to the agent but whose `bash_ack_completions` has not yet + * Acknowledgement target keys DELIVERED to the agent but whose `bash_ack_completions` has not yet * confirmed (so the Rust registry still holds them and, over subc, re-nudges). * Ingest skips these for fresh delivery AND a forced drain RE-ACKs them (the * self-terminating close of the re-nudge loop). Entries are removed by @@ -237,6 +244,16 @@ const deferredBusyWakes = new Map(); export interface BgNotificationDelivery { completion(completion: BgCompletion, bridge: AftProjectTransport): Promise; patternMatch(match: PatternMatchEntry, bridge: AftProjectTransport): Promise; + /** Confirmed exact acknowledgements let a consumer retire its admission watermarks. */ + patternMatchAcked?( + sessionID: string | undefined, + match: import("@cortexkit/aft-bridge").BashPatternMatchAck, + ): void; + /** Durable absence also retires state when an acknowledgement response was lost. */ + patternMatchesPending?( + sessionID: string | undefined, + matches: readonly PatternMatchEntry[], + ): void; } interface DrainContext { @@ -381,9 +398,7 @@ export async function markBgCompletionDelivered( // by markTaskWaiting, so re-acking all of it would be wrong.) const state = stateFor(drainContext.sessionID); markDeliveredAwaitingAck(state, [taskId]); - const acked = await ackCompletions(drainContext, [ - { task_id: taskId, status: "unknown", exit_code: null, command: "" }, - ]); + const acked = await ackCompletions(drainContext, [{ task_id: taskId }]); if (acked) confirmAcked(state, [taskId]); } @@ -515,7 +530,10 @@ function queuePendingPatternMatch(state: SessionBgState, entry: PatternMatchEntr const existingIdx = state.pendingPatternMatches.findIndex( (match) => match.task_id === normalized.task_id && - (normalized.batch || match.batch ? match.watch_id === normalized.watch_id : true), + (match.watch_id === normalized.watch_id || + (!normalized.batch && + !match.batch && + (normalized.reason === "task_exit" || match.reason === "task_exit"))), ); if (existingIdx >= 0) { const existing = state.pendingPatternMatches[existingIdx]; @@ -567,6 +585,7 @@ export async function handlePushedPatternMatch( return; } const state = stateFor(drainContext.sessionID); + if (isDeliveringOrAwaitingAck(state, frame)) return; queuePendingPatternMatch(state, { ...frame, ackCompletionOnDelivery: true }); await triggerWakeIfPending(drainContext, true, true, false, true); } @@ -713,7 +732,7 @@ export async function appendInTurnBgCompletions( // C-#1 site 4: this path delivers via the tool-result piggyback (already // written to output.output above — delivery cannot fail here), so mark the // ack-target ids awaiting-ack directly, then confirm/keep on the ack result. - const inTurnAckIds = completionAcks.map((completion) => completion.task_id); + const inTurnAckIds = completionAcks.map(bashAckKey); markDeliveredAwaitingAck(state, inTurnAckIds); const inTurnAcked = await ackCompletions(drainContext, completionAcks); if (inTurnAcked) confirmAcked(state, inTurnAckIds); @@ -821,12 +840,16 @@ async function deliverDrainedNotifications( const matches = state.pendingPatternMatches.splice(0); // Reserve before awaiting admission: an in-turn drain must not deliver the // same notification while the host is still deciding whether to admit it. - const taskIds = [...new Set([...completions, ...matches].map((item) => item.task_id))]; + const taskIds = completionAcksForDelivery(completions, matches).map(bashAckKey); markDelivering(state, taskIds); const bridge = bridgeForDrain(drainContext); const items = [...completions, ...matches]; for (const [index, item] of items.entries()) { const isMatch = isPatternMatchEntry(item); + const targets = isMatch + ? completionAcksForDelivery([], [item]) + : completionAcksForDelivery([item], []); + const keys = targets.map(bashAckKey); try { if (!isMatch && state.consumedTaskIds.has(item.task_id)) continue; const admitted = isMatch @@ -838,11 +861,9 @@ async function deliverDrainedNotifications( continue; } if (!isMatch) consumeBgCompletion(drainContext.sessionID, item.task_id); - markDeliveredAwaitingAck(state, [item.task_id]); - const acked = await ackCompletions(drainContext, [ - { task_id: item.task_id, status: "unknown", exit_code: null, command: "" }, - ]); - if (acked) confirmAcked(state, [item.task_id]); + markDeliveredAwaitingAck(state, keys); + const acked = await ackCompletions(drainContext, targets); + if (acked) confirmAcked(state, keys); } catch (error) { if (isMatch) queuePendingPatternMatch(state, item); else state.pendingCompletions.push(item); @@ -853,7 +874,7 @@ async function deliverDrainedNotifications( clearDelivering(state, taskIds); throw error; } finally { - clearDelivering(state, [item.task_id]); + clearDelivering(state, keys); } } } @@ -1328,6 +1349,7 @@ async function triggerWakeIfPending( return; } const taskIDs = deliveredCompletions.map((completion) => completion.task_id); + const ackKeys = deliveredCompletions.map(bashAckKey); const watchIds = deliveredPatternMatches?.map((m) => m.watch_id) ?? []; const watchId = watchIds.join(",") || undefined; @@ -1486,10 +1508,10 @@ async function triggerWakeIfPending( onPending: batch.pending, onLateAcceptance: () => { batch.accepted(); - markDeliveredAwaitingAck(state, taskIDs); + markDeliveredAwaitingAck(state, ackKeys); void ackCompletions(drainContext, deliveredCompletions, deliveryID) .then((acked) => { - if (acked) confirmAcked(state, taskIDs); + if (acked) confirmAcked(state, ackKeys); }) .catch((cause) => logDeliveryHop( @@ -1583,9 +1605,9 @@ async function triggerWakeIfPending( batch.accepted(); // Session injection has resolved successfully. Only now may these task IDs // enter awaiting-ack state and become eligible for daemon acknowledgement. - markDeliveredAwaitingAck(state, taskIDs); + markDeliveredAwaitingAck(state, ackKeys); const acked = await ackCompletions(drainContext, deliveredCompletions, deliveryID); - if (acked) confirmAcked(state, taskIDs); + if (acked) confirmAcked(state, ackKeys); }, (err, hardStopped) => { if (isTerminalNudgeError(err)) { @@ -1812,6 +1834,7 @@ async function drainCompletions(drainContext: DrainContext): Promise { const drainedMatches = Array.isArray(response.pending_matches) ? response.pending_matches.filter(isPatternMatchEntry) : []; + drainContext.delivery?.patternMatchesPending?.(drainContext.sessionID, drainedMatches); const drainedTaskIds = [ ...new Set([ ...drainedCompletions.map((completion) => completion.task_id), @@ -1829,19 +1852,15 @@ async function drainCompletions(drainContext: DrainContext): Promise { // drain no longer returns was already dropped daemon-side → reconcile forgets // it. ingestDrainedBgCompletions skips awaiting-ack tasks for fresh delivery, // so a re-ack never double-delivers. - const reackTaskIds = reconcileAwaitingAck(state, drainedTaskIds); + const drainedAckKeys = [ + ...drainedCompletions.map(bashAckKey), + ...drainedMatches.map((match) => bashAckKey(bashPatternAckTarget(match))), + ]; + const reackTaskIds = reconcileAwaitingAck(state, drainedAckKeys); ingestDrainedBgCompletions(drainContext.sessionID, drainedCompletions); ingestDrainedPatternMatches(drainContext.sessionID, drainedMatches); if (reackTaskIds.length > 0) { - const reacked = await ackCompletions( - drainContext, - reackTaskIds.map((task_id) => ({ - task_id, - status: "unknown", - exit_code: null, - command: "", - })), - ); + const reacked = await ackCompletions(drainContext, reackTaskIds.map(bashAckTargetFromKey)); if (reacked) confirmAcked(state, reackTaskIds); } } catch (err) { @@ -1863,22 +1882,23 @@ async function drainCompletions(drainContext: DrainContext): Promise { async function ackCompletions( drainContext: DrainContext, - completions: readonly BgCompletion[], + completions: readonly BashAckTarget[], deliveryID?: string, ): Promise { const taskIds = [...new Set(completions.map((completion) => completion.task_id))]; - if (taskIds.length === 0) return true; + if (completions.length === 0) return true; + const targets = bashAckRequest(completions); try { const bridge = bridgeForDrain(drainContext); const response = await withBgHopTimeout( bridge.send( "bash_ack_completions", - { session_id: drainContext.sessionID, task_ids: taskIds }, + { session_id: drainContext.sessionID, ...targets }, { timeoutMs: bgHopTimeoutMs }, ), "bash_ack_completions", ); - if (response.success === false) { + if (!bashAcksConfirmed(response, completions)) { logPerTaskDeliveryHop( drainContext, "ack-error", @@ -1892,6 +1912,10 @@ async function ackCompletions( ); return false; } + for (const target of completions) { + if ("watch_id" in target) + drainContext.delivery?.patternMatchAcked?.(drainContext.sessionID, target); + } // Trace #6 of 7: bash_ack_completions succeeded on the Rust side. // Closes the wake chain: scheduled → fire → start → ok → ack_ok. // Note: ack also runs from appendInTurnBgCompletions without a @@ -1963,7 +1987,7 @@ function scheduleWake( state: SessionBgState, sendWake: ( reminder: string, - completions: readonly BgCompletion[], + completions: readonly BashAckTarget[], patternMatches: readonly PatternMatchEntry[] | undefined, batch: WakeBatchCallbacks, ) => Promise, @@ -2069,7 +2093,7 @@ function scheduleWake( // matches, not just `pending`) as delivery-in-flight at the same synchronous // tick they leave the queues, BEFORE the sendWake await. Ingest skips them so // a forced drain in the delivery window can't re-accept them. - const ackTargetIds = completionAcks.map((completion) => completion.task_id); + const ackTargetIds = completionAcks.map(bashAckKey); markDelivering(state, ackTargetIds); const batch: WakeBatchCallbacks = { taskIDs: [ @@ -2114,7 +2138,9 @@ function scheduleWake( state.pendingPatternMatches = state.pendingPatternMatches.filter( (item) => !pendingPatternMatches.includes(item), ); - for (const taskID of ackTargetIds) consumeBgCompletion(sessionID, taskID); + for (const target of completionAcks) { + if (!("watch_id" in target)) consumeBgCompletion(sessionID, target.task_id); + } }, cancelled: () => batch.pending(), }; @@ -2207,7 +2233,7 @@ function ingestDrainedPatternMatches( const state = stateFor(sessionID); for (const match of matches) { state.outstandingTaskIds.delete(match.task_id); - if (isDeliveringOrAwaitingAck(state, match.task_id)) continue; + if (isDeliveringOrAwaitingAck(state, match)) continue; queuePendingPatternMatch(state, { ...match, ackCompletionOnDelivery: true }); } } @@ -2326,24 +2352,22 @@ function completionToExitPattern( function completionAcksForDelivery( completions: readonly BgCompletion[], patternMatches: readonly PatternMatchEntry[], -): BgCompletion[] { - const acks = [...completions]; - const ackedTaskIds = new Set(acks.map((completion) => completion.task_id)); - for (const match of patternMatches) { - if (!match.ackCompletionOnDelivery || ackedTaskIds.has(match.task_id)) continue; - acks.push({ task_id: match.task_id, status: "unknown", exit_code: null, command: "" }); - ackedTaskIds.add(match.task_id); - } - return acks; +): BashAckTarget[] { + return bashAckTargets(completions, patternMatches); } -/** - * True if `taskId` has a delivery in flight or delivered-awaiting-ack — i.e. it - * has already left a deliverable queue and will be acked, so a fresh ingest - * (push or forced drain) must NOT re-accept it (audit C-#1 double-deliver guard). - */ -function isDeliveringOrAwaitingAck(state: SessionBgState, taskId: string): boolean { - return state.deliveringTaskIds.has(taskId) || state.deliveredAwaitingAckTaskIds.has(taskId); +/** Suppress an exact watch version, or any notice covered by a task-level acknowledgement. */ +function isDeliveringOrAwaitingAck( + state: SessionBgState, + target: string | PatternMatchEntry, +): boolean { + const keys = + typeof target === "string" + ? [target] + : [target.task_id, bashAckKey(bashPatternAckTarget(target))]; + return keys.some( + (key) => state.deliveringTaskIds.has(key) || state.deliveredAwaitingAckTaskIds.has(key), + ); } /** Mark ack-target task ids as delivery-in-flight (at queue-removal, before await). */ diff --git a/packages/opencode-plugin/src/tools/bash_watch.ts b/packages/opencode-plugin/src/tools/bash_watch.ts index 243d90bc2e..a355fefe7f 100644 --- a/packages/opencode-plugin/src/tools/bash_watch.ts +++ b/packages/opencode-plugin/src/tools/bash_watch.ts @@ -12,6 +12,7 @@ import { TERMINAL_SCAN_INCOMPLETE_TEXT, taskKillDeadlineText, taskKillDeadlineWithinHandoffMargin, + WATCH_EACH_LINE_NEEDS_BACKGROUND, WATCH_SYNC_DEFAULTS_DESCRIPTION, WATCH_TIMEOUT_PARAM_DESCRIPTION, WATCH_UNAVAILABLE_GIVE_UP_MS, @@ -175,9 +176,7 @@ export function createBashWatchTool(ctx: PluginContext): ToolDefinition { const isSubagent = subagentStatus === true; const asyncMode = requestedAsync && !subagentForcedSync; if (args.deliver !== undefined && !asyncMode) { - throw new Error( - "invalid_request: bash_watch deliver requires background:true; this watch waits synchronously", - ); + throw new Error(WATCH_EACH_LINE_NEEDS_BACKGROUND); } if (args.deliver !== undefined && args.deliver !== "each_line") { throw new Error("invalid_request: bash_watch deliver must be each_line"); diff --git a/packages/opencode-plugin/src/wakes/runtime-consumer.ts b/packages/opencode-plugin/src/wakes/runtime-consumer.ts index 76ba060824..dbd5cb500b 100644 --- a/packages/opencode-plugin/src/wakes/runtime-consumer.ts +++ b/packages/opencode-plugin/src/wakes/runtime-consumer.ts @@ -5,10 +5,15 @@ import type { BashCompletedPayload, BashLongRunningPayload, BashPatternMatchAck, + BashWatchLineBatch, BgNudgeRef, BridgeOptions, } from "@cortexkit/aft-bridge"; -import { canonicalizeProjectRoot } from "@cortexkit/aft-bridge"; +import { + canonicalizeProjectRoot, + WatchBatchProgress, + watchBatchHasContent, +} from "@cortexkit/aft-bridge"; import { Effect } from "effect"; import { interruptBashWaitsForChatMessage } from "../bash-wait-detach.js"; @@ -379,9 +384,9 @@ export function createV2RuntimeConsumer( options: V2RuntimeConsumerOptions = {}, ): V2RuntimeConsumer { const delivery = new V2SessionDelivery(context.session); - // SubC owns acknowledgement retries outside this consumer and does not - // expose confirmed retirement here. Preserve its retained deduplication keys - // without imposing the standalone path's cap on long-lived SubC sessions. + // SubC owns acknowledgement retries outside admission. Keep its existing + // completion/scalar-watch keys; batched watches retire through exact-ack and + // durable-pending callbacks below. const subcDelivery = new V2SessionDelivery(context.session, Number.POSITIVE_INFINITY); const sessions = new Set(); let subcPool: (() => AftTransportPool) | undefined; @@ -395,6 +400,38 @@ export function createV2RuntimeConsumer( >(); const completing = new Map>(); const matching = new Map>(); + const eachLineWork = new Map< + string, + { + version: number; + latest?: { match: PatternMatchEntry; bridge: AftProjectTransport }; + promise: Promise; + } + >(); + const lineProgress = new Map(); + const subcLineProgress = new Map(); + const subcMatchID = (watch: string, version: number) => `match\0${watch}\0${version}`; + const retireSubcBatchVersion = ( + sessionID: string, + task: string, + watch: string, + version: number, + ) => + subcDelivery.retireCompletion({ + sessionID, + taskIDs: [task], + notificationID: subcMatchID(watch, version), + }); + const watchKey = (match: PatternMatchEntry) => + JSON.stringify([match.session_id, match.task_id, match.watch_id]); + const matchKey = (match: PatternMatchEntry) => + JSON.stringify([ + match.session_id, + match.task_id, + match.watch_id, + match.match_offset, + match.reason, + ]); const draining = new Map>(); let disposed = false; const retryDelays = options.standaloneRetryDelaysMs ?? STANDALONE_RETRY_DELAYS_MS; @@ -449,110 +486,40 @@ export function createV2RuntimeConsumer( (retry.timer as { unref?: () => void }).unref?.(); retries.set(key, retry); }; - const route: WakeRoute = { - standalonePatternMatch: async (match, bridge) => { - const key = JSON.stringify([ - match.session_id, - match.task_id, - match.watch_id, - match.match_offset, - match.reason, - ]); - // As in `patternMatch`: a deleted session has no one to tell. Acknowledge - // the match without queueing it, so the bridge does not deliver it again. - if (v2SessionDeleted(context, match.session_id)) { + const retireOlderEachLine = (match: PatternMatchEntry): boolean => { + const watch = watchKey(match); + for (const [key, pending] of pendingMatches) { + if (!pending.match.batch || watchKey(pending.match) !== watch) continue; + if (pending.match.match_offset > match.match_offset) return false; + if (pending.match.match_offset < match.match_offset) { pendingMatches.delete(key); clearRetry(key); - await bridge - .send("bash_ack_completions", { - session_id: match.session_id, - ...(match.reason === "task_exit" - ? { task_ids: [match.task_id] } - : { - matches: [ - { - task_id: match.task_id, - watch_id: match.watch_id, - match_offset: match.match_offset, - } satisfies BashPatternMatchAck, - ], - }), - }) - .catch((error) => { - warn( - `[bash_pattern_match] acknowledging watch ${match.watch_id} of deleted session ${match.session_id} failed: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - return; + delivery.retirePatternMatch({ + sessionID: pending.match.session_id, + taskIDs: [pending.match.task_id], + watchID: pending.match.watch_id, + matchOffset: pending.match.match_offset, + reason: pending.match.reason, + text: "", + }); } - const active = matching.get(key); - if (active) return active; - const pending = pendingMatches.get(key) ?? { match, bridge, admitted: false }; - pendingMatches.set(key, pending); - const deliver = async () => { - if (disposed) return; - const isSubagent = await resolveIsSubagent(context, match.session_id); - if (disposed) return; - if (isSubagent) { - await awaitV2ExecutionSettlement(context, match.session_id); - if (disposed) return; - if (!v2WatchWakeAllowed(context, match.session_id, match.task_id)) return; - } - const wake = { - sessionID: match.session_id, - taskIDs: [match.task_id], - watchID: match.watch_id, - matchOffset: match.match_offset, - reason: match.reason, - text: formatPatternMatchReminder([match]), - }; - // Rust reports a deleted task by sending a terminal notification, not - // by saving a pending match. There is no task row in which to persist it. - const erased = match.reason === "task_exit" && match.match_text === "watch target erased"; - if (!erased) { - // A late push can arrive after its ack, or after a newer sticky match - // replaced it. Durable pending state is the replay fence, not an - // ever-growing set of every offset this Location has delivered. - const current = await pending.bridge.send("bash_drain_completions", { - session_id: match.session_id, - }); - if (disposed) return; - if (current.success === false || !Array.isArray(current.pending_matches)) { - throw new Error("pattern replay validation failed"); - } - if ( - !current.pending_matches - .filter(isPatternMatchEntry) - .some( - (entry) => - entry.session_id === match.session_id && - entry.task_id === match.task_id && - entry.watch_id === match.watch_id && - entry.match_offset === match.match_offset && - entry.reason === match.reason, - ) - ) { - delivery.retirePatternMatch(wake); - pendingMatches.delete(key); - return; - } - } - if (!pending.admitted) { - const text = wake.text; - const admitted = await Effect.runPromise( - delivery.patternMatch({ - ...wake, - metadata: { source: "aft", kind: "bash_pattern_match", watch_id: match.watch_id }, - }), - ); - if (disposed || admitted === "not-delivered") return; - pending.admitted = true; - if (admitted === "delivered") - detachWaitsForCompletion(match.session_id, text, bridge, getConfig()); - } - // The daemon deletes an acknowledged once-watch, but only clears a - // sticky watch's pending match so it can keep scanning later output. - const ack = await pending.bridge.send("bash_ack_completions", { + } + return true; + }; + const deliverStandalonePatternMatch = async ( + match: PatternMatchEntry, + bridge: AftProjectTransport, + ): Promise => { + const key = matchKey(match); + const watch = watchKey(match); + // As in `patternMatch`: a deleted session has no one to tell. Acknowledge + // the match without queueing it, so the bridge does not deliver it again. + if (v2SessionDeleted(context, match.session_id)) { + pendingMatches.delete(key); + if (match.batch) lineProgress.delete(watch); + clearRetry(key); + await bridge + .send("bash_ack_completions", { session_id: match.session_id, ...(match.reason === "task_exit" ? { task_ids: [match.task_id] } @@ -565,49 +532,195 @@ export function createV2RuntimeConsumer( } satisfies BashPatternMatchAck, ], }), + }) + .catch((error) => { + warn( + `[bash_pattern_match] acknowledging watch ${match.watch_id} of deleted session ${match.session_id} failed: ${error instanceof Error ? error.message : String(error)}`, + ); }); + return; + } + const active = matching.get(key); + if (active) return active; + const pending = pendingMatches.get(key) ?? { match, bridge, admitted: false }; + pendingMatches.set(key, pending); + const deliver = async () => { + if (disposed) return; + const isSubagent = await resolveIsSubagent(context, match.session_id); + if (disposed) return; + if (isSubagent) { + await awaitV2ExecutionSettlement(context, match.session_id); if (disposed) return; - if (ack.success === false) - throw new Error(String(ack.message ?? "pattern acknowledgement failed")); - if ( - match.reason !== "task_exit" && - (!Array.isArray(ack.acked_matches) || - !ack.acked_matches.some((entry) => { - if (typeof entry !== "object" || entry === null) return false; - const confirmed = entry as Partial; - return ( - confirmed.task_id === match.task_id && - confirmed.watch_id === match.watch_id && - confirmed.match_offset === match.match_offset - ); - })) - ) { - // Older engines may ignore the matches parameter. Keep the admission - // reserved until a later drain proves this match is no longer pending. - throw new Error("pattern acknowledgement was not confirmed"); - } - pendingMatches.delete(key); - delivery.retirePatternMatch(wake); + if (!v2WatchWakeAllowed(context, match.session_id, match.task_id)) return; + } + let admittedBatch: BashWatchLineBatch | undefined; + let progress: WatchBatchProgress | undefined; + const wake = { + sessionID: match.session_id, + taskIDs: [match.task_id], + watchID: match.watch_id, + matchOffset: match.match_offset, + reason: match.reason, + text: formatPatternMatchReminder([match]), }; - const attempt = deliver() - .then( - () => clearRetry(key), - (error) => { - warn( - `[bash_pattern_match] delivery of watch ${match.watch_id} to session ${match.session_id} failed: ${error instanceof Error ? error.message : String(error)}`, + // Rust reports a deleted task by sending a terminal notification, not + // by saving a pending match. There is no task row in which to persist it. + const erased = match.reason === "task_exit" && match.match_text === "watch target erased"; + if (!erased) { + // A late push can arrive after its ack, or after a newer sticky match + // replaced it. Durable pending state is the replay fence, not an + // ever-growing set of every offset this consumer has delivered. + const current = await pending.bridge.send("bash_drain_completions", { + session_id: match.session_id, + }); + if (disposed) return; + if (current.success === false || !Array.isArray(current.pending_matches)) { + throw new Error("pattern replay validation failed"); + } + const durable = current.pending_matches.filter(isPatternMatchEntry); + const currentMatch = durable.find( + (entry) => + entry.session_id === match.session_id && + entry.task_id === match.task_id && + entry.watch_id === match.watch_id && + entry.match_offset === match.match_offset && + entry.reason === match.reason, + ); + if (!currentMatch) { + if (match.batch && !durable.some((entry) => watchKey(entry) === watch)) + lineProgress.delete(watch); + delivery.retirePatternMatch(wake); + pendingMatches.delete(key); + return; + } + if (match.batch) { + // A superseded push may never have reached the host. The durable + // snapshot retains its rows; watermarks remove only admitted data. + admittedBatch = { + ...(currentMatch.batch ?? match.batch), + snapshot: true, + version: match.match_offset, + }; + progress = lineProgress.get(watch) ?? new WatchBatchProgress(); + const unseen = progress.unseen(admittedBatch); + wake.text = formatPatternMatchReminder([{ ...currentMatch, batch: unseen }]); + if (!watchBatchHasContent(unseen)) pending.admitted = true; + } + } + if (!pending.admitted) { + const text = wake.text; + const admitted = await Effect.runPromise( + delivery.patternMatch({ + ...wake, + metadata: { source: "aft", kind: "bash_pattern_match", watch_id: match.watch_id }, + }), + ); + if (disposed || admitted === "not-delivered") return; + pending.admitted = true; + if (admittedBatch && progress) { + progress.admit(admittedBatch); + lineProgress.set(watch, progress); + } + if (admitted === "delivered") + detachWaitsForCompletion(match.session_id, text, bridge, getConfig()); + } + // The daemon deletes an acknowledged once-watch, but only clears a + // sticky watch's pending match so it can keep scanning later output. + const ack = await pending.bridge.send("bash_ack_completions", { + session_id: match.session_id, + ...(match.reason === "task_exit" + ? { task_ids: [match.task_id] } + : { + matches: [ + { + task_id: match.task_id, + watch_id: match.watch_id, + match_offset: match.match_offset, + } satisfies BashPatternMatchAck, + ], + }), + }); + if (disposed) return; + if (ack.success === false) + throw new Error(String(ack.message ?? "pattern acknowledgement failed")); + if ( + match.reason !== "task_exit" && + (!Array.isArray(ack.acked_matches) || + !ack.acked_matches.some((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const confirmed = entry as Partial; + return ( + confirmed.task_id === match.task_id && + confirmed.watch_id === match.watch_id && + confirmed.match_offset === match.match_offset ); - // The match stays in `pendingMatches`. Retry it from there, unless - // something else delivered or retired it first. - scheduleRetry(key, match.session_id, `watch ${match.watch_id} match`, () => { - const current = pendingMatches.get(key); - if (current) void route.standalonePatternMatch?.(current.match, current.bridge); - else clearRetry(key); - }); - }, - ) - .finally(() => matching.delete(key)); - matching.set(key, attempt); - return attempt; + })) + ) { + // Older engines may ignore the matches parameter. Keep the admission + // reserved until a later drain proves this match is no longer pending. + throw new Error("pattern acknowledgement was not confirmed"); + } + pendingMatches.delete(key); + if (match.batch) lineProgress.delete(watch); + delivery.retirePatternMatch(wake); + }; + const attempt = deliver() + .then( + () => clearRetry(key), + (error) => { + warn( + `[bash_pattern_match] delivery of watch ${match.watch_id} to session ${match.session_id} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + // The match stays in `pendingMatches`. Retry it from there, unless + // something else delivered or retired it first. + scheduleRetry(key, match.session_id, `watch ${match.watch_id} match`, () => { + const current = pendingMatches.get(key); + if (current) void route.standalonePatternMatch?.(current.match, current.bridge); + else clearRetry(key); + }); + }, + ) + .finally(() => matching.delete(key)); + matching.set(key, attempt); + return attempt; + }; + const route: WakeRoute = { + standalonePatternMatch: async (match, bridge) => { + if (!match.batch) return deliverStandalonePatternMatch(match, bridge); + const watch = watchKey(match); + const active = eachLineWork.get(watch); + if (active) { + if (match.match_offset > (active.latest?.match.match_offset ?? active.version)) + active.latest = { match, bridge }; + return; + } + if (!retireOlderEachLine(match)) return; + // One active admission and one latest version bound a noisy watch's + // backlog. The durable snapshot accounts for coalesced versions' rows. + const work: { + version: number; + latest?: { match: PatternMatchEntry; bridge: AftProjectTransport }; + promise: Promise; + } = { + version: match.match_offset, + latest: { match, bridge }, + promise: Promise.resolve(), + }; + eachLineWork.set(watch, work); + work.promise = Promise.resolve() + .then(async () => { + while (!disposed && work.latest) { + const next = work.latest; + work.latest = undefined; + work.version = next.match.match_offset; + if (retireOlderEachLine(next.match)) + await deliverStandalonePatternMatch(next.match, next.bridge); + } + }) + .finally(() => { + if (eachLineWork.get(watch) === work) eachLineWork.delete(watch); + }); + return work.promise; }, drainPatterns: async (sessionID, root) => { // SubC owns session-event subscriptions and retries pending wakes after @@ -744,12 +857,25 @@ export function createV2RuntimeConsumer( await awaitV2ExecutionSettlement(context, match.session_id); if (!v2WatchWakeAllowed(context, match.session_id, match.task_id)) return false; } - const text = formatPatternMatchReminder([match]); + const watch = watchKey(match); + const batch = match.batch + ? { ...match.batch, snapshot: true, version: match.match_offset } + : undefined; + const progress = batch + ? (subcLineProgress.get(watch) ?? new WatchBatchProgress()) + : undefined; + const unseen = batch && progress ? progress.unseen(batch) : undefined; + if (batch && progress && unseen && !watchBatchHasContent(unseen)) { + progress.admit(batch); + subcLineProgress.set(watch, progress); + return true; + } + const text = formatPatternMatchReminder([unseen ? { ...match, batch: unseen } : match]); const admitted = await Effect.runPromise( subcDelivery.completion({ sessionID: match.session_id, taskIDs: [match.task_id], - notificationID: `match\0${match.watch_id}\0${match.match_offset}`, + notificationID: subcMatchID(match.watch_id, match.match_offset), text, metadata: { source: "aft", @@ -760,6 +886,10 @@ export function createV2RuntimeConsumer( }), ); if (disposed || admitted === "not-delivered") return false; + if (batch && progress) { + progress.admit(batch); + subcLineProgress.set(watch, progress); + } if (admitted === "delivered") { detachWaitsForCompletion(match.session_id, text, bridge, getConfig()); if (match.reason === "task_exit") forgetUnroutedCompletion(match.session_id, match.task_id); @@ -950,6 +1080,31 @@ export function createV2RuntimeConsumer( false ); }, + patternMatchAcked: (sessionID, match) => { + if (!sessionID) return; + const key = JSON.stringify([sessionID, match.task_id, match.watch_id]); + const progress = subcLineProgress.get(key); + if (!progress) return; + retireSubcBatchVersion(sessionID, match.task_id, match.watch_id, match.match_offset); + if (progress.admittedVersion === match.match_offset) subcLineProgress.delete(key); + }, + patternMatchesPending: (sessionID, matches) => { + const pending = new Map( + matches.filter((match) => match.batch).map((match) => [watchKey(match), match]), + ); + for (const [key, progress] of subcLineProgress) { + const [session, task, watch] = JSON.parse(key) as [string, string, string]; + if (session !== sessionID) continue; + const current = pending.get(key); + if ( + progress.admittedVersion !== undefined && + current?.match_offset !== progress.admittedVersion + ) { + retireSubcBatchVersion(session, task, watch, progress.admittedVersion); + } + if (!current) subcLineProgress.delete(key); + } + }, }, }); // A drain keeps its own per-session bookkeeping while it delivers and @@ -990,6 +1145,9 @@ export function createV2RuntimeConsumer( disposed = true; for (const key of [...retries.keys()]) clearRetry(key); pendingMatches.clear(); + eachLineWork.clear(); + lineProgress.clear(); + subcLineProgress.clear(); delivery.dispose(); subcDelivery.dispose(); const { routes } = wakeRouteState(); diff --git a/packages/pi-plugin/src/__tests__/bash.test.ts b/packages/pi-plugin/src/__tests__/bash.test.ts index 7f35c0375c..701325bdae 100644 --- a/packages/pi-plugin/src/__tests__/bash.test.ts +++ b/packages/pi-plugin/src/__tests__/bash.test.ts @@ -1323,7 +1323,7 @@ describe("bash tool adapter", () => { undefined, { cwd: projectRoot }, ), - ).rejects.toThrow("deliver requires background:true"); + ).rejects.toThrow('bash_watch: deliver: "each_line" needs background: true.'); }); test('async bash_watch forwards string once "false" as boolean false', async () => { diff --git a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts index 603483bcc1..1bd4a9325e 100644 --- a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts @@ -1099,7 +1099,11 @@ describe("Pi subc forced-drain dedup (C-#1 / C-#3)", () => { }, ], } - : { success: true, acked_task_ids: ["task-pattern"] }, + : { + success: true, + acked_task_ids: [], + acked_matches: [{ task_id: "task-pattern", watch_id: "watch-1", match_offset: 42 }], + }, ); const { ctx } = harness(send); const sendUserMessage = mock(() => {}); @@ -1118,7 +1122,7 @@ describe("Pi subc forced-drain dedup (C-#1 / C-#3)", () => { expect(sendUserMessage.mock.calls[0][0]).toContain('task task-pattern matched "READY"'); expect(send.mock.calls.find((call) => call[0] === "bash_ack_completions")?.[1]).toEqual({ session_id: "s1", - task_ids: ["task-pattern"], + matches: [{ task_id: "task-pattern", watch_id: "watch-1", match_offset: 42 }], }); }); @@ -1443,7 +1447,8 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { watch_id: "watch-lines", match_text: "matching lines", match_offset: 0, - context: "stdout byte 0 (x2): ERROR a\n3 matching lines dropped; Read bash_status offset=16.", + context: + 'stdout byte 0 ×2: ERROR a\n3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", output_offset: 16 }).', once: false, batch: { lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 2 }], @@ -1455,9 +1460,9 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { }, ]); expect(reminder).toContain("task bash-lines matching lines:"); - expect(reminder).toContain("stdout byte 0 (x2): ERROR a"); + expect(reminder).toContain("stdout byte 0 ×2: ERROR a"); expect(reminder).toContain( - '3 matching lines dropped. Read bash_status({ task_id: "bash-lines", output_offset: 16 }).', + '3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", output_offset: 16 }).', ); }); @@ -1502,3 +1507,58 @@ test("each_line queue replaces snapshots without discarding another watch", asyn expect(pending).toHaveLength(2); expect(pending?.find((entry) => entry.watch_id === "watch-a")?.batch?.dropped_lines).toBe(3); }); + +test("exact pattern ack retries keep sibling watches and newer versions deliverable", async () => { + const old = { + task_id: "task-pattern", + session_id: "s1", + watch_id: "watch-one", + match_offset: 40, + match_text: "READY", + context: "READY old", + once: false, + reason: "pattern_match", + }; + let pending = [old]; + let confirm = false; + const calls: Array<{ command: string; params: Record }> = []; + const { ctx } = harness((command, params) => { + calls.push({ command, params }); + if (command === "bash_drain_completions") + return { success: true, bg_completions: [], pending_matches: pending }; + return { success: true, acked_matches: confirm ? (params.matches ?? []) : [] }; + }); + const context = { ctx, directory: "/tmp/project", sessionID: "s1" }; + const deliver = async () => { + const content = await appendToolResultBgCompletions(context, [{ type: "text", text: "tool" }]); + return ( + content + ?.filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n") ?? "" + ); + }; + expect(await deliver()).toContain("READY old"); + expect(await deliver()).not.toContain("READY old"); + pending = [ + { ...old, watch_id: "watch-two", context: "READY sibling" }, + { ...old, match_offset: 41, context: "READY newer" }, + ]; + confirm = true; + const next = await deliver(); + expect(next).toContain("READY sibling"); + expect(next).toContain("READY newer"); + expect(next).not.toContain("READY old"); + const acks = calls.filter((call) => call.command === "bash_ack_completions"); + expect( + acks.some( + (call) => + JSON.stringify(call.params.matches) === + JSON.stringify([{ task_id: "task-pattern", watch_id: "watch-one", match_offset: 40 }]), + ), + ).toBe(true); + expect( + acks.some((call) => Array.isArray(call.params.matches) && call.params.matches.length === 2), + ).toBe(true); + for (const call of acks) expect(call.params.task_ids ?? []).not.toContain("task-pattern"); +}); diff --git a/packages/pi-plugin/src/bg-notifications.ts b/packages/pi-plugin/src/bg-notifications.ts index 6c5701bb7d..23726d29a9 100644 --- a/packages/pi-plugin/src/bg-notifications.ts +++ b/packages/pi-plugin/src/bg-notifications.ts @@ -1,7 +1,14 @@ import type { BashWatchLineBatch } from "@cortexkit/aft-bridge"; import { type AftProjectTransport, + type BashAckTarget, type BgNudgeRef, + bashAckKey, + bashAckRequest, + bashAcksConfirmed, + bashAckTargetFromKey, + bashAckTargets, + bashPatternAckTarget, formatWatchLineBatch, mergeWatchLineBatches, resolveBridgeForNudge, @@ -103,14 +110,14 @@ type SessionBgState = { consumedTaskIds: Set; consumedTaskOrder: string[]; /** - * Task IDs whose delivery is IN FLIGHT (removed from pendingCompletions / + * Task IDs and exact watch-version keys whose delivery is IN FLIGHT (removed from pendingCompletions / * pattern queues, delivery not yet resolved). Ingest skips these so a subc * forced drain in the removal→delivery window can't re-accept and double-deliver * an already-departing completion (audit C-#1). Mirrors the OpenCode twin. */ deliveringTaskIds: Set; /** - * Task IDs DELIVERED to the agent but whose `bash_ack_completions` has not yet + * Acknowledgement target keys DELIVERED to the agent but whose `bash_ack_completions` has not yet * confirmed (Rust still holds them and, over subc, re-nudges). Ingest skips them * for fresh delivery AND a forced drain RE-ACKs them (the self-terminating close * of the re-nudge loop, C-#3). Removed by DAEMON RECONCILIATION, not a timer: @@ -246,9 +253,7 @@ export async function markBgCompletionDelivered( // that set is also used pre-delivery by markTaskWaiting.) const state = stateFor(drainContext.sessionID); markDeliveredAwaitingAck(state, [taskId]); - const acked = await ackCompletions(drainContext, [ - { task_id: taskId, status: "unknown", exit_code: null, command: "" }, - ]); + const acked = await ackCompletions(drainContext, [{ task_id: taskId }]); if (acked) confirmAcked(state, [taskId]); } @@ -340,7 +345,10 @@ function queuePendingPatternMatch(state: SessionBgState, entry: PatternMatchEntr const existingIdx = state.pendingPatternMatches.findIndex( (match) => match.task_id === normalized.task_id && - (normalized.batch || match.batch ? match.watch_id === normalized.watch_id : true), + (match.watch_id === normalized.watch_id || + (!normalized.batch && + !match.batch && + (normalized.reason === "task_exit" || match.reason === "task_exit"))), ); if (existingIdx >= 0) { const existing = state.pendingPatternMatches[existingIdx]; @@ -389,6 +397,7 @@ export async function handlePushedPatternMatch( return; } const state = stateFor(drainContext.sessionID); + if (isDeliveringOrAwaitingAck(state, frame)) return; queuePendingPatternMatch(state, { ...frame, ackCompletionOnDelivery: true }); await triggerWakeIfPending(drainContext, true); } @@ -511,7 +520,7 @@ export async function appendToolResultBgCompletions( // C-#1 site 4: tool-result piggyback delivery (the reminder is returned below // and cannot fail here), so mark ack-target ids awaiting-ack, then confirm/keep // on the ack result (keep → re-ack on a later forced drain, C-#3). - const inTurnAckIds = completionAcks.map((completion) => completion.task_id); + const inTurnAckIds = completionAcks.map(bashAckKey); markDeliveredAwaitingAck(state, inTurnAckIds); const inTurnAcked = await ackCompletions(drainContext, completionAcks); if (inTurnAcked) confirmAcked(state, inTurnAckIds); @@ -728,6 +737,7 @@ async function triggerWakeIfPending( return; } const ackTargetIds = deliveredCompletions.map((completion) => completion.task_id); + const ackKeys = deliveredCompletions.map(bashAckKey); const watchIds = deliveredPatternMatches?.map((m) => m.watch_id) ?? []; const watchId = watchIds.join(",") || undefined; logPerTaskDeliveryHop(drainContext, "inject-start", "session inject start", ackTargetIds, { @@ -760,9 +770,9 @@ async function triggerWakeIfPending( }); // Session injection completed synchronously. Only now may these task IDs // enter awaiting-ack state and become eligible for daemon acknowledgement. - markDeliveredAwaitingAck(state, ackTargetIds); + markDeliveredAwaitingAck(state, ackKeys); const acked = await ackCompletions(drainContext, deliveredCompletions); - if (acked) confirmAcked(state, ackTargetIds); + if (acked) confirmAcked(state, ackKeys); }, (err, hardStopped) => { if (isTerminalNudgeError(err)) { @@ -955,19 +965,15 @@ async function drainCompletions(drainContext: DrainContext): Promise { // nudges, self-terminating); one it no longer returns was dropped daemon-side // → reconcile forgets it. Ingest skips awaiting-ack tasks, so re-ack never // double-delivers. - const reackTaskIds = reconcileAwaitingAck(state, drainedTaskIds); + const drainedAckKeys = [ + ...drainedCompletions.map(bashAckKey), + ...drainedMatches.map((match) => bashAckKey(bashPatternAckTarget(match))), + ]; + const reackTaskIds = reconcileAwaitingAck(state, drainedAckKeys); ingestDrainedBgCompletions(drainContext.sessionID, drainedCompletions); ingestDrainedPatternMatches(drainContext.sessionID, drainedMatches); if (reackTaskIds.length > 0) { - const reacked = await ackCompletions( - drainContext, - reackTaskIds.map((task_id) => ({ - task_id, - status: "unknown", - exit_code: null, - command: "", - })), - ); + const reacked = await ackCompletions(drainContext, reackTaskIds.map(bashAckTargetFromKey)); if (reacked) confirmAcked(state, reackTaskIds); } } catch (err) { @@ -989,20 +995,21 @@ async function drainCompletions(drainContext: DrainContext): Promise { async function ackCompletions( drainContext: DrainContext, - completions: readonly BgCompletion[], + completions: readonly BashAckTarget[], ): Promise { const taskIds = [...new Set(completions.map((completion) => completion.task_id))]; - if (taskIds.length === 0) return true; + if (completions.length === 0) return true; + const targets = bashAckRequest(completions); try { const bridge = bridgeForDrain(drainContext); const params = drainContext.sessionID - ? { session_id: drainContext.sessionID, task_ids: taskIds } - : { task_ids: taskIds }; + ? { session_id: drainContext.sessionID, ...targets } + : targets; const response = await withBgHopTimeout( bridge.send("bash_ack_completions", params, { timeoutMs: bgHopTimeoutMs }), "bash_ack_completions", ); - if (response.success === false) { + if (!bashAcksConfirmed(response, completions)) { logPerTaskDeliveryHop( drainContext, "ack-error", @@ -1082,7 +1089,7 @@ function scheduleWake( state: SessionBgState, sendWake: ( reminder: string, - completions: readonly BgCompletion[], + completions: readonly BashAckTarget[], patternMatches?: readonly PatternMatchEntry[], ) => Promise, onSendFailure: (err: unknown, hardStopped: boolean) => void, @@ -1148,7 +1155,7 @@ function scheduleWake( // C-#1 site 1: mark ALL ack-target ids (completions + ack-on-delivery pattern // matches) as delivery-in-flight at the same sync tick they leave the queues, // before the sendWake await, so ingest skips them during delivery. - const ackTargetIds = completionAcks.map((completion) => completion.task_id); + const ackTargetIds = completionAcks.map(bashAckKey); markDelivering(state, ackTargetIds); void sendWake(reminder, completionAcks, pendingPatternMatches) .then(() => { @@ -1232,7 +1239,7 @@ function ingestDrainedPatternMatches( const state = stateFor(sessionID); for (const match of matches) { state.outstandingTaskIds.delete(match.task_id); - if (isDeliveringOrAwaitingAck(state, match.task_id)) continue; + if (isDeliveringOrAwaitingAck(state, match)) continue; queuePendingPatternMatch(state, { ...match, ackCompletionOnDelivery: true }); } } @@ -1337,24 +1344,22 @@ function completionToExitPattern( function completionAcksForDelivery( completions: readonly BgCompletion[], patternMatches: readonly PatternMatchEntry[], -): BgCompletion[] { - const acks = [...completions]; - const ackedTaskIds = new Set(acks.map((completion) => completion.task_id)); - for (const match of patternMatches) { - if (!match.ackCompletionOnDelivery || ackedTaskIds.has(match.task_id)) continue; - acks.push({ task_id: match.task_id, status: "unknown", exit_code: null, command: "" }); - ackedTaskIds.add(match.task_id); - } - return acks; +): BashAckTarget[] { + return bashAckTargets(completions, patternMatches); } -/** - * True if `taskId` has a delivery in flight or delivered-awaiting-ack — it has - * already left a deliverable queue and will be acked, so a fresh ingest (push or - * forced drain) must NOT re-accept it (audit C-#1 double-deliver guard). - */ -function isDeliveringOrAwaitingAck(state: SessionBgState, taskId: string): boolean { - return state.deliveringTaskIds.has(taskId) || state.deliveredAwaitingAckTaskIds.has(taskId); +/** Suppress an exact watch version, or any notice covered by a task-level acknowledgement. */ +function isDeliveringOrAwaitingAck( + state: SessionBgState, + target: string | PatternMatchEntry, +): boolean { + const keys = + typeof target === "string" + ? [target] + : [target.task_id, bashAckKey(bashPatternAckTarget(target))]; + return keys.some( + (key) => state.deliveringTaskIds.has(key) || state.deliveredAwaitingAckTaskIds.has(key), + ); } /** Mark ack-target task ids as delivery-in-flight (at queue-removal, before await). */ diff --git a/packages/pi-plugin/src/tools/bash.ts b/packages/pi-plugin/src/tools/bash.ts index 40e029a5f4..ba56afcb3d 100644 --- a/packages/pi-plugin/src/tools/bash.ts +++ b/packages/pi-plugin/src/tools/bash.ts @@ -29,6 +29,7 @@ import { TERMINAL_SCAN_INCOMPLETE_TEXT, taskKillDeadlineText, taskKillDeadlineWithinHandoffMargin, + WATCH_EACH_LINE_NEEDS_BACKGROUND, WATCH_SYNC_DEFAULTS_DESCRIPTION, WATCH_TIMEOUT_PARAM_DESCRIPTION, WATCH_UNAVAILABLE_GIVE_UP_MS, @@ -1217,9 +1218,7 @@ export function createBashWatchTool(ctx: PluginContext) { !bashCfg.subagent_background && isPiWorkerSession(extCtx); if (params.deliver !== undefined && (!coerceBoolean(params.background) || workerForcedSync)) { - throw new Error( - "invalid_request: bash_watch deliver requires background:true; this watch waits synchronously", - ); + throw new Error(WATCH_EACH_LINE_NEEDS_BACKGROUND); } if (params.deliver !== undefined && params.deliver !== "each_line") { throw new Error("invalid_request: bash_watch deliver must be each_line"); From 5c0d9f0171b96ac64749f6ad01cf200352a0c460 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:31:15 +0100 Subject: [PATCH 03/15] release notes and search-quality descriptor for bash-watch-each-line Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../slice-descriptors/land-bash-watch-each-line.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-bash-watch-each-line.json diff --git a/benchmarks/aft-search/slice-descriptors/land-bash-watch-each-line.json b/benchmarks/aft-search/slice-descriptors/land-bash-watch-each-line.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-bash-watch-each-line.json @@ -0,0 +1,8 @@ +{ + "targeted_mechanism": "none", + "kind": "harness", + "slice_class": "non_ranking", + "fixtures": [ + "harness-goldens" + ] +} From 3246353fde9d6541b96f79a7f38784f731f4c8aa Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:27:40 +0100 Subject: [PATCH 04/15] mason: fix bash_watch each_line review findings (#751) A once watch now stays armed through an incomplete-scan warning and only a proven match consumes it. A matching line too long to check for secrets is withheld and counted unavailable instead of shown as a secret placeholder. Masking chunks align to UTF-8 boundaries so a character split across chunks renders once, and an unavailable line counts once per source line. Plugins no longer repeat admitted rows or counters after a failed acknowledgement. The tool-provider catalog goldens are regenerated for the deliver argument. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../src/bash_background/completion_preview.rs | 75 +- crates/aft/src/bash_background/registry.rs | 355 ++++++++- crates/aft/src/bash_background/watch_lines.rs | 45 ++ crates/aft/src/bash_background/watches.rs | 9 +- crates/aft/src/subc_format.rs | 14 +- .../tests/fixtures/tool_provider_catalog.json | 725 +++++++----------- .../tool_provider_catalog_presets.json | 258 +++---- .../src/__tests__/bash-watch-lines.test.ts | 42 +- .../src/__tests__/bash-watch-progress.test.ts | 48 +- packages/aft-bridge/src/bash-watch-lines.ts | 16 +- .../aft-bridge/src/bash-watch-progress.ts | 64 ++ packages/aft-bridge/src/bridge.ts | 10 +- packages/aft-bridge/src/index.ts | 6 +- .../src/__tests__/bg-notifications.test.ts | 88 +++ .../opencode-plugin/src/bg-notifications.ts | 21 +- .../src/wakes/runtime-consumer.ts | 4 +- .../src/__tests__/bg-notifications.test.ts | 96 ++- packages/pi-plugin/src/bg-notifications.ts | 19 +- 18 files changed, 1234 insertions(+), 661 deletions(-) diff --git a/crates/aft/src/bash_background/completion_preview.rs b/crates/aft/src/bash_background/completion_preview.rs index dadde12b46..659dc23de2 100644 --- a/crates/aft/src/bash_background/completion_preview.rs +++ b/crates/aft/src/bash_background/completion_preview.rs @@ -12,7 +12,29 @@ pub(super) const TRUNCATED_PREVIEW_WITHHELD: &str = const WATCH_SECRET_HALO_BYTES: usize = 1024; /// A watch preview is masked over whole lines (see `watch_mask_region`). When /// those lines exceed this many bytes, the preview is withheld instead. -pub(super) const WATCH_PREVIEW_LINE_CAP_BYTES: usize = 8 * 1024; +pub(super) const WATCH_PREVIEW_LINE_CAP_BYTES: usize = + super::super::watch_lines::MASKING_LINE_CAP_BYTES; + +#[derive(Clone, Copy, Debug)] +pub(super) struct UnavailablePreview { + pub line_limit: bool, + pub reason: &'static str, +} + +impl UnavailablePreview { + pub(super) fn context(reason: &'static str) -> Self { + Self { + line_limit: false, + reason, + } + } + pub(super) fn line_limit() -> Self { + Self { + line_limit: true, + reason: TRUNCATED_PREVIEW_WITHHELD, + } + } +} /// Bytes read beyond the largest maskable region on each side, so a JSON field /// or private-key block that crosses a line break into the region can be seen /// whole and the region widened to include it. @@ -101,6 +123,29 @@ impl BgTaskRegistry { starts_capture: bool, ends_capture: bool, ) -> (String, String) { + self.render_watch_preview_window_checked( + task_id, + bytes, + range, + starts_capture, + ends_capture, + ) + .unwrap_or_else(|unavailable| { + ( + crate::log_redact::SECRET_PLACEHOLDER.to_string(), + unavailable.reason.to_string(), + ) + }) + } + + pub(super) fn render_watch_preview_window_checked( + &self, + task_id: &str, + bytes: &[u8], + range: std::ops::Range, + starts_capture: bool, + ends_capture: bool, + ) -> Result<(String, String), UnavailablePreview> { let text = String::from_utf8_lossy(bytes); // A scan window can end inside a character that is intact in the full // capture. Decode the expanded source neighborhood rather than separate @@ -120,17 +165,21 @@ impl BgTaskRegistry { .unwrap_or_else(std::sync::PoisonError::into_inner) }); let Some(secrets) = guard.as_deref().and_then(Option::as_ref) else { - return ( - crate::log_redact::SECRET_PLACEHOLDER.to_string(), - RESTART_PREVIEW_WITHHELD.to_string(), - ); + return Err(UnavailablePreview::context(RESTART_PREVIEW_WITHHELD)); }; if secrets.longest_value_len() > WATCH_SECRET_HALO_BYTES { - return withheld_watch_preview(); + return Err(UnavailablePreview::context(TRUNCATED_PREVIEW_WITHHELD)); + } + // A range can fit the byte window while its complete line exceeds the + // audited masking bound. Report that separately from a detected secret. + if whole_lines(&text, range.clone(), starts_capture, ends_capture) + .is_none_or(|line| line.len() > WATCH_PREVIEW_LINE_CAP_BYTES) + { + return Err(UnavailablePreview::line_limit()); } let Some(region) = watch_mask_region(&text, &range, secrets, starts_capture, ends_capture) else { - return withheld_watch_preview(); + return Err(UnavailablePreview::context(TRUNCATED_PREVIEW_WITHHELD)); }; let mut range = range.start - region.start..range.end - region.start; let text = &text[region]; @@ -163,19 +212,16 @@ impl BgTaskRegistry { &mut range, ticket_digest.as_deref(), ) else { - return withheld_watch_preview(); + return Err(UnavailablePreview::context(TRUNCATED_PREVIEW_WITHHELD)); }; align_watch_range(&masked, &mut range); let Some(matched) = masked.get(range.clone()) else { - return ( - crate::log_redact::SECRET_PLACEHOLDER.to_string(), - TRUNCATED_PREVIEW_WITHHELD.to_string(), - ); + return Err(UnavailablePreview::context(TRUNCATED_PREVIEW_WITHHELD)); }; - ( + Ok(( matched.to_string(), super::super::watches::context_snippet(&masked, range.start, range.end), - ) + )) } pub(super) fn completion_preview_for_notice( @@ -491,6 +537,7 @@ fn whole_lines( Some(start..end.max(start)) } +#[cfg(test)] fn withheld_watch_preview() -> (String, String) { ( crate::log_redact::SECRET_PLACEHOLDER.to_string(), diff --git a/crates/aft/src/bash_background/registry.rs b/crates/aft/src/bash_background/registry.rs index 208069ff52..6988414b03 100644 --- a/crates/aft/src/bash_background/registry.rs +++ b/crates/aft/src/bash_background/registry.rs @@ -3830,7 +3830,7 @@ impl BgTaskRegistry { match_text, row.match_offset.unwrap_or_default().max(0) as u64, context, - row.once, + row.once && !row.scanning, ) }; frame.batch = row @@ -4276,7 +4276,7 @@ impl BgTaskRegistry { { return Ok(None); } - let removed = row.once || terminal; + let removed = (row.once && !row.scanning) || terminal; if removed { crate::db::bash_watches::delete_bash_pattern_watch( &conn, &harness, session_id, task_id, watch_id, @@ -4339,7 +4339,7 @@ impl BgTaskRegistry { return; }; for mut row in rows { - if row.once && (!row.scanning || row.pending_match) { + if row.once && !row.scanning { let _ = crate::db::bash_watches::delete_bash_pattern_watch( &conn, &harness, @@ -7880,24 +7880,111 @@ impl BgTaskRegistry { ) -> String { const MASK_CHUNK_BYTES: usize = 4 * 1024; let mut text = String::new(); + let mut unavailable_lines = 0_u64; + let mut last_unavailable_line = None; + let mut context_notice = false; + let mut append = + |start: usize, rendered: Result| { + match rendered { + Ok(part) => text.push_str(&part), + Err(unavailable) if unavailable.line_limit => { + let line = bytes[..start] + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |index| index + 1); + if last_unavailable_line != Some(line) { + unavailable_lines = unavailable_lines.saturating_add(1); + last_unavailable_line = Some(line); + } + } + Err(unavailable) if !context_notice => { + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(unavailable.reason); + text.push('\n'); + context_notice = true; + } + Err(_) => {} + } + }; let mut start = 0; while start < bytes.len() { let limit = (start + MASK_CHUNK_BYTES).min(bytes.len()); - let end = bytes[start..limit] + let mut end = bytes[start..limit] .iter() .rposition(|byte| *byte == b'\n') .map(|index| start + index + 1) .unwrap_or(limit); - // Keep each credential-mask region below the preview cap, while - // every subrange still reads its full surrounding secret halo. - text.push_str(&self.masked_watch_range( + if end < bytes.len() { + let mut boundary = end; + for _ in 0..3 { + if boundary > start && bytes[boundary] & 0xc0 == 0x80 { + boundary -= 1; + } else { + break; + } + } + let width = bytes[boundary].leading_ones() as usize; + if (2..=4).contains(&width) + && boundary < end + && boundary + width <= bytes.len() + && end < boundary + width + && std::str::from_utf8(&bytes[boundary..boundary + width]).is_ok() + { + end = boundary; + } + } + // Source boundaries must not bisect a character: the mask renderer + // deliberately widens ranges, which would otherwise repeat it. + let rendered = self.masked_watch_range_checked( task_id, artifact, from + start as u64, from + end as u64, - )); + ); + let split = bytes[start..end] + .iter() + .position(|byte| *byte == b'\n') + .map(|index| start + index + 1); + if matches!(&rendered, Err(unavailable) if unavailable.line_limit) + && split.is_some_and(|split| split < end) + { + let split = split.unwrap(); + // A chunk may include the tail of an over-limit line and short + // complete lines. Preserve the short lines instead of discarding + // them with the unavailable tail; each read retains its halo. + append( + start, + self.masked_watch_range_checked( + task_id, + artifact, + from + start as u64, + from + split as u64, + ), + ); + append( + split, + self.masked_watch_range_checked( + task_id, + artifact, + from + split as u64, + from + end as u64, + ), + ); + } else { + append(start, rendered); + } start = end; } + if unavailable_lines > 0 { + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&super::watch_lines::unavailable_page_text( + unavailable_lines, + )); + } text } @@ -7908,6 +7995,20 @@ impl BgTaskRegistry { from: u64, to: u64, ) -> String { + self.masked_watch_range_checked(task_id, artifact, from, to) + .unwrap_or_else(|unavailable| unavailable.reason.to_string()) + } + + fn masked_watch_range_checked( + &self, + task_id: &str, + artifact: TaskArtifact, + from: u64, + to: u64, + ) -> Result { + if to.saturating_sub(from) > completion_preview::WATCH_PREVIEW_LINE_CAP_BYTES as u64 { + return Err(completion_preview::UnavailablePreview::line_limit()); + } let rendered = self.task(task_id).and_then(|task| { let mut file = open_task_artifact(&task.paths, artifact).ok()?; let len = file.len().ok()?; @@ -7925,17 +8026,21 @@ impl BgTaskRegistry { return None; } Some( - self.render_watch_preview_window( + self.render_watch_preview_window_checked( task_id, &bytes, from as usize - source.start..to as usize - source.start, source.start == 0, source.end == len as usize, ) - .0, + .map(|preview| preview.0), ) }); - rendered.unwrap_or_else(|| crate::log_redact::SECRET_PLACEHOLDER.to_string()) + rendered.unwrap_or_else(|| { + Err(completion_preview::UnavailablePreview::context( + completion_preview::TRUNCATED_PREVIEW_WITHHELD, + )) + }) } fn take_watch_line_batches(&self, task_id: &str, terminal: bool) -> Vec { @@ -7955,10 +8060,22 @@ impl BgTaskRegistry { }; // Render only retained rows, after releasing the scanner lock. The // preview reader supplies the secret halo before redacting a line. - line.text = self - .masked_watch_range(task_id, artifact, line.offset, line.end_offset) - .trim_end_matches('\n') - .to_string(); + match self.masked_watch_range_checked( + task_id, + artifact, + line.offset, + line.end_offset, + ) { + Ok(text) => line.text = text.trim_end_matches('\n').to_string(), + Err(unavailable) if unavailable.line_limit => { + line.unavailable = true; + let limit = completion_preview::WATCH_PREVIEW_LINE_CAP_BYTES; + batch.masking_limit_bytes = Some(limit as u64); + line.text = + super::watch_lines::unavailable_match_text(line.count, limit); + } + Err(unavailable) => line.text = unavailable.reason.to_string(), + } } matched.context = crate::subc_format::format_watch_line_batch(task_id, batch); } @@ -10389,7 +10506,16 @@ impl BgTaskRegistry { ) else { return; }; - let has_pending_match = rows.iter().any(|row| row.pending_match); + let has_pending_match = rows.iter().any(|row| { + row.pending_match + && row + .match_batch + .as_deref() + .and_then(|value| { + serde_json::from_str::(value).ok() + }) + .is_none_or(|batch| batch.has_matches()) + }); let remaining: HashSet = rows.into_iter().map(|row| row.watch_id).collect(); if let Ok(mut registry) = self.inner.watch_registry.lock() { registry.reconcile_watch_ids(task_id, &remaining, has_pending_match); @@ -11993,7 +12119,7 @@ impl BgTaskRegistry { match_text, match_offset: match_offset.max(0) as u64, context, - once: row.once, + once: row.once && !row.scanning, batch: row .match_batch .as_deref() @@ -26835,6 +26961,18 @@ mod tests { Arc, Arc>>, String, + ) { + each_line_running_fixture_with_once(storage, false) + } + + fn each_line_running_fixture_with_once( + storage: &Path, + once: bool, + ) -> ( + BgTaskRegistry, + Arc, + Arc>>, + String, ) { let (registry, _, frames) = registry_with_db_and_frames(storage); let task_id = registry @@ -26861,7 +26999,7 @@ mod tests { .register_watch_delivery( task_id.clone(), WatchPattern::Substring("READY".into()), - false, + once, true, ) .unwrap(); @@ -26878,6 +27016,186 @@ mod tests { .unwrap(); } + #[test] + fn each_line_once_warning_stays_armed_through_ack_and_restart() { + for restart in [false, true] { + for acknowledge_warning in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, frames, watch) = + each_line_running_fixture_with_once(dir.path(), true); + let _reap = KillTasksOnDrop(registry.clone()); + let mut noise = vec![b'x'; super::super::watch_lines::MAX_LINE_BYTES + 1]; + noise.push(b'\n'); + append_each_line_output(&task.paths.stdout, &noise); + registry.scan_task_watch_output(&task); + let warning = pattern_match_frames(&frames).pop().unwrap(); + assert_eq!( + registry.active_watch_count(&task.task_id), + 1, + "a warning is not a proven pattern match" + ); + assert!(!warning.once); + assert_eq!(warning.batch.as_ref().unwrap().skipped_long_lines, 1); + assert!(warning.context.contains("the scan is incomplete")); + let row = || { + let (_, pool) = registry.db_harness_and_pool_for_session("session").unwrap(); + let conn = pool.lock().unwrap(); + crate::db::bash_watches::get_bash_pattern_watch( + &conn, + "opencode", + "session", + &task.task_id, + &watch, + ) + .unwrap() + .unwrap() + }; + assert!(row().scanning); + if acknowledge_warning { + let ack = registry + .ack_pattern_match_for_session( + "session", + &task.task_id, + &watch, + warning.match_offset, + ) + .unwrap() + .unwrap(); + assert!(!ack.watch_removed); + assert!(row().scanning); + } + let (live, live_frames) = if restart { + registry.clear_task_watch_state(&task.task_id); + let (replayed, _, replay_frames) = registry_with_db_and_frames(dir.path()); + replayed + .replay_session_inner(dir.path(), "session", None) + .unwrap(); + replayed + .inner + .shutdown + .store(true, std::sync::atomic::Ordering::SeqCst); + assert_eq!(replayed.active_watch_count(&task.task_id), 1); + for frame in pattern_match_frames(&replay_frames) { + assert!(!frame.once); + } + (replayed, replay_frames) + } else { + (registry.clone(), frames.clone()) + }; + let _reap_live = KillTasksOnDrop(live.clone()); + append_each_line_output(&task.paths.stdout, b"READY after warning\n"); + let live_task = live.task_for_session(&task.task_id, "session").unwrap(); + live.scan_task_watch_output(&live_task); + let matched = pattern_match_frames(&live_frames).pop().unwrap(); + assert_eq!( + matched.batch.as_ref().unwrap().lines[0].text, + "READY after warning" + ); + assert!(matched.once); + assert_eq!(live.active_watch_count(&task.task_id), 0); + let (_, pool) = live.db_harness_and_pool_for_session("session").unwrap(); + let conn = pool.lock().unwrap(); + assert!( + !crate::db::bash_watches::get_bash_pattern_watch( + &conn, + "opencode", + "session", + &task.task_id, + &watch + ) + .unwrap() + .unwrap() + .scanning + ); + } + } + } + + #[test] + fn each_line_over_mask_limit_is_counted_unavailable_without_false_secret_text() { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, frames, _) = each_line_running_fixture(dir.path()); + let _reap = KillTasksOnDrop(registry.clone()); + let line = format!( + "{{\"status\":\"READY\",\"payload\":\"{}\"}}\n", + "x".repeat(10 * 1024) + ); + append_each_line_output(&task.paths.stdout, line.as_bytes()); + registry.scan_task_watch_output(&task); + let first = pattern_match_frames(&frames).pop().unwrap(); + assert!( + !first + .context + .contains(crate::log_redact::SECRET_PLACEHOLDER), + "nonsecret text must not be represented as a secret" + ); + let batch = serde_json::to_value(first.batch.as_ref().unwrap()).unwrap(); + assert_eq!(batch["lines"][0]["unavailable"], true); + assert_eq!(batch["lines"][0]["offset"], 0); + assert_eq!(batch["lines"][0]["stream"], "stdout"); + assert_eq!(first.context, "stdout byte 0: 1 matching line over 8 KiB was not shown; AFT can check lines only up to 8 KiB for secrets."); + assert_eq!(registry.masked_status_page(&task.task_id, TaskArtifact::Stdout, 0, line.as_bytes()), + "1 line in this page over 8 KiB was not shown; AFT can check lines only up to 8 KiB for secrets."); + append_each_line_output(&task.paths.stdout, line.as_bytes()); + let both = line.repeat(2); + assert_eq!(registry.masked_status_page(&task.task_id, TaskArtifact::Stdout, 0, both.as_bytes()), + "2 lines in this page over 8 KiB were not shown; AFT can check lines only up to 8 KiB for secrets."); + } + + #[test] + fn masked_status_page_utf8_internal_boundary_renders_character_once() { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, _, _) = each_line_running_fixture(dir.path()); + let _reap = KillTasksOnDrop(registry.clone()); + let line = format!("{}€{}\n", "x".repeat(4095), "y".repeat(1024)); + append_each_line_output(&task.paths.stdout, line.as_bytes()); + let page = + registry.masked_status_page(&task.task_id, TaskArtifact::Stdout, 0, line.as_bytes()); + assert_eq!( + page.matches('€').count(), + 1, + "an internal byte split must not duplicate a character" + ); + assert_eq!(page, line); + } + + #[test] + fn masked_status_page_unavailable_line_is_counted_once_and_neighbors_survive() { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, _, _) = each_line_running_fixture(dir.path()); + let _reap = KillTasksOnDrop(registry.clone()); + let input = format!( + "plain before\n{{\"payload\":\"{}\"}}\nplain after\n", + "x".repeat(10 * 1024) + ); + append_each_line_output(&task.paths.stdout, input.as_bytes()); + assert_eq!(registry.masked_status_page(&task.task_id, TaskArtifact::Stdout, 0, input.as_bytes()), + "plain before\nplain after\n1 line in this page over 8 KiB was not shown; AFT can check lines only up to 8 KiB for secrets."); + } + + #[test] + fn masked_status_page_keeps_real_secret_redaction_across_chunk_edges() { + let dir = tempfile::tempdir().unwrap(); + let (registry, task, _, _) = each_line_running_fixture(dir.path()); + let _reap = KillTasksOnDrop(registry.clone()); + let secret = "actual-secret-crossing-the-internal-boundary"; + let fixed = "{\"padding\":\"\",\"password\":\"".len(); + let input = format!( + "{{\"padding\":\"{}\",\"password\":\"{secret}\",\"tail\":\"{}\"}}\n", + "x".repeat(4090 - fixed), + "y".repeat(1000) + ); + let start = input.find(secret).unwrap(); + assert!(start < 4096 && start + secret.len() > 4096); + append_each_line_output(&task.paths.stdout, input.as_bytes()); + let page = + registry.masked_status_page(&task.task_id, TaskArtifact::Stdout, 0, input.as_bytes()); + assert!(!page.contains(secret)); + assert!(!page.contains("actual-secret")); + assert!(!page.contains("internal-boundary")); + assert!(page.contains(crate::log_redact::SECRET_PLACEHOLDER)); + } + #[test] fn each_line_stream_offsets_do_not_collide_as_ack_versions() { let dir = tempfile::tempdir().unwrap(); @@ -27047,6 +27365,7 @@ mod tests { end_offset: 8, count: 1, text: "ERROR a".into(), + unavailable: false, }], ..Default::default() }; diff --git a/crates/aft/src/bash_background/watch_lines.rs b/crates/aft/src/bash_background/watch_lines.rs index ac251b2dbc..472d1583e8 100644 --- a/crates/aft/src/bash_background/watch_lines.rs +++ b/crates/aft/src/bash_background/watch_lines.rs @@ -11,6 +11,41 @@ pub const MAX_LINES_PER_NOTIFICATION: usize = 50; pub const MAX_NOTIFICATIONS_PER_MINUTE: usize = 12; pub const NOTIFICATION_WINDOW: Duration = Duration::from_secs(60); pub const MAX_LINE_BYTES: usize = 64 * 1024; +pub const MASKING_LINE_CAP_BYTES: usize = 8 * 1024; +pub const UNAVAILABLE_MATCH_SINGULAR: &str = "1 matching line over {limit} KiB was not shown; AFT can check lines only up to {limit} KiB for secrets."; +pub const UNAVAILABLE_MATCH_PLURAL: &str = "{count} matching lines over {limit} KiB were not shown; AFT can check lines only up to {limit} KiB for secrets."; +pub const UNAVAILABLE_PAGE_SINGULAR: &str = "1 line in this page over {limit} KiB was not shown; AFT can check lines only up to {limit} KiB for secrets."; +pub const UNAVAILABLE_PAGE_PLURAL: &str = "{count} lines in this page over {limit} KiB were not shown; AFT can check lines only up to {limit} KiB for secrets."; + +pub fn unavailable_match_text(count: u64, limit: usize) -> String { + unavailable_text( + if count == 1 { + UNAVAILABLE_MATCH_SINGULAR + } else { + UNAVAILABLE_MATCH_PLURAL + }, + count, + limit, + ) +} + +pub(super) fn unavailable_page_text(count: u64) -> String { + unavailable_text( + if count == 1 { + UNAVAILABLE_PAGE_SINGULAR + } else { + UNAVAILABLE_PAGE_PLURAL + }, + count, + MASKING_LINE_CAP_BYTES, + ) +} + +fn unavailable_text(template: &str, count: u64, limit: usize) -> String { + template + .replace("{count}", &count.to_string()) + .replace("{limit}", &limit.div_ceil(1024).to_string()) +} pub const EACH_LINE_NEEDS_BACKGROUND: &str = "bash_watch: deliver: \"each_line\" needs background: true."; @@ -21,10 +56,14 @@ pub struct WatchLine { pub end_offset: u64, pub text: String, pub count: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unavailable: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct LineBatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub masking_limit_bytes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -41,7 +80,12 @@ pub struct LineBatch { } impl LineBatch { + pub(super) fn has_matches(&self) -> bool { + !self.lines.is_empty() || self.dropped_lines > 0 + } + pub fn merge(&mut self, next: LineBatch) { + self.masking_limit_bytes = next.masking_limit_bytes.or(self.masking_limit_bytes); self.terminal_summary = next.terminal_summary; let mut retained = self.lines.iter().map(|line| line.count).sum::(); for mut line in next.lines { @@ -201,6 +245,7 @@ impl LineDelivery { end_offset: end, text: text.into_owned(), count: 1, + unavailable: false, }); } diff --git a/crates/aft/src/bash_background/watches.rs b/crates/aft/src/bash_background/watches.rs index 120b18edd7..5c319bbec3 100644 --- a/crates/aft/src/bash_background/watches.rs +++ b/crates/aft/src/bash_background/watches.rs @@ -132,23 +132,26 @@ impl WatchRegistry { let Some(batch) = delivery.take(now, terminal, &watch.pattern) else { continue; }; + let matched = batch.has_matches(); let offset = batch .lines .first() .map(|line| line.offset) .or(batch.first_dropped_offset) .unwrap_or(0); - self.matched_tasks.insert(task_id.to_string()); + if matched { + self.matched_tasks.insert(task_id.to_string()); + } found.push(PatternMatch { watch_id: watch.watch_id.clone(), task_id: task_id.to_string(), match_text: "matching lines".to_string(), match_offset: offset, context: String::new(), - once: watch.once, + once: watch.once && matched, batch: Some(batch), }); - if watch.once { + if watch.once && matched { self.unregister(task_id, &watch.watch_id); } } diff --git a/crates/aft/src/subc_format.rs b/crates/aft/src/subc_format.rs index ea39d95956..f259a74520 100644 --- a/crates/aft/src/subc_format.rs +++ b/crates/aft/src/subc_format.rs @@ -5456,6 +5456,17 @@ pub fn format_watch_line_batch( ) -> String { let mut text = String::new(); for line in &batch.lines { + let body = if line.unavailable { + crate::bash_background::watch_lines::unavailable_match_text( + line.count, + batch + .masking_limit_bytes + .map(|limit| limit as usize) + .unwrap_or(crate::bash_background::watch_lines::MASKING_LINE_CAP_BYTES), + ) + } else { + line.text.clone() + }; let repeats = if line.count > 1 { format!(" ×{}", line.count) } else { @@ -5463,7 +5474,7 @@ pub fn format_watch_line_batch( }; text.push_str(&format!( "{} byte {}{repeats}: {}\n", - line.stream, line.offset, line.text + line.stream, line.offset, body )); } if batch.dropped_lines > 0 || batch.skipped_long_lines > 0 { @@ -5498,6 +5509,7 @@ mod watch_line_format_tests { offset: 18234, end_offset: 18257, text: "ERROR db timeout".into(), + unavailable: false, count: 1, }], ..Default::default() diff --git a/crates/aft/tests/fixtures/tool_provider_catalog.json b/crates/aft/tests/fixtures/tool_provider_catalog.json index e6deca3c14..2dd70103fe 100644 --- a/crates/aft/tests/fixtures/tool_provider_catalog.json +++ b/crates/aft/tests/fixtures/tool_provider_catalog.json @@ -16,8 +16,8 @@ "version": "0.58.1" }, "reply": { - "generation": "6acd0f55a7e0a3cb5f35b19ba4d844ee8f646cf9ef2dd2f55f1bfceb84811569", - "catalog_digest": "6acd0f55a7e0a3cb5f35b19ba4d844ee8f646cf9ef2dd2f55f1bfceb84811569", + "generation": "728db7e124f8386f259da5d259d05a0488a130eb49079b4e0142bb4684181938", + "catalog_digest": "728db7e124f8386f259da5d259d05a0488a130eb49079b4e0142bb4684181938", "tools": [ { "name": "status", @@ -27,7 +27,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -48,7 +47,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -130,7 +128,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -181,7 +178,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -213,7 +209,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -296,7 +291,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -324,7 +318,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -363,7 +356,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -393,7 +385,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -442,7 +433,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -488,7 +478,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -553,7 +542,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -614,7 +602,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -685,7 +672,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -709,7 +695,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -774,7 +759,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -842,7 +826,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -877,7 +860,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -909,7 +891,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -996,7 +977,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -1040,13 +1020,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1059,6 +1038,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -1082,7 +1073,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1109,7 +1099,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1217,8 +1206,8 @@ "version": "0.58.1" }, "reply": { - "generation": "87374a7245a828f9e1d849c44602031b906a6e945a43eb20ff7824e70e65e46a", - "catalog_digest": "87374a7245a828f9e1d849c44602031b906a6e945a43eb20ff7824e70e65e46a", + "generation": "e1357ffd76e61815219e6c6c97f60c0f6c88078bab01895780652473591051dc", + "catalog_digest": "e1357ffd76e61815219e6c6c97f60c0f6c88078bab01895780652473591051dc", "tools": [ { "name": "status", @@ -1228,7 +1217,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -1249,7 +1237,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1331,7 +1318,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -1382,7 +1368,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1414,7 +1399,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1497,7 +1481,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1525,7 +1508,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1564,7 +1546,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1594,7 +1575,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1643,7 +1623,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1689,7 +1668,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1754,7 +1732,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -1815,7 +1792,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -1886,7 +1862,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -1910,7 +1885,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -1975,7 +1949,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -2043,7 +2016,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2078,7 +2050,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2110,7 +2081,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -2197,7 +2167,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -2241,13 +2210,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2260,6 +2228,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -2283,7 +2263,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2310,7 +2289,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2420,8 +2398,8 @@ "version": "0.58.1" }, "reply": { - "generation": "5542ce3275d21530752c6d5a3a547c00844557f25f6e7a26c9d7f34b87c4a9f6", - "catalog_digest": "5542ce3275d21530752c6d5a3a547c00844557f25f6e7a26c9d7f34b87c4a9f6", + "generation": "9e4aa444af3fd3cb3e749a3d8df9b46f179c7ad99a79a0d0c53623bf99161e8b", + "catalog_digest": "9e4aa444af3fd3cb3e749a3d8df9b46f179c7ad99a79a0d0c53623bf99161e8b", "tools": [ { "name": "status", @@ -2431,7 +2409,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -2453,7 +2430,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -2504,7 +2480,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -2536,7 +2511,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -2619,7 +2593,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -2647,7 +2620,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2686,7 +2658,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2716,7 +2687,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2765,7 +2735,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2811,7 +2780,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2876,7 +2844,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -2937,7 +2904,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -3008,7 +2974,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -3032,7 +2997,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -3097,7 +3061,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -3165,7 +3128,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -3200,7 +3162,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -3232,7 +3193,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -3319,7 +3279,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -3363,13 +3322,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3382,6 +3340,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -3405,7 +3375,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3432,7 +3401,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3541,8 +3509,8 @@ "version": "0.58.1" }, "reply": { - "generation": "a425afdd1b84c4d0b4c7485cc4e4ea887107d38061fee51ddc2a3b82c357eb11", - "catalog_digest": "a425afdd1b84c4d0b4c7485cc4e4ea887107d38061fee51ddc2a3b82c357eb11", + "generation": "91e2fd53f58b07d7c339faf5e78d11ef9b6e85147af7a40ab603b441e1bc02cd", + "catalog_digest": "91e2fd53f58b07d7c339faf5e78d11ef9b6e85147af7a40ab603b441e1bc02cd", "tools": [ { "name": "status", @@ -3552,7 +3520,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -3573,7 +3540,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3655,7 +3621,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -3706,7 +3671,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3738,7 +3702,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3821,7 +3784,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3849,7 +3811,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3888,7 +3849,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3918,7 +3878,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3967,7 +3926,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -4032,7 +3990,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -4093,7 +4050,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -4164,7 +4120,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -4188,7 +4143,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4253,7 +4207,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4321,7 +4274,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4356,7 +4308,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4388,7 +4339,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -4475,7 +4425,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -4519,13 +4468,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4538,6 +4486,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -4561,7 +4521,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4588,7 +4547,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4697,8 +4655,8 @@ "version": "0.58.1" }, "reply": { - "generation": "f6243c9b40734e773acae9fe3cb59386bd1e43edfea3d2bd08ab96925787bc25", - "catalog_digest": "f6243c9b40734e773acae9fe3cb59386bd1e43edfea3d2bd08ab96925787bc25", + "generation": "89e642e6224cb678eb1df833e767bb10a9776928170f395a1698decb3f300d96", + "catalog_digest": "89e642e6224cb678eb1df833e767bb10a9776928170f395a1698decb3f300d96", "tools": [ { "name": "status", @@ -4708,7 +4666,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -4729,7 +4686,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4811,7 +4767,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -4862,7 +4817,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -4894,7 +4848,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -4977,7 +4930,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -5005,7 +4957,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5044,7 +4995,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5074,7 +5024,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5120,7 +5069,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5185,7 +5133,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -5246,7 +5193,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -5317,7 +5263,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -5341,7 +5286,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5406,7 +5350,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5474,7 +5417,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5509,7 +5451,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5541,7 +5482,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -5628,7 +5568,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -5672,13 +5611,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5691,6 +5629,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -5714,7 +5664,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5741,7 +5690,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5851,8 +5799,8 @@ "version": "0.58.1" }, "reply": { - "generation": "406e9a1163332ade930a2cfea82791d1f08bc21465151eb436fb73afed097d15", - "catalog_digest": "406e9a1163332ade930a2cfea82791d1f08bc21465151eb436fb73afed097d15", + "generation": "957b3e0e5af1f0969d51bd3846b7fa089a375ecc8a197d13d617300a21629d5b", + "catalog_digest": "957b3e0e5af1f0969d51bd3846b7fa089a375ecc8a197d13d617300a21629d5b", "tools": [ { "name": "status", @@ -5862,7 +5810,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -5883,7 +5830,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5965,7 +5911,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -6016,7 +5961,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -6048,7 +5992,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -6131,7 +6074,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -6159,7 +6101,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -6198,7 +6139,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -6228,7 +6168,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -6277,7 +6216,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -6343,7 +6281,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -6414,7 +6351,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -6438,7 +6374,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -6503,7 +6438,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -6571,7 +6505,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -6606,7 +6539,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -6638,7 +6570,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -6725,7 +6656,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -6769,13 +6699,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -6788,6 +6717,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -6811,7 +6752,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -6838,7 +6778,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -6947,8 +6886,8 @@ "version": "0.58.1" }, "reply": { - "generation": "9bb8e82f9dd24ddd00fb5d22b7786da7858a8a7f2ed48e0e867d474872b85d48", - "catalog_digest": "9bb8e82f9dd24ddd00fb5d22b7786da7858a8a7f2ed48e0e867d474872b85d48", + "generation": "cf835bfa09517237d104d775202299acacd759a325a687de1949c6b32b24084d", + "catalog_digest": "cf835bfa09517237d104d775202299acacd759a325a687de1949c6b32b24084d", "tools": [ { "name": "status", @@ -6958,7 +6897,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -6979,7 +6917,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -7061,7 +6998,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -7112,7 +7048,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -7144,7 +7079,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -7227,7 +7161,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -7255,7 +7188,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -7294,7 +7226,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -7324,7 +7255,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -7370,7 +7300,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -7436,7 +7365,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -7507,7 +7435,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -7531,7 +7458,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -7596,7 +7522,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -7664,7 +7589,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -7699,7 +7623,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -7731,7 +7654,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -7818,7 +7740,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -7862,13 +7783,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -7881,6 +7801,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -7904,7 +7836,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -7931,7 +7862,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -8040,8 +7970,8 @@ "version": "0.58.1" }, "reply": { - "generation": "394423c042beffc88e0fa1d60d3087300e5a5a975d08ffa4b1620f7737415a7c", - "catalog_digest": "394423c042beffc88e0fa1d60d3087300e5a5a975d08ffa4b1620f7737415a7c", + "generation": "3092326e1aae4eecdcbb5a0a635fdd1a84c6683e5bf07a473dfb0bcc62c16bb9", + "catalog_digest": "3092326e1aae4eecdcbb5a0a635fdd1a84c6683e5bf07a473dfb0bcc62c16bb9", "tools": [ { "name": "status", @@ -8051,7 +7981,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -8072,7 +8001,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -8154,7 +8082,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -8205,7 +8132,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -8237,7 +8163,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -8320,7 +8245,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -8348,7 +8272,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -8387,7 +8310,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -8417,7 +8339,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -8465,7 +8386,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -8526,7 +8446,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -8597,7 +8516,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -8621,7 +8539,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -8686,7 +8603,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -8754,7 +8670,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -8789,7 +8704,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -8821,7 +8735,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -8908,7 +8821,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -8952,13 +8864,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -8971,6 +8882,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -8994,7 +8917,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -9021,7 +8943,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -9130,8 +9051,8 @@ "version": "0.58.1" }, "reply": { - "generation": "63694ae3b86048b3122363f99b4444ad68bc04ba9868eb1f9863000d9abb47ec", - "catalog_digest": "63694ae3b86048b3122363f99b4444ad68bc04ba9868eb1f9863000d9abb47ec", + "generation": "6677ec15e9d8ce24d46ae302b04739e7099f3c30a9249a3a1fce818bb3e27fd4", + "catalog_digest": "6677ec15e9d8ce24d46ae302b04739e7099f3c30a9249a3a1fce818bb3e27fd4", "tools": [ { "name": "status", @@ -9141,7 +9062,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -9162,7 +9082,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -9244,7 +9163,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -9295,7 +9213,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -9327,7 +9244,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -9410,7 +9326,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -9438,7 +9353,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -9477,7 +9391,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -9507,7 +9420,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -9552,7 +9464,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -9613,7 +9524,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -9684,7 +9594,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -9708,7 +9617,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -9773,7 +9681,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -9841,7 +9748,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -9876,7 +9782,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -9908,7 +9813,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -9995,7 +9899,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -10039,13 +9942,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10058,6 +9960,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -10081,7 +9995,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10108,7 +10021,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10214,8 +10126,8 @@ "version": "0.58.1" }, "reply": { - "generation": "2dcdf552ec7c32271dea1c5ad8fd52d907804ad9272f5cf8a3f787f15df26c01", - "catalog_digest": "2dcdf552ec7c32271dea1c5ad8fd52d907804ad9272f5cf8a3f787f15df26c01", + "generation": "2209e57bf674816437255b2fc06e02027d7218fac0305e4370ed7f80f8b36f5a", + "catalog_digest": "2209e57bf674816437255b2fc06e02027d7218fac0305e4370ed7f80f8b36f5a", "tools": [ { "name": "status", @@ -10225,7 +10137,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -10246,7 +10157,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10323,7 +10233,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10402,7 +10311,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -10453,7 +10361,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -10485,7 +10392,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -10568,7 +10474,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -10596,7 +10501,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -10635,7 +10539,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -10665,7 +10568,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -10714,7 +10616,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -10760,7 +10661,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -10825,7 +10725,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -10886,7 +10785,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -10957,7 +10855,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -10981,7 +10878,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -11046,7 +10942,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -11114,7 +11009,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -11149,7 +11043,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -11181,7 +11074,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -11268,7 +11160,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -11312,13 +11203,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11331,6 +11221,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -11354,7 +11256,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11381,7 +11282,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11490,8 +11390,8 @@ "version": "0.58.1" }, "reply": { - "generation": "479daf2c2b3aa387b64ed1d7ee54d7345908f26cbf4e0b80d0c9a49fc5405d6f", - "catalog_digest": "479daf2c2b3aa387b64ed1d7ee54d7345908f26cbf4e0b80d0c9a49fc5405d6f", + "generation": "e8516995312c1017b12431d251d0bba950b50c41075aa196ac841393e345aa1d", + "catalog_digest": "e8516995312c1017b12431d251d0bba950b50c41075aa196ac841393e345aa1d", "tools": [ { "name": "status", @@ -11501,7 +11401,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -11522,7 +11421,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11599,7 +11497,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11678,7 +11575,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -11729,7 +11625,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -11761,7 +11656,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -11844,7 +11738,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -11872,7 +11765,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -11911,7 +11803,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -11941,7 +11832,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -11990,7 +11880,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -12036,7 +11925,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -12101,7 +11989,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -12162,7 +12049,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -12233,7 +12119,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -12257,7 +12142,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -12322,7 +12206,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -12390,7 +12273,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -12425,7 +12307,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -12457,7 +12338,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -12544,7 +12424,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -12588,13 +12467,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12607,6 +12485,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -12630,7 +12520,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12657,7 +12546,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12768,8 +12656,8 @@ "version": "0.58.1" }, "reply": { - "generation": "8e3aea4beca3ff63cdb2637ca808cfc8613885008f844048912935b13a6e91d2", - "catalog_digest": "8e3aea4beca3ff63cdb2637ca808cfc8613885008f844048912935b13a6e91d2", + "generation": "f73cf766bcc33f7ae27132124821b71912e0ab0d84cb14064af18e230db8e9a2", + "catalog_digest": "f73cf766bcc33f7ae27132124821b71912e0ab0d84cb14064af18e230db8e9a2", "tools": [ { "name": "status", @@ -12779,7 +12667,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -12796,7 +12683,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12875,7 +12761,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -12926,7 +12811,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -12958,7 +12842,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -13041,7 +12924,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -13069,7 +12951,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -13108,7 +12989,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -13138,7 +13018,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -13187,7 +13066,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -13233,7 +13111,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -13298,7 +13175,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -13359,7 +13235,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -13430,7 +13305,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -13454,7 +13328,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -13519,7 +13392,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -13587,7 +13459,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -13622,7 +13493,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -13654,7 +13524,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -13741,7 +13610,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -13785,13 +13653,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -13804,6 +13671,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -13827,7 +13706,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -13854,7 +13732,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -13964,8 +13841,8 @@ "version": "0.58.1" }, "reply": { - "generation": "8ff9764cbed7cd00435451ab9150acfac21a10dbb0a681bbb1ed2f27dd68ca56", - "catalog_digest": "8ff9764cbed7cd00435451ab9150acfac21a10dbb0a681bbb1ed2f27dd68ca56", + "generation": "8e803035b2aebf036af01ccae8f82b3d501b3cf39341989cc7747dce3f6caa14", + "catalog_digest": "8e803035b2aebf036af01ccae8f82b3d501b3cf39341989cc7747dce3f6caa14", "tools": [ { "name": "status", @@ -13975,7 +13852,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -13996,7 +13872,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -14073,7 +13948,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -14152,7 +14026,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -14203,7 +14076,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -14235,7 +14107,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -14318,7 +14189,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -14346,7 +14216,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -14385,7 +14254,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -14415,7 +14283,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -14464,7 +14331,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -14529,7 +14395,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -14590,7 +14455,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -14661,7 +14525,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -14685,7 +14548,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -14750,7 +14612,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -14818,7 +14679,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -14853,7 +14713,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -14885,7 +14744,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -14972,7 +14830,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -15016,13 +14873,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15035,6 +14891,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -15058,7 +14926,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15085,7 +14952,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15195,8 +15061,8 @@ "version": "0.58.1" }, "reply": { - "generation": "ab7c34727ff1cc050a07dde48ec92b187a24de148d1b7660046414397a5edc4b", - "catalog_digest": "ab7c34727ff1cc050a07dde48ec92b187a24de148d1b7660046414397a5edc4b", + "generation": "9a5f6c22b3d7576a0e64369c36e6d7ffb38539759466fc621d591deee0f7a52d", + "catalog_digest": "9a5f6c22b3d7576a0e64369c36e6d7ffb38539759466fc621d591deee0f7a52d", "tools": [ { "name": "status", @@ -15206,7 +15072,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -15227,7 +15092,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15304,7 +15168,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15383,7 +15246,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -15434,7 +15296,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -15466,7 +15327,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -15549,7 +15409,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -15577,7 +15436,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -15616,7 +15474,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -15646,7 +15503,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -15692,7 +15548,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -15757,7 +15612,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -15818,7 +15672,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -15889,7 +15742,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -15913,7 +15765,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -15978,7 +15829,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -16046,7 +15896,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -16081,7 +15930,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -16113,7 +15961,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -16200,7 +16047,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -16244,13 +16090,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16263,6 +16108,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -16286,7 +16143,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16313,7 +16169,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16424,8 +16279,8 @@ "version": "0.58.1" }, "reply": { - "generation": "8dd19a2bb406911fa77fe14be366be5dde1a717179cba188f564696691a8ae83", - "catalog_digest": "8dd19a2bb406911fa77fe14be366be5dde1a717179cba188f564696691a8ae83", + "generation": "a38cc6990aecff7670a96ead2ce060330ce90b07942444097c97225fec4f7df0", + "catalog_digest": "a38cc6990aecff7670a96ead2ce060330ce90b07942444097c97225fec4f7df0", "tools": [ { "name": "status", @@ -16435,7 +16290,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -16456,7 +16310,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16533,7 +16386,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16612,7 +16464,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -16663,7 +16514,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -16695,7 +16545,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -16778,7 +16627,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -16806,7 +16654,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -16845,7 +16692,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -16875,7 +16721,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -16924,7 +16769,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -16990,7 +16834,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -17061,7 +16904,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -17085,7 +16927,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -17150,7 +16991,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -17218,7 +17058,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -17253,7 +17092,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -17285,7 +17123,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -17372,7 +17209,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -17416,13 +17252,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17435,6 +17270,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -17458,7 +17305,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17485,7 +17331,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17595,8 +17440,8 @@ "version": "0.58.1" }, "reply": { - "generation": "f53ef55b3e18e15b6d13ecdd464e7da13fc741f2dc9ee004ae0e8b4fca3946fc", - "catalog_digest": "f53ef55b3e18e15b6d13ecdd464e7da13fc741f2dc9ee004ae0e8b4fca3946fc", + "generation": "617faa8cc7dc334d52961fefd54be7afeed680fab657e45e3984cd340f2754d0", + "catalog_digest": "617faa8cc7dc334d52961fefd54be7afeed680fab657e45e3984cd340f2754d0", "tools": [ { "name": "status", @@ -17606,7 +17451,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -17627,7 +17471,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17704,7 +17547,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17783,7 +17625,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -17834,7 +17675,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -17866,7 +17706,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -17949,7 +17788,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -17977,7 +17815,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -18016,7 +17853,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -18046,7 +17882,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -18092,7 +17927,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -18158,7 +17992,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -18229,7 +18062,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -18253,7 +18085,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -18318,7 +18149,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -18386,7 +18216,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -18421,7 +18250,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -18453,7 +18281,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -18540,7 +18367,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -18584,13 +18410,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18603,6 +18428,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -18626,7 +18463,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18653,7 +18489,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18763,8 +18598,8 @@ "version": "0.58.1" }, "reply": { - "generation": "23091932d963f35204727d96b2d56a0e015142b799439215db08f948bead1b44", - "catalog_digest": "23091932d963f35204727d96b2d56a0e015142b799439215db08f948bead1b44", + "generation": "715f6b1b6525bcde7d65a11228e11011ff477dc0d07ff4e08181b060ea8219c9", + "catalog_digest": "715f6b1b6525bcde7d65a11228e11011ff477dc0d07ff4e08181b060ea8219c9", "tools": [ { "name": "status", @@ -18774,7 +18609,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -18795,7 +18629,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18872,7 +18705,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18951,7 +18783,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -19002,7 +18833,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -19034,7 +18864,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -19117,7 +18946,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -19145,7 +18973,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -19184,7 +19011,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -19214,7 +19040,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -19262,7 +19087,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -19323,7 +19147,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -19394,7 +19217,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -19418,7 +19240,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -19483,7 +19304,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -19551,7 +19371,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -19586,7 +19405,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -19618,7 +19436,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -19705,7 +19522,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -19749,13 +19565,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19768,6 +19583,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -19791,7 +19618,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19818,7 +19644,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19928,8 +19753,8 @@ "version": "0.58.1" }, "reply": { - "generation": "6953a382e27b9b9398e8db83e554c4205aeaf162c6f05f3692fbfb700a809ee9", - "catalog_digest": "6953a382e27b9b9398e8db83e554c4205aeaf162c6f05f3692fbfb700a809ee9", + "generation": "28c2d2a2ced659f7ab324f8f5d94c38f78ed7fccf727c6bac31c2a4cbdf7fcd7", + "catalog_digest": "28c2d2a2ced659f7ab324f8f5d94c38f78ed7fccf727c6bac31c2a4cbdf7fcd7", "tools": [ { "name": "status", @@ -19939,7 +19764,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -19960,7 +19784,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -20037,7 +19860,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -20116,7 +19938,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -20167,7 +19988,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -20199,7 +20019,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -20282,7 +20101,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -20310,7 +20128,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -20349,7 +20166,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -20379,7 +20195,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -20424,7 +20239,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -20485,7 +20299,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -20556,7 +20369,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -20580,7 +20392,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -20645,7 +20456,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -20713,7 +20523,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -20748,7 +20557,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -20780,7 +20588,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -20867,7 +20674,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -20911,13 +20717,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -20930,6 +20735,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -20953,7 +20770,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -20980,7 +20796,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], diff --git a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json index b882e193c1..0ce2957abf 100644 --- a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json +++ b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json @@ -10,8 +10,8 @@ "disabled_tools": [], "powershell_available": false, "reply": { - "generation": "2111895a0a09efca0b7dd4b6734737c70647f9cd1e685c8e5fa84c9635098304", - "catalog_digest": "2111895a0a09efca0b7dd4b6734737c70647f9cd1e685c8e5fa84c9635098304", + "generation": "b72d9ca1a119835ce0a26c136c52da81e5d5ee9d8b160fb1555bf61f238e2e7b", + "catalog_digest": "b72d9ca1a119835ce0a26c136c52da81e5d5ee9d8b160fb1555bf61f238e2e7b", "tools": [ { "name": "status", @@ -21,7 +21,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -42,7 +41,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -124,7 +122,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -175,7 +172,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -207,7 +203,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -290,7 +285,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -318,7 +312,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -357,7 +350,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -387,7 +379,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -436,7 +427,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -482,7 +472,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -547,7 +536,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -608,7 +596,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -679,7 +666,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -703,7 +689,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -768,7 +753,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -836,7 +820,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -871,7 +854,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -903,7 +885,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -990,7 +971,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -1034,13 +1014,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1053,6 +1032,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -1070,9 +1061,8 @@ }, { "name": "bash_watch", - "schema_digest": "1f3fb25a580902d58c7e391df1c2da3d065079f62c9ba1a3363f6eb880955a35", + "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", "semantics": 2, - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1117,6 +1107,13 @@ "minimum": 1, "maximum": 1800000 }, + "deliver": { + "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", + "type": "string", + "enum": [ + "each_line" + ] + }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", "type": "boolean" @@ -1135,7 +1132,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1162,7 +1158,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1260,8 +1255,8 @@ "disabled_tools": [], "powershell_available": true, "reply": { - "generation": "4fd58920eb47227531e7a3c8c56c0a223d311cf9c8e17c863474397bf65d2009", - "catalog_digest": "4fd58920eb47227531e7a3c8c56c0a223d311cf9c8e17c863474397bf65d2009", + "generation": "1385f78c9c4b26deea68c370e4c3c0c522e36f8cf4137c3916f2ee850a7db0c2", + "catalog_digest": "1385f78c9c4b26deea68c370e4c3c0c522e36f8cf4137c3916f2ee850a7db0c2", "tools": [ { "name": "status", @@ -1271,7 +1266,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -1292,7 +1286,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1369,7 +1362,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1448,7 +1440,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -1499,7 +1490,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1531,7 +1521,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1614,7 +1603,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1642,7 +1630,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1681,7 +1668,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1711,7 +1697,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1760,7 +1745,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1806,7 +1790,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1871,7 +1854,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -1932,7 +1914,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -2003,7 +1984,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -2027,7 +2007,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -2092,7 +2071,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -2160,7 +2138,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2195,7 +2172,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2227,7 +2203,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -2314,7 +2289,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -2358,13 +2332,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2377,6 +2350,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -2394,9 +2379,8 @@ }, { "name": "bash_watch", - "schema_digest": "1f3fb25a580902d58c7e391df1c2da3d065079f62c9ba1a3363f6eb880955a35", + "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", "semantics": 2, - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2441,6 +2425,13 @@ "minimum": 1, "maximum": 1800000 }, + "deliver": { + "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", + "type": "string", + "enum": [ + "each_line" + ] + }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", "type": "boolean" @@ -2459,7 +2450,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2486,7 +2476,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2585,8 +2574,8 @@ "disabled_tools": [], "powershell_available": false, "reply": { - "generation": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", - "catalog_digest": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", + "generation": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", + "catalog_digest": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", "tools": [ { "name": "read", @@ -2601,7 +2590,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -2653,7 +2641,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2692,7 +2679,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2722,7 +2708,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2771,7 +2756,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2817,7 +2801,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2882,7 +2865,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -2943,7 +2925,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -3038,8 +3019,8 @@ "disabled_tools": [], "powershell_available": true, "reply": { - "generation": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", - "catalog_digest": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", + "generation": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", + "catalog_digest": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", "tools": [ { "name": "read", @@ -3054,7 +3035,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -3106,7 +3086,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3145,7 +3124,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3175,7 +3153,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3224,7 +3201,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3270,7 +3246,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3335,7 +3310,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -3396,7 +3370,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -3492,8 +3465,8 @@ "powershell_available": false, "remote_runs": true, "reply": { - "generation": "bd64527bddcaa6bf8faf6a884faba123a50d86f6cac19ed17174d14fc6a11104", - "catalog_digest": "bd64527bddcaa6bf8faf6a884faba123a50d86f6cac19ed17174d14fc6a11104", + "generation": "9df500ec0a2a304a10a869e05894016e5ef20053dcc752e20b841eb58be3223f", + "catalog_digest": "9df500ec0a2a304a10a869e05894016e5ef20053dcc752e20b841eb58be3223f", "tools": [ { "name": "status", @@ -3503,7 +3476,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -3514,7 +3486,7 @@ }, { "name": "bash", - "schema_digest": "019128a28f9f2724d2c086f93a1c74222b294ad95fee8b255fa799b257397a4f", + "schema_digest": "029ddfaa46f2b46b773e15231b41cf5c86acd1ed1668068236039c1edb978aca", "semantics": 2, "modes": [ "classic", @@ -3524,11 +3496,10 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], - "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; a build or test that needs only macOS (its sandbox, code signing or file watching) can use runon: \"macos\" instead; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", + "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", "reply": { "max_ms": 2040000 }, @@ -3589,15 +3560,7 @@ }, "runon": { "type": "string", - "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options). `macos` sends the command to a macOS machine for builds and tests that need macOS (no vCPU or network options)." - }, - "return_paths": { - "type": "array", - "items": { - "type": "string" - }, - "maxItems": 64, - "description": "With runon: up to 64 files to copy back from the build server after the command, as paths relative to workdir inside the project (10 MiB in total). Each verified file replaces the local file at the same path; the result lists every path as returned or not returned, with the reason." + "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options)." } }, "required": [ @@ -3618,7 +3581,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -3669,7 +3631,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3701,7 +3662,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3784,7 +3744,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3812,7 +3771,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3851,7 +3809,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3881,7 +3838,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3930,7 +3886,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3976,7 +3931,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -4041,7 +3995,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -4102,7 +4055,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -4173,7 +4125,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -4197,7 +4148,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4262,7 +4212,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4330,7 +4279,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4365,7 +4313,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4397,7 +4344,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -4484,7 +4430,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -4528,13 +4473,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4547,6 +4491,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -4564,9 +4520,8 @@ }, { "name": "bash_watch", - "schema_digest": "1f3fb25a580902d58c7e391df1c2da3d065079f62c9ba1a3363f6eb880955a35", + "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", "semantics": 2, - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4611,6 +4566,13 @@ "minimum": 1, "maximum": 1800000 }, + "deliver": { + "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", + "type": "string", + "enum": [ + "each_line" + ] + }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", "type": "boolean" @@ -4629,7 +4591,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4656,7 +4617,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4755,8 +4715,8 @@ "powershell_available": true, "remote_runs": true, "reply": { - "generation": "e3d5b309182b2e65fdd660b8b14c48bfbb669b0ca6c469e5a61556776536af94", - "catalog_digest": "e3d5b309182b2e65fdd660b8b14c48bfbb669b0ca6c469e5a61556776536af94", + "generation": "cffd704ddb1c1685097625acf1df5d76c57e555722ffa784193c50de67b40058", + "catalog_digest": "cffd704ddb1c1685097625acf1df5d76c57e555722ffa784193c50de67b40058", "tools": [ { "name": "status", @@ -4766,7 +4726,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -4777,7 +4736,7 @@ }, { "name": "bash", - "schema_digest": "019128a28f9f2724d2c086f93a1c74222b294ad95fee8b255fa799b257397a4f", + "schema_digest": "029ddfaa46f2b46b773e15231b41cf5c86acd1ed1668068236039c1edb978aca", "semantics": 2, "modes": [ "classic", @@ -4787,11 +4746,10 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], - "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; a build or test that needs only macOS (its sandbox, code signing or file watching) can use runon: \"macos\" instead; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", + "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", "reply": { "max_ms": 2040000 }, @@ -4852,15 +4810,7 @@ }, "runon": { "type": "string", - "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options). `macos` sends the command to a macOS machine for builds and tests that need macOS (no vCPU or network options)." - }, - "return_paths": { - "type": "array", - "items": { - "type": "string" - }, - "maxItems": 64, - "description": "With runon: up to 64 files to copy back from the build server after the command, as paths relative to workdir inside the project (10 MiB in total). Each verified file replaces the local file at the same path; the result lists every path as returned or not returned, with the reason." + "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options)." } }, "required": [ @@ -4876,7 +4826,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4955,7 +4904,6 @@ "prepend", "append" ], - "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -5006,7 +4954,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -5038,7 +4985,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -5121,7 +5067,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -5149,7 +5094,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5188,7 +5132,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5218,7 +5161,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5267,7 +5209,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5313,7 +5254,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5378,7 +5318,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -5439,7 +5378,6 @@ "codemode", "flow" ], - "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -5510,7 +5448,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -5534,7 +5471,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5599,7 +5535,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5667,7 +5602,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5702,7 +5636,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5734,7 +5667,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -5821,7 +5753,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -5865,13 +5796,12 @@ }, { "name": "bash_status", - "schema_digest": "fc37e91b574024b0342f3b0b255341e472004f0203eac47f6b1abd9ff7f58f2c", + "schema_digest": "d6a69ca626fb7e4e733a2e253e23deec542462ec598c417265f2846303c16f1c", "semantics": 1, "modes": [ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5884,6 +5814,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -5901,9 +5843,8 @@ }, { "name": "bash_watch", - "schema_digest": "1f3fb25a580902d58c7e391df1c2da3d065079f62c9ba1a3363f6eb880955a35", + "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", "semantics": 2, - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5948,6 +5889,13 @@ "minimum": 1, "maximum": 1800000 }, + "deliver": { + "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", + "type": "string", + "enum": [ + "each_line" + ] + }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", "type": "boolean" @@ -5966,7 +5914,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5993,7 +5940,6 @@ "classic", "codemode" ], - "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], diff --git a/packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts b/packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts index 87068e2f07..50de9a721e 100644 --- a/packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts +++ b/packages/aft-bridge/src/__tests__/bash-watch-lines.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "bun:test"; -import { formatWatchLineBatch, WATCH_EACH_LINE_NEEDS_BACKGROUND } from "../bash-watch-lines.js"; +import { + formatWatchLineBatch, + mergeWatchLineBatches, + WATCH_EACH_LINE_NEEDS_BACKGROUND, +} from "../bash-watch-lines.js"; import type { BashWatchLineBatch } from "../bridge.js"; function batch(): BashWatchLineBatch { @@ -56,3 +60,39 @@ test("each_line sync refusal wording golden", () => { 'bash_watch: deliver: "each_line" needs background: true.', ); }); + +test("unavailable matching lines preserve source locations and use the transmitted masking bound", () => { + const value = batch(); + value.masking_limit_bytes = 8 * 1024; + value.lines.push({ + stream: "stdout", + offset: 42, + end_offset: 10283, + text: "stored diagnostic", + count: 1, + unavailable: true, + }); + expect(formatWatchLineBatch("task", value, false)).toBe( + "stdout byte 42: 1 matching line over 8 KiB was not shown; AFT can check lines only up to 8 KiB for secrets.", + ); + value.lines[0].count = 3; + expect(formatWatchLineBatch("task", value, false)).toBe( + "stdout byte 42 ×3: 3 matching lines over 8 KiB were not shown; AFT can check lines only up to 8 KiB for secrets.", + ); + value.masking_limit_bytes = 16 * 1024; + expect(formatWatchLineBatch("task", value, false)).toContain( + "over 16 KiB were not shown; AFT can check lines only up to 16 KiB for secrets.", + ); +}); + +test("masked rows with different source widths are not folded into an invented byte span", () => { + const first = { + ...batch(), + lines: [{ stream: "stdout", offset: 0, end_offset: 100, text: "same masked text", count: 1 }], + }; + const next = { + ...batch(), + lines: [{ stream: "stdout", offset: 100, end_offset: 220, text: "same masked text", count: 1 }], + }; + expect(mergeWatchLineBatches(first, next).lines).toEqual([...first.lines, ...next.lines]); +}); diff --git a/packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts b/packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts index 0820e488c3..36ed47996a 100644 --- a/packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts +++ b/packages/aft-bridge/src/__tests__/bash-watch-progress.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "bun:test"; -import { WatchBatchProgress, watchBatchHasContent } from "../bash-watch-progress.js"; +import { + WatchBatchAdmissions, + WatchBatchProgress, + watchBatchHasContent, +} from "../bash-watch-progress.js"; import type { BashWatchLineBatch } from "../bridge.js"; function snapshot(id = "a"): BashWatchLineBatch { @@ -44,3 +48,45 @@ test("watch progress does not reserve rows before admission or across pending ge expect(progress.unseen(fresh).dropped_lines).toBe(10); expect(progress.unseen(fresh).lines).toHaveLength(1); }); + +test("session batch admissions retain content watermarks while exact ack versions change", () => { + const admissions = new WatchBatchAdmissions(); + const a = { task_id: "task", watch_id: "watch", match_offset: 0, batch: snapshot() }; + admissions.admit([a]); + const b = { + ...a, + match_offset: 1, + batch: { + ...snapshot(), + version: 1, + dropped_lines: 10, + lines: [ + ...snapshot().lines, + { stream: "stderr", offset: 0, end_offset: 8, text: "READY b", count: 1 }, + ], + }, + }; + const shown = admissions.unseen([b]); + expect(shown[0]?.match_offset).toBe(1); + expect(shown[0]?.batch?.lines.map((line) => line.text)).toEqual(["READY b"]); + expect(shown[0]?.batch?.dropped_lines).toBe(3); + admissions.admit([b]); + admissions.confirmed([{ task_id: "task", watch_id: "watch", match_offset: 0 }]); + expect(admissions.size).toBe(1); + expect(admissions.unseen([b])).toEqual([]); + admissions.confirmed([{ task_id: "task", watch_id: "watch", match_offset: 1 }]); + expect(admissions.size).toBe(0); +}); + +test("session batch admission preview is not a reservation and durable absence retires state", () => { + const admissions = new WatchBatchAdmissions(); + const a = { task_id: "task", watch_id: "watch", match_offset: 0, batch: snapshot() }; + expect(admissions.unseen([a])).toHaveLength(1); + expect(admissions.unseen([a])).toHaveLength(1); + expect(admissions.size).toBe(0); + admissions.admit([a]); + admissions.reconcile([{ ...a, match_offset: 1 }]); + expect(admissions.size).toBe(1); + admissions.reconcile([]); + expect(admissions.size).toBe(0); +}); diff --git a/packages/aft-bridge/src/bash-watch-lines.ts b/packages/aft-bridge/src/bash-watch-lines.ts index 8d850cbba4..307237e624 100644 --- a/packages/aft-bridge/src/bash-watch-lines.ts +++ b/packages/aft-bridge/src/bash-watch-lines.ts @@ -1,6 +1,17 @@ import type { BashWatchLineBatch } from "./bridge.js"; const MAX_PENDING_WATCH_LINES = 50; +const DEFAULT_MASKING_LINE_CAP_BYTES = 8 * 1024; +export const UNAVAILABLE_MATCH_SINGULAR = + "1 matching line over {limit} KiB was not shown; AFT can check lines only up to {limit} KiB for secrets."; +export const UNAVAILABLE_MATCH_PLURAL = + "{count} matching lines over {limit} KiB were not shown; AFT can check lines only up to {limit} KiB for secrets."; + +function unavailableMatchText(count: number, limit: number): string { + return (count === 1 ? UNAVAILABLE_MATCH_SINGULAR : UNAVAILABLE_MATCH_PLURAL) + .replaceAll("{count}", String(count)) + .replaceAll("{limit}", String(Math.ceil(limit / 1024))); +} export const WATCH_EACH_LINE_NEEDS_BACKGROUND = 'bash_watch: deliver: "each_line" needs background: true.'; @@ -13,6 +24,7 @@ export function mergeWatchLineBatches( if (previous.pending_id && next.pending_id && previous.pending_id !== next.pending_id) return next; const merged = { ...previous, lines: previous.lines.map((line) => ({ ...line })) }; + merged.masking_limit_bytes = next.masking_limit_bytes ?? previous.masking_limit_bytes; merged.pending_id = next.pending_id ?? previous.pending_id; merged.version = next.version ?? previous.version; let retained = merged.lines.reduce((sum, line) => sum + line.count, 0); @@ -30,6 +42,8 @@ export function mergeWatchLineBatches( last && last.stream === line.stream && last.text === line.text && + Boolean(last.unavailable) === Boolean(line.unavailable) && + last.end_offset - last.offset === line.end_offset - line.offset && last.offset + last.count * (last.end_offset - last.offset) === line.offset ) last.count += keep; @@ -54,7 +68,7 @@ export function formatWatchLineBatch( ): string { const lines = batch.lines.map( (line) => - `${line.stream} byte ${line.offset}${line.count > 1 ? ` ×${line.count}` : ""}: ${line.text}`, + `${line.stream} byte ${line.offset}${line.count > 1 ? ` ×${line.count}` : ""}: ${line.unavailable ? unavailableMatchText(line.count, batch.masking_limit_bytes ?? DEFAULT_MASKING_LINE_CAP_BYTES) : line.text}`, ); if (batch.dropped_lines > 0 || batch.skipped_long_lines > 0) { const taskKey = camelCase ? "taskId" : "task_id"; diff --git a/packages/aft-bridge/src/bash-watch-progress.ts b/packages/aft-bridge/src/bash-watch-progress.ts index 4632d0404b..3c5f69b110 100644 --- a/packages/aft-bridge/src/bash-watch-progress.ts +++ b/packages/aft-bridge/src/bash-watch-progress.ts @@ -1,3 +1,4 @@ +import type { BashAckTarget } from "./bash-pattern-ack.js"; import type { BashWatchLineBatch } from "./bridge.js"; type Stream = "stdout" | "stderr" | "pty"; @@ -88,3 +89,66 @@ export class WatchBatchProgress { export function watchBatchHasContent(batch: BashWatchLineBatch): boolean { return batch.lines.length > 0 || batch.dropped_lines > 0 || batch.skipped_long_lines > 0; } + +type BatchMatch = { + task_id: string; + watch_id: string; + match_offset: number; + batch?: BashWatchLineBatch; +}; + +/** Watermarks survive failed acknowledgements, but clear on a confirmed ack or absence from the pending list. */ +export class WatchBatchAdmissions { + private readonly watches = new Map(); + + private key(match: Pick): string { + return JSON.stringify([match.task_id, match.watch_id]); + } + + unseen(matches: readonly T[]): T[] { + const visible: T[] = []; + for (const match of matches) { + if (!match.batch) { + visible.push(match); + continue; + } + const progress = this.watches.get(this.key(match)) ?? new WatchBatchProgress(); + const batch = progress.unseen({ ...match.batch, version: match.match_offset }); + if (watchBatchHasContent(batch)) visible.push({ ...match, batch }); + } + return visible; + } + + admit(matches: readonly BatchMatch[]): void { + for (const match of matches) { + if (!match.batch) continue; + const key = this.key(match); + const progress = this.watches.get(key) ?? new WatchBatchProgress(); + progress.admit({ ...match.batch, version: match.match_offset }); + this.watches.set(key, progress); + } + } + + confirmed(targets: readonly BashAckTarget[]): void { + for (const target of targets) { + if ("watch_id" in target) { + const key = this.key(target); + if (this.watches.get(key)?.admittedVersion === target.match_offset) + this.watches.delete(key); + } else { + for (const key of this.watches.keys()) { + if ((JSON.parse(key) as [string, string])[0] === target.task_id) this.watches.delete(key); + } + } + } + } + + reconcile(pending: readonly BatchMatch[]): void { + const present = new Set(pending.map((match) => this.key(match))); + for (const key of this.watches.keys()) if (!present.has(key)) this.watches.delete(key); + } + + get size(): number { + return this.watches.size; + } +} diff --git a/packages/aft-bridge/src/bridge.ts b/packages/aft-bridge/src/bridge.ts index 8fcd8a6344..7f607e0c10 100644 --- a/packages/aft-bridge/src/bridge.ts +++ b/packages/aft-bridge/src/bridge.ts @@ -505,7 +505,15 @@ export interface BashWatchLineBatch { version?: number; terminal_summary?: boolean; snapshot?: boolean; - lines: { stream: string; offset: number; end_offset: number; text: string; count: number }[]; + lines: { + stream: string; + offset: number; + end_offset: number; + text: string; + count: number; + unavailable?: boolean; + }[]; + masking_limit_bytes?: number; dropped_lines: number; first_dropped_offset: number | null; first_dropped_stream: string | null; diff --git a/packages/aft-bridge/src/index.ts b/packages/aft-bridge/src/index.ts index c14451a00d..2f2e3b5cc8 100644 --- a/packages/aft-bridge/src/index.ts +++ b/packages/aft-bridge/src/index.ts @@ -104,7 +104,11 @@ export { mergeWatchLineBatches, WATCH_EACH_LINE_NEEDS_BACKGROUND, } from "./bash-watch-lines.js"; -export { WatchBatchProgress, watchBatchHasContent } from "./bash-watch-progress.js"; +export { + WatchBatchAdmissions, + WatchBatchProgress, + watchBatchHasContent, +} from "./bash-watch-progress.js"; // --- binary identity (no-exec cache trust) --- export type { BinaryIdentity, BinaryIdentityCheck } from "./binary-identity.js"; export { diff --git a/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts b/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts index 0a7d8bcbd2..0d16a67463 100644 --- a/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/opencode-plugin/src/__tests__/bg-notifications.test.ts @@ -2820,3 +2820,91 @@ test("exact pattern ack retries keep sibling watches and newer versions delivera ).toBe(true); for (const call of acks) expect(call.params.task_ids ?? []).not.toContain("task-pattern"); }); + +for (const route of ["in-turn", "wake"] as const) { + test(`admitted each_line snapshot rows and counters are not repeated after a failed ack (${route})`, async () => { + const first = { + task_id: "task-batch", + session_id: "s1", + watch_id: "watch-batch", + match_offset: 0, + match_text: "matching lines", + context: "batch", + once: false, + reason: "pattern_match", + batch: { + pending_id: "pending-a", + version: 0, + snapshot: true, + lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR A", count: 1 }], + dropped_lines: 3, + skipped_long_lines: 1, + first_dropped_offset: 20, + first_dropped_stream: "stdout", + }, + }; + let current = first; + let failAck = true; + const acknowledgements: Record[] = []; + const { ctx } = harness((command, params) => { + if (command === "bash_drain_completions") + return { success: true, bg_completions: [], pending_matches: [current] }; + if (command === "bash_ack_completions") { + acknowledgements.push(params); + if (failAck) { + failAck = false; + return { success: false, message: "ack response lost" }; + } + return { success: true, acked_matches: params.matches ?? [] }; + } + return { success: true }; + }); + const sent: string[] = []; + const client = makeClient( + mock(async (input: unknown) => { + sent.push((input as { body: { parts: { text: string }[] } }).body.parts[0].text); + }), + ); + const context = { ctx, directory: "/tmp/project", sessionID: "s1", client }; + const deliver = async () => { + if (route === "in-turn") { + const output = { output: "tool" }; + await appendInTurnBgCompletions(context, output); + return output.output; + } + const wanted = sent.length + 1; + await handleSubcBgEventsNudge(context); + await waitUntil(() => sent.length === wanted && acknowledgements.length >= wanted); + return sent.at(-1)!; + }; + const old = await deliver(); + expect(old).toContain("stdout byte 0: ERROR A"); + expect(old).toContain("3 matching lines dropped."); + expect(old).toContain("1 lines longer than 64 KiB"); + current = { + ...first, + match_offset: 1, + batch: { + ...first.batch, + version: 1, + lines: [ + { ...first.batch.lines[0] }, + { stream: "stderr", offset: 0, end_offset: 8, text: "ERROR B", count: 1 }, + ], + dropped_lines: 5, + skipped_long_lines: 2, + }, + }; + const next = await deliver(); + expect(next).toContain("stderr byte 0: ERROR B"); + expect(next).not.toContain("ERROR A"); + expect(next).toContain("2 matching lines dropped."); + expect(next).not.toContain("5 matching lines dropped."); + expect(next).toContain("1 lines longer than 64 KiB"); + expect(next).not.toContain("2 lines longer than 64 KiB"); + expect(acknowledgements.at(-1)).toEqual({ + session_id: "s1", + matches: [{ task_id: "task-batch", watch_id: "watch-batch", match_offset: 1 }], + }); + }); +} diff --git a/packages/opencode-plugin/src/bg-notifications.ts b/packages/opencode-plugin/src/bg-notifications.ts index 066990ea64..bf79bdf9cd 100644 --- a/packages/opencode-plugin/src/bg-notifications.ts +++ b/packages/opencode-plugin/src/bg-notifications.ts @@ -13,6 +13,7 @@ import { formatWatchLineBatch, mergeWatchLineBatches, resolveBridgeForNudge, + WatchBatchAdmissions, } from "@cortexkit/aft-bridge"; import { sessionLog, sessionWarn } from "./logger.js"; import { resolvePromptContext } from "./shared/last-assistant-model.js"; @@ -93,6 +94,7 @@ type SessionBgState = { pendingCompletions: BgCompletion[]; pendingLongRunning: BgLongRunningReminder[]; pendingPatternMatches: PatternMatchEntry[]; + batchAdmissions: WatchBatchAdmissions; explicitControlTasks: Set; debounceTimer: NodeJS.Timeout | null; firstCompletionAt: number | null; @@ -701,9 +703,10 @@ export async function appendInTurnBgCompletions( const reminder = formatCombinedSystemReminder( state.pendingCompletions, state.pendingLongRunning, - state.pendingPatternMatches, + state.batchAdmissions.unseen(deliveredPatternMatches), ); - output.output = appendReminder(output.output ?? "", reminder); + if (reminder) output.output = appendReminder(output.output ?? "", reminder); + state.batchAdmissions.admit(deliveredPatternMatches); cancelSatisfiedAdmissions(state, inTurnTasks); // Trace #7 of 7: reminder went out as part of an existing tool result // instead of through promptAsync. NO wake_prompt_async_start event @@ -1350,6 +1353,13 @@ async function triggerWakeIfPending( } const taskIDs = deliveredCompletions.map((completion) => completion.task_id); const ackKeys = deliveredCompletions.map(bashAckKey); + if (!reminder) { + batch.accepted(); + state.batchAdmissions.admit(deliveredPatternMatches ?? []); + markDeliveredAwaitingAck(state, ackKeys); + if (await ackCompletions(drainContext, deliveredCompletions)) confirmAcked(state, ackKeys); + return; + } const watchIds = deliveredPatternMatches?.map((m) => m.watch_id) ?? []; const watchId = watchIds.join(",") || undefined; @@ -1508,6 +1518,7 @@ async function triggerWakeIfPending( onPending: batch.pending, onLateAcceptance: () => { batch.accepted(); + state.batchAdmissions.admit(deliveredPatternMatches ?? []); markDeliveredAwaitingAck(state, ackKeys); void ackCompletions(drainContext, deliveredCompletions, deliveryID) .then((acked) => { @@ -1603,6 +1614,7 @@ async function triggerWakeIfPending( if (!result.accepted) return; const { deliveryID } = result; batch.accepted(); + state.batchAdmissions.admit(deliveredPatternMatches ?? []); // Session injection has resolved successfully. Only now may these task IDs // enter awaiting-ack state and become eligible for daemon acknowledgement. markDeliveredAwaitingAck(state, ackKeys); @@ -1835,6 +1847,7 @@ async function drainCompletions(drainContext: DrainContext): Promise { ? response.pending_matches.filter(isPatternMatchEntry) : []; drainContext.delivery?.patternMatchesPending?.(drainContext.sessionID, drainedMatches); + state.batchAdmissions.reconcile(drainedMatches); const drainedTaskIds = [ ...new Set([ ...drainedCompletions.map((completion) => completion.task_id), @@ -1916,6 +1929,7 @@ async function ackCompletions( if ("watch_id" in target) drainContext.delivery?.patternMatchAcked?.(drainContext.sessionID, target); } + stateFor(drainContext.sessionID).batchAdmissions.confirmed(completions); // Trace #6 of 7: bash_ack_completions succeeded on the Rust side. // Closes the wake chain: scheduled → fire → start → ok → ack_ok. // Note: ack also runs from appendInTurnBgCompletions without a @@ -2061,7 +2075,7 @@ function scheduleWake( const reminder = formatCombinedSystemReminder( pending, pendingLongRunning, - pendingPatternMatches, + state.batchAdmissions.unseen(pendingPatternMatches), ); // Trace #2 of 7: timer actually fired and we captured a non-empty @@ -2195,6 +2209,7 @@ function stateFor(sessionID: string | undefined): SessionBgState { pendingCompletions: [], pendingLongRunning: [], pendingPatternMatches: [], + batchAdmissions: new WatchBatchAdmissions(), explicitControlTasks: new Set(), debounceTimer: null, firstCompletionAt: null, diff --git a/packages/opencode-plugin/src/wakes/runtime-consumer.ts b/packages/opencode-plugin/src/wakes/runtime-consumer.ts index dbd5cb500b..76077aa582 100644 --- a/packages/opencode-plugin/src/wakes/runtime-consumer.ts +++ b/packages/opencode-plugin/src/wakes/runtime-consumer.ts @@ -385,8 +385,8 @@ export function createV2RuntimeConsumer( ): V2RuntimeConsumer { const delivery = new V2SessionDelivery(context.session); // SubC owns acknowledgement retries outside admission. Keep its existing - // completion/scalar-watch keys; batched watches retire through exact-ack and - // durable-pending callbacks below. + // completion and non-batched watch keys. Batched watch keys clear when an + // acknowledgement is confirmed or the watch is absent from the pending list. const subcDelivery = new V2SessionDelivery(context.session, Number.POSITIVE_INFINITY); const sessions = new Set(); let subcPool: (() => AftTransportPool) | undefined; diff --git a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts index 1bd4a9325e..5b1c4cbbf4 100644 --- a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts @@ -1042,7 +1042,7 @@ describe("Pi subc forced-drain dedup (C-#1 / C-#3)", () => { match_offset: 0, context: "background task artifacts were erased", once: true, - reason: "watch_target_erased", + reason: "task_exit", }, ] : [], @@ -1562,3 +1562,97 @@ test("exact pattern ack retries keep sibling watches and newer versions delivera ).toBe(true); for (const call of acks) expect(call.params.task_ids ?? []).not.toContain("task-pattern"); }); + +for (const route of ["in-turn", "wake"] as const) { + test(`admitted each_line snapshot rows and counters are not repeated after a failed ack (${route})`, async () => { + const first = { + task_id: "task-batch", + session_id: "s1", + watch_id: "watch-batch", + match_offset: 0, + match_text: "matching lines", + context: "batch", + once: false, + reason: "pattern_match", + batch: { + pending_id: "pending-a", + version: 0, + snapshot: true, + lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR A", count: 1 }], + dropped_lines: 3, + skipped_long_lines: 1, + first_dropped_offset: 20, + first_dropped_stream: "stdout", + }, + }; + let current = first; + let failAck = true; + const acknowledgements: Record[] = []; + const { ctx } = harness((command, params) => { + if (command === "bash_drain_completions") + return { success: true, bg_completions: [], pending_matches: [current] }; + if (command === "bash_ack_completions") { + acknowledgements.push(params); + if (failAck) { + failAck = false; + return { success: false, message: "ack response lost" }; + } + return { success: true, acked_matches: params.matches ?? [] }; + } + return { success: true }; + }); + const sent: string[] = []; + const runtime = { + sendUserMessage: (text: string) => { + sent.push(text); + }, + }; + const context = { ctx, directory: "/tmp/project", sessionID: "s1", runtime }; + const deliver = async () => { + if (route === "in-turn") { + const content = await appendToolResultBgCompletions(context, [ + { type: "text", text: "tool" }, + ]); + return ( + content + ?.filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n") ?? "" + ); + } + const wanted = sent.length + 1; + await handleSubcBgEventsNudge(context); + await waitUntil(() => sent.length === wanted && acknowledgements.length >= wanted); + return sent.at(-1)!; + }; + const old = await deliver(); + expect(old).toContain("stdout byte 0: ERROR A"); + expect(old).toContain("3 matching lines dropped."); + expect(old).toContain("1 lines longer than 64 KiB"); + current = { + ...first, + match_offset: 1, + batch: { + ...first.batch, + version: 1, + lines: [ + { ...first.batch.lines[0] }, + { stream: "stderr", offset: 0, end_offset: 8, text: "ERROR B", count: 1 }, + ], + dropped_lines: 5, + skipped_long_lines: 2, + }, + }; + const next = await deliver(); + expect(next).toContain("stderr byte 0: ERROR B"); + expect(next).not.toContain("ERROR A"); + expect(next).toContain("2 matching lines dropped."); + expect(next).not.toContain("5 matching lines dropped."); + expect(next).toContain("1 lines longer than 64 KiB"); + expect(next).not.toContain("2 lines longer than 64 KiB"); + expect(acknowledgements.at(-1)).toEqual({ + session_id: "s1", + matches: [{ task_id: "task-batch", watch_id: "watch-batch", match_offset: 1 }], + }); + }); +} diff --git a/packages/pi-plugin/src/bg-notifications.ts b/packages/pi-plugin/src/bg-notifications.ts index 23726d29a9..2e96ab2f96 100644 --- a/packages/pi-plugin/src/bg-notifications.ts +++ b/packages/pi-plugin/src/bg-notifications.ts @@ -12,6 +12,7 @@ import { formatWatchLineBatch, mergeWatchLineBatches, resolveBridgeForNudge, + WatchBatchAdmissions, } from "@cortexkit/aft-bridge"; import { sessionLog, sessionWarn } from "./logger.js"; import type { PluginContext } from "./types.js"; @@ -75,6 +76,7 @@ type SessionBgState = { pendingCompletions: BgCompletion[]; pendingLongRunning: BgLongRunningReminder[]; pendingPatternMatches: PatternMatchEntry[]; + batchAdmissions: WatchBatchAdmissions; explicitControlTasks: Set; debounceTimer: NodeJS.Timeout | null; firstCompletionAt: number | null; @@ -506,8 +508,9 @@ export async function appendToolResultBgCompletions( const reminder = formatCombinedSystemReminder( state.pendingCompletions, state.pendingLongRunning, - state.pendingPatternMatches, + state.batchAdmissions.unseen(deliveredPatternMatches), ); + state.batchAdmissions.admit(deliveredPatternMatches); state.pendingCompletions = []; for (const completion of deliveredCompletions) { state.wakeDeferredTaskIds.delete(completion.task_id); @@ -528,7 +531,7 @@ export async function appendToolResultBgCompletions( // pendingLongRunning are now drained, and firing the timer anyway would // build an empty-body "[BACKGROUND BASH STILL RUNNING]" reminder. clearWakeTimerIfNoPending(state); - return [...content, { type: "text", text: reminder }]; + return reminder ? [...content, { type: "text", text: reminder }] : undefined; } export async function handleTurnEndBgCompletions( @@ -738,6 +741,12 @@ async function triggerWakeIfPending( } const ackTargetIds = deliveredCompletions.map((completion) => completion.task_id); const ackKeys = deliveredCompletions.map(bashAckKey); + if (!reminder) { + state.batchAdmissions.admit(deliveredPatternMatches ?? []); + markDeliveredAwaitingAck(state, ackKeys); + if (await ackCompletions(drainContext, deliveredCompletions)) confirmAcked(state, ackKeys); + return; + } const watchIds = deliveredPatternMatches?.map((m) => m.watch_id) ?? []; const watchId = watchIds.join(",") || undefined; logPerTaskDeliveryHop(drainContext, "inject-start", "session inject start", ackTargetIds, { @@ -747,6 +756,7 @@ async function triggerWakeIfPending( }); try { drainContext.runtime.sendUserMessage(reminder, { deliverAs: "steer" }); + state.batchAdmissions.admit(deliveredPatternMatches ?? []); } catch (err) { logPerTaskDeliveryHop( drainContext, @@ -949,6 +959,7 @@ async function drainCompletions(drainContext: DrainContext): Promise { const drainedMatches = Array.isArray(response.pending_matches) ? response.pending_matches.filter(isPatternMatchEntry) : []; + state.batchAdmissions.reconcile(drainedMatches); const drainedTaskIds = [ ...new Set([ ...drainedCompletions.map((completion) => completion.task_id), @@ -1023,6 +1034,7 @@ async function ackCompletions( ); return false; } + stateFor(drainContext.sessionID).batchAdmissions.confirmed(completions); logPerTaskDeliveryHop(drainContext, "ack-ok", "ack ok", taskIds, { event: "bash_completion_ack_ok", }); @@ -1142,7 +1154,7 @@ function scheduleWake( const reminder = formatCombinedSystemReminder( pending, pendingLongRunning, - pendingPatternMatches, + state.batchAdmissions.unseen(pendingPatternMatches), ); const deliveredTaskIds = new Set(pending.map((completion) => completion.task_id)); state.pendingCompletions = state.pendingCompletions.filter( @@ -1207,6 +1219,7 @@ function stateFor(sessionID: string | undefined): SessionBgState { pendingCompletions: [], pendingLongRunning: [], pendingPatternMatches: [], + batchAdmissions: new WatchBatchAdmissions(), explicitControlTasks: new Set(), debounceTimer: null, firstCompletionAt: null, From 2c70bd3adb8574f23baa14ad61e9a4db42da7e53 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:39:41 +0100 Subject: [PATCH 05/15] mason: renumber bash-watch migration 20 -> 21 on rebased main Main owns migration 20 (rich_ui::SCHEMA_VERSION); the rebased bash-watch each_line/match_batch step becomes 21 with CURRENT_SCHEMA_VERSION 21. The v21 test builds a v20 database so only the new step runs. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/db/mod.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/aft/src/db/mod.rs b/crates/aft/src/db/mod.rs index 7cb0845bf2..5024e3cad9 100644 --- a/crates/aft/src/db/mod.rs +++ b/crates/aft/src/db/mod.rs @@ -58,7 +58,6 @@ const MIGRATION_VERSIONS: &[u32] = &[ 19, rich_ui::SCHEMA_VERSION, 21, -] ]; // Performance-only indexes do not change the table format or reader floor. @@ -1456,24 +1455,24 @@ mod tests { } #[test] - fn migration_v20_opens_v19_with_default_first_match_and_new_reader_floor() { + fn migration_v21_opens_v20_with_default_first_match_and_new_reader_floor() { let dir = tempdir().unwrap(); let path = dir.path().join("aft.db"); let conn = Connection::open(&path).unwrap(); - for version in 1..=19 { + for version in 1..=20 { apply_migration_statements(&conn, version).unwrap(); } - conn.execute("INSERT INTO schema_version(version) VALUES (19)", []) + conn.execute("INSERT INTO schema_version(version) VALUES (20)", []) .unwrap(); drop(conn); crate::reader_floor::raise( dir.path(), - &[(crate::persisted_format::PersistedStore::AftDb, 19)], + &[(crate::persisted_format::PersistedStore::AftDb, 20)], ) .unwrap(); assert!(crate::reader_floor::prepare(dir.path()).is_empty()); let conn = open(&path).unwrap(); - assert_eq!(schema_version(&conn), 20); + assert_eq!(schema_version(&conn), 21); let columns = conn .prepare("PRAGMA table_info(bash_pattern_watches)") .unwrap() @@ -1500,7 +1499,7 @@ mod tests { .unwrap() .unwrap() .get(crate::persisted_format::PersistedStore::AftDb), - Some(20) + Some(21) ); } From 0b95413a4a052fddb1df205bb168479971d50648 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:16:38 +0100 Subject: [PATCH 06/15] mason: sort bridge export blocks after rebase (biome organize imports) Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- packages/aft-bridge/src/index.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/aft-bridge/src/index.ts b/packages/aft-bridge/src/index.ts index 2f2e3b5cc8..175ac04bdf 100644 --- a/packages/aft-bridge/src/index.ts +++ b/packages/aft-bridge/src/index.ts @@ -80,15 +80,6 @@ export { REGEX_WINDOW_SCAN_INCOMPLETE_TEXT, TERMINAL_SCAN_INCOMPLETE_TEXT, } from "./bash-output-page.js"; -export { - BASH_PERMISSION_NOT_SETTLED, - type BashPermissionAsk, - BashPermissionLoopError, - bashPermissionRetryGrants, - escalationAskText, - groupBashPermissionAsks, - withBashPermissionLoop, -} from "./bash-permissions.js"; export { type BashAckTarget, bashAckKey, @@ -98,6 +89,15 @@ export { bashAckTargets, bashPatternAckTarget, } from "./bash-pattern-ack.js"; +export { + BASH_PERMISSION_NOT_SETTLED, + type BashPermissionAsk, + BashPermissionLoopError, + bashPermissionRetryGrants, + escalationAskText, + groupBashPermissionAsks, + withBashPermissionLoop, +} from "./bash-permissions.js"; export { formatBashOutputPages, formatWatchLineBatch, From 01e3158ff21cb24ea77150ef20cce314e6cd39f3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:37:34 +0100 Subject: [PATCH 07/15] mason: reconcile rebased bash-watch surface with main's pin tests Pi bash_status paging params use the canonical camelCase names (outputOffset/stderrOffset); deliver description no longer names the disableable bash_status companion; v1/pi captures regenerated; v2 deleted-session drain test asserts the matches-shape ack contract. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- packages/aft-bridge/src/bash-watch-lines.ts | 7 ++-- .../__tests__/v2-session-lifecycle.test.ts | 33 +++++++++++++------ .../opencode-plugin/src/tools/bash_watch.ts | 2 +- .../tool-surface/fixtures/v1-bash-family.json | 19 +++++++++++ packages/pi-plugin/src/__tests__/bash.test.ts | 4 +-- .../src/__tests__/bg-notifications.test.ts | 4 +-- .../__tests__/fixtures/pi-bash-family.json | 17 ++++++++++ packages/pi-plugin/src/bg-notifications.ts | 2 +- packages/pi-plugin/src/tools/bash.ts | 18 +++++----- 9 files changed, 79 insertions(+), 27 deletions(-) diff --git a/packages/aft-bridge/src/bash-watch-lines.ts b/packages/aft-bridge/src/bash-watch-lines.ts index 307237e624..5a2ad74e17 100644 --- a/packages/aft-bridge/src/bash-watch-lines.ts +++ b/packages/aft-bridge/src/bash-watch-lines.ts @@ -60,11 +60,14 @@ export function mergeWatchLineBatches( return merged; } -/** Render masked source lines and copyable, host-specific byte-page recovery calls. */ +/** Render masked source lines and copyable, host-specific byte-page recovery calls. + * taskKey follows the host's task-id spelling; offset keys follow offsetCamelCase + * (Pi spells the task id task_id but takes camelCase page offsets). */ export function formatWatchLineBatch( taskId: string, batch: BashWatchLineBatch, camelCase: boolean, + offsetCamelCase: boolean = camelCase, ): string { const lines = batch.lines.map( (line) => @@ -73,7 +76,7 @@ export function formatWatchLineBatch( if (batch.dropped_lines > 0 || batch.skipped_long_lines > 0) { const taskKey = camelCase ? "taskId" : "task_id"; const cursor = batch.first_dropped_stream === "stderr" ? "stderr" : "output"; - const offsetKey = camelCase ? `${cursor}Offset` : `${cursor}_offset`; + const offsetKey = offsetCamelCase ? `${cursor}Offset` : `${cursor}_offset`; const incomplete = batch.skipped_long_lines > 0 ? ` ${batch.skipped_long_lines} lines longer than 64 KiB were skipped, so the scan is incomplete.` diff --git a/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts b/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts index 203dbc55cd..d1fc9cab3c 100644 --- a/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts +++ b/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts @@ -651,9 +651,16 @@ test("V2 subc drain for a deleted session acknowledges its completion and watch if (command === "bash_drain_completions") return { success: true, bg_completions: pending, pending_matches: matches }; if (command === "bash_ack_completions") { - const acked = new Set(params.task_ids as string[]); + const acked = new Set((params.task_ids as string[] | undefined) ?? []); + const ackedMatches = new Set( + (((params.matches as Array<{ task_id: string }> | undefined) ?? []).map( + (match) => match.task_id, + )), + ); pending = pending.filter((item) => !acked.has(item.task_id)); - matches = matches.filter((item) => !acked.has(item.task_id)); + matches = matches.filter( + (item) => !acked.has(item.task_id) && !ackedMatches.has(item.task_id), + ); } return { success: true }; }, @@ -673,20 +680,26 @@ test("V2 subc drain for a deleted session acknowledges its completion and watch ); await wakes.drainSession("drained-child"); expect(synthetic).toEqual([]); + // The completion is acked by task id so the bridge never redelivers it. expect( sends.some( (send) => send.command === "bash_ack_completions" && - (send.params.task_ids as string[]).includes("bash-drained-late"), - ), - ).toBe(true); - expect( - sends.some( - (send) => - send.command === "bash_ack_completions" && - (send.params.task_ids as string[]).includes("bash-watched-late"), + (((send.params.task_ids as string[] | undefined) ?? []).includes("bash-drained-late")), ), ).toBe(true); + // The watch match is acked in the matches shape (task, watch, offset), + // not as a completion, so the deleted session's match is retired + // without queueing a wake for a session that no longer exists. + const matchAcks = sends.filter( + (send) => + send.command === "bash_ack_completions" && Array.isArray(send.params.matches), + ); + expect(matchAcks).toHaveLength(1); + expect(matchAcks[0]?.params).toEqual({ + session_id: "drained-child", + matches: [{ task_id: "bash-watched-late", watch_id: "watch-late", match_offset: 0 }], + }); expect(sessionBgStates.has("drained-child")).toBe(false); } finally { consumer.dispose(); diff --git a/packages/opencode-plugin/src/tools/bash_watch.ts b/packages/opencode-plugin/src/tools/bash_watch.ts index a355fefe7f..a1b28af45a 100644 --- a/packages/opencode-plugin/src/tools/bash_watch.ts +++ b/packages/opencode-plugin/src/tools/bash_watch.ts @@ -150,7 +150,7 @@ export function createBashWatchTool(ctx: PluginContext): ToolDefinition { .enum(["each_line"]) .optional() .describe( - "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with bash_status byte offsets.", + "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with byte offsets.", ), once: z .boolean() diff --git a/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json b/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json index 364491f7ce..9a8bf06261 100644 --- a/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json +++ b/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json @@ -72,6 +72,18 @@ "description": "Background task ID returned by bash({ background: true }), e.g. bash-6b454047a1c39ded.", "type": "string" }, + "outputOffset": { + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "stderrOffset": { + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "outputMode": { "description": "PTY output rendering mode. Defaults to screen for PTY tasks and preserves existing behavior for piped tasks when omitted.", "type": "string", @@ -127,6 +139,13 @@ "minimum": 1, "maximum": 1800000 }, + "deliver": { + "description": "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with byte offsets.", + "type": "string", + "enum": [ + "each_line" + ] + }, "once": { "description": "Async-only. Defaults true; false keeps the watch sticky until task exit.", "type": "boolean" diff --git a/packages/pi-plugin/src/__tests__/bash.test.ts b/packages/pi-plugin/src/__tests__/bash.test.ts index 701325bdae..48fe788f27 100644 --- a/packages/pi-plugin/src/__tests__/bash.test.ts +++ b/packages/pi-plugin/src/__tests__/bash.test.ts @@ -1283,7 +1283,7 @@ describe("bash tool adapter", () => { .get("bash_status")! .execute( "call", - { task_id: "bash-lines", output_offset: 16, stderr_offset: 10 }, + { task_id: "bash-lines", outputOffset: 16, stderrOffset: 10 }, undefined, undefined, { cwd: projectRoot }, @@ -1294,7 +1294,7 @@ describe("bash tool adapter", () => { ]; expect(status[1]).toMatchObject({ output_offset: 16, stderr_offset: 10 }); expect(result.content[0].text).toContain("masked output"); - expect(result.content[0].text).toContain("next output_offset: 48"); + expect(result.content[0].text).toContain("next outputOffset: 48"); expect(result.content[0].text).not.toContain("RAW-SECRET"); }); diff --git a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts index 5b1c4cbbf4..e4ac8ad04e 100644 --- a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts @@ -1448,7 +1448,7 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { match_text: "matching lines", match_offset: 0, context: - 'stdout byte 0 ×2: ERROR a\n3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", output_offset: 16 }).', + 'stdout byte 0 ×2: ERROR a\n3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", outputOffset: 16 }).', once: false, batch: { lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 2 }], @@ -1462,7 +1462,7 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { expect(reminder).toContain("task bash-lines matching lines:"); expect(reminder).toContain("stdout byte 0 ×2: ERROR a"); expect(reminder).toContain( - '3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", output_offset: 16 }).', + '3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", outputOffset: 16 }).', ); }); diff --git a/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json b/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json index 574b13113b..0b392da7d0 100644 --- a/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json +++ b/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json @@ -98,6 +98,18 @@ "task_id" ], "properties": { + "outputOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset." + }, + "stderrOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Read a bounded stderr byte page from this offset; returns masked text and the next byte offset." + }, "task_id": { "type": "string", "description": "Background bash task id returned by bash({ background: true })." @@ -170,6 +182,11 @@ ], "description": "Sync-only timeout in milliseconds. In a main session: default 30000, max `bash.watch_sync_max_ms` (120000 by default). In a delegated session: omit it to wait up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), after which the watch reports the command is still running; a value you pass is clamped to that worker limit." }, + "deliver": { + "type": "string", + "const": "each_line", + "description": "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with byte offsets." + }, "once": { "type": "boolean" } diff --git a/packages/pi-plugin/src/bg-notifications.ts b/packages/pi-plugin/src/bg-notifications.ts index 2e96ab2f96..3e7d0eaf84 100644 --- a/packages/pi-plugin/src/bg-notifications.ts +++ b/packages/pi-plugin/src/bg-notifications.ts @@ -832,7 +832,7 @@ export function formatPatternMatchReminder(matches: readonly PatternMatchEntry[] .map((match) => { const context = ( match.batch - ? formatWatchLineBatch(match.task_id, match.batch, false) + ? formatWatchLineBatch(match.task_id, match.batch, false, true) : match.context || match.match_text ).replace(/\n/g, "\n > "); if (match.batch) { diff --git a/packages/pi-plugin/src/tools/bash.ts b/packages/pi-plugin/src/tools/bash.ts index ba56afcb3d..f482e88a6e 100644 --- a/packages/pi-plugin/src/tools/bash.ts +++ b/packages/pi-plugin/src/tools/bash.ts @@ -414,7 +414,7 @@ const BashTaskParams = Type.Object({ }); const BashStatusParams = Type.Object({ - output_offset: Type.Optional( + outputOffset: Type.Optional( Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, @@ -422,7 +422,7 @@ const BashStatusParams = Type.Object({ "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", }), ), - stderr_offset: Type.Optional( + stderrOffset: Type.Optional( Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, @@ -451,7 +451,7 @@ const BashWatchParams = Type.Object({ deliver: Type.Optional( Type.Literal("each_line", { description: - "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with bash_status byte offsets.", + "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with byte offsets.", }), ), once: Type.Optional(Type.Boolean()), @@ -1150,7 +1150,7 @@ export function createBashStatusTool(ctx: PluginContext) { // bash_status is snapshot-only. wait_for / exit / timeout_ms moved to // bash_watch; if the agent passes them here they're silently ignored // at the TypeBox schema layer. - const paging = params.output_offset !== undefined || params.stderr_offset !== undefined; + const paging = params.outputOffset !== undefined || params.stderrOffset !== undefined; const data = paging ? await callBashBridge( bridge, @@ -1159,14 +1159,14 @@ export function createBashStatusTool(ctx: PluginContext) { task_id: params.task_id, output_mode: params.output_mode, output_offset: coerceOptionalInt( - params.output_offset, - "output_offset", + params.outputOffset, + "outputOffset", 0, Number.MAX_SAFE_INTEGER, ), stderr_offset: coerceOptionalInt( - params.stderr_offset, - "stderr_offset", + params.stderrOffset, + "stderrOffset", 0, Number.MAX_SAFE_INTEGER, ), @@ -1177,7 +1177,7 @@ export function createBashStatusTool(ctx: PluginContext) { if (data.success === false) throw new Error(String(data.message ?? "bash_status failed")); if (paging) return bashStatusResult( - formatBashOutputPages(data, false), + formatBashOutputPages(data, true), data as unknown as BashStatusDetails, ); const details = data as unknown as BashStatusDetails; From d463e255c6d8dce533a412a3ba554ab001eae802 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:40:38 +0100 Subject: [PATCH 08/15] mason: encode singleton deliver as literal to match Pi TypeBox const Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/subc_tool_presets.json | 4 +--- packages/opencode-plugin/src/tools/bash_watch.ts | 2 +- .../test/tool-surface/fixtures/v1-bash-family.json | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/aft/src/subc_tool_presets.json b/crates/aft/src/subc_tool_presets.json index 7137d3ca42..6365c7abfa 100644 --- a/crates/aft/src/subc_tool_presets.json +++ b/crates/aft/src/subc_tool_presets.json @@ -154,9 +154,7 @@ "deliver": { "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", "type": "string", - "enum": [ - "each_line" - ] + "const": "each_line" }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", diff --git a/packages/opencode-plugin/src/tools/bash_watch.ts b/packages/opencode-plugin/src/tools/bash_watch.ts index a1b28af45a..f81df6e1a9 100644 --- a/packages/opencode-plugin/src/tools/bash_watch.ts +++ b/packages/opencode-plugin/src/tools/bash_watch.ts @@ -147,7 +147,7 @@ export function createBashWatchTool(ctx: PluginContext): ToolDefinition { ), timeoutMs: optionalInt(1, 1800000).describe(WATCH_TIMEOUT_PARAM_DESCRIPTION), deliver: z - .enum(["each_line"]) + .literal("each_line") .optional() .describe( "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with byte offsets.", diff --git a/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json b/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json index 9a8bf06261..b9e4989caf 100644 --- a/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json +++ b/packages/opencode-plugin/test/tool-surface/fixtures/v1-bash-family.json @@ -142,9 +142,7 @@ "deliver": { "description": "Async-only: batch every matching complete line, fold consecutive duplicates, and report cap drops with byte offsets.", "type": "string", - "enum": [ - "each_line" - ] + "const": "each_line" }, "once": { "description": "Async-only. Defaults true; false keeps the watch sticky until task exit.", From 2b73e64cb3036dc53b1f68734b3a0190b7caf158 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:42:10 +0100 Subject: [PATCH 09/15] mason: biome format drain-contract test Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../src/__tests__/v2-session-lifecycle.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts b/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts index d1fc9cab3c..3cbf555694 100644 --- a/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts +++ b/packages/opencode-plugin/src/__tests__/v2-session-lifecycle.test.ts @@ -653,9 +653,9 @@ test("V2 subc drain for a deleted session acknowledges its completion and watch if (command === "bash_ack_completions") { const acked = new Set((params.task_ids as string[] | undefined) ?? []); const ackedMatches = new Set( - (((params.matches as Array<{ task_id: string }> | undefined) ?? []).map( + ((params.matches as Array<{ task_id: string }> | undefined) ?? []).map( (match) => match.task_id, - )), + ), ); pending = pending.filter((item) => !acked.has(item.task_id)); matches = matches.filter( @@ -685,15 +685,14 @@ test("V2 subc drain for a deleted session acknowledges its completion and watch sends.some( (send) => send.command === "bash_ack_completions" && - (((send.params.task_ids as string[] | undefined) ?? []).includes("bash-drained-late")), + ((send.params.task_ids as string[] | undefined) ?? []).includes("bash-drained-late"), ), ).toBe(true); // The watch match is acked in the matches shape (task, watch, offset), // not as a completion, so the deleted session's match is retired // without queueing a wake for a session that no longer exists. const matchAcks = sends.filter( - (send) => - send.command === "bash_ack_completions" && Array.isArray(send.params.matches), + (send) => send.command === "bash_ack_completions" && Array.isArray(send.params.matches), ); expect(matchAcks).toHaveLength(1); expect(matchAcks[0]?.params).toEqual({ From 31803eaa2042ad0b5c85481fd75d1e822a6b400e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:54:33 +0100 Subject: [PATCH 10/15] mason: regen provider catalog presets for literal deliver Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../tool_provider_catalog_presets.json | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json index 0ce2957abf..4fd91db8ee 100644 --- a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json +++ b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json @@ -10,8 +10,8 @@ "disabled_tools": [], "powershell_available": false, "reply": { - "generation": "b72d9ca1a119835ce0a26c136c52da81e5d5ee9d8b160fb1555bf61f238e2e7b", - "catalog_digest": "b72d9ca1a119835ce0a26c136c52da81e5d5ee9d8b160fb1555bf61f238e2e7b", + "generation": "7fffbe19c43f8d672e5bb502c276c7ac43cf3500c0038f2a3f47fc7d1762d694", + "catalog_digest": "7fffbe19c43f8d672e5bb502c276c7ac43cf3500c0038f2a3f47fc7d1762d694", "tools": [ { "name": "status", @@ -1061,7 +1061,7 @@ }, { "name": "bash_watch", - "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", + "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, "capabilities": [ "shell.exec/v1" @@ -1110,9 +1110,7 @@ "deliver": { "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", "type": "string", - "enum": [ - "each_line" - ] + "const": "each_line" }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", @@ -1255,8 +1253,8 @@ "disabled_tools": [], "powershell_available": true, "reply": { - "generation": "1385f78c9c4b26deea68c370e4c3c0c522e36f8cf4137c3916f2ee850a7db0c2", - "catalog_digest": "1385f78c9c4b26deea68c370e4c3c0c522e36f8cf4137c3916f2ee850a7db0c2", + "generation": "7a8e06762dab18cfb8e532fe5b194714af424fc17f98186d2ecb81a427fc239a", + "catalog_digest": "7a8e06762dab18cfb8e532fe5b194714af424fc17f98186d2ecb81a427fc239a", "tools": [ { "name": "status", @@ -2379,7 +2377,7 @@ }, { "name": "bash_watch", - "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", + "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, "capabilities": [ "shell.exec/v1" @@ -2428,9 +2426,7 @@ "deliver": { "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", "type": "string", - "enum": [ - "each_line" - ] + "const": "each_line" }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", @@ -3465,8 +3461,8 @@ "powershell_available": false, "remote_runs": true, "reply": { - "generation": "9df500ec0a2a304a10a869e05894016e5ef20053dcc752e20b841eb58be3223f", - "catalog_digest": "9df500ec0a2a304a10a869e05894016e5ef20053dcc752e20b841eb58be3223f", + "generation": "a2c3c971952943f8154cd186c3e5bfa9589bd2e97d32e42a0762423beb98fbdc", + "catalog_digest": "a2c3c971952943f8154cd186c3e5bfa9589bd2e97d32e42a0762423beb98fbdc", "tools": [ { "name": "status", @@ -4520,7 +4516,7 @@ }, { "name": "bash_watch", - "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", + "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, "capabilities": [ "shell.exec/v1" @@ -4569,9 +4565,7 @@ "deliver": { "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", "type": "string", - "enum": [ - "each_line" - ] + "const": "each_line" }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", @@ -4715,8 +4709,8 @@ "powershell_available": true, "remote_runs": true, "reply": { - "generation": "cffd704ddb1c1685097625acf1df5d76c57e555722ffa784193c50de67b40058", - "catalog_digest": "cffd704ddb1c1685097625acf1df5d76c57e555722ffa784193c50de67b40058", + "generation": "e2c94ff20b64a6c8ee850ae1f49da2ba4fb72dc25e61607ebb478a8c177c7a37", + "catalog_digest": "e2c94ff20b64a6c8ee850ae1f49da2ba4fb72dc25e61607ebb478a8c177c7a37", "tools": [ { "name": "status", @@ -5843,7 +5837,7 @@ }, { "name": "bash_watch", - "schema_digest": "ef95a7f8b48971965f988186737cac47c1f88c1a246468a2cf924b62bfbc594e", + "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, "capabilities": [ "shell.exec/v1" @@ -5892,9 +5886,7 @@ "deliver": { "description": "Rejected: this watch always waits synchronously; deliver requires background:true with an async notification channel.", "type": "string", - "enum": [ - "each_line" - ] + "const": "each_line" }, "once": { "description": "Accepted for compatibility; only an async watch reads it.", From d96d05c9daca22dba878e3fb29b089501c359477 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:03:48 +0100 Subject: [PATCH 11/15] mason: keep Pi snake_case offsets, map them in parity aliases Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- packages/aft-bridge/src/bash-watch-lines.ts | 7 ++----- .../src/__tests__/registration-parity.test.ts | 2 ++ packages/pi-plugin/src/__tests__/bash.test.ts | 4 ++-- .../src/__tests__/bg-notifications.test.ts | 4 ++-- .../src/__tests__/fixtures/pi-bash-family.json | 4 ++-- packages/pi-plugin/src/bg-notifications.ts | 2 +- packages/pi-plugin/src/tools/bash.ts | 16 ++++++++-------- 7 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/aft-bridge/src/bash-watch-lines.ts b/packages/aft-bridge/src/bash-watch-lines.ts index 5a2ad74e17..307237e624 100644 --- a/packages/aft-bridge/src/bash-watch-lines.ts +++ b/packages/aft-bridge/src/bash-watch-lines.ts @@ -60,14 +60,11 @@ export function mergeWatchLineBatches( return merged; } -/** Render masked source lines and copyable, host-specific byte-page recovery calls. - * taskKey follows the host's task-id spelling; offset keys follow offsetCamelCase - * (Pi spells the task id task_id but takes camelCase page offsets). */ +/** Render masked source lines and copyable, host-specific byte-page recovery calls. */ export function formatWatchLineBatch( taskId: string, batch: BashWatchLineBatch, camelCase: boolean, - offsetCamelCase: boolean = camelCase, ): string { const lines = batch.lines.map( (line) => @@ -76,7 +73,7 @@ export function formatWatchLineBatch( if (batch.dropped_lines > 0 || batch.skipped_long_lines > 0) { const taskKey = camelCase ? "taskId" : "task_id"; const cursor = batch.first_dropped_stream === "stderr" ? "stderr" : "output"; - const offsetKey = offsetCamelCase ? `${cursor}Offset` : `${cursor}_offset`; + const offsetKey = camelCase ? `${cursor}Offset` : `${cursor}_offset`; const incomplete = batch.skipped_long_lines > 0 ? ` ${batch.skipped_long_lines} lines longer than 64 KiB were skipped, so the scan is incomplete.` diff --git a/packages/opencode-plugin/src/__tests__/registration-parity.test.ts b/packages/opencode-plugin/src/__tests__/registration-parity.test.ts index cba102314c..1553aeea38 100644 --- a/packages/opencode-plugin/src/__tests__/registration-parity.test.ts +++ b/packages/opencode-plugin/src/__tests__/registration-parity.test.ts @@ -182,6 +182,8 @@ function aliasForTool(toolName: string, key: string): string { if (key === "toFile") return "toPath"; if (toolName === "bash_status" && key === "task_id") return "taskId"; if (toolName === "bash_status" && key === "output_mode") return "outputMode"; + if (toolName === "bash_status" && key === "output_offset") return "outputOffset"; + if (toolName === "bash_status" && key === "stderr_offset") return "stderrOffset"; if (toolName === "bash_write" && key === "task_id") return "taskId"; if (toolName === "bash_watch" && key === "task_id") return "taskId"; if (toolName === "bash_watch" && key === "timeout_ms") return "timeoutMs"; diff --git a/packages/pi-plugin/src/__tests__/bash.test.ts b/packages/pi-plugin/src/__tests__/bash.test.ts index 48fe788f27..701325bdae 100644 --- a/packages/pi-plugin/src/__tests__/bash.test.ts +++ b/packages/pi-plugin/src/__tests__/bash.test.ts @@ -1283,7 +1283,7 @@ describe("bash tool adapter", () => { .get("bash_status")! .execute( "call", - { task_id: "bash-lines", outputOffset: 16, stderrOffset: 10 }, + { task_id: "bash-lines", output_offset: 16, stderr_offset: 10 }, undefined, undefined, { cwd: projectRoot }, @@ -1294,7 +1294,7 @@ describe("bash tool adapter", () => { ]; expect(status[1]).toMatchObject({ output_offset: 16, stderr_offset: 10 }); expect(result.content[0].text).toContain("masked output"); - expect(result.content[0].text).toContain("next outputOffset: 48"); + expect(result.content[0].text).toContain("next output_offset: 48"); expect(result.content[0].text).not.toContain("RAW-SECRET"); }); diff --git a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts index e4ac8ad04e..5b1c4cbbf4 100644 --- a/packages/pi-plugin/src/__tests__/bg-notifications.test.ts +++ b/packages/pi-plugin/src/__tests__/bg-notifications.test.ts @@ -1448,7 +1448,7 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { match_text: "matching lines", match_offset: 0, context: - 'stdout byte 0 ×2: ERROR a\n3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", outputOffset: 16 }).', + 'stdout byte 0 ×2: ERROR a\n3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", output_offset: 16 }).', once: false, batch: { lines: [{ stream: "stdout", offset: 0, end_offset: 8, text: "ERROR a", count: 2 }], @@ -1462,7 +1462,7 @@ test("each_line reminder keeps bounded rows and drop recovery hint", () => { expect(reminder).toContain("task bash-lines matching lines:"); expect(reminder).toContain("stdout byte 0 ×2: ERROR a"); expect(reminder).toContain( - '3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", outputOffset: 16 }).', + '3 matching lines dropped. Read them with bash_status({ task_id: "bash-lines", output_offset: 16 }).', ); }); diff --git a/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json b/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json index 0b392da7d0..30d2bcee34 100644 --- a/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json +++ b/packages/pi-plugin/src/__tests__/fixtures/pi-bash-family.json @@ -98,13 +98,13 @@ "task_id" ], "properties": { - "outputOffset": { + "output_offset": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "description": "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset." }, - "stderrOffset": { + "stderr_offset": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, diff --git a/packages/pi-plugin/src/bg-notifications.ts b/packages/pi-plugin/src/bg-notifications.ts index 3e7d0eaf84..2e96ab2f96 100644 --- a/packages/pi-plugin/src/bg-notifications.ts +++ b/packages/pi-plugin/src/bg-notifications.ts @@ -832,7 +832,7 @@ export function formatPatternMatchReminder(matches: readonly PatternMatchEntry[] .map((match) => { const context = ( match.batch - ? formatWatchLineBatch(match.task_id, match.batch, false, true) + ? formatWatchLineBatch(match.task_id, match.batch, false) : match.context || match.match_text ).replace(/\n/g, "\n > "); if (match.batch) { diff --git a/packages/pi-plugin/src/tools/bash.ts b/packages/pi-plugin/src/tools/bash.ts index f482e88a6e..96af718bf1 100644 --- a/packages/pi-plugin/src/tools/bash.ts +++ b/packages/pi-plugin/src/tools/bash.ts @@ -414,7 +414,7 @@ const BashTaskParams = Type.Object({ }); const BashStatusParams = Type.Object({ - outputOffset: Type.Optional( + output_offset: Type.Optional( Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, @@ -422,7 +422,7 @@ const BashStatusParams = Type.Object({ "Read a bounded stdout/PTY byte page from this offset; returns masked text and the next byte offset.", }), ), - stderrOffset: Type.Optional( + stderr_offset: Type.Optional( Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, @@ -1150,7 +1150,7 @@ export function createBashStatusTool(ctx: PluginContext) { // bash_status is snapshot-only. wait_for / exit / timeout_ms moved to // bash_watch; if the agent passes them here they're silently ignored // at the TypeBox schema layer. - const paging = params.outputOffset !== undefined || params.stderrOffset !== undefined; + const paging = params.output_offset !== undefined || params.stderr_offset !== undefined; const data = paging ? await callBashBridge( bridge, @@ -1159,14 +1159,14 @@ export function createBashStatusTool(ctx: PluginContext) { task_id: params.task_id, output_mode: params.output_mode, output_offset: coerceOptionalInt( - params.outputOffset, - "outputOffset", + params.output_offset, + "output_offset", 0, Number.MAX_SAFE_INTEGER, ), stderr_offset: coerceOptionalInt( - params.stderrOffset, - "stderrOffset", + params.stderr_offset, + "stderr_offset", 0, Number.MAX_SAFE_INTEGER, ), @@ -1177,7 +1177,7 @@ export function createBashStatusTool(ctx: PluginContext) { if (data.success === false) throw new Error(String(data.message ?? "bash_status failed")); if (paging) return bashStatusResult( - formatBashOutputPages(data, true), + formatBashOutputPages(data, false), data as unknown as BashStatusDetails, ); const details = data as unknown as BashStatusDetails; From 030a6719be0139f1629f0e68c0d21d3d8ff34c00 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:24:03 +0100 Subject: [PATCH 12/15] mason: regen provider catalog presets on new main (runon macos wording) Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../fixtures/tool_provider_catalog_presets.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json index 4fd91db8ee..c57fc43c87 100644 --- a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json +++ b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json @@ -3461,8 +3461,8 @@ "powershell_available": false, "remote_runs": true, "reply": { - "generation": "a2c3c971952943f8154cd186c3e5bfa9589bd2e97d32e42a0762423beb98fbdc", - "catalog_digest": "a2c3c971952943f8154cd186c3e5bfa9589bd2e97d32e42a0762423beb98fbdc", + "generation": "d153da41964a07c9ef4c9b433bf68ca0c944480690f4a31a33576574e42806bc", + "catalog_digest": "d153da41964a07c9ef4c9b433bf68ca0c944480690f4a31a33576574e42806bc", "tools": [ { "name": "status", @@ -3495,7 +3495,7 @@ "capabilities": [ "shell.exec/v1" ], - "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", + "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; a build or test that needs only macOS (its sandbox, code signing or file watching) can use runon: \"macos\" instead; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", "reply": { "max_ms": 2040000 }, @@ -3556,7 +3556,7 @@ }, "runon": { "type": "string", - "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options)." + "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options). `macos` sends the command to a macOS machine for builds and tests that need macOS (no vCPU or network options)." } }, "required": [ @@ -4709,8 +4709,8 @@ "powershell_available": true, "remote_runs": true, "reply": { - "generation": "e2c94ff20b64a6c8ee850ae1f49da2ba4fb72dc25e61607ebb478a8c177c7a37", - "catalog_digest": "e2c94ff20b64a6c8ee850ae1f49da2ba4fb72dc25e61607ebb478a8c177c7a37", + "generation": "e97b1a5727c7d6d1598135e0d24e48ceca1653fef1c2d07a2bb85bf034f7d2d3", + "catalog_digest": "e97b1a5727c7d6d1598135e0d24e48ceca1653fef1c2d07a2bb85bf034f7d2d3", "tools": [ { "name": "status", @@ -4743,7 +4743,7 @@ "capabilities": [ "shell.exec/v1" ], - "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", + "description": "Execute shell commands. Output is compressed by default; pass compressed: false for raw output. Piped commands run verbatim and show the pipeline's output; for AFT's test/build summary, run the runner without | head, | tail, or | grep. Pipeline-failure notes cover single top-level pipelines only; multi-statement commands (`a; b | c; d`) are not instrumented, so masked failures inside them still need explicit exit-code checks. Commands run in the foreground and return inline; wait: true blocks until a long command finishes instead of auto-promoting (it blocks up to bash.worker_wait_max_ms, 30 minutes by default, then reports the command is still running; watch again to keep waiting); Any new message detaches this wait. Set `bash.detach_on_user_message: false` to keep it blocking; even then, a message containing the literal `&detach` forces detachment, and the token is stripped before delivery and the rest of the message is preserved; a token-only message becomes `(requested background detach)`. Use it when you need the result before doing anything else; keep it off otherwise so a long command moves to the background while you work. Use background: true yourself ONLY when you have other useful work to do while it runs; then wait on it with bash_watch before you report a result: a background task never wakes you, and a watch without a timeout waits up to the worker wait limit (`bash.worker_wait_max_ms`, 30 minutes by default), then reports it is still running; watch again to keep waiting. Never background a command and immediately bash_watch it (that wastes a turn for what foreground returns in one); never loop bash_status to wait. Do not change or delete files a background command uses until it finishes. A `nohup … &` launch still holds the call if the child keeps stdout/stderr; redirect both or use background:true. pty: true runs interactive programs (REPLs, TUIs), implies background, and is driven with bash_status({ outputMode: \"screen\" }) plus bash_write. Finished output expires after the task is 24 hours old and its completion has been delivered; under project-root restrictions, only the starting session can read output outside the project—copy cited lines into your report. `timeout` starts after spawn and kills the Unix process group (exit 124; Windows uses taskkill /T /F); processes that leave the group survive. When remote runs are available, put `runon: \"linux\"` on build and test lines (cargo, bun test), including chains and pipes. Keep git, gh, interactive and file-editing commands local, and keep a line local if it needs this machine's own operating system or hardware (its sandbox, code signing, service manager, permission prompts or GUI frameworks) or runs a binary that already exists on this machine; a build or test that needs only macOS (its sandbox, code signing or file watching) can use runon: \"macos\" instead; building and testing still go remote, and a remote build leaves no binaries or target/ output in the local worktree. Add `,Nc` to request N vCPUs (`linux,4c`); without it the job gets 2 vCPUs. The job sees exactly the CPUs it gets, so ask for what the command uses: 2c (the default) for a single test binary or script, 4c for `cargo check`/clippy on one crate, 8c for a workspace test suite, and 16c for a large release build.\n\nDO NOT use bash for code search or code exploration. If you are about to run grep, rg, sed, awk, find, or cat through bash to locate or read code: STOP — use the grep tool, read, outline, or zoom instead. When a list is cut, the reply ends with `shown N of M () · narrow: `; absence of that line means the list is complete.", "reply": { "max_ms": 2040000 }, @@ -4804,7 +4804,7 @@ }, "runon": { "type": "string", - "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options)." + "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options). `macos` sends the command to a macOS machine for builds and tests that need macOS (no vCPU or network options)." } }, "required": [ From b080b454c611a066ea1aebb5d7eabdce74119430 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:36:03 +0100 Subject: [PATCH 13/15] mason: check in native each_line terminal frame, stop spawning cargo in host test Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/bash_background/registry.rs | 18 ++-- .../fixtures/each-line-terminal-frame.json | 19 ++++ .../src/__tests__/v2-pattern-wakes.test.ts | 91 +++++++------------ 3 files changed, 66 insertions(+), 62 deletions(-) create mode 100644 packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json diff --git a/crates/aft/src/bash_background/registry.rs b/crates/aft/src/bash_background/registry.rs index 6988414b03..944e3c1c97 100644 --- a/crates/aft/src/bash_background/registry.rs +++ b/crates/aft/src/bash_background/registry.rs @@ -27401,12 +27401,18 @@ mod tests { #[test] fn each_line_terminal_summary_frame_fixture() { - // Host integration tests consume the native emitter's frame, not a - // reconstruction that could hide a disagreement in durable identity. - println!( - "AFT_EACH_LINE_TERMINAL_FRAME={}", - serde_json::to_string(&each_line_terminal_frame()).unwrap() - ); + // Host integration tests consume the checked-in native emitter frame + // (packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json), + // not a reconstruction that could hide a disagreement in durable identity. + // Spawning cargo from a host test hangs offline builders, so the frame + // lives in the file and this test pins it: if the emitter changes, + // update the fixture to the new live frame printed below. + let live = serde_json::to_value(&each_line_terminal_frame()).unwrap(); + let pinned: serde_json::Value = serde_json::from_str(include_str!( + "../../../packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json" + )) + .unwrap(); + assert_eq!(live, pinned); } #[test] diff --git a/packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json b/packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json new file mode 100644 index 0000000000..f8e645e7d8 --- /dev/null +++ b/packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json @@ -0,0 +1,19 @@ +{ + "type": "bash_pattern_match", + "task_id": "bash-running", + "session_id": "session-v2", + "watch_id": "watch-ready", + "match_text": "matching lines", + "match_offset": 123, + "context": "7 matching lines dropped. Read them with bash_status({ task_id: \"bash-running\", stderr_offset: 123 }).", + "once": false, + "reason": "pattern_match", + "batch": { + "terminal_summary": true, + "lines": [], + "dropped_lines": 7, + "first_dropped_offset": 123, + "first_dropped_stream": "stderr", + "skipped_long_lines": 0 + } +} diff --git a/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts b/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts index d5d1b1ec8e..eaf37882ff 100644 --- a/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts +++ b/packages/opencode-plugin/src/__tests__/v2-pattern-wakes.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { BashPatternMatchFrame } from "@cortexkit/aft-bridge"; import { Effect, Exit, PubSub, Scope, Stream } from "effect"; @@ -654,64 +655,42 @@ function latch() { } function nativeTerminalFrame(): BashPatternMatchFrame { - const fixture = Bun.spawnSync({ - cmd: [ - "cargo", - "test", - "-p", - "agent-file-tools", - "--lib", - "--", - "bash_background::registry::tests::each_line_terminal_summary_frame_fixture", - "--exact", - "--nocapture", - ], - cwd: join(import.meta.dir, "../../../.."), - env: { ...process.env, RUSTC_WRAPPER: "", CARGO_BUILD_RUSTC_WRAPPER: "" }, - stdout: "pipe", - stderr: "pipe", - timeout: 20 * 60 * 1000, - }); - if (fixture.exitCode !== 0) - throw new Error(`native terminal fixture failed: ${fixture.stderr.toString()}`); - const frame = fixture.stdout.toString().match(/AFT_EACH_LINE_TERMINAL_FRAME=(\{[^\n]+\})/)?.[1]; - if (!frame) - throw new Error(`native terminal fixture emitted no frame: ${fixture.stdout.toString()}`); - return JSON.parse(frame) as BashPatternMatchFrame; + // Checked-in native emitter output (see each-line-terminal-frame.json). + // Spawning cargo from a host test hangs offline builders; the Rust suite + // asserts the live frame equals this pinned file. + return JSON.parse( + readFileSync(join(import.meta.dir, "fixtures/each-line-terminal-frame.json"), "utf8"), + ) as BashPatternMatchFrame; } -test( - "V2 each_line terminal cap summary survives the durable version fence", - async () => { - const native = nativeTerminalFrame(); - const r = await rig(); - try { - await r.register(false, "each_line"); - const pending = eachLineSnapshot("terminal-pending", eachLineFrame(0, "READY first")); - pending.match_offset = 123; - pending.batch!.dropped_lines = 7; - pending.batch!.first_dropped_offset = 123; - pending.batch!.first_dropped_stream = "stderr"; - r.setPending(pending); - await r.push(native); - expect(r.synthetic).toHaveLength(1); - expect(r.synthetic[0]?.text).toContain( - '7 matching lines dropped. Read them with bash_status({ taskId: "bash-running", stderrOffset: 123 }).', - ); - expect(r.synthetic[0]?.text).toContain("stdout byte 0: READY first"); - expect( - r.sends.filter((send) => send.command === "bash_ack_completions").at(-1)?.params, - ).toEqual({ - session_id: "session-v2", - matches: [{ task_id: "bash-running", watch_id: "watch-ready", match_offset: 123 }], - }); - expect(r.pending()).toBeUndefined(); - } finally { - await r.close(); - } - }, - 20 * 60 * 1000, -); +test("V2 each_line terminal cap summary survives the durable version fence", async () => { + const native = nativeTerminalFrame(); + const r = await rig(); + try { + await r.register(false, "each_line"); + const pending = eachLineSnapshot("terminal-pending", eachLineFrame(0, "READY first")); + pending.match_offset = 123; + pending.batch!.dropped_lines = 7; + pending.batch!.first_dropped_offset = 123; + pending.batch!.first_dropped_stream = "stderr"; + r.setPending(pending); + await r.push(native); + expect(r.synthetic).toHaveLength(1); + expect(r.synthetic[0]?.text).toContain( + '7 matching lines dropped. Read them with bash_status({ taskId: "bash-running", stderrOffset: 123 }).', + ); + expect(r.synthetic[0]?.text).toContain("stdout byte 0: READY first"); + expect( + r.sends.filter((send) => send.command === "bash_ack_completions").at(-1)?.params, + ).toEqual({ + session_id: "session-v2", + matches: [{ task_id: "bash-running", watch_id: "watch-ready", match_offset: 123 }], + }); + expect(r.pending()).toBeUndefined(); + } finally { + await r.close(); + } +}); test("V2 each_line superseded push delivers every durable line once", async () => { const r = await rig(); From 54951a8e439811dae61aef9be795baecc6157e47 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:41:13 +0100 Subject: [PATCH 14/15] mason: fix fixture include path depth Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/bash_background/registry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/aft/src/bash_background/registry.rs b/crates/aft/src/bash_background/registry.rs index 944e3c1c97..eb5849085f 100644 --- a/crates/aft/src/bash_background/registry.rs +++ b/crates/aft/src/bash_background/registry.rs @@ -27409,7 +27409,7 @@ mod tests { // update the fixture to the new live frame printed below. let live = serde_json::to_value(&each_line_terminal_frame()).unwrap(); let pinned: serde_json::Value = serde_json::from_str(include_str!( - "../../../packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json" + "../../../../packages/opencode-plugin/src/__tests__/fixtures/each-line-terminal-frame.json" )) .unwrap(); assert_eq!(live, pinned); From d81aa7f5a7479379e5e04aeb81172c73cf5241c4 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:36:44 +0100 Subject: [PATCH 15/15] mason: regen provider catalog fixtures on new main (tool-result/v1) Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../tests/fixtures/tool_provider_catalog.json | 473 ++++++++++++++++-- .../tool_provider_catalog_presets.json | 158 +++++- 2 files changed, 581 insertions(+), 50 deletions(-) diff --git a/crates/aft/tests/fixtures/tool_provider_catalog.json b/crates/aft/tests/fixtures/tool_provider_catalog.json index 2dd70103fe..aaba6df45e 100644 --- a/crates/aft/tests/fixtures/tool_provider_catalog.json +++ b/crates/aft/tests/fixtures/tool_provider_catalog.json @@ -16,8 +16,8 @@ "version": "0.58.1" }, "reply": { - "generation": "728db7e124f8386f259da5d259d05a0488a130eb49079b4e0142bb4684181938", - "catalog_digest": "728db7e124f8386f259da5d259d05a0488a130eb49079b4e0142bb4684181938", + "generation": "c621e6a29d07b643da41d02373808e183753d3e4d6137bce1c3a05f1e9ad3755", + "catalog_digest": "c621e6a29d07b643da41d02373808e183753d3e4d6137bce1c3a05f1e9ad3755", "tools": [ { "name": "status", @@ -27,6 +27,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -47,6 +48,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -128,6 +130,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -178,6 +181,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -209,6 +213,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -291,6 +296,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -318,6 +324,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -356,6 +363,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -385,6 +393,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -433,6 +442,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -478,6 +488,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -542,6 +553,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -602,6 +614,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -672,6 +685,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -695,6 +709,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -759,6 +774,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -826,6 +842,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -860,6 +877,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -891,6 +909,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -977,6 +996,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -1026,6 +1046,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1073,6 +1094,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1099,6 +1121,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1206,8 +1229,8 @@ "version": "0.58.1" }, "reply": { - "generation": "e1357ffd76e61815219e6c6c97f60c0f6c88078bab01895780652473591051dc", - "catalog_digest": "e1357ffd76e61815219e6c6c97f60c0f6c88078bab01895780652473591051dc", + "generation": "1b3ece74b8988a4b946169e8a3c765f9b093814ed8c8d7640cc64a21b904c580", + "catalog_digest": "1b3ece74b8988a4b946169e8a3c765f9b093814ed8c8d7640cc64a21b904c580", "tools": [ { "name": "status", @@ -1217,6 +1240,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -1237,6 +1261,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1318,6 +1343,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -1368,6 +1394,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1399,6 +1426,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1481,6 +1509,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1508,6 +1537,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1546,6 +1576,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1575,6 +1606,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1623,6 +1655,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1668,6 +1701,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1732,6 +1766,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -1792,6 +1827,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -1862,6 +1898,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -1885,6 +1922,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -1949,6 +1987,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -2016,6 +2055,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2050,6 +2090,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2081,6 +2122,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -2167,6 +2209,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -2216,6 +2259,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2263,6 +2307,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2289,6 +2334,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2398,8 +2444,8 @@ "version": "0.58.1" }, "reply": { - "generation": "9e4aa444af3fd3cb3e749a3d8df9b46f179c7ad99a79a0d0c53623bf99161e8b", - "catalog_digest": "9e4aa444af3fd3cb3e749a3d8df9b46f179c7ad99a79a0d0c53623bf99161e8b", + "generation": "1ad048c2ff6adbe3ffa9c7bc9f789d4b5fc86e6a5eb1d9de7e8040162eb85f35", + "catalog_digest": "1ad048c2ff6adbe3ffa9c7bc9f789d4b5fc86e6a5eb1d9de7e8040162eb85f35", "tools": [ { "name": "status", @@ -2409,6 +2455,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -2430,6 +2477,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -2480,6 +2528,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -2511,6 +2560,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -2593,6 +2643,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -2620,6 +2671,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2658,6 +2710,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2687,6 +2740,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2735,6 +2789,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2780,6 +2835,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2844,6 +2900,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -2904,6 +2961,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -2974,6 +3032,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -2997,6 +3056,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -3061,6 +3121,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -3128,6 +3189,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -3162,6 +3224,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -3193,6 +3256,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -3279,6 +3343,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -3328,6 +3393,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3375,6 +3441,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3401,6 +3468,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3509,8 +3577,8 @@ "version": "0.58.1" }, "reply": { - "generation": "91e2fd53f58b07d7c339faf5e78d11ef9b6e85147af7a40ab603b441e1bc02cd", - "catalog_digest": "91e2fd53f58b07d7c339faf5e78d11ef9b6e85147af7a40ab603b441e1bc02cd", + "generation": "90b7a2729864ce8f7dffc0b3c1ab3613755b6b76dd50950bacb01fc92edaf5a8", + "catalog_digest": "90b7a2729864ce8f7dffc0b3c1ab3613755b6b76dd50950bacb01fc92edaf5a8", "tools": [ { "name": "status", @@ -3520,6 +3588,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -3540,6 +3609,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3621,6 +3691,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -3671,6 +3742,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3702,6 +3774,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3784,6 +3857,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3811,6 +3885,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3849,6 +3924,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3878,6 +3954,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3926,6 +4003,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3990,6 +4068,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -4050,6 +4129,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -4120,6 +4200,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -4143,6 +4224,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4207,6 +4289,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4274,6 +4357,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4308,6 +4392,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4339,6 +4424,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -4425,6 +4511,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -4474,6 +4561,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4521,6 +4609,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4547,6 +4636,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4655,8 +4745,8 @@ "version": "0.58.1" }, "reply": { - "generation": "89e642e6224cb678eb1df833e767bb10a9776928170f395a1698decb3f300d96", - "catalog_digest": "89e642e6224cb678eb1df833e767bb10a9776928170f395a1698decb3f300d96", + "generation": "e1c063e9bc26a653697afc6c257010ec570dd63c560e7a8bbc9d3c23d504ee74", + "catalog_digest": "e1c063e9bc26a653697afc6c257010ec570dd63c560e7a8bbc9d3c23d504ee74", "tools": [ { "name": "status", @@ -4666,6 +4756,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -4686,6 +4777,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4767,6 +4859,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -4817,6 +4910,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -4848,6 +4942,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -4930,6 +5025,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -4957,6 +5053,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -4995,6 +5092,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5024,6 +5122,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5069,6 +5168,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5133,6 +5233,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -5193,6 +5294,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -5263,6 +5365,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -5286,6 +5389,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5350,6 +5454,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5417,6 +5522,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5451,6 +5557,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5482,6 +5589,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -5568,6 +5676,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -5617,6 +5726,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5664,6 +5774,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5690,6 +5801,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5799,8 +5911,8 @@ "version": "0.58.1" }, "reply": { - "generation": "957b3e0e5af1f0969d51bd3846b7fa089a375ecc8a197d13d617300a21629d5b", - "catalog_digest": "957b3e0e5af1f0969d51bd3846b7fa089a375ecc8a197d13d617300a21629d5b", + "generation": "a22c252064e236f622232985f3fe96fb6c1043165209a4b79e63e49098b1f449", + "catalog_digest": "a22c252064e236f622232985f3fe96fb6c1043165209a4b79e63e49098b1f449", "tools": [ { "name": "status", @@ -5810,6 +5922,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -5830,6 +5943,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5911,6 +6025,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -5961,6 +6076,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -5992,6 +6108,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -6074,6 +6191,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -6101,6 +6219,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -6139,6 +6258,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -6168,6 +6288,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -6216,6 +6337,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -6281,6 +6403,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -6351,6 +6474,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -6374,6 +6498,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -6438,6 +6563,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -6505,6 +6631,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -6539,6 +6666,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -6570,6 +6698,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -6656,6 +6785,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -6705,6 +6835,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -6752,6 +6883,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -6778,6 +6910,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -6886,8 +7019,8 @@ "version": "0.58.1" }, "reply": { - "generation": "cf835bfa09517237d104d775202299acacd759a325a687de1949c6b32b24084d", - "catalog_digest": "cf835bfa09517237d104d775202299acacd759a325a687de1949c6b32b24084d", + "generation": "7fe28a424ad3ba01136b69644285a7674e001d7fc57521a72bf67790609d8a26", + "catalog_digest": "7fe28a424ad3ba01136b69644285a7674e001d7fc57521a72bf67790609d8a26", "tools": [ { "name": "status", @@ -6897,6 +7030,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -6917,6 +7051,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -6998,6 +7133,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -7048,6 +7184,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -7079,6 +7216,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -7161,6 +7299,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -7188,6 +7327,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -7226,6 +7366,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -7255,6 +7396,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -7300,6 +7442,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -7365,6 +7508,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -7435,6 +7579,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -7458,6 +7603,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -7522,6 +7668,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -7589,6 +7736,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -7623,6 +7771,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -7654,6 +7803,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -7740,6 +7890,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -7789,6 +7940,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -7836,6 +7988,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -7862,6 +8015,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -7970,8 +8124,8 @@ "version": "0.58.1" }, "reply": { - "generation": "3092326e1aae4eecdcbb5a0a635fdd1a84c6683e5bf07a473dfb0bcc62c16bb9", - "catalog_digest": "3092326e1aae4eecdcbb5a0a635fdd1a84c6683e5bf07a473dfb0bcc62c16bb9", + "generation": "9d0c2c1e4c88456e228d3c60957362c0c4ef37c0248a5f915038692b2247f8b0", + "catalog_digest": "9d0c2c1e4c88456e228d3c60957362c0c4ef37c0248a5f915038692b2247f8b0", "tools": [ { "name": "status", @@ -7981,6 +8135,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -8001,6 +8156,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -8082,6 +8238,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -8132,6 +8289,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -8163,6 +8321,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -8245,6 +8404,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -8272,6 +8432,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -8310,6 +8471,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -8339,6 +8501,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -8386,6 +8549,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -8446,6 +8610,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -8516,6 +8681,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -8539,6 +8705,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -8603,6 +8770,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -8670,6 +8838,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -8704,6 +8873,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -8735,6 +8905,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -8821,6 +8992,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -8870,6 +9042,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -8917,6 +9090,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -8943,6 +9117,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -9051,8 +9226,8 @@ "version": "0.58.1" }, "reply": { - "generation": "6677ec15e9d8ce24d46ae302b04739e7099f3c30a9249a3a1fce818bb3e27fd4", - "catalog_digest": "6677ec15e9d8ce24d46ae302b04739e7099f3c30a9249a3a1fce818bb3e27fd4", + "generation": "2cef50c1c131899ed951a620e0ff8311e74c339e2a97f33bcfe24a04adfdd460", + "catalog_digest": "2cef50c1c131899ed951a620e0ff8311e74c339e2a97f33bcfe24a04adfdd460", "tools": [ { "name": "status", @@ -9062,6 +9237,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -9082,6 +9258,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -9163,6 +9340,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -9213,6 +9391,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -9244,6 +9423,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -9326,6 +9506,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -9353,6 +9534,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -9391,6 +9573,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -9420,6 +9603,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -9464,6 +9648,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -9524,6 +9709,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -9594,6 +9780,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -9617,6 +9804,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -9681,6 +9869,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -9748,6 +9937,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -9782,6 +9972,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -9813,6 +10004,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -9899,6 +10091,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -9948,6 +10141,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -9995,6 +10189,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10021,6 +10216,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10126,8 +10322,8 @@ "version": "0.58.1" }, "reply": { - "generation": "2209e57bf674816437255b2fc06e02027d7218fac0305e4370ed7f80f8b36f5a", - "catalog_digest": "2209e57bf674816437255b2fc06e02027d7218fac0305e4370ed7f80f8b36f5a", + "generation": "2b3c5656f572647d340078589532de676a8a71938305ad7ae02380d0f2ec921d", + "catalog_digest": "2b3c5656f572647d340078589532de676a8a71938305ad7ae02380d0f2ec921d", "tools": [ { "name": "status", @@ -10137,6 +10333,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -10157,6 +10354,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10233,6 +10431,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -10311,6 +10510,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -10361,6 +10561,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -10392,6 +10593,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -10474,6 +10676,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -10501,6 +10704,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -10539,6 +10743,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -10568,6 +10773,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -10616,6 +10822,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -10661,6 +10868,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -10725,6 +10933,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -10785,6 +10994,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -10855,6 +11065,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -10878,6 +11089,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -10942,6 +11154,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -11009,6 +11222,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -11043,6 +11257,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -11074,6 +11289,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -11160,6 +11376,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -11209,6 +11426,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11256,6 +11474,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11282,6 +11501,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11390,8 +11610,8 @@ "version": "0.58.1" }, "reply": { - "generation": "e8516995312c1017b12431d251d0bba950b50c41075aa196ac841393e345aa1d", - "catalog_digest": "e8516995312c1017b12431d251d0bba950b50c41075aa196ac841393e345aa1d", + "generation": "762051e55fed01e192c15a1c46c624ab624ad10e6b6f62f681c30d9a7125364f", + "catalog_digest": "762051e55fed01e192c15a1c46c624ab624ad10e6b6f62f681c30d9a7125364f", "tools": [ { "name": "status", @@ -11401,6 +11621,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -11421,6 +11642,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11497,6 +11719,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -11575,6 +11798,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -11625,6 +11849,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -11656,6 +11881,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -11738,6 +11964,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -11765,6 +11992,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -11803,6 +12031,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -11832,6 +12061,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -11880,6 +12110,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -11925,6 +12156,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -11989,6 +12221,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -12049,6 +12282,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -12119,6 +12353,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -12142,6 +12377,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -12206,6 +12442,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -12273,6 +12510,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -12307,6 +12545,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -12338,6 +12577,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -12424,6 +12664,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -12473,6 +12714,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12520,6 +12762,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12546,6 +12789,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12656,8 +12900,8 @@ "version": "0.58.1" }, "reply": { - "generation": "f73cf766bcc33f7ae27132124821b71912e0ab0d84cb14064af18e230db8e9a2", - "catalog_digest": "f73cf766bcc33f7ae27132124821b71912e0ab0d84cb14064af18e230db8e9a2", + "generation": "16b786700d65c0a8cf5d85f391175635dde44667e5e3a3296f44cd72c77ad269", + "catalog_digest": "16b786700d65c0a8cf5d85f391175635dde44667e5e3a3296f44cd72c77ad269", "tools": [ { "name": "status", @@ -12667,6 +12911,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -12683,6 +12928,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -12761,6 +13007,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -12811,6 +13058,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -12842,6 +13090,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -12924,6 +13173,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -12951,6 +13201,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -12989,6 +13240,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -13018,6 +13270,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -13066,6 +13319,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -13111,6 +13365,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -13175,6 +13430,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -13235,6 +13491,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -13305,6 +13562,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -13328,6 +13586,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -13392,6 +13651,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -13459,6 +13719,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -13493,6 +13754,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -13524,6 +13786,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -13610,6 +13873,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -13659,6 +13923,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -13706,6 +13971,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -13732,6 +13998,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -13841,8 +14108,8 @@ "version": "0.58.1" }, "reply": { - "generation": "8e803035b2aebf036af01ccae8f82b3d501b3cf39341989cc7747dce3f6caa14", - "catalog_digest": "8e803035b2aebf036af01ccae8f82b3d501b3cf39341989cc7747dce3f6caa14", + "generation": "7a268824e9c7b27ad062a87368944391e45434eeddbcac435b19d54dd52737ba", + "catalog_digest": "7a268824e9c7b27ad062a87368944391e45434eeddbcac435b19d54dd52737ba", "tools": [ { "name": "status", @@ -13852,6 +14119,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -13872,6 +14140,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -13948,6 +14217,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -14026,6 +14296,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -14076,6 +14347,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -14107,6 +14379,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -14189,6 +14462,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -14216,6 +14490,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -14254,6 +14529,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -14283,6 +14559,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -14331,6 +14608,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -14395,6 +14673,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -14455,6 +14734,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -14525,6 +14805,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -14548,6 +14829,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -14612,6 +14894,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -14679,6 +14962,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -14713,6 +14997,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -14744,6 +15029,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -14830,6 +15116,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -14879,6 +15166,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -14926,6 +15214,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -14952,6 +15241,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15061,8 +15351,8 @@ "version": "0.58.1" }, "reply": { - "generation": "9a5f6c22b3d7576a0e64369c36e6d7ffb38539759466fc621d591deee0f7a52d", - "catalog_digest": "9a5f6c22b3d7576a0e64369c36e6d7ffb38539759466fc621d591deee0f7a52d", + "generation": "87d80fc4163856625d6e5f24729dc120ead10c5d8d598958331f18a04fddf4d4", + "catalog_digest": "87d80fc4163856625d6e5f24729dc120ead10c5d8d598958331f18a04fddf4d4", "tools": [ { "name": "status", @@ -15072,6 +15362,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -15092,6 +15383,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15168,6 +15460,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -15246,6 +15539,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -15296,6 +15590,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -15327,6 +15622,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -15409,6 +15705,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -15436,6 +15733,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -15474,6 +15772,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -15503,6 +15802,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -15548,6 +15848,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -15612,6 +15913,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -15672,6 +15974,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -15742,6 +16045,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -15765,6 +16069,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -15829,6 +16134,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -15896,6 +16202,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -15930,6 +16237,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -15961,6 +16269,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -16047,6 +16356,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -16096,6 +16406,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16143,6 +16454,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16169,6 +16481,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16279,8 +16592,8 @@ "version": "0.58.1" }, "reply": { - "generation": "a38cc6990aecff7670a96ead2ce060330ce90b07942444097c97225fec4f7df0", - "catalog_digest": "a38cc6990aecff7670a96ead2ce060330ce90b07942444097c97225fec4f7df0", + "generation": "5ce1f0b73d818cc8ab742ffdb48feec3c50562e6983431cb894898112e4de92a", + "catalog_digest": "5ce1f0b73d818cc8ab742ffdb48feec3c50562e6983431cb894898112e4de92a", "tools": [ { "name": "status", @@ -16290,6 +16603,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -16310,6 +16624,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16386,6 +16701,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -16464,6 +16780,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -16514,6 +16831,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -16545,6 +16863,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -16627,6 +16946,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -16654,6 +16974,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -16692,6 +17013,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -16721,6 +17043,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -16769,6 +17092,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -16834,6 +17158,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -16904,6 +17229,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -16927,6 +17253,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -16991,6 +17318,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -17058,6 +17386,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -17092,6 +17421,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -17123,6 +17453,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -17209,6 +17540,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -17258,6 +17590,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17305,6 +17638,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17331,6 +17665,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17440,8 +17775,8 @@ "version": "0.58.1" }, "reply": { - "generation": "617faa8cc7dc334d52961fefd54be7afeed680fab657e45e3984cd340f2754d0", - "catalog_digest": "617faa8cc7dc334d52961fefd54be7afeed680fab657e45e3984cd340f2754d0", + "generation": "404493cd9d2a9e68b5a396740fd39db1cab540f1d6d96ec595b51c20175d23cf", + "catalog_digest": "404493cd9d2a9e68b5a396740fd39db1cab540f1d6d96ec595b51c20175d23cf", "tools": [ { "name": "status", @@ -17451,6 +17786,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -17471,6 +17807,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17547,6 +17884,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -17625,6 +17963,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -17675,6 +18014,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -17706,6 +18046,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -17788,6 +18129,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -17815,6 +18157,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -17853,6 +18196,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -17882,6 +18226,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -17927,6 +18272,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -17992,6 +18338,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -18062,6 +18409,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -18085,6 +18433,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -18149,6 +18498,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -18216,6 +18566,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -18250,6 +18601,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -18281,6 +18633,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -18367,6 +18720,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -18416,6 +18770,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18463,6 +18818,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18489,6 +18845,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18598,8 +18955,8 @@ "version": "0.58.1" }, "reply": { - "generation": "715f6b1b6525bcde7d65a11228e11011ff477dc0d07ff4e08181b060ea8219c9", - "catalog_digest": "715f6b1b6525bcde7d65a11228e11011ff477dc0d07ff4e08181b060ea8219c9", + "generation": "e4d7a59eed06c1146b53260b7ca2c4b3309dacde65ee49d7ca7a9e9f5a2d90f4", + "catalog_digest": "e4d7a59eed06c1146b53260b7ca2c4b3309dacde65ee49d7ca7a9e9f5a2d90f4", "tools": [ { "name": "status", @@ -18609,6 +18966,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -18629,6 +18987,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18705,6 +19064,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -18783,6 +19143,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -18833,6 +19194,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -18864,6 +19226,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -18946,6 +19309,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -18973,6 +19337,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -19011,6 +19376,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -19040,6 +19406,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -19087,6 +19454,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -19147,6 +19515,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -19217,6 +19586,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -19240,6 +19610,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -19304,6 +19675,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -19371,6 +19743,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -19405,6 +19778,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -19436,6 +19810,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -19522,6 +19897,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -19571,6 +19947,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19618,6 +19995,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19644,6 +20022,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19753,8 +20132,8 @@ "version": "0.58.1" }, "reply": { - "generation": "28c2d2a2ced659f7ab324f8f5d94c38f78ed7fccf727c6bac31c2a4cbdf7fcd7", - "catalog_digest": "28c2d2a2ced659f7ab324f8f5d94c38f78ed7fccf727c6bac31c2a4cbdf7fcd7", + "generation": "411b938e273cda011b8808cc6551e6c12ce9bfdf66c91e85e0bd740fe02cfc1e", + "catalog_digest": "411b938e273cda011b8808cc6551e6c12ce9bfdf66c91e85e0bd740fe02cfc1e", "tools": [ { "name": "status", @@ -19764,6 +20143,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -19784,6 +20164,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19860,6 +20241,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -19938,6 +20320,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -19988,6 +20371,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -20019,6 +20403,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -20101,6 +20486,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -20128,6 +20514,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -20166,6 +20553,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -20195,6 +20583,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -20239,6 +20628,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -20299,6 +20689,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -20369,6 +20760,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -20392,6 +20784,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -20456,6 +20849,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -20523,6 +20917,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -20557,6 +20952,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -20588,6 +20984,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -20674,6 +21071,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -20723,6 +21121,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -20770,6 +21169,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -20796,6 +21196,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], diff --git a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json index c57fc43c87..03af3a6f99 100644 --- a/crates/aft/tests/fixtures/tool_provider_catalog_presets.json +++ b/crates/aft/tests/fixtures/tool_provider_catalog_presets.json @@ -10,8 +10,8 @@ "disabled_tools": [], "powershell_available": false, "reply": { - "generation": "7fffbe19c43f8d672e5bb502c276c7ac43cf3500c0038f2a3f47fc7d1762d694", - "catalog_digest": "7fffbe19c43f8d672e5bb502c276c7ac43cf3500c0038f2a3f47fc7d1762d694", + "generation": "eab0c31b7875b73dac12f8a0e6f1274ac817cefb627f0cca0fd72b546a038616", + "catalog_digest": "eab0c31b7875b73dac12f8a0e6f1274ac817cefb627f0cca0fd72b546a038616", "tools": [ { "name": "status", @@ -21,6 +21,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -41,6 +42,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -122,6 +124,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -172,6 +175,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -203,6 +207,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -285,6 +290,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -312,6 +318,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -350,6 +357,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -379,6 +387,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -427,6 +436,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -472,6 +482,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -536,6 +547,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -596,6 +608,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -666,6 +679,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -689,6 +703,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -753,6 +768,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -820,6 +836,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -854,6 +871,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -885,6 +903,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -971,6 +990,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -1020,6 +1040,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1063,6 +1084,7 @@ "name": "bash_watch", "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1130,6 +1152,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1156,6 +1179,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1253,8 +1277,8 @@ "disabled_tools": [], "powershell_available": true, "reply": { - "generation": "7a8e06762dab18cfb8e532fe5b194714af424fc17f98186d2ecb81a427fc239a", - "catalog_digest": "7a8e06762dab18cfb8e532fe5b194714af424fc17f98186d2ecb81a427fc239a", + "generation": "86f1dd440ebde41112a0f8043b0518d38f7de33c72968f48743dac38afea3297", + "catalog_digest": "86f1dd440ebde41112a0f8043b0518d38f7de33c72968f48743dac38afea3297", "tools": [ { "name": "status", @@ -1264,6 +1288,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -1284,6 +1309,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1360,6 +1386,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -1438,6 +1465,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -1488,6 +1516,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1519,6 +1548,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1601,6 +1631,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -1628,6 +1659,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1666,6 +1698,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1695,6 +1728,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -1743,6 +1777,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1788,6 +1823,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -1852,6 +1888,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -1912,6 +1949,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -1982,6 +2020,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -2005,6 +2044,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -2069,6 +2109,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -2136,6 +2177,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2170,6 +2212,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -2201,6 +2244,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -2287,6 +2331,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -2336,6 +2381,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2379,6 +2425,7 @@ "name": "bash_watch", "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2446,6 +2493,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2472,6 +2520,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -2570,8 +2619,8 @@ "disabled_tools": [], "powershell_available": false, "reply": { - "generation": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", - "catalog_digest": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", + "generation": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", + "catalog_digest": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", "tools": [ { "name": "read", @@ -2586,6 +2635,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -2637,6 +2687,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2675,6 +2726,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2704,6 +2756,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -2752,6 +2805,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2797,6 +2851,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -2861,6 +2916,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -2921,6 +2977,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -3015,8 +3072,8 @@ "disabled_tools": [], "powershell_available": true, "reply": { - "generation": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", - "catalog_digest": "b9f52eab7b5fbe5109f23c609b5c726ccd9b86d7abf7e1d974ed4d157c2c8101", + "generation": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", + "catalog_digest": "1801458c3f5790126bcccaae9b8cb96df2e25ecbcbc4fe94bbae607762f5865d", "tools": [ { "name": "read", @@ -3031,6 +3088,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -3082,6 +3140,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3120,6 +3179,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3149,6 +3209,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3197,6 +3258,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3242,6 +3304,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3306,6 +3369,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -3366,6 +3430,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -3461,8 +3526,8 @@ "powershell_available": false, "remote_runs": true, "reply": { - "generation": "d153da41964a07c9ef4c9b433bf68ca0c944480690f4a31a33576574e42806bc", - "catalog_digest": "d153da41964a07c9ef4c9b433bf68ca0c944480690f4a31a33576574e42806bc", + "generation": "dd0a9ace2cb15419d75f8fee0a124a604446c7e92285a5ec1631ed25117e0716", + "catalog_digest": "dd0a9ace2cb15419d75f8fee0a124a604446c7e92285a5ec1631ed25117e0716", "tools": [ { "name": "status", @@ -3472,6 +3537,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -3482,7 +3548,7 @@ }, { "name": "bash", - "schema_digest": "029ddfaa46f2b46b773e15231b41cf5c86acd1ed1668068236039c1edb978aca", + "schema_digest": "019128a28f9f2724d2c086f93a1c74222b294ad95fee8b255fa799b257397a4f", "semantics": 2, "modes": [ "classic", @@ -3492,6 +3558,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -3557,6 +3624,14 @@ "runon": { "type": "string", "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options). `macos` sends the command to a macOS machine for builds and tests that need macOS (no vCPU or network options)." + }, + "return_paths": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 64, + "description": "With runon: up to 64 files to copy back from the build server after the command, as paths relative to workdir inside the project (10 MiB in total). Each verified file replaces the local file at the same path; the result lists every path as returned or not returned, with the reason." } }, "required": [ @@ -3577,6 +3652,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -3627,6 +3703,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3658,6 +3735,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3740,6 +3818,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -3767,6 +3846,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3805,6 +3885,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3834,6 +3915,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -3882,6 +3964,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3927,6 +4010,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -3991,6 +4075,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -4051,6 +4136,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -4121,6 +4207,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -4144,6 +4231,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4208,6 +4296,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -4275,6 +4364,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4309,6 +4399,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -4340,6 +4431,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -4426,6 +4518,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -4475,6 +4568,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4518,6 +4612,7 @@ "name": "bash_watch", "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4585,6 +4680,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4611,6 +4707,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4709,8 +4806,8 @@ "powershell_available": true, "remote_runs": true, "reply": { - "generation": "e97b1a5727c7d6d1598135e0d24e48ceca1653fef1c2d07a2bb85bf034f7d2d3", - "catalog_digest": "e97b1a5727c7d6d1598135e0d24e48ceca1653fef1c2d07a2bb85bf034f7d2d3", + "generation": "a3a5e195d9610bccfcf40339b3fa7cd4dbe15e32b97c401ba0140c21d42cdc4b", + "catalog_digest": "a3a5e195d9610bccfcf40339b3fa7cd4dbe15e32b97c401ba0140c21d42cdc4b", "tools": [ { "name": "status", @@ -4720,6 +4817,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [], "description": "Show AFT status, index health, cache usage, and runtime details", "input_schema": { @@ -4730,7 +4828,7 @@ }, { "name": "bash", - "schema_digest": "029ddfaa46f2b46b773e15231b41cf5c86acd1ed1668068236039c1edb978aca", + "schema_digest": "019128a28f9f2724d2c086f93a1c74222b294ad95fee8b255fa799b257397a4f", "semantics": 2, "modes": [ "classic", @@ -4740,6 +4838,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4805,6 +4904,14 @@ "runon": { "type": "string", "description": "Run on a remote build server: `linux` (2 vCPUs), or `linux,Nc` for N vCPUs such as `linux,8c`; add \",net\" for outbound internet (offline by default). `windows` runs the command in Git Bash on a Windows build machine (no vCPU or network options). `macos` sends the command to a macOS machine for builds and tests that need macOS (no vCPU or network options)." + }, + "return_paths": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 64, + "description": "With runon: up to 64 files to copy back from the build server after the command, as paths relative to workdir inside the project (10 MiB in total). Each verified file replaces the local file at the same path; the result lists every path as returned or not returned, with the reason." } }, "required": [ @@ -4820,6 +4927,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -4898,6 +5006,7 @@ "prepend", "append" ], + "result_format": "tool-result/v1", "capabilities": [ "code.read/v1" ], @@ -4948,6 +5057,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -4979,6 +5089,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -5061,6 +5172,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.edit/v1" ], @@ -5088,6 +5200,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5126,6 +5239,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5155,6 +5269,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.search/v1" ], @@ -5203,6 +5318,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5248,6 +5364,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.outline/v1" ], @@ -5312,6 +5429,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.diagnostics/v1" ], @@ -5372,6 +5490,7 @@ "codemode", "flow" ], + "result_format": "tool-result/v1", "capabilities": [ "code.callgraph/v1" ], @@ -5442,6 +5561,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:git.conflicts/v1" ], @@ -5465,6 +5585,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5529,6 +5650,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.ast_grep/v1" ], @@ -5596,6 +5718,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5630,6 +5753,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "code.files/v1" ], @@ -5661,6 +5785,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:code.imports/v1" ], @@ -5747,6 +5872,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "aft:safety/v1" ], @@ -5796,6 +5922,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5839,6 +5966,7 @@ "name": "bash_watch", "schema_digest": "f311ed43521271004ef01a1c1a87c8bb7b0bb38985b3bf02410061681f7dc935", "semantics": 2, + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5906,6 +6034,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ], @@ -5932,6 +6061,7 @@ "classic", "codemode" ], + "result_format": "tool-result/v1", "capabilities": [ "shell.exec/v1" ],