Compare commits

...

6 Commits

Author SHA1 Message Date
db51abb970 Add image paste support for xterm.js terminal
All checks were successful
Build App / build-linux (push) Successful in 2m41s
Build App / build-windows (push) Successful in 3m56s
Intercept clipboard paste events containing images in the terminal,
upload them into the Docker container via bollard's tar upload API,
and inject the resulting file path into terminal stdin so Claude Code
can reference the image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:52:08 -08:00
d947824436 Fix URL detector truncating wrapped URLs by flattening buffer
All checks were successful
Build App / build-linux (push) Successful in 2m32s
Build App / build-windows (push) Successful in 3m45s
Replace fragile line-by-line reassembly heuristic with a simpler
approach: flatten the buffer by converting blank lines to spaces
(URL terminators) and stripping remaining newlines (PTY wraps),
then match URLs with a single regex on the flat string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:15:29 -08:00
c2b21b794c Fix URL detector truncating wrapped URLs arriving in separate PTY chunks
All checks were successful
Build App / build-linux (push) Successful in 2m42s
Build App / build-windows (push) Successful in 3m45s
The PTY may deliver a long URL across multiple chunks with enough delay
that the debounce fires between them, emitting a truncated URL. Fixed by:
1. Stripping trailing empty strings from split (artifact of trailing \n)
2. Deferring emission when the URL reaches the end of the buffer — a
   confirmation timer (500ms) waits for more data before emitting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:42:52 -08:00
40493ae284 Add toast notification for wrapped long URLs in terminal
All checks were successful
Build App / build-linux (push) Successful in 2m40s
Build App / build-windows (push) Successful in 3m40s
PTY hard-wraps long URLs (e.g. OAuth) with \r\n at column width, breaking
xterm.js link detection. This adds a UrlDetector that reassembles wrapped
URLs from the output stream and shows a non-intrusive floating toast with
an "Open" button. Auto-dismisses after 30s, no terminal layout impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:29:43 -08:00
2e81b52205 Add container-native scheduled task system with timezone support
All checks were successful
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m43s
Build Container / build-container (push) Successful in 16s
Introduces a cron-based scheduler that lets Claude set up recurring and
one-time tasks inside containers. Tasks run as separate Claude Code agents
and persist across container recreation via the named volume.

New files:
- container/triple-c-scheduler: CLI for add/remove/enable/disable/list/logs/run/notifications
- container/triple-c-task-runner: cron wrapper with flock, logging, notifications, auto-cleanup

Key changes:
- Dockerfile: add cron package and COPY both scripts
- entrypoint.sh: timezone setup, cron daemon, crontab restore, env saving
- container.rs: init=true for zombie reaping, TZ env, scheduler instructions, timezone recreation check
- image.rs: embed scheduler scripts in build context
- app_settings.rs + types.ts: timezone field
- settings_commands.rs: detect_host_timezone via iana-time-zone crate
- SettingsPanel.tsx: timezone input with auto-detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:57:22 +00:00
06be613e36 Add port mappings feature, update app icon, and enhance default instructions
All checks were successful
Build App / build-linux (push) Successful in 2m49s
Build App / build-windows (push) Successful in 4m57s
- Add per-project port mapping configuration (host:container port pairs with
  TCP/UDP protocol) stored in project config and applied as Docker port
  bindings at container creation. Port changes trigger automatic container
  recreation via fingerprint detection.
- Create PortMappingsModal UI component following the same pattern as
  EnvVarsModal, integrated into ProjectCard config panel.
- Inject port mapping details into CLAUDE_INSTRUCTIONS so Claude inside the
  container knows which ports are available for testing services.
- Update default global instructions for new installs to encourage use of
  subagents for long-running and parallel tasks.
- Replace app icons with new v2 sun logo design for better visibility at
  small sizes (taskbar/dock).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:36:51 +00:00
31 changed files with 1462 additions and 12 deletions

View File

@@ -4675,6 +4675,7 @@ dependencies = [
"dirs", "dirs",
"fern", "fern",
"futures-util", "futures-util",
"iana-time-zone",
"keyring", "keyring",
"log", "log",
"reqwest 0.12.28", "reqwest 0.12.28",

View File

@@ -29,6 +29,7 @@ log = "0.4"
fern = { version = "0.7", features = ["date-based"] } fern = { version = "0.7", features = ["date-based"] }
tar = "0.4" tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
iana-time-zone = "0.1"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -160,6 +160,7 @@ pub async fn start_project_container(
&project, &project,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(),
) )
.await .await
.unwrap_or(false); .unwrap_or(false);
@@ -175,6 +176,7 @@ pub async fn start_project_container(
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(),
).await?; ).await?;
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id
@@ -191,6 +193,7 @@ pub async fn start_project_container(
&settings.global_aws, &settings.global_aws,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(),
).await?; ).await?;
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
new_id new_id

View File

