diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 26ebaa7..f28bb21 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,3 +34,6 @@ jobs:
- name: Build
run: npm run build
+
+ - name: Test
+ run: npm test
diff --git a/README.md b/README.md
index dd76c8a..dbc3629 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,12 @@
- **手动清除条目**:`/subagent-clear-entries` 清除当前会话所有记录,防止历史条目扫描重建
- **语言适配**:支持 `/subagent-lang` 运行时切换中/英文,偏好持久化
- **可切换边框**:`/subagent-border` 控制面板外边框显隐(默认隐藏),开启后与缓存统计面板风格统一
-- **斜杠命令**:`/subagent-lang` `/subagent-max` `/subagent-order` `/subagent-scroll` `/subagent-ttl` `/subagent-clear-entries` `/subagent-session` `/subagent-version` `/subagent-clear-running` `/subagent-border` 动态配置
+- **多实例安全**:多开 TUI 时条目不再互相覆盖(原子合并写入)
+- **漏事件兜底**:周期性校正 + 历史扫描 + 发现轮询,掉事件的子代理也能恢复显示
+- **条目显示开关**:`/subagent-cost` 控制列表内条目费用(4 位小数,默认关;展开区始终显示);`/subagent-time`、`/subagent-tokens` 控制耗时与 Token(默认开,保持原有显示)
+- **耗时格式**:`/subagent-time-format` 可选 short(默认)/ decimal / clock / compact / seconds
+- **设置菜单**:`/subagent-sections` 打开原生循环设置菜单(V2);`/subagent-config` 查看当前设置摘要
+- **斜杠命令**:`/subagent-lang` `/subagent-max` `/subagent-order` `/subagent-scroll` `/subagent-ttl` `/subagent-clear-entries` `/subagent-session` `/subagent-version` `/subagent-clear-running` `/subagent-border` `/subagent-config` `/subagent-sections` `/subagent-cost` `/subagent-time` `/subagent-tokens` `/subagent-time-format` 动态配置
---
@@ -152,6 +157,12 @@ npm install -g opencode-subagent-magazine@latest
| `/subagent-version` | 查看插件版本 | 弹出当前插件版本号 |
| `/subagent-clear-running` | 批量清理僵尸条目 | 一键将所有运行中的条目标记为完成,清理卡住的旧数据 |
| `/subagent-border` | 开关面板边框 | 切换面板外边框显隐(默认隐藏),即时生效并持久化 |
+| `/subagent-config` | 查看设置摘要 | 弹窗列出语言、条目数、排序、翻页、保留期、边框与各显示开关的当前值 |
+| `/subagent-sections` | 打开设置菜单 | 原生选择列表逐项修改,修改后自动回到菜单(Esc 关闭) |
+| `/subagent-cost` | 开关条目费用 | 列表内条目费用显示(4 位小数,默认关;展开区始终显示) |
+| `/subagent-time` | 开关条目耗时 | 列表内耗时显示(默认开) |
+| `/subagent-tokens` | 开关条目 Token | 列表内 Token 显示(默认开) |
+| `/subagent-time-format` | 切换耗时格式 | short(默认 45.32s / 112m35s)/ decimal / clock / compact / seconds |

