Skip to content
Open
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
7 changes: 7 additions & 0 deletions .announcements/automations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"items": [
{
"text": "New: Automations — schedule a prompt to run in a chat on an interval (hourly, daily, weekly, or custom). Find it via the clock icon at the bottom of the sidebar, or ask an agent to set one up for you."
}
]
}
9 changes: 9 additions & 0 deletions .changeset/calm-clocks-tick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"helmor": minor
---

Add Automations — scheduled prompts that run on an interval, like Codex's:
- New Automations page (clock icon at the bottom of the sidebar): create automations manually or via chat, pause/resume, edit the interval, or run one immediately.
- Each run sends the saved prompt into its chat as a normal agent turn, labeled "Sent via automation" — the chat itself is the run history. Runs can target an existing chat or create a fresh session per run in a workspace.
- Intervals: hourly, daily, weekly, or every N minutes/hours. Schedules survive restarts and sleep — a slot missed while Helmor was closed catches up exactly once on next launch, and never double-fires.
- New `helmor automation` CLI (list/create/show/pause/resume/run/delete), so agents can set up automations for you straight from a conversation.
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,15 @@ Feature-based layout. Each feature folder follows: `index.tsx` (main) + `contain
| Module | Role |
| --- | --- |
| `lib.rs` | Tauri app builder. Registers commands, runs setup hook. |
| `commands/` | Tauri command handlers split by domain (session, repository, workspace, editor, github, conductor, settings, system). `terminal_commands.rs` includes `set_terminal_session_busy` and `create_session` (now accepts `session_kind` and `agent_type` parameters). |
| `agents/` | Agent streaming + persistence (catalog, persistence, queries, streaming, support). |
| `commands/` | Tauri command handlers split by domain (session, repository, workspace, editor, github, conductor, settings, system). `terminal_commands.rs` includes `set_terminal_session_busy` and `create_session` (now accepts `session_kind` and `agent_type` parameters). `automation_commands.rs` provides IPC for automation management (list, create, update, pause, resume, delete, run). |
| `agents/` | Agent streaming + persistence (catalog, persistence, queries, streaming, support). Integrates with scheduled automations: `AgentSendRequest.source` and `ExchangeContext.is_background` track automation-initiated turns so they skip mark-read side effects. |
| `automations/` | Scheduled automations: `mod.rs` (main exports), `dispatch.rs` (dispatch logic), `ops.rs` (CRUD operations), `schedule.rs` (schedule calculation/parsing), `scheduler.rs` (background scheduler, 30-second tick). |
| `cli/` | CLI entry point + subcommands. `terminal_hook.rs` provides the hidden `terminal-hook` command used by agent CLIs to communicate PTY lifecycle events (session id, busy/idle, prompt capture). |
| `pipeline/` | Message pipeline: `accumulator/` -> `adapter/` + `collapse` -> `ThreadMessageLike[]`. Includes `event_filter.rs`, `classify.rs`, `types.rs`. |
| `workspace/` | Workspace operations (branching, lifecycle, helpers) + `files/` sub-module (editor, changes, types). `scripts.rs` includes PTY output coalescing (8ms flush window, 16KB threshold, UTF-8 safe flush). |
| `git/` | Git operations (ops, watcher). |
| `github/` | GitHub integration (auth, CLI, GraphQL). |
| `models/` | Persistence layer (db, repos, sessions, settings, workspaces). Sessions table now has `session_kind` (distinguishes terminal vs GUI sessions) and `agent_type` (tracks which agent is being used). |
| `models/` | Persistence layer (db, repos, sessions, settings, workspaces, automations). Sessions table now has `session_kind` (distinguishes terminal vs GUI sessions) and `agent_type` (tracks which agent is being used). `automations` contains the Automation struct and persistence. |
| `service.rs` | Service layer. |
| `sidecar.rs` | Sidecar process manager (spawn, stdio, graceful SIGTERM). |
| `schema.rs` | DB schema + idempotent migrations. |
Expand Down
101 changes: 101 additions & 0 deletions docs/cli-and-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ helmor workspace stack helmor/earth # show PR stack for a workspace
helmor session list --workspace helmor/earth
helmor session new --workspace helmor/earth
helmor send --workspace helmor/earth "Refactor the auth module"
helmor automation list
helmor automation create --title "Daily digest" --prompt "summarize inbox" \
--workspace helmor/earth --daily 09:00
```

