SLOPSHOPPER

rovo

rovo のタスクの進み具合とコンソールを会話の横に出す(読み取り専用)

newpaneguardcommandtoaststatus
v0.6.7MITupdated 2026-10-26youyo/rovo/plugins/rovo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rovo
│ ┃ rovo ✕ › fix the failing auth test and add an audit log call │ ┃ 追跡中のタスクはありません。 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /rovo-console │ ⎿ rovo: コンソールのペインを開きました(読み取り専用)。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · rovo
追跡中のタスクはありません。
README

rovo (Claude Code の mod)

rovo のタスクを、Claude Code の会話の横のペインに出す mod。読み取り専用で、タスクの状態を見るだけ。タスクの取り消し・Ready 化・マージなどはしない。

何をするか

会話の横のペインに、rovo のワーカーの進み具合を「パイプラインの列」として出す。上の段がワーカーの一覧(1 行目が列見出し)、下の段が選んだワーカーのコンソール。

rovo  作業中 2 · 質問待ち 1 · 完了 3
  タイトル                         準備 実行 検証 PR モデル     経過
▎ task_console: 作業の記録を差…    ●━━━━◐┈━▶┈○┈┈┈┈○  sonnet-5.5   3分51秒
▎   rovo  $ git status --short | head; pnpm --filter ./apps/gateway exe…
  gateway: 自分のタスクを数える…   ●━━━━●━━━━?┈┈┈┈○  opus-5.5    12分4秒
    rovo  ? 質問に答えてください(会話で task_answer)
    task_list: 途中で止まったワ…     ✓ PR #83          sonnet-5.5   3分前
    docs: README を英語にする        ✕ 失敗            haiku-5.5    8分前
  他 2 件(完了。終わって 1 時間で消えます)
