Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
45d361a
ios: record voice messages on mic hold instead of showing a menu
morajabi Nov 6, 2026
7c48c94
cli: retry codex resumes refused while the thread is closing
morajabi Nov 6, 2026
5b0dc46
cli: only treat a slash command at the start of a message as a command
morajabi Nov 6, 2026
c0324e6
cli: count streamed agent edits as one message for loop caps
morajabi Nov 6, 2026
b3ab268
cli: keep agent progress moving after a refused or oversized edit
morajabi Nov 6, 2026
d8846de
cli: strip nul and control characters before writing block ranges
morajabi Nov 6, 2026
3fa8c60
cli: apply bridge updates as soon as work finishes
morajabi Nov 6, 2026
af5a094
cli: expire v1 session pickers when the bridge moves to v2
morajabi Nov 6, 2026
9ee8076
server: show nul as u+fffd when encoding message text
morajabi Nov 6, 2026
1e4139d
docs: drop the six-hour spacing from bridge update docs
morajabi Nov 6, 2026
aad4802
Merge remote-tracking branch 'origin/main' into claude/inspiring-neum…
morajabi Nov 7, 2026
5830315
cli: describe the six-hour activation spacing and explicit updates
morajabi Nov 7, 2026
ba75427
docs: restore the six-hour activation spacing in bridge update docs
morajabi Nov 7, 2026
82a1246
Merge remote-tracking branch 'origin/main' into claude/inspiring-neum…
morajabi Nov 7, 2026
e2a3ca5
Merge branch 'main' of https://github.com/inline-chat/inline into cla…
morajabi Nov 8, 2026
79161ea
Merge branch 'main' of https://github.com/inline-chat/inline into cla…
morajabi Nov 10, 2026
9b6556f
Merge branch 'main' of https://github.com/inline-chat/inline into cla…
morajabi Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions cli/docs/local-agent-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ requires explicit handoff mentions and warns against reciprocal loops. The Rust
bridge is bundled with the Inline CLI and runs as one per-user background
service; agent processes and project files remain on the user's computer.

## Updates and restarts

The bridge applies a staged update once active work and pending publication
finish; open idle sessions do not block it, and there is no extra quiet period.
Automatic activations stay at least six hours apart. Running `inline update` by
hand asks the bridge to adopt the staged runtime at its next idle moment
instead, skipping that spacing but not the idle wait or the trial. Admission
closes atomically before the old runtime stops, so new work cannot race the
handoff.

`inline bridge status` and `inline bridge doctor` show the running CLI version,
the retained supervisor's version, and pending updates or rollbacks. The binary
in the bridge's `bin` directory belongs to the supervisor; its `--version` can
differ from the runtime doing agent work.

A deliberate `inline bridge stop` or `restart` during a candidate's startup
probation leaves that candidate available for retry. Crashes and failed health
checks still trigger rollback. A restart preserves native session bindings,
thread settings, and durable delivery records. Existing sessions reopen when
you next message them; input with an uncertain delivery is reconciled rather
than automatically sent again. Unsent input interrupted by a manual restart
asks you to resend it.

## Setup

Authenticate the provider's own CLI first, then run setup. If the Inline CLI is
Expand Down
6 changes: 3 additions & 3 deletions cli/docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ native sessions and folders. Codex and Claude may replace an unavailable v1
session with a fresh one. OpenCode and Amp preserve an unavailable saved
binding and ask the user to retry or explicitly start a new conversation with
`/new`; old Amp adapter sessions cannot resume across processes. Turns v1 had
queued or was running get a "send it again" reply, and v1 approval and question
cards expire. Settings may reset to v2 defaults; migration
queued or was running get a "send it again" reply, and v1 approval, question,
and session-picker cards expire. Settings may reset to v2 defaults; migration
does not require preserving every old setting.
When a legacy binding matches the registered native session, its saved folder
is retained and persisted for subsequent restarts. Explicit thread folder
Expand Down Expand Up @@ -259,7 +259,7 @@ persisted policy and actual provider readiness after this new-binary restart.
For an already supervised bridge, `inline bridge refresh --json` refreshes the
retained parent from its committed, verified runtime. It never installs the
invoking CLI. It returns `waiting_for_idle` while turns, queued inputs, questions,
approvals or the two-minute quiet period remain; retry later. It refuses pending
approvals or pending publication remain; retry later. It refuses pending
activations and preserves configuration, authentication, receipt databases and
runtime rollback history. An already current parent returns `up_to_date`.