Debug builds use the same commands under `helmor-dev`.
Expand Down Expand Up @@ -98,6 +101,104 @@ The agent provides three commands for stacked PR workflows:

## CLI Commands

### Automation Commands

Helmor automations allow you to schedule recurring prompts that run on an interval. Each automation can run in a chat (appending turns to an existing session) or in a workspace (creating a fresh session per run).

#### helmor automation list

List all automations.

Example:
```bash
helmor automation list
```

#### helmor automation show <id>

Show details of an automation including title, status, schedule, prompt, and next/last run times.

Example:
```bash
helmor automation show abc123
```

#### helmor automation create

Create a new automation with a title, prompt, target (chat or workspace), and schedule.

Syntax:
```bash
helmor automation create --title <title> --prompt <prompt> \
[--chat <session-id> | --workspace <workspace>] \
[--hourly | --daily HH:MM | --weekly DAY:HH:MM | --every Nm|Nh]
```

Options:
- **`--title`** — Descriptive name for the automation
- **`--prompt`** — The message to send when the automation runs
- **`--chat <session-id>`** — Run in an existing chat session (each run appends a turn)
- **`--workspace <workspace>`** — Run in a workspace (each run creates a fresh session)
- **`--hourly`** — Run every hour
- **`--daily HH:MM`** — Run every day at a local wall-clock time (e.g. 09:00)
- **`--weekly DAY:HH:MM`** — Run weekly on a specific day and time (e.g. mon:09:30)
- **`--every Nm|Nh`** — Run every N minutes or hours (e.g. 15m, 2h)

Examples:
```bash
# Run hourly in a chat
helmor automation create --title "Order monitor" --prompt "check order status" \
--chat <session-id> --hourly

# Run daily in a workspace
helmor automation create --title "Daily digest" --prompt "summarize inbox" \
--workspace helmor/earth --daily 09:00

# Run weekly
helmor automation create --title "Weekly report" --prompt "generate status report" \
--workspace helmor/earth --weekly mon:09:30

# Run every 15 minutes
helmor automation create --title "Frequent check" --prompt "check status" \
--workspace helmor/earth --every 15m
```

#### helmor automation pause <id>

Pause an automation. The automation remains listed but stops firing until resumed.

Example:
```bash
helmor automation pause abc123
```

#### helmor automation resume <id>

Resume a paused automation. The next run time is computed from now.

Example:
```bash
helmor automation resume abc123
```

#### helmor automation run <id>

Manually trigger an automation to run on the app's next scheduler tick (within ~30 seconds). The automation must be active (not paused).

Example:
```bash
helmor automation run abc123
```

#### helmor automation delete <id>

Delete an automation. The chats or sessions it created remain untouched.

Example:
```bash
helmor automation delete abc123
```

### Workspace Commands

#### helmor workspace stack <ref>
Expand Down
66 changes: 61 additions & 5 deletions src-tauri/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub use self::streaming::{
abort_all_active_streams_blocking, bridge_aborted_event, bridge_done_event, bridge_error_event,
bridge_permission_request_event, bridge_user_input_request_event, build_send_message_params,
lookup_workspace_linked_directories, ActiveStreamSummary, ActiveStreams,
BuildSendMessageParamsInput, SessionStreamHub,
BuildSendMessageParamsInput, SessionStreamHub, SESSION_BUSY_MARKER,
};

