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

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 をまとめた直前に レビュー … 取出 調査 投稿 の見出しを足す▎)・タイトル・線路・モデル(anthropic/claude-sonnet-5-5 は sonnet-5.5)・経過時間。タイトルは、ペインの幅から他の列を引いた残りを使う。線路は、済んだ駅が緑の ●━━、いまの駅がスピナー、次の駅への線路に光(▶)が流れる。駅に着いた直後は ◉ が白く光る。質問待ち(INPUT_REQUIRED)は流れを止めて ? を点滅する。2 行目は字下げの後に、リポジトリ名(owner/ を落とした名前だけ)といまの動き($ コマンド。検証中は [2/4] が前に付く。質問待ちは warning 色で ? 質問に答えてください(会話で task_answer))✓ PR #n / ✓ 完了 / ✕ 失敗 / ✕ 予算超過 / – 取り消し。状態色)・モデル・終わってからの時間(3分前)。タイトルの前に記号は付けない。結果以外は薄くし、失敗の行は薄くしない。終端の行は最大 5 行で、それ以上は 他 n 件(完了。終わって 1 時間で消えます)▎ の付いた行)のタイトルを全文で ─ タイトル の行に出し、右端に PR #n · ダッシュボード のリンクを寄せる(タイトルはリンクを優先して切る)task_list か task_start で分かっていれば常に出す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 分間は付けない$.store に残し、再起動後は続きから積むtask_console の awaiting が budget_extension)のワーカーは「予算の承認待ち」と出し、入った時に 1 回トーストで知らせる。答えるのは利用者に確認してから(mod 自身は答えない)並べるのは「呼んだ人の全タスク」。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 が自動で作るので、コミットしない。
hooks/register.tsx 1269 lines1import 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 lines1import 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};
1200types/index.d.ts 99 lines1/** 追跡している 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