tako terminal integration: reports this pane's Claude Code state (context, rate limits, turn) to tako and shows tako's status in a band above the prompt and a…

AI エージェント時代の、集約監視に特化した高速 GUI ターミナル A fast GUI terminal built for the AI-agent era — monitor your whole agent fleet in one tab.
開発中です。macOS で動作し、Windows は移植を進めています(対応状況)。 In development. Runs on macOS; the Windows port is in progress.
ドキュメント / Documentation — tako.takushio2525.com セットアップ ・ クイックスタート ・ CLI リファレンス ・ MCP ツール一覧 ・ オーケストレーション
Claude Code のような AI エージェントを使う開発では、1 つの作業が「エージェント本体 + 子エージェント + dev サーバー + ログ」に分裂し、既存ターミナルではタブやウィンドウに散らばってしまいます。tako は 「1 グループ = 1 タブ」 で、エージェントが起動した子プロセスのペインを同じタブ内に自動で生やし、全体をひと目で監視できるようにします。
Working with AI agents like Claude Code, a single task naturally splits into the agent itself, sub-agents, dev servers, and logs — scattered across tabs and windows in existing terminals. tako keeps one group in one tab: panes for agent-spawned processes appear automatically right next to their parent, so you can watch the whole fleet at a glance.
配布しているビルド済みバイナリは macOS(Apple Silicon / macOS 11.0 以降) と Windows(x64 / Windows 10 バージョン 1809 以降) 向けで、どちらも同じリリースに載ります。 The prebuilt binaries target macOS (Apple Silicon, macOS 11.0+) and Windows (x64, Windows 10 version 1809+) — both ship in the same release.
brew install --cask takushio2525/tako/tako
更新は brew upgrade --cask takushio2525/tako/tako、またはアプリ内の更新通知から行えます。 tako CLI も同時に PATH へ入るため、tako コマンドがそのまま使えます。
Update with brew upgrade --cask takushio2525/tako/tako, or from the in-app update notification. The cask also links the tako CLI into your PATH.
GitHub Releases から tako-vX.X.X-macos-arm64.zip を取得します。 Grab tako-vX.X.X-macos-arm64.zip from the Releases page.
tako.app を /Applications へドラッグ / Extract and drag tako.app into /Applicationstako.app をダブルクリックして警告が出たら一旦キャンセル / Double-click, then cancel the warningtako.app を起動すると「開く」ボタンが表示される / Launch again and click "Open"GitHub Releases から次のどちらかを取得します(Windows 版は macOS 版と同じリリースに載ります)。動作要件は Windows 10 バージョン 1809(ビルド 10.0.17763)以降 / x64 です。 Grab either of these from the Releases page (Windows builds ship in the same release as the macOS ones). Requires Windows 10 version 1809 (build 10.0.17763) or later, x64.
tako-vX.X.X-windows-x86_64.exe — 標準のインストーラー。管理者権限は要りません(%LOCALAPPDATA%\Programs\tako に入り、スタートメニュー登録と tako コマンドの PATH 追加まで行います)/ Recommended installer. No administrator rights required (it installs into %LOCALAPPDATA%\Programs\tako, adds the Start menu entry, and puts tako on your PATH)tako-vX.X.X-windows-x86_64.zip — 展開して置くだけのポータブル版。tako-app.exe(本体)と tako.exe(CLI)は同じフォルダに置いたまま使ってください / Portable build. Keep tako-app.exe (the app) and tako.exe (the CLI) in the same folderセッション永続化には psmux を入れてください(Windows で tmux と同じ役割を担います)。 Install psmux for persistent sessions; it plays the same role as tmux on Windows.
winget install marlocarlo.psmux
移植は途中です。手順の詳細は セットアップ、どの機能が使えるかは Windows 対応状況 にまとめてあります。 The port is still in progress: see Setup for the full walkthrough and Windows support status for what works today.
素のターミナルとして使うなら、起動すればそのまま使えます。AI 連携を使う場合は tako 内のターミナルで次の 2 つを実行します。 As a plain terminal, just launch it. To use the AI integration, run these two commands inside tako:
tako setup # 初回のみ。claude / codex / agy を検出して設定を整える
tako master # 司令塔の AI(マスター)を今いるペインで起動する
あとは日本語で頼むだけです。マスターが作業役の AI(worker)を隣のペインに立ち上げ、指示を渡し、完了を見届けて報告します。
「~/Documents/webapp にあるリポジトリを管理対象に追加して」
「webapp の README の誤字を直しておいて」
オーケストレーションを使わず 1 対 1 で相談したいときは tako solo。専用タブで動かしたいときは tako master --tab です。 初回起動時はタブバー下のバナー(および Cmd+K のコマンドパレット)から同じ操作ができます。 詳しい流れはクイックスタート、設定項目はセットアップガイドにあります。
tako setup detects your installed and authenticated agent CLIs (claude / codex / agy) and fills in the rest with previous or safe default values — with a single authenticated CLI it asks nothing. tako master then starts the orchestrator in the current pane, and you talk to it in plain language; it spawns workers next to itself and reports back. Use tako solo for one-on-one work without orchestration, and tako master --tab for a dedicated tab.
tako 内の Claude Code からペイン操作(分割・送信・読み取り等)を使うには、初回 1 回だけ MCP サーバーの接続設定が必要です(以後はどのプロジェクトでも設定ゼロ)。
tako setup-mcp
Claude Code のユーザー設定に tako MCP サーバーを自動登録します(内部で claude mcp add --scope user を呼び出します)。 プロジェクト単位にしたい場合は tako setup-mcp --project(カレントディレクトリの .mcp.json に追加)。 tako アプリが起動中なら、Claude Code に「tako の MCP を設定して」と頼んでも設定できます(MCP ツール tako_setup_mcp)。 旧バージョンが ~/.claude/settings.json に書いた無効な設定は自動で掃除されます。
This registers the tako MCP server in Claude Code's user config (internally claude mcp add --scope user); --project writes to the current directory's .mcp.json instead. If tako is running you can also ask Claude Code to "set up tako MCP" (the tako_setup_mcp tool). Outside tako the bridge exposes 0 tools and stays out of the way.
claude CLI から登録する場合:
claude mcp add --scope user --transport stdio tako -- /Applications/tako.app/Contents/MacOS/tako mcp serve
command のパスは tako CLI のインストール場所に合わせてください(which tako で確認できます)。
設定ファイルを直接書く場合は ~/.claude.json の mcpServers に以下を追加します(既存のキーを壊さないよう注意)。プロジェクト単位ならプロジェクトルートの .mcp.json に同じ構造を書きます。
{
"mcpServers": {
"tako": {
"type": "stdio",
"command": "/Applications/tako.app/Contents/MacOS/tako",
"args": ["mcp", "serve"],
"env": {}
}
}
}
tako remote start は、外出先のスマホのブラウザから tako のペインを見て操作するための API サーバーを起動します。既定で無効で、明示的に起動したときだけ動きます。セットアップは tako remote setup の対話ウィザードが案内します。
通信は Tailscale の serve が HTTPS → ループバック TCP(127.0.0.1 のエフェメラルポート)をプロキシする構成です。daemon の待ち受けはループバックだけにバインドされるので、LAN や外部ネットワークからは到達できません。URL は tailnet 内にのみ存在し、WireGuard でエンドツーエンド暗号化されます。認証は二層で、層①が tailscale whois による tailnet ノードの検証、層②が機器ペアリング(初回接続時に Mac 画面の承認ダイアログを通すまで画面データを受け取れない)です。仕組みの詳細はリモートアクセスのドキュメントにあります。
使う前に必ず読んでください / Read before use:
tako remote start launches an API server that lets you drive tako's panes from a phone browser. It is disabled by default. Tailscale serve proxies HTTPS to a loopback TCP listener (127.0.0.1, ephemeral port), so the daemon is not reachable from your LAN or the internet; see the remote access docs for how it works. Treat it as a legitimate remote-control tool: once connected, the remote browser can send arbitrary keystrokes and commands to your terminal — effectively full shell access. Use it only to control your own machine, never share the connection URL, and do not enable it if you cannot trust every device on your tailnet.
開発中は cargo run -p tako-app がそのまま使えます(バンドル不要)。 For development, plain cargo run -p tako-app works without bundling.
tako.app を生成して /Applications へ配置する場合:
# dist/tako.app を生成(--verify でバンドル版のセルフテストも実行)
scripts/build-app.sh --verify
# /Applications へ配置(配置後、ビルド出力の dist/tako.app は片付けられる)
scripts/build-app.sh --install
同じ .app が 2 つディスク上にあると macOS の Launch Services が両方を登録し、Finder の「このアプリケーションで開く」に tako が 2 つ並びます。--install は配置後にビルド出力を消して登録も外すので、候補は /Applications の 1 つだけになります。
アイコンの再描画には rsvg-convert(brew install librsvg)を使います。無い場合は同梱の PNG から自動でフォールバックします。
scripts/build-app.sh --verify creates dist/tako.app and runs the bundled self-test; --install copies it into /Applications, then removes the build copy and unregisters it from Launch Services so Finder's "Open With" lists tako only once. Icon rendering uses rsvg-convert (brew install librsvg) with a PNG fallback.
cargo build --workspace
cargo test --workspace
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings
AI エージェント向けの規約は AGENTS.md、詳細仕様は .agent/ にあります。 Conventions for AI agents live in AGENTS.md; detailed specs are in .agent/.
Homebrew の Swift toolchain(copy-xattrs.swift)が CommandLineTools/SDK のバージョン不整合でビルド失敗し、brew upgrade --cask tako が中断されると、cask 台帳から tako が消えているのに /Applications/tako.app の実体は残る「詰み状態」が発生することがあります。この状態では brew install --cask tako も「It seems there is already an App at '/Applications/tako.app'」で失敗します。
復旧方法(いずれか):
# 方法 1: tako CLI で修復(推奨。tako が起動している場合)
tako update repair
# 方法 2: brew で台帳を再締結
brew install --cask takushio2525/tako/tako --force
# 方法 3: brew を諦めて zip で手動更新
tako update apply-zip
tako update status で現在の配布系統を確認できます。install_method が broken-brew と表示される場合、上記の復旧が必要です。
根本原因の解消: Homebrew の Swift toolchain エラーが根本原因の場合、以下で Xcode CommandLineTools を再インストールすると brew 側の問題も解消します。
sudo rm -rf /Library/Developer/CommandLineTools
xcode-select --install
macOS 26 (Tahoe) 以降では、tako 内で動く AI エージェント(Claude Code 等)のサンドボックス化されたコマンドが iCloud Drive・Google Drive・他アプリのデータ領域に触れるたびに、macOS が対象ごとに個別の許可ダイアログを tako.app 名義で表示します(tako 自身がこれらの領域を読むわけではありません)。対象の数だけダイアログが出るため、頻発する場合は以下で恒久解消できます。
システム設定 → プライバシーとセキュリティ → フルディスクアクセス → tako を ON
フルディスクアクセスは個別許可の上位互換のため、以後このダイアログは表示されません。
v0.2.6 以降は署名の designated requirement が identifier 固定になり、付与した許可(フルディスクアクセス・個別許可とも)が再ビルド・アプリ内更新をまたいで保持されます。v0.2.5 以前からの更新直後は署名要件の移行のため 1 回だけ再許可が必要です。
タブやターミナルペインが突然大量に消えても、実体のプロセスはバックエンド tmux セッションの中で生き続けていることがほとんどです(AI エージェントは会話の文脈ごと生存しています)。以下の順で復旧してください。
tako recover # バックアップ世代の一覧(タブ数 / ペイン数 / 更新時刻)
# tako を終了(Cmd-Q)してから:
tako recover --apply 1 # 直前の世代を復元
# tako を再起動 → 実行中プロセスごと画面に戻ります
tako tmux list # バックエンドセッションと cwd の一覧
tako tab new # 受け皿のタブを作る(出力の pane ID を控える)
tako tmux open --socket tako --pane <ペインID> <セッション名>
脆弱性は公開の Issue に書かず、SECURITY.md の非公開の窓口から知らせてください。 / Please report vulnerabilities privately as described in SECURITY.md, not in public issues.
GPL-3.0-or-later — 依存クレート(zlog / ztracing、Zed リポ由来)が GPL-3.0 のため。
同梱している第三者成果物(zsh-autosuggestions・ファイルアイコン・スマホ用画面の JavaScript とフォントほか)の告知は THIRD-PARTY-NOTICES.md、配布バイナリに静的リンクしている Rust クレートのライセンス本文と著作権表示は THIRD-PARTY-LICENSES.md にあります。 Notices for bundled third-party works are in THIRD-PARTY-NOTICES.md; the license texts and copyright notices of the Rust crates linked into the binaries are in THIRD-PARTY-LICENSES.md.
配布しているバイナリに対応するソースコードは、GitHub Releases の各リリースのタグにあります。tako は無保証です(GPL-3.0 第 15 条・第 16 条。詳細は LICENSE)。 The source code for each released binary is the tag of that release on GitHub Releases. tako comes with ABSOLUTELY NO WARRANTY (sections 15 and 16 of the GPL-3.0; see LICENSE).
hooks/register.ts 714 lines1// tako mod(#1877 / S1 #1879 / S3 #1881): このペインの Claude Code の状態を集めて tako へ報告し、
2// tako の応答(帯・サイドバーの材料)で Claude Code の画面に tako の状況を出す。
3// 設計の正本は .agent/plans/2026-10-tako-mod.md §5(規約)と §7 S3(帯・サイドバー)。
4//
5// 画面に出すもの(S3):
6// - 帯(プロンプトの上の 1 行): このペインの名前・タブ・worker 数と要注意の数。ctx / 使用制限は
7// tako が閾値を超えたと判断したときだけ(statusLine を持つ利用者の画面で二重にならない既定)。
8// 幅(bodyColumns)に収まらなければ優先度の低い区切りから落とし、それでも溢れたら末尾を切る
9// = **必ず 1 行**。権限ダイアログ・質問の表示中は Claude Code が帯ごと隠すので「承認待ち」は出さない
10// - `/tako`: サイドバーのペイン($.ui.open)に詳細。頼まれずには開かない(開くのはコマンドだけ)
11// - 帯を隠すトグル: $.store の `band`({ hidden, at })が正本。`/tako band on|off`・ペインのボタン・
12// tako からの中継(`tako mod band on|off` = 応答の view.band_request)のうち、時刻の新しいものが勝つ
13// 何を出すかの判断は tako 側(tako_core::claude_mod::band_view)。ここは幅に合わせて詰めて描くだけ
14//
15// 守っていること(§5 の規約。番犬 crates/tako-control/tests/issue1879_claude_mod_watchdog.rs が走査する):
16// - 観測だけで判断を奪わない: すべての on(...) は next(e) へ流し、.catch(($, e, next) => next(e)) を付ける
17// (mod が壊れても tool call と権限ダイアログを止めない。権限を勝手に許可しない)
18// - フックの中で CLI を待たない: フックは状態を書き換えて dirty を立てるだけ。CLI は 1 秒の flush が叩く
19// (例外は session.end の最後の 1 回だけ。待たないとプロセスが先に終わって届かない)
20// - tako の外では休眠: TAKO_PANE_ID か TAKO_CLI が無ければ何も呼ばない
21// (CLI は接続できないと control.json へ落ちるので、tako の外から叩くと別のインスタンスへ繋がりうる)
22// - 失敗で騒がない: CLI の失敗は $.ui.log(…, { to: 'debug' }) だけ
23// - $ を渡す補助関数はファイル最上位の関数宣言(validate の規則)
24// - 会話の本文・プロンプト・ツールの引数は報告に載せない(ツール名だけ)
25import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
26
27import type { TakoLimitsSeen, TakoModReport, TakoRateLimit, TakoTurn, TakoView, TakoWorker } from '../types'
28
29// 展開時(tako_core::claude_mod::install)に tako の版へ置き換わる
30const MOD_VERSION = '__TAKO_MOD_VERSION__'
31/** 変化があるときの報告の最短間隔 */
32const FLUSH_MS = 1_000
33/** 変化が無くても送る間隔(tako は 45 秒で失効させる = 2 回の取りこぼしまで許す) */
34const HEARTBEAT_MS = 15_000
35/** CLI 1 回の上限。tako が落ちていれば 10 ms 未満で exit 1 になる */
36const REPORT_TIMEOUT_MS = 5_000
37/** session.end の最後の報告だけは待つので、上限は短く */
38const FINAL_TIMEOUT_MS = 2_000
39/** tako の応答(帯の材料)がこれより古くなったら帯を消す(tako の鮮度 FRESH_FOR と同じ 45 秒) */
40const VIEW_FRESH_MS = 45_000
41/** 帯の区切りの区切り文字(幅 1 の ASCII だけ。曖昧幅の文字は端末の設定で 2 桁になりうる) */
42const SEP = ' | '
43/** 帯のペイン名・タブ名を切り詰める上限(桁) */
44const NAME_MAX = 24
45/** サイドバーのペインの id($.ui.open)。描画の requestId もこれ */
46const PANE_ID = 'tako'
47/** $.store のキー(帯のトグル) */
48const BAND_KEY = 'band'
49
50// $.state(セッションの値。描画が購読するので、書けば読み手が描き直される)
51const VIEW = { plugin: 'tako', key: 'view' } as const
52const HIDDEN = { plugin: 'tako', key: 'bandHidden' } as const
53// 使用制限の窓ごとの観測時刻(#1903)。$.state に置くのはホットリロード(tako の更新で mod が
54// 書き換わる)をまたいで残すため。モジュール変数だと読み込み直しのたびに全窓を「今」の観測にしてしまう
55const LIMITS_SEEN = { plugin: 'tako', key: 'limitsSeen' } as const
56
57// モジュール変数。ホットリロードで消えるが、そのとき session.start が再発火して張り直す
58let cli: string | undefined
59let timer: Timer | undefined
60let dirty = false
61let sending = false
62let lastSentAt = 0
63let turn: TakoTurn = 'idle'
64let pendingTool: string | undefined
65let effort: string | undefined
66let lastTurn: { duration_ms: number; reason: string } | undefined
67// 帯(S3): 最後に受け取った tako の材料の時刻・JSON(同じなら $.state を書かない = 無駄に描き直さない)
68let viewAt = 0
69let lastViewJson: string | undefined
70let lang = 'en'
71// `/tako` の説明文を登録した言語(tako の表示言語が応答で分かったら登録し直す)
72let commandLang: string | undefined
73// 帯のトグル($.store の写し)と、それを変えた時刻(中継・他のセッションとの突き合わせ)
74let bandHidden = false
75let bandToggledAt: number | undefined
76// 直近の描画(報告で tako へ返す。何を描いたかは区切りの種類だけ)
77let bandShown = false
78let bandColumns: number | undefined
79let bandSegments: string[] = []
80let lastBandKey: string | undefined
81// classic 系のイベント(classic.PreToolUse 等)がこのセッションの mod へ届いているか。
82// 組織アカウントでは 1 つも届かないことがある(2.1.294 で実測。FR-2.42.7)。
83// 届かないときの権限待ちは tool.check の判定から確かなものだけを拾う
84let classicEvents = false
85
86// effort の値を読む。turn.step は文字列(2.1.294 の実測: "medium")、classic 系は { level } で運ぶ
87function effortLevel(value: unknown): string | undefined {
88 if (typeof value === 'string') return value
89 if (typeof value === 'object' && value !== null && 'level' in value) {
90 const level = (value as { level: unknown }).level
91 if (typeof level === 'string') return level
92 }
93 return undefined
94}
95
96// 使用制限に観測時刻を付ける(#1903)。窓ごとに % か resetsAt が変わったときだけ今の時刻を打ち、
97// 変わらなければ前に打った時刻のまま送る。heartbeat で打ち直すと、1 時間放置したペインの古い % も
98// 「今」の観測に見え、tako が同じアカウントの値を最新の観測で束ねられない(設計書 §6)。
99// $.state を読めない・書けないときは今の時刻を打つ(報告そのものは止めない = §5 の「失敗で騒がない」)
100async function stampLimits($: EngineInterface, limits: readonly SessionRateLimit[], at: number): Promise<TakoRateLimit[]> {
101 let seen: TakoLimitsSeen = {}
102 try {
103 seen = (await $.state.get(LIMITS_SEEN)).value ?? {}
104 } catch (err) {
105 $.ui.log(`tako mod: limitsSeen unreadable: ${String(err).slice(0, 200)}`, { to: 'debug' })
106 }
107 const next: TakoLimitsSeen = {}
108 let changed = Object.keys(seen).length !== limits.length
109 const out = limits.map(limit => {
110 const prev = seen[limit.kind]
111 const same = prev !== undefined && prev.percent_used === limit.percentUsed && prev.resets_at === limit.resetsAt
112 if (!same) changed = true
113 const observed = same ? prev.observed_at : at
114 next[limit.kind] =
115 limit.resetsAt === undefined
116 ? { percent_used: limit.percentUsed, observed_at: observed }
117 : { percent_used: limit.percentUsed, resets_at: limit.resetsAt, observed_at: observed }
118 return { kind: limit.kind, percent_used: limit.percentUsed, resets_at: limit.resetsAt, observed_at: observed }
119 })
120 if (changed) {
121 try {
122 await $.state.set(LIMITS_SEEN, next)
123 } catch (err) {
124 $.ui.log(`tako mod: limitsSeen unwritable: ${String(err).slice(0, 200)}`, { to: 'debug' })
125 }
126 }
127 return out
128}
129
130// 報告を組む。欠けた値(最初の API 応答の前の ctx% など)は undefined のまま = JSON から落ちる
131async function buildReport($: EngineInterface, ended: boolean): Promise<TakoModReport> {
132 const at = await $.clock.now()
133 const usage = await $.session.usage()
134 const version = await $.session.version()
135 return {
136 schema: 1,
137 mod_version: MOD_VERSION,
138 claude_version: version.version,
139 session_id: await $.session.id(),
140 at,
141 model: await $.session.model(),
142 effort,
143 context: { tokens: usage.context.tokens, window: usage.context.window, percent: usage.context.percent },
144 rate_limits: await stampLimits($, usage.rateLimits, at),
145 cost_usd: usage.cost?.usd,
146 turn,
147 pending_tool: pendingTool,
148 last_turn: lastTurn,
149 classic_events: classicEvents,
150 config_dir: await $.env.get('CLAUDE_CONFIG_DIR'),
151 band: { hidden: bandHidden, shown: bandShown, columns: bandColumns, segments: bandSegments, toggled_at: bandToggledAt },
152 ended,
153 }
154}
155
156// 1 回送る。応答(tako 側のスナップショット = 帯・サイドバーの材料)を $.state へ写す
157async function send($: EngineInterface, ended: boolean, timeoutMs: number): Promise<void> {
158 const path = cli
159 if (path === undefined) return
160 dirty = false
161 try {
162 await syncStore($)
163 const report = await buildReport($, ended)
164 lastSentAt = report.at
165 const done = await $.process.run([path, 'mod', 'report'], { stdin: JSON.stringify(report), timeoutMs })
166 if (done.exitCode !== 0) {
167 $.ui.log(`tako mod: report exit ${done.exitCode}: ${done.stderr.trim().slice(0, 200)}`, { to: 'debug' })
168 } else if (!ended) {
169 await absorb($, done.stdout)
170 }
171 } catch (err) {
172 $.ui.log(`tako mod: report failed: ${String(err).slice(0, 200)}`, { to: 'debug' })
173 }
174}
175
176// 1 秒ごとの flush。変化があれば送り、無くても 15 秒に 1 回は送る(heartbeat)
177async function tick($: EngineInterface): Promise<void> {
178 if (cli === undefined || sending) return
179 const now = await $.clock.now()
180 // tako が応答しなくなったら(落ちた・別の tako へ繋がらない)古い材料で描き続けない
181 if (lastViewJson !== undefined && lastViewJson !== 'null' && now - viewAt > VIEW_FRESH_MS) {
182 await setView($, null)
183 }
184 if (!dirty && now - lastSentAt < HEARTBEAT_MS) return
185 sending = true
186 try {
187 await send($, false, REPORT_TIMEOUT_MS)
188 } finally {
189 sending = false
190 }
191}
192
193// session.start(ホットリロードでも来る)で入口を決める。tako の外なら休眠のまま
194async function wake($: EngineInterface): Promise<void> {
195 timer?.cancel()
196 timer = undefined
197 const pane = await $.env.get('TAKO_PANE_ID')
198 const path = await $.env.get('TAKO_CLI')
199 if (pane === undefined || pane === '' || path === undefined || path === '') {
200 cli = undefined
201 return
202 }
203 cli = path
204 dirty = true
205 // ホットリロードでは $.state の材料が残っている。次の応答まで(最大 45 秒)はそれで描く
206 viewAt = await $.clock.now()
207 timer = $.clock.every(FLUSH_MS, () => {
208 void tick($)
209 })
210 // 帯の準備は報告の後ろに置き、失敗しても報告(S1 / S2)を止めない
211 try {
212 await syncStore($)
213 await registerCommand($)
214 } catch (err) {
215 $.ui.log(`tako mod: band setup failed: ${String(err).slice(0, 200)}`, { to: 'debug' })
216 }
217}
218
219// `/tako` を登録する(同じ名前の登録は置き換わる = 言語が変わったら登録し直す)。
220// 説明文の頭に tako を付けない(Claude Code が一覧にプラグイン名を添える)
221async function registerCommand($: EngineInterface): Promise<void> {
222 commandLang = lang
223 await $.command.register({
224 name: 'tako',
225 description: words(lang).command,
226 argumentHint: '[band on|off]',
227 immediate: true,
228 })
229}
230
231// 最後の報告(ended: true)。tako はそのペインの報告を即座に捨てる
232async function finish($: EngineInterface): Promise<void> {
233 timer?.cancel()
234 timer = undefined
235 await send($, true, FINAL_TIMEOUT_MS)
236 cli = undefined
237}
238
239// --- 帯とサイドバー(S3 #1881)------------------------------------------------------
240
241type Words = {
242 tab: string
243 workers: (n: number) => string
244 attention: (n: number) => string
245 noWorkers: string
246 more: (n: number) => string
247 ctxPending: string
248 resetsIn: (d: string) => string
249 state: (w: TakoWorker) => string
250 thresholds: (ctx: number, limit: number) => string
251 hide: string
252 show: string
253 noView: string
254 hidden: string
255 shown: string
256 usage: string
257 notPlaced: (reason: string) => string
258 command: string
259}
260
261const WORDS: Record<'ja' | 'en', Words> = {
262 ja: {
263 tab: 'タブ',
264 workers: n => `worker ${n}`,
265 attention: n => `要注意 ${n}`,
266 noWorkers: 'worker なし',
267 more: n => `ほか ${n} 本`,
268 ctxPending: '最初の応答の前',
269 resetsIn: d => `リセットまで ${d}`,
270 state: w => {
271 if (w.attention === 'permission') return '承認待ち'
272 if (w.attention === 'question') return '質問待ち'
273 if (w.attention === 'dialog') return '選択待ち'
274 if (w.state === 'limited') return '使用制限'
275 if (w.state === 'failed') return '異常終了'
276 if (w.state === 'busy') return '作業中'
277 if (w.state === 'idle') return '待機'
278 return '不明'
279 },
280 thresholds: (c, l) => `帯に ctx / 使用制限を出すのは ${c}% / ${l}% 以上`,
281 hide: '帯を隠す',
282 show: '帯を出す',
283 noView: 'tako から応答が無い(tako のペインの外か、tako が止まっている)',
284 hidden: '帯を隠した(/tako band on で戻す)',
285 shown: '帯を出した',
286 usage: '使い方: /tako(サイドバー)・/tako band on|off(帯)',
287 notPlaced: r => `サイドバーを置けない: ${r}`,
288 command: 'このペインの tako の状況をサイドバーに出す(band on|off で帯を出す / 隠す)',
289 },
290 en: {
291 tab: 'tab',
292 workers: n => `workers ${n}`,
293 attention: n => `attention ${n}`,
294 noWorkers: 'no workers',
295 more: n => `${n} more`,
296 ctxPending: 'before the first reply',
297 resetsIn: d => `resets in ${d}`,
298 state: w => {
299 if (w.attention === 'permission') return 'needs approval'
300 if (w.attention === 'question') return 'has a question'
301 if (w.attention === 'dialog') return 'waiting on a choice'
302 if (w.state === 'limited') return 'rate-limited'
303 if (w.state === 'failed') return 'exited with an error'
304 if (w.state === 'busy') return 'working'
305 if (w.state === 'idle') return 'idle'
306 return 'unknown'
307 },
308 thresholds: (c, l) => `the band shows ctx / limits from ${c}% / ${l}%`,
309 hide: 'Hide band',
310 show: 'Show band',
311 noView: 'No reply from tako (outside a tako pane, or tako has stopped)',
312 hidden: 'band hidden (/tako band on to show it)',
313 shown: 'band shown',
314 usage: 'usage: /tako (sidebar), /tako band on|off (band)',
315 notPlaced: r => `the sidebar cannot be placed: ${r}`,
316 command: "Show this pane's tako status in a sidebar (band on|off shows / hides the band)",
317 },
318}
319
320function words(code: string): Words {
321 return code === 'ja' ? WORDS.ja : WORDS.en
322}
323
324// 端末の桁数(全角・CJK は 2 桁。Claude Code の配置と同じ数え方の近似)
325function isWide(c: number): boolean {
326 return (
327 (c >= 0x1100 && c <= 0x115f) ||
328 (c >= 0x2e80 && c <= 0xa4cf) ||
329 (c >= 0xac00 && c <= 0xd7a3) ||
330 (c >= 0xf900 && c <= 0xfaff) ||
331 (c >= 0xfe30 && c <= 0xfe4f) ||
332 (c >= 0xff00 && c <= 0xff60) ||
333 (c >= 0xffe0 && c <= 0xffe6) ||
334 (c >= 0x20000 && c <= 0x3fffd)
335 )
336}
337
338function cellWidth(text: string): number {
339 let width = 0
340 for (const ch of text) width += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
341 return width
342}
343
344// max 桁に収める(溢れたら末尾を … にする)
345function clipCells(text: string, max: number): string {
346 if (cellWidth(text) <= max) return text
347 let out = ''
348 let width = 0
349 for (const ch of text) {
350 const w = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
351 if (width + w > max - 1) break
352 out += ch
353 width += w
354 }
355 return `${out}…`
356}
357
358function limitName(kind: string): string {
359 if (kind === 'five_hour') return '5h'
360 if (kind === 'seven_day') return '7d'
361 if (kind.startsWith('seven_day_')) return `7d ${kind.slice('seven_day_'.length)}`
362 return kind.replace(/_/g, ' ')
363}
364
365/** 帯の区切り 1 つ。rank が小さいほど最後まで残す(0 / 1 = tako とペイン名は落とさない) */
366type BandSegment = { kind: string; label: string; tone?: 'warning'; rank: number }
367
368// 帯の区切りを左から並べる(何を出すかは tako の view が決めている)
369function segmentsOf(view: TakoView): BandSegment[] {
370 const w = words(view.lang)
371 const name = view.pane_title ?? `pane ${view.pane}`
372 const out: BandSegment[] = [
373 { kind: 'tako', label: 'tako', rank: 0 },
374 { kind: 'pane', label: clipCells(name, NAME_MAX), rank: 1 },
375 ]
376 if (view.tab_title !== undefined && view.tab_title !== '' && view.tab_title !== name) {
377 out.push({ kind: 'tab', label: `${w.tab} ${clipCells(view.tab_title, NAME_MAX)}`, rank: 6 })
378 }
379 if (view.worker_count > 0) out.push({ kind: 'workers', label: w.workers(view.worker_count), rank: 5 })
380 if (view.attention > 0) out.push({ kind: 'attention', label: w.attention(view.attention), tone: 'warning', rank: 2 })
381 view.warnings.forEach((warn, i) => {
382 const pct = `${Math.round(warn.percent)}%`
383 if (warn.kind === 'ctx') {
384 out.push({ kind: 'ctx', label: `ctx ${pct}`, tone: 'warning', rank: 3 })
385 } else {
386 // 使用制限は % の大きい順に来る。小さい方から落とす
387 out.push({ kind: warn.kind, label: `${limitName(warn.kind)} ${pct}`, tone: 'warning', rank: 4 + i / 100 })
388 }
389 })
390 return out
391}
392
393function lineWidth(segments: readonly BandSegment[]): number {
394 return segments.reduce((sum, seg, i) => sum + cellWidth(seg.label) + (i === 0 ? 0 : SEP.length), 0)
395}
396
397// columns 桁に収まるまで、優先度の低い区切りから落とす(並びは変えない)。
398// tako とペイン名だけでも溢れる幅では、描画側の wrap="truncate-end" が末尾を切る = 必ず 1 行
399function fitBand(view: TakoView, columns: number): BandSegment[] {
400 let kept = segmentsOf(view)
401 const order = [...kept].sort((a, b) => b.rank - a.rank)
402 for (const drop of order) {
403 if (lineWidth(kept) <= columns || drop.rank <= 1) break
404 kept = kept.filter(seg => seg !== drop)
405 }
406 return kept
407}
408
409// 直近の描画を控え、変わったら次の報告で tako へ返す
410function noteBand(shown: boolean, columns: number | undefined, segments: string[]): void {
411 const key = JSON.stringify([shown, columns, segments])
412 if (key === lastBandKey) return
413 lastBandKey = key
414 bandShown = shown
415 bandColumns = columns
416 bandSegments = segments
417 dirty = true
418}
419
420// 残り時間(リセットまで)。端末の時刻帯に依らない書き方にする
421function untilText(ms: number): string {
422 const minutes = Math.max(0, Math.round(ms / 60_000))
423 const days = Math.floor(minutes / 1440)
424 const hours = Math.floor((minutes % 1440) / 60)
425 const mins = minutes % 60
426 if (days > 0) return `${days}d${hours}h`
427 if (hours > 0) return `${hours}h${mins}m`
428 return `${mins}m`
429}
430
431type SavedBand = { hidden: boolean; at: number }
432
433function savedBand(value: unknown): SavedBand | undefined {
434 if (typeof value !== 'object' || value === null) return undefined
435 const v = value as { hidden?: unknown; at?: unknown }
436 if (typeof v.hidden !== 'boolean' || typeof v.at !== 'number') return undefined
437 return { hidden: v.hidden, at: v.at }
438}
439
440// tako の材料を $.state へ(同じなら書かない)
441async function setView($: EngineInterface, view: TakoView | null): Promise<void> {
442 const json = JSON.stringify(view)
443 if (json === lastViewJson) return
444 lastViewJson = json
445 await $.state.set(VIEW, view)
446}
447
448// トグルを変える($.store が正本。$.state は描画の購読のための写し)
449async function setHidden($: EngineInterface, hidden: boolean, at: number): Promise<void> {
450 bandToggledAt = at
451 if (hidden !== bandHidden) {
452 bandHidden = hidden
453 dirty = true
454 }
455 await $.store.set(BAND_KEY, { hidden, at })
456 await $.state.set(HIDDEN, hidden)
457}
458
459// $.store を読み直す(同じ設定 dir の別のセッションが切り替えたぶんを拾う)。
460// 読めなくても報告は続ける(帯のトグルが前の値のままになるだけ)
461async function syncStore($: EngineInterface): Promise<void> {
462 let stored: unknown
463 try {
464 stored = await $.store.get(BAND_KEY)
465 } catch {
466 return
467 }
468 const saved = savedBand(stored)
469 if (saved === undefined || (bandToggledAt !== undefined && saved.at <= bandToggledAt)) return
470 bandToggledAt = saved.at
471 if (saved.hidden !== bandHidden) {
472 bandHidden = saved.hidden
473 dirty = true
474 }
475 await $.state.set(HIDDEN, saved.hidden)
476}
477
478// サイドバーのボタン。押した時点のトグルを反転する(描いた時点の値は使わない)
479async function toggleBand($: EngineInterface): Promise<void> {
480 await setHidden($, !bandHidden, await $.clock.now())
481}
482
483// `tako mod report` の応答を取り込む。view が無い(S3 の A/B・古い tako)なら何も描かない
484async function absorb($: EngineInterface, stdout: string): Promise<void> {
485 let view: TakoView | null = null
486 try {
487 const reply = JSON.parse(stdout) as { tako?: { view?: TakoView | null } }
488 view = reply.tako?.view ?? null
489 } catch {
490 view = null
491 }
492 if (view !== null) {
493 viewAt = await $.clock.now()
494 lang = view.lang
495 if (commandLang !== lang) {
496 try {
497 await registerCommand($)
498 } catch {
499 // 説明文が前の言語のままになるだけ
500 }
501 }
502 const request = view.band_request
503 if (request !== undefined && request.at > (bandToggledAt ?? 0)) {
504 await setHidden($, request.hidden, request.at)
505 }
506 }
507 await setView($, view)
508}
509
510export const register: Register = on => {
511 on('session.start', async ($, e, next) => {
512 const started = await next(e)
513 await wake($)
514 return started
515 }).catch(($, e, next) => next(e))
516
517 on('turn.start', ($, e, next) => {
518 turn = 'busy'
519 pendingTool = undefined
520 dirty = true
521 return next(e)
522 }).catch(($, e, next) => next(e))
523
524 // effort は各ステップの turn.step が運ぶ(関数フックなので classic 系が届かない組織アカウントでも
525 // 来る = FR-2.42.7 で欠けていた effort を埋める。#1880)。サブエージェントのステップは数えない。
526 // turn.step はモデルの出力を流すストリームのフック: 値を控えたら `yield*` で**そのまま素通し**し、
527 // チャンクには触らない(壊れても .catch が素通しへ戻す)
528 on('turn.step', async function* ($, e, next) {
529 const step = e as unknown as { effort?: unknown; agentId?: unknown }
530 const level = effortLevel(step.effort)
531 if (step.agentId === undefined && level !== undefined && level !== effort) {
532 effort = level
533 dirty = true
534 }
535 return yield* next(e)
536 }).catch(async function* ($, e, next) {
537 return yield* next(e)
538 })
539
540 // classic 系が届くかの印(すべてのツール呼び出しで tool.check より先に来る)
541 on('classic.PreToolUse', ($, e, next) => {
542 classicEvents = true
543 return next(e)
544 }).catch(($, e, next) => next(e))
545
546 // ダイアログの表示と同時に来る(設計書 §1.3)。AskUserQuestion もここを通るのでツール名で分ける
547 on('classic.PermissionRequest', ($, e, next) => {
548 classicEvents = true
549 turn = e.tool_name === 'AskUserQuestion' ? 'question' : 'permission'
550 pendingTool = e.tool_name
551 if (e.effort !== undefined) effort = e.effort.level
552 dirty = true
553 return next(e)
554 }).catch(($, e, next) => next(e))
555
556 // classic 系が届かない環境の代わりの手がかり。判定が ask でも auto モードは分類器が黙って
557 // 通しうるので、**ダイアログが確かに出るものだけ**を拾う: ask ルールに当たった呼び出し
558 // (auto モードより優先される)と、人に計画の承認を求める ExitPlanMode
559 on('tool.check', async ($, e, next) => {
560 const verdict = await next(e)
561 if (!classicEvents && verdict.decision === 'ask' && (verdict.rule !== undefined || e.tool === 'ExitPlanMode')) {
562 turn = 'permission'
563 pendingTool = e.tool
564 dirty = true
565 }
566 return verdict
567 }).catch(($, e, next) => next(e))
568
569 // 承認・拒否・回答の瞬間を知らせるイベントは無いので、そのツールの呼び出しが返ったところで戻す。
570 // AskUserQuestion は呼び出しの開始と同時に質問が出る(classic 系が届かなくても分かる)
571 on('tool.call', async ($, e, next) => {
572 if (e.tool === 'AskUserQuestion') {
573 turn = 'question'
574 pendingTool = e.tool
575 dirty = true
576 }
577 const result = await next(e)
578 if ((turn === 'permission' || turn === 'question') && e.tool === pendingTool) {
579 turn = 'busy'
580 pendingTool = undefined
581 dirty = true
582 }
583 return result
584 }).catch(($, e, next) => next(e))
585
586 // サブエージェントのターン(agentId あり)は数えない(#1021 の isSidechain 除外と同じ理由)
587 on('turn.complete', async ($, e, next) => {
588 const done = await next(e)
589 if (e.agentId === undefined) {
590 turn = 'idle'
591 pendingTool = undefined
592 lastTurn = { duration_ms: e.durationMs, reason: e.reason }
593 dirty = true
594 }
595 return done
596 }).catch(($, e, next) => next(e))
597
598 // effort はターンの終わりの Stop が運ぶ(effort を持たないモデルでは来ない)
599 on('classic.Stop', ($, e, next) => {
600 if (e.effort !== undefined && e.effort.level !== effort) {
601 effort = e.effort.level
602 dirty = true
603 }
604 return next(e)
605 }).catch(($, e, next) => next(e))
606
607 // /clear と /resume は同じプロセスで別のセッションが続く(session.start は来ない)。
608 // 次の報告が新しい session_id を運ぶので、状態だけ戻して報告は続ける
609 on('session.end', async ($, e, next) => {
610 if (e.reason === 'clear' || e.reason === 'resume') {
611 turn = 'idle'
612 pendingTool = undefined
613 lastTurn = undefined
614 dirty = true
615 return next(e)
616 }
617 await finish($)
618 return next(e)
619 }).catch(($, e, next) => next(e))
620
621 // 帯(プロンプトの上の 1 行)。tako の外・材料が無い(古い)・隠している・調査票が使っている
622 // ときは何も描かない。権限ダイアログ・質問の表示中はそもそも Claude Code が帯を出さない
623 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
624 const view = (await $.state.get(VIEW)).value ?? null
625 const hidden = (await $.state.get(HIDDEN)).value ?? false
626 const columns = e.props.bodyColumns
627 if (cli === undefined || view === null || hidden || e.props.hasSurvey) {
628 noteBand(false, columns, [])
629 return next(e)
630 }
631 const segments = fitBand(view, columns)
632 noteBand(true, columns, segments.map(seg => seg.kind))
633 const { Text } = $.ui.resolve(e)
634 const parts: Array<ReturnType<typeof Text> | string> = []
635 segments.forEach((seg, i) => {
636 if (i > 0) parts.push(SEP)
637 parts.push(seg.tone === 'warning' ? Text({ color: 'warning', children: seg.label }) : seg.label)
638 })
639 return Text({ dimColor: true, wrap: 'truncate-end', children: parts })
640 }).catch(($, e, next) => next(e))
641
642 // `/tako` のサイドバー(詳細)
643 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
644 const view = (await $.state.get(VIEW)).value ?? null
645 const hidden = (await $.state.get(HIDDEN)).value ?? false
646 const w = words(view?.lang ?? lang)
647 const { Box, Button, Text } = $.ui.resolve(e)
648 const toggle = Button({
649 key: 'band-toggle',
650 label: hidden ? w.show : w.hide,
651 onPress: () => toggleBand($),
652 })
653 if (view === null) {
654 return Box({ flexDirection: 'column', children: [Text({ dimColor: true, children: w.noView }), toggle] })
655 }
656 const now = await $.clock.now()
657 const rows: Array<ReturnType<typeof Text>> = []
658 const name = view.pane_title ?? `pane ${view.pane}`
659 rows.push(Text({ bold: true, wrap: 'truncate-end', children: name }))
660 if (view.tab_title !== undefined) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: `${w.tab} ${view.tab_title}` }))
661 const ctx = view.ctx
662 rows.push(
663 Text({
664 children:
665 ctx?.percent === undefined
666 ? `ctx - (${w.ctxPending})`
667 : `ctx ${ctx.percent}% (${ctx.tokens ?? '-'} / ${ctx.window})`,
668 }),
669 )
670 for (const limit of view.rate_limits) {
671 const reset = limit.resets_at === undefined ? '' : ` ${w.resetsIn(untilText(limit.resets_at * 1000 - now))}`
672 const line = `${limitName(limit.kind)} ${Math.round(limit.percent)}%${reset}`
673 rows.push(limit.percent >= view.thresholds.limit_percent ? Text({ color: 'warning', children: line }) : Text({ children: line }))
674 }
675 rows.push(
676 Text({
677 bold: true,
678 children:
679 view.worker_count === 0 ? w.noWorkers : `${w.workers(view.worker_count)} ${w.attention(view.attention)}`,
680 }),
681 )
682 for (const worker of view.workers) {
683 const line = `${worker.attention === undefined ? ' ' : '! '}${worker.name} ${w.state(worker)}`
684 rows.push(
685 worker.attention === undefined
686 ? Text({ wrap: 'truncate-end', children: line })
687 : Text({ color: 'warning', wrap: 'truncate-end', children: line }),
688 )
689 }
690 if (view.worker_count > view.workers.length) {
691 rows.push(Text({ dimColor: true, children: w.more(view.worker_count - view.workers.length) }))
692 }
693 rows.push(Text({ dimColor: true, children: w.thresholds(view.thresholds.ctx_percent, view.thresholds.limit_percent) }))
694 return Box({ flexDirection: 'column', children: [...rows, toggle] })
695 }).catch(($, e, next) => next(e))
696
697 // `/tako`(サイドバーを開く)・`/tako band on|off`(帯のトグル)。tako の外では登録していない。
698 // 出力の行には Claude Code がプラグイン名(tako:)を前置するので、文言には付けない。
699 // 出力の行はモデルも読むので短くする(サイドバーを開いたときは何も出さない)
700 on('command.run', { command: 'tako' }, async ($, e, next) => {
701 if (cli === undefined) return next(e)
702 const w = words(lang)
703 const args = (typeof e.args === 'string' ? e.args : '').trim().split(/\s+/).filter(arg => arg !== '')
704 if (args[0] === 'band' && (args[1] === undefined || args[1] === 'on' || args[1] === 'off') && args.length <= 2) {
705 const want = args[1] === 'on' ? false : args[1] === 'off' ? true : !bandHidden
706 await setHidden($, want, await $.clock.now())
707 return { text: want ? w.hidden : w.shown }
708 }
709 if (args.length > 0) return { text: w.usage }
710 const opened = await $.ui.open({ id: PANE_ID, title: 'tako' })
711 return opened.isPlaced ? {} : { text: w.notPlaced(opened.reason) }
712 }).catch(($, e, next) => next(e))
713}
714types/index.d.ts 122 lines1// tako mod が tako へ送る報告の契約(#1879。設計書 .agent/plans/2026-10-tako-mod.md §4.2)と、
2// tako が応答で返す帯・サイドバーの材料(#1881。§7 S3)、mod の $.state の契約。
3// 受け手 / 送り手の正本は tako_core::claude_mod::{ModReport, ModBand, BandView}。
4// キーを増やすときは両方を同じコミットで直す。
5//
6// **会話の本文・プロンプト・ツールの引数は載せない**(AGENTS.md の絶対ルール)。
7// pending_tool はツール名だけ。番犬 crates/tako-control/tests/issue1879_claude_mod_watchdog.rs が
8// 本文系のキーが紛れ込んでいないかを走査する。
9
10/** ターンの状態。permission = 権限ダイアログ表示中、question = AskUserQuestion 表示中 */
11export type TakoTurn = 'idle' | 'busy' | 'permission' | 'question'
12
13export type TakoContext = {
14 /** 最初の API 応答までは欠ける(0 埋めしない = 未観測) */
15 tokens?: number
16 window: number
17 percent?: number
18}
19
20export type TakoRateLimit = {
21 /** five_hour / seven_day / ゲートウェイの spend_limit など */
22 kind: string
23 percent_used: number
24 resets_at?: string
25 /**
26 * この値を観測した時刻(epoch ms)。% か resets_at が変わったときだけ打ち直す(heartbeat では
27 * 前の時刻のまま = #1903)。tako はアカウント単位の値を束ねるときに新しい方を採る
28 */
29 observed_at: number
30}
31
32/** $.state の limitsSeen(窓の種類 → 最後に値が変わった観測)。ホットリロードをまたいで残す */
33export type TakoLimitsSeen = Record<string, { percent_used: number; resets_at?: string; observed_at: number }>
34
35export type TakoModReport = {
36 schema: 1
37 mod_version: string
38 claude_version?: string
39 session_id?: string
40 /** 報告を組んだ時刻(epoch ms) */
41 at: number
42 model?: string
43 effort?: string
44 context?: TakoContext
45 rate_limits: TakoRateLimit[]
46 cost_usd?: number
47 turn: TakoTurn
48 pending_tool?: string
49 last_turn?: { duration_ms: number; reason: string }
50 /**
51 * classic 系のイベント(PermissionRequest 等)がこのセッションの mod へ届いているか。
52 * false のとき permission は ask ルール由来と ExitPlanMode だけ(ルールの無い ask は拾わない)
53 */
54 classic_events: boolean
55 /** CLAUDE_CONFIG_DIR(使用制限はアカウント単位なので束ねる鍵にする。ログには出さない) */
56 config_dir?: string
57 /** 帯の状態(#1881。描いた文字列は載せない = 区切りの種類だけ) */
58 band?: TakoBand
59 /** session.end を受けた最後の報告。tako はそのペインの報告を即座に捨てる */
60 ended: boolean
61}
62
63/** 帯(プロンプトの上の 1 行)の状態(tako_core::claude_mod::ModBand) */
64export type TakoBand = {
65 /** 利用者が帯を隠している($.store の band) */
66 hidden: boolean
67 /** 直近の描画で帯を描いたか */
68 shown: boolean
69 /** 直近の描画の帯の本文の桁(AbovePrompt の bodyColumns) */
70 columns?: number
71 /** 描いた区切りの種類(左から。tako / pane / tab / workers / attention / ctx / five_hour …) */
72 segments: string[]
73 /** トグルを最後に変えた時刻(epoch ms) */
74 toggled_at?: number
75}
76
77/** worker の状態(tako_core::claude_mod::WorkerState) */
78export type TakoWorkerState = 'busy' | 'idle' | 'waiting' | 'limited' | 'failed' | 'unknown'
79/** 要注意の理由(tako_core::claude_mod::Attention) */
80export type TakoAttention = 'permission' | 'question' | 'dialog' | 'limited' | 'failed'
81
82export type TakoWorker = {
83 pane: number
84 name: string
85 state: TakoWorkerState
86 attention?: TakoAttention
87}
88
89/** tako が `tako mod report` の応答(tako.view)で返す帯・サイドバーの材料(tako_core::claude_mod::BandView) */
90export type TakoView = {
91 pane: number
92 pane_title?: string
93 tab_title?: string
94 /** tako の表示言語(ja / en) */
95 lang: string
96 worker_count: number
97 attention: number
98 /** 要注意を先に、上限まで */
99 workers: TakoWorker[]
100 /** 帯に出す警告(tako が閾値を超えたと判断したものだけ) */
101 warnings: { kind: string; percent: number; resets_at?: number }[]
102 /** サイドバー用(閾値に関わらず) */
103 ctx?: TakoContext
104 rate_limits: { kind: string; percent: number; resets_at?: number }[]
105 thresholds: { ctx_percent: number; limit_percent: number }
106 /** `tako mod band on|off` の中継(at は epoch ms。$.store の時刻より新しいときだけ従う) */
107 band_request?: { hidden: boolean; at: number }
108}
109
110declare module 'claude-code' {
111 interface PluginState {
112 tako: {
113 /** 最後に受け取った tako の材料(無い・古い = null で帯を描かない) */
114 view: TakoView | null
115 /** 帯を隠すトグル($.store の band の写し。描画が購読する) */
116 bandHidden: boolean
117 /** 使用制限の窓ごとの観測時刻(#1903。描画は読まない) */
118 limitsSeen: TakoLimitsSeen
119 }
120 }
121}
122