─ task_console: 作業の記録を差分で返す log_after…          PR #84 · ダッシュボード
┌ コンソール(残りの行いっぱい。末尾に追従)┐
  • 見出しは 作業中 n · 質問待ち n · 完了 n(費用は出さない)。2 行目の列見出しに、駅の名前を 1 回だけ出す(task は 準備・実行・検証・PR)。review のワーカーがいるときだけ、review をまとめた直前に レビュー … 取出 調査 投稿 の見出しを足す
  • 並びは 質問待ち → 作業中(開始が新しい順)→ 終端(終了が新しい順)。状態が変わったときだけ動く
  • 作業中・質問待ちのワーカーは 2 行。1 行目は選択の帯(▎)・タイトル・線路・モデル(anthropic/claude-sonnet-5-5 は sonnet-5.5)・経過時間。タイトルは、ペインの幅から他の列を引いた残りを使う。線路は、済んだ駅が緑の ●━━、いまの駅がスピナー、次の駅への線路に光(▶)が流れる。駅に着いた直後は ◉ が白く光る。質問待ち(INPUT_REQUIRED)は流れを止めて ? を点滅する。2 行目は字下げの後に、リポジトリ名(owner/ を落とした名前だけ)といまの動き($ コマンド。検証中は [2/4] が前に付く。質問待ちは warning 色で ? 質問に答えてください(会話で task_answer))
  • 終端のワーカーは 1 行で薄い色: タイトル・結果(線路の位置に ✓ PR #n / ✓ 完了 / ✕ 失敗 / ✕ 予算超過 / – 取り消し。状態色)・モデル・終わってからの時間(3分前)。タイトルの前に記号は付けない。結果以外は薄くし、失敗の行は薄くしない。終端の行は最大 5 行で、それ以上は 他 n 件(完了。終わって 1 時間で消えます)
  • 作業中の行は最大 4 件。出しているワーカーは必ず入れる。行が足りないときは、終端の行 → 作業中の行の順に減らし、出力の窓に 6 行は残す
  • 出しているワーカー(▎ の付いた行)のタイトルを全文で ─ タイトル の行に出し、右端に PR #n · ダッシュボード のリンクを寄せる(タイトルはリンクを優先して切る)
  • リポジトリとモデルは、task_list か task_start で分かっていれば常に出す
  • どのワーカーを出すかは、描くたびには選び直さない。動くのは、クリックしたときと、次のイベントのときだけ: 新しい作業中のタスクを拾った、またはあるワーカーが質問待ちに入った。そのとき、いま出しているワーカーが終端か、一覧から消えているときだけ、質問待ち > 最新の実行中 > 最新の順で移る(作業中や質問待ちのワーカーを見ているときは奪わない。出しているワーカーが完了しても動かさないので、最後の出力と PR を読める)。起動直後は同じ優先で自動で決める
  • 行を選ぶと(どの行を押してもよい)、下の段のコンソール(出力の窓、スクロール)がそのワーカーに替わる。枠はペインの下まで伸びる(行が足りないときは最小の高さ)
  • コンソールは、選んだワーカーのログが積み上がる。task_console に log_after(カーソル。最初は "0")を付けて呼び、返った log.entries を、ワーカーごとに最大 500 行まで足していく(古い行から捨てる)。行は $ pnpm test とその出力の末尾、✎ edit apps/x.ts (+12 -4)、⎇ commit …、🔍 search …、薄い色の状態の変化。末尾に追従し、上へスクロールすると止まる(末尾まで戻すと再開)。実行中のコマンドの出力は、ログの末尾に「いま流れている出力」として続け、終わるとログの側に入る
  • log を返さないサーバー(log_after を知らない task_console)では、これまでどおり最新のコマンド 1 本だけを、$ コマンド を枠の先頭に固定して出す。log_after を受け付けないサーバーは、付けずに呼び直し、1 分間は付けない
  • ログの最後の 100 行とカーソルは $.store に残し、再起動後は続きから積む
  • 予算の延長の承認待ち(task_console の awaiting が budget_extension)のワーカーは「予算の承認待ち」と出し、入った時に 1 回トーストで知らせる。答えるのは利用者に確認してから(mod 自身は答えない)
  • 流れは約 0.7 秒ごとに描き直す。動いているワーカーがいなければ止まる

並べるのは「呼んだ人の全タスク」。task_list がある rovo では、別のセッションや Cowork から始めたワーカーも数秒ごとに取って並べる(scope: "recent"、終わってから 1 時間で隠す)。task_list が無いサーバーでは、このセッションで task_start を拾ったタスクだけに戻る(1 分に 1 回 task_list を試し直す)。task_list を呼ぶ先は、rovo のツール(task_*)を呼んだ MCP サーバーから覚える。

いまの動きは、出しているワーカーと実行中のワーカーについて task_console から取る(約 4 秒ごと。一度に取るのは最大 6 件で、選んだものを先に回す)。終わったワーカーは開いたときに 1 回だけ取る。

コマンド

コマンド内容
/rovo:create-task <依頼>依頼を rovo のタスクにする(task_start)。リポジトリ・ブランチ・SHA は作業ディレクトリの git から求める
/rovo:consult <相談>別のモデルに意見を聞く(consult)。Claude が会話の文脈と質問をまとめて 1 回相談し、答えを確かめてから伝える。答えが 50 秒以内に出なければ pending が返り、consult_result で受け取る(待つ間は会話を止めない)
/rovo-consoleペインを開く・閉じる(打つたびに切り替え。他のタブの裏で見えていないときは前に出す)。mod 自身のコマンドで、作業中に打っても処理を止めずその場で動く

自動で開く条件

新しいタスクが始まるたびに、ペインを開こうとする。/rovo:create-task で始めたときは、利用者の操作がきっかけなので、端末の幅に関係なく開く。Claude が自分の判断で rovo を使ったときは、Claude Code の決まりで、端末の幅が 144 桁以上(このセッションで一度自分でペインを開いていれば 110 桁以上)のときだけ表示される。足りないときは開かず、トーストで /rovo-console を案内する。

前提

rovo の MCP サーバーが Claude Code に接続されていること(ツール task_start・task_status・task_console、あれば task_list を読む)。接続されていないと、ペインは何も出さない。

インストール

ターミナルのプロンプトで:

/plugin install rovo --marketplace youyo/rovo

シェルから:

claude plugin install rovo --marketplace youyo/rovo

デスクトップアプリでは、プロンプト横の + → Plugins → Add marketplace に youyo/rovo を入れて Add plugin する。

開発

claude --plugin-dir plugins/rovo     # 手元の mod を読み込んで起動
claude plugin test plugins/rovo      # テスト
claude plugin validate plugins/rovo  # 検査

リポジトリのルートからは mise run test:plugin。型(.claude-plugin/types/)は Claude Code が自動で作るので、コミットしない。

Source 3 files
hooks/register.tsx 1269 lines
1import type { EngineInterface, McpToolResult, Register, Timer } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { RovoConsole, RovoTask } from "../types";
5import type {
6  Awaiting,
7  LaneState,
8  LogLine,
9  ParsedConsole,
10  PrLink,
11  TaskKind,
12  WorkerPlan,
13} from "./lib";
14import {
15  agoText,
16  appendLines,
17  bestWorker,
18  budgetToastText,
19  CONSOLE_RETRY_MS,
20  cleanTitle,
21  coerceLogs,
22  coerceTasks,
23  columnHeader,
24  detailText,
25  displayWidth,
26  expandTabs,
27  formatElapsed,
28  frameTop,
29  glyph,
30  headingText,
31  httpsOnly,
32  isTerminal,
33  isWaiting,
34  laneCells,
35  logStyle,
36  MAX_LOG_PAGES,
37  matchRovoTool,
38  matchTaskStart,
39  mergeListed,
40  moveTarget,
41  orderTasks,
42  padWidth,
43  paneColumns,
44  parseConsole,
45  parseStartResult,
46  parseStatus,
47  parseTaskList,
48  planWorkers,
49  pruneTasks,
50  repoName,
51  reviewHeader,
52  STATIONS,
53  STORED_LOG_LINES,
54  shortModel,
55  shortText,
56  slideShift,
57  spacerRows,
58  stageIndex,
59  statusColor,
60  TRACK_COL,
61  tailLines,
62  taskKey,
63  terminalCell,
64  toastText,
65  truncateWidth,
66} from "./lib";
67
68type Dollar = EngineInterface;
69
70const PANE_ID = "rovo";
71const POLL_MS = 4000;
72const STORE_KEY = "tasks";
73const LOGS_KEY = "logs";
74
75// 読み取り専用: 呼ぶ MCP ツールはこの 3 つだけ(task_start は model が呼んだ結果を見るだけ)。
76const TOOL_CONSOLE = "task_console";
77const TOOL_STATUS = "task_status";
78const TOOL_LIST = "task_list";
79const ENDPOINTS_KEY = "endpoints";
80const MAX_ENDPOINTS = 4;
81// 流れ(▶・スピナー・点滅)の描き直し。動いているワーカーがいる間だけ回す。
82const ANIM_MS = 700;
83// 着いた直後の ◉ を出しておくフレーム数。
84const ARRIVE_FRAMES = 3;
85// task_list を取り直す間隔。動いているワーカーがいなければゆっくり。
86const LIST_IDLE_MS = 20_000;
87// 1 回の tick で task_console を取るワーカーの上限(選んでいるものを先頭に、残りは順番に回す)。
88const MAX_CONSOLE_POLLS = 6;
89
90const tasksAtom = atom({ plugin: "rovo", key: "tasks" } as const, []);
91const consolesAtom = atom({ plugin: "rovo", key: "consoles" } as const, {});
92const frameAtom = atom({ plugin: "rovo", key: "frame" } as const, 0);
93const arrivalsAtom = atom({ plugin: "rovo", key: "arrivals" } as const, {});
94const listsAtom = atom({ plugin: "rovo", key: "lists" } as const, {});
95const endpointsAtom = atom({ plugin: "rovo", key: "endpoints" } as const, []);
96// 積み上がるログ(ワーカーごと)と、log_after を受け付けなかった接続先。
97const logsAtom = atom({ plugin: "rovo", key: "logs" } as const, {});
98const logOffAtom = atom({ plugin: "rovo", key: "logOff" } as const, {});
99// 出力の窓。follow が true の間は末尾に追従する。offset は追従を止めたときの先頭行(出力の中の行番号)。
100const scrollAtom = atom({ plugin: "rovo", key: "scroll" } as const, { follow: true, offset: 0 });
101// いま出しているワーカー(taskKey)。描画のたびに選び直さない。動くのは、クリックと、
102// イベント(新しい作業中のタスクを拾った・質問待ちに入った)で出しているものが終端か消えているときだけ。変わったら追従に戻す。
103const shownAtom = atom({ plugin: "rovo", key: "shown" } as const, null);
104// /rovo:create-task が実行された時刻。タスクを拾うまで「タスクを作成中…」を出す。
105const creatingAtom = atom({ plugin: "rovo", key: "creating" } as const, null);
106const CREATING_MS = 10 * 60 * 1000;
107const CREATE_COMMAND = "create-task";
108const CONSOLE_COMMAND = "rovo-console";
109// 名前空間なし(create-task)と rovo: 付き(rovo:create-task)だけを同じ名前として扱う。旧名は拾わない。
110const baseName = (command: string): string =>
111  command.startsWith("rovo:") ? command.slice(5) : command;
112
113// 自分のコマンド(名前空間なし・rovo: 付き)。baseName と同じ範囲。
114// 正規表現ではなく名前の一覧にする(正規表現の matcher だと、他の mod のコマンドにも rovo が関わったと表示される)
115const OWN_COMMANDS = ["create-task", "rovo:create-task", "rovo-console"];
116
117const endpointKey = (e: { server: string; prefix: string }): string => `${e.server}/${e.prefix}`;
118
119const textOf = (result: McpToolResult): string =>
120  result.content
121    .map((block) => (block.type === "text" && typeof block.text === "string" ? block.text : ""))
122    .join("");
123
124let timer: Timer | null = null;
125let animTimer: Timer | null = null;
126let isPolling = false;
127let pollCursor = 0;
128
129/** ペインを開く。描かれたか(isPlaced)を返す。開けなくても追跡は続ける。 */
130const openPane = async ($: Dollar): Promise<boolean> => {
131  try {
132    return (await $.ui.open({ id: PANE_ID, title: "rovo" })).isPlaced;
133  } catch {
134    return false;
135  }
136};
137
138const HOUR_MS = 60 * 60 * 1000;
139
140const visibleOf = (tasks: readonly RovoTask[], now: number): RovoTask[] =>
141  tasks.filter((t) => t.terminalAt === undefined || now - t.terminalAt < HOUR_MS);
142
143/** 出すタスク: 出しているもの。無い・消えているときは見かけ上 best(保存はイベントのとき)。 */
144const resolveShown = (tasks: readonly RovoTask[], shown: string | null): RovoTask | undefined =>
145  tasks.find((t) => taskKey(t) === shown) ?? bestWorker(tasks);
146
147/** いまペインに出しているタスクの taskKey(無ければ null)。 */
148const shownKeyNow = async ($: Dollar): Promise<string | null> => {
149  const now = await $.clock.now();
150  const picked = resolveShown(visibleOf(await read($, tasksAtom), now), await read($, shownAtom));
151  return picked === undefined ? null : taskKey(picked);
152};
153
154/** 出すワーカーを替える。替わったら窓を追従に戻す。 */
155const setShown = async ($: Dollar, key: string | null): Promise<void> => {
156  if (key === (await read($, shownAtom))) return;
157  await update($, shownAtom, () => key);
158  await update($, scrollAtom, () => ({ follow: true, offset: 0 }));
159};
160
161/** イベントのとき: 出しているものが終端・消えている・無いときだけ、質問待ち > 最新の実行中 > 最新へ移る。 */
162const considerMove = async ($: Dollar, tasks: readonly RovoTask[]): Promise<void> => {
163  const now = await $.clock.now();
164  await setShown($, moveTarget(visibleOf(tasks, now), await read($, shownAtom)));
165};
166
167const showStatus = (tasks: readonly RovoTask[], $: Dollar): void => {
168  const running = tasks.filter((t) => !isTerminal(t.status)).length;
169  $.ui.status(running > 0 ? `${running} 件実行中` : undefined);
170};
171
172/** 追跡リストを書き換える。古いものを落とし、$.store に残し、ステータスラインを直す。 */
173const mutate = async ($: Dollar, change: (list: RovoTask[]) => RovoTask[]): Promise<RovoTask[]> => {
174  const now = await $.clock.now();
175  let before: RovoTask[] = [];
176  const after = await update($, tasksAtom, (list) => {
177    before = list;
178    return pruneTasks(change(list), now);
179  });
180  if (JSON.stringify(before) !== JSON.stringify(after)) {
181    await $.store.set(STORE_KEY, after);
182  }
183  showStatus(after, $);
184  // イベント: 新しい作業中のタスクを拾った・質問待ちに入った(まだ何も出していないときも決める)。
185  const was = new Map(before.map((t) => [taskKey(t), t]));
186  const isEvent = after.some((t) => {
187    if (isTerminal(t.status)) return false;
188    const old = was.get(taskKey(t));
189    return old === undefined || (isWaiting(t) && !isWaiting(old));
190  });
191  // 出しているものが一覧から消えた(終端から 1 時間)ときも、同じ規則で移る。
192  const shownNow = await read($, shownAtom);
193  const isGone = shownNow !== null && !visibleOf(after, now).some((t) => taskKey(t) === shownNow);
194  if (isEvent || isGone || shownNow === null) await considerMove($, after);
195  return after;
196};
197
198/** task_list を取り直す必要があるか: 取れている接続先が 1 つでもある。 */
199const hasListSupport = async ($: Dollar): Promise<boolean> =>
200  Object.values(await read($, listsAtom)).some((l) => l.supported);
201
202const stopIfIdle = async ($: Dollar): Promise<void> => {
203  const list = await read($, tasksAtom);
204  const active = list.some((t) => !isTerminal(t.status));
205  if (timer !== null && !active && !(await hasListSupport($))) {
206    timer.cancel();
207    timer = null;
208  }
209  if (animTimer !== null && !active) {
210    animTimer.cancel();
211    animTimer = null;
212  }
213};
214
215/** task_console 自体が無いサーバーのエラー(引数を変えても直らない)。 */
216const UNKNOWN_TOOL = /unknown tool|no such tool|tool not found|method not found/i;
217
218const callConsole = async (
219  $: Dollar,
220  t: RovoTask,
221  args: Record<string, unknown>,
222): Promise<{ parsed: ParsedConsole | null; toolMissing: boolean }> => {
223  try {
224    const res = await $.mcp.call(t.server, t.prefix + TOOL_CONSOLE, args);
225    if (res.isError) return { parsed: null, toolMissing: UNKNOWN_TOOL.test(textOf(res)) };
226    return { parsed: parseConsole(textOf(res)), toolMissing: false };
227  } catch {
228    return { parsed: null, toolMissing: false };
229  }
230};
231
232/** ログを取り込んだ結果。logSeen はこのサーバーが log を返したこと。 */
233type LogOutcome = { seen: boolean; added: LogLine[]; cursor: string; rejected: boolean };
234
235/** 取り込んだログを buffer に足す。古い行が落ちたぶんだけ、追従を止めている窓も前へ寄せる。 */
236const applyLog = async (
237  $: Dollar,
238  key: string,
239  outcome: LogOutcome | null,
240  parsedOk: boolean,
241): Promise<void> => {
242  const cur = (await read($, logsAtom))[key];
243  if (outcome === null) return;
244  if (!outcome.seen) {
245    // log を返さない(古いサーバー・受け付けなかった・取得に失敗): 無ければ印だけ作り、あれば触らない。
246    // 取れたのに log が無いときは、前に積んだログがあっても最新のコマンドだけの表示に戻す。
247    if (cur === undefined || (parsedOk && cur.supported)) {
248      await update($, logsAtom, (m) => ({
249        ...m,
250        [key]: { supported: false, cursor: cur?.cursor ?? "0", lines: cur?.lines ?? [] },
251      }));
252    }
253    return;
254  }
255  const { lines, dropped } = appendLines(cur?.lines ?? [], outcome.added);
256  await update($, logsAtom, (m) => ({
257    ...m,
258    [key]: { supported: true, cursor: outcome.cursor, lines },
259  }));
260  if (dropped > 0 && (await shownKeyNow($)) === key) {
261    await update($, scrollAtom, (s) =>
262      s.follow ? s : { ...s, offset: Math.max(0, s.offset - dropped) },
263    );
264  }
265  await persistLogs($);
266};
267
268/** ログを $.store に小さく残す(追跡中のワーカーの、最後の STORED_LOG_LINES 行とカーソル)。 */
269const persistLogs = async ($: Dollar): Promise<void> => {
270  const keys = new Set((await read($, tasksAtom)).map(taskKey));
271  let kept: Record<string, { cursor: string; lines: LogLine[] }> = {};
272  await update($, logsAtom, (m) => {
273    const next: typeof m = {};
274    kept = {};
275    for (const [k, v] of Object.entries(m)) {
276      if (!keys.has(k)) continue;
277      next[k] = v;
278      if (v.supported) kept[k] = { cursor: v.cursor, lines: v.lines.slice(-STORED_LOG_LINES) };
279    }
280    return Object.keys(next).length === Object.keys(m).length ? m : next;
281  });
282  await $.store.set(LOGS_KEY, kept);
283};
284
285const pollOne = async ($: Dollar, t: RovoTask): Promise<void> => {
286  let parsed: ParsedConsole | null = null;
287  const startedAt = await $.clock.now();
288  // task_console が使えないタスクも、60 秒に 1 回は試し直す(同時 1 本は tick の isPolling が守る)。
289  const isRetry = t.noConsole === true && startedAt >= (t.consoleRetryAt ?? 0);
290  let isConsoleMissing = t.noConsole === true && !isRetry;
291  const key = taskKey(t);
292  const epKey = endpointKey(t);
293  // ログ(log_after)を頼むのは、ペインに出しているワーカーだけ。受け付けなかったサーバーは 1 分あきらめる。
294  const wantLog =
295    !isConsoleMissing &&
296    (await shownKeyNow($)) === key &&
297    startedAt >= ((await read($, logOffAtom))[epKey] ?? 0);
298  let outcome: LogOutcome | null = null;
299  if (!isConsoleMissing) {
300    if (!wantLog) {
301      parsed = (await callConsole($, t, { task_id: t.taskId })).parsed;
302    } else {
303      outcome = {
304        seen: false,
305        added: [],
306        cursor: (await read($, logsAtom))[key]?.cursor ?? "0",
307        rejected: false,
308      };
309      for (let page = 0; page < MAX_LOG_PAGES; page++) {
310        const { parsed: p, toolMissing } = await callConsole($, t, {
311          task_id: t.taskId,
312          log_after: outcome.cursor,
313        });
314        if (p === null) {
315          if (page === 0 && !toolMissing) {
316            // log_after を受け付けないサーバーかもしれない: 付けずにもう一度。
317            parsed = (await callConsole($, t, { task_id: t.taskId })).parsed;
318            outcome.rejected = parsed !== null;
319          }
320          break;
321        }
322        parsed = p;
323        if (p.log === undefined) break;
324        outcome.seen = true;
325        outcome.added.push(...p.log.lines);
326        const advanced = p.log.nextCursor !== outcome.cursor;
327        outcome.cursor = p.log.nextCursor;
328        if (!p.log.truncated || !advanced) break;
329      }
330      if (outcome.rejected) {
331        await update($, logOffAtom, (m) => ({ ...m, [epKey]: startedAt + CONSOLE_RETRY_MS }));
332      }
333    }
334    if (parsed === null) isConsoleMissing = true;
335  }
336  await applyLog($, key, outcome, parsed !== null);
337
338  let status: string;
339  let dashboardUrl: string | undefined;
340  let pr: PrLink | undefined;
341  let awaiting: Awaiting | undefined;
342  let view: Omit<RovoConsole, "fetchedAt">;
343  if (parsed !== null) {
344    status = parsed.status;
345    dashboardUrl = parsed.dashboardUrl;
346    pr = parsed.pr;
347    awaiting = parsed.awaiting;
348    view = {
349      phase: parsed.phase,
350      stoppedPhase: parsed.stoppedPhase,
351      nowText: parsed.nowText,
352      command: parsed.command,
353      ...(parsed.nowWhere !== undefined ? { nowWhere: parsed.nowWhere } : {}),
354      ...(parsed.commandWhere !== undefined ? { commandWhere: parsed.commandWhere } : {}),
355      tail: parsed.tail,
356      isRunning: parsed.isRunning,
357      hasOutput: true,
358      ...(pr !== undefined ? { pr } : {}),
359    };
360  } else {
361    let one: { status: string; dashboardUrl?: string; pr?: PrLink; awaiting?: Awaiting } | null =
362      null;
363    try {
364      const res = await $.mcp.call(t.server, t.prefix + TOOL_STATUS, { task_id: t.taskId });
365      one = res.isError ? null : parseStatus(textOf(res));
366    } catch {
367      one = null;
368    }
369    if (one === null) return;
370    status = one.status;
371    dashboardUrl = one.dashboardUrl;
372    pr = one.pr;
373    awaiting = one.awaiting;
374    view = {
375      phase: null,
376      stoppedPhase: null,
377      nowText: null,
378      command: null,
379      tail: null,
380      isRunning: false,
381      hasOutput: false,
382      ...(pr !== undefined ? { pr } : {}),
383    };
384  }
385
386  const now = await $.clock.now();
387  const becameTerminal = !isTerminal(t.status) && isTerminal(status);
388  const shouldToast = becameTerminal && t.notified !== true;
389  const shouldToastBudget = awaiting !== undefined && t.awaiting === undefined;
390  await mutate($, (list) =>
391    list.map((x) =>
392      taskKey(x) !== key
393        ? x
394        : {
395            ...x,
396            status,
397            awaiting,
398            ...(isConsoleMissing
399              ? {
400                  noConsole: true as const,
401                  consoleRetryAt:
402                    isRetry || t.noConsole !== true ? now + CONSOLE_RETRY_MS : t.consoleRetryAt,
403                }
404              : { noConsole: undefined, consoleRetryAt: undefined }),
405            ...(x.dashboardUrl === undefined && dashboardUrl !== undefined ? { dashboardUrl } : {}),
406            ...(becameTerminal ? { terminalAt: now, notified: true as const } : {}),
407          },
408    ),
409  );
410  // 追従を止めて読んでいる間は、末尾が前へずれた分だけ窓も前へ寄せ、同じ行を見せ続ける。
411  const old = (await read($, consolesAtom))[key];
412  if (outcome?.seen !== true && old?.tail != null && view.tail !== null) {
413    const shift = slideShift(tailLines(old.tail).split("\n"), tailLines(view.tail).split("\n"));
414    const stand = await read($, scrollAtom);
415    if (shift > 0 && !stand.follow) {
416      const shownTask = (await buildModel($, 30)).selected;
417      if (shownTask !== undefined && taskKey(shownTask) === key) {
418        await update($, scrollAtom, (s) => ({ ...s, offset: Math.max(0, s.offset - shift) }));
419      }
420    }
421  }
422  await update($, consolesAtom, (map) => ({ ...map, [key]: { ...view, fetchedAt: now } }));
423  if (shouldToast) $.ui.toast(toastText(t.title, status));
424  if (shouldToastBudget) $.ui.toast(budgetToastText(t.title));
425};
426
427/**
428 * 呼んだ人の全タスク(task_list)を取って追跡リストに混ぜる。task_list が無いサーバー
429 * (エラー・形が違う)は 1 分間あきらめ、その間は task_start で拾ったものだけで動く。
430 * 呼び出し側が isPolling を握っていること。
431 */
432const syncLists = async ($: Dollar): Promise<void> => {
433  const endpoints = await read($, endpointsAtom);
434  for (const ep of endpoints) {
435    const key = endpointKey(ep);
436    const now = await $.clock.now();
437    const state = (await read($, listsAtom))[key];
438    if (state !== undefined) {
439      if (!state.supported && now < state.retryAt) continue;
440      const active = (await read($, tasksAtom)).some((t) => !isTerminal(t.status));
441      if (state.supported && now - state.lastAt < (active ? POLL_MS - 500 : LIST_IDLE_MS)) continue;
442    }
443    let parsed: ReturnType<typeof parseTaskList> = null;
444    try {
445      const res = await $.mcp.call(ep.server, ep.prefix + TOOL_LIST, {
446        scope: "recent",
447        limit: 50,
448      });
449      parsed = res.isError ? null : parseTaskList(textOf(res));
450    } catch {
451      parsed = null;
452    }
453    const at = await $.clock.now();
454    if (parsed === null) {
455      await update($, listsAtom, (m) => ({
456        ...m,
457        [key]: { supported: false, retryAt: at + CONSOLE_RETRY_MS, lastAt: at },
458      }));
459      continue;
460    }
461    await update($, listsAtom, (m) => ({
462      ...m,
463      [key]: { supported: true, retryAt: 0, lastAt: at },
464    }));
465    const listed = parsed.tasks;
466    const merged = await mutate($, (list) => mergeListed(list, listed, ep, at));
467    await ensureTimer($);
468    // 終端に変わったワーカーを知らせる(初めから終わっていたものは notified 済みで、ここに来ない)。
469    const toAnnounce = merged.filter((t) => isTerminal(t.status) && t.notified !== true);
470    if (toAnnounce.length > 0) {
471      const keys = new Set(toAnnounce.map(taskKey));
472      await mutate($, (list) =>
473        list.map((x) => (keys.has(taskKey(x)) ? { ...x, notified: true as const } : x)),
474      );
475      for (const t of toAnnounce) $.ui.toast(toastText(t.title, t.status));
476    }
477  }
478};
479
480/** 今回 task_console を取るワーカー: 選んでいるものを先頭に、残りは順番に回して上限まで。並びは一覧のまま。 */
481const chooseToPoll = async ($: Dollar, running: RovoTask[]): Promise<RovoTask[]> => {
482  if (running.length <= MAX_CONSOLE_POLLS) return running;
483  const shownKey = await shownKeyNow($);
484  const selected = running.find((t) => taskKey(t) === shownKey);
485  const others = running.filter((t) => t !== selected);
486  const room = MAX_CONSOLE_POLLS - (selected === undefined ? 0 : 1);
487  const start = pollCursor % others.length;
488  pollCursor += room;
489  const rotated = [...others.slice(start), ...others.slice(0, start)].slice(0, room);
490  const chosen = new Set<RovoTask>(selected === undefined ? rotated : [selected, ...rotated]);
491  return running.filter((t) => chosen.has(t));
492};
493
494/** 前の呼び出しが終わるまで次を出さない。1 回の中でも 1 本ずつ。 */
495const tick = async ($: Dollar): Promise<void> => {
496  if (isPolling) return;
497  isPolling = true;
498  try {
499    try {
500      await syncLists($);
501    } catch {
502      // task_list がだめでも、task_console は取る。
503    }
504    await mutate($, (list) => list);
505    const running = (await read($, tasksAtom)).filter((t) => !isTerminal(t.status));
506    for (const t of await chooseToPoll($, running)) await pollOne($, t);
507    await fetchMissingNow($, undefined, true);
508  } catch {
509    // 次の周期でやり直す。
510  } finally {
511    isPolling = false;
512  }
513  try {
514    await stopIfIdle($);
515  } catch {
516    // タイマーが残るだけ。次の周期で止まる。
517  }
518};
519
520/**
521 * consolesAtom に中身が無い終端のタスクを 1 回ずつ取る(取れたら二度と取らない)。
522 * pollOne をそのまま使う。task_list から入ったものは数が多いので、いま出しているもの
523 * (か only で指したもの)だけ。失敗しても他の表示には触れない。呼び出し側が isPolling を握っていること。
524 */
525const fetchMissingNow = async ($: Dollar, only?: string, listedOnly = false): Promise<void> => {
526  const tasks = await read($, tasksAtom);
527  const now = await $.clock.now();
528  const pickedKey = await shownKeyNow($);
529  for (const t of tasks) {
530    const key = taskKey(t);
531    if (only !== undefined && key !== only) continue;
532    if (only === undefined && t.listed === true && key !== pickedKey) continue;
533    // タイマーの周期では、task_start で拾った終端のタスクは取り直さない(開いたときだけ)。
534    if (listedOnly && t.listed !== true) continue;
535    // 実行中のタスクはタイマーが取る。
536    if (!isTerminal(t.status)) continue;
537    // 中身がある終端のタスクは取り直さない。ただし出しているものは、ログをまだ頼んでいなければ 1 回頼む。
538    const needsLog =
539      key === pickedKey &&
540      (await read($, logsAtom))[key] === undefined &&
541      now >= ((await read($, logOffAtom))[endpointKey(t)] ?? 0);
542    if ((await read($, consolesAtom))[key] !== undefined && !needsLog) continue;
543    try {
544      await pollOne($, t);
545    } catch {
546      // このタスクは今回あきらめる。次の機会にやり直す。
547    }
548  }
549};
550
551const fetchMissing = async ($: Dollar, only?: string): Promise<void> => {
552  if (isPolling) return;
553  isPolling = true;
554  try {
555    await fetchMissingNow($, only);
556  } catch {
557    // 読めなければ何もしない。
558  } finally {
559    isPolling = false;
560  }
561  try {
562    await stopIfIdle($);
563  } catch {
564    // タイマーが残るだけ。
565  }
566};
567
568/** task_list を取り直し、中身の無い終端のワーカーを 1 回取る。開いたとき・始まったときに使う。 */
569const refresh = async ($: Dollar): Promise<void> => {
570  if (isPolling) return;
571  isPolling = true;
572  try {
573    await syncLists($);
574    await fetchMissingNow($);
575  } catch {
576    // 次の機会にやり直す。
577  } finally {
578    isPolling = false;
579  }
580  try {
581    await stopIfIdle($);
582  } catch {
583    // タイマーが残るだけ。
584  }
585};
586
587/** いまの駅の番号と状態。list の stage を先に、無ければ task_console の phase を読む。 */
588const laneOf = (
589  t: RovoTask,
590  view: RovoConsole | undefined,
591): { kind: TaskKind; at: number | null; state: LaneState } => {
592  const kind = t.kind ?? "task";
593  const n = STATIONS[kind].length;
594  const own = stageIndex(kind, t.stage);
595  const seen = view?.hasOutput === true ? stageIndex(kind, view.phase) : null;
596  const stopped = view?.hasOutput === true ? stageIndex(kind, view.stoppedPhase) : null;
597  if (t.status === "COMPLETED") return { kind, at: n, state: "done" };
598  if (isTerminal(t.status)) return { kind, at: stopped ?? own ?? seen, state: "failed" };
599  return { kind, at: own ?? seen, state: isWaiting(t) ? "waiting" : "active" };
600};
601
602/** 描き直しを 1 つ進める。駅が変わったワーカーを覚え(着いた直後の ◉ のため)、動くものがなければ止める。 */
603const advanceFrame = async ($: Dollar): Promise<void> => {
604  const frame = await update($, frameAtom, (n) => n + 1);
605  const tasks = await read($, tasksAtom);
606  const consoles = await read($, consolesAtom);
607  await update($, arrivalsAtom, (old) => {
608    const next: Record<string, { at: number; since: number }> = {};
609    for (const t of tasks) {
610      if (isTerminal(t.status)) continue;
611      const key = taskKey(t);
612      const at = laneOf(t, consoles[key]).at ?? -1;
613      const prev = old[key];
614      next[key] =
615        prev === undefined ? { at, since: -99 } : prev.at === at ? prev : { at, since: frame };
616    }
617    return JSON.stringify(next) === JSON.stringify(old) ? old : next;
618  });
619  if (!tasks.some((t) => !isTerminal(t.status))) {
620    animTimer?.cancel();
621    animTimer = null;
622  }
623};
624
625const ensureTimer = async ($: Dollar): Promise<void> => {
626  const list = await read($, tasksAtom);
627  const active = list.some((t) => !isTerminal(t.status));
628  if (timer === null && (active || (await hasListSupport($)))) {
629    timer = $.clock.every(POLL_MS, () => {
630      void tick($);
631    });
632  }
633  if (animTimer === null && active) {
634    animTimer = $.clock.every(ANIM_MS, () => {
635      void advanceFrame($).catch(() => undefined);
636    });
637  }
638};
639
640/** $.store から読んだ接続先を、形を確かめて作り直す。 */
641const coerceEndpoints = (value: unknown): { server: string; prefix: string }[] => {
642  if (!Array.isArray(value)) return [];
643  const out: { server: string; prefix: string }[] = [];
644  for (const raw of value) {
645    const e = raw as Record<string, unknown> | null;
646    if (typeof e !== "object" || e === null) continue;
647    if (typeof e.server !== "string" || e.server === "" || typeof e.prefix !== "string") continue;
648    if (!/^(?:[A-Za-z0-9-]+_)*$/.test(e.prefix)) continue;
649    out.push({ server: e.server, prefix: e.prefix });
650  }
651  return out.slice(0, MAX_ENDPOINTS);
652};
653
654/** rovo のツールを呼んだ接続先を覚える(task_list を呼ぶ先)。新しい接続先なら true。 */
655const rememberEndpoint = async (
656  $: Dollar,
657  ep: { server: string; prefix: string },
658): Promise<boolean> => {
659  const known = await read($, endpointsAtom);
660  if (known.some((k) => endpointKey(k) === endpointKey(ep))) return false;
661  const next = [...known, ep].slice(-MAX_ENDPOINTS);
662  await update($, endpointsAtom, () => next);
663  await $.store.set(ENDPOINTS_KEY, next);
664  return true;
665};
666
667const track = async (
668  $: Dollar,
669  hit: { server: string; prefix: string },
670  started: { taskId: string; status?: string; dashboardUrl?: string; model?: string },
671  title: unknown,
672  input: { repository?: unknown; model?: unknown } = {},
673): Promise<void> => {
674  const now = await $.clock.now();
675  const status = started.status ?? "RUNNING";
676  // task_list が答える前でも、1 行目にリポジトリとモデルを出せるよう、task_start の入力と応答から取る。
677  const repository = shortText(input.repository, 100);
678  const model = started.model ?? shortText(input.model, 60);
679  const added: RovoTask = {
680    taskId: started.taskId,
681    title: cleanTitle(title, started.taskId),
682    server: hit.server,
683    prefix: hit.prefix,
684    startedAt: now,
685    status,
686    ...(started.dashboardUrl !== undefined ? { dashboardUrl: started.dashboardUrl } : {}),
687    ...(repository !== undefined ? { repository } : {}),
688    ...(model !== undefined ? { model } : {}),
689    ...(isTerminal(status) ? { terminalAt: now, notified: true as const } : {}),
690  };
691  const isNew = !(await read($, tasksAtom)).some((x) => taskKey(x) === taskKey(added));
692  await mutate($, (list) => [...list.filter((x) => taskKey(x) !== taskKey(added)), added]);
693  await update($, creatingAtom, () => null);
694  if (isNew) {
695    // 新しいタスクを拾うたびに開く。狭い端末では待ちになるので、トーストで知らせる。
696    const isPlaced = await openPane($);
697    if (!isPlaced) $.ui.toast("rovo のタスクを始めました。/rovo-console で開けます");
698  }
699};
700
701/** ヘッダー・出力の窓の寸法。描画と ui.scroll の両方が同じ計算を使う。 */
702type PaneModel = {
703  tasks: RovoTask[];
704  selected: RovoTask | undefined;
705  view: RovoConsole | undefined;
706  now: number;
707  /** 一覧に出すワーカー(並びは orderTasks)と、出さなかった件数・行の数。 */
708  plan: WorkerPlan;
709  link: string | undefined;
710  prLink: RovoConsole["pr"];
711  /** ヘッダーの行数(枠の外。空行を含む)。 */
712  headerRows: number;
713  /** 空行の数(0〜2)。1 以上で見出しの下、2 で一覧と枠の間にも入れる。 */
714  spacers: number;
715  /** 出力の全行(ログがあればログ + いま流れている出力、無ければ末尾 TAIL_LINES まで)と、窓の高さ(枠の行を除く)・先頭行の範囲。 */
716  lines: LogLine[];
717  /** ログ(積み上がる表示)か。false なら最新のコマンドだけ(`$ コマンド` を枠の先頭に固定)。 */
718  stacked: boolean;
719  /** 枠の行数(上下の辺と、固定する `$ コマンド`)。枠の高さは frameRows + windowRows。 */
720  frameRows: number;
721  windowRows: number;
722  maxStart: number;
723  /** タスクを作成中(10 分以内)。true の間は案内だけを描き、一覧・詳細は出さない。 */
724  creating: boolean;
725};
726
727const CREATING_ROWS = 2;
728
729const buildModel = async ($: Dollar, bodyRows: number): Promise<PaneModel> => {
730  const now = await $.clock.now();
731  const creatingAt = await read($, creatingAtom);
732  if (creatingAt !== null && now - creatingAt < CREATING_MS) {
733    return {
734      tasks: [],
735      selected: undefined,
736      view: undefined,
737      now,
738      plan: { shown: [], hidden: 0, hiddenAllTerminal: false, rows: 0 },
739      link: undefined,
740      prLink: undefined,
741      headerRows: CREATING_ROWS,
742      spacers: 0,
743      lines: [],
744      stacked: false,
745      frameRows: 0,
746      windowRows: Math.max(1, bodyRows - CREATING_ROWS),
747      maxStart: 0,
748      creating: true,
749    };
750  }
751  const all = await read($, tasksAtom);
752  const tasks = visibleOf(all, now);
753  const consoles = await read($, consolesAtom);
754  const selected = resolveShown(tasks, await read($, shownAtom));
755  const view = selected === undefined ? undefined : consoles[taskKey(selected)];
756  const link = selected === undefined ? undefined : httpsOnly(selected.dashboardUrl);
757  const prCandidate = view?.pr ?? selected?.pr;
758  const prLink =
759    prCandidate !== undefined && httpsOnly(prCandidate.url) !== undefined ? prCandidate : undefined;
760
761  // 行が足りないときは、終端の行 → 作業中の行の順に減らして出力の窓を残す(出しているワーカーは残す)。
762  const plan = planWorkers(
763    orderTasks(tasks),
764    selected === undefined ? null : taskKey(selected),
765    bodyRows,
766  );
767
768  // 数える行: 見出し + 列見出し + 一覧(plan.rows)。出力の枠が無いときだけ、タイトルとリンクの 1 行 + 取得前・非対応の一言。
769  // 枠があるときのタイトルとリンクは枠の上辺に載る(frameRows に含まれる)。
770  let headerRows = 2 + plan.rows;
771  if (selected !== undefined && (view === undefined || !view.hasOutput)) headerRows += 2;
772  const worker = selected === undefined ? undefined : (await read($, logsAtom))[taskKey(selected)];
773  const stacked = view !== undefined && view.hasOutput && worker?.supported === true;
774  const tailOf = (): LogLine[] =>
775    view !== undefined && view.hasOutput && view.tail !== null
776      ? tailLines(view.tail)
777          .split("\n")
778          .map((text) => ({ kind: "out" as const, text }))
779      : [];
780  let lines: LogLine[];
781  if (stacked && view !== undefined) {
782    // ログの末尾に、いま流れている出力を続ける(終わったコマンドは entries のほうにある)。
783    const live: LogLine[] = [];
784    if (view.isRunning) {
785      if (view.command !== null) {
786        live.push({
787          kind: "cmd",
788          text: `$ ${view.command}`,
789          ...(view.commandWhere !== undefined ? { where: view.commandWhere } : {}),
790        });
791      }
792      live.push(...tailOf());
793    }
794    lines = [...(worker?.lines ?? []), ...live];
795  } else {
796    lines = tailOf();
797  }
798  // 出力は枠で囲む: 上下の辺で 2 行。ログでなければ `$ コマンド` を枠の中の 1 行目に固定して 1 行。
799  const frameRows =
800    view !== undefined && view.hasOutput ? 2 + (!stacked && view.command !== null ? 1 : 0) : 0;
801  // 空行は、窓が MIN_WINDOW を超えて余った行からだけ取る。
802  const spacers = frameRows > 0 ? spacerRows(bodyRows - headerRows - frameRows) : 0;
803  headerRows += spacers;
804  const windowRows = Math.max(1, bodyRows - headerRows - frameRows);
805  return {
806    tasks,
807    selected,
808    view,
809    now,
810    plan,
811    link,
812    prLink,
813    headerRows,
814    spacers,
815    lines,
816    stacked,
817    frameRows,
818    windowRows,
819    maxStart: Math.max(0, lines.length - windowRows),
820    creating: false,
821  };
822};
823
824const startOf = (m: PaneModel, s: { follow: boolean; offset: number }): number =>
825  s.follow ? m.maxStart : Math.min(Math.max(0, s.offset), m.maxStart);
826
827export const register: Register = (on) => {
828  on("session.start", async ($, e, next) => {
829    // 開け閉めするだけなので、作業中のターンを待たせずその場で動かす(immediate)。
830    // run はターンの状態に依存しない。スキルだと作業中に打ったとき終わりを待つため mod のコマンドにする。
831    try {
832      await $.command.register({
833        name: CONSOLE_COMMAND,
834        description: "rovo のペインを開く・閉じる",
835        immediate: true,
836      });
837    } catch {
838      // 登録できなくてもほかの機能は動かす。
839    }
840    try {
841      const stored = coerceTasks(await $.store.get(STORE_KEY));
842      await mutate($, () => stored);
843    } catch {
844      // 読めなければ空から始める。
845    }
846    try {
847      const logs = coerceLogs(await $.store.get(LOGS_KEY));
848      await update($, logsAtom, () =>
849        Object.fromEntries(
850          Object.entries(logs).map(([k, v]) => [
851            k,
852            { supported: true, cursor: v.cursor, lines: v.lines },
853          ]),
854        ),
855      );
856    } catch {
857      // ログが読めなければ空から積む。
858    }
859    try {
860      const endpoints = coerceEndpoints(await $.store.get(ENDPOINTS_KEY));
861      await update($, endpointsAtom, () => endpoints);
862      await ensureTimer($);
863    } catch {
864      // 接続先が読めなければ task_start で拾ったものだけで動く。
865    }
866    // 起動を待たせない。task_list を取り、終端だけのときはタイマーが動かないので中身も 1 回取る。
867    void refresh($).catch(() => undefined);
868
869    return next(e);
870  });
871
872  // rovo-console は mod 自身が登録した immediate のコマンド(開閉の切り替え)で、next を呼ばずに自分で答える。
873  // create-task は next で本来の処理(モデルへ手順を渡す)に続ける。mod は task_start を呼ばない。
874  // matcher で自分のコマンドだけに絞る(他の mod のコマンドの結果に rovo が混ざらないように)。
875  on("command.run", { command: OWN_COMMANDS }, async ($, e, next) => {
876    const name = baseName(e.command);
877    if (name === CONSOLE_COMMAND) {
878      // 開いて見えているときは閉じる。閉じている・他のタブの裏にあるときは開く(前に出す)。
879      let isShown = false;
880      try {
881        isShown = (await $.ui.panes()).some((p) => p.id === PANE_ID && p.isShown);
882      } catch {
883        // 分からなければ開く側に倒す。
884      }
885      if (isShown) {
886        try {
887          await $.ui.close({ id: PANE_ID });
888        } catch {
889          // 閉じられなくても続ける。
890        }
891        try {
892          await update($, creatingAtom, () => null);
893        } catch {
894          // 作成中の表示が消せなくても続ける。
895        }
896
897        return { text: "コンソールのペインを閉じました。" };
898      }
899      try {
900        // 作成中の逃げ道: 作成中の表示を消して、通常の表示(前のタスク)に戻す。
901        await update($, creatingAtom, () => null);
902      } catch {
903        // 消せなくてもペインは開く。
904      }
905      await openPane($);
906      try {
907        await ensureTimer($);
908      } catch {
909        // ペインは開いた。
910      }
911      void refresh($).catch(() => undefined);
912
913      return { text: "コンソールのペインを開きました(読み取り専用)。" };
914    }
915    if (name === CREATE_COMMAND) {
916      try {
917        const at = await $.clock.now();
918        await update($, creatingAtom, () => at);
919        await openPane($);
920      } catch {
921        // 表示に失敗しても本来の処理は続ける。
922      }
923    }
924
925    return next(e);
926  });
927
928  // 呼び出しは変えない・止めない: next の結果をそのまま返す。
929  on("tool.call", async ($, e, next) => {
930    const ran = await next(e);
931    try {
932      const outcome = ran as { deny?: string; isError?: boolean; text?: string };
933      const hit = matchTaskStart(String(e.tool));
934      if (hit !== null && outcome.deny === undefined && outcome.isError !== true) {
935        const started = parseStartResult(outcome.text ?? "");
936        if (started !== null) {
937          const input = e as Record<string, unknown>;
938          await track($, hit, started, input.title, {
939            repository: input.repository,
940            model: input.model,
941          });
942        }
943      }
944      const rovoTool = outcome.deny === undefined ? matchRovoTool(String(e.tool)) : null;
945      if (rovoTool !== null && (await rememberEndpoint($, rovoTool))) {
946        void refresh($).catch(() => undefined);
947      }
948      await ensureTimer($);
949    } catch {
950      // 追跡に失敗しても tool の結果には触れない。
951    }
952
953    return ran;
954  }).catch(($, e, next) => next(e));
955
956  on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e) => {
957    const { Box, Text, Button, Link } = $.ui.resolve(e);
958    const m = await buildModel($, e.props.scroll.bodyRows);
959    const { selected, view, link, prLink, now } = m;
960
961    // 作成中は案内だけ。前のタスクの一覧・詳細・出力は描かない(2 行なので bodyRows に収まる)。
962    if (m.creating) {
963      return (
964        <Box flexDirection="column">
965          <Text wrap="truncate-end">
966            <Text color="suggestion">●</Text>
967            {" タスクを作成中…"}
968          </Text>
969          <Text dimColor wrap="truncate-end">
970            rovo のタスクを作っています。始まるとここに表示されます。
971          </Text>
972        </Box>
973      );
974    }
975    if (m.tasks.length === 0) {
976      return (
977        <Box flexDirection="column">
978          <Text dimColor>追跡中のタスクはありません。</Text>
979        </Box>
980      );
981    }
982
983    const W = e.props.bodyColumns - 1; // 右端で折り返さないよう 1 桁あける
984    const cols = paneColumns(W);
985    const start = startOf(m, await read($, scrollAtom));
986    const shown = m.lines.slice(start, start + m.windowRows);
987    const frame = await read($, frameAtom);
988    const arrivals = await read($, arrivalsAtom);
989    const consoles = await read($, consolesAtom);
990    const padLeft = (text: string, width: number): string =>
991      " ".repeat(Math.max(0, width - displayWidth(text))) + text;
992
993    // 1 ワーカーぶんの行。作業中・質問待ちは 2 行(タイトル+線路+モデル+経過 / リポジトリ+いまの動き)、終端は 1 行。
994    const workerRows = (t: RovoTask) => {
995      const key = taskKey(t);
996      const isShown = selected !== undefined && taskKey(selected) === key;
997      const own = consoles[key];
998      const lane = laneOf(t, own);
999      const bar = isShown ? <Text color="claude">▎</Text> : " ";
1000      const track = laneCells({ ...lane, frame, arrived: false });
1001      const press = async () => {
1002        await setShown($, key);
1003        await update($, scrollAtom, () => ({ follow: true, offset: 0 }));
1004        void fetchMissing($, key).catch(() => undefined);
1005      };
1006      const trackCells = (cells: typeof track) =>
1007        cells.map((c, i) => (
1008          <Text key={`cell:${i}`} color={c.color} weight={c.bold ? "bold" : undefined}>
1009            {c.text}
1010          </Text>
1011        ));
1012      const trackPad = (cells: typeof track): string =>
1013        " ".repeat(Math.max(0, TRACK_COL - cells.reduce((n, c) => n + displayWidth(c.text), 0)));
1014      if (isTerminal(t.status)) {
1015        const cell = terminalCell(t, own?.pr);
1016        // 結果は線路の位置に文字で出す(✓ PR #n / ✕ 失敗 など)。タイトルの前は選択の帯の位置だけ。
1017        const result = truncateWidth(`${glyph(t.status)} ${cell.text}`, TRACK_COL - 2);
1018        const dim = t.status === "COMPLETED" || t.status === "CANCELLED";
1019        const ago = agoText(now - (t.terminalAt ?? now));
1020        const model = shortModel(t.model ?? "");
1021        return (
1022          <Button key={`row:${key}:0`} plain onPress={press}>
1023            {bar}{" "}
1024            <Text dimColor={dim} weight={isShown ? "bold" : undefined}>
1025              {padWidth(truncateWidth(t.title, cols.title), cols.title)}
1026            </Text>{" "}
1027            <Text color={cell.color}>{result}</Text>
1028            {" ".repeat(Math.max(0, TRACK_COL - displayWidth(result)))}
1029            {cols.model > 0 && (
1030              <Text dimColor={dim}>
1031                {`${padWidth(truncateWidth(model, cols.model), cols.model)} `}
1032              </Text>
1033            )}
1034            <Text dimColor>{padLeft(ago, cols.elapsed)}</Text>
1035          </Button>
1036        );
1037      }
1038      const arrival = arrivals[key];
1039      const arrived =
1040        arrival !== undefined && lane.at !== null && arrival.at === lane.at
1041          ? frame - arrival.since < ARRIVE_FRAMES
1042          : false;
1043      const live = laneCells({ ...lane, frame, arrived });
1044      const detail = detailText(t, own?.hasOutput === true ? own.nowText : null);
1045      const repo = repoName(t.repository);
1046      const detailW = Math.max(1, W - 4 - (repo === "" ? 0 : displayWidth(repo) + 2));
1047      return (
1048        <Box key={`worker:${key}`} flexDirection="column">
1049          <Button key={`row:${key}:0`} plain onPress={press}>
1050            {bar}{" "}
1051            <Text weight={isShown ? "bold" : undefined}>
1052              {padWidth(truncateWidth(t.title, cols.title), cols.title)}
1053            </Text>{" "}
1054            {trackCells(live)}
1055            {trackPad(live)}
1056            {cols.model > 0 && (
1057              <Text>{`${padWidth(truncateWidth(shortModel(t.model ?? ""), cols.model), cols.model)} `}</Text>
1058            )}
1059            <Text dimColor>{padLeft(formatElapsed(now - t.startedAt), cols.elapsed)}</Text>
1060          </Button>
1061          <Button key={`row:${key}:1`} plain onPress={press}>
1062            {bar}
1063            {"   "}
1064            {repo !== "" && <Text dimColor>{`${repo}  `}</Text>}
1065            {detail.text !== "" && (
1066              <Text color={detail.color}>{truncateWidth(detail.text, detailW)}</Text>
1067            )}
1068          </Button>
1069        </Box>
1070      );
1071    };
1072
1073    const taskKind = m.plan.shown.filter((t) => t.kind !== "review");
1074    const reviewKind = m.plan.shown.filter((t) => t.kind === "review");
1075    const hiddenNote =
1076      m.plan.hidden > 0
1077        ? m.plan.hiddenAllTerminal
1078          ? `他 ${m.plan.hidden} 件(完了。終わって 1 時間で消えます)`
1079          : `他 ${m.plan.hidden} 件`
1080        : "";
1081
1082    const heading = headingText(m.tasks);
1083    const prText =
1084      prLink === undefined ? "" : prLink.number === null ? "PR" : `PR #${prLink.number}`;
1085    const dashText = link === undefined ? "" : "ダッシュボード";
1086    // リンクの束。上辺は幅が足りないとき、ダッシュボード → PR の順に落とす。
1087    const linksOf = (withPr: boolean, withDash: boolean) => {
1088      const showPr = withPr && prLink !== undefined;
1089      const showDash = withDash && link !== undefined;
1090      return {
1091        text: [showPr ? prText : "", showDash ? dashText : ""].filter((x) => x !== "").join(" · "),
1092        // Fragment は Box になって inline に入れられないので、配列で返す
1093        node: [
1094          showPr && prLink !== undefined && (
1095            <Text key="pr" color="suggestion">
1096              <Link href={prLink.url}>{prText}</Link>
1097            </Text>
1098          ),
1099          showPr && showDash && (
1100            <Text key="sep" dimColor>
1101              {" · "}
1102            </Text>
1103          ),
1104          showDash && link !== undefined && (
1105            <Text key="dash" color="suggestion">
1106              <Link href={link}>{dashText}</Link>
1107            </Text>
1108          ),
1109        ],
1110      };
1111    };
1112    const { text: linkText, node: links } = linksOf(true, true);
1113    // 枠の色: 作業中は控えめ(光るのは列車だけ)。終端になったら状態の色。
1114    const frameColor =
1115      selected !== undefined && isTerminal(selected.status)
1116        ? (statusColor(selected.status) ?? "subtle")
1117        : "subtle";
1118    const FW = Math.max(4, W - 2); // 枠の幅(左に 2 桁ずらす)
1119    const innerW = FW - 2;
1120    const title = selected?.title ?? "";
1121    const variants = [linksOf(true, true), linksOf(true, false)].filter((v) => v.text !== "");
1122    const chosen =
1123      variants.find((v) => frameTop(FW, title, displayWidth(v.text)).showLinks) ??
1124      linksOf(false, false);
1125    const top = frameTop(FW, title, displayWidth(chosen.text));
1126    const isEmptyNote = m.lines.length === 0 && (m.stacked || view?.command === null);
1127    // 窓が満ちないときは空の行で枠の下辺を一番下に保つ。
1128    const padRows = Math.max(0, m.windowRows - shown.length - (isEmptyNote ? 1 : 0));
1129    const side = (key: string, body: React.ReactNode) => (
1130      <Box key={key}>
1131        <Text color={frameColor}>│</Text>
1132        <Box width={innerW} flexShrink={0}>
1133          {body}
1134        </Box>
1135        <Text color={frameColor}>│</Text>
1136      </Box>
1137    );
1138
1139    // 上から: 見出し・列見出し・ワーカーの行・出力の枠(上辺にタイトルとリンク)。
1140    // 空行は余った行からだけ。ツリー全体を bodyRows に収めるので、エンジンはスクロールしない。
1141    return (
1142      <Box flexDirection="column">
1143        <Box key="heading">
1144          {heading.map((part, i) => (
1145            <Box key={`heading:${part.label}`}>
1146              <Text>{i === 0 ? "  " : "    "}</Text>
1147              <Text dimColor={part.n === 0}>{`${part.label} `}</Text>
1148              <Text
1149                color={part.tone}
1150                weight={part.tone === "warning" ? "bold" : undefined}
1151                dimColor={part.n === 0}
1152              >
1153                {String(part.n)}
1154              </Text>
1155            </Box>
1156          ))}
1157        </Box>
1158        {m.spacers >= 1 && <Box key="spacer:heading" height={1} />}
1159        <Text dimColor wrap="truncate-end">
1160          {columnHeader(cols, taskKind.length === 0 && reviewKind.length > 0 ? "review" : "task")}
1161        </Text>
1162        {taskKind.map(workerRows)}
1163        {taskKind.length > 0 && reviewKind.length > 0 && (
1164          <Text dimColor wrap="truncate-end">
1165            {reviewHeader(cols)}
1166          </Text>
1167        )}
1168        {reviewKind.map(workerRows)}
1169        {hiddenNote !== "" && <Text dimColor>{hiddenNote}</Text>}
1170        {m.spacers >= 2 && <Box key="spacer:frame" height={1} />}
1171        {selected !== undefined && (
1172          <Box flexDirection="column">
1173            {(view === undefined || !view.hasOutput) && (
1174              <Box key="titlebar" justifyContent="space-between">
1175                <Box flexShrink={1}>
1176                  <Text wrap="truncate-end">
1177                    <Text dimColor>{"─ "}</Text>
1178                    <Text weight="bold">{selected.title}</Text>
1179                  </Text>
1180                </Box>
1181                {linkText !== "" && (
1182                  <Box flexShrink={0} marginLeft={2}>
1183                    {links}
1184                  </Box>
1185                )}
1186              </Box>
1187            )}
1188            {view === undefined && <Text dimColor>まだ取得していません。</Text>}
1189            {view !== undefined && !view.hasOutput && (
1190              <Text dimColor>このサーバーは出力に対応していません。</Text>
1191            )}
1192            {view !== undefined && view.hasOutput && (
1193              <Box
1194                key="frame"
1195                flexDirection="column"
1196                marginLeft={2}
1197                height={m.frameRows + m.windowRows}
1198              >
1199                {/* 上辺は必ず 1 行・枠の幅ちょうど。全部を 1 つの Text の inline に並べ、Box で分けない(リンクは Link の inline) */}
1200                <Box key="frame-top" width={FW} flexShrink={0}>
hooks/lib.ts 1252 lines
1import type { ThemeKey } from "claude-code";
2
3import type { RovoLogLine, RovoTask } from "../types";
4
5export const MAX_TASKS = 10;
6/** task_list から入ったタスクの上限(task_list の limit の最大と同じ)。 */
7export const MAX_LISTED = 50;
8export const KEEP_AFTER_TERMINAL_MS = 60 * 60 * 1000;
9export const TAIL_LINES = 30;
10const MAX_LINE = 300;
11const MAX_TITLE = 120;
12const MAX_REPO = 100;
13const MAX_MODEL = 60;
14
15/** PR へのリンク。number は task_status の URL から取れなかったとき null(「PR」とだけ出す)。 */
16export type PrLink = { number: number | null; url: string };
17
18export type ParsedConsole = {
19  status: string;
20  phase: string | null;
21  stoppedPhase: string | null;
22  nowText: string | null;
23  /** nowText のコマンドの場所(/workspace からの相対)。 */
24  nowWhere?: string;
25  command: string | null;
26  /** command の場所(/workspace からの相対)。 */
27  commandWhere?: string;
28  tail: string | null;
29  isRunning: boolean;
30  dashboardUrl?: string;
31  pr?: PrLink;
32  /** 予算の延長の承認待ちのときだけ。 */
33  awaiting?: Awaiting;
34  /** `log` を返すサーバーのときだけ(task_console の log_after に対する応答)。 */
35  log?: LogPage;
36};
37
38export type Awaiting = "budget_extension";
39
40/** 未知の値は信用せず、"budget_extension" の完全一致だけを通す。INPUT_REQUIRED のときだけ意味がある。 */
41const parseAwaiting = (status: string, value: unknown): Awaiting | undefined =>
42  status === "INPUT_REQUIRED" && value === "budget_extension" ? "budget_extension" : undefined;
43
44export const CONSOLE_RETRY_MS = 60_000;
45
46const TERMINAL = new Set(["COMPLETED", "FAILED", "BUDGET_EXCEEDED", "CANCELLED"]);
47
48export const isTerminal = (status: string): boolean => TERMINAL.has(status);
49
50/** `https://` で始まるものだけを通す。リンクにしてよい唯一の文字列。 */
51export const httpsOnly = (url: unknown): string | undefined =>
52  typeof url === "string" && url.startsWith("https://") && !/[\s\u0000-\u001f\u007f]/.test(url)
53    ? url
54    : undefined;
55
56/** タブを、タブ幅 4 の次の位置まで空白に置き換える(枠の右辺がずれないように)。 */
57export const expandTabs = (s: string): string => {
58  if (!s.includes("\t")) return s;
59  let out = "";
60  let col = 0;
61  for (const ch of s) {
62    if (ch === "\t") {
63      const n = 4 - (col % 4);
64      out += " ".repeat(n);
65      col += n;
66    } else {
67      out += ch;
68      col += displayWidth(ch);
69    }
70  }
71  return out;
72};
73
74/**
75 * ワーカーが書いた文字列の表示用の整え方。囲み(UNTRUSTED_CHILD_QUESTION)を外し、
76 * 端末を動かす制御列(ANSI・OSC)と、向きを偽装する文字を落とす。Markdown には触らない
77 * (描くときにプレーンな Text に入れるので、そのまま文字として出る)。
78 */
79export const stripFence = (s: string): string =>
80  s
81    .replace(/<<<UNTRUSTED_CHILD_QUESTION[^\n]*(?:\n|$)/g, "")
82    .replace(/\n?<<<END_UNTRUSTED_CHILD_QUESTION>>>/g, "")
83    // eslint-disable-next-line no-control-regex
84    .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
85    // eslint-disable-next-line no-control-regex
86    .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
87    // eslint-disable-next-line no-control-regex
88    .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g, "");
89
90const asObject = (text: string): Record<string, unknown> | null => {
91  try {
92    const value: unknown = JSON.parse(text);
93    return typeof value === "object" && value !== null && !Array.isArray(value)
94      ? (value as Record<string, unknown>)
95      : null;
96  } catch {
97    return null;
98  }
99};
100
101const plain = (value: unknown): string | null =>
102  typeof value === "string" ? stripFence(value) : null;
103
104/** `mcp__<server>__<prefix>task_start` を見分ける。prefix は空か `my-rovo_` のようなもの。 */
105export const matchTaskStart = (tool: string): { server: string; prefix: string } | null => {
106  if (!tool.startsWith("mcp__")) return null;
107  const parts = tool.split("__");
108  if (parts.length < 3) return null;
109  const name = parts[parts.length - 1] ?? "";
110  const hit = /^((?:[A-Za-z0-9-]+_)*)task_start$/.exec(name);
111  const server = parts.slice(1, -1).join("__");
112  if (hit === null || server === "") return null;
113  return { server, prefix: hit[1] ?? "" };
114};
115
116export const parseStartResult = (
117  text: string,
118): { taskId: string; status?: string; dashboardUrl?: string; model?: string } | null => {
119  const body = asObject(text);
120  if (body === null || typeof body.task_id !== "string" || body.task_id === "") return null;
121  const dashboardUrl = httpsOnly(body.dashboard_url);
122  const status = plain(body.status) ?? undefined;
123  const model = shortText(body.model, MAX_MODEL);
124  return {
125    taskId: stripFence(body.task_id),
126    ...(model !== undefined ? { model } : {}),
127    ...(status !== undefined && status !== "" ? { status } : {}),
128    ...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
129  };
130};
131
132/** task_console の `pr`。{ number: 数値, url: https } だけを通す。 */
133const parsePr = (value: unknown): PrLink | undefined => {
134  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
135  const { number, url } = value as Record<string, unknown>;
136  const safe = httpsOnly(url);
137  if (safe === undefined || typeof number !== "number" || !Number.isInteger(number) || number < 1)
138    return undefined;
139  return { number, url: safe };
140};
141
142/** task_status の draft_pr_url から。番号は URL 末尾の /pull/<n>、取れなければ null。 */
143const prFromUrl = (value: unknown): PrLink | undefined => {
144  const url = httpsOnly(value);
145  if (url === undefined) return undefined;
146  const hit = /\/pull\/(\d+)\/?(?:[?#].*)?$/.exec(url);
147  const n = hit === null ? NaN : Number(hit[1]);
148  return { number: Number.isSafeInteger(n) && n > 0 ? n : null, url };
149};
150
151const DIR_SEGMENT = /^[\p{L}\p{N}_.@+-]+$/u;
152const CD_PREFIX = /^cd\s+(\S+)\s+&&\s+(\S.*)$/;
153const WORKSPACE = "/workspace";
154
155/** cd の行き先を /workspace からの相対にする。素直なパスでなければ undefined、/workspace 自身と . は ""。 */
156const relativeDir = (path: string): string | undefined => {
157  let rest: string;
158  if (path === WORKSPACE || path.startsWith(`${WORKSPACE}/`))
159    rest = path.slice(WORKSPACE.length + 1);
160  else if (/^[/~$-]/.test(path)) return undefined;
161  else if (path === ".") return "";
162  else rest = path.startsWith("./") ? path.slice(2) : path;
163  if (rest === "") return "";
164  const parts = rest.split("/");
165  if (parts.at(-1) === "") parts.pop();
166  for (const p of parts) if (p === "." || p === ".." || !DIR_SEGMENT.test(p)) return undefined;
167  return parts.join("/");
168};
169
170/**
171 * 表示用に、コマンドの先頭の `cd <相対パス> && ` を外して場所に分ける(dashboard の splitCommand と同じ規則)。
172 * `cd /workspace && ` と `cd . && ` は前置きだけ外す。.. を含む・/workspace の外・~ や $HOME や -・引用符つきは触らない。
173 */
174export const splitCommand = (text: string): { command: string; where?: string } => {
175  const m = CD_PREFIX.exec(text);
176  if (m === null) return { command: text };
177  const dir = relativeDir(m[1] as string);
178  if (dir === undefined) return { command: text };
179  const command = m[2] as string;
180  return dir === "" ? { command } : { command, where: dir };
181};
182
183/** now.text は `$ ` で始まる形があるので、その後ろに splitCommand を当てる。 */
184const splitNow = (text: string | null): { text: string | null; where?: string } => {
185  if (text === null || !text.startsWith("$ ")) return { text };
186  const c = splitCommand(text.slice(2));
187  return { text: `$ ${c.command}`, ...(c.where === undefined ? {} : { where: c.where }) };
188};
189
190export const parseConsole = (text: string): ParsedConsole | null => {
191  const body = asObject(text);
192  if (body === null || typeof body.status !== "string") return null;
193  const now = body.now as { text?: unknown } | null | undefined;
194  const output = body.output as
195    | { command?: unknown; tail?: unknown; running?: unknown }
196    | null
197    | undefined;
198  const dashboardUrl = httpsOnly(body.dashboard_url);
199  const pr = parsePr(body.pr);
200  const awaiting = parseAwaiting(body.status, body.awaiting);
201  const log = parseLog(body.log);
202  const now2 = splitNow(plain(now?.text));
203  const cmdRaw = plain(output?.command);
204  const cmd = cmdRaw === null ? null : splitCommand(cmdRaw);
205  return {
206    status: stripFence(body.status),
207    phase: plain(body.phase),
208    stoppedPhase: plain(body.stopped_phase),
209    nowText: now2.text,
210    ...(now2.where !== undefined ? { nowWhere: now2.where } : {}),
211    command: cmd === null ? null : cmd.command,
212    ...(cmd?.where !== undefined ? { commandWhere: cmd.where } : {}),
213    tail: plain(output?.tail),
214    isRunning: output?.running === true,
215    ...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
216    ...(pr !== undefined ? { pr } : {}),
217    ...(awaiting !== undefined ? { awaiting } : {}),
218    ...(log !== null ? { log } : {}),
219  };
220};
221
222export const parseStatus = (
223  text: string,
224): { status: string; dashboardUrl?: string; pr?: PrLink; awaiting?: Awaiting } | null => {
225  const body = asObject(text);
226  if (body === null || typeof body.status !== "string") return null;
227  const dashboardUrl = httpsOnly(body.dashboard_url);
228  const pr = prFromUrl(body.draft_pr_url);
229  const question = body.pending_question as { kind?: unknown } | null | undefined;
230  const awaiting = parseAwaiting(body.status, question?.kind);
231  return {
232    status: stripFence(body.status),
233    ...(awaiting !== undefined ? { awaiting } : {}),
234    ...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
235    ...(pr !== undefined ? { pr } : {}),
236  };
237};
238
239const cut = (line: string): string =>
240  line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}…` : line;
241
242// --- 積み上がるログ(task_console の log) -------------------------------------
243
244/** ワーカーごとに持つ行の上限。古い行から捨てる。 */
245export const MAX_LOG_LINES = 500;
246/** $.store に残す行数(再起動後に続きを見せるぶんだけ)。 */
247export const STORED_LOG_LINES = 100;
248/** 1 回の取得で追いかけるページ数の上限(1 ページは最大 100 件)。 */
249export const MAX_LOG_PAGES = 10;
250const MAX_ENTRIES_PER_PAGE = 200;
251const MAX_CURSOR = 200;
252
253export type LogLine = RovoLogLine;
254export type LogKind = LogLine["kind"];
255
256export type LogPage = { lines: LogLine[]; nextCursor: string; truncated: boolean };
257
258const LOG_KINDS: ReadonlySet<string> = new Set([
259  "cmd",
260  "out",
261  "edit",
262  "commit",
263  "web",
264  "tool",
265  "status",
266  "pr",
267  "error",
268]);
269
270/** 構造化イベントを別に出すツール(dashboard の STRUCTURED_OF と同じ)。tool_start は重複するので出さない。 */
271const STRUCTURED_TOOLS: ReadonlySet<string> = new Set([
272  "bash",
273  "write_file",
274  "edit_file",
275  "git_commit",
276  "web_search",
277  "web_fetch",
278  "web_browse",
279]);
280
281const oneLine = (value: unknown, max: number = MAX_LINE): string | undefined => {
282  if (typeof value !== "string") return undefined;
283  const text = stripFence(value).replace(/\s+/g, " ").trim();
284  return text === "" ? undefined : cut(text.slice(0, max * 2));
285};
286
287const count = (value: unknown): number =>
288  typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
289
290const EDIT_LABEL: Record<string, string> = { create: "create", modify: "edit", delete: "delete" };
291
292/**
293 * task_console の log.entries の 1 件を、描く行にする(ダッシュボードの流れるコンソールと同じ型)。
294 * ワーカーが書いた文字列は囲みを外して整える。知らない型・形が違うものは空。
295 */
296export const entryLines = (entry: unknown): LogLine[] => {
297  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return [];
298  const e = entry as Record<string, unknown>;
299  switch (e.type) {
300    case "command": {
301      const raw = plain(e.command);
302      if (raw === null || raw.trim() === "") return [];
303      const c = splitCommand(raw.replace(/\s+/g, " ").trim());
304      const out: LogLine[] = [
305        {
306          kind: "cmd",
307          text: cut(`$ ${c.command}`),
308          ...(c.where !== undefined ? { where: c.where } : {}),
309        },
310      ];
311      const tail = plain(e.output_tail);
312      if (tail !== null && tail.trim() !== "") {
313        for (const line of tailLines(tail).split("\n")) out.push({ kind: "out", text: line });
314      }
315      if (typeof e.exit_code === "number" && e.exit_code !== 0) {
316        out.push({ kind: "error", text: `✕ exit ${e.exit_code}` });
317      }
318      return out;
319    }
320    case "edit": {
321      const path = oneLine(e.path);
322      if (path === undefined) return [];
323      const label = EDIT_LABEL[String(e.op)] ?? "edit";
324      return [
325        { kind: "edit", text: `✎ ${label} ${path} (+${count(e.added)} -${count(e.removed)})` },
326      ];
327    }
328    case "commit": {
329      const message = oneLine(e.message);
330      return message === undefined ? [] : [{ kind: "commit", text: `⎇ commit ${message}` }];
331    }
332    case "web": {
333      const kind = e.kind === "search" || e.kind === "fetch" || e.kind === "browse" ? e.kind : null;
334      if (kind === null) return [];
335      const host = oneLine(e.host, 253);
336      const icon = kind === "search" ? "🔍" : "🌐";
337      return [{ kind: "web", text: `${icon} ${kind}${host === undefined ? "" : ` ${host}`}` }];
338    }
339    case "status_changed": {
340      const to = oneLine(e.to, 40);
341      if (to === undefined) return [];
342      const from = oneLine(e.from, 40);
343      const text =
344        from === undefined ? statusLabel(to) : `${statusLabel(from)} → ${statusLabel(to)}`;
345      return [{ kind: "status", text: `· ${text}` }];
346    }
347    case "draft_pr_created": {
348      const n = e.pr_number;
349      return typeof n === "number" && Number.isInteger(n) && n >= 1
350        ? [{ kind: "pr", text: `⎇ Draft PR #${n}` }]
351        : [];
352    }
353    case "tool_start": {
354      const name = oneLine(e.name, 100);
355      if (name === undefined || STRUCTURED_TOOLS.has(name)) return [];
356      const summary = oneLine(e.summary);
357      return [{ kind: "tool", text: `· ${name}${summary === undefined ? "" : ` ${summary}`}` }];
358    }
359    case "tool_end": {
360      if (e.is_error !== true) return [];
361      const summary = oneLine(e.summary);
362      return [{ kind: "error", text: `✕ ${summary ?? "失敗"}` }];
363    }
364    default:
365      return [];
366  }
367};
368
369/** task_console の `log`。entries と next_cursor(文字列)が無ければ null(log の無いサーバー)。 */
370export const parseLog = (value: unknown): LogPage | null => {
371  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
372  const v = value as Record<string, unknown>;
373  if (!Array.isArray(v.entries)) return null;
374  if (
375    typeof v.next_cursor !== "string" ||
376    v.next_cursor === "" ||
377    v.next_cursor.length > MAX_CURSOR
378  )
379    return null;
380  const lines: LogLine[] = [];
381  for (const entry of (v.entries as unknown[]).slice(0, MAX_ENTRIES_PER_PAGE)) {
382    lines.push(...entryLines(entry));
383  }
384  return { lines, nextCursor: v.next_cursor, truncated: v.truncated === true };
385};
386
387/** 行を末尾に足し、上限を超えた古い行を捨てる。dropped は先頭から捨てた行数(窓の位置を直すのに使う)。 */
388export const appendLines = (
389  buffer: readonly LogLine[],
390  add: readonly LogLine[],
391  max: number = MAX_LOG_LINES,
392): { lines: LogLine[]; dropped: number } => {
393  const all = [...buffer, ...add];
394  const dropped = Math.max(0, all.length - max);
395  return { lines: dropped === 0 ? all : all.slice(dropped), dropped };
396};
397
398export type WorkerLog = { cursor: string; lines: LogLine[] };
399
400const coerceLine = (raw: unknown): LogLine | null => {
401  if (typeof raw !== "object" || raw === null) return null;
402  const l = raw as Record<string, unknown>;
403  if (typeof l.kind !== "string" || !LOG_KINDS.has(l.kind) || typeof l.text !== "string")
404    return null;
405  const text = cut(stripFence(l.text));
406  const where = typeof l.where === "string" ? stripFence(l.where).slice(0, MAX_TITLE) : undefined;
407  return {
408    kind: l.kind as LogKind,
409    text,
410    ...(where !== undefined && where !== "" ? { where } : {}),
411  };
412};
413
414/** $.store から読んだログを信用せず、形を確かめて作り直す。 */
415export const coerceLogs = (value: unknown): Record<string, WorkerLog> => {
416  const out: Record<string, WorkerLog> = {};
417  if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
418  for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
419    const w = raw as Record<string, unknown> | null;
420    if (typeof w !== "object" || w === null) continue;
421    if (typeof w.cursor !== "string" || w.cursor === "" || w.cursor.length > MAX_CURSOR) continue;
422    const lines: LogLine[] = [];
423    if (Array.isArray(w.lines)) {
424      for (const l of w.lines.slice(-STORED_LOG_LINES)) {
425        const line = coerceLine(l);
426        if (line !== null) lines.push(line);
427      }
428    }
429    out[key] = { cursor: w.cursor, lines };
430  }
431  return out;
432};
433
434/** ログの行の色。undefined は本文の色、dim は薄く。 */
435export const logStyle = (kind: LogKind): { color?: ThemeKey; dim?: boolean; bold?: boolean } => {
436  switch (kind) {
437    case "cmd":
438      return { bold: true };
439    case "edit":
440    case "web":
441    case "pr":
442      return { color: "suggestion" };
443    case "commit":
444      return { color: "success" };
445    case "error":
446      return { color: "error" };
447    case "status":
448    case "tool":
449      return { dim: true };
450    default:
451      return {};
452  }
453};
454
455// --- 列の幅 ---------------------------------------------------------------------------
456
457/** `anthropic/claude-sonnet-5-5` → `sonnet-5.5`。provider/ と claude- を落とし、末尾の -N-N を -N.N に。 */
458export const shortModel = (model: string): string =>
459  model
460    .replace(/^.*\//, "")
461    .replace(/^claude-/, "")
462    .replace(/-(\d{1,2})-(\d{1,2})$/, "-$1.$2");
463
464/** `owner/name` → `name`。 */
465export const repoName = (repository: string | undefined): string =>
466  (repository ?? "").split("/").pop() ?? "";
467
468/** 末尾の n 行。長い行は切る。 */
469export const tailLines = (s: string, n: number = TAIL_LINES): string =>
470  s.replace(/\n+$/, "").split("\n").slice(-n).map(cut).join("\n");
471
472/** 出力の末尾が前の取得から何行分前へずれたか(古い行が落ちた数)。重なりが無ければ 0。 */
473export const slideShift = (before: readonly string[], after: readonly string[]): number => {
474  for (let k = 0; k < before.length; k++) {
475    const rest = before.slice(k);
476    if (rest.length <= after.length && rest.every((line, i) => line === after[i])) return k;
477  }
478  return 0;
479};
480
481export const formatElapsed = (ms: number): string => {
482  const total = Math.max(0, Math.floor(ms / 1000));
483  if (total < 60) return `${total}秒`;
484  const minutes = Math.floor(total / 60);
485  if (total < 3600) return `${minutes}分${total % 60}秒`;
486  return `${Math.floor(minutes / 60)}時間${minutes % 60}分`;
487};
488
489/** 状態の色(利用者のテーマのキー)。実行中 suggestion・完了 success・失敗 error・入力待ち warning・止まった inactive。 */
490export const statusColor = (status: string): ThemeKey | undefined => {
491  switch (status) {
492    case "RUNNING":
493    case "CREATING":
494    case "VERIFYING":
495      return "suggestion";
496    case "COMPLETED":
497      return "success";
498    case "FAILED":
499      return "error";
500    case "INPUT_REQUIRED":
501      return "warning";
502    case "CANCELLED":
503    case "BUDGET_EXCEEDED":
504      return "inactive";
505    default:
506      return undefined;
507  }
508};
509
510export const glyph = (status: string): string => {
511  switch (status) {
512    case "COMPLETED":
513      return "✓";
514    case "FAILED":
515    case "BUDGET_EXCEEDED":
516      return "✕";
517    case "CANCELLED":
518      return "–";
519    case "INPUT_REQUIRED":
520      return "?";
521    default:
522      return "●";
523  }
524};
525
526export const statusLabel = (status: string, awaiting?: string): string => {
527  if (status === "INPUT_REQUIRED" && awaiting === "budget_extension") return "予算の承認待ち";
528  switch (status) {
529    case "COMPLETED":
530      return "完了";
531    case "FAILED":
532      return "失敗";
533    case "BUDGET_EXCEEDED":
534      return "予算超過";
535    case "CANCELLED":
536      return "取り消し";
537    case "INPUT_REQUIRED":
538      return "入力待ち";
539    case "CREATING":
540      return "準備中";
541    case "VERIFYING":
542      return "検証中";
543    case "RUNNING":
544      return "実行中";
545    default:
546      return status;
547  }
548};
549
550/** トーストの文。 */
551export const toastText = (title: string, status: string): string => {
552  const what =
553    status === "COMPLETED"
554      ? "完了"
555      : status === "FAILED"
556        ? "失敗"
557        : status === "BUDGET_EXCEEDED"
558          ? "予算超過で停止"
559          : status === "CANCELLED"
560            ? "取り消し"
561            : status;
562  return `${title} が${what}`;
563};
564
565/** 予算の承認待ちに入ったときのトースト。 */
566export const budgetToastText = (title: string): string =>
567  `${title} が予算の承認待ち(利用者に確認してから task_answer で答える)`;
568
569/** 質問への答え待ち。INPUT_REQUIRED か、終端でない間の waiting_for_answer。 */
570export const isWaiting = (t: { status: string; waiting?: boolean }): boolean =>
571  t.status === "INPUT_REQUIRED" || (t.waiting === true && !isTerminal(t.status));
572
573export const taskKey = (t: Pick<RovoTask, "server" | "taskId">): string =>
574  `${t.server}/${t.taskId}`;
575
576export const cleanTitle = (title: unknown, fallback: string): string => {
577  const text = typeof title === "string" ? stripFence(title).replace(/\s+/g, " ").trim() : "";
578  return (text === "" ? fallback : text).slice(0, MAX_TITLE);
579};
580
581/** 終端から 1 時間たったものを消す。task_start で拾ったものは新しい方から 10 件、task_list から入ったものは 50 件に絞る。 */
582export const pruneTasks = (list: RovoTask[], now: number): RovoTask[] => {
583  const live = list.filter(
584    (t) => t.terminalAt === undefined || now - t.terminalAt < KEEP_AFTER_TERMINAL_MS,
585  );
586  const keep = new Set<RovoTask>([
587    ...live.filter((t) => t.listed !== true).slice(-MAX_TASKS),
588    ...live.filter((t) => t.listed === true).slice(-MAX_LISTED),
589  ]);
590  return live.filter((t) => keep.has(t));
591};
592
593const asCount = (value: unknown): number | undefined =>
594  typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 99
595    ? value
596    : undefined;
597
598/** PrLink(number は null もある)。url は https のみ。 */
599const asPrLink = (value: unknown): PrLink | undefined => {
600  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
601  const { number, url } = value as Record<string, unknown>;
602  const safe = httpsOnly(url);
603  if (safe === undefined) return undefined;
604  if (number === null) return { number: null, url: safe };
605  return typeof number === "number" && Number.isInteger(number) && number >= 1
606    ? { number, url: safe }
607    : undefined;
608};
609
610export const shortText = (value: unknown, max: number): string | undefined => {
611  if (typeof value !== "string") return undefined;
612  const text = stripFence(value).replace(/\s+/g, " ").trim().slice(0, max);
613  return text === "" ? undefined : text;
614};
615
616export type TaskKind = "task" | "review";
617
618/** task_list の stage。駅の並びと同じ順で、最後の done は全部済んだ印。 */
619const STAGES: Record<TaskKind, readonly string[]> = {
620  task: ["setup", "work", "verify", "pr", "done"],
621  review: ["fetch", "investigate", "post", "done"],
622};
623
624const asStage = (kind: TaskKind, value: unknown): string | undefined =>
625  typeof value === "string" && STAGES[kind].includes(value) ? value : undefined;
626
627/** task_list 由来のフィールドを、形を確かめて取り出す($.store の値も同じ規則で通す)。 */
628const coerceListed = (t: Record<string, unknown>): Partial<RovoTask> => {
629  const kind: TaskKind | undefined = t.kind === "task" || t.kind === "review" ? t.kind : undefined;
630  const repository = shortText(t.repository, MAX_REPO);
631  const model = shortText(t.model, MAX_MODEL);
632  const stage = kind === undefined ? undefined : asStage(kind, t.stage);
633  const verifyAttempt = asCount(t.verifyAttempt);
634  const verifyMax = asCount(t.verifyMax);
635  const pr = asPrLink(t.pr);
636  return {
637    ...(kind !== undefined ? { kind } : {}),
638    ...(repository !== undefined ? { repository } : {}),
639    ...(model !== undefined ? { model } : {}),
640    ...(stage !== undefined ? { stage } : {}),
641    ...(verifyAttempt !== undefined ? { verifyAttempt } : {}),
642    ...(verifyMax !== undefined ? { verifyMax } : {}),
643    ...(t.waiting === true ? { waiting: true } : {}),
644    ...(t.listed === true ? { listed: true } : {}),
645    ...(pr !== undefined ? { pr } : {}),
646  };
647};
648
649/** $.store から読んだ値を信用せず、形を確かめて作り直す。 */
650export const coerceTasks = (value: unknown): RovoTask[] => {
651  if (!Array.isArray(value)) return [];
652  const out: RovoTask[] = [];
653  for (const raw of value) {
654    const t = raw as Record<string, unknown> | null;
655    if (
656      typeof t !== "object" ||
657      t === null ||
658      typeof t.taskId !== "string" ||
659      typeof t.server !== "string" ||
660      typeof t.status !== "string" ||
661      typeof t.startedAt !== "number"
662    ) {
663      continue;
664    }
665    const dashboardUrl = httpsOnly(t.dashboardUrl);
666    out.push({
667      taskId: t.taskId,
668      title: cleanTitle(t.title, t.taskId),
669      server: t.server,
670      prefix: typeof t.prefix === "string" ? t.prefix : "",
671      startedAt: t.startedAt,
672      status: stripFence(t.status),
673      ...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
674      ...(typeof t.terminalAt === "number" ? { terminalAt: t.terminalAt } : {}),
675      ...(t.notified === true ? { notified: true } : {}),
676      ...(t.noConsole === true ? { noConsole: true } : {}),
677      ...(t.awaiting === "budget_extension" ? { awaiting: "budget_extension" as const } : {}),
678      ...(typeof t.consoleRetryAt === "number" ? { consoleRetryAt: t.consoleRetryAt } : {}),
679      ...coerceListed(t),
680    });
681  }
682  return out;
683};
684
685// --- パイプラインの列(lanes) ------------------------------------------------
686
687/** 駅の名前(列の下の行に出す)。 */
688export const STATIONS: Record<TaskKind, readonly string[]> = {
689  task: ["準備", "実行", "検証", "PR"],
690  review: ["取出", "調査", "投稿"],
691};
692
693/** task_console の phase(task_list が無いサーバー)を task の stage に読み替える。 */
694const PHASE_TO_STAGE: Record<string, string> = {
695  prepare: "setup",
696  run: "work",
697  verify: "verify",
698  done: "done",
699};
700
701/**
702 * いまの駅の番号。済んだ印(done)は駅の数と同じ値。stage が無い・知らない・別の kind のものは null。
703 * kind が task のときは console の phase(prepare|run|verify|done)も読む。
704 */
705export const stageIndex = (kind: TaskKind, stage: string | null | undefined): number | null => {
706  if (typeof stage !== "string") return null;
707  const names = STAGES[kind];
708  const direct = names.indexOf(stage);
709  if (direct >= 0) return direct;
710  const mapped = kind === "task" ? PHASE_TO_STAGE[stage] : undefined;
711  const at = mapped === undefined ? -1 : names.indexOf(mapped);
712  return at >= 0 ? at : null;
713};
714
715const isWide = (cp: number): boolean =>
716  (cp >= 0x1100 && cp <= 0x115f) ||
717  (cp >= 0x2e80 && cp <= 0xa4cf) ||
718  (cp >= 0xac00 && cp <= 0xd7a3) ||
719  (cp >= 0xf900 && cp <= 0xfaff) ||
720  (cp >= 0xfe30 && cp <= 0xfe6f) ||
721  (cp >= 0xff00 && cp <= 0xff60) ||
722  (cp >= 0xffe0 && cp <= 0xffe6) ||
723  (cp >= 0x1f300 && cp <= 0x1faff) ||
724  (cp >= 0x20000 && cp <= 0x3fffd);
725
726/** 端末での表示幅。全角(日本語など)は 2 桁、それ以外は 1 桁。 */
727export const displayWidth = (s: string): number => {
728  let w = 0;
729  for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
730  return w;
731};
732
733export const padWidth = (s: string, width: number): string =>
734  s + " ".repeat(Math.max(0, width - displayWidth(s)));
735
736/** 表示幅が width 以下になるよう切り、切ったら末尾に … を付ける。 */
737export const truncateWidth = (s: string, width: number): string => {
738  if (displayWidth(s) <= width) return s;
739  let out = "";
740  let w = 0;
741  for (const ch of s) {
742    const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
743    if (w + cw > width - 1) break;
744    out += ch;
745    w += cw;
746  }
747  return `${out}…`;
748};
749
750/** 線路 1 区間の長さ。1 駅ぶんの幅は駅 1 + 線路 SEG。名前の行もこの幅で並べる。 */
751const SEG = 4;
752const COLUMN = SEG + 1;
753
754/** 列の下の行(駅の名前)。 */
755export const labelRow = (kind: TaskKind): string =>
756  STATIONS[kind].map((name) => padWidth(name, COLUMN)).join("");
757
758export type Cell = { text: string; color?: ThemeKey; bold?: boolean };
759
760export type LaneState = "active" | "waiting" | "done" | "failed";
761
762const SPINNER = "◐◓◑◒";
763
764/** 同じ色が続く文字をまとめて 1 つの Cell にする。 */
765const runs = (parts: { text: string; color?: ThemeKey }[]): Cell[] => {
766  const out: Cell[] = [];
767  for (const p of parts) {
768    const last = out[out.length - 1];
769    if (last !== undefined && last.color === p.color) last.text += p.text;
770    else out.push({ text: p.text, ...(p.color !== undefined ? { color: p.color } : {}) });
771  }
772  return out;
773};
774
775/**
776 * 駅と線路 1 行ぶん。済んだ駅は ●━━、いまの駅はスピナー(着いた直後は白い ◉)、
777 * その先の線路に ▶ の光が frame ごとに 1 つ進む。質問待ちは流れを止めて ? を点滅。
778 * at は いまの駅の番号(駅の数と同じなら全部済み)。null は分からない(全部これから)。
779 */
780export const laneCells = (opts: {
781  kind: TaskKind;
782  at: number | null;
783  state: LaneState;
784  frame: number;
785  arrived: boolean;
786}): Cell[] => {
787  const n = STATIONS[opts.kind].length;
788  const { state, frame } = opts;
789  const at = state === "done" ? n : opts.at === null ? -1 : Math.min(opts.at, n);
790  const cells: Cell[] = [];
791  for (let j = 0; j < n; j++) {
792    if (j < at) cells.push({ text: "●", color: "success" });
793    else if (j === at && state === "waiting") {
794      cells.push(frame % 2 === 0 ? { text: "?", color: "warning" } : { text: " " });
795    } else if (j === at && state === "failed") cells.push({ text: "✕", color: "error" });
796    else if (j === at && opts.arrived) cells.push({ text: "◉", color: "text", bold: true });
797    else if (j === at) {
798      cells.push({ text: SPINNER[frame % SPINNER.length] ?? "◐", color: "suggestion" });
799    } else cells.push({ text: "○", color: "subtle" });
800    if (j === n - 1) break;
801    if (j < at) cells.push({ text: "━".repeat(SEG), color: "success" });
802    else if (j === at && state === "active") {
803      const p = frame % (SEG + 2);
804      const bar: { text: string; color?: ThemeKey }[] = [];
805      for (let k = 0; k < SEG; k++) {
806        if (k === p) bar.push({ text: "▶", color: "claude" });
807        else if (k === p - 1) bar.push({ text: "━", color: "suggestion" });
808        else bar.push({ text: "┈", color: "subtle" });
809      }
810      cells.push(...runs(bar));
811    } else cells.push({ text: "┈".repeat(SEG), color: "subtle" });
812  }
813  return cells;
814};
815
816export type HeadingTone = "warning" | "suggestion" | "success";
817export type HeadingPart = { label: string; n: number; tone: HeadingTone };
818
819/**
820 * ペインの見出しの部品。質問待ちが 1 件以上のときだけ先頭に出し、そのあと作業中(質問待ちでない実行中)・完了。
821 * 0 の項目は残す(位置を動かさない)。費用は出さない。
822 */
823export const headingText = (tasks: readonly RovoTask[]): HeadingPart[] => {
824  let run = 0;
825  let wait = 0;
826  let done = 0;
827  for (const t of tasks) {
828    if (t.status === "COMPLETED") done++;
829    else if (isTerminal(t.status)) continue;
830    else if (isWaiting(t)) wait++;
831    else run++;
832  }
833  return [
834    ...(wait > 0 ? [{ label: "質問待ち", n: wait, tone: "warning" as const }] : []),
835    { label: "作業中", n: run, tone: "suggestion" as const },
836    { label: "完了", n: done, tone: "success" as const },
837  ];
838};
839
840const MAX_RIGHT = 40;
841
842/** 列の右に出す文字。実行中はいまの道具と対象、終われば結果、質問待ちはその旨。 */
843export const rightText = (
844  t: RovoTask,
845  nowText: string | null,
846  pr?: PrLink,
847): { text: string; color?: ThemeKey } => {
848  if (isTerminal(t.status)) {
849    const link = pr ?? t.pr;
850    const text =
851      t.status === "COMPLETED"
852        ? link === undefined
853          ? "✓ 完了"
854          : link.number === null
855            ? "✓ PR"
856            : `✓ PR #${link.number}`
857        : `${glyph(t.status)} ${statusLabel(t.status)}`;
858    const color = statusColor(t.status);
859    return { text: truncateWidth(text, MAX_RIGHT), ...(color !== undefined ? { color } : {}) };
860  }
861  if (isWaiting(t)) {
862    const label = statusLabel(
863      "INPUT_REQUIRED",
864      t.status === "INPUT_REQUIRED" ? t.awaiting : undefined,
865    );
866    return { text: truncateWidth(label, MAX_RIGHT), color: "warning" };
867  }
868  const attempt =
869    t.stage === "verify" && t.verifyAttempt !== undefined
870      ? `[${t.verifyAttempt}${t.verifyMax !== undefined ? `/${t.verifyMax}` : ""}]`
871      : "";
872  const text = [attempt, nowText ?? ""].filter((x) => x !== "").join(" ");
873  return { text: truncateWidth(text, MAX_RIGHT) };
874};
875
876// --- task_list ----------------------------------------------------------------
877
878/** task_list の 1 件(整えたもの)。 */
879export type ListedTask = {
880  taskId: string;
881  kind: TaskKind;
882  title: string;
883  repository?: string;
884  status: string;
885  stage?: string;
886  verifyAttempt?: number;
887  verifyMax?: number;
888  waiting: boolean;
889  model?: string;
890  createdAt?: number;
891  updatedAt?: number;
892  endedAt?: number;
893  pr?: PrLink;
894  dashboardUrl?: string;
895};
896
897const asTime = (value: unknown): number | undefined => {
898  if (typeof value !== "string") return undefined;
899  const ms = Date.parse(value);
900  return Number.isFinite(ms) ? ms : undefined;
901};
902
903/**
904 * task_list の結果。形が違うもの(task_list が無いサーバーのエラーなど)は null。
905 * 1 件ずつ形を確かめ、task_id か status が無い行は落とす。ワーカーが書いた文字列は整える。
906 */
907export const parseTaskList = (text: string): { tasks: ListedTask[]; truncated: boolean } | null => {
908  const body = asObject(text);
909  if (body === null || !Array.isArray(body.tasks)) return null;
910  const tasks: ListedTask[] = [];
911  for (const raw of body.tasks as unknown[]) {
912    if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
913    const r = raw as Record<string, unknown>;
914    if (typeof r.task_id !== "string" || r.task_id === "" || typeof r.status !== "string") continue;
915    const taskId = stripFence(r.task_id);
916    const kind: TaskKind = r.kind === "review" ? "review" : "task";
917    const repository = shortText(r.repository, MAX_REPO);
918    const model = shortText(r.model, MAX_MODEL);
919    const stage = asStage(kind, r.stage);
920    const verifyAttempt = asCount(r.verify_attempt);
921    const verifyMax = asCount(r.verify_max);
922    const createdAt = asTime(r.created_at);
923    const updatedAt = asTime(r.updated_at);
924    const endedAt = asTime(r.ended_at);
925    const pr = asPrLink(r.pr);
926    const dashboardUrl = httpsOnly(r.dashboard_url);
927    tasks.push({
928      taskId,
929      kind,
930      title: cleanTitle(r.title, taskId),
931      ...(repository !== undefined ? { repository } : {}),
932      status: stripFence(r.status),
933      ...(stage !== undefined ? { stage } : {}),
934      ...(verifyAttempt !== undefined ? { verifyAttempt } : {}),
935      ...(verifyMax !== undefined ? { verifyMax } : {}),
936      waiting: r.waiting_for_answer === true,
937      ...(model !== undefined ? { model } : {}),
938      ...(createdAt !== undefined ? { createdAt } : {}),
939      ...(updatedAt !== undefined ? { updatedAt } : {}),
940      ...(endedAt !== undefined ? { endedAt } : {}),
941      ...(pr !== undefined ? { pr } : {}),
942      ...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
943    });
944  }
945  return { tasks, truncated: body.truncated === true };
946};
947
948/**
949 * task_list の結果を追跡リストに混ぜる。新しいものは足し、知っているものは状態を更新する
950 * (startedAt と task_start で付けた印は保つ)。すでに終わっていたものは通知済みにして、トーストを出さない。
951 * 終端に変わったことを知らせる(トースト・notified)のは呼ぶ側。開始時刻の古い順に並べる。
952 */
953export const mergeListed = (
954  existing: RovoTask[],
955  listed: readonly ListedTask[],
956  endpoint: { server: string; prefix: string },
957  now: number,
958): RovoTask[] => {
959  const out = [...existing];
960  for (const l of listed) {
961    const key = taskKey({ server: endpoint.server, taskId: l.taskId });
962    const at = out.findIndex((x) => taskKey(x) === key);
963    const prev = at >= 0 ? out[at] : undefined;
964    const terminal = isTerminal(l.status);
965    const fields: Partial<RovoTask> = {
966      kind: l.kind,
967      ...(l.repository !== undefined ? { repository: l.repository } : {}),
968      ...(l.model !== undefined ? { model: l.model } : {}),
969      ...(l.stage !== undefined ? { stage: l.stage } : {}),
970      ...(l.verifyAttempt !== undefined ? { verifyAttempt: l.verifyAttempt } : {}),
971      ...(l.verifyMax !== undefined ? { verifyMax: l.verifyMax } : {}),
972      ...(l.waiting ? { waiting: true as const } : {}),
973      ...(l.pr !== undefined ? { pr: l.pr } : {}),
974    };
975    const dashboardUrl = l.dashboardUrl ?? prev?.dashboardUrl;
976    if (prev === undefined) {
977      out.push({
978        taskId: l.taskId,
979        title: l.title,
980        server: endpoint.server,
981        prefix: endpoint.prefix,
982        startedAt: l.createdAt ?? now,
983        status: l.status,
984        ...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
985        ...fields,
986        listed: true,
987        ...(terminal
988          ? { terminalAt: l.endedAt ?? l.updatedAt ?? now, notified: true as const }
989          : {}),
990      });
991      continue;
992    }
993    // list が毎回言い直すフィールドは、前の値を捨ててから入れ直す。
994    const {
995      stage: _stage,
996      verifyAttempt: _attempt,
997      verifyMax: _max,
998      waiting: _waiting,
999      pr: _pr,
1000      awaiting: _awaiting,
1001      ...rest
1002    } = prev;
1003    const becameTerminal = !isTerminal(prev.status) && terminal;
1004    out[at] = {
1005      ...rest,
1006      title: l.title,
1007      status: l.status,
1008      ...(l.status === "INPUT_REQUIRED" && prev.awaiting !== undefined
1009        ? { awaiting: prev.awaiting }
1010        : {}),
1011      ...(dashboardUrl !== undefined ? { dashboardUrl } : {}),
1012      ...fields,
1013      listed: true,
1014      ...(becameTerminal ? { terminalAt: l.endedAt ?? now } : {}),
1015    };
1016  }
1017  return out
1018    .map((t, i) => ({ t, i }))
1019    .sort((a, b) => a.t.startedAt - b.t.startedAt || a.i - b.i)
1020    .map(({ t }) => t);
1021};
1022
1023/** `mcp__<server>__<prefix>task_*` のどれか。task_list を呼ぶ先(server と prefix)を知るのに使う。 */
1024export const matchRovoTool = (tool: string): { server: string; prefix: string } | null => {
1025  if (!tool.startsWith("mcp__")) return null;
1026  const parts = tool.split("__");
1027  if (parts.length < 3) return null;
1028  const name = parts[parts.length - 1] ?? "";
1029  const hit =
1030    /^((?:[A-Za-z0-9-]+_)*)task_(?:start|status|console|result|review|list|wait|answer|steer|cancel)$/.exec(
1031      name,
1032    );
1033  const server = parts.slice(1, -1).join("__");
1034  if (hit === null || server === "") return null;
1035  return { server, prefix: hit[1] ?? "" };
1036};
1037
1038// --- ペインの情報設計(0.6.0) -------------------------------------------------------
1039
1040/** 線路の列の幅(線路 16 桁 + あき 3。列見出しの駅名とモデルの間を 2 桁以上あける)。 */
1041export const TRACK_COL = 19;
1042const MODEL_COL = 10;
1043const ELAPSED_COL = 9;
1044const MARK_COL = 2;
1045
1046export type PaneCols = { title: number; model: number; elapsed: number };
1047
1048/**
1049 * 1 行目の列の幅。選択の帯 2 + タイトル + あき 1 + 線路 18 + モデル + あき 1 + 経過。
1050 * タイトルは残りを使う。狭くてタイトルが 12 桁を割るときは、モデルの列をやめる。
1051 */
1052export const paneColumns = (total: number): PaneCols => {
1053  const rest = (model: number): number =>
1054    total - MARK_COL - 1 - TRACK_COL - model - (model > 0 ? 1 : 0) - ELAPSED_COL;
1055  let model = MODEL_COL;
1056  if (rest(model) < 12) model = 0;
1057  return { title: Math.max(1, rest(model)), model, elapsed: ELAPSED_COL };
1058};
1059
1060const stationNames = (kind: TaskKind): string =>
1061  STATIONS[kind]
1062    .map((name, i, all) => (i === all.length - 1 ? name : padWidth(name, COLUMN)))
1063    .join("");
1064
1065/** 列見出しの 1 行(駅の名前はここに 1 回だけ)。 */
1066export const columnHeader = (cols: PaneCols, kind: TaskKind): string => {
1067  const title = " ".repeat(MARK_COL + cols.title);
1068  const track = padWidth(stationNames(kind), TRACK_COL);
1069  const model = cols.model > 0 ? padWidth("モデル", cols.model) + " " : "";
1070  const elapsed = " ".repeat(Math.max(0, cols.elapsed - displayWidth("経過"))) + "経過";
1071  return `${title} ${track}${model}${elapsed}`;
1072};
1073
1074/** review のワーカーをまとめた直前の見出し(そのグループ専用の駅の名前)。 */
1075export const reviewHeader = (cols: PaneCols): string =>
1076  `${" ".repeat(MARK_COL + cols.title)} ${stationNames("review")}`;
1077
1078/** 終了からの時間。1 分未満は「たった今」。 */
1079export const agoText = (ms: number): string => {
1080  const minutes = Math.floor(Math.max(0, ms) / 60_000);
1081  if (minutes < 1) return "たった今";
1082  return minutes < 60 ? `${minutes}分前` : `${Math.floor(minutes / 60)}時間前`;
1083};
1084
1085const isDone = (t: RovoTask): boolean => isTerminal(t.status);
1086
1087/** 並び: 質問待ち → 作業中(開始が新しい順)→ 終端(終了が新しい順)。同じなら元の順。 */
1088export const orderTasks = (tasks: readonly RovoTask[]): RovoTask[] => {
1089  const rank = (t: RovoTask): number => (isDone(t) ? 2 : isWaiting(t) ? 0 : 1);
1090  return tasks
1091    .map((t, i) => ({ t, i }))
1092    .sort((a, b) => {
1093      const ra = rank(a.t);
1094      const rb = rank(b.t);
1095      if (ra !== rb) return ra - rb;
1096      const ka = ra === 2 ? (a.t.terminalAt ?? a.t.startedAt) : a.t.startedAt;
1097      const kb = rb === 2 ? (b.t.terminalAt ?? b.t.startedAt) : b.t.startedAt;
1098      return kb - ka || a.i - b.i;
1099    })
1100    .map(({ t }) => t);
1101};
1102
1103/** 自動で出すワーカー: 質問待ち > 最新の実行中 > 最新(開始が新しいもの。同じなら後に入ったもの)。 */
1104export const bestWorker = (tasks: readonly RovoTask[]): RovoTask | undefined => {
1105  const newest = (list: RovoTask[]): RovoTask | undefined =>
1106    list.reduce<RovoTask | undefined>(
1107      (a, t) => (a === undefined || t.startedAt >= a.startedAt ? t : a),
1108      undefined,
1109    );
1110  return (
1111    newest(tasks.filter((t) => !isDone(t) && isWaiting(t))) ??
1112    newest(tasks.filter((t) => !isDone(t))) ??
1113    newest([...tasks])
1114  );
1115};
1116
1117/**
1118 * イベント(新しい作業中のタスクを拾った・質問待ちに入った)のときに出すワーカー。
1119 * いま出しているワーカーが作業中・質問待ちなら動かさない。終端か、一覧に無いか、何も出していないときだけ best に移る。
1120 */
1121export const moveTarget = (tasks: readonly RovoTask[], current: string | null): string | null => {
1122  const cur = current === null ? undefined : tasks.find((t) => taskKey(t) === current);
1123  if (cur !== undefined && !isDone(cur)) return current;
1124  const best = bestWorker(tasks);
1125  return best === undefined ? null : taskKey(best);
1126};
1127
1128/** 終端の行の右(モデルの列)に出す文字。完了は PR、それ以外は結果。 */
1129export const terminalCell = (t: RovoTask, pr?: PrLink): { text: string; color?: ThemeKey } => {
1130  const color = statusColor(t.status);
1131  const out = (text: string): { text: string; color?: ThemeKey } => ({
1132    text,
1133    ...(color !== undefined ? { color } : {}),
1134  });
1135  if (t.status === "COMPLETED") {
1136    const link = pr ?? t.pr;
1137    return out(link === undefined ? "完了" : link.number === null ? "PR" : `PR #${link.number}`);
1138  }
1139  return out(statusLabel(t.status));
1140};
1141
1142const QUESTION_NOTE = "? 質問に答えてください(会話で task_answer)";
1143const BUDGET_NOTE = "? 予算の承認待ち(利用者に確認してから task_answer)";
1144
1145/** 2 行目に出すいまの動き。質問待ちは warning、検証中は [n/m] を前に。 */
1146export const detailText = (
1147  t: RovoTask,
1148  nowText: string | null,
1149): { text: string; color?: ThemeKey } => {
1150  if (isWaiting(t)) {
1151    return {
1152      text:
1153        t.status === "INPUT_REQUIRED" && t.awaiting === "budget_extension"
1154          ? BUDGET_NOTE
1155          : QUESTION_NOTE,
1156      color: "warning",
1157    };
1158  }
1159  const attempt =
1160    t.stage === "verify" && t.verifyAttempt !== undefined
1161      ? `[${t.verifyAttempt}${t.verifyMax !== undefined ? `/${t.verifyMax}` : ""}]`
1162      : "";
1163  return { text: [attempt, nowText ?? ""].filter((x) => x !== "").join(" ") };
1164};
1165
1166export const MIN_WINDOW = 6;
1167const MAX_WORKING = 4;
1168const MAX_ENDED = 5;
1169/** 一覧以外の行: 見出し 1 + 列見出し 1 + 枠 2 + 余り 1(タイトルは枠の上辺に載る)。 */
1170const LIST_OVERHEAD = 5;
1171
1172/** 見出しの下・一覧と枠の間に入れる空行。窓が MIN_WINDOW を超えて余った行からだけ取る(最大 2)。 */
1173export const spacerRows = (leftover: number): number =>
1174  Math.min(2, Math.max(0, leftover - MIN_WINDOW));
1175
1176/**
1177 * 枠の上辺 `╭─ タイトル ─── リンク ─╮` の部品。幅は width ちょうど:
1178 * 3(`╭─ `)+ タイトル + 1 + dashes + (リンクあり: 1 + linksWidth + 3 / なし: 1 `╮`)。
1179 * タイトルは入る幅で切る。リンクの分の余地(ダッシュ 3 本以上 + タイトル 1 桁)がなければリンクは出さない。
1180 */
1181export const frameTop = (
1182  width: number,
1183  title: string,
1184  linksWidth: number,
1185): { title: string; dashes: number; showLinks: boolean } => {
1186  const withLinks = width - (3 + 1 + 3 + 1 + linksWidth + 3);
1187  const showLinks = linksWidth > 0 && withLinks >= 1;
1188  const cap = showLinks ? withLinks : width - 6;
1189  const cut = cap < 1 ? "" : truncateWidth(title, cap);
1190  const tail = showLinks ? 1 + linksWidth + 3 : 1;
1191  return { title: cut, dashes: Math.max(1, width - (3 + displayWidth(cut) + 1 + tail)), showLinks };
1192};
1193
1194export type WorkerPlan = {
1195  shown: RovoTask[];
1196  hidden: number;
1197  hiddenAllTerminal: boolean;
1198  rows: number;
1199};
1200
types/index.d.ts 99 lines
1/** 追跡している rovo のタスク 1 件($.store にも保存する)。 */
2export type RovoTask = {
3  taskId: string;
4  title: string;
5  /** task_start を呼んだ MCP サーバー名(ツール名の `mcp__<server>__` の部分)。 */
6  server: string;
7  /** ツール名の接頭辞。空か `my-rovo_` のようなもの。 */
8  prefix: string;
9  /** `https://` で始まるものだけ入れる。 */
10  dashboardUrl?: string;
11  /** 追跡を始めた時刻(ms)。 */
12  startedAt: number;
13  /** RUNNING / INPUT_REQUIRED / COMPLETED / FAILED / BUDGET_EXCEEDED / CANCELLED など。 */
14  status: string;
15  /** 終端になったと気づいた時刻(ms)。1 時間たつと消す。 */
16  terminalAt?: number;
17  /** 終端のトーストを出し済み。 */
18  notified?: boolean;
19  /** 予算の延長の承認待ち(INPUT_REQUIRED のとき)。トーストを繰り返さない印も兼ねる。 */
20  awaiting?: "budget_extension";
21  /** task_console が使えなかったので task_status に切り替えた。 */
22  noConsole?: boolean;
23  /** noConsole の間、task_console を次に試し直す時刻(ms)。60 秒に 1 回。 */
24  consoleRetryAt?: number;
25  /** ここから下は task_list(呼んだ人の全タスク)から来る。task_list が無いサーバーでは入らない。 */
26  kind?: "task" | "review";
27  repository?: string;
28  model?: string;
29  /** task: setup|work|verify|pr|done、review: fetch|investigate|post|done。 */
30  stage?: string;
31  verifyAttempt?: number;
32  verifyMax?: number;
33  /** 質問への答え待ち(waiting_for_answer)。 */
34  waiting?: boolean;
35  /** task_list から入ったタスク。一覧の上限が task_start で拾ったものと別(50 件)。 */
36  listed?: boolean;
37  /** task_list の pr(https のみ)。task_console の pr があればそちらを優先する。 */
38  pr?: { number: number | null; url: string };
39};
40
41/** task_console(か task_status)の最後の取得結果。囲みは外し、制御文字は落としてある。 */
42export type RovoConsole = {
43  phase: string | null;
44  stoppedPhase: string | null;
45  nowText: string | null;
46  /** nowText のコマンドの場所(/workspace からの相対)。 */
47  nowWhere?: string;
48  command: string | null;
49  /** command の場所(/workspace からの相対)。 */
50  commandWhere?: string;
51  tail: string | null;
52  isRunning: boolean;
53  /** 出力に対応していないサーバー(task_status だけ)。 */
54  hasOutput: boolean;
55  /** 取得した時刻(ms)。 */
56  fetchedAt: number;
57  dashboardUrl?: string;
58  /** PR へのリンク(https のみ)。number が null なら「PR」とだけ出す。 */
59  pr?: { number: number | null; url: string };
60};
61
62/** ログ(task_console の log)の 1 行。kind で色が決まる。where は cmd の場所(/workspace からの相対)。 */
63export type RovoLogLine = {
64  kind: "cmd" | "out" | "edit" | "commit" | "web" | "tool" | "status" | "pr" | "error";
65  text: string;
66  where?: string;
67};
68
69declare module "claude-code" {
70  interface PluginState {
71    rovo: {
72      tasks: RovoTask[];
73      /** キーは taskKey(task) = `${server}/${taskId}`。 */
74      consoles: Record<string, RovoConsole>;
75      /** 出力の窓。follow は末尾に追従中。offset は追従を止めたときの先頭行(出力の中の行番号)。 */
76      scroll: { follow: boolean; offset: number };
77      /** いまペインに出しているワーカーの taskKey。描くたびには選び直さず、クリックとイベント(新しい作業中のタスク・質問待ち)のときだけ替わる。替わったら窓を追従に戻す。 */
78      shown: string | null;
79      /** /rovo-create-task が実行された時刻(ms)。タスクを拾うと null に戻る。10 分たつと表示しない。 */
80      creating: number | null;
81      /** 描き直しの番号。0.7 秒ごとに 1 つ進み、スピナー・▶・点滅の位置を決める。 */
82      frame: number;
83      /** 各ワーカーが今いる駅と、その駅に着いたフレーム(着いた直後の ◉ のため)。キーは taskKey。 */
84      arrivals: Record<string, { at: number; since: number }>;
85      /** rovo のツールを呼んだ接続先(server と prefix)。task_list を呼ぶ先。$.store にも残す。 */
86      endpoints: { server: string; prefix: string }[];
87      /** task_list の接続先(server と prefix の組)ごとの状態。キーは `${server}/${prefix}`。 */
88      lists: Record<string, { supported: boolean; retryAt: number; lastAt: number }>;
89      /**
90       * 積み上がるログ。キーは taskKey。supported は task_console が log を返したワーカー
91       * (false なら最新のコマンドだけの表示)。cursor は次に渡す log_after、lines は最大 500 行。
92       */
93      logs: Record<string, { supported: boolean; cursor: string; lines: RovoLogLine[] }>;
94      /** log_after を受け付けなかった接続先(`${server}/${prefix}`)を、この時刻(ms)まで使わない。 */
95      logOff: Record<string, number>;
96    };
97  }
98}
99