Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

UTC release dates from [PyPI](https://pypi.org/project/hcom/#history).

## Unreleased

### Added

- Added a conversation tab to the TUI's vertical view (`c`): the selected agent's recent turns with the request, a tool summary and the reply, for local and remote agents. The latest turn is open and earlier ones are one line each; `e` shows every turn in full, `R` refreshes, and Enter messages the agent. Markdown headings, lists, bold, code and tables are rendered. Set `HCOM_TUI_TAB=chat` to open vertical view on it.
- Showed which account a Claude or Codex agent runs under (tool and logged-in e-mail, read from `CLAUDE_CONFIG_DIR` / `CODEX_HOME` when set) in the conversation tab header. Remote agents' e-mail is shared only when their device sets `hcom config relay_share_account 1`, and only for agents started after upgrading; a stopped agent's account is not shown.
- Showed a spinner with the current tool and elapsed time under the conversation while the agent is working.
- Answered a Claude agent's choice prompt from the conversation tab: while a local agent waits on a question (`AskUserQuestion`) or a permission prompt, its options are read off the agent's screen and listed under the conversation; a digit picks one and Enter moves on from a multi-select question. Keys are sent only while the same prompt is still on screen. Options that need typing are left to the agent's terminal.
- Marked a blocked agent in the conversation tab and in the compose footer: messages to it are held until its prompt is answered.

## 0.7.31 — 2026-10-21

### Breaking changes
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ hcom codex
hcom
```

In the TUI, `\` switches to the side-by-side view and `c` there shows the selected agent's recent conversation: each request, the tools it used, and the reply. Set `HCOM_TUI_TAB=chat` to open on it.

## What agents can do

- **Message** each other in real time. Messages arrive mid-turn (after tool calls) or wake idle agents immediately.
Expand Down Expand Up @@ -300,6 +302,7 @@ hcom config -i <name> <key> <value> # per-agent override at runtime
| `terminal` | Where new agent windows open (`hcom config terminal --info`) |
| `timeout` | Idle timeout for headless Claude (seconds) |
| `subagent_timeout` | Keep-alive for Claude subagents (seconds) |
| `relay_share_account` | Send each agent's logged-in account e-mail to relay peers so their TUI can show it (default: off) |
| `claude_args` / `gemini_args` / `codex_args` / `opencode_args` / `kilo_args` / `pi_args` / `omp_args` / `cursor_args` / `kimi_args` / `copilot_args` / `qoder_args` / `grok_args` / `hermes_args` | Default args passed to the tool |

#### Scope
Expand Down
183 changes: 183 additions & 0 deletions src/accounts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! Logged-in account e-mail for a tool's config directory. Only the e-mail
//! leaves this module; tokens are parsed in place and dropped.
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use crate::tool::Tool;

/// Every `config_dir_env_key`.
pub const CONFIG_DIR_ENV_KEYS: [&str; 2] = ["CLAUDE_CONFIG_DIR", "CODEX_HOME"];

/// Env var that points a tool at a non-default config directory.
pub fn config_dir_env_key(tool: Tool) -> Option<&'static str> {
match tool {
Tool::Claude => Some("CLAUDE_CONFIG_DIR"),
Tool::Codex => Some("CODEX_HOME"),
_ => None,
}
}

pub fn account_file(tool: Tool, config_dir: Option<&Path>) -> Option<PathBuf> {
let home = crate::runtime_env::user_home;
match tool {
Tool::Claude => Some(match config_dir {
Some(d) => d.join(".claude.json"),
None => home()?.join(".claude.json"),
}),
Tool::Codex => Some(match config_dir {
Some(d) => d.join("auth.json"),
None => home()?.join(".codex").join("auth.json"),
}),
_ => None,
}
}

pub type Stamp = Option<(u64, Option<SystemTime>)>;

pub fn file_stamp(path: &Path) -> Stamp {
std::fs::metadata(path)
.ok()
.map(|m| (m.len(), m.modified().ok()))
}

pub fn read_email(tool: Tool, config_dir: Option<&Path>) -> Option<String> {
let path = account_file(tool, config_dir)?;
let text = std::fs::read_to_string(path).ok()?;
match tool {
Tool::Claude => parse_claude_email(&text),
Tool::Codex => parse_codex_email(&text),
_ => None,
}
}

fn parse_claude_email(json: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(json).ok()?;
v.get("oauthAccount")?
.get("emailAddress")?
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_owned)
}

/// ChatGPT logins carry the e-mail in the id_token; API-key logins have none.
fn parse_codex_email(json: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(json).ok()?;
if v.get("auth_mode").and_then(|m| m.as_str()) != Some("chatgpt") {
return None;
}
jwt_payload_email(v.get("tokens")?.get("id_token")?.as_str()?)
}

fn jwt_payload_email(id_token: &str) -> Option<String> {
use base64::Engine;
let parts: Vec<&str> = id_token.split('.').collect();
if parts.len() != 3 {
return None;
}
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1].trim_end_matches('='))
.ok()?;
let v: serde_json::Value = serde_json::from_slice(&raw).ok()?;
v.get("email")?
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_owned)
}

/// Re-reads an account file only when its size or mtime changes;
/// `.claude.json` can be large.
#[derive(Default)]
pub struct EmailCache {
entries: Vec<(PathBuf, Stamp, Option<String>)>,
}

impl EmailCache {
pub fn email(&mut self, tool: Tool, config_dir: Option<&Path>) -> Option<String> {
let path = account_file(tool, config_dir)?;
let stamp = file_stamp(&path);
if let Some((_, s, e)) = self.entries.iter().find(|(p, ..)| *p == path)
&& *s == stamp
{
return e.clone();
}
let email = read_email(tool, config_dir);
self.entries.retain(|(p, ..)| *p != path);
if self.entries.len() >= 32 {
self.entries.remove(0);
}
self.entries.push((path, stamp, email.clone()));
email
}
}

#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;

fn fake_jwt(payload: &str) -> String {
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload);
format!("eyJhbGciOiJub25lIn0.{p}.sig")
}

#[test]
fn claude_email() {
assert_eq!(
parse_claude_email(r#"{"oauthAccount":{"emailAddress":"a@b.com"}}"#).as_deref(),
Some("a@b.com")
);
assert_eq!(parse_claude_email(r#"{"other":1}"#), None);
assert_eq!(parse_claude_email("{broken"), None);
}

#[test]
fn codex_email() {
let auth = |mode: &str, tok: &str| {
format!(r#"{{"auth_mode":"{mode}","tokens":{{"id_token":"{tok}"}}}}"#)
};
let tok = fake_jwt(r#"{"email":"c@d.com"}"#);
assert_eq!(
parse_codex_email(&auth("chatgpt", &tok)).as_deref(),
Some("c@d.com")
);
assert_eq!(parse_codex_email(&auth("apikey", &tok)), None);
assert_eq!(parse_codex_email(&auth("chatgpt", "a.b")), None);
assert_eq!(parse_codex_email(&auth("chatgpt", "a.b.c.d")), None);
// Trailing garbage after the JSON is rejected.
let padded = format!(
"h.{}.s",
base64::engine::general_purpose::URL_SAFE.encode(r#"{"email":"p@q.com"}x"#)
);
assert_eq!(jwt_payload_email(&padded), None);
// Padded payloads decode too.
let padded = format!(
"h.{}.s",
base64::engine::general_purpose::URL_SAFE.encode(r#"{"email":"p@q.com"}"#)
);
assert_eq!(jwt_payload_email(&padded).as_deref(), Some("p@q.com"));
}

#[test]
fn files_and_cache() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
account_file(Tool::Claude, Some(dir.path())),
Some(dir.path().join(".claude.json"))
);
assert_eq!(account_file(Tool::Gemini, Some(dir.path())), None);
let path = dir.path().join(".claude.json");
std::fs::write(&path, r#"{"oauthAccount":{"emailAddress":"a@b.com"}}"#).unwrap();
let mut cache = EmailCache::default();
assert_eq!(
cache.email(Tool::Claude, Some(dir.path())).as_deref(),
Some("a@b.com")
);
std::fs::write(&path, r#"{"oauthAccount":{"emailAddress":"longer@b.com"}}"#).unwrap();
assert_eq!(
cache.email(Tool::Claude, Some(dir.path())).as_deref(),
Some("longer@b.com")
);
std::fs::remove_file(&path).unwrap();
assert_eq!(cache.email(Tool::Claude, Some(dir.path())), None);
}
}
35 changes: 34 additions & 1 deletion src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,16 @@ fn config_bootstrap(db: &HcomDb, args: &[String]) -> i32 {
}

/// Main entry point for `hcom config` command.
/// Keys only `hcom relay` sets. `HCOM_RELAY_SHARE_ACCOUNT` is not one: it
/// changes what the worker sends, not whether or where it runs.
const RELAY_MANAGED_KEYS: &[&str] = &[
"HCOM_RELAY",
"HCOM_RELAY_ID",
"HCOM_RELAY_TOKEN",
"HCOM_RELAY_ENABLED",
"HCOM_RELAY_PSK",
];

pub fn cmd_config(db: &HcomDb, args: &ConfigArgs, ctx: Option<&CommandContext>) -> i32 {
if args.key.as_deref() != Some("bootstrap") && !args.extra.is_empty() {
eprintln!("Error: quote multi-word config values");
Expand Down Expand Up @@ -999,7 +1009,7 @@ pub fn cmd_config(db: &HcomDb, args: &ConfigArgs, ctx: Option<&CommandContext>)
}
// Relay settings change what the relay worker does and must go through
// `hcom relay`, which validates them and starts/stops the worker to match.
if key.starts_with("HCOM_RELAY") {
if RELAY_MANAGED_KEYS.contains(&key.as_str()) {
eprintln!(
"Error: {key} is managed by 'hcom relay' (new, connect, on, off).\nRun: hcom relay --help"
);
Expand Down Expand Up @@ -2327,6 +2337,29 @@ mod tests {
);
}

#[test]
fn test_relay_share_account_is_set_through_config() {
assert!(!RELAY_MANAGED_KEYS.contains(&normalize_key("relay_share_account").as_str()));
for key in [
"relay",
"relay_id",
"relay_token",
"relay_enabled",
"relay_psk",
] {
assert!(
RELAY_MANAGED_KEYS.contains(&normalize_key(key).as_str()),
"{key}"
);
}

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
config_set_at_path(&path, "HCOM_RELAY_SHARE_ACCOUNT", Some("1")).unwrap();
let parsed: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
assert_eq!(parsed["relay"]["share_account"].as_bool(), Some(true));
}

#[test]
fn test_config_set_saves_valid_args() {
let dir = tempfile::tempdir().unwrap();
Expand Down
7 changes: 6 additions & 1 deletion src/commands/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,9 +662,14 @@ const CONFIG_KEYS_HELP: &[HelpEntry] = &[
" title_mode",
"Terminal/tab title: combined, label, or off",
),
(
" relay_share_account",
"Send agents' login e-mail to relay peers (default off)",
),
];

/// Registry keys left out of help: relay keys are managed by `hcom relay`;
/// Registry keys left out of help: the relay worker's keys are managed by
/// `hcom relay` (`relay_share_account` is set with `hcom config`, so listed);
/// `timeout` only affects headless/vanilla Claude (see `config timeout --info`).
#[cfg(test)]
const CONFIG_KEYS_NOT_IN_HELP: &[&str] = &[
Expand Down
Loading
Loading