🐥

OpenCode Viewer(opencode-viewer)入門 ─ OpenCodeセッションを可視化・操作する

に公開

OpenCodeを使ったAIコーディングが加速する中、対話ログやツール実行履歴を「見える化」して、再開・中断・履歴の俯瞰をサクッとこなせるウェブクライアントが欲しくなります。そこで、Codex Viewerをベースに派生した「OpenCode Viewer(opencode-viewer)」を公開しました。

スクリーンショット 2025-10-03 11.14.09.png

この記事では、OpenCode Viewerの特徴、アーキテクチャ、活用ポイントをコード抜粋を交えて解説します。

OpenCode Viewerとは?

OpenCode Viewerは、OpenCodeのセッション・メッセージ・パーツ(ツール結果など)をブラウザで俯瞰し、対話の再開・進行中タスクの可視化・中断ができるウェブクライアントです。

  • セッションの一覧・検索・並び替え
  • セッション詳細での「ユーザー/アシスタント/推論/ツール呼び出し/結果」タイムライン表示
  • 実行中タスクのステータス監視(running/waiting/completed/failed)と中断
  • SSEによるリアルタイム更新(ファイル監視で差分を即反映)
  • Git統合(branches/commits/diff)で差分確認
  • ファイル補完APIで対話中のパス入力を支援

Quick Startは次のとおりです。

# インストール不要で起動
PORT=3400 npx @nogataka/opencode-viewer@latest

# グローバルインストール
npm install -g @nogataka/opencode-viewer
opencode-viewer

# ソースから
git clone https://github.com/nogataka/opencode-viewer.git
cd opencode-viewer
pnpm install
pnpm build
pnpm start

デフォルトで http://localhost:3400 が起動します。自動ブラウザ起動は CC_VIEWER_NO_AUTO_OPEN=1 などで無効化できます。

アーキテクチャ概要

  • フロント: Next.js 15 + React 19、TanStack Query、Jotai
  • バックエンド: Hono API(Node)、SSEによるイベント配信
  • 監視対象: ~/.local/share/opencode/storage/ 下の session/, message/, part/
  • セッションIDはファイルパスをbase64url化(人間には扱いやすく、衝突を避ける)
  • Git操作やファイル補完などのサービスはsrc/server/service配下に分割

UIは既存のClaude Code Viewerの快適さを継承し、OpenCodeの保存形式に合わせた「セッション・メッセージ・パーツ」の読み込みとリアルタイム更新を実現しています。

重要コンポーネントをコードで理解する

1) タスク制御: OpencodeTaskController

OpenCode CLIプロセスを起動して、進行中タスクの状態やセッションID(UUID/パスID)を追跡します。新規/継続の両方をキューで直列実行し、SSEへ状態を配信。

// src/server/service/opencode/taskController.ts(抜粋)
const args = ["run", "--format", "json"];
if (options.sessionUuid) args.push("--session", options.sessionUuid);
args.push(options.message);

const child = spawn("opencode", args, {
  cwd: options.cwd,
  env: { ...process.env },
  stdio: ["ignore", "pipe", "pipe"],
});

task.process = child;
const rl = readline.createInterface({ input: child.stdout });

rl.on("line", (line) => {
  const parsed = JSON.parse(line.trim());
  if (typeof parsed.sessionID === "string") {
    task.sessionUuid = parsed.sessionID; // CLI側からUUIDを受け取る
    ensureSessionPath();                 // ファイルパス由来のIDも解決
  }
});

// 終了時は次のキューに移す or prune
child.on("exit", (code) => {
  task.process = null;
  // ...中略...
  if (task.queue.length > 0) {
    const next = task.queue.shift()!;
    this.launchProcess(task, {/*...*/}).then(next.resolve).catch(next.reject);
    return;
  }
  this.pruneTaskIfInactive(task);
});