Expand Down
9 changes: 2 additions & 7 deletions cli/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3609,18 +3609,13 @@ fn opencode_native_for(version: &str) -> bool {
async fn settle_v1_leftovers(bot: &InlineClient, store: &BridgeStore, installation_id: &str) {
let result: Result<(), Box<dyn std::error::Error>> = async {
let installation_id = InstallationId::new(installation_id.to_owned())?;
store.expire_session_pickers_for_installation(&installation_id, now_seconds())?;
store.stage_unfinished_inbound_for_installation(
&installation_id,
"bridge moved to v2 before the turn finished",
"This didn't finish before the bridge was updated. Send it again if you still want it.",
)?;
recover_provider_inbox(
bot,
store,
&installation_id,
InterruptionKind::BridgeRestart,
)
.await
recover_provider_inbox(bot, store, &installation_id, InterruptionKind::BridgeUpdate).await
}
.await;
if let Err(error) = result {
Expand Down
13 changes: 12 additions & 1 deletion cli/src/bridge/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ use super::*;
#[derive(Clone, Copy, Debug)]
pub(super) enum InterruptionKind {
BridgeRestart,
BridgeUpdate,
ProviderRestart,
}

impl InterruptionKind {
fn failure(self) -> &'static str {
match self {
Self::BridgeRestart => "bridge process restarted during the turn",
Self::BridgeUpdate => "bridge moved to v2 before the turn finished",
Self::ProviderRestart => "local agent disconnected during the turn",
}
}
Expand All @@ -21,6 +23,9 @@ impl InterruptionKind {
Self::BridgeRestart => {
"This turn was interrupted when the bridge restarted. Send it again if you still want me to continue."
}
Self::BridgeUpdate => {
"This didn't finish before the bridge was updated. Send it again if you still want it."
}
Self::ProviderRestart => {
"This turn was interrupted when the local agent disconnected. Send it again after the agent reconnects."
}
Expand All @@ -35,7 +40,13 @@ pub(super) async fn recover_provider_inbox(
interruption: InterruptionKind,
) -> Result<(), Box<dyn std::error::Error>> {
store.recover_session_pickers(installation_id, now_seconds())?;
recover_session_picker_commands(bot, store, installation_id).await?;
recover_session_picker_commands(
bot,
store,
installation_id,
matches!(interruption, InterruptionKind::BridgeUpdate),
)
.await?;
for request in store
.expire_open_command_choice_requests_for_installation(installation_id, now_seconds())?
{
Expand Down
60 changes: 46 additions & 14 deletions cli/src/bridge/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ pub(super) async fn stop_service(
secrets: &AccountBridgeSecrets,
installation: &ProviderInstallationConfig,
) -> Result<BridgeStatus, Box<dyn std::error::Error>> {
record_stop_intent(paths);
#[cfg(target_os = "macos")]
let mut stopped_cleanly = false;
if let Ok(response) = control_request(paths, secrets, ControlCommand::Shutdown).await
Expand Down Expand Up @@ -667,6 +668,7 @@ async fn restart_installed_service(
refresh_systemd_user_unit(paths, account)?;
let unit_name = systemd_unit_name(&account.service_label);
run_systemctl(["--user", "enable", &unit_name])?;
record_stop_intent(paths);
run_systemctl(["--user", "restart", &unit_name])?;
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
Expand Down Expand Up @@ -734,15 +736,20 @@ pub(super) async fn status(
provider_detail(&response, &installation.installation_id);
let provider_ready = matches!(provider_state, ProviderRuntimeState::Ready);
let account_running = response.status == "running";
let detail = response.detail.or(provider_connection_detail).or_else(|| {
(!provider_ready).then(|| {
format!(
"{} is {}",
installation.display_name,
provider_state.status()
)
})
});
let detail = response
.detail
.clone()
.or(provider_connection_detail)
.or_else(|| {
(!provider_ready).then(|| {
format!(
"{} is {}",
installation.display_name,
provider_state.status()
)
})
});
let detail = with_update_detail(paths, Some(&response.cli_version), detail);
BridgeStatus {
status: if account_running {
provider_state.status().to_string()
Expand Down Expand Up @@ -787,16 +794,41 @@ pub(super) async fn status(
process_id: None,
inline_connected: false,
provider_ready: false,
detail: with_service_lifecycle_detail(if process_running {
Some(safe_diagnostic(&error.to_string()))
} else {
setup_required
}),
detail: with_service_lifecycle_detail(with_update_detail(
paths,
None,
if process_running {
Some(safe_diagnostic(&error.to_string()))
} else {
setup_required
},
)),
}
}
}
}

fn record_stop_intent(paths: &BridgePaths) {
if let Err(error) = super::update_supervisor::request_stop(paths) {
// Stopping must remain possible when disk/journal access is broken.
log::warn!("could not record deliberate bridge stop: {}", error.stage());
}
}

fn with_update_detail(
paths: &BridgePaths,
version: Option<&str>,
detail: Option<String>,
) -> Option<String> {
match (
detail,
super::update_supervisor::status_detail(paths, version),
) {
(Some(detail), Some(update)) => Some(format!("{detail}; {update}")),
(detail, update) => detail.or(update),
}
}

/// Why a stopped service or agent will not come back until setup runs again.
fn setup_required_detail(
paths: &BridgePaths,
Expand Down
12 changes: 11 additions & 1 deletion cli/src/bridge/session_browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1782,9 +1782,12 @@ pub(super) async fn recover_session_picker_commands(
bot: &InlineClient,
store: &BridgeStore,
installation_id: &InstallationId,
bridge_updated: bool,
) -> Result<(), Box<dyn std::error::Error>> {
for picker in store.session_picker_recovery_cards(installation_id)? {
if let Err(error) = repair_recovered_session_picker_card(bot, store, &picker).await {
if let Err(error) =
repair_recovered_session_picker_card(bot, store, &picker, bridge_updated).await
{
eprintln!(
"Could not repair an interrupted session picker card: {}",
safe_diagnostic(&error.to_string())
Expand Down Expand Up @@ -1829,6 +1832,7 @@ async fn repair_recovered_session_picker_card(
bot: &InlineClient,
store: &BridgeStore,
picker: &SessionPickerRecord,
bridge_updated: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let message_id = picker.picker_message_id.ok_or_else(|| {
io::Error::new(
Expand All @@ -1837,6 +1841,12 @@ async fn repair_recovered_session_picker_card(
)
})?;
let (text, actions, terminal) = match picker.state {
SessionPickerState::Expired | SessionPickerState::Failed if bridge_updated => (
"This session picker expired when the bridge was updated. Start a new thread with /new."
.to_string(),
MessageActions { rows: Vec::new() },
true,
),
SessionPickerState::Retryable => {
let (card, actions) = session_picker_record_card(picker)?;
(card, actions, false)
Expand Down
54 changes: 15 additions & 39 deletions cli/src/bridge/update_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,17 @@ use std::{
Arc, Mutex, OnceLock,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
};

#[derive(Clone)]
pub(crate) struct Activity(Arc<Mutex<State>>);
struct State {
active: usize,
busy: HashSet<u64>,
last_work: Instant,
requested: Option<Instant>,
draining: bool,
}
pub(crate) struct Lease {
activity: Activity,
work: bool,
}
pub(crate) struct BusyKey {
activity: Activity,
Expand All @@ -36,26 +32,23 @@ impl Activity {
Self(Arc::new(Mutex::new(State {
active: 0,
busy: HashSet::new(),
last_work: Instant::now(),
requested: None,
draining: false,
})))
}
pub fn enter(&self) -> Option<Lease> {
self.reserve(true)
self.reserve()
}
pub fn maintenance(&self) -> Option<Lease> {
self.reserve(false)
self.reserve()
}
fn reserve(&self, work: bool) -> Option<Lease> {
fn reserve(&self) -> Option<Lease> {
let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
if state.draining {
return None;
}
state.active += 1;
Some(Lease {
activity: self.clone(),
work,
})
}
pub fn key(&self) -> BusyKey {
Expand All @@ -70,24 +63,13 @@ impl Activity {
pub fn cancel(&self) {
let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
state.draining = false;
state.requested = None;
}
pub fn prepare(&self) -> bool {
self.prepare_with_quiet(Duration::from_secs(120))
}
fn prepare_with_quiet(&self, quiet: Duration) -> bool {
let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let requested = *state.requested.get_or_insert(now);
let quiet = if now.duration_since(requested) >= Duration::from_secs(24 * 60 * 60) {
Duration::ZERO
} else {
quiet
};
if state.active == 0
&& state.busy.is_empty()
&& now.duration_since(state.last_work) >= quiet
{
// The admission lock and the work leases provide the handoff boundary.
// A time window also counts unrelated bot events as work, so an idle
// bridge could otherwise wait indefinitely on its own traffic.
if state.active == 0 && state.busy.is_empty() {
state.draining = true;
}
state.draining
Expand All @@ -97,21 +79,15 @@ impl Drop for Lease {
fn drop(&mut self) {
let mut state = self.activity.0.lock().unwrap_or_else(|e| e.into_inner());
state.active = state.active.saturating_sub(1);
if self.work {
state.last_work = Instant::now();
}
}
}
impl BusyKey {
pub fn set(&self, busy: bool) {
let mut state = self.activity.0.lock().unwrap_or_else(|e| e.into_inner());
let changed = if busy {
state.busy.insert(self.id)
if busy {
state.busy.insert(self.id);
} else {
state.busy.remove(&self.id)
};
if changed {
state.last_work = Instant::now();
state.busy.remove(&self.id);
}
}
}
Expand All @@ -125,10 +101,10 @@ impl Drop for BusyKey {
mod tests {
use super::*;
#[test]
fn updater_housekeeping_does_not_prevent_a_quiet_handoff() {
fn finished_deliveries_and_housekeeping_do_not_delay_an_idle_handoff() {
let activity = Activity::new();
activity.0.lock().unwrap().last_work = Instant::now() - Duration::from_secs(180);
for _ in 0..10 {
drop(activity.enter().unwrap());
drop(activity.maintenance().unwrap());
}
assert!(activity.prepare());
Expand All @@ -139,13 +115,13 @@ mod tests {
fn queued_work_and_questions_close_the_admission_race() {
let activity = Activity::new();
let queued = activity.enter().unwrap();
assert!(!activity.prepare_with_quiet(Duration::ZERO));
assert!(!activity.prepare());
let question = activity.key();
question.set(true);
drop(queued);
assert!(!activity.prepare_with_quiet(Duration::ZERO));
assert!(!activity.prepare());
question.set(false);
assert!(activity.prepare_with_quiet(Duration::ZERO));
assert!(activity.prepare());
assert!(activity.enter().is_none());
activity.cancel();
assert!(activity.enter().is_some());
Expand Down
Loading
Loading