diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 47ec8d1153..b8b6120ae0 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added Letta Code detection, optional native conversation restore, and `agent start --kind letta` support. (#3106, thanks @just-cameron) + ### Fixed - Retained mouse selections now copy when Ctrl+C or Cmd+C arrives before a delayed mouse release instead of forwarding the copy shortcut to the pane. (#3100, thanks @moret) - Removing a background worktree workspace no longer changes focus to its parent workspace. (#3098) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index f9642ffa0d..78c0d757d0 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -2118,6 +2118,7 @@ "hermes", "qodercli", "qwen", + "letta", "cursor", "mastracode", "antigravity_cli", @@ -7174,6 +7175,7 @@ "hermes", "qodercli", "qwen", + "letta", "cursor", "mastracode", "antigravity_cli", diff --git a/docs/next/website/src/content/docs/agent-automation.mdx b/docs/next/website/src/content/docs/agent-automation.mdx index 16d694e23c..905ec74f78 100644 --- a/docs/next/website/src/content/docs/agent-automation.mdx +++ b/docs/next/website/src/content/docs/agent-automation.mdx @@ -41,7 +41,7 @@ Agent commands accept either a unique live name or the pane ID that currently ho An available shell pane is at its interactive shell prompt: the shell itself owns the foreground, with no foreground command, editor, or agent running. Return the pane to its prompt before calling `agent start`. -`--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, and `maki`. Arguments after `--` are passed unchanged to that executable. +`--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, and `maki`. Arguments after `--` are passed unchanged to that executable. Successful `agent start` returns only after Herdr detects the expected agent in the same terminal and marks it ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. Startup waits for 30 seconds by default; `--timeout` must be greater than 3000 and no more than 300000 milliseconds. diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index c43097e213..19eb355951 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -21,6 +21,7 @@ Automatic detection works out of the box for common coding agents. The table sho | Hermes Agent | screen manifest | session | | Qoder CLI | screen manifest | session | | Qwen Code | screen manifest | session | +| Letta Code | screen manifest | session | | Droid | screen manifest | session | | OpenCode | lifecycle plugin when installed; otherwise screen manifest | state and session | | Kilo Code CLI | lifecycle plugin when installed; otherwise screen manifest | state and session | diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index a0abc54118..ab8093997d 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -305,7 +305,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] Agent targets are either a unique live agent name or the pane ID that currently hosts the agent. Terminal IDs and bare agent-kind labels are not agent targets. Agents started through `agent start` require a name; manually launched agents remain unnamed and use their pane ID. -`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, and `maki`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. +`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, and `maki`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. A successful start returns only after the expected agent owns the same terminal and is ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. @@ -373,6 +373,7 @@ herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install mastracode herdr integration install grok @@ -389,6 +390,7 @@ herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall grok diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 9fea14d3bc..c661452976 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -1,6 +1,6 @@ --- title: Integrations -description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. --- Herdr detects supported agents automatically. Install official integrations when you want native agent session restore, direct lifecycle reports, or both. See [Agents](/docs/agents/) for the full status authority model. @@ -23,6 +23,7 @@ herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install mastracode herdr integration install antigravity-cli @@ -45,6 +46,7 @@ herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall antigravity-cli @@ -58,7 +60,7 @@ Herdr uses integrations in two ways: | Integration type | Agents | Effect | | --- | --- | --- | | Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. | -| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | +| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable. @@ -89,9 +91,9 @@ Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that n [Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world example. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, preserves report ordering across sessions, and releases authority on exit. -Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. ## Pi @@ -275,6 +277,20 @@ Herdr uses `~/.qwen` by default, or `QWEN_HOME` when set. The Qwen config direct Herdr resumes stored Qwen Code sessions with `qwen --resume `. +## Letta Code + +Install the Letta Code hook: + +```bash +herdr integration install letta +``` + +The quiet `SessionStart` hook reports the conversation ID for native restore. Letta Code state still comes from Herdr's screen manifest detection. + +Herdr uses `~/.letta`. The config directory must already exist. Install writes `hooks/herdr-agent-session.sh` (`hooks/herdr-agent-session.ps1` on Windows) and adds a Herdr entry to `settings.json`. Uninstall removes only the matching entry and managed script. + +Herdr resumes named conversations with `letta --conversation `. It records the default conversation as `default:` and resumes it with `letta --conversation default --agent `. + ## Cursor Agent CLI Install the Cursor Agent CLI hook: diff --git a/docs/next/website/src/content/docs/ja/agent-automation.mdx b/docs/next/website/src/content/docs/ja/agent-automation.mdx index 2f0a520d65..40093ac14f 100644 --- a/docs/next/website/src/content/docs/ja/agent-automation.mdx +++ b/docs/next/website/src/content/docs/ja/agent-automation.mdx @@ -41,7 +41,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') 利用可能なシェルペインとは、対話シェルのプロンプトに戻っており、フォアグラウンドをシェル自身が所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていないペインです。`agent start` の前にプロンプトへ戻してください。 -`--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 +`--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 成功した `agent start` は、同じターミナルで期待したエージェントを検出し、対話入力の準備ができたと Herdr が判断してから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。起動待機はデフォルトで 30 秒です。`--timeout` は 3000 より大きく 300000 以下のミリ秒で指定します。 diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 0cc00a16c9..5aac8acf76 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -21,6 +21,7 @@ Herdr は複数のコーディングエージェントを同時に動かすた | Hermes Agent | スクリーンマニフェスト | セッション | | Qoder CLI | スクリーンマニフェスト | セッション | | Qwen Code | スクリーンマニフェスト | セッション | +| Letta Code | スクリーンマニフェスト | セッション | | Droid | スクリーンマニフェスト | セッション | | OpenCode | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | | Kilo Code CLI | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 9c14c6d678..6e5a6052df 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -285,7 +285,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] エージェントターゲットは、一意なライブエージェント名、または現在そのエージェントをホストしているペイン ID です。ターミナル ID とエージェント kind のラベルだけでは指定できません。`agent start` で起動するエージェントには名前が必須で、手動で起動したエージェントは名前なしのままペイン ID で指定します。 -`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 +`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 成功した start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 @@ -341,6 +341,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration uninstall pi herdr integration uninstall omp @@ -356,6 +357,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration status [--outdated-only] ``` diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index 45feb41c5e..25014ca6ad 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -1,6 +1,6 @@ --- title: インテグレーション -description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 +description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 --- Herdr は対応エージェントを自動的に検出します。公式インテグレーションは、復元のためのネイティブセッション識別、ライフサイクル状態の報告、またはその両方を追加できます。 @@ -26,6 +26,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install antigravity-cli herdr integration install grok @@ -48,6 +49,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall antigravity-cli herdr integration uninstall grok @@ -60,7 +62,7 @@ Herdr はインテグレーションを 2 つの異なる方法で使います: | インテグレーションの種類 | エージェント | 効果 | | --- | --- | --- | | ライフサイクル権威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | インストールされ、そのペインについて能動的に報告している間は、フックまたはプラグインのイベントが `idle`、`working`、`blocked` を決定します。同じライフサイクル権威に対して、Herdr はスクリーンマニフェストのフォールバックを併用しません。 | -| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | +| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。 @@ -91,9 +93,9 @@ Herdr の外では何もしないように、`HERDR_ENV=1` で必要な変数が [Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際の実装例です。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時に権威を解放します。 -一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 +一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 -エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `1`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 +エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Letta Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `1`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 ## Pi @@ -277,6 +279,20 @@ Herdr はデフォルトで `~/.qwen` を使い、`QWEN_HOME` が設定されて Herdr は保存された Qwen Code セッションを `qwen --resume ` で resume します。 +## Letta Code + +Letta Code フックをインストールします: + +```bash +herdr integration install letta +``` + +静かな `SessionStart` フックは、ネイティブ復元用の会話 ID を報告します。Letta Code の状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr は `~/.letta` を使います。設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-session.sh`(Windows では `hooks/herdr-agent-session.ps1`)を書き込み、`settings.json` に Herdr エントリを追加します。アンインストールは一致するエントリと管理対象スクリプトだけを削除します。 + +Herdr は名前付き会話を `letta --conversation ` で resume します。デフォルト会話は `default:` として記録し、`letta --conversation default --agent ` で resume します。 + ## Cursor Agent CLI Cursor Agent CLI フックをインストールします: diff --git a/docs/next/website/src/content/docs/ja/session-state.mdx b/docs/next/website/src/content/docs/ja/session-state.mdx index 6f3753f0a8..db7fdd19c8 100644 --- a/docs/next/website/src/content/docs/ja/session-state.mdx +++ b/docs/next/website/src/content/docs/ja/session-state.mdx @@ -77,6 +77,7 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation `、または `default:` の場合は `letta --conversation default --agent ` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index f6d1b95ef6..50ed2e7df3 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -77,6 +77,7 @@ Native session restore requires these Herdr integration versions or newer: | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation ` or `letta --conversation default --agent ` for `default:` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx index dd3b920e2b..657afd9344 100644 --- a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx @@ -41,7 +41,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') 可用的 shell 窗格必须停在交互式 shell 提示符,由 shell 自身占用前台,没有正在前台运行的命令、编辑器或智能体。调用 `agent start` 前先让窗格回到提示符。 -`--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen` 和 `maki`。`--` 后的参数会原样传给该可执行文件。 +`--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta` 和 `maki`。`--` 后的参数会原样传给该可执行文件。 成功的 `agent start` 只有在 Herdr 于同一终端检测到预期智能体,并确认它可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认等待启动 30 秒;`--timeout` 必须大于 3000 且不超过 300000 毫秒。 diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx index 4e4f6c06b3..84963cd59f 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -21,6 +21,7 @@ Herdr 为同时运行多个编程智能体而生。每个智能体都待在一 | Hermes Agent | 屏幕清单 | 会话 | | Qoder CLI | 屏幕清单 | 会话 | | Qwen Code | 屏幕清单 | 会话 | +| Letta Code | 屏幕清单 | 会话 | | Droid | 屏幕清单 | 会话 | | OpenCode | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | | Kilo Code CLI | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 496f4a6ac4..cefc016e30 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -285,7 +285,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] 智能体目标只能是唯一的实时智能体名称,或当前承载该智能体的窗格 ID。终端 ID 和单独的智能体 kind 标签不能作为目标。通过 `agent start` 启动的智能体必须有名称;手动启动的智能体保持未命名,通过窗格 ID 寻址。 -`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen` 和 `maki`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 +`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta` 和 `maki`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 成功的 start 只有在预期智能体占用同一终端并可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 @@ -341,6 +341,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration uninstall pi herdr integration uninstall omp @@ -356,6 +357,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration status [--outdated-only] ``` diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index 2d7ec99f25..0f7973b8a4 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -1,6 +1,6 @@ --- title: 集成 -description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 +description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 --- Herdr 自动检测受支持的智能体。官方集成可以额外提供用于恢复的原生会话身份、生命周期状态上报,或两者兼有。 @@ -26,6 +26,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install antigravity-cli herdr integration install grok @@ -48,6 +49,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall antigravity-cli herdr integration uninstall grok @@ -60,7 +62,7 @@ Herdr 以两种不同方式使用集成: | 集成类型 | 智能体 | 效果 | | --- | --- | --- | | 生命周期权威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | 已安装且在为该窗格主动上报时,由钩子或插件事件决定 `idle`、`working` 和 `blocked`。对同一个生命周期权威,Herdr 不再使用屏幕清单兜底。 | -| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | +| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | 自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。 @@ -91,9 +93,9 @@ Herdr 以两种不同方式使用集成: [Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实实现示例。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放权威。 -一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 +一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 -原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `1`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 +原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Letta Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `1`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 ## Pi @@ -277,6 +279,20 @@ Herdr 默认使用 `~/.qwen`,设置了 `QWEN_HOME` 时使用后者。Qwen 配置 Herdr 用 `qwen --resume ` 恢复保存的 Qwen Code 会话。 +## Letta Code + +安装 Letta Code 钩子: + +```bash +herdr integration install letta +``` + +静默的 `SessionStart` 钩子会上报会话 ID,用于原生恢复。Letta Code 状态仍来自 Herdr 的屏幕清单检测。 + +Herdr 使用 `~/.letta`。配置目录必须已经存在。安装会写入 `hooks/herdr-agent-session.sh`(Windows 上为 `hooks/herdr-agent-session.ps1`),并向 `settings.json` 添加 Herdr 条目。卸载只移除匹配条目和托管脚本。 + +Herdr 用 `letta --conversation ` 恢复命名会话。它把默认会话记录为 `default:`,并用 `letta --conversation default --agent ` 恢复该会话。 + ## Cursor Agent CLI 安装 Cursor Agent CLI 钩子: diff --git a/docs/next/website/src/content/docs/zh-cn/session-state.mdx b/docs/next/website/src/content/docs/zh-cn/session-state.mdx index 0a0936adf4..e2ff6c1546 100644 --- a/docs/next/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/next/website/src/content/docs/zh-cn/session-state.mdx @@ -77,6 +77,7 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation `,或对 `default:` 使用 `letta --conversation default --agent ` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 1765c10fb1..e856e20680 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -1140,6 +1140,17 @@ "off" ] }, + { + "key": "ui.sound.agents.letta", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Letta Code agents.", + "values": [ + "default", + "on", + "off" + ] + }, { "key": "ui.sound.agents.maki", "type": "enum", @@ -1233,7 +1244,7 @@ "key": "experimental.cjk_ime_agents", "type": "list of strings", "default": "[]", - "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, qwen-code, maki." + "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, qwen-code, letta, letta-code, maki." }, { "key": "experimental.cjk_ime_cursor_shape", diff --git a/src/agent_resume.rs b/src/agent_resume.rs index cd1ae7bb2f..fae36ba81e 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -207,6 +207,26 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:grok", "grok", AgentSessionRefKind::Id) => { vec!["grok".into(), "--resume".into(), session_ref.value.clone()] } + ("herdr:letta", "letta", AgentSessionRefKind::Id) => { + if let Some(agent_id) = session_ref.value.strip_prefix("default:") { + if agent_id.is_empty() { + return None; + } + vec![ + "letta".into(), + "--conversation".into(), + "default".into(), + "--agent".into(), + agent_id.into(), + ] + } else { + vec![ + "letta".into(), + "--conversation".into(), + session_ref.value.clone(), + ] + } + } _ => return None, }; @@ -244,6 +264,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:cursor", "cursor") | ("herdr:antigravity_cli", "agy") | ("herdr:grok", "grok") + | ("herdr:letta", "letta") ) } @@ -464,6 +485,32 @@ mod tests { .argv, vec!["grok", "--resume", "grok-session"] ); + assert_eq!( + plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("conversation-123").unwrap() + ) + .unwrap() + .argv, + vec!["letta", "--conversation", "conversation-123"] + ); + assert_eq!( + plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("default:agent-123").unwrap() + ) + .unwrap() + .argv, + vec!["letta", "--conversation", "default", "--agent", "agent-123"] + ); + assert!(plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("default:").unwrap() + ) + .is_none()); } #[test] diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index 6151dedbcb..b1f50b621c 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -26,6 +26,7 @@ pub enum IntegrationTarget { Hermes, Qodercli, Qwen, + Letta, Cursor, Mastracode, AntigravityCli, @@ -33,7 +34,7 @@ pub enum IntegrationTarget { } impl IntegrationTarget { - pub(crate) const ALL: [Self; 17] = [ + pub(crate) const ALL: [Self; 18] = [ Self::Pi, Self::Omp, Self::Claude, @@ -47,6 +48,7 @@ impl IntegrationTarget { Self::Hermes, Self::Qodercli, Self::Qwen, + Self::Letta, Self::Cursor, Self::Mastracode, Self::AntigravityCli, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index d0a53f7b53..f9e9f1233d 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -110,13 +110,13 @@ fn parse_integration_target( ) -> std::io::Result> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); } @@ -135,6 +135,7 @@ fn parse_integration_target( "hermes" => IntegrationTarget::Hermes, "qodercli" => IntegrationTarget::Qodercli, "qwen" => IntegrationTarget::Qwen, + "letta" => IntegrationTarget::Letta, "cursor" => IntegrationTarget::Cursor, "mastracode" => IntegrationTarget::Mastracode, "antigravity-cli" | "antigravity_cli" => IntegrationTarget::AntigravityCli, @@ -142,7 +143,7 @@ fn parse_integration_target( _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, cursor, mastracode, antigravity-cli, grok" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, letta, cursor, mastracode, antigravity-cli, grok" ); return Ok(None); } @@ -166,6 +167,7 @@ fn print_integration_help() { eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration install qwen"); + eprintln!(" herdr integration install letta"); eprintln!(" herdr integration install cursor"); eprintln!(" herdr integration install mastracode"); eprintln!(" herdr integration install antigravity-cli"); @@ -183,6 +185,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration uninstall qwen"); + eprintln!(" herdr integration uninstall letta"); eprintln!(" herdr integration uninstall cursor"); eprintln!(" herdr integration uninstall mastracode"); eprintln!(" herdr integration uninstall antigravity-cli"); diff --git a/src/config/model.rs b/src/config/model.rs index c10cd73307..d07b4aed53 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1003,7 +1003,7 @@ pub struct ExperimentalConfig { /// if the list contains no valid names, the reveal does not apply. /// Accepted names: pi, claude, codex, gemini, cursor, devin, cline, /// opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, - /// qodercli, qoder, qwen, qwen-code, maki. + /// qodercli, qoder, qwen, qwen-code, letta, letta-code, maki. /// Default: empty. pub cjk_ime_agents: Vec, /// Cursor shape rendered for the IME anchor when diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index c548c839b2..551708835d 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -622,6 +622,7 @@ rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = tru Agent::Kilo, Agent::Qodercli, Agent::Qwen, + Agent::Letta, Agent::Maki, ]; let entries = agents diff --git a/src/config/sound.rs b/src/config/sound.rs index c84f0d59c9..4917b3ba9b 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -44,6 +44,7 @@ pub struct AgentSoundOverrides { pub kilo: AgentSoundSetting, pub qodercli: AgentSoundSetting, pub qwen: AgentSoundSetting, + pub letta: AgentSoundSetting, pub maki: AgentSoundSetting, } @@ -141,6 +142,7 @@ impl AgentSoundOverrides { Some(Agent::Kilo) => self.kilo, Some(Agent::Qodercli) => self.qodercli, Some(Agent::Qwen) => self.qwen, + Some(Agent::Letta) => self.letta, Some(Agent::Maki) => self.maki, None => AgentSoundSetting::Default, } @@ -181,6 +183,7 @@ impl Default for AgentSoundOverrides { kilo: AgentSoundSetting::Default, qodercli: AgentSoundSetting::Default, qwen: AgentSoundSetting::Default, + letta: AgentSoundSetting::Default, maki: AgentSoundSetting::Default, } } @@ -220,6 +223,17 @@ claude = "on" assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); + assert_eq!(config.ui.sound.agents.letta, AgentSoundSetting::Default); + } + + #[test] + fn letta_sound_override_is_used() { + let overrides: AgentSoundOverrides = toml::from_str("letta = \"on\"").unwrap(); + assert_eq!(overrides.letta, AgentSoundSetting::On); + assert_eq!( + overrides.for_agent(Some(Agent::Letta)), + AgentSoundSetting::On + ); } #[test] diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index b9fe786a08..4cfc1c35c8 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -251,6 +251,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ ("kilo", include_str!("manifests/kilo.toml")), ("kimi", include_str!("manifests/kimi.toml")), ("kiro", include_str!("manifests/kiro.toml")), + ("letta", include_str!("manifests/letta.toml")), ("maki", include_str!("manifests/maki.toml")), ("opencode", include_str!("manifests/opencode.toml")), ("pi", include_str!("manifests/pi.toml")), diff --git a/src/detect/manifests/letta.toml b/src/detect/manifests/letta.toml new file mode 100644 index 0000000000..f337a38eed --- /dev/null +++ b/src/detect/manifests/letta.toml @@ -0,0 +1,81 @@ +id = "letta" +version = "2026.08.22.1" +min_engine_version = 3 +updated_at = "2026-08-22T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index def440dcf0..05eed26d2e 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -62,11 +62,12 @@ pub enum Agent { Kilo, Qodercli, Qwen, + Letta, Maki, } impl Agent { - pub const ALL: [Self; 22] = [ + pub const ALL: [Self; 23] = [ Self::Pi, Self::Claude, Self::Codex, @@ -88,10 +89,11 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Qwen, + Self::Letta, Self::Maki, ]; - pub const SCREEN_MANIFEST_AGENTS: [Self; 20] = [ + pub const SCREEN_MANIFEST_AGENTS: [Self; 21] = [ Self::Pi, Self::Claude, Self::Codex, @@ -111,6 +113,7 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Qwen, + Self::Letta, Self::Maki, ]; } @@ -138,6 +141,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Qwen => "qwen", + Agent::Letta => "letta", Agent::Maki => "maki", } } @@ -171,6 +175,7 @@ pub fn interactive_agent_executable(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Qwen => "qwen", + Agent::Letta => "letta", Agent::Maki => "maki", } } @@ -208,6 +213,7 @@ fn lookup_agent(name: &str) -> Option { "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo), "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), "qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen), + "letta" | "letta-code" | "letta code" => Some(Agent::Letta), "maki" => Some(Agent::Maki), _ => None, } @@ -227,7 +233,9 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag { let candidate = normalized_process_name(process); if let Some(agent) = identify_agent(&candidate) { - return Some((agent, candidate)); + if agent != Agent::Letta || is_interactive_letta_process(process) { + return Some((agent, candidate)); + } } } @@ -238,6 +246,9 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag let Some(agent) = identify_agent(&candidate) else { continue; }; + if agent == Agent::Letta && !is_interactive_letta_process(process) { + continue; + } let score = process_priority(process, &candidate); match &best { @@ -307,7 +318,10 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool { matches!( (source, agent_label), - ("herdr:hermes", "hermes") | ("herdr:qwen", "qwen") | ("herdr:antigravity_cli", "agy") + ("herdr:hermes", "hermes") + | ("herdr:qwen", "qwen") + | ("herdr:letta", "letta") + | ("herdr:antigravity_cli", "agy") ) } @@ -357,7 +371,10 @@ fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> Stri if let Some(wrapped_agent) = wrapped_agent_name_from_runtime_argv(runtime, process.argv.as_deref()) { - if identify_agent(&wrapped_agent) == Some(Agent::Qwen) { + if matches!( + identify_agent(&wrapped_agent), + Some(Agent::Qwen | Agent::Letta) + ) { return wrapped_agent; } } @@ -609,10 +626,121 @@ fn agent_name_from_known_package_path(path: &str) -> Option { if window == ["node_modules", "mastracode", "dist", "cli"] { return Some(agent_label(Agent::Mastracode).to_string()); } + if window == ["node_modules", "@letta-ai", "letta-code", "letta"] { + return Some(agent_label(Agent::Letta).to_string()); + } + } + None +} + +fn letta_entrypoint_index(argv: &[String]) -> Option { + let is_letta = + |arg: &str| agent_name_from_path_token(arg).as_deref() == Some(agent_label(Agent::Letta)); + if argv.first().is_some_and(|arg| is_letta(arg)) { + return Some(0); + } + + let runtime = argv + .first() + .map(|arg| normalized_agent_lookup_name(path_basename(arg)))?; + if !matches!(runtime.as_str(), "node" | "bun") { + return None; + } + + let mut index = 1; + while let Some(arg) = argv.get(index) { + if arg == "--" { + return argv + .get(index + 1) + .is_some_and(|arg| is_letta(arg)) + .then_some(index + 1); + } + if flag_matches(arg, &["-e", "--eval", "-p", "--print"]) { + return None; + } + if arg.starts_with('-') { + index += if option_takes_value(arg) { 2 } else { 1 }; + continue; + } + return is_letta(arg).then_some(index); + } + None +} + +fn letta_first_arg_after_backend_selection(args: &[String]) -> Option<&str> { + let mut args = args.iter(); + while let Some(arg) = args.next() { + if arg == "--backend" { + let _ = args.next(); + continue; + } + if arg.starts_with("--backend=") { + continue; + } + return Some(arg); } None } +fn is_interactive_letta_process(process: &crate::platform::ForegroundProcess) -> bool { + let parsed_cmdline; + let argv = if let Some(argv) = process.argv.as_deref() { + argv + } else { + parsed_cmdline = process + .cmdline + .as_deref() + .unwrap_or_default() + .split_whitespace() + .map(|arg| arg.trim_matches(|ch| matches!(ch, '\'' | '"')).to_string()) + .collect::>(); + if parsed_cmdline.is_empty() { + return true; + } + &parsed_cmdline + }; + + let cli_args = letta_entrypoint_index(argv) + .map(|index| &argv[index + 1..]) + .unwrap_or(argv); + + if cli_args.iter().any(|arg| { + let option = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name); + matches!( + option, + "-p" | "--print" + | "--prompt" + | "--json" + | "--stream-json" + | "--run" + | "--disable-memory-guard" + | "--output-format" + | "--input-format" + | "--include-partial-messages" + | "--from-agent" + | "--environment" + | "--env" + | "--pre-load-skills" + | "--tags" + | "--ephemeral" + | "--stateless" + | "--max-turns" + | "--memfs-startup" + | "-h" + | "--help" + | "-v" + | "--version" + | "--info" + | "--update" + | "--upgrade" + ) + }) { + return false; + } + + letta_first_arg_after_backend_selection(cli_args).is_none_or(|arg| arg.starts_with('-')) +} + fn resolved_agent_name_from_path_token(token: &str) -> Option { let path = std::path::Path::new(token); if path.components().count() < 2 { @@ -765,6 +893,8 @@ mod tests { assert_eq!(identify_agent("kilo-code"), Some(Agent::Kilo)); assert_eq!(identify_agent("qwen"), Some(Agent::Qwen)); assert_eq!(identify_agent("Qwen Code"), Some(Agent::Qwen)); + assert_eq!(identify_agent("letta"), Some(Agent::Letta)); + assert_eq!(identify_agent("Letta Code"), Some(Agent::Letta)); assert_eq!(identify_agent("maki"), Some(Agent::Maki)); } @@ -791,6 +921,7 @@ mod tests { assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok)); assert_eq!(parse_agent_label("hermes-agent"), Some(Agent::Hermes)); assert_eq!(parse_agent_label("qwen-code"), Some(Agent::Qwen)); + assert_eq!(parse_agent_label("letta-code"), Some(Agent::Letta)); assert_eq!(parse_agent_label("maki"), Some(Agent::Maki)); assert_eq!(parse_agent_label("kilo-code"), Some(Agent::Kilo)); } @@ -835,6 +966,7 @@ mod tests { (Agent::Kilo, "kilo"), (Agent::Qodercli, "qodercli"), (Agent::Qwen, "qwen"), + (Agent::Letta, "letta"), (Agent::Maki, "maki"), ]; assert_eq!(expected.len(), Agent::ALL.len()); @@ -865,6 +997,7 @@ mod tests { for (source, label, agent) in [ ("herdr:hermes", "hermes", Agent::Hermes), ("herdr:qwen", "qwen", Agent::Qwen), + ("herdr:letta", "letta", Agent::Letta), ("herdr:antigravity_cli", "agy", Agent::Antigravity), ] { assert!(!full_lifecycle_hook_authority(source, label)); @@ -926,6 +1059,160 @@ mod tests { } } + #[test] + fn identify_agent_in_job_detects_interactive_letta_entrypoints() { + for argv in [ + vec!["letta", "--backend", "local"], + vec![ + "node", + "/home/user/project/node_modules/.bin/letta", + "--conversation", + "conversation-id", + ], + vec![ + "node.exe", + r"C:\Users\user\AppData\Roaming\npm\node_modules\@letta-ai\letta-code\letta.js", + "--agent", + "agent-id", + ], + ] { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, "MainThread", &argv)], + }; + + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Letta, "letta".to_string())) + ); + } + } + + #[test] + fn identify_agent_in_job_ignores_noninteractive_letta_processes() { + for args in [ + vec!["--prompt", "hello"], + vec!["--output-format", "json"], + vec!["--input-format=stream-json"], + vec!["--ephemeral"], + vec!["--max-turns=1"], + vec!["server"], + vec!["--backend", "local", "server"], + vec!["fix this bug"], + vec!["agents", "list"], + vec!["version"], + ] { + let mut argv = vec!["node", "/home/user/project/node_modules/.bin/letta"]; + argv.extend(args); + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, "MainThread", &argv)], + }; + + assert_eq!(identify_agent_in_job(&job), None, "argv: {argv:?}"); + } + + let unrelated = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node", + &["node", "/tmp/server.js", "letta"], + )], + }; + assert_eq!(identify_agent_in_job(&unrelated), None); + + let source_checkout = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node", + &["node", "/home/user/src/letta-code/letta/build.js"], + )], + }; + assert_eq!(identify_agent_in_job(&source_checkout), None); + } + + #[test] + fn letta_manifest_detects_observed_working_and_idle_chrome() { + for screen in [ + "✻ Thinking…\nTutor is reflecting… (esc to interrupt · 2m 3s)", + "My Tutor is thinking about thinking… (esc to interrupt · 3s)", + "Tutor is calibrating… (interrupting)", + "• Run Compile the integration\n└ Running... (1s)", + ] { + assert_eq!( + detect_state(Some(Agent::Letta), screen), + AgentState::Working + ); + } + assert_eq!( + detect_state( + Some(Agent::Letta), + "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · No model selected" + ), + AgentState::Idle + ); + assert_eq!( + detect_state( + Some(Agent::Letta), + "────────────────\n› explain this code\n────────────────\nTutor · No model selected" + ), + AgentState::Unknown + ); + } + + #[test] + fn letta_manifest_uses_osc_activity_and_approval_signals() { + let idle_screen = + "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · GPT-5.5"; + + for title in ["⠋ Tutor", "project | ⠏ Tutor"] { + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + } + + for title in [ + "[ ! ] Action Required | Tutor", + "[ . ] Action Required | Tutor", + ] { + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, "Tutor", "4;3;0"); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn letta_manifest_detects_observed_command_approval() { + let approval = r#"✻ Thinking… + +──────────────────────────────────────────────────────────────── +Run this command? + + $ rm -f /var/tmp/herdr-blocked-capture-never-created + +❯ 1. Yes + 2. No, and tell Letta Code what to do differently + +Enter to select · Esc to cancel"#; + assert_eq!( + detect_state(Some(Agent::Letta), approval), + AgentState::Blocked + ); + assert_eq!( + detect_state( + Some(Agent::Letta), + "The user asked: Run this command?\n›\n────\nTutor · No model selected" + ), + AgentState::Idle + ); + } + #[test] fn identify_agent_in_job_detects_windows_cursor_install() { let job = crate::platform::ForegroundJob { diff --git a/src/integration/actions.rs b/src/integration/actions.rs index 24d1f9e37c..6e5094553f 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -4,11 +4,11 @@ use super::registry::{integration_target_label, integration_target_supported}; use super::targets::{ install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi, - install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, install_qwen, - uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, + install_letta, install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, + install_qwen, uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, - uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, - uninstall_pi, uninstall_qodercli, uninstall_qwen, + uninstall_kilo, uninstall_kimi, uninstall_letta, uninstall_mastracode, uninstall_omp, + uninstall_opencode, uninstall_pi, uninstall_qodercli, uninstall_qwen, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -205,6 +205,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::Letta => { + let installed = install_letta()?; + vec![ + format!( + "installed letta integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured letta settings at {}", + installed.settings_path.display() + ), + ] + } crate::api::schema::IntegrationTarget::Cursor => { let installed = install_cursor()?; vec![ @@ -599,6 +612,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Letta => { + let result = uninstall_letta()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed letta hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no letta hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr letta hook entry from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr letta hook entry found in {}", + result.settings_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Cursor => { let result = uninstall_cursor()?; let mut messages = Vec::new(); diff --git a/src/integration/assets/letta/herdr-agent-session.ps1 b/src/integration/assets/letta/herdr-agent-session.ps1 new file mode 100644 index 0000000000..27f40e9bd0 --- /dev/null +++ b/src/integration/assets/letta/herdr-agent-session.ps1 @@ -0,0 +1,51 @@ +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.conversation_id)) { exit 0 } + +$sessionId = [string]$payload.conversation_id +if ($sessionId -eq "default") { + if ([string]::IsNullOrWhiteSpace($payload.agent_id)) { exit 0 } + $sessionId = "default:" + [string]$payload.agent_id +} + +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +$source = if ($payload.is_new_session -eq $true) { "new" } else { "resume" } +$commandArgs = @( + "pane", "report-agent-session", $env:HERDR_PANE_ID, + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", $sessionId, + "--seq", [string][DateTime]::UtcNow.Ticks, + "--session-start-source", $source +) +$job = $null +try { + $job = Start-Job -ScriptBlock { + param($Executable, [object[]]$Arguments) + & $Executable @Arguments *> $null + } -ArgumentList $herdr, (,$commandArgs) + if ($null -eq (Wait-Job -Job $job -Timeout 1)) { + Stop-Job -Job $job + } +} catch { + # Hook failures must stay silent because Letta injects output into the next prompt. + $null = $_ +} finally { + if ($null -ne $job) { + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue + } +} diff --git a/src/integration/assets/letta/herdr-agent-session.sh b/src/integration/assets/letta/herdr-agent-session.sh new file mode 100644 index 0000000000..d6e052d66c --- /dev/null +++ b/src/integration/assets/letta/herdr-agent-session.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +[ "${1:-}" = "session" ] || exit 0 +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +if [ -n "${HERDR_BIN_PATH:-}" ]; then + [ -x "$HERDR_BIN_PATH" ] || exit 0 +else + command -v herdr >/dev/null 2>&1 || exit 0 +fi +command -v python3 >/dev/null 2>&1 || exit 0 + +# SessionStart stdout is injected into Letta's next user message. Keep every +# path silent, including successful reports and malformed hook input. +python3 -c ' +import json +import os +import subprocess +import sys +import time + +try: + payload = json.load(sys.stdin) + conversation_id = payload.get("conversation_id") + agent_id = payload.get("agent_id") + if not isinstance(conversation_id, str) or not conversation_id: + raise ValueError + if conversation_id == "default": + if not isinstance(agent_id, str) or not agent_id: + raise ValueError + session_id = "default:" + agent_id + else: + session_id = conversation_id + + command = os.environ.get("HERDR_BIN_PATH") or "herdr" + args = [ + command, "pane", "report-agent-session", os.environ["HERDR_PANE_ID"], + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", session_id, "--seq", str(time.time_ns()), + "--session-start-source", "new" if payload.get("is_new_session") else "resume", + ] + subprocess.run( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=1, + check=False, + ) +except Exception: + pass +' 2>/dev/null || true diff --git a/src/integration/env.rs b/src/integration/env.rs index 8f3a9e655d..a74dbf4d67 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -162,6 +162,10 @@ pub(crate) fn qwen_dir() -> io::Result { config_dir_from_env_or_home(QWEN_HOME_ENV_VAR, &[".qwen"]) } +pub(crate) fn letta_dir() -> io::Result { + Ok(home_dir()?.join(".letta")) +} + pub(crate) fn cursor_dir() -> io::Result { config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"]) } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index c87ccac6cc..3ba1acbd8a 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -204,6 +204,18 @@ const QWEN_HOOK_ASSET: &str = if cfg!(windows) { }; const QWEN_INTEGRATION_VERSION: u32 = 1; const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const LETTA_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-session.ps1" +} else { + "herdr-agent-session.sh" +}; +const LETTA_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/letta/herdr-agent-session.ps1") +} else { + include_str!("assets/letta/herdr-agent-session.sh") +}; +const LETTA_INTEGRATION_VERSION: u32 = 1; +const LETTA_HOOK_TIMEOUT_MS: u64 = 10_000; const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ ("SessionStart", "idle"), ("UserPromptSubmit", "working"), diff --git a/src/integration/registry.rs b/src/integration/registry.rs index 4625197059..43d7c9217d 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -21,6 +21,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", crate::api::schema::IntegrationTarget::Qwen => "qwen", + crate::api::schema::IntegrationTarget::Letta => "letta", crate::api::schema::IntegrationTarget::Cursor => "cursor", crate::api::schema::IntegrationTarget::Mastracode => "mastracode", crate::api::schema::IntegrationTarget::AntigravityCli => "antigravity-cli", @@ -51,6 +52,7 @@ pub(crate) fn integration_target_command_names( crate::api::schema::IntegrationTarget::Hermes => &["hermes"], crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), crate::api::schema::IntegrationTarget::Qwen => &["qwen"], + crate::api::schema::IntegrationTarget::Letta => &["letta"], crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], crate::api::schema::IntegrationTarget::AntigravityCli => &["agy"], @@ -78,6 +80,7 @@ pub(crate) fn integration_target_supported(target: crate::api::schema::Integrati | crate::api::schema::IntegrationTarget::Kimi | crate::api::schema::IntegrationTarget::Qodercli | crate::api::schema::IntegrationTarget::Qwen + | crate::api::schema::IntegrationTarget::Letta | crate::api::schema::IntegrationTarget::AntigravityCli | crate::api::schema::IntegrationTarget::Devin | crate::api::schema::IntegrationTarget::Hermes @@ -267,7 +270,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result, u32, -); 17] { +); 18] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -337,6 +340,11 @@ fn integration_specs() -> [( qwen_dir().map(|dir| dir.join("hooks").join(super::QWEN_HOOK_INSTALL_NAME)), super::QWEN_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Letta, + letta_dir().map(|dir| dir.join("hooks").join(super::LETTA_HOOK_INSTALL_NAME)), + super::LETTA_INTEGRATION_VERSION, + ), ( crate::api::schema::IntegrationTarget::Cursor, cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)), diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 0150ccaa83..c6d33c4577 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -19,8 +19,8 @@ use super::config_edit::{ }; use super::env::{ antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, - grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, - opencode_dir, pi_extension_dir, qodercli_dir, qwen_dir, + grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, letta_dir, mastracode_dir, + omp_extension_dir, opencode_dir, pi_extension_dir, qodercli_dir, qwen_dir, }; use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, @@ -34,10 +34,10 @@ use super::types::{ CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, GrokInstallPaths, GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, - KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, - MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, - OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, - QwenInstallPaths, QwenUninstallResult, + KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, LettaInstallPaths, + LettaUninstallResult, MastracodeInstallPaths, MastracodeUninstallResult, OmpInstallPaths, + OmpUninstallResult, OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, + QodercliInstallPaths, QodercliUninstallResult, QwenInstallPaths, QwenUninstallResult, }; use super::{ ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS, @@ -50,7 +50,8 @@ use super::{ GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, - KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, + KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, LETTA_HOOK_ASSET, LETTA_HOOK_INSTALL_NAME, + LETTA_HOOK_TIMEOUT_MS, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME, @@ -1000,6 +1001,207 @@ pub(crate) fn install_qwen() -> io::Result { }) } +fn letta_install_artifact_path(path: &Path, role: &str) -> io::Result { + let file_name = path + .file_name() + .ok_or_else(|| io::Error::other(format!("invalid install path: {}", path.display())))?; + let mut artifact_name = file_name.to_os_string(); + artifact_name.push(format!(".herdr-install-{}-{role}", std::process::id())); + Ok(path.with_file_name(artifact_name)) +} + +pub(super) fn prepare_letta_install_file( + target: &Path, + contents: &[u8], + executable: bool, + preserve_permissions: bool, +) -> io::Result<(PathBuf, PathBuf)> { + if target.try_exists()? && !target.is_file() { + return Err(io::Error::other(format!( + "install target is not a file: {}", + target.display() + ))); + } + + let staged = letta_install_artifact_path(target, "staged")?; + let backup = letta_install_artifact_path(target, "backup")?; + if staged.try_exists()? || backup.try_exists()? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("stale install artifact exists for {}", target.display()), + )); + } + + let prepare_result = (|| { + fs::write(&staged, contents)?; + if preserve_permissions && target.is_file() { + fs::set_permissions(&staged, fs::metadata(target)?.permissions())?; + } + if executable { + make_executable(&staged)?; + } + Ok(()) + })(); + if let Err(err) = prepare_result { + let _ = remove_file_if_exists(&staged); + return Err(err); + } + + Ok((staged, backup)) +} + +fn combine_letta_install_errors(primary: io::Error, rollback: io::Result<()>) -> io::Error { + match rollback { + Ok(()) => primary, + Err(rollback_err) => io::Error::new( + primary.kind(), + format!("{primary}; rollback failed: {rollback_err}"), + ), + } +} + +pub(super) fn publish_letta_install_file( + target: &Path, + staged: &Path, + backup: &Path, +) -> io::Result { + let had_original = target.try_exists()?; + if had_original { + fs::rename(target, backup)?; + } + + if let Err(err) = fs::rename(staged, target) { + let rollback = if had_original { + fs::rename(backup, target) + } else { + Ok(()) + }; + return Err(combine_letta_install_errors(err, rollback)); + } + + Ok(had_original) +} + +pub(super) fn rollback_letta_install_file( + target: &Path, + backup: &Path, + had_original: bool, +) -> io::Result<()> { + remove_file_if_exists(target)?; + if had_original { + fs::rename(backup, target)?; + } + Ok(()) +} + +fn cleanup_letta_install_artifact(path: &Path) { + if let Err(err) = remove_file_if_exists(path) { + tracing::warn!(path = %path.display(), %err, "failed to remove Letta install artifact"); + } +} + +fn ensure_letta_session_hook(hooks: &mut Map, command: String) -> io::Result<()> { + let entries = hooks + .entry("SessionStart".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| io::Error::other("hook entries for SessionStart must be an array"))?; + + entries.push(json!({ + "hooks": [{ + "type": "command", + "command": command, + "timeout": LETTA_HOOK_TIMEOUT_MS, + "quiet": true, + }], + })); + Ok(()) +} + +pub(crate) fn install_letta() -> io::Result { + let dir = letta_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "letta code config directory not found at {}. install letta code first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(LETTA_HOOK_INSTALL_NAME); + + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "letta settings", + "letta settings hooks", + )?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + ensure_letta_session_hook(hooks, hook_command(&hook_path, Some("session")))?; + + let settings_contents = serde_json::to_string_pretty(&settings)?; + let (hook_staged, hook_backup) = + prepare_letta_install_file(&hook_path, LETTA_HOOK_ASSET.as_bytes(), true, false)?; + let (settings_staged, settings_backup) = + match prepare_letta_install_file(&settings_path, settings_contents.as_bytes(), false, true) + { + Ok(paths) => paths, + Err(err) => { + cleanup_letta_install_artifact(&hook_staged); + return Err(err); + } + }; + + let hook_had_original = match publish_letta_install_file(&hook_path, &hook_staged, &hook_backup) + { + Ok(had_original) => had_original, + Err(err) => { + cleanup_letta_install_artifact(&hook_staged); + cleanup_letta_install_artifact(&settings_staged); + return Err(err); + } + }; + + let settings_had_original = + match publish_letta_install_file(&settings_path, &settings_staged, &settings_backup) { + Ok(had_original) => had_original, + Err(err) => { + let err = combine_letta_install_errors( + err, + rollback_letta_install_file(&hook_path, &hook_backup, hook_had_original), + ); + cleanup_letta_install_artifact(&settings_staged); + return Err(err); + } + }; + + if hook_had_original { + cleanup_letta_install_artifact(&hook_backup); + } + if settings_had_original { + cleanup_letta_install_artifact(&settings_backup); + } + + Ok(LettaInstallPaths { + hook_path, + settings_path, + }) +} + pub(crate) fn install_cursor() -> io::Result { let dir = cursor_dir()?; if !dir.is_dir() { @@ -1142,6 +1344,46 @@ pub(crate) fn uninstall_qwen() -> io::Result { }) } +pub(crate) fn uninstall_letta() -> io::Result { + let dir = letta_dir()?; + let hook_path = dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME); + let settings_path = dir.join("settings.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "letta settings", + "letta settings hooks", + )? { + updated_settings |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(LettaUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + pub(crate) fn uninstall_cursor() -> io::Result { let cursor_home = cursor_dir()?; let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME); diff --git a/src/integration/tests.rs b/src/integration/tests.rs index d54183f295..acdef43453 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -3290,6 +3290,194 @@ fn install_qwen_errors_when_config_dir_missing() { let _ = fs::remove_dir_all(base); } +#[test] +fn install_and_uninstall_letta_preserve_unrelated_settings_and_hooks() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let letta_dir = home.join(".letta"); + fs::create_dir_all(&letta_dir).unwrap(); + let settings_path = letta_dir.join("settings.json"); + fs::write( + &settings_path, + r#"{"theme":"dark","hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user"}]}]}}"#, + ) + .unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + let installed = install_letta().unwrap(); + assert_eq!( + installed.hook_path, + letta_dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME) + ); + let first_install = fs::read_to_string(&settings_path).unwrap(); + let settings: Value = serde_json::from_str(&first_install).unwrap(); + let entries = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["hooks"][0]["command"], "echo user"); + assert!(entries[1].get("matcher").is_none()); + assert_eq!(entries[1]["hooks"][0]["timeout"], LETTA_HOOK_TIMEOUT_MS); + assert_eq!(entries[1]["hooks"][0]["quiet"], true); + assert!(entries[1]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("session")); + assert_eq!(settings["theme"], "dark"); + + install_letta().unwrap(); + assert_eq!(fs::read_to_string(&settings_path).unwrap(), first_install); + + let result = uninstall_letta().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_settings); + assert!(!installed.hook_path.exists()); + let settings: Value = + serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + assert_eq!(settings["theme"], "dark"); + let remaining = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["hooks"][0]["command"], "echo user"); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn letta_session_hook_is_silent_and_encodes_default_conversation() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(home.join(".letta")).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + let installed = install_letta().unwrap(); + + let capture = base.join("args.txt"); + let fake_herdr = base.join("herdr"); + fs::write( + &fake_herdr, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\n", + capture.display() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&fake_herdr).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_herdr, permissions).unwrap(); + + let mut child = Command::new("sh") + .arg(&installed.hook_path) + .arg("session") + .env("HERDR_ENV", "1") + .env("HERDR_PANE_ID", "w1:p2") + .env("HERDR_SOCKET_PATH", "/tmp/herdr.sock") + .env("HERDR_BIN_PATH", &fake_herdr) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all( + br#"{"event_type":"SessionStart","conversation_id":"default","agent_id":"agent-123","is_new_session":false}"#, + ) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + let args = fs::read_to_string(capture).unwrap(); + assert!(args.contains("report-agent-session w1:p2")); + assert!(args.contains("--source herdr:letta --agent letta")); + assert!(args.contains("--agent-session-id default:agent-123")); + assert!(args.contains("--session-start-source resume")); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_letta_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + let err = install_letta().unwrap_err().to_string(); + assert!(err.contains("letta code config directory not found")); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_letta_does_not_publish_hook_when_settings_are_invalid() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let letta_dir = home.join(".letta"); + fs::create_dir_all(&letta_dir).unwrap(); + fs::write(letta_dir.join("settings.json"), "not json").unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + assert!(install_letta().is_err()); + assert!(!letta_dir + .join("hooks") + .join(LETTA_HOOK_INSTALL_NAME) + .exists()); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn letta_staged_install_can_restore_the_prior_file() { + let base = unique_base(); + fs::create_dir_all(&base).unwrap(); + let target = base.join("settings.json"); + fs::write(&target, "old").unwrap(); + + let (staged, backup) = prepare_letta_install_file(&target, b"new", false, true).unwrap(); + let had_original = publish_letta_install_file(&target, &staged, &backup).unwrap(); + assert!(had_original); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + + rollback_letta_install_file(&target, &backup, had_original).unwrap(); + assert_eq!(fs::read_to_string(&target).unwrap(), "old"); + assert!(!backup.exists()); + + let _ = fs::remove_dir_all(base); +} + #[test] fn install_cursor_writes_hook_and_updates_hooks_json() { let _lock = integration_env_lock(); diff --git a/src/integration/types.rs b/src/integration/types.rs index c7eaf795e6..00e7cc80df 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -75,6 +75,12 @@ pub(crate) struct QwenInstallPaths { pub settings_path: PathBuf, } +#[derive(Debug)] +pub(crate) struct LettaInstallPaths { + pub hook_path: PathBuf, + pub settings_path: PathBuf, +} + #[derive(Debug)] pub(crate) struct CursorInstallPaths { pub hook_path: PathBuf, @@ -133,6 +139,14 @@ pub(crate) struct QwenUninstallResult { pub updated_settings: bool, } +#[derive(Debug)] +pub(crate) struct LettaUninstallResult { + pub hook_path: PathBuf, + pub settings_path: PathBuf, + pub removed_hook_file: bool, + pub updated_settings: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct IntegrationStatus { pub target: crate::api::schema::IntegrationTarget, diff --git a/src/main.rs b/src/main.rs index 56dbb8df26..a06a2bd389 100644 --- a/src/main.rs +++ b/src/main.rs @@ -459,7 +459,7 @@ pane_history = false # If the list contains no valid names, the reveal does not apply. # Accepted: pi, claude, codex, gemini, cursor, devin, cline, opencode, # copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, -# qwen-code, maki. +# qwen-code, letta, letta-code, maki. # cjk_ime_agents = [] # Cursor shape rendered when reveal_hidden_cursor_for_cjk_ime is true. # Values: block, steady_block (default), underline, steady_underline, bar, steady_bar. diff --git a/website/agent-detection/index.toml b/website/agent-detection/index.toml index f44a83065a..e69f22109c 100644 --- a/website/agent-detection/index.toml +++ b/website/agent-detection/index.toml @@ -56,6 +56,10 @@ path = "kimi.toml" id = "kiro" path = "kiro.toml" +[[agents]] +id = "letta" +path = "letta.toml" + [[agents]] id = "maki" path = "maki.toml" diff --git a/website/agent-detection/letta.toml b/website/agent-detection/letta.toml new file mode 100644 index 0000000000..f337a38eed --- /dev/null +++ b/website/agent-detection/letta.toml @@ -0,0 +1,81 @@ +id = "letta" +version = "2026.08.22.1" +min_engine_version = 3 +updated_at = "2026-08-22T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +]