From 03b44eeabb0f7deb3c1bdf0748a09ad70cf9628c Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Fri, 13 Mar 2026 22:39:52 +0800 Subject: [PATCH 1/7] =?UTF-8?q?Agent=E4=BE=9D=E7=85=A7=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E4=B8=AD=E3=80=81=E4=BC=91=E6=81=AF=E4=B8=AD=E3=80=81=E4=B8=8B?= =?UTF-8?q?=E7=8F=AD=E4=BA=86=E6=8E=92=E5=BA=8F=EF=BC=9B=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=95=9F=E5=8B=95Gateway=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + app/api/gateway-logs/route.ts | 51 ++++++++++ app/api/gateway-restart/route.ts | 62 ++++++++++++ app/gateway-status.tsx | 169 ++++++++++++++++++++++++++++--- app/pixel-office/page.tsx | 10 +- 5 files changed, 275 insertions(+), 18 deletions(-) create mode 100644 app/api/gateway-logs/route.ts create mode 100644 app/api/gateway-restart/route.ts diff --git a/.gitignore b/.gitignore index a1471d9..e88fcbd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ next-env.d.ts /public/assets/pixel-office/*.mp3 .env.local .env.*.local +.DS_Store diff --git a/app/api/gateway-logs/route.ts b/app/api/gateway-logs/route.ts new file mode 100644 index 0000000..4a27565 --- /dev/null +++ b/app/api/gateway-logs/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { readFileSync } from "fs"; +import os from "os"; +import path from "path"; + +const LOG_PATH = path.join(os.homedir(), ".openclaw/logs/gateway.err.log"); +const TAIL_LINES = 120; +const STALL_RECENT_MS = 10 * 60 * 1000; // consider stall "active" if within last 10 min + +const PATTERNS = [ + { re: /Polling stall detected/, issue: "telegram_stall" }, + { re: /sendChatAction failed: Network request/, issue: "telegram_network" }, + { re: /gateway timeout after \d+ms/, issue: "subagent_timeout" }, +] as const; + +export async function GET() { + try { + const content = readFileSync(LOG_PATH, "utf8"); + const lines = content.split("\n").filter(Boolean).slice(-TAIL_LINES); + + const issues = new Set(); + for (const line of lines) { + for (const { re, issue } of PATTERNS) { + if (re.test(line)) issues.add(issue); + } + } + + // Find timestamp of most recent stall line + let lastStallAt: string | null = null; + for (let i = lines.length - 1; i >= 0; i--) { + if (/Polling stall detected/.test(lines[i])) { + const m = lines[i].match(/^(\d{4}-\d{2}-\d{2}T[\d:.+]+)/); + if (m) { lastStallAt = m[1]; break; } + } + } + + // Only treat stall as active if it happened recently + const stallActive = lastStallAt + ? Date.now() - new Date(lastStallAt).getTime() < STALL_RECENT_MS + : false; + + return NextResponse.json({ + ok: true, + issues: [...issues], + lastStallAt, + stallActive, + }); + } catch { + return NextResponse.json({ ok: false, issues: [], lastStallAt: null, stallActive: false }); + } +} diff --git a/app/api/gateway-restart/route.ts b/app/api/gateway-restart/route.ts new file mode 100644 index 0000000..bf2dfd4 --- /dev/null +++ b/app/api/gateway-restart/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { execFile, exec, spawn } from "child_process"; +import { promisify } from "util"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); +const LAUNCHCTL = "/bin/launchctl"; +const PLIST = path.join(os.homedir(), "Library/LaunchAgents/ai.openclaw.gateway.plist"); + +const EXTRA_PATH = `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`; + +/** Kill any running gateway process by name */ +async function killGatewayProcess(): Promise { + try { + const { stdout } = await execAsync("pgrep -f 'openclaw.gateway\\|openclaw-gateway'"); + const pids = stdout.trim().split("\n").filter(Boolean); + if (pids.length > 0) { + await execAsync(`kill ${pids.join(" ")}`); + await new Promise((r) => setTimeout(r, 1000)); + } + } catch { /* no process running — ok */ } +} + +/** Find openclaw binary in PATH */ +async function findOpenclawBin(): Promise { + try { + const { stdout } = await execAsync("which openclaw", { env: { ...process.env, PATH: EXTRA_PATH } }); + return stdout.trim(); + } catch { + return "openclaw"; + } +} + +export async function POST() { + try { + const hasPlist = fs.existsSync(PLIST); + + if (hasPlist) { + // Plist exists — use launchctl to reload (works whether currently loaded or not) + try { await execFileAsync(LAUNCHCTL, ["unload", PLIST]); } catch { /* ignore if already unloaded */ } + await new Promise((r) => setTimeout(r, 500)); + await execFileAsync(LAUNCHCTL, ["load", PLIST]); + } else { + // No plist — kill and restart directly + await killGatewayProcess(); + const bin = await findOpenclawBin(); + const child = spawn(bin, ["gateway"], { + detached: true, + stdio: "ignore", + env: { ...process.env, PATH: EXTRA_PATH }, + }); + child.unref(); + } + + return NextResponse.json({ ok: true, method: hasPlist ? "launchd" : "direct" }); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 500 }); + } +} diff --git a/app/gateway-status.tsx b/app/gateway-status.tsx index dc24149..e0ea97a 100644 --- a/app/gateway-status.tsx +++ b/app/gateway-status.tsx @@ -20,6 +20,13 @@ interface HealthResult { openclawVersion?: string; } +interface LogResult { + ok: boolean; + issues: string[]; + lastStallAt: string | null; + stallActive: boolean; +} + interface GatewayStatusProps { compact?: boolean; className?: string; @@ -29,30 +36,81 @@ interface GatewayStatusProps { export function GatewayStatus({ compact = false, className = "", hideIconOnMobile = false }: GatewayStatusProps) { const { t } = useI18n(); const [health, setHealth] = useState(null); - const [showError, setShowError] = useState(false); + const [logResult, setLogResult] = useState(null); + const [showDetail, setShowDetail] = useState(false); const [showVersionTip, setShowVersionTip] = useState(false); + const [restarting, setRestarting] = useState(false); + const [restartMsg, setRestartMsg] = useState(null); - const check = useCallback(() => { + const checkHealth = useCallback(() => { fetch("/api/gateway-health") .then((r) => r.json()) - .then((d) => setHealth(d)) + .then((d: HealthResult) => { + setHealth(d); + // If health is down, also fetch logs for more context + if (!d.ok) fetchLogs(); + }) .catch(() => setHealth({ ok: false, error: t("gateway.fetchError") })); }, [t]); + const fetchLogs = useCallback(() => { + fetch("/api/gateway-logs") + .then((r) => r.json()) + .then((d: LogResult) => setLogResult(d)) + .catch(() => {}); + }, []); + useEffect(() => { - check(); - const timer = setInterval(check, 10000); + checkHealth(); + const timer = setInterval(checkHealth, 10000); return () => clearInterval(timer); - }, [check]); + }, [checkHealth]); + + const handleDetailClick = useCallback(() => { + setShowDetail((v) => !v); + // Fetch fresh logs whenever the user opens the detail panel + fetchLogs(); + }, [fetchLogs]); + + const handleRestart = useCallback(async () => { + if (restarting) return; + setRestarting(true); + setRestartMsg(null); + try { + const res = await fetch("/api/gateway-restart", { method: "POST" }); + const data = await res.json(); + if (data.ok) { + setRestartMsg("✅ 重啟指令已送出,稍後自動重新檢查…"); + setTimeout(() => { + checkHealth(); + setRestartMsg(null); + setShowDetail(false); + }, 4000); + } else { + setRestartMsg(`❌ 重啟失敗:${data.error || "未知錯誤"}`); + } + } catch (err: any) { + setRestartMsg(`❌ 重啟失敗:${err.message}`); + } finally { + setRestarting(false); + } + }, [restarting, checkHealth]); const gatewayTitle = health?.openclawVersion ? `OpenClaw ${health.openclawVersion}` : "OpenClaw"; + // Determine warning state: gateway alive but Telegram stalled + const telegramStall = health?.ok && logResult?.stallActive === true; + const showWarning = telegramStall; + // Show restart button when: down, or Telegram stalled + const showRestart = health !== null; + return (
+ {/* Gateway link badge */} + {showVersionTip && (
{gatewayTitle}
)} + + {/* Health indicator */} {!health ? ( -- - ) : health.ok ? ( + ) : health.ok && !showWarning ? ( + ) : showWarning ? ( + ⚠️ ) : ( setShowError((v) => !v)} + onClick={handleDetailClick} >❌ )} - {showError && health && !health.ok && health.error && ( -
- {health.error} + + {/* Restart button — shown when there's a problem */} + {showRestart && ( + + )} + + {/* Detail panel */} + {showDetail && ( +
+
+ Gateway 狀態 + +
+ +
+ {/* Health status */} +
+ Process: + + {health?.ok ? "✅ 運作中" : "❌ 無回應"} + +
+ + {/* Telegram stall */} + {logResult && logResult.issues.includes("telegram_stall") && ( +
+ Telegram: + + ⚠️ Polling 異常 + {logResult.lastStallAt && ( + + ({new Date(logResult.lastStallAt).toLocaleTimeString("zh-TW", { hour: "2-digit", minute: "2-digit" })}) + + )} + +
+ )} + + {/* Subagent timeout */} + {logResult && logResult.issues.includes("subagent_timeout") && ( +
+ Subagent: + ⚠️ 有 timeout 記錄 +
+ )} + + {/* Error message when down */} + {health && !health.ok && health.error && ( +
+ {health.error} +
+ )} + + {/* Restart result message */} + {restartMsg && ( +
+ {restartMsg} +
+ )} +
+ + {/* Restart button */} +
+ +
)}
diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 5ee66f3..39c0ffd 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -1180,12 +1180,8 @@ export default function PixelOfficePage() { return tileX >= f.col && tileX < f.col + entry.footprintW && tileY >= f.row && tileY < f.row + entry.footprintH })) { - // Click on PC — open gateway chat for main agent - const gw = gatewayRef.current - const sessionKey = 'agent:main:main' - let chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey }, gw.host) - if (gw.token) chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey, token: gw.token }, gw.host) - window.open(chatUrl, '_blank') + // Click on PC — navigate to dashboard settings + window.location.href = '/' } else if (office.layout.furniture.some(f => { if (f.uid !== 'library-r') return false const entry = getCatalogEntry(f.type) @@ -1658,6 +1654,8 @@ export default function PixelOfficePage() { }) } } + const stateOrder: Record = { working: 0, waiting: 1, idle: 2, offline: 3 } + expanded.sort((a, b) => (stateOrder[a.state] ?? 9) - (stateOrder[b.state] ?? 9)) return expanded }, [agents]) From 575fcd6d63057504c107acd62905b39c9b4be2c9 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sat, 14 Mar 2026 07:26:52 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E8=AA=BF=E6=95=B4=E8=A1=8C=E5=8B=95?= =?UTF-8?q?=E8=B7=AF=E7=B7=9A=E7=AD=96=E7=95=A5=EF=BC=8C=E6=B8=9B=E5=B0=91?= =?UTF-8?q?=E7=A2=B0=E6=92=9E=E6=A9=9F=E6=9C=83=EF=BC=88=E9=82=84=E6=98=AF?= =?UTF-8?q?=E6=9C=83=E7=A2=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pixel-office/page.tsx | 34 +++++ lib/pixel-office/engine/characters.ts | 6 +- lib/pixel-office/engine/officeState.ts | 182 +++++++++++++++++++++++++ lib/pixel-office/types.ts | 4 + 4 files changed, 224 insertions(+), 2 deletions(-) diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 39c0ffd..e7df71c 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -892,6 +892,40 @@ export default function PixelOfficePage() { return () => clearInterval(interval) }, [refreshGatewayHealthSnapshot]) + // Debug helper: expose __pixelOffice on window for console testing + const debugCounterRef = useRef(0) + useEffect(() => { + ;(window as any).__pixelOffice = { + addAgents(count = 1) { + const office = officeRef.current + if (!office) { console.warn('[pixelOffice] office not ready'); return } + for (let i = 0; i < count; i++) { + debugCounterRef.current++ + const id = 9000 + debugCounterRef.current + office.addAgent(id, undefined, undefined, undefined, undefined, true) + } + console.log(`[pixelOffice] added ${count} agent(s), ids 9001–${9000 + debugCounterRef.current}`) + }, + clearDebug() { + const office = officeRef.current + if (!office) return + for (let i = 1; i <= debugCounterRef.current; i++) { + office.removeAgent(9000 + i) + } + debugCounterRef.current = 0 + console.log('[pixelOffice] cleared debug agents') + }, + list() { + const office = officeRef.current + if (!office) return + const rows: any[] = [] + for (const [id, ch] of office.characters) rows.push({ id, state: ch.state, tile: `${ch.tileCol},${ch.tileRow}` }) + console.table(rows) + }, + } + return () => { delete (window as any).__pixelOffice } + }, []) // mount once only + useEffect(() => { if (!selectedAgentId) return try { diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts index 2d284a2..c8ea8d7 100644 --- a/lib/pixel-office/engine/characters.ts +++ b/lib/pixel-office/engine/characters.ts @@ -103,6 +103,8 @@ export function createCharacter( codeSnippets: [], photoComments: [], isViewingPhoto: false, + yieldTimer: 0, + yieldDestination: null, } } @@ -366,8 +368,8 @@ export function updateCharacter( ch.moveProgress = 0 } - // If became active while wandering, repath to seat (skip if under greeting control) - if (ch.isActive && ch.seatId && !ch.greetLocked) { + // If became active while wandering, repath to seat (skip if under greeting control or yielding) + if (ch.isActive && ch.seatId && !ch.greetLocked && !ch.yieldDestination) { const seat = seats.get(ch.seatId) if (seat) { const lastStep = ch.path[ch.path.length - 1] diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 6e646be..70699f4 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1913,9 +1913,165 @@ export class OfficeState { } } + /** + * Collision avoidance: when two walking characters contest the same next tile, + * the one farther from its destination steps aside (sideways first, then backwards) + * and waits for the closer one to pass before resuming. + */ + /** Find a free adjacent tile for a character to dodge to, or null if none found */ + private findDodgeTile( + ch: Character, + dc: number, dr: number, + walkableSet: Set, + occupiedKeys: Set, + claimedNext: Map, + ): { col: number; row: number } | null { + const candidates: Array<{ col: number; row: number }> = dc !== 0 || dr !== 0 + ? [ + { col: ch.tileCol + dr, row: ch.tileRow + dc }, // side A (perpendicular) + { col: ch.tileCol - dr, row: ch.tileRow - dc }, // side B (perpendicular) + { col: ch.tileCol - dc, row: ch.tileRow - dr }, // backwards + ] + : [ + // No direction info — try all 4 neighbours + { col: ch.tileCol + 1, row: ch.tileRow }, + { col: ch.tileCol - 1, row: ch.tileRow }, + { col: ch.tileCol, row: ch.tileRow + 1 }, + { col: ch.tileCol, row: ch.tileRow - 1 }, + ] + + for (const cand of candidates) { + const key = `${cand.col},${cand.row}` + if (!walkableSet.has(key)) continue + if (this.blockedTiles.has(key)) continue + if (occupiedKeys.has(key)) continue + if (claimedNext.has(key)) continue + return cand + } + return null + } + + private resolveWalkConflicts(): void { + const allHumanoids: Character[] = [] + for (const ch of this.characters.values()) { + if (ch.matrixEffect || ch.isCat || ch.isLobster || ch.greetLocked) continue + if (ch.yieldTimer > 0) continue + allHumanoids.push(ch) + } + if (allHumanoids.length < 2) return + + // Current tile positions of every character (for dodge-target exclusion) + const occupiedKeys = new Set() + for (const ch of this.characters.values()) { + occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`) + } + + const walkableSet = new Set(this.walkableTiles.map(t => `${t.col},${t.row}`)) + + // claimedNext: tiles that are "taken" — no other character may step into them. + // Pre-populate with standing characters' current tiles only. + // Walking characters claim their next tile dynamically in Pass 1 (sorted by priority). + const claimedNext = new Map() + for (const ch of allHumanoids) { + if (ch.state !== CharacterState.WALK) { + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + } + } + + // ── Pass 1: next-tile conflicts ─────────────────────────────────────────── + // All walkers (including mid-step), sorted by remaining path length. + // Shorter path = closer to destination = higher priority = claims the tile. + // Loser is snapped back to current tile and must dodge. + const walkers = allHumanoids.filter( + ch => ch.state === CharacterState.WALK && !ch.yieldDestination && ch.path.length > 0, + ) + walkers.sort((a, b) => a.path.length - b.path.length) + + for (const ch of walkers) { + const next = ch.path[0] + const key = `${next.col},${next.row}` + if (!claimedNext.has(key)) { + // Also claim current tile so no one walks into us from behind + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + claimedNext.set(key, ch.id) + continue + } + // Tile is taken — snap back to current tile and dodge + if (ch.moveProgress > 0) { + // Abort mid-step: snap back to the tile we came from + ch.x = ch.tileCol * TILE_SIZE + TILE_SIZE / 2 + ch.y = ch.tileRow * TILE_SIZE + TILE_SIZE / 2 + ch.moveProgress = 0 + } + const dest = ch.path[ch.path.length - 1] + const dc = next.col - ch.tileCol + const dr = next.row - ch.tileRow + const dodgeTile = this.findDodgeTile(ch, dc, dr, walkableSet, occupiedKeys, claimedNext) + if (dodgeTile) { + ch.path = [dodgeTile] + ch.moveProgress = 0 + ch.yieldDestination = { col: dest.col, row: dest.row } + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id) + } else { + ch.path = [] + ch.yieldTimer = 0.5 + Math.random() * 0.4 + ch.yieldDestination = { col: dest.col, row: dest.row } + claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) + } + } + + // ── Pass 2: hard overlap — same tileCol/tileRow right now ──────────────── + const tileGroups = new Map() + for (const ch of allHumanoids) { + const key = `${ch.tileCol},${ch.tileRow}` + const g = tileGroups.get(key) + if (g) g.push(ch) + else tileGroups.set(key, [ch]) + } + + for (const group of tileGroups.values()) { + if (group.length < 2) continue + + // Highest priority (index 0) stays; others dodge + group.sort((a, b) => { + const aScore = a.state === CharacterState.WALK ? a.path.length : 9999 + const bScore = b.state === CharacterState.WALK ? b.path.length : 9999 + return aScore - bScore + }) + + for (let i = 1; i < group.length; i++) { + const dodger = group[i] + if (dodger.yieldTimer > 0 || dodger.yieldDestination) continue + + const dc = dodger.dir === Direction.RIGHT ? 1 : dodger.dir === Direction.LEFT ? -1 : 0 + const dr = dodger.dir === Direction.DOWN ? 1 : dodger.dir === Direction.UP ? -1 : 0 + + const dodgeTile = this.findDodgeTile(dodger, dc, dr, walkableSet, occupiedKeys, claimedNext) + if (dodgeTile) { + if (dodger.state === CharacterState.WALK && dodger.path.length > 0) { + const orig = dodger.path[dodger.path.length - 1] + dodger.yieldDestination = { col: orig.col, row: orig.row } + } + dodger.path = [dodgeTile] + dodger.moveProgress = 0 + dodger.state = CharacterState.WALK + dodger.frame = 0 + dodger.frameTimer = 0 + claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id) + occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`) + occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`) + } else { + dodger.yieldTimer = 0.3 + Math.random() * 0.3 + } + } + } + } + update(dt: number): void { this.ensureGatewaySre() this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight) + this.resolveWalkConflicts() const toDelete: number[] = [] const firstIdleHumanoid = this.getFirstIdleHumanoid() for (const ch of this.characters.values()) { @@ -1941,6 +2097,24 @@ export class OfficeState { continue } + // Yield timer: character is waiting at dodge tile before resuming + if (ch.yieldTimer > 0) { + ch.yieldTimer = Math.max(0, ch.yieldTimer - dt) + if (ch.yieldTimer === 0 && ch.yieldDestination) { + const dest = ch.yieldDestination + ch.yieldDestination = null + const resumePath = findPath(ch.tileCol, ch.tileRow, dest.col, dest.row, this.tileMap, this.blockedTiles) + if (resumePath.length > 0) { + ch.path = resumePath + ch.moveProgress = 0 + ch.state = CharacterState.WALK + ch.frame = 0 + ch.frameTimer = 0 + } + } + continue // frozen while waiting + } + if (ch.systemRoleType === 'gateway_sre' && !ch.greetLocked) { this.updateGatewaySreCharacter(ch, dt) } else { @@ -1951,6 +2125,14 @@ export class OfficeState { ) } + // If character just finished walking to dodge tile, start wait timer + if (ch.yieldDestination && ch.state !== CharacterState.WALK && ch.path.length === 0) { + ch.yieldTimer = 0.6 + Math.random() * 0.5 + ch.state = CharacterState.IDLE + ch.frame = 0 + ch.frameTimer = 0 + } + if (ch.isLobster) { if (ch.lobsterRageTimer > 0) { ch.lobsterRageTimer = Math.max(0, ch.lobsterRageTimer - dt) diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index bd0e8b9..e59582b 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -214,4 +214,8 @@ export interface Character { systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down' /** Walk back to this tile after farewell greeting, then despawn */ pendingDespawn?: { col: number; row: number } | true + /** Seconds remaining while character waits at dodge tile before resuming path */ + yieldTimer: number + /** Original destination to repath to after yield completes */ + yieldDestination: { col: number; row: number } | null } From 0d1501f7ad94459106567284c757feb24b152bde Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 00:04:02 +0800 Subject: [PATCH 3/7] feat: improve agent state detection and pixel office greeting logic - Detect agent state by reading session JSONL files instead of fixed time thresholds - Add detectStateFromSession() to determine idle/working/offline states accurately - Fix pixel office: only show 'online' broadcast when agent returns from offline (not idle) - Add meeting point logic: subagent meets MainAgent at midpoint when MainAgent is not at seat --- app/api/agent-activity/route.ts | 103 +++++++++++++++++++++++-- app/pixel-office/page.tsx | 3 +- lib/pixel-office/engine/officeState.ts | 102 ++++++++++++++++++++++-- 3 files changed, 193 insertions(+), 15 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index bd5bb86..a7fc4d5 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -560,6 +560,58 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis return allSubagents } +/** + * Read last N lines of a JSONL session file and determine the agent's true working state. + * + * Logic: + * - Last message role is 'toolResult' → working (parent about to process tool output) + * - Last assistant stopReason is 'toolUse' → working (tool call in flight) + * - Last assistant stopReason is 'stop' → idle (turn completed, waiting for next input) + * - Fallback: time-based heuristic + */ +async function detectStateFromSession( + sessionFilePath: string, + now: number, + lastActive: number, +): Promise<'idle' | 'working' | 'offline'> { + const OFFLINE_MS = 10 * 60 * 1000 + const timeDiff = now - lastActive + if (lastActive === 0 || timeDiff > OFFLINE_MS) return 'offline' + + try { + const content = await fs.readFile(sessionFilePath, 'utf8') + const lines = content.split('\n').filter(l => l.trim()).slice(-30) + + let lastRole: string | null = null + let lastStopReason: string | null = null + + for (let i = lines.length - 1; i >= 0; i--) { + try { + const record = JSON.parse(lines[i]) + if (record.type !== 'message' || !record.message) continue + const { role, stopReason } = record.message + if (!lastRole) lastRole = role ?? null + if (role === 'assistant') { + lastStopReason = stopReason ?? null + break + } + } catch { /* skip malformed line */ } + } + + // User message is the last written record → agent is generating a response right now + if (lastRole === 'user') return 'working' + // toolResult as the latest message → agent is processing the tool output + if (lastRole === 'toolResult') return 'working' + // Agent called a tool and is waiting for the result + if (lastStopReason === 'toolUse') return 'working' + // Agent completed its turn — it's now idle + if (lastStopReason === 'stop') return 'idle' + } catch { /* file unreadable — fall through */ } + + // Fallback: time-based (no session content available) + return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle' +} + export async function GET() { const configPath = OPENCLAW_CONFIG_PATH const agentsDir = OPENCLAW_AGENTS_DIR @@ -577,34 +629,69 @@ export async function GET() { for (const agent of agentList) { let lastActive = 0 + let mostRecentSessionFile: string | null = null let agentSessionsDir = '' if (existsSync(agentsDir)) { agentSessionsDir = path.join(agentsDir, agent.id, 'sessions') if (existsSync(agentSessionsDir)) { + // Use JSONL file mtime for lastActive (reliable, updates on every message write). + // Also build a map from sessionId → filePath using sessions.json, so we can + // pick the correct file for content-based state detection. + const sessionIdToFile = new Map() + try { + const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json') + if (existsSync(sessionsIndexPath)) { + const raw = await fs.readFile(sessionsIndexPath, 'utf8') + const index = JSON.parse(raw) as Record + for (const [, meta] of Object.entries(index)) { + if (typeof meta.sessionId === 'string') { + sessionIdToFile.set(meta.sessionId, path.join(agentSessionsDir, `${meta.sessionId}.jsonl`)) + } + } + } + } catch { /* ignore */ } + try { const files = await fs.readdir(agentSessionsDir) for (const file of files) { + if (!file.endsWith('.jsonl')) continue const filePath = path.join(agentSessionsDir, file) const stat = await fs.stat(filePath) if (stat.mtimeMs > lastActive) { lastActive = stat.mtimeMs + mostRecentSessionFile = filePath + } + } + } catch { /* ignore */ } + + // Prefer the sessions.json-mapped file over raw scan when available + // (ensures we read a proper session, not a probe or temp file) + if (mostRecentSessionFile) { + const sessionId = path.basename(mostRecentSessionFile, '.jsonl') + if (!sessionIdToFile.has(sessionId)) { + // Most-recent file is not in sessions.json — find best known session by mtime + let bestMtime = 0 + for (const [, fp] of sessionIdToFile) { + try { + const s = await fs.stat(fp) + if (s.mtimeMs > bestMtime) { bestMtime = s.mtimeMs; mostRecentSessionFile = fp } + } catch { /* ignore */ } } } - } catch { - // Ignore } } } + // Determine state from session content (falls back to time-based) let state: 'idle' | 'working' | 'waiting' | 'offline' - const timeDiff = now - lastActive - if (lastActive === 0 || timeDiff > 10 * 60 * 1000) { - state = 'offline' - } else if (timeDiff <= 2 * 60 * 1000) { - state = 'working' + if (mostRecentSessionFile && existsSync(mostRecentSessionFile)) { + state = await detectStateFromSession(mostRecentSessionFile, now, lastActive) } else { - state = 'idle' + const timeDiff = now - lastActive + if (lastActive === 0 || timeDiff > 10 * 60 * 1000) state = 'offline' + else if (timeDiff <= 2 * 60 * 1000) state = 'working' + else state = 'idle' } // Parse subagents for online agents diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index e7df71c..c2c4e39 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -812,7 +812,8 @@ export default function PixelOfficePage() { } // Broadcast notification on meaningful state transitions if (prev && prev !== agent.state) { - if (agent.state === 'working' && prev !== 'working') { + // Only show "上班了" when agent comes back from offline (not from idle) + if (agent.state === 'working' && prev === 'offline') { const bid = Date.now() + Math.random() setBroadcasts(b => [...b, { id: bid, emoji: agent.emoji, text: `${agent.emoji} ${agent.name} ${t('pixelOffice.broadcast.online')}` }]) setTimeout(() => setBroadcasts(b => b.filter(x => x.id !== bid)), 5000) diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 70699f4..4657202 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -390,6 +390,8 @@ interface GreetingSequence { childId: number parentId: number childTarget: { col: number; row: number } | null + /** Where the parent walks to when they are NOT at their seat (midpoint meeting) */ + parentTarget: { col: number; row: number } | null /** Tile to wait at when MainAgent is busy with another greeter */ waitTarget: { col: number; row: number } | null phase: 'waiting' | 'walk' | 'pause' | 'parent_smile' | 'child_smile' | 'final_pause' | 'complete' @@ -439,6 +441,8 @@ export class OfficeState { private lingerSubagents: Map = new Map() /** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */ private exitOnGreetComplete: Set = new Set() + /** Agent IDs that were explicitly set to idle (下班) — only these should greet on next activation */ + private explicitlyIdledAgents: Set = new Set() /** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */ private exitReturnStash: Map = new Map() @@ -893,6 +897,41 @@ export class OfficeState { return null } + /** + * Find a midpoint meeting location when MainAgent is not at their seat. + * Returns childTarget (where child walks) and parentTarget (where parent walks), + * adjacent to each other near the midpoint between the two characters. + */ + private findMeetingTiles( + child: Character, + parent: Character, + ): { childTarget: { col: number; row: number }; parentTarget: { col: number; row: number } } | null { + const midCol = Math.round((child.tileCol + parent.tileCol) / 2) + const midRow = Math.round((child.tileRow + parent.tileRow) / 2) + + // Find walkable tile closest to midpoint for the parent to walk to + const parentTile = this.findClosestWalkable(midCol, midRow) + if (!parentTile) return null + + // Verify parent can reach that tile + const parentPath = findPath(parent.tileCol, parent.tileRow, parentTile.col, parentTile.row, this.tileMap, this.blockedTiles) + if (parentPath.length === 0 && !(parent.tileCol === parentTile.col && parent.tileRow === parentTile.row)) return null + + // Child walks to a tile adjacent to parent's meeting tile + const adjacents = [ + { col: parentTile.col - 1, row: parentTile.row }, + { col: parentTile.col + 1, row: parentTile.row }, + { col: parentTile.col, row: parentTile.row - 1 }, + { col: parentTile.col, row: parentTile.row + 1 }, + ] + for (const adj of adjacents) { + if (!isWalkable(adj.col, adj.row, this.tileMap, this.blockedTiles)) continue + const childPath = findPath(child.tileCol, child.tileRow, adj.col, adj.row, this.tileMap, this.blockedTiles) + if (childPath.length > 0) return { childTarget: adj, parentTarget: parentTile } + } + return null + } + /** Returns true if MainAgent is currently in an active greeting sequence */ private isMainAgentBusy(): boolean { if (this.mainAgentId === null) return false @@ -935,7 +974,25 @@ export class OfficeState { this.greetQueue.length = 0 return } - const greetTile = this.findAdjacentWalkable(mainCh) + // Determine meeting point based on whether MainAgent is at their seat + let greetTile: { col: number; row: number } | null + let parentTarget: { col: number; row: number } | null = null + + if (mainCh.state === CharacterState.TYPE) { + // MainAgent is at seat — child walks to adjacent tile, parent stays + greetTile = this.findAdjacentWalkable(mainCh) + } else { + // MainAgent is not at seat — meet halfway + const meeting = this.findMeetingTiles(ch, mainCh) + if (meeting) { + greetTile = meeting.childTarget + parentTarget = meeting.parentTarget + } else { + // Fallback: walk to wherever MainAgent currently is + greetTile = this.findAdjacentWalkable(mainCh) + } + } + if (!greetTile) { this.greetQueue.shift(); continue } const greetPath = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles) if (greetPath.length === 0) { this.greetQueue.shift(); continue } @@ -944,6 +1001,20 @@ export class OfficeState { ch.state = CharacterState.WALK ch.moveProgress = 0 ch.greetLocked = true + + // If parent needs to walk to meeting point, lock and start them moving now + if (parentTarget) { + const parentPath = findPath(mainCh.tileCol, mainCh.tileRow, parentTarget.col, parentTarget.row, this.tileMap, this.blockedTiles) + if (parentPath.length > 0) { + mainCh.greetLocked = true + mainCh.path = parentPath + mainCh.state = CharacterState.WALK + mainCh.moveProgress = 0 + } else { + parentTarget = null // parent already there or unreachable + } + } + const isExitGreeting = this.exitOnGreetComplete.has(ch.id) if (isExitGreeting) this.exitOnGreetComplete.delete(ch.id) // Recover the stashed return position for this exit greeting @@ -953,6 +1024,7 @@ export class OfficeState { childId: ch.id, parentId: this.mainAgentId!, childTarget: greetTile, + parentTarget, waitTarget: null, phase: 'walk', timer: 0, @@ -989,9 +1061,27 @@ export class OfficeState { const target = seq.childTarget if (!target) { seq.phase = 'complete'; break } - // Detected arrival: child tile matches target - if (child.tileCol === target.col && child.tileRow === target.row) { - // Override any FSM re-path + // If parent has a meeting target, keep them walking toward it + if (seq.parentTarget && parent) { + const pt = seq.parentTarget + const parentArrived = parent.tileCol === pt.col && parent.tileRow === pt.row + if (!parentArrived && parent.path.length === 0 && parent.state !== CharacterState.WALK) { + const repath = findPath(parent.tileCol, parent.tileRow, pt.col, pt.row, this.tileMap, this.blockedTiles) + if (repath.length > 0) { + parent.path = repath + parent.state = CharacterState.WALK + parent.moveProgress = 0 + parent.greetLocked = true + } + } + } + + const childArrived = child.tileCol === target.col && child.tileRow === target.row + const parentArrived = !seq.parentTarget || !parent || + (parent.tileCol === seq.parentTarget.col && parent.tileRow === seq.parentTarget.row) + + // Both have arrived at their respective meeting tiles + if (childArrived && parentArrived) { child.path = [] child.state = CharacterState.IDLE child.greetLocked = true @@ -1005,14 +1095,13 @@ export class OfficeState { seq.phase = 'pause' seq.timer = 1.0 } else if (child.path.length === 0 && child.state !== CharacterState.WALK) { - // Lost path mid-walk — try to re-path to target + // Child lost path mid-walk — try to re-path to target const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles) if (path.length > 0) { child.path = path child.state = CharacterState.WALK child.moveProgress = 0 } else { - // Can't reach — complete without greeting seq.phase = 'complete' } } @@ -1739,6 +1828,7 @@ export class OfficeState { childId: id, parentId: parentAgentId, childTarget: greetTile, + parentTarget: null, waitTarget: null, phase: 'walk', timer: 0, From c826424add6c916a47d731aef1ebebc23068d704 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 10:15:35 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20openclaw.json=20=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E5=82=99=E4=BB=BD=E3=80=81=E5=81=B5=E6=B8=AC=E6=90=8D=E6=AF=80?= =?UTF-8?q?=E8=88=87=E9=82=84=E5=8E=9F=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 備份機制 - 新增 lib/config-backup.ts:以 SHA-256 hash 偵測 openclaw.json 變更,自動備份 - Hash 持久化至 ~/.openclaw/backups/config/.last-hash,Next.js 重啟後仍能正確偵測變更 - 備份保留策略:滾動 8 個 + 昨天錨點 + 上週錨點(各取最後一個正常備份,≥ 1 KB) - 新增 API:GET /api/config-backup(列出備份)、POST /api/config-backup(還原) ## Dashboard 偵測與還原 UI(gateway-status.tsx) - Gateway 第一次失敗即觸發備份清單抓取,不等 30 秒 - 備份清單顯示大小、[建議] / [可能損毀] 標籤,並自動推薦第一個正常備份 - 還原後自動重啟 Gateway,並倒數 5 秒後自動重新整理頁面 - 提示使用者點選機器人卡片「測試」確認是否正常 - 「查看錯誤日誌」按鈕固定顯示於面板,可展開/收起最後 30 行 log - config 損毀導致頁面載入失敗時,錯誤頁仍顯示 GatewayStatus 還原入口 ## 多語系支援 - 所有新增文字支援繁中/简中/English(i18n.tsx 新增 8 個 key) Co-Authored-By: Claude Sonnet 4.6 --- app/api/config-backup/route.ts | 61 +++++++ app/api/config/route.ts | 13 ++ app/api/gateway-logs/route.ts | 4 + app/gateway-status.tsx | 303 +++++++++++++++++++++++++++++++-- app/page.tsx | 12 +- lib/config-backup.ts | 235 +++++++++++++++++++++++++ lib/i18n.tsx | 57 +++++++ 7 files changed, 669 insertions(+), 16 deletions(-) create mode 100644 app/api/config-backup/route.ts create mode 100644 lib/config-backup.ts diff --git a/app/api/config-backup/route.ts b/app/api/config-backup/route.ts new file mode 100644 index 0000000..482f884 --- /dev/null +++ b/app/api/config-backup/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, NextRequest } from "next/server"; +import { + listBackupFiles, + restoreFromBackup, + getBackupDir, +} from "@/lib/config-backup"; + +/** + * GET /api/config-backup + * 列出所有可用的 openclaw.json 備份 + */ +export async function GET() { + try { + const backups = listBackupFiles(); + return NextResponse.json({ + backupDir: getBackupDir(), + backups, + }); + } catch (err: any) { + return NextResponse.json( + { error: err.message }, + { status: 500 } + ); + } +} + +/** + * POST /api/config-backup + * 從指定備份還原 openclaw.json + * + * Request body: { filename: "openclaw.2026-03-15T08-30-00.json" } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { filename } = body; + + if (!filename || typeof filename !== "string") { + return NextResponse.json( + { error: "Missing or invalid 'filename' in request body" }, + { status: 400 } + ); + } + + const result = restoreFromBackup(filename); + + if (!result.success) { + return NextResponse.json( + { error: result.message }, + { status: 400 } + ); + } + + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json( + { error: err.message }, + { status: 500 } + ); + } +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts index c66f279..faf3a57 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; +import { detectChangeAndBackup } from "@/lib/config-backup"; // 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw const CONFIG_PATH = OPENCLAW_CONFIG_PATH; @@ -262,6 +263,10 @@ export async function GET() { try { const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); + + // 偵測 openclaw.json 是否有變更,若有則自動備份 + detectChangeAndBackup(raw); + const config = JSON.parse(raw); // 提取 agents 信息 @@ -536,6 +541,13 @@ export async function GET() { } } + // 取得 openclaw.json 的最後修改時間,用於前端偵測近期 config 變更 + let configLastModified: string | null = null; + try { + const stat = fs.statSync(CONFIG_PATH); + configLastModified = stat.mtime.toISOString(); + } catch { /* ignore */ } + const data = { agents: agentsWithStatus, providers, @@ -546,6 +558,7 @@ export async function GET() { host: process.env.NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL || config.gateway?.host || config.gateway?.hostname || "", }, groupChats, + configLastModified, }; configCache = { data, ts: Date.now() }; return NextResponse.json(data); diff --git a/app/api/gateway-logs/route.ts b/app/api/gateway-logs/route.ts index 4a27565..5079a26 100644 --- a/app/api/gateway-logs/route.ts +++ b/app/api/gateway-logs/route.ts @@ -39,11 +39,15 @@ export async function GET() { ? Date.now() - new Date(lastStallAt).getTime() < STALL_RECENT_MS : false; + // 回傳最後 30 行作為原始紀錄供 UI 顯示 + const recentLines = lines.slice(-30); + return NextResponse.json({ ok: true, issues: [...issues], lastStallAt, stallActive, + recentLines, }); } catch { return NextResponse.json({ ok: false, issues: [], lastStallAt: null, stallActive: false }); diff --git a/app/gateway-status.tsx b/app/gateway-status.tsx index e0ea97a..4fe42b3 100644 --- a/app/gateway-status.tsx +++ b/app/gateway-status.tsx @@ -25,6 +25,13 @@ interface LogResult { issues: string[]; lastStallAt: string | null; stallActive: boolean; + recentLines?: string[]; +} + +interface BackupEntry { + filename: string; + timestamp: string; + sizeBytes: number; } interface GatewayStatusProps { @@ -42,16 +49,17 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil const [restarting, setRestarting] = useState(false); const [restartMsg, setRestartMsg] = useState(null); - const checkHealth = useCallback(() => { - fetch("/api/gateway-health") - .then((r) => r.json()) - .then((d: HealthResult) => { - setHealth(d); - // If health is down, also fetch logs for more context - if (!d.ok) fetchLogs(); - }) - .catch(() => setHealth({ ok: false, error: t("gateway.fetchError") })); - }, [t]); + // Config backup/restore state + const [backups, setBackups] = useState([]); + const [restoring, setRestoring] = useState(false); + const [restoreMsg, setRestoreMsg] = useState(null); + const [reloadCountdown, setReloadCountdown] = useState(null); + const [showLogs, setShowLogs] = useState(false); + // Track consecutive failures to detect persistent config problems + const [consecutiveDownCount, setConsecutiveDownCount] = useState(0); + // Config change detection + const [configLastModified, setConfigLastModified] = useState(null); + const [configPromptDismissed, setConfigPromptDismissed] = useState(false); const fetchLogs = useCallback(() => { fetch("/api/gateway-logs") @@ -60,6 +68,46 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil .catch(() => {}); }, []); + const fetchBackups = useCallback(() => { + fetch("/api/config-backup") + .then((r) => r.json()) + .then((d) => setBackups(d.backups || [])) + .catch(() => setBackups([])); + }, []); + + const fetchConfigMtime = useCallback(() => { + fetch("/api/config") + .then((r) => r.json()) + .then((d) => { if (d.configLastModified) setConfigLastModified(d.configLastModified); }) + .catch(() => {}); + }, []); + + const checkHealth = useCallback(() => { + fetch("/api/gateway-health") + .then((r) => r.json()) + .then((d: HealthResult) => { + setHealth(d); + if (!d.ok) { + fetchLogs(); + setConsecutiveDownCount((c) => { + // 第一次失敗就抓備份,讓使用者一開面板就能看到 + if (c === 0) { + fetchBackups(); + fetchConfigMtime(); + } + return c + 1; + }); + } else { + setConsecutiveDownCount(0); + setConfigPromptDismissed(false); + } + }) + .catch(() => { + setHealth({ ok: false, error: t("gateway.fetchError") }); + setConsecutiveDownCount((c) => c + 1); + }); + }, [t, fetchLogs, fetchBackups, fetchConfigMtime]); + useEffect(() => { checkHealth(); const timer = setInterval(checkHealth, 10000); @@ -67,10 +115,15 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil }, [checkHealth]); const handleDetailClick = useCallback(() => { - setShowDetail((v) => !v); - // Fetch fresh logs whenever the user opens the detail panel - fetchLogs(); - }, [fetchLogs]); + setShowDetail((v) => { + if (!v) { + // Opening panel — fetch fresh data + fetchLogs(); + fetchBackups(); + } + return !v; + }); + }, [fetchLogs, fetchBackups]); const handleRestart = useCallback(async () => { if (restarting) return; @@ -96,6 +149,45 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil } }, [restarting, checkHealth]); + const handleRestore = useCallback(async (filename: string) => { + if (restoring) return; + setRestoring(true); + setRestoreMsg(null); + try { + const res = await fetch("/api/config-backup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }); + const data = await res.json(); + if (data.success) { + setRestoreMsg(t("gateway.restoreSuccess")); + // Auto-restart gateway after restore, then countdown to reload + setTimeout(async () => { + await fetch("/api/gateway-restart", { method: "POST" }).catch(() => {}); + // Start 5-second countdown + let count = 5; + setReloadCountdown(count); + const tick = setInterval(() => { + count -= 1; + if (count <= 0) { + clearInterval(tick); + window.location.reload(); + } else { + setReloadCountdown(count); + } + }, 1000); + }, 500); + } else { + setRestoreMsg(`${t("gateway.restoreFailed")}:${data.error || ""}`); + } + } catch (err: any) { + setRestoreMsg(`${t("gateway.restoreFailed")}:${err.message}`); + } finally { + setRestoring(false); + } + }, [restoring, checkHealth, t]); + const gatewayTitle = health?.openclawVersion ? `OpenClaw ${health.openclawVersion}` : "OpenClaw"; @@ -105,6 +197,17 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil const showWarning = telegramStall; // Show restart button when: down, or Telegram stalled const showRestart = health !== null; + // 只要 gateway 下線且有備份,就顯示還原清單(不等 3 次失敗) + const showConfigHint = !health?.ok && backups.length > 0; + + // Detect recent config change: modified within last 5 minutes + const configRecentlyChanged = (() => { + if (!configLastModified) return false; + const mtime = new Date(configLastModified).getTime(); + return Date.now() - mtime < 5 * 60 * 1000; + })(); + // 只要 gateway 下線 + config 近期有改動,就顯示醒目提示 + const showConfigChangePrompt = !health?.ok && configRecentlyChanged && !configPromptDismissed; return (
@@ -220,6 +323,149 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil
)} + {/* Log toggle — always visible when gateway is down */} + {health && !health.ok && ( + + )} + + {/* Log viewer */} + {showLogs && ( +
+ {logResult?.recentLines && logResult.recentLines.length > 0 ? ( +
+                    {logResult.recentLines.join("\n")}
+                  
+ ) : ( +

+ )} +
+ )} + + {/* Config change prompt — prominent banner when config recently changed */} + {showConfigChangePrompt && ( +
+
+ ⚠️ +
+
{t("gateway.noResponse")}
+
{t("gateway.configChanged")}
+
+
+ + {/* Action buttons */} +
+ {(() => { + const recommended = findRecommendedBackup(backups); + return recommended ? ( + + ) : null; + })()} + +
+
+ )} + + {/* Config error hint + backup restore (when no recent change detected, or dismissed the prompt) */} + {showConfigHint && !showConfigChangePrompt && ( +
+
+ 📋 +
+
{t("gateway.configError")}
+
{t("gateway.configErrorDesc")}
+
+
+ + {/* Backup list */} +
+
+ {t("gateway.backupAvailable")} ({backups.length}) +
+ {backups.map((b) => { + const isRecommended = b.sizeBytes >= 1024; + const isSuspect = b.sizeBytes < 1024; + return ( +
+
+ + {formatBackupTime(b.timestamp)} + + + {formatSize(b.sizeBytes)} + + {isRecommended && ( + {t("gateway.backupRecommended")} + )} + {isSuspect && ( + {t("gateway.backupSuspect")} + )} +
+ +
+ ); + })} +
+
+ )} + + {/* When gateway is down but no backups available */} + {!health?.ok && consecutiveDownCount >= 3 && backups.length === 0 && ( +
+ 📋 {t("gateway.noBackups")} +
+ )} + + {/* Restore result message */} + {restoreMsg && ( +
+ {restoreMsg} +
+ )} + + {/* Countdown to reload */} + {reloadCountdown !== null && ( +
+
+ + 🔄 {reloadCountdown} {t("gateway.reloadCountdown")} + + +
+
+ {t("gateway.reloadHint")} +
+
+ )} + {/* Restart result message */} {restartMsg && (
); } + +/** Format backup timestamp for display: "3/15 08:30" */ +function formatBackupTime(timestamp: string): string { + try { + const d = new Date(timestamp); + if (isNaN(d.getTime())) return timestamp; + const month = d.getMonth() + 1; + const day = d.getDate(); + const hour = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + return `${month}/${day} ${hour}:${min}`; + } catch { + return timestamp; + } +} + +/** Format file size for display */ +function formatSize(bytes: number): string { + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"; + return bytes + " B"; +} + +/** + * Find the recommended backup: the latest one with size >= 1 KB. + * Small files (< 1024 bytes) are likely broken/empty configs. + */ +function findRecommendedBackup(backups: BackupEntry[]): BackupEntry | null { + return backups.find((b) => b.sizeBytes >= 1024) ?? null; +} diff --git a/app/page.tsx b/app/page.tsx index cef3a75..3eb404c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -526,8 +526,16 @@ export default function Home() { if (error && !data) { return ( -
-

{t("common.loadError")}: {error}

+
+
+ +
+
+

{t("common.loadError")}: {error}

+

+ {t("gateway.configCorruptHint")} +

+
); } diff --git a/lib/config-backup.ts b/lib/config-backup.ts new file mode 100644 index 0000000..b9d5c61 --- /dev/null +++ b/lib/config-backup.ts @@ -0,0 +1,235 @@ +/** + * openclaw.json 備份與還原工具模組 + * + * 功能: + * 1. 透過 SHA-256 hash 偵測設定檔變更 + * 2. 變更時自動備份上一個版本 + * 3. 列出可用備份 + * 4. 從備份還原 + */ +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { OPENCLAW_HOME, OPENCLAW_CONFIG_PATH } from "./openclaw-paths"; + +// ── 常數 ──────────────────────────────────────────────── +const BACKUP_DIR = path.join(OPENCLAW_HOME, "backups", "config"); +const HASH_FILE = path.join(BACKUP_DIR, ".last-hash"); +const MAX_ROLLING = 8; // 一般滾動備份保留數 +const MIN_GOOD_SIZE = 1024; // 小於此 bytes 視為損毀,不計入錨點 + +// ── 持久化 hash(讀寫磁碟,重啟後仍有效)──────────────── +function readPersistedHash(): string | null { + try { + const h = fs.readFileSync(HASH_FILE, "utf-8").trim(); + return h.length === 64 ? h : null; // SHA-256 = 64 hex chars + } catch { + return null; + } +} + +function writePersistedHash(hash: string): void { + try { + ensureBackupDir(); + fs.writeFileSync(HASH_FILE, hash, "utf-8"); + } catch { /* ignore */ } +} + +// ── Hash ──────────────────────────────────────────────── +export function computeHash(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex"); +} + +// ── 備份目錄初始化 ────────────────────────────────────── +function ensureBackupDir(): void { + if (!fs.existsSync(BACKUP_DIR)) { + fs.mkdirSync(BACKUP_DIR, { recursive: true }); + } +} + +// ── 產生備份檔名 ──────────────────────────────────────── +function makeBackupFilename(): string { + // openclaw.2026-03-15T08-30-00.json + const ts = new Date() + .toISOString() + .replace(/:/g, "-") + .replace(/\.\d+Z$/, ""); + return `openclaw.${ts}.json`; +} + +// ── 執行備份(將「目前磁碟上的版本」存到備份資料夾)────── +export function backupCurrentConfig(): { filename: string } | null { + try { + const content = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"); + ensureBackupDir(); + const filename = makeBackupFilename(); + const dest = path.join(BACKUP_DIR, filename); + fs.writeFileSync(dest, content, "utf-8"); + pruneOldBackups(); + return { filename }; + } catch { + return null; + } +} + +// ── 清理備份,保留策略:────────────────────────────────── +// - 昨天錨點:昨天最後一個正常備份(sizeBytes >= MIN_GOOD_SIZE) +// - 上週錨點:2~7 天前最後一個正常備份 +// - 滾動視窗:最新 MAX_ROLLING 個(不含上述兩個錨點) +function pruneOldBackups(): void { + try { + const files = listBackupFiles(); // 最新在前 + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterdayStart = todayStart - 86400000; + const weekAgoStart = todayStart - 7 * 86400000; + + const toKeep = new Set(); + + // 昨天錨點 + const yesterdayAnchor = files.find((f) => { + const t = new Date(f.timestamp).getTime(); + return t >= yesterdayStart && t < todayStart && f.sizeBytes >= MIN_GOOD_SIZE; + }); + if (yesterdayAnchor) toKeep.add(yesterdayAnchor.filename); + + // 上週錨點(2~7 天前) + const weekAnchor = files.find((f) => { + const t = new Date(f.timestamp).getTime(); + return t >= weekAgoStart && t < yesterdayStart && f.sizeBytes >= MIN_GOOD_SIZE; + }); + if (weekAnchor) toKeep.add(weekAnchor.filename); + + // 滾動視窗:最新 MAX_ROLLING 個(錨點不佔名額) + let rollingCount = 0; + for (const f of files) { + if (toKeep.has(f.filename)) continue; + if (rollingCount < MAX_ROLLING) { + toKeep.add(f.filename); + rollingCount++; + } + } + + // 刪除不在保留名單的備份 + for (const f of files) { + if (!toKeep.has(f.filename)) { + try { fs.unlinkSync(path.join(BACKUP_DIR, f.filename)); } catch { /* ignore */ } + } + } + } catch { /* ignore */ } +} + +// ── 列出所有備份 ──────────────────────────────────────── +export interface BackupEntry { + filename: string; + timestamp: string; // ISO 格式 + sizeBytes: number; +} + +export function listBackupFiles(): BackupEntry[] { + try { + ensureBackupDir(); + const files = fs.readdirSync(BACKUP_DIR) + .filter((f) => f.startsWith("openclaw.") && f.endsWith(".json")); + + return files + .map((filename) => { + const stat = fs.statSync(path.join(BACKUP_DIR, filename)); + // 從檔名解析時間戳:openclaw.2026-03-15T08-30-00.json + const tsMatch = filename.match(/^openclaw\.(.+)\.json$/); + const timestamp = tsMatch + ? tsMatch[1].replace(/-(\d{2})-(\d{2})$/, ":$1:$2").replace(/T(\d{2})-/, "T$1:") + : stat.mtime.toISOString(); + return { filename, timestamp, sizeBytes: stat.size }; + }) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)); // 最新在前 + } catch { + return []; + } +} + +// ── 從備份還原 ────────────────────────────────────────── +export interface RestoreResult { + success: boolean; + message: string; + restoredFrom?: string; + backedUpAs?: string; +} + +export function restoreFromBackup(filename: string): RestoreResult { + const backupPath = path.join(BACKUP_DIR, filename); + + // 安全檢查:防止 path traversal + if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) { + return { success: false, message: "Invalid filename" }; + } + + if (!fs.existsSync(backupPath)) { + return { success: false, message: `Backup not found: ${filename}` }; + } + + try { + // 讀取備份內容並驗證是否為合法 JSON + const backupContent = fs.readFileSync(backupPath, "utf-8"); + JSON.parse(backupContent); // 驗證 JSON 格式 + + // 先備份當前版本(還原前的安全網) + const currentBackup = backupCurrentConfig(); + + // 執行還原 + fs.writeFileSync(OPENCLAW_CONFIG_PATH, backupContent, "utf-8"); + + // 還原後持久化 hash,讓下次 polling 不會再觸發備份 + writePersistedHash(computeHash(backupContent)); + + return { + success: true, + message: `Restored from ${filename}`, + restoredFrom: filename, + backedUpAs: currentBackup?.filename, + }; + } catch (err: any) { + return { success: false, message: `Restore failed: ${err.message}` }; + } +} + +// ── 偵測變更並自動備份(在 /api/config GET 中呼叫)────── +export interface ChangeDetectionResult { + changed: boolean; + currentHash: string; + backedUp: boolean; + backupFilename?: string; +} + +export function detectChangeAndBackup(rawContent: string): ChangeDetectionResult { + const currentHash = computeHash(rawContent); + const lastKnownHash = readPersistedHash(); + + // 第一次執行(無持久化記錄):記錄 hash,不觸發備份 + if (lastKnownHash === null) { + writePersistedHash(currentHash); + return { changed: false, currentHash, backedUp: false }; + } + + // Hash 未變:無需備份 + if (currentHash === lastKnownHash) { + return { changed: false, currentHash, backedUp: false }; + } + + // Hash 已變:備份目前版本,更新持久化 hash + const backup = backupCurrentConfig(); + writePersistedHash(currentHash); + + return { + changed: true, + currentHash, + backedUp: backup !== null, + backupFilename: backup?.filename, + }; +} + +// ── 取得備份目錄路徑(供外部使用)──────────────────────── +export function getBackupDir(): string { + return BACKUP_DIR; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 210af68..4813505 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -221,6 +221,25 @@ const translations: Record> = { "gateway.healthy": "Gateway 運作正常", "gateway.unhealthy": "Gateway 異常", "gateway.fetchError": "無法檢查 Gateway 狀態", + "gateway.noResponse": "Gateway 無回應", + "gateway.configChanged": "偵測到 openclaw.json 最近有變更,可能是設定錯誤導致。", + "gateway.configError": "設定檔可能有誤", + "gateway.configErrorDesc": "Gateway 無法啟動,可能是 openclaw.json 設定錯誤", + "gateway.restorePrev": "🔄 還原上一版設定", + "gateway.viewLogs": "📋 查看錯誤日誌", + "gateway.dismiss": "❌ 不處理", + "gateway.backupAvailable": "有可用備份", + "gateway.restoreBackup": "還原備份", + "gateway.restoring": "還原中…", + "gateway.restoreSuccess": "✅ 已還原,正在重啟 Gateway…", + "gateway.restoreFailed": "❌ 還原失敗", + "gateway.noBackups": "沒有可用的備份", + "gateway.backupRecommended": "建議", + "gateway.backupSuspect": "可能損毀", + "gateway.reloadCountdown": "秒後自動重新整理…", + "gateway.reloadNow": "立即重新整理", + "gateway.reloadHint": "重新整理後,可點選機器人卡片上的「測試」確認是否正常運作", + "gateway.configCorruptHint": "設定檔可能損毀,請使用上方 Gateway 面板還原備份", // pixel office "pixelOffice.title": "OpenClaw Agents 辦公室", @@ -481,6 +500,25 @@ const translations: Record> = { "gateway.healthy": "Gateway 运行正常", "gateway.unhealthy": "Gateway 异常", "gateway.fetchError": "无法检查 Gateway 状态", + "gateway.noResponse": "Gateway 无响应", + "gateway.configChanged": "检测到 openclaw.json 最近有变更,可能是配置错误导致。", + "gateway.configError": "配置文件可能有误", + "gateway.configErrorDesc": "Gateway 无法启动,可能是 openclaw.json 配置错误", + "gateway.restorePrev": "🔄 还原上一版配置", + "gateway.viewLogs": "📋 查看错误日志", + "gateway.dismiss": "❌ 不处理", + "gateway.backupAvailable": "有可用备份", + "gateway.restoreBackup": "还原备份", + "gateway.restoring": "还原中…", + "gateway.restoreSuccess": "✅ 已还原,正在重启 Gateway…", + "gateway.restoreFailed": "❌ 还原失败", + "gateway.noBackups": "没有可用的备份", + "gateway.backupRecommended": "建议", + "gateway.backupSuspect": "可能损坏", + "gateway.reloadCountdown": "秒后自动刷新…", + "gateway.reloadNow": "立即刷新", + "gateway.reloadHint": "刷新后,可点击机器人卡片上的「测试」确认是否正常运作", + "gateway.configCorruptHint": "配置文件可能损坏,请使用上方 Gateway 面板还原备份", // pixel office "pixelOffice.title": "OpenClaw Agents办公室", @@ -741,6 +779,25 @@ const translations: Record> = { "gateway.healthy": "Gateway is running", "gateway.unhealthy": "Gateway is down", "gateway.fetchError": "Cannot check Gateway status", + "gateway.noResponse": "Gateway is not responding", + "gateway.configChanged": "openclaw.json was recently modified. This may be caused by a config error.", + "gateway.configError": "Config file may have errors", + "gateway.configErrorDesc": "Gateway failed to start, possibly due to openclaw.json config errors", + "gateway.restorePrev": "🔄 Restore previous config", + "gateway.viewLogs": "📋 View error logs", + "gateway.dismiss": "❌ Dismiss", + "gateway.backupAvailable": "Backup available", + "gateway.restoreBackup": "Restore backup", + "gateway.restoring": "Restoring…", + "gateway.restoreSuccess": "✅ Restored, restarting Gateway…", + "gateway.restoreFailed": "❌ Restore failed", + "gateway.noBackups": "No backups available", + "gateway.backupRecommended": "Recommended", + "gateway.backupSuspect": "Possibly corrupt", + "gateway.reloadCountdown": "s until auto-refresh…", + "gateway.reloadNow": "Refresh now", + "gateway.reloadHint": "After refresh, click the Test button on each bot card to verify it's working.", + "gateway.configCorruptHint": "Config may be corrupt. Use the Gateway panel above to restore a backup.", // pixel office "pixelOffice.title": "OpenClaw Agents Office", From 2352ee9b85b9c1ad928339784d631f46c772cda1 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 15 Mar 2026 11:31:53 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E5=83=8F=E7=B4=A0=E8=BE=A6?= =?UTF-8?q?=E5=85=AC=E5=AE=A4=E9=A1=AF=E7=A4=BA=20Agent=20=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E5=9F=B7=E8=A1=8C=E7=9A=84=E4=BA=A4=E8=BE=A6=E4=BB=BB?= =?UTF-8?q?=E5=8B=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-activity API 新增 lastTask 欄位,回傳 working 中 Agent 最後被交辦的任務內容 - 支援兩種格式:Telegram 訊息(``` fence 後的文字)與子 Agent spawn([Subagent Task]: 內容) - Character 新增 taskText 欄位,OfficeState 新增 setAgentTaskText() 方法 - agentBridge 同步任務文字至像素辦公室角色 - renderer 在 working 中的 Agent 頭上顯示固定寬度氣泡,內容過長自動跑馬燈捲動 Co-Authored-By: Claude Sonnet 4.6 --- app/api/agent-activity/route.ts | 91 ++++++++++++++++++++++++++ lib/pixel-office/agentBridge.ts | 9 ++- lib/pixel-office/engine/officeState.ts | 7 ++ lib/pixel-office/engine/renderer.ts | 78 ++++++++++++++++++++++ lib/pixel-office/types.ts | 2 + 5 files changed, 184 insertions(+), 3 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index a7fc4d5..a7afc78 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -38,6 +38,7 @@ export interface AgentActivity { toolStatus?: string lastActive: number subagents?: SubagentInfo[] + lastTask?: string } type AgentConfigEntry = { @@ -560,6 +561,89 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis return allSubagents } +/** + * Extract the actual user-typed text from a session message block. + * + * Handles three formats: + * 1. Subagent spawn: contains "[Subagent Task]: ..." — extract what follows the label + * 2. Channel message (Telegram etc.): injected "Conversation info" + "Sender" code fences + * before the real text — extract what comes after the last ``` fence + * 3. Fallback: strip XML context blocks and leading timestamp + */ +function extractUserText(rawText: string): string | null { + // Strip XML context injections first (relevant-memories etc.) + const noXml = rawText.replace(/<[a-z][\s\S]*?<\/[a-z][^>]*>/gi, '') + + // Strategy 1: subagent task — "[Subagent Task]: ..." + const subagentMatch = noXml.match(/\[Subagent Task\]:\s*([\s\S]+)/) + if (subagentMatch) { + return subagentMatch[1].replace(/\s+/g, ' ').trim().slice(0, 500) + } + + // Strategy 2: channel message — text after last ``` fence + const lastFence = rawText.lastIndexOf('```') + if (lastFence !== -1) { + const afterFence = rawText.slice(lastFence + 3).trim() + if (afterFence.length > 3) { + return afterFence.replace(/\s+/g, ' ').trim().slice(0, 500) + } + } + + // Strategy 3: strip timestamp prefix and return remaining + const noTs = noXml.replace(/^\[[^\]]{5,40}\]\s*/, '').trim() + const text = noTs.replace(/\s+/g, ' ').trim() + return text.length > 5 ? text.slice(0, 500) : null +} + +/** + * Extract the last user message text from a session file — used as the agent's "last task". + * + * Priority: + * 1. The original subagent spawn task ("[Subagent Task]: ...") — scan from the start + * 2. Most recent real user message — scan from the end, skip system notifications + */ +async function extractLastUserTask(sessionFilePath: string): Promise { + try { + const content = await fs.readFile(sessionFilePath, 'utf8') + const allLines = content.split('\n').filter(l => l.trim()) + + // Pass 1: find the first [Subagent Task] (set at spawn time, stable throughout session) + for (const line of allLines.slice(0, 40)) { + try { + const record = JSON.parse(line) + if (record.type !== 'message' || !record.message) continue + if (record.message.role !== 'user') continue + const blocks = Array.isArray(record.message.content) ? record.message.content : [] + for (const block of blocks) { + if (block?.type !== 'text' || typeof block.text !== 'string') continue + if (!block.text.includes('[Subagent Task]')) continue + const text = extractUserText(block.text) + if (text) return text + } + } catch { /* skip */ } + } + + // Pass 2: most recent real user message (direct agents, e.g. main) + const skipPhrases = ['A completed subagent task', 'Action:\n', 'END_UNTRUSTED_CHILD_RESULT', 'Continue where you left off'] + const recent = allLines.slice(-80) + for (let i = recent.length - 1; i >= 0; i--) { + try { + const record = JSON.parse(recent[i]) + if (record.type !== 'message' || !record.message) continue + if (record.message.role !== 'user') continue + const blocks = Array.isArray(record.message.content) ? record.message.content : [] + for (const block of blocks) { + if (block?.type !== 'text' || typeof block.text !== 'string') continue + if (skipPhrases.some(p => block.text.includes(p))) continue + const text = extractUserText(block.text) + if (text) return text + } + } catch { /* skip */ } + } + } catch { /* ignore */ } + return null +} + /** * Read last N lines of a JSONL session file and determine the agent's true working state. * @@ -701,6 +785,12 @@ export async function GET() { if (subagents.length === 0) subagents = undefined } + // Extract last user task for working agents + let lastTask: string | undefined + if (state === 'working' && mostRecentSessionFile && existsSync(mostRecentSessionFile)) { + lastTask = (await extractLastUserTask(mostRecentSessionFile)) ?? undefined + } + agents.push({ agentId: agent.id, name: agent.name || agent.id, @@ -708,6 +798,7 @@ export async function GET() { state, lastActive, subagents, + lastTask, }) } } diff --git a/lib/pixel-office/agentBridge.ts b/lib/pixel-office/agentBridge.ts index 44c66d2..2ab37a5 100644 --- a/lib/pixel-office/agentBridge.ts +++ b/lib/pixel-office/agentBridge.ts @@ -17,6 +17,7 @@ export interface AgentActivity { toolStatus?: string lastActive: number subagents?: SubagentInfo[] + lastTask?: string } /** Track which subagent keys were active last sync, per parent agent */ @@ -60,10 +61,10 @@ export function syncAgentsToOffice( if (charId === undefined) { charId = nextIdRef.current++ agentIdMap.set(activity.agentId, charId) - // Spawn at door if agent was previously offline or is brand new + // 只有從 offline 恢復時才從門口走進來; + // 頁面初始載入(isNew)時直接放到座位,避免讓使用者以為角色剛去摸魚回來 const wasOffline = prevAgentStates.get(activity.agentId) === 'offline' - const isNew = !prevAgentStates.has(activity.agentId) - office.addAgent(charId, undefined, undefined, undefined, undefined, wasOffline || isNew) + office.addAgent(charId, undefined, undefined, undefined, undefined, wasOffline) } // Set label (agent name with id in parentheses) @@ -76,10 +77,12 @@ export function syncAgentsToOffice( case 'working': office.setAgentActive(charId, true) office.setAgentTool(charId, activity.currentTool || null) + office.setAgentTaskText(charId, activity.lastTask) break case 'idle': office.setAgentActive(charId, false) office.setAgentTool(charId, null) + office.setAgentTaskText(charId, undefined) break case 'waiting': office.setAgentActive(charId, true) diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 4657202..63406cd 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1949,6 +1949,13 @@ export class OfficeState { } } + setAgentTaskText(id: number, text: string | undefined): void { + const ch = this.characters.get(id) + if (ch) { + ch.taskText = text + } + } + showPermissionBubble(id: number): void { const ch = this.characters.get(id) if (ch) { diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 5c599a6..2f7dbeb 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -218,6 +218,22 @@ interface ZDrawable { draw: (ctx: CanvasRenderingContext2D) => void } +/** Wrap task text at maxChars characters or at Chinese punctuation boundaries */ +function wrapTaskText(text: string, maxChars = 10): string[] { + const punctuation = /[,。!?、;:,!?;:]/ + const lines: string[] = [] + let current = '' + for (let i = 0; i < text.length; i++) { + current += text[i] + if (punctuation.test(text[i]) || current.length >= maxChars) { + lines.push(current) + current = '' + } + } + if (current) lines.push(current) + return lines.slice(0, 6) // max 6 lines +} + export function renderScene( ctx: CanvasRenderingContext2D, furniture: FurnitureInstance[], @@ -559,6 +575,68 @@ export function renderScene( }) } + // Task text bubble: scrolling marquee above the agent's head when working + if (ch.taskText && ch.isActive && ch.state === CharacterState.TYPE && !ch.isSubagent) { + const taskX = Math.round(offsetX + ch.x * zoom) + const labelFontSize = Math.max(12, Math.round(5.25 * zoom)) + const taskFontSize = Math.max(10, Math.round(4.5 * zoom)) + const padX = 5 * zoom + const padY = 3 * zoom + const bubbleH = taskFontSize + padY * 2 + const bubbleW = Math.round(72 * zoom) // fixed width window + const boxX = taskX - bubbleW / 2 + const boxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom + const fullText = ch.taskText + drawables.push({ + zY: charZY + 0.2, + draw: (c) => { + c.save() + c.font = `${taskFontSize}px sans-serif` + const fullW = c.measureText(fullText).width + // Scroll speed: pixels per ms. Pause at start/end with a gap. + const gap = bubbleW * 0.5 + const cycle = fullW + gap + const speed = 30 // px/s at zoom=1, scaled below + const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle) + const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0) + + // Bubble background + const r = 3 * zoom + c.beginPath() + c.moveTo(boxX + r, boxY) + c.lineTo(boxX + bubbleW - r, boxY) + c.arcTo(boxX + bubbleW, boxY, boxX + bubbleW, boxY + r, r) + c.lineTo(boxX + bubbleW, boxY + bubbleH - r) + c.arcTo(boxX + bubbleW, boxY + bubbleH, boxX + bubbleW - r, boxY + bubbleH, r) + // Tail pointing down + const tailW = 5 * zoom + c.lineTo(taskX + tailW, boxY + bubbleH) + c.lineTo(taskX, boxY + bubbleH + 4 * zoom) + c.lineTo(taskX - tailW, boxY + bubbleH) + c.lineTo(boxX + r, boxY + bubbleH) + c.arcTo(boxX, boxY + bubbleH, boxX, boxY + bubbleH - r, r) + c.lineTo(boxX, boxY + r) + c.arcTo(boxX, boxY, boxX + r, boxY, r) + c.closePath() + c.fillStyle = 'rgba(15,23,42,0.88)' + c.fill() + c.strokeStyle = 'rgba(99,102,241,0.7)' + c.lineWidth = zoom + c.stroke() + + // Clip to bubble interior and draw scrolling text + c.beginPath() + c.rect(boxX + padX, boxY, bubbleW - padX * 2, bubbleH) + c.clip() + c.fillStyle = '#e2e8f0' + c.textAlign = 'left' + c.textBaseline = 'middle' + c.fillText(fullText, textX, boxY + bubbleH / 2) + c.restore() + }, + }) + } + // Code snippet particles are rendered as DOM overlays in app/pixel-office/page.tsx // so they can float beyond the canvas area and pass over the top agent list. } diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index e59582b..e2c5829 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -212,6 +212,8 @@ export interface Character { isSystemRole?: boolean systemRoleType?: 'gateway_sre' systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down' + /** Last assigned task text to display above the agent's head */ + taskText?: string /** Walk back to this tile after farewell greeting, then despawn */ pendingDespawn?: { col: number; row: number } | true /** Seconds remaining while character waits at dodge tile before resuming path */ From b12a5c99723a1538c2c78b2230820d4420df1600 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Fri, 20 Mar 2026 10:42:35 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E8=AA=BF=E6=95=B4pix=20office=20=E8=A1=8C?= =?UTF-8?q?=E7=82=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/agent-activity/route.ts | 55 +++++++++++++++---- app/api/config/route.ts | 22 +++++++- app/globals.css | 3 ++ app/pixel-office/page.tsx | 26 ++++----- lib/pixel-office/engine/officeState.ts | 75 +++++++++++++++++++------- lib/pixel-office/engine/renderer.ts | 42 +++++++++------ 6 files changed, 162 insertions(+), 61 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index a7afc78..5569505 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -658,9 +658,14 @@ async function detectStateFromSession( now: number, lastActive: number, ): Promise<'idle' | 'working' | 'offline'> { - const OFFLINE_MS = 10 * 60 * 1000 + if (lastActive === 0) return 'offline' + + const OFFLINE_MS = 10 * 60 * 1000 // idle > 10 min → offline + const WORKING_MAX_MS = 10 * 60 * 1000 // working > 10 min → force idle const timeDiff = now - lastActive - if (lastActive === 0 || timeDiff > OFFLINE_MS) return 'offline' + + // Last activity > 10 min ago → offline regardless of session content + if (timeDiff > OFFLINE_MS) return 'offline' try { const content = await fs.readFile(sessionFilePath, 'utf8') @@ -682,17 +687,15 @@ async function detectStateFromSession( } catch { /* skip malformed line */ } } - // User message is the last written record → agent is generating a response right now - if (lastRole === 'user') return 'working' - // toolResult as the latest message → agent is processing the tool output - if (lastRole === 'toolResult') return 'working' - // Agent called a tool and is waiting for the result - if (lastStopReason === 'toolUse') return 'working' - // Agent completed its turn — it's now idle + // Work completed → immediately idle if (lastStopReason === 'stop') return 'idle' + // Still processing — but cap at 10 min, after that force idle + if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') { + return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle' + } } catch { /* file unreadable — fall through */ } - // Fallback: time-based (no session content available) + // Fallback within the 10-min window return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle' } @@ -716,6 +719,36 @@ export async function GET() { let mostRecentSessionFile: string | null = null let agentSessionsDir = '' + // Resolve emoji: IDENTITY.md > agent.json > openclaw.json > default + let agentJsonEmoji: string | undefined + if (existsSync(agentsDir)) { + // 1. Read from workspace IDENTITY.md ("- **Emoji:** 🌸") + const workspaceDir = typeof (agent as any).workspace === 'string' ? (agent as any).workspace : null + if (workspaceDir) { + const identityPath = path.join(workspaceDir, 'IDENTITY.md') + if (existsSync(identityPath)) { + try { + const identityRaw = await fs.readFile(identityPath, 'utf8') + const m = identityRaw.match(/\*\*Emoji:\*\*\s*(\S+)/) + if (m?.[1]) agentJsonEmoji = m[1] + } catch { /* ignore */ } + } + } + // 2. Fallback: read from agent's agent.json emoji field + if (!agentJsonEmoji) { + const agentJsonPath = path.join(agentsDir, agent.id, 'agent', 'agent.json') + if (existsSync(agentJsonPath)) { + try { + const raw = await fs.readFile(agentJsonPath, 'utf8') + const parsed = JSON.parse(raw) + if (typeof parsed?.emoji === 'string' && parsed.emoji.trim()) { + agentJsonEmoji = parsed.emoji.trim() + } + } catch { /* ignore */ } + } + } + } + if (existsSync(agentsDir)) { agentSessionsDir = path.join(agentsDir, agent.id, 'sessions') if (existsSync(agentSessionsDir)) { @@ -794,7 +827,7 @@ export async function GET() { agents.push({ agentId: agent.id, name: agent.name || agent.id, - emoji: agent.identity?.emoji || agent.emoji || '🤖', + emoji: agentJsonEmoji || agent.identity?.emoji || agent.emoji || '🤖', state, lastActive, subagents, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index faf3a57..feff71e 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -232,6 +232,25 @@ function getChannelDirectPeerIds( return map; } // 从 IDENTITY.md 读取机器人名字 +function readIdentityEmoji(agentId: string, agentDir?: string, workspace?: string): string | null { + const candidates = [ + agentDir ? path.join(agentDir, "IDENTITY.md") : null, + workspace ? path.join(workspace, "IDENTITY.md") : null, + path.join(OPENCLAW_DIR, `agents/${agentId}/agent/IDENTITY.md`), + path.join(OPENCLAW_DIR, `workspace-${agentId}/IDENTITY.md`), + agentId === "main" ? path.join(OPENCLAW_DIR, `workspace/IDENTITY.md`) : null, + ].filter(Boolean) as string[]; + + for (const p of candidates) { + try { + const content = fs.readFileSync(p, "utf-8"); + const match = content.match(/\*\*Emoji:\*\*\s*(\S+)/); + if (match?.[1]) return match[1].trim(); + } catch {} + } + return null; +} + function readIdentityName(agentId: string, agentDir?: string, workspace?: string): string | null { const candidates = [ agentDir ? path.join(agentDir, "IDENTITY.md") : null, @@ -354,7 +373,8 @@ export async function GET() { const id = agent.id; const identityName = readIdentityName(id, agent.agentDir, agent.workspace); const name = identityName || agent.name || id; - const emoji = agent.identity?.emoji || "🤖"; + const identityEmoji = readIdentityEmoji(id, agent.agentDir, agent.workspace); + const emoji = identityEmoji || agent.identity?.emoji || agent.emoji || "🤖"; const model = normalizeModelRef(agent.model, defaultModel); // 查找绑定的平台 diff --git a/app/globals.css b/app/globals.css index 71b55dd..1d792f9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -134,3 +134,6 @@ body { border-top: 1px solid var(--border); padding: 12px 16px; } + +.no-scrollbar::-webkit-scrollbar { display: none; } +.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index c2c4e39..d6b5d13 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -1814,22 +1814,7 @@ export default function PixelOfficePage() {
-
- {displayAgents.length === 0 ? ( -
{t('common.noData')}
- ) : ( -
- {mobileAgentPages.map((page, pageIndex) => ( -
- {page.map((agent) => renderAgentChip(agent, true))} - {page.length < 9 && Array.from({ length: 9 - page.length }).map((_, i) => ( -
- ))} -
- ))} -
- )} -
+{/* Mobile agent list moved to canvas overlay below */}
{displayAgents.map((agent) => renderAgentChip(agent))} {displayAgents.length === 0 && ( @@ -1858,6 +1843,15 @@ export default function PixelOfficePage() {
)} + {/* Mobile agent list overlay at bottom of canvas */} + {isMobileViewport && ( +
+
+ {displayAgents.map((agent) => renderAgentChip(agent, true))} +
+
+ )} + {/* Broadcast notifications */} {broadcasts.length > 0 && (
diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 63406cd..6502cef 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -399,6 +399,8 @@ interface GreetingSequence { isExit: boolean /** Tile to walk back to after farewell, before despawning */ exitReturnPos: { col: number; row: number } | null + /** True when child arrived at greeting tile but MainAgent wasn't physically present */ + parentAbsent?: boolean } export class OfficeState { @@ -1091,6 +1093,14 @@ export class OfficeState { parent.state = CharacterState.IDLE child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow) parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow) + // Detect if parent is not physically nearby (wandered away from greeting tile) + const distToParent = Math.abs(parent.tileCol - child.tileCol) + Math.abs(parent.tileRow - child.tileRow) + if (seq.isExit && distToParent > 3) { + seq.parentAbsent = true + } + } else { + // Parent character doesn't exist + if (seq.isExit) seq.parentAbsent = true } seq.phase = 'pause' seq.timer = 1.0 @@ -1169,13 +1179,8 @@ export class OfficeState { if (seq.isExit) { child.bubbleType = null - const walkBack = this.findExitWalkPath(child, seq.exitReturnPos) - if (walkBack) { - child.path = walkBack.path - child.state = CharacterState.WALK - child.moveProgress = 0 - child.pendingDespawn = walkBack.target - } else { + // After farewell (whether MainAgent was present or not), walk to bottom-right corner + if (!this.walkToBottomRightThenDespawn(child)) { child.matrixEffect = 'despawn' child.matrixEffectTimer = 0 child.matrixEffectSeeds = matrixEffectSeeds() @@ -1231,6 +1236,38 @@ export class OfficeState { return this.findClosestWalkable(sofa.col, sofa.row) } + /** Find the walkable tile closest to the bottom-right corner of the map */ + private findBottomRightCornerTile(): { col: number; row: number } | null { + if (this.walkableTiles.length === 0) return null + // Bottom-right in tile space = max col + max row + const maxCol = Math.max(...this.walkableTiles.map((t) => t.col)) + const maxRow = Math.max(...this.walkableTiles.map((t) => t.row)) + return this.findClosestWalkable(maxCol, maxRow) + } + + /** + * Walk character to the bottom-right corner then despawn. + * Unlike findExitWalkPath, accepts any path length >= 1 so nearby agents still move. + */ + private walkToBottomRightThenDespawn(ch: Character): boolean { + const corner = this.findBottomRightCornerTile() + if (!corner) return false + if (corner.col === ch.tileCol && corner.row === ch.tileRow) { + // Already there — despawn directly + ch.matrixEffect = 'despawn' + ch.matrixEffectTimer = 0 + ch.matrixEffectSeeds = matrixEffectSeeds() + return true + } + const path = findPath(ch.tileCol, ch.tileRow, corner.col, corner.row, this.tileMap, this.blockedTiles) + if (path.length === 0) return false + ch.path = path + ch.state = CharacterState.WALK + ch.moveProgress = 0 + ch.pendingDespawn = corner + return true + } + /** * Find a walk-back path for a departing temp worker. * Ensures the path is long enough that the worker visibly walks away from MainAgent. @@ -1768,14 +1805,8 @@ export class OfficeState { // Stash the return position until the greeting actually starts if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos) } else { - // No MainAgent reachable — walk back directly then despawn - const walkBack = this.findExitWalkPath(ch, exitReturnPos) - if (walkBack) { - ch.pendingDespawn = walkBack.target - ch.path = walkBack.path - ch.state = CharacterState.WALK - ch.moveProgress = 0 - } else { + // No MainAgent reachable — walk to bottom-right corner then despawn + if (!this.walkToBottomRightThenDespawn(ch)) { ch.matrixEffect = 'despawn' ch.matrixEffectTimer = 0 ch.matrixEffectSeeds = matrixEffectSeeds() @@ -1842,9 +1873,12 @@ export class OfficeState { continue } } - ch.matrixEffect = 'despawn' - ch.matrixEffectTimer = 0 - ch.matrixEffectSeeds = matrixEffectSeeds() + // No greeting path — walk to bottom-right corner then despawn + if (!this.walkToBottomRightThenDespawn(ch)) { + ch.matrixEffect = 'despawn' + ch.matrixEffectTimer = 0 + ch.matrixEffectSeeds = matrixEffectSeeds() + } } this.subagentMeta.delete(id) if (this.selectedAgentId === id) this.selectedAgentId = null @@ -2107,6 +2141,9 @@ export class OfficeState { if (dodgeTile) { ch.path = [dodgeTile] ch.moveProgress = 0 + // Face the dodge direction immediately so the character doesn't appear to + // move backward while still facing forward. + ch.dir = this.directionToward(ch.tileCol, ch.tileRow, dodgeTile.col, dodgeTile.row) ch.yieldDestination = { col: dest.col, row: dest.row } claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id) claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id) @@ -2155,6 +2192,8 @@ export class OfficeState { dodger.state = CharacterState.WALK dodger.frame = 0 dodger.frameTimer = 0 + // Face the dodge direction immediately + dodger.dir = this.directionToward(dodger.tileCol, dodger.tileRow, dodgeTile.col, dodgeTile.row) claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id) occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`) occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`) diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 2f7dbeb..670e978 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -575,7 +575,7 @@ export function renderScene( }) } - // Task text bubble: scrolling marquee above the agent's head when working + // Task text bubble: scrolling marquee above (or below if near top) the agent's head if (ch.taskText && ch.isActive && ch.state === CharacterState.TYPE && !ch.isSubagent) { const taskX = Math.round(offsetX + ch.x * zoom) const labelFontSize = Math.max(12, Math.round(5.25 * zoom)) @@ -583,9 +583,13 @@ export function renderScene( const padX = 5 * zoom const padY = 3 * zoom const bubbleH = taskFontSize + padY * 2 - const bubbleW = Math.round(72 * zoom) // fixed width window - const boxX = taskX - bubbleW / 2 - const boxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom + const bubbleW = Math.round(72 * zoom) + const boxX = Math.max(2, Math.min(taskX - bubbleW / 2, (ctx.canvas.width - bubbleW - 2))) + const idealBoxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom + // If bubble would be clipped by top edge, flip it below the character instead + const belowY = drawY + cached.height + labelFontSize + 4 * zoom + const tailUp = idealBoxY >= 4 // tail points down when bubble is above, up when below + const boxY = idealBoxY >= 4 ? idealBoxY : belowY const fullText = ch.taskText drawables.push({ zY: charZY + 0.2, @@ -593,30 +597,39 @@ export function renderScene( c.save() c.font = `${taskFontSize}px sans-serif` const fullW = c.measureText(fullText).width - // Scroll speed: pixels per ms. Pause at start/end with a gap. const gap = bubbleW * 0.5 const cycle = fullW + gap - const speed = 30 // px/s at zoom=1, scaled below + const speed = 30 const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle) const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0) - // Bubble background const r = 3 * zoom + const tailW = 5 * zoom c.beginPath() c.moveTo(boxX + r, boxY) c.lineTo(boxX + bubbleW - r, boxY) c.arcTo(boxX + bubbleW, boxY, boxX + bubbleW, boxY + r, r) c.lineTo(boxX + bubbleW, boxY + bubbleH - r) c.arcTo(boxX + bubbleW, boxY + bubbleH, boxX + bubbleW - r, boxY + bubbleH, r) - // Tail pointing down - const tailW = 5 * zoom - c.lineTo(taskX + tailW, boxY + bubbleH) - c.lineTo(taskX, boxY + bubbleH + 4 * zoom) - c.lineTo(taskX - tailW, boxY + bubbleH) + if (tailUp) { + // Tail at bottom pointing down toward label + c.lineTo(taskX + tailW, boxY + bubbleH) + c.lineTo(taskX, boxY + bubbleH + 4 * zoom) + c.lineTo(taskX - tailW, boxY + bubbleH) + } c.lineTo(boxX + r, boxY + bubbleH) c.arcTo(boxX, boxY + bubbleH, boxX, boxY + bubbleH - r, r) - c.lineTo(boxX, boxY + r) - c.arcTo(boxX, boxY, boxX + r, boxY, r) + if (!tailUp) { + // Tail at top pointing up toward character + c.lineTo(boxX, boxY + r) + c.arcTo(boxX, boxY, boxX + r, boxY, r) + c.lineTo(taskX - tailW, boxY) + c.lineTo(taskX, boxY - 4 * zoom) + c.lineTo(taskX + tailW, boxY) + } else { + c.lineTo(boxX, boxY + r) + c.arcTo(boxX, boxY, boxX + r, boxY, r) + } c.closePath() c.fillStyle = 'rgba(15,23,42,0.88)' c.fill() @@ -624,7 +637,6 @@ export function renderScene( c.lineWidth = zoom c.stroke() - // Clip to bubble interior and draw scrolling text c.beginPath() c.rect(boxX + padX, boxY, bubbleW - padX * 2, bubbleH) c.clip() From 51742576d5792ae9e2998ae39b002b1b3180ac68 Mon Sep 17 00:00:00 2001 From: Dan Lee Date: Sun, 22 Mar 2026 09:47:54 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E6=94=B9=E5=96=84=20agent=20?= =?UTF-8?q?=E7=8B=80=E6=85=8B=E5=81=B5=E6=B8=AC=E6=BA=96=E7=A2=BA=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session 讀取行數從 30 增加至 50,減少漏判 - idle 判斷改為需同時滿足 lastRole=assistant + stopReason=stop,避免誤判 - 新增 mid-generation(streaming)狀態偵測 - 新 session 檔案(5 分鐘內)不再被 sessions.json 索引強制取代 Co-Authored-By: Claude Sonnet 4.6 --- app/api/agent-activity/route.ts | 40 ++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index 5569505..56b72ba 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -669,7 +669,7 @@ async function detectStateFromSession( try { const content = await fs.readFile(sessionFilePath, 'utf8') - const lines = content.split('\n').filter(l => l.trim()).slice(-30) + const lines = content.split('\n').filter(l => l.trim()).slice(-50) let lastRole: string | null = null let lastStopReason: string | null = null @@ -687,12 +687,16 @@ async function detectStateFromSession( } catch { /* skip malformed line */ } } - // Work completed → immediately idle - if (lastStopReason === 'stop') return 'idle' - // Still processing — but cap at 10 min, after that force idle + // Work completed → idle ONLY if the last message itself was the assistant finishing + if (lastRole === 'assistant' && lastStopReason === 'stop') return 'idle' + // New user/tool message after assistant stop, or tool call in flight → working if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') { return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle' } + // assistant with no stopReason = mid-generation (streaming), treat as working + if (lastRole === 'assistant' && !lastStopReason) { + return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle' + } } catch { /* file unreadable — fall through */ } // Fallback within the 10-min window @@ -782,19 +786,29 @@ export async function GET() { } } catch { /* ignore */ } - // Prefer the sessions.json-mapped file over raw scan when available - // (ensures we read a proper session, not a probe or temp file) + // If the most-recent .jsonl is not in sessions.json, keep it when it's + // recent (< 5 min) — it may be a brand-new session not yet indexed. + // Only fall back to the sessions.json-indexed file when the un-indexed + // file is stale, to avoid reading probe/temp files from old runs. if (mostRecentSessionFile) { const sessionId = path.basename(mostRecentSessionFile, '.jsonl') if (!sessionIdToFile.has(sessionId)) { - // Most-recent file is not in sessions.json — find best known session by mtime - let bestMtime = 0 - for (const [, fp] of sessionIdToFile) { - try { - const s = await fs.stat(fp) - if (s.mtimeMs > bestMtime) { bestMtime = s.mtimeMs; mostRecentSessionFile = fp } - } catch { /* ignore */ } + const unindexedAge = now - lastActive + if (unindexedAge > 5 * 60 * 1000) { + // Stale un-indexed file — prefer the best sessions.json entry + let bestMtime = 0 + for (const [, fp] of sessionIdToFile) { + try { + const s = await fs.stat(fp) + if (s.mtimeMs > bestMtime) { + bestMtime = s.mtimeMs + mostRecentSessionFile = fp + } + } catch { /* ignore */ } + } + if (bestMtime > 0) lastActive = bestMtime } + // else: keep the un-indexed file — it's a fresh active session } } }