From a9e1383f559ec42d57d68780cef869f795a888c6 Mon Sep 17 00:00:00 2001 From: Tiago Date: Wed, 23 Sep 2026 22:33:00 -0300 Subject: [PATCH 1/8] =?UTF-8?q?fix(v2):=20=E4=BF=AE=E5=A4=8D=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E4=B8=A2=E5=A4=B1=E3=80=81=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=AF=AF=E5=88=A4=E4=B8=8E=E5=A4=9A=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留 tool part id,修复历史扫描看不到子代理的问题 - 合并 part/state 两级 metadata,后台子代理不再被误判为已完成 - 非后台条目完成但未收到 success 事件时,发现轮询触发补扫 - KVApi.update 原子合并写入,多 TUI 实例不再互相覆盖;无 update 时退回读改写 - 扫描/轮询恢复漏事件的条目,周期校正 idle 僵尸条目 --- src/core/kv.ts | 43 ++- src/i18n.ts | 4 + src/panel/SubAgentPanel.tsx | 694 ++++++++++++++++++++++++------------ src/panel/entry-map.ts | 78 +++- src/v2/commands.ts | 111 +++--- src/v2/v2-panel-api.ts | 195 ++++++++-- tests/merge.test.ts | 146 ++++++++ 7 files changed, 982 insertions(+), 289 deletions(-) create mode 100644 tests/merge.test.ts diff --git a/src/core/kv.ts b/src/core/kv.ts index 0c0fec3..33f59fe 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,10 @@ 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`, } as const export function loadSessionData(kv: KVApi): Record { @@ -27,8 +38,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/i18n.ts b/src/i18n.ts index efbbdba..96d9cdf 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": "目标不是子会话", @@ -104,6 +105,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", @@ -158,6 +160,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": "対象は子セッションではありません", @@ -212,6 +215,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": "대상이 하위 세션이 아닙니다", diff --git a/src/panel/SubAgentPanel.tsx b/src/panel/SubAgentPanel.tsx index 736e10b..130c9bd 100644 --- a/src/panel/SubAgentPanel.tsx +++ b/src/panel/SubAgentPanel.tsx @@ -32,6 +32,17 @@ 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 +} + export function SubAgentPanel(props: { api: PanelApi theme: Record @@ -73,8 +84,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 +147,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 +231,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 +390,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 +425,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 +452,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 +465,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 +657,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 +723,357 @@ 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 settled: string[] = [] + 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: SubStatus = entry.status === "cancel_requested" && entry.abortAccepted + ? "cancelled" + : "done" + next.set(id, { + ...entry, status: finalStatus, endedAt: Date.now(), + tokens: tokens ?? entry.tokens, + cost: cost ?? entry.cost, + }) + changed = true + settled.push(id) + } 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) + const scanSubSid = scanMeta?.session_id !== undefined ? String(scanMeta.session_id) + : scanMeta?.sessionId !== undefined ? String(scanMeta.sessionId) + : scanMeta?.sessionID !== undefined ? String(scanMeta.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 + } + + // 已落定的条目:仅在补全缺失数据时回访 + // (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 || + exists!.model === undefined) + 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 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 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: 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 + if (!entry.sessionId) continue + const running = entry.status === "running" || entry.status === "cancel_requested" + const missing = entry.tokens === undefined || entry.cost === undefined || entry.model === undefined + if (!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] + const entry = next.get(id) + if (!entry || !entry.sessionId) continue + // 只从子会话读取,绝不读父会话。较旧的子会话 + // 可能不在本地缓存中,此时它们仍属于 + // 本面板的 map,读取是安全的。 let isChild = false try { const s = props.api.session.get(entry.sessionId) - isChild = s?.parentID === props.sessionId - } catch {} + 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(entry.sessionId) + if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; entryChanged = true } + } + if (running || entry.cost === undefined) { + const cost = props.api.usage.readSessionCost(entry.sessionId) + if (cost !== undefined && cost !== entry.cost) { nextEntry.cost = cost; entryChanged = true } + } + if (running || entry.model === undefined) { + const model = props.api.usage.readSessionModel(entry.sessionId) + if (model && model !== entry.model) { nextEntry.model = model; entryChanged = true } + } + if (running) { + const todo = props.api.usage.readSessionTodo(entry.sessionId) + 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() + } + }) + } bump() }, 500) bump() @@ -717,7 +1082,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 +1094,7 @@ export function SubAgentPanel(props: { onCleanup(() => { disposed = true + clearTimeout(rescanTimer) clearInterval(clock) clearInterval(tokenTimer) unsubPart() @@ -750,7 +1116,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 +1131,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 ── 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/v2/commands.ts b/src/v2/commands.ts index f34c3fd..cbbfe39 100644 --- a/src/v2/commands.ts +++ b/src/v2/commands.ts @@ -140,28 +140,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 +237,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/v2-panel-api.ts b/src/v2/v2-panel-api.ts index 176ad0b..8c1f0d6 100644 --- a/src/v2/v2-panel-api.ts +++ b/src/v2/v2-panel-api.ts @@ -25,7 +25,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 +78,56 @@ 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) }) + } 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,17 +153,27 @@ 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: {} }, } } 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 { @@ -127,14 +188,22 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) // 忽略——最终状态由 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 ?? {} 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, + input: finalInput, metadata: meta, }, } @@ -159,10 +228,47 @@ 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 => { @@ -181,24 +287,45 @@ 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 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 => { @@ -210,17 +337,37 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) 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 = typeof model === "string" ? model : model?.id + if (id) { rememberUsage(sid, { model: String(id) }); return String(id) } } } + const session = context.data.session.get(sid) + if (session?.model) { rememberUsage(sid, { model: String(session.model) }); return String(session.model) } + const listed = listedSession(sid) + if (listed?.model) { rememberUsage(sid, { model: String(listed.model) }); return String(listed.model) } + 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) @@ -251,11 +398,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/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") +}) From 72ea6926e8a2ef3f0a55d4aca22d4c581992d9bf Mon Sep 17 00:00:00 2001 From: Tiago Date: Wed, 23 Sep 2026 22:33:10 -0300 Subject: [PATCH 2/8] =?UTF-8?q?feat(panel):=20=E8=B4=B9=E7=94=A8=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E3=80=81=E6=9D=A1=E7=9B=AE=E6=98=BE=E7=A4=BA=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E3=80=81=E8=AE=BE=E7=BD=AE=E8=8F=9C=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E8=80=97=E6=97=B6=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标题栏总费用(2 位小数);/subagent-cost 列出条目费用(4 位小数,默认关) - /subagent-time、/subagent-tokens 开关耗时与 Token 显示(默认开) - /subagent-config 设置摘要;/subagent-sections 原生循环设置菜单 - /subagent-time-format:short(默认)/ decimal / clock / compact / seconds - i18n 四语言补齐新设置项 --- src/core/format.ts | 44 +++++++ src/core/types.ts | 10 ++ src/i18n.ts | 68 +++++++++++ src/index.tsx | 92 ++++++++++++++- src/panel/SubAgentPanel.tsx | 29 +++-- src/v2/commands.ts | 105 ++++++++++++++++- src/v2/index.tsx | 52 +++++++-- src/v2/settings-menu.ts | 223 ++++++++++++++++++++++++++++++++++++ src/v2/types.ts | 4 +- tests/time-format.test.ts | 52 +++++++++ 10 files changed, 658 insertions(+), 21 deletions(-) create mode 100644 src/v2/settings-menu.ts create mode 100644 tests/time-format.test.ts 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/types.ts b/src/core/types.ts index 22099bb..7f1507c 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,13 @@ 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 sessionId: string } diff --git a/src/i18n.ts b/src/i18n.ts index 96d9cdf..2c96dd0 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -59,6 +59,23 @@ 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.on": "开", + "settings.off": "关", + "settings.saved": "已保存 {item}:{value}", + "settings.invalid": "无效的数值", + "settings.close": "关闭", } as const /** 结构约束:值放宽为 string,键集合来自中文表(新增语言缺 key 会编译报错)。 */ @@ -117,6 +134,23 @@ 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.on": "on", + "settings.off": "off", + "settings.saved": "Saved {item}: {value}", + "settings.invalid": "Invalid value", + "settings.close": "Close", } const JA_T: Translation = { @@ -172,6 +206,23 @@ 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.on": "オン", + "settings.off": "オフ", + "settings.saved": "保存しました {item}:{value}", + "settings.invalid": "無効な値です", + "settings.close": "閉じる", } const KO_T: Translation = { @@ -227,6 +278,23 @@ 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.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..105c8c7 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,21 @@ 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 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, sessionId: "" } // ── V1 PanelApi adapter: wraps the V1 host API into the shared panel contract ── const v1Api: PanelApi = { @@ -462,6 +480,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 130c9bd..c1acac6 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 @@ -51,6 +51,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()) @@ -1473,12 +1477,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 @@ -1486,6 +1494,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()) @@ -1517,6 +1527,9 @@ export function SubAgentPanel(props: { {tokenText() ? ( {tokenText()} ) : null} + {costText() ? ( + {costText()} + ) : null} {/* expanded detail — right-aligned values */} @@ -1541,7 +1554,7 @@ export function SubAgentPanel(props: { {t("time.label")}: {" ".repeat(expandedPad(t("time.label")))} - {fmtDurationShort(elapsed(), isActiveRunning)} + {fmtDuration(elapsed(), isActiveRunning, props.timeFormat())} diff --git a/src/v2/commands.ts b/src/v2/commands.ts index cbbfe39..fae249c 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,97 @@ 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()]}`, + ].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.version", title: "SubAgent Magazine: Version", diff --git a/src/v2/index.tsx b/src/v2/index.tsx index ea884f4..92eec6a 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,41 @@ 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", + ) 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, + 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..1130a49 --- /dev/null +++ b/src/v2/settings-menu.ts @@ -0,0 +1,223 @@ +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.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)) + } + } + + // 让宿主先关闭编辑器,再重新打开菜单。 + 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/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) + } +}) From caee28f6f26bc0588f74ecd39424009b21c01f9c Mon Sep 17 00:00:00 2001 From: Tiago Date: Wed, 23 Sep 2026 22:33:16 -0300 Subject: [PATCH 3/8] =?UTF-8?q?docs(readme):=20=E8=A1=A5=E5=85=85=E6=96=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=B8=8E=E5=91=BD=E4=BB=A4=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 ++++++++++++- README_EN.md | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) 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 |
Slash Commands From 4276c2f75b9590d3f41e4745cbb1481e28a65c7e Mon Sep 17 00:00:00 2001 From: Tiago Date: Wed, 23 Sep 2026 22:33:16 -0300 Subject: [PATCH 4/8] =?UTF-8?q?ci:=20=E5=9C=A8=20CI=20=E4=B8=AD=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) 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 From 3ac263ac0ae7c65697ec531a5f140423b9ac84c3 Mon Sep 17 00:00:00 2001 From: Tiago Date: Wed, 23 Sep 2026 22:37:41 -0300 Subject: [PATCH 5/8] =?UTF-8?q?fix(v2):=20success=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=BC=BA=E5=B0=91=20metadata=20=E6=97=B6=E5=9B=9E=E8=90=BD=20c?= =?UTF-8?q?ompleted=EF=BC=8Crunning=20=E6=9D=A1=E7=9B=AE=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前台任务(无子会话 metadata)的工具完成事件不再被丢弃,条目即时收尾 - 后台任务缺少 metadata 仍交由扫描/轮询兜底 - 存在 running 条目时每约 30s 与历史对账一次 --- src/panel/SubAgentPanel.tsx | 11 +++++++++++ src/v2/v2-panel-api.ts | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/panel/SubAgentPanel.tsx b/src/panel/SubAgentPanel.tsx index c1acac6..6cf9a62 100644 --- a/src/panel/SubAgentPanel.tsx +++ b/src/panel/SubAgentPanel.tsx @@ -1078,6 +1078,17 @@ export function SubAgentPanel(props: { } }) } + // 周期性对账(约每 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() diff --git a/src/v2/v2-panel-api.ts b/src/v2/v2-panel-api.ts index 8c1f0d6..68cddc0 100644 --- a/src/v2/v2-panel-api.ts +++ b/src/v2/v2-panel-api.ts @@ -183,7 +183,6 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) } if (type === "session.tool.success" || type === "session.tool.failed") { const meta = normalizeMeta(data.metadata) - if (meta.sessionID === undefined) return undefined // 取消导致的工具失败:metadata.status 是工具被打断时的状态("running")——不是真错误。 // 忽略——最终状态由 execution.interrupted → settleOnIdle 裁定 cancelled // (否则 handlePartUpdated 的 error 分支会把 cancel_requested 覆盖成 error) @@ -199,6 +198,11 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) if (name && !isSubagentTool(name)) return undefined 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 + } return { type: "tool", tool: normName(String(name ?? "task")), id: String(data.id), subagent_type: agentOf(finalInput), state: { From e04597dd122fb3ddfee323162b95b391f209aa42 Mon Sep 17 00:00:00 2001 From: Tiago Date: Wed, 23 Sep 2026 22:48:15 -0300 Subject: [PATCH 6/8] =?UTF-8?q?fix(v2):=20model=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=8F=90=E5=8F=96=20id=20=E5=B9=B6=E6=B8=85=E7=90=86=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=20"[object=20Object]"=20=E8=84=8F=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host 的 session.model 为对象时提取 id,不再写入序列化垃圾 - 历史脏值视为缺失,scan/enrich 重新读取修复;无法修复时不再显示 --- src/panel/SubAgentPanel.tsx | 15 ++++++++++----- src/v2/v2-panel-api.ts | 23 ++++++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/panel/SubAgentPanel.tsx b/src/panel/SubAgentPanel.tsx index 6cf9a62..ff31676 100644 --- a/src/panel/SubAgentPanel.tsx +++ b/src/panel/SubAgentPanel.tsx @@ -43,6 +43,11 @@ function partMetadata( return stateMeta && Object.keys(stateMeta).length > 0 ? stateMeta : undefined } +/** model 字段的有效值——历史 KV 里可能混入 "[object Object]" 这类序列化垃圾。 */ +function validModel(model: string | undefined): model is string { + return typeof model === "string" && model.length > 0 && model !== "[object Object]" +} + export function SubAgentPanel(props: { api: PanelApi theme: Record @@ -836,7 +841,7 @@ export function SubAgentPanel(props: { (exists!.sessionId === undefined || exists!.tokens === undefined || exists!.cost === undefined || - exists!.model === undefined) + !validModel(exists!.model)) if (settled && !needsRepair) continue let status: SubStatus = "running" @@ -900,7 +905,7 @@ export function SubAgentPanel(props: { // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite tokens: exists?.tokens ?? tokens, cost: exists?.cost ?? cost, - model: exists?.model ?? model, + model: validModel(exists?.model) ? exists!.model : model, sessionId: exists?.sessionId ?? scanSubSid, status, startedAt: exists?.startedAt || Date.now(), @@ -1009,7 +1014,7 @@ export function SubAgentPanel(props: { for (const [id, entry] of next) { if (!entry.sessionId) continue const running = entry.status === "running" || entry.status === "cancel_requested" - const missing = entry.tokens === undefined || entry.cost === undefined || entry.model === undefined + const missing = entry.tokens === undefined || entry.cost === undefined || !validModel(entry.model) if (!running && !missing) continue candidates.push(id) } @@ -1042,7 +1047,7 @@ export function SubAgentPanel(props: { const cost = props.api.usage.readSessionCost(entry.sessionId) if (cost !== undefined && cost !== entry.cost) { nextEntry.cost = cost; entryChanged = true } } - if (running || entry.model === undefined) { + if (running || !validModel(entry.model)) { const model = props.api.usage.readSessionModel(entry.sessionId) if (model && model !== entry.model) { nextEntry.model = model; entryChanged = true } } @@ -1598,7 +1603,7 @@ export function SubAgentPanel(props: { ) })()} - + {" "} {t("model.label")}: diff --git a/src/v2/v2-panel-api.ts b/src/v2/v2-panel-api.ts index 68cddc0..9546a44 100644 --- a/src/v2/v2-panel-api.ts +++ b/src/v2/v2-panel-api.ts @@ -15,6 +15,16 @@ function normalizeMeta(meta: unknown): Record { return m } +/** 从 host 的 model 字段(字符串或 { id } 对象)提取模型 id。 */ +function modelIdOf(value: unknown): string | undefined { + if (typeof value === "string") return value + if (value && typeof value === "object") { + const id = (value as { id?: unknown }).id + if (id !== undefined) return String(id) + } + return undefined +} + /** V2 content part → V1 Part 形状(scan 的 part() 消费)。 */ function toV1Part(p: Record): Record { if (p.type === "tool") { @@ -340,15 +350,14 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) 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 - const id = typeof model === "string" ? model : model?.id - if (id) { rememberUsage(sid, { model: String(id) }); return String(id) } + const id = modelIdOf(m.model) + if (id) { rememberUsage(sid, { model: id }); return id } } } - const session = context.data.session.get(sid) - if (session?.model) { rememberUsage(sid, { model: String(session.model) }); return String(session.model) } - const listed = listedSession(sid) - if (listed?.model) { rememberUsage(sid, { model: String(listed.model) }); return String(listed.model) } + 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) From c0edc1379cec368d07501845a68a9d4b5e525f81 Mon Sep 17 00:00:00 2001 From: Tiago Date: Wed, 23 Sep 2026 23:09:56 -0300 Subject: [PATCH 7/8] =?UTF-8?q?feat(v2):=20=E6=9C=AC=E5=9C=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=A1=A5=E5=85=A8=E5=89=8D=E5=8F=B0=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E7=9A=84=20sessionId=20=E4=B8=8E=E7=94=A8?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只读读取宿主 opencode.db(bun:sqlite 动态加载,OPENCODE_DB/XDG_DATA_HOME 可定位) - 用父会话工具 part 的 state.metadata.sessionID 按 call id 回填子会话链接 - 从 session_v2 读取 agent/model/tokens/cost 与 time_idle/idle_outcome 终态 - 移除无链接 running 条目的时间阈值猜测;扫描/对账/用量轮询均走真实数据 - 新增 /subagent-db 开关、设置菜单项、4 语言文案与 tests/db-index.test.ts --- build.tui.mjs | 4 +- src/core/kv.ts | 1 + src/core/types.ts | 2 + src/i18n.ts | 4 + src/index.tsx | 5 +- src/panel/SubAgentPanel.tsx | 66 ++++++--- src/panel/panel-api.ts | 8 ++ src/v2/commands.ts | 14 ++ src/v2/db.ts | 261 ++++++++++++++++++++++++++++++++++++ src/v2/index.tsx | 4 + src/v2/settings-menu.ts | 18 +++ src/v2/v2-panel-api.ts | 74 ++++++++-- tests/db-index.test.ts | 39 ++++++ 13 files changed, 469 insertions(+), 31 deletions(-) create mode 100644 src/v2/db.ts create mode 100644 tests/db-index.test.ts 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/kv.ts b/src/core/kv.ts index 33f59fe..ab64993 100644 --- a/src/core/kv.ts +++ b/src/core/kv.ts @@ -29,6 +29,7 @@ export const SETTING_KEYS = { 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 { diff --git a/src/core/types.ts b/src/core/types.ts index 7f1507c..23c51ac 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -66,5 +66,7 @@ export interface SharedSignals { 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 2c96dd0..23bb16c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -71,6 +71,7 @@ const ZH_T = { "settings.showEntryTime": "列表中显示耗时", "settings.showEntryTokens": "列表中显示 Token 数", "settings.timeFormat": "时间格式", + "settings.dbSync": "本地数据库补全", "settings.on": "开", "settings.off": "关", "settings.saved": "已保存 {item}:{value}", @@ -146,6 +147,7 @@ const EN_T: Translation = { "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}", @@ -218,6 +220,7 @@ const JA_T: Translation = { "settings.showEntryTime": "一覧に所要時間を表示", "settings.showEntryTokens": "一覧にトークン数を表示", "settings.timeFormat": "時間形式", + "settings.dbSync": "ローカルDB補完", "settings.on": "オン", "settings.off": "オフ", "settings.saved": "保存しました {item}:{value}", @@ -290,6 +293,7 @@ const KO_T: Translation = { "settings.showEntryTime": "목록에 소요 시간 표시", "settings.showEntryTokens": "목록에 토큰 수 표시", "settings.timeFormat": "시간 형식", + "settings.dbSync": "로컬 DB 보완", "settings.on": "켬", "settings.off": "끔", "settings.saved": "저장됨 {item}: {value}", diff --git a/src/index.tsx b/src/index.tsx index 105c8c7..6e69de0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -79,8 +79,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { 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, showEntryCost, setShowEntryCost, showEntryTime, setShowEntryTime, showEntryTokens, setShowEntryTokens, timeFormat, setTimeFormat, 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 = { diff --git a/src/panel/SubAgentPanel.tsx b/src/panel/SubAgentPanel.tsx index ff31676..d89cd2b 100644 --- a/src/panel/SubAgentPanel.tsx +++ b/src/panel/SubAgentPanel.tsx @@ -738,25 +738,37 @@ export function SubAgentPanel(props: { * 纯 map 变换——是否持久化由调用方决定。 */ const settleIdleEntries = (prev: Map): Map => { let changed = false - const settled: string[] = [] const next = new Map(prev) for (const [id, entry] of next) { - if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId) continue + 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(entry.sessionId) + const st = props.api.session.status(sid) if (!st || st.type !== "idle") continue - const tokens = props.api.usage.readSessionTokens(entry.sessionId) - const cost = props.api.usage.readSessionCost(entry.sessionId) + 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, status: finalStatus, endedAt: Date.now(), + ...entry, sessionId: sid, status: finalStatus, endedAt: Date.now(), tokens: tokens ?? entry.tokens, cost: cost ?? entry.cost, }) changed = true - settled.push(id) } catch {} } return changed ? next : prev @@ -808,10 +820,16 @@ export function SubAgentPanel(props: { // TUI 数据层把子代理 metadata 放在 part 级;旧缓存 // 形状可能嵌在 state 里。两处都读。 const scanMeta = partMetadata(part, st) - const scanSubSid = scanMeta?.session_id !== undefined ? String(scanMeta.session_id) + 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 @@ -871,9 +889,8 @@ export function SubAgentPanel(props: { 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 { + // 无终态证据时不猜测:等本地库/事件回填子会话状态后由 reconcile 落定。 continue } } else { @@ -1012,10 +1029,10 @@ export function SubAgentPanel(props: { // 饿死列表其余部分;每个 tick 只读取少数会话。 const candidates: string[] = [] for (const [id, entry] of next) { - if (!entry.sessionId) continue const running = entry.status === "running" || entry.status === "cancel_requested" const missing = entry.tokens === undefined || entry.cost === undefined || !validModel(entry.model) - if (!running && !missing) continue + // 无链接条目(含历史 done)也参与:先用宿主/本地库回填 sid,再补 usage。 + if (entry.sessionId && !running && !missing) continue candidates.push(id) } if (candidates.length > 0) { @@ -1023,14 +1040,25 @@ export function SubAgentPanel(props: { let budget = 4 for (let k = 0; k < candidates.length && budget > 0; k++) { const id = candidates[(start + k) % candidates.length] - const entry = next.get(id) - if (!entry || !entry.sessionId) continue + 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) + const s = props.api.session.get(childSid) isChild = !s || s.parentID === props.sessionId } catch { isChild = true } if (!isChild) continue @@ -1040,19 +1068,19 @@ export function SubAgentPanel(props: { let entryChanged = false const nextEntry: SubEntry = { ...entry } if (running || entry.tokens === undefined) { - const total = props.api.usage.readSessionTokens(entry.sessionId) + 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(entry.sessionId) + 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(entry.sessionId) + 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(entry.sessionId) + 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 } 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 fae249c..4dbfd3a 100644 --- a/src/v2/commands.ts +++ b/src/v2/commands.ts @@ -126,6 +126,7 @@ export function makeCommands( `${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 }) }, @@ -197,6 +198,19 @@ export function makeCommands( 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", diff --git a/src/v2/db.ts b/src/v2/db.ts new file mode 100644 index 0000000..2659a34 --- /dev/null +++ b/src/v2/db.ts @@ -0,0 +1,261 @@ +/** + * 本地 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。 */ +export function modelIdOf(value: unknown): string | undefined { + if (typeof value === "string") return value.length > 0 ? value : undefined + if (value && typeof value === "object") { + const id = (value as { id?: unknown }).id + if (id !== undefined) return String(id) + } + 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 92eec6a..83df6b7 100644 --- a/src/v2/index.tsx +++ b/src/v2/index.tsx @@ -95,6 +95,9 @@ const mod: PluginModule & { server: () => Promise> } = { 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 @@ -106,6 +109,7 @@ const mod: PluginModule & { server: () => Promise> } = { showEntryTime: showEntryTimeSignal, setShowEntryTime, showEntryTokens: showEntryTokensSignal, setShowEntryTokens, timeFormat: timeFormatSignal, setTimeFormat, + dbSync: dbSyncSignal, setDbSync, sessionId: "", } diff --git a/src/v2/settings-menu.ts b/src/v2/settings-menu.ts index 1130a49..a882463 100644 --- a/src/v2/settings-menu.ts +++ b/src/v2/settings-menu.ts @@ -49,6 +49,7 @@ async function runSettingsMenu(context: Context, api: PanelApi, signals: SharedS { 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" }, ], }) @@ -217,6 +218,23 @@ async function runSettingsMenu(context: Context, api: PanelApi, signals: SharedS } } + // ── 本地数据库补全 ── + 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/v2-panel-api.ts b/src/v2/v2-panel-api.ts index 9546a44..642b4b8 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, statusOfChild } from "./db" const SUBAGENT_TOOL_V2 = "subagent" @@ -104,6 +106,22 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) 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" @@ -178,7 +196,7 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) if (key) toolInfo.set(key, { name: String(name), input: input ?? {} }) return { type: "tool", tool: normName(String(name)), id: String(data.id), subagent_type: agentOf(input), - state: { status: "running", input: input ?? {}, metadata: {} }, + state: { status: "running", input: input ?? {}, metadata: withResolvedSid(data, {}) }, } } if (type === "session.tool.progress") { @@ -188,11 +206,12 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) 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) + const eventMeta = normalizeMeta(data.metadata) + const meta = withResolvedSid(data, eventMeta) // 取消导致的工具失败:metadata.status 是工具被打断时的状态("running")——不是真错误。 // 忽略——最终状态由 execution.interrupted → settleOnIdle 裁定 cancelled // (否则 handlePartUpdated 的 error 分支会把 cancel_requested 覆盖成 error) @@ -213,10 +232,19 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) 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(finalInput), state: { - status: type === "session.tool.failed" ? "error" : "completed", + status, input: finalInput, metadata: meta, }, @@ -287,6 +315,11 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) 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) { @@ -314,6 +347,10 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) }, 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 } @@ -344,6 +381,10 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) }, 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) { @@ -384,11 +425,19 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"]) 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) => { @@ -402,6 +451,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) => { diff --git a/tests/db-index.test.ts b/tests/db-index.test.ts new file mode 100644 index 0000000..6f15fe3 --- /dev/null +++ b/tests/db-index.test.ts @@ -0,0 +1,39 @@ +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("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) +}) From 4f1d4dfb36f81fc18db385cb8bc7b0fe4e0adb49 Mon Sep 17 00:00:00 2001 From: Tiago Date: Thu, 24 Sep 2026 12:25:54 -0300 Subject: [PATCH 8/8] =?UTF-8?q?fix(v2):=20=E4=BF=AE=E5=A4=8D=20model=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=9C=A8=E6=9C=AC=E5=9C=B0=E5=BA=93=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8B=E9=80=8F=E4=BC=A0=20JSON=20=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session_v2.model 是序列化 JSON 文本;modelIdOf 现在解析并取 id - 适配层改用共享的 modelIdOf(去掉本地副本) - validModel 拒绝以 "{" 开头的脏值,防旧 KV 数据回显 - 新增 JSON 形态的单元测试 --- src/panel/SubAgentPanel.tsx | 5 ++++- src/v2/db.ts | 19 ++++++++++++++++--- src/v2/v2-panel-api.ts | 12 +----------- tests/db-index.test.ts | 7 +++++++ 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/panel/SubAgentPanel.tsx b/src/panel/SubAgentPanel.tsx index d89cd2b..209c7d8 100644 --- a/src/panel/SubAgentPanel.tsx +++ b/src/panel/SubAgentPanel.tsx @@ -45,7 +45,10 @@ function partMetadata( /** model 字段的有效值——历史 KV 里可能混入 "[object Object]" 这类序列化垃圾。 */ function validModel(model: string | undefined): model is string { - return typeof model === "string" && model.length > 0 && model !== "[object Object]" + 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: { diff --git a/src/v2/db.ts b/src/v2/db.ts index 2659a34..c126b32 100644 --- a/src/v2/db.ts +++ b/src/v2/db.ts @@ -52,12 +52,25 @@ interface SqlDatabase { // 打包产物保留运行时 import;Node(CI 测试)加载失败会静默禁用。 const BUN_SQLITE = "bun:sqlite" -/** 从宿主 model 字段(字符串或 { id } 对象)提取模型 id。 */ +/** 从宿主 model 字段提取模型 id。 + * 兼容三种形态:名字字符串、{ id } 对象,以及序列化后的 JSON 字符串 + * (session_v2.model 列存的就是 `{"id":...}` 文本)。 */ export function modelIdOf(value: unknown): string | undefined { - if (typeof value === "string") return value.length > 0 ? value : 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 String(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 } diff --git a/src/v2/v2-panel-api.ts b/src/v2/v2-panel-api.ts index 642b4b8..e839d91 100644 --- a/src/v2/v2-panel-api.ts +++ b/src/v2/v2-panel-api.ts @@ -1,7 +1,7 @@ import type { Context } from "./types" import type { PanelApi, PanelEvent, PanelEventType } from "../panel/panel-api" import { SETTING_KEYS } from "../core/kv" -import { createSessionDbIndex, statusOfChild } from "./db" +import { createSessionDbIndex, modelIdOf, statusOfChild } from "./db" const SUBAGENT_TOOL_V2 = "subagent" @@ -17,16 +17,6 @@ function normalizeMeta(meta: unknown): Record { return m } -/** 从 host 的 model 字段(字符串或 { id } 对象)提取模型 id。 */ -function modelIdOf(value: unknown): string | undefined { - if (typeof value === "string") return value - if (value && typeof value === "object") { - const id = (value as { id?: unknown }).id - if (id !== undefined) return String(id) - } - return undefined -} - /** V2 content part → V1 Part 形状(scan 的 part() 消费)。 */ function toV1Part(p: Record): Record { if (p.type === "tool") { diff --git a/tests/db-index.test.ts b/tests/db-index.test.ts index 6f15fe3..a6fe77d 100644 --- a/tests/db-index.test.ts +++ b/tests/db-index.test.ts @@ -24,6 +24,13 @@ test("modelIdOf accepts both string and object model shapes", () => { 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")