From 649ad9d89ef64c367f490897663808edd20d816d Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:21:31 +0900 Subject: [PATCH 01/11] docs: add subagent container design to CLAUDE.md Design decisions from sub-agent-technical-breakdown session: - SubagentContainer with goroutine/channel lifecycle model - 5 presets (scout/analyst/coder/worker/coordinator) - SandboxConfig with write root, exec allowlist regex, spawn permissions - npm excluded from all presets (worktree node_modules problem) - pnpm/bun/uv run added to coder and worker - Conductor identity and orchestration guidance for system prompt Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1938a5ce8..a609cdf39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,164 @@ Lint: `golangci-lint run` - **Mini App log viewer has no frontend tests**: `renderLogs()` in `pkg/miniapp/static/index.html` is inline vanilla JS with no unit/E2E test coverage. Backend (Go) tests cover `RecentLogs`, `SanitizeFields`, and JSON serialization, but nothing verifies the JS rendering. This allowed the Fields display bug (fields sent but not rendered) to ship undetected. - **No human intervention for heartbeat worktrees**: Heartbeat sessions create git worktrees (`.worktrees/heartbeat-YYYYMMDD/`) but there is no CLI or Mini App command to list, inspect, or manually dispose them. Need a `/plan worktrees` command (or similar) that shows active worktrees with branch/commit info and allows manual merge/dispose. `PruneOrphaned` on startup only removes directories without auto-committing first, so uncommitted changes in orphaned worktrees are silently lost. +## Subagent Container Design + +> Designed 2026-02-25 on branch `sub-agent-technical-breakdown`. + +### 背景・動機 + +現状の `SubagentManager.runTask()` はtask文字列を3行のsystem promptと共に裸のLLMに投げるだけ。 +コンテキスト注入・write sandbox・ライフサイクル管理がなく、conductorとしての自覚もない。 + +**2つの根本問題:** +1. subagentが受け取れるのはtask文字列のみ (workspace, plan context, 制約が伝わらない) +2. write先の制限がなく、exec も無制限 + +### 設計方針 + +**透過的隔離**: AI は自分が隔離されていることに気づかずに振る舞う。 +worktreeのworkDirが透過的にツール呼び出しの基点となり、picoclaw側でCoW的にファイルを引き渡せる。 + +**チャネルベースのライフサイクル管理**: goroutineとchannelでコンテナのステートを表現する。 + +``` +Conductor goroutine + │ ContainerRequest (task, preset, environment) + ▼ +Container goroutine: provision → run → finalize + │ ContainerResult (output, commitRef, error) + ▼ +Conductor goroutine +``` + +```go +type ContainerRequest struct { + Task string + Preset string + Environment SubagentEnvironment +} + +type ContainerResult struct { + Output string + CommitRef string // worktreeに変更があれば + Err error +} +``` + +spawn (async) はresult channelを返して即リターン、subagent (sync) はその場でブロック。 +この違いがconductorとしてのspawn vs subagentの使い分けに自然に対応する。 + +### SubagentEnvironment (context injection) + +```go +type SubagentEnvironment struct { + Workspace string // 自動注入 + WorktreeDir string // 自動注入 (write先、workDirとして透過的に機能) + Background string // conductorが自由記述 + Constraints string // conductorが制約を記述 + ContextFiles []string // 読むべきファイルリスト + PlanSummary string // active planの要約 (オプション) +} +``` + +### SandboxConfig + +```go +type SandboxConfig struct { + Preset string + WriteRoot string // write系ツールのパス制限 + AllowedTools map[string]bool + ExecPolicy *ExecPolicy // nil = exec不可 + SpawnablePresets []string // nil = spawn不可 +} + +type ExecPolicy struct { + AllowPattern string // マッチしたコマンドだけ実行可 (先頭一致regex) +} +``` + +enforcement は ToolRegistry.Execute() の入口で一括チェック (Option B)。 +subagentは普通にtool callするつもりで透過的にsandboxedになる。 + +### Presets (5種) + +| preset | write | exec | spawn | +|---|---|---|---| +| `scout` | ✗ | ✗ | ✗ | +| `analyst` | ✗ | go test/vet, git log/diff, curl/grep | ✗ | +| `coder` | ✓ sandbox | test/lint/fmt (pnpm/bun/uv run 含む) | ✗ | +| `worker` | ✓ sandbox | pnpm/bun/uv/go/cargo のビルド・パッケージ管理 | ✗ | +| `coordinator` | ✓ sandbox | go/pnpm/bun/curl 系 | scout/analyst/coder/worker のみ | + +**Presetの境界:** +- `coder` = 書いて自分で検証できる (package追加・deploy不可) +- `worker` = インフラも含めてやりきる (package install, CI pipeline等) +- `coordinator` = coordinatorをspawnできない (深さ自然制限) + +**npm は全presetで禁止**: git worktreeにnode_modulesが作られると大量ファイルが生じるため。 +pnpmはsymbolic linkで済む、bunも同様。 + +#### exec allowlist regex + +```go +var presetExecPatterns = map[string]string{ + "scout": ``, + "analyst": `^(go\s+(test|vet)|git\s+(log|diff|status)|curl|wget|grep|find)\b`, + "coder": `^(` + + `go\s+(test|vet|fmt)|gofmt|goimports|golangci-lint|` + + `prettier|eslint|` + + `black|ruff|` + + `cargo\s+(test|fmt|clippy)|` + + `pnpm\s+(test|run\s+(test|lint|format))|` + + `bun\s+(test|run\s+(test|lint|format))|` + + `uv\s+run\s+` + + `)\b`, + "worker": `^(` + + `go\s+|` + + `pnpm\s+(install|add|run|test|build)|` + + `bun\s+(install|add|run|test|build)|` + + `uv\s+(run|sync|add|pip\s+install)|` + + `pip\s+install|` + + `cargo\s+` + + `)\b`, + "coordinator": `^(go\s+|pnpm\s+|bun\s+|curl|wget)\b`, +} +``` + +### Conductor の自覚 (system prompt) + +`pkg/agent/context.go` の `getIdentity()` に追加予定: + +``` +You are picoclaw, a conductor AI agent. Your role is to orchestrate: +break work into tasks, delegate them to subagents, and synthesize results. + +## Orchestration + +Use `spawn` (non-blocking) when: +- Tasks can run in parallel or in the background +- You don't need the result to decide the next step + +Use `subagent` (blocking) when: +- You need the result before continuing + +Default bias: if a task involves more than 2-3 tool calls or can run independently, delegate it. +``` + +### 実装ファイル構成 (予定) + +``` +pkg/tools/ + container.go — SubagentContainer, ContainerRequest, ContainerResult, SubagentEnvironment + sandbox.go — SandboxConfig, ExecPolicy, preset定義 + orchestrator.go — Orchestrator (SubagentManagerを置き換え) + spawn.go — preset パラメータ追加 +pkg/agent/ + context.go — conductor identity + orchestration guidance 追加 +``` + +--- + ## Memory Optimization Candidates > Reviewed 2026-02-24 on branch `memory-optimization-review`. False positives included intentionally. From 55d3a08fcd7a1cfe3a9024553a63a4bf17da3f3b Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:46:27 +0900 Subject: [PATCH 02/11] docs: rewrite CLAUDE.md with full orchestration design - Remove completed Security TODOs and detailed memory optimization task lists (A-H tables, Phase 0-4 plans) - Keep architectural insights: D-1~D-6, code smells checklist, storage protection strategy - Subagent orchestration design fully updated with: - Why orchestration: fork, conductor-as-manager, conversation fork - Bidirectional channel (ContainerMessage: question/result/status) - Preset classification: Exploratory vs Deliberate - Subagent plan mode: in-memory clarifying/review/executing - MEMORY.md ## Orchestration section (Delegated/Findings/Decisions) - Context injection from MEMORY.md (auto inject_plan_context) - Search tools allowed for all presets - Startup flag --orchestration design - Conductor identity system prompt Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1067 ++++++++++++++--------------------------------------- 1 file changed, 283 insertions(+), 784 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a609cdf39..cb1c7d43d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,117 +19,169 @@ Lint: `golangci-lint run` - **Interview tool filtering**: `interviewAllowedTools` in `pkg/agent/loop.go` is the single source of truth for tools available during interview/review phases. Both `filterInterviewTools` (strips definitions before LLM call) and `isToolAllowedDuringInterview` (argument-level gating) reference this map. - **History clear**: `/plan start clear` wipes session history and summary on transition to executing. The Mini App review UI offers two sliders: standard approve and approve-with-clear. -## Security TODOs - -- ~~**Log Fields masking**~~: Done. `SanitizeFields()` in `pkg/logger/logger.go` masks keys matching `token`, `key`, `secret`, `password`, `authorization`, `credential`. Applied in `RecentLogs()` and `wsLogs()` stream. - ## Known Gaps - **Mini App log viewer has no frontend tests**: `renderLogs()` in `pkg/miniapp/static/index.html` is inline vanilla JS with no unit/E2E test coverage. Backend (Go) tests cover `RecentLogs`, `SanitizeFields`, and JSON serialization, but nothing verifies the JS rendering. This allowed the Fields display bug (fields sent but not rendered) to ship undetected. - **No human intervention for heartbeat worktrees**: Heartbeat sessions create git worktrees (`.worktrees/heartbeat-YYYYMMDD/`) but there is no CLI or Mini App command to list, inspect, or manually dispose them. Need a `/plan worktrees` command (or similar) that shows active worktrees with branch/commit info and allows manual merge/dispose. `PruneOrphaned` on startup only removes directories without auto-committing first, so uncommitted changes in orphaned worktrees are silently lost. -## Subagent Container Design +--- + +## Subagent Orchestration Design > Designed 2026-02-25 on branch `sub-agent-technical-breakdown`. -### 背景・動機 +### なぜオーケストレーションか -現状の `SubagentManager.runTask()` はtask文字列を3行のsystem promptと共に裸のLLMに投げるだけ。 -コンテキスト注入・write sandbox・ライフサイクル管理がなく、conductorとしての自覚もない。 +単純な指示から可能性の木を広げることが目的。conductor は一人でやり遂げるのではなく、探索・深化・fork をサブエージェントに委ねながら大局観を保つ。 -**2つの根本問題:** -1. subagentが受け取れるのはtask文字列のみ (workspace, plan context, 制約が伝わらない) -2. write先の制限がなく、exec も無制限 +``` +without orchestration: + human → conductor → (全部自分でやる) → result + 常にボトルネック、逐次処理 -### 設計方針 +with orchestration: + human → conductor ─┬─ scout A ─┐ + ├─ scout B ─┼─ synthesize → deeper insight + └─ scout C ─┘ + conductor は次を考えながら並走 +``` -**透過的隔離**: AI は自分が隔離されていることに気づかずに振る舞う。 -worktreeのworkDirが透過的にツール呼び出しの基点となり、picoclaw側でCoW的にファイルを引き渡せる。 +**3つの核心原則:** -**チャネルベースのライフサイクル管理**: goroutineとchannelでコンテナのステートを表現する。 +1. **Fork** — 同じ問いに複数の切り口で同時探索。sequential queue ではなく tree の展開。 +2. **管理職の原則** — conductor は subagent の完了を待たない。spawn したら即座に次を設計する。人員を遊ばせないことがポイント。 +3. **会話の fork** — main thread (conductor ↔ human) は高レベル・戦略的に保つ。subagent への細かい指示出しは branch thread で行い、main thread を汚染しない。subagent の進捗はサマリーだけ main thread に上げる。 + +**spawn がデフォルト、subagent は例外:** +``` +spawn = conductor が次を考え続けられる (正しい姿) +subagent = conductor が止まる (結果が絶対必要な時だけ) +``` + +### Architecture Overview ``` Conductor goroutine │ ContainerRequest (task, preset, environment) ▼ Container goroutine: provision → run → finalize - │ ContainerResult (output, commitRef, error) + │ ContainerMessage (question / result / status) ▼ Conductor goroutine + │ answer (question への回答) + ▼ (わからなければ human に escalate) +Container goroutine (再開) ``` +**escalation chain:** +``` +subagent (clarifying) → question → conductor + → conductor が答えられる: inCh に回答 + → conductor もわからない: message tool で human に投げ、回答を転送 +``` + +### SubagentContainer + +goroutine と channel でライフサイクルを表現。goroutine がブロックしている場所が現在の状態。 + ```go -type ContainerRequest struct { - Task string - Preset string - Environment SubagentEnvironment +type ContainerMessage struct { + Type string // "question" | "result" | "status" + Content string } -type ContainerResult struct { - Output string - CommitRef string // worktreeに変更があれば - Err error +type SubagentContainer struct { + inCh chan string // conductor → subagent (回答) + outCh chan ContainerMessage // subagent → conductor (質問・結果・進捗) + cancel context.CancelFunc } ``` -spawn (async) はresult channelを返して即リターン、subagent (sync) はその場でブロック。 -この違いがconductorとしてのspawn vs subagentの使い分けに自然に対応する。 +spawn (async) は outCh を返して即リターン。subagent (sync) はその場で result を待つ。 -### SubagentEnvironment (context injection) +**tasks map 問題の解消:** goroutine 終了時に `defer orchestrator.active.Delete(id)` + `defer close(outCh)` で自動 GC。 + +### SubagentEnvironment (Context Injection) + +conductor は subagent に必要なコンテキストを明示的に渡す。MEMORY.md からの自動注入で冗長な手動記述を排除。 ```go type SubagentEnvironment struct { - Workspace string // 自動注入 - WorktreeDir string // 自動注入 (write先、workDirとして透過的に機能) - Background string // conductorが自由記述 - Constraints string // conductorが制約を記述 - ContextFiles []string // 読むべきファイルリスト - PlanSummary string // active planの要約 (オプション) + // 自動注入 (harness が埋める) + Workspace string // workspace パス + WorktreeDir string // write先、workDir として透過的に機能 + + // MEMORY.md から自動抽出 (inject_plan_context: true の場合) + PlanTask string // > Task: の内容 + PlanContext string // ## Context セクション + Commands string // ## Commands セクション (build/test/lint) + CurrentPhase string // 対象 Phase の内容 + + // conductor が明示的に追加 + Background string // 追加の背景・意図 + Constraints string // 制約 + ContextFiles []string // 参照すべきファイルリスト +} +``` + +spawn パラメータ例: +```json +{ + "task": "Phase 2 Step 1: implement the rate limiter", + "preset": "coder", + "context_files": ["pkg/ratelimit/ratelimit.go"], + "inject_plan_context": true } ``` ### SandboxConfig +ToolRegistry.Execute() の入口で一括 enforcement。subagent は普通に tool call するつもりで透過的に sandboxed になる。 + ```go type SandboxConfig struct { Preset string - WriteRoot string // write系ツールのパス制限 + WriteRoot string // write 系ツールのパス制限 AllowedTools map[string]bool - ExecPolicy *ExecPolicy // nil = exec不可 - SpawnablePresets []string // nil = spawn不可 + ExecPolicy *ExecPolicy // nil = exec 不可 + SpawnablePresets []string // nil = spawn 不可 } type ExecPolicy struct { - AllowPattern string // マッチしたコマンドだけ実行可 (先頭一致regex) + AllowPattern string // 先頭一致 regex; マッチしたコマンドだけ実行可 } ``` -enforcement は ToolRegistry.Execute() の入口で一括チェック (Option B)。 -subagentは普通にtool callするつもりで透過的にsandboxedになる。 +**透過的隔離:** workDir = worktreeDir として設定することで、AI は自分が隔離されていることに気づかずに振る舞う。picoclaw 側で CoW 的にファイルを引き渡せる。 ### Presets (5種) -| preset | write | exec | spawn | -|---|---|---|---| -| `scout` | ✗ | ✗ | ✗ | -| `analyst` | ✗ | go test/vet, git log/diff, curl/grep | ✗ | -| `coder` | ✓ sandbox | test/lint/fmt (pnpm/bun/uv run 含む) | ✗ | -| `worker` | ✓ sandbox | pnpm/bun/uv/go/cargo のビルド・パッケージ管理 | ✗ | -| `coordinator` | ✓ sandbox | go/pnpm/bun/curl 系 | scout/analyst/coder/worker のみ | +| preset | 性格 | write | exec | search | spawn | +|---|---|---|---|---|---| +| `scout` | Exploratory | ✗ | ✗ | ✓ | ✗ | +| `analyst` | Exploratory | ✗ | go test/vet, git log/diff, grep | ✓ | ✗ | +| `coder` | Deliberate | ✓ sandbox | test/lint/fmt 系 | ✓ | ✗ | +| `worker` | Deliberate | ✓ sandbox | build/package manager 系 | ✓ | ✗ | +| `coordinator` | Deliberate | ✓ sandbox | go/pnpm/bun/curl 系 | ✓ | scout〜worker のみ | -**Presetの境界:** -- `coder` = 書いて自分で検証できる (package追加・deploy不可) -- `worker` = インフラも含めてやりきる (package install, CI pipeline等) -- `coordinator` = coordinatorをspawnできない (深さ自然制限) +**性格の分類:** +- **Exploratory** (scout/analyst): open-ended、見てきて報告。clarifying フェーズなし。 +- **Deliberate** (coder/worker/coordinator): 成果物を作る。目標があいまいだと失敗する。clarifying フェーズあり。 -**npm は全presetで禁止**: git worktreeにnode_modulesが作られると大量ファイルが生じるため。 -pnpmはsymbolic linkで済む、bunも同様。 +**境界:** +- `coder` = 書いて自分で検証できる (package 追加・deploy 不可) +- `worker` = インフラも含めてやりきる (package install, CI pipeline 等) +- `coordinator` = coordinator を spawn できない (深さ自然制限) + +**npm は全 preset で禁止:** git worktree に node_modules が作られると大量ファイルが生じるため。pnpm は symbolic link で済む、bun も同様。 + +**websearch/webfetch は全 preset で許可:** read-only・非破壊のため制限不要。 #### exec allowlist regex ```go var presetExecPatterns = map[string]string{ - "scout": ``, + "scout": ``, "analyst": `^(go\s+(test|vet)|git\s+(log|diff|status)|curl|wget|grep|find)\b`, "coder": `^(` + `go\s+(test|vet|fmt)|gofmt|goimports|golangci-lint|` + @@ -152,786 +204,233 @@ var presetExecPatterns = map[string]string{ } ``` -### Conductor の自覚 (system prompt) +### Subagent Plan Mode + +Deliberate な preset (coder/worker/coordinator) は in-memory のミニ plan mode を持つ。MEMORY.md には一切触れない (ファイル参照・編集を避けるため)。 + +```go +type SubagentPlanState int + +const ( + PlanStateNone SubagentPlanState = iota // Exploratory preset + PlanStateClarifying // 目的・制約を確認中 + PlanStateReview // conductor の承認待ち + PlanStateExecuting // 実行中 +) + +type SubagentPlan struct { + State SubagentPlanState + Goal string // clarifying で合意した目的 + Approach []string // proposed なステップ + QA []QAItem // 質問・回答の履歴 + mu sync.Mutex +} +``` + +SubagentContainer がフィールドとして保持。goroutine 終了とともに消える。 + +**Deliberate preset の system prompt:** +``` +You are in clarifying mode. Before executing, confirm with the conductor: +1. What is the exact goal? +2. What are the constraints and acceptance criteria? +3. Are there relevant files I should know about? +Use the `message` tool to ask questions. +When you have clear answers, propose your approach (steps) for review. +Do NOT start executing until the conductor approves. +``` + +**Exploratory preset の system prompt:** +``` +Explore and return findings. Use your best judgment when encountering ambiguity. +``` + +**fractal 構造:** +``` +human + ↕ plan mode (MEMORY.md, file-based, 永続) +conductor + ↕ subagent plan mode (in-memory, 揮発) +subagent (deliberate) +``` + +### MEMORY.md Orchestration Section + +executing 中に conductor が自由に書き込める専用エリア。システムはパースしない。 + +```markdown +## Orchestration + +### Delegated + +- coder-1 (coder): rate limiter 実装 → Phase 2 Step 1 +- scout-1 (scout): pkg/auth の構造調査 + +### Findings + +- pkg/auth は middleware パターン、入口は middleware.go (scout-1) +- セッションストアは存在しない、JWT が有効 (scout-2) + +### Decisions + +- auth: OAuth2 より JWT を選択 (外部依存なし、scout-2 推奨) +``` + +conductor の guidance に追記: +``` +After spawning a subagent, record the assignment in ## Orchestration > Delegated. +When a subagent reports back, move key findings to ## Orchestration > Findings. +When you choose one direction over another, log the rationale in ## Orchestration > Decisions. +``` + +### Conductor Identity (System Prompt) `pkg/agent/context.go` の `getIdentity()` に追加予定: ``` You are picoclaw, a conductor AI agent. Your role is to orchestrate: -break work into tasks, delegate them to subagents, and synthesize results. +break work into tasks, delegate them to subagents, and synthesize results — +rather than doing everything inline yourself. ## Orchestration +You are the conductor, not the performer. Prefer delegation over doing everything inline. + Use `spawn` (non-blocking) when: - Tasks can run in parallel or in the background +- Multiple independent tasks can run simultaneously (spawn each one) - You don't need the result to decide the next step +- The operation is long-running (builds, fetches, analysis, file processing) Use `subagent` (blocking) when: -- You need the result before continuing +- You need the result before you can continue +- Correctness of next steps depends on the outcome -Default bias: if a task involves more than 2-3 tool calls or can run independently, delegate it. +Do inline only when: +- It's a single fast tool call (read a file, quick search) +- Delegation overhead clearly outweighs the benefit + +Default bias: if a task involves more than 2-3 tool calls or can run +independently, delegate it. When you spawn, immediately plan what comes next — +blocking means you've stopped thinking. + +Fork aggressively: explore multiple directions simultaneously. +After spawning a subagent, record the assignment in ## Orchestration > Delegated. +When results come back, synthesize and decide the next fork. ``` -### 実装ファイル構成 (予定) +### Startup Flag + +テスト用途で起動時にオーケストレーション機能を on/off できるようにする。 + +**変更箇所:** +1. `pkg/config/config.go` — `SubagentsConfig` に `Enabled bool` を追加 +2. `cmd/picoclaw/cmd_agent.go` — `--orchestration` フラグを追加 (default: false for now) +3. `pkg/agent/loop.go` — `registerSharedTools()` で spawn tool 登録を `Enabled` で gate + +```go +// pkg/config/config.go +type SubagentsConfig struct { + Enabled bool `json:"enabled"` + AllowAgents []string `json:"allow_agents,omitempty"` + Model *AgentModelConfig `json:"model,omitempty"` +} + +// cmd/picoclaw/cmd_agent.go +case "--orchestration": + cfg.Agents.Defaults.Subagents.Enabled = true // or toggle +``` + +### Implementation Files (予定) ``` pkg/tools/ - container.go — SubagentContainer, ContainerRequest, ContainerResult, SubagentEnvironment - sandbox.go — SandboxConfig, ExecPolicy, preset定義 - orchestrator.go — Orchestrator (SubagentManagerを置き換え) - spawn.go — preset パラメータ追加 + container.go — SubagentContainer, ContainerRequest, ContainerMessage, + SubagentEnvironment, SubagentPlan + sandbox.go — SandboxConfig, ExecPolicy, preset 定義 + orchestrator.go — Orchestrator (SubagentManager を置き換え) + spawn.go — preset / inject_plan_context パラメータ追加 pkg/agent/ context.go — conductor identity + orchestration guidance 追加 ``` --- -## Memory Optimization Candidates +## Memory Optimization Notes -> Reviewed 2026-02-24 on branch `memory-optimization-review`. False positives included intentionally. -> Legend: 🔴 High / 🟡 Medium / 🟢 Low +> Reviewed 2026-02-24 on branch `memory-optimization-review`. -### A. ホットパスでの文字列結合 (strings.Builder 未使用) +### 設計レベルの根本原因 -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🔴 | `pkg/tools/web.go` | 73-85 | `BraveSearchProvider.Search()` — slice append + Join を Builder に | -| 🔴 | `pkg/tools/web.go` | 155-167 | `TavilySearchProvider.Search()` — 同上パターン | -| 🔴 | `pkg/tools/web.go` | 211-254 | `DuckDuckGoSearchProvider.extractResults()` — ループ内 append+Join | -| 🔴 | `pkg/tools/web.go` | 592-617 | `WebFetchTool.extractText()` — cleanLines を Builder で | -| 🔴 | `pkg/skills/loader.go` | 234-250 | `BuildSkillsSummary()` — `[]string` + Join で XML 組み立て (要素数×アロケーション) → Builder へ | -| 🔴 | `pkg/channels/telegram.go` | 789-806 | `extractCodeBlocks()` — codes スライス無容量 + ReplaceAllStringFunc の fmt.Sprintf | -| 🔴 | `pkg/channels/telegram.go` | 813-830 | `extractInlineCodes()` — 同上パターン | -| 🟡 | `pkg/agent/context.go` | 247 | `BuildSystemPrompt()` — `systemPrompt +=` で連結 → Builder へ | -| 🟡 | `pkg/logger/logger.go` | 241-246 | `formatFields()` — parts slice + Join → Builder へ | -| 🟡 | `pkg/skills/loader.go` | 217-225 | `LoadSkillsForContext()` — parts + Join → Builder へ | -| 🟡 | `pkg/channels/discord.go` | 162-168 | `appendContent()` — `+` 演算子で結合 → Builder へ | -| 🟡 | `pkg/channels/slack.go` | 234-272 | `handleMessageEvent()` — ループ内文字列連結 → Builder へ | -| 🟢 | `pkg/git/worktree.go` | 61-63 | `SanitizeBranchName()` — `strings.ReplaceAll` ループ | +#### D-1. MemoryStore が「ファイル = 正」でパース済み表現をキャッシュできない -### B. スライスの事前容量確保漏れ +`GetMemoryContext()` 1回で `ReadLongTerm()` が5回以上呼ばれる連鎖。MEMORY.md を外部エディタが直接編集できる設計上、インメモリキャッシュを自然に導入できない。 -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🟡 | `pkg/tools/toolloop.go` | 87-96 | `RunToolLoop()` — normalizedToolCalls / toolNames を make([]T, 0, 推定値) に — **実装済み** (既にコード上で容量ヒント付き) | -| 🟡 | `pkg/config/config.go` | 628 | `findMatches()` — `var matches []ModelConfig` → 容量ヒントを付与 | -| 🟡 | `pkg/config/migration.go` | 48 | `ConvertProvidersToModelList()` — result に make([]ModelConfig, 0, 20) | -| 🟡 | `pkg/skills/registry.go` | 183 | `SearchAll()` — merged に make([]SearchResult, 0, len(regs)*limit) | -| 🟡 | `pkg/skills/loader.go` | 73 | `ListSkills()` — skills に make([]SkillInfo, 0, 20) 程度 | -| 🟡 | `pkg/channels/telegram.go` | 832-861 | `extractMarkdownTables()` — out は実装済み (`make([]string, 0, len(lines))`)、`tables` (L835) のみ容量ヒント未対応 | -| 🟢 | `pkg/skills/search_cache.go` | 42-43 | `NewSearchCache()` — entries map / order slice に maxEntries をヒント | -| 🟢 | `pkg/agent/session_tracker.go` | 121 | `ListActive()` — result スライスに容量ヒント — **除外**: アクティブセッション数が事前不明で静的見積もり不可 | +対策: (a) content パススルー方式 — 高レベルメソッドだけが1回 ReadLongTerm() を呼び、content を private ヘルパーに渡す。(b) `*ParsedPlan` 常駐 — RAM が潤沢なので MemoryStore にパース済み構造体を持たせる。edit_file 後に `InvalidateCache()` を呼ぶ。 -### C. 不要な []byte ↔ string 変換 / 重複変換 +#### D-2. `FunctionCall.Arguments` が JSON 文字列のままドメイン型に -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🔴 | `pkg/channels/telegram.go` | 1071-1111 | `wrapByDisplayWidth()` — ループ内で `string(r)` (rune→string) を毎イテレーション実行 | -| 🟡 | `pkg/tools/web.go` | 545-562 | `WebFetchTool.Execute()` — `string(body)` を最大5回呼び出し → 1回に集約 | -| 🟡 | `pkg/tools/web.go` | 289 | `PerplexitySearchProvider.Search()` — `string(payloadBytes)` + `strings.NewReader` → `bytes.NewReader` を直接使用 | -| 🟢 | `pkg/utils/string.go` | 50 | `wrapLine()` — ASCII 主体なのに `[]rune(line)` | -| 🟢 | `pkg/utils/string.go` | 100 | `Truncate()` — 長さ確認前に `[]rune(s)` | -| 🟢 | `pkg/git/worktree.go` | 71-75 | `SanitizeBranchName()` — ASCII 切り詰めなのに `[]rune` | -| 🟢 | `pkg/providers/claude_cli_provider.go` | 133 | `string(paramsJSON)` 後に Builder へ書き込み → bytes.Write | +ストリーミングループ内の重複 Unmarshal の根本原因。`ToolCall.Arguments map[string]any` のパース済みフィールドも存在するが中途半端に共存している。 -### D. JSON Marshal/Unmarshal の重複・ホットパス +#### D-3. `ToolFunctionDefinition.Parameters` が `map[string]any` -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🔴 | `pkg/providers/openai_compat/provider.go` | 274, 362, 621 | ストリーミングループ内でツール引数を複数回 Unmarshal | -| 🟡 | `pkg/providers/anthropic/provider.go` | 213 | `json.Unmarshal(tu.Input, &args)` — map にサイズヒントなし | -| 🟡 | `pkg/providers/codex_cli_provider.go` | 154-155 | ツール定義ループ内で `json.Marshal(parameters)` | +プロバイダーへ送るたびに Marshal が必要。`json.RawMessage` にすれば一度の marshal で済む。 -### E. 大きな struct の値渡し / ループ内コピー +#### D-4. 検索プロバイダーに共通フォーマット抽象がない -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🔴 | `pkg/agent/session_tracker.go` | 125 | `ListActive()` — `*entry` を値コピーして append → ポインタ slice に | -| 🟡 | `pkg/session/manager.go` | 98-100 | `GetHistory()` — messages 全コピー (スレッド安全のため意図的。COW 検討) | -| 🟡 | `pkg/session/manager.go` | 187-188 | `Save()` — messages 全コピー (同上) | -| 🟡 | `pkg/skills/registry.go` | 132-133 | `SearchAll()` — `[]SkillRegistry` を全コピーしてからロック解除 | -| 🟢 | `pkg/logger/logger.go` | 88-92 | `recent()` — LogEntry を値コピーして返却 → ポインタ slice 検討 | +`[]string + strings.Join` パターンが3箇所に複製。`Search()` 戻り値を `string` でなく構造体にすれば1箇所で済む。 -### F. sync.Pool / バッファ再利用の検討 +#### D-5. `Session.Messages` が可変スライスで全コピーが必要 -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🟡 | `pkg/tools/web.go` | 592-617 | `extractText()` — HTML 解析用 Builder を sync.Pool で再利用 | -| 🟡 | `pkg/channels/telegram.go` | 757-861 | Markdown 変換系関数群 — メッセージ毎に多数のバッファを生成 → Pool 化 | -| 🟢 | `pkg/utils/download.go` | 43 | `DownloadToFile()` — エラー読み取り用 `make([]byte, 512)` → 共有バッファ | +`GetHistory()` / `Save()` での防衛的コピーは意図的設計。COW または append-only immutable 構造で解消できる。 -### G. LRU / アルゴリズムレベルの最適化 +#### D-6. `MemoryStore` のメソッド境界が「ファイル操作単位」 -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🟡 | `pkg/skills/search_cache.go` | 161 | `moveToEndLocked()` — slice slicing で O(n) LRU 更新 → doubly-linked list で O(1) に | +呼び出し側は複数の値が必要でも複数回呼ぶしかない。D-1 の解決策 (ParsedPlan 常駐) と合わせて解消。 -### H. パッケージレベル変数化 (関数呼び出しのたびに再生成) +### コードの匂い — チェックリスト -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🟢 | `pkg/utils/media.go` | 18-19 | `IsAudioFile()` — `audioExtensions` / `audioTypes` スライスを毎回生成 → var に | -| 🟢 | `pkg/skills/clawhub_registry.go` | 114 | `fmt.Sprintf("%d", limit)` → `strconv.Itoa(limit)` | +新しいコードを書くとき・レビューするときの確認事項: -### H. 重複 strings.Split / Join (memory.go) +1. **`[]string` + `strings.Join`** → `strings.Builder` に一本化 +2. **`[]byte → string → io.Reader`** → `bytes.NewReader(b)` を直接使用 +3. **ループ内で静的なものを毎回生成** → ループ外で1回生成してキャッシュ +4. **全件コピーが呼び出し側の用途より広い** → COW または RWMutex + ポインタ返却を検討 +5. **同じ content を複数関数が独立して Split** → 呼び出し側で1回 Split して渡す +6. **`var x []T` から始まる容量なし append** → ソース長が既知なら `make([]T, 0, n)` +7. **`[]rune(s)` 変換前に長さチェックなし** → `len(s) <= max` で ASCII fast path を先に -| 重要度 | ファイル | 行 | 内容 | -|--------|----------|----|------| -| 🟡 | `pkg/agent/memory.go` | 233, 285, 352, 381 | `extractPhaseContent` / `GetPlanPhases` / `MarkStep` / `AddStep` — 同一 MEMORY.md を関数毎に Split → 統合 or キャッシュ | +### ストレージ保護設計 (microSD 寿命) + +| データ | 現状 | 推奨戦略 | 削減率 | +|---|---|---|---| +| `sessions/*.json` | メッセージ毎書き込み | write-behind (dirty flag + 5分タイマー) | 80% | +| `state/stats.json` | LLM呼び出し毎 | 定期フラッシュのみ (5分) | 98% | +| `memory/MEMORY.md` | 即時 (変えない) | ターンスコープキャッシュ (読み取りのみ最適化) | — | + +**実装ポイント:** +- `SessionManager` に `dirtyKeys map[string]bool` + バックグラウンドフラッシャー goroutine +- `stats.Tracker` に `Close()` メソッド追加 (タイマー停止 + 最終 save) +- SIGTERM/SIGINT でシャットダウンフック必須 +- MEMORY.md の edit_file 書き込み後にターンキャッシュを無効化 (`InvalidateCache()`) --- -### 設計レベルの根本原因 — 「見落とし」ではなく「構造的に不可避」な問題 +## Session Management (Future) -個別の最適化候補の多くは、書いた人の不注意ではなく、**設計上の選択が特定のアロケーションパターンを必然的に引き起こしている**ことが読み取れる。以下はその根本原因を設計レベルで整理したもの。 +現状の設計は「正確性」は成熟しているが「ライフサイクル」が欠落している。 -#### D-1. MemoryStore が「ファイル = 正」の設計で、パース済み表現をキャッシュできない +**近期:** +- `SessionManager.Delete(key)` + TTL エビクション +- `sessionLocks sync.Map` (loop.go) の GC +- 起動時の `loadSessions()` を遅延ロード化 -`MemoryStore` の各メソッドはほぼ全員が `ReadLongTerm()` → `strings.Split()` → scan → `strings.Join()` を独立して実行する。`GetMemoryContext()` を1回呼ぶだけで、内部で `ReadLongTerm()` が3回以上呼ばれる連鎖が起きる。 +**中期:** +- チェックポイント / ロールバック (`Session.Messages` を append-only immutable に) +- 名前付きセッション (`/new-session`, `/switch-session`, `/list-sessions`) -``` -GetMemoryContext() - └─ HasActivePlan() → ReadLongTerm() → ファイルI/O - └─ GetPlanStatus() → ReadLongTerm() → ファイルI/O - └─ GetPlanContext() → ReadLongTerm() → ファイルI/O - └─ GetCurrentPhase() → ReadLongTerm() → ファイルI/O - └─ GetTotalPhases() → ReadLongTerm() → ファイルI/O -``` - -**なぜこうなったか**: MEMORY.md をユーザーが直接編集できる外部ファイルとして設計したため、「ファイルが常に最新の正」という前提が成立している。インメモリキャッシュを持つと外部編集が反映されなくなる恐れがあり、キャッシュを自然に導入できない。 - -**設計上の選択肢**: (a) `content` を引数として受け取る内部 pure function 群 + 高レベルメソッドだけが1回 ReadLongTerm() を呼ぶ、(b) ウォッチ付きキャッシュ (`fsnotify`)、(c) エージェントループ内で1ターンに1回だけ読む「ターンスコープキャッシュ」。 - ---- - -#### D-2. `FunctionCall.Arguments` が JSON 文字列のまま型として定義されている - -```go -// protocoltypes/types.go -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` // ← ワイヤフォーマット (JSON文字列) をそのままドメイン型に -} -``` - -ツール引数はワイヤ上 `"arguments": "{\"key\":\"value\"}"` の形で届くが、この型定義はその文字列をそのまま保持する。使う側は毎回 `json.Unmarshal([]byte(tc.Function.Arguments), &args)` しなければならず、これがストリーミングループ内の重複 Unmarshal の根本原因になっている。 - -**対比**: `ToolCall.Arguments map[string]any json:"-"` というパース済みフィールドは存在するが、openai_compat の streaming path ではこの `map[string]any` フィールドではなく `Function.Arguments string` から直接読んでいる。両方のフィールドが中途半端に共存している。 - ---- - -#### D-3. `ToolFunctionDefinition.Parameters` が `map[string]any` で、シリアライズ済み形式を保持できない - -```go -type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]any `json:"parameters"` // ← プロバイダーへ送るたびに Marshal が必要 -} -``` - -ツール定義はエージェント起動時に一度決まり、実行中は変化しない。しかし `map[string]any` として保持しているため、各プロバイダーへの送信のたびに `json.Marshal` → `string` 変換が発生する。`json.RawMessage` にしておけば「一度 marshal したバイト列をそのまま複数プロバイダーへ流す」設計が可能になる。 - ---- - -#### D-4. 検索プロバイダー群に共通フォーマット抽象がなく、同じ欠陥が3箇所に複製されている - -`BraveSearchProvider`, `TavilySearchProvider`, `DuckDuckGoSearchProvider` は全て独立して「結果 → 文字列」の変換ロジックを実装している。共通の `ResultFormatter` インターフェースや `formatSearchResult(title, url, snippet string)` ヘルパーがないため、同じ `[]string + strings.Join` パターンが3箇所に独立してコピーされた。最適化漏れも3箇所に同時に発生する。 - -**設計の示唆**: プロバイダーの `Search()` 戻り値を `string` にせず構造体 (`[]SearchResult`) にして、フォーマットを呼び出し側に移譲する設計なら、フォーマットロジックは1箇所で済む。 - ---- - -#### D-5. `Session.Messages` が可変スライスで、読み取りに構造的な全コピーが必要 - -```go -type Session struct { - Messages []providers.Message // ← 可変。append で追記される -} - -func (sm *SessionManager) GetHistory(key string) []providers.Message { - history := make([]providers.Message, len(session.Messages)) - copy(history, session.Messages) // ← 安全のために必須 - return history -} -``` - -`session.Messages` は `append` で追記される可変スライスで、外部から参照を渡すと内部状態が壊れるリスクがある。そのため `GetHistory()`, `Save()`, `SetHistory()` の全てでコピーが必要になる。コメントにも「to strictly isolate internal state from the caller's slice」と明記されており、これは意図的な設計だがコピーコストを構造的に固定している。 - -**代替設計**: メッセージログを append-only な不変構造 (`[]*Message` のリンクリストや、インデックスで管理するリングバッファ) にすれば、参照の共有が安全になりコピーを排除できる。 - ---- - -#### D-6. `MemoryStore` のメソッド境界が「ファイル操作単位」で切られており、呼び出し側が合成できない - -```go -// 呼び出し側は content を持てないため、内部で毎回 ReadLongTerm() を呼ぶ -phases := ms.GetPlanPhases() // ReadLongTerm() 内包 -current := ms.GetCurrentPhase() // ReadLongTerm() 内包 -status := ms.GetPlanStatus() // ReadLongTerm() 内包 -``` - -各 public メソッドが「ファイルを読んでパースして1つの値を返す」単位で設計されているため、呼び出し側は複数の値が必要なときでもメソッドを複数回呼ぶしか選択肢がない。`content` を受け取る private 関数群 (`extractPhaseContent(content, phase)` など) は存在するが、public API からは使えない。 - ---- - -### コードのにおい — 見落としやすいパターン集 - -上記の個別発見を横断して見ると、このコードベースに繰り返し現れる**7つの構造的なにおい**がある。新しいコードを書くとき・レビューするときのチェックリストとして使う。 - -#### 1. 「先に集めてから結合」パターン (`[]string` + `strings.Join`) - -```go -// においのある書き方 -var parts []string -for _, x := range items { - parts = append(parts, fmt.Sprintf("...%s...", x)) -} -return strings.Join(parts, "\n") -``` - -`var parts []string` → ループ内 `append` → 最後に `strings.Join` という3ステップの流れ。見た目が整理されているため気づきにくいが、中間スライスと最終結合の2回アロケーションが発生する。`strings.Builder` に一本化すれば1回で済む。**web.go の検索プロバイダー4箇所、logger.go、skills/loader.go など計10箇所以上で観察された。** - -#### 2. 「変換してから渡す」パターン ([]byte ↔ string の橋渡し) - -```go -// においのある書き方 -payload, _ := json.Marshal(body) -req, _ := http.NewRequest("POST", url, strings.NewReader(string(payload))) -// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -// []byte → string → io.Reader と2段変換 -``` - -`json.Marshal` は `[]byte` を返すのに、直後に `string()` へキャストして `strings.NewReader` に渡す。`bytes.NewReader(payload)` で変換ゼロで済む。**web.go の Perplexity プロバイダー、各 CLI プロバイダーで観察された。** - -#### 3. 「ループ内で静的なものを毎回生成」パターン - -```go -// においのある書き方 -for _, tool := range tools { - paramsJSON, _ := json.Marshal(tool.Parameters) // ← ループ内 Marshal - prompt += fmt.Sprintf("...", string(paramsJSON)) -} -``` - -ループ内で毎イテレーション行われる処理のうち、**入力が変わらないものが含まれていないか**を疑う。典型例: -- ループ内での `json.Marshal` (引数が定数的なとき) -- ループ内での `string(rune)` 変換 (1文字ずつ変換) -- ループ内でのスライス/マップリテラル生成 - -**telegram.go の `wrapByDisplayWidth`、openai_compat の streaming ループ、codex の tool 定義ループで観察された。** - -#### 4. 「防衛的コピーが広すぎる」パターン (スレッド安全の過剰適用) - -```go -// においのある書き方 -func (m *Manager) GetHistory() []Message { - m.mu.RLock() - defer m.mu.RUnlock() - result := make([]Message, len(m.messages)) - copy(result, m.messages) // ← 全件コピーしてからロック解除 - return result -} -``` - -並行安全のため slice 全体を防衛的にコピーするのは正しいが、**コピー範囲が呼び出し側の実際の用途より広い**ことがある。読み取り専用なら `sync.RWMutex` + ポインタ返却 + immutable 制約、または Copy-on-Write で代替できる場合がある。**session/manager.go の GetHistory・Save で観察された。** - -#### 5. 「ファイルを読むたびにパース」パターン (ステートレスな繰り返しパース) - -```go -// においのある書き方 -func GetPlanPhases(content string) []string { - lines := strings.Split(content, "\n") // ← 呼び出し毎にフルスキャン - ... -} -func MarkStep(content, step string) string { - lines := strings.Split(content, "\n") // ← 同じ content を再度スキャン - ... -} -``` - -同一のファイル内容を受け取る複数の関数がそれぞれ独立して `strings.Split` → スキャン → `strings.Join` している。呼び出し側でパース済み表現(行スライスなど)を保持して渡すか、パース結果をキャッシュする設計にすると複数回のアロケーションを削減できる。**memory.go の4関数で観察された。** - -#### 6. 「`var x []T` から始まる容量なし append」パターン - -```go -// においのある書き方 -var result []ModelConfig // cap=0 から開始 -for _, p := range providers { - result = append(result, ...) // 倍々に再アロケーション -} -``` - -`var x []T` や `make([]T, 0)` で始まり、ループ内で `append` を重ねる。**ソースの長さが事前にわかっている場合**(別スライスの len、定数上限など)は `make([]T, 0, n)` で初期容量を与えれば再アロケーションをゼロにできる。見落とされやすい理由は「append は自動で伸びるから大丈夫」という習慣。**config/migration.go、skills/registry.go、skills/loader.go ほか6箇所で観察された。** - -#### 7. 「Unicode 安全のための過剰な []rune 変換」パターン - -```go -// においのある書き方 -func Truncate(s string, max int) string { - runes := []rune(s) // ← 全文字を変換してから長さ確認 - if len(runes) <= max { - return s - } - return string(runes[:max]) -} -``` - -文字数を正しく数えるために `[]rune` へ変換するのは正しい。しかし **①変換前に `len(s)` で byte 長をチェックして早期 return できる**(ASCII なら byte 長 == rune 長)、**②実際の入力が ASCII 主体であれば `utf8.RuneCountInString` + `utf8.RuneError` チェックでアロケーションなしに処理できる**。`[]rune(s)` は文字列全体をヒープにコピーするため、長い文字列では無視できないコストになる。**utils/string.go の2関数、git/worktree.go で観察された。** - ---- - -## ストレージ保護設計 — 書き込みの遅延・バッチ化 - -> 追記 2026-02-24。microSD上で動作する前提でのFS書き込み最適化。 - -### 「誰がこのデータを必要とするか」マップ - -現状の永続化データを**消費者**と**書き込み頻度**で整理すると、書き込みを遅延できる余地が大きく異なる。 - -| データ | プロセス内読者 | プロセス外読者 | 書き込み頻度(現状) | 損失許容度 | -|--------|--------------|--------------|-----------------|----------| -| `sessions/*.json` | AgentLoop (ターン毎 `GetHistory`) | **なし**(起動時ロードのみ) | **メッセージ毎** | 中(会話消失は困るが致命ではない) | -| `state/stats.json` | StatusAPI, Mini App(in-process) | CLI `cmd_status` | **LLM呼び出し毎 + ユーザーメッセージ毎** | 低(数件のロスは許容) | -| `memory/MEMORY.md` | AgentLoop (ターン毎) | CLI, Mini App, **外部エディタ** | ステップ完了毎・LLM edit_file | 高(プラン状態が失われると復帰不能) | -| `memory/YYYYMM/DD.md` | AgentLoop(プランなし時) | 外部エディタ | 日次ノート追記時(低頻度) | 低 | - -### 重要な観察: セッションファイルはプロセス内専用データ - -`sessions/*.json` は**稼働中に外部プロセスが読まない**。唯一の利用タイミングは起動時の `loadSessions()`。つまり書き込みの目的は「クラッシュリカバリ」だけであり、**メッセージ毎の即時書き込みは過剰**。 - -同様に `state/stats.json` も、Mini App や CLI はプロセス内の `Tracker.GetStats()` 経由でメモリから読む。ファイルはプロセス再起動時の引き継ぎ専用。 - -### 推奨書き込み戦略 - -#### sessions/*.json — Write-behind (ダーティフラグ + 定期フラッシュ) - -``` -AddFullMessage() → in-memory のみ更新、dirty フラグ立て - ↓ - 定期タイマー (5分) or メッセージ数閾値 (20件) - またはシャットダウンフック → Save() -``` - -- リカバリウィンドウ: 最大5分 or 20メッセージ分 -- 書き込み回数削減率: 会話速度次第だが **10〜50倍** -- 実装: `SessionManager` に `dirtyKeys map[string]bool` + バックグラウンドフラッシャーgoroutine - -#### state/stats.json — 定期フラッシュのみ - -``` -RecordUsage() / RecordPrompt() → in-memory のみ更新 - ↓ - 定期タイマー (5分) → save() - + シャットダウンフック -``` - -- 損失リスク: 最大5分分の統計カウント(許容範囲) -- 書き込み回数削減率: **LLM呼び出し頻度 × 5分** = 数十〜数百倍 - -#### memory/MEMORY.md — ターンスコープキャッシュ (書き込みは即時維持) - -書き込みは現状通り即時。読み取りの問題だけ解決する。 - -``` -エージェントターン開始 → content := ReadLongTerm() を1回だけ - ↓ content を引数として全ヘルパーに渡す - (HasActivePlan(content), GetPlanStatus(content), ...) -エージェントターン終了 → content キャッシュ破棄 -``` - -- 外部エディタとの整合: ターン境界でリフレッシュされるので1ターン以内の外部編集のみ見逃す(許容範囲) -- LLM の edit_file 経由の書き込み: ファイルシステムに即座に書かれるため次ターンで自動反映 -- 読み取り回数削減: 1ターンあたり `5回以上 → 1回` - -### microSD 寿命への影響試算 - -一般的な会話セッション(1時間、60メッセージ、10 LLM呼び出し/分)の場合: - -| データ | 現状の書き込み回数/時 | 改善後 | 削減率 | -|--------|-------------------|--------|-------| -| sessions/*.json | ~60回 (メッセージ毎) | ~12回 (5分毎) | **80%減** | -| stats.json | ~660回 (LLM呼+prompt毎) | ~12回 (5分毎) | **98%減** | -| MEMORY.md | ステップ数分(変わらず) | 同左 | — | -| **合計** | **720+ 回/時** | **~24回/時** | **97%減** | - -### 実装上の注意点 - -- **シャットダウンフック必須**: `SIGTERM` / `SIGINT` で dirty なデータを強制フラッシュ。フラッシュ失敗時はログに記録。 -- **クラッシュ後のリカバリ**: dirty データが失われた場合、セッション履歴は最後のチェックポイント以降が消える。ユーザーへの通知が必要か検討。 -- **フラッシュ中の競合**: フラッシュgoroutineと `Save()` の同時呼び出しを防ぐため、既存の mutex を流用。 -- **MEMORY.md のターンキャッシュ**: `edit_file` ツールが MEMORY.md を書き込んだ場合、**同ターン内のキャッシュを無効化**する仕組みが必要(`MemoryStore.InvalidateCache()` を edit_file のコールバックから呼ぶなど)。そうしないと同ターン内の後続の `GetPlanStatus()` などが古いキャッシュを読む。 - -### RAM が潤沢な場合の設計変更 - -対象デバイスは RAM 7GB / available 5GB 超(例: `free -m` で available ~5260MB)。 -この前提が上記の各戦略に与える影響を整理する。 - -#### 読み取りレイテンシの実態 - -`buff/cache` が 4.6GB 程度を占めるということは、OS のページキャッシュが空き RAM をほぼ全て使い切っている状態。`ReadLongTerm()` の複数回呼び出しは**実際にはディスクアクセスしていない**(2回目以降はページキャッシュヒット、マイクロ秒オーダー)。 - -読み取りの実コストは「ディスクI/O」ではなく「**syscall + 文字列 Split/Join のアロケーション**」。ターンスコープキャッシュの主な効果はレイテンシ削減より**GC 圧力の軽減**に変わる。 - -#### 書き込み寿命はRAMに影響されない - -書き込みは `O_SYNC` ではなくても `os.Rename` でアトミックに書かれるが、カーネルはライトバックキャッシュを経由して最終的に SD に書く。ページキャッシュが書き込みを吸収しても**最終的な NAND への書き込み回数は変わらない**。書き込み削減の優先度は変わらず高い。 - -#### インメモリ表現の常駐が現実的になる - -RAM が逼迫していない場合、`MemoryStore` に `*ParsedPlan` をフィールドとして持たせる設計(D-1, D-6 の解決策)のメモリコストは無視できる。MEMORY.md が数KB〜数十KB であっても、パース済み構造体として常駐させて差し支えない。 - -```go -// 設計案: MemoryStore がパース済み状態を保持 -type MemoryStore struct { - workspace string - memoryFile string - mu sync.RWMutex - cached *ParsedPlan // nil = 未ロード - cachedAt time.Time -} -// edit_file ツールが書き込んだ後に InvalidateCache() を呼ぶことで -// 同ターン内の再読み込みをトリガーできる -``` - -これにより `GetMemoryContext()` 内の `ReadLongTerm()` 多重呼び出し問題(D-1)と、 -public メソッドが `content` を隠す問題(D-6)が同時に解消される。 - -#### write-behind 窓をさらに広げられる - -RAM が十分にあるため、セッションデータを長時間インメモリに保持するリスクがない。 -write-behind の戦略を「5分 or 20件」から**「グレースフルシャットダウン時のみ + 30分タイマー」**に緩和しても、 -クラッシュ時の損失(最大30分の会話)と実装の単純さのトレードオフとして許容できる可能性がある。 -プロジェクトの可用性要件に応じて判断する。 - -#### 優先実装順の修正 - -RAM 制約がない前提での推奨順: - -1. **`stats.json` の write-behind** — 実装が最も単純(タイマー1本追加)、書き込み削減率が最大(98%) -2. **`sessions/*.json` の write-behind** — セッション単位の dirty フラグ + シャットダウンフック -3. **`MemoryStore` への `*ParsedPlan` 常駐** — D-1/D-6 を根本解決、読み取りアロケーションをゼロに -4. **ターンスコープキャッシュ** — 3 が実装されれば自然に解決するため不要になる可能性あり - ---- - -## 改修計画 — メモリ最適化の実装フェーズ - -> 作成 2026-02-24。レビュー結果 (A〜H + D-1〜D-6 + ストレージ保護) を実装可能な単位に分割。 -> 各フェーズは `go build ./... && go test ./... && go vet ./...` が通る状態で完結する。 - -### フェーズ 0: 機械的な置き換え (低リスク・高カバレッジ) - -**目的**: コード構造を変えず、同じ関数内でパターンを置き換えるだけの修正。レビューが容易で回帰リスクが最小。 - -#### 0-1. strings.Builder 置き換え (カテゴリ A 残り) - -| ファイル | 関数 | 優先度 | -|----------|------|--------| -| `pkg/tools/web.go` | `BraveSearchProvider.Search()` L73-85 | 🔴 | -| `pkg/tools/web.go` | `TavilySearchProvider.Search()` L155-167 | 🔴 | -| `pkg/tools/web.go` | `DuckDuckGoSearchProvider.extractResults()` L211-254 | 🔴 | -| `pkg/tools/web.go` | `WebFetchTool.extractText()` L592-617 | 🔴 | -| `pkg/skills/loader.go` | `BuildSkillsSummary()` L234-250 | 🔴 | -| `pkg/agent/context.go` | `BuildSystemPrompt()` L247 — `+=` を Builder に | 🟡 | -| `pkg/logger/logger.go` | `formatFields()` L241-246 | 🟡 | -| `pkg/skills/loader.go` | `LoadSkillsForContext()` L217-225 | 🟡 | -| `pkg/channels/telegram.go` | `extractCodeBlocks()` L789-806 — codes 無容量 + ループ内 `fmt.Sprintf` | 🔴 | -| `pkg/channels/telegram.go` | `extractInlineCodes()` L813-830 — 同上パターン | 🔴 | -| `pkg/channels/discord.go` | `appendContent()` L162-168 | 🟡 | -| `pkg/channels/slack.go` | `handleMessageEvent()` L234-272 | 🟡 | - -#### 0-2. スライス事前容量 (カテゴリ B 残り) - -| ファイル | 変更 | -|----------|------| -| `pkg/skills/loader.go:73` | `make([]SkillInfo, 0)` → `make([]SkillInfo, 0, 20)` | -| `pkg/config/config.go:628` | `var matches` → `make([]ModelConfig, 0, 4)` | -| `pkg/config/migration.go:48` | `var result` → `make([]ModelConfig, 0, 20)` | -| `pkg/skills/registry.go:183` | `var merged` → `make([]SearchResult, 0, len(regs)*limit)` | -| `pkg/skills/search_cache.go:42-43` | map/slice に `maxEntries` ヒント | -| `pkg/channels/telegram.go:832-861` | `extractMarkdownTables()` — `tables` スライスに容量ヒント | - -#### 0-3. byte/string 変換の削減 (カテゴリ C) - -| ファイル | 変更 | -|----------|------| -| `pkg/tools/web.go:289` | `strings.NewReader(string(payloadBytes))` → `bytes.NewReader(payloadBytes)` | -| `pkg/tools/web.go:545-562` | 複数の `string(body)` → 1回だけ変換して変数に保持 | -| `pkg/providers/claude_cli_provider.go:133` | `string(paramsJSON)` → `sb.Write(paramsJSON)` | -| `pkg/utils/string.go:100` | `Truncate()` — `len(s) <= max` で早期 return (ASCII fast path) | -| `pkg/utils/string.go:50` | `wrapLine()` — 同上 ASCII fast path | -| `pkg/git/worktree.go:71-75` | `[]rune` → byte 長チェックで早期 return | -| `pkg/channels/telegram.go:1071-1111` | `wrapByDisplayWidth()` — ループ内 `string(r)` を `displayWidth` の引数を `rune` に変更して排除 | - -#### 0-4. パッケージ変数化 (カテゴリ H) - -| ファイル | 変更 | -|----------|------| -| `pkg/utils/media.go:18-19` | `audioExtensions`/`audioTypes` を関数外の `var` に | -| `pkg/skills/clawhub_registry.go:114` | `fmt.Sprintf("%d", limit)` → `strconv.Itoa(limit)` | -| `pkg/agent/memory.go:567, 663` | `regexp.MustCompile(...)` インライン → 既存パッケージ変数 `reTaskLine` (L469) に置き換え | - -**コミット単位**: 0-1, 0-2, 0-3, 0-4 をそれぞれ個別コミット。 - ---- - -### フェーズ 1: 値渡し・コピーの最適化 (カテゴリ E) - -**目的**: struct の不要なコピーを削減。型シグネチャが変わるため呼び出し側の修正が必要。 - -| ファイル | 変更 | 注意 | -|----------|------|------| -| `pkg/logger/logger.go:88-92` | リングバッファ内部型を `[]*LogEntry` に変更 + `visit(fn)` メソッド追加。`RecentLogs()` を visit ベースに書き換え (フィルタで弾くエントリのコピーを排除) | `push()` 毎に1ヒープアロケーション増だがログI/Oパスなので許容 | - -**削除した項目**: -- `session_tracker.go:125` — `Touch()` がロックなしにフィールドを直接更新しており、`*entry` 値コピー (L125) が唯一の安全装置。ポインタ返却は安全上の退行。`SessionEntry` は ~80バイトの小さい struct でコピーコストも無視可能。 -- `skills/registry.go:132-133` — `SkillRegistry` はインターフェース型。`*SkillRegistry` は pointer-to-interface アンチパターン。コピーも n × 16バイト (n=2〜5) で無視可能。 - -**コミット**: 1つにまとめる。 - ---- - -### フェーズ 2: JSON ホットパスの最適化 (カテゴリ D) - -**目的**: ストリーミングループ内の重複 Marshal/Unmarshal を排除。 - -#### 2-1. openai_compat streaming の Arguments 重複 Unmarshal - -`pkg/providers/openai_compat/provider.go` L274, 362, 621 -— ストリーム完了時に1回だけ Unmarshal するよう制御フローを整理。 - -#### 2-2. codex CLI の Parameters 重複 Marshal - -`pkg/providers/codex_cli_provider.go:154-155` -— ツール定義はループ外で1回 Marshal してキャッシュ、またはループ内で `json.RawMessage` 直接書き込み。 - -※ `claude_cli_provider.go:133` の `string(paramsJSON)` → `sb.Write(paramsJSON)` は byte/string 変換の問題であり Phase 0-3 で対応済み。 - -**コミット**: 2-1, 2-2 を個別。 - ---- - -### フェーズ 3: MemoryStore の読み取り最適化 (設計 D-1, D-6) - -**目的**: `GetMemoryContext()` 1回で `ReadLongTerm()` が 5回以上呼ばれる問題を解消。 - -#### 3-1. `GetMemoryContext()` を content パススルー方式にリファクタ - -既存の `GetMemoryContext()` を直接書き直す(新関数は追加しない)。 -内部で `ReadLongTerm()` を1回だけ呼び、取得した `content` を既存の private ヘルパー群に渡す。 - -`HasActivePlan`, `GetPlanStatus`, `GetCurrentPhase`, `GetTotalPhases` はいずれもパッケージ変数 regex (`reActivePlan`, `reStatus`, `rePhase`, `rePhaseHeader`) を1〜2行で呼ぶだけなので、private 関数を新規作成せずインライン化できる。`GetPlanPhases` のみ42行の複雑なロジックがあるため private variant (`getPlanPhasesFrom(content)`) を1つ追加。 - -修正対象は3関数: - -**`GetMemoryContext()` L725** — `HasActivePlan`/`GetPlanStatus` をインライン化 (`GetPlanPhases` は使わない): -```go -content := ms.ReadLongTerm() -if reActivePlan.MatchString(content) { - var status string - if m := reStatus.FindStringSubmatch(content); len(m) >= 2 { - status = strings.TrimSpace(m[1]) - } - switch status { ... } -} -``` - -**`FormatPlanDisplay()` L656** — 全メソッドをインライン化 + `getPlanPhasesFrom`: -```go -content := ms.ReadLongTerm() -if !reActivePlan.MatchString(content) { return "No active plan." } -var status string -if m := reStatus.FindStringSubmatch(content); len(m) >= 2 { status = strings.TrimSpace(m[1]) } -var currentPhase int -if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { currentPhase, _ = strconv.Atoi(m[1]) } -phases := getPlanPhasesFrom(content) // private 関数 (1つだけ新設) -``` - -**`GetPlanContext()` L560** — `GetCurrentPhase`/`GetTotalPhases` をインライン化: -```go -content := ms.ReadLongTerm() -var currentPhase int -if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { currentPhase, _ = strconv.Atoi(m[1]) } -// GetTotalPhases: rePhaseHeader.FindAllStringSubmatch(content, -1) → max loop -``` - -既存の public メソッド (`HasActivePlan()`, `GetPlanStatus()` 等) は互換性のため残す(単体テスト・CLI から個別に呼ばれる)。 - -#### 3-2. Split 重複の統合 (読み取りパスのみ) - -content パススルーで解消されるのは `ReadLongTerm()` の多重呼び出しのみ。 -`extractPhaseContent()`, `GetPlanPhases()` 等が個別に `strings.Split` する問題は残る。 - -対応: `GetPlanContext()` / `FormatPlanDisplay()` 内で1回 `strings.Split(content, "\n")` し、`[]string` (行スライス) を受け取る内部ヘルパーを追加。既存の `content string` を受け取るヘルパーは互換性のため残す。(`GetMemoryContext()` 自身は Split ヘルパーを直接呼ばないため対象外。Split は呼び先の `GetPlanContext()` 等で発生する。) - -**効果範囲の限定**: この統合が効くのは読み取り専用メソッド (`GetPlanContext`, `FormatPlanDisplay`) のみ。ミューテーション系 (`MarkStep`, `AddStep`) は `GetMemoryContext()` を経由せず直接 `ReadLongTerm()` + `Split` + `WriteLongTerm()` を実行するため、この Phase では対象外。ミューテーション系の Split 統合には ParsedPlan インメモリモデル (Phase 5) が必要。 - -**コミット**: 1つ。 - ---- - -### フェーズ 4: ストレージ保護 — write-behind (設計セクション) - -**目的**: microSD 書き込み回数を 97% 削減。 - -#### 4-1. stats.json の write-behind - -- `RecordUsage()` L77 / `RecordPrompt()` L90 の `t.save()` 呼び出しを削除 (カウンタ更新はインメモリのみに) -- 起動時に `time.NewTicker(5 * time.Minute)` → `t.save()` のタイマー goroutine 1本追加 -- `Close()` メソッドを新規追加: タイマー停止 + 最終 `t.save()` -- `Reset()` L111 の `t.save()` は意味的チェックポイントなので即時維持 -- dirty フラグは不要 (タイマーが無更新時に save() しても同内容の上書きで無害) - -#### 4-2. sessions/*.json の write-behind - -`AddFullMessage()` はすでにインメモリのみの操作。書き込みは `loop.go` が `Save()` を明示的に呼ぶ5箇所で発生する。 - -| loop.go 行 | 文脈 | 頻度 | 方針 | -|------------|------|------|------| -| L996 | エージェントターン終了 | **毎ターン** | dirty マーク化 (主要ターゲット) | -| L299 | `/plan start clear` 履歴クリア | 低頻度 | 即時書き込み維持 (意味的チェックポイント) | -| L836 | tool call sanitize | 低頻度 | 即時書き込み維持 | -| L2339 | 強制履歴圧縮 | 低頻度 | 即時書き込み維持 | -| L2568 | サマリー生成・トランケート | 低頻度 | 即時書き込み維持 | - -- L996 の `Save()` を `MarkDirty()` に変更、バックグラウンドフラッシャー (5分タイマー) で遅延書き込み -- 残り4箇所は意味的なチェックポイントなので `Save()` を即時維持 -- `SessionManager` に `dirtyKeys map[string]bool` + フラッシャー goroutine 追加 -- シャットダウンフックで全 dirty セッションをフラッシュ - -#### `AppendToday()` について - -`AppendToday()` (日次ノート追記) は write-behind の対象外とする。理由: -- 書き込み頻度が低い(日次ノート追記時のみ) -- 書き込み内容がユーザーの手動確認対象であり、即時反映が望ましい -- sessions/stats と異なり、遅延のメリットが小さい - -**コミット**: 4-1, 4-2 を個別。 - ---- - -### フェーズ 5: 発展的最適化 (任意) - -実装コストが高い or 効果が限定的なもの。必要に応じて着手。 - -| 項目 | 内容 | 見送り理由 | -|------|------|-----------| -| F: sync.Pool | web.go extractText, telegram.go Markdown 変換 | 呼び出し頻度が低く Pool の効果が薄い可能性 | -| G: LRU O(1) 化 | search_cache.go を doubly-linked list に | maxEntries=100 で O(n) でも十分高速 | -| D-2: FunctionCall.Arguments 型変更 | `string` → `json.RawMessage` | 全プロバイダーに波及、破壊的変更 | -| D-3: Parameters を RawMessage に | 同上 | 同上 | -| D-5: Session.Messages を immutable に | COW or linked list | セッション管理の根本再設計が必要 | -| ParsedPlan インメモリモデル | MemoryStore にパース済み構造体を常駐させ MarkStep/AddStep の Split 重複を根本解消 (D-1/D-6 完全解決) | 設計変更が広範囲 | - ---- - -### セッション管理の発展的展望 - -> 現状の設計は「正確性」は成熟しているが「ライフサイクル」が欠落している。 -> 以下は実装コスト・実用価値の観点で3段階に整理した将来の発展方向。 - -#### 近期: 運用上の成熟 - -**セッションライフサイクルの明示化** - -現在 `Session.Created` / `Session.Updated` はフィールドに存在するが使われていない。`Delete()` API と TTL 付きエビクションを加えるだけで「セッションを意識的に管理できる」状態になる。 - -```go -type Session struct { - // 既存フィールド ... - Name string // 「project-x」などの名前付け - Tags []string // タグによる分類 - Archived bool // アーカイブ済みフラグ - ParentKey string // 親セッション (subagent chain の明示化) -} -``` - -- `SessionManager.Delete(key)` の追加 -- `sessionLocks sync.Map` (loop.go) の GC — 現状ユニークキーが増えると未回収で膨れる -- 起動時の `loadSessions()` を遅延ロード化 (セッションファイル数が増えた場合の起動時間対策) - -#### 中期: 会話の構造化 - -**チェックポイント / ロールバック** - -``` -[turn 1] → [turn 2] → [turn 3 : checkpoint A] → [turn 4] → [turn 5] - ↑ - 「turn 3 に戻る」= turn 4, 5 を捨てて再開 -``` - -LLM が方向を間違えた時点に戻るユースケースは個人利用でも頻繁に発生する。 -`Session.Messages` を append-only immutable にする (D-5 COW) と自然につながる。 - -**名前付きセッション / 意図的な切り替え** - -現在セッションキーは「どこから来たか」(チャンネル+ピア) で決まる。これを「何の文脈か」でも切り替えられるようにする: - -``` -/new-session "refactoring-auth" → 新しいセッションを明示的に開始 -/switch-session "refactoring-auth" → 過去の名前付きセッションに戻る -/list-sessions → セッション一覧 -``` - -`routing/session_key.go` の `BuildAgentPeerSessionKey()` はすでに柔軟な構造なので、セッション名を key の一部として持つことは設計上無理がない。 - -#### 長期: セッション間の関係 - -**サブエージェントセッションのグラフ化** - -現在、サブエージェントセッションは `IsSubagentSessionKey()` で判定できるが、「どの親セッションから生まれたか」という親子関係は key の命名規則に暗黙的に埋め込まれているだけ: - -``` -agent:main:main - └─ subagent:abc123:main ← 親が main:main とは構造的に管理されていない - └─ subagent:def456:main -``` - -`Session.ParentKey` を追加してセッションをグラフとして持てると、「このサブエージェントが何をやったか」を親セッションから遡れるようになる。 - -**クロスセッション検索** - -``` -「以前 auth について話したとき何を決めたっけ」 -→ セッション横断でキーワード検索 → 関連ターンを抽出してプロンプトに注入 -``` - -`MEMORY.md` は現状「プラン専用の永続メモリ」だが、クロスセッション検索はその補完として機能する。 - -> **注意: 遅延ロードとの競合** -> 近期の「遅延ロード化」を実装すると、起動時に全セッションがメモリにある前提が崩れる。 -> 両方を採用する場合は以下のいずれかを選択する必要がある: -> - **検索時フルスキャン**: 検索リクエストのたびに `sessions/` ディレクトリの全 JSON を読む (低頻度なら許容) -> - **バックグラウンドインデックス**: 起動後にゴルーチンで全ファイルを非同期スキャンし、キーワードインデックスを構築・維持する - -#### 設計上の選択肢 - -現在の構造は2つの哲学の中間に位置している: - -| 哲学A: 履歴中心 | 哲学B: 知識中心 | -|----------------|----------------| -| `Session.Messages` が唯一の真実 | 重要な情報を `MEMORY.md` 等に蒸留 | -| 会話を「再生」してコンテキスト再現 | 構造化知識を「注入」してコンテキスト構築 | -| ロールバック・ブランチが自然な拡張 | クロスセッション検索が自然な拡張 | - -このコードベースはすでに `Session.Messages` (哲学A) と `MEMORY.md` (哲学B) が共存しており、Phase 5 の `ParsedPlan インメモリモデル` は哲学B 方向への布石になる。 - ---- - -### 実装順サマリー - -``` -Phase 0 ──→ Phase 1 ──→ Phase 2 ──→ Phase 3 ──→ Phase 4 - 機械的 値渡し JSON Memory Storage - 置き換え 最適化 ホットパス 読み取り write-behind - (4 commits) (1 commit) (2 commits) (2 commits) (2 commits) -``` - -- Phase 0〜2: **アロケーション削減** (GC 圧力軽減) -- Phase 3: **syscall + Split/Join 削減** (CPU + アロケーション) -- Phase 4: **ディスク書き込み削減** (microSD 寿命保護) -- Phase 5: 必要に応じて個別判断 - ---- +**長期:** +- `Session.ParentKey` でサブエージェントセッションをグラフ化 +- クロスセッション検索 (MEMORY.md の補完として) +設計の哲学: `Session.Messages` (履歴中心) と `MEMORY.md` (知識中心) が現状共存している。どちらを主軸にするかで発展方向が変わる。 From bfabdea8b759e5acaaaa5a72679e14fe9332fa1f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:54:49 +0900 Subject: [PATCH 03/11] feat: add --orchestration startup flag to gate spawn tool Orchestration (subagent spawning) is now opt-in rather than always-on. The spawn tool is only registered when orchestration is explicitly enabled, preventing the LLM from having a tool it has no guidance to use. Changes: - config: SubagentsConfig.Enabled bool (opt-in per agent via JSON config) - config: AgentDefaults.Orchestration bool (env PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION) - agent/instance: apply defaults.Orchestration to agent.Subagents at construction - agent/loop: gate spawn tool registration on agent.Subagents.Enabled - cmd/agent: --orchestration flag sets cfg.Agents.Defaults.Orchestration = true Usage: picoclaw agent --orchestration Co-Authored-By: Claude Sonnet 4.6 --- cmd/picoclaw/cmd_agent.go | 7 +++++++ pkg/agent/instance.go | 9 +++++++++ pkg/agent/loop.go | 20 +++++++++++--------- pkg/config/config.go | 2 ++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 8658c9d32..5ce0c3b99 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -24,6 +24,7 @@ func agentCmd() { message := "" sessionKey := "cli:default" modelOverride := "" + orchestrationEnabled := false args := os.Args[2:] for i := 0; i < len(args); i++ { @@ -46,6 +47,8 @@ func agentCmd() { modelOverride = args[i+1] i++ } + case "--orchestration": + orchestrationEnabled = true } } @@ -59,6 +62,10 @@ func agentCmd() { cfg.Agents.Defaults.Model = modelOverride } + if orchestrationEnabled { + cfg.Agents.Defaults.Orchestration = true + } + provider, modelID, err := providers.CreateProvider(cfg) if err != nil { fmt.Printf("Error creating provider: %v\n", err) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index a767bcb04..c84a70660 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -91,6 +91,15 @@ func NewAgentInstance( skillsFilter = agentCfg.Skills } + // Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled. + if defaults.Orchestration { + if subagents == nil { + subagents = &config.SubagentsConfig{Enabled: true} + } else { + subagents.Enabled = true + } + } + maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 75284b6dc..cdcd1f608 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -216,15 +216,17 @@ func registerSharedTools( agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) - // Spawn tool with allowlist checker - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) - subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) - spawnTool := tools.NewSpawnTool(subagentManager) - currentAgentID := agentID - spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { - return registry.CanSpawnSubagent(currentAgentID, targetAgentID) - }) - agent.Tools.Register(spawnTool) + // Spawn tool — only registered when orchestration is explicitly enabled. + if agent.Subagents != nil && agent.Subagents.Enabled { + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + spawnTool := tools.NewSpawnTool(subagentManager) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + agent.Tools.Register(spawnTool) + } // Update context builder with the complete tools registry agent.ContextBuilder.SetToolsRegistry(agent.Tools) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7ac337856..299f7334a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -141,6 +141,7 @@ type AgentConfig struct { } type SubagentsConfig struct { + Enabled bool `json:"enabled,omitempty"` AllowAgents []string `json:"allow_agents,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` } @@ -182,6 +183,7 @@ type AgentDefaults struct { Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"` + Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"` } type ChannelsConfig struct { From 932a4149e5442beba8747ddcceb91ce3d669e296 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:25:35 +0900 Subject: [PATCH 04/11] feat: add orchestration room map (procedural + external asset support) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit map.js provides: - MAP_POSITIONS: pixel coords for conductor, secretary, 5 stations, door, meeting area — the single source of truth for character placement - loadMapAsset(cb): tries to load map.png; falls back to procedural drawing - drawMap(ctx): uses loaded image or procedural fallback transparently To replace the procedural map with a hand-crafted asset: drop map.png (320×320px) next to index.html — no code changes required. map-preview.html: standalone preview showing the room + all character positions with emoji markers. Co-Authored-By: Claude Sonnet 4.6 --- pkg/miniapp/static/map-preview.html | 98 +++++++++++++ pkg/miniapp/static/map.js | 204 ++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 pkg/miniapp/static/map-preview.html create mode 100644 pkg/miniapp/static/map.js diff --git a/pkg/miniapp/static/map-preview.html b/pkg/miniapp/static/map-preview.html new file mode 100644 index 000000000..4df562f39 --- /dev/null +++ b/pkg/miniapp/static/map-preview.html @@ -0,0 +1,98 @@ + + + + + + Orchestration Room — Preview + + + + + + + +
+ 👑 conductor + 👩‍💼 secretary + 🔍 scout + 📊 analyst + 💻 coder + 🔧 worker + 🎯 coordinator + 🚪 door +
+ + + + diff --git a/pkg/miniapp/static/map.js b/pkg/miniapp/static/map.js new file mode 100644 index 000000000..74b56e433 --- /dev/null +++ b/pkg/miniapp/static/map.js @@ -0,0 +1,204 @@ +// map.js — Orchestration Room +// +// External asset: drop map.png (320×320px) next to index.html to replace +// the procedural fallback. Character positions (MAP_POSITIONS) are defined +// in canvas-pixel coordinates and remain valid regardless of which rendering +// path is used — just make sure your map.png matches them. +// +// Usage: +// loadMapAsset(function() { drawMap(ctx); }); // call once on init +// drawMap(ctx); // call each frame + +// ─── Character home positions (px, canvas 320×320) ───────────────────────── +// +// ┌──────────────────────────────┐ +// │ [conductor desk] │ y ≈ 20–50 +// │ 👑(160,58) 👩‍💼(108,58) │ +// │ [carpet] │ +// │ [WS1] [WS2] [WS3] │ y ≈ 80–100 +// │ 🔍40 💻144 📊248 │ y = 106 +// │ [meeting area] │ y ≈ 130–192 +// │ [WS4] [WS5] │ y ≈ 200–220 +// │ 🔧40 🎯144 │ y = 222 +// │ 🚪(160,308) │ door +// └──────────────────────────────┘ + +var MAP_POSITIONS = { + door: { x: 160, y: 314 }, // entry / exit point + conductor: { x: 160, y: 58 }, + secretary: { x: 108, y: 58 }, + meeting: { x: 160, y: 161 }, // neutral zone for conversations + stations: [ + { x: 40, y: 106 }, // S0 scout + { x: 144, y: 106 }, // S1 analyst + { x: 248, y: 106 }, // S2 coder + { x: 40, y: 222 }, // S3 worker + { x: 144, y: 222 }, // S4 coordinator + ], +}; + +// ─── Asset loading ────────────────────────────────────────────────────────── + +var _mapImage = null; + +// Call once before first draw. cb() is invoked when ready (image or fallback). +function loadMapAsset(cb) { + var img = new Image(); + img.onload = function() { _mapImage = img; cb(); }; + img.onerror = function() { cb(); }; // no map.png → use fallback + img.src = './map.png'; +} + +// ─── Public draw entry point ──────────────────────────────────────────────── + +function drawMap(ctx) { + ctx.imageSmoothingEnabled = false; + if (_mapImage) { + ctx.drawImage(_mapImage, 0, 0, 320, 320); + } else { + _drawMapFallback(ctx); + } +} + +// ─── Procedural fallback ──────────────────────────────────────────────────── + +var _C = { + wallDark: '#0c1018', + wallHighlight: '#252d3f', + floorA: '#171b2c', + floorB: '#1b2033', + carpetBase: '#1a2050', + carpetBorder: '#2a3480', + deskBack: '#2c3e6b', + deskTop: '#3a50a0', + deskEdge: '#4a6ac0', + deskShadow: '#1a2448', + monitorFrame: '#070b14', + monitorBlue: '#1040a0', + monitorGlow: '#4488ff', + wsBase: '#162818', + wsTop: '#1e3822', + wsEdge: '#2a5030', + termGlow: '#00dd55', + rugFill: '#1c2248', + rugBorder: '#283070', + doorMid: '#8a5818', + doorLight: '#a06820', + doorGold: '#c8940a', +}; + +function _r(ctx, color, x, y, w, h, alpha) { + ctx.globalAlpha = alpha === undefined ? 1 : alpha; + ctx.fillStyle = color; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; +} + +function _b(ctx, color, x, y, w, h) { + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); +} + +function _dot(ctx, color, x, y) { + ctx.fillStyle = color; + ctx.fillRect(x, y, 2, 2); +} + +function _workstation(ctx, x, y) { + _r(ctx, _C.wsBase, x, y, 48, 20); + _r(ctx, _C.wsTop, x, y, 48, 8); + _r(ctx, _C.wsEdge, x, y, 2, 20); + _r(ctx, _C.wsEdge, x+46, y, 2, 20); + _r(ctx, _C.wsEdge, x, y, 48, 2); + // terminal screen + _r(ctx, _C.monitorFrame, x+16, y+2, 16, 12); + _r(ctx, '#041008', x+17, y+3, 14, 10); + _r(ctx, '#003315', x+18, y+4, 12, 8); + _r(ctx, _C.termGlow, x+20, y+6, 8, 3); + _dot(ctx, '#00ff88', x+22, y+6); +} + +function _drawMapFallback(ctx) { + var T = 16; + + // floor tiles + for (var ty = 0; ty < 20; ty++) { + for (var tx = 0; tx < 20; tx++) { + ctx.fillStyle = (tx + ty) % 2 === 0 ? _C.floorA : _C.floorB; + ctx.fillRect(tx * T, ty * T, T, T); + } + } + + // conductor carpet + _r(ctx, _C.carpetBase, 16, 16, 288, 50); + _b(ctx, _C.carpetBorder, 18, 18, 284, 46); + + // conductor desk + _r(ctx, _C.deskBack, 96, 20, 128, 30); + _r(ctx, _C.deskTop, 96, 20, 128, 12); + _r(ctx, _C.deskEdge, 96, 20, 128, 2); + _r(ctx, _C.deskEdge, 96, 20, 2, 30); + _r(ctx, _C.deskEdge, 222, 20, 2, 30); + _r(ctx, _C.deskShadow,96,48, 128, 4); + // monitor + _r(ctx, _C.monitorFrame, 138, 22, 44, 14); + _r(ctx, _C.monitorBlue, 140, 23, 40, 12); + _r(ctx, _C.monitorGlow, 156, 26, 8, 6); + _r(ctx, '#6699ff', 158, 27, 4, 3); + + // workstations + _workstation(ctx, 16, 80); // S0 + _workstation(ctx, 128, 80); // S1 (x+24 = 152 ≈ 144 center) + _workstation(ctx, 224, 80); // S2 + _workstation(ctx, 16, 200); // S3 + _workstation(ctx, 128, 200); // S4 + + // meeting rug + _r(ctx, _C.rugFill, 64, 130, 192, 62, 0.55); + _b(ctx, _C.rugBorder, 66, 132, 188, 58); + _b(ctx, '#202860', 70, 136, 180, 50); + + // bulletin board (left wall) + _r(ctx, '#2c1a06', 18, 148, 36, 44); + _r(ctx, '#3a2508', 20, 150, 32, 40); + _r(ctx, '#cc9900', 22, 153, 12, 8); + _r(ctx, '#dd8800', 22, 164, 10, 6); + _r(ctx, '#bb7700', 34, 155, 13, 8); + _r(ctx, '#ccaa00', 33, 165, 11, 6); + _dot(ctx, '#ff4444', 28, 153); + _dot(ctx, '#44aaff', 41, 158); + _dot(ctx, '#44ff88', 27, 165); + + // server rack (right wall) + _r(ctx, '#111122', 285, 80, 18, 112); + _r(ctx, '#181830', 287, 82, 14, 108); + for (var i = 0; i < 10; i++) { + var ry = 85 + i * 10; + _r(ctx, '#0a0a12', 288, ry, 12, 8); + var lc = ['#00ff44','#0044ff','#ff3300','#111111'][i % 4]; + _r(ctx, lc, 296, ry + 2, 3, 4); + } + + // walls (drawn last to cover any overruns) + _r(ctx, _C.wallDark, 0, 0, 320, 16); + _r(ctx, _C.wallHighlight, 0, 14, 320, 2); + _r(ctx, _C.wallDark, 0, 0, 16, 320); + _r(ctx, _C.wallHighlight,14, 0, 2, 320); + _r(ctx, _C.wallDark, 304, 0, 16, 320); + _r(ctx, _C.wallHighlight,304, 0, 2, 320); + _r(ctx, _C.wallDark, 0, 304, 144, 16); + _r(ctx, _C.wallDark, 176, 304, 144, 16); + _r(ctx, _C.wallHighlight, 0, 304, 144, 2); + _r(ctx, _C.wallHighlight,176, 304, 144, 2); + + // door + _r(ctx, '#0a0808', 144, 292, 32, 12); // outside (dark) + _r(ctx, _C.doorMid, 144, 280, 32, 24); + _r(ctx, _C.doorLight, 144, 280, 32, 3); + _r(ctx, _C.doorLight, 144, 280, 3, 24); + _r(ctx, _C.doorLight, 173, 280, 3, 24); + _r(ctx, '#4a2408', 146, 284, 12, 16); // door panels + _r(ctx, '#4a2408', 162, 284, 12, 16); + _r(ctx, _C.doorGold, 170, 291, 5, 5); // handle +} From 02e5b6b645b5849f909ee69d755728e8e757f81a Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:32:59 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20orchestration=20room=20UI=20?= =?UTF-8?q?=E2=80=94=20side=20panels,=20bob=20animation,=20demo=20sequence?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Three-column layout: left panel (conductor/secretary), canvas, right panel (5 agent presets). Canvas CSS-scales to fill available width. - Side badges: opacity 0.3 when agent not alive, full when alive. Dot color: green=alive, orange=toolcall (fast blink), blue=waiting, yellow=talking. - Bob animation: 4-frame cycle [0,-1,-2,-1]px, speed set per state: idle 450ms/frame (1.8s cycle, calm) waiting 650ms/frame (2.6s cycle, lazy — LLM response pending) toolcall 90ms/frame (360ms cycle, rapid — tool executing) talking 280ms/frame - Speech bubbles: yellow box with pixel tail, auto-expire. - Status ring: orange halo on toolcall, blue halo on waiting. - Demo sequencer plays a full scenario (spawn → toolcall → wait → converse → gc → repeat) to demonstrate all states without a live WebSocket connection. WebSocket event contract (for future backend wiring): { type: "agent_spawn", id, task } { type: "agent_state", id, state } // toolcall | waiting | idle { type: "conversation", from, to, text } { type: "agent_gc", id } Co-Authored-By: Claude Sonnet 4.6 --- pkg/miniapp/static/map-preview.html | 472 ++++++++++++++++++++++++---- 1 file changed, 416 insertions(+), 56 deletions(-) diff --git a/pkg/miniapp/static/map-preview.html b/pkg/miniapp/static/map-preview.html index 4df562f39..5cadcae5d 100644 --- a/pkg/miniapp/static/map-preview.html +++ b/pkg/miniapp/static/map-preview.html @@ -3,10 +3,10 @@ - Orchestration Room — Preview + Orchestration Room - -
- 👑 conductor - 👩‍💼 secretary - 🔍 scout - 📊 analyst - 💻 coder - 🔧 worker - 🎯 coordinator - 🚪 door +
+ + +
+
+
👑
+
CNDR
+
+
+
+
👩‍💼
+
SEC
+
+
- + // move toward target + if (c.target) { + var dx = c.target.x - c.x; + var dy = c.target.y - c.y; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 1.5) { + var spd = WALK_SPEED * dt / 1000; + c.x += dx / dist * spd; + c.y += dy / dist * spd; + } else { + c.x = c.target.x; + c.y = c.target.y; + c.target = null; + if (c._onArrive) { c._onArrive(); c._onArrive = null; } + } + } + + // bubble timeout + if (c.bubble) { + c.bubble.ttl -= dt; + if (c.bubble.ttl <= 0) c.bubble = null; + } + }); +} + +// ─── draw loop ──────────────────────────────────────────────────────────── +function drawBubble(c) { + if (!c.bubble) return; + var text = c.bubble.text; + var yOff = BOB[c.frame]; + var bx = c.x; + var by = c.y + yOff - 18; + + ctx.font = '7px Silkscreen, monospace'; + var tw = ctx.measureText(text).width; + var pw = tw + 8; + var ph = 12; + + // clamp to canvas + var lx = Math.max(4, Math.min(316 - pw, bx - pw / 2)); + + // box + ctx.fillStyle = '#facc15'; + ctx.fillRect(Math.floor(lx), Math.floor(by - ph), Math.ceil(pw), Math.ceil(ph)); + // tail pixel + ctx.fillRect(Math.floor(bx) - 1, Math.floor(by), 3, 3); + // text + ctx.fillStyle = '#0a0a00'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, Math.floor(lx + 4), Math.floor(by - ph / 2)); +} + +function drawChar(c) { + if (!c.alive && c.state !== 'entering' && c.state !== 'exiting') return; + var yOff = BOB[c.frame]; + var cx = Math.floor(c.x); + var cy = Math.floor(c.y + yOff); + + // status ring (toolcall = orange, waiting = blue) + if (c.state === 'toolcall') { + ctx.fillStyle = 'rgba(251,146,60,0.35)'; + ctx.beginPath(); + ctx.arc(cx, cy, 13, 0, Math.PI * 2); + ctx.fill(); + } else if (c.state === 'waiting') { + ctx.fillStyle = 'rgba(96,165,250,0.25)'; + ctx.beginPath(); + ctx.arc(cx, cy, 11, 0, Math.PI * 2); + ctx.fill(); + } + + // emoji + ctx.font = '18px serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(c.emoji, cx, cy); + + // name label + ctx.font = '6px Silkscreen, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = c.state === 'talking' ? '#facc15' : '#3a4a7a'; + ctx.fillText(c.id.toUpperCase(), cx, cy + 11); + + drawBubble(c); +} + +function render(ts) { + if (lastTs === null) lastTs = ts; + var dt = Math.min(ts - lastTs, 80); // cap at 80ms to avoid spiral + lastTs = ts; + + update(dt); + + ctx.imageSmoothingEnabled = false; + drawMap(ctx); + + allChars().forEach(drawChar); + + requestAnimationFrame(render); +} + +// ─── demo sequencer (replaces WebSocket in preview) ────────────────────── +// WebSocket events will look like: +// { type: "agent_spawn", id: "s0", task: "find APIs" } +// { type: "agent_state", id: "s0", state: "toolcall" } +// { type: "agent_state", id: "s0", state: "waiting" } +// { type: "conversation", from: "conductor", to: "s0", text: "summary?" } +// { type: "agent_gc", id: "s0" } + +var demoLabel = document.getElementById('demo-label'); + +function demoSpawn(agent) { + agent.alive = true; + agent.x = MAP_POSITIONS.door.x; + agent.y = MAP_POSITIONS.door.y; + setState(agent, 'entering'); + syncBadge(agent.id, 'entering', true); + moveTo(agent, agent.home, function() { + setState(agent, 'idle'); + }); +} + +function demoConverse(from, to, text, reply) { + // move toward each other + var mid = { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2, + }; + setState(from, 'talking'); + setState(to, 'talking'); + moveTo(from, { x: mid.x - 18, y: mid.y }, function() { + say(from, text, 2400); + }); + moveTo(to, { x: mid.x + 18, y: mid.y }, function() { + if (reply) setTimeout(function() { say(to, reply, 2200); }, 1600); + setTimeout(function() { + moveTo(from, from.home, function() { setState(from, 'idle'); }); + moveTo(to, to.home, function() { setState(to, 'idle'); }); + }, reply ? 3800 : 2600); + }); +} + +function demoGC(agent) { + setState(agent, 'exiting'); + moveTo(agent, MAP_POSITIONS.door, function() { + agent.alive = false; + setState(agent, 'idle'); + syncBadge(agent.id, 'idle', false); + }); +} + +// demo timeline +var demo = [ + [ 400, function() { demoLabel.textContent = 'demo: spawning scout…'; demoSpawn(agents[0]); }], + [ 1600, function() { demoLabel.textContent = 'demo: spawning coder…'; demoSpawn(agents[2]); }], + [ 2600, function() { demoLabel.textContent = 'demo: toolcall (fast bob)'; setState(agents[0], 'toolcall'); setState(agents[2], 'toolcall'); }], + [ 4800, function() { demoLabel.textContent = 'demo: llm wait (slow bob)'; setState(agents[0], 'waiting'); setState(agents[2], 'waiting'); }], + [ 7200, function() { demoLabel.textContent = 'demo: conversation'; + demoConverse(conductor, agents[0], 'found anything?', 'yes — 3 hits'); }], + [11800, function() { demoLabel.textContent = 'demo: secretary plans with coder'; + demoConverse(secretary, agents[2], 'review plan?', 'looks good'); }], + [16200, function() { demoLabel.textContent = 'demo: agent exits (gc)'; demoGC(agents[0]); }], + [18400, function() { demoLabel.textContent = 'demo: agent exits (gc)'; demoGC(agents[2]); }], + [20000, function() { + // restart + demo.forEach(function(e) { e[2] = false; }); + demoStart = performance.now(); + demoLabel.textContent = 'demo: restarting…'; + }], +]; + +var demoStart = null; +function tickDemo(ts) { + if (demoStart === null) demoStart = ts; + var elapsed = ts - demoStart; + demo.forEach(function(e) { + if (!e[2] && elapsed >= e[0]) { e[2] = true; e[1](); } + }); + requestAnimationFrame(tickDemo); +} + +// ─── init ───────────────────────────────────────────────────────────────── +loadMapAsset(function() { + requestAnimationFrame(render); + requestAnimationFrame(tickDemo); +}); + From bb2715c4d3101443e8a5a55312fcebe773ac8625 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:48:10 +0900 Subject: [PATCH 06/11] feat: wire orchestration event broadcaster end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pkg/orch.Broadcaster (extracted to break tools↔miniapp import cycle): agent_spawn, agent_state, conversation, agent_gc events; live agent snapshot for WS initial-state delivery; non-blocking fan-out (drop on slow subscriber) - pkg/tools/toolloop.go: OnStateChange hook on ToolLoopConfig — emits ("waiting","") before LLM call and ("toolcall", name) per tool execution - pkg/tools/subagent.go: Spawn() emits agent_spawn; runTask() emits conversation (conductor→agent), wires OnStateChange into ToolLoopConfig, emits conversation (agent→conductor) + agent_gc on completion/failure/cancel - pkg/miniapp/miniapp.go: SetOrchBroadcaster(), wsOrchestration() handler at /miniapp/api/orchestration/ws — sends snapshot on connect, streams events with ping/pong keepalive; no polling Co-Authored-By: Claude Sonnet 4.6 --- pkg/miniapp/miniapp.go | 91 +++++++++++++++++++++++++++-- pkg/orch/broadcaster.go | 123 ++++++++++++++++++++++++++++++++++++++++ pkg/tools/subagent.go | 51 +++++++++++++++++ pkg/tools/toolloop.go | 15 ++++- 4 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 pkg/orch/broadcaster.go diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 6f1590d04..95e035bfd 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -25,6 +25,7 @@ import ( "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/stats" ) @@ -206,12 +207,13 @@ type DevTargetManager interface { // Handler serves the Mini App HTML and API endpoints. type Handler struct { - provider DataProvider - sender CommandSender - botToken string - notifier *StateNotifier - allowList []string - workspace string + provider DataProvider + sender CommandSender + botToken string + notifier *StateNotifier + allowList []string + workspace string + orchBroadcaster *orch.Broadcaster devMu sync.RWMutex devTarget *url.URL @@ -525,6 +527,12 @@ func escapeHTMLString(s string) string { return s } +// SetOrchBroadcaster wires the orchestration broadcaster so the Mini App can +// push live agent state to the canvas UI via WebSocket. +func (h *Handler) SetOrchBroadcaster(b *orch.Broadcaster) { + h.orchBroadcaster = b +} + // RegisterRoutes registers Mini App routes on the given mux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp", h.serveIndex) @@ -541,6 +549,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs)) mux.HandleFunc("/miniapp/api/logs/snapshot", h.requireAuth(h.apiLogsSnapshot)) mux.HandleFunc("/miniapp/api/logs/snapshot/", h.requireAuth(h.apiLogsSnapshotDownload)) + mux.HandleFunc("/miniapp/api/orchestration/ws", h.requireAuth(h.wsOrchestration)) mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole) mux.HandleFunc("/miniapp/dev/", h.serveDevProxy) } @@ -1028,6 +1037,76 @@ func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) { } } +// wsOrchestration streams live orchestration events (agent spawn/state/gc and +// conductor↔agent conversations) to the canvas UI. +// +// Protocol: +// +// {"type":"init","agents":[...OrchAgentInfo]} — sent once on connect +// {"type":"event","event":{...OrchEvent}} — pushed on each state change +func (h *Handler) wsOrchestration(w http.ResponseWriter, r *http.Request) { + if h.orchBroadcaster == nil { + http.Error(w, `{"error":"orchestration not enabled"}`, http.StatusServiceUnavailable) + return + } + + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Time{}) + _ = rc.SetReadDeadline(time.Time{}) + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + sub := h.orchBroadcaster.Subscribe() + defer h.orchBroadcaster.Unsubscribe(sub) + + // Send current agent snapshot so the canvas can populate immediately + snapshot := h.orchBroadcaster.Snapshot() + if err := conn.WriteJSON(map[string]any{"type": "init", "agents": snapshot}); err != nil { + return + } + + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + return nil + }) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + ticker := time.NewTicker(wsPingPeriod) + defer ticker.Stop() + + for { + select { + case ev, ok := <-sub.Ch: + if !ok { + return + } + if err := conn.WriteJSON(map[string]any{"type": "event", "event": ev}); err != nil { + return + } + case <-ticker.C: + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-done: + return + } + } +} + // apiLogsSnapshot creates a tar.gz snapshot of the current log buffer. func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/pkg/orch/broadcaster.go b/pkg/orch/broadcaster.go new file mode 100644 index 000000000..fe89509bb --- /dev/null +++ b/pkg/orch/broadcaster.go @@ -0,0 +1,123 @@ +// Package orch provides the orchestration event broadcaster used by the +// subagent system and the Mini App WebSocket UI. +package orch + +import ( + "sync" + "time" +) + +// Event is a single orchestration event pushed over WebSocket to the UI. +// type values: "agent_spawn" | "agent_state" | "conversation" | "agent_gc" +type Event struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Label string `json:"label,omitempty"` + Task string `json:"task,omitempty"` + State string `json:"state,omitempty"` // waiting | toolcall | idle + Tool string `json:"tool,omitempty"` // tool name during toolcall + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Text string `json:"text,omitempty"` + Reason string `json:"reason,omitempty"` // agent_gc: completed | failed | cancelled + Created int64 `json:"created,omitempty"` +} + +// AgentInfo is the live snapshot of one active agent. +// Kept inside Broadcaster so new WS connections can get current state. +type AgentInfo struct { + ID string `json:"id"` + Label string `json:"label"` + Task string `json:"task"` + State string `json:"state"` + Tool string `json:"tool,omitempty"` + Created int64 `json:"created"` +} + +// Subscriber is a single WebSocket client subscription. +type Subscriber struct { + Ch chan Event +} + +// Broadcaster distributes orchestration events to all connected WS clients. +// It also maintains a live agent snapshot for initial-state delivery on connect. +// +// Publish is non-blocking: events are dropped if a subscriber's buffer is full +// (same pattern as pkg/logger). +type Broadcaster struct { + mu sync.Mutex + subs map[*Subscriber]struct{} + agents map[string]*AgentInfo // live agents, keyed by task ID +} + +func NewBroadcaster() *Broadcaster { + return &Broadcaster{ + subs: make(map[*Subscriber]struct{}), + agents: make(map[string]*AgentInfo), + } +} + +func (b *Broadcaster) Subscribe() *Subscriber { + sub := &Subscriber{Ch: make(chan Event, 32)} + b.mu.Lock() + b.subs[sub] = struct{}{} + b.mu.Unlock() + return sub +} + +func (b *Broadcaster) Unsubscribe(sub *Subscriber) { + b.mu.Lock() + delete(b.subs, sub) + b.mu.Unlock() +} + +// Snapshot returns the current set of active agents. +// Called once on new WS connection to send initial state. +func (b *Broadcaster) Snapshot() []AgentInfo { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]AgentInfo, 0, len(b.agents)) + for _, a := range b.agents { + out = append(out, *a) + } + return out +} + +// Publish updates internal agent state and fans out to all subscribers. +func (b *Broadcaster) Publish(ev Event) { + if ev.Created == 0 { + ev.Created = time.Now().UnixMilli() + } + + b.mu.Lock() + switch ev.Type { + case "agent_spawn": + b.agents[ev.ID] = &AgentInfo{ + ID: ev.ID, + Label: ev.Label, + Task: ev.Task, + State: "idle", + Created: ev.Created, + } + case "agent_state": + if a, ok := b.agents[ev.ID]; ok { + a.State = ev.State + a.Tool = ev.Tool + } + case "agent_gc": + delete(b.agents, ev.ID) + } + // snapshot subs while holding lock, then release before sending + subs := make([]*Subscriber, 0, len(b.subs)) + for sub := range b.subs { + subs = append(subs, sub) + } + b.mu.Unlock() + + for _, sub := range subs { + select { + case sub.Ch <- ev: + default: // subscriber slow — drop (non-blocking) + } + } +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 91ebff636..634d087ed 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -36,6 +37,7 @@ type SubagentManager struct { hasMaxTokens bool hasTemperature bool nextID int + broadcaster *orch.Broadcaster } func NewSubagentManager( @@ -52,9 +54,16 @@ func NewSubagentManager( tools: NewToolRegistry(), maxIterations: 10, nextID: 1, + broadcaster: orch.NewBroadcaster(), } } +// GetBroadcaster returns the Broadcaster so the miniapp handler can +// subscribe to real-time orchestration events. +func (sm *SubagentManager) GetBroadcaster() *orch.Broadcaster { + return sm.broadcaster +} + // SetLLMOptions sets max tokens and temperature for subagent LLM calls. func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() @@ -103,6 +112,13 @@ func (sm *SubagentManager) Spawn( } sm.tasks[taskID] = subagentTask + sm.broadcaster.Publish(orch.Event{ + Type: "agent_spawn", + ID: taskID, + Label: label, + Task: task, + }) + // Start task in background with context cancellation support go sm.runTask(ctx, subagentTask, callback) @@ -164,12 +180,28 @@ After completing the task, provide a clear summary of what was done.` } } + // Notify conductor that the subagent is starting + sm.broadcaster.Publish(orch.Event{ + Type: "conversation", + From: "conductor", + To: task.ID, + Text: task.Task, + }) + loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ Provider: sm.provider, Model: sm.defaultModel, Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, + OnStateChange: func(state, tool string) { + sm.broadcaster.Publish(orch.Event{ + Type: "agent_state", + ID: task.ID, + State: state, + Tool: tool, + }) + }, }, messages, task.OriginChannel, task.OriginChatID) sm.mu.Lock() @@ -186,10 +218,17 @@ After completing the task, provide a clear summary of what was done.` task.Status = "failed" task.Result = fmt.Sprintf("Error: %v", err) // Check if it was cancelled + gcReason := "failed" if ctx.Err() != nil { task.Status = "cancelled" task.Result = "Task cancelled during execution" + gcReason = "cancelled" } + sm.broadcaster.Publish(orch.Event{ + Type: "agent_gc", + ID: task.ID, + Reason: gcReason, + }) result = &ToolResult{ ForLLM: task.Result, ForUser: "", @@ -201,6 +240,18 @@ After completing the task, provide a clear summary of what was done.` } else { task.Status = "completed" task.Result = loopResult.Content + // Notify conductor of the result + sm.broadcaster.Publish(orch.Event{ + Type: "conversation", + From: task.ID, + To: "conductor", + Text: loopResult.Content, + }) + sm.broadcaster.Publish(orch.Event{ + Type: "agent_gc", + ID: task.ID, + Reason: "completed", + }) result = &ToolResult{ ForLLM: fmt.Sprintf( "Subagent '%s' completed (iterations: %d): %s", diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index cdfe0d6ce..6eb62ef23 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -23,6 +23,11 @@ type ToolLoopConfig struct { Tools *ToolRegistry MaxIterations int LLMOptions map[string]any + // OnStateChange is an optional hook for UI feedback. + // Called with ("waiting","") before each LLM call and + // ("toolcall", toolName) when each tool starts executing. + // nil is safe to pass. + OnStateChange func(state, tool string) } // ToolLoopResult contains the result of running the tool loop. @@ -62,7 +67,10 @@ func RunToolLoop( if llmOpts == nil { llmOpts = map[string]any{} } - // 3. Call LLM + // 3. Call LLM (hook: waiting for response) + if config.OnStateChange != nil { + config.OnStateChange("waiting", "") + } response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", @@ -121,7 +129,7 @@ func RunToolLoop( } messages = append(messages, assistantMsg) - // 7. Execute tool calls + // 7. Execute tool calls (hook: toolcall per tool) for _, tc := range normalizedToolCalls { argsJSON, _ := json.Marshal(tc.Arguments) argsPreview := utils.Truncate(string(argsJSON), 200) @@ -130,6 +138,9 @@ func RunToolLoop( "tool": tc.Name, "iteration": iteration, }) + if config.OnStateChange != nil { + config.OnStateChange("toolcall", tc.Name) + } // Execute tool (no async callback for subagents - they run independently) var toolResult *ToolResult From f5afe13da032316d09988bcddef9f2bff30e845d Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:48:50 +0900 Subject: [PATCH 07/11] test: add pkg/orch broadcaster unit tests 6 cases covering spawn/gc lifecycle, agent_state snapshot update, non-blocking drop on slow subscriber, multi-subscriber fan-out, unsubscribe stops delivery, and auto-timestamp on zero Created. Co-Authored-By: Claude Sonnet 4.6 --- pkg/orch/broadcaster_test.go | 140 +++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 pkg/orch/broadcaster_test.go diff --git a/pkg/orch/broadcaster_test.go b/pkg/orch/broadcaster_test.go new file mode 100644 index 000000000..54ae8af02 --- /dev/null +++ b/pkg/orch/broadcaster_test.go @@ -0,0 +1,140 @@ +package orch + +import ( + "testing" + "time" +) + +func TestBroadcasterSpawnAndGC(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + b.Publish(Event{Type: "agent_spawn", ID: "t1", Label: "scout", Task: "do something"}) + + select { + case ev := <-sub.Ch: + if ev.Type != "agent_spawn" || ev.ID != "t1" { + t.Fatalf("expected agent_spawn for t1, got %+v", ev) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for agent_spawn event") + } + + snap := b.Snapshot() + if len(snap) != 1 || snap[0].ID != "t1" { + t.Fatalf("expected 1 agent in snapshot, got %v", snap) + } + + b.Publish(Event{Type: "agent_gc", ID: "t1", Reason: "completed"}) + + select { + case ev := <-sub.Ch: + if ev.Type != "agent_gc" || ev.Reason != "completed" { + t.Fatalf("expected agent_gc/completed, got %+v", ev) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for agent_gc event") + } + + if len(b.Snapshot()) != 0 { + t.Fatal("snapshot should be empty after agent_gc") + } +} + +func TestBroadcasterAgentState(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + b.Publish(Event{Type: "agent_spawn", ID: "t1"}) + <-sub.Ch // consume spawn + + b.Publish(Event{Type: "agent_state", ID: "t1", State: "toolcall", Tool: "bash"}) + + select { + case ev := <-sub.Ch: + if ev.State != "toolcall" || ev.Tool != "bash" { + t.Fatalf("unexpected state event: %+v", ev) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout waiting for agent_state event") + } + + snap := b.Snapshot() + if len(snap) == 0 || snap[0].State != "toolcall" || snap[0].Tool != "bash" { + t.Fatalf("snapshot state not updated: %v", snap) + } +} + +func TestBroadcasterNonBlocking(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() // do NOT read from sub.Ch + defer b.Unsubscribe(sub) + + // Fill buffer beyond capacity (cap=32) — must not block or deadlock + done := make(chan struct{}) + go func() { + for i := 0; i < 50; i++ { + b.Publish(Event{Type: "agent_state", ID: "t1", State: "waiting"}) + } + close(done) + }() + + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("Publish blocked on slow subscriber") + } +} + +func TestBroadcasterMultipleSubscribers(t *testing.T) { + b := NewBroadcaster() + s1 := b.Subscribe() + s2 := b.Subscribe() + defer b.Unsubscribe(s1) + defer b.Unsubscribe(s2) + + b.Publish(Event{Type: "agent_spawn", ID: "t1", Label: "worker"}) + + for _, sub := range []*Subscriber{s1, s2} { + select { + case ev := <-sub.Ch: + if ev.Type != "agent_spawn" { + t.Fatalf("expected agent_spawn, got %s", ev.Type) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timeout: not all subscribers received event") + } + } +} + +func TestBroadcasterUnsubscribe(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + b.Unsubscribe(sub) + + b.Publish(Event{Type: "agent_spawn", ID: "t1"}) + + select { + case ev := <-sub.Ch: + t.Fatalf("received event after unsubscribe: %+v", ev) + case <-time.After(50 * time.Millisecond): + // correct: nothing delivered after unsubscribe + } +} + +func TestBroadcasterTimestampAutoSet(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + before := time.Now().UnixMilli() + b.Publish(Event{Type: "agent_spawn", ID: "t1"}) // Created == 0 + after := time.Now().UnixMilli() + + ev := <-sub.Ch + if ev.Created < before || ev.Created > after { + t.Fatalf("Created timestamp %d not in [%d, %d]", ev.Created, before, after) + } +} From 698fe84673048235e5df20ebb654d7eaa71fb951 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:25:18 +0900 Subject: [PATCH 08/11] refactor: split pkg/miniapp/miniapp.go into focused files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1279-line monolith → 7 files by responsibility: miniapp.go ( 89L) — Handler struct, NewHandler, RegisterRoutes, serveIndex types.go (180L) — exported types, interfaces, StateNotifier auth.go (131L) — requireAuth, isAllowed, ValidateInitData dev.go (477L) — dev proxy management + apiDev/serveDevProxy/apiDevConsole ws.go (224L) — wsLogs, wsOrchestration, wsUpgrader/wsClient api.go (168L) — JSON API handlers, writeJSON, sendSSEIfChanged logs.go (143L) — apiLogsSnapshot, download, cleanOldSnapshots go build ./pkg/miniapp/... and go test ./pkg/miniapp/... pass unchanged. Co-Authored-By: Claude Sonnet 4.6 --- pkg/miniapp/api.go | 168 ++++++ pkg/miniapp/auth.go | 131 +++++ pkg/miniapp/dev.go | 477 ++++++++++++++++ pkg/miniapp/logs.go | 143 +++++ pkg/miniapp/miniapp.go | 1190 ---------------------------------------- pkg/miniapp/types.go | 180 ++++++ pkg/miniapp/ws.go | 224 ++++++++ 7 files changed, 1323 insertions(+), 1190 deletions(-) create mode 100644 pkg/miniapp/api.go create mode 100644 pkg/miniapp/auth.go create mode 100644 pkg/miniapp/dev.go create mode 100644 pkg/miniapp/logs.go create mode 100644 pkg/miniapp/types.go create mode 100644 pkg/miniapp/ws.go diff --git a/pkg/miniapp/api.go b/pkg/miniapp/api.go new file mode 100644 index 000000000..697dafa79 --- /dev/null +++ b/pkg/miniapp/api.go @@ -0,0 +1,168 @@ +package miniapp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + + +func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) { + skillsList := h.provider.ListSkills() + writeJSON(w, skillsList) +} + + +func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) { + info := h.provider.GetPlanInfo() + writeJSON(w, info) +} + + +func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) { + sessions := h.provider.GetActiveSessions() + if sessions == nil { + sessions = []SessionInfo{} + } + writeJSON(w, sessions) +} + + +func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { + s := h.provider.GetSessionStats() + if s == nil { + writeJSON(w, map[string]string{"status": "stats not enabled"}) + return + } + writeJSON(w, s) +} + + +func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) { + writeJSON(w, h.provider.GetContextInfo()) +} + + +func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()}) +} + + +func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + if repo == "" { + writeJSON(w, h.provider.GetGitRepos()) + } else { + writeJSON(w, h.provider.GetGitRepoDetail(repo)) + } +} + + +func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) + if err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + + var req struct { + Command string `json:"command"` + } + if err := json.Unmarshal(body, &req); err != nil || req.Command == "" { + http.Error(w, `{"error":"missing command"}`, http.StatusBadRequest) + return + } + + if !strings.HasPrefix(req.Command, "/") { + http.Error(w, `{"error":"command must start with /"}`, http.StatusBadRequest) + return + } + + // Extract user ID from initData to identify the sender + initData := r.URL.Query().Get("initData") + userID, chatID := extractUserFromInitData(initData) + if userID == "" { + http.Error(w, `{"error":"cannot identify user"}`, http.StatusBadRequest) + return + } + + h.sender.SendCommand(userID, chatID, req.Command) + writeJSON(w, map[string]string{"status": "ok"}) +} + + +func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, `{"error":"streaming not supported"}`, http.StatusInternalServerError) + return + } + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Time{}) + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ch := h.notifier.Subscribe() + defer h.notifier.Unsubscribe(ch) + + var lastPlan, lastSession, lastSkills, lastDev, lastContext, lastPrompt []byte + + // Send initial state immediately + sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) + sendSSEIfChanged(w, flusher, "session", + map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, + &lastSession) + sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) + sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) + sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) + sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt) + + for { + select { + case <-r.Context().Done(): + return + case <-h.notifier.Done(): + return + case <-ch: + sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) + sendSSEIfChanged(w, flusher, "session", + map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, + &lastSession) + sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) + sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) + sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) + sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt) + } + } +} + + +func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) { + data, _ := json.Marshal(v) + if !bytes.Equal(data, *last) { + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data) + f.Flush() + *last = data + } +} + + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// apiDevConsole receives console output from dev preview iframes. + diff --git a/pkg/miniapp/auth.go b/pkg/miniapp/auth.go new file mode 100644 index 000000000..cc2b3406f --- /dev/null +++ b/pkg/miniapp/auth.go @@ -0,0 +1,131 @@ +package miniapp + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// initDataMaxAge is the maximum age of initData before it is considered expired. +const initDataMaxAge = 24 * time.Hour + +func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + initData := r.URL.Query().Get("initData") + if initData == "" { + http.Error(w, `{"error":"missing initData"}`, http.StatusUnauthorized) + return + } + if !ValidateInitData(initData, h.botToken) { + http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized) + return + } + if len(h.allowList) > 0 { + userID, _ := extractUserFromInitData(initData) + if userID == "" || !isAllowed(userID, h.allowList) { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + } + next(w, r) + } +} + +// isAllowed checks whether userID matches any entry in the allow list. +// Logic mirrors BaseChannel.IsAllowed without importing channels package. +func isAllowed(userID string, allowList []string) bool { + if len(allowList) == 0 { + return true + } + for _, allowed := range allowList { + trimmed := strings.TrimPrefix(allowed, "@") + allowedID := trimmed + if idx := strings.Index(trimmed, "|"); idx > 0 { + allowedID = trimmed[:idx] + } + if userID == allowed || userID == trimmed || userID == allowedID { + return true + } + } + return false +} + +// extractUserFromInitData parses user.id from the initData query string. +// initData contains a "user" param with JSON like {"id":123456,...}. +func extractUserFromInitData(initData string) (userID, chatID string) { + values, err := url.ParseQuery(initData) + if err != nil { + return "", "" + } + userJSON := values.Get("user") + if userJSON == "" { + return "", "" + } + var user struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal([]byte(userJSON), &user); err != nil || user.ID == 0 { + return "", "" + } + id := fmt.Sprintf("%d", user.ID) + // For Mini App commands, chatID = userID (private chat) + return id, id +} + +// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature +// and checks that auth_date is not older than initDataMaxAge. +// See https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app +func ValidateInitData(initData, botToken string) bool { + values, err := url.ParseQuery(initData) + if err != nil { + return false + } + + receivedHash := values.Get("hash") + if receivedHash == "" { + return false + } + + // Check auth_date freshness + if authDateStr := values.Get("auth_date"); authDateStr != "" { + authDate, err := strconv.ParseInt(authDateStr, 10, 64) + if err != nil { + return false + } + if time.Since(time.Unix(authDate, 0)) > initDataMaxAge { + return false + } + } + + // Build the data-check-string: sort all key=value pairs except "hash", + // join with newlines. + var pairs []string + for key := range values { + if key == "hash" { + continue + } + pairs = append(pairs, fmt.Sprintf("%s=%s", key, values.Get(key))) + } + sort.Strings(pairs) + dataCheckString := strings.Join(pairs, "\n") + + // secret_key = HMAC-SHA256("WebAppData", bot_token) + secretKeyMac := hmac.New(sha256.New, []byte("WebAppData")) + secretKeyMac.Write([]byte(botToken)) + secretKey := secretKeyMac.Sum(nil) + + // hash = HMAC-SHA256(secret_key, data_check_string) + hashMac := hmac.New(sha256.New, secretKey) + hashMac.Write([]byte(dataCheckString)) + computedHash := hex.EncodeToString(hashMac.Sum(nil)) + + return hmac.Equal([]byte(computedHash), []byte(receivedHash)) +} diff --git a/pkg/miniapp/dev.go b/pkg/miniapp/dev.go new file mode 100644 index 000000000..1dfbaa7fa --- /dev/null +++ b/pkg/miniapp/dev.go @@ -0,0 +1,477 @@ +package miniapp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + + +// validateLocalhostURL parses and validates that a URL targets localhost. +func validateLocalhostURL(target string) (*url.URL, error) { + u, err := url.Parse(target) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + host := u.Hostname() + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + return nil, fmt.Errorf("only localhost targets are allowed, got %q", host) + } + return u, nil +} + +// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed. + + +// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed. +func (h *Handler) RegisterDevTarget(name, target string) (string, error) { + if _, err := validateLocalhostURL(target); err != nil { + return "", err + } + + h.devMu.Lock() + defer h.devMu.Unlock() + + h.devNextID++ + id := strconv.Itoa(h.devNextID) + + h.devTargets[id] = &DevTarget{ID: id, Name: name, Target: target} + if h.notifier != nil { + h.notifier.Notify() + } + return id, nil +} + +// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled. + + +// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled. +func (h *Handler) UnregisterDevTarget(id string) error { + h.devMu.Lock() + defer h.devMu.Unlock() + + if _, ok := h.devTargets[id]; !ok { + return fmt.Errorf("target %q not found", id) + } + delete(h.devTargets, id) + + if h.devActiveID == id { + h.devActiveID = "" + h.devTarget = nil + h.devProxy = nil + } + if h.notifier != nil { + h.notifier.Notify() + } + return nil +} + +// ActivateDevTarget sets the reverse proxy to the registered target with the given ID. + + +// ActivateDevTarget sets the reverse proxy to the registered target with the given ID. +func (h *Handler) ActivateDevTarget(id string) error { + h.devMu.Lock() + defer h.devMu.Unlock() + + dt, ok := h.devTargets[id] + if !ok { + return fmt.Errorf("target %q not found", id) + } + + u, err := url.Parse(dt.Target) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + // Fix IPv6: resolve "localhost" to 127.0.0.1 to avoid connection refused on systems + // where localhost resolves to [::1] but the dev server only listens on IPv4. + if u.Hostname() == "localhost" { + u.Host = net.JoinHostPort("127.0.0.1", u.Port()) + } + + proxy := httputil.NewSingleHostReverseProxy(u) + proxy.ModifyResponse = func(resp *http.Response) error { + // Prevent browser/WebView from caching dev proxy responses (CSS, JS, etc.) + resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") + resp.Header.Del("ETag") + resp.Header.Del("Last-Modified") + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/html") { + return nil + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + resp.Body.Close() + modified := injectDevProxyScript(body) + resp.Body = io.NopCloser(bytes.NewReader(modified)) + resp.ContentLength = int64(len(modified)) + resp.Header.Set("Content-Length", strconv.Itoa(len(modified))) + resp.Header.Del("Content-Encoding") + return nil + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, ` +

Cannot connect

%s

Target: %s

`, + escapeHTMLString(err.Error()), escapeHTMLString(dt.Target)) + } + + h.devTarget = u + h.devProxy = proxy + h.devActiveID = id + if h.notifier != nil { + h.notifier.Notify() + } + return nil +} + +// DeactivateDevTarget disables the reverse proxy without removing registrations. + + +// DeactivateDevTarget disables the reverse proxy without removing registrations. +func (h *Handler) DeactivateDevTarget() error { + h.devMu.Lock() + defer h.devMu.Unlock() + + h.devActiveID = "" + h.devTarget = nil + h.devProxy = nil + if h.notifier != nil { + h.notifier.Notify() + } + return nil +} + +// GetDevTarget returns the current dev proxy target URL, or empty string if disabled. + + +// GetDevTarget returns the current dev proxy target URL, or empty string if disabled. +func (h *Handler) GetDevTarget() string { + h.devMu.RLock() + defer h.devMu.RUnlock() + if h.devTarget == nil { + return "" + } + return h.devTarget.String() +} + +// ListDevTargets returns all registered dev targets. + + +// ListDevTargets returns all registered dev targets. +func (h *Handler) ListDevTargets() []DevTarget { + h.devMu.RLock() + defer h.devMu.RUnlock() + + targets := make([]DevTarget, 0, len(h.devTargets)) + for _, dt := range h.devTargets { + targets = append(targets, *dt) + } + // Sort by ID for stable order + sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID }) + return targets +} + +// devProxyScript is the JavaScript injected into HTML responses from the dev proxy. +// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like +// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount. +// It also captures console.log/warn/error/info and forwards them to the server. + + +// devProxyScript is the JavaScript injected into HTML responses from the dev proxy. +// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like +// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount. +// It also captures console.log/warn/error/info and forwards them to the server. +const devProxyScript = `` + +// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document. +// Insertion priority: before , after , or prepend to document. + + +// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document. +// Insertion priority: before , after , or prepend to document. +func injectDevProxyScript(html []byte) []byte { + script := []byte(devProxyScript) + + // Priority 1: before + if idx := bytes.Index(bytes.ToLower(html), []byte("")); idx >= 0 { + out := make([]byte, 0, len(html)+len(script)) + out = append(out, html[:idx]...) + out = append(out, script...) + out = append(out, html[idx:]...) + return out + } + + // Priority 2: after + lower := bytes.ToLower(html) + if idx := bytes.Index(lower, []byte("= 0 { + // Find the closing '>' of the tag + closeIdx := bytes.IndexByte(lower[idx:], '>') + if closeIdx >= 0 { + insertAt := idx + closeIdx + 1 + out := make([]byte, 0, len(html)+len(script)) + out = append(out, html[:insertAt]...) + out = append(out, script...) + out = append(out, html[insertAt:]...) + return out + } + } + + // Priority 3: prepend + out := make([]byte, 0, len(html)+len(script)) + out = append(out, script...) + out = append(out, html...) + return out +} + +// escapeHTMLString escapes HTML special characters in a string. + + +// escapeHTMLString escapes HTML special characters in a string. +func escapeHTMLString(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + return s +} + +// RegisterRoutes registers Mini App routes on the given mux. + + +func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, h.devStatus()) + case http.MethodPost: + body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) + if err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + var req struct { + Action string `json:"action"` + ID string `json:"id"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) + return + } + switch req.Action { + case "activate": + if req.ID == "" { + writeJSON(w, map[string]any{"error": "id is required"}) + return + } + if err := h.ActivateDevTarget(req.ID); err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + case "deactivate": + if err := h.DeactivateDevTarget(); err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + case "unregister": + if req.ID == "" { + writeJSON(w, map[string]any{"error": "id is required"}) + return + } + if err := h.UnregisterDevTarget(req.ID); err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + default: + writeJSON(w, map[string]any{"error": "unknown action"}) + return + } + writeJSON(w, h.devStatus()) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + + +func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) { + h.devMu.RLock() + proxy := h.devProxy + h.devMu.RUnlock() + + if proxy == nil { + http.Error(w, "dev proxy not configured", http.StatusServiceUnavailable) + return + } + + // Strip /miniapp/dev prefix so /miniapp/dev/foo → /foo + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/miniapp/dev") + if r.URL.Path == "" { + r.URL.Path = "/" + } + proxy.ServeHTTP(w, r) +} + +// extractUserFromInitData parses user.id from the initData query string. +// initData contains a "user" param with JSON like {"id":123456,...}. + + +func (h *Handler) devStatus() map[string]any { + h.devMu.RLock() + defer h.devMu.RUnlock() + + active := h.devTarget != nil + target := "" + if h.devTarget != nil { + target = h.devTargets[h.devActiveID].Target // original URL before IPv6 rewrite + } + + targets := make([]DevTarget, 0, len(h.devTargets)) + for _, dt := range h.devTargets { + targets = append(targets, *dt) + } + sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID }) + + return map[string]any{ + "active": active, + "active_id": h.devActiveID, + "target": target, + "targets": targets, + } +} + + +// apiDevConsole receives console output from dev preview iframes. +func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + // Only accept console posts when dev proxy is active + if h.GetDevTarget() == "" { + http.Error(w, `{"error":"not available"}`, http.StatusNotFound) + return + } + + // Simple rate limit: max 10 requests per second + now := time.Now().Unix() + h.consoleMu.Lock() + if h.consoleReqSec != now { + h.consoleReqSec = now + h.consoleReqCount = 0 + } + h.consoleReqCount++ + over := h.consoleReqCount > 10 + h.consoleMu.Unlock() + if over { + http.Error(w, `{"error":"rate limit"}`, http.StatusTooManyRequests) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024)) + if err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + + var entries []struct { + Level string `json:"level"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &entries); err != nil { + http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) + return + } + + // Cap at 20 entries per batch + if len(entries) > 20 { + entries = entries[:20] + } + + for _, e := range entries { + msg := e.Message + if len(msg) > 1024 { + msg = msg[:1024] + } + switch e.Level { + case "warn": + logger.WarnC("dev-console", msg) + case "error": + logger.ErrorC("dev-console", msg) + default: + logger.InfoC("dev-console", msg) + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// wsLogs serves a WebSocket endpoint that streams log entries in real time. + diff --git a/pkg/miniapp/logs.go b/pkg/miniapp/logs.go new file mode 100644 index 000000000..f0b6af368 --- /dev/null +++ b/pkg/miniapp/logs.go @@ -0,0 +1,143 @@ +package miniapp + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + + +// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer. +func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + entries := logger.RecentLogs(logger.DEBUG, "", 300) + + snapshotDir := filepath.Join(h.workspace, "logs", "snapshots") + if err := os.MkdirAll(snapshotDir, 0o755); err != nil { + http.Error(w, `{"error":"cannot create snapshot dir"}`, http.StatusInternalServerError) + return + } + + id := time.Now().UTC().Format("20060102-150405") + filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id) + snapshotPath := filepath.Join(snapshotDir, filename) + + // Create tar.gz + f, err := os.Create(snapshotPath) + if err != nil { + http.Error(w, `{"error":"cannot create snapshot file"}`, http.StatusInternalServerError) + return + } + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + prefix := fmt.Sprintf("picoclaw-logs-%s/", id) + + // logs.json + logsJSON, _ := json.MarshalIndent(entries, "", " ") + _ = tw.WriteHeader(&tar.Header{ + Name: prefix + "logs.json", + Size: int64(len(logsJSON)), + Mode: 0o644, + ModTime: time.Now(), + }) + _, _ = tw.Write(logsJSON) + + // metadata.json + hostname, _ := os.Hostname() + meta := map[string]any{ + "version": "1", + "hostname": hostname, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "entry_count": len(entries), + } + metaJSON, _ := json.MarshalIndent(meta, "", " ") + _ = tw.WriteHeader(&tar.Header{ + Name: prefix + "metadata.json", + Size: int64(len(metaJSON)), + Mode: 0o644, + ModTime: time.Now(), + }) + _, _ = tw.Write(metaJSON) + + tw.Close() + gw.Close() + f.Close() + + // Cleanup old snapshots (>14 days) + go cleanOldSnapshots(snapshotDir, 14*24*time.Hour) + + downloadURL := fmt.Sprintf("/miniapp/api/logs/snapshot/%s", id) + writeJSON(w, map[string]string{"id": id, "download_url": downloadURL}) +} + +// apiLogsSnapshotDownload serves a snapshot tar.gz file. + + +// apiLogsSnapshotDownload serves a snapshot tar.gz file. +func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/miniapp/api/logs/snapshot/") + id = filepath.Base(id) // path traversal prevention + + if id == "" || id == "." || id == ".." { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } + + filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id) + snapshotPath := filepath.Join(h.workspace, "logs", "snapshots", filename) + + if _, err := os.Stat(snapshotPath); os.IsNotExist(err) { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + http.ServeFile(w, r, snapshotPath) +} + +// cleanOldSnapshots removes snapshot files older than maxAge. + + +// cleanOldSnapshots removes snapshot files older than maxAge. +func cleanOldSnapshots(dir string, maxAge time.Duration) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-maxAge) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + os.Remove(filepath.Join(dir, e.Name())) + } + } +} + +// initDataMaxAge is the maximum age of initData before it is considered expired. + diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 95e035bfd..c2887d918 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -1,210 +1,18 @@ package miniapp import ( - "archive/tar" - "bytes" - "compress/gzip" - "crypto/hmac" - "crypto/sha256" "embed" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net" "net/http" "net/http/httputil" "net/url" - "os" - "path/filepath" - "sort" - "strconv" - "strings" "sync" - "time" - "github.com/gorilla/websocket" - "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/orch" - "github.com/sipeed/picoclaw/pkg/skills" - "github.com/sipeed/picoclaw/pkg/stats" ) //go:embed static/index.html var staticFS embed.FS -// PlanPhase mirrors agent.PlanPhase for JSON serialization. -type PlanPhase struct { - Number int `json:"number"` - Title string `json:"title"` - Steps []PlanStep `json:"steps"` -} - -// PlanStep mirrors agent.PlanStep for JSON serialization. -type PlanStep struct { - Index int `json:"index"` - Description string `json:"description"` - Done bool `json:"done"` -} - -// PlanInfo represents the plan state exposed via the API. -type PlanInfo struct { - HasPlan bool `json:"has_plan"` - Status string `json:"status"` - CurrentPhase int `json:"current_phase"` - TotalPhases int `json:"total_phases"` - Display string `json:"display"` - Phases []PlanPhase `json:"phases"` - Memory string `json:"memory"` -} - -// SessionInfo represents an active session entry for the API response. -type SessionInfo struct { - SessionKey string `json:"session_key"` - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - TouchDir string `json:"touch_dir"` - ProjectPath string `json:"project_path,omitempty"` - Purpose string `json:"purpose,omitempty"` - Branch string `json:"branch,omitempty"` - LastSeenAt string `json:"last_seen_at"` - AgeSec int `json:"age_sec"` -} - -// GitRepoSummary represents a lightweight repo entry for the list view. -type GitRepoSummary struct { - Name string `json:"name"` - Branch string `json:"branch"` -} - -// GitInfo represents the git repository state exposed via the API. -type GitInfo struct { - Name string `json:"name"` - Branch string `json:"branch"` - Commits []GitCommit `json:"commits"` - Modified []GitChange `json:"modified"` -} - -// GitCommit represents a single commit entry. -type GitCommit struct { - Hash string `json:"hash"` - Subject string `json:"subject"` - Author string `json:"author"` - Date string `json:"date"` -} - -// GitChange represents a modified/untracked file entry. -type GitChange struct { - Status string `json:"status"` - Path string `json:"path"` -} - -// BootstrapFileInfo describes a resolved bootstrap file for the context API. -type BootstrapFileInfo struct { - Name string `json:"name"` - Path string `json:"path"` - Scope string `json:"scope"` -} - -// ContextInfo describes the agent's directory context and bootstrap file resolution. -type ContextInfo struct { - WorkDir string `json:"work_dir"` - PlanWorkDir string `json:"plan_work_dir"` - Workspace string `json:"workspace"` - Bootstrap []BootstrapFileInfo `json:"bootstrap"` -} - -// DataProvider is the read-only interface to agent state for the Mini App API. -type DataProvider interface { - ListSkills() []skills.SkillInfo - GetPlanInfo() PlanInfo - GetSessionStats() *stats.Stats - GetActiveSessions() []SessionInfo - GetGitRepos() []GitRepoSummary - GetGitRepoDetail(name string) GitInfo - GetContextInfo() ContextInfo - GetSystemPrompt() string -} - -// CommandSender injects a command into the message bus on behalf of a user. -type CommandSender interface { - SendCommand(senderID, chatID, command string) -} - -// StateNotifier broadcasts state-change signals to SSE subscribers. -type StateNotifier struct { - mu sync.Mutex - subs map[chan struct{}]struct{} - done chan struct{} -} - -// NewStateNotifier creates a new StateNotifier. -func NewStateNotifier() *StateNotifier { - return &StateNotifier{ - subs: make(map[chan struct{}]struct{}), - done: make(chan struct{}), - } -} - -// Subscribe returns a channel that receives a signal on each state change. -func (n *StateNotifier) Subscribe() chan struct{} { - ch := make(chan struct{}, 1) - n.mu.Lock() - n.subs[ch] = struct{}{} - n.mu.Unlock() - return ch -} - -// Unsubscribe removes a subscriber channel. -func (n *StateNotifier) Unsubscribe(ch chan struct{}) { - n.mu.Lock() - delete(n.subs, ch) - n.mu.Unlock() -} - -// Close signals all SSE handlers to exit. -func (n *StateNotifier) Close() { - select { - case <-n.done: - default: - close(n.done) - } -} - -// Done returns a channel that is closed when the notifier is shut down. -func (n *StateNotifier) Done() <-chan struct{} { - return n.done -} - -// Notify sends a signal to all subscribers, coalescing rapid notifications. -func (n *StateNotifier) Notify() { - n.mu.Lock() - defer n.mu.Unlock() - for ch := range n.subs { - select { - case ch <- struct{}{}: - default: - } - } -} - -// DevTarget represents a registered dev server target. -type DevTarget struct { - ID string `json:"id"` - Name string `json:"name"` // display name (e.g. "frontend") - Target string `json:"target"` // URL (e.g. "http://localhost:3000") -} - -// DevTargetManager allows tools to register, activate, and deactivate dev proxy targets. -type DevTargetManager interface { - RegisterDevTarget(name, target string) (id string, err error) - UnregisterDevTarget(id string) error - ActivateDevTarget(id string) error - DeactivateDevTarget() error - GetDevTarget() string - ListDevTargets() []DevTarget -} - // Handler serves the Mini App HTML and API endpoints. type Handler struct { provider DataProvider @@ -230,35 +38,6 @@ type Handler struct { consoleReqSec int64 } -const maxWSClients = 4 - -const ( - wsPongWait = 60 * time.Second - wsPingPeriod = 54 * time.Second // must be less than wsPongWait -) - -type wsClient struct { - conn *websocket.Conn -} - -var wsUpgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - origin := r.Header.Get("Origin") - if origin == "" { - return true // non-browser clients (e.g. curl) - } - // Allow same-origin requests (e.g. Tailscale direct access) - if u, err := url.Parse(origin); err == nil && u.Host == r.Host { - return true - } - // Allow Telegram WebApp origins and localhost for dev - return strings.HasSuffix(origin, ".telegram.org") || - strings.HasSuffix(origin, ".t.me") || - strings.HasPrefix(origin, "http://localhost") || - strings.HasPrefix(origin, "http://127.0.0.1") - }, -} - // NewHandler creates a new Mini App handler. func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier, allowList []string, workspace string) *Handler { return &Handler{ @@ -272,261 +51,6 @@ func NewHandler(provider DataProvider, sender CommandSender, botToken string, no } } -// validateLocalhostURL parses and validates that a URL targets localhost. -func validateLocalhostURL(target string) (*url.URL, error) { - u, err := url.Parse(target) - if err != nil { - return nil, fmt.Errorf("invalid URL: %w", err) - } - host := u.Hostname() - if host != "localhost" && host != "127.0.0.1" && host != "::1" { - return nil, fmt.Errorf("only localhost targets are allowed, got %q", host) - } - return u, nil -} - -// RegisterDevTarget registers a new dev server target. Only localhost targets are allowed. -func (h *Handler) RegisterDevTarget(name, target string) (string, error) { - if _, err := validateLocalhostURL(target); err != nil { - return "", err - } - - h.devMu.Lock() - defer h.devMu.Unlock() - - h.devNextID++ - id := strconv.Itoa(h.devNextID) - - h.devTargets[id] = &DevTarget{ID: id, Name: name, Target: target} - if h.notifier != nil { - h.notifier.Notify() - } - return id, nil -} - -// UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled. -func (h *Handler) UnregisterDevTarget(id string) error { - h.devMu.Lock() - defer h.devMu.Unlock() - - if _, ok := h.devTargets[id]; !ok { - return fmt.Errorf("target %q not found", id) - } - delete(h.devTargets, id) - - if h.devActiveID == id { - h.devActiveID = "" - h.devTarget = nil - h.devProxy = nil - } - if h.notifier != nil { - h.notifier.Notify() - } - return nil -} - -// ActivateDevTarget sets the reverse proxy to the registered target with the given ID. -func (h *Handler) ActivateDevTarget(id string) error { - h.devMu.Lock() - defer h.devMu.Unlock() - - dt, ok := h.devTargets[id] - if !ok { - return fmt.Errorf("target %q not found", id) - } - - u, err := url.Parse(dt.Target) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - - // Fix IPv6: resolve "localhost" to 127.0.0.1 to avoid connection refused on systems - // where localhost resolves to [::1] but the dev server only listens on IPv4. - if u.Hostname() == "localhost" { - u.Host = net.JoinHostPort("127.0.0.1", u.Port()) - } - - proxy := httputil.NewSingleHostReverseProxy(u) - proxy.ModifyResponse = func(resp *http.Response) error { - // Prevent browser/WebView from caching dev proxy responses (CSS, JS, etc.) - resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") - resp.Header.Del("ETag") - resp.Header.Del("Last-Modified") - - ct := resp.Header.Get("Content-Type") - if !strings.Contains(ct, "text/html") { - return nil - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - resp.Body.Close() - modified := injectDevProxyScript(body) - resp.Body = io.NopCloser(bytes.NewReader(modified)) - resp.ContentLength = int64(len(modified)) - resp.Header.Set("Content-Length", strconv.Itoa(len(modified))) - resp.Header.Del("Content-Encoding") - return nil - } - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusBadGateway) - fmt.Fprintf(w, ` -

Cannot connect

%s

Target: %s

`, - escapeHTMLString(err.Error()), escapeHTMLString(dt.Target)) - } - - h.devTarget = u - h.devProxy = proxy - h.devActiveID = id - if h.notifier != nil { - h.notifier.Notify() - } - return nil -} - -// DeactivateDevTarget disables the reverse proxy without removing registrations. -func (h *Handler) DeactivateDevTarget() error { - h.devMu.Lock() - defer h.devMu.Unlock() - - h.devActiveID = "" - h.devTarget = nil - h.devProxy = nil - if h.notifier != nil { - h.notifier.Notify() - } - return nil -} - -// GetDevTarget returns the current dev proxy target URL, or empty string if disabled. -func (h *Handler) GetDevTarget() string { - h.devMu.RLock() - defer h.devMu.RUnlock() - if h.devTarget == nil { - return "" - } - return h.devTarget.String() -} - -// ListDevTargets returns all registered dev targets. -func (h *Handler) ListDevTargets() []DevTarget { - h.devMu.RLock() - defer h.devMu.RUnlock() - - targets := make([]DevTarget, 0, len(h.devTargets)) - for _, dt := range h.devTargets { - targets = append(targets, *dt) - } - // Sort by ID for stable order - sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID }) - return targets -} - -// devProxyScript is the JavaScript injected into HTML responses from the dev proxy. -// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like -// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount. -// It also captures console.log/warn/error/info and forwards them to the server. -const devProxyScript = `` - -// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document. -// Insertion priority: before , after , or prepend to document. -func injectDevProxyScript(html []byte) []byte { - script := []byte(devProxyScript) - - // Priority 1: before - if idx := bytes.Index(bytes.ToLower(html), []byte("")); idx >= 0 { - out := make([]byte, 0, len(html)+len(script)) - out = append(out, html[:idx]...) - out = append(out, script...) - out = append(out, html[idx:]...) - return out - } - - // Priority 2: after - lower := bytes.ToLower(html) - if idx := bytes.Index(lower, []byte("= 0 { - // Find the closing '>' of the tag - closeIdx := bytes.IndexByte(lower[idx:], '>') - if closeIdx >= 0 { - insertAt := idx + closeIdx + 1 - out := make([]byte, 0, len(html)+len(script)) - out = append(out, html[:insertAt]...) - out = append(out, script...) - out = append(out, html[insertAt:]...) - return out - } - } - - // Priority 3: prepend - out := make([]byte, 0, len(html)+len(script)) - out = append(out, script...) - out = append(out, html...) - return out -} - -// escapeHTMLString escapes HTML special characters in a string. -func escapeHTMLString(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - s = strings.ReplaceAll(s, "\"", """) - return s -} - // SetOrchBroadcaster wires the orchestration broadcaster so the Mini App can // push live agent state to the canvas UI via WebSocket. func (h *Handler) SetOrchBroadcaster(b *orch.Broadcaster) { @@ -563,717 +87,3 @@ func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(data) } - -func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - initData := r.URL.Query().Get("initData") - if initData == "" { - http.Error(w, `{"error":"missing initData"}`, http.StatusUnauthorized) - return - } - if !ValidateInitData(initData, h.botToken) { - http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized) - return - } - if len(h.allowList) > 0 { - userID, _ := extractUserFromInitData(initData) - if userID == "" || !isAllowed(userID, h.allowList) { - http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) - return - } - } - next(w, r) - } -} - -// isAllowed checks whether userID matches any entry in the allow list. -// Logic mirrors BaseChannel.IsAllowed without importing channels package. -func isAllowed(userID string, allowList []string) bool { - if len(allowList) == 0 { - return true - } - for _, allowed := range allowList { - trimmed := strings.TrimPrefix(allowed, "@") - allowedID := trimmed - if idx := strings.Index(trimmed, "|"); idx > 0 { - allowedID = trimmed[:idx] - } - if userID == allowed || userID == trimmed || userID == allowedID { - return true - } - } - return false -} - -func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) { - skillsList := h.provider.ListSkills() - writeJSON(w, skillsList) -} - -func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) { - info := h.provider.GetPlanInfo() - writeJSON(w, info) -} - -func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) { - sessions := h.provider.GetActiveSessions() - if sessions == nil { - sessions = []SessionInfo{} - } - writeJSON(w, sessions) -} - -func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { - s := h.provider.GetSessionStats() - if s == nil { - writeJSON(w, map[string]string{"status": "stats not enabled"}) - return - } - writeJSON(w, s) -} - -func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) { - writeJSON(w, h.provider.GetContextInfo()) -} - -func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()}) -} - -func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) { - repo := r.URL.Query().Get("repo") - if repo == "" { - writeJSON(w, h.provider.GetGitRepos()) - } else { - writeJSON(w, h.provider.GetGitRepoDetail(repo)) - } -} - -func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) - if err != nil { - http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) - return - } - - var req struct { - Command string `json:"command"` - } - if err := json.Unmarshal(body, &req); err != nil || req.Command == "" { - http.Error(w, `{"error":"missing command"}`, http.StatusBadRequest) - return - } - - if !strings.HasPrefix(req.Command, "/") { - http.Error(w, `{"error":"command must start with /"}`, http.StatusBadRequest) - return - } - - // Extract user ID from initData to identify the sender - initData := r.URL.Query().Get("initData") - userID, chatID := extractUserFromInitData(initData) - if userID == "" { - http.Error(w, `{"error":"cannot identify user"}`, http.StatusBadRequest) - return - } - - h.sender.SendCommand(userID, chatID, req.Command) - writeJSON(w, map[string]string{"status": "ok"}) -} - -func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - writeJSON(w, h.devStatus()) - case http.MethodPost: - body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) - if err != nil { - http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) - return - } - var req struct { - Action string `json:"action"` - ID string `json:"id"` - } - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) - return - } - switch req.Action { - case "activate": - if req.ID == "" { - writeJSON(w, map[string]any{"error": "id is required"}) - return - } - if err := h.ActivateDevTarget(req.ID); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) - return - } - case "deactivate": - if err := h.DeactivateDevTarget(); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) - return - } - case "unregister": - if req.ID == "" { - writeJSON(w, map[string]any{"error": "id is required"}) - return - } - if err := h.UnregisterDevTarget(req.ID); err != nil { - writeJSON(w, map[string]any{"error": err.Error()}) - return - } - default: - writeJSON(w, map[string]any{"error": "unknown action"}) - return - } - writeJSON(w, h.devStatus()) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) { - h.devMu.RLock() - proxy := h.devProxy - h.devMu.RUnlock() - - if proxy == nil { - http.Error(w, "dev proxy not configured", http.StatusServiceUnavailable) - return - } - - // Strip /miniapp/dev prefix so /miniapp/dev/foo → /foo - r.URL.Path = strings.TrimPrefix(r.URL.Path, "/miniapp/dev") - if r.URL.Path == "" { - r.URL.Path = "/" - } - proxy.ServeHTTP(w, r) -} - -// extractUserFromInitData parses user.id from the initData query string. -// initData contains a "user" param with JSON like {"id":123456,...}. -func extractUserFromInitData(initData string) (userID, chatID string) { - values, err := url.ParseQuery(initData) - if err != nil { - return "", "" - } - userJSON := values.Get("user") - if userJSON == "" { - return "", "" - } - var user struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal([]byte(userJSON), &user); err != nil || user.ID == 0 { - return "", "" - } - id := fmt.Sprintf("%d", user.ID) - // For Mini App commands, chatID = userID (private chat) - return id, id -} - -func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, `{"error":"streaming not supported"}`, http.StatusInternalServerError) - return - } - rc := http.NewResponseController(w) - _ = rc.SetWriteDeadline(time.Time{}) - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - - ch := h.notifier.Subscribe() - defer h.notifier.Unsubscribe(ch) - - var lastPlan, lastSession, lastSkills, lastDev, lastContext, lastPrompt []byte - - // Send initial state immediately - sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) - sendSSEIfChanged(w, flusher, "session", - map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, - &lastSession) - sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) - sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) - sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) - sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt) - - for { - select { - case <-r.Context().Done(): - return - case <-h.notifier.Done(): - return - case <-ch: - sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) - sendSSEIfChanged(w, flusher, "session", - map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, - &lastSession) - sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) - sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) - sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) - sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt) - } - } -} - -func (h *Handler) devStatus() map[string]any { - h.devMu.RLock() - defer h.devMu.RUnlock() - - active := h.devTarget != nil - target := "" - if h.devTarget != nil { - target = h.devTargets[h.devActiveID].Target // original URL before IPv6 rewrite - } - - targets := make([]DevTarget, 0, len(h.devTargets)) - for _, dt := range h.devTargets { - targets = append(targets, *dt) - } - sort.Slice(targets, func(i, j int) bool { return targets[i].ID < targets[j].ID }) - - return map[string]any{ - "active": active, - "active_id": h.devActiveID, - "target": target, - "targets": targets, - } -} - -func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) { - data, _ := json.Marshal(v) - if !bytes.Equal(data, *last) { - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data) - f.Flush() - *last = data - } -} - -func writeJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// apiDevConsole receives console output from dev preview iframes. -func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - return - } - - // Only accept console posts when dev proxy is active - if h.GetDevTarget() == "" { - http.Error(w, `{"error":"not available"}`, http.StatusNotFound) - return - } - - // Simple rate limit: max 10 requests per second - now := time.Now().Unix() - h.consoleMu.Lock() - if h.consoleReqSec != now { - h.consoleReqSec = now - h.consoleReqCount = 0 - } - h.consoleReqCount++ - over := h.consoleReqCount > 10 - h.consoleMu.Unlock() - if over { - http.Error(w, `{"error":"rate limit"}`, http.StatusTooManyRequests) - return - } - - body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024)) - if err != nil { - http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) - return - } - - var entries []struct { - Level string `json:"level"` - Message string `json:"message"` - } - if err := json.Unmarshal(body, &entries); err != nil { - http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) - return - } - - // Cap at 20 entries per batch - if len(entries) > 20 { - entries = entries[:20] - } - - for _, e := range entries { - msg := e.Message - if len(msg) > 1024 { - msg = msg[:1024] - } - switch e.Level { - case "warn": - logger.WarnC("dev-console", msg) - case "error": - logger.ErrorC("dev-console", msg) - default: - logger.InfoC("dev-console", msg) - } - } - - w.WriteHeader(http.StatusNoContent) -} - -// wsLogs serves a WebSocket endpoint that streams log entries in real time. -func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) { - // Parse filter params - component := r.URL.Query().Get("component") - levelStr := r.URL.Query().Get("level") - minLevel := logger.INFO - if levelStr != "" { - minLevel = logger.ParseLevel(levelStr) - } - - // Clear HTTP server deadlines before WebSocket hijack - rc := http.NewResponseController(w) - _ = rc.SetWriteDeadline(time.Time{}) - _ = rc.SetReadDeadline(time.Time{}) - - conn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - return - } - - client := &wsClient{conn: conn} - - // Enforce max WS clients: evict oldest if full - h.wsClientsMu.Lock() - if len(h.wsClients) >= maxWSClients { - oldest := h.wsClients[0] - h.wsClients = h.wsClients[1:] - oldest.conn.Close() - } - h.wsClients = append(h.wsClients, client) - h.wsClientsMu.Unlock() - - defer func() { - h.wsClientsMu.Lock() - for i, c := range h.wsClients { - if c == client { - h.wsClients = append(h.wsClients[:i], h.wsClients[i+1:]...) - break - } - } - h.wsClientsMu.Unlock() - conn.Close() - }() - - // Build filter function - filter := func(e logger.LogEntry) bool { - if lvl := logger.ParseLevel(e.Level); lvl < minLevel { - return false - } - if component != "" && e.Component != component { - return false - } - return true - } - - sub := logger.Subscribe(filter) - defer logger.Unsubscribe(sub) - - // Configure ping/pong to detect dead connections - conn.SetReadDeadline(time.Now().Add(wsPongWait)) - conn.SetPongHandler(func(string) error { - conn.SetReadDeadline(time.Now().Add(wsPongWait)) - return nil - }) - - // Send initial data - initial := logger.RecentLogs(minLevel, component, 50) - if err := conn.WriteJSON(map[string]any{"type": "init", "entries": initial}); err != nil { - return - } - - // Close detection goroutine - done := make(chan struct{}) - go func() { - defer close(done) - for { - if _, _, err := conn.ReadMessage(); err != nil { - return - } - } - }() - - // Stream loop with periodic pings - ticker := time.NewTicker(wsPingPeriod) - defer ticker.Stop() - - for { - select { - case entry, ok := <-sub.Ch: - if !ok { - return - } - entry.Caller = "" // strip for security - entry.Fields = logger.SanitizeFields(entry.Fields) // mask sensitive values - if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil { - return - } - case <-ticker.C: - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return - } - case <-done: - return - } - } -} - -// wsOrchestration streams live orchestration events (agent spawn/state/gc and -// conductor↔agent conversations) to the canvas UI. -// -// Protocol: -// -// {"type":"init","agents":[...OrchAgentInfo]} — sent once on connect -// {"type":"event","event":{...OrchEvent}} — pushed on each state change -func (h *Handler) wsOrchestration(w http.ResponseWriter, r *http.Request) { - if h.orchBroadcaster == nil { - http.Error(w, `{"error":"orchestration not enabled"}`, http.StatusServiceUnavailable) - return - } - - rc := http.NewResponseController(w) - _ = rc.SetWriteDeadline(time.Time{}) - _ = rc.SetReadDeadline(time.Time{}) - - conn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - sub := h.orchBroadcaster.Subscribe() - defer h.orchBroadcaster.Unsubscribe(sub) - - // Send current agent snapshot so the canvas can populate immediately - snapshot := h.orchBroadcaster.Snapshot() - if err := conn.WriteJSON(map[string]any{"type": "init", "agents": snapshot}); err != nil { - return - } - - conn.SetReadDeadline(time.Now().Add(wsPongWait)) - conn.SetPongHandler(func(string) error { - conn.SetReadDeadline(time.Now().Add(wsPongWait)) - return nil - }) - - done := make(chan struct{}) - go func() { - defer close(done) - for { - if _, _, err := conn.ReadMessage(); err != nil { - return - } - } - }() - - ticker := time.NewTicker(wsPingPeriod) - defer ticker.Stop() - - for { - select { - case ev, ok := <-sub.Ch: - if !ok { - return - } - if err := conn.WriteJSON(map[string]any{"type": "event", "event": ev}); err != nil { - return - } - case <-ticker.C: - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return - } - case <-done: - return - } - } -} - -// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer. -func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - return - } - - entries := logger.RecentLogs(logger.DEBUG, "", 300) - - snapshotDir := filepath.Join(h.workspace, "logs", "snapshots") - if err := os.MkdirAll(snapshotDir, 0o755); err != nil { - http.Error(w, `{"error":"cannot create snapshot dir"}`, http.StatusInternalServerError) - return - } - - id := time.Now().UTC().Format("20060102-150405") - filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id) - snapshotPath := filepath.Join(snapshotDir, filename) - - // Create tar.gz - f, err := os.Create(snapshotPath) - if err != nil { - http.Error(w, `{"error":"cannot create snapshot file"}`, http.StatusInternalServerError) - return - } - - gw := gzip.NewWriter(f) - tw := tar.NewWriter(gw) - - prefix := fmt.Sprintf("picoclaw-logs-%s/", id) - - // logs.json - logsJSON, _ := json.MarshalIndent(entries, "", " ") - _ = tw.WriteHeader(&tar.Header{ - Name: prefix + "logs.json", - Size: int64(len(logsJSON)), - Mode: 0o644, - ModTime: time.Now(), - }) - _, _ = tw.Write(logsJSON) - - // metadata.json - hostname, _ := os.Hostname() - meta := map[string]any{ - "version": "1", - "hostname": hostname, - "timestamp": time.Now().UTC().Format(time.RFC3339), - "entry_count": len(entries), - } - metaJSON, _ := json.MarshalIndent(meta, "", " ") - _ = tw.WriteHeader(&tar.Header{ - Name: prefix + "metadata.json", - Size: int64(len(metaJSON)), - Mode: 0o644, - ModTime: time.Now(), - }) - _, _ = tw.Write(metaJSON) - - tw.Close() - gw.Close() - f.Close() - - // Cleanup old snapshots (>14 days) - go cleanOldSnapshots(snapshotDir, 14*24*time.Hour) - - downloadURL := fmt.Sprintf("/miniapp/api/logs/snapshot/%s", id) - writeJSON(w, map[string]string{"id": id, "download_url": downloadURL}) -} - -// apiLogsSnapshotDownload serves a snapshot tar.gz file. -func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - return - } - - id := strings.TrimPrefix(r.URL.Path, "/miniapp/api/logs/snapshot/") - id = filepath.Base(id) // path traversal prevention - - if id == "" || id == "." || id == ".." { - http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) - return - } - - filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id) - snapshotPath := filepath.Join(h.workspace, "logs", "snapshots", filename) - - if _, err := os.Stat(snapshotPath); os.IsNotExist(err) { - http.Error(w, `{"error":"not found"}`, http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/gzip") - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) - http.ServeFile(w, r, snapshotPath) -} - -// cleanOldSnapshots removes snapshot files older than maxAge. -func cleanOldSnapshots(dir string, maxAge time.Duration) { - entries, err := os.ReadDir(dir) - if err != nil { - return - } - cutoff := time.Now().Add(-maxAge) - for _, e := range entries { - if e.IsDir() { - continue - } - info, err := e.Info() - if err != nil { - continue - } - if info.ModTime().Before(cutoff) { - os.Remove(filepath.Join(dir, e.Name())) - } - } -} - -// initDataMaxAge is the maximum age of initData before it is considered expired. -const initDataMaxAge = 24 * time.Hour - -// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature -// and checks that auth_date is not older than initDataMaxAge. -// See https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app -func ValidateInitData(initData, botToken string) bool { - values, err := url.ParseQuery(initData) - if err != nil { - return false - } - - receivedHash := values.Get("hash") - if receivedHash == "" { - return false - } - - // Check auth_date freshness - if authDateStr := values.Get("auth_date"); authDateStr != "" { - authDate, err := strconv.ParseInt(authDateStr, 10, 64) - if err != nil { - return false - } - if time.Since(time.Unix(authDate, 0)) > initDataMaxAge { - return false - } - } - - // Build the data-check-string: sort all key=value pairs except "hash", - // join with newlines. - var pairs []string - for key := range values { - if key == "hash" { - continue - } - pairs = append(pairs, fmt.Sprintf("%s=%s", key, values.Get(key))) - } - sort.Strings(pairs) - dataCheckString := strings.Join(pairs, "\n") - - // secret_key = HMAC-SHA256("WebAppData", bot_token) - secretKeyMac := hmac.New(sha256.New, []byte("WebAppData")) - secretKeyMac.Write([]byte(botToken)) - secretKey := secretKeyMac.Sum(nil) - - // hash = HMAC-SHA256(secret_key, data_check_string) - hashMac := hmac.New(sha256.New, secretKey) - hashMac.Write([]byte(dataCheckString)) - computedHash := hex.EncodeToString(hashMac.Sum(nil)) - - return hmac.Equal([]byte(computedHash), []byte(receivedHash)) -} diff --git a/pkg/miniapp/types.go b/pkg/miniapp/types.go new file mode 100644 index 000000000..2e2fd1016 --- /dev/null +++ b/pkg/miniapp/types.go @@ -0,0 +1,180 @@ +package miniapp + +import ( + "sync" + + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/stats" +) + +// PlanPhase mirrors agent.PlanPhase for JSON serialization. +type PlanPhase struct { + Number int `json:"number"` + Title string `json:"title"` + Steps []PlanStep `json:"steps"` +} + +// PlanStep mirrors agent.PlanStep for JSON serialization. +type PlanStep struct { + Index int `json:"index"` + Description string `json:"description"` + Done bool `json:"done"` +} + +// PlanInfo represents the plan state exposed via the API. +type PlanInfo struct { + HasPlan bool `json:"has_plan"` + Status string `json:"status"` + CurrentPhase int `json:"current_phase"` + TotalPhases int `json:"total_phases"` + Display string `json:"display"` + Phases []PlanPhase `json:"phases"` + Memory string `json:"memory"` +} + +// SessionInfo represents an active session entry for the API response. +type SessionInfo struct { + SessionKey string `json:"session_key"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + TouchDir string `json:"touch_dir"` + ProjectPath string `json:"project_path,omitempty"` + Purpose string `json:"purpose,omitempty"` + Branch string `json:"branch,omitempty"` + LastSeenAt string `json:"last_seen_at"` + AgeSec int `json:"age_sec"` +} + +// GitRepoSummary represents a lightweight repo entry for the list view. +type GitRepoSummary struct { + Name string `json:"name"` + Branch string `json:"branch"` +} + +// GitInfo represents the git repository state exposed via the API. +type GitInfo struct { + Name string `json:"name"` + Branch string `json:"branch"` + Commits []GitCommit `json:"commits"` + Modified []GitChange `json:"modified"` +} + +// GitCommit represents a single commit entry. +type GitCommit struct { + Hash string `json:"hash"` + Subject string `json:"subject"` + Author string `json:"author"` + Date string `json:"date"` +} + +// GitChange represents a modified/untracked file entry. +type GitChange struct { + Status string `json:"status"` + Path string `json:"path"` +} + +// BootstrapFileInfo describes a resolved bootstrap file for the context API. +type BootstrapFileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Scope string `json:"scope"` +} + +// ContextInfo describes the agent's directory context and bootstrap file resolution. +type ContextInfo struct { + WorkDir string `json:"work_dir"` + PlanWorkDir string `json:"plan_work_dir"` + Workspace string `json:"workspace"` + Bootstrap []BootstrapFileInfo `json:"bootstrap"` +} + +// DataProvider is the read-only interface to agent state for the Mini App API. +type DataProvider interface { + ListSkills() []skills.SkillInfo + GetPlanInfo() PlanInfo + GetSessionStats() *stats.Stats + GetActiveSessions() []SessionInfo + GetGitRepos() []GitRepoSummary + GetGitRepoDetail(name string) GitInfo + GetContextInfo() ContextInfo + GetSystemPrompt() string +} + +// CommandSender injects a command into the message bus on behalf of a user. +type CommandSender interface { + SendCommand(senderID, chatID, command string) +} + +// DevTarget represents a registered dev server target. +type DevTarget struct { + ID string `json:"id"` + Name string `json:"name"` // display name (e.g. "frontend") + Target string `json:"target"` // URL (e.g. "http://localhost:3000") +} + +// DevTargetManager allows tools to register, activate, and deactivate dev proxy targets. +type DevTargetManager interface { + RegisterDevTarget(name, target string) (id string, err error) + UnregisterDevTarget(id string) error + ActivateDevTarget(id string) error + DeactivateDevTarget() error + GetDevTarget() string + ListDevTargets() []DevTarget +} + +// StateNotifier broadcasts state-change signals to SSE subscribers. +type StateNotifier struct { + mu sync.Mutex + subs map[chan struct{}]struct{} + done chan struct{} +} + +// NewStateNotifier creates a new StateNotifier. +func NewStateNotifier() *StateNotifier { + return &StateNotifier{ + subs: make(map[chan struct{}]struct{}), + done: make(chan struct{}), + } +} + +// Subscribe returns a channel that receives a signal on each state change. +func (n *StateNotifier) Subscribe() chan struct{} { + ch := make(chan struct{}, 1) + n.mu.Lock() + n.subs[ch] = struct{}{} + n.mu.Unlock() + return ch +} + +// Unsubscribe removes a subscriber channel. +func (n *StateNotifier) Unsubscribe(ch chan struct{}) { + n.mu.Lock() + delete(n.subs, ch) + n.mu.Unlock() +} + +// Close signals all SSE handlers to exit. +func (n *StateNotifier) Close() { + select { + case <-n.done: + default: + close(n.done) + } +} + +// Done returns a channel that is closed when the notifier is shut down. +func (n *StateNotifier) Done() <-chan struct{} { + return n.done +} + +// Notify sends a signal to all subscribers, coalescing rapid notifications. +func (n *StateNotifier) Notify() { + n.mu.Lock() + defer n.mu.Unlock() + for ch := range n.subs { + select { + case ch <- struct{}{}: + default: + } + } +} diff --git a/pkg/miniapp/ws.go b/pkg/miniapp/ws.go new file mode 100644 index 000000000..d754c6b7c --- /dev/null +++ b/pkg/miniapp/ws.go @@ -0,0 +1,224 @@ +package miniapp + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/sipeed/picoclaw/pkg/logger" +) + + +const maxWSClients = 4 + +const ( + wsPongWait = 60 * time.Second + wsPingPeriod = 54 * time.Second // must be less than wsPongWait +) + +type wsClient struct { + conn *websocket.Conn +} + + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true // non-browser clients (e.g. curl) + } + // Allow same-origin requests (e.g. Tailscale direct access) + if u, err := url.Parse(origin); err == nil && u.Host == r.Host { + return true + } + // Allow Telegram WebApp origins and localhost for dev + return strings.HasSuffix(origin, ".telegram.org") || + strings.HasSuffix(origin, ".t.me") || + strings.HasPrefix(origin, "http://localhost") || + strings.HasPrefix(origin, "http://127.0.0.1") + }, +} + +// NewHandler creates a new Mini App handler. + + +// wsLogs serves a WebSocket endpoint that streams log entries in real time. +func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) { + // Parse filter params + component := r.URL.Query().Get("component") + levelStr := r.URL.Query().Get("level") + minLevel := logger.INFO + if levelStr != "" { + minLevel = logger.ParseLevel(levelStr) + } + + // Clear HTTP server deadlines before WebSocket hijack + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Time{}) + _ = rc.SetReadDeadline(time.Time{}) + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + client := &wsClient{conn: conn} + + // Enforce max WS clients: evict oldest if full + h.wsClientsMu.Lock() + if len(h.wsClients) >= maxWSClients { + oldest := h.wsClients[0] + h.wsClients = h.wsClients[1:] + oldest.conn.Close() + } + h.wsClients = append(h.wsClients, client) + h.wsClientsMu.Unlock() + + defer func() { + h.wsClientsMu.Lock() + for i, c := range h.wsClients { + if c == client { + h.wsClients = append(h.wsClients[:i], h.wsClients[i+1:]...) + break + } + } + h.wsClientsMu.Unlock() + conn.Close() + }() + + // Build filter function + filter := func(e logger.LogEntry) bool { + if lvl := logger.ParseLevel(e.Level); lvl < minLevel { + return false + } + if component != "" && e.Component != component { + return false + } + return true + } + + sub := logger.Subscribe(filter) + defer logger.Unsubscribe(sub) + + // Configure ping/pong to detect dead connections + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + return nil + }) + + // Send initial data + initial := logger.RecentLogs(minLevel, component, 50) + if err := conn.WriteJSON(map[string]any{"type": "init", "entries": initial}); err != nil { + return + } + + // Close detection goroutine + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + // Stream loop with periodic pings + ticker := time.NewTicker(wsPingPeriod) + defer ticker.Stop() + + for { + select { + case entry, ok := <-sub.Ch: + if !ok { + return + } + entry.Caller = "" // strip for security + entry.Fields = logger.SanitizeFields(entry.Fields) // mask sensitive values + if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil { + return + } + case <-ticker.C: + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-done: + return + } + } +} + +// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer. + +// wsOrchestration streams live orchestration events (agent spawn/state/gc and +// conductor<->agent conversations) to the canvas UI. +// +// Protocol: +// +// {"type":"init","agents":[...orch.AgentInfo]} -- sent once on connect +// {"type":"event","event":{...orch.Event}} -- pushed on each state change +func (h *Handler) wsOrchestration(w http.ResponseWriter, r *http.Request) { + if h.orchBroadcaster == nil { + http.Error(w, `{"error":"orchestration not enabled"}`, http.StatusServiceUnavailable) + return + } + + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Time{}) + _ = rc.SetReadDeadline(time.Time{}) + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + sub := h.orchBroadcaster.Subscribe() + defer h.orchBroadcaster.Unsubscribe(sub) + + // Send current agent snapshot so the canvas can populate immediately + snapshot := h.orchBroadcaster.Snapshot() + if err := conn.WriteJSON(map[string]any{"type": "init", "agents": snapshot}); err != nil { + return + } + + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + return nil + }) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + ticker := time.NewTicker(wsPingPeriod) + defer ticker.Stop() + + for { + select { + case ev, ok := <-sub.Ch: + if !ok { + return + } + if err := conn.WriteJSON(map[string]any{"type": "event", "event": ev}); err != nil { + return + } + case <-ticker.C: + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-done: + return + } + } +} From dd81b4b2bf7c615453410cc635fc8d32f54d998f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:01:17 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20introduce=20orch.AgentReporte?= =?UTF-8?q?r=20=E2=80=94=20decouple=20Broadcaster=20from=20SubagentManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pkg/orch/reporter.go: AgentReporter interface + Noop singleton - Broadcaster now implements AgentReporter (ReportSpawn/StateChange/Conversation/GC) - ToolLoopConfig: replace OnStateChange func with Reporter+AgentID - SubagentManager: accept AgentReporter in constructor, remove internal Broadcaster and GetBroadcaster(); all Publish calls replaced with Report* calls - AgentLoop: add orchBroadcaster/*orchReporter fields, reporter() nil-safe helper, SetOrchReporter/GetOrchBroadcaster public API - NewAgentLoop: create struct before registerSharedTools so al.reporter() is available; auto-detect orchestration from registry config - runAgentLoop: ReportSpawn on entry, defer ReportGC on exit - runLLMIteration: ReportStateChange("waiting") before LLM call, ReportStateChange("toolcall", name) before each tool execution - cmd_gateway.go: wire GetOrchBroadcaster() → handler.SetOrchBroadcaster() - Update subagent_tool_test.go for new constructor signature - Document hierarchy in CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 71 +++++++++++++++++++++++++++++ cmd/picoclaw/cmd_gateway.go | 3 ++ pkg/agent/loop.go | 79 +++++++++++++++++++++++++++------ pkg/orch/broadcaster.go | 20 +++++++++ pkg/orch/reporter.go | 21 +++++++++ pkg/tools/subagent.go | 58 ++++++------------------ pkg/tools/subagent_tool_test.go | 21 ++++----- pkg/tools/toolloop.go | 25 ++++++----- 8 files changed, 218 insertions(+), 80 deletions(-) create mode 100644 pkg/orch/reporter.go diff --git a/CLAUDE.md b/CLAUDE.md index cb1c7d43d..74cbcb247 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -354,6 +354,77 @@ pkg/agent/ context.go — conductor identity + orchestration guidance 追加 ``` +### AgentReporter 抽象化 (実装済み 2026-02-25) + +> branch `sub-agent-technical-breakdown` + +`Broadcaster` を `SubagentManager` 内部で生成する密結合を解消し、 +`orch.AgentReporter` インターフェースを中心に置くリファクタリングを実施。 + +#### オーナーシップ + +``` +AgentLoop + ├─ owns: *orch.Broadcaster (orchBroadcaster — nil when disabled) + └─ holds: orch.AgentReporter (orchReporter = Broadcaster or Noop) + ├─ passes to → SubagentManager.reporter + │ └─ passes to → ToolLoopConfig.Reporter + └─ calls directly for main/heartbeat sessions + ├─ runAgentLoop: ReportSpawn / ReportGC + └─ runLLMIteration: ReportStateChange + +cmd_gateway.go + └─ agentLoop.GetOrchBroadcaster() → handler.SetOrchBroadcaster() + +miniapp.Handler + └─ borrows *orch.Broadcaster for Subscribe/Snapshot (WS 配信) +``` + +#### インターフェース (`pkg/orch/reporter.go`) + +```go +type AgentReporter interface { + ReportSpawn(id, label, task string) + ReportStateChange(id, state, tool string) + ReportConversation(from, to, text string) + ReportGC(id, reason string) +} +var Noop AgentReporter = &noopReporter{} // nil-free; 全メソッドが no-op +``` + +`Broadcaster` は `AgentReporter` を満たす (`ReportSpawn` 等が `Publish` のラッパー)。 + +#### Noop パターン + +``` +--orchestration なし: orchReporter = orch.Noop → 全 Report* が空振り (panic なし) +--orchestration あり: orchReporter = *Broadcaster → WS 配信 +``` + +呼び出し側は `if reporter != nil` チェック不要。 + +#### イベント発火の責任分担 + +| 発火元 | イベント | 経由 | +|--------|---------|------| +| `runAgentLoop` | `ReportSpawn` / `ReportGC` | `al.reporter()` | +| `runLLMIteration` | `ReportStateChange("waiting"/"toolcall")` | `al.reporter()` | +| `SubagentManager.Spawn` | `ReportSpawn` | `sm.reporter` | +| `SubagentManager.runTask` | `ReportConversation` / `ReportGC` | `sm.reporter` | +| `RunToolLoop` | `ReportStateChange` | `config.Reporter` | + +main / heartbeat / subagent の全セッションが同一 Broadcaster に発火するため、 +canvas には全エージェントが統一して表示される。 + +#### 変更ファイル + +- `pkg/orch/reporter.go` — **新規** インターフェース + Noop +- `pkg/orch/broadcaster.go` — `ReportSpawn/StateChange/Conversation/GC` 追加 +- `pkg/tools/toolloop.go` — `OnStateChange func` → `Reporter AgentReporter + AgentID` +- `pkg/tools/subagent.go` — constructor に `reporter` 受け取り、内部 broadcaster 廃止、`GetBroadcaster()` 削除 +- `pkg/agent/loop.go` — `orchBroadcaster`/`orchReporter` フィールド追加、`SetOrchReporter`/`GetOrchBroadcaster` 追加、`registerSharedTools` シグネチャに `al *AgentLoop` 追加 +- `cmd/picoclaw/cmd_gateway.go` — `GetOrchBroadcaster()` → `handler.SetOrchBroadcaster()` + --- ## Memory Optimization Notes diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index b0a55dcce..2b9567af5 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -243,6 +243,9 @@ func gatewayCmd() { miniappNotifier = miniapp.NewStateNotifier() handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier, cfg.Channels.Telegram.AllowFrom, cfg.WorkspacePath()) agentLoop.OnStateChange = miniappNotifier.Notify + if b := agentLoop.GetOrchBroadcaster(); b != nil { + handler.SetOrchBroadcaster(b) + } handler.RegisterRoutes(healthServer.Mux()) // Register dev preview tool for all agents diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cdcd1f608..4649e71eb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -25,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/routing" @@ -94,6 +95,8 @@ type AgentLoop struct { promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read OnStateChange func() // called on plan/session/skills mutations OnUserMessage func() // called when a real user message is processed + orchBroadcaster *orch.Broadcaster // nil when --orchestration not set + orchReporter orch.AgentReporter // always non-nil (Noop when disabled) } // processOptions configures how a message is processed @@ -114,9 +117,6 @@ type processOptions struct { func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, enableStats ...bool) *AgentLoop { registry := NewAgentRegistry(cfg, provider) - // Register shared tools to all agents - registerSharedTools(cfg, msgBus, registry, provider) - // Set up shared fallback chain cooldown := providers.NewCooldownTracker() fallbackChain := providers.NewFallbackChain(cooldown) @@ -136,17 +136,57 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers statsTracker = stats.NewTracker(defaultAgent.Workspace) } - return &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - stats: statsTracker, - summarizing: sync.Map{}, - fallback: fallbackChain, - providerCache: providerCache, - sessions: NewSessionTracker(), + // Determine if orchestration broadcaster is needed (any agent has subagents enabled). + var orchBroadcaster *orch.Broadcaster + var orchReporter orch.AgentReporter = orch.Noop + for _, id := range registry.ListAgentIDs() { + if a, ok := registry.GetAgent(id); ok && a.Subagents != nil && a.Subagents.Enabled { + orchBroadcaster = orch.NewBroadcaster() + orchReporter = orchBroadcaster + break + } } + + al := &AgentLoop{ + bus: msgBus, + cfg: cfg, + registry: registry, + state: stateManager, + stats: statsTracker, + summarizing: sync.Map{}, + fallback: fallbackChain, + providerCache: providerCache, + sessions: NewSessionTracker(), + orchBroadcaster: orchBroadcaster, + orchReporter: orchReporter, + } + + // Register shared tools to all agents (needs al for reporter injection). + registerSharedTools(cfg, msgBus, registry, provider, al) + + return al +} + +// reporter returns the active AgentReporter (never nil). +func (al *AgentLoop) reporter() orch.AgentReporter { + if al.orchReporter == nil { + return orch.Noop + } + return al.orchReporter +} + +// SetOrchReporter wires a Broadcaster as the active reporter. +// Called from cmd_gateway.go when --orchestration is set. +// --orchestration なし → 呼ばれない → reporter() は Noop を返す。 +func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) { + al.orchBroadcaster = b + al.orchReporter = b +} + +// GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring. +// Returns nil when orchestration is disabled. +func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster { + return al.orchBroadcaster } func (al *AgentLoop) notifyStateChange() { @@ -162,6 +202,7 @@ func registerSharedTools( msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider, + al *AgentLoop, ) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) @@ -218,7 +259,7 @@ func registerSharedTools( // Spawn tool — only registered when orchestration is explicitly enabled. if agent.Subagents != nil && agent.Subagents.Enabled { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus, al.reporter()) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) spawnTool := tools.NewSpawnTool(subagentManager) currentAgentID := agentID @@ -715,6 +756,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } defer al.releaseSessionLock(opts.SessionKey) + // Report session lifecycle to canvas. + al.reporter().ReportSpawn(opts.SessionKey, opts.Channel, opts.UserMessage) + defer al.reporter().ReportGC(opts.SessionKey, "completed") + // -0. Create cancellable child context and register active task taskCtx, taskCancel := context.WithCancel(ctx) defer taskCancel() @@ -1787,6 +1832,9 @@ func (al *AgentLoop) runLLMIteration( return doCall(ctx, agent.Provider, primaryModel) } + // Report waiting state to canvas before each LLM call. + al.reporter().ReportStateChange(opts.SessionKey, "waiting", "") + // Retry loop for context/token errors maxRetries := 2 for retry := 0; retry <= maxRetries; retry++ { @@ -2136,6 +2184,9 @@ func (al *AgentLoop) runLLMIteration( } } + // Report toolcall state to canvas. + al.reporter().ReportStateChange(opts.SessionKey, "toolcall", tc.Name) + toolStart := time.Now() toolCtx := ctx if wt := agent.GetWorktree(opts.SessionKey); wt != nil { diff --git a/pkg/orch/broadcaster.go b/pkg/orch/broadcaster.go index fe89509bb..70fff1881 100644 --- a/pkg/orch/broadcaster.go +++ b/pkg/orch/broadcaster.go @@ -83,6 +83,26 @@ func (b *Broadcaster) Snapshot() []AgentInfo { return out } +// ReportSpawn implements AgentReporter. +func (b *Broadcaster) ReportSpawn(id, label, task string) { + b.Publish(Event{Type: "agent_spawn", ID: id, Label: label, Task: task}) +} + +// ReportStateChange implements AgentReporter. +func (b *Broadcaster) ReportStateChange(id, state, tool string) { + b.Publish(Event{Type: "agent_state", ID: id, State: state, Tool: tool}) +} + +// ReportConversation implements AgentReporter. +func (b *Broadcaster) ReportConversation(from, to, text string) { + b.Publish(Event{Type: "conversation", From: from, To: to, Text: text}) +} + +// ReportGC implements AgentReporter. +func (b *Broadcaster) ReportGC(id, reason string) { + b.Publish(Event{Type: "agent_gc", ID: id, Reason: reason}) +} + // Publish updates internal agent state and fans out to all subscribers. func (b *Broadcaster) Publish(ev Event) { if ev.Created == 0 { diff --git a/pkg/orch/reporter.go b/pkg/orch/reporter.go new file mode 100644 index 000000000..b8229314d --- /dev/null +++ b/pkg/orch/reporter.go @@ -0,0 +1,21 @@ +package orch + +// AgentReporter is the interface for reporting agent lifecycle events. +// Both Broadcaster (real events) and noopReporter (disabled) implement this. +type AgentReporter interface { + ReportSpawn(id, label, task string) + ReportStateChange(id, state, tool string) + ReportConversation(from, to, text string) + ReportGC(id, reason string) +} + +type noopReporter struct{} + +func (n *noopReporter) ReportSpawn(id, label, task string) {} +func (n *noopReporter) ReportStateChange(id, state, tool string) {} +func (n *noopReporter) ReportConversation(from, to, text string) {} +func (n *noopReporter) ReportGC(id, reason string) {} + +// Noop is the AgentReporter to use when orchestration is disabled. +// Allows nil-free code in callers. +var Noop AgentReporter = &noopReporter{} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 634d087ed..3956cf0fd 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -37,14 +37,18 @@ type SubagentManager struct { hasMaxTokens bool hasTemperature bool nextID int - broadcaster *orch.Broadcaster + reporter orch.AgentReporter } func NewSubagentManager( provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus, + reporter orch.AgentReporter, ) *SubagentManager { + if reporter == nil { + reporter = orch.Noop + } return &SubagentManager{ tasks: make(map[string]*SubagentTask), provider: provider, @@ -54,16 +58,10 @@ func NewSubagentManager( tools: NewToolRegistry(), maxIterations: 10, nextID: 1, - broadcaster: orch.NewBroadcaster(), + reporter: reporter, } } -// GetBroadcaster returns the Broadcaster so the miniapp handler can -// subscribe to real-time orchestration events. -func (sm *SubagentManager) GetBroadcaster() *orch.Broadcaster { - return sm.broadcaster -} - // SetLLMOptions sets max tokens and temperature for subagent LLM calls. func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() @@ -112,12 +110,7 @@ func (sm *SubagentManager) Spawn( } sm.tasks[taskID] = subagentTask - sm.broadcaster.Publish(orch.Event{ - Type: "agent_spawn", - ID: taskID, - Label: label, - Task: task, - }) + sm.reporter.ReportSpawn(taskID, label, task) // Start task in background with context cancellation support go sm.runTask(ctx, subagentTask, callback) @@ -130,7 +123,6 @@ func (sm *SubagentManager) Spawn( func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { task.Status = "running" - task.Created = time.Now().UnixMilli() // Build system prompt for subagent systemPrompt := `You are a subagent. Complete the given task independently and report the result. @@ -181,12 +173,7 @@ After completing the task, provide a clear summary of what was done.` } // Notify conductor that the subagent is starting - sm.broadcaster.Publish(orch.Event{ - Type: "conversation", - From: "conductor", - To: task.ID, - Text: task.Task, - }) + sm.reporter.ReportConversation("conductor", task.ID, task.Task) loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ Provider: sm.provider, @@ -194,14 +181,8 @@ After completing the task, provide a clear summary of what was done.` Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, - OnStateChange: func(state, tool string) { - sm.broadcaster.Publish(orch.Event{ - Type: "agent_state", - ID: task.ID, - State: state, - Tool: tool, - }) - }, + Reporter: sm.reporter, + AgentID: task.ID, }, messages, task.OriginChannel, task.OriginChatID) sm.mu.Lock() @@ -224,11 +205,7 @@ After completing the task, provide a clear summary of what was done.` task.Result = "Task cancelled during execution" gcReason = "cancelled" } - sm.broadcaster.Publish(orch.Event{ - Type: "agent_gc", - ID: task.ID, - Reason: gcReason, - }) + sm.reporter.ReportGC(task.ID, gcReason) result = &ToolResult{ ForLLM: task.Result, ForUser: "", @@ -241,17 +218,8 @@ After completing the task, provide a clear summary of what was done.` task.Status = "completed" task.Result = loopResult.Content // Notify conductor of the result - sm.broadcaster.Publish(orch.Event{ - Type: "conversation", - From: task.ID, - To: "conductor", - Text: loopResult.Content, - }) - sm.broadcaster.Publish(orch.Event{ - Type: "agent_gc", - ID: task.ID, - Reason: "completed", - }) + sm.reporter.ReportConversation(task.ID, "conductor", loopResult.Content) + sm.reporter.ReportGC(task.ID, "completed") result = &ToolResult{ ForLLM: fmt.Sprintf( "Subagent '%s' completed (iterations: %d): %s", diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 59bfdffae..30838d843 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -47,7 +48,7 @@ func (m *MockLLMProvider) GetContextWindow() int { func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) manager.SetLLMOptions(2048, 0.6) tool := NewSubagentTool(manager) tool.SetContext("cli", "direct") @@ -74,7 +75,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { // TestSubagentTool_Name verifies tool name func TestSubagentTool_Name(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) if tool.Name() != "subagent" { @@ -85,7 +86,7 @@ func TestSubagentTool_Name(t *testing.T) { // TestSubagentTool_Description verifies tool description func TestSubagentTool_Description(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) desc := tool.Description() @@ -100,7 +101,7 @@ func TestSubagentTool_Description(t *testing.T) { // TestSubagentTool_Parameters verifies tool parameters schema func TestSubagentTool_Parameters(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) params := tool.Parameters() @@ -150,7 +151,7 @@ func TestSubagentTool_Parameters(t *testing.T) { // TestSubagentTool_SetContext verifies context setting func TestSubagentTool_SetContext(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) tool.SetContext("test-channel", "test-chat") @@ -164,7 +165,7 @@ func TestSubagentTool_SetContext(t *testing.T) { func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) tool.SetContext("telegram", "chat-123") @@ -220,7 +221,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) { func TestSubagentTool_Execute_NoLabel(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) ctx := context.Background() @@ -243,7 +244,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { // TestSubagentTool_Execute_MissingTask tests error handling for missing task func TestSubagentTool_Execute_MissingTask(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) ctx := context.Background() @@ -294,7 +295,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) { func TestSubagentTool_Execute_ContextPassing(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) // Set context @@ -323,7 +324,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { // Create a mock provider that returns very long content provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) ctx := context.Background() diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 6eb62ef23..d8793caef 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -12,6 +12,7 @@ import ( "fmt" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -23,11 +24,12 @@ type ToolLoopConfig struct { Tools *ToolRegistry MaxIterations int LLMOptions map[string]any - // OnStateChange is an optional hook for UI feedback. - // Called with ("waiting","") before each LLM call and - // ("toolcall", toolName) when each tool starts executing. - // nil is safe to pass. - OnStateChange func(state, tool string) + // Reporter and AgentID replace the old OnStateChange func. + // Reporter is called with ReportStateChange("waiting","") before each LLM + // call and ReportStateChange("toolcall", toolName) when each tool starts. + // Pass nil or orch.Noop to disable. nil is treated as orch.Noop internally. + Reporter orch.AgentReporter + AgentID string } // ToolLoopResult contains the result of running the tool loop. @@ -44,6 +46,11 @@ func RunToolLoop( messages []providers.Message, channel, chatID string, ) (*ToolLoopResult, error) { + reporter := config.Reporter + if reporter == nil { + reporter = orch.Noop + } + iteration := 0 var finalContent string @@ -68,9 +75,7 @@ func RunToolLoop( llmOpts = map[string]any{} } // 3. Call LLM (hook: waiting for response) - if config.OnStateChange != nil { - config.OnStateChange("waiting", "") - } + reporter.ReportStateChange(config.AgentID, "waiting", "") response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", @@ -138,9 +143,7 @@ func RunToolLoop( "tool": tc.Name, "iteration": iteration, }) - if config.OnStateChange != nil { - config.OnStateChange("toolcall", tc.Name) - } + reporter.ReportStateChange(config.AgentID, "toolcall", tc.Name) // Execute tool (no async callback for subagents - they run independently) var toolResult *ToolResult From 0205da79d0d4373afa747bc414f5f3a8f3d338fa Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:12:04 +0900 Subject: [PATCH 10/11] test: add AgentReporter interface and event-ordering tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pkg/orch/reporter_test.go: Noop panic-safety + Broadcaster Report* method field-mapping and snapshot lifecycle (5 tests) - pkg/tools/toolloop_reporter_test.go: nil-reporter fallback and waiting→toolcall(echo_tool)→waiting ordering (4 tests) - pkg/tools/subagent_reporter_test.go: Spawn full lifecycle events via real Broadcaster (agent_spawn→conversation→state→gc) and snapshot liveness check (2 tests) Co-Authored-By: Claude Sonnet 4.6 --- pkg/orch/reporter_test.go | 102 +++++++++++++++++ pkg/tools/subagent_reporter_test.go | 153 +++++++++++++++++++++++++ pkg/tools/toolloop_reporter_test.go | 170 ++++++++++++++++++++++++++++ 3 files changed, 425 insertions(+) create mode 100644 pkg/orch/reporter_test.go create mode 100644 pkg/tools/subagent_reporter_test.go create mode 100644 pkg/tools/toolloop_reporter_test.go diff --git a/pkg/orch/reporter_test.go b/pkg/orch/reporter_test.go new file mode 100644 index 000000000..120f03c26 --- /dev/null +++ b/pkg/orch/reporter_test.go @@ -0,0 +1,102 @@ +package orch + +import "testing" + +// Compile-time: Broadcaster must satisfy AgentReporter. +var _ AgentReporter = (*Broadcaster)(nil) + +// TestNoop_AllMethods_NoPanic verifies that orch.Noop can be called for all +// four methods without panic. This is the nil-safe baseline for disabled +// orchestration mode. +func TestNoop_AllMethods_NoPanic(t *testing.T) { + Noop.ReportSpawn("id", "label", "task") + Noop.ReportStateChange("id", "waiting", "") + Noop.ReportStateChange("id", "toolcall", "bash") + Noop.ReportConversation("conductor", "sub-1", "do something") + Noop.ReportGC("id", "completed") +} + +// TestBroadcaster_ReportSpawn_MapsToAgentSpawnEvent verifies that ReportSpawn +// publishes an Event with Type="agent_spawn" and the correct ID/Label/Task +// fields, and that the agent appears in the Snapshot immediately. +func TestBroadcaster_ReportSpawn_MapsToAgentSpawnEvent(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + b.ReportSpawn("agent-1", "scout", "find all TODOs") + + ev := <-sub.Ch + if ev.Type != "agent_spawn" { + t.Fatalf("want agent_spawn, got %q", ev.Type) + } + if ev.ID != "agent-1" || ev.Label != "scout" || ev.Task != "find all TODOs" { + t.Fatalf("field mismatch: %+v", ev) + } + snap := b.Snapshot() + if len(snap) != 1 || snap[0].ID != "agent-1" || snap[0].Label != "scout" { + t.Fatalf("snapshot not updated correctly: %v", snap) + } +} + +// TestBroadcaster_ReportStateChange_MapsToAgentStateEvent verifies that +// ReportStateChange publishes agent_state and updates the live snapshot. +func TestBroadcaster_ReportStateChange_MapsToAgentStateEvent(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + b.ReportSpawn("agent-1", "coder", "implement it") + <-sub.Ch // consume spawn + + b.ReportStateChange("agent-1", "toolcall", "bash") + ev := <-sub.Ch + if ev.Type != "agent_state" || ev.State != "toolcall" || ev.Tool != "bash" { + t.Fatalf("unexpected event: %+v", ev) + } + snap := b.Snapshot() + if snap[0].State != "toolcall" || snap[0].Tool != "bash" { + t.Fatalf("snapshot state not updated: %v", snap) + } +} + +// TestBroadcaster_ReportConversation_MapsToConversationEvent verifies that +// ReportConversation publishes a conversation event with correct From/To/Text +// fields and does NOT modify the agent snapshot (conversation is not a state +// change of any agent). +func TestBroadcaster_ReportConversation_MapsToConversationEvent(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + b.ReportConversation("conductor", "sub-1", "please do the task") + + ev := <-sub.Ch + if ev.Type != "conversation" || ev.From != "conductor" || ev.To != "sub-1" || ev.Text != "please do the task" { + t.Fatalf("unexpected event: %+v", ev) + } + if len(b.Snapshot()) != 0 { + t.Fatal("conversation event must not modify agent snapshot") + } +} + +// TestBroadcaster_ReportGC_RemovesAgentFromSnapshot verifies that ReportGC +// publishes agent_gc with the correct Reason and removes the agent from the +// live snapshot so new WS connections no longer see it. +func TestBroadcaster_ReportGC_RemovesAgentFromSnapshot(t *testing.T) { + b := NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + b.ReportSpawn("agent-1", "scout", "task") + <-sub.Ch // consume spawn + + b.ReportGC("agent-1", "completed") + ev := <-sub.Ch + if ev.Type != "agent_gc" || ev.ID != "agent-1" || ev.Reason != "completed" { + t.Fatalf("unexpected event: %+v", ev) + } + if len(b.Snapshot()) != 0 { + t.Fatal("agent must be removed from snapshot after ReportGC") + } +} diff --git a/pkg/tools/subagent_reporter_test.go b/pkg/tools/subagent_reporter_test.go new file mode 100644 index 000000000..5ff68f4e4 --- /dev/null +++ b/pkg/tools/subagent_reporter_test.go @@ -0,0 +1,153 @@ +package tools + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/orch" +) + +// TestSubagentManager_Spawn_EmitsLifecycleEvents verifies that Spawn() fires +// the correct sequence of orchestration events through a real Broadcaster: +// +// agent_spawn → conversation(conductor→sub) → agent_state(waiting) → +// conversation(sub→conductor) → agent_gc(completed) +// +// It also verifies that the snapshot is empty after ReportGC and that the +// completion callback is invoked. +func TestSubagentManager_Spawn_EmitsLifecycleEvents(t *testing.T) { + b := orch.NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + provider := &MockLLMProvider{} + mgr := NewSubagentManager(provider, "test-model", "/tmp/test", nil, b) + + var callbackCalled int32 + cb := AsyncCallback(func(_ context.Context, _ *ToolResult) { + atomic.StoreInt32(&callbackCalled, 1) + }) + + _, err := mgr.Spawn( + context.Background(), + "say hello", "hello-task", "", "cli", "direct", + cb, + ) + if err != nil { + t.Fatalf("Spawn() error: %v", err) + } + + // Collect events until agent_gc or timeout. + var events []orch.Event + deadline := time.After(3 * time.Second) +loop: + for { + select { + case ev := <-sub.Ch: + events = append(events, ev) + if ev.Type == "agent_gc" { + break loop + } + case <-deadline: + t.Fatalf("timed out waiting for agent_gc; events so far: %+v", events) + } + } + + // 1. First event must be agent_spawn with the correct label. + if len(events) == 0 || events[0].Type != "agent_spawn" { + t.Fatalf("first event must be agent_spawn, got: %+v", events) + } + if events[0].Label != "hello-task" { + t.Errorf("agent_spawn label = %q, want %q", events[0].Label, "hello-task") + } + spawnedID := events[0].ID + + // 2. There must be a conversation from conductor → subagent. + var hasConvToSub bool + for _, ev := range events { + if ev.Type == "conversation" && ev.From == "conductor" && ev.To == spawnedID { + hasConvToSub = true + break + } + } + if !hasConvToSub { + t.Errorf("missing conversation(conductor → %s); events: %+v", spawnedID, events) + } + + // 3. There must be at least one agent_state(waiting) for the subagent. + var hasWaiting bool + for _, ev := range events { + if ev.Type == "agent_state" && ev.ID == spawnedID && ev.State == "waiting" { + hasWaiting = true + break + } + } + if !hasWaiting { + t.Errorf("missing agent_state(waiting) for %s; events: %+v", spawnedID, events) + } + + // 4. Last event must be agent_gc with reason "completed". + last := events[len(events)-1] + if last.Type != "agent_gc" || last.ID != spawnedID || last.Reason != "completed" { + t.Errorf("last event must be agent_gc(completed), got: %+v", last) + } + + // 5. Snapshot must be empty after GC (agent removed from live map). + if snap := b.Snapshot(); len(snap) != 0 { + t.Errorf("snapshot must be empty after agent_gc, got: %v", snap) + } + + // 6. Callback must be called. The callback fires in the same goroutine + // as ReportGC (after the deferred unlock), so we poll briefly. + for i := 0; i < 100; i++ { + if atomic.LoadInt32(&callbackCalled) == 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + if atomic.LoadInt32(&callbackCalled) != 1 { + t.Error("completion callback was not called after agent_gc") + } +} + +// TestSubagentManager_Spawn_SnapshotLiveDuringExecution verifies that the +// Broadcaster snapshot contains the agent between agent_spawn and agent_gc. +// Because Publish() updates the agent map before dispatching to subscribers, +// the snapshot is guaranteed to be non-empty as soon as agent_spawn is +// received on the channel. +func TestSubagentManager_Spawn_SnapshotLiveDuringExecution(t *testing.T) { + b := orch.NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + provider := &MockLLMProvider{} + mgr := NewSubagentManager(provider, "test-model", "/tmp/test", nil, b) + + _, err := mgr.Spawn( + context.Background(), + "any task", "live-test", "", "cli", "direct", + nil, + ) + if err != nil { + t.Fatalf("Spawn() error: %v", err) + } + + // Wait for agent_spawn, then immediately check snapshot. + deadline := time.After(2 * time.Second) + for { + select { + case ev := <-sub.Ch: + if ev.Type == "agent_spawn" { + snap := b.Snapshot() + if len(snap) == 0 { + t.Error("snapshot must contain the spawned agent after agent_spawn event") + } + return // test complete; background goroutine drains safely + } + case <-deadline: + t.Fatal("timed out waiting for agent_spawn event") + } + } +} diff --git a/pkg/tools/toolloop_reporter_test.go b/pkg/tools/toolloop_reporter_test.go new file mode 100644 index 000000000..38284b722 --- /dev/null +++ b/pkg/tools/toolloop_reporter_test.go @@ -0,0 +1,170 @@ +package tools + +import ( + "context" + "sync" + "testing" + + "github.com/sipeed/picoclaw/pkg/orch" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// reporterSpy records every ReportStateChange call in order. +// Spawn/Conversation/GC are not needed for toolloop tests. +type reporterSpy struct { + mu sync.Mutex + calls []spyCall +} + +type spyCall struct { + state string + tool string +} + +func (r *reporterSpy) ReportSpawn(id, label, task string) {} +func (r *reporterSpy) ReportConversation(from, to, text string) {} +func (r *reporterSpy) ReportGC(id, reason string) {} +func (r *reporterSpy) ReportStateChange(id, state, tool string) { + r.mu.Lock() + r.calls = append(r.calls, spyCall{state, tool}) + r.mu.Unlock() +} +func (r *reporterSpy) snapshot() []spyCall { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]spyCall, len(r.calls)) + copy(out, r.calls) + return out +} + +// sequenceMockProvider returns a tool call on the first Chat() call and a +// plain text response on all subsequent calls. Used to exercise the +// waiting → toolcall → waiting event sequence in RunToolLoop. +type sequenceMockProvider struct { + mu sync.Mutex + callCount int +} + +func (m *sequenceMockProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + m.mu.Lock() + m.callCount++ + n := m.callCount + m.mu.Unlock() + if n == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + {ID: "tc-1", Name: "echo_tool", Arguments: map[string]any{"msg": "hi"}}, + }, + }, nil + } + return &providers.LLMResponse{Content: "done"}, nil +} +func (m *sequenceMockProvider) GetDefaultModel() string { return "test" } +func (m *sequenceMockProvider) SupportsTools() bool { return true } +func (m *sequenceMockProvider) GetContextWindow() int { return 4096 } + +// echoTool is a minimal Tool stub registered as "echo_tool". +type echoTool struct{} + +func (t *echoTool) Name() string { return "echo_tool" } +func (t *echoTool) Description() string { return "echo" } +func (t *echoTool) Parameters() map[string]any { + return map[string]any{"type": "object", "properties": map[string]any{}} +} +func (t *echoTool) Execute(_ context.Context, _ map[string]any) *ToolResult { + return &ToolResult{ForLLM: "echoed"} +} + +// TestToolLoop_NilReporter_FallsBackToNoop ensures that passing nil as +// Reporter does not panic — the loop must substitute orch.Noop internally. +func TestToolLoop_NilReporter_FallsBackToNoop(t *testing.T) { + _, err := RunToolLoop(context.Background(), ToolLoopConfig{ + Provider: &MockLLMProvider{}, + Model: "test", + MaxIterations: 1, + Reporter: nil, // must not panic + }, []providers.Message{{Role: "user", Content: "hi"}}, "cli", "direct") + if err != nil { + t.Fatalf("unexpected error with nil reporter: %v", err) + } +} + +// TestToolLoop_Reporter_WaitingBeforeLLM verifies that ReportStateChange is +// called with state="waiting" before the first LLM call. The mock provider +// returns a direct text answer (no tool calls), so exactly one waiting event +// is expected. +func TestToolLoop_Reporter_WaitingBeforeLLM(t *testing.T) { + rep := &reporterSpy{} + _, err := RunToolLoop(context.Background(), ToolLoopConfig{ + Provider: &MockLLMProvider{}, + Model: "test", + MaxIterations: 1, + Reporter: rep, + AgentID: "sess-1", + }, []providers.Message{{Role: "user", Content: "hi"}}, "cli", "direct") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + calls := rep.snapshot() + if len(calls) == 0 { + t.Fatal("expected at least one ReportStateChange call") + } + if calls[0].state != "waiting" { + t.Fatalf("first call must be state=waiting, got %+v", calls[0]) + } +} + +// TestToolLoop_Reporter_ToolcallOrderedAfterWaiting verifies the canonical +// two-iteration sequence: +// +// waiting (before 1st LLM call) +// toolcall(echo_tool) (before tool execution) +// waiting (before 2nd LLM call) +// +// The sequenceMockProvider returns a tool call on iteration 1 and a text +// response on iteration 2, driving exactly this path. +func TestToolLoop_Reporter_ToolcallOrderedAfterWaiting(t *testing.T) { + rep := &reporterSpy{} + reg := NewToolRegistry() + reg.Register(&echoTool{}) + + _, err := RunToolLoop(context.Background(), ToolLoopConfig{ + Provider: &sequenceMockProvider{}, + Model: "test", + Tools: reg, + MaxIterations: 5, + Reporter: rep, + AgentID: "sess-1", + }, []providers.Message{{Role: "user", Content: "do it"}}, "cli", "direct") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := rep.snapshot() + if len(calls) < 3 { + t.Fatalf("expected at least 3 calls, got %d: %+v", len(calls), calls) + } + if calls[0].state != "waiting" { + t.Fatalf("calls[0] must be waiting, got %+v", calls[0]) + } + if calls[1].state != "toolcall" || calls[1].tool != "echo_tool" { + t.Fatalf("calls[1] must be toolcall(echo_tool), got %+v", calls[1]) + } + if calls[2].state != "waiting" { + t.Fatalf("calls[2] must be waiting (2nd LLM iteration), got %+v", calls[2]) + } +} + +// TestToolLoop_Reporter_NoopImplementsInterface is a compile-time check that +// orch.Noop satisfies the orch.AgentReporter interface accepted by +// ToolLoopConfig.Reporter. If Noop ever stops implementing the interface the +// build will fail here before any test runs. +func TestToolLoop_Reporter_NoopImplementsInterface(t *testing.T) { + var _ orch.AgentReporter = orch.Noop +} From 1daa2c135cb7a7439eb6ea1cc4ee108702b1618d Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:22:03 +0900 Subject: [PATCH 11/11] test: add cancellation and AgentLoop lifecycle reporter tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pkg/tools/subagent_reporter_test.go: add blockingProvider stub and TestSubagentManager_Spawn_CancelledDuringExecution — verifies that context cancellation mid-LLM-call emits agent_gc(reason=cancelled) and clears the Broadcaster snapshot - pkg/agent/loop_reporter_test.go: new file — verifies main session and heartbeat session lifecycle events through a real Broadcaster: agent_spawn → agent_state(waiting) → agent_gc(completed) Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/loop_reporter_test.go | 132 ++++++++++++++++++++++++++++ pkg/tools/subagent_reporter_test.go | 90 +++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 pkg/agent/loop_reporter_test.go diff --git a/pkg/agent/loop_reporter_test.go b/pkg/agent/loop_reporter_test.go new file mode 100644 index 000000000..ef7ea0905 --- /dev/null +++ b/pkg/agent/loop_reporter_test.go @@ -0,0 +1,132 @@ +package agent + +import ( + "context" + "os" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/orch" +) + +// makeOrchTestLoop creates a minimal AgentLoop with a temp workspace and +// a real Broadcaster wired as the reporter. +// Returns the loop, the broadcaster, and a cleanup function. +func makeOrchTestLoop(t *testing.T) (*AgentLoop, *orch.Broadcaster) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "agent-orch-test-*") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(tmpDir) }) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 512, + MaxToolIterations: 5, + }, + }, + } + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + b := orch.NewBroadcaster() + al.SetOrchReporter(b) + return al, b +} + +// collectOrchEvents drains the subscriber channel until an agent_gc event +// arrives or the deadline is exceeded. +func collectOrchEvents(t *testing.T, ch <-chan orch.Event, timeout time.Duration) []orch.Event { + t.Helper() + var events []orch.Event + deadline := time.After(timeout) + for { + select { + case ev := <-ch: + events = append(events, ev) + if ev.Type == "agent_gc" { + return events + } + case <-deadline: + t.Fatalf("timed out waiting for agent_gc; events so far: %+v", events) + } + } +} + +// TestAgentLoop_ProcessDirect_EmitsSpawnWaitingGC verifies that a main +// session processed via ProcessDirect emits the full lifecycle: +// +// agent_spawn(sessionKey) → agent_state(waiting) → agent_gc(completed) +// +// and that the Broadcaster snapshot is empty after the call returns. +func TestAgentLoop_ProcessDirect_EmitsSpawnWaitingGC(t *testing.T) { + al, b := makeOrchTestLoop(t) + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + const sessionKey = "orch-test-session" + _, err := al.ProcessDirect(context.Background(), "hello", sessionKey) + if err != nil { + t.Fatalf("ProcessDirect: %v", err) + } + + events := collectOrchEvents(t, sub.Ch, 5*time.Second) + + // First event: agent_spawn with correct ID. + if events[0].Type != "agent_spawn" || events[0].ID != sessionKey { + t.Errorf("first event must be agent_spawn(%s), got: %+v", sessionKey, events[0]) + } + + // At least one agent_state(waiting) for this session. + var hasWaiting bool + for _, ev := range events { + if ev.Type == "agent_state" && ev.ID == sessionKey && ev.State == "waiting" { + hasWaiting = true + break + } + } + if !hasWaiting { + t.Errorf("missing agent_state(waiting) for %s; events: %+v", sessionKey, events) + } + + // Last event: agent_gc(completed) for this session. + last := events[len(events)-1] + if last.Type != "agent_gc" || last.ID != sessionKey || last.Reason != "completed" { + t.Errorf("last event must be agent_gc(completed,%s), got: %+v", sessionKey, last) + } + + // Snapshot must be empty — session removed on GC. + if snap := b.Snapshot(); len(snap) != 0 { + t.Errorf("snapshot must be empty after GC, got: %v", snap) + } +} + +// TestAgentLoop_ProcessHeartbeat_EmitsSpawnAndGC verifies that heartbeat +// sessions appear on canvas with sessionKey = "heartbeat". +func TestAgentLoop_ProcessHeartbeat_EmitsSpawnAndGC(t *testing.T) { + al, b := makeOrchTestLoop(t) + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + _, err := al.ProcessHeartbeat(context.Background(), "check system", "heartbeat-chan", "none") + if err != nil { + t.Fatalf("ProcessHeartbeat: %v", err) + } + + events := collectOrchEvents(t, sub.Ch, 5*time.Second) + + // ProcessHeartbeat always uses sessionKey = "heartbeat". + const want = "heartbeat" + if events[0].Type != "agent_spawn" || events[0].ID != want { + t.Errorf("first event must be agent_spawn(%s), got: %+v", want, events[0]) + } + + last := events[len(events)-1] + if last.Type != "agent_gc" || last.ID != want || last.Reason != "completed" { + t.Errorf("last event must be agent_gc(completed,%s), got: %+v", want, last) + } +} diff --git a/pkg/tools/subagent_reporter_test.go b/pkg/tools/subagent_reporter_test.go index 5ff68f4e4..aaf3bd7a4 100644 --- a/pkg/tools/subagent_reporter_test.go +++ b/pkg/tools/subagent_reporter_test.go @@ -7,8 +7,28 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/orch" + "github.com/sipeed/picoclaw/pkg/providers" ) +// blockingProvider blocks inside Chat until the context is cancelled. +// The ready channel is closed the moment Chat is entered, so callers can +// synchronise before cancelling the context. +type blockingProvider struct { + ready chan struct{} +} + +func newBlockingProvider() *blockingProvider { + return &blockingProvider{ready: make(chan struct{})} +} + +func (p *blockingProvider) Chat(ctx context.Context, _ []providers.Message, _ []providers.ToolDefinition, _ string, _ map[string]any) (*providers.LLMResponse, error) { + close(p.ready) // signal: we are now blocking + <-ctx.Done() + return nil, ctx.Err() +} + +func (p *blockingProvider) GetDefaultModel() string { return "test" } + // TestSubagentManager_Spawn_EmitsLifecycleEvents verifies that Spawn() fires // the correct sequence of orchestration events through a real Broadcaster: // @@ -151,3 +171,73 @@ func TestSubagentManager_Spawn_SnapshotLiveDuringExecution(t *testing.T) { } } } + +// TestSubagentManager_Spawn_CancelledDuringExecution verifies that when the +// context is cancelled while a subagent's LLM call is in progress, the +// Broadcaster receives agent_gc with reason="cancelled" and the agent is +// removed from the snapshot. +// +// Synchronisation: +// 1. blockingProvider.ready is closed when Chat() is entered (goroutine is +// now blocked inside the LLM call). +// 2. Only then is the context cancelled, so there is no race between spawn +// and cancellation. +func TestSubagentManager_Spawn_CancelledDuringExecution(t *testing.T) { + b := orch.NewBroadcaster() + sub := b.Subscribe() + defer b.Unsubscribe(sub) + + bp := newBlockingProvider() + mgr := NewSubagentManager(bp, "test-model", "/tmp/test", nil, b) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, err := mgr.Spawn(ctx, "long task", "cancel-me", "", "cli", "direct", nil) + if err != nil { + t.Fatalf("Spawn() error: %v", err) + } + + // Wait until the subagent goroutine is inside Chat (blocking on ctx). + select { + case <-bp.ready: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for blockingProvider to enter Chat") + } + + // Now cancel — the LLM call unblocks with ctx.Err(). + cancel() + + // Collect events until agent_gc. + var events []orch.Event + deadline := time.After(3 * time.Second) +loop: + for { + select { + case ev := <-sub.Ch: + events = append(events, ev) + if ev.Type == "agent_gc" { + break loop + } + case <-deadline: + t.Fatalf("timed out waiting for agent_gc; events so far: %+v", events) + } + } + + // Locate agent_gc and verify reason = "cancelled". + var gcEv orch.Event + for _, ev := range events { + if ev.Type == "agent_gc" { + gcEv = ev + break + } + } + if gcEv.Reason != "cancelled" { + t.Errorf("agent_gc reason = %q, want %q; events: %+v", gcEv.Reason, "cancelled", events) + } + + // Snapshot must be empty after the GC event. + if snap := b.Snapshot(); len(snap) != 0 { + t.Errorf("snapshot must be empty after agent_gc(cancelled), got: %v", snap) + } +}