diff --git a/README_EN.md b/README_EN.md
index b21500d..6fbdf4a 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -57,7 +57,12 @@ Interested in token cache visualization? Check out [opencode-visual-cache](https
- **Manual Entry Clear**: `/subagent-clear-entries` removes all records for the current session and prevents scan re-creation of historical entries
- **Language Support**: Runtime language switch via `/subagent-lang` (Chinese / English), preference persisted
- **Toggleable Border**: `/subagent-border` controls the panel border (hidden by default); when shown, it matches the cache stats panel style
-- **Slash Commands**: `/subagent-lang`, `/subagent-max`, `/subagent-order`, `/subagent-scroll`, `/subagent-ttl`, `/subagent-clear-entries`, `/subagent-session`, `/subagent-version`, `/subagent-clear-running`, `/subagent-border` for dynamic configuration
+- **Multi-instance Safe**: sidebar entries no longer overwrite each other when several TUIs are open (atomic merge writes)
+- **Missed-event Recovery**: periodic reconcile + history scan + discovery poll recover sub-agents whose events were dropped
+- **Entry Display Toggles**: `/subagent-cost` shows per-entry cost in the list (4 decimals, off by default; always shown when expanded); `/subagent-time` and `/subagent-tokens` toggle elapsed time and tokens (on by default)
+- **Configurable Time Format**: `/subagent-time-format` supports short (default), decimal, clock, compact, seconds
+- **Settings Menu**: `/subagent-sections` opens the native looping settings menu (V2); `/subagent-config` shows a settings summary
+- **Slash Commands**: `/subagent-lang`, `/subagent-max`, `/subagent-order`, `/subagent-scroll`, `/subagent-ttl`, `/subagent-clear-entries`, `/subagent-session`, `/subagent-version`, `/subagent-clear-running`, `/subagent-border`, `/subagent-config`, `/subagent-sections`, `/subagent-cost`, `/subagent-time`, `/subagent-tokens`, `/subagent-time-format` for dynamic configuration
---
@@ -116,6 +121,12 @@ The plugin supports dynamic configuration via slash commands or the command pale
| `/subagent-version` | View plugin version | Displays the current plugin version |
| `/subagent-clear-running` | Batch cleanup stuck entries | Marks all running entries as done in one click, cleaning up stale data |
| `/subagent-border` | Toggle panel border | Show/hide the panel border (hidden by default) |
+| `/subagent-config` | Show settings summary | Popup listing current language, max entries, sort, scroll, retention, border and display toggles |
+| `/subagent-sections` | Open settings menu | Native select list; each change returns to the menu (Esc to close) |
+| `/subagent-cost` | Toggle entry cost | Per-entry cost in the list (4 decimals, off by default; always shown when expanded) |
+| `/subagent-time` | Toggle entry time | Elapsed time in the list (on by default) |
+| `/subagent-tokens` | Toggle entry tokens | Token count in the list (on by default) |
+| `/subagent-time-format` | Set time format | short (default, 45.32s / 112m35s) / decimal / clock / compact / seconds |

diff --git a/build.tui.mjs b/build.tui.mjs
index 7f0ef9b..d675c11 100644
--- a/build.tui.mjs
+++ b/build.tui.mjs
@@ -17,7 +17,7 @@ await esbuild.build({
format: "esm",
platform: "node",
bundle: true,
- external: ["@opencode-ai/*", "@opentui/*", "solid-js"],
+ external: ["@opencode-ai/*", "@opentui/*", "solid-js", "bun:sqlite"],
plugins: [solidPlugin({ solid: { moduleName: "@opentui/solid", generate: "universal" } })],
})
@@ -30,6 +30,6 @@ await esbuild.build({
format: "esm",
platform: "node",
bundle: true,
- external: ["@opencode-ai/*", "@opentui/*", "solid-js"],
+ external: ["@opencode-ai/*", "@opentui/*", "solid-js", "bun:sqlite"],
plugins: [solidPlugin({ solid: { moduleName: "@opentui/solid", generate: "universal" } })],
})
diff --git a/src/core/format.ts b/src/core/format.ts
index cfa17a0..eefa65d 100644
--- a/src/core/format.ts
+++ b/src/core/format.ts
@@ -1,3 +1,5 @@
+import type { TimeFormat } from "./types"
+
/** Visual width of a single character (CJK/wide chars count as 2). */
function charColumns(c: string): number {
const code = c.codePointAt(0) ?? 0
@@ -47,6 +49,48 @@ export function fmtDurationShort(ms: number, running: boolean): string {
return `${m}m${s}s`
}
+/** All supported elapsed-time display modes, in picker order. */
+export const TIME_FORMATS: readonly TimeFormat[] = ["short", "decimal", "clock", "compact", "seconds"]
+
+/** Neutral samples shown in the time-format picker (language independent). */
+export const TIME_FORMAT_SAMPLES: Record
= {
+ decimal: "45.3s · 9.5m · 1.9h",
+ short: "45.32s · 112m35s",
+ clock: "0:45 · 1:52:35",
+ compact: "45s · 1h52m",
+ seconds: "45s · 6755s",
+}
+
+/** Formats elapsed milliseconds using the configured display mode. */
+export function fmtDuration(ms: number, running: boolean, mode: TimeFormat = "short"): string {
+ if (running && ms < 2000) return ""
+ const t = Number.isFinite(ms) && ms > 0 ? ms : 0
+ switch (mode) {
+ case "short":
+ return fmtDurationShort(t, false)
+ case "compact": {
+ if (t < 60000) return `${Math.floor(t / 1000)}s`
+ if (t < 3600000) return `${Math.floor(t / 60000)}m${Math.floor((t % 60000) / 1000)}s`
+ return `${Math.floor(t / 3600000)}h${Math.floor((t % 3600000) / 60000)}m`
+ }
+ case "clock": {
+ const s = Math.floor((t % 60000) / 1000)
+ if (t < 3600000) return `${Math.floor(t / 60000)}:${String(s).padStart(2, "0")}`
+ const m = Math.floor((t % 3600000) / 60000)
+ return `${Math.floor(t / 3600000)}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
+ }
+ case "seconds":
+ return `${Math.floor(t / 1000)}s`
+ case "decimal":
+ default: {
+ // Promote units when rounding would show "60.0s" / "60.0m".
+ if (t < 59950) return `${(t / 1000).toFixed(1)}s`
+ if (t < 3597000) return `${(t / 60000).toFixed(1)}m`
+ return `${(t / 3600000).toFixed(1)}h`
+ }
+ }
+}
+
export function fmtTokens(n: number): string {
if (n < 1000) return `${n}`
if (n < 1000000) return `${(n / 1000).toFixed(1)}k`
diff --git a/src/core/kv.ts b/src/core/kv.ts
index 0c0fec3..ab64993 100644
--- a/src/core/kv.ts
+++ b/src/core/kv.ts
@@ -7,6 +7,13 @@ export const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
export interface KVApi {
get(key: string, fallback?: unknown): unknown
set(key: string, value: unknown): void
+ /**
+ * Optional atomic read-modify-write. When the host exposes it (V2), the
+ * updater runs under the host storage lock against the latest on-disk value,
+ * which is what makes concurrent TUI instances safe: no instance can
+ * overwrite another one's fresh writes with a stale in-memory snapshot.
+ */
+ update?(key: string, updater: (current: unknown) => unknown): void | Promise
}
/** Setting keys shared by V1/V2 (values are raw strings in KV). */
@@ -18,6 +25,11 @@ export const SETTING_KEYS = {
open: `${KV_PREFIX}.open`,
ttlDays: `${KV_PREFIX}.ttl_days`,
border: `${KV_PREFIX}.border`,
+ showEntryCost: `${KV_PREFIX}.show_entry_cost`,
+ showEntryTime: `${KV_PREFIX}.show_entry_time`,
+ showEntryTokens: `${KV_PREFIX}.show_entry_tokens`,
+ timeFormat: `${KV_PREFIX}.time_format`,
+ dbSync: `${KV_PREFIX}.db_sync`,
} as const
export function loadSessionData(kv: KVApi): Record {
@@ -27,8 +39,36 @@ export function loadSessionData(kv: KVApi): Record {
} catch { return {} }
}
-export function saveSessionData(kv: KVApi, data: Record) {
- try { kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
+/**
+ * Atomic read-merge-write of the session store.
+ *
+ * `mutator` receives the latest persisted map (read under the host lock when
+ * `kv.update` is available) and may mutate it freely; the result is written
+ * back. Every writer must go through here: writing a whole in-memory snapshot
+ * directly would silently drop entries created by the other TUIs.
+ */
+export function updateSessionData(
+ kv: KVApi,
+ mutator: (data: Record) => void,
+): void | Promise {
+ try {
+ if (typeof kv.update === "function") {
+ return kv.update(SESSION_DATA_KEY, (raw) => {
+ const data = parseSessionData(raw)
+ mutator(data)
+ return JSON.stringify(data)
+ })
+ }
+ // Fallback (V1 hosts without atomic update): read-modify-write. The window
+ // is small and the mutation is merged against the value we just read.
+ const data = loadSessionData(kv)
+ mutator(data)
+ kv.set(SESSION_DATA_KEY, JSON.stringify(data))
+ } catch {}
+}
+
+function parseSessionData(raw: unknown): Record {
+ try { return JSON.parse(String(raw ?? "{}")) } catch { return {} }
}
export function readTTLDays(kv: KVApi): number {
diff --git a/src/core/types.ts b/src/core/types.ts
index 22099bb..23c51ac 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -25,6 +25,8 @@ export interface SubEntry {
export type Lang = LangCode
export type SortOrder = "desc" | "asc"
export type ScrollMode = "wheel" | "click"
+/** How elapsed time is rendered in the sidebar (see TIME_FORMAT_SAMPLES). */
+export type TimeFormat = "decimal" | "short" | "clock" | "compact" | "seconds"
/** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */
export const SUBAGENT_TOOLS = new Set(["task", "subagent", "delegate", "call_omo_agent"])
@@ -56,5 +58,15 @@ export interface SharedSignals {
setScrollMode: (m: ScrollMode) => void
borderVisible: () => boolean
setBorderVisible: (v: boolean) => void
+ showEntryCost: () => boolean
+ setShowEntryCost: (v: boolean) => void
+ showEntryTime: () => boolean
+ setShowEntryTime: (v: boolean) => void
+ showEntryTokens: () => boolean
+ setShowEntryTokens: (v: boolean) => void
+ timeFormat: () => TimeFormat
+ setTimeFormat: (f: TimeFormat) => void
+ dbSync: () => boolean
+ setDbSync: (v: boolean) => void
sessionId: string
}
diff --git a/src/i18n.ts b/src/i18n.ts
index efbbdba..23bb16c 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -47,6 +47,7 @@ const ZH_T = {
"clear.prompt": "确定清除当前会话所有子代理记录?此操作不可撤销。",
"clear.prompt_running": "当前有 {n} 个运行中的子代理,清除后将不可恢复。确定继续?",
"clear.done": "已清除 {n} 条子代理记录",
+ "clear.failed": "清除失败",
"clear.empty": "当前会话无子代理记录",
"cancel.no_session": "子会话 ID 不可用",
"cancel.not_child": "目标不是子会话",
@@ -58,6 +59,24 @@ const ZH_T = {
"cancel.failed": "取消失败",
"borderShown": "面板边框 已显示",
"borderHidden": "面板边框 已隐藏",
+ "settings.title": "SubAgent Magazine — 设置",
+ "settings.section.general": "通用",
+ "settings.lang": "语言",
+ "settings.max": "最大条目数",
+ "settings.order": "排序方式",
+ "settings.scroll": "滚动模式",
+ "settings.ttl": "保留期限",
+ "settings.border": "面板边框",
+ "settings.showEntryCost": "每条子代理显示费用",
+ "settings.showEntryTime": "列表中显示耗时",
+ "settings.showEntryTokens": "列表中显示 Token 数",
+ "settings.timeFormat": "时间格式",
+ "settings.dbSync": "本地数据库补全",
+ "settings.on": "开",
+ "settings.off": "关",
+ "settings.saved": "已保存 {item}:{value}",
+ "settings.invalid": "无效的数值",
+ "settings.close": "关闭",
} as const
/** 结构约束:值放宽为 string,键集合来自中文表(新增语言缺 key 会编译报错)。 */
@@ -104,6 +123,7 @@ const EN_T: Translation = {
"clear.prompt": "Clear all sub-agent records for this session? This cannot be undone.",
"clear.prompt_running": "{n} sub-agent(s) are still running. Clearing will discard them permanently. Continue?",
"clear.done": "Cleared {n} sub-agent record(s)",
+ "clear.failed": "Clear failed",
"clear.empty": "No sub-agent records in this session",
"cancel.no_session": "Child session ID is unavailable",
"cancel.not_child": "Target is not a child session",
@@ -115,6 +135,24 @@ const EN_T: Translation = {
"cancel.failed": "Cancellation failed",
"borderShown": "Panel border shown",
"borderHidden": "Panel border hidden",
+ "settings.title": "SubAgent Magazine — Settings",
+ "settings.section.general": "General",
+ "settings.lang": "Language",
+ "settings.max": "Max entries",
+ "settings.order": "Sort order",
+ "settings.scroll": "Scroll mode",
+ "settings.ttl": "Retention",
+ "settings.border": "Panel border",
+ "settings.showEntryCost": "Cost per sub-agent",
+ "settings.showEntryTime": "Elapsed time in list",
+ "settings.showEntryTokens": "Token count in list",
+ "settings.timeFormat": "Time format",
+ "settings.dbSync": "Local database enrichment",
+ "settings.on": "on",
+ "settings.off": "off",
+ "settings.saved": "Saved {item}: {value}",
+ "settings.invalid": "Invalid value",
+ "settings.close": "Close",
}
const JA_T: Translation = {
@@ -158,6 +196,7 @@ const JA_T: Translation = {
"clear.prompt": "このセッションの全サブエージェント記録を削除しますか?この操作は元に戻せません。",
"clear.prompt_running": "実行中のサブエージェントが {n} 個あります。削除すると復元できません。続行しますか?",
"clear.done": "{n} 件のサブエージェント記録を削除しました",
+ "clear.failed": "クリアに失敗しました",
"clear.empty": "このセッションにサブエージェント記録はありません",
"cancel.no_session": "子セッション ID が利用できません",
"cancel.not_child": "対象は子セッションではありません",
@@ -169,6 +208,24 @@ const JA_T: Translation = {
"cancel.failed": "キャンセルに失敗しました",
"borderShown": "パネル枠線 表示",
"borderHidden": "パネル枠線 非表示",
+ "settings.title": "SubAgent Magazine — 設定",
+ "settings.section.general": "一般",
+ "settings.lang": "言語",
+ "settings.max": "最大表示数",
+ "settings.order": "並び順",
+ "settings.scroll": "スクロール",
+ "settings.ttl": "保持期間",
+ "settings.border": "パネル枠",
+ "settings.showEntryCost": "サブエージェント毎の費用",
+ "settings.showEntryTime": "一覧に所要時間を表示",
+ "settings.showEntryTokens": "一覧にトークン数を表示",
+ "settings.timeFormat": "時間形式",
+ "settings.dbSync": "ローカルDB補完",
+ "settings.on": "オン",
+ "settings.off": "オフ",
+ "settings.saved": "保存しました {item}:{value}",
+ "settings.invalid": "無効な値です",
+ "settings.close": "閉じる",
}
const KO_T: Translation = {
@@ -212,6 +269,7 @@ const KO_T: Translation = {
"clear.prompt": "이 세션의 모든 서브 에이전트 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"clear.prompt_running": "실행 중인 서브 에이전트가 {n}개 있습니다. 삭제하면 복구할 수 없습니다. 계속하시겠습니까?",
"clear.done": "서브 에이전트 기록 {n}개를 삭제했습니다",
+ "clear.failed": "지우기에 실패했습니다",
"clear.empty": "이 세션에 서브 에이전트 기록이 없습니다",
"cancel.no_session": "하위 세션 ID를 사용할 수 없습니다",
"cancel.not_child": "대상이 하위 세션이 아닙니다",
@@ -223,6 +281,24 @@ const KO_T: Translation = {
"cancel.failed": "취소에 실패했습니다",
"borderShown": "패널 테두리 표시",
"borderHidden": "패널 테두리 숨김",
+ "settings.title": "SubAgent Magazine — 설정",
+ "settings.section.general": "일반",
+ "settings.lang": "언어",
+ "settings.max": "최대 항목 수",
+ "settings.order": "정렬 순서",
+ "settings.scroll": "스크롤 모드",
+ "settings.ttl": "보존 기간",
+ "settings.border": "패널 테두리",
+ "settings.showEntryCost": "하위 에이전트별 비용",
+ "settings.showEntryTime": "목록에 소요 시간 표시",
+ "settings.showEntryTokens": "목록에 토큰 수 표시",
+ "settings.timeFormat": "시간 형식",
+ "settings.dbSync": "로컬 DB 보완",
+ "settings.on": "켬",
+ "settings.off": "끔",
+ "settings.saved": "저장됨 {item}: {value}",
+ "settings.invalid": "잘못된 값",
+ "settings.close": "닫기",
}
export const LANGS: Record = { zh: ZH_T, en: EN_T, ja: JA_T, ko: KO_T }
diff --git a/src/index.tsx b/src/index.tsx
index 9fd1a45..6e69de0 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -11,8 +11,9 @@ import type {
import { createSignal } from "solid-js"
import { PLUGIN_VERSION } from "./_version"
import { LANG_META, createT, detectLang } from "./i18n"
-import type { Lang, SortOrder, ScrollMode, SubStatus, SharedSignals } from "./core/types"
+import type { Lang, SortOrder, ScrollMode, SubStatus, SharedSignals, TimeFormat } from "./core/types"
import { KV_PREFIX } from "./core/kv"
+import { TIME_FORMATS, TIME_FORMAT_SAMPLES } from "./core/format"
import type { PanelApi } from "./panel/panel-api"
import { SubAgentPanel } from "./panel/SubAgentPanel"
import { globalEntryCache, setClearTick } from "./panel/store"
@@ -35,6 +36,10 @@ function createSidebarSlot(api: TuiPluginApi, panelApi: PanelApi, sig: SharedSig
sortOrder={sig.sortOrder}
scrollMode={sig.scrollMode}
borderVisible={sig.borderVisible}
+ showEntryCost={sig.showEntryCost}
+ showEntryTime={sig.showEntryTime}
+ showEntryTokens={sig.showEntryTokens}
+ timeFormat={sig.timeFormat}
sessionId={input.session_id}
/>
)
@@ -61,8 +66,24 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
const [borderVisible, setBorderVisible] = createSignal(
(api.kv.get(`${KV_PREFIX}.border`, false) as boolean) === true
)
+ const [showEntryCost, setShowEntryCost] = createSignal(
+ (api.kv.get(`${KV_PREFIX}.show_entry_cost`, false) as boolean) === true
+ )
+ const [showEntryTime, setShowEntryTime] = createSignal(
+ (api.kv.get(`${KV_PREFIX}.show_entry_time`, true) as boolean) !== false
+ )
+ const [showEntryTokens, setShowEntryTokens] = createSignal(
+ (api.kv.get(`${KV_PREFIX}.show_entry_tokens`, true) as boolean) !== false
+ )
+ const storedTimeFormat = String(api.kv.get(`${KV_PREFIX}.time_format`, "short"))
+ const [timeFormat, setTimeFormat] = createSignal(
+ (TIME_FORMATS as readonly string[]).includes(storedTimeFormat) ? (storedTimeFormat as TimeFormat) : "short"
+ )
+ const [dbSync, setDbSync] = createSignal(
+ (api.kv.get(`${KV_PREFIX}.db_sync`, true) as boolean) !== false
+ )
- const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible, setBorderVisible, sessionId: "" }
+ const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible, setBorderVisible, showEntryCost, setShowEntryCost, showEntryTime, setShowEntryTime, showEntryTokens, setShowEntryTokens, timeFormat, setTimeFormat, dbSync, setDbSync, sessionId: "" }
// ── V1 PanelApi adapter: wraps the V1 host API into the shared panel contract ──
const v1Api: PanelApi = {
@@ -462,6 +483,76 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
dialog?.clear()
},
},
+ {
+ title: "SubAgent Magazine: Entry Cost",
+ value: "subagent-cost",
+ description: "Show or hide the per-sub-agent cost in the sidebar",
+ slash: { name: "subagent-cost" },
+ onSelect: (dialog) => {
+ const t = createT(() => signals.lang())
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.show_entry_cost`, false))
+ api.kv.set(`${KV_PREFIX}.show_entry_cost`, !cur)
+ signals.setShowEntryCost(!cur)
+ api.ui.toast({
+ message: `${t("settings.showEntryCost")}: ${!cur ? t("settings.on") : t("settings.off")}`,
+ })
+ dialog?.clear()
+ },
+ },
+ {
+ title: "SubAgent Magazine: Entry Time",
+ value: "subagent-time",
+ description: "Show or hide the elapsed time in the sidebar list",
+ slash: { name: "subagent-time" },
+ onSelect: (dialog) => {
+ const t = createT(() => signals.lang())
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.show_entry_time`, true))
+ api.kv.set(`${KV_PREFIX}.show_entry_time`, !cur)
+ signals.setShowEntryTime(!cur)
+ api.ui.toast({
+ message: `${t("settings.showEntryTime")}: ${!cur ? t("settings.on") : t("settings.off")}`,
+ })
+ dialog?.clear()
+ },
+ },
+ {
+ title: "SubAgent Magazine: Entry Tokens",
+ value: "subagent-tokens",
+ description: "Show or hide the token count in the sidebar list",
+ slash: { name: "subagent-tokens" },
+ onSelect: (dialog) => {
+ const t = createT(() => signals.lang())
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.show_entry_tokens`, true))
+ api.kv.set(`${KV_PREFIX}.show_entry_tokens`, !cur)
+ signals.setShowEntryTokens(!cur)
+ api.ui.toast({
+ message: `${t("settings.showEntryTokens")}: ${!cur ? t("settings.on") : t("settings.off")}`,
+ })
+ dialog?.clear()
+ },
+ },
+ {
+ title: "SubAgent Magazine: Time Format",
+ value: "subagent-time-format",
+ description: "Set how elapsed time is displayed",
+ slash: { name: "subagent-time-format" },
+ onSelect: (dialog) => {
+ dialog?.replace(() => (
+ ({ title: `${f} — ${TIME_FORMAT_SAMPLES[f]}`, value: f }))}
+ onSelect={(opt) => {
+ const f = opt.value as TimeFormat
+ setTimeFormat(f)
+ api.kv.set(`${KV_PREFIX}.time_format`, f)
+ const t = createT(() => lang())
+ api.ui.toast({ message: `${t("settings.timeFormat")}: ${TIME_FORMAT_SAMPLES[f]}` })
+ dialog?.clear()
+ }}
+ />
+ ))
+ },
+ },
])
}
diff --git a/src/panel/SubAgentPanel.tsx b/src/panel/SubAgentPanel.tsx
index 736e10b..209c7d8 100644
--- a/src/panel/SubAgentPanel.tsx
+++ b/src/panel/SubAgentPanel.tsx
@@ -14,15 +14,15 @@ import {
import { PLUGIN_VERSION } from "../_version"
import { copyText } from "../clipboard"
import { createT } from "../i18n"
-import type { Lang, SortOrder, ScrollMode, SubEntry, SubStatus, SessionRecord } from "../core/types"
+import type { Lang, SortOrder, ScrollMode, SubEntry, SubStatus, SessionRecord, TimeFormat } from "../core/types"
import { SUBAGENT_TOOLS } from "../core/types"
-import { visualWidth, truncate, fmtDurationShort, fmtTokens, safeErrorMsg } from "../core/format"
+import { visualWidth, truncate, fmtDuration, fmtTokens, safeErrorMsg } from "../core/format"
import { rgb, desaturateTo, dimColor, FALLBACK, MAX_SAT } from "../core/color"
-import { KV_PREFIX } from "../core/kv"
+import { KV_PREFIX, updateSessionData } from "../core/kv"
import type { PanelApi, PanelEvent } from "./panel-api"
import { globalEntryCache, clearTick } from "./store"
import { isDirectChildSession } from "./session-routing"
-import { findSubEntryKey, upsertSubEntry } from "./entry-map"
+import { findSubEntryKey, mergeSubEntries, upsertSubEntry } from "./entry-map"
/** Entry line left prefix: icon + space + status dot + space */
const LEFT_PAD = 4
@@ -32,6 +32,25 @@ const INDENT = 2
// Sidebar component
// ===================================================================
+/** 读取 part/state 两级的 tool metadata(TUI 数据层放在 part 级,旧形状嵌在 state 里)。 */
+function partMetadata(
+ part: Record,
+ st: Record | undefined,
+): Record | undefined {
+ const partMeta = part?.metadata as Record | undefined
+ if (partMeta && Object.keys(partMeta).length > 0) return partMeta
+ const stateMeta = st?.metadata as Record | undefined
+ return stateMeta && Object.keys(stateMeta).length > 0 ? stateMeta : undefined
+}
+
+/** model 字段的有效值——历史 KV 里可能混入 "[object Object]" 这类序列化垃圾。 */
+function validModel(model: string | undefined): model is string {
+ if (typeof model !== "string") return false
+ const s = model.trim()
+ // 历史脏值:String(对象) 与未解析的 JSON 文本(session_v2.model 的形态)。
+ return s.length > 0 && s !== "[object Object]" && !s.startsWith("{")
+}
+
export function SubAgentPanel(props: {
api: PanelApi
theme: Record
@@ -40,6 +59,10 @@ export function SubAgentPanel(props: {
sortOrder: () => SortOrder
scrollMode: () => ScrollMode
borderVisible: () => boolean
+ showEntryCost: () => boolean
+ showEntryTime: () => boolean
+ showEntryTokens: () => boolean
+ timeFormat: () => TimeFormat
sessionId: string
}): JSX.Element {
const t = createT(() => props.lang())
@@ -73,8 +96,34 @@ export function SubAgentPanel(props: {
} catch { return {} }
}
- const saveSessionData = (data: Record) => {
- try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
+ /**
+ * 持久化存储的原子 read-merge-write:所有写入都经过这里。
+ * 多个 TUI 共用一个 KV 文件时,直接写入内存快照
+ * 会丢掉其他实例创建的条目。
+ */
+ const mutateSessionData = (mutator: (data: Record) => void) => {
+ // 宿主存储可能异步 reject;吞掉以免失败写入变成未处理 rejection 拖垮 TUI。
+ void Promise.resolve(updateSessionData(props.api.kv, mutator)).catch(() => {})
+ }
+
+ /** 带合理默认值的 SessionRecord(记录可能在首次写入时创建)。 */
+ const ensureRootRecord = (data: Record, sid: string): SessionRecord => {
+ const rec = data[sid]
+ if (rec) return rec
+ const created: SessionRecord = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
+ data[sid] = created
+ return created
+ }
+
+ const ensureChildRecord = (
+ data: Record,
+ parentSid: string,
+ sid: string,
+ ): ChildRecord => {
+ const parent = ensureRootRecord(data, parentSid)
+ if (!parent.children) parent.children = {}
+ if (!parent.children[sid]) parent.children[sid] = { scroll: 0, expanded: "", entries: [] }
+ return parent.children[sid]
}
/** 将任意 session ID 解析为父会话 ID + 是否子会话。
@@ -110,66 +159,62 @@ export function SubAgentPanel(props: {
clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
try {
- const data = loadSessionData()
- const { parentSid, isChild } = resolveParent(sid)
- if (isChild) {
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
- if (!data[parentSid].children) data[parentSid].children = {}
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] }
- } else {
- data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()], children: data[sid]?.children ?? {} }
- }
- saveSessionData(data)
+ mutateSessionData((data) => {
+ const { parentSid, isChild } = resolveParent(sid)
+ if (isChild) {
+ const parent = ensureRootRecord(data, parentSid)
+ const child = ensureChildRecord(data, parentSid, sid)
+ child.entries = [...mergeSubEntries(child.entries ?? [], entries.values()).values()]
+ parent.ts = Date.now()
+ } else {
+ const rec = ensureRootRecord(data, sid)
+ rec.ts = Date.now()
+ rec.entries = [...mergeSubEntries(rec.entries ?? [], entries.values()).values()]
+ }
+ })
} catch {}
}, 200)
}
const persistScroll = (sid: string, scroll: number) => {
try {
- const data = loadSessionData()
- const { parentSid, isChild } = resolveParent(sid)
- if (isChild) {
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
- if (!data[parentSid].children) data[parentSid].children = {}
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], scroll }
- } else {
- data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} }
- }
- saveSessionData(data)
+ mutateSessionData((data) => {
+ const { parentSid, isChild } = resolveParent(sid)
+ if (isChild) {
+ ensureChildRecord(data, parentSid, sid).scroll = scroll
+ } else {
+ const rec = ensureRootRecord(data, sid)
+ rec.ts = Date.now()
+ rec.scroll = scroll
+ }
+ })
} catch {}
}
const persistExpanded = (sid: string, expanded: string) => {
try {
- const data = loadSessionData()
- const { parentSid, isChild } = resolveParent(sid)
- if (isChild) {
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
- if (!data[parentSid].children) data[parentSid].children = {}
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], expanded }
- } else {
- data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} }
- }
- saveSessionData(data)
+ mutateSessionData((data) => {
+ const { parentSid, isChild } = resolveParent(sid)
+ if (isChild) {
+ ensureChildRecord(data, parentSid, sid).expanded = expanded
+ } else {
+ const rec = ensureRootRecord(data, sid)
+ rec.ts = Date.now()
+ rec.expanded = expanded
+ }
+ })
} catch {}
}
const cleanupOldSessions = () => {
if (ttlDays <= 0) return // 无期限,跳过清理
try {
- const data = loadSessionData()
const cutoff = Date.now() - TTL_MS
- let changed = false
- for (const sid of Object.keys(data)) {
- if (data[sid].ts < cutoff) {
- delete data[sid]
- changed = true
+ mutateSessionData((data) => {
+ for (const sid of Object.keys(data)) {
+ if (data[sid].ts < cutoff) delete data[sid]
}
- }
- if (changed) saveSessionData(data)
+ })
} catch {}
}
@@ -198,17 +243,19 @@ export function SubAgentPanel(props: {
if (needsImmediateFlush) {
clearTimeout(persistTimer)
try {
- const data = loadSessionData()
- const { parentSid, isChild } = resolveParent(props.sessionId)
- if (isChild) {
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
- if (!data[parentSid].children) data[parentSid].children = {}
- if (!data[parentSid].children[props.sessionId]) data[parentSid].children[props.sessionId] = { scroll: 0, expanded: "", entries: [] }
- data[parentSid].children[props.sessionId] = { ...data[parentSid].children[props.sessionId], entries: [...next.values()] }
- } else {
- data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()], children: data[props.sessionId]?.children ?? {} }
- }
- saveSessionData(data)
+ mutateSessionData((data) => {
+ const { parentSid, isChild } = resolveParent(props.sessionId)
+ if (isChild) {
+ const parent = ensureRootRecord(data, parentSid)
+ const child = ensureChildRecord(data, parentSid, props.sessionId)
+ child.entries = [...mergeSubEntries(child.entries ?? [], next.values()).values()]
+ parent.ts = Date.now()
+ } else {
+ const rec = ensureRootRecord(data, props.sessionId)
+ rec.ts = Date.now()
+ rec.entries = [...mergeSubEntries(rec.entries ?? [], next.values()).values()]
+ }
+ })
} catch {}
} else {
persistEntries(props.sessionId, next)
@@ -355,7 +402,9 @@ export function SubAgentPanel(props: {
// V2 事件流是全局的——若 payload 带发起会话 ID,则只归账到正在查看的会话,
// 避免其他会话的子代理写进当前侧边栏;V1 宿主已按会话 scope,不传 sessionID。
const eventSid = event.payload?.sessionID !== undefined ? String(event.payload.sessionID) : undefined
- if (event.scope === "global" && eventSid !== props.sessionId) return
+ if (event.scope === "global" && eventSid !== props.sessionId) {
+ return
+ }
// SubtaskPart
if (part.type === "subtask") {
@@ -388,7 +437,7 @@ export function SubAgentPanel(props: {
if (rawStatus === "error") {
const id = `tool:${String(part.id ?? "")}`
if (!part.id) return
- const stMeta = st?.metadata as Record | undefined
+ const stMeta = partMetadata(part as Record, st)
const errorSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
: stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
: undefined
@@ -415,7 +464,7 @@ export function SubAgentPanel(props: {
// Only keep running if state metadata confirms a child session was spawned;
// otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
if ((input?.run_in_background === true || input?.background === true) && status === "done") {
- const stMetaCheck = st?.metadata as Record | undefined
+ const stMetaCheck = partMetadata(part as Record, st)
const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined
if (hasChild) status = "running"
}
@@ -428,7 +477,7 @@ export function SubAgentPanel(props: {
const id = `tool:${String(part.id ?? crypto.randomUUID())}`
// Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
// injected by the tool executor. ToolPart.sessionID is the parent session.
- const stMeta = st?.metadata as Record | undefined
+ const stMeta = partMetadata(part as Record, st)
const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
: stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
: undefined
@@ -620,23 +669,35 @@ export function SubAgentPanel(props: {
if (!found) {
const data = loadSessionData()
const rec = data[parentSid]
- if (rec?.entries) {
+ if (rec?.entries?.length) {
const fallbackMap = new Map(rec.entries.map((e: SubEntry) => [e.id, e]))
found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs)
if (found) {
- // 回退命中后写入 KV 并回填缓存
- data[parentSid] = { ...rec, ts: nowTs, entries: [...fallbackMap.values()] }
- saveSessionData(data)
+ // 回退命中后写入 KV 并回填缓存。
+ // merge(而非整表替换):其他实例写入的新条目不能被旧缓存覆盖。
globalEntryCache.set(parentSid, fallbackMap)
+ mutateSessionData((latest) => {
+ const latestRec = latest[parentSid]
+ latest[parentSid] = {
+ ...(latestRec ?? rec),
+ ts: nowTs,
+ entries: [...mergeSubEntries(latestRec?.entries ?? [], fallbackMap.values()).values()],
+ }
+ })
}
}
}
- // 将模块级缓存中的最新状态同步到 KV
+ // 将模块级缓存中的最新状态 merge 进 KV(缓存可能缺少其他实例的新条目)
if (found && parentCache) {
- const data = loadSessionData()
- data[parentSid] = { ...data[parentSid], ts: nowTs, entries: [...parentCache.values()] }
- saveSessionData(data)
+ mutateSessionData((data) => {
+ const rec = data[parentSid]
+ data[parentSid] = {
+ ...(rec ?? { entries: [], scroll: 0, expanded: "", children: {}, ts: nowTs }),
+ ts: nowTs,
+ entries: [...mergeSubEntries(rec?.entries ?? [], parentCache.values()).values()],
+ }
+ })
}
}
} catch {}
@@ -674,41 +735,396 @@ export function SubAgentPanel(props: {
// ── bumpRenderTick: force re-render (visual-cache pattern) ──
const bump = () => setRenderTick((v) => v + 1)
+ // ── history scan(mount/switch、重试与消息驱动 rescan 共用)──
+
+ /** 将子会话已 idle 的 running 条目标记为已落定。
+ * 纯 map 变换——是否持久化由调用方决定。 */
+ const settleIdleEntries = (prev: Map): Map => {
+ let changed = false
+ const next = new Map(prev)
+ for (const [id, entry] of next) {
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
+ // 无链接条目:先用宿主/本地库按 call id 回填子会话 id;
+ // 回填不了就维持原状(不猜测、不设时间阈值)。
+ let sid = entry.sessionId
+ if (!sid) {
+ try {
+ const callId = String(entry.id).replace(/^tool:/, "")
+ sid = props.api.session.resolveChild?.({
+ parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
+ })
+ } catch {}
+ if (!sid) continue
+ next.set(id, { ...entry, sessionId: sid })
+ changed = true
+ }
+ try {
+ const st = props.api.session.status(sid)
+ if (!st || st.type !== "idle") continue
+ const tokens = props.api.usage.readSessionTokens(sid)
+ const cost = props.api.usage.readSessionCost(sid)
+ const finalStatus: SubStatus = entry.status === "cancel_requested" && entry.abortAccepted
+ ? "cancelled"
+ : "done"
+ next.set(id, {
+ ...entry, sessionId: sid, status: finalStatus, endedAt: Date.now(),
+ tokens: tokens ?? entry.tokens,
+ cost: cost ?? entry.cost,
+ })
+ changed = true
+ } catch {}
+ }
+ return changed ? next : prev
+ }
+
+ /** 周期性兜底遗漏的 session.idle 事件(僵尸 running 条目)。
+ * 由 500ms 定时器驱动,即使没有事件到达也能让条目落定。 */
+ const reconcileIdleEntries = () => {
+ try {
+ const before = entryMap()
+ const after = settleIdleEntries(before)
+ if (after !== before) setEntryMap(after)
+ } catch {}
+ }
+
+ /** 扫描会话历史并 merge 进 entry map。
+ * 消息数据尚不可用时返回 false,供调用方重试。 */
+ const runSessionScan = (sid: string, replace: boolean): boolean => {
+ let msgs: unknown[] | undefined
+ try { msgs = props.api.session.messages(sid) } catch {}
+ if (!msgs || (msgs as any[]).length === 0) return false
+ // scan 结果先写入内存(setEntryMapRaw);scan 末尾统一走 persistEntries 的 merge-safe 落盘。
+ setEntryMapRaw((prev) => {
+ // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
+ const next = replace
+ ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
+ : new Map(prev)
+ // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
+ const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid)
+ const scanRec = loadSessionData()[scanPSid]
+ const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds)
+ try {
+ for (const msg of msgs as any[]) {
+ const parts = props.api.session.part((msg as any).id) ?? []
+ for (const partRaw of parts) {
+ const part = partRaw as Record
+
+ // Subtask entries are purely event-driven — never created by scan.
+ // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
+ if (part.type === "tool") {
+ const tool = String((part as any).tool ?? "")
+ if (!SUBAGENT_TOOLS.has(tool)) continue
+ const id = `tool:${String(part.id ?? "")}`
+ if (!part.id) continue
+
+ const st = (part as any).state as Record | undefined
+ const rawStatus = String(st?.status ?? "")
+ const scanInput = st?.input as Record | undefined
+ // TUI 数据层把子代理 metadata 放在 part 级;旧缓存
+ // 形状可能嵌在 state 里。两处都读。
+ const scanMeta = partMetadata(part, st)
+ let scanSubSid = scanMeta?.session_id !== undefined ? String(scanMeta.session_id)
+ : scanMeta?.sessionId !== undefined ? String(scanMeta.sessionId)
+ : scanMeta?.sessionID !== undefined ? String(scanMeta.sessionID)
+ : undefined
+ // 宿主缓存/历史缺少子会话链接时,用本地库按工具 call id 回填。
+ if (!scanSubSid) {
+ try {
+ scanSubSid = props.api.session.resolveChild?.({ parentId: sid, callId: String(part.id) })
+ } catch {}
+ }
+ const existingKey = findSubEntryKey(next, id, scanSubSid)
+ const exists = existingKey ? next.get(existingKey) : undefined
+
+ // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
+ if (!exists && clearedIds.has(id)) continue
+
+ // Only create entries for tool calls that entered execution.
+ // "pending" / empty: skip new entries; allow heuristics for existing ones below.
+ if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
+
+ // "error": only update existing, never create a new entry
+ if (rawStatus === "error") {
+ if (exists && exists.status === "running") {
+ next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() })
+ }
+ continue
+ }
+
+ // 已落定的条目:仅在补全缺失数据时回访
+ // (session/tokens/cost/model)。其终态必须保持。
+ const settled =
+ exists !== undefined &&
+ exists.status !== "running" &&
+ exists.status !== "cancel_requested"
+ const needsRepair =
+ settled &&
+ (exists!.sessionId === undefined ||
+ exists!.tokens === undefined ||
+ exists!.cost === undefined ||
+ !validModel(exists!.model))
+ if (settled && !needsRepair) continue
+
+ let status: SubStatus = "running"
+ if (rawStatus === "completed") status = "done"
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
+ // 仅在 metadata 确认已派生子会话时保持 running。
+ if ((scanInput?.run_in_background === true || scanInput?.background === true) && status === "done") {
+ if (scanSubSid !== undefined) status = "running"
+ }
+ // 子会话存活判定:工具调用已完成但子会话仍 busy,
+ // 说明子代理本身仍在运行(覆盖缓存 part 中
+ // 缺少 background 标志的 input)。
+ if (status === "done" && scanSubSid) {
+ try {
+ const childStatus = props.api.session.status(scanSubSid)
+ if (childStatus && childStatus.type !== "idle") status = "running"
+ } catch {}
+ }
+
+ if (settled) {
+ // 修复流程:保留已记录的终态。
+ status = exists!.status
+ } else if (exists && status === "running") {
+ // Running entry with no explicit status improvement from part:
+ // try message-level heuristics first, then time-based fallback.
+ if (!rawStatus) {
+ const msgTokens = (msg as any)?.tokens as Record | undefined
+ if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
+ status = "done" // LLM returned tokens → agent completed
+ } else {
+ // 无终态证据时不猜测:等本地库/事件回填子会话状态后由 reconcile 落定。
+ continue
+ }
+ } else {
+ continue
+ }
+ }
+
+ // If already tracked as running but tool state says completed/error → update
+ // If not tracked → add fresh
+
+ const agent = String((part as any).subagent_type ?? scanInput?.agent ?? scanInput?.subagent_type ?? tool)
+ const prompt = String(scanInput?.prompt ?? (part as any).description ?? "")
+ const desc = scanInput?.description !== undefined ? String(scanInput.description) : ""
+ const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
+
+ let tokens: number | undefined
+ if (scanSubSid) tokens = props.api.usage.readSessionTokens(scanSubSid)
+ let cost: number | undefined
+ if (scanSubSid) cost = props.api.usage.readSessionCost(scanSubSid)
+ let model: string | undefined
+ if (scanSubSid) model = props.api.usage.readSessionModel(scanSubSid)
+
+ const entryKey = existingKey ?? id
+ next.set(entryKey, {
+ ...(exists ?? { id }),
+ title: title || exists?.title || "",
+ agent: exists?.agent ?? agent,
+ prompt: prompt || exists?.prompt || "",
+ // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
+ tokens: exists?.tokens ?? tokens,
+ cost: exists?.cost ?? cost,
+ model: validModel(exists?.model) ? exists!.model : model,
+ sessionId: exists?.sessionId ?? scanSubSid,
+ status,
+ startedAt: exists?.startedAt || Date.now(),
+ endedAt: exists?.endedAt ?? (status === "done" ? Date.now() : undefined),
+ })
+ if (entryKey !== id) next.delete(id)
+ }
+ }
+ }
+ } catch {}
+ return next
+ })
+ // Reconcile: check running entries against live child session status.
+ // Covers session.idle events missed while user was inside a child session.
+ setEntryMapRaw((prev) => settleIdleEntries(prev))
+ // 持久化 scan 修复结果(merge-safe:缺失字段始终以 KV 现有值为准),
+ // 让恢复的条目在重新挂载后仍在,并能同步到其他实例。
+ const snapshot = entryMap()
+ if (snapshot.size > 0) {
+ globalEntryCache.set(sid, new Map(snapshot))
+ persistEntries(sid, snapshot)
+ }
+ return true
+ }
+
+ let rescanTimer: ReturnType | undefined
+
+ /** 当最新消息与 entry map 不一致时为 true:子代理 tool part 尚未建条目,
+ * 或非后台 tool 已完成而条目仍停在 running。
+ * 事件驱动 rescan 与周期性 discovery poll 共用的轻量检查:
+ * 即使宿主的所有事件订阅都静默失效,
+ * 也能恢复条目。 */
+ const missingNewestToolPart = (): boolean => {
+ try {
+ const msgs = props.api.session.messages(props.sessionId) as any[] | undefined
+ if (!msgs || msgs.length === 0) return false
+ const map = entryMap()
+ const from = Math.max(0, msgs.length - 6)
+ for (let i = msgs.length - 1; i >= from; i--) {
+ const parts = props.api.session.part(msgs[i]?.id) ?? []
+ for (const part of parts as any[]) {
+ if (!part || part.type !== "tool") continue
+ if (!SUBAGENT_TOOLS.has(String(part.tool ?? ""))) continue
+ const partId = String(part.id ?? "")
+ if (!partId) continue
+ const st = part.state as Record | undefined
+ const rawStatus = String(st?.status ?? "")
+ // scan 从不为 pending/error part 创建条目;
+ // 这里忽略它们可避免对一直保持该状态的 part 反复 rescan。
+ if (rawStatus !== "running" && rawStatus !== "completed") continue
+ const id = `tool:${partId}`
+ const meta = partMetadata(part, st)
+ const subSid = meta?.session_id !== undefined ? String(meta.session_id)
+ : meta?.sessionId !== undefined ? String(meta.sessionId)
+ : undefined
+ const key = findSubEntryKey(map, id, subSid)
+ const entry = key ? map.get(key) : undefined
+ if (!entry) return true
+ // 非后台 tool 已完成、条目却仍停在 running:补一次 scan 收尾
+ // (success 事件可能因缺少 metadata 被适配层丢弃)。
+ const input = st?.input as Record | undefined
+ const isBackground = input?.background === true || input?.run_in_background === true
+ const stillRunning = entry.status === "running" || entry.status === "cancel_requested"
+ if (rawStatus === "completed" && !isBackground && stillRunning) return true
+ }
+ }
+ } catch {}
+ return false
+ }
+
+ /** 新消息到达时防抖重扫。覆盖 scan 早于宿主消息缓存
+ * 加载完成的竞态。 */
+ const scheduleRescan = () => {
+ if (disposed) return
+ clearTimeout(rescanTimer)
+ rescanTimer = setTimeout(() => {
+ if (disposed) return
+ untrack(() => {
+ if (!missingNewestToolPart()) return
+ runSessionScan(props.sessionId, false)
+ bump()
+ })
+ }, 400)
+ }
+
+ // usage enrichment 轮询的 round-robin 游标。
+ let enrichCursor = 0
+
onMount(() => {
// Fast clock for smooth time display, separate from token polling
const clock = setInterval(() => { setNow(Date.now()); bump() }, 100)
// Token poll — runs every 500ms for running entries
+ let tick = 0
const tokenTimer = setInterval(() => {
+ tick++
untrack(() => {
+ let enriched: Map | undefined
+ let attempted = 0
setEntryMapRaw((prev) => {
let changed = false
const next = new Map(prev)
+ // 候选:仍缺 usage 数据的条目。以 round-robin 遍历,
+ // 避免一串读不到数据的会话(如 free-model 子会话)
+ // 饿死列表其余部分;每个 tick 只读取少数会话。
+ const candidates: string[] = []
for (const [id, entry] of next) {
- if (entry.status === "running" && entry.sessionId) {
- // Only read from child sessions, never the parent
+ const running = entry.status === "running" || entry.status === "cancel_requested"
+ const missing = entry.tokens === undefined || entry.cost === undefined || !validModel(entry.model)
+ // 无链接条目(含历史 done)也参与:先用宿主/本地库回填 sid,再补 usage。
+ if (entry.sessionId && !running && !missing) continue
+ candidates.push(id)
+ }
+ if (candidates.length > 0) {
+ const start = enrichCursor % candidates.length
+ let budget = 4
+ for (let k = 0; k < candidates.length && budget > 0; k++) {
+ const id = candidates[(start + k) % candidates.length]
+ let entry = next.get(id)
+ if (!entry) continue
+ if (!entry.sessionId) {
+ try {
+ const callId = String(entry.id).replace(/^tool:/, "")
+ const sid = props.api.session.resolveChild?.({
+ parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
+ })
+ if (sid) { entry = { ...entry, sessionId: sid }; next.set(id, entry); changed = true }
+ } catch {}
+ }
+ if (!entry.sessionId) continue
+ const childSid = entry.sessionId
+ // 只从子会话读取,绝不读父会话。较旧的子会话
+ // 可能不在本地缓存中,此时它们仍属于
+ // 本面板的 map,读取是安全的。
let isChild = false
try {
- const s = props.api.session.get(entry.sessionId)
- isChild = s?.parentID === props.sessionId
- } catch {}
+ const s = props.api.session.get(childSid)
+ isChild = !s || s.parentID === props.sessionId
+ } catch { isChild = true }
if (!isChild) continue
- const total = props.api.usage.readSessionTokens(entry.sessionId)
- const todo = props.api.usage.readSessionTodo(entry.sessionId)
- const model = entry.model ?? props.api.usage.readSessionModel(entry.sessionId)
+ budget--
+ attempted++
+ const running = entry.status === "running" || entry.status === "cancel_requested"
+ let entryChanged = false
const nextEntry: SubEntry = { ...entry }
- if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true }
- if (todo !== undefined) {
- if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) {
- nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true
+ if (running || entry.tokens === undefined) {
+ const total = props.api.usage.readSessionTokens(childSid)
+ if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; entryChanged = true }
+ }
+ if (running || entry.cost === undefined) {
+ const cost = props.api.usage.readSessionCost(childSid)
+ if (cost !== undefined && cost !== entry.cost) { nextEntry.cost = cost; entryChanged = true }
+ }
+ if (running || !validModel(entry.model)) {
+ const model = props.api.usage.readSessionModel(childSid)
+ if (model && model !== entry.model) { nextEntry.model = model; entryChanged = true }
+ }
+ if (running) {
+ const todo = props.api.usage.readSessionTodo(childSid)
+ if (todo !== undefined && (todo.total !== entry.todoTotal || todo.done !== entry.todoDone)) {
+ nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; entryChanged = true
}
}
- if (model && !entry.model) { nextEntry.model = model; changed = true }
- if (changed) next.set(id, nextEntry)
+ if (entryChanged) { next.set(id, nextEntry); changed = true }
}
+ enrichCursor = (start + Math.max(1, attempted)) % candidates.length
}
- return changed ? next : prev
+ if (!changed) return prev
+ enriched = next
+ return next
})
+ if (enriched) {
+ // usage enrichment 是真实状态变更(cost/tokens/model)——通过与事件
+ // 处理器相同的 merge-safe 路径持久化。
+ globalEntryCache.set(props.sessionId, new Map(enriched))
+ persistEntries(props.sessionId, enriched)
+ }
})
+ reconcileIdleEntries()
+ // discovery poll(约每 2s):恢复从未通过宿主事件到达的
+ // 子代理 part。与事件订阅的健康状况无关。
+ if (tick % 4 === 0) {
+ untrack(() => {
+ if (missingNewestToolPart()) {
+ runSessionScan(props.sessionId, false)
+ bump()
+ }
+ })
+ }
+ // 周期性对账(约每 30s):存在 running 条目时与历史重扫一次,
+ // 兜底丢失的 success / idle 事件(含消息同步后才可见的 part)。
+ if (tick % 60 === 0) {
+ untrack(() => {
+ const hasRunning = entryList().some((e) => e.status === "running" || e.status === "cancel_requested")
+ if (hasRunning) {
+ runSessionScan(props.sessionId, false)
+ bump()
+ }
+ })
+ }
bump()
}, 500)
bump()
@@ -717,7 +1133,7 @@ export function SubAgentPanel(props: {
handlePartUpdated(e)
bump()
})
- const unsubMsg = props.api.event.on("message.updated", () => bump())
+ const unsubMsg = props.api.event.on("message.updated", () => { bump(); scheduleRescan() })
const unsubIdle = props.api.event.on("session.idle", (e) => {
handleSessionEnd(e, "done")
bump()
@@ -729,6 +1145,7 @@ export function SubAgentPanel(props: {
onCleanup(() => {
disposed = true
+ clearTimeout(rescanTimer)
clearInterval(clock)
clearInterval(tokenTimer)
unsubPart()
@@ -750,7 +1167,11 @@ export function SubAgentPanel(props: {
const tick = clearTick() // 外部触发清除时 +1,effect 重跑
const forceReload = tick !== lastTick && !switched
lastTick = tick
- const t = setTimeout(() => {
+
+ let attempts = 0
+ let retryTimer: ReturnType | undefined
+
+ const run = () => {
untrack(() => {
if (switched) {
const { parentSid, isChild } = resolveParent(sid)
@@ -761,150 +1182,28 @@ export function SubAgentPanel(props: {
setScrollOffset(saved)
// 刷新父会话的访问时间 TTL,防止活跃会话的数据过期
if (!isChild && data[sid]?.entries?.length) {
- data[sid].ts = Date.now()
- saveSessionData(data)
+ void Promise.resolve(updateSessionData(props.api.kv, (latest) => {
+ if (latest[sid]?.entries?.length) latest[sid].ts = Date.now()
+ })).catch(() => {})
}
}
- // scan uses setEntryMapRaw — ephemeral data, not persisted to kv.
- // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
- setEntryMapRaw((prev) => {
- // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
- const next = (switched || forceReload)
- ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
- : new Map(prev)
- // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
- const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid)
- const scanRec = loadSessionData()[scanPSid]
- const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds)
- try {
- const msgs = props.api.session.messages(sid)
- if (msgs && (msgs as any[]).length) {
- for (const msg of msgs) {
- const parts = props.api.session.part((msg as any).id) ?? []
- for (const partRaw of parts) {
- const part = partRaw as Record
-
- // Subtask entries are purely event-driven — never created by scan.
- // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
- if (part.type === "tool") {
- const tool = String((part as any).tool ?? "")
- if (!SUBAGENT_TOOLS.has(tool)) continue
- const id = `tool:${String(part.id ?? "")}`
- if (!part.id) continue
-
- const st = (part as any).state as Record | undefined
- const rawStatus = String(st?.status ?? "")
- const scanStMeta = st?.metadata as Record | undefined
- const scanSubSid = scanStMeta?.session_id !== undefined ? String(scanStMeta.session_id)
- : scanStMeta?.sessionId !== undefined ? String(scanStMeta.sessionId)
- : undefined
- const existingKey = findSubEntryKey(next, id, scanSubSid)
- const exists = existingKey ? next.get(existingKey) : undefined
-
- // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
- if (!exists && clearedIds.has(id)) continue
-
- // Only create entries for tool calls that entered execution.
- // "pending" / empty: skip new entries; allow heuristics for existing ones below.
- if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
-
- // "error": only update existing, never create a new entry
- if (rawStatus === "error") {
- if (exists && exists.status === "running") {
- next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() })
- }
- continue
- }
-
- let status: SubStatus = "running"
- if (rawStatus === "completed") status = "done"
- // Background tasks: tool completion ≠ agent completion — keep running until session.idle
- // Only keep running if state metadata confirms a child session was spawned.
- if (((st?.input as Record | undefined)?.run_in_background === true || (st?.input as Record | undefined)?.background === true) && status === "done") {
- const scanStMeta = st?.metadata as Record | undefined
- const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined
- if (scanHasChild) status = "running"
- }
-
- // Already settled → skip
- if (exists && existingKey === id && exists.status !== "running" && exists.status !== "cancel_requested") continue
- // Running entry with no explicit status improvement from part:
- // try message-level heuristics first, then time-based fallback.
- if (exists && status === "running") {
- if (!rawStatus) {
- const msgTokens = (msg as any)?.tokens as Record | undefined
- if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
- status = "done" // LLM returned tokens → agent completed
- } else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
- status = "done" // >30 min idle → assume completed
- } else {
- continue
- }
- } else {
- continue
- }
- }
-
- // If already tracked as running but tool state says completed/error → update
- // If not tracked → add fresh
-
- const input = st?.input as Record | undefined
- const agent = String((part as any).subagent_type ?? input?.agent ?? input?.subagent_type ?? tool)
- const prompt = String(input?.prompt ?? (part as any).description ?? "")
- const desc = input?.description !== undefined ? String(input.description) : ""
- const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
-
- let tokens: number | undefined
- if (scanSubSid) tokens = props.api.usage.readSessionTokens(scanSubSid)
-
- const ended = status === "done" // "error" handled above, never reaches here
- const entryKey = existingKey ?? id
- next.set(entryKey, {
- id: exists?.id ?? id, title, agent, prompt,
- // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
- tokens: exists?.tokens ?? tokens,
- sessionId: exists?.sessionId ?? scanSubSid,
- status,
- startedAt: exists?.startedAt || Date.now(),
- endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
- })
- if (entryKey !== id) next.delete(id)
- }
- }
- }
- }
- } catch {}
- return next
- })
- // Reconcile: check running entries against live child session status.
- // Covers session.idle events missed while user was inside a child session.
- setEntryMapRaw((prev) => {
- let changed = false
- const next = new Map(prev)
- for (const [id, entry] of next) {
- if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId) continue
- try {
- const st = props.api.session.status(entry.sessionId)
- if (!st || st.type !== "idle") continue
- const tokens = props.api.usage.readSessionTokens(entry.sessionId)
- const cost = props.api.usage.readSessionCost(entry.sessionId)
- const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
- ? "cancelled" as SubStatus
- : "done" as SubStatus
- next.set(id, {
- ...entry, status: finalStatus, endedAt: Date.now(),
- tokens: tokens ?? entry.tokens,
- cost: cost ?? entry.cost,
- })
- changed = true
- } catch {}
- }
- return changed ? next : prev
- })
- bump()
+ const ok = runSessionScan(sid, switched || forceReload)
+ // 首次挂载时宿主消息缓存可能尚未就绪;
+ // 重试几次,避免面板一直空着直到
+ // 下一次切换会话。
+ if (!ok && attempts < 4) {
+ attempts++
+ retryTimer = setTimeout(run, 400)
+ }
})
- }, 150)
- onCleanup(() => clearTimeout(t))
+ bump()
+ }
+
+ const t = setTimeout(run, 150)
+ onCleanup(() => {
+ clearTimeout(t)
+ if (retryTimer) clearTimeout(retryTimer)
+ })
})
// ── palette ──
@@ -1225,12 +1524,16 @@ export function SubAgentPanel(props: {
// Entry label: collapsed shows title only, expanded shows title only too
const tokenText = () =>
- !isExpanded() && entry.tokens !== undefined && entry.tokens > 0
+ !isExpanded() && props.showEntryTokens() && entry.tokens !== undefined && entry.tokens > 0
? ` ${fmtTokens(entry.tokens!)}`
: ""
+ const costText = () =>
+ !isExpanded() && props.showEntryCost() && entry.cost !== undefined && entry.cost > 0
+ ? ` $${entry.cost.toFixed(4)}`
+ : ""
const timeText = () =>
- !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
- ? fmtDurationShort(elapsed(), isActiveRunning)
+ !isExpanded() && props.showEntryTime() && (elapsed() >= 2000 || entry.endedAt !== undefined)
+ ? fmtDuration(elapsed(), isActiveRunning, props.timeFormat())
: ""
const suffixW = () => {
let w = 0
@@ -1238,6 +1541,8 @@ export function SubAgentPanel(props: {
if (t) w += 1 + visualWidth(t)
const tk = tokenText()
if (tk) w += visualWidth(tk)
+ const c = costText()
+ if (c) w += visualWidth(c)
return w
}
const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - suffixW())
@@ -1269,6 +1574,9 @@ export function SubAgentPanel(props: {
{tokenText() ? (
{tokenText()}
) : null}
+ {costText() ? (
+ {costText()}
+ ) : null}
{/* expanded detail — right-aligned values */}
@@ -1293,7 +1601,7 @@ export function SubAgentPanel(props: {
{t("time.label")}:
{" ".repeat(expandedPad(t("time.label")))}
- {fmtDurationShort(elapsed(), isActiveRunning)}
+ {fmtDuration(elapsed(), isActiveRunning, props.timeFormat())}
@@ -1326,7 +1634,7 @@ export function SubAgentPanel(props: {
)
})()}
-
+
{" "}
{t("model.label")}:
diff --git a/src/panel/entry-map.ts b/src/panel/entry-map.ts
index 039cd58..d823b0e 100644
--- a/src/panel/entry-map.ts
+++ b/src/panel/entry-map.ts
@@ -1,4 +1,4 @@
-import type { SubEntry } from "../core/types"
+import type { SubEntry, SubStatus } from "../core/types"
export type SubEntryPatch = Omit & {
startedAt?: number
@@ -37,3 +37,79 @@ export function upsertSubEntry(
})
return next
}
+
+/** running < cancel_requested < 终态(done/error/cancelled)。 */
+function statusRank(status: SubStatus): number {
+ if (status === "running") return 0
+ if (status === "cancel_requested") return 1
+ return 2
+}
+
+const isTerminalStatus = (status: SubStatus) => statusRank(status) === 2
+
+function minDefined(a: number | undefined, b: number | undefined): number | undefined {
+ if (a === undefined) return b
+ if (b === undefined) return a
+ return Math.min(a, b)
+}
+
+function maxDefined(a: number | undefined, b: number | undefined): number | undefined {
+ if (a === undefined) return b
+ if (b === undefined) return a
+ return Math.max(a, b)
+}
+
+/**
+ * 合并同一子代理条目的两个视图,绝不回退状态。
+ *
+ * 持久化存储由多个 TUI 实例写入,每个写入者都可能
+ * 持有过期快照。终态始终优先于 running /
+ * cancel_requested,进度计数只增不减,描述性字段
+ * 则回退到仍保有它的一侧。
+ */
+export function mergeSubEntry(base: SubEntry, incoming: SubEntry): SubEntry {
+ const successor = statusRank(incoming.status) > statusRank(base.status) ? incoming : base
+ const status = successor.status
+ const ended = isTerminalStatus(status)
+ return {
+ ...base,
+ ...incoming,
+ id: base.id,
+ status,
+ sessionId: incoming.sessionId ?? base.sessionId,
+ startedAt: minDefined(base.startedAt, incoming.startedAt) ?? base.startedAt,
+ endedAt: ended ? (base.endedAt ?? incoming.endedAt ?? Date.now()) : undefined,
+ tokens: maxDefined(base.tokens, incoming.tokens),
+ cost: maxDefined(base.cost, incoming.cost),
+ model: incoming.model ?? base.model,
+ todoTotal: maxDefined(base.todoTotal, incoming.todoTotal),
+ todoDone: maxDefined(base.todoDone, incoming.todoDone),
+ error: incoming.error ?? base.error,
+ cancelRequestedAt: minDefined(base.cancelRequestedAt, incoming.cancelRequestedAt),
+ abortAccepted: base.abortAccepted || incoming.abortAccepted || undefined,
+ cancelReason: incoming.cancelReason ?? base.cancelReason,
+ }
+}
+
+/**
+ * 合并两个条目集合,去重方式与 `upsertSubEntry` 相同
+ * (先子会话、后条目 id),冲突用 `mergeSubEntry` 合并。
+ * 所有持久化路径都走这里,避免并发实例以整表写入
+ * 互相丢弃对方的条目。
+ */
+export function mergeSubEntries(
+ base: Iterable,
+ incoming: Iterable,
+): Map {
+ const out = new Map()
+ for (const entry of base) out.set(entry.id, entry)
+ for (const entry of incoming) {
+ const key = findSubEntryKey(out, entry.id, entry.sessionId)
+ if (!key) {
+ out.set(entry.id, entry)
+ continue
+ }
+ out.set(key, mergeSubEntry(out.get(key)!, entry))
+ }
+ return out
+}
diff --git a/src/panel/panel-api.ts b/src/panel/panel-api.ts
index e95b37e..4502c46 100644
--- a/src/panel/panel-api.ts
+++ b/src/panel/panel-api.ts
@@ -51,6 +51,14 @@ export interface PanelApi {
messages(sid: string): unknown[] | undefined
/** Raw parts of a message (scan). */
part(messageID: string): unknown[] | undefined
+ /** Optional host-side fallback: map a subagent tool call to its child session
+ * when live events/history lack the link (V2 reads the local database). */
+ resolveChild?(input: {
+ parentId: string
+ callId?: string
+ agent?: string
+ startedAt?: number
+ }): string | undefined
}
event: {
on(type: PanelEventType, cb: (e: PanelEvent) => void): () => void
diff --git a/src/v2/commands.ts b/src/v2/commands.ts
index f34c3fd..4dbfd3a 100644
--- a/src/v2/commands.ts
+++ b/src/v2/commands.ts
@@ -1,16 +1,24 @@
import type { Context, KeymapCommand } from "./types"
import type { PanelApi } from "../panel/panel-api"
-import type { Lang, SharedSignals, SubStatus } from "../core/types"
-import { KV_PREFIX, SETTING_KEYS, loadSessionData, saveSessionData, readTTLDays } from "../core/kv"
+import type { Lang, SharedSignals, SubStatus, TimeFormat } from "../core/types"
+import { TIME_FORMATS, TIME_FORMAT_SAMPLES } from "../core/format"
+import { KV_PREFIX, SETTING_KEYS, updateSessionData, readTTLDays } from "../core/kv"
import { PLUGIN_VERSION } from "../_version"
import { LANG_META, createT } from "../i18n"
import { globalEntryCache, setClearTick } from "../panel/store"
+import { mergeSubEntries } from "../panel/entry-map"
+import { openSettingsMenu } from "./settings-menu"
/** V2 命令(对齐 V1 的 9 个斜杠命令——promise 式对话框)。 */
-export function makeCommands(context: Context, api: PanelApi, signals: SharedSignals): KeymapCommand[] {
+export function makeCommands(
+ context: Context,
+ api: PanelApi,
+ signals: SharedSignals,
+): KeymapCommand[] {
const t = createT(() => signals.lang())
const kv = api.kv
const clampMax = (n: number) => Math.max(1, Math.min(50, n))
+ const onOff = (v: boolean) => (v ? t("settings.on") : t("settings.off"))
const resolveParent = (sid: string): { parentSid: string; isChild: boolean } => {
try {
@@ -98,6 +106,111 @@ export function makeCommands(context: Context, api: PanelApi, signals: SharedSig
api.ui.toast(`Max entries: ${n}`)
},
},
+ {
+ id: "opencode-subagent-magazine.subagent.config",
+ title: "SubAgent Magazine: Settings",
+ description: "Show current settings (change each via its own command)",
+ slash: { name: "subagent-config" },
+ palette: true,
+ run: () => {
+ const ttl = readTTLDays(kv)
+ const langLabel = LANG_META.find((m) => m.code === signals.lang())?.label ?? signals.lang()
+ const message = [
+ `${t("settings.lang")}: ${langLabel}`,
+ `${t("settings.max")}: ${signals.maxEntries()}`,
+ `${t("settings.order")}: ${signals.sortOrder() === "desc" ? t("order.desc") : t("order.asc")}`,
+ `${t("settings.scroll")}: ${signals.scrollMode() === "wheel" ? t("scroll.wheel") : t("scroll.click")}`,
+ `${t("settings.ttl")}: ${ttl === 0 ? t("ttl.unlimited") : `${ttl}d`}`,
+ `${t("settings.border")}: ${onOff(signals.borderVisible())}`,
+ `${t("settings.showEntryCost")}: ${onOff(signals.showEntryCost())}`,
+ `${t("settings.showEntryTime")}: ${onOff(signals.showEntryTime())}`,
+ `${t("settings.showEntryTokens")}: ${onOff(signals.showEntryTokens())}`,
+ `${t("settings.timeFormat")}: ${TIME_FORMAT_SAMPLES[signals.timeFormat()]}`,
+ `${t("settings.dbSync")}: ${onOff(signals.dbSync())}`,
+ ].join("\n")
+ context.ui.toast.show({ title: t("settings.title"), message })
+ },
+ },
+ {
+ id: "opencode-subagent-magazine.subagent.sections",
+ title: "SubAgent Magazine: Settings Menu",
+ description: "Open the interactive settings menu (Esc to close)",
+ slash: { name: "subagent-sections" },
+ palette: true,
+ run: () => {
+ openSettingsMenu(context, api, signals)
+ },
+ },
+ {
+ id: "opencode-subagent-magazine.subagent.cost",
+ title: "SubAgent Magazine: Toggle Entry Cost",
+ description: "Show or hide the per-sub-agent cost in the sidebar",
+ slash: { name: "subagent-cost" },
+ palette: true,
+ run: () => {
+ const v = !signals.showEntryCost()
+ signals.setShowEntryCost(v)
+ kv.set(SETTING_KEYS.showEntryCost, v)
+ api.ui.toast(`${t("settings.showEntryCost")}: ${onOff(v)}`)
+ },
+ },
+ {
+ id: "opencode-subagent-magazine.subagent.time",
+ title: "SubAgent Magazine: Toggle Entry Time",
+ description: "Show or hide the elapsed time in the sidebar list",
+ slash: { name: "subagent-time" },
+ palette: true,
+ run: () => {
+ const v = !signals.showEntryTime()
+ signals.setShowEntryTime(v)
+ kv.set(SETTING_KEYS.showEntryTime, v)
+ api.ui.toast(`${t("settings.showEntryTime")}: ${onOff(v)}`)
+ },
+ },
+ {
+ id: "opencode-subagent-magazine.subagent.time-format",
+ title: "SubAgent Magazine: Time Format",
+ description: "Set how elapsed time is displayed (short / decimal / clock / compact / seconds)",
+ slash: { name: "subagent-time-format" },
+ palette: true,
+ run: async () => {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.timeFormat"),
+ options: TIME_FORMATS.map((f) => ({ title: `${f} — ${TIME_FORMAT_SAMPLES[f]}`, value: f })),
+ current: signals.timeFormat(),
+ })
+ if (!picked) return
+ signals.setTimeFormat(picked)
+ kv.set(SETTING_KEYS.timeFormat, picked)
+ api.ui.toast(`${t("settings.timeFormat")}: ${TIME_FORMAT_SAMPLES[picked]}`)
+ },
+ },
+ {
+ id: "opencode-subagent-magazine.subagent.tokens",
+ title: "SubAgent Magazine: Toggle Entry Tokens",
+ description: "Show or hide the token count in the sidebar list",
+ slash: { name: "subagent-tokens" },
+ palette: true,
+ run: () => {
+ const v = !signals.showEntryTokens()
+ signals.setShowEntryTokens(v)
+ kv.set(SETTING_KEYS.showEntryTokens, v)
+ api.ui.toast(`${t("settings.showEntryTokens")}: ${onOff(v)}`)
+ },
+ },
+ {
+ id: "opencode-subagent-magazine.subagent.db",
+ title: "SubAgent Magazine: Toggle Local DB",
+ description: "Enrich sub-agent entries from the local OpenCode database (V2)",
+ slash: { name: "subagent-db" },
+ palette: true,
+ run: () => {
+ const v = !signals.dbSync()
+ signals.setDbSync(v)
+ kv.set(SETTING_KEYS.dbSync, v)
+ api.ui.toast(`${t("settings.dbSync")}: ${onOff(v)}`)
+ },
+ },
{
id: "opencode-subagent-magazine.subagent.version",
title: "SubAgent Magazine: Version",
@@ -140,28 +253,49 @@ export function makeCommands(context: Context, api: PanelApi, signals: SharedSig
}
}
if (count > 0) {
+ const ok = () => api.ui.toast(signals.lang() === "zh"
+ ? `已标记 ${count} 个运行中的条目为完成`
+ : `Marked ${count} running entries as done`)
+ const fail = () => {
+ try { api.ui.toast(t("clear.failed")) } catch {}
+ }
try {
- const data = loadSessionData(kv)
const { parentSid, isChild } = resolveParent(sid)
- if (isChild) {
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
- if (!data[parentSid].children) data[parentSid].children = {}
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] }
- } else {
- data[sid] = {
- ts: Date.now(),
- entries: [...entries.values()],
- scroll: data[sid]?.scroll ?? 0,
- expanded: data[sid]?.expanded ?? "",
- children: data[sid]?.children ?? {},
+ const done = updateSessionData(kv, (data) => {
+ // 落定前先把(可能过期的)模块缓存与持久化记录
+ // merge:其他实例可能有本实例从未见过的条目,
+ // 这次写入不能把它们丢掉。
+ if (isChild) {
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
+ if (!data[parentSid].children) data[parentSid].children = {}
+ const prev = data[parentSid].children[sid]?.entries ?? []
+ const merged = [...mergeSubEntries(prev, entries.values()).values()].map((e) =>
+ e.status === "running" || e.status === "cancel_requested"
+ ? { ...e, status: "done" as SubStatus, endedAt: e.endedAt ?? Date.now() }
+ : e
+ )
+ data[parentSid].children[sid] = {
+ ...(data[parentSid].children[sid] ?? { scroll: 0, expanded: "" }),
+ entries: merged,
+ }
+ } else {
+ const rec = data[parentSid] ?? { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
+ const merged = [...mergeSubEntries(rec.entries ?? [], entries.values()).values()].map((e) =>
+ e.status === "running" || e.status === "cancel_requested"
+ ? { ...e, status: "done" as SubStatus, endedAt: e.endedAt ?? Date.now() }
+ : e
+ )
+ data[parentSid] = { ...rec, ts: Date.now(), entries: merged, children: rec.children ?? {} }
}
+ })
+ if (done && typeof (done as Promise).then === "function") {
+ void (done as Promise).then(ok, fail)
+ } else {
+ ok()
}
- saveSessionData(kv, data)
- } catch {}
- api.ui.toast(signals.lang() === "zh"
- ? `已标记 ${count} 个运行中的条目为完成`
- : `Marked ${count} running entries as done`)
+ } catch {
+ fail()
+ }
} else {
api.ui.toast(signals.lang() === "zh" ? "没有需要清理的运行中条目" : "No running entries to clear")
}
@@ -216,32 +350,42 @@ export function makeCommands(context: Context, api: PanelApi, signals: SharedSig
})
if (choice !== "yes") return
try {
- const data = loadSessionData(kv)
let count = 0
- if (parentID) {
- if (data[parentID]?.children?.[sid]) {
- const child = data[parentID].children[sid]
- const ids = child.entries?.map((e) => e.id) ?? []
- count = ids.length
- child.entries = []
- child.scroll = 0
- child.expanded = ""
- child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])]
+ const finish = () => {
+ globalEntryCache.delete(sid)
+ setClearTick((v) => v + 1)
+ api.ui.toast(t("clear.done", { n: count }))
+ }
+ const done = updateSessionData(kv, (data) => {
+ if (parentID) {
+ const child = data[parentID]?.children?.[sid]
+ if (child) {
+ const ids = child.entries?.map((e) => e.id) ?? []
+ count = ids.length
+ child.entries = []
+ child.scroll = 0
+ child.expanded = ""
+ child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])]
+ }
+ } else {
+ const rec = data[sid]
+ if (rec) {
+ const ids = rec.entries?.map((e) => e.id) ?? []
+ count = ids.length
+ rec.entries = []
+ rec.scroll = 0
+ rec.expanded = ""
+ rec.clearedIds = [...new Set([...(rec.clearedIds ?? []), ...ids])]
+ }
}
+ })
+ if (done && typeof (done as Promise).then === "function") {
+ void (done as Promise).then(finish, () => {
+ try { api.ui.toast(t("clear.failed")) } catch {}
+ })
} else {
- count = data[sid]?.entries?.length ?? 0
- if (data[sid]) {
- const ids = data[sid].entries?.map((e) => e.id) ?? []
- data[sid].entries = []
- data[sid].scroll = 0
- data[sid].expanded = ""
- data[sid].clearedIds = [...new Set([...(data[sid].clearedIds ?? []), ...ids])]
- }
+ finish()
}
- saveSessionData(kv, data)
- globalEntryCache.delete(sid)
- setClearTick((v) => v + 1)
- api.ui.toast(t("clear.done", { n: count }))
} catch {}
},
},
diff --git a/src/v2/db.ts b/src/v2/db.ts
new file mode 100644
index 0000000..c126b32
--- /dev/null
+++ b/src/v2/db.ts
@@ -0,0 +1,274 @@
+/**
+ * 本地 opencode.db 只读索引(V2)。
+ *
+ * 宿主数据层只为当前 TUI 已加载的会话缓存消息:前台子代理(非 background)
+ * 的 success 事件经常拿不到 metadata.sessionID,条目会一直停在 running,
+ * model/tokens/cost 也无法回填。这些数据在宿主数据库里是完整的:
+ * - 父会话 assistant 消息中,工具 part 的 state.metadata.sessionID 指向子会话(前台/后台都有);
+ * - session_v2 保存子会话的 agent/model/tokens/cost 与 time_idle/idle_outcome。
+ *
+ * 本模块以只读方式查询该库作为补充数据源;bun:sqlite 不可用、库不存在或
+ * 设置项 dbSync 关闭时静默禁用(回退宿主 API)。库结构属于宿主内部实现,
+ * 任何读取失败都返回 undefined,绝不影响面板其余功能。
+ */
+
+import { homedir } from "node:os"
+
+export interface ChildSessionInfo {
+ id: string
+ parentId?: string
+ agent?: string
+ title?: string
+ model?: string
+ cost?: number
+ tokens?: number
+ timeCreated?: number
+ timeIdle?: number
+ idleOutcome?: string
+}
+
+export interface SessionDbIndex {
+ /** 数据库可用且设置项开启时为 true。 */
+ enabled(): boolean
+ /** call id → 子会话 id(前台/后台通用)。 */
+ resolveCall(parentId: string, callId: string): string | undefined
+ /** 无 call id 时的兜底:按 agent + 起始时间匹配子会话。 */
+ matchChild(parentId: string, agent: string | undefined, startedAt: number | undefined): string | undefined
+ /** 子会话汇总(model/tokens/cost/time_idle)。 */
+ info(sid: string): ChildSessionInfo | undefined
+}
+
+type SqlRow = Record
+interface SqlStatement {
+ all(...params: unknown[]): SqlRow[]
+ get(...params: unknown[]): SqlRow | undefined
+}
+interface SqlDatabase {
+ query(sql: string): SqlStatement
+ close?(): void
+}
+
+// 动态加载:仅 Bun 运行时可用。字符串变量使 tsc/esbuild 不做静态解析,
+// 打包产物保留运行时 import;Node(CI 测试)加载失败会静默禁用。
+const BUN_SQLITE = "bun:sqlite"
+
+/** 从宿主 model 字段提取模型 id。
+ * 兼容三种形态:名字字符串、{ id } 对象,以及序列化后的 JSON 字符串
+ * (session_v2.model 列存的就是 `{"id":...}` 文本)。 */
+export function modelIdOf(value: unknown): string | undefined {
+ if (typeof value === "string") {
+ const s = value.trim()
+ if (s.length === 0) return undefined
+ if (s.startsWith("{")) {
+ try { return modelIdOf(JSON.parse(s)) } catch { return undefined }
+ }
+ return s
+ }
+ if (value && typeof value === "object") {
+ const id = (value as { id?: unknown }).id
+ if (id === undefined) return undefined
+ const direct = modelIdOf(id)
+ if (direct !== undefined) return direct
+ const s = String(id)
+ return s.length > 0 ? s : undefined
+ }
+ return undefined
+}
+
+/** 宿主 readSessionTokens 同语义:最后一条 assistant 消息的上下文规模。 */
+export function contextTokens(tokens: unknown): number | undefined {
+ if (!tokens || typeof tokens !== "object") return undefined
+ const t = tokens as Record
+ const cache = (t.cache && typeof t.cache === "object") ? (t.cache as Record) : {}
+ const sum =
+ (Number(t.input) || 0) +
+ (Number(t.output) || 0) +
+ (Number(t.reasoning) || 0) +
+ (Number(cache.read) || 0) +
+ (Number(cache.write) || 0)
+ return sum > 0 ? sum : undefined
+}
+
+/** 子会话终态:time_idle 存在才算结束;interrupted 视为取消。
+ * 没有 time_idle 时不作判断(交给宿主实时状态)——避免时间阈值猜测。 */
+export function statusOfChild(info: ChildSessionInfo | undefined): "done" | "cancelled" | undefined {
+ if (!info || info.timeIdle == null) return undefined
+ return info.idleOutcome === "interrupted" ? "cancelled" : "done"
+}
+
+/** 定位宿主数据库(可用 OPENCODE_DB 覆盖,遵循 XDG_DATA_HOME)。 */
+export function findDbPath(
+ env: Record = process.env,
+ home = safeHomedir(),
+): string | undefined {
+ if (env.OPENCODE_DB) return env.OPENCODE_DB
+ const base = env.XDG_DATA_HOME && env.XDG_DATA_HOME.length > 0 ? env.XDG_DATA_HOME : `${home}/.local/share`
+ if (!base || base === "/.local/share") return undefined
+ return `${base}/opencode/opencode.db`
+}
+
+function safeHomedir(): string {
+ try { return homedir() } catch { return "" }
+}
+
+const CALL_MAP_SQL = `
+ SELECT json_extract(p.value, '$.id') AS call_id,
+ json_extract(p.value, '$.state.metadata.sessionID') AS child_sid
+ FROM session_message m, json_each(m.data, '$.content') p
+ WHERE m.session_id = ? AND m.type = 'assistant'
+ AND json_extract(p.value, '$.id') LIKE 'call_%'
+ AND json_extract(p.value, '$.state.metadata.sessionID') IS NOT NULL`
+
+const CHILD_INFO_SQL = `
+ SELECT parent_id, agent, title, model, cost,
+ tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
+ time_created, time_idle, idle_outcome
+ FROM session_v2 WHERE id = ?`
+
+const LAST_ASSISTANT_SQL = `
+ SELECT json_extract(data, '$.tokens.input') AS input,
+ json_extract(data, '$.tokens.output') AS output,
+ json_extract(data, '$.tokens.reasoning') AS reasoning,
+ json_extract(data, '$.tokens.cache.read') AS cache_read,
+ json_extract(data, '$.tokens.cache.write') AS cache_write,
+ json_extract(data, '$.model.id') AS model_id
+ FROM session_message
+ WHERE session_id = ? AND type = 'assistant' AND json_extract(data, '$.tokens.output') > 0
+ ORDER BY seq DESC LIMIT 1`
+
+const ASSISTANT_COST_SQL = `
+ SELECT COUNT(*) AS n, SUM(COALESCE(json_extract(data, '$.cost'), 0)) AS total
+ FROM session_message WHERE session_id = ? AND type = 'assistant'`
+
+const CHILDREN_SQL = `
+ SELECT id, agent, time_created FROM session_v2 WHERE parent_id = ? ORDER BY time_created`
+
+const num = (v: unknown): number | undefined => {
+ const n = Number(v)
+ return Number.isFinite(n) ? n : undefined
+}
+const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined)
+
+export function createSessionDbIndex(enabled: () => boolean): SessionDbIndex | undefined {
+ const dbPath = findDbPath()
+ if (!dbPath) return undefined
+
+ let db: SqlDatabase | undefined
+ let failed = false
+ void (async () => {
+ try {
+ const mod = (await import(BUN_SQLITE)) as { Database?: new (p: string, o?: Record) => SqlDatabase }
+ const Database = mod?.Database
+ if (!Database) { failed = true; return }
+ db = new Database(dbPath, { readonly: true })
+ try { db.query("pragma query_only = on").all() } catch {}
+ } catch {
+ failed = true
+ db = undefined
+ }
+ })()
+
+ const CALL_MAP_TTL_MS = 15000
+ const CALL_MAP_RETRY_MS = 2000
+ const INFO_TTL_MS = 5000
+ const callMaps = new Map; at: number }>()
+ const infos = new Map()
+ const childrenCache = new Map()
+
+ const ready = (): boolean => {
+ if (failed || db === undefined) return false
+ try { return enabled() } catch { return false }
+ }
+
+ const buildCallMap = (parentId: string): Map => {
+ const now = Date.now()
+ const prev = callMaps.get(parentId)
+ if (prev && now - prev.at < CALL_MAP_RETRY_MS) return prev.map
+ const map = new Map()
+ try {
+ for (const row of db!.query(CALL_MAP_SQL).all(parentId)) {
+ const id = str(row.call_id)
+ const sid = str(row.child_sid)
+ if (id && sid) map.set(id, sid)
+ }
+ } catch {}
+ callMaps.set(parentId, { map, at: now })
+ return map
+ }
+
+ const resolveCall = (parentId: string, callId: string): string | undefined => {
+ if (!ready() || !parentId || !callId) return undefined
+ const prev = callMaps.get(parentId)
+ const map = prev && Date.now() - prev.at < CALL_MAP_TTL_MS ? prev.map : buildCallMap(parentId)
+ return map.get(callId) ?? buildCallMap(parentId).get(callId)
+ }
+
+ const matchChild = (parentId: string, agent: string | undefined, startedAt: number | undefined): string | undefined => {
+ if (!ready() || !parentId || startedAt === undefined) return undefined
+ const now = Date.now()
+ let cached = childrenCache.get(parentId)
+ if (!cached || now - cached.at > CALL_MAP_TTL_MS) {
+ let list: SqlRow[] = []
+ try { list = db!.query(CHILDREN_SQL).all(parentId) } catch {}
+ cached = { list, at: now }
+ childrenCache.set(parentId, cached)
+ }
+ let best: string | undefined
+ let bestAt = Number.POSITIVE_INFINITY
+ for (const row of cached.list) {
+ const sid = str(row.id)
+ const t = num(row.time_created)
+ if (!sid || t === undefined) continue
+ if (t < startedAt - 2000) continue
+ if (agent !== undefined && str(row.agent) !== agent) continue
+ if (t < bestAt) { bestAt = t; best = sid }
+ }
+ return best
+ }
+
+ const info = (sid: string): ChildSessionInfo | undefined => {
+ if (!sid) return undefined
+ const hit = infos.get(sid)
+ if (hit && Date.now() - hit.at < INFO_TTL_MS) return hit.info
+ if (!ready()) return undefined
+ let result: ChildSessionInfo | undefined
+ try {
+ const row = db!.query(CHILD_INFO_SQL).get(sid)
+ if (row) {
+ const last = db!.query(LAST_ASSISTANT_SQL).get(sid)
+ let tokens = contextTokens(last ? {
+ input: last.input, output: last.output, reasoning: last.reasoning,
+ cache: { read: last.cache_read, write: last.cache_write },
+ } : undefined)
+ if (tokens === undefined) {
+ const agg = (num(row.tokens_input) || 0) + (num(row.tokens_output) || 0) + (num(row.tokens_reasoning) || 0) +
+ (num(row.tokens_cache_read) || 0) + (num(row.tokens_cache_write) || 0)
+ if (agg > 0) tokens = agg
+ }
+ // cost 语义对齐宿主 readSessionCost:优先会话聚合值,
+ // 0 时回退 assistant 消息求和(有消息即权威,0 表示确实免费)。
+ let cost = num(row.cost)
+ if (!(typeof cost === "number" && cost > 0)) {
+ const aggCost = db!.query(ASSISTANT_COST_SQL).get(sid)
+ if (aggCost && Number(aggCost.n) > 0) cost = Number(aggCost.total) || 0
+ }
+ result = {
+ id: sid,
+ parentId: str(row.parent_id),
+ agent: str(row.agent),
+ title: str(row.title),
+ model: modelIdOf(last?.model_id) ?? modelIdOf(row.model),
+ cost,
+ tokens,
+ timeCreated: num(row.time_created),
+ timeIdle: num(row.time_idle),
+ idleOutcome: str(row.idle_outcome),
+ }
+ }
+ } catch { result = undefined }
+ infos.set(sid, { info: result, at: Date.now() })
+ return result
+ }
+
+ return { enabled: ready, resolveCall, matchChild, info }
+}
diff --git a/src/v2/index.tsx b/src/v2/index.tsx
index ea884f4..83df6b7 100644
--- a/src/v2/index.tsx
+++ b/src/v2/index.tsx
@@ -7,22 +7,31 @@ import { makeCommands } from "./commands"
import { mapTheme } from "./theme"
import { SubAgentPanel } from "../panel/SubAgentPanel"
import type { PanelApi } from "../panel/panel-api"
-import type { Lang, SortOrder, ScrollMode, SharedSignals } from "../core/types"
+import type { Lang, SortOrder, ScrollMode, SharedSignals, TimeFormat } from "../core/types"
+import { TIME_FORMATS } from "../core/format"
import { SETTING_KEYS } from "../core/kv"
import { LANG_META, detectLang } from "../i18n"
-/** 面板根组件:keymap.layer 必须在组件渲染上下文注册(setup 内调用报
- * Keymap.Provider missing),再渲染共享 SubAgentPanel。 */
-function PluginRoot(props: {
+/** 命令层必须在 app 槽注册:侧栏隐藏(sidebar: auto)时命令仍需可用。 */
+function CommandRoot(props: {
context: Context
api: PanelApi
signals: SharedSignals
- sessionID: string
}) {
props.context.keymap.layer(() => ({
mode: "global" as const,
commands: makeCommands(props.context, props.api, props.signals),
}))
+ return null
+}
+
+/** 面板根组件:渲染共享 SubAgentPanel(命令层见 CommandRoot)。 */
+function PluginRoot(props: {
+ context: Context
+ api: PanelApi
+ signals: SharedSignals
+ sessionID: string
+}) {
return (
)
@@ -69,16 +82,45 @@ const mod: PluginModule & { server: () => Promise> } = {
const [borderSignal, setBorderVisible] = createSignal(
(api.kv.get(SETTING_KEYS.border, false) as boolean) === true,
)
+ const [showEntryCostSignal, setShowEntryCost] = createSignal(
+ (api.kv.get(SETTING_KEYS.showEntryCost, false) as boolean) === true,
+ )
+ const [showEntryTimeSignal, setShowEntryTime] = createSignal(
+ (api.kv.get(SETTING_KEYS.showEntryTime, true) as boolean) !== false,
+ )
+ const [showEntryTokensSignal, setShowEntryTokens] = createSignal(
+ (api.kv.get(SETTING_KEYS.showEntryTokens, true) as boolean) !== false,
+ )
+ const storedTimeFormat = String(api.kv.get(SETTING_KEYS.timeFormat, "short"))
+ const [timeFormatSignal, setTimeFormat] = createSignal(
+ (TIME_FORMATS as readonly string[]).includes(storedTimeFormat) ? (storedTimeFormat as TimeFormat) : "short",
+ )
+ const [dbSyncSignal, setDbSync] = createSignal(
+ (api.kv.get(SETTING_KEYS.dbSync, true) as boolean) !== false,
+ )
lang = langSignal
maxEntries = maxSignal
sortOrder = orderSignal
scrollMode = scrollSignal
const signals: SharedSignals = {
- lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible: borderSignal, setBorderVisible, sessionId: "",
+ lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible: borderSignal, setBorderVisible,
+ showEntryCost: showEntryCostSignal, setShowEntryCost,
+ showEntryTime: showEntryTimeSignal, setShowEntryTime,
+ showEntryTokens: showEntryTokensSignal, setShowEntryTokens,
+ timeFormat: timeFormatSignal, setTimeFormat,
+ dbSync: dbSyncSignal, setDbSync,
+ sessionId: "",
}
- // 命令 layer(组件内注册——见 PluginRoot)
+ // 命令层挂 app 槽:侧栏隐藏时斜杠命令仍需可用(见 CommandRoot)
+ context.ui.slot({
+ append: "app",
+ render: () => (
+
+ ),
+ })
+
// 侧边栏面板(共享 SubAgentPanel——V1/V2 同一组件)
context.ui.slot({
prepend: "sidebar.content",
diff --git a/src/v2/settings-menu.ts b/src/v2/settings-menu.ts
new file mode 100644
index 0000000..a882463
--- /dev/null
+++ b/src/v2/settings-menu.ts
@@ -0,0 +1,241 @@
+import type { Context } from "./types"
+import type { PanelApi } from "../panel/panel-api"
+import type { Lang, SortOrder, ScrollMode, SharedSignals, TimeFormat } from "../core/types"
+import { SETTING_KEYS, readTTLDays } from "../core/kv"
+import { TIME_FORMATS, TIME_FORMAT_SAMPLES } from "../core/format"
+import { LANG_META, createT } from "../i18n"
+
+/** 与侧栏设置共用的 TTL 周期:3/7/14/30 天或无限期。 */
+const TTL_VALUES = [3, 7, 14, 30, 0]
+
+/** 链式原生对话框之间的延迟,让宿主能关闭上一个对话框。 */
+const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0))
+
+/**
+ * 原生设置菜单(visual-cache 风格):用单个选择列表展示每项设置
+ * 及其当前值;选中某项会打开对应的编辑器,
+ * 随后菜单重新打开,直到用户关闭(Esc / Close)。
+ */
+export function openSettingsMenu(context: Context, api: PanelApi, signals: SharedSignals): void {
+ runSettingsMenu(context, api, signals).catch((err) => {
+ try { context.ui.toast.show({ message: String(err), variant: "error" }) } catch {}
+ })
+}
+
+async function runSettingsMenu(context: Context, api: PanelApi, signals: SharedSignals): Promise {
+ const t = createT(() => signals.lang())
+ const kv = api.kv
+ const onOff = (v: boolean) => (v ? t("settings.on") : t("settings.off"))
+ const langLabel = () => LANG_META.find((m) => m.code === signals.lang())?.label ?? signals.lang()
+ const ttlTitle = (d: number) => (d === 0 ? t("ttl.unlimited") : t(d === 3 ? "ttl.3d" : d === 7 ? "ttl.7d" : d === 14 ? "ttl.14d" : "ttl.30d"))
+ const ttlLabel = () => ttlTitle(readTTLDays(kv))
+ const orderLabel = () => (signals.sortOrder() === "desc" ? t("order.desc") : t("order.asc"))
+ const scrollLabel = () => (signals.scrollMode() === "wheel" ? t("scroll.wheel") : t("scroll.click"))
+ const toast = (item: string, value: string) =>
+ context.ui.toast.show({ message: t("settings.saved", { item, value }) })
+
+ while (true) {
+ const category = t("settings.section.general")
+ const choice = await context.ui.dialog.select({
+ title: t("settings.title"),
+ options: [
+ { title: t("settings.lang"), value: "lang", description: langLabel(), category },
+ { title: t("settings.max"), value: "max", description: String(signals.maxEntries()), category },
+ { title: t("settings.order"), value: "order", description: orderLabel(), category },
+ { title: t("settings.scroll"), value: "scroll", description: scrollLabel(), category },
+ { title: t("settings.ttl"), value: "ttl", description: ttlLabel(), category },
+ { title: t("settings.border"), value: "border", description: onOff(signals.borderVisible()), category },
+ { title: t("settings.showEntryCost"), value: "cost", description: onOff(signals.showEntryCost()), category },
+ { title: t("settings.showEntryTime"), value: "time", description: onOff(signals.showEntryTime()), category },
+ { title: t("settings.timeFormat"), value: "timefmt", description: TIME_FORMAT_SAMPLES[signals.timeFormat()], category },
+ { title: t("settings.showEntryTokens"), value: "tokens", description: onOff(signals.showEntryTokens()), category },
+ { title: t("settings.dbSync"), value: "dbsync", description: onOff(signals.dbSync()), category },
+ { title: t("settings.close"), value: "close" },
+ ],
+ })
+ if (choice === undefined || choice === "close") return
+
+ // ── 语言 ──
+ if (choice === "lang") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.lang"),
+ options: LANG_META.map((m) => ({ title: m.label, value: m.code })),
+ current: signals.lang(),
+ })
+ if (picked) {
+ signals.setLang(picked)
+ kv.set(SETTING_KEYS.lang, picked)
+ toast(t("settings.lang"), LANG_META.find((m) => m.code === picked)?.label ?? picked)
+ }
+ }
+
+ // ── 最大可见条目数 ──
+ if (choice === "max") {
+ const val = await context.ui.dialog.prompt({
+ title: t("settings.max"),
+ message: "1–50",
+ placeholder: String(signals.maxEntries()),
+ })
+ if (val !== undefined) {
+ const n = parseInt(val.trim(), 10)
+ if (Number.isNaN(n)) {
+ context.ui.toast.show({ message: t("settings.invalid"), variant: "warning" })
+ } else {
+ const clamped = Math.max(1, Math.min(50, n))
+ signals.setMaxEntries(clamped)
+ kv.set(SETTING_KEYS.maxEntries, clamped)
+ toast(t("settings.max"), String(clamped))
+ }
+ }
+ }
+
+ // ── 排序方式 ──
+ if (choice === "order") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.order"),
+ options: [
+ { title: t("order.desc"), value: "desc" },
+ { title: t("order.asc"), value: "asc" },
+ ],
+ current: signals.sortOrder(),
+ })
+ if (picked) {
+ signals.setSortOrder(picked)
+ kv.set(SETTING_KEYS.order, picked)
+ toast(t("settings.order"), picked === "desc" ? t("order.desc") : t("order.asc"))
+ }
+ }
+
+ // ── 滚动模式 ──
+ if (choice === "scroll") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.scroll"),
+ options: [
+ { title: t("scroll.wheel"), value: "wheel" },
+ { title: t("scroll.click"), value: "click" },
+ ],
+ current: signals.scrollMode(),
+ })
+ if (picked) {
+ signals.setScrollMode(picked)
+ kv.set(SETTING_KEYS.scrollMode, picked)
+ toast(t("settings.scroll"), picked === "wheel" ? t("scroll.wheel") : t("scroll.click"))
+ }
+ }
+
+ // ── TTL / 保留期 ──
+ if (choice === "ttl") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.ttl"),
+ options: TTL_VALUES.map((d) => ({ title: ttlTitle(d), value: d })),
+ current: readTTLDays(kv),
+ })
+ if (picked !== undefined) {
+ kv.set(SETTING_KEYS.ttlDays, String(picked))
+ toast(t("settings.ttl"), ttlTitle(picked))
+ }
+ }
+
+ // ── 边框 ──
+ if (choice === "border") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.border"),
+ options: [
+ { title: t("settings.on"), value: true },
+ { title: t("settings.off"), value: false },
+ ],
+ current: signals.borderVisible(),
+ })
+ if (picked !== undefined) {
+ signals.setBorderVisible(picked)
+ kv.set(SETTING_KEYS.border, picked)
+ toast(t("settings.border"), onOff(picked))
+ }
+ }
+
+ // ── 单条目费用 ──
+ if (choice === "cost") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.showEntryCost"),
+ options: [
+ { title: t("settings.on"), value: true },
+ { title: t("settings.off"), value: false },
+ ],
+ current: signals.showEntryCost(),
+ })
+ if (picked !== undefined) {
+ signals.setShowEntryCost(picked)
+ kv.set(SETTING_KEYS.showEntryCost, picked)
+ toast(t("settings.showEntryCost"), onOff(picked))
+ }
+ }
+
+ // ── 单条目时间 ──
+ if (choice === "time") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.showEntryTime"),
+ options: [
+ { title: t("settings.on"), value: true },
+ { title: t("settings.off"), value: false },
+ ],
+ current: signals.showEntryTime(),
+ })
+ if (picked !== undefined) {
+ signals.setShowEntryTime(picked)
+ kv.set(SETTING_KEYS.showEntryTime, picked)
+ toast(t("settings.showEntryTime"), onOff(picked))
+ }
+ }
+
+ // ── 时间格式 ──
+ if (choice === "timefmt") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.timeFormat"),
+ options: TIME_FORMATS.map((f) => ({ title: `${f} — ${TIME_FORMAT_SAMPLES[f]}`, value: f })),
+ current: signals.timeFormat(),
+ })
+ if (picked) {
+ signals.setTimeFormat(picked)
+ kv.set(SETTING_KEYS.timeFormat, picked)
+ toast(t("settings.timeFormat"), TIME_FORMAT_SAMPLES[picked])
+ }
+ }
+
+ // ── 单条目 token 数 ──
+ if (choice === "tokens") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.showEntryTokens"),
+ options: [
+ { title: t("settings.on"), value: true },
+ { title: t("settings.off"), value: false },
+ ],
+ current: signals.showEntryTokens(),
+ })
+ if (picked !== undefined) {
+ signals.setShowEntryTokens(picked)
+ kv.set(SETTING_KEYS.showEntryTokens, picked)
+ toast(t("settings.showEntryTokens"), onOff(picked))
+ }
+ }
+
+ // ── 本地数据库补全 ──
+ if (choice === "dbsync") {
+ const picked = await context.ui.dialog.select({
+ title: t("settings.dbSync"),
+ options: [
+ { title: t("settings.on"), value: true },
+ { title: t("settings.off"), value: false },
+ ],
+ current: signals.dbSync(),
+ })
+ if (picked !== undefined) {
+ signals.setDbSync(picked)
+ kv.set(SETTING_KEYS.dbSync, picked)
+ toast(t("settings.dbSync"), onOff(picked))
+ }
+ }
+
+ // 让宿主先关闭编辑器,再重新打开菜单。
+ await nextTick()
+ }
+}
diff --git a/src/v2/types.ts b/src/v2/types.ts
index edde012..bdaa967 100644
--- a/src/v2/types.ts
+++ b/src/v2/types.ts
@@ -154,7 +154,9 @@ export interface Context {
}
}
readonly keymap: {
- layer(input: () => { readonly commands?: readonly KeymapCommand[] }): void
+ layer(input: () => {
+ readonly commands?: readonly KeymapCommand[]
+ }): void
shortcuts(id: string): readonly string[]
}
}
diff --git a/src/v2/v2-panel-api.ts b/src/v2/v2-panel-api.ts
index 176ad0b..e839d91 100644
--- a/src/v2/v2-panel-api.ts
+++ b/src/v2/v2-panel-api.ts
@@ -1,5 +1,7 @@
import type { Context } from "./types"
import type { PanelApi, PanelEvent, PanelEventType } from "../panel/panel-api"
+import { SETTING_KEYS } from "../core/kv"
+import { createSessionDbIndex, modelIdOf, statusOfChild } from "./db"
const SUBAGENT_TOOL_V2 = "subagent"
@@ -25,7 +27,12 @@ function toV1Part(p: Record): Record {
.filter((t): t is string => typeof t === "string" && t.length > 0)
.join("\n")
}
- return { type: "tool", tool: p.tool ?? p.name, state: st, subagent_type: p.subagent_type, metadata: normalizeMeta(p.metadata) }
+ // TUI 数据层把子代理 metadata 放在 part 级,旧缓存形状可能嵌在 state 里——两处合并后再双写。
+ const meta = normalizeMeta({
+ ...(((st as Record).metadata ?? {}) as Record),
+ ...((p.metadata ?? {}) as Record),
+ })
+ return { type: "tool", id: p.id, tool: p.tool ?? p.name, state: st, subagent_type: p.subagent_type, metadata: meta }
}
if (p.type === "text") return { type: "text", text: String(p.text ?? "") }
if (p.type === "file") return { type: "file", source: p.source ?? {} }
@@ -73,10 +80,72 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
const [, mutate] = entry
return mutate((d) => { d.value = value })
}
+ // 原子 read-modify-write:宿主在存储锁内基于最新磁盘值
+ // 应用 updater,因此并发 TUI 实例会 merge,
+ // 而不是用过期快照互相覆盖。
+ const kvUpdate = (key: string, updater: (current: unknown) => unknown): Promise => {
+ let entry = kvStore.get(key)
+ if (!entry) {
+ const created = context.storage.store>(`subagent_magazine.${key}`, {
+ initial: { value: undefined },
+ })
+ entry = created as [Record, (fn: (d: Record) => void) => Promise]
+ kvStore.set(key, entry)
+ }
+ const [, mutate] = entry
+ return mutate((d) => { d.value = updater(d.value) })
+ }
+
+ // 本地 opencode.db 索引:补全前台子代理缺失的 sessionId 与用量字段。
+ // 数据库不可用、库结构不符或设置项 dbSync 关闭时静默禁用(回退宿主 API)。
+ const dbIndex = createSessionDbIndex(() => kvGet(SETTING_KEYS.dbSync, true) !== false)
+
+ /** 事件 metadata 缺少子会话 id 时用本地库按 call id 回填(前台/后台通用)。 */
+ const withResolvedSid = (data: Record, meta: Record): Record => {
+ if (meta.sessionID !== undefined) return meta
+ try {
+ const parent = data.sessionID !== undefined ? String(data.sessionID) : ""
+ const callId = data.id !== undefined ? String(data.id) : ""
+ const sid = parent && callId ? dbIndex?.resolveCall(parent, callId) : undefined
+ if (sid) return normalizeMeta({ ...meta, sessionID: sid })
+ } catch {}
+ return meta
+ }
const isSubagentTool = (name: string | undefined): boolean =>
name === SUBAGENT_TOOL_V2 || name === "task" || name === "delegate" || name === "call_omo_agent"
+ /** 事件未携带工具名/input 时,从宿主消息缓存中恢复。
+ * V2 的 called/progress/success 事件没有 `name`,且
+ * input.started 可能被错过(TUI 启动、插件重载、流抖动)——
+ * 没有这个回退,运行中的子代理要到完成时才会出现。 */
+ const findToolPart = (data: Record): { name?: string; input?: Record } | undefined => {
+ const sid = data.sessionID
+ const msgID = data.assistantMessageID
+ const partID = data.id
+ if (sid === undefined || msgID === undefined || partID === undefined) return undefined
+ try {
+ const raw = context.data.session.message.list(String(sid))
+ if (!raw) return undefined
+ for (let i = raw.length - 1; i >= 0; i--) {
+ const m = raw[i] as Record
+ if (!m || String(m.id) !== String(msgID)) continue
+ const content = Array.isArray(m.content) ? m.content : []
+ for (const p of content) {
+ const pp = p as Record
+ if (pp && pp.type === "tool" && String(pp.id) === String(partID)) {
+ const st = (pp.state ?? {}) as Record
+ const input = (st.input && typeof st.input === "object") ? st.input as Record : undefined
+ const name = pp.name ?? pp.tool
+ return { name: name !== undefined ? String(name) : undefined, input }
+ }
+ }
+ break
+ }
+ } catch {}
+ return undefined
+ }
+
/** V2 tool 事件 → V1 ToolPart(非 subagent 工具返回 undefined——面板只关心子代理)。
* 识别规则(对齐 V2 官方 stream-v2.subagent.ts):
* - input.started/called:工具名在 subagent 集合(subagent/task/delegate/call_omo_agent)
@@ -102,39 +171,71 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
return { type: "tool", tool: normName(String(data.name)), id: String(data.id), state: { status: "pending", input: {} } }
}
if (type === "session.tool.called") {
- const name = data.name ?? info?.name
+ let name: string | undefined = data.name ?? info?.name
+ let input: Record | undefined =
+ (data.input as Record | undefined) ?? info?.input
+ if (!name) {
+ const resolved = findToolPart(data)
+ if (resolved?.name) {
+ name = resolved.name
+ if (input === undefined) input = resolved.input
+ }
+ }
if (name && !isSubagentTool(name)) return undefined
if (!name) return undefined
- if (key) toolInfo.set(key, { name: String(name), input: data.input ?? {} })
+ if (key) toolInfo.set(key, { name: String(name), input: input ?? {} })
return {
- type: "tool", tool: normName(String(name)), id: String(data.id), subagent_type: agentOf(data.input),
- state: { status: "running", input: data.input ?? {}, metadata: {} },
+ type: "tool", tool: normName(String(name)), id: String(data.id), subagent_type: agentOf(input),
+ state: { status: "running", input: input ?? {}, metadata: withResolvedSid(data, {}) },
}
}
if (type === "session.tool.progress") {
- const name = data.name ?? info?.name
+ let name: string | undefined = data.name ?? info?.name
+ if (!name) name = findToolPart(data)?.name
if (name && !isSubagentTool(name)) return undefined
if (!name) return undefined
return {
type: "tool", tool: normName(String(name)), id: String(data.id), subagent_type: agentOf(info?.input),
- state: { status: "running", input: info?.input ?? {}, metadata: normalizeMeta(data.metadata) },
+ state: { status: "running", input: info?.input ?? {}, metadata: withResolvedSid(data, normalizeMeta(data.metadata)) },
}
}
if (type === "session.tool.success" || type === "session.tool.failed") {
- const meta = normalizeMeta(data.metadata)
- if (meta.sessionID === undefined) return undefined
+ const eventMeta = normalizeMeta(data.metadata)
+ const meta = withResolvedSid(data, eventMeta)
// 取消导致的工具失败:metadata.status 是工具被打断时的状态("running")——不是真错误。
// 忽略——最终状态由 execution.interrupted → settleOnIdle 裁定 cancelled
// (否则 handlePartUpdated 的 error 分支会把 cancel_requested 覆盖成 error)
if (type === "session.tool.failed" && meta.status === "running") return undefined
- const name = data.name ?? info?.name
+ let name: string | undefined = data.name ?? info?.name
+ let input: Record | undefined =
+ info?.input ?? (data.input as Record | undefined)
+ if (!name || input === undefined) {
+ const resolved = findToolPart(data)
+ if (!name && resolved?.name) name = resolved.name
+ if (input === undefined && resolved?.input) input = resolved.input
+ }
if (name && !isSubagentTool(name)) return undefined
- const input = info?.input ?? data.input ?? {}
+ if (key && name) toolInfo.set(key, { name: String(name), input: input ?? info?.input ?? {} })
+ const finalInput = input ?? {}
+ // 后台任务缺少子会话 metadata 时无法判定终态,交给扫描/轮询兜底;
+ // 前台任务没有 sessionID 也必须回传 completed,否则条目会一直停在 running。
+ if (meta.sessionID === undefined && (finalInput.background === true || finalInput.run_in_background === true)) {
+ return undefined
+ }
+ // sid 来自本地库(事件没带)时以库里的 time_idle 为准:库里会话仍在运行
+ // 就不提前收尾,交给周期性 reconcile 按真实状态落定(无时间阈值猜测)。
+ let status: "completed" | "error" | "running" = type === "session.tool.failed" ? "error" : "completed"
+ if (meta.sessionID !== undefined && meta.sessionID !== eventMeta.sessionID) {
+ try {
+ const childInfo = dbIndex?.info(String(meta.sessionID))
+ if (childInfo && statusOfChild(childInfo) === undefined) status = "running"
+ } catch {}
+ }
return {
- type: "tool", tool: normName(String(name ?? "task")), id: String(data.id), subagent_type: agentOf(input),
+ type: "tool", tool: normName(String(name ?? "task")), id: String(data.id), subagent_type: agentOf(finalInput),
state: {
- status: type === "session.tool.failed" ? "error" : "completed",
- input,
+ status,
+ input: finalInput,
metadata: meta,
},
}
@@ -159,14 +260,56 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
return out
}
+ // ── usage enrichment(用量补全)──
+ // TUI 数据层只为已加载的会话缓存 messages。从 KV 恢复的
+ // 子会话通常没有缓存,因此读取依次回退到
+ // 会话级聚合值、最后是(限流的)message sync——
+ // 结果写入本地缓存,供面板的周期性 enrichment 读取。
+ const usageCache = new Map()
+ const syncRequestedAt = new Map()
+ const USAGE_CACHE_TTL_MS = 5000
+ const SYNC_THROTTLE_MS = 30000
+
+ const rememberUsage = (sid: string, patch: { tokens?: number; cost?: number; model?: string }) => {
+ const prev = usageCache.get(sid)
+ usageCache.set(sid, { ...prev, ...patch, at: Date.now() })
+ }
+
+ const cachedUsage = (sid: string) => {
+ const hit = usageCache.get(sid)
+ if (!hit) return undefined
+ if (Date.now() - hit.at > USAGE_CACHE_TTL_MS) return undefined
+ return hit
+ }
+
+ const requestMessageSync = (sid: string) => {
+ const last = syncRequestedAt.get(sid) ?? 0
+ if (Date.now() - last < SYNC_THROTTLE_MS) return
+ syncRequestedAt.set(sid, Date.now())
+ try {
+ const sync = context.data.session.message.sync
+ if (typeof sync === "function") void sync.call(context.data.session.message, sid).catch(() => {})
+ } catch {}
+ }
+
+ const listedSession = (sid: string) => {
+ try { return context.data.session.list()?.find((s) => s.id === sid) } catch { return undefined }
+ }
+
return {
kv: {
get: kvGet,
set: kvSet as (key: string, value: unknown) => void,
+ update: kvUpdate,
},
usage: {
readSessionTokens: (sid: string): number | undefined => {
if (!sid) return undefined
+ // 本地库优先:宿主数据层缺少该子会话缓存时也能给出用量。
+ try {
+ const dbTokens = dbIndex?.info(sid)?.tokens
+ if (dbTokens !== undefined) { rememberUsage(sid, { tokens: dbTokens }); return dbTokens }
+ } catch {}
try {
const msgs = context.data.session.message.list(sid)
if (msgs) {
@@ -181,54 +324,110 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
(Number(t.reasoning) || 0) +
(Number(t.cache?.read) || 0) +
(Number(t.cache?.write) || 0)
- if (ctx > 0) return ctx
+ if (ctx > 0) { rememberUsage(sid, { tokens: ctx }); return ctx }
}
}
+ // 本地无消息(子会话尚未加载):使用最近同步的值,
+ // 否则请数据层同步,稍后重试。
+ const cached = cachedUsage(sid)?.tokens
+ if (cached !== undefined) return cached
+ requestMessageSync(sid)
return undefined
} catch { return undefined }
},
readSessionCost: (sid: string): number | undefined => {
if (!sid) return undefined
+ try {
+ const dbCost = dbIndex?.info(sid)?.cost
+ if (dbCost !== undefined) { rememberUsage(sid, { cost: dbCost }); return dbCost }
+ } catch {}
+ try {
+ const direct = context.data.session.cost(sid)
+ if (typeof direct === "number" && direct > 0) { rememberUsage(sid, { cost: direct }); return direct }
+ } catch {}
try {
const session = context.data.session.get(sid)
- if (session?.cost != null && session.cost > 0) return session.cost
+ if (session?.cost != null && session.cost > 0) { rememberUsage(sid, { cost: session.cost }); return session.cost }
+ const listed = listedSession(sid)
+ if (listed?.cost != null && listed.cost > 0) { rememberUsage(sid, { cost: listed.cost }); return listed.cost }
const msgs = context.data.session.message.list(sid)
- if (!msgs) return undefined
- let total = 0
- for (const m of msgs as any[]) {
- if (m.type === "assistant" && typeof m.cost === "number") total += m.cost
+ if (Array.isArray(msgs) && msgs.length > 0) {
+ let total = 0
+ let sawAssistant = false
+ for (const m of msgs as any[]) {
+ if (m.type !== "assistant") continue
+ sawAssistant = true
+ if (typeof m.cost === "number") total += m.cost
+ }
+ // 已加载的历史是权威来源:assistant 消息 cost 为 0 表示
+ // 该会话确实免费(如 budget/test 模型),而非未知。
+ if (sawAssistant) { rememberUsage(sid, { cost: total }); return total }
}
- return total > 0 ? total : undefined
+ const cached = cachedUsage(sid)?.cost
+ if (cached !== undefined) return cached
+ requestMessageSync(sid)
+ return undefined
} catch { return undefined }
},
readSessionModel: (sid: string): string | undefined => {
if (!sid) return undefined
+ try {
+ const dbModel = dbIndex?.info(sid)?.model
+ if (dbModel !== undefined) { rememberUsage(sid, { model: dbModel }); return dbModel }
+ } catch {}
try {
const msgs = context.data.session.message.list(sid)
if (msgs) {
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i] as Record
if (m.type !== "assistant") continue
- const model = m.model as { id?: string } | string | undefined
- if (typeof model === "string") return model
- if (model?.id) return String(model.id)
+ const id = modelIdOf(m.model)
+ if (id) { rememberUsage(sid, { model: id }); return id }
}
}
+ const sessionModel = modelIdOf(context.data.session.get(sid)?.model)
+ if (sessionModel) { rememberUsage(sid, { model: sessionModel }); return sessionModel }
+ const listedModel = modelIdOf(listedSession(sid)?.model)
+ if (listedModel) { rememberUsage(sid, { model: listedModel }); return listedModel }
+ const cached = cachedUsage(sid)?.model
+ if (cached !== undefined) return cached
+ requestMessageSync(sid)
return undefined
} catch { return undefined }
},
readSessionTodo: () => undefined, // V2 无 todo 数据源
},
session: {
- get: (sid) => { try { return context.data.session.get(sid) as any } catch { return undefined } },
+ get: (sid) => {
+ try {
+ const direct = context.data.session.get(sid)
+ if (direct) return direct as any
+ // 较旧的子会话可能未注入本地缓存;回退到
+ // 会话列表,保证基于 parentID 的路由继续工作。
+ const list = context.data.session.list?.()
+ if (Array.isArray(list)) {
+ const found = list.find((s) => (s as any)?.id === sid)
+ if (found) return found as any
+ }
+ return undefined
+ } catch { return undefined }
+ },
status: (sid) => {
try {
const st = context.data.session.status(sid)
- // V2 的 "running" 等价 V1 的 "busy"(cancelEntry 检查 `st.type !== "busy"`——
- // 不映射会跳过取消流程直接标记 done)
- const type = st === "running" ? "busy" : st ?? ""
- return { type }
- } catch { return undefined }
+ if (st) {
+ // V2 的 "running" 等价 V1 的 "busy"(cancelEntry 检查 `st.type !== "busy"`——
+ // 不映射会跳过取消流程直接标记 done)
+ const type = st === "running" ? "busy" : st
+ return { type }
+ }
+ } catch {}
+ // 宿主没有该子会话的实时状态(未加载缓存)时用本地库终态兜底:
+ // time_idle 存在即已结束;不存在则不猜测(交给事件/扫描)。
+ try {
+ if (statusOfChild(dbIndex?.info(sid)) !== undefined) return { type: "idle" }
+ } catch {}
+ return undefined
},
messages: (sid) => { try { return normalizeMessages(sid) } catch { return undefined } },
part: (messageID) => {
@@ -242,6 +441,13 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
}
return msg.content.map((p) => toV1Part(p as Record))
},
+ resolveChild: (input) => {
+ try {
+ const sid = input.callId ? dbIndex?.resolveCall(input.parentId, input.callId) : undefined
+ if (sid) return sid
+ return dbIndex?.matchChild(input.parentId, input.agent, input.startedAt)
+ } catch { return undefined }
+ },
},
event: {
on: (type: PanelEventType, cb: (e: PanelEvent) => void) => {
@@ -251,11 +457,13 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
for (const evt of ["session.tool.input.started", "session.tool.called", "session.tool.progress", "session.tool.success", "session.tool.failed"]) {
unsubs.push(context.data.on(evt, (e) => {
const part = toolEventToPart((e as Record))
- if (!part) return
+ const evtData = (e as Record).data as Record | undefined
+ if (!part) {
+ return
+ }
// V2 事件流是全局的(跨所有会话);把发起会话 ID 一并传给面板,
// 让面板只归账到正在查看的会话(V1 宿主已按会话 scope,无需该字段)。
- const evt = (e as Record).data as Record | undefined
- const sid = evt?.sessionID !== undefined ? String(evt.sessionID) : undefined
+ const sid = evtData?.sessionID !== undefined ? String(evtData.sessionID) : undefined
if (!sid) return
cb({ type, scope: "global", payload: { part, sessionID: sid } })
}))
diff --git a/tests/db-index.test.ts b/tests/db-index.test.ts
new file mode 100644
index 0000000..a6fe77d
--- /dev/null
+++ b/tests/db-index.test.ts
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { contextTokens, createSessionDbIndex, findDbPath, modelIdOf, statusOfChild } from "../src/v2/db"
+
+test("contextTokens mirrors the host readSessionTokens semantics", () => {
+ assert.equal(contextTokens({ input: 10, output: 5, reasoning: 1, cache: { read: 100, write: 2 } }), 118)
+ assert.equal(contextTokens({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }), undefined)
+ assert.equal(contextTokens(undefined), undefined)
+ assert.equal(contextTokens("nope"), undefined)
+})
+
+test("statusOfChild only settles sessions whose time_idle is recorded", () => {
+ assert.equal(statusOfChild(undefined), undefined)
+ assert.equal(statusOfChild({ id: "ses_a" }), undefined)
+ assert.equal(statusOfChild({ id: "ses_a", timeIdle: 123, idleOutcome: "succeeded" }), "done")
+ assert.equal(statusOfChild({ id: "ses_a", timeIdle: 123, idleOutcome: "interrupted" }), "cancelled")
+ assert.equal(statusOfChild({ id: "ses_a", timeIdle: 123 }), "done")
+})
+
+test("modelIdOf accepts both string and object model shapes", () => {
+ assert.equal(modelIdOf("gpt-5.6-luna"), "gpt-5.6-luna")
+ assert.equal(modelIdOf({ id: "gpt-5.6-luna", providerID: "openai" }), "gpt-5.6-luna")
+ assert.equal(modelIdOf(""), undefined)
+ assert.equal(modelIdOf(undefined), undefined)
+})
+
+test("modelIdOf extracts the id from serialized JSON (session_v2.model)", () => {
+ assert.equal(modelIdOf('{"id":"deepseek-flash","providerID":"deepseek"}'), "deepseek-flash")
+ assert.equal(modelIdOf(' {"id":"gpt-5.6-luna"} '), "gpt-5.6-luna")
+ assert.equal(modelIdOf("{broken json"), undefined)
+ assert.equal(modelIdOf('{"providerID":"deepseek"}'), undefined)
+})
+
+test("findDbPath honours OPENCODE_DB and XDG_DATA_HOME", () => {
+ assert.equal(findDbPath({ OPENCODE_DB: "/tmp/custom.db" }, "/home/x"), "/tmp/custom.db")
+ assert.equal(findDbPath({ XDG_DATA_HOME: "/data" }, "/home/x"), "/data/opencode/opencode.db")
+ assert.equal(findDbPath({}, "/home/x"), "/home/x/.local/share/opencode/opencode.db")
+})
+
+test("the db index stays inert when bun:sqlite is unavailable", () => {
+ // Node/CI has no bun:sqlite; every lookup must fall back silently.
+ const index = createSessionDbIndex(() => true)
+ assert.equal(index?.resolveCall("ses_parent", "call_1"), undefined)
+ assert.equal(index?.matchChild("ses_parent", "bud-coder", Date.now()), undefined)
+ assert.equal(index?.info("ses_child"), undefined)
+})
diff --git a/tests/merge.test.ts b/tests/merge.test.ts
new file mode 100644
index 0000000..b7caced
--- /dev/null
+++ b/tests/merge.test.ts
@@ -0,0 +1,146 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import type { SessionRecord, SubEntry } from "../src/core/types"
+import type { KVApi } from "../src/core/kv"
+import { mergeSubEntries, mergeSubEntry } from "../src/panel/entry-map"
+import { updateSessionData, SESSION_DATA_KEY } from "../src/core/kv"
+
+const entry = (
+ id: string,
+ sessionId: string,
+ status: SubEntry["status"],
+ extra: Partial = {},
+): SubEntry => ({
+ id,
+ title: id,
+ agent: "explore",
+ prompt: id,
+ sessionId,
+ status,
+ startedAt: 100,
+ ...extra,
+})
+
+const sessionRecord = (entries: SubEntry[]): SessionRecord => ({
+ ts: 1,
+ entries,
+ scroll: 0,
+ expanded: "",
+ children: {},
+})
+
+test("a terminal status never regresses to running", () => {
+ const persisted = entry("tool:a", "child-1", "done", { endedAt: 500, tokens: 120 })
+ const staleInstance = entry("tool:a", "child-1", "running")
+
+ const merged = mergeSubEntry(persisted, staleInstance)
+
+ assert.equal(merged.status, "done")
+ assert.equal(merged.endedAt, 500)
+ assert.equal(merged.tokens, 120)
+})
+
+test("a newer terminal status wins over running", () => {
+ const running = entry("tool:a", "child-1", "running")
+ const settled = entry("tool:a", "child-1", "done", { endedAt: 900, tokens: 300 })
+
+ const merged = mergeSubEntry(running, settled)
+
+ assert.equal(merged.status, "done")
+ assert.equal(merged.endedAt, 900)
+})
+
+test("cancel_requested does not beat a terminal status", () => {
+ const cancelled = entry("tool:a", "child-1", "cancelled", { endedAt: 700, abortAccepted: true })
+ const stale = entry("tool:a", "child-1", "cancel_requested", { abortAccepted: true })
+
+ const merged = mergeSubEntry(cancelled, stale)
+
+ assert.equal(merged.status, "cancelled")
+ assert.equal(merged.abortAccepted, true)
+})
+
+test("mergeSubEntries unions both sides instead of replacing", () => {
+ const persisted = [entry("tool:a", "child-1", "done", { endedAt: 500 })]
+ const memory = [
+ entry("tool:a", "child-1", "running"),
+ entry("tool:b", "child-2", "running"),
+ ]
+
+ const merged = mergeSubEntries(persisted, memory)
+
+ assert.equal(merged.size, 2)
+ assert.equal(merged.get("tool:a")?.status, "done")
+ assert.equal(merged.get("tool:b")?.status, "running")
+})
+
+test("mergeSubEntries deduplicates the same child session", () => {
+ const first = entry("tool:first", "child-1", "done", { endedAt: 500 })
+ const continued = entry("tool:continue", "child-1", "running")
+
+ const merged = mergeSubEntries([first], [continued])
+
+ assert.equal(merged.size, 1)
+ assert.equal(merged.get("tool:first")?.status, "done")
+})
+
+test("progress counters only grow", () => {
+ const newer = entry("tool:a", "child-1", "running", { tokens: 900, cost: 0.5, todoTotal: 10, todoDone: 4 })
+ const stale = entry("tool:a", "child-1", "running", { tokens: 300, cost: 0.1, todoTotal: 8, todoDone: 2 })
+
+ const merged = mergeSubEntry(newer, stale)
+
+ assert.equal(merged.tokens, 900)
+ assert.equal(merged.cost, 0.5)
+ assert.equal(merged.todoTotal, 10)
+ assert.equal(merged.todoDone, 4)
+})
+
+test("startedAt keeps the earliest observed value", () => {
+ const early = entry("tool:a", "child-1", "running", { startedAt: 10 })
+ const late = entry("tool:a", "child-1", "done", { startedAt: 90, endedAt: 200 })
+
+ const merged = mergeSubEntry(early, late)
+
+ assert.equal(merged.startedAt, 10)
+})
+
+test("updateSessionData merges against the latest on-disk value via atomic update", () => {
+ const disk = new Map()
+ const fromOtherInstance = sessionRecord([entry("tool:other", "child-9", "running")])
+ disk.set(SESSION_DATA_KEY, JSON.stringify({ root: fromOtherInstance }))
+
+ const kv: KVApi = {
+ get: (key, fallback) => (disk.has(key) ? disk.get(key) : fallback),
+ set: (key, value) => { disk.set(key, value) },
+ update: (key, updater) => { disk.set(key, updater(disk.get(key))) },
+ }
+
+ updateSessionData(kv, (data) => {
+ const rec = data.root ?? sessionRecord([])
+ rec.entries = [...mergeSubEntries(rec.entries, [entry("tool:mine", "child-1", "running")]).values()]
+ data.root = rec
+ })
+
+ const persisted = JSON.parse(String(disk.get(SESSION_DATA_KEY))) as Record
+ assert.deepEqual(
+ persisted.root.entries.map((e) => e.id).sort(),
+ ["tool:mine", "tool:other"],
+ )
+})
+
+test("updateSessionData falls back to get+set when the host has no atomic update", () => {
+ const disk = new Map()
+ const kv: KVApi = {
+ get: (key, fallback) => (disk.has(key) ? disk.get(key) : fallback),
+ set: (key, value) => { disk.set(key, value) },
+ }
+
+ updateSessionData(kv, (data) => {
+ data.root = sessionRecord([entry("tool:a", "child-1", "done", { endedAt: 500 })])
+ })
+
+ const persisted = JSON.parse(String(disk.get(SESSION_DATA_KEY))) as Record
+ assert.equal(persisted.root.entries.length, 1)
+ assert.equal(persisted.root.entries[0].status, "done")
+})
diff --git a/tests/time-format.test.ts b/tests/time-format.test.ts
new file mode 100644
index 0000000..51c53a8
--- /dev/null
+++ b/tests/time-format.test.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { fmtDuration, TIME_FORMATS, TIME_FORMAT_SAMPLES } from "../src/core/format"
+
+test("fmtDuration short is the default and keeps the legacy format", () => {
+ assert.equal(fmtDuration(45_320, false), "45.32s")
+ assert.equal(fmtDuration(6_755_000, false), "112m35s")
+ assert.equal(fmtDuration(45_320, false, "short"), "45.32s")
+})
+
+test("fmtDuration decimal uses one decimal and promotes units on rounding", () => {
+ assert.equal(fmtDuration(45_300, false, "decimal"), "45.3s")
+ assert.equal(fmtDuration(570_000, false, "decimal"), "9.5m")
+ assert.equal(fmtDuration(6_840_000, false, "decimal"), "1.9h")
+ // 59.95s would round to "60.0s" — promote to minutes instead.
+ assert.equal(fmtDuration(59_950, false, "decimal"), "1.0m")
+ // 59.95m would round to "60.0m" — promote to hours instead.
+ assert.equal(fmtDuration(3_597_000, false, "decimal"), "1.0h")
+ assert.equal(fmtDuration(3_599_500, false, "decimal"), "1.0h")
+})
+
+test("fmtDuration clock renders mm:ss and h:mm:ss", () => {
+ assert.equal(fmtDuration(45_000, false, "clock"), "0:45")
+ assert.equal(fmtDuration(572_000, false, "clock"), "9:32")
+ assert.equal(fmtDuration(6_755_000, false, "clock"), "1:52:35")
+})
+
+test("fmtDuration compact drops leading units once promoted", () => {
+ assert.equal(fmtDuration(45_900, false, "compact"), "45s")
+ assert.equal(fmtDuration(572_000, false, "compact"), "9m32s")
+ assert.equal(fmtDuration(6_755_000, false, "compact"), "1h52m")
+})
+
+test("fmtDuration seconds always uses whole seconds", () => {
+ assert.equal(fmtDuration(45_000, false, "seconds"), "45s")
+ assert.equal(fmtDuration(6_755_000, false, "seconds"), "6755s")
+})
+
+test("fmtDuration hides very fresh running entries and clamps invalid input", () => {
+ assert.equal(fmtDuration(1_500, true), "")
+ assert.equal(fmtDuration(2_000, true), "2.00s")
+ assert.equal(fmtDuration(Number.NaN, false), "0.00s")
+ assert.equal(fmtDuration(-5, false), "0.00s")
+})
+
+test("short is the first mode and every mode has a printable sample", () => {
+ assert.equal(TIME_FORMATS[0], "short")
+ for (const mode of TIME_FORMATS) {
+ assert.equal(typeof TIME_FORMAT_SAMPLES[mode], "string")
+ assert.ok(TIME_FORMAT_SAMPLES[mode].length > 0)
+ }
+})