Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 13 additions & 9 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
EFFORT_PARAMS,
HookDispatcher,
ReadTool,
RuntimeHost,
SessionManager,
ToolRegistry,
WebFetchTool,
Expand All @@ -39,7 +40,6 @@ import {
loadSkills,
makeSkillTool,
resolveCredentials,
runAgent,
wirePlugins,
collectPluginContributions,
type AgentEvent,
Expand Down Expand Up @@ -271,25 +271,28 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
}
let exitCode = 0;
try {
const result = await runAgent({
const runtime = new RuntimeHost({
provider,
tools,
cwd,
mode,
permissions: settings.permissions,
hooks,
pluginDirs: pluginContrib.dirs,
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
});
const result = await runtime.run({
systemPrompt,
userMessage,
history: [],
model,
maxTokens,
temperature,
maxTurns,
cwd,
signal: ctrl.signal,
session: { manager: sessions, id: session.id },
mode,
permissions: settings.permissions,
hooks,
pluginDirs: pluginContrib.dirs,
autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 },
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
// In headless mode there's no human to ask: auto-deny anything that
// would normally need approval. Users wanting auto-yes should pass
// --mode dontAsk or --mode bypassPermissions (gated by trust).
Expand Down Expand Up @@ -441,6 +444,7 @@ function formatEventText(out: Writable, e: AgentEvent): void {
return;
case 'usage':
case 'thinking_delta':
case 'model_step_complete':
case 'turn_complete':
return;
}
Expand Down
58 changes: 29 additions & 29 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
EFFORT_PARAMS,
HookDispatcher,
ReadTool,
RuntimeHost,
SessionManager,
TaskManager,
ToolRegistry,
Expand Down Expand Up @@ -37,7 +38,6 @@ import {
contextWindowFor,
makeSkillTool,
resolveCredentials,
runAgent,
settingsPaths,
wirePlugins,
collectPluginContributions,
Expand Down Expand Up @@ -429,6 +429,17 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
}

let history: StoredMessage[] = resolved.seededHistory;
const runtime = new RuntimeHost({
provider,
tools,
cwd,
mode,
permissions: settings.permissions,
hooks,
pluginDirs: pluginContrib.dirs,
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
});
const ctx: SessionContext = {
cwd,
model,
Expand Down Expand Up @@ -471,25 +482,20 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
// reading ctx.model/ctx.mode live so /model and /mode switches are honored.
const tasks = new TaskManager((spec) => {
const ac = new AbortController();
const done = runAgent({
provider,
tools,
systemPrompt,
userMessage: spec.prompt,
model: ctx.model,
maxTokens,
temperature,
cwd: ctx.cwd,
signal: ac.signal,
mode: ctx.mode as Mode,
permissions: settings.permissions,
hooks,
pluginDirs: pluginContrib.dirs,
sandboxConfig: settings.sandbox,
autoMode: settings.autoMode,
subAgentDepth: 1,
systemReminders: false,
}).then((r) => assistantText(r.history));
const done = runtime
.run({
systemPrompt,
userMessage: spec.prompt,
model: ctx.model,
maxTokens,
temperature,
cwd: ctx.cwd,
signal: ac.signal,
modeOverride: ctx.mode as Mode,
subAgentDepth: 1,
systemReminders: false,
})
.then((r) => assistantText(r.history));
return { done, abort: () => ac.abort() };
});
ctx.tasks = tasks;
Expand Down Expand Up @@ -649,9 +655,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
}

// Otherwise: send to agent (with mode/permission/hooks gating from M3b)
const result = await runAgent({
provider,
tools,
const result = await runtime.run({
systemPrompt,
userMessage: userInput,
history,
Expand All @@ -663,13 +667,8 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
// ctx.sessionId (not the launch `session.id`) so a live `/resume <id>`
// switch redirects new messages to the resumed session.
session: { manager: sessions, id: ctx.sessionId },
mode: ctx.mode as Mode,
permissions: settings.permissions,
hooks,
pluginDirs: pluginContrib.dirs,
modeOverride: ctx.mode as Mode,
autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 },
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
// Session-scoped manager: the agent's TaskCreate calls land here too, so
// background tasks persist across turns and show up in /tasks.
taskManager: tasks,
Expand Down Expand Up @@ -762,6 +761,7 @@ function formatEvent(out: Writable, e: AgentEvent): void {
else out.write(` ✓ ${truncate(e.result.content, 200)}\n`);
return;
case 'usage':
case 'model_step_complete':
return;
case 'error':
out.write(`\n ✕ ${e.error}\n`);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ sha2 = "0.10"
thiserror = "1"
tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "sync", "time", "process"] }
dirs = "5"
libc = "0.2"

[profile.release]
panic = "abort"
Expand Down
47 changes: 44 additions & 3 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,26 @@ pub fn session_read(id: String) -> Result<Vec<serde_json::Value>, String> {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(format!("read {}: {}", path.display(), e)),
};
parse_session_messages(&text)
}

fn parse_session_messages(text: &str) -> Result<Vec<serde_json::Value>, String> {
let lines: Vec<&str> = text.split('\n').collect();
let last_content = lines.iter().rposition(|line| !line.trim().is_empty());
let mut out = Vec::new();
for line in text.lines() {
for (index, line) in lines.iter().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue; // tolerate a partial trailing line
let v = match serde_json::from_str::<serde_json::Value>(line) {
Ok(value) => value,
Err(_) if Some(index) == last_content && !text.ends_with('\n') => {
continue; // recover an interrupted final append only
}
Err(error) => {
return Err(format!("corrupt session at line {}: {}", index + 1, error));
}
};
// Desktop sessions tag messages with type:"message"; CLI/headless sessions
// write bare {role, content} lines with no type. Accept both, skip meta.
Expand All @@ -206,6 +218,12 @@ pub fn session_read(id: String) -> Result<Vec<serde_json::Value>, String> {
Some("user") | Some("assistant")
);
if t == Some("message") || (t.is_none() && is_role_msg) {
if !v.get("content").is_some_and(|content| content.is_array()) {
return Err(format!(
"corrupt session at line {}: message content must be an array",
index + 1
));
}
out.push(v);
}
}
Expand Down Expand Up @@ -773,6 +791,29 @@ mod contract_tests {
assert!(name.is_none() && desc.is_none());
}

#[test]
fn session_parser_accepts_both_legacy_formats_and_truncated_tail() {
let text = concat!(
"{\"type\":\"session_meta\",\"id\":\"x\"}\n",
"{\"type\":\"message\",\"role\":\"user\",\"content\":[]}\n",
"{\"role\":\"assistant\",\"content\":[]}\n",
"{\"role\":\"assistant\""
);
let messages = parse_session_messages(text).unwrap();
assert_eq!(messages.len(), 2);
}

#[test]
fn session_parser_rejects_middle_corruption() {
let text = concat!(
"{\"role\":\"user\",\"content\":[]}\n",
"{not-json}\n",
"{\"role\":\"assistant\",\"content\":[]}\n"
);
let error = parse_session_messages(text).unwrap_err();
assert!(error.contains("line 2"), "got {error}");
}

#[test]
fn skill_info_serializes_camel_case() {
let v = serde_json::to_value(SkillInfo {
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ use commands::{
};
use snapshots::session_snapshots;
use tauri::Manager;
use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write};
use tools::{
tool_bash, tool_bash_cancel, tool_edit, tool_glob, tool_grep, tool_read, tool_write, BashState,
};
use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
Expand All @@ -37,6 +39,7 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(VoiceState::default())
.manage(BashState::default())
.invoke_handler(tauri::generate_handler![
get_app_info,
read_credentials,
Expand All @@ -62,6 +65,7 @@ pub fn run() {
tool_write,
tool_edit,
tool_bash,
tool_bash_cancel,
tool_glob,
tool_grep,
session_snapshots,
Expand Down
Loading
Loading