herdr のデスクトップアプリと herdr-core を TUI 版と比べながら試してみた

herdr のデスクトップアプリと herdr-core を TUI 版と比べながら試してみた

Herdr app とそのランタイム herdr-core が公開されました。これまでは TUI 版を使ってましたが、デスクトップアプリと herdr-core を実際に動かし、TUI 版との違いを確かめた結果を紹介します。
2026.11.04

はじめに

こんにちは、クラスメソッド製造ビジネステクノロジー部の嶋田です。

herdr から、デスクトップアプリ(Herdr app)と、その土台になるランタイムの herdr-core が公開されました。
私はこれまでターミナルで動く TUI 版の herdr を使っており、tmux から移行したあとは業務などでも使い続けてきました。

https://x.com/herdrdev/status/2115841099575222765?a=QwxEUoaiabTzKLdZh2k&s=20
https://dev.classmethod.jp/articles/herdr-tmux-replacement/

本記事では、デスクトップアプリと herdr-core を実際に動かし、TUI 版と何が違うのかを確かめた結果を紹介します。

この記事の内容は 2026-11-04 時点のものです。
検証時のバージョンは macOS 26.7.1、Herdr app 0.1.8(BETA 表記)、herdr-core 0.1.14、TUI 版 herdr 0.9.0、Claude Code 2.1.312 です。
mod とキー操作の検証は、Herdr app 0.1.12、herdr-core 0.1.16 で行いました。

herdr-core とデスクトップアプリの関係

herdr-core は、ターミナルとエージェントを動かし続けるための常駐プログラムです。
CLI は hc という名前で、ターミナルの起動、画面の読み取り、テキストの送信、完了待ちを担います。
公式ページは herdr-core を「draws nothing and guesses nothing」と説明しており、画面の描画はしません。

デスクトップアプリは herdr-core のクライアントの 1 つです。
アプリのウィンドウを閉じても、ターミナルは herdr-core の中で動き続けます。
リモートのマシンを追加すると、そのマシンでも herdr-core が動き、アプリは SSH 越しにつなぎます。

TUI 版の herdr もサーバーが常駐してペインを保持する点は同じです。
違いは、TUI 版がサーバーと描画を 1 つの製品にまとめているのに対し、新しい構成ではランタイム(herdr-core)と画面(Herdr app)が別のプログラムに分かれていることです。
herdr-core は hc schema で OpenRPC 形式の API を出力でき、アプリ以外のクライアントも作れます。

TUI 版と herdr-core は別のランタイムです。
手元の Mac では、TUI 版の herdr server と herdr-core serve が別プロセスとして同時に動いていました。
TUI 版で開いているペインは、そのまま Herdr app に出てくるわけではありません。

インストールと初回起動

macOS では DMG を入れるか、ターミナルからインストールスクリプトを実行します。

curl -fsSL https://releases.herdr.dev/app/install.sh | sh

インストール後は、~/.local/bin に herdr-core と、そのシンボリックリンクの hc、アプリ本体を指す herdr-app が入りました。

$ hc --version
hc 0.1.14
$ hc server status
{"core":{...},"live":0,"pid":63181,"version":"0.1.14", ...}

アプリを起動すると、ウェルカム画面が開きます。
「エージェントが必要なときに知らせる」「エージェントがヘルパーを起動できる」「他のマシンも同じウィンドウに出せる」の 3 点が、アプリの主な機能として紹介されています。

Herdr app のウェルカム画面。ターミナルがバックグラウンドで動くことと、主な機能 3 点が説明されている
初回起動時のウェルカム画面

アプリの画面構成は、左のサイドバーにスペースとエージェントの一覧、右にタブとペインです。
スペースはリポジトリなど作業場所ごとの単位で、タブはその中の作業ごとの単位です。

Claude Code の状態表示

TUI 版でサイドバーに Claude Code の状態を出すには、herdr integration install claude でフックスクリプトを入れる必要がありました。

herdr-core は、画面の文字列からエージェントの状態を推測しません。
プログラム自身が OSC 7501(Program Status Protocol)というエスケープシーケンスで報告した状態だけを使います。
Claude Code は 2.1.295 の changelog に「Added Program Status Protocol (OSC 7501) support」とあり、このプロトコルに対応しています。

hc から Claude Code を起動し、状態を確かめました。

$ hc run --bg --name try1 claude "hello.txt に今日の日付を書いて終わって"
$ hc get try1 --json
{ ..., "status": {"app": "claude-code", "state": "done"}, "reported": true, ... }

herdr-core 側には何も設定していませんが、作業を終えた Claude Code の状態が done として返りました。
状態は working、blocked、idle、done のいずれかです。
アプリのサイドバーでは、blocked が「Needs you」として一覧の上に出ます。

ただし、フォルダを初めて開いたときの trust 確認では、status は null のままでした(herdr-core 0.1.16、Claude Code 2.1.318 でも同じでした)。
blocked にはならないので、アプリから見ると、エージェントが入力を待っていることがわかりません。
hc から起動する場合は、hc read で画面を読み、hc keys <端末> down enter で承認しました。

TUI 版のために入れた herdr integration install claude のフックは、設定に残したままでした。
そこで、ユーザー設定を読まないオプション(--setting-sources project,local)を付けて、フックが 1 つも動かない状態の Claude Code でも確かめました。
この確認は、あとから herdr-core 0.1.16、Claude Code 2.1.318 で行っています。

$ hc run --bg --name nohook claude --setting-sources project,local "hello.txt に今日の日付を書いて終わって"
$ hc get nohook --json
{ ..., "status": {"app": "claude-code", "msg": "Writing today's date to hello.txt", "state": "working"}, "reported": true, ... }
{ ..., "status": {"app": "claude-code", "state": "done"}, "reported": true, ... }

フックがなくても、状態は working から done に変わりました。
もともと TUI 版のフックは、TUI 版が設定する環境変数(HERDR_ENV など)がないと何もせずに終わります。
hc が起動したターミナルには HC_TERMINAL_ID などの HC_ で始まる環境変数だけが入っているので、herdr-core の下ではフックは動いていません。

アプリを閉じてもターミナルが残るか

アプリからスペースを作り、Claude Code にバグ修正を頼みました。
修正が終わったあとで、アプリを終了してから hc ls を実行した結果が次です。

$ hc ls
{"terminals":[{"address":"t2","agent":"claude-code","cwd":"/Users/<user>/tmp/herdr-demo","state":"live","status":"done","turn":1,"where":"herdr-demo / 1"}, ...]}

アプリが動いていない間も、ターミナル t2 は live のまま残りました。
アプリを起動し直すと、スペースとタブが終了前と同じ状態で表示されました。

アプリが開いたターミナルには、herdr.space と herdr.tab のラベルが付きます。
アプリは、このラベルを持つターミナルを該当するスペースとタブに表示します。
hc run で自分で起動したターミナルも、同じラベルを付ければアプリに表示されます。

エージェントがエージェントを起動する

herdr-core では、エージェントが hc run で別のエージェント(サブエージェント)を起動できます。
起動されたエージェントは起動元の子として記録され、起動元が止まると一緒に止まります。

アプリで動いている Claude Code に、レビュー役のエージェントを起動するよう頼みました。

hc run --bg --name reviewer claude "calc.py をレビューして、結果を hc send $HC_TERMINAL_ID で報告して" を実行して、レビュー役のエージェントを起動して。報告が来たら要約して

$HC_TERMINAL_ID には、そのターミナル自身のアドレスが入っています。
Claude Code は reviewer を起動し、reviewer はレビュー結果を hc send で起動元へ送り返しました。
起動元の画面には、<hc from="t3 reviewer"> で囲まれたメッセージとして届きます。

Herdr app の画面。サイドバーの Agents に herdr-demo の下に reviewer がぶら下がり、リモートのエージェントが Needs you に出ている
サブエージェントの reviewer が起動元の下に表示される。右は Changes パネル

サイドバーの Agents では、reviewer が起動元の herdr-demo の下にぶら下がって表示されました。
右の Changes パネルには、そのリポジトリの変更差分が出ており、コミットメッセージの入力欄とコミットボタンもあります。

TUI 版でも、公式の SKILL.md をエージェントに読ませれば、ペインを作って別のエージェントを動かせました。
herdr-core では、起動元と起動されたエージェントの親子関係を herdr-core 自体が持っており、hc wait で完了を待ったり hc send で報告を受けたりする手段が CLI に揃っています。
エージェント向けのマニュアルは hc --skill で出力できます。

リモートのマシンを同じサイドバーに出す

以前の記事で TUI 版のサーバーにした Mac mini を、herdr-core のマシンとして追加しました。
アプリのサイドバーにある「Add machine」か、CLI の hc machine add で追加します。

hc machine add mac-mini

追加すると、リモート側の ~/.local/bin にも hc と herdr-core が入りました。
サイドバーには「This computer」と並んでリモートのマシンが出て、その下にスペースを作れます。

Herdr app の Add a machine ダイアログ。ssh config から読み取ったホストが一覧になり、それぞれに Add ボタンが付いている。下には user@host を直接入力する欄がある
Add machine のダイアログ。ssh config のホストから選ぶか、user@host を入力する(ホスト名などはモザイクで伏せている)

リモートで claude が見つからない

リモートのスペースで Claude Code を起動しようとしたところ、次のエラーになりました。

sh: claude: command not found

アプリはリモートのプログラムを sh -c '"$@"; exec "${SHELL:-sh}" -i' の形で起動します。
SSH 経由の非対話シェルの PATH には ~/.local/bin が入っていなかったため、claude が見つかりませんでした。
起動に失敗したあとは対話シェルが開くので、そのシェルから claude を実行すると動きました。

ログイン切れが「Needs you」に出る

リモートの Claude Code は、ログインの期限が切れていました。
このとき herdr-core は、状態を次のように報告しています。

{"app": "claude-code", "kind": "auth", "msg": "Login expired · Please run /login", "state": "blocked"}

先ほどの画像のとおり、このエージェントはサイドバーの「Needs you」に出ました。
手元のエージェントとリモートのエージェントが 1 つの一覧に並ぶので、どちらのマシンで止まっているエージェントがあるかを同じ場所で確認できます。

マシンの名前を変えると状態が出なくなる

hc machine rename でマシンの表示名を変えたところ、リモートのエージェントの状態がアプリに出なくなりました。
hc get では blocked が返っているのに、アプリではペインの見出しが「Shell」になり、Agents の一覧からも消えました。
アプリを再起動しても変わらず、名前を元に戻すとすぐに状態が表示されました。

現時点では、マシンの名前は追加するときに --name で決めておき、あとから変えないのが無難です。

画面を設定ファイルで変えられる

アプリの画面は、すべて ~/.config/herdr-app の中のファイルで定義されています。
レイアウト、キーバインド、サイドバーのパネル(mod)はファイルを書き換えると 1 秒ほどで反映される、と同梱の AGENTS.md に書かれています。

~/.config/herdr-app/
├── AGENTS.md        # エージェント向けの説明書(起動のたびに書き直される)
├── layouts/         # 画面の構成と見た目
├── keys.json        # キーバインド
├── settings.json    # 拡大率、音、フォントなど
├── mods/            # パネルを作る JavaScript
├── gpui-kit.d.ts    # mod から使える UI 要素の型
└── herdr.d.ts       # mod から使えるアプリのデータとコマンドの型

公式ガイドでは、エージェントにこの AGENTS.md を読ませて、「PR の一覧をサイドバーに出して」のように頼む使い方を想定しています。
mod は GPUI の要素を JavaScript で組み立てる形式で、HTML や CSS、Web ビューは使えません。
設定を壊したときは、ctrl-alt-shift-escape で既定のレイアウトに戻せます。

アプリでは、サイドバーのスペース一覧やエージェント一覧、タブバーも、同じ mod の仕組みで作られています。
既定のものを複製して書き換えれば、一覧の並びや表示する項目を変えられます。

mod で Inbox パネルを作る

