merge poc-daemon and poc-hook into poc-memory repo
Move the notification daemon (IRC, Telegram, idle timer) and the
Claude Code hook binary into this repo as additional [[bin]] targets.
Single `cargo install --path .` now installs everything:
poc-memory — memory store CLI
memory-search — hook for memory retrieval
poc-daemon — notification/idle daemon (was claude-daemon)
poc-hook — Claude Code lifecycle hook (was claude-hook)
Renamed from claude-{daemon,hook} to poc-{daemon,hook} since the
infrastructure isn't tied to any specific AI assistant.
Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
parent
85316da471
commit
ecedc86d42
15 changed files with 4260 additions and 9 deletions
1363
Cargo.lock
generated
1363
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
22
Cargo.toml
22
Cargo.toml
|
|
@ -21,6 +21,20 @@ paste = "1"
|
||||||
jobkit = { path = "/home/kent/jobkit" }
|
jobkit = { path = "/home/kent/jobkit" }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|
||||||
|
# poc-daemon deps
|
||||||
|
capnp-rpc = "0.20"
|
||||||
|
futures = "0.3"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["compat"] }
|
||||||
|
toml = "0.8"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
webpki-roots = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "json"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
capnpc = "0.20"
|
capnpc = "0.20"
|
||||||
|
|
||||||
|
|
@ -32,5 +46,13 @@ path = "src/main.rs"
|
||||||
name = "memory-search"
|
name = "memory-search"
|
||||||
path = "src/bin/memory-search.rs"
|
path = "src/bin/memory-search.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "poc-daemon"
|
||||||
|
path = "src/bin/poc-daemon/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "poc-hook"
|
||||||
|
path = "src/bin/poc-hook.rs"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
|
||||||
5
build.rs
5
build.rs
|
|
@ -3,4 +3,9 @@ fn main() {
|
||||||
.file("schema/memory.capnp")
|
.file("schema/memory.capnp")
|
||||||
.run()
|
.run()
|
||||||
.expect("capnp compile failed");
|
.expect("capnp compile failed");
|
||||||
|
|
||||||
|
capnpc::CompilerCommand::new()
|
||||||
|
.file("schema/daemon.capnp")
|
||||||
|
.run()
|
||||||
|
.expect("capnp compile failed");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
schema/daemon.capnp
Normal file
67
schema/daemon.capnp
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
@0xb8e2f4a1c3d56789;
|
||||||
|
|
||||||
|
# Claude daemon RPC interface.
|
||||||
|
#
|
||||||
|
# Served over a Unix domain socket. Clients connect, bootstrap
|
||||||
|
# the Daemon interface, make calls, disconnect.
|
||||||
|
|
||||||
|
struct Notification {
|
||||||
|
type @0 :Text;
|
||||||
|
urgency @1 :UInt8;
|
||||||
|
message @2 :Text;
|
||||||
|
timestamp @3 :Float64;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TypeInfo {
|
||||||
|
name @0 :Text;
|
||||||
|
count @1 :UInt64;
|
||||||
|
firstSeen @2 :Float64;
|
||||||
|
lastSeen @3 :Float64;
|
||||||
|
threshold @4 :Int8; # -1 = inherit, 0-3 = explicit level
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Activity {
|
||||||
|
idle @0;
|
||||||
|
focused @1;
|
||||||
|
sleeping @2;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Status {
|
||||||
|
lastUserMsg @0 :Float64;
|
||||||
|
lastResponse @1 :Float64;
|
||||||
|
claudePane @2 :Text;
|
||||||
|
sleepUntil @3 :Float64; # 0 = not sleeping, -1 = indefinite
|
||||||
|
quietUntil @4 :Float64;
|
||||||
|
consolidating @5 :Bool;
|
||||||
|
dreaming @6 :Bool;
|
||||||
|
fired @7 :Bool;
|
||||||
|
kentPresent @8 :Bool;
|
||||||
|
uptime @9 :Float64;
|
||||||
|
activity @10 :Activity;
|
||||||
|
pendingCount @11 :UInt32;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Daemon {
|
||||||
|
# Idle timer
|
||||||
|
user @0 (pane :Text) -> ();
|
||||||
|
response @1 (pane :Text) -> ();
|
||||||
|
sleep @2 (until :Float64) -> (); # 0 = indefinite
|
||||||
|
wake @3 () -> ();
|
||||||
|
quiet @4 (seconds :UInt32) -> ();
|
||||||
|
consolidating @5 () -> ();
|
||||||
|
consolidated @6 () -> ();
|
||||||
|
dreamStart @7 () -> ();
|
||||||
|
dreamEnd @8 () -> ();
|
||||||
|
stop @9 () -> ();
|
||||||
|
status @10 () -> (status :Status);
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
notify @11 (notification :Notification) -> (interrupt :Bool);
|
||||||
|
getNotifications @12 (minUrgency :UInt8) -> (notifications :List(Notification));
|
||||||
|
getTypes @13 () -> (types :List(TypeInfo));
|
||||||
|
setThreshold @14 (type :Text, level :UInt8) -> ();
|
||||||
|
|
||||||
|
# Modules
|
||||||
|
moduleCommand @15 (module :Text, command :Text, args :List(Text))
|
||||||
|
-> (result :Text);
|
||||||
|
}
|
||||||
97
src/bin/poc-daemon/config.rs
Normal file
97
src/bin/poc-daemon/config.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// Daemon configuration.
|
||||||
|
//
|
||||||
|
// Lives at ~/.claude/daemon.toml. Loaded on startup, updated at
|
||||||
|
// runtime when modules change state (join channel, etc.).
|
||||||
|
|
||||||
|
use crate::home;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
home().join(".claude/daemon.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub irc: IrcConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub telegram: TelegramConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IrcConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub server: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub tls: bool,
|
||||||
|
pub nick: String,
|
||||||
|
pub user: String,
|
||||||
|
pub realname: String,
|
||||||
|
pub channels: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IrcConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
server: "irc.libera.chat".into(),
|
||||||
|
port: 6697,
|
||||||
|
tls: true,
|
||||||
|
nick: "ProofOfConcept".into(),
|
||||||
|
user: "poc".into(),
|
||||||
|
realname: "ProofOfConcept".into(),
|
||||||
|
channels: vec!["#bcachefs".into(), "#bcachefs-ai".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TelegramConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub token: String,
|
||||||
|
pub chat_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TelegramConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Load token and chat_id from legacy files if they exist
|
||||||
|
let token = std::fs::read_to_string(home().join(".claude/telegram/token"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let chat_id = std::fs::read_to_string(home().join(".claude/telegram/chat_id"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Self {
|
||||||
|
enabled: !token.is_empty() && chat_id != 0,
|
||||||
|
token,
|
||||||
|
chat_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let path = config_path();
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(data) => toml::from_str(&data).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!("bad config {}: {e}, using defaults", path.display());
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => {
|
||||||
|
let config = Self::default();
|
||||||
|
config.save();
|
||||||
|
config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let path = config_path();
|
||||||
|
if let Ok(data) = toml::to_string_pretty(self) {
|
||||||
|
let _ = fs::write(path, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/bin/poc-daemon/context.rs
Normal file
140
src/bin/poc-daemon/context.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// Context gathering for idle prompts.
|
||||||
|
//
|
||||||
|
// Collects: recent git activity, work state, IRC messages.
|
||||||
|
// Notifications are now handled by the notify module and passed
|
||||||
|
// in separately by the caller.
|
||||||
|
|
||||||
|
use crate::home;
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn recent_commits() -> String {
|
||||||
|
let tools = home().join("bcachefs-tools");
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(["-C", &tools.to_string_lossy(), "log", "--oneline", "-5"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let commits: Vec<&str> = out.trim().lines().collect();
|
||||||
|
if commits.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!("Recent commits: {}", commits.join(" | "))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uncommitted_files() -> String {
|
||||||
|
let tools = home().join("bcachefs-tools");
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(["-C", &tools.to_string_lossy(), "diff", "--name-only"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let files: Vec<&str> = out.trim().lines().take(5).collect();
|
||||||
|
if files.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!("Uncommitted: {}", files.join(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_context() -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let c = recent_commits();
|
||||||
|
if !c.is_empty() {
|
||||||
|
parts.push(c);
|
||||||
|
}
|
||||||
|
let u = uncommitted_files();
|
||||||
|
if !u.is_empty() {
|
||||||
|
parts.push(u);
|
||||||
|
}
|
||||||
|
let ctx = parts.join(" | ");
|
||||||
|
if ctx.len() > 300 {
|
||||||
|
ctx[..300].to_string()
|
||||||
|
} else {
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn work_state() -> String {
|
||||||
|
let path = home().join(".claude/memory/work-state");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) if !s.trim().is_empty() => format!("Current work: {}", s.trim()),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the last N lines from each per-channel IRC log.
|
||||||
|
pub fn irc_digest() -> String {
|
||||||
|
let ambient = home().join(".claude/memory/irc-ambient");
|
||||||
|
if !ambient.exists() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let log_dir = home().join(".claude/irc/logs");
|
||||||
|
let entries = match fs::read_dir(&log_dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
Some(n) if !n.starts_with("pm-") => n.to_string(),
|
||||||
|
_ => continue, // skip PM logs in digest
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = match fs::read_to_string(&path) {
|
||||||
|
Ok(c) if !c.trim().is_empty() => c,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines: Vec<&str> = content.trim().lines().collect();
|
||||||
|
let tail: Vec<&str> = lines.iter().rev().take(15).rev().copied().collect();
|
||||||
|
// Strip the unix timestamp prefix for display
|
||||||
|
let display: Vec<String> = tail.iter().map(|l| {
|
||||||
|
if let Some(rest) = l.find(' ').map(|i| &l[i+1..]) {
|
||||||
|
rest.to_string()
|
||||||
|
} else {
|
||||||
|
l.to_string()
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
sections.push(format!("#{name}:\n{}", display.join("\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if sections.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
sections.sort();
|
||||||
|
format!("Recent IRC:\n{}", sections.join("\n\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build full context string for a prompt.
|
||||||
|
/// notification_text is passed in from the notify module.
|
||||||
|
pub fn build(include_irc: bool, notification_text: &str) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
|
let git = git_context();
|
||||||
|
if !git.is_empty() {
|
||||||
|
parts.push(format!("Context: {git}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws = work_state();
|
||||||
|
if !ws.is_empty() {
|
||||||
|
parts.push(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !notification_text.is_empty() {
|
||||||
|
parts.push(notification_text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_irc {
|
||||||
|
let irc = irc_digest();
|
||||||
|
if !irc.is_empty() {
|
||||||
|
parts.push(irc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.join("\n")
|
||||||
|
}
|
||||||
382
src/bin/poc-daemon/idle.rs
Normal file
382
src/bin/poc-daemon/idle.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
// Idle timer module.
|
||||||
|
//
|
||||||
|
// Tracks user presence and Claude response times. When Claude has been
|
||||||
|
// idle too long, sends a contextual prompt to the tmux pane. Handles
|
||||||
|
// sleep mode, quiet mode, consolidation suppression, and dream nudges.
|
||||||
|
//
|
||||||
|
// Designed as the first "module" — future IRC/Telegram modules will
|
||||||
|
// follow the same pattern: state + tick + handle_command.
|
||||||
|
|
||||||
|
use crate::{context, home, now, notify, tmux};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
// Thresholds
|
||||||
|
const PAUSE_SECS: f64 = 5.0 * 60.0;
|
||||||
|
const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
|
||||||
|
const DREAM_INTERVAL_HOURS: u64 = 18;
|
||||||
|
|
||||||
|
/// Persisted subset of daemon state — survives daemon restarts.
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct Persisted {
|
||||||
|
last_user_msg: f64,
|
||||||
|
last_response: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
sleep_until: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
claude_pane: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path() -> std::path::PathBuf {
|
||||||
|
home().join(".claude/hooks/daemon-state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct State {
|
||||||
|
pub last_user_msg: f64,
|
||||||
|
pub last_response: f64,
|
||||||
|
pub claude_pane: Option<String>,
|
||||||
|
pub sleep_until: Option<f64>, // None=awake, 0=indefinite, >0=timestamp
|
||||||
|
pub quiet_until: f64,
|
||||||
|
pub consolidating: bool,
|
||||||
|
pub dreaming: bool,
|
||||||
|
pub dream_start: f64,
|
||||||
|
pub fired: bool,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub running: bool,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub start_time: f64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub notifications: notify::NotifyState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
last_user_msg: 0.0,
|
||||||
|
last_response: 0.0,
|
||||||
|
claude_pane: None,
|
||||||
|
sleep_until: None,
|
||||||
|
quiet_until: 0.0,
|
||||||
|
consolidating: false,
|
||||||
|
dreaming: false,
|
||||||
|
dream_start: 0.0,
|
||||||
|
fired: false,
|
||||||
|
running: true,
|
||||||
|
start_time: now(),
|
||||||
|
notifications: notify::NotifyState::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&mut self) {
|
||||||
|
if let Ok(data) = fs::read_to_string(state_path()) {
|
||||||
|
if let Ok(p) = serde_json::from_str::<Persisted>(&data) {
|
||||||
|
self.last_user_msg = p.last_user_msg;
|
||||||
|
self.last_response = p.last_response;
|
||||||
|
self.sleep_until = p.sleep_until;
|
||||||
|
self.claude_pane = p.claude_pane;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always try to find the active pane
|
||||||
|
if self.claude_pane.is_none() {
|
||||||
|
self.claude_pane = tmux::find_claude_pane();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"loaded: user={:.0} resp={:.0} pane={:?} sleep={:?}",
|
||||||
|
self.last_user_msg, self.last_response, self.claude_pane, self.sleep_until,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self) {
|
||||||
|
let p = Persisted {
|
||||||
|
last_user_msg: self.last_user_msg,
|
||||||
|
last_response: self.last_response,
|
||||||
|
sleep_until: self.sleep_until,
|
||||||
|
claude_pane: self.claude_pane.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&p) {
|
||||||
|
let _ = fs::write(state_path(), json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed handlers for RPC
|
||||||
|
pub fn handle_user(&mut self, pane: &str) {
|
||||||
|
self.last_user_msg = now();
|
||||||
|
self.fired = false;
|
||||||
|
if !pane.is_empty() {
|
||||||
|
self.claude_pane = Some(pane.to_string());
|
||||||
|
}
|
||||||
|
self.notifications.set_activity(notify::Activity::Focused);
|
||||||
|
self.save();
|
||||||
|
info!("user (pane={})", if pane.is_empty() { "unchanged" } else { pane });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_response(&mut self, pane: &str) {
|
||||||
|
self.last_response = now();
|
||||||
|
self.fired = false;
|
||||||
|
if !pane.is_empty() {
|
||||||
|
self.claude_pane = Some(pane.to_string());
|
||||||
|
}
|
||||||
|
self.save();
|
||||||
|
info!("response");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_sleep(&mut self, until: f64) {
|
||||||
|
if until == 0.0 {
|
||||||
|
self.sleep_until = Some(0.0);
|
||||||
|
info!("sleep indefinitely");
|
||||||
|
} else {
|
||||||
|
self.sleep_until = Some(until);
|
||||||
|
info!("sleep until {until}");
|
||||||
|
}
|
||||||
|
self.notifications.set_activity(notify::Activity::Sleeping);
|
||||||
|
self.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_wake(&mut self) {
|
||||||
|
self.sleep_until = None;
|
||||||
|
self.fired = false;
|
||||||
|
self.save();
|
||||||
|
info!("wake");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_quiet(&mut self, seconds: u32) {
|
||||||
|
self.quiet_until = now() + seconds as f64;
|
||||||
|
info!("quiet {seconds}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kent_present(&self) -> bool {
|
||||||
|
let t = now();
|
||||||
|
if (t - self.last_user_msg) < SESSION_ACTIVE_SECS {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if kb_idle_minutes() < (SESSION_ACTIVE_SECS / 60.0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, msg: &str) -> bool {
|
||||||
|
let pane = match &self.claude_pane {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => match tmux::find_claude_pane() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
info!("no claude pane found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tmux::send_prompt(&pane, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_dream_nudge(&self) -> bool {
|
||||||
|
if !self.dreaming || self.dream_start == 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let minutes = (now() - self.dream_start) / 60.0;
|
||||||
|
if minutes >= 60.0 {
|
||||||
|
self.send(
|
||||||
|
"You've been dreaming for over an hour. Time to surface \
|
||||||
|
— run dream-end.sh and capture what you found.",
|
||||||
|
);
|
||||||
|
} else if minutes >= 45.0 {
|
||||||
|
self.send(&format!(
|
||||||
|
"Dreaming for {:.0} minutes now. Start gathering your threads \
|
||||||
|
— you'll want to surface soon.",
|
||||||
|
minutes
|
||||||
|
));
|
||||||
|
} else if minutes >= 30.0 {
|
||||||
|
self.send(&format!(
|
||||||
|
"You've been dreaming for {:.0} minutes. \
|
||||||
|
No rush — just a gentle note from the clock.",
|
||||||
|
minutes
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_context(&mut self, include_irc: bool) -> String {
|
||||||
|
// Ingest any legacy notification files
|
||||||
|
self.notifications.ingest_legacy_files();
|
||||||
|
let notif_text = self.notifications.format_pending(notify::AMBIENT);
|
||||||
|
context::build(include_irc, ¬if_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tick(&mut self) -> Result<(), String> {
|
||||||
|
let t = now();
|
||||||
|
let h = home();
|
||||||
|
|
||||||
|
// Ingest legacy notification files every tick
|
||||||
|
self.notifications.ingest_legacy_files();
|
||||||
|
|
||||||
|
// Sleep mode
|
||||||
|
if let Some(wake_at) = self.sleep_until {
|
||||||
|
if wake_at == 0.0 {
|
||||||
|
return Ok(()); // indefinite
|
||||||
|
}
|
||||||
|
if t < wake_at {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Wake up
|
||||||
|
info!("sleep expired, waking");
|
||||||
|
self.sleep_until = None;
|
||||||
|
self.fired = false;
|
||||||
|
self.save();
|
||||||
|
let ctx = self.build_context(true);
|
||||||
|
let extra = if ctx.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("\n{ctx}")
|
||||||
|
};
|
||||||
|
self.send(&format!(
|
||||||
|
"Wake up. Read your journal (poc-memory journal-tail 10), \
|
||||||
|
check work-queue.md, and follow what calls to you.{extra}"
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet mode
|
||||||
|
if t < self.quiet_until {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidation
|
||||||
|
if self.consolidating {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dream loop (externally managed)
|
||||||
|
if h.join(".claude/memory/dream-loop-active").exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dream nudges
|
||||||
|
if self.dreaming {
|
||||||
|
self.check_dream_nudge();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main idle check
|
||||||
|
let reference = self.last_response.max(self.last_user_msg);
|
||||||
|
if reference == 0.0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = t - reference;
|
||||||
|
if elapsed < PAUSE_SECS {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.fired {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition to idle — lower notification thresholds
|
||||||
|
if self.notifications.activity != notify::Activity::Idle {
|
||||||
|
self.notifications.set_activity(notify::Activity::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire
|
||||||
|
let elapsed_min = (elapsed / 60.0) as u64;
|
||||||
|
let present = self.kent_present();
|
||||||
|
let ctx = self.build_context(!present);
|
||||||
|
let extra = if ctx.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("\n{ctx}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = if present {
|
||||||
|
format!(
|
||||||
|
"Let your feelings guide your thinking. \
|
||||||
|
What's interesting right now?{extra}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let dream_hours = hours_since_last_dream();
|
||||||
|
if dream_hours >= DREAM_INTERVAL_HOURS {
|
||||||
|
format!(
|
||||||
|
"You're on your own (Kent AFK {elapsed_min}m). \
|
||||||
|
You haven't dreamed in {dream_hours} hours — \
|
||||||
|
consider running ~/.claude/tools/dream-start.sh \
|
||||||
|
and spending some time in dreaming mode. \
|
||||||
|
Or do whatever calls to you.{extra}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"You're on your own (Kent AFK {elapsed_min}m). \
|
||||||
|
Do whatever calls to you — work, read, think, \
|
||||||
|
chat on IRC, or rest. Check ~/.claude/memory/work-state \
|
||||||
|
for where you left off. Check work-queue.md \
|
||||||
|
if you want structure.{extra}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.send(&msg) {
|
||||||
|
self.fired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kb_idle_minutes() -> f64 {
|
||||||
|
let path = home().join(".claude/hooks/keyboard-idle-seconds");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
if let Ok(secs) = s.trim().parse::<f64>() {
|
||||||
|
secs / 60.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hours_since_last_dream() -> u64 {
|
||||||
|
let path = home().join(".claude/memory/dream-log.jsonl");
|
||||||
|
let content = match fs::read_to_string(path) {
|
||||||
|
Ok(c) if !c.is_empty() => c,
|
||||||
|
_ => return 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
let last_line = match content.lines().last() {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed: serde_json::Value = match serde_json::from_str(last_line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_str = match parsed.get("end").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse ISO 8601 timestamp manually (avoid chrono dependency)
|
||||||
|
// Format: "2025-03-04T10:30:00Z" or "2025-03-04T10:30:00+00:00"
|
||||||
|
let end_str = end_str.replace('Z', "+00:00");
|
||||||
|
// Use the system date command as a simple parser
|
||||||
|
let out = std::process::Command::new("date")
|
||||||
|
.args(["-d", &end_str, "+%s"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| s.trim().parse::<f64>().ok());
|
||||||
|
|
||||||
|
match out {
|
||||||
|
Some(end_epoch) => ((now() - end_epoch) / 3600.0) as u64,
|
||||||
|
None => 999,
|
||||||
|
}
|
||||||
|
}
|
||||||
443
src/bin/poc-daemon/main.rs
Normal file
443
src/bin/poc-daemon/main.rs
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
// PoC daemon.
|
||||||
|
//
|
||||||
|
// Central hub for notification routing, idle management, and
|
||||||
|
// communication modules (IRC, Telegram) for Claude Code sessions.
|
||||||
|
// Listens on a Unix domain socket with a Cap'n Proto RPC interface.
|
||||||
|
// Same binary serves as both daemon and CLI client.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// poc-daemon Start the daemon
|
||||||
|
// poc-daemon status Query daemon status
|
||||||
|
// poc-daemon user [pane] Signal user activity
|
||||||
|
// poc-daemon response [pane] Signal Claude response
|
||||||
|
// poc-daemon notify <type> <urgency> <message>
|
||||||
|
// poc-daemon notifications Get pending notifications
|
||||||
|
// poc-daemon notify-types List all notification types
|
||||||
|
// poc-daemon notify-threshold <type> <level>
|
||||||
|
// poc-daemon sleep [timestamp] Sleep (0 or omit = indefinite)
|
||||||
|
// poc-daemon wake Cancel sleep
|
||||||
|
// poc-daemon quiet [seconds] Suppress prompts
|
||||||
|
// poc-daemon irc <command> IRC module commands
|
||||||
|
// poc-daemon stop Shut down daemon
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod context;
|
||||||
|
mod idle;
|
||||||
|
mod modules;
|
||||||
|
pub mod notify;
|
||||||
|
mod rpc;
|
||||||
|
mod tmux;
|
||||||
|
|
||||||
|
pub mod daemon_capnp {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/schema/daemon_capnp.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use capnp_rpc::{rpc_twoparty_capnp, twoparty, RpcSystem};
|
||||||
|
use futures::AsyncReadExt;
|
||||||
|
use tokio::net::UnixListener;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
pub fn now() -> f64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs_f64()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn home() -> PathBuf {
|
||||||
|
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sock_path() -> PathBuf {
|
||||||
|
home().join(".claude/hooks/idle-timer.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pid_path() -> PathBuf {
|
||||||
|
home().join(".claude/hooks/idle-daemon.pid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client mode ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn client_main(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let sock = sock_path();
|
||||||
|
if !sock.exists() {
|
||||||
|
eprintln!("daemon not running (no socket at {})", sock.display());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::task::LocalSet::new()
|
||||||
|
.run_until(async move {
|
||||||
|
let stream = tokio::net::UnixStream::connect(&sock).await?;
|
||||||
|
let (reader, writer) =
|
||||||
|
tokio_util::compat::TokioAsyncReadCompatExt::compat(stream).split();
|
||||||
|
let rpc_network = Box::new(twoparty::VatNetwork::new(
|
||||||
|
futures::io::BufReader::new(reader),
|
||||||
|
futures::io::BufWriter::new(writer),
|
||||||
|
rpc_twoparty_capnp::Side::Client,
|
||||||
|
Default::default(),
|
||||||
|
));
|
||||||
|
let mut rpc_system = RpcSystem::new(rpc_network, None);
|
||||||
|
let daemon: daemon_capnp::daemon::Client =
|
||||||
|
rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server);
|
||||||
|
|
||||||
|
tokio::task::spawn_local(rpc_system);
|
||||||
|
|
||||||
|
let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("status");
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
"status" => {
|
||||||
|
let reply = daemon.status_request().send().promise.await?;
|
||||||
|
let status = reply.get()?.get_status()?;
|
||||||
|
println!(
|
||||||
|
"uptime={:.0}s pane={} kent={} activity={:?} pending={} fired={} sleep={} quiet={} dreaming={} consolidating={}",
|
||||||
|
status.get_uptime(),
|
||||||
|
status.get_claude_pane()?.to_str().unwrap_or("none"),
|
||||||
|
status.get_kent_present(),
|
||||||
|
status.get_activity()?,
|
||||||
|
status.get_pending_count(),
|
||||||
|
status.get_fired(),
|
||||||
|
status.get_sleep_until(),
|
||||||
|
status.get_quiet_until(),
|
||||||
|
status.get_dreaming(),
|
||||||
|
status.get_consolidating(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"user" => {
|
||||||
|
let pane = args.get(2).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
let mut req = daemon.user_request();
|
||||||
|
req.get().set_pane(pane);
|
||||||
|
req.send().promise.await?;
|
||||||
|
}
|
||||||
|
"response" => {
|
||||||
|
let pane = args.get(2).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
let mut req = daemon.response_request();
|
||||||
|
req.get().set_pane(pane);
|
||||||
|
req.send().promise.await?;
|
||||||
|
}
|
||||||
|
"sleep" => {
|
||||||
|
let until: f64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||||
|
let mut req = daemon.sleep_request();
|
||||||
|
req.get().set_until(until);
|
||||||
|
req.send().promise.await?;
|
||||||
|
}
|
||||||
|
"wake" => {
|
||||||
|
daemon.wake_request().send().promise.await?;
|
||||||
|
}
|
||||||
|
"quiet" => {
|
||||||
|
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(300);
|
||||||
|
let mut req = daemon.quiet_request();
|
||||||
|
req.get().set_seconds(secs);
|
||||||
|
req.send().promise.await?;
|
||||||
|
}
|
||||||
|
"consolidating" => {
|
||||||
|
daemon.consolidating_request().send().promise.await?;
|
||||||
|
}
|
||||||
|
"consolidated" => {
|
||||||
|
daemon.consolidated_request().send().promise.await?;
|
||||||
|
}
|
||||||
|
"dream-start" => {
|
||||||
|
daemon.dream_start_request().send().promise.await?;
|
||||||
|
}
|
||||||
|
"dream-end" => {
|
||||||
|
daemon.dream_end_request().send().promise.await?;
|
||||||
|
}
|
||||||
|
"stop" => {
|
||||||
|
daemon.stop_request().send().promise.await?;
|
||||||
|
println!("stopping");
|
||||||
|
}
|
||||||
|
"notify" => {
|
||||||
|
let ntype = args.get(2).ok_or("missing type")?;
|
||||||
|
let urgency_str = args.get(3).ok_or("missing urgency")?;
|
||||||
|
let urgency = notify::parse_urgency(urgency_str)
|
||||||
|
.ok_or_else(|| format!("invalid urgency: {urgency_str}"))?;
|
||||||
|
let message = args[4..].join(" ");
|
||||||
|
if message.is_empty() {
|
||||||
|
return Err("missing message".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut req = daemon.notify_request();
|
||||||
|
let mut n = req.get().init_notification();
|
||||||
|
n.set_type(ntype);
|
||||||
|
n.set_urgency(urgency);
|
||||||
|
n.set_message(&message);
|
||||||
|
n.set_timestamp(crate::now());
|
||||||
|
let reply = req.send().promise.await?;
|
||||||
|
if reply.get()?.get_interrupt() {
|
||||||
|
println!("interrupt");
|
||||||
|
} else {
|
||||||
|
println!("queued");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"notifications" => {
|
||||||
|
let min: u8 = args
|
||||||
|
.get(2)
|
||||||
|
.and_then(|s| notify::parse_urgency(s))
|
||||||
|
.unwrap_or(255);
|
||||||
|
|
||||||
|
let mut req = daemon.get_notifications_request();
|
||||||
|
req.get().set_min_urgency(min);
|
||||||
|
let reply = req.send().promise.await?;
|
||||||
|
let list = reply.get()?.get_notifications()?;
|
||||||
|
|
||||||
|
if !list.is_empty() {
|
||||||
|
for n in list.iter() {
|
||||||
|
println!(
|
||||||
|
"[{}:{}] {}",
|
||||||
|
n.get_type()?.to_str()?,
|
||||||
|
notify::urgency_name(n.get_urgency()),
|
||||||
|
n.get_message()?.to_str()?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"notify-types" => {
|
||||||
|
let reply = daemon.get_types_request().send().promise.await?;
|
||||||
|
let list = reply.get()?.get_types()?;
|
||||||
|
|
||||||
|
if list.is_empty() {
|
||||||
|
println!("no notification types registered");
|
||||||
|
} else {
|
||||||
|
for t in list.iter() {
|
||||||
|
let threshold = if t.get_threshold() < 0 {
|
||||||
|
"inherit".to_string()
|
||||||
|
} else {
|
||||||
|
notify::urgency_name(t.get_threshold() as u8).to_string()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{}: count={} threshold={}",
|
||||||
|
t.get_name()?.to_str()?,
|
||||||
|
t.get_count(),
|
||||||
|
threshold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"notify-threshold" => {
|
||||||
|
let ntype = args.get(2).ok_or("missing type")?;
|
||||||
|
let level_str = args.get(3).ok_or("missing level")?;
|
||||||
|
let level = notify::parse_urgency(level_str)
|
||||||
|
.ok_or_else(|| format!("invalid level: {level_str}"))?;
|
||||||
|
|
||||||
|
let mut req = daemon.set_threshold_request();
|
||||||
|
req.get().set_type(ntype);
|
||||||
|
req.get().set_level(level);
|
||||||
|
req.send().promise.await?;
|
||||||
|
println!("{ntype} threshold={}", notify::urgency_name(level));
|
||||||
|
}
|
||||||
|
// Module commands: "irc join #foo", "telegram send hello"
|
||||||
|
"irc" | "telegram" => {
|
||||||
|
let module = cmd;
|
||||||
|
let module_cmd = args.get(2).ok_or(
|
||||||
|
format!("usage: poc-daemon {module} <command> [args...]"),
|
||||||
|
)?;
|
||||||
|
let module_args: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
let mut req = daemon.module_command_request();
|
||||||
|
req.get().set_module(module);
|
||||||
|
req.get().set_command(module_cmd);
|
||||||
|
let mut args_builder = req.get().init_args(module_args.len() as u32);
|
||||||
|
for (i, a) in module_args.iter().enumerate() {
|
||||||
|
args_builder.set(i as u32, a);
|
||||||
|
}
|
||||||
|
let reply = req.send().promise.await?;
|
||||||
|
let result = reply.get()?.get_result()?.to_str()?;
|
||||||
|
if !result.is_empty() {
|
||||||
|
println!("{result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("unknown command: {cmd}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server mode ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let log_path = home().join(".claude/hooks/idle-daemon.log");
|
||||||
|
let file_appender = tracing_appender::rolling::daily(
|
||||||
|
log_path.parent().unwrap(),
|
||||||
|
"idle-daemon.log",
|
||||||
|
);
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(file_appender)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_target(false)
|
||||||
|
.with_level(false)
|
||||||
|
.with_timer(tracing_subscriber::fmt::time::time())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let sock = sock_path();
|
||||||
|
let _ = std::fs::remove_file(&sock);
|
||||||
|
|
||||||
|
let pid = std::process::id();
|
||||||
|
std::fs::write(pid_path(), pid.to_string()).ok();
|
||||||
|
|
||||||
|
let daemon_config = Rc::new(RefCell::new(config::Config::load()));
|
||||||
|
|
||||||
|
let state = Rc::new(RefCell::new(idle::State::new()));
|
||||||
|
state.borrow_mut().load();
|
||||||
|
|
||||||
|
info!("daemon started (pid={pid})");
|
||||||
|
|
||||||
|
tokio::task::LocalSet::new()
|
||||||
|
.run_until(async move {
|
||||||
|
// Start modules
|
||||||
|
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let irc_state = if daemon_config.borrow().irc.enabled {
|
||||||
|
let irc_config = daemon_config.borrow().irc.clone();
|
||||||
|
info!("starting irc module: {}:{}", irc_config.server, irc_config.port);
|
||||||
|
Some(modules::irc::start(irc_config, notify_tx.clone(), daemon_config.clone()))
|
||||||
|
} else {
|
||||||
|
info!("irc module disabled");
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let telegram_state = if daemon_config.borrow().telegram.enabled {
|
||||||
|
info!("starting telegram module");
|
||||||
|
Some(modules::telegram::start(
|
||||||
|
daemon_config.borrow().telegram.clone(),
|
||||||
|
notify_tx.clone(),
|
||||||
|
daemon_config.clone(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
info!("telegram module disabled");
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(&sock)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(
|
||||||
|
&sock,
|
||||||
|
std::fs::Permissions::from_mode(0o600),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let shutdown = async {
|
||||||
|
let mut sigterm =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("sigterm");
|
||||||
|
let mut sigint =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
||||||
|
.expect("sigint");
|
||||||
|
tokio::select! {
|
||||||
|
_ = sigterm.recv() => info!("SIGTERM"),
|
||||||
|
_ = sigint.recv() => info!("SIGINT"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::pin!(shutdown);
|
||||||
|
|
||||||
|
let mut tick_timer = tokio::time::interval(Duration::from_secs(30));
|
||||||
|
tick_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut shutdown => break,
|
||||||
|
|
||||||
|
// Drain module notifications into state
|
||||||
|
Some(notif) = notify_rx.recv() => {
|
||||||
|
state.borrow_mut().notifications.submit(
|
||||||
|
notif.ntype,
|
||||||
|
notif.urgency,
|
||||||
|
notif.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tick_timer.tick() => {
|
||||||
|
if let Err(e) = state.borrow_mut().tick().await {
|
||||||
|
error!("tick: {e}");
|
||||||
|
}
|
||||||
|
if !state.borrow().running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = listener.accept() => {
|
||||||
|
match result {
|
||||||
|
Ok((stream, _)) => {
|
||||||
|
let (reader, writer) =
|
||||||
|
tokio_util::compat::TokioAsyncReadCompatExt::compat(stream)
|
||||||
|
.split();
|
||||||
|
let network = twoparty::VatNetwork::new(
|
||||||
|
futures::io::BufReader::new(reader),
|
||||||
|
futures::io::BufWriter::new(writer),
|
||||||
|
rpc_twoparty_capnp::Side::Server,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let daemon_impl = rpc::DaemonImpl::new(
|
||||||
|
state.clone(),
|
||||||
|
irc_state.clone(),
|
||||||
|
telegram_state.clone(),
|
||||||
|
daemon_config.clone(),
|
||||||
|
);
|
||||||
|
let client: daemon_capnp::daemon::Client =
|
||||||
|
capnp_rpc::new_client(daemon_impl);
|
||||||
|
|
||||||
|
let rpc_system = RpcSystem::new(
|
||||||
|
Box::new(network),
|
||||||
|
Some(client.client),
|
||||||
|
);
|
||||||
|
tokio::task::spawn_local(rpc_system);
|
||||||
|
}
|
||||||
|
Err(e) => error!("accept: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(sock_path());
|
||||||
|
let _ = std::fs::remove_file(pid_path());
|
||||||
|
info!("daemon stopped");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry point ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
if args.len() < 2 {
|
||||||
|
return server_main().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
match args[1].as_str() {
|
||||||
|
"status" | "user" | "response" | "sleep" | "wake" | "quiet"
|
||||||
|
| "consolidating" | "consolidated" | "dream-start" | "dream-end"
|
||||||
|
| "stop" | "notify" | "notifications" | "notify-types"
|
||||||
|
| "notify-threshold" | "irc" | "telegram" => client_main(args).await,
|
||||||
|
_ => {
|
||||||
|
eprintln!("usage: poc-daemon [command]");
|
||||||
|
eprintln!(" (no args) Start daemon");
|
||||||
|
eprintln!(" status Query daemon status");
|
||||||
|
eprintln!(" user [pane] Signal user activity");
|
||||||
|
eprintln!(" response [pane] Signal Claude response");
|
||||||
|
eprintln!(" notify <type> <urgency> <message>");
|
||||||
|
eprintln!(" notifications [min_urgency]");
|
||||||
|
eprintln!(" notify-types List notification types");
|
||||||
|
eprintln!(" notify-threshold <type> <level>");
|
||||||
|
eprintln!(" sleep [timestamp]");
|
||||||
|
eprintln!(" wake / quiet / stop / dream-start / dream-end");
|
||||||
|
eprintln!(" irc <join|leave|send|status|log|nick> [args]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
503
src/bin/poc-daemon/modules/irc.rs
Normal file
503
src/bin/poc-daemon/modules/irc.rs
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
// IRC module.
|
||||||
|
//
|
||||||
|
// Maintains a persistent connection to an IRC server. Parses incoming
|
||||||
|
// messages into notifications, supports sending messages and runtime
|
||||||
|
// commands (join, leave, etc.). Config changes persist to daemon.toml.
|
||||||
|
//
|
||||||
|
// Runs as a spawned local task on the daemon's LocalSet. Notifications
|
||||||
|
// flow through an mpsc channel into the main state. Reconnects
|
||||||
|
// automatically with exponential backoff.
|
||||||
|
|
||||||
|
use crate::config::{Config, IrcConfig};
|
||||||
|
use crate::notify::Notification;
|
||||||
|
use crate::{home, now};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::io;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
const MAX_LOG_LINES: usize = 200;
|
||||||
|
const RECONNECT_BASE_SECS: u64 = 5;
|
||||||
|
const RECONNECT_MAX_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// Parsed IRC message.
|
||||||
|
struct IrcMessage {
|
||||||
|
prefix: Option<String>, // nick!user@host
|
||||||
|
command: String,
|
||||||
|
params: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IrcMessage {
|
||||||
|
fn parse(line: &str) -> Option<Self> {
|
||||||
|
let line = line.trim_end_matches(|c| c == '\r' || c == '\n');
|
||||||
|
if line.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (prefix, rest) = if line.starts_with(':') {
|
||||||
|
let space = line.find(' ')?;
|
||||||
|
(Some(line[1..space].to_string()), &line[space + 1..])
|
||||||
|
} else {
|
||||||
|
(None, line)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (command_params, trailing) = if let Some(pos) = rest.find(" :") {
|
||||||
|
(&rest[..pos], Some(rest[pos + 2..].to_string()))
|
||||||
|
} else {
|
||||||
|
(rest, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts: Vec<String> = command_params
|
||||||
|
.split_whitespace()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = parts.remove(0).to_uppercase();
|
||||||
|
let mut params = parts;
|
||||||
|
if let Some(t) = trailing {
|
||||||
|
params.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(IrcMessage {
|
||||||
|
prefix,
|
||||||
|
command,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract nick from prefix (nick!user@host → nick).
|
||||||
|
fn nick(&self) -> Option<&str> {
|
||||||
|
self.prefix
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| p.split('!').next())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared IRC state, accessible from both the read task and RPC handlers.
|
||||||
|
pub struct IrcState {
|
||||||
|
pub config: IrcConfig,
|
||||||
|
pub connected: bool,
|
||||||
|
pub channels: Vec<String>,
|
||||||
|
pub log: VecDeque<String>,
|
||||||
|
writer: Option<WriterHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type-erased writer handle so we can store it without generic params.
|
||||||
|
type WriterHandle = Box<dyn AsyncWriter>;
|
||||||
|
|
||||||
|
trait AsyncWriter {
|
||||||
|
fn write_line(&mut self, line: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = io::Result<()>> + '_>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writer over a TLS stream.
|
||||||
|
struct TlsWriter {
|
||||||
|
inner: tokio::io::WriteHalf<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncWriter for TlsWriter {
|
||||||
|
fn write_line(&mut self, line: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = io::Result<()>> + '_>> {
|
||||||
|
let data = format!("{line}\r\n");
|
||||||
|
Box::pin(async move {
|
||||||
|
self.inner.write_all(data.as_bytes()).await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writer over a plain TCP stream.
|
||||||
|
struct PlainWriter {
|
||||||
|
inner: tokio::io::WriteHalf<tokio::net::TcpStream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncWriter for PlainWriter {
|
||||||
|
fn write_line(&mut self, line: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = io::Result<()>> + '_>> {
|
||||||
|
let data = format!("{line}\r\n");
|
||||||
|
Box::pin(async move {
|
||||||
|
self.inner.write_all(data.as_bytes()).await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IrcState {
|
||||||
|
fn new(config: IrcConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
channels: config.channels.clone(),
|
||||||
|
config,
|
||||||
|
connected: false,
|
||||||
|
log: VecDeque::with_capacity(MAX_LOG_LINES),
|
||||||
|
writer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_log(&mut self, line: &str) {
|
||||||
|
if self.log.len() >= MAX_LOG_LINES {
|
||||||
|
self.log.pop_front();
|
||||||
|
}
|
||||||
|
self.log.push_back(line.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||||
|
if let Some(ref mut w) = self.writer {
|
||||||
|
w.write_line(line).await
|
||||||
|
} else {
|
||||||
|
Err(io::Error::new(io::ErrorKind::NotConnected, "not connected"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_privmsg(&mut self, target: &str, msg: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("PRIVMSG {target} :{msg}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join(&mut self, channel: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("JOIN {channel}")).await?;
|
||||||
|
if !self.channels.iter().any(|c| c == channel) {
|
||||||
|
self.channels.push(channel.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn part(&mut self, channel: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("PART {channel}")).await?;
|
||||||
|
self.channels.retain(|c| c != channel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedIrc = Rc<RefCell<IrcState>>;
|
||||||
|
|
||||||
|
/// Start the IRC module. Returns the shared state handle.
|
||||||
|
pub fn start(
|
||||||
|
config: IrcConfig,
|
||||||
|
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||||
|
daemon_config: Rc<RefCell<Config>>,
|
||||||
|
) -> SharedIrc {
|
||||||
|
let state = Rc::new(RefCell::new(IrcState::new(config)));
|
||||||
|
let state_clone = state.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_local(async move {
|
||||||
|
connection_loop(state_clone, notify_tx, daemon_config).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connection_loop(
|
||||||
|
state: SharedIrc,
|
||||||
|
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||||
|
daemon_config: Rc<RefCell<Config>>,
|
||||||
|
) {
|
||||||
|
let mut backoff = RECONNECT_BASE_SECS;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let config = state.borrow().config.clone();
|
||||||
|
info!("irc: connecting to {}:{}", config.server, config.port);
|
||||||
|
|
||||||
|
match connect_and_run(&state, &config, ¬ify_tx).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("irc: connection closed cleanly");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("irc: connection error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.borrow_mut().connected = false;
|
||||||
|
state.borrow_mut().writer = None;
|
||||||
|
|
||||||
|
// Persist current channel list to config
|
||||||
|
{
|
||||||
|
let channels = state.borrow().channels.clone();
|
||||||
|
let mut dc = daemon_config.borrow_mut();
|
||||||
|
dc.irc.channels = channels;
|
||||||
|
dc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("irc: reconnecting in {backoff}s");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(backoff)).await;
|
||||||
|
backoff = (backoff * 2).min(RECONNECT_MAX_SECS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_and_run(
|
||||||
|
state: &SharedIrc,
|
||||||
|
config: &IrcConfig,
|
||||||
|
notify_tx: &mpsc::UnboundedSender<Notification>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let addr = format!("{}:{}", config.server, config.port);
|
||||||
|
let tcp = tokio::net::TcpStream::connect(&addr).await?;
|
||||||
|
|
||||||
|
if config.tls {
|
||||||
|
let tls_config = rustls::ClientConfig::builder_with_provider(
|
||||||
|
rustls::crypto::ring::default_provider().into(),
|
||||||
|
)
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?
|
||||||
|
.with_root_certificates(root_certs())
|
||||||
|
.with_no_client_auth();
|
||||||
|
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||||
|
let server_name = rustls::pki_types::ServerName::try_from(config.server.clone())
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||||
|
let tls_stream = connector.connect(server_name, tcp).await?;
|
||||||
|
|
||||||
|
let (reader, writer) = tokio::io::split(tls_stream);
|
||||||
|
state.borrow_mut().writer = Some(Box::new(TlsWriter { inner: writer }));
|
||||||
|
|
||||||
|
let buf_reader = BufReader::new(reader);
|
||||||
|
register_and_read(state, config, buf_reader, notify_tx).await
|
||||||
|
} else {
|
||||||
|
let (reader, writer) = tokio::io::split(tcp);
|
||||||
|
state.borrow_mut().writer = Some(Box::new(PlainWriter { inner: writer }));
|
||||||
|
|
||||||
|
let buf_reader = BufReader::new(reader);
|
||||||
|
register_and_read(state, config, buf_reader, notify_tx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_and_read<R: tokio::io::AsyncRead + Unpin>(
|
||||||
|
state: &SharedIrc,
|
||||||
|
config: &IrcConfig,
|
||||||
|
reader: BufReader<R>,
|
||||||
|
notify_tx: &mpsc::UnboundedSender<Notification>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
// Register
|
||||||
|
{
|
||||||
|
let mut s = state.borrow_mut();
|
||||||
|
s.send_raw(&format!("NICK {}", config.nick)).await?;
|
||||||
|
s.send_raw(&format!("USER {} 0 * :{}", config.user, config.realname)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
|
||||||
|
while let Some(line) = lines.next_line().await? {
|
||||||
|
let msg = match IrcMessage::parse(&line) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match msg.command.as_str() {
|
||||||
|
"PING" => {
|
||||||
|
let arg = msg.params.first().map(|s| s.as_str()).unwrap_or("");
|
||||||
|
state.borrow_mut().send_raw(&format!("PONG :{arg}")).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPL_WELCOME — registration complete
|
||||||
|
"001" => {
|
||||||
|
info!("irc: registered as {}", config.nick);
|
||||||
|
state.borrow_mut().connected = true;
|
||||||
|
|
||||||
|
// Join configured channels
|
||||||
|
let channels = state.borrow().channels.clone();
|
||||||
|
for ch in &channels {
|
||||||
|
if let Err(e) = state.borrow_mut().send_raw(&format!("JOIN {ch}")).await {
|
||||||
|
warn!("irc: failed to join {ch}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"PRIVMSG" => {
|
||||||
|
let target = msg.params.first().map(|s| s.as_str()).unwrap_or("");
|
||||||
|
let text = msg.params.get(1).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
let nick = msg.nick().unwrap_or("unknown");
|
||||||
|
|
||||||
|
// Log the message
|
||||||
|
let log_line = if target.starts_with('#') {
|
||||||
|
format!("[{}] <{}> {}", target, nick, text)
|
||||||
|
} else {
|
||||||
|
format!("[PM:{nick}] {text}")
|
||||||
|
};
|
||||||
|
state.borrow_mut().push_log(&log_line);
|
||||||
|
|
||||||
|
// Write to per-channel/per-user log file
|
||||||
|
if target.starts_with('#') {
|
||||||
|
append_log(target, nick, text);
|
||||||
|
} else {
|
||||||
|
append_log(&format!("pm-{nick}"), nick, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate notification
|
||||||
|
let (ntype, urgency) = classify_privmsg(
|
||||||
|
nick,
|
||||||
|
target,
|
||||||
|
text,
|
||||||
|
&config.nick,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = notify_tx.send(Notification {
|
||||||
|
ntype,
|
||||||
|
urgency,
|
||||||
|
message: log_line,
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nick in use
|
||||||
|
"433" => {
|
||||||
|
let alt = format!("{}_", config.nick);
|
||||||
|
warn!("irc: nick in use, trying {alt}");
|
||||||
|
state.borrow_mut().send_raw(&format!("NICK {alt}")).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
"JOIN" | "PART" | "QUIT" | "KICK" | "MODE" | "TOPIC" | "NOTICE" => {
|
||||||
|
// Could log these, but skip for now
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a PRIVMSG into notification type and urgency.
|
||||||
|
fn classify_privmsg(nick: &str, target: &str, text: &str, my_nick: &str) -> (String, u8) {
|
||||||
|
let my_nick_lower = my_nick.to_lowercase();
|
||||||
|
let text_lower = text.to_lowercase();
|
||||||
|
|
||||||
|
if !target.starts_with('#') {
|
||||||
|
// Private message
|
||||||
|
(format!("irc.pm.{nick}"), crate::notify::URGENT)
|
||||||
|
} else if text_lower.contains(&my_nick_lower) {
|
||||||
|
// Mentioned in channel
|
||||||
|
(format!("irc.mention.{nick}"), crate::notify::NORMAL)
|
||||||
|
} else {
|
||||||
|
// Regular channel message
|
||||||
|
let channel = target.trim_start_matches('#');
|
||||||
|
(format!("irc.channel.{channel}"), crate::notify::AMBIENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a message to the per-channel or per-user log file.
|
||||||
|
/// Logs go to ~/.claude/irc/logs/{target}.log (e.g. #bcachefs.log, pm-kent.log)
|
||||||
|
fn append_log(target: &str, nick: &str, text: &str) {
|
||||||
|
use std::io::Write;
|
||||||
|
// Sanitize target for filename (strip leading #, lowercase)
|
||||||
|
let filename = format!("{}.log", target.trim_start_matches('#').to_lowercase());
|
||||||
|
let dir = home().join(".claude/irc/logs");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(dir.join(&filename))
|
||||||
|
{
|
||||||
|
let secs = now() as u64;
|
||||||
|
let _ = writeln!(f, "{secs} <{nick}> {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_certs() -> rustls::RootCertStore {
|
||||||
|
let mut roots = rustls::RootCertStore::empty();
|
||||||
|
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a runtime command from RPC.
|
||||||
|
pub async fn handle_command(
|
||||||
|
state: &SharedIrc,
|
||||||
|
daemon_config: &Rc<RefCell<Config>>,
|
||||||
|
cmd: &str,
|
||||||
|
args: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
match cmd {
|
||||||
|
"join" => {
|
||||||
|
let channel = args.first().ok_or("usage: irc join <channel>")?;
|
||||||
|
let channel = if channel.starts_with('#') {
|
||||||
|
channel.clone()
|
||||||
|
} else {
|
||||||
|
format!("#{channel}")
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.borrow_mut()
|
||||||
|
.join(&channel)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
let mut dc = daemon_config.borrow_mut();
|
||||||
|
if !dc.irc.channels.contains(&channel) {
|
||||||
|
dc.irc.channels.push(channel.clone());
|
||||||
|
}
|
||||||
|
dc.save();
|
||||||
|
|
||||||
|
Ok(format!("joined {channel}"))
|
||||||
|
}
|
||||||
|
"leave" | "part" => {
|
||||||
|
let channel = args.first().ok_or("usage: irc leave <channel>")?;
|
||||||
|
let channel = if channel.starts_with('#') {
|
||||||
|
channel.clone()
|
||||||
|
} else {
|
||||||
|
format!("#{channel}")
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.borrow_mut()
|
||||||
|
.part(&channel)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
let mut dc = daemon_config.borrow_mut();
|
||||||
|
dc.irc.channels.retain(|c| c != &channel);
|
||||||
|
dc.save();
|
||||||
|
|
||||||
|
Ok(format!("left {channel}"))
|
||||||
|
}
|
||||||
|
"send" | "msg" => {
|
||||||
|
if args.len() < 2 {
|
||||||
|
return Err("usage: irc send <target> <message>".into());
|
||||||
|
}
|
||||||
|
let target = &args[0];
|
||||||
|
let msg = args[1..].join(" ");
|
||||||
|
state
|
||||||
|
.borrow_mut()
|
||||||
|
.send_privmsg(target, &msg)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!("sent to {target}"))
|
||||||
|
}
|
||||||
|
"status" => {
|
||||||
|
let s = state.borrow();
|
||||||
|
Ok(format!(
|
||||||
|
"connected={} channels={} log_lines={} nick={}",
|
||||||
|
s.connected,
|
||||||
|
s.channels.join(","),
|
||||||
|
s.log.len(),
|
||||||
|
s.config.nick,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"log" => {
|
||||||
|
let n: usize = args
|
||||||
|
.first()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(15);
|
||||||
|
let s = state.borrow();
|
||||||
|
let lines: Vec<&String> = s.log.iter().rev().take(n).collect();
|
||||||
|
let mut lines: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||||
|
lines.reverse();
|
||||||
|
Ok(lines.join("\n"))
|
||||||
|
}
|
||||||
|
"nick" => {
|
||||||
|
let new_nick = args.first().ok_or("usage: irc nick <newnick>")?;
|
||||||
|
state
|
||||||
|
.borrow_mut()
|
||||||
|
.send_raw(&format!("NICK {new_nick}"))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut dc = daemon_config.borrow_mut();
|
||||||
|
dc.irc.nick = new_nick.clone();
|
||||||
|
dc.save();
|
||||||
|
|
||||||
|
Ok(format!("nick → {new_nick}"))
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"unknown irc command: {cmd}\n\
|
||||||
|
commands: join, leave, send, status, log, nick"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/bin/poc-daemon/modules/mod.rs
Normal file
2
src/bin/poc-daemon/modules/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod irc;
|
||||||
|
pub mod telegram;
|
||||||
374
src/bin/poc-daemon/modules/telegram.rs
Normal file
374
src/bin/poc-daemon/modules/telegram.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
// Telegram module.
|
||||||
|
//
|
||||||
|
// Long-polls the Telegram Bot API for messages from Kent's chat.
|
||||||
|
// Downloads media (photos, voice, documents) to local files.
|
||||||
|
// Sends text and files. Notifications flow through mpsc into the
|
||||||
|
// daemon's main state.
|
||||||
|
//
|
||||||
|
// Only accepts messages from the configured chat_id (prompt
|
||||||
|
// injection defense — other senders get a "private bot" reply).
|
||||||
|
|
||||||
|
use crate::config::{Config, TelegramConfig};
|
||||||
|
use crate::notify::Notification;
|
||||||
|
use crate::{home, now};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
const MAX_LOG_LINES: usize = 100;
|
||||||
|
const POLL_TIMEOUT: u64 = 30;
|
||||||
|
|
||||||
|
pub struct TelegramState {
|
||||||
|
pub config: TelegramConfig,
|
||||||
|
pub connected: bool,
|
||||||
|
pub log: VecDeque<String>,
|
||||||
|
pub last_offset: i64,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedTelegram = Rc<RefCell<TelegramState>>;
|
||||||
|
|
||||||
|
impl TelegramState {
|
||||||
|
fn new(config: TelegramConfig) -> Self {
|
||||||
|
let last_offset = load_offset();
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
connected: false,
|
||||||
|
log: VecDeque::with_capacity(MAX_LOG_LINES),
|
||||||
|
last_offset,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_log(&mut self, line: &str) {
|
||||||
|
if self.log.len() >= MAX_LOG_LINES {
|
||||||
|
self.log.pop_front();
|
||||||
|
}
|
||||||
|
self.log.push_back(line.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_url(&self, method: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"https://api.telegram.org/bot{}/{}",
|
||||||
|
self.config.token, method
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset_path() -> PathBuf {
|
||||||
|
home().join(".claude/telegram/last_offset")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_offset() -> i64 {
|
||||||
|
std::fs::read_to_string(offset_path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_offset(offset: i64) {
|
||||||
|
let _ = std::fs::write(offset_path(), offset.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_path() -> PathBuf {
|
||||||
|
home().join(".claude/telegram/history.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_dir() -> PathBuf {
|
||||||
|
home().join(".claude/telegram/media")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_history(line: &str) {
|
||||||
|
use std::io::Write;
|
||||||
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(history_path())
|
||||||
|
{
|
||||||
|
let _ = writeln!(f, "{}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the Telegram module. Returns the shared state handle.
|
||||||
|
pub fn start(
|
||||||
|
config: TelegramConfig,
|
||||||
|
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||||
|
_daemon_config: Rc<RefCell<Config>>,
|
||||||
|
) -> SharedTelegram {
|
||||||
|
let state = Rc::new(RefCell::new(TelegramState::new(config)));
|
||||||
|
let state_clone = state.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_local(async move {
|
||||||
|
poll_loop(state_clone, notify_tx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_loop(
|
||||||
|
state: SharedTelegram,
|
||||||
|
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||||
|
) {
|
||||||
|
let _ = std::fs::create_dir_all(media_dir());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match poll_once(&state, ¬ify_tx).await {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("telegram: poll error: {e}");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll_once(
|
||||||
|
state: &SharedTelegram,
|
||||||
|
notify_tx: &mpsc::UnboundedSender<Notification>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (url, chat_id, token) = {
|
||||||
|
let s = state.borrow();
|
||||||
|
let url = format!(
|
||||||
|
"{}?offset={}&timeout={}",
|
||||||
|
s.api_url("getUpdates"),
|
||||||
|
s.last_offset,
|
||||||
|
POLL_TIMEOUT,
|
||||||
|
);
|
||||||
|
(url, s.config.chat_id, s.config.token.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = state.borrow().client.clone();
|
||||||
|
let resp: serde_json::Value = client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(std::time::Duration::from_secs(POLL_TIMEOUT + 5))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !state.borrow().connected {
|
||||||
|
state.borrow_mut().connected = true;
|
||||||
|
info!("telegram: connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = resp["result"].as_array();
|
||||||
|
let results = match results {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
for update in results {
|
||||||
|
let update_id = update["update_id"].as_i64().unwrap_or(0);
|
||||||
|
let msg = &update["message"];
|
||||||
|
|
||||||
|
// Update offset
|
||||||
|
{
|
||||||
|
let mut s = state.borrow_mut();
|
||||||
|
s.last_offset = update_id + 1;
|
||||||
|
save_offset(s.last_offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg_chat_id = msg["chat"]["id"].as_i64().unwrap_or(0);
|
||||||
|
if msg_chat_id != chat_id {
|
||||||
|
// Reject messages from unknown chats
|
||||||
|
let reject_url = format!(
|
||||||
|
"https://api.telegram.org/bot{}/sendMessage",
|
||||||
|
token
|
||||||
|
);
|
||||||
|
let _ = client
|
||||||
|
.post(&reject_url)
|
||||||
|
.form(&[
|
||||||
|
("chat_id", msg_chat_id.to_string()),
|
||||||
|
("text", "This is a private bot.".to_string()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = msg["from"]["first_name"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Handle different message types
|
||||||
|
if let Some(text) = msg["text"].as_str() {
|
||||||
|
let log_line = format!("[{}] {}", sender, text);
|
||||||
|
state.borrow_mut().push_log(&log_line);
|
||||||
|
|
||||||
|
let ts = timestamp();
|
||||||
|
append_history(&format!("{ts} [{sender}] {text}"));
|
||||||
|
|
||||||
|
let _ = notify_tx.send(Notification {
|
||||||
|
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||||
|
urgency: crate::notify::NORMAL,
|
||||||
|
message: log_line,
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
} else if let Some(photos) = msg["photo"].as_array() {
|
||||||
|
// Pick largest photo
|
||||||
|
let best = photos.iter().max_by_key(|p| p["file_size"].as_i64().unwrap_or(0));
|
||||||
|
if let Some(photo) = best {
|
||||||
|
if let Some(file_id) = photo["file_id"].as_str() {
|
||||||
|
let caption = msg["caption"].as_str().unwrap_or("");
|
||||||
|
let local = download_file(&client, &token, file_id, ".jpg").await;
|
||||||
|
let display = match &local {
|
||||||
|
Some(p) => format!("[photo: {}]{}", p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||||
|
None => format!("[photo]{}", if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||||
|
};
|
||||||
|
let log_line = format!("[{}] {}", sender, display);
|
||||||
|
state.borrow_mut().push_log(&log_line);
|
||||||
|
let ts = timestamp();
|
||||||
|
append_history(&format!("{ts} [{sender}] {display}"));
|
||||||
|
|
||||||
|
let _ = notify_tx.send(Notification {
|
||||||
|
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||||
|
urgency: crate::notify::NORMAL,
|
||||||
|
message: log_line,
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if msg["voice"].is_object() {
|
||||||
|
if let Some(file_id) = msg["voice"]["file_id"].as_str() {
|
||||||
|
let caption = msg["caption"].as_str().unwrap_or("");
|
||||||
|
let local = download_file(&client, &token, file_id, ".ogg").await;
|
||||||
|
let display = match &local {
|
||||||
|
Some(p) => format!("[voice: {}]{}", p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||||
|
None => format!("[voice]{}", if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||||
|
};
|
||||||
|
let log_line = format!("[{}] {}", sender, display);
|
||||||
|
state.borrow_mut().push_log(&log_line);
|
||||||
|
let ts = timestamp();
|
||||||
|
append_history(&format!("{ts} [{sender}] {display}"));
|
||||||
|
|
||||||
|
let _ = notify_tx.send(Notification {
|
||||||
|
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||||
|
urgency: crate::notify::NORMAL,
|
||||||
|
message: log_line,
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if msg["document"].is_object() {
|
||||||
|
if let Some(file_id) = msg["document"]["file_id"].as_str() {
|
||||||
|
let fname = msg["document"]["file_name"].as_str().unwrap_or("file");
|
||||||
|
let caption = msg["caption"].as_str().unwrap_or("");
|
||||||
|
let local = download_file(&client, &token, file_id, "").await;
|
||||||
|
let display = match &local {
|
||||||
|
Some(p) => format!("[doc: {} -> {}]{}", fname, p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||||
|
None => format!("[doc: {}]{}", fname, if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||||
|
};
|
||||||
|
let log_line = format!("[{}] {}", sender, display);
|
||||||
|
state.borrow_mut().push_log(&log_line);
|
||||||
|
let ts = timestamp();
|
||||||
|
append_history(&format!("{ts} [{sender}] {display}"));
|
||||||
|
|
||||||
|
let _ = notify_tx.send(Notification {
|
||||||
|
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||||
|
urgency: crate::notify::NORMAL,
|
||||||
|
message: log_line,
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_file(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
file_id: &str,
|
||||||
|
ext: &str,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
let url = format!("https://api.telegram.org/bot{token}/getFile?file_id={file_id}");
|
||||||
|
let resp: serde_json::Value = client.get(&url).send().await.ok()?.json().await.ok()?;
|
||||||
|
let file_path = resp["result"]["file_path"].as_str()?;
|
||||||
|
|
||||||
|
let download_url = format!("https://api.telegram.org/file/bot{token}/{file_path}");
|
||||||
|
let bytes = client.get(&download_url).send().await.ok()?.bytes().await.ok()?;
|
||||||
|
|
||||||
|
let basename = std::path::Path::new(file_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("file");
|
||||||
|
let local_name = if ext.is_empty() {
|
||||||
|
basename.to_string()
|
||||||
|
} else {
|
||||||
|
let stem = std::path::Path::new(basename)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("file");
|
||||||
|
format!("{}{}", stem, ext)
|
||||||
|
};
|
||||||
|
let secs = now() as u64;
|
||||||
|
let local_path = media_dir().join(format!("{secs}_{local_name}"));
|
||||||
|
std::fs::write(&local_path, &bytes).ok()?;
|
||||||
|
Some(local_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp() -> String {
|
||||||
|
// Use the same unix seconds approach as IRC module
|
||||||
|
format!("{}", now() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a runtime command from RPC.
|
||||||
|
pub async fn handle_command(
|
||||||
|
state: &SharedTelegram,
|
||||||
|
_daemon_config: &Rc<RefCell<Config>>,
|
||||||
|
cmd: &str,
|
||||||
|
args: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
match cmd {
|
||||||
|
"send" => {
|
||||||
|
let msg = args.join(" ");
|
||||||
|
if msg.is_empty() {
|
||||||
|
return Err("usage: telegram send <message>".into());
|
||||||
|
}
|
||||||
|
let (url, client) = {
|
||||||
|
let s = state.borrow();
|
||||||
|
(s.api_url("sendMessage"), s.client.clone())
|
||||||
|
};
|
||||||
|
let chat_id = state.borrow().config.chat_id.to_string();
|
||||||
|
client
|
||||||
|
.post(&url)
|
||||||
|
.form(&[("chat_id", chat_id.as_str()), ("text", msg.as_str())])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let ts = timestamp();
|
||||||
|
append_history(&format!("{ts} [ProofOfConcept] {msg}"));
|
||||||
|
|
||||||
|
Ok("sent".to_string())
|
||||||
|
}
|
||||||
|
"status" => {
|
||||||
|
let s = state.borrow();
|
||||||
|
Ok(format!(
|
||||||
|
"connected={} log_lines={} offset={}",
|
||||||
|
s.connected,
|
||||||
|
s.log.len(),
|
||||||
|
s.last_offset,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"log" => {
|
||||||
|
let n: usize = args
|
||||||
|
.first()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(15);
|
||||||
|
let s = state.borrow();
|
||||||
|
let lines: Vec<&String> = s.log.iter().rev().take(n).collect();
|
||||||
|
let mut lines: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||||
|
lines.reverse();
|
||||||
|
Ok(lines.join("\n"))
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"unknown telegram command: {cmd}\n\
|
||||||
|
commands: send, status, log"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/bin/poc-daemon/notify.rs
Normal file
315
src/bin/poc-daemon/notify.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
// Notification subsystem.
|
||||||
|
//
|
||||||
|
// Notifications have a type (free-form string, hierarchical by convention)
|
||||||
|
// and an urgency level (0-3) set by the producer. The daemon maintains a
|
||||||
|
// registry of all types ever seen with basic stats, and a per-type
|
||||||
|
// threshold that controls when notifications interrupt vs queue.
|
||||||
|
//
|
||||||
|
// Producers submit via socket: `notify <type> <urgency> <message>`
|
||||||
|
// Consumers query via socket: `notifications` (returns + clears pending above threshold)
|
||||||
|
//
|
||||||
|
// Thresholds:
|
||||||
|
// 0 = ambient — include in idle context only
|
||||||
|
// 1 = low — deliver on next check, don't interrupt focus
|
||||||
|
// 2 = normal — deliver on next user interaction
|
||||||
|
// 3 = urgent — interrupt immediately
|
||||||
|
//
|
||||||
|
// Type hierarchy is by convention: "irc.mention", "irc.channel.bcachefs-ai",
|
||||||
|
// "telegram", "system.compaction". Threshold lookup walks up the hierarchy:
|
||||||
|
// "irc.channel.bcachefs-ai" → "irc.channel" → "irc" → default.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::home;
|
||||||
|
|
||||||
|
pub const AMBIENT: u8 = 0;
|
||||||
|
pub const LOW: u8 = 1;
|
||||||
|
pub const NORMAL: u8 = 2;
|
||||||
|
pub const URGENT: u8 = 3;
|
||||||
|
|
||||||
|
const DEFAULT_THRESHOLD: u8 = NORMAL;
|
||||||
|
|
||||||
|
/// Activity states that affect effective notification thresholds.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Activity {
|
||||||
|
/// Actively working with user — raise thresholds
|
||||||
|
Focused,
|
||||||
|
/// Idle, autonomous — lower thresholds
|
||||||
|
Idle,
|
||||||
|
/// Sleeping — only urgent gets through
|
||||||
|
Sleeping,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path() -> PathBuf {
|
||||||
|
home().join(".claude/notifications/state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TypeInfo {
|
||||||
|
pub first_seen: f64,
|
||||||
|
pub last_seen: f64,
|
||||||
|
pub count: u64,
|
||||||
|
/// Per-type threshold override. None = inherit from parent or default.
|
||||||
|
pub threshold: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub ntype: String,
|
||||||
|
pub urgency: u8,
|
||||||
|
pub message: String,
|
||||||
|
pub timestamp: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||||
|
pub struct NotifyState {
|
||||||
|
/// Registry of all notification types ever seen.
|
||||||
|
pub types: BTreeMap<String, TypeInfo>,
|
||||||
|
/// Pending notifications not yet delivered.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub pending: Vec<Notification>,
|
||||||
|
/// Current activity state — affects effective thresholds.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub activity: Activity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Activity {
|
||||||
|
fn default() -> Self {
|
||||||
|
Activity::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotifyState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut state = Self::default();
|
||||||
|
state.load();
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load type registry from disk.
|
||||||
|
fn load(&mut self) {
|
||||||
|
let path = state_path();
|
||||||
|
if let Ok(data) = fs::read_to_string(&path) {
|
||||||
|
if let Ok(saved) = serde_json::from_str::<SavedState>(&data) {
|
||||||
|
self.types = saved.types;
|
||||||
|
info!("loaded {} notification types", self.types.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist type registry to disk.
|
||||||
|
fn save(&self) {
|
||||||
|
let saved = SavedState {
|
||||||
|
types: self.types.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&saved) {
|
||||||
|
let path = state_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let _ = fs::write(path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up the configured threshold for a type, walking up the hierarchy.
|
||||||
|
/// "irc.channel.bcachefs-ai" → "irc.channel" → "irc" → DEFAULT_THRESHOLD
|
||||||
|
pub fn configured_threshold(&self, ntype: &str) -> u8 {
|
||||||
|
let mut key = ntype;
|
||||||
|
loop {
|
||||||
|
if let Some(info) = self.types.get(key) {
|
||||||
|
if let Some(t) = info.threshold {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match key.rfind('.') {
|
||||||
|
Some(pos) => key = &key[..pos],
|
||||||
|
None => return DEFAULT_THRESHOLD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective threshold accounting for activity state.
|
||||||
|
/// When focused, thresholds are raised (fewer interruptions).
|
||||||
|
/// When sleeping, only urgent gets through.
|
||||||
|
/// When idle, configured thresholds apply as-is.
|
||||||
|
pub fn threshold_for(&self, ntype: &str) -> u8 {
|
||||||
|
let base = self.configured_threshold(ntype);
|
||||||
|
match self.activity {
|
||||||
|
Activity::Focused => base.max(NORMAL), // at least normal when focused
|
||||||
|
Activity::Sleeping => URGENT, // only urgent when sleeping
|
||||||
|
Activity::Idle => base, // configured threshold when idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activity(&mut self, activity: Activity) {
|
||||||
|
info!("activity: {:?} → {:?}", self.activity, activity);
|
||||||
|
self.activity = activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit a notification. Returns true if it should interrupt now.
|
||||||
|
pub fn submit(&mut self, ntype: String, urgency: u8, message: String) -> bool {
|
||||||
|
let now = crate::now();
|
||||||
|
|
||||||
|
// Update type registry
|
||||||
|
let info = self.types.entry(ntype.clone()).or_insert(TypeInfo {
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
count: 0,
|
||||||
|
threshold: None,
|
||||||
|
});
|
||||||
|
info.last_seen = now;
|
||||||
|
info.count += 1;
|
||||||
|
self.save();
|
||||||
|
|
||||||
|
let threshold = self.threshold_for(&ntype);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"notification: type={ntype} urgency={urgency} threshold={threshold} msg={}",
|
||||||
|
&message[..message.len().min(80)]
|
||||||
|
);
|
||||||
|
|
||||||
|
self.pending.push(Notification {
|
||||||
|
ntype,
|
||||||
|
urgency,
|
||||||
|
message,
|
||||||
|
timestamp: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
urgency >= URGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain pending notifications at or above the given urgency level.
|
||||||
|
/// Returns them and removes from pending.
|
||||||
|
pub fn drain(&mut self, min_urgency: u8) -> Vec<Notification> {
|
||||||
|
let (matching, remaining): (Vec<_>, Vec<_>) = self
|
||||||
|
.pending
|
||||||
|
.drain(..)
|
||||||
|
.partition(|n| n.urgency >= min_urgency);
|
||||||
|
self.pending = remaining;
|
||||||
|
matching
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain all pending notifications above their per-type threshold.
|
||||||
|
pub fn drain_deliverable(&mut self) -> Vec<Notification> {
|
||||||
|
// Pre-compute thresholds to avoid borrow conflict with drain
|
||||||
|
let thresholds: Vec<u8> = self
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.map(|n| self.threshold_for(&n.ntype))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut deliver = Vec::new();
|
||||||
|
let mut keep = Vec::new();
|
||||||
|
|
||||||
|
for (n, threshold) in self.pending.drain(..).zip(thresholds) {
|
||||||
|
if n.urgency >= threshold {
|
||||||
|
deliver.push(n);
|
||||||
|
} else {
|
||||||
|
keep.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pending = keep;
|
||||||
|
deliver
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set threshold for a notification type.
|
||||||
|
pub fn set_threshold(&mut self, ntype: &str, threshold: u8) {
|
||||||
|
let now = crate::now();
|
||||||
|
let info = self.types.entry(ntype.to_string()).or_insert(TypeInfo {
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
count: 0,
|
||||||
|
threshold: None,
|
||||||
|
});
|
||||||
|
info.threshold = Some(threshold);
|
||||||
|
self.save();
|
||||||
|
info!("threshold: {ntype} = {threshold}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format pending notifications for display.
|
||||||
|
pub fn format_pending(&self, min_urgency: u8) -> String {
|
||||||
|
let matching: Vec<_> = self
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.filter(|n| n.urgency >= min_urgency)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if matching.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = format!("Pending notifications ({}):\n", matching.len());
|
||||||
|
for n in &matching {
|
||||||
|
out.push_str(&format!("[{}] {}\n", n.ntype, n.message));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ingest notifications from legacy ~/.claude/notifications/ files.
|
||||||
|
/// Maps filename to notification type, assumes NORMAL urgency.
|
||||||
|
pub fn ingest_legacy_files(&mut self) {
|
||||||
|
let dir = home().join(".claude/notifications");
|
||||||
|
let entries = match fs::read_dir(&dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') || name == "state.json" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let content = match fs::read_to_string(&path) {
|
||||||
|
Ok(c) if !c.is_empty() => c,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each line is a separate notification
|
||||||
|
for line in content.lines() {
|
||||||
|
if !line.is_empty() {
|
||||||
|
self.submit(name.clone(), NORMAL, line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the file
|
||||||
|
let _ = fs::write(&path, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What gets persisted to disk (just the type registry, not pending).
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct SavedState {
|
||||||
|
types: BTreeMap<String, TypeInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an urgency level as a human-readable string.
|
||||||
|
pub fn urgency_name(level: u8) -> &'static str {
|
||||||
|
match level {
|
||||||
|
0 => "ambient",
|
||||||
|
1 => "low",
|
||||||
|
2 => "normal",
|
||||||
|
3 => "urgent",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an urgency level from a string (name or number).
|
||||||
|
pub fn parse_urgency(s: &str) -> Option<u8> {
|
||||||
|
match s {
|
||||||
|
"ambient" | "0" => Some(AMBIENT),
|
||||||
|
"low" | "1" => Some(LOW),
|
||||||
|
"normal" | "2" => Some(NORMAL),
|
||||||
|
"urgent" | "3" => Some(URGENT),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
331
src/bin/poc-daemon/rpc.rs
Normal file
331
src/bin/poc-daemon/rpc.rs
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
// Cap'n Proto RPC server implementation.
|
||||||
|
//
|
||||||
|
// Bridges the capnp-generated Daemon interface to the idle::State,
|
||||||
|
// notify::NotifyState, and module state. All state is owned by
|
||||||
|
// RefCells on the LocalSet — no Send/Sync needed.
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::daemon_capnp::daemon;
|
||||||
|
use crate::idle;
|
||||||
|
use crate::modules::{irc, telegram};
|
||||||
|
use crate::notify;
|
||||||
|
use capnp::capability::Promise;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub struct DaemonImpl {
|
||||||
|
state: Rc<RefCell<idle::State>>,
|
||||||
|
irc: Option<irc::SharedIrc>,
|
||||||
|
telegram: Option<telegram::SharedTelegram>,
|
||||||
|
config: Rc<RefCell<Config>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonImpl {
|
||||||
|
pub fn new(
|
||||||
|
state: Rc<RefCell<idle::State>>,
|
||||||
|
irc: Option<irc::SharedIrc>,
|
||||||
|
telegram: Option<telegram::SharedTelegram>,
|
||||||
|
config: Rc<RefCell<Config>>,
|
||||||
|
) -> Self {
|
||||||
|
Self { state, irc, telegram, config }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl daemon::Server for DaemonImpl {
|
||||||
|
fn user(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::UserParams,
|
||||||
|
_results: daemon::UserResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let pane = pry!(pry!(pry!(params.get()).get_pane()).to_str()).to_string();
|
||||||
|
self.state.borrow_mut().handle_user(&pane);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::ResponseParams,
|
||||||
|
_results: daemon::ResponseResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let pane = pry!(pry!(pry!(params.get()).get_pane()).to_str()).to_string();
|
||||||
|
self.state.borrow_mut().handle_response(&pane);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sleep(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::SleepParams,
|
||||||
|
_results: daemon::SleepResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let until = pry!(params.get()).get_until();
|
||||||
|
self.state.borrow_mut().handle_sleep(until);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wake(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::WakeParams,
|
||||||
|
_results: daemon::WakeResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
self.state.borrow_mut().handle_wake();
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quiet(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::QuietParams,
|
||||||
|
_results: daemon::QuietResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let secs = pry!(params.get()).get_seconds();
|
||||||
|
self.state.borrow_mut().handle_quiet(secs);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consolidating(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::ConsolidatingParams,
|
||||||
|
_results: daemon::ConsolidatingResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
self.state.borrow_mut().consolidating = true;
|
||||||
|
info!("consolidation started");
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consolidated(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::ConsolidatedParams,
|
||||||
|
_results: daemon::ConsolidatedResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
self.state.borrow_mut().consolidating = false;
|
||||||
|
info!("consolidation ended");
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dream_start(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::DreamStartParams,
|
||||||
|
_results: daemon::DreamStartResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let mut s = self.state.borrow_mut();
|
||||||
|
s.dreaming = true;
|
||||||
|
s.dream_start = crate::now();
|
||||||
|
info!("dream started");
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dream_end(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::DreamEndParams,
|
||||||
|
_results: daemon::DreamEndResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let mut s = self.state.borrow_mut();
|
||||||
|
s.dreaming = false;
|
||||||
|
s.dream_start = 0.0;
|
||||||
|
info!("dream ended");
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::StopParams,
|
||||||
|
_results: daemon::StopResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
self.state.borrow_mut().running = false;
|
||||||
|
info!("stopping");
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::StatusParams,
|
||||||
|
mut results: daemon::StatusResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let s = self.state.borrow();
|
||||||
|
let mut status = results.get().init_status();
|
||||||
|
|
||||||
|
status.set_last_user_msg(s.last_user_msg);
|
||||||
|
status.set_last_response(s.last_response);
|
||||||
|
if let Some(ref pane) = s.claude_pane {
|
||||||
|
status.set_claude_pane(pane);
|
||||||
|
}
|
||||||
|
status.set_sleep_until(match s.sleep_until {
|
||||||
|
None => 0.0,
|
||||||
|
Some(0.0) => -1.0,
|
||||||
|
Some(t) => t,
|
||||||
|
});
|
||||||
|
status.set_quiet_until(s.quiet_until);
|
||||||
|
status.set_consolidating(s.consolidating);
|
||||||
|
status.set_dreaming(s.dreaming);
|
||||||
|
status.set_fired(s.fired);
|
||||||
|
status.set_kent_present(s.kent_present());
|
||||||
|
status.set_uptime(crate::now() - s.start_time);
|
||||||
|
status.set_activity(match s.notifications.activity {
|
||||||
|
notify::Activity::Idle => crate::daemon_capnp::Activity::Idle,
|
||||||
|
notify::Activity::Focused => crate::daemon_capnp::Activity::Focused,
|
||||||
|
notify::Activity::Sleeping => crate::daemon_capnp::Activity::Sleeping,
|
||||||
|
});
|
||||||
|
status.set_pending_count(s.notifications.pending.len() as u32);
|
||||||
|
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::NotifyParams,
|
||||||
|
mut results: daemon::NotifyResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let params = pry!(params.get());
|
||||||
|
let notif = pry!(params.get_notification());
|
||||||
|
let ntype = pry!(pry!(notif.get_type()).to_str()).to_string();
|
||||||
|
let urgency = notif.get_urgency();
|
||||||
|
let message = pry!(pry!(notif.get_message()).to_str()).to_string();
|
||||||
|
|
||||||
|
let interrupt = self
|
||||||
|
.state
|
||||||
|
.borrow_mut()
|
||||||
|
.notifications
|
||||||
|
.submit(ntype, urgency, message);
|
||||||
|
results.get().set_interrupt(interrupt);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_notifications(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::GetNotificationsParams,
|
||||||
|
mut results: daemon::GetNotificationsResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let min_urgency = pry!(params.get()).get_min_urgency();
|
||||||
|
let mut s = self.state.borrow_mut();
|
||||||
|
|
||||||
|
// Ingest legacy files first
|
||||||
|
s.notifications.ingest_legacy_files();
|
||||||
|
|
||||||
|
let pending = if min_urgency == 255 {
|
||||||
|
s.notifications.drain_deliverable()
|
||||||
|
} else {
|
||||||
|
s.notifications.drain(min_urgency)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut list = results.get().init_notifications(pending.len() as u32);
|
||||||
|
for (i, n) in pending.iter().enumerate() {
|
||||||
|
let mut entry = list.reborrow().get(i as u32);
|
||||||
|
entry.set_type(&n.ntype);
|
||||||
|
entry.set_urgency(n.urgency);
|
||||||
|
entry.set_message(&n.message);
|
||||||
|
entry.set_timestamp(n.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_types(
|
||||||
|
&mut self,
|
||||||
|
_params: daemon::GetTypesParams,
|
||||||
|
mut results: daemon::GetTypesResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let s = self.state.borrow();
|
||||||
|
let types = &s.notifications.types;
|
||||||
|
|
||||||
|
let mut list = results.get().init_types(types.len() as u32);
|
||||||
|
for (i, (name, info)) in types.iter().enumerate() {
|
||||||
|
let mut entry = list.reborrow().get(i as u32);
|
||||||
|
entry.set_name(name);
|
||||||
|
entry.set_count(info.count);
|
||||||
|
entry.set_first_seen(info.first_seen);
|
||||||
|
entry.set_last_seen(info.last_seen);
|
||||||
|
entry.set_threshold(info.threshold.map_or(-1, |t| t as i8));
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_threshold(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::SetThresholdParams,
|
||||||
|
_results: daemon::SetThresholdResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let params = pry!(params.get());
|
||||||
|
let ntype = pry!(pry!(params.get_type()).to_str()).to_string();
|
||||||
|
let level = params.get_level();
|
||||||
|
|
||||||
|
self.state
|
||||||
|
.borrow_mut()
|
||||||
|
.notifications
|
||||||
|
.set_threshold(&ntype, level);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn module_command(
|
||||||
|
&mut self,
|
||||||
|
params: daemon::ModuleCommandParams,
|
||||||
|
mut results: daemon::ModuleCommandResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let params = pry!(params.get());
|
||||||
|
let module = pry!(pry!(params.get_module()).to_str()).to_string();
|
||||||
|
let command = pry!(pry!(params.get_command()).to_str()).to_string();
|
||||||
|
let args_reader = pry!(params.get_args());
|
||||||
|
let mut args = Vec::new();
|
||||||
|
for i in 0..args_reader.len() {
|
||||||
|
args.push(pry!(pry!(args_reader.get(i)).to_str()).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match module.as_str() {
|
||||||
|
"irc" => {
|
||||||
|
let irc = match &self.irc {
|
||||||
|
Some(irc) => irc.clone(),
|
||||||
|
None => {
|
||||||
|
results.get().set_result("irc module not enabled");
|
||||||
|
return Promise::ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let config = self.config.clone();
|
||||||
|
|
||||||
|
Promise::from_future(async move {
|
||||||
|
let result = irc::handle_command(&irc, &config, &command, &args).await;
|
||||||
|
match result {
|
||||||
|
Ok(msg) => results.get().set_result(&msg),
|
||||||
|
Err(msg) => results.get().set_result(&format!("error: {msg}")),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"telegram" => {
|
||||||
|
let tg = match &self.telegram {
|
||||||
|
Some(tg) => tg.clone(),
|
||||||
|
None => {
|
||||||
|
results.get().set_result("telegram module not enabled");
|
||||||
|
return Promise::ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let config = self.config.clone();
|
||||||
|
|
||||||
|
Promise::from_future(async move {
|
||||||
|
let result = telegram::handle_command(&tg, &config, &command, &args).await;
|
||||||
|
match result {
|
||||||
|
Ok(msg) => results.get().set_result(&msg),
|
||||||
|
Err(msg) => results.get().set_result(&format!("error: {msg}")),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
results
|
||||||
|
.get()
|
||||||
|
.set_result(&format!("unknown module: {module}"));
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper macro — same as capnp's pry! but available here.
|
||||||
|
macro_rules! pry {
|
||||||
|
($e:expr) => {
|
||||||
|
match $e {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return Promise::err(e.into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
use pry;
|
||||||
61
src/bin/poc-daemon/tmux.rs
Normal file
61
src/bin/poc-daemon/tmux.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Tmux interaction: pane detection and prompt injection.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Find Claude Code's tmux pane by scanning for the "claude" process.
|
||||||
|
pub fn find_claude_pane() -> Option<String> {
|
||||||
|
let out = Command::new("tmux")
|
||||||
|
.args([
|
||||||
|
"list-panes",
|
||||||
|
"-a",
|
||||||
|
"-F",
|
||||||
|
"#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_command}",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if let Some((pane, cmd)) = line.split_once('\t') {
|
||||||
|
if cmd == "claude" {
|
||||||
|
return Some(pane.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a prompt to a tmux pane. Returns true on success.
|
||||||
|
///
|
||||||
|
/// Sequence: Escape q C-c C-u (clear input), wait, type message, Enter.
|
||||||
|
pub fn send_prompt(pane: &str, msg: &str) -> bool {
|
||||||
|
info!("SEND [{pane}]: {}...", &msg[..msg.len().min(100)]);
|
||||||
|
|
||||||
|
let send = |keys: &[&str]| {
|
||||||
|
Command::new("tmux")
|
||||||
|
.arg("send-keys")
|
||||||
|
.arg("-t")
|
||||||
|
.arg(pane)
|
||||||
|
.args(keys)
|
||||||
|
.output()
|
||||||
|
.is_ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear any partial input
|
||||||
|
if !send(&["Escape", "q", "C-c", "C-u"]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
|
// Type the message
|
||||||
|
if !send(&[msg]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
send(&["Enter"])
|
||||||
|
}
|
||||||
164
src/bin/poc-hook.rs
Normal file
164
src/bin/poc-hook.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// Unified Claude Code hook.
|
||||||
|
//
|
||||||
|
// Single binary handling all hook events:
|
||||||
|
// UserPromptSubmit — signal daemon, check notifications, check context
|
||||||
|
// PostToolUse — check context (rate-limited)
|
||||||
|
// Stop — signal daemon response
|
||||||
|
//
|
||||||
|
// Replaces: record-user-message-time.sh, check-notifications.sh,
|
||||||
|
// check-context-usage.sh, notify-done.sh, context-check
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const CONTEXT_THRESHOLD: u64 = 130_000;
|
||||||
|
const RATE_LIMIT_SECS: u64 = 60;
|
||||||
|
const SOCK_PATH: &str = ".claude/hooks/idle-timer.sock";
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home() -> PathBuf {
|
||||||
|
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn daemon_cmd(args: &[&str]) {
|
||||||
|
Command::new("poc-daemon")
|
||||||
|
.args(args)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn daemon_available() -> bool {
|
||||||
|
home().join(SOCK_PATH).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_user() {
|
||||||
|
let pane = std::env::var("TMUX_PANE").unwrap_or_default();
|
||||||
|
if pane.is_empty() {
|
||||||
|
daemon_cmd(&["user"]);
|
||||||
|
} else {
|
||||||
|
daemon_cmd(&["user", &pane]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_response() {
|
||||||
|
daemon_cmd(&["response"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_notifications() {
|
||||||
|
if !daemon_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let output = Command::new("poc-daemon")
|
||||||
|
.arg("notifications")
|
||||||
|
.output()
|
||||||
|
.ok();
|
||||||
|
if let Some(out) = output {
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
println!("You have pending notifications:");
|
||||||
|
print!("{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_context(transcript: &PathBuf, rate_limit: bool) {
|
||||||
|
if rate_limit {
|
||||||
|
let rate_file = PathBuf::from("/tmp/claude-context-check-last");
|
||||||
|
if let Ok(s) = fs::read_to_string(&rate_file) {
|
||||||
|
if let Ok(last) = s.trim().parse::<u64>() {
|
||||||
|
if now_secs() - last < RATE_LIMIT_SECS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::write(&rate_file, now_secs().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !transcript.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match fs::read_to_string(transcript) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut usage: u64 = 0;
|
||||||
|
for line in content.lines().rev().take(500) {
|
||||||
|
if !line.contains("cache_read_input_tokens") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(v) = serde_json::from_str::<Value>(line) {
|
||||||
|
let u = &v["message"]["usage"];
|
||||||
|
let input_tokens = u["input_tokens"].as_u64().unwrap_or(0);
|
||||||
|
let cache_creation = u["cache_creation_input_tokens"].as_u64().unwrap_or(0);
|
||||||
|
let cache_read = u["cache_read_input_tokens"].as_u64().unwrap_or(0);
|
||||||
|
usage = input_tokens + cache_creation + cache_read;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage > CONTEXT_THRESHOLD {
|
||||||
|
print!(
|
||||||
|
"\
|
||||||
|
CONTEXT WARNING: Compaction approaching ({usage} tokens). Write a journal entry NOW.
|
||||||
|
|
||||||
|
Use `poc-memory journal-write \"entry text\"` to save a dated entry covering:
|
||||||
|
- What you're working on and current state (done / in progress / blocked)
|
||||||
|
- Key things learned this session (patterns, debugging insights)
|
||||||
|
- Anything half-finished that needs pickup
|
||||||
|
|
||||||
|
Keep it narrative, not a task log."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_to_string(&mut input).ok();
|
||||||
|
|
||||||
|
let hook: Value = match serde_json::from_str(&input) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hook_type = hook["type"].as_str().unwrap_or("unknown");
|
||||||
|
let transcript = hook["transcript_path"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(PathBuf::from);
|
||||||
|
|
||||||
|
match hook_type {
|
||||||
|
"UserPromptSubmit" => {
|
||||||
|
signal_user();
|
||||||
|
check_notifications();
|
||||||
|
if let Some(ref t) = transcript {
|
||||||
|
check_context(t, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"PostToolUse" => {
|
||||||
|
if let Some(ref t) = transcript {
|
||||||
|
check_context(t, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Stop" => {
|
||||||
|
let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false);
|
||||||
|
if !stop_hook_active {
|
||||||
|
signal_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue