Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,4 @@ test-conflict-repo/

# Claude Code session lock (runtime artifact)
.claude/scheduled_tasks.lock
temp/
124 changes: 124 additions & 0 deletions apps/desktop/plans/20260417-todo-agent-remote-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# TODO Agent Remote Control 統合 計画

## 背景

Claude Code CLI は v2.1.51 で `claude remote-control` / `claude --remote-control` / スラッシュコマンド `/remote-control` を提供し、ローカルで走っているセッションを claude.ai/code や Claude iOS/Android アプリから閲覧・操作できるようになった。

TODO Agent は現在 `claude -p --output-format stream-json` をサブプロセスで起動して stdout の NDJSON を parse するヘッドレス方式で動いている。これは Remote Control と互換性がない (`-p` は Ink TUI を持たず、interactive 端末 UI を要求する `/remote-control` を受けられない)。

本 PR は PTY + JSONL tail ベースの代替エンジンを feature flag 付きで追加し、Remote Control を opt-in で使えるようにする。

## 検証済み事実 (手元 POC 完了)

- interactive `claude --permission-mode bypassPermissions --settings '<inline JSON>'` で Stop / UserPromptSubmit / PreToolUse / PostToolUse / SessionStart hook を inline 注入可能
- `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` は interactive モードでも書き込まれる。spawn 後 3 秒以内に生成される
- interactive モードの `--session-id <uuid>` は JSONL ファイル名を制御**しない** (別 UUID が内部生成される)。`fs.watch` で project dir の新規ファイルを自セッションとして同定する必要がある
- JSONL event type: `system` / `user` / `user(tool_result)` / `assistant(thinking|text|tool_use)` / `attachment` / `permission-mode` / `file-history-snapshot` / `queue-operation` / `last-prompt`
- PTY への bracketed paste (`\x1b[200~...\x1b[201~\r`) で prompt 投入成功
- `/remote-control\r` で stdout に `https://claude.ai/code/session_...` が表示される
- mid-session で追加プロンプトを送信可能

## アーキテクチャ

### 選択肢比較

| 案 | Remote Control | Live stream | コスト | 採否 |
|----|----------------|-------------|--------|------|
| A. 現状 `-p` | 不可 | ○ | 0 | 部分採用 (既定・非 RC 系は当面これ) |
| B. Agent SDK | 不可 (API key 必須) | ○ | 大 | 却下 |
| C. PTY + JSONL tail | ○ | △ (per-token なし / whole message) | 中 | **本 PR で採用** |
| D. Dual process | △ (競合) | ○ | 小 | 却下 (会話競合リスク) |

### 案 C の構成

```
[daemon]
├── supervisor-engine.ts (従来 -p エンジン / 既定)
│ └── runClaudeTurn() : stream-json stdout parse
└── pty-turn-runner.ts (新規 PTY エンジン / opt-in)
└── runClaudeTurnPty()
├── node-pty spawn
│ claude --permission-mode bypassPermissions
│ --settings '<inline JSON with Stop hook>'
│ [--model ...] [--effort ...]
│ [--resume <id>]
├── fs.watch(~/.claude/projects/<encoded-cwd>/)
│ → 新規 .jsonl を自セッションとして同定
├── chokidar 相当の poll + offset tracking
│ → assistant / user(tool_result) / assistant(tool_use) を
│ supervisor-engine と同じ TodoStreamEvent 形に変換
├── Stop hook 発火 (Unix/tmp ファイル経由) で turn 終了検知
├── Remote Control 有効時のみ PTY stdin に `/remote-control\r`
│ → PTY stdout を ANSI strip 後 `https://claude.ai/code/session_...`
│ を抽出してセッションに保存
└── bracketed paste で prompt 投入 / 次ターンも同じ PTY 再利用 ...
ではなく、既存 supervisor の iteration ループに合わせて
**1 ターン 1 プロセス** とし、次 iteration は
`--resume <claudeSessionId>` で再 spawn する
```

**重要な設計判断: 1 ターン 1 プロセス**
既存 `supervisor-engine.ts` は iteration ごとに `claude -p` を spawn → exit する。PTY 版もこのライフサイクルに合わせ、1 ターンごとに PTY プロセスを起こして Stop hook で終了させる。これで:

- 既存 `runSession` ループを変更せず `runClaudeTurn` を差し替えるだけで済む
- ScheduleWakeup の既存処理 (waiting 状態 → 別プロセスで resume) がそのまま動く
- Intervention (追加メッセージ) も既存の queue → 次 iteration 投入フローで動く
- 長命プロセスのリソース管理問題を回避

### Feature flag

- 環境変数 `TODO_ENGINE=pty` で PTY エンジンに切り替え (既定: headless)
- セッション単位の `remote_control_enabled` フラグは UI チェックボックスで opt-in
- PTY エンジン + `remote_control_enabled=true` の AND 条件で Remote Control 発動
- チェックボックスは `TODO_ENGINE=pty` が無効なときは disabled

## DB schema 変更

`todo_sessions` に 1 列追加:

```sql
ALTER TABLE todo_sessions ADD COLUMN remote_control_enabled INTEGER DEFAULT 0;
```

- `remote_control_session_url` は **永続化しない**。daemon 再起動で RC セッションは切れるため、URL は in-memory + stream event のみで表現
- Remote Control 状態は stream event で live-stream に流す

## UI 変更

- `TodoModal`: 「Remote Control を有効化」チェックボックス追加 (PTY mode 時のみ有効)
- `ScheduleEditorDialog`: 同様のチェックボックス追加
- `TodoManager` live stream: RC 接続中バッジ + URL リンクを stream events から読んで表示

## 実装順序

1. plan.md 追加 (本文書)
2. DB schema: `remote_control_enabled` 列追加
3. PTY turn runner 本体 (`pty-turn-runner.ts`)
4. supervisor-engine 側の feature flag 分岐
5. StartRequest / tRPC 入出力 に RC フィールド追加
6. TodoModal / ScheduleEditorDialog UI
7. live stream バッジ表示
8. lint / typecheck / 自己レビュー
9. commit / push / PR

## フォローアップ (後続 PR)

- dogfood 後 `-p` エンジン削除
- per-token streaming (JSONL には text_delta が無いので別経路を検討)
- mid-session メッセージ送信 UI (`queueIntervention` 拡張)
- Remote Control URL の永続化 + セッション再接続導線
- 並列起動時の race 対策強化
- Electron パッケージでの node-pty ネイティブ rebuild 確認 (既に terminal-host で使用中)

## 前提条件

- `claude auth login` 済 (claude.ai OAuth)
- Claude Code v2.1.51+
- Pro/Max/Team/Enterprise プラン
- Team/Enterprise は admin が Remote Control トグルを有効化済
2 changes: 2 additions & 0 deletions apps/desktop/src/main/todo-agent/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ class TodoSessionStore {
customSystemPrompt?: string | null;
claudeModel?: string | null;
claudeEffort?: string | null;
remoteControlEnabled?: boolean;
artifactPath: string;
}): SelectTodoSession {
return this.insert({
Expand All @@ -310,6 +311,7 @@ class TodoSessionStore {
customSystemPrompt: template.customSystemPrompt ?? null,
claudeModel: template.claudeModel ?? null,
claudeEffort: template.claudeEffort ?? null,
remoteControlEnabled: template.remoteControlEnabled ?? false,
verdictPassed: null,
verdictReason: null,
verdictFailingTest: null,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/todo-agent/trpc-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const createTodoAgentRouter = () => {
customSystemPrompt: input.customSystemPrompt,
claudeModel: resolvedModel,
claudeEffort: resolvedEffort,
remoteControlEnabled: input.remoteControlEnabled,
artifactPath,
});

Expand Down Expand Up @@ -414,6 +415,7 @@ export const createTodoAgentRouter = () => {
customSystemPrompt: source.customSystemPrompt,
claudeModel: source.claudeModel,
claudeEffort: source.claudeEffort,
remoteControlEnabled: source.remoteControlEnabled ?? false,
verdictPassed: null,
verdictReason: null,
verdictFailingTest: null,
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/main/todo-agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export const todoCreateInputSchema = z.object({
// means "use the user's configured default" (see todoSettingsSchema).
claudeModel: todoClaudeModelSchema.nullish(),
claudeEffort: todoClaudeEffortSchema.nullish(),
// When true, the daemon starts the session under the PTY engine
// and sends `/remote-control` after spawn so it is reachable from
// claude.ai/code and the Claude mobile app. Requires the daemon to
// be running in PTY mode (`TODO_ENGINE=pty`) and a claude.ai
// subscription (Pro/Max). See
// apps/desktop/plans/20260417-todo-agent-remote-control.md.
remoteControlEnabled: z.boolean().optional().default(false),
});

export const todoPresetKindSchema = z.enum(["system", "description", "goal"]);
Expand Down Expand Up @@ -206,7 +213,14 @@ export type TodoStreamEventKind =
| "tool_result"
| "result"
| "error"
| "raw";
| "raw"
// PTY engine (`TODO_ENGINE=pty`) emits these when Remote Control is
// enabled on the session. `remote_control` carries the connection URL
// (`https://claude.ai/code/session_...`) the UI surfaces as a badge;
// `remote_control_error` is non-fatal — the turn continues without RC.
// See apps/desktop/plans/20260417-todo-agent-remote-control.md.
| "remote_control"
| "remote_control_error";

/**
* One condensed event we store in the per-session in-memory buffer and send
Expand Down
Loading
Loading