@@ -29,6 +29,33 @@ pub async fn pull_image(
.await .await
} }
#[tauri::command]
pub async fn detect_host_timezone() -> Result<String, String> {
// Try the iana-time-zone crate first (cross-platform)
match iana_time_zone::get_timezone() {
Ok(tz) => return Ok(tz),
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
}
// Fallback: check TZ env var
if let Ok(tz) = std::env::var("TZ") {
if !tz.is_empty() {
return Ok(tz);
}
}
// Fallback: read /etc/timezone (Linux)
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
let tz = tz.trim().to_string();
if !tz.is_empty() {
return Ok(tz);
}
}
// Default to UTC if detection fails
Ok("UTC".to_string())
}
#[tauri::command] #[tauri::command]
pub async fn detect_aws_config() -> Result<Option<String>, String> { pub async fn detect_aws_config() -> Result<Option<String>, String> {
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {

View File

@@ -72,3 +72,23 @@ pub async fn close_terminal_session(
state.exec_manager.close_session(&session_id).await; state.exec_manager.close_session(&session_id).await;
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn paste_image_to_terminal(
session_id: String,
image_data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let file_name = format!("clipboard_{}.png", timestamp);
state
.exec_manager
.write_file_to_container(&container_id, &file_name, &image_data)
.await
}

View File

@@ -2,13 +2,43 @@ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions, StartContainerOptions, StopContainerOptions,
}; };
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum}; use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use super::client::get_docker; use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath}; use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.
### Commands
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
- `triple-c-scheduler list` — List all scheduled tasks
- `triple-c-scheduler remove --id ID` — Remove a task
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
### Cron format
Standard 5-field cron: `minute hour day-of-month month day-of-week`
Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)
### One-time tasks
Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution.
### Working directory
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
### Checking results
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
/// Compute a fingerprint string for the custom environment variables. /// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation. /// Sorted alphabetically so order changes do not cause spurious recreation.
@@ -95,6 +125,20 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
format!("{:x}", hasher.finish()) format!("{:x}", hasher.finish())
} }
/// Compute a fingerprint for port mappings so we can detect changes.
/// Sorted so order changes don't cause spurious recreation.
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
let mut parts: Vec<String> = port_mappings
.iter()
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
.collect();
parts.sort();
let joined = parts.join(",");
let mut hasher = DefaultHasher::new();
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> { pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
let docker = get_docker()?; let docker = get_docker()?;
let container_name = project.container_name(); let container_name = project.container_name();
@@ -133,6 +177,7 @@ pub async fn create_container(
global_aws: &GlobalAwsSettings, global_aws: &GlobalAwsSettings,
global_claude_instructions: Option<&str>, global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar], global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let docker = get_docker()?; let docker = get_docker()?;
let container_name = project.container_name(); let container_name = project.container_name();
@@ -255,11 +300,41 @@ pub async fn create_container(
let custom_env_fingerprint = compute_env_fingerprint(&merged_env); let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint)); env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
// Claude instructions (global + per-project) // Container timezone
let combined_instructions = merge_claude_instructions( if let Some(tz) = timezone {
if !tz.is_empty() {
env_vars.push(format!("TZ={}", tz));
}
}
// Claude instructions (global + per-project, plus port mapping info)
let mut combined_instructions = merge_claude_instructions(
global_claude_instructions, global_claude_instructions,
project.claude_instructions.as_deref(), project.claude_instructions.as_deref(),
); );
if !project.port_mappings.is_empty() {
let mut port_lines: Vec<String> = Vec::new();
port_lines.push("## Available Port Mappings".to_string());
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
for pm in &project.port_mappings {
port_lines.push(format!(
"- Host port {} -> Container port {} ({})",
pm.host_port, pm.container_port, pm.protocol
));
}
let port_info = port_lines.join("\n");
combined_instructions = Some(match combined_instructions {
Some(existing) => format!("{}\n\n{}", existing, port_info),
None => port_info,
});
}
// Scheduler instructions (always appended so all containers get scheduling docs)
let scheduler_docs = SCHEDULER_INSTRUCTIONS;
combined_instructions = Some(match combined_instructions {
Some(existing) => format!("{}\n\n{}", existing, scheduler_docs),
None => scheduler_docs.to_string(),
});
if let Some(ref instructions) = combined_instructions { if let Some(ref instructions) = combined_instructions {
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
} }
@@ -346,6 +421,21 @@ pub async fn create_container(
}); });
} }
// Port mappings
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
for pm in &project.port_mappings {
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
exposed_ports.insert(container_key.clone(), HashMap::new());
port_bindings.insert(
container_key,
Some(vec![PortBinding {
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(pm.host_port.to_string()),
}]),
);
}
let mut labels = HashMap::new(); let mut labels = HashMap::new();
labels.insert("triple-c.managed".to_string(), "true".to_string()); labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone()); labels.insert("triple-c.project-id".to_string(), project.id.clone());
@@ -353,10 +443,14 @@ pub async fn create_container(
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode)); labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths)); labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project)); labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
let host_config = HostConfig { let host_config = HostConfig {
mounts: Some(mounts), mounts: Some(mounts),
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
init: Some(true),
..Default::default() ..Default::default()
}; };
@@ -373,6 +467,7 @@ pub async fn create_container(
labels: Some(labels), labels: Some(labels),
working_dir: Some(working_dir), working_dir: Some(working_dir),
host_config: Some(host_config), host_config: Some(host_config),
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
tty: Some(true), tty: Some(true),
..Default::default() ..Default::default()
}; };
@@ -436,6 +531,7 @@ pub async fn container_needs_recreation(
project: &Project, project: &Project,
global_claude_instructions: Option<&str>, global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar], global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let docker = get_docker()?; let docker = get_docker()?;
let info = docker let info = docker
@@ -488,6 +584,14 @@ pub async fn container_needs_recreation(
} }
} }
// ── Port mappings fingerprint ──────────────────────────────────────────
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
if container_ports_fp != expected_ports_fp {
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
return Ok(true);
}
// ── Bedrock config fingerprint ─────────────────────────────────────── // ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project); let expected_bedrock_fp = compute_bedrock_fingerprint(project);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default(); let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
@@ -515,6 +619,14 @@ pub async fn container_needs_recreation(
} }
} }
// ── Timezone ─────────────────────────────────────────────────────────
let expected_tz = timezone.unwrap_or("");
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
if container_tz != expected_tz {
log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz);
return Ok(true);
}
// ── SSH key path mount ─────────────────────────────────────────────── // ── SSH key path mount ───────────────────────────────────────────────
let ssh_mount_source = mounts let ssh_mount_source = mounts
.and_then(|m| { .and_then(|m| {

View File

@@ -1,3 +1,4 @@
use bollard::container::UploadToContainerOptions;
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::StreamExt; use futures_util::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
@@ -212,4 +213,51 @@ impl ExecSessionManager {
session.shutdown(); session.shutdown();
} }
} }
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
Ok(session.container_id.clone())
}
pub async fn write_file_to_container(
&self,
container_id: &str,
file_name: &str,
data: &[u8],
) -> Result<String, String> {
let docker = get_docker()?;
// Build a tar archive in memory containing the file
let mut tar_buf = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, file_name, data)
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", file_name))
}
} }