既定の Agents の一覧は、エージェントを状態ごとに並べます。
ただ、作業を終えたエージェントは見終わっても一覧に残り続けるので、エージェントが増えると、まだ確認していないものがどれかわかりにくくなります。
そこで、確認が済んだエージェントを手で片付けられる Inbox パネルを mod で作りました。

  • Needs you: 入力を待っている(blocked)エージェントをカードで上に出す
  • Finished: 作業を終えたエージェントをカードで出す。確認したら「Put away」で片付ける
  • Working: 作業中のエージェントを 1 行ずつ出す
  • Put away: 片付けたエージェント。再び作業を始めると、自動で Finished 側に戻る

カードをクリックすると、そのエージェントのペインに移動します(agent.focus コマンド)。
リモートのマシンのエージェントには、カードにマシン名を付けています。

Herdr app の画面。左のサイドバーの下半分が Inbox になり、Needs you にリモートの Claude Code、Finished に herdr-demo の Claude Code のカードが並んでいる
サイドバーの Agents を Inbox に置き換えた画面。リモートのエージェントには herdr-mac-mini のラベルが付く

mod は ~/.config/herdr-app/mods/inbox/ に main.js と mod.json を置くだけで読み込まれます。
エージェントの一覧は herdr モジュールの agents() で取得でき、状態が変わると render が自動で呼び直されるので、ポーリングは要りません。
片付けたエージェントのアドレスは、mod ごとの localStorage に保存しています。

~/.config/herdr-app/mods/inbox/mod.json
{ "place": "right", "size": 300, "title": "Inbox" }
main.js の全体
~/.config/herdr-app/mods/inbox/main.js
// Inbox: agents that need you on top, finished ones as cards you put away by hand.
//
// - Needs you: blocked agents. They can't be put away.
// - Finished: done or idle agents you haven't put away yet. "Put away" moves one
//   to the Put away list; it comes back by itself when it starts working again.
// - Working: a compact list with a spinner.

import { View, div, svg, image } from "gpui-kit";
import { v_flex, h_flex } from "gpui-base";
import { agents, theme, run, setAgentOrder } from "herdr";

const KEY = "put-away"; // localStorage: addresses put away, in order

const machineOf = (a) => (a.address.includes("/") ? a.address.split("/")[0] : null);

function ago(ms) {
  const m = Math.floor(ms / 60000);
  if (m < 1) return "now";
  if (m < 60) return `${m}m`;
  const h = Math.floor(m / 60);
  return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
}

function icon(p, color) {
  if (!p?.icon) return null;
  return p.tint ? svg(p.icon).size(14).flex_none().text_color(color) : image(p.icon).w(14).h(14).flex_none();
}

export default class Inbox extends View {
  init(_props, cx) {
    this.away = JSON.parse(localStorage.getItem(KEY) ?? "[]");
    this.since = JSON.parse(localStorage.getItem("since") ?? "{}"); // address -> { s, at }
    this.showAway = false;
    // Only to refresh the "5m" labels; live data re-renders by itself.
    cx.timer.every(30000, (cx) => cx.notify());
  }

  save() {
    localStorage.setItem(KEY, JSON.stringify(this.away));
  }

  putAway(address, cx) {
    if (!this.away.includes(address)) this.away.push(address);
    this.save();
    cx.notify();
  }

  bringBack(address, cx) {
    this.away = this.away.filter((x) => x !== address);
    this.save();
    cx.notify();
  }

  // Done and idle are one state here: looking at a done agent makes it idle.
  stamp(list, now) {
    let dirty = false;
    for (const a of list) {
      const s = a.status === "done" ? "idle" : a.status;
      if (this.since[a.address]?.s === s) continue;
      this.since[a.address] = { s, at: now };
      dirty = true;
    }
    if (dirty) localStorage.setItem("since", JSON.stringify(this.since));
  }

  // New work brings an agent back; a closed one is forgotten.
  sweep(list) {
    const live = new Map(list.map((a) => [a.address, a.status]));
    const kept = this.away.filter((x) => live.has(x) && !["working", "blocked"].includes(live.get(x)));
    if (kept.length !== this.away.length) {
      this.away = kept;
      this.save();
    }
  }

  heading(t, label, count, color) {
    return h_flex()
      .px(12)
      .pt(14)
      .pb(6)
      .gap(6)
      .items_center()
      .text_size(11.5)
      .text_color(color ?? t.dim)
      .child(div().child(label))
      .child(div().text_color(t.dim).child(String(count)));
  }

