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
1 change: 1 addition & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,7 @@ def init_agent(

# SQLite session store (optional -- provided by CLI or gateway)
agent._session_db = session_db
agent._owns_session_db = False # caller-injected; do not close on teardown
agent._parent_session_id = parent_session_id
# A close flush and the worker's turn-start flush can overlap. The durable
# marker is attached to each in-memory message dict, so its test-and-append
Expand Down
21 changes: 7 additions & 14 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,13 @@ def format_duration_compact(*args, **kwargs):
def _reverse_alias_for_display(model_name: str) -> str:
"""Return the shortest configured alias for ``model_name``, or ``model_name``.

Looks up both ``model_aliases:`` (dict-based, full DirectAlias entries)
and ``model.aliases:`` (string-based, set via ``hermes config set``)
from config.yaml. Multiple aliases pointing at the same model — the
shortest wins, so ``opus47`` beats ``palantir-claude47``.
Only looks up ``model_aliases:`` (dict-based, full DirectAlias entries
intentionally set by the user for display purposes) — NOT
``model.aliases:`` (string-based aliases auto-generated by the model
picker, which are convenience shorthands for resolution and typically
*less* readable than the model name itself). Multiple aliases pointing
at the same model: the shortest wins, so ``opus47`` beats
``palantir-claude47``.
"""
global _REVERSE_ALIAS_CACHE
if not model_name:
Expand All @@ -149,16 +152,6 @@ def _reverse_alias_for_display(model_name: str) -> str:
m = str(entry.get("model", "") or "").strip()
if m and (m not in rmap or len(alias) < len(rmap[m])):
rmap[m] = alias
mdl = cfg.get("model", {}) or {}
if isinstance(mdl, dict):
simple = mdl.get("aliases")
if isinstance(simple, dict):
for alias, val in simple.items():
if isinstance(val, str) and val.strip():
v = val.strip()
m = v.split("/", 1)[1] if "/" in v else v
if m and (m not in rmap or len(alias) < len(rmap[m])):
rmap[m] = alias
except Exception:
pass
_REVERSE_ALIAS_CACHE = rmap
Expand Down
30 changes: 29 additions & 1 deletion cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@
logger = logging.getLogger(__name__)


def _close_late_session_db_fds(future: "concurrent.futures.Future") -> None:
"""Done-callback: retrieve and close a SessionDB that completed after its timeout.

When ``run_job``'s SessionDB init times out, the worker thread is abandoned
and the future's eventual result would leak SQLite FDs for the process
lifetime. This callback is attached to that future so that if/when the
constructor completes, the SessionDB (and its open .db / WAL / SHM handles)
are immediately closed.
"""
try:
db = future.result(timeout=60)
if db is not None:
db.close()
except Exception:
pass


def _set_cron_session_title(session_db, session_id, base_title):
"""Robustly title a finished cron session before it is closed.

Expand Down Expand Up @@ -2927,8 +2944,19 @@ def run_job(

if _session_db_timeout > 0:
_session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
_session_db_future = _session_db_pool.submit(SessionDB)
try:
_session_db = _session_db_pool.submit(SessionDB).result(timeout=_session_db_timeout)
_session_db = _session_db_future.result(timeout=_session_db_timeout)
except concurrent.futures.TimeoutError:
# The worker is abandoned (shutdown below doesn't wait). If
# SessionDB() later completes inside it, the future's result
# would be orphaned and its SQLite FDs (.db, WAL, SHM) would
# leak until process exit. Register a done callback that
# retrieves and closes any eventual late result.
_session_db_future.add_done_callback(
lambda _f: _close_late_session_db_fds(_f)
)
raise
finally:
# Don't wait for a wedged connect() to unwind — abandon the
# worker thread (same pattern as the agent inactivity timeout
Expand Down
39 changes: 39 additions & 0 deletions plugins/platforms/photon/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,45 @@ def store_photon_token(token: str) -> None:
_save_auth(auth)


def clear_photon_token() -> None:
"""Remove any stored Photon dashboard token from auth.json.

Used to discard a stale/expired token before re-authentication.
"""
auth = _load_auth()
pool = auth.get("credential_pool", {})
photon = pool.get("photon", [])
if isinstance(photon, list) and photon:
pool["photon"] = []
_save_auth(auth)
# Also clear the legacy shape if present.
providers = auth.get("providers", {})
if "photon" in providers:
providers["photon"] = {}
_save_auth(auth)


def check_photon_token_valid(token: str) -> bool:
"""Return True if the token is accepted by the dashboard API.

Makes a lightweight ``GET /api/auth/get-session`` call. A non-401
response (including non-auth errors like network blips) is treated as
"probably valid" so transient failures don't force unnecessary re-login.
Only a definitive 401 / 403 is treated as stale.
"""
if not token:
return False
try:
resp = _dashboard_get("/api/auth/get-session", token)
if resp.status_code in (401, 403):
return False
return True
except Exception:
# Transient error — don't force a re-auth; let the caller's
# management-call error propagate if the token really is bad.
return True


def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
"""Return the runtime SDK creds ``(spectrum_project_id, project_secret)``.

Expand Down
28 changes: 26 additions & 2 deletions plugins/platforms/photon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,23 @@ def _print_code(code):


def _cmd_setup(args: argparse.Namespace) -> int:
# 1. Login (skip if we already have a token).
# 1. Login (skip if we already have a valid token).
token = photon_auth.load_photon_token()
if token:
# Validate the existing token — the dashboard token has a short TTL
# and can go stale between runs (observed: ~3-4 days). Reusing a
# stale token causes every management call to fail with 401 and
# leaves the operator confused about why setup "succeeds" but nothing
# works. Check upfront so we fail fast and fall back to fresh login.
print("[1/5] Checking existing Photon token...")
if photon_auth.check_photon_token_valid(token):
print(" ✓ token is valid")
else:
print(" ✗ token is stale (dashboard rejected it) — re-authenticating")
photon_auth.clear_photon_token()
token = None
if not token:
print("[1/5] No Photon token found — running device login...")
print("[1/5] No valid Photon token found — running device login...")
rc = _run_device_login(args)
if rc != 0:
return rc
Expand Down Expand Up @@ -270,6 +283,17 @@ def _cmd_setup(args: argparse.Namespace) -> int:
if rc != 0:
return rc

# 7. Ensure the photon platform is enabled in config.yaml so the
# gateway loads it on next start. Without this the channel stays
# disabled even after a successful provisioning run, silently
# keeping iMessage offline.
try:
from hermes_cli.config import write_platform_config_field
write_platform_config_field("photon", "enabled", True, raw=True)
print(" ✓ photon platform enabled in config.yaml")
except Exception as e:
print(f" (could not enable Photon in config: {e})", file=sys.stderr)

print()
print("✓ Photon setup complete.")
print(" Start the gateway: hermes gateway start")
Expand Down
12 changes: 12 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ def _get_session_db_for_recall(self):
from hermes_state import SessionDB

self._session_db = SessionDB()
self._owns_session_db = True
return self._session_db
except Exception as exc:
logger.debug("SessionDB unavailable for recall", exc_info=True)
Expand Down Expand Up @@ -3946,6 +3947,17 @@ def close(self) -> None:
session_db.end_session(session_id, "agent_close")
except Exception:
pass
# 8. Close the SQLite session store if this agent owns it (lazy-recall
# path). Caller-injected gateway/CLI stores remain open; they are
# shared across agents and closed by their original owner.
try:
if getattr(self, "_owns_session_db", False):
db = getattr(self, "_session_db", None)
if db is not None:
db.close()
self._session_db = None
except Exception:
pass

def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:
"""
Expand Down
81 changes: 36 additions & 45 deletions skills/email/himalaya/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: himalaya
description: "Himalaya CLI: IMAP/SMTP email from terminal."
version: 1.1.0
version: 2.0.0
author: community
license: MIT
platforms: [linux, macos, windows]
Expand Down Expand Up @@ -48,70 +48,62 @@ cargo install himalaya --locked

## Configuration Setup

Run the interactive wizard to set up an account:
Run the interactive wizard (bare `himalaya` — no subcommand) to set up an account:

```bash
himalaya account configure
himalaya
```

Or create `~/.config/himalaya/config.toml` manually:
The wizard tests IMAP and SMTP connectivity, then prints a ready-to-save
TOML config to stdout. Redirect it directly:

```bash
himalaya > ~/.config/himalaya/config.toml
```

Or create `~/.config/himalaya/config.toml` manually using per-backend tables:

```toml
[accounts.personal]
email = "you@example.com"
display-name = "Your Name"
default = true

backend.type = "imap"
backend.host = "imap.example.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "you@example.com"
backend.auth.type = "password"
backend.auth.cmd = "pass show email/imap" # or use keyring

message.send.backend.type = "smtp"
message.send.backend.host = "smtp.example.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "you@example.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "pass show email/smtp"

# Folder aliases (himalaya v1.2.0+ syntax). Required whenever the
# server's folder names don't match himalaya's canonical names
# (inbox/sent/drafts/trash). Gmail is the common case — see
# `references/configuration.md` for the `[Gmail]/Sent Mail` mapping.
folder.aliases.inbox = "INBOX"
folder.aliases.sent = "Sent"
folder.aliases.drafts = "Drafts"
folder.aliases.trash = "Trash"
imap.server = "imap.example.com:993"
imap.sasl.plain.username = "you@example.com"
imap.sasl.plain.password.raw = "your-password"

smtp.server = "smtp.example.com:587"
smtp.starttls = true
smtp.sasl.plain.username = "you@example.com"
smtp.sasl.plain.password.raw = "your-password"

mailbox.alias.inbox = "INBOX"
mailbox.alias.sent = "Sent"
mailbox.alias.drafts = "Drafts"
mailbox.alias.trash = "Trash"
```

> **Heads up on the alias syntax.** Pre-v1.2.0 docs used a
> `[accounts.NAME.folder.alias]` sub-section (singular `alias`).
> v1.2.0 silently ignores that form — TOML parses fine, but the
> alias resolver never reads it, so every lookup falls through to
> the canonical name. On Gmail this means save-to-Sent fails *after*
> SMTP delivery succeeds, and `himalaya message send` exits non-zero.
> Any caller (agent, script, user) that retries on that exit code
> will re-run the entire send — including SMTP — producing duplicate
> emails to recipients. Always use `folder.aliases.X` (plural, dotted
> keys, directly under `[accounts.NAME]`).
> **Heads up on the alias syntax.** Himalaya v2.0.0 renamed
> `folder.aliases.*` to `mailbox.alias.*`. The old dotted keys are
> silently ignored — TOML parses fine, but the alias resolver never reads
> them. On Gmail this means save-to-Sent fails *after* SMTP delivery
> succeeds, and `himalaya message send` exits non-zero. Always use
> `mailbox.alias.X` in v2.0.0+.

## Hermes Integration Notes

- **Reading, listing, searching, moving, deleting** all work directly through the terminal tool
- **Composing/replying/forwarding** — piped input (`cat << EOF | himalaya template send`) is recommended for reliability. Interactive `$EDITOR` mode works with `pty=true` + background + process tool, but requires knowing the editor and its commands
- Use `--output json` for structured output that's easier to parse programmatically
- The `himalaya account configure` wizard requires interactive input — use PTY mode: `terminal(command="himalaya account configure", pty=true)`
- Use `--json` before the subcommand for structured output that's easier to parse programmatically (e.g. `himalaya --json envelope list`)
- The bare `himalaya` wizard requires interactive input — use PTY mode: `terminal(command="himalaya", pty=true)`

## Common Operations

### List Folders
### List Mailboxes

```bash
himalaya folder list
himalaya mailbox list
```

### List Emails
Expand Down Expand Up @@ -275,11 +267,10 @@ himalaya attachment download 42 --downloads-dir ~/Downloads

## Output Formats

Most commands support `--output` for structured output:
Most commands support `--json` (before the subcommand) for structured output:

```bash
himalaya envelope list --output json
himalaya envelope list --output plain
himalaya --json envelope list
```

## Debugging
Expand Down
Loading
Loading