diff --git a/CLAUDE.md b/CLAUDE.md
index 1938a5ce8..74cbcb247 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,761 +19,489 @@ 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.
-## Memory Optimization Candidates
-
-> Reviewed 2026-02-24 on branch `memory-optimization-review`. False positives included intentionally.
-> Legend: ðŽ High / ð¡ Medium / ð¢ Low
-
-### 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` ã«ãŒã |
-
-### B. ã¹ã©ã€ã¹ã®äºå容éç¢ºä¿æŒã
-
-| éèŠåºŠ | ãã¡ã€ã« | è¡ | å
容 |
-|--------|----------|----|------|
-| ð¡ | `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 ã¹ã©ã€ã¹ã«å®¹éãã³ã â **é€å€**: ã¢ã¯ãã£ãã»ãã·ã§ã³æ°ãäºåäžæã§éçèŠç©ããäžå¯ |
-
-### C. äžèŠãª []byte â string 倿 / éè€å€æ
-
-| éèŠåºŠ | ãã¡ã€ã« | è¡ | å
容 |
-|--------|----------|----|------|
-| ðŽ | `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 |
-
-### D. JSON Marshal/Unmarshal ã®éè€ã»ããããã¹
-
-| éèŠåºŠ | ãã¡ã€ã« | è¡ | å
容 |
-|--------|----------|----|------|
-| ðŽ | `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)` |
-
-### E. 倧ã㪠struct ã®å€æž¡ã / ã«ãŒãå
ã³ããŒ
-
-| éèŠåºŠ | ãã¡ã€ã« | è¡ | å
容 |
-|--------|----------|----|------|
-| ðŽ | `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 æ€èš |
-
-### F. sync.Pool / ãããã¡åå©çšã®æ€èš
-
-| éèŠåºŠ | ãã¡ã€ã« | è¡ | å
容 |
-|--------|----------|----|------|
-| ð¡ | `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)` â å
±æãããã¡ |
-
-### G. LRU / ã¢ã«ãŽãªãºã ã¬ãã«ã®æé©å
-
-| éèŠåºŠ | ãã¡ã€ã« | è¡ | å
容 |
-|--------|----------|----|------|
-| ð¡ | `pkg/skills/search_cache.go` | 161 | `moveToEndLocked()` â slice slicing ã§ O(n) LRU æŽæ° â doubly-linked list ã§ O(1) ã« |
-
-### 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)
-
-| éèŠåºŠ | ãã¡ã€ã« | è¡ | å
容 |
-|--------|----------|----|------|
-| ð¡ | `pkg/agent/memory.go` | 233, 285, 352, 381 | `extractPhaseContent` / `GetPlanPhases` / `MarkStep` / `AddStep` â åäž MEMORY.md ã颿°æ¯ã« Split â çµ±å or ãã£ãã·ã¥ |
-
---
-### èšèšã¬ãã«ã®æ ¹æ¬åå â ãèŠèœãšããã§ã¯ãªããæ§é çã«äžå¯é¿ããªåé¡
+## Subagent Orchestration Design
-åå¥ã®æé©ååè£ã®å€ãã¯ãæžãã人ã®äžæ³šæã§ã¯ãªãã**èšèšäžã®éžæãç¹å®ã®ã¢ãã±ãŒã·ã§ã³ãã¿ãŒã³ãå¿
ç¶çã«åŒãèµ·ãããŠãã**ããšãèªã¿åããã以äžã¯ãã®æ ¹æ¬åå ãèšèšã¬ãã«ã§æŽçãããã®ã
+> Designed 2026-02-25 on branch `sub-agent-technical-breakdown`.
-#### D-1. MemoryStore ãããã¡ã€ã« = æ£ãã®èšèšã§ãããŒã¹æžã¿è¡šçŸããã£ãã·ã¥ã§ããªã
+### ãªããªãŒã±ã¹ãã¬ãŒã·ã§ã³ã
-`MemoryStore` ã®åã¡ãœããã¯ã»ãŒå
šå¡ã `ReadLongTerm()` â `strings.Split()` â scan â `strings.Join()` ãç¬ç«ããŠå®è¡ããã`GetMemoryContext()` ã1ååŒã¶ã ãã§ãå
éšã§ `ReadLongTerm()` ã3å以äžåŒã°ããé£éãèµ·ããã
+åçŽãªæç€ºããå¯èœæ§ã®æšãåºããããšãç®çãconductor ã¯äžäººã§ããéããã®ã§ã¯ãªããæ¢çŽ¢ã»æ·±åã»fork ããµããšãŒãžã§ã³ãã«å§ããªãã倧å±èгãä¿ã€ã
```
-GetMemoryContext()
- ââ HasActivePlan() â ReadLongTerm() â ãã¡ã€ã«I/O
- ââ GetPlanStatus() â ReadLongTerm() â ãã¡ã€ã«I/O
- ââ GetPlanContext() â ReadLongTerm() â ãã¡ã€ã«I/O
- ââ GetCurrentPhase() â ReadLongTerm() â ãã¡ã€ã«I/O
- ââ GetTotalPhases() â ReadLongTerm() â ãã¡ã€ã«I/O
+without orchestration:
+ human â conductor â (å
šéšèªåã§ãã) â result
+ åžžã«ããã«ããã¯ã鿬¡åŠç
+
+with orchestration:
+ human â conductor ââ¬â scout A ââ
+ ââ scout B ââŒâ synthesize â deeper insight
+ ââ scout C ââ
+ conductor ã¯æ¬¡ãèããªãã䞊走
```
-**ãªããããªã£ãã**: MEMORY.md ããŠãŒã¶ãŒãçŽæ¥ç·šéã§ããå€éšãã¡ã€ã«ãšããŠèšèšãããããããã¡ã€ã«ãåžžã«ææ°ã®æ£ããšããåæãæç«ããŠãããã€ã³ã¡ã¢ãªãã£ãã·ã¥ãæã€ãšå€éšç·šéãåæ ãããªããªãæããããããã£ãã·ã¥ãèªç¶ã«å°å
¥ã§ããªãã
+**3ã€ã®æ žå¿åå:**
-**èšèšäžã®éžæè¢**: (a) `content` ãåŒæ°ãšããŠåãåãå
éš pure function 矀 + é«ã¬ãã«ã¡ãœããã ãã1å ReadLongTerm() ãåŒã¶ã(b) ãŠã©ããä»ããã£ãã·ã¥ (`fsnotify`)ã(c) ãšãŒãžã§ã³ãã«ãŒãå
ã§1ã¿ãŒã³ã«1åã ãèªããã¿ãŒã³ã¹ã³ãŒããã£ãã·ã¥ãã
+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 ãæ¢ãŸã (çµæã絶察å¿
èŠãªæã ã)
+```
-#### D-2. `FunctionCall.Arguments` ã JSON æååã®ãŸãŸåãšããŠå®çŸ©ãããŠãã
+### Architecture Overview
+
+```
+Conductor goroutine
+ â ContainerRequest (task, preset, environment)
+ âŒ
+Container goroutine: provision â run â finalize
+ â 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
-// protocoltypes/types.go
-type FunctionCall struct {
- Name string `json:"name"`
- Arguments string `json:"arguments"` // â ã¯ã€ã€ãã©ãŒããã (JSONæåå) ããã®ãŸãŸãã¡ã€ã³åã«
+type ContainerMessage struct {
+ Type string // "question" | "result" | "status"
+ Content string
+}
+
+type SubagentContainer struct {
+ inCh chan string // conductor â subagent (åç)
+ outCh chan ContainerMessage // subagent â conductor (質åã»çµæã»é²æ)
+ cancel context.CancelFunc
}
```
-ããŒã«åŒæ°ã¯ã¯ã€ã€äž `"arguments": "{\"key\":\"value\"}"` ã®åœ¢ã§å±ããããã®åå®çŸ©ã¯ãã®æååããã®ãŸãŸä¿æããã䜿ãåŽã¯æ¯å `json.Unmarshal([]byte(tc.Function.Arguments), &args)` ããªããã°ãªããããããã¹ããªãŒãã³ã°ã«ãŒãå
ã®éè€ Unmarshal ã®æ ¹æ¬åå ã«ãªã£ãŠããã
+spawn (async) 㯠outCh ãè¿ããŠå³ãªã¿ãŒã³ãsubagent (sync) ã¯ãã®å Žã§ result ãåŸ
ã€ã
-**察æ¯**: `ToolCall.Arguments map[string]any json:"-"` ãšããããŒã¹æžã¿ãã£ãŒã«ãã¯ååšããããopenai_compat ã® streaming path ã§ã¯ãã® `map[string]any` ãã£ãŒã«ãã§ã¯ãªã `Function.Arguments string` ããçŽæ¥èªãã§ãããäž¡æ¹ã®ãã£ãŒã«ããäžéå端ã«å
±åããŠããã
+**tasks map åé¡ã®è§£æ¶:** goroutine çµäºæã« `defer orchestrator.active.Delete(id)` + `defer close(outCh)` ã§èªå GCã
----
+### SubagentEnvironment (Context Injection)
-#### D-3. `ToolFunctionDefinition.Parameters` ã `map[string]any` ã§ãã·ãªã¢ã©ã€ãºæžã¿åœ¢åŒãä¿æã§ããªã
+conductor 㯠subagent ã«å¿
èŠãªã³ã³ããã¹ããæç€ºçã«æž¡ããMEMORY.md ããã®èªå泚å
¥ã§åé·ãªæåèšè¿°ãæé€ã
```go
-type ToolFunctionDefinition struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]any `json:"parameters"` // â ãããã€ããŒãžéããã³ã« Marshal ãå¿
èŠ
+type SubagentEnvironment struct {
+ // èªå泚å
¥ (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 // åç
§ãã¹ããã¡ã€ã«ãªã¹ã
}
```
-ããŒã«å®çŸ©ã¯ãšãŒãžã§ã³ãèµ·åæã«äžåºŠæ±ºãŸããå®è¡äžã¯å€åããªããããã `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
+spawn ãã©ã¡ãŒã¿äŸ:
+```json
+{
+ "task": "Phase 2 Step 1: implement the rate limiter",
+ "preset": "coder",
+ "context_files": ["pkg/ratelimit/ratelimit.go"],
+ "inject_plan_context": true
}
```
-`session.Messages` 㯠`append` ã§è¿œèšãããå¯å€ã¹ã©ã€ã¹ã§ãå€éšããåç
§ãæž¡ããšå
éšç¶æ
ãå£ãããªã¹ã¯ãããããã®ãã `GetHistory()`, `Save()`, `SetHistory()` ã®å
šãŠã§ã³ããŒãå¿
èŠã«ãªããã³ã¡ã³ãã«ããto strictly isolate internal state from the caller's sliceããšæèšãããŠãããããã¯æå³çãªèšèšã ãã³ããŒã³ã¹ããæ§é çã«åºå®ããŠããã
+### SandboxConfig
-**代æ¿èšèš**: ã¡ãã»ãŒãžãã°ã append-only ãªäžå€æ§é (`[]*Message` ã®ãªã³ã¯ãªã¹ãããã€ã³ããã¯ã¹ã§ç®¡çãããªã³ã°ãããã¡) ã«ããã°ãåç
§ã®å
±æãå®å
šã«ãªãã³ããŒãæé€ã§ããã
-
----
-
-#### D-6. `MemoryStore` ã®ã¡ãœããå¢çãããã¡ã€ã«æäœåäœãã§åãããŠãããåŒã³åºãåŽãåæã§ããªã
+ToolRegistry.Execute() ã®å
¥å£ã§äžæ¬ enforcementãsubagent ã¯æ®éã« tool call ããã€ããã§ééçã« sandboxed ã«ãªãã
```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))
+type SandboxConfig struct {
+ Preset string
+ WriteRoot string // write ç³»ããŒã«ã®ãã¹å¶é
+ AllowedTools map[string]bool
+ ExecPolicy *ExecPolicy // nil = exec äžå¯
+ SpawnablePresets []string // nil = spawn äžå¯
}
-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))
+type ExecPolicy struct {
+ AllowPattern string // å
é äžèŽ regex; ãããããã³ãã³ãã ãå®è¡å¯
}
```
-ã«ãŒãå
ã§æ¯ã€ãã¬ãŒã·ã§ã³è¡ãããåŠçã®ãã¡ã**å
¥åãå€ãããªããã®ãå«ãŸããŠããªãã**ãçããå
žåäŸïŒ
-- ã«ãŒãå
ã§ã® `json.Marshal` (åŒæ°ã宿°çãªãšã)
-- ã«ãŒãå
ã§ã® `string(rune)` 倿 (1æåãã€å€æ)
-- ã«ãŒãå
ã§ã®ã¹ã©ã€ã¹/ããããªãã©ã«çæ
+**ééçéé¢:** workDir = worktreeDir ãšããŠèšå®ããããšã§ãAI ã¯èªåãéé¢ãããŠããããšã«æ°ã¥ããã«æ¯ãèããpicoclaw åŽã§ CoW çã«ãã¡ã€ã«ãåŒãæž¡ããã
-**telegram.go ã® `wrapByDisplayWidth`ãopenai_compat ã® streaming ã«ãŒããcodex ã® tool å®çŸ©ã«ãŒãã§èгå¯ãããã**
+### Presets (5çš®)
-#### 4. ãé²è¡çã³ããŒãåºãããããã¿ãŒã³ (ã¹ã¬ããå®å
šã®éå°é©çš)
+| 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 ã®ã¿ |
+
+**æ§æ Œã®åé¡:**
+- **Exploratory** (scout/analyst): open-endedãèŠãŠããŠå ±åãclarifying ãã§ãŒãºãªãã
+- **Deliberate** (coder/worker/coordinator): ææç©ãäœããç®æšããããŸãã ãšå€±æãããclarifying ãã§ãŒãºããã
+
+**å¢ç:**
+- `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
-// ã«ããã®ããæžãæ¹
-func (m *Manager) GetHistory() []Message {
- m.mu.RLock()
- defer m.mu.RUnlock()
- result := make([]Message, len(m.messages))
- copy(result, m.messages) // â å
šä»¶ã³ããŒããŠããããã¯è§£é€
- return result
+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`,
}
```
-䞊è¡å®å
šã®ãã slice å
šäœãé²è¡çã«ã³ããŒããã®ã¯æ£ãããã**ã³ããŒç¯å²ãåŒã³åºãåŽã®å®éã®çšéããåºã**ããšããããèªã¿åãå°çšãªã `sync.RWMutex` + ãã€ã³ã¿è¿åŽ + immutable å¶çŽããŸã㯠Copy-on-Write ã§ä»£æ¿ã§ããå Žåãããã**session/manager.go ã® GetHistoryã»Save ã§èгå¯ãããã**
+### Subagent Plan Mode
-#### 5. ããã¡ã€ã«ãèªããã³ã«ããŒã¹ããã¿ãŒã³ (ã¹ããŒãã¬ã¹ãªç¹°ãè¿ãããŒã¹)
+Deliberate 㪠preset (coder/worker/coordinator) 㯠in-memory ã®ãã plan mode ãæã€ãMEMORY.md ã«ã¯äžåè§Šããªã (ãã¡ã€ã«åç
§ã»ç·šéãé¿ãããã)ã
```go
-// ã«ããã®ããæžãæ¹
-func GetPlanPhases(content string) []string {
- lines := strings.Split(content, "\n") // â åŒã³åºãæ¯ã«ãã«ã¹ãã£ã³
- ...
-}
-func MarkStep(content, step string) string {
- lines := strings.Split(content, "\n") // â åã content ãå床ã¹ãã£ã³
- ...
+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
}
```
-åäžã®ãã¡ã€ã«å
容ãåãåãè€æ°ã®é¢æ°ãããããç¬ç«ã㊠`strings.Split` â ã¹ãã£ã³ â `strings.Join` ããŠãããåŒã³åºãåŽã§ããŒã¹æžã¿è¡šçŸïŒè¡ã¹ã©ã€ã¹ãªã©ïŒãä¿æããŠæž¡ãããããŒã¹çµæããã£ãã·ã¥ããèšèšã«ãããšè€æ°åã®ã¢ãã±ãŒã·ã§ã³ãåæžã§ããã**memory.go ã®4颿°ã§èгå¯ãããã**
+SubagentContainer ããã£ãŒã«ããšããŠä¿æãgoroutine çµäºãšãšãã«æ¶ããã
-#### 6. ã`var x []T` ããå§ãŸã容éãªã appendããã¿ãŒã³
+**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 â
+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 you can continue
+- Correctness of next steps depends on the outcome
+
+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
-// ã«ããã®ããæžãæ¹
-var result []ModelConfig // cap=0 ããéå§
-for _, p := range providers {
- result = append(result, ...) // åã
ã«åã¢ãã±ãŒã·ã§ã³
+// 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
```
-`var x []T` ã `make([]T, 0)` ã§å§ãŸããã«ãŒãå
ã§ `append` ãéããã**ãœãŒã¹ã®é·ããäºåã«ããã£ãŠããå Žå**ïŒå¥ã¹ã©ã€ã¹ã® lenã宿°äžéãªã©ïŒã¯ `make([]T, 0, n)` ã§åæå®¹éãäžããã°åã¢ãã±ãŒã·ã§ã³ããŒãã«ã§ãããèŠèœãšãããããçç±ã¯ãappend ã¯èªåã§äŒžã³ããã倧äžå€«ããšããç¿æ
£ã**config/migration.goãskills/registry.goãskills/loader.go ã»ã6ç®æã§èгå¯ãããã**
+### Implementation Files (äºå®)
-#### 7. ãUnicode å®å
šã®ããã®éå°ãª []rune 倿ããã¿ãŒã³
+```
+pkg/tools/
+ 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 远å
+```
+
+### 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
-// ã«ããã®ããæžãæ¹
-func Truncate(s string, max int) string {
- runes := []rune(s) // â å
šæåã倿ããŠããé·ã確èª
- if len(runes) <= max {
- return s
- }
- return string(runes[:max])
+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
```
-æåæ°ãæ£ããæ°ããããã« `[]rune` ãžå€æããã®ã¯æ£ãããããã **â 倿åã« `len(s)` ã§ byte é·ããã§ãã¯ããŠæ©æ return ã§ãã**ïŒASCII ãªã byte é· == rune é·ïŒã**â¡å®éã®å
¥åã ASCII äž»äœã§ããã° `utf8.RuneCountInString` + `utf8.RuneError` ãã§ãã¯ã§ã¢ãã±ãŒã·ã§ã³ãªãã«åŠçã§ãã**ã`[]rune(s)` ã¯æååå
šäœãããŒãã«ã³ããŒãããããé·ãæååã§ã¯ç¡èŠã§ããªãã³ã¹ãã«ãªãã**utils/string.go ã®2颿°ãgit/worktree.go ã§èгå¯ãããã**
+`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
-> è¿œèš 2026-02-24ãmicroSDäžã§åäœããåæã§ã®FSæžãèŸŒã¿æé©åã
+> Reviewed 2026-02-24 on branch `memory-optimization-review`.
-### ã誰ããã®ããŒã¿ãå¿
èŠãšããããããã
+### èšèšã¬ãã«ã®æ ¹æ¬åå
-çŸç¶ã®æ°žç¶åããŒã¿ã**æ¶è²»è
**ãš**æžã蟌ã¿é »åºŠ**ã§æŽçãããšãæžã蟌ã¿ãé
å»¶ã§ããäœå°ã倧ããç°ãªãã
+#### D-1. MemoryStore ãããã¡ã€ã« = æ£ãã§ããŒã¹æžã¿è¡šçŸããã£ãã·ã¥ã§ããªã
-| ããŒã¿ | ããã»ã¹å
èªè
| ããã»ã¹å€èªè
| æžã蟌ã¿é »åºŠ(çŸç¶) | æå€±èš±å®¹åºŠ |
-|--------|--------------|--------------|-----------------|----------|
-| `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ïŒãã©ã³ãªãæïŒ | å€éšãšãã£ã¿ | æ¥æ¬¡ããŒãè¿œèšæïŒäœé »åºŠïŒ | äœ |
+`GetMemoryContext()` 1åã§ `ReadLongTerm()` ã5å以äžåŒã°ããé£éãMEMORY.md ãå€éšãšãã£ã¿ãçŽæ¥ç·šéã§ããèšèšäžãã€ã³ã¡ã¢ãªãã£ãã·ã¥ãèªç¶ã«å°å
¥ã§ããªãã
-### éèŠãªèгå¯: ã»ãã·ã§ã³ãã¡ã€ã«ã¯ããã»ã¹å
å°çšããŒã¿
+察ç: (a) content ãã¹ã¹ã«ãŒæ¹åŒ â é«ã¬ãã«ã¡ãœããã ãã1å ReadLongTerm() ãåŒã³ãcontent ã private ãã«ããŒã«æž¡ãã(b) `*ParsedPlan` åžžé§ â RAM ãæœ€æ²¢ãªã®ã§ MemoryStore ã«ããŒã¹æžã¿æ§é äœãæããããedit_file åŸã« `InvalidateCache()` ãåŒã¶ã
-`sessions/*.json` ã¯**皌åäžã«å€éšããã»ã¹ãèªãŸãªã**ãå¯äžã®å©çšã¿ã€ãã³ã°ã¯èµ·åæã® `loadSessions()`ãã€ãŸãæžã蟌ã¿ã®ç®çã¯ãã¯ã©ãã·ã¥ãªã«ããªãã ãã§ããã**ã¡ãã»ãŒãžæ¯ã®å³ææžã蟌ã¿ã¯éå°**ã
+#### D-2. `FunctionCall.Arguments` ã JSON æååã®ãŸãŸãã¡ã€ã³åã«
-åæ§ã« `state/stats.json` ããMini App ã CLI ã¯ããã»ã¹å
ã® `Tracker.GetStats()` çµç±ã§ã¡ã¢ãªããèªãããã¡ã€ã«ã¯ããã»ã¹åèµ·åæã®åŒãç¶ãå°çšã
+ã¹ããªãŒãã³ã°ã«ãŒãå
ã®éè€ Unmarshal ã®æ ¹æ¬åå ã`ToolCall.Arguments map[string]any` ã®ããŒã¹æžã¿ãã£ãŒã«ããååšãããäžéå端ã«å
±åããŠããã
-### æšå¥šæžãèŸŒã¿æŠç¥
+#### D-3. `ToolFunctionDefinition.Parameters` ã `map[string]any`
-#### sessions/*.json â Write-behind (ããŒãã£ãã©ã° + 宿ãã©ãã·ã¥)
+ãããã€ããŒãžéããã³ã« Marshal ãå¿
èŠã`json.RawMessage` ã«ããã°äžåºŠã® marshal ã§æžãã
-```
-AddFullMessage() â in-memory ã®ã¿æŽæ°ãdirty ãã©ã°ç«ãŠ
- â
- 宿ã¿ã€ã㌠(5å) or ã¡ãã»ãŒãžæ°éŸå€ (20ä»¶)
- ãŸãã¯ã·ã£ããããŠã³ãã㯠â Save()
-```
+#### D-4. æ€çŽ¢ãããã€ããŒã«å
±éãã©ãŒãããæœè±¡ããªã
-- ãªã«ããªãŠã£ã³ããŠ: æå€§5å or 20ã¡ãã»ãŒãžå
-- æžã蟌ã¿åæ°åæžç: äŒè©±é床次第ã ã **10ã50å**
-- å®è£
: `SessionManager` ã« `dirtyKeys map[string]bool` + ããã¯ã°ã©ãŠã³ããã©ãã·ã£ãŒgoroutine
+`[]string + strings.Join` ãã¿ãŒã³ã3ç®æã«è€è£œã`Search()` æ»ãå€ã `string` ã§ãªãæ§é äœã«ããã°1ç®æã§æžãã
-#### state/stats.json â 宿ãã©ãã·ã¥ã®ã¿
+#### D-5. `Session.Messages` ãå¯å€ã¹ã©ã€ã¹ã§å
šã³ããŒãå¿
èŠ
-```
-RecordUsage() / RecordPrompt() â in-memory ã®ã¿æŽæ°
- â
- 宿ã¿ã€ã㌠(5å) â save()
- + ã·ã£ããããŠã³ããã¯
-```
+`GetHistory()` / `Save()` ã§ã®é²è¡çã³ããŒã¯æå³çèšèšãCOW ãŸã㯠append-only immutable æ§é ã§è§£æ¶ã§ããã
-- æå€±ãªã¹ã¯: æå€§5ååã®çµ±èšã«ãŠã³ãïŒèš±å®¹ç¯å²ïŒ
-- æžã蟌ã¿åæ°åæžç: **LLMåŒã³åºãé »åºŠ à 5å** = æ°åãæ°çŸå
+#### D-6. `MemoryStore` ã®ã¡ãœããå¢çãããã¡ã€ã«æäœåäœã
-#### memory/MEMORY.md â ã¿ãŒã³ã¹ã³ãŒããã£ãã·ã¥ (æžã蟌ã¿ã¯å³æç¶æ)
+åŒã³åºãåŽã¯è€æ°ã®å€ãå¿
èŠã§ãè€æ°ååŒã¶ãããªããD-1 ã®è§£æ±ºç (ParsedPlan åžžé§) ãšåãããŠè§£æ¶ã
-æžã蟌ã¿ã¯çŸç¶éã峿ãèªã¿åãã®åé¡ã ã解決ããã
+### ã³ãŒãã®åã â ãã§ãã¯ãªã¹ã
-```
-ãšãŒãžã§ã³ãã¿ãŒã³éå§ â content := ReadLongTerm() ã1åã ã
- â content ãåŒæ°ãšããŠå
šãã«ããŒã«æž¡ã
- (HasActivePlan(content), GetPlanStatus(content), ...)
-ãšãŒãžã§ã³ãã¿ãŒã³çµäº â content ãã£ãã·ã¥ç Žæ£
-```
+æ°ããã³ãŒããæžããšãã»ã¬ãã¥ãŒãããšãã®ç¢ºèªäºé
:
-- å€éšãšãã£ã¿ãšã®æŽå: ã¿ãŒã³å¢çã§ãªãã¬ãã·ã¥ãããã®ã§1ã¿ãŒã³ä»¥å
ã®å€éšç·šéã®ã¿èŠéãïŒèš±å®¹ç¯å²ïŒ
-- LLM ã® edit_file çµç±ã®æžã蟌ã¿: ãã¡ã€ã«ã·ã¹ãã ã«å³åº§ã«æžãããããæ¬¡ã¿ãŒã³ã§èªååæ
-- èªã¿åãåæ°åæž: 1ã¿ãŒã³ããã `5åä»¥äž â 1å`
+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 ãå
ã«
-### microSD 寿åœãžã®åœ±é¿è©Šç®
+### ã¹ãã¬ãŒãžä¿è·èšèš (microSD 寿åœ)
-äžè¬çãªäŒè©±ã»ãã·ã§ã³ïŒ1æéã60ã¡ãã»ãŒãžã10 LLMåŒã³åºã/åïŒã®å Žå:
+| ããŒã¿ | çŸç¶ | æšå¥šæŠç¥ | åæžç |
+|---|---|---|---|
+| `sessions/*.json` | ã¡ãã»ãŒãžæ¯æžã蟌㿠| write-behind (dirty flag + 5åã¿ã€ããŒ) | 80% |
+| `state/stats.json` | LLMåŒã³åºãæ¯ | 宿ãã©ãã·ã¥ã®ã¿ (5å) | 98% |
+| `memory/MEMORY.md` | 峿 (å€ããªã) | ã¿ãŒã³ã¹ã³ãŒããã£ãã·ã¥ (èªã¿åãã®ã¿æé©å) | â |
-| ããŒã¿ | çŸç¶ã®æžã蟌ã¿åæ°/æ | æ¹ååŸ | åæžç |
-|--------|-------------------|--------|-------|
-| 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 ãå®è£
ãããã°èªç¶ã«è§£æ±ºããããäžèŠã«ãªãå¯èœæ§ãã
+**å®è£
ãã€ã³ã:**
+- `SessionManager` ã« `dirtyKeys map[string]bool` + ããã¯ã°ã©ãŠã³ããã©ãã·ã£ãŒ goroutine
+- `stats.Tracker` ã« `Close()` ã¡ãœãã远å (ã¿ã€ããŒåæ¢ + æçµ save)
+- SIGTERM/SIGINT ã§ã·ã£ããããŠã³ããã¯å¿
é
+- MEMORY.md ã® edit_file æžã蟌ã¿åŸã«ã¿ãŒã³ãã£ãã·ã¥ãç¡å¹å (`InvalidateCache()`)
---
-## æ¹ä¿®èšç» â ã¡ã¢ãªæé©åã®å®è£
ãã§ãŒãº
+## Session Management (Future)
-> äœæ 2026-02-24ãã¬ãã¥ãŒçµæ (AãH + D-1ãD-6 + ã¹ãã¬ãŒãžä¿è·) ãå®è£
å¯èœãªåäœã«åå²ã
-> åãã§ãŒãºã¯ `go build ./... && go test ./... && go vet ./...` ãéãç¶æ
ã§å®çµããã
+çŸç¶ã®èšèšã¯ãæ£ç¢ºæ§ãã¯æçããŠããããã©ã€ããµã€ã¯ã«ããæ¬ èœããŠããã
-### ãã§ãŒãº 0: æ©æ¢°çãªçœ®ãæã (äœãªã¹ã¯ã»é«ã«ãã¬ããž)
+**è¿æ:**
+- `SessionManager.Delete(key)` + TTL ãšãã¯ã·ã§ã³
+- `sessionLocks sync.Map` (loop.go) ã® GC
+- èµ·åæã® `loadSessions()` ãé
å»¶ããŒãå
-**ç®ç**: ã³ãŒãæ§é ãå€ãããåã颿°å
ã§ãã¿ãŒã³ã眮ãæããã ãã®ä¿®æ£ãã¬ãã¥ãŒã容æã§ååž°ãªã¹ã¯ãæå°ã
+**äžæ:**
+- ãã§ãã¯ãã€ã³ã / ããŒã«ãã㯠(`Session.Messages` ã append-only immutable ã«)
+- ååä»ãã»ãã·ã§ã³ (`/new-session`, `/switch-session`, `/list-sessions`)
-#### 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` (ç¥èäžå¿) ãçŸç¶å
±åããŠãããã©ã¡ãã䞻軞ã«ãããã§çºå±æ¹åãå€ããã
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/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/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..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)
@@ -216,15 +257,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, al.reporter())
+ 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)
@@ -713,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()
@@ -1785,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++ {
@@ -2134,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/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/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 {
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 6f1590d04..c2887d918 100644
--- a/pkg/miniapp/miniapp.go
+++ b/pkg/miniapp/miniapp.go
@@ -1,217 +1,27 @@
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/skills"
- "github.com/sipeed/picoclaw/pkg/stats"
+ "github.com/sipeed/picoclaw/pkg/orch"
)
//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
- 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
@@ -228,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{
@@ -270,259 +51,10 @@ 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) {
+ h.orchBroadcaster = b
}
// RegisterRoutes registers Mini App routes on the given mux.
@@ -541,6 +73,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)
}
@@ -554,647 +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
- }
- }
-}
-
-// 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/static/map-preview.html b/pkg/miniapp/static/map-preview.html
new file mode 100644
index 000000000..5cadcae5d
--- /dev/null
+++ b/pkg/miniapp/static/map-preview.html
@@ -0,0 +1,458 @@
+
+
+
+
+
+ Orchestration Room
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ demo: idle
+ ⬡ fast = toolcall
+ ⬡ slow = llm wait
+
+
+
+
+
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
+}
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
+ }
+ }
+}
diff --git a/pkg/orch/broadcaster.go b/pkg/orch/broadcaster.go
new file mode 100644
index 000000000..70fff1881
--- /dev/null
+++ b/pkg/orch/broadcaster.go
@@ -0,0 +1,143 @@
+// 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
+}
+
+// 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 {
+ 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/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)
+ }
+}
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/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.go b/pkg/tools/subagent.go
index 91ebff636..3956cf0fd 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,13 +37,18 @@ type SubagentManager struct {
hasMaxTokens bool
hasTemperature bool
nextID int
+ 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,
@@ -52,6 +58,7 @@ func NewSubagentManager(
tools: NewToolRegistry(),
maxIterations: 10,
nextID: 1,
+ reporter: reporter,
}
}
@@ -103,6 +110,8 @@ func (sm *SubagentManager) Spawn(
}
sm.tasks[taskID] = subagentTask
+ sm.reporter.ReportSpawn(taskID, label, task)
+
// Start task in background with context cancellation support
go sm.runTask(ctx, subagentTask, callback)
@@ -114,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.
@@ -164,12 +172,17 @@ After completing the task, provide a clear summary of what was done.`
}
}
+ // Notify conductor that the subagent is starting
+ sm.reporter.ReportConversation("conductor", task.ID, task.Task)
+
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
+ Reporter: sm.reporter,
+ AgentID: task.ID,
}, messages, task.OriginChannel, task.OriginChatID)
sm.mu.Lock()
@@ -186,10 +199,13 @@ 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.reporter.ReportGC(task.ID, gcReason)
result = &ToolResult{
ForLLM: task.Result,
ForUser: "",
@@ -201,6 +217,9 @@ 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.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_reporter_test.go b/pkg/tools/subagent_reporter_test.go
new file mode 100644
index 000000000..aaf3bd7a4
--- /dev/null
+++ b/pkg/tools/subagent_reporter_test.go
@@ -0,0 +1,243 @@
+package tools
+
+import (
+ "context"
+ "sync/atomic"
+ "testing"
+ "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:
+//
+// 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")
+ }
+ }
+}
+
+// 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)
+ }
+}
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 cdfe0d6ce..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,6 +24,12 @@ type ToolLoopConfig struct {
Tools *ToolRegistry
MaxIterations int
LLMOptions map[string]any
+ // 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.
@@ -39,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
@@ -62,7 +74,8 @@ func RunToolLoop(
if llmOpts == nil {
llmOpts = map[string]any{}
}
- // 3. Call LLM
+ // 3. Call LLM (hook: waiting for response)
+ 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",
@@ -121,7 +134,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 +143,7 @@ func RunToolLoop(
"tool": tc.Name,
"iteration": iteration,
})
+ reporter.ReportStateChange(config.AgentID, "toolcall", tc.Name)
// Execute tool (no async callback for subagents - they run independently)
var toolResult *ToolResult
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
+}