View File

@@ -9,6 +9,8 @@ use crate::models::container_config;
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile"); const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh"); const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
pub async fn image_exists(image_name: &str) -> Result<bool, String> { pub async fn image_exists(image_name: &str) -> Result<bool, String> {
let docker = get_docker()?; let docker = get_docker()?;
@@ -135,6 +137,20 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
header.set_cksum(); header.set_cksum();
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?; archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
let scheduler_bytes = SCHEDULER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(scheduler_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
let task_runner_bytes = TASK_RUNNER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(task_runner_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
archive.finish()?; archive.finish()?;
} }

View File

@@ -84,11 +84,13 @@ pub fn run() {
commands::settings_commands::pull_image, commands::settings_commands::pull_image,
commands::settings_commands::detect_aws_config, commands::settings_commands::detect_aws_config,
commands::settings_commands::list_aws_profiles, commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Terminal // Terminal
commands::terminal_commands::open_terminal_session, commands::terminal_commands::open_terminal_session,
commands::terminal_commands::terminal_input, commands::terminal_commands::terminal_input,
commands::terminal_commands::terminal_resize, commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session, commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
// Updates // Updates
commands::update_commands::get_app_version, commands::update_commands::get_app_version,
commands::update_commands::check_for_updates, commands::update_commands::check_for_updates,

View File

@@ -7,7 +7,7 @@ fn default_true() -> bool {
} }
fn default_global_instructions() -> Option<String> { fn default_global_instructions() -> Option<String> {
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.".to_string()) Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -68,6 +68,8 @@ pub struct AppSettings {
pub auto_check_updates: bool, pub auto_check_updates: bool,
#[serde(default)] #[serde(default)]
pub dismissed_update_version: Option<String>, pub dismissed_update_version: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
} }
impl Default for AppSettings { impl Default for AppSettings {
@@ -84,6 +86,7 @@ impl Default for AppSettings {
global_custom_env_vars: Vec::new(), global_custom_env_vars: Vec::new(),
auto_check_updates: true, auto_check_updates: true,
dismissed_update_version: None, dismissed_update_version: None,
timezone: None,
} }
} }
} }

View File

@@ -12,6 +12,18 @@ pub struct ProjectPath {
pub mount_name: String, pub mount_name: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PortMapping {
pub host_port: u16,
pub container_port: u16,
#[serde(default = "default_protocol")]
pub protocol: String,
}
fn default_protocol() -> String {
"tcp".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project { pub struct Project {
pub id: String, pub id: String,
@@ -30,6 +42,8 @@ pub struct Project {
#[serde(default)] #[serde(default)]
pub custom_env_vars: Vec<EnvVar>, pub custom_env_vars: Vec<EnvVar>,
#[serde(default)] #[serde(default)]
pub port_mappings: Vec<PortMapping>,
#[serde(default)]
pub claude_instructions: Option<String>, pub claude_instructions: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
@@ -114,6 +128,7 @@ impl Project {
git_user_name: None, git_user_name: None,
git_user_email: None, git_user_email: None,
custom_env_vars: Vec::new(), custom_env_vars: Vec::new(),
port_mappings: Vec::new(),
claude_instructions: None, claude_instructions: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,

View File

@@ -0,0 +1,157 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { PortMapping } from "../../lib/types";
interface Props {
portMappings: PortMapping[];
disabled: boolean;
onSave: (mappings: PortMapping[]) => Promise<void>;
onClose: () => void;
}
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
const [mappings, setMappings] = useState<PortMapping[]>(initial);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
const updated = [...mappings];
const num = parseInt(value, 10);
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
setMappings(updated);
};
const updateProtocol = (index: number, value: string) => {
const updated = [...mappings];
updated[index] = { ...updated[index], protocol: value };
setMappings(updated);
};
const removeMapping = async (index: number) => {
const updated = mappings.filter((_, i) => i !== index);
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove port mapping:", err);
}
};
const addMapping = async () => {
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add port mapping:", err);
}
};
const handleBlur = async () => {
try { await onSave(mappings); } catch (err) {
console.error("Failed to update port mappings:", err);
}
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Map host ports to container ports. Services can be started after the container is running.
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change port mappings.
</div>
)}
<div className="space-y-2 mb-4">
{mappings.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
)}
{mappings.length > 0 && (
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
<span className="w-[30%]">Host Port</span>
<span className="w-[30%]">Container Port</span>
<span className="w-[25%]">Protocol</span>
<span className="w-[15%]" />
</div>
)}
{mappings.map((pm, i) => (
<div key={i} className="flex gap-2 items-center">
<input
type="number"
min="1"
max="65535"
value={pm.host_port || ""}
onChange={(e) => updatePort(i, "host_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
type="number"
min="1"
max="65535"
value={pm.container_port || ""}
onChange={(e) => updatePort(i, "container_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<select
value={pm.protocol}
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
disabled={disabled}
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<button
onClick={() => removeMapping(i)}
disabled={disabled}
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addMapping}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add port mapping
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useProjects } from "../../hooks/useProjects";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import EnvVarsModal from "./EnvVarsModal"; import EnvVarsModal from "./EnvVarsModal";
import PortMappingsModal from "./PortMappingsModal";
import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
interface Props { interface Props {
@@ -20,6 +21,7 @@ export default function ProjectCard({ project }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false); const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const isSelected = selectedProjectId === project.id; const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error"; const isStopped = project.status === "stopped" || project.status === "error";
@@ -32,6 +34,7 @@ export default function ProjectCard({ project }: Props) {
const [gitToken, setGitToken] = useState(project.git_token ?? ""); const [gitToken, setGitToken] = useState(project.git_token ?? "");
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? ""); const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []); const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
// Bedrock local state for text fields // Bedrock local state for text fields
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1"); const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
@@ -51,6 +54,7 @@ export default function ProjectCard({ project }: Props) {
setGitToken(project.git_token ?? ""); setGitToken(project.git_token ?? "");
setClaudeInstructions(project.claude_instructions ?? ""); setClaudeInstructions(project.claude_instructions ?? "");
setEnvVars(project.custom_env_vars ?? []); setEnvVars(project.custom_env_vars ?? []);
setPortMappings(project.port_mappings ?? []);
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1"); setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? ""); setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? ""); setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
@@ -524,6 +528,19 @@ export default function ProjectCard({ project }: Props) {
</button> </button>
</div> </div>
{/* Port Mappings */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
</label>
<button
onClick={() => setShowPortMappingsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* Claude Instructions */} {/* Claude Instructions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]"> <label className="text-xs text-[var(--text-secondary)]">
@@ -682,6 +699,18 @@ export default function ProjectCard({ project }: Props) {
/> />
)} )}
{showPortMappingsModal && (
<PortMappingsModal
portMappings={portMappings}
disabled={!isStopped}
onSave={async (mappings) => {
setPortMappings(mappings);
await update({ ...project, port_mappings: mappings });
}}
onClose={() => setShowPortMappingsModal(false)}
/>
)}
{showClaudeInstructionsModal && ( {showClaudeInstructionsModal && (
<ClaudeInstructionsModal <ClaudeInstructionsModal
instructions={claudeInstructions} instructions={claudeInstructions}

View File

@@ -6,6 +6,7 @@ import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates"; import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal"; import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
import EnvVarsModal from "../projects/EnvVarsModal"; import EnvVarsModal from "../projects/EnvVarsModal";
import { detectHostTimezone } from "../../lib/tauri-commands";
import type { EnvVar } from "../../lib/types"; import type { EnvVar } from "../../lib/types";
export default function SettingsPanel() { export default function SettingsPanel() {
@@ -14,6 +15,7 @@ export default function SettingsPanel() {
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? ""); const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []); const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false); const [checkingUpdates, setCheckingUpdates] = useState(false);
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
const [showInstructionsModal, setShowInstructionsModal] = useState(false); const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
@@ -21,7 +23,18 @@ export default function SettingsPanel() {
useEffect(() => { useEffect(() => {
setGlobalInstructions(appSettings?.global_claude_instructions ?? ""); setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []); setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]); setTimezone(appSettings?.timezone ?? "");
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
// Auto-detect timezone on first load if not yet set
useEffect(() => {
if (appSettings && !appSettings.timezone) {
detectHostTimezone().then((tz) => {
setTimezone(tz);
saveSettings({ ...appSettings, timezone: tz });
}).catch(() => {});
}
}, [appSettings?.timezone]);
const handleCheckNow = async () => { const handleCheckNow = async () => {
setCheckingUpdates(true); setCheckingUpdates(true);
@@ -46,6 +59,26 @@ export default function SettingsPanel() {
<DockerSettings /> <DockerSettings />
<AwsSettings /> <AwsSettings />
{/* Container Timezone */}
<div>
<label className="block text-sm font-medium mb-1">Container Timezone</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Timezone for containers affects scheduled task timing (IANA format, e.g. America/New_York)
</p>
<input
type="text"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, timezone: timezone || null });
}
}}
placeholder="UTC"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Global Claude Instructions */} {/* Global Claude Instructions */}
<div> <div>
<label className="block text-sm font-medium mb-1">Claude Instructions</label> <label className="block text-sm font-medium mb-1">Claude Instructions</label>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl"; import { WebglAddon } from "@xterm/addon-webgl";
@@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast";
interface Props { interface Props {
sessionId: string; sessionId: string;
@@ -14,10 +16,15 @@ interface Props {
export default function TerminalView({ sessionId, active }: Props) { export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null); const webglRef = useRef<WebglAddon | null>(null);
const { sendInput, resize, onOutput, onExit } = useTerminal(); const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -79,12 +86,50 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data); sendInput(sessionId, data);
}); });
// Handle image paste: intercept paste events with image data,
// upload to the container, and inject the file path into terminal input.
const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
e.preventDefault();
e.stopPropagation();
const blob = item.getAsFile();
if (!blob) return;
blob.arrayBuffer().then(async (buf) => {
try {
setImagePasteMsg("Uploading image...");
const data = new Uint8Array(buf);
const filePath = await pasteImage(sessionId, data);
// Inject the file path into terminal stdin
sendInput(sessionId, filePath);
setImagePasteMsg(`Image saved to ${filePath}`);
} catch (err) {
console.error("Image paste failed:", err);
setImagePasteMsg("Image paste failed");
}
});
return; // Only handle the first image
}
}
};
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
// Handle backend output -> terminal // Handle backend output -> terminal
let aborted = false; let aborted = false;
const detector = new UrlDetector((url) => setDetectedUrl(url));
detectorRef.current = detector;
const outputPromise = onOutput(sessionId, (data) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return; if (aborted) return;
term.write(data); term.write(data);
detector.feed(data);
}).then((unlisten) => { }).then((unlisten) => {
if (aborted) unlisten(); if (aborted) unlisten();
return unlisten; return unlisten;
@@ -116,7 +161,10 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => { return () => {
aborted = true; aborted = true;
detector.dispose();
detectorRef.current = null;
inputDisposable.dispose(); inputDisposable.dispose();
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.()); outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.());
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
@@ -160,11 +208,54 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
}, [active]); }, [active]);
// Auto-dismiss toast after 30 seconds
useEffect(() => {
if (!detectedUrl) return;
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
return () => clearTimeout(timer);
}, [detectedUrl]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
if (!imagePasteMsg) return;
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
return () => clearTimeout(timer);
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => {
if (detectedUrl) {
openUrl(detectedUrl).catch((e) =>
console.error("Failed to open URL:", e),
);
setDetectedUrl(null);
}
}, [detectedUrl]);
return ( return (
<div
ref={terminalContainerRef}
className={`w-full h-full relative ${active ? "" : "hidden"}`}
>
{detectedUrl && (
<UrlToast
url={detectedUrl}
onOpen={handleOpenUrl}
onDismiss={() => setDetectedUrl(null)}
/>
)}
{imagePasteMsg && (
<div
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
onClick={() => setImagePasteMsg(null)}
>
{imagePasteMsg}
</div>
)}
<div <div
ref={containerRef} ref={containerRef}
className={`w-full h-full ${active ? "" : "hidden"}`} className="w-full h-full"
style={{ padding: "8px" }} style={{ padding: "8px" }}
/> />
</div>
); );
} }