  where(t, a) {
    const m = machineOf(a);
    return h_flex()
      .gap(6)
      .text_size(11.5)
      .text_color(t.dim)
      .child(div().truncate().child(a.space_name))
      .when(Boolean(m), (el) => el.child(div().px(5).rounded(4).bg(t.raised).child(m)));
  }

  // A card: what it is, where it runs, what it last said, and its one action.
  card(t, a, now, action) {
    const blocked = a.status === "blocked";
    const accent = blocked ? t.blocked : t.done;
    return v_flex()
      .id(`card-${a.address}`)
      .mx(8)
      .mb(6)
      .p(10)
      .gap(5)
      .rounded(8)
      .border(1)
      .border_color(a.focused ? t.input : t.border)

      .bg(t.background)
      .hover((s) => s.bg(t.hover))
      .on_click(() => run("agent.focus", { terminal: a.address }))
      .child(
        h_flex()
          .gap(7)
          .items_center()
          .when(Boolean(icon(a.program, t.foreground)), (el) => el.child(icon(a.program, t.foreground)))
          .child(div().flex_1().truncate().text_size(13).text_color(t.foreground).child(a.title || a.tab_name))
          .child(div().flex_none().text_size(11).text_color(t.dim).child(ago(now - (this.since[a.address]?.at ?? now)))),
      )
      .child(this.where(t, a))
      .child(
        h_flex()
          .gap(6)
          .items_center()
          .child(div().flex_1().text_size(11.5).font_semibold().text_color(accent).child(blocked ? "Needs you" : "Finished"))
          .when(Boolean(action), (el) => el.child(action)),
      );
  }

  button(t, id, label, onClick) {
    return div()
      .id(id)
      .px(8)
      .h(22)
      .flex()
      .items_center()
      .rounded(5)
      .border(1)
      .border_color(t.border)
      .text_size(11.5)
      .text_color(t.muted)
      .hover((s) => s.bg(t.raised).text_color(t.foreground))
      .on_click((_e, cx) => {
        cx.stop_propagation();
        onClick(cx);
      })
      .child(label);
  }

  row(t, a, right) {
    return h_flex()
      .id(`row-${a.address}`)
      .mx(8)
      .px(8)
      .h(28)
      .gap(7)
      .items_center()
      .rounded(6)
      .hover((s) => s.bg(t.hover))
      .on_click(() => run("agent.focus", { terminal: a.address }))
      .when(Boolean(icon(a.program, t.muted)), (el) => el.child(icon(a.program, t.muted)))
      .child(div().flex_1().truncate().text_size(12.5).text_color(t.muted).child(a.title || a.tab_name))
      .child(div().flex_none().text_size(11).text_color(t.dim).child(a.space_name))
      .child(right);
  }

  render(cx) {
    const t = theme();
    const now = Date.now();
    const list = agents();
    this.stamp(list, now);
    this.sweep(list);

    const newest = (x, y) => (this.since[y.address]?.at ?? 0) - (this.since[x.address]?.at ?? 0);
    const needs = list.filter((a) => a.status === "blocked").sort(newest);
    const finished = list.filter((a) => ["done", "idle"].includes(a.status) && !this.away.includes(a.address)).sort(newest);
    const working = list.filter((a) => a.status === "working").sort(newest);
    const away = this.away.map((x) => list.find((a) => a.address === x)).filter(Boolean);
    setAgentOrder([...needs, ...finished, ...working, ...away].map((a) => a.address));

    const empty = !needs.length && !finished.length && !working.length;
    return v_flex()
      .id("inbox")
      .size_full()
      .bg(t.chrome)
      .overflow_y_scrollbar()
      .child(h_flex().px(14).pt(14).pb(2).text_size(13).text_color(t.muted).child("Inbox"))
      .when(empty, (el) => el.child(div().px(12).pt(10).text_size(12).text_color(t.dim).child("Nothing to look at.")))
      .when(needs.length > 0, (el) =>
        el.child(this.heading(t, "Needs you", needs.length, t.blocked)).children(needs.map((a) => this.card(t, a, now, null))),
      )
      .when(finished.length > 0, (el) =>
        el
          .child(this.heading(t, "Finished", finished.length, t.done))
          .children(finished.map((a) => this.card(t, a, now, this.button(t, `away-${a.address}`, "Put away", (cx) => this.putAway(a.address, cx))))),
      )
      .when(working.length > 0, (el) =>
        el
          .child(this.heading(t, "Working", working.length, t.working))
          .children(working.map((a) => this.row(t, a, svg("mods/workspace-chrome/spinner.svg").size(12).flex_none().text_color(t.working).spin(900)))),
      )
      .when(away.length > 0, (el) =>
        el
          .child(
            h_flex()
              .id("away-toggle")
              .hover((s) => s.text_color(t.foreground))
              .on_click((_e, cx) => {
                this.showAway = !this.showAway;
                cx.notify();
              })
              .child(this.heading(t, this.showAway ? "Put away ▾" : "Put away ▸", away.length)),
          )
          .when(this.showAway, (el) =>
            el.children(away.map((a) => this.row(t, a, this.button(t, `back-${a.address}`, "Bring back", (cx) => this.bringBack(a.address, cx))))),
          ),
      );
  }
}

