From 047a9bb835a9227a808e5a4637e9235e7aab790b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?=
Date: Fri, 13 Mar 2026 01:41:27 +0800
Subject: [PATCH 1/5] fix(skill): tighten weather location matching guidance
---
workspace/skills/weather/SKILL.md | 54 ++++++++++++++++++-------------
1 file changed, 32 insertions(+), 22 deletions(-)
diff --git a/workspace/skills/weather/SKILL.md b/workspace/skills/weather/SKILL.md
index 8073de192..aa90a9b20 100644
--- a/workspace/skills/weather/SKILL.md
+++ b/workspace/skills/weather/SKILL.md
@@ -1,49 +1,59 @@
---
name: weather
-description: Get current weather and forecasts (no API key required).
+description: Get current weather and forecasts with verified location matching (no API key required).
homepage: https://wttr.in/:help
metadata: {"nanobot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
---
# Weather
-Two free services, no API keys needed.
+Use the most reliable location match first. For Chinese city names or other non-Latin input, prefer `wttr.in` with the original query because it resolves native names directly. Use Open-Meteo for structured current conditions and forecasts only after you have confirmed the exact city.
-## wttr.in (primary)
+## Accuracy Rules
-Quick one-liner:
+- Always restate the matched location, region/country, and observation time in the final answer.
+- Do not trust the first geocoding hit blindly. Check `country`, `admin1`, `admin2`, and `population`.
+- For Chinese city queries, do not send Hanzi directly to Open-Meteo geocoding unless the top result is obviously correct. Prefer `wttr.in` with the original Chinese name, or geocode the English/pinyin city name instead.
+- If multiple plausible matches remain, ask a follow-up question or state the assumption clearly.
+- Use `timezone=auto` when calling Open-Meteo so the reported time matches the location.
+
+## wttr.in (best for direct city-name queries)
+
+Quick current conditions:
```bash
-curl -s "wttr.in/London?format=3"
-# Output: London: ⛅️ +8°C
+curl -s "https://wttr.in/London?format=%l:+%c+%t+%h+%w"
```
-Compact format:
+Chinese city example:
```bash
-curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
-# Output: London: ⛅️ +8°C 71% ↙5km/h
+curl -s "https://wttr.in/%E6%88%90%E9%83%BD?format=%l:+%c+%t+%h+%w"
+curl -s "https://wttr.in/%E4%B8%8A%E6%B5%B7?format=%l:+%c+%t+%h+%w"
```
-Full forecast:
+JSON output if you need more detail:
```bash
-curl -s "wttr.in/London?T"
+curl -s "https://wttr.in/Chengdu?format=j1"
```
-Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
-
Tips:
-- URL-encode spaces: `wttr.in/New+York`
-- Airport codes: `wttr.in/JFK`
-- Units: `?m` (metric) `?u` (USCS)
-- Today only: `?1` · Current only: `?0`
-- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
+- URL-encode spaces: `New York` -> `New+York`
+- URL-encode non-ASCII text before sending the request
+- Use `?m` for metric units and `?u` for US units
-## Open-Meteo (fallback, JSON)
+## Open-Meteo (best for structured forecasts)
-Free, no key, good for programmatic use:
+1. Geocode the city and verify the returned location metadata:
```bash
-curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true"
+curl -s "https://geocoding-api.open-meteo.com/v1/search?name=Chengdu&count=3&language=en&format=json"
```
-Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode.
+2. Query current weather and today's forecast with the verified coordinates:
+```bash
+curl -s "https://api.open-meteo.com/v1/forecast?latitude=30.66667&longitude=104.06667¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=1&timezone=auto"
+```
+
+Important:
+- For Chinese inputs like `成都`, geocoding `name=%E6%88%90%E9%83%BD` may return smaller homonym locations first. Prefer `Chengdu` after verifying it matches Sichuan, China.
+- If geocoding looks suspicious, fall back to `wttr.in` for the original city name instead of presenting a likely wrong result.
Docs: https://open-meteo.com/en/docs
From 6b72326be1e586ba1229b1b5128674e8e4687183 Mon Sep 17 00:00:00 2001
From: Hakancan <142545736+hkc5@users.noreply.github.com>
Date: Fri, 13 Mar 2026 09:16:05 +0000
Subject: [PATCH 2/5] fix: safety guard incorrectly blocks commands with URLs
(#1254)
* fix: safety guard incorrectly blocks commands with URLs
The absolutePathPattern regex was matching URL path components like
//github.com as file system paths, causing commands containing URLs
to be incorrectly blocked by the workspace restriction safety guard.
For example, 'agent-browser open https://github.com' would be blocked
because //github.com was treated as an absolute file path outside
the working directory.
The fix adds a check to skip any path match that starts with '//',
as these are URL path components, not file system paths.
Fixes #1203
* fix: handle file:// URIs correctly in safety guard
The previous fix skipped all paths starting with '//', which incorrectly
also skipped file:// URIs that could escape the workspace sandbox.
Changes:
- Only skip '//' paths when preceded by web URL schemes (http:, https:, ftp:, etc.)
- file:// URIs are now properly checked against workspace boundaries
- Added TestShellTool_FileURISandboxing to verify the fix
Fixes security issue raised by @alexhoshina in PR #1254
* style: fix gofumpt formatting
* fix(safety-guard): use exact match position to prevent URL exemption bypass
Using strings.Index(cmd, raw) always returned the first occurrence of the
matched substring, allowing a bypass where the same //path appeared both
inside a URL and as a standalone shell path (e.g. echo https://etc/passwd
&& cat //etc/passwd would skip the second match).
Switch to FindAllStringIndex so each match is evaluated at its actual
position in the command string.
Adds TestShellTool_URLBypassPrevented to cover the exploit scenario.
---
pkg/tools/shell.go | 32 +++++++++++++-
pkg/tools/shell_test.go | 98 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 128 insertions(+), 2 deletions(-)
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 67e2ad257..9ea05bb12 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -373,9 +373,37 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return ""
}
- matches := absolutePathPattern.FindAllString(cmd, -1)
+ // Web URL schemes whose path components (starting with //) should be exempt
+ // from workspace sandbox checks. file: is intentionally excluded so that
+ // file:// URIs are still validated against the workspace boundary.
+ webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"}
+
+ matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1)
+
+ for _, loc := range matchIndices {
+ raw := cmd[loc[0]:loc[1]]
+
+ // Skip URL path components that look like they're from web URLs.
+ // When a URL like "https://github.com" is parsed, the regex captures
+ // "//github.com" as a match (the path portion after "https:").
+ // Use the exact match position (loc[0]) so that duplicate //path substrings
+ // in the same command are each evaluated at their own position.
+ if strings.HasPrefix(raw, "//") && loc[0] > 0 {
+ before := cmd[:loc[0]]
+ isWebURL := false
+
+ for _, scheme := range webSchemes {
+ if strings.HasSuffix(before, scheme) {
+ isWebURL = true
+ break
+ }
+ }
+
+ if isWebURL {
+ continue
+ }
+ }
- for _, raw := range matches {
p, err := filepath.Abs(raw)
if err != nil {
continue
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index 90265e5bd..c4553020f 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -522,3 +522,101 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) {
t.Errorf("'git push upstream main' should still be blocked by deny pattern")
}
}
+
+// TestShellTool_URLsNotBlocked verifies that commands containing URLs are not
+// incorrectly blocked by the workspace restriction safety guard (issue #1203).
+func TestShellTool_URLsNotBlocked(t *testing.T) {
+ tmpDir := t.TempDir()
+ tool, err := NewExecTool(tmpDir, true)
+ if err != nil {
+ t.Fatalf("unable to configure exec tool: %s", err)
+ }
+
+ // These commands contain URLs and should NOT be blocked by workspace restriction.
+ // The URL path components (e.g., "//github.com") should be recognized as URLs,
+ // not as file system paths.
+ commands := []string{
+ "agent-browser open https://github.com",
+ "curl https://api.example.com/data",
+ "wget http://example.com/file",
+ "browser open https://github.com/user/repo",
+ "fetch ftp://ftp.example.com/file.txt",
+ "git clone https://github.com/sipeed/picoclaw.git",
+ }
+
+ for _, cmd := range commands {
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
+ t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
+ }
+ }
+}
+
+// TestShellTool_FileURISandboxing verifies that file:// URIs that escape the
+// workspace are still blocked, even though other URLs are allowed (issue #1254).
+func TestShellTool_FileURISandboxing(t *testing.T) {
+ tmpDir := t.TempDir()
+ tool, err := NewExecTool(tmpDir, true)
+ if err != nil {
+ t.Fatalf("unable to configure exec tool: %s", err)
+ }
+
+ // These file:// URIs should be blocked if they reference paths outside the workspace.
+ // Unlike web URLs (http://, https://, ftp://), file:// URIs can be used to escape the sandbox.
+ blockedCommands := []string{
+ "cat file:///etc/passwd",
+ "cat file:///etc/hosts",
+ "cat file:///root/.ssh/id_rsa",
+ }
+
+ for _, cmd := range blockedCommands {
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
+ t.Errorf("file:// URI outside workspace should be blocked: %s", cmd)
+ }
+ }
+
+ // These file:// URIs should be allowed if they reference paths inside the workspace.
+ // Create a test file inside the temp directory
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
+ t.Fatalf("failed to create test file: %s", err)
+ }
+
+ allowedCommands := []string{
+ "cat file://" + testFile,
+ }
+
+ for _, cmd := range allowedCommands {
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
+ t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM)
+ }
+ }
+}
+
+// TestShellTool_URLBypassPrevented verifies that a command cannot bypass the workspace
+// sandbox by smuggling a real path after a URL that contains the same //path substring.
+// e.g. "echo https://etc/passwd && cat //etc/passwd" must still be blocked.
+func TestShellTool_URLBypassPrevented(t *testing.T) {
+ tmpDir := t.TempDir()
+ tool, err := NewExecTool(tmpDir, true)
+ if err != nil {
+ t.Fatalf("unable to configure exec tool: %s", err)
+ }
+
+ // The path //etc/passwd appears twice: once as the host part of an https URL
+ // and once as a real (escaped) absolute path. The guard must block the command
+ // because the second occurrence is a genuine out-of-workspace path.
+ blockedCommands := []string{
+ "echo https://etc/passwd && cat //etc/passwd",
+ "curl https://host/file && ls //etc",
+ }
+
+ for _, cmd := range blockedCommands {
+ result := tool.Execute(context.Background(), map[string]any{"command": cmd})
+ if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
+ t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM)
+ }
+ }
+}
From c69c48ad464db5a6d17e9d8025726926df4171f1 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 13 Mar 2026 17:58:20 +0800
Subject: [PATCH 3/5] refactor(web): split gateway logs out of the status
endpoint (#1504)
- add a dedicated /api/gateway/logs endpoint for incremental log polling
- keep /api/gateway/status focused on runtime and health data only
- update frontend log fetching to use the new API and add backend tests covering the status/logs separation and cleared-log behavior
---
web/backend/api/gateway.go | 32 ++++---
web/backend/api/gateway_test.go | 100 ++++++++++++++++++---
web/frontend/src/api/gateway.ts | 21 +++--
web/frontend/src/hooks/use-gateway-logs.ts | 4 +-
4 files changed, 126 insertions(+), 31 deletions(-)
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index 95b482ce0..1813cac92 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -52,6 +52,7 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
+ mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs)
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs)
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
@@ -560,16 +561,16 @@ func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request)
})
}
-// handleGatewayStatus returns the gateway run status, health info, and logs.
+// handleGatewayStatus returns the gateway run status and health info.
//
// GET /api/gateway/status
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
- data := h.gatewayStatusData(r, true)
+ data := h.gatewayStatusData()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
-func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[string]any {
+func (h *Handler) gatewayStatusData() map[string]any {
data := map[string]any{}
cfg, cfgErr := config.LoadConfig(h.configPath)
configDefaultModel := ""
@@ -661,16 +662,22 @@ func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[strin
}
}
- if includeLogs {
- appendGatewayLogs(r, data)
- }
-
return data
}
-// appendGatewayLogs reads log_offset and log_run_id query params from the request
-// and populates the response data map with incremental log lines.
-func appendGatewayLogs(r *http.Request, data map[string]any) {
+// handleGatewayLogs returns buffered gateway logs, optionally incrementally.
+//
+// GET /api/gateway/logs
+func (h *Handler) handleGatewayLogs(w http.ResponseWriter, r *http.Request) {
+ data := gatewayLogsData(r)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
+
+// gatewayLogsData reads log_offset and log_run_id query params from the request
+// and returns incremental log lines.
+func gatewayLogsData(r *http.Request) map[string]any {
+ data := map[string]any{}
clientOffset := 0
clientRunID := -1
@@ -692,7 +699,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
data["logs"] = []string{}
data["log_total"] = 0
data["log_run_id"] = 0
- return
+ return data
}
// If runID changed, reset offset to get all logs from new run
@@ -709,6 +716,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
data["logs"] = lines
data["log_total"] = total
data["log_run_id"] = runID
+ return data
}
// handleGatewayEvents serves an SSE stream of gateway state change events.
@@ -751,7 +759,7 @@ func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
// currentGatewayStatus returns the current gateway status as a JSON string.
func (h *Handler) currentGatewayStatus() string {
- data := h.gatewayStatusData(nil, false)
+ data := h.gatewayStatusData()
encoded, _ := json.Marshal(data)
return string(encoded)
}
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index fe3fccdee..06803722d 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -707,6 +707,79 @@ func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.
}
}
+func TestGatewayStatusExcludesLogsFields(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ if _, ok := body["logs"]; ok {
+ t.Fatalf("logs unexpectedly present in status response: %#v", body["logs"])
+ }
+ if _, ok := body["log_total"]; ok {
+ t.Fatalf("log_total unexpectedly present in status response: %#v", body["log_total"])
+ }
+ if _, ok := body["log_run_id"]; ok {
+ t.Fatalf("log_run_id unexpectedly present in status response: %#v", body["log_run_id"])
+ }
+}
+
+func TestGatewayLogsReturnsIncrementalHistory(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ gateway.logs.Clear()
+ gateway.logs.Append("first line")
+ gateway.logs.Append("second line")
+ runID := gateway.logs.RunID()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/api/gateway/logs?log_offset=1&log_run_id="+strconv.Itoa(runID),
+ nil,
+ )
+ mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("logs status = %d, want %d", rec.Code, http.StatusOK)
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal logs response: %v", err)
+ }
+
+ logs, ok := body["logs"].([]any)
+ if !ok {
+ t.Fatalf("logs missing or not array: %#v", body["logs"])
+ }
+ if len(logs) != 1 || logs[0] != "second line" {
+ t.Fatalf("logs = %#v, want [\"second line\"]", logs)
+ }
+ if got := body["log_total"]; got != float64(2) {
+ t.Fatalf("log_total = %#v, want 2", got)
+ }
+ if got := body["log_run_id"]; got != float64(runID) {
+ t.Fatalf("log_run_id = %#v, want %d", got, runID)
+ }
+}
+
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@@ -743,33 +816,36 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
}
- statusRec := httptest.NewRecorder()
- statusReq := httptest.NewRequest(
+ logsRec := httptest.NewRecorder()
+ logsReq := httptest.NewRequest(
http.MethodGet,
- "/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
+ "/api/gateway/logs?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
nil,
)
- mux.ServeHTTP(statusRec, statusReq)
+ mux.ServeHTTP(logsRec, logsReq)
- if statusRec.Code != http.StatusOK {
- t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK)
+ if logsRec.Code != http.StatusOK {
+ t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK)
}
- var statusBody map[string]any
- if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil {
- t.Fatalf("unmarshal status response: %v", err)
+ var logsBody map[string]any
+ if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {
+ t.Fatalf("unmarshal logs response: %v", err)
}
- logs, ok := statusBody["logs"].([]any)
+ logs, ok := logsBody["logs"].([]any)
if !ok {
- t.Fatalf("logs missing or not array: %#v", statusBody["logs"])
+ t.Fatalf("logs missing or not array: %#v", logsBody["logs"])
}
if len(logs) != 0 {
t.Fatalf("logs len = %d, want 0", len(logs))
}
- if got := statusBody["log_total"]; got != float64(0) {
+ if got := logsBody["log_total"]; got != float64(0) {
t.Fatalf("log_total = %#v, want 0", got)
}
+ if got := logsBody["log_run_id"]; got != clearBody["log_run_id"] {
+ t.Fatalf("log_run_id = %#v, want %#v", got, clearBody["log_run_id"])
+ }
}
func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts
index 1688a5278..9e02a02b5 100644
--- a/web/frontend/src/api/gateway.ts
+++ b/web/frontend/src/api/gateway.ts
@@ -8,10 +8,13 @@ interface GatewayStatusResponse {
pid?: number
boot_default_model?: string
config_default_model?: string
+ [key: string]: unknown
+}
+
+interface GatewayLogsResponse {
logs?: string[]
log_total?: number
log_run_id?: number
- [key: string]: unknown
}
interface GatewayActionResponse {
@@ -31,10 +34,14 @@ async function request(path: string, options?: RequestInit): Promise {
return res.json() as Promise
}
-export async function getGatewayStatus(options?: {
+export async function getGatewayStatus(): Promise {
+ return request("/api/gateway/status")
+}
+
+export async function getGatewayLogs(options?: {
log_offset?: number
log_run_id?: number
-}): Promise {
+}): Promise {
const params = new URLSearchParams()
if (options?.log_offset !== undefined) {
params.set("log_offset", options.log_offset.toString())
@@ -43,7 +50,7 @@ export async function getGatewayStatus(options?: {
params.set("log_run_id", options.log_run_id.toString())
}
const queryString = params.toString() ? `?${params.toString()}` : ""
- return request(`/api/gateway/status${queryString}`)
+ return request(`/api/gateway/logs${queryString}`)
}
export async function startGateway(): Promise {
@@ -70,4 +77,8 @@ export async function clearGatewayLogs(): Promise {
})
}
-export type { GatewayStatusResponse, GatewayActionResponse }
+export type {
+ GatewayStatusResponse,
+ GatewayLogsResponse,
+ GatewayActionResponse,
+}
diff --git a/web/frontend/src/hooks/use-gateway-logs.ts b/web/frontend/src/hooks/use-gateway-logs.ts
index 593e90b26..15cbca4ae 100644
--- a/web/frontend/src/hooks/use-gateway-logs.ts
+++ b/web/frontend/src/hooks/use-gateway-logs.ts
@@ -1,7 +1,7 @@
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
-import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
+import { clearGatewayLogs, getGatewayLogs } from "@/api/gateway"
import { gatewayAtom } from "@/store/gateway"
export function useGatewayLogs() {
@@ -49,7 +49,7 @@ export function useGatewayLogs() {
const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current
- const data = await getGatewayStatus({
+ const data = await getGatewayLogs({
log_offset: requestOffset,
log_run_id: requestRunId,
})
From 2f83c185ae5338cba10df19799203e85f86dd956 Mon Sep 17 00:00:00 2001
From: lxowalle <83055338+lxowalle@users.noreply.github.com>
Date: Fri, 13 Mar 2026 17:58:34 +0800
Subject: [PATCH 4/5] Fix the issue where the cursor moves inaccurately left
and right after entering Chinese when running the picoclaw agent. (#1505)
---
cmd/picoclaw/internal/agent/helpers.go | 2 +-
go.mod | 6 +++---
go.sum | 9 ++-------
3 files changed, 6 insertions(+), 11 deletions(-)
diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go
index a995945d2..c3ddbb77f 100644
--- a/cmd/picoclaw/internal/agent/helpers.go
+++ b/cmd/picoclaw/internal/agent/helpers.go
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
- "github.com/chzyer/readline"
+ "github.com/ergochat/readline"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
diff --git a/go.mod b/go.mod
index 3762015e9..f29ef7207 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +7,11 @@ require (
github.com/anthropics/anthropic-sdk-go v1.22.1
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1
- github.com/chzyer/readline v1.5.1
github.com/ergochat/irc-go v0.5.0
+ github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
- github.com/google/uuid v1.6.0
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
+ github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
@@ -30,6 +30,7 @@ require (
golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
+ gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.46.1
)
@@ -60,7 +61,6 @@ require (
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index cdca4fc12..addbab56c 100644
--- a/go.sum
+++ b/go.sum
@@ -27,12 +27,6 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
-github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
-github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
-github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
-github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
-github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
@@ -50,6 +44,8 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
+github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
+github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
@@ -297,7 +293,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
From 4d8fdb0b3d35d0aa2cdf223a92eaa71ce117f2d3 Mon Sep 17 00:00:00 2001
From: wenjie
Date: Fri, 13 Mar 2026 19:04:18 +0800
Subject: [PATCH 5/5] feat(web): use a global WebSocket for Pico chat sessions
(#1507)
- centralize Pico chat connection and session state in a shared store
- move chat lifecycle control out of usePicoChat
- hydrate and restore the active session across the app
---
.../src/components/chat/chat-page.tsx | 5 +
web/frontend/src/hooks/use-pico-chat.ts | 442 +-----------------
web/frontend/src/lib/pico-chat-controller.ts | 405 ++++++++++++++++
web/frontend/src/lib/pico-chat-state.ts | 59 +++
web/frontend/src/routes/__root.tsx | 6 +
web/frontend/src/store/chat.ts | 62 +++
web/frontend/src/store/index.ts | 1 +
7 files changed, 549 insertions(+), 431 deletions(-)
create mode 100644 web/frontend/src/lib/pico-chat-controller.ts
create mode 100644 web/frontend/src/lib/pico-chat-state.ts
create mode 100644 web/frontend/src/store/chat.ts
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx
index 2daeb2e26..1906a0367 100644
--- a/web/frontend/src/components/chat/chat-page.tsx
+++ b/web/frontend/src/components/chat/chat-page.tsx
@@ -15,6 +15,7 @@ import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat"
import { useSessionHistory } from "@/hooks/use-session-history"
+import { hydrateActiveSession } from "@/lib/pico-chat-controller"
export function ChatPage() {
const { t } = useTranslation()
@@ -67,6 +68,10 @@ export function ChatPage() {
syncScrollState(e.currentTarget)
}
+ useEffect(() => {
+ void hydrateActiveSession()
+ }, [])
+
useEffect(() => {
if (scrollRef.current) {
if (isAtBottom) {
diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts
index 2b7a510af..1b97a2a9c 100644
--- a/web/frontend/src/hooks/use-pico-chat.ts
+++ b/web/frontend/src/hooks/use-pico-chat.ts
@@ -1,79 +1,12 @@
import dayjs from "dayjs"
import { useAtomValue } from "jotai"
+
import {
- type SetStateAction,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from "react"
-import { useTranslation } from "react-i18next"
-import { toast } from "sonner"
-
-import { getPicoToken } from "@/api/pico"
-import { getSessionHistory } from "@/api/sessions"
-import { gatewayAtom } from "@/store"
-
-// Pico Protocol message types
-interface PicoMessage {
- type: string
- id?: string
- session_id?: string
- timestamp?: number | string
- payload?: Record
-}
-
-export interface ChatMessage {
- id: string
- role: "user" | "assistant"
- content: string
- timestamp: number | string
-}
-
-type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
-
-const LAST_SESSION_STORAGE_KEY = "picoclaw:last-session-id"
-
-function readStoredSessionId(): string {
- const value = localStorage.getItem(LAST_SESSION_STORAGE_KEY)?.trim()
- return value || ""
-}
-
-function writeStoredSessionId(sessionId: string) {
- if (sessionId) {
- localStorage.setItem(LAST_SESSION_STORAGE_KEY, sessionId)
- return
- }
-
- localStorage.removeItem(LAST_SESSION_STORAGE_KEY)
-}
-
-function generateSessionId(): string {
- const webCrypto = globalThis.crypto
- if (webCrypto && typeof webCrypto.randomUUID === "function") {
- return webCrypto.randomUUID()
- }
-
- if (webCrypto && typeof webCrypto.getRandomValues === "function") {
- const bytes = new Uint8Array(16)
- webCrypto.getRandomValues(bytes)
-
- // RFC4122 v4: set version and variant bits.
- bytes[6] = (bytes[6] & 0x0f) | 0x40
- bytes[8] = (bytes[8] & 0x3f) | 0x80
-
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
- return (
- `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
- `${hex[4]}${hex[5]}-` +
- `${hex[6]}${hex[7]}-` +
- `${hex[8]}${hex[9]}-` +
- `${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
- )
- }
-
- return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
-}
+ newChatSession,
+ sendChatMessage,
+ switchChatSession,
+} from "@/lib/pico-chat-controller"
+import { chatAtom } from "@/store/chat"
const UNIX_MS_THRESHOLD = 1e12
@@ -124,369 +57,16 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
}
export function usePicoChat() {
- const { t } = useTranslation()
- const { status: gatewayState } = useAtomValue(gatewayAtom)
- const [messages, setMessages] = useState([])
- const [connectionState, setConnectionState] =
- useState("disconnected")
- const [isTyping, setIsTyping] = useState(false)
- const [activeSessionId, setActiveSessionId] = useState(
- () => readStoredSessionId() || generateSessionId(),
- )
-
- const wsRef = useRef(null)
- const isConnectingRef = useRef(false)
- const msgIdCounter = useRef(0)
- const activeSessionIdRef = useRef(activeSessionId)
- const messagesRevisionRef = useRef(0)
-
- const setTrackedMessages = useCallback(
- (nextState: SetStateAction) => {
- setMessages((prev) => {
- const next =
- typeof nextState === "function"
- ? (nextState as (prevState: ChatMessage[]) => ChatMessage[])(prev)
- : nextState
-
- if (next !== prev) {
- messagesRevisionRef.current += 1
- }
-
- return next
- })
- },
- [],
- )
-
- // Keep ref in sync
- useEffect(() => {
- activeSessionIdRef.current = activeSessionId
- writeStoredSessionId(activeSessionId)
- }, [activeSessionId])
-
- const loadSessionMessages = useCallback(async (sessionId: string) => {
- const detail = await getSessionHistory(sessionId)
- const fallbackTime = detail.updated
-
- return detail.messages.map((m, i) => ({
- id: `hist-${i}-${Date.now()}`,
- role: m.role as "user" | "assistant",
- content: m.content,
- timestamp: fallbackTime,
- }))
- }, [])
-
- useEffect(() => {
- const storedSessionId = readStoredSessionId()
- if (!storedSessionId) {
- return
- }
-
- const restoreRevision = messagesRevisionRef.current
- let cancelled = false
- void loadSessionMessages(storedSessionId)
- .then((historyMessages) => {
- if (cancelled) {
- return
- }
- if (activeSessionIdRef.current !== storedSessionId) {
- return
- }
- if (messagesRevisionRef.current !== restoreRevision) {
- return
- }
- setTrackedMessages(historyMessages)
- setIsTyping(false)
- })
- .catch((err) => {
- console.error("Failed to restore last session history:", err)
- if (cancelled) {
- return
- }
- if (activeSessionIdRef.current !== storedSessionId) {
- return
- }
- if (messagesRevisionRef.current !== restoreRevision) {
- return
- }
- localStorage.removeItem(LAST_SESSION_STORAGE_KEY)
- setTrackedMessages([])
- setIsTyping(false)
- })
-
- return () => {
- cancelled = true
- }
- }, [loadSessionMessages, setTrackedMessages])
-
- const handlePicoMessage = useCallback(
- (msg: PicoMessage) => {
- const payload = msg.payload || {}
-
- switch (msg.type) {
- case "message.create": {
- const content = (payload.content as string) || ""
- const messageId =
- (payload.message_id as string) || `pico-${Date.now()}`
- // Use provided timestamp or current time
- const timestampRaw =
- msg.timestamp !== undefined &&
- Number.isFinite(Number(msg.timestamp))
- ? normalizeUnixTimestamp(Number(msg.timestamp))
- : Date.now()
-
- setTrackedMessages((prev) => [
- ...prev,
- {
- id: messageId,
- role: "assistant",
- content,
- timestamp: timestampRaw,
- },
- ])
- setIsTyping(false)
- break
- }
-
- case "message.update": {
- const content = (payload.content as string) || ""
- const messageId = payload.message_id as string
- if (!messageId) break
-
- setTrackedMessages((prev) =>
- prev.map((m) => (m.id === messageId ? { ...m, content } : m)),
- )
- break
- }
-
- case "typing.start":
- setIsTyping(true)
- break
-
- case "typing.stop":
- setIsTyping(false)
- break
-
- case "error":
- console.error("Pico error:", payload)
- setIsTyping(false)
- break
-
- case "pong":
- // heartbeat response, ignore
- break
-
- default:
- console.log("Unknown pico message type:", msg.type)
- }
- },
- [setTrackedMessages],
- )
-
- const connect = useCallback(async () => {
- if (
- isConnectingRef.current ||
- (wsRef.current &&
- (wsRef.current.readyState === WebSocket.OPEN ||
- wsRef.current.readyState === WebSocket.CONNECTING))
- ) {
- return
- }
-
- isConnectingRef.current = true
- setConnectionState("connecting")
-
- try {
- const { token, ws_url } = await getPicoToken()
-
- if (!token) {
- console.error("No pico token available")
- setConnectionState("error")
- isConnectingRef.current = false
- return
- }
-
- // If the backend returns a localhost URL but we are accessing it via a LAN IP
- // (e.g., from a mobile device during dev), rewrite the hostname to match.
- let finalWsUrl = ws_url
- try {
- const parsedUrl = new URL(ws_url)
- const isLocalHost =
- parsedUrl.hostname === "localhost" ||
- parsedUrl.hostname === "127.0.0.1" ||
- parsedUrl.hostname === "0.0.0.0"
- const isBrowserLocal =
- window.location.hostname === "localhost" ||
- window.location.hostname === "127.0.0.1"
-
- if (isLocalHost && !isBrowserLocal) {
- parsedUrl.hostname = window.location.hostname
- finalWsUrl = parsedUrl.toString()
- }
- } catch (e) {
- console.warn("Could not parse ws_url:", e)
- }
-
- // Build WebSocket URL with session_id
- const sessionId = activeSessionIdRef.current
- const url = `${finalWsUrl}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(sessionId)}`
- const socket = new WebSocket(url)
-
- socket.onopen = () => {
- setConnectionState("connected")
- isConnectingRef.current = false
- }
-
- socket.onmessage = (event) => {
- try {
- const msg: PicoMessage = JSON.parse(event.data)
- handlePicoMessage(msg)
- } catch {
- console.warn("Non-JSON message from pico:", event.data)
- }
- }
-
- socket.onclose = () => {
- setConnectionState("disconnected")
- wsRef.current = null
- isConnectingRef.current = false
- }
-
- socket.onerror = () => {
- setConnectionState("error")
- isConnectingRef.current = false
- }
-
- wsRef.current = socket
- } catch (err) {
- console.error("Failed to connect to pico:", err)
- setConnectionState("error")
- isConnectingRef.current = false
- }
- }, [handlePicoMessage])
-
- const disconnect = useCallback(() => {
- if (wsRef.current) {
- wsRef.current.close()
- wsRef.current = null
- }
- setConnectionState("disconnected")
- isConnectingRef.current = false
- }, [])
-
- // Auto connect/disconnect based on gateway state
- useEffect(() => {
- // Wrap in setTimeout to avoid React calling setState synchronously during render
- const timerId = setTimeout(() => {
- if (gatewayState === "running") {
- connect()
- } else {
- disconnect()
- }
- }, 0)
-
- return () => clearTimeout(timerId)
- }, [gatewayState, connect, disconnect])
-
- // Cleanup on unmount
- useEffect(() => {
- return () => disconnect()
- }, [disconnect])
-
- const sendMessage = useCallback(
- (content: string) => {
- if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
- console.warn("WebSocket not connected")
- return
- }
-
- const id = `msg-${++msgIdCounter.current}-${Date.now()}`
- const timestampRaw = Date.now()
-
- // Add user message to local state
- setTrackedMessages((prev) => [
- ...prev,
- { id, role: "user", content, timestamp: timestampRaw },
- ])
-
- // Show typing indicator immediately
- setIsTyping(true)
-
- // Send via Pico Protocol
- const picoMsg: PicoMessage = {
- type: "message.send",
- id,
- payload: { content },
- }
- wsRef.current.send(JSON.stringify(picoMsg))
- },
- [setTrackedMessages],
- )
-
- // Switch to a historical session
- const switchSession = useCallback(
- async (sessionId: string) => {
- if (sessionId === activeSessionIdRef.current) {
- return
- }
-
- try {
- const historyMessages = await loadSessionMessages(sessionId)
-
- // Only switch the active websocket session after history has loaded successfully.
- disconnect()
- setActiveSessionId(sessionId)
- setIsTyping(false)
- setTrackedMessages(historyMessages)
- } catch (err) {
- console.error("Failed to load session history:", err)
- toast.error(t("chat.historyOpenFailed"))
- return
- }
-
- setTimeout(() => {
- if (gatewayState === "running") {
- connect()
- }
- }, 100)
- },
- [
- connect,
- disconnect,
- gatewayState,
- loadSessionMessages,
- setTrackedMessages,
- t,
- ],
- )
-
- // Start a new empty chat
- const newChat = useCallback(() => {
- if (messages.length === 0) {
- return
- }
-
- disconnect()
- const newId = generateSessionId()
- setActiveSessionId(newId)
- setTrackedMessages([])
- setIsTyping(false)
-
- // Reconnect with the fresh session
- setTimeout(() => {
- if (gatewayState === "running") {
- connect()
- }
- }, 100)
- }, [disconnect, connect, gatewayState, messages.length, setTrackedMessages])
+ const { messages, connectionState, isTyping, activeSessionId } =
+ useAtomValue(chatAtom)
return {
messages,
connectionState,
isTyping,
activeSessionId,
- sendMessage,
- switchSession,
- newChat,
+ sendMessage: sendChatMessage,
+ switchSession: switchChatSession,
+ newChat: newChatSession,
}
}
diff --git a/web/frontend/src/lib/pico-chat-controller.ts b/web/frontend/src/lib/pico-chat-controller.ts
new file mode 100644
index 000000000..be3397bae
--- /dev/null
+++ b/web/frontend/src/lib/pico-chat-controller.ts
@@ -0,0 +1,405 @@
+import { getDefaultStore } from "jotai"
+import { toast } from "sonner"
+
+import { getPicoToken } from "@/api/pico"
+import { getSessionHistory } from "@/api/sessions"
+import i18n from "@/i18n"
+import {
+ clearStoredSessionId,
+ generateSessionId,
+ normalizeUnixTimestamp,
+ readStoredSessionId,
+} from "@/lib/pico-chat-state"
+import { type ChatMessage, getChatState, updateChatStore } from "@/store/chat"
+import { gatewayAtom } from "@/store/gateway"
+
+interface PicoMessage {
+ type: string
+ id?: string
+ session_id?: string
+ timestamp?: number | string
+ payload?: Record
+}
+
+const store = getDefaultStore()
+
+let wsRef: WebSocket | null = null
+let isConnecting = false
+let msgIdCounter = 0
+let activeSessionIdRef = getChatState().activeSessionId
+let initialized = false
+let unsubscribeGateway: (() => void) | null = null
+let hydratePromise: Promise | null = null
+let connectionGeneration = 0
+
+async function loadSessionMessages(sessionId: string): Promise {
+ const detail = await getSessionHistory(sessionId)
+ const fallbackTime = detail.updated
+
+ return detail.messages.map((message, index) => ({
+ id: `hist-${index}-${Date.now()}`,
+ role: message.role,
+ content: message.content,
+ timestamp: fallbackTime,
+ }))
+}
+
+function handlePicoMessage(message: PicoMessage) {
+ const payload = message.payload || {}
+
+ switch (message.type) {
+ case "message.create": {
+ const content = (payload.content as string) || ""
+ const messageId = (payload.message_id as string) || `pico-${Date.now()}`
+ const timestamp =
+ message.timestamp !== undefined &&
+ Number.isFinite(Number(message.timestamp))
+ ? normalizeUnixTimestamp(Number(message.timestamp))
+ : Date.now()
+
+ updateChatStore((prev) => ({
+ messages: [
+ ...prev.messages,
+ {
+ id: messageId,
+ role: "assistant",
+ content,
+ timestamp,
+ },
+ ],
+ isTyping: false,
+ }))
+ break
+ }
+
+ case "message.update": {
+ const content = (payload.content as string) || ""
+ const messageId = payload.message_id as string
+ if (!messageId) {
+ break
+ }
+
+ updateChatStore((prev) => ({
+ messages: prev.messages.map((msg) =>
+ msg.id === messageId ? { ...msg, content } : msg,
+ ),
+ }))
+ break
+ }
+
+ case "typing.start":
+ updateChatStore({ isTyping: true })
+ break
+
+ case "typing.stop":
+ updateChatStore({ isTyping: false })
+ break
+
+ case "error":
+ console.error("Pico error:", payload)
+ updateChatStore({ isTyping: false })
+ break
+
+ case "pong":
+ break
+
+ default:
+ console.log("Unknown pico message type:", message.type)
+ }
+}
+
+function setActiveSessionId(sessionId: string) {
+ activeSessionIdRef = sessionId
+ updateChatStore({ activeSessionId: sessionId })
+}
+
+export async function connectChat() {
+ if (store.get(gatewayAtom).status !== "running") {
+ return
+ }
+
+ if (
+ isConnecting ||
+ (wsRef &&
+ (wsRef.readyState === WebSocket.OPEN ||
+ wsRef.readyState === WebSocket.CONNECTING))
+ ) {
+ return
+ }
+
+ const generation = connectionGeneration + 1
+ connectionGeneration = generation
+ isConnecting = true
+ updateChatStore({ connectionState: "connecting" })
+
+ try {
+ const { token, ws_url } = await getPicoToken()
+
+ if (generation !== connectionGeneration) {
+ return
+ }
+
+ if (!token) {
+ console.error("No pico token available")
+ updateChatStore({ connectionState: "error" })
+ isConnecting = false
+ return
+ }
+
+ let finalWsUrl = ws_url
+ try {
+ const parsedUrl = new URL(ws_url)
+ const isLocalHost =
+ parsedUrl.hostname === "localhost" ||
+ parsedUrl.hostname === "127.0.0.1" ||
+ parsedUrl.hostname === "0.0.0.0"
+ const isBrowserLocal =
+ window.location.hostname === "localhost" ||
+ window.location.hostname === "127.0.0.1"
+
+ if (isLocalHost && !isBrowserLocal) {
+ parsedUrl.hostname = window.location.hostname
+ finalWsUrl = parsedUrl.toString()
+ }
+ } catch (error) {
+ console.warn("Could not parse ws_url:", error)
+ }
+
+ const url = `${finalWsUrl}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(activeSessionIdRef)}`
+ const socket = new WebSocket(url)
+
+ if (generation !== connectionGeneration) {
+ socket.close()
+ return
+ }
+
+ socket.onopen = () => {
+ if (wsRef !== socket) {
+ return
+ }
+ updateChatStore({ connectionState: "connected" })
+ isConnecting = false
+ }
+
+ socket.onmessage = (event) => {
+ try {
+ const message: PicoMessage = JSON.parse(event.data)
+ handlePicoMessage(message)
+ } catch {
+ console.warn("Non-JSON message from pico:", event.data)
+ }
+ }
+
+ socket.onclose = () => {
+ if (wsRef !== socket) {
+ return
+ }
+ wsRef = null
+ isConnecting = false
+ updateChatStore({
+ connectionState: "disconnected",
+ isTyping: false,
+ })
+ }
+
+ socket.onerror = () => {
+ if (wsRef !== socket) {
+ return
+ }
+ isConnecting = false
+ updateChatStore({ connectionState: "error" })
+ }
+
+ wsRef = socket
+ } catch (error) {
+ if (generation !== connectionGeneration) {
+ return
+ }
+ console.error("Failed to connect to pico:", error)
+ updateChatStore({ connectionState: "error" })
+ isConnecting = false
+ }
+}
+
+export function disconnectChat() {
+ connectionGeneration += 1
+
+ const socket = wsRef
+ wsRef = null
+ isConnecting = false
+
+ if (socket) {
+ socket.close()
+ }
+
+ updateChatStore({
+ connectionState: "disconnected",
+ isTyping: false,
+ })
+}
+
+export async function hydrateActiveSession() {
+ if (hydratePromise) {
+ return hydratePromise
+ }
+
+ const state = getChatState()
+ const storedSessionId = readStoredSessionId()
+
+ if (
+ !storedSessionId ||
+ state.hasHydratedActiveSession ||
+ state.messages.length > 0 ||
+ storedSessionId !== state.activeSessionId
+ ) {
+ if (!state.hasHydratedActiveSession) {
+ updateChatStore({ hasHydratedActiveSession: true })
+ }
+ return
+ }
+
+ hydratePromise = loadSessionMessages(storedSessionId)
+ .then((historyMessages) => {
+ const currentState = getChatState()
+ if (currentState.activeSessionId !== storedSessionId) {
+ return
+ }
+
+ if (currentState.messages.length > 0) {
+ updateChatStore({ hasHydratedActiveSession: true })
+ return
+ }
+
+ updateChatStore({
+ messages: historyMessages,
+ isTyping: false,
+ hasHydratedActiveSession: true,
+ })
+ })
+ .catch((error) => {
+ console.error("Failed to restore last session history:", error)
+
+ const currentState = getChatState()
+ if (currentState.activeSessionId !== storedSessionId) {
+ return
+ }
+
+ if (currentState.messages.length > 0) {
+ updateChatStore({ hasHydratedActiveSession: true })
+ return
+ }
+
+ clearStoredSessionId()
+ updateChatStore({
+ messages: [],
+ isTyping: false,
+ hasHydratedActiveSession: true,
+ })
+ })
+ .finally(() => {
+ hydratePromise = null
+ })
+
+ return hydratePromise
+}
+
+export function sendChatMessage(content: string) {
+ if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
+ console.warn("WebSocket not connected")
+ return
+ }
+
+ const id = `msg-${++msgIdCounter}-${Date.now()}`
+
+ updateChatStore((prev) => ({
+ messages: [
+ ...prev.messages,
+ { id, role: "user", content, timestamp: Date.now() },
+ ],
+ isTyping: true,
+ }))
+
+ wsRef.send(
+ JSON.stringify({
+ type: "message.send",
+ id,
+ payload: { content },
+ }),
+ )
+}
+
+export async function switchChatSession(sessionId: string) {
+ if (sessionId === activeSessionIdRef) {
+ return
+ }
+
+ try {
+ const historyMessages = await loadSessionMessages(sessionId)
+
+ disconnectChat()
+ setActiveSessionId(sessionId)
+ updateChatStore({
+ messages: historyMessages,
+ isTyping: false,
+ hasHydratedActiveSession: true,
+ })
+
+ if (store.get(gatewayAtom).status === "running") {
+ await connectChat()
+ }
+ } catch (error) {
+ console.error("Failed to load session history:", error)
+ toast.error(i18n.t("chat.historyOpenFailed"))
+ }
+}
+
+export async function newChatSession() {
+ if (getChatState().messages.length === 0) {
+ return
+ }
+
+ disconnectChat()
+ setActiveSessionId(generateSessionId())
+ updateChatStore({
+ messages: [],
+ isTyping: false,
+ hasHydratedActiveSession: true,
+ })
+
+ if (store.get(gatewayAtom).status === "running") {
+ await connectChat()
+ }
+}
+
+export function initializeChatStore() {
+ if (initialized) {
+ return
+ }
+
+ initialized = true
+ activeSessionIdRef = getChatState().activeSessionId
+
+ const syncConnectionWithGateway = () => {
+ if (store.get(gatewayAtom).status === "running") {
+ void connectChat()
+ return
+ }
+
+ disconnectChat()
+ }
+
+ unsubscribeGateway = store.sub(gatewayAtom, syncConnectionWithGateway)
+
+ if (!readStoredSessionId()) {
+ updateChatStore({ hasHydratedActiveSession: true })
+ }
+
+ syncConnectionWithGateway()
+}
+
+export function teardownChatStore() {
+ unsubscribeGateway?.()
+ unsubscribeGateway = null
+ initialized = false
+ disconnectChat()
+}
diff --git a/web/frontend/src/lib/pico-chat-state.ts b/web/frontend/src/lib/pico-chat-state.ts
new file mode 100644
index 000000000..5b7d6c6cd
--- /dev/null
+++ b/web/frontend/src/lib/pico-chat-state.ts
@@ -0,0 +1,59 @@
+const LAST_SESSION_STORAGE_KEY = "picoclaw:last-session-id"
+const UNIX_MS_THRESHOLD = 1e12
+
+function readStorageValue() {
+ return (
+ globalThis.localStorage?.getItem(LAST_SESSION_STORAGE_KEY)?.trim() || ""
+ )
+}
+
+export function readStoredSessionId(): string {
+ return readStorageValue()
+}
+
+export function writeStoredSessionId(sessionId: string) {
+ if (sessionId) {
+ globalThis.localStorage?.setItem(LAST_SESSION_STORAGE_KEY, sessionId)
+ return
+ }
+
+ globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)
+}
+
+export function clearStoredSessionId() {
+ globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)
+}
+
+export function generateSessionId(): string {
+ const webCrypto = globalThis.crypto
+ if (webCrypto && typeof webCrypto.randomUUID === "function") {
+ return webCrypto.randomUUID()
+ }
+
+ if (webCrypto && typeof webCrypto.getRandomValues === "function") {
+ const bytes = new Uint8Array(16)
+ webCrypto.getRandomValues(bytes)
+
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
+ return (
+ `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
+ `${hex[4]}${hex[5]}-` +
+ `${hex[6]}${hex[7]}-` +
+ `${hex[8]}${hex[9]}-` +
+ `${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
+ )
+ }
+
+ return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
+}
+
+export function getInitialActiveSessionId(): string {
+ return readStorageValue() || generateSessionId()
+}
+
+export function normalizeUnixTimestamp(timestamp: number): number {
+ return timestamp < UNIX_MS_THRESHOLD ? timestamp * 1000 : timestamp
+}
diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx
index 48f228d84..6431d9490 100644
--- a/web/frontend/src/routes/__root.tsx
+++ b/web/frontend/src/routes/__root.tsx
@@ -1,9 +1,15 @@
import { Outlet, createRootRoute } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
+import { useEffect } from "react"
import { AppLayout } from "@/components/app-layout"
+import { initializeChatStore } from "@/lib/pico-chat-controller"
const RootLayout = () => {
+ useEffect(() => {
+ initializeChatStore()
+ }, [])
+
return (
diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts
new file mode 100644
index 000000000..d79a1a93b
--- /dev/null
+++ b/web/frontend/src/store/chat.ts
@@ -0,0 +1,62 @@
+import { atom, getDefaultStore } from "jotai"
+
+import {
+ getInitialActiveSessionId,
+ writeStoredSessionId,
+} from "@/lib/pico-chat-state"
+
+export interface ChatMessage {
+ id: string
+ role: "user" | "assistant"
+ content: string
+ timestamp: number | string
+}
+
+export type ConnectionState =
+ | "disconnected"
+ | "connecting"
+ | "connected"
+ | "error"
+
+export interface ChatStoreState {
+ messages: ChatMessage[]
+ connectionState: ConnectionState
+ isTyping: boolean
+ activeSessionId: string
+ hasHydratedActiveSession: boolean
+}
+
+type ChatStorePatch = Partial
+
+const DEFAULT_CHAT_STATE: ChatStoreState = {
+ messages: [],
+ connectionState: "disconnected",
+ isTyping: false,
+ activeSessionId: getInitialActiveSessionId(),
+ hasHydratedActiveSession: false,
+}
+
+export const chatAtom = atom(DEFAULT_CHAT_STATE)
+
+const store = getDefaultStore()
+
+export function getChatState() {
+ return store.get(chatAtom)
+}
+
+export function updateChatStore(
+ patch:
+ | ChatStorePatch
+ | ((prev: ChatStoreState) => ChatStorePatch | ChatStoreState),
+) {
+ store.set(chatAtom, (prev) => {
+ const nextPatch = typeof patch === "function" ? patch(prev) : patch
+ const next = { ...prev, ...nextPatch }
+
+ if (next.activeSessionId !== prev.activeSessionId) {
+ writeStoredSessionId(next.activeSessionId)
+ }
+
+ return next
+ })
+}
diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts
index 9dfcdf3c7..d377cdace 100644
--- a/web/frontend/src/store/index.ts
+++ b/web/frontend/src/store/index.ts
@@ -1 +1,2 @@
export * from "./gateway"
+export * from "./chat"