View File

@@ -0,0 +1,101 @@
interface Props {
url: string;
onOpen: () => void;
onDismiss: () => void;
}
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
return (
<div
className="animate-slide-down"
style={{
position: "absolute",
top: 12,
left: "50%",
transform: "translateX(-50%)",
zIndex: 40,
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
maxWidth: "min(90%, 600px)",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 2,
}}
>
Long URL detected
</div>
<div
style={{
fontSize: 12,
fontFamily: "monospace",
color: "var(--text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{url}
</div>
</div>
<button
onClick={onOpen}
style={{
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent)",
border: "none",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "var(--accent)")
}
>
Open
</button>
<button
onClick={onDismiss}
style={{
padding: "2px 6px",
fontSize: 14,
lineHeight: 1,
color: "var(--text-secondary)",
background: "transparent",
border: "none",
borderRadius: 4,
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--text-primary)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--text-secondary)")
}
aria-label="Dismiss"
>
</button>
</div>
);
}

View File

@@ -49,6 +49,14 @@ export function useTerminal() {
[], [],
); );
const pasteImage = useCallback(
async (sessionId: string, imageData: Uint8Array) => {
const bytes = Array.from(imageData);
return commands.pasteImageToTerminal(sessionId, bytes);
},
[],
);
const onOutput = useCallback( const onOutput = useCallback(
(sessionId: string, callback: (data: Uint8Array) => void) => { (sessionId: string, callback: (data: Uint8Array) => void) => {
const eventName = `terminal-output-${sessionId}`; const eventName = `terminal-output-${sessionId}`;
@@ -76,6 +84,7 @@ export function useTerminal() {
open, open,
close, close,
sendInput, sendInput,
pasteImage,
resize, resize,
onOutput, onOutput,
onExit, onExit,

View File

@@ -46,3 +46,10 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--border-color); background: var(--border-color);
} }
/* Toast slide-down animation */
@keyframes slide-down {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.animate-slide-down { animation: slide-down 0.2s ease-out; }

View File

@@ -35,6 +35,8 @@ export const detectAwsConfig = () =>
invoke<string | null>("detect_aws_config"); invoke<string | null>("detect_aws_config");
export const listAwsProfiles = () => export const listAwsProfiles = () =>
invoke<string[]>("list_aws_profiles"); invoke<string[]>("list_aws_profiles");
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
// Terminal // Terminal
export const openTerminalSession = (projectId: string, sessionId: string) => export const openTerminalSession = (projectId: string, sessionId: string) =>
@@ -45,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
invoke<void>("terminal_resize", { sessionId, cols, rows }); invoke<void>("terminal_resize", { sessionId, cols, rows });
export const closeTerminalSession = (sessionId: string) => export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId }); invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
// Updates // Updates
export const getAppVersion = () => invoke<string>("get_app_version"); export const getAppVersion = () => invoke<string>("get_app_version");

View File

@@ -8,6 +8,12 @@ export interface ProjectPath {
mount_name: string; mount_name: string;
} }
export interface PortMapping {
host_port: number;
container_port: number;
protocol: string;
}
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
@@ -22,6 +28,7 @@ export interface Project {
git_user_name: string | null; git_user_name: string | null;
git_user_email: string | null; git_user_email: string | null;
custom_env_vars: EnvVar[]; custom_env_vars: EnvVar[];
port_mappings: PortMapping[];
claude_instructions: string | null; claude_instructions: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -91,6 +98,7 @@ export interface AppSettings {
global_custom_env_vars: EnvVar[]; global_custom_env_vars: EnvVar[];
auto_check_updates: boolean; auto_check_updates: boolean;
dismissed_update_version: string | null; dismissed_update_version: string | null;
timezone: string | null;
} }
export interface UpdateInfo { export interface UpdateInfo {

127
app/src/lib/urlDetector.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
*
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
*
* When a URL match extends to the end of the flattened buffer, emission is
* deferred (more chunks may still be arriving). A confirmation timer emits
* the pending URL if no further data arrives within 500 ms.
*/
const ANSI_RE =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
const DEBOUNCE_MS = 300;
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
const MIN_URL_LENGTH = 100;
export type UrlCallback = (url: string) => void;
export class UrlDetector {
private decoder = new TextDecoder();
private buffer = "";
private timer: ReturnType<typeof setTimeout> | null = null;
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
private lastEmitted = "";
private pendingUrl: string | null = null;
private callback: UrlCallback;
constructor(callback: UrlCallback) {
this.callback = callback;
}
/** Feed raw PTY output chunks. */
feed(data: Uint8Array): void {
this.buffer += this.decoder.decode(data, { stream: true });
// Cap buffer to avoid unbounded growth
if (this.buffer.length > MAX_BUFFER) {
this.buffer = this.buffer.slice(-MAX_BUFFER);
}
// Cancel pending timers — new data arrived, rescan from scratch
if (this.timer !== null) clearTimeout(this.timer);
if (this.confirmTimer !== null) {
clearTimeout(this.confirmTimer);
this.confirmTimer = null;
}
// Debounce — scan after 300 ms of silence
this.timer = setTimeout(() => {
this.timer = null;
this.scan();
}, DEBOUNCE_MS);
}
private scan(): void {
// 1. Strip ANSI escape sequences
const clean = this.buffer.replace(ANSI_RE, "");
// 2. Flatten the buffer:
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
const flat = clean
.replace(/(\r?\n){2,}/g, " ")
.replace(/[\r\n]/g, "");
if (!flat) return;
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
let m: RegExpExecArray | null;
while ((m = urlRe.exec(flat)) !== null) {
const url = m[0];
// 4. Filter by length
if (url.length < MIN_URL_LENGTH) continue;
// 5. If the match extends to the very end of the flattened string,
// more chunks may still be arriving — defer emission.
if (m.index + url.length >= flat.length) {
this.pendingUrl = url;
this.confirmTimer = setTimeout(() => {
this.confirmTimer = null;
this.emitPending();
}, CONFIRM_MS);
return;
}
// 6. URL is clearly complete (more content follows) — dedup + emit
this.pendingUrl = null;
if (url !== this.lastEmitted) {
this.lastEmitted = url;
this.callback(url);
}
}
// Scan finished without a URL at the buffer end.
// If we had a pending URL from a previous scan, it's now confirmed complete.
if (this.pendingUrl) {
this.emitPending();
}
}
private emitPending(): void {
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
this.lastEmitted = this.pendingUrl;
this.callback(this.pendingUrl);
}
this.pendingUrl = null;
}
dispose(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.confirmTimer !== null) {
clearTimeout(this.confirmTimer);
this.confirmTimer = null;
}
}
}

View File

@@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unzip \ unzip \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
cron \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Remove default ubuntu user to free UID 1000 for host-user remapping # Remove default ubuntu user to free UID 1000 for host-user remapping
@@ -101,5 +102,9 @@ WORKDIR /workspace
USER root USER root
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
RUN chmod +x /usr/local/bin/triple-c-scheduler
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
RUN chmod +x /usr/local/bin/triple-c-task-runner
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -113,6 +113,59 @@ if [ -S /var/run/docker.sock ]; then
usermod -aG "$DOCKER_GROUP" claude usermod -aG "$DOCKER_GROUP" claude
fi fi
# ── Timezone setup ───────────────────────────────────────────────────────────
if [ -n "${TZ:-}" ]; then
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
echo "$TZ" > /etc/timezone
echo "entrypoint: timezone set to $TZ"
else
echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo"
fi
fi
# ── Scheduler setup ─────────────────────────────────────────────────────────
SCHEDULER_DIR="/home/claude/.claude/scheduler"
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
chown -R claude:claude "$SCHEDULER_DIR"
# Start cron daemon (runs as root, executes jobs per user crontab)
cron
# Save environment variables for cron jobs (cron runs with a minimal env)
ENV_FILE="$SCHEDULER_DIR/.env"
: > "$ENV_FILE"
env | while IFS='=' read -r key value; do
case "$key" in
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM)
# Escape single quotes in value and write as KEY='VALUE'
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
;;
esac
done
chown claude:claude "$ENV_FILE"
chmod 600 "$ENV_FILE"
# Restore crontab from persisted task JSON files (survives container recreation)
if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then
CRON_TMP=$(mktemp)
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP"
echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP"
echo "" >> "$CRON_TMP"
for task_file in "$SCHEDULER_DIR/tasks/"*.json; do
[ -f "$task_file" ] || continue
enabled=$(jq -r '.enabled' "$task_file")
[ "$enabled" = "true" ] || continue
schedule=$(jq -r '.schedule' "$task_file")
id=$(jq -r '.id' "$task_file")
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$CRON_TMP"
done
crontab -u claude "$CRON_TMP" 2>/dev/null || true
rm -f "$CRON_TMP"
echo "entrypoint: restored crontab from persisted tasks"
fi
# ── Stay alive as claude ───────────────────────────────────────────────────── # ── Stay alive as claude ─────────────────────────────────────────────────────
echo "Triple-C container ready." echo "Triple-C container ready."
exec su -s /bin/bash claude -c "exec sleep infinity" exec su -s /bin/bash claude -c "exec sleep infinity"