ポイント:

  • --format jsonで標準出力を機械可読化し、sessionID(UUID)を拾ってからファイルパス由来のsessionPathIdも解決します。
  • 直列キューで「追いメッセージ」を順序保証。UIから連続操作しても壊れない作り。
  • task_changedイベントをEventBusへ投げ、SSEでブラウザへ即反映。

2) Honoルート: セッション開始・再開・タスク中断

新規開始、既存再開、タスク一覧、強制中断はHonoのAPIで提供されています。

// src/server/hono/route.ts(要点のみ)
.post("/projects/:projectId/new-session", zValidator("json", z.object({ message: z.string() })), async (c) => {
  const { projectId } = c.req.param();
  const { project } = await getProject(projectId);
  const task = await taskController.startOrContinueTask({ projectId, cwd: project.meta.workspacePath }, message);
  return c.json({ taskId: task.id, sessionId: task.sessionId, sessionUuid: task.sessionUuid, userMessageId: task.userMessageId });
})

.post("/projects/:projectId/sessions/:sessionId/resume", zValidator("json", z.object({ resumeMessage: z.string() })), async (c) => {
  const { sessionId } = c.req.param();
  const header = await readSessionHeader(decodeSessionId(sessionId));
  const task = await taskController.startOrContinueTask({ /* 既存UUIDを使って再開 */ }, resumeMessage);
  return c.json({ /* 同上 */ });
})

.get("/tasks/alive", async (c) => {
  return c.json({ aliveTasks: taskController.getSerializableAliveTasks() });
})

.post("/tasks/abort", zValidator("json", z.object({ sessionId: z.string() })), async (c) => {
  taskController.abortTask(c.req.valid("json").sessionId);
  return c.json({ message: "Task aborted" });
})

UI側はuseAliveTask + SSEで、進行中をバッジ表示し、Abort/Resumeの操作を即時反映します。

3) ファイル監視: セッション/メッセージ/パーツでリアルタイム更新

OpenCodeのストレージルートをfs.watchで監視し、変更時にproject_changed/session_changedイベントをSSE送信します。

// src/server/service/events/fileWatcher.ts(抜粋)
this.watcher = watch(opencodeSessionsRootPath, { persistent: false, recursive: true }, async (eventType, filename) => {
  if (!filename.endsWith(".json")) return;
  const absolutePath = join(opencodeSessionsRootPath, filename);
  const sessionId = encodeSessionId(absolutePath); // base64url化
  this.emitSessionEvents(projectIdResolved, sessionId, eventType);
});

// message/ と part/ も監視。part ファイルから sessionID を直接抽出
const raw = await readFile(absolutePath, "utf-8");
const parsed = JSON.parse(raw) as { sessionID?: unknown };
if (typeof parsed.sessionID === "string") sessionUuid = parsed.sessionID;

工夫点:

  • まずsession/*.json起点で更新検知、続いてmessage/*.jsonpart/*.jsonを監視して「アシスタントの返答やツール結果」の到着を即時反映。
  • 監視イベントはSSEでPush、フロントはReact Queryのキャッシュを適切に再取得してUI同期。

4) セッション解析: テキスト、推論、ツールをきれいに整形

OpenCodeの「メッセージ+パーツ」から、人間が読みやすいタイムラインに整形します。

// src/server/service/opencode/parseSession.ts(抜粋)
const combineTextParts = (parts: OpencodePartFile[]) => {
  return parts.filter(p => p.type === "text" && typeof p.text === "string")
              .map(p => p.text.trim())
              .join("\n\n")
              .trim();
};

const extractToolData = (parts: OpencodePartFile[], timestamp: string | null) => {
  for (const part of parts) {
    if (part.type !== "tool") continue;
    const call = { id, name: toolName, arguments: state?.input ? safeJsonStringify(state.input) : null, callId, timestamp };
    const result = /* statusがcompleted/errorなら出力も整形 */;
    // タイムライン用entriesに tool-call / tool-result を追加
  }
};