書いたら、herdr-app check で読み込めるかを確かめます。

$ herdr-app check ~/.config/herdr-app/mods/inbox
check passed: /Users/<user>/.config/herdr-app/mods/inbox

サイドバーの Agents を Inbox に置き換える

mod.json の place は、Herdr が初めてその mod を見つけたときに、表示中のレイアウトのどこに置くかを決めます。
ただし、既定の main レイアウトは Herdr 同梱のもので、構造を変えられません。
そのため、main のままでは Inbox はサイドバーに入りませんでした。

そこで、main を inbox という名前で複製し、複製したレイアウトのサイドバーを書き換えます。
layout.clone を実行すると、~/.config/herdr-app/layouts/inbox.json ができ、表示もそのレイアウトに切り替わります。

herdr-app do layout.clone --name main --as inbox

レイアウトのサイドバーは、上から brand、spaces、agents、sidebar-footer のブロックでできています。
agents のブロックの中身を、既定の agents mod から inbox に変えました。

~/.config/herdr-app/layouts/inbox.json(agents のブロック)
{ "id": "agents", "type": "block", "content": { "mod": "inbox" }, "size": { "weight": 1 }, "min": 90 }

保存すると、すぐにサイドバーの下半分が Inbox に変わります。
元に戻したいときは、herdr-app do layout.switch --name main で既定のレイアウトに切り替えます。

tmux 風のキー操作を再現できるか

Rex の記事では、init.lua で tmux の prefix キー(ctrl+q>v のようなキーの並び)、続けて押せるリサイズのモード、ペインに重ねて開くコピーモードを再現しました。
同じことを Herdr app でもできるかを試しました。

prefix キーは mod で作る

~/.config/herdr-app/keys.json には、1 つのキーに 1 つのコマンドを割り当てます。
ctrl+q を押してから v を押す、のようなキーの並びは書けません。

そこで、ctrl-q で小さなポップアップの mod を開き、次の 1 キーでコマンドを実行する形にしました。
keys.json では ctrl-q に mod.open:prefix を割り当てています。
ポップアップの mod がキーを受け取り、ペインの分割や移動、タブの切り替え、コピーモードなどを実行します。

Herdr app の画面。上部に ctrl-q のあとに押すキーと動作の一覧を表示したポップアップが開いている
ctrl-q で開く prefix のポップアップ。次の 1 キーでコマンドを実行する

コピーモードは開けたが、実用的ではなかった

コピーモードは、Rex のときと同じく、hc read --lines で画面と履歴を取り出して nvim で開く形で作りました。
ペインに重ねて表示するには、ペインに一時的に別のターミナルを表示する terminal.show コマンドを使います。
nvim を閉じると、ペインは元のターミナルの表示に戻ります。

