feat(desktop): TODO Agent に PTY エンジン + Remote Control 統合を追加 (opt-in)#278
feat(desktop): TODO Agent に PTY エンジン + Remote Control 統合を追加 (opt-in)#278
Conversation
PTY + JSONL tail ベースの代替エンジンで Claude Code Remote Control (claude.ai/code や iOS/Android アプリからの接続) を opt-in 提供する ための設計ドキュメント。POC で検証済の挙動と feature flag 方針を記録。
PTY エンジン経由で起動された TODO セッションが Remote Control を opt-in で有効化できるようにするための列。既存セッションは default false で影響なし。URL 側は永続化せず stream event で表現する。
- `apps/desktop/src/main/todo-daemon/pty-turn-runner.ts` を新規追加し、 node-pty で interactive claude を起動、~/.claude/projects/.../*.jsonl を tail して TodoStreamEvent に変換する - `--settings` に inline で Stop hook を仕込み、ターン終了を検出 - Remote Control 有効時は PTY に `/remote-control` を送信し、 stdout から `https://claude.ai/code/session_...` を抽出して stream event (`remote_control` kind) として UI に通知 - `supervisor-engine.ts` は `TODO_ENGINE=pty` 環境変数または セッションの remote_control_enabled フラグで PTY 経路に dispatch。 従来の `-p` ヘッドレス経路はそのまま残る (dogfood 後に削除予定) - tRPC todoAgent.create / todoCreateInputSchema に remoteControlEnabled を追加 - TodoStreamEventKind に `remote_control` / `remote_control_error` を追加
- TodoModal: 「Remote Control を有効化」チェックボックスを追加。 ON で作成したセッションは PTY エンジン経路で起動し、 /remote-control を発行する - TodoManager ライブストリーム: remote_control 種別のイベントを indigo バッジ + 接続 URL (クリックで外部ブラウザ) として描画。 エラー時は amber バッジで理由を表示 - store.insertQueuedFromTemplate / trpc todoAgent.recreate で remoteControlEnabled を受け渡し、スキーマと整合 - typecheck / lint は成功 (PTY 経路の型エラーと ANSI 正規表現の biome-ignore コメント位置を修正)
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 38 minutes and 11 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 Walkthrough概要TODO AgentにClaude Code「リモートコントロール」機能を実装するための変更。PTY(疑似ターミナル)ベースのターンランナーを導入し、既存のヘッドレス 変更内容
シーケンス図sequenceDiagram
participant User as ユーザー (UI)
participant Modal as TodoModal
participant tRPC as tRPC Router
participant SessionStore as SessionStore
participant Supervisor as Supervisor
participant PTY as PTY Runner
participant Claude as Claude TUI
participant Stream as JSONL Stream
User->>Modal: remoteControlEnabledフラグをON
User->>Modal: セッション作成を送信
Modal->>tRPC: create(remoteControlEnabled: true)
tRPC->>SessionStore: insertQueuedFromTemplate({ remoteControlEnabled: true })
SessionStore->>SessionStore: DBに remoteControlEnabled=true で行を作成
Note over Supervisor: 監視ループ起動
Supervisor->>Supervisor: remoteControlEnabled を確認
Supervisor->>PTY: runClaudeTurnPty({ remoteControlEnabled: true, ... })
PTY->>Claude: node-ptyで claude TUI を起動
PTY->>Claude: /remote-control 有効化
User->>Claude: (ブラウザで Remote Control URL にアクセス)
Claude->>Claude: Claude Code で対話実行
Claude->>Stream: session.jsonl に イベント追記
PTY->>Stream: JSONLを監視、イベント行をパース
Stream-->>PTY: remote_control イベント(URLを含む)
PTY->>SessionStore: イベントをセッションストアに記録
SessionStore-->>Supervisor: イベント完了通知
Supervisor->>User: TodoManager に remote_control イベント表示
User->>User: バッジと URL リンクが表示される
推定コードレビュー工数🎯 4 (複雑) | ⏱️ ~50分 関連する可能性のあるPR
詩
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2725a36af
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const script = `#!/bin/sh | ||
| set -e | ||
| EVENT="$1" |
There was a problem hiding this comment.
Make PTY Stop-hook command work on Windows
This hook sink always generates a POSIX shell script (#!/bin/sh) and registers it as the Stop hook command, but there is no Windows-specific path. In PTY mode on Windows (either TODO_ENGINE=pty or a session with remoteControlEnabled), the hook command cannot execute, so hasStopEvent() never flips and the turn loop waits until the 30-minute timeout or manual abort. That makes PTY turns effectively hang on every iteration for Windows users.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
P1 指摘を対応しました (be8a7c6)。JSONL race は SessionStart hook で runtime session_id を受け取って <session_id>.jsonl にバインドする形に変更。Windows 非対応は Stop/SessionStart hook を Node.js スクリプトにしてクロスプラットフォーム化しました。
| } else if (added.length > 0) { | ||
| discovered = added[0]; |
There was a problem hiding this comment.
Bind each runner to the correct JSONL file
For non-resume runs, the discovery logic picks added[0] from the project directory. If two PTY sessions start in the same workspace concurrently, both can observe multiple new JSONLs and choose the same first entry, causing one session to tail another session’s transcript, adopt the wrong Claude session id, and emit incorrect stream events/results. The file selection needs a session-specific handshake instead of “first new file.”
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
P1 指摘 (JSONL file race) を対応しました (be8a7c6)。新実装では SessionStart hook で Claude Code が生成する runtime session_id を受け取り、<session_id>.jsonl を明示的にバインドする形に変更。first-new-file ヒューリスティックを廃止したので同一 cwd で同時起動しても transcript を取り違えません。
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (6)
apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx (1)
288-313:TODO_ENGINE=ptyが無効な環境でもトグルを操作できてしまいます(任意)
packages/local-db/src/schema/todo-sessions.tsのコメントには「TODO_ENGINE=ptyでない場合 UI 側でトグルを無効化する」と書かれていますが、ここではチェックボックスが常時有効です。PTY エンジンが無効な状態で Remote Control を有効にしてセッションを作成すると、backend 側で無視される/期待通りに動かない可能性があります。対応案としては、(a)
todoAgent.settingsなどでエンジン種別を露出してdisabledをかける、(b) スキーマ側のコメントを実装実態に合わせて「エンジンが PTY のときだけ効果があり、それ以外では無視される」旨に更新する、のいずれかで整合を取るのがよいと思います。Pro/Max の注意書きは良い UX です。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx` around lines 288 - 313, The Remote Control checkbox (Checkbox with id "todo-remote-control", state remoteControlEnabled and setter setRemoteControlEnabled) is always interactive even when TODO_ENGINE is not "pty"; either disable the control or reflect backend behavior. Expose the engine type from todoAgent.settings (or props/state that represent the session engine) and use it to set the Checkbox disabled prop and adjust the label styling when engine !== "pty", and ensure onCheckedChange no-ops or prevents enabling when disabled; alternatively, update the schema comment if you intentionally want the UI to be permissive but non-effective.apps/desktop/src/main/todo-agent/trpc-router.ts (1)
418-418:?? falseは実質冗長です(任意)
todo_sessions.remote_control_enabledはnotNull().default(false)として定義されているため、source.remoteControlEnabledは常にbooleanでnull/undefinedを返しません。防御的コードとしては問題ありませんが、他フィールド(source.customSystemPrompt等の nullable 列)とは扱いが一段異なるので、意図的であることが分かるようにコメントを添えるか、単にsource.remoteControlEnabledにしてもよいと思います。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/main/todo-agent/trpc-router.ts` at line 418, The line setting remoteControlEnabled uses a redundant nullish coalescing fallback (source.remoteControlEnabled ?? false) because the DB column todo_sessions.remote_control_enabled is defined as notNull().default(false) so source.remoteControlEnabled is already boolean; either remove the "?? false" and use source.remoteControlEnabled directly, or keep it but add a short inline comment referencing todo_sessions.remote_control_enabled to make the defensive intent explicit (refer to the remoteControlEnabled property and source.remoteControlEnabled).apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx (1)
2256-2285: URL 正規表現が session id 部分しか許容していません(任意)
/https:\/\/claude\.ai\/code\/session_[A-Za-z0-9_-]+/は現行の出力には合致しますが、将来クエリ文字列やフラグメント(?foo=bar,#...)が付与された場合に末尾を取りこぼし、リンクが壊れたセッション URL を指す可能性があります。取り込みやすい範囲で\S+に緩めるか、末尾の句読点だけ除外する正規表現にしておくとロバストです。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx` around lines 2256 - 2285, The regex used to extract the Claude session URL (in the event handling branch where event.kind === "remote_control" and variables urlMatch/url are computed from event.text) only matches the session id portion and will drop query strings or fragments; update the pattern to capture the entire URL (allow trailing non-space chars) while avoiding trailing punctuation—e.g., change the current /https:\/\/claude\.ai\/code\/session_[A-Za-z0-9_-]+/ usage to a more permissive pattern like matching https://claude.ai/code/session_ followed by \S+ but strip trailing punctuation characters before assigning url so the anchor href and displayed url remain correct.apps/desktop/plans/20260417-todo-agent-remote-control.md (1)
34-34: Fenced code block に言語タグを付けると markdownlint (MD040) が黙ります静的解析ヒント通りの軽微なもの。アーキ図のブロックなので
textで十分です。提案 diff
-``` +```text [daemon] ├── supervisor-engine.ts (従来 -p エンジン / 既定)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/plans/20260417-todo-agent-remote-control.md` at line 34, Add the language tag "text" to the fenced code block that contains the architecture diagram so markdownlint MD040 is satisfied; locate the fenced block containing the daemon diagram (the triple-backtick block showing "[daemon] ├── supervisor-engine.ts (従来 -p エンジン / 既定)") and change the opening fence to include "text" (i.e., ```text) so the block is treated as plain text.apps/desktop/src/main/todo-daemon/pty-turn-runner.ts (2)
215-247: abort シグナル時にinterruptedを立てずに終わる — 呼び出し側でturnResult.interruptedが false のまま通常終了パスに入る可能性
pollStateはparams.signal.abortedを検知したらsafeKill(ptyProcess)して return false しますが、interruptedフラグは介入時にしか立てません。abort 時は supervisor のac.signal.abortedチェック (supervisor-engine.tsLine 417) で早期 return するので実害は出ませんが、ptyStatus.aliveが false になった後、最後のtailJsonlで偶然lastAssistantTextを拾うとresultに値が残ってしまい、ログ上の見え方がノイズになる恐れがあります。
params.signal.aborted時はptyExitErrorにも行かず、明示的にinterrupted: trueorerror: "aborted"で返すほうが呼び出し側と整合が取れます。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/main/todo-daemon/pty-turn-runner.ts` around lines 215 - 247, pollState currently kills the PTY on params.signal.aborted but doesn't set the interrupted flag, which can leave turnResult.interrupted false; update the params.signal.aborted branch inside pollState to set interrupted = true (and/or mark an explicit abort error marker that will be returned) before calling safeKill(ptyProcess) so callers like supervisor-engine see interrupted/error consistently; also ensure the abortHandler (abortHandler) behavior aligns with this change so both synchronous abort paths set the same interrupted state used by turnResult.
62-90:CLAUDE_BINが PATH 依存になるケースで node-pty が spawn に失敗する可能性
process.env.TODO_CLAUDE_BIN || process.env.CLAUDE_BIN || "claude"で、デフォルトは単なる"claude"です。Electron の本番ビルドでは daemon プロセスの PATH が開発時と大きく異なる(例: macOS の launchd 経由、packaged app 起動で/usr/local/binが見えないことがある)ため、headless のspawn("claude", ...)と同じトラブルが起きます。
- 既存の headless
-pパスと症状を合わせる選択なら OK ですが、PTY 側は失敗時の diagnostic がclaude を PTY 起動できませんでした: spawn ENOENT程度しか出ません。which claude相当を起動時に一度解決してキャッシュする、もしくはエラーメッセージで「claude auth login済みか / PATH にあるか」を案内してあげるとユーザが詰まりません。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/main/todo-daemon/pty-turn-runner.ts` around lines 62 - 90, CLAUDE_BIN currently defaults to the literal "claude" which can cause spawn ENOENT in packaged Electron; at startup resolve the actual executable path (e.g., run child_process.execSync("command -v <name>" or "which <name>") for process.env.TODO_CLAUDE_BIN || process.env.CLAUDE_BIN || "claude"), cache that resolved path into a new symbol (e.g., CLAUDE_BIN_RESOLVED) and use it when spawning the PTY, and if resolution/spawn fails enhance the error path where you handle spawn errors to include actionable diagnostics ("executable not found on PATH; try `claude auth login` or add to PATH") plus the original ENOENT details so users can diagnose missing PATH vs auth issues.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/desktop/src/main/todo-daemon/pty-turn-runner.ts`:
- Around line 699-707: The current logic uses buf.toString("utf8") and then
computes a rewind using Buffer.byteLength(lastLine, "utf8"), which breaks when
the buffer ends in the middle of a UTF-8 multibyte sequence; instead detect the
last newline at the raw-byte level and rewind by the exact number of trailing
bytes after that newline: use buf.lastIndexOf(0x0A) to find the last newline
byte, set state.pendingLine to the string for the partial tail (prepend it next
read), and decrement state.jsonlReadOffset by (buf.length - (lastNewlineIndex +
1)) when lastNewlineIndex >= 0 (or handle the entire buffer as pending when
lastNewlineIndex === -1); update the code around the existing buf / text / lines
handling (the block using buf.toString, lines, lastLine, and
state.jsonlReadOffset) to use this byte-level calculation so multibyte
characters are preserved.
- Around line 665-697: The try/catch/finally double-closes the same fd in
tailJsonl (fd variable) — modify the error handling so the descriptor is only
closed once: either remove the fs.closeSync(fd) call from the catch block and
let the finally block always close the fd, or keep the close in the catch but
set fd = null immediately after closing so the finally block skips it; update
the catch accordingly around the fs.openSync/fs.readSync block (referencing fd
and the surrounding try/catch/finally) to ensure a single close.
- Around line 776-782: In pty-turn-runner.ts the JSONL fields are being read
with camelCase causing sessionId and parentToolUseId to always be
null/undefined; update the checks that set sessionId and parentToolUseId to read
rec.session_id and rec.parent_tool_use_id (respectively) instead of
rec.sessionId / rec.parentToolUseId, preserving the same typeof string guards
and fallback behavior so the variables (sessionId, parentToolUseId) mirror
supervisor-engine.ts’s snake_case handling.
- Around line 495-507: The Stop hook command interpolates hookScriptPath
directly in buildSettingsJson causing shell-escaping issues; wrap the path with
the existing escapeShell helper so the command becomes
`${escapeShell(hookScriptPath)} Stop` (i.e., update buildSettingsJson to call
escapeShell on hookScriptPath when constructing the command string) to safely
handle spaces and special characters for the POSIX shell execution.
In `@apps/desktop/src/main/todo-daemon/supervisor-engine.ts`:
- Around line 917-950: The shim created by buildChildProcessShim currently makes
once/on no-ops and never updates shim.signalCode/exitCode, so abort's
child.once("close", ...) never fires and the SIGKILL timer cannot be cleared;
modify buildChildProcessShim to store listeners registered via once/on (at least
for the "close" event) and provide a way for the real process handle to notify
the shim of exit (e.g., add an exit callback on the passed-in handle like
handle.onExit or call a supplied notifyExit function) so that when the PTY/real
child exits you invoke the stored "close" callbacks and set
shim.exitCode/shim.signalCode appropriately; also ensure abort path updates
shim.signalCode (either by routing abort's process.kill through handle.kill or
by setting shim.signalCode when the handle reports the SIGINT) so the timer
check (child.exitCode == null && child.signalCode == null) correctly observes
the child state and cancels the SIGKILL.
In `@apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx`:
- Around line 2268-2282: Replace the renderer use of window.open in the anchor
onClick with the tRPC procedure electronTrpc.external.openUrl(url): keep the
href and e.preventDefault(), then call await
electronTrpc.external.openUrl.mutate({ url }) (or .query/.call per your client)
instead of window.open; update the onClick handler in the JSX where url is
referenced and remove any reliance on window.open so the renderer uses the
existing electronTrpc.external.openUrl IPC path.
---
Nitpick comments:
In `@apps/desktop/plans/20260417-todo-agent-remote-control.md`:
- Line 34: Add the language tag "text" to the fenced code block that contains
the architecture diagram so markdownlint MD040 is satisfied; locate the fenced
block containing the daemon diagram (the triple-backtick block showing "[daemon]
├── supervisor-engine.ts (従来 -p エンジン / 既定)") and change the opening fence to
include "text" (i.e., ```text) so the block is treated as plain text.
In `@apps/desktop/src/main/todo-agent/trpc-router.ts`:
- Line 418: The line setting remoteControlEnabled uses a redundant nullish
coalescing fallback (source.remoteControlEnabled ?? false) because the DB column
todo_sessions.remote_control_enabled is defined as notNull().default(false) so
source.remoteControlEnabled is already boolean; either remove the "?? false" and
use source.remoteControlEnabled directly, or keep it but add a short inline
comment referencing todo_sessions.remote_control_enabled to make the defensive
intent explicit (refer to the remoteControlEnabled property and
source.remoteControlEnabled).
In `@apps/desktop/src/main/todo-daemon/pty-turn-runner.ts`:
- Around line 215-247: pollState currently kills the PTY on
params.signal.aborted but doesn't set the interrupted flag, which can leave
turnResult.interrupted false; update the params.signal.aborted branch inside
pollState to set interrupted = true (and/or mark an explicit abort error marker
that will be returned) before calling safeKill(ptyProcess) so callers like
supervisor-engine see interrupted/error consistently; also ensure the
abortHandler (abortHandler) behavior aligns with this change so both synchronous
abort paths set the same interrupted state used by turnResult.
- Around line 62-90: CLAUDE_BIN currently defaults to the literal "claude" which
can cause spawn ENOENT in packaged Electron; at startup resolve the actual
executable path (e.g., run child_process.execSync("command -v <name>" or "which
<name>") for process.env.TODO_CLAUDE_BIN || process.env.CLAUDE_BIN || "claude"),
cache that resolved path into a new symbol (e.g., CLAUDE_BIN_RESOLVED) and use
it when spawning the PTY, and if resolution/spawn fails enhance the error path
where you handle spawn errors to include actionable diagnostics ("executable not
found on PATH; try `claude auth login` or add to PATH") plus the original ENOENT
details so users can diagnose missing PATH vs auth issues.
In `@apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx`:
- Around line 2256-2285: The regex used to extract the Claude session URL (in
the event handling branch where event.kind === "remote_control" and variables
urlMatch/url are computed from event.text) only matches the session id portion
and will drop query strings or fragments; update the pattern to capture the
entire URL (allow trailing non-space chars) while avoiding trailing
punctuation—e.g., change the current
/https:\/\/claude\.ai\/code\/session_[A-Za-z0-9_-]+/ usage to a more permissive
pattern like matching https://claude.ai/code/session_ followed by \S+ but strip
trailing punctuation characters before assigning url so the anchor href and
displayed url remain correct.
In `@apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx`:
- Around line 288-313: The Remote Control checkbox (Checkbox with id
"todo-remote-control", state remoteControlEnabled and setter
setRemoteControlEnabled) is always interactive even when TODO_ENGINE is not
"pty"; either disable the control or reflect backend behavior. Expose the engine
type from todoAgent.settings (or props/state that represent the session engine)
and use it to set the Checkbox disabled prop and adjust the label styling when
engine !== "pty", and ensure onCheckedChange no-ops or prevents enabling when
disabled; alternatively, update the schema comment if you intentionally want the
UI to be permissive but non-effective.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c8d03b76-fe42-4e9d-a37f-f9e0f85cc646
📒 Files selected for processing (13)
.gitignoreapps/desktop/plans/20260417-todo-agent-remote-control.mdapps/desktop/src/main/todo-agent/session-store.tsapps/desktop/src/main/todo-agent/trpc-router.tsapps/desktop/src/main/todo-agent/types.tsapps/desktop/src/main/todo-daemon/pty-turn-runner.tsapps/desktop/src/main/todo-daemon/supervisor-engine.tsapps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsxapps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsxpackages/local-db/drizzle/0059_todo_remote_control_enabled.sqlpackages/local-db/drizzle/meta/0059_snapshot.jsonpackages/local-db/drizzle/meta/_journal.jsonpackages/local-db/src/schema/todo-sessions.ts
1. JSONL file race (#278 codex review) 同一 cwd で 2 セッションを同時起動した場合、旧実装は project dir の新規 .jsonl の「先頭」を自セッションとみなして いたため、互いの transcript を tail する可能性があった。 SessionStart hook でランタイム session_id を受け取って <session_id>.jsonl にバインドする形に変更。 2. Windows 非対応 (#278 codex review) 旧 Stop hook は `#!/bin/sh` シェルスクリプトで、Windows では 実行できず 30 分タイムアウトまでハングしていた。 hook を Node.js スクリプト (cross-platform) に置き換え、 `node <script> <event>` コマンドとして登録する。 合わせて: - buildSettingsJson に SessionStart フックを追加 - 未使用になった existingJsonl / mostRecentFile ヘルパを削除 - JSONL 発見失敗時のエラーメッセージを SessionStart 未発火 パターンに対応できるよう拡張
Critical:
- classifyJsonlRecord が camelCase (`sessionId` / `parentToolUseId`)
しか見ていなかったため、snake_case (`session_id` / `parent_tool_use_id`)
で書く Claude Code バージョンでは常に null になっていた。両方を
読むフォールバックを追加。
Major:
- TodoManager の Remote Control バッジが `window.open` で URL を
開こうとしていたが、Electron renderer では外部ブラウザに飛ば
ない可能性がある。`electronTrpc.external.openUrl` に切り替え、
RemoteControlBadge を独立コンポーネントに分離。
Minor:
- tailJsonl の catch ブロックで `fs.closeSync(fd)` を呼んだ後、
finally でも同じ fd を閉じていた二重 close を解消 (catch 側を
削除し finally 一本に)。
- tailJsonl の行バッファリングが `buf.toString("utf8")` の置換に
依存していたため、チャンク末尾が UTF-8 マルチバイトの途中で切れると
`Buffer.byteLength(lastLine)` が元バイト数と合わず、日本語ログで
rewind がずれる問題を修正。`buf.lastIndexOf(0x0a)` で最後の LF を
バイト境界で見つけて消費バイト数ぶんだけ進める実装に変更。
- PTY runner の onChild handle に `onExit(cb)` サブスクライブ口を
追加。supervisor-engine の shim はこれを受けて `once("close", ...)`
を記録し、PTY 終了時に発火。abort 時の SIGKILL タイマーの
clearTimeout が効くようになり、死んだ/再利用 PID への不要な
SIGKILL を避ける。
- PTY モードでは cost_usd が JSONL に載らないため totalCostUsd が
常に null/0 になる旨をセットアップバナーに明示。
概要
TODO Agent のセッションを Claude 公式の Remote Control (claude.ai/code / Claude iOS・Android アプリ) から閲覧・操作できるようにする opt-in 実装。
従来の
claude -p --output-format stream-jsonヘッドレス経路は interactive TUI を持たないため/remote-controlを受けられないので、node-pty で interactive claude を起動し、~/.claude/projects/<encoded-cwd>/<sessionId>.jsonlを tail するサブエンジンを追加した。既存の-p経路はそのまま残し、環境変数 + per-session チェックで段階的に移行する。設計メモ:
apps/desktop/plans/20260417-todo-agent-remote-control.md主要変更
バックエンド (daemon)
apps/desktop/src/main/todo-daemon/pty-turn-runner.tsを新規追加--settingsに Stop hook を inline 注入してターン終了を検知~/.claude/projects/<encoded-cwd>/*.jsonlを polling + offset で tail、assistant/user(tool_result)/assistant(tool_use)/assistant(thinking)を既存 TodoStreamEvent にマッピング/remote-controlを送信、stdout を ANSI ストリップしてhttps://claude.ai/code/session_...を抽出 → stream event (remote_controlkind) として UI に流すremote_control_errorkind のイベントを emitsupervisor-engine.tsのrunClaudeTurnを feature flag で dispatchTODO_ENGINE=ptyが設定されている or セッション側のremoteControlEnabledが true のとき、PTY 経路へ-pヘッドレス経路 (runClaudeTurnHeadlessに分離)DB / API
todo_sessions.remote_control_enabled列を追加 (migration0059_todo_remote_control_enabled.sql)todoAgent.create入力にremoteControlEnabled(optional, default false) を追加insertQueuedFromTemplate/ trpcrecreateが RC フラグを引き継ぐTodoStreamEventKindにremote_control/remote_control_errorを追加UI
TodoModalに「Remote Control を有効化」チェックボックスを追加 (indigo 系ハイライト、Pro/Max 必須の注意書き付き)TodoManagerライブストリームでremote_controlイベントを indigo バッジ + 接続 URL リンク (クリックで外部ブラウザ) として描画、remote_control_errorは amber バッジでエラー理由を表示運用方針 (dogfood 期間)
TODO_ENGINE未設定 + RC チェック OFF-pヘッドレスTODO_ENGINE=pty+ RC チェック OFFTODO_ENGINE未設定 + RC チェック ON/remote-control発行)TODO_ENGINE=pty+ RC チェック ON/remote-control発行)既存
-p経路は残してあり、問題があれば RC チェックを外すだけで従来動作に戻る。dogfood で安定確認ができたら後続 PR で-p経路を撤去予定。既知の制約 / フォローアップ
text_deltaが書かれないので、PTY 経路では whole message 単位のライブ表示になるScheduleEditorDialogには RC トグル未実装: スケジュール経由のセッションは常に RC off で作成される (UI を追加する後続 PR 予定)fs.watchではなく polling で project dir の新規ファイルを検出している。同一 cwd で 2 セッション同時起動した場合の race は理論上あるが worktree 分離が主用途なので実害は小動作確認 (手元 POC + 想定フロー)
手元の
/tmp/claude-poc-rc/poc.mjsで以下を事前検証済み:tool_use/tool_result/thinkingを JSONL tail で観測可能/remote-controlでhttps://claude.ai/code/session_01LGA...を ANSI stripped stdout から抽出成功本ブランチの daemon 統合版の最終確認 (dev server 起動 → TODO 作成 → RC チェック → claude.ai/code 接続) はマージ後の dogfood で行う前提。
事前要件 (Remote Control を使うユーザー向け)
claude auth login済み (claude.ai OAuth)Test plan
remote_control_errorバッジが出ることSummary by CodeRabbit
リリースノート
新機能
ドキュメント
Chores
.gitignoreに一時ファイルディレクトリを追加