工夫点:

  • 「テキスト」「推論(reasoning)」「ツール呼び出し」「ツール結果」「その他(system)」に分類し、セクションごとに見やすい順序で表示。
  • 最初のユーザー文(firstCommand)やメッセージ数などサマリを同時に作成し、セッション一覧のカードに活用。

5) Git差分: 追加分も見逃さない「未追跡ファイルの人工diff」

parse-git-diffで通常の差分を解析しつつ、未追跡ファイルは中身を行ごとに「追加」として人工生成するのがユニークです。

// src/server/service/git/getDiff.ts(抜粋)
const numstatResult = await executeGitCommand(["diff", "--numstat", ...args], cwd);
const diffResult    = await executeGitCommand(["diff", "--unified=5", ...args], cwd);

// さらに working比較時は未追跡ファイルも拾う
if (toRef === undefined) {
  const untrackedResult = await getUntrackedFiles(cwd); // git status --short から ?? 行のみ抽出
  for (const file of untrackedResult.data) {
    const content = await readFile(resolve(cwd, file), "utf8");
    // 行ごと追加のhunkを生成して diff に混ぜる
  }
}

これにより「まだgit addしていない追加分」もブラウザのDiffビューで俯瞰でき、レビューの抜け漏れを減らします。

フロントの連携ポイント

  • useAliveTask(sessionPathId, sessionUuid?)で進行中タスクを把握、ステータス(running/waiting)をバッジ表示
  • SSE /api/events/state_changesを受け取り、aliveTasksAtomやセッション表示を同期
  • ファイル補完 GET /projects/:projectId/file-completion?basePath=/src を使い、対話中のパス入力を支援
// src/app/.../hooks/useAliveTask.ts(抜粋)
useQuery({
  queryKey: ["aliveTasks"],
  queryFn: async () => {
    const res = await honoClient.api.tasks.alive.$get({});
    const data = await res.json();
    setAliveTasks(data.aliveTasks);
    return data;
  },
});

代表的なユースケース

  • Resumeに必要なsessionIdを瞬時にコピーしてターミナルのopencode run --session <UUID>
  • 進行中タスクの状況をサイドバーやヘッダーで把握し、詰まったら「Abort」→再開メッセージ送信
  • 同一タイトルのセッションを統合表示(unifySameTitleSession)で重複を整理
  • Git差分の俯瞰で「未追跡ファイルの追加」まで見える化、レビュー準備を高速化

設定と運用Tips

  • 自動ブラウザ起動を無効化: CC_VIEWER_NO_AUTO_OPEN=1NO_AUTO_OPEN=1/NO_AUTO_BROWSER=1も可)
  • ストレージルートの変更: OPENCODE_STORAGE_ROOT(デフォルトは ~/.local/share/opencode/storage/
  • セッション一覧のフィルタ:
    • hideNoUserMessageSession: 最初のユーザー入力がないセッションを非表示
    • unifySameTitleSession: 同タイトルを集約して見やすく

ベンチマークでトップを獲得した理由と魅力を徹底解説へのリンク

OpenCodeの全体像・強み・実践方法を先に掴むと、Viewerの価値がより明確になります。

おわりに

OpenCode Viewerは、OpenCodeが生成・保存するセッション構造に寄り添い、CLI/TUIだけでは見えづらい部分をブラウザで補完します。Codex Viewer由来の磨かれたUIと、OpenCodeストレージへの監視・SSE同期・タスク制御を組み合わせることで、日々の「AI駆動開発」を快適に進める土台になります。ぜひ使ってみてください。

謝辞

Claude Code Viewerをベースに実装しました。
素晴らしいツールを公開してくださった作者の方に感謝いたします。
UIなどはほぼそのまま使わせてもらっており、非常に助かっています。
紹介記事はこちら → Claude Code Viewer 紹介記事
GitHubはこちら → d-kimuson/claude-code-viewer

Discussion