動くまでに、次の 3 点でつまずきました。

  • hc run で起動した直後のターミナルは、アプリにまだ認識されていません。この間に terminal.show を呼んでも、成功が返るだけで表示は変わりません。ペインが実際にそのターミナルを表示するまで、呼び直す必要がありました。
  • terminal.show で表示を切り替えたあとに、そのペインへ pane.focus でフォーカスを移すと、表示が元のターミナルに戻ります。nvim は見えないまま動き続けるので、気付きにくいです。
  • ポップアップの mod は、閉じた時点で実行中の処理も止められます。ターミナルの起動や表示の切り替えを待つ処理は、ポップアップを閉じる前に終わらせる必要があります。

また、Claude Code のペインで hc read を呼ぶと、ときどき 2.5 秒ほど止まります。
止まらないときは 0.1 秒もかからず、止まるかどうかは読む行数とは関係ありませんでした。
原因はわかっていません。
先にペインを切り替えて読み込み中と表示するようにしましたが、nvim が開くまでの待ち時間そのものは縮められませんでした。

コピーモードは、文字を選んでコピーしたいときにすぐ開けないと意味がありません。
押すたびに数秒待たされることがあるので、普段使いの機能としては実用的ではないと判断しました。

リサイズは再現できなかった

Herdr app には、ペインの大きさを変えるコマンドがありません。
layout.resize はありますが、対象はサイドバーなどのレイアウトの部品で、タブの中のペインには使えません。
そのため、Rex で作った「続けて押すとリサイズし続けるモード」は再現できませんでした。

prefix キーとコピーモードは mod で作れましたが、Rex なら init.lua の設定だけで済むところを、アプリの細かい挙動を調べながら作り込む必要がありました。
しかも、作り込んだコピーモードも開くまでに時間がかかり、Rex のように気軽には使えません。
エージェントの状態を見る画面としては Herdr app のほうが見やすい一方で、キー操作を自分好みに作り込む自由度は Rex のほうが高いと感じました。

テレメトリの扱い

アプリは、既定で匿名の利用統計を 1 日 1 回送ります。
AGENTS.md によると、送るのはバージョン、OS、動かしたエージェントの種類、スペースやタブの数、使ったレイアウトなどの件数です。
ターミナルの内容、ファイル名、フォルダ名、スペース名は送らず、herdr-core からは何も送らないと書かれています。

送る内容は、設定画面の Privacy で 1 件ずつ確認できます。

Herdr app の設定画面の Privacy。Share anonymous usage data のトグルと、送信内容を表示する What we send の Show ボタンがある
設定画面の Privacy。Show を押すと、送信される内容がそのまま表示される

止める場合は、settings.json に次の 1 行を入れます。

~/.config/herdr-app/settings.json
{
  "telemetry": false
}

1 回の起動だけ止める場合は、環境変数 DO_NOT_TRACK=1 か HERDR_TELEMETRY=0 を付けて起動します。

TUI 版との比較

ここまでの違いを表にまとめます。

項目 TUI 版 herdr Herdr app + herdr-core
画面 ターミナルの中の TUI ネイティブのデスクトップアプリ
ランタイム herdr server herdr-core(hc)
Claude Code の状態表示 フックを入れて連携 Claude Code が OSC 7501 で報告
他のエージェントの起動 SKILL.md を読ませて CLI で操作 hc run と親子関係を herdr-core が管理
リモートのマシン herdr --remote でつなぐ hc machine add で追加し、同じサイドバーに表示
見た目の変更 config.toml とプラグイン ~/.config/herdr-app のレイアウトと mod
テレメトリ なし 既定で有効、設定で停止可

おわりに

herdr のデスクトップアプリと herdr-core を、TUI 版と比べながら試しました。

Claude Code の状態表示はフックなしで出て、エージェントが起動したサブエージェントも親子関係つきでサイドバーに並びました。
リモートの Mac mini のエージェントも同じ一覧に出るので、複数のマシンでエージェントを動かしている場合にまとめて確認できます。

mod は JavaScript 1 ファイルで書け、エージェントの状態を使ったパネルを自分の使い方に合わせて足せます。
X では T3 Code を模した mod を作成して公開しているユーザーもいて、アプリケーション UI をユーザー自身が組み替えられる自由度の高さがうかがえます。
今後の発展に期待したいです。

参考資料

この記事をシェアする

関連記事