use self::persistence::{
Expand Down Expand Up @@ -191,6 +191,13 @@ pub struct AgentSendRequest {
/// round-trip without regex re-extraction.
#[serde(default)]
pub images: Option<Vec<String>>,
/// Who initiated this turn. `None` = the user (frontend/CLI never set
/// it); `Some("automation")` = the automations scheduler. Persisted into
/// the `user_prompt` payload so the chat renders a "Sent via automation"
/// badge, and background turns skip the mark-read / active-session
/// side effects a human send performs.
#[serde(default)]
pub source: Option<String>,
/// UTF-16 ranges of pasted-text tag spans inside `prompt` (composer
/// badge pastes). Persisted with the user_prompt so the renderer shows
/// those spans as tag chips; the agent still receives the full prompt
Expand All @@ -208,6 +215,11 @@ pub(crate) struct ExchangeContext {
pub(crate) model_id: String,
pub(crate) model_provider: String,
pub(crate) user_message_id: String,
/// True for scheduler-initiated turns (`request.source` set). Finalize
/// then skips marking the session read and stealing the workspace's
/// active session — a background run must surface as unread, not
/// rearrange the user's UI.
pub(crate) is_background: bool,
}

#[tauri::command]
Expand Down Expand Up @@ -299,6 +311,46 @@ fn resolve_stream_working_directory(
resolve_working_directory(request.working_directory.as_deref())
}

/// Start an agent turn with no owning IPC channel — used by the automations
/// scheduler. Identical to `send_agent_message_stream` minus the triage
/// priming: persistence, ActiveStreams busy-locking, and watcher fan-out
/// (`SessionStreamHub`) all run as for a frontend-initiated send, so an open
/// conversation still streams the turn live. The no-op channel only drops the
/// initiator-facing copy nobody is listening to.
pub(crate) fn start_background_turn(app: &AppHandle, request: AgentSendRequest) -> CmdResult<()> {
let prompt = request.prompt.trim().to_string();
if prompt.is_empty() {
return Err(anyhow::anyhow!("Prompt cannot be empty.").into());
}

let model = resolve_model(&request.model_id, Some(request.provider.as_str()));
if request.provider != model.provider {
return Err(anyhow::anyhow!(
"Model {} does not belong to provider {}.",
request.model_id,
request.provider
)
.into());
}

let working_directory = resolve_stream_working_directory(&request)?;
let stream_id = Uuid::new_v4().to_string();
let sidecar = app.state::<crate::sidecar::ManagedSidecar>();
let active_streams = app.state::<ActiveStreams>();

stream_via_sidecar(
app.clone(),
Channel::new(|_| Ok(())),
&sidecar,
&active_streams,
&stream_id,
&model,
&prompt,
&request,
&working_directory,
)
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentStopRequest {
Expand Down Expand Up @@ -807,10 +859,11 @@ mod tests {
model_id: "opus-1m".to_string(),
model_provider: "claude".to_string(),
user_message_id: Uuid::new_v4().to_string(),
is_background: false,
};

// 1. Persist user message
persist_user_message(&conn, &ctx, "Hello", &[], &[], &[]).unwrap();
persist_user_message(&conn, &ctx, "Hello", &[], &[], None, &[]).unwrap();

persist_result_and_finalize(
&conn,
Expand Down Expand Up @@ -880,9 +933,10 @@ mod tests {
model_id: "opus-1m".to_string(),
model_provider: "claude".to_string(),
user_message_id: Uuid::new_v4().to_string(),
is_background: false,
};

persist_user_message(&conn, &ctx, "Hi", &[], &[], &[]).unwrap();
persist_user_message(&conn, &ctx, "Hi", &[], &[], None, &[]).unwrap();
persist_result_and_finalize(
&conn,
&ctx,
Expand Down Expand Up @@ -938,10 +992,11 @@ mod tests {
model_id: "opus-1m".to_string(),
model_provider: "claude".to_string(),
user_message_id: Uuid::new_v4().to_string(),
is_background: false,
};

// Persist user message
persist_user_message(&conn, &ctx, "Do something", &[], &[], &[]).unwrap();
persist_user_message(&conn, &ctx, "Do something", &[], &[], None, &[]).unwrap();

// Persist two intermediate turns
let turn1 = CollectedTurn {
Expand Down Expand Up @@ -1009,10 +1064,11 @@ mod tests {
model_id: "opus-1m".to_string(),
model_provider: "claude".to_string(),
user_message_id: "user-initial".to_string(),
is_background: false,
};

// 1. Initial prompt persisted via the normal path.
persist_user_message(&conn, &ctx, "investigate the bug", &[], &[], &[]).unwrap();
persist_user_message(&conn, &ctx, "investigate the bug", &[], &[], None, &[]).unwrap();

// 2. Drive the accumulator the same way the streaming loop does:
// assistant deltas, steer event, more assistant deltas, result.
Expand Down
43 changes: 27 additions & 16 deletions src-tauri/src/agents/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ use super::ExchangeContext;
/// Persist the user's prompt as the first message of the exchange.
/// Wraps as `{"type":"user_prompt","text":"...","files":[...],"images":[...],
/// "pastedTexts":[{"start":n,"end":n}]}`. Empty arrays are omitted from the
/// JSON.
/// JSON; `source` (e.g. `"automation"`) is only written when present so
/// pre-existing rows keep their exact shape.
pub(super) fn persist_user_message(
conn: &Connection,
ctx: &ExchangeContext,
prompt: &str,
files: &[String],
images: &[String],
source: Option<&str>,
pasted_texts: &[PastedTextRange],
) -> Result<()> {
let now = current_timestamp_string()?;
Expand All @@ -25,6 +27,9 @@ pub(super) fn persist_user_message(
"type": "user_prompt",
"text": prompt,
});
if let Some(source) = source {
payload["source"] = serde_json::Value::String(source.to_string());
}
if !files.is_empty() {
payload["files"] = serde_json::Value::Array(
files
Expand Down Expand Up @@ -290,17 +295,22 @@ fn finalize_session_metadata_in_transaction(
],
)?;

transaction.execute(
r#"
UPDATE workspaces
SET
active_session_id = ?2
WHERE id = (SELECT workspace_id FROM sessions WHERE id = ?1)
"#,
params![ctx.helmor_session_id, ctx.helmor_session_id],
)?;

mark_session_read_in_transaction(transaction, &ctx.helmor_session_id)?;
// Background (automation) turns must not rearrange the user's UI: the
// result should surface as *unread*, and the workspace's active session
// should stay wherever the user left it.
if !ctx.is_background {
transaction.execute(
r#"
UPDATE workspaces
SET
active_session_id = ?2
WHERE id = (SELECT workspace_id FROM sessions WHERE id = ?1)
"#,
params![ctx.helmor_session_id, ctx.helmor_session_id],
)?;

mark_session_read_in_transaction(transaction, &ctx.helmor_session_id)?;
}
Ok(())
}

Expand All @@ -318,6 +328,7 @@ mod tests {
model_id: "gpt-5.4".to_string(),
model_provider: "codex".to_string(),
user_message_id: "user-1".to_string(),
is_background: false,
}
}

Expand Down Expand Up @@ -381,7 +392,7 @@ mod tests {
let conn = Connection::open_in_memory().unwrap();
make_messages_table(&conn);
let ctx = test_exchange_context();
persist_user_message(&conn, &ctx, "fix bug X", &[], &[], &[]).unwrap();
persist_user_message(&conn, &ctx, "fix bug X", &[], &[], None, &[]).unwrap();

let (role, content, id): (String, String, String) = conn
.query_row(
Expand All @@ -408,7 +419,7 @@ mod tests {
make_messages_table(&conn);
let ctx = test_exchange_context();
let pasted = vec![PastedTextRange { start: 4, end: 16 }];
persist_user_message(&conn, &ctx, "see <big paste> ok", &[], &[], &pasted).unwrap();
persist_user_message(&conn, &ctx, "see <big paste> ok", &[], &[], None, &pasted).unwrap();

let content: String = conn
.query_row(
Expand All @@ -427,7 +438,7 @@ mod tests {
make_messages_table(&conn);
let ctx = test_exchange_context();
let files = vec!["a.rs".to_string(), "b.rs".to_string()];
persist_user_message(&conn, &ctx, "refactor", &files, &[], &[]).unwrap();
persist_user_message(&conn, &ctx, "refactor", &files, &[], None, &[]).unwrap();

let content: String = conn
.query_row(
Expand All @@ -452,7 +463,7 @@ mod tests {
let images = vec![
"/Users/me/Library/Application Support/CleanShot/CleanShot 2026-04-29 at 08.24.35@2x.jpg".to_string(),
];
persist_user_message(&conn, &ctx, "look at this", &[], &images, &[]).unwrap();
persist_user_message(&conn, &ctx, "look at this", &[], &images, None, &[]).unwrap();

let content: String = conn
.query_row(
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/agents/streaming/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ mod tests {
model_id: "opus".to_string(),
model_provider: "claude".to_string(),
user_message_id: "user-1".to_string(),
is_background: false,
}
}

Expand Down
Loading
Loading