View File

@@ -0,0 +1,436 @@
#!/bin/bash
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
set -euo pipefail
SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
# ── Helpers ──────────────────────────────────────────────────────────────────
ensure_dirs() {
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR"
}
generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
rebuild_crontab() {
local tmp
tmp=$(mktemp)
# Header
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
echo "" >> "$tmp"
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
local enabled schedule id
enabled=$(jq -r '.enabled' "$task_file")
[ "$enabled" = "true" ] || continue
schedule=$(jq -r '.schedule' "$task_file")
id=$(jq -r '.id' "$task_file")
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
done
crontab "$tmp" 2>/dev/null || true
rm -f "$tmp"
}
usage() {
cat <<'EOF'
Usage: triple-c-scheduler <command> [options]
Commands:
add Add a new scheduled task
remove Remove a task
enable Enable a disabled task
disable Disable a task
list List all tasks
logs Show execution logs
run Manually trigger a task now
notifications Show or clear completion notifications
Add options:
--name NAME Task name (required)
--prompt "TASK" Task prompt for Claude (required)
--schedule "CRON" Cron schedule expression (for recurring tasks)
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
--working-dir DIR Working directory (default: /workspace)
Remove/Enable/Disable/Run options:
--id ID Task ID (required)
Logs options:
--id ID Show logs for a specific task (optional)
--tail N Show last N lines (default: 50)
Notifications options:
--clear Clear all notifications
Examples:
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
triple-c-scheduler list
triple-c-scheduler logs --id a1b2c3d4 --tail 20
triple-c-scheduler run --id a1b2c3d4
EOF
}
# ── Commands ─────────────────────────────────────────────────────────────────
cmd_add() {
local name="" prompt="" schedule="" at="" working_dir="/workspace"
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
--schedule) schedule="$2"; shift 2 ;;
--at) at="$2"; shift 2 ;;
--working-dir) working_dir="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$name" ]; then
echo "Error: --name is required" >&2
return 1
fi
if [ -z "$prompt" ]; then
echo "Error: --prompt is required" >&2
return 1
fi
if [ -z "$schedule" ] && [ -z "$at" ]; then
echo "Error: either --schedule or --at is required" >&2
return 1
fi
if [ -n "$schedule" ] && [ -n "$at" ]; then
echo "Error: use either --schedule or --at, not both" >&2
return 1
fi
local id task_type cron_expr
id=$(generate_id)
if [ -n "$at" ]; then
task_type="once"
# Parse "YYYY-MM-DD HH:MM" into cron expression
local year month day hour minute
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
return 1
fi
year="${BASH_REMATCH[1]}"
month="${BASH_REMATCH[2]}"
day="${BASH_REMATCH[3]}"
hour="${BASH_REMATCH[4]}"
minute="${BASH_REMATCH[5]}"
# Remove leading zeros for cron
month=$((10#$month))
day=$((10#$day))
hour=$((10#$hour))
minute=$((10#$minute))
cron_expr="$minute $hour $day $month *"
else
task_type="recurring"
cron_expr="$schedule"
fi
local created_at
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local task_json
task_json=$(jq -n \
--arg id "$id" \
--arg name "$name" \
--arg prompt "$prompt" \
--arg schedule "$cron_expr" \
--arg type "$task_type" \
--arg at "$at" \
--arg created_at "$created_at" \
--argjson enabled true \
--arg working_dir "$working_dir" \
'{
id: $id,
name: $name,
prompt: $prompt,
schedule: $schedule,
type: $type,
at: $at,
created_at: $created_at,
enabled: $enabled,
working_dir: $working_dir
}')
echo "$task_json" > "$TASKS_DIR/${id}.json"
rebuild_crontab
echo "Task created:"
echo " ID: $id"
echo " Name: $name"
echo " Type: $task_type"
if [ "$task_type" = "once" ]; then
echo " At: $at"
fi
echo " Schedule: $cron_expr"
echo " Prompt: $prompt"
}
cmd_remove() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local name
name=$(jq -r '.name' "$task_file")
rm -f "$task_file"
rebuild_crontab
echo "Removed task '$name' ($id)"
}
cmd_enable() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local tmp
tmp=$(mktemp)
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
rebuild_crontab
local name
name=$(jq -r '.name' "$task_file")
echo "Enabled task '$name' ($id)"
}
cmd_disable() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local tmp
tmp=$(mktemp)
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
rebuild_crontab
local name
name=$(jq -r '.name' "$task_file")
echo "Disabled task '$name' ($id)"
}
cmd_list() {
local found=false
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
found=true
local id name type enabled schedule at prompt
id=$(jq -r '.id' "$task_file")
name=$(jq -r '.name' "$task_file")
type=$(jq -r '.type' "$task_file")
enabled=$(jq -r '.enabled' "$task_file")
schedule=$(jq -r '.schedule' "$task_file")
at=$(jq -r '.at // ""' "$task_file")
prompt=$(jq -r '.prompt' "$task_file")
local display_schedule="$schedule"
if [ "$type" = "once" ] && [ -n "$at" ]; then
display_schedule="at $at"
fi
# Truncate long fields for display
[ ${#name} -gt 20 ] && name="${name:0:17}..."
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
done
if [ "$found" = "false" ]; then
echo "No scheduled tasks."
fi
}
cmd_logs() {
local id="" tail_n=50
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
--tail) tail_n="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -n "$id" ]; then
local log_dir="$LOGS_DIR/$id"
if [ ! -d "$log_dir" ]; then
echo "No logs found for task '$id'"
return 0
fi
# Show the most recent log file
local latest
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
if [ -z "$latest" ]; then
echo "No logs found for task '$id'"
return 0
fi
echo "=== Latest log for task $id: $(basename "$latest") ==="
tail -n "$tail_n" "$latest"
else
# Show recent logs across all tasks
local all_logs
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
if [ -z "$all_logs" ]; then
echo "No logs found."
return 0
fi
for log_file in $all_logs; do
local task_id
task_id=$(basename "$(dirname "$log_file")")
echo "=== Task $task_id: $(basename "$log_file") ==="
tail -n 5 "$log_file"
echo ""
done
fi
}
cmd_run() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local name
name=$(jq -r '.name' "$task_file")
echo "Manually triggering task '$name' ($id)..."
/usr/local/bin/triple-c-task-runner "$id"
}
cmd_notifications() {
local clear=false
while [[ $# -gt 0 ]]; do
case "$1" in
--clear) clear=true; shift ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ "$clear" = "true" ]; then
rm -f "$NOTIFICATIONS_DIR"/*.notify
echo "Notifications cleared."
return 0
fi
local found=false
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
[ -f "$notify_file" ] || continue
found=true
cat "$notify_file"
echo "---"
done
if [ "$found" = "false" ]; then
echo "No notifications."
fi
}
# ── Main ─────────────────────────────────────────────────────────────────────
ensure_dirs
if [ $# -eq 0 ]; then
usage
exit 1
fi
command="$1"
shift
case "$command" in
add) cmd_add "$@" ;;
remove) cmd_remove "$@" ;;
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
list) cmd_list ;;
logs) cmd_logs "$@" ;;
run) cmd_run "$@" ;;
notifications) cmd_notifications "$@" ;;
help|--help|-h) usage ;;
*)
echo "Unknown command: $command" >&2
usage
exit 1
;;
esac

View File

@@ -0,0 +1,142 @@
#!/bin/bash
# triple-c-task-runner — Executes a scheduled task via Claude Code agent
# Called by cron with a task ID argument. Handles locking, logging,
# notifications, one-time task cleanup, and log pruning.
set -uo pipefail
SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
ENV_FILE="${SCHEDULER_DIR}/.env"
TASK_ID="${1:-}"
if [ -z "$TASK_ID" ]; then
echo "Usage: triple-c-task-runner <task-id>" >&2
exit 1
fi
TASK_FILE="${TASKS_DIR}/${TASK_ID}.json"
LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}"
if [ ! -f "$TASK_FILE" ]; then
echo "Task file not found: $TASK_FILE" >&2
exit 1
fi
# ── Acquire lock (prevent overlapping runs of the same task) ─────────────────
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "Task $TASK_ID is already running, skipping." >&2
exit 0
fi
# ── Source saved environment ─────────────────────────────────────────────────
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# ── Read task definition ────────────────────────────────────────────────────
PROMPT=$(jq -r '.prompt' "$TASK_FILE")
WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# ── Prepare log directory ───────────────────────────────────────────────────
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
mkdir -p "$TASK_LOG_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
# ── Execute Claude agent ────────────────────────────────────────────────────
{
echo "=== Task: $TASK_NAME ($TASK_ID) ==="
echo "=== Started: $(date) ==="
echo "=== Working dir: $WORKING_DIR ==="
echo "=== Prompt: $PROMPT ==="
echo ""
} > "$LOG_FILE"
EXIT_CODE=0
if [ -d "$WORKING_DIR" ]; then
cd "$WORKING_DIR"
claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$?
else
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
EXIT_CODE=1
fi
{
echo ""
echo "=== Finished: $(date) ==="
echo "=== Exit code: $EXIT_CODE ==="
} >> "$LOG_FILE"
# ── Write notification ──────────────────────────────────────────────────────
mkdir -p "$NOTIFICATIONS_DIR"
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
if [ $EXIT_CODE -eq 0 ]; then
STATUS="SUCCESS"
else
STATUS="FAILED (exit code $EXIT_CODE)"
fi
# Extract a summary (last 10 meaningful lines before the footer)
SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10)
cat > "$NOTIFY_FILE" <<NOTIFY
Task: $TASK_NAME ($TASK_ID)
Status: $STATUS
Time: $(date)
Type: $TASK_TYPE
Summary:
$SUMMARY
NOTIFY
# ── One-time task cleanup ───────────────────────────────────────────────────
if [ "$TASK_TYPE" = "once" ]; then
rm -f "$TASK_FILE"
# Rebuild crontab to remove the completed one-time task
/usr/local/bin/triple-c-scheduler list > /dev/null 2>&1 || true
# Direct crontab rebuild (in case scheduler list doesn't trigger it)
TMP_CRON=$(mktemp)
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON"
echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON"
echo "" >> "$TMP_CRON"
for tf in "$TASKS_DIR"/*.json; do
[ -f "$tf" ] || continue
local_enabled=$(jq -r '.enabled' "$tf")
[ "$local_enabled" = "true" ] || continue
local_schedule=$(jq -r '.schedule' "$tf")
local_id=$(jq -r '.id' "$tf")
echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON"
done
crontab "$TMP_CRON" 2>/dev/null || true
rm -f "$TMP_CRON"
fi
# ── Prune old logs (keep 20 per task) ───────────────────────────────────────
LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l)
if [ "$LOG_COUNT" -gt 20 ]; then
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
fi
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
if [ "$NOTIFY_COUNT" -gt 50 ]; then
find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f
fi
# Release lock
flock -u 200
rm -f "$LOCK_FILE"
exit $EXIT_CODE

BIN
triple-c-app-logov2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB