From f3d04b83c5cdde96838dba80c1d55abc22f6b85a Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 21:48:37 +0800 Subject: [PATCH 1/2] feat(plugins): add a read-only usage.listTurns API Give plugins a host-owned completed-turn fact listing gated by usage.read, so dashboards like pi.token-insights can stop reading pi.sqlite. The host returns identifiers and token counters only: no message body, no ranking, no dashboard shape. Query logic lives in plugin_usage.rs. Electron and host reject the same window/limit bounds; empty session titles serialize as null. --- .../electron/main/plugin-host-process.mjs | 6 + apps/desktop/electron/main/plugin-runtime.ts | 91 ++++++ .../electron/main/services/plugin-services.ts | 7 + .../resources/skills/plugin-development.md | 2 +- apps/desktop/src/features/plugins/model.ts | 2 + apps/desktop/test/plugin-session-api.test.mjs | 99 ++++++ crates/host-core/src/main.rs | 1 + crates/host-core/src/plugin_usage.rs | 293 ++++++++++++++++++ crates/host-core/src/rpc/mod.rs | 252 +++++++++++++++ docs/spec/03-runtime/06-host-rpc-protocol.md | 3 + docs/spec/06-delivery/04-e2e-test-plan.md | 20 ++ docs/spec/07-plugins/01-plugin-system.md | 1 + .../07-plugins/02-plugin-manifest-schema.md | 1 + docs/spec/07-plugins/03-plugin-api.md | 44 +++ .../13-plugin-permissions-matrix.md | 2 + .../spec/03-runtime/06-host-rpc-protocol.md | 3 + .../spec/06-delivery/04-e2e-test-plan.md | 10 + .../zh-CN/spec/07-plugins/01-plugin-system.md | 1 + .../07-plugins/02-plugin-manifest-schema.md | 1 + docs/zh-CN/spec/07-plugins/03-plugin-api.md | 38 +++ .../13-plugin-permissions-matrix.md | 2 + packages/i18n/src/locales/de/index.ts | 7 +- packages/i18n/src/locales/en/index.ts | 3 + packages/i18n/src/locales/es/index.ts | 7 +- packages/i18n/src/locales/fr/index.ts | 7 +- packages/i18n/src/locales/ko/index.ts | 3 + packages/i18n/src/locales/tr/index.ts | 3 + packages/i18n/src/locales/zh-CN/index.ts | 3 + packages/i18n/src/locales/zh-TW/index.ts | 3 + packages/plugin-sdk/src/index.test.ts | 1 + packages/plugin-sdk/src/index.ts | 57 ++++ 31 files changed, 966 insertions(+), 7 deletions(-) create mode 100644 crates/host-core/src/plugin_usage.rs diff --git a/apps/desktop/electron/main/plugin-host-process.mjs b/apps/desktop/electron/main/plugin-host-process.mjs index ad624a98bf..1ed4f3db32 100644 --- a/apps/desktop/electron/main/plugin-host-process.mjs +++ b/apps/desktop/electron/main/plugin-host-process.mjs @@ -315,6 +315,12 @@ function buildApi() { rename: (input) => call("session.rename", [input ?? {}]), delete: (input) => call("session.delete", [input ?? {}]), }, + // Read-only usage facts (`usage.read`). The main-process dispatch owns + // the permission check and parameter bounds; the host returns per-turn + // counters and identifiers only, so no message body crosses this bridge. + usage: { + listTurns: (input) => call("usage.listTurns", [input ?? {}]), + }, /** * Resident background workers (spec 07 §3). Registration is local: the * manifest already declared the service, and the broker starts it only when diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index df087cbc5e..fa5af2eef0 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -435,6 +435,10 @@ export type PluginHostServices = { project?: { create: (pluginId: string, input: Record) => Promise; }; + /** Read-only completed-turn facts served by host-core's usage domain. */ + usage?: { + listTurns: (pluginId: string, input: Record) => Promise; + }; }; /** Host APIs a plugin process may reach. Anything else does not exist (spec 04 §2). */ @@ -514,6 +518,7 @@ const HOST_API_ALLOWLIST = new Set([ "session.importBatch", "session.rename", "session.delete", + "usage.listTurns", "agent.complete", "keyboard.registerGlobalShortcut", "keyboard.unregisterGlobalShortcut", @@ -888,6 +893,81 @@ function normalizePluginSessionInput( return { ...(input as Record) }; } +/** + * Bounds for the read-only usage fact listing. The host RPC re-checks the + * same windows, so a caller that skips this main-process side still cannot + * widen the scan (spec 07-plugins/03 §usage). + */ +const PLUGIN_USAGE_MAX_WINDOW_MS = 365 * 24 * 60 * 60 * 1000; +const PLUGIN_USAGE_DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +/** Absent/null keeps the host default; anything else must be an integer. */ +function pluginUsageProjectId(value: Record): number | undefined { + const raw = value.projectId; + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "number" || !Number.isInteger(raw)) { + throw apiError("INVALID_PARAMS", "projectId must be an integer"); + } + return raw; +} + +/** + * Mirrors the host-side validation for `usage.listTurns`: absent/null fields + * stay absent (the host applies the 30-day default window and 200-row page), + * and anything out of range is rejected here so a plugin sees a plain + * INVALID_PARAMS instead of a host round-trip. Implied bounds (now / now-30d) + * are used only to check order and the 365-day cap. + */ +function normalizePluginUsageListTurnsInput(input: unknown): Record { + if (input === undefined || input === null) return {}; + if (typeof input !== "object" || Array.isArray(input)) { + throw apiError("INVALID_PARAMS", "usage input must be an object"); + } + const value = input as Record; + const normalized: Record = {}; + const intField = (key: string): number | undefined => { + const raw = value[key]; + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) { + throw apiError("INVALID_PARAMS", `${key} must be a non-negative integer`); + } + normalized[key] = raw; + return raw; + }; + const fromMs = intField("fromMs"); + const toMs = intField("toMs"); + const resolvedTo = toMs ?? Date.now(); + const resolvedFrom = fromMs ?? resolvedTo - PLUGIN_USAGE_DEFAULT_WINDOW_MS; + if (resolvedTo < resolvedFrom) { + throw apiError("INVALID_PARAMS", "toMs must be >= fromMs"); + } + if (resolvedTo - resolvedFrom > PLUGIN_USAGE_MAX_WINDOW_MS) { + throw apiError("INVALID_PARAMS", "usage window must span at most 365 days"); + } + if (value.sessionId !== undefined && value.sessionId !== null) { + if (typeof value.sessionId !== "string" || !value.sessionId.trim()) { + throw apiError("INVALID_PARAMS", "sessionId must be a non-empty string"); + } + normalized.sessionId = value.sessionId; + } + const projectId = pluginUsageProjectId(value); + if (projectId !== undefined) normalized.projectId = projectId; + if (value.cursor !== undefined && value.cursor !== null) { + if (typeof value.cursor !== "string") { + throw apiError("INVALID_PARAMS", "cursor must be a string"); + } + if (value.cursor) normalized.cursor = value.cursor; + } + if (value.limit !== undefined && value.limit !== null) { + const limit = value.limit; + if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 500) { + throw apiError("INVALID_PARAMS", "limit must be an integer between 1 and 500"); + } + normalized.limit = limit; + } + return normalized; +} + /** Key for the per-service supervision map. */ function serviceStateKey(pluginId: string, serviceId: string): string { return `${pluginId}:${serviceId}`; @@ -2615,6 +2695,17 @@ export class PluginRuntime { } return this.services.session.delete(loaded.manifest.id, input); } + case "usage.listTurns": { + // Read-only completed-turn facts (spec 07-plugins/03 §usage): flat + // counters and identifiers, no message body, no write path. Every + // dashboard shape stays the plugin's own computation. + this.assertPermission(loaded, "usage.read"); + const input = normalizePluginUsageListTurnsInput(args[0]); + if (!this.services.usage?.listTurns) { + throw apiError("UNSUPPORTED", "host api not available: usage.listTurns"); + } + return this.services.usage.listTurns(loaded.manifest.id, input); + } case "agent.complete": { return this.runAgentComplete(loaded, (args[0] ?? {}) as PluginCompleteInput); } diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index f1de272d91..229db1f3fc 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -339,6 +339,13 @@ export function createPluginServices({ project: { create: (pluginId, input) => callPluginProjectHost(pluginId, input), }, + // Read-only usage facts: the same host-owned session transport, no + // mutation, so no `sessionsChanged` fan-out (callPluginSessionHost only + // announces the mutating methods). + usage: { + listTurns: (pluginId, input) => + callPluginSessionHost("plugin.usage.listTurns", pluginId, input), + }, complete: async (input): Promise => { if (!getHost()) { throw Object.assign(new Error("host unavailable"), { code: "UNSUPPORTED" }); diff --git a/apps/desktop/resources/skills/plugin-development.md b/apps/desktop/resources/skills/plugin-development.md index a32629eeb5..c94b775c02 100644 --- a/apps/desktop/resources/skills/plugin-development.md +++ b/apps/desktop/resources/skills/plugin-development.md @@ -119,7 +119,7 @@ user. `agent.tool.register`, `agent.complete`, `session.read`, `mcp.server.local`, `mcp.server.remote` - Medium: `fs.read`, `clipboard.read`, `clipboard.write`, `shell.openExternal`, - `background.service`, `bus.publish`, `bus.subscribe`, `models.list` + `background.service`, `bus.publish`, `bus.subscribe`, `models.list`, `usage.read` - Low: `ui.panel`, `ui.theme`, `notify` (Toast and best-effort native notifications) ### File and network range diff --git a/apps/desktop/src/features/plugins/model.ts b/apps/desktop/src/features/plugins/model.ts index 55af2fb89c..d1766c098e 100644 --- a/apps/desktop/src/features/plugins/model.ts +++ b/apps/desktop/src/features/plugins/model.ts @@ -60,6 +60,8 @@ export const PERMISSION_RISK: Record = { "mcp.server.local": "high", "mcp.server.remote": "high", "background.service": "high", + // Per-turn counters and session titles only, per the usage.read matrix row. + "usage.read": "medium", // Two capabilities that reach outside PI-Desktop's own window or read its // live audio stream sit at the top tier with the other outbound paths. "net.websocket": "high", diff --git a/apps/desktop/test/plugin-session-api.test.mjs b/apps/desktop/test/plugin-session-api.test.mjs index fcf1dd42e8..554c6df08d 100644 --- a/apps/desktop/test/plugin-session-api.test.mjs +++ b/apps/desktop/test/plugin-session-api.test.mjs @@ -311,3 +311,102 @@ test("plugin session read, update, and delete permissions are independent", asyn "delete:PERMISSION_DENIED", ]); }); + +test("plugin usage listTurns requires usage.read and forwards the plugin id", async (t) => { + const calls = []; + const runtime = new PluginRuntime({ + hostEntry: hostProcessEntry, + spawnProcess: forkPluginProcess, + usage: { + listTurns: async (pluginId, input) => { + calls.push(["listTurns", pluginId, input]); + return { turns: [{ turnId: "t2", inputTokens: 100 }], nextCursor: null }; + }, + }, + }); + t.after(async () => { + for (const loaded of runtime.listLoaded()) await runtime.unload(loaded.manifest.id); + }); + const dir = writePlugin({ + id: "demo.usage", + permissions: ["usage.read"], + main: ` + module.exports = { + async onLoad() { + await pi.commands.register({ + id: "read-usage", + title: "Read usage", + run: async () => { + const page = await pi.usage.listTurns({ fromMs: 1, toMs: 2, limit: 7 }); + await pi.ui.showToast("rows:" + page.turns.length); + await pi.usage.listTurns({ limit: 5, projectId: 3, sessionId: "s2", cursor: "abc" }); + for (const [name, call] of [ + ["badWindow", () => pi.usage.listTurns({ fromMs: 0, toMs: 1 + 365 * 86400000 })], + ["badOrder", () => pi.usage.listTurns({ fromMs: 10, toMs: 1 })], + ["badFromOnly", () => pi.usage.listTurns({ fromMs: 0 })], + ["badLimit", () => pi.usage.listTurns({ limit: 0 })], + ["badProject", () => pi.usage.listTurns({ projectId: "seven" })], + ["badFrom", () => pi.usage.listTurns({ fromMs: -1 })] + ]) { + try { await call(); } + catch (error) { await pi.ui.showToast(name + ":" + error.code); } + } + } + }); + } + }; + `, + }); + await runtime.loadFromPath(dir, ["usage.read"]); + await runCommand(runtime, "read-usage"); + // Authorized calls forward with the plugin id and only the normalized fields. + assert.deepEqual(calls, [ + ["listTurns", "demo.usage", { fromMs: 1, toMs: 2, limit: 7 }], + ["listTurns", "demo.usage", { limit: 5, projectId: 3, sessionId: "s2", cursor: "abc" }], + ]); + assert.deepEqual(runtime.drainToasts(), [ + "rows:1", + "badWindow:INVALID_PARAMS", + "badOrder:INVALID_PARAMS", + "badFromOnly:INVALID_PARAMS", + "badLimit:INVALID_PARAMS", + "badProject:INVALID_PARAMS", + "badFrom:INVALID_PARAMS", + ]); +}); + +test("plugin usage listTurns is refused without the usage.read permission", async (t) => { + const calls = []; + const runtime = new PluginRuntime({ + hostEntry: hostProcessEntry, + spawnProcess: forkPluginProcess, + usage: { + listTurns: async () => calls.push("listTurns"), + }, + }); + t.after(async () => { + for (const loaded of runtime.listLoaded()) await runtime.unload(loaded.manifest.id); + }); + const dir = writePlugin({ + id: "demo.usage-denied", + permissions: ["session.read.own"], + main: ` + module.exports = { + async onLoad() { + await pi.commands.register({ + id: "peek", + title: "Peek", + run: async () => { + try { await pi.usage.listTurns(); } + catch (error) { await pi.ui.showToast("listTurns:" + error.code); } + } + }); + } + }; + `, + }); + await runtime.loadFromPath(dir, ["session.read.own"]); + await runCommand(runtime, "peek"); + assert.deepEqual(calls, []); + assert.deepEqual(runtime.drainToasts(), ["listTurns:PERMISSION_DENIED"]); +}); diff --git a/crates/host-core/src/main.rs b/crates/host-core/src/main.rs index bb0f0c0b29..d3ab8eadd0 100644 --- a/crates/host-core/src/main.rs +++ b/crates/host-core/src/main.rs @@ -10,6 +10,7 @@ mod notifications; mod permissions; mod plans; mod plugin_sessions; +mod plugin_usage; mod plugins; mod providers; mod review; diff --git a/crates/host-core/src/plugin_usage.rs b/crates/host-core/src/plugin_usage.rs new file mode 100644 index 0000000000..73fee17be4 --- /dev/null +++ b/crates/host-core/src/plugin_usage.rs @@ -0,0 +1,293 @@ +//! Read-only completed-turn facts for plugins (`usage.read`). +//! +//! One flat row per completed turn of a non-deleted session: identifiers and +//! token counters only. No message body, no transcript, no dashboard shape. + +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rusqlite::params; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::db::{now_ms, Database}; + +const DEFAULT_LIMIT: i64 = 200; +const MAX_LIMIT: i64 = 500; +const DEFAULT_WINDOW_MS: i64 = 30 * 24 * 3600 * 1000; +const MAX_WINDOW_MS: i64 = 365 * 24 * 3600 * 1000; + +fn invalid(message: impl Into) -> anyhow::Error { + anyhow!("INVALID_PARAMS: {}", message.into()) +} + +pub(crate) struct PluginUsageCursor { + ended_at: i64, + id: String, +} + +pub(crate) struct PluginUsageTurnQuery<'a> { + pub from_ms: i64, + pub to_ms: i64, + pub session_id: Option<&'a str>, + pub project_id: Option, + pub cursor: Option, + pub limit: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginUsageTurn { + pub turn_id: String, + pub session_id: String, + pub session_title: Option, + pub project_id: Option, + pub provider_id: Option, + pub model_id: Option, + pub started_at: i64, + pub ended_at: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub reasoning_tokens: i64, +} + +pub(crate) fn encode_cursor(ended_at: i64, id: &str) -> String { + B64.encode(format!("v1:{ended_at}:{id}")) +} + +pub(crate) fn decode_cursor(raw: &str) -> Result { + let decoded = B64 + .decode(raw) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|text| { + let rest = text.strip_prefix("v1:")?; + let (ended, id) = rest.split_once(':')?; + let ended = ended.parse::().ok()?; + if id.is_empty() { + return None; + } + Some(PluginUsageCursor { + ended_at: ended, + id: id.to_string(), + }) + }); + decoded.ok_or_else(|| invalid("cursor is invalid")) +} + +/// Absent/null bounds fall back to the last 30 days ending now. An explicit +/// pair may span at most 365 days and must be ordered. `toMs: null` is the +/// same as omitting the field (unlike a typed non-integer, which is rejected). +pub(crate) fn window_params(params: &Value) -> Result<(i64, i64)> { + let to_ms = match params.get("toMs") { + None | Some(Value::Null) => now_ms(), + Some(value) => value + .as_i64() + .filter(|ms| *ms >= 0) + .ok_or_else(|| invalid("toMs must be a non-negative integer"))?, + }; + let from_ms = match params.get("fromMs") { + None | Some(Value::Null) => to_ms - DEFAULT_WINDOW_MS, + Some(value) => value + .as_i64() + .filter(|ms| *ms >= 0) + .ok_or_else(|| invalid("fromMs must be a non-negative integer"))?, + }; + if to_ms < from_ms { + return Err(invalid("toMs must be >= fromMs")); + } + if to_ms - from_ms > MAX_WINDOW_MS { + return Err(invalid("time window must span at most 365 days")); + } + Ok((from_ms, to_ms)) +} + +fn limit_param(params: &Value) -> Result { + match params.get("limit") { + None | Some(Value::Null) => Ok(DEFAULT_LIMIT), + Some(value) => match value.as_i64() { + Some(limit) if (1..=MAX_LIMIT).contains(&limit) => Ok(limit), + _ => Err(invalid("limit must be an integer between 1 and 500")), + }, + } +} + +fn session_id_param(params: &Value) -> Result> { + match params.get("sessionId") { + None | Some(Value::Null) => Ok(None), + Some(value) => { + let session_id = value + .as_str() + .ok_or_else(|| invalid("sessionId must be a string"))?; + if session_id.trim().is_empty() { + return Err(invalid("sessionId must be a non-empty string")); + } + Ok(Some(session_id.to_string())) + } + } +} + +fn project_id_param(params: &Value) -> Result> { + match params.get("projectId") { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_i64() + .map(Some) + .ok_or_else(|| invalid("projectId must be an integer")), + } +} + +fn cursor_param(params: &Value) -> Result> { + match params.get("cursor") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(raw)) if raw.is_empty() => Ok(None), + Some(Value::String(raw)) => Ok(Some(decode_cursor(raw)?)), + Some(_) => Err(invalid("cursor must be a string")), + } +} + +/// Keyset scan over completed turns of non-deleted sessions, ordered by +/// `ended_at ASC, id ASC`. Fetches `limit + 1` rows to learn `has_more` +/// without a separate COUNT, then trims to exactly `limit` rows. +pub(crate) fn list_turns( + db: &Database, + query: &PluginUsageTurnQuery<'_>, +) -> Result<(Vec, bool)> { + let mut statement = db.conn().prepare( + "SELECT t.id, t.session_id, s.title, s.project_id, t.provider_id, t.model_id, + t.started_at, t.ended_at, t.input_tokens, t.output_tokens, t.usage_json + FROM turns t JOIN sessions s ON s.id = t.session_id + WHERE s.deleted_at IS NULL + AND t.status = 'completed' AND t.ended_at IS NOT NULL + AND (?1 IS NULL OR t.ended_at > ?1 OR (t.ended_at = ?1 AND t.id > ?2)) + AND t.ended_at >= ?3 AND t.ended_at <= ?4 + AND (?5 IS NULL OR t.session_id = ?5) + AND (?6 IS NULL OR s.project_id = ?6) + ORDER BY t.ended_at ASC, t.id ASC + LIMIT ?7", + )?; + let cursor_last = query.cursor.as_ref().map(|c| c.ended_at); + let cursor_id = query.cursor.as_ref().map(|c| c.id.as_str()); + let fetch = query.limit + 1; + let rows = statement.query_map( + params![ + cursor_last, + cursor_id, + query.from_ms, + query.to_ms, + query.session_id, + query.project_id, + fetch, + ], + |row| { + let usage_json: Option = row.get(10)?; + let parsed = usage_json + .as_deref() + .and_then(|json| serde_json::from_str::(json).ok()); + let token = |key: &str| { + parsed + .as_ref() + .and_then(|u| u.get(key)) + .and_then(Value::as_i64) + .unwrap_or(0) + }; + let title: String = row.get(2)?; + Ok(PluginUsageTurn { + turn_id: row.get(0)?, + session_id: row.get(1)?, + session_title: if title.is_empty() { None } else { Some(title) }, + project_id: row.get(3)?, + provider_id: row.get(4)?, + model_id: row.get(5)?, + started_at: row.get(6)?, + ended_at: row.get(7)?, + input_tokens: row.get::<_, Option>(8)?.unwrap_or(0), + output_tokens: row.get::<_, Option>(9)?.unwrap_or(0), + cache_read_tokens: token("cacheReadTokens"), + cache_write_tokens: token("cacheWriteTokens"), + reasoning_tokens: token("reasoningTokens"), + }) + }, + )?; + let mut turns: Vec = rows + .collect::>>() + .map_err(anyhow::Error::from)?; + let has_more = turns.len() > query.limit as usize; + if has_more { + turns.truncate(query.limit as usize); + } + Ok((turns, has_more)) +} + +/// Parse plugin RPC params, run the keyset scan, and return the page JSON. +pub fn list_turns_page(db: &Database, params: &Value) -> Result { + let (from_ms, to_ms) = window_params(params)?; + let session_id = session_id_param(params)?; + let project_id = project_id_param(params)?; + let limit = limit_param(params)?; + let cursor = cursor_param(params)?; + let (turns, has_more) = list_turns( + db, + &PluginUsageTurnQuery { + from_ms, + to_ms, + session_id: session_id.as_deref(), + project_id, + cursor, + limit, + }, + )?; + let next_cursor = if has_more { + turns + .last() + .map(|last| encode_cursor(last.ended_at, &last.turn_id)) + } else { + None + }; + Ok(json!({ + "turns": turns, + "nextCursor": next_cursor, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn null_bounds_match_omitted_bounds() { + let omitted = json!({}); + let nulls = json!({ "fromMs": null, "toMs": null }); + let (from_a, to_a) = window_params(&omitted).unwrap(); + let (from_b, to_b) = window_params(&nulls).unwrap(); + assert_eq!(to_a - from_a, DEFAULT_WINDOW_MS); + assert_eq!(to_b - from_b, DEFAULT_WINDOW_MS); + assert!((to_a - to_b).abs() < 5_000, "both use wall-clock now"); + } + + #[test] + fn window_rejects_inverted_and_oversized_ranges() { + let now = 2_000_000_000_000i64; + assert!(window_params(&json!({ "fromMs": now, "toMs": now - 1 })).is_err()); + assert!(window_params(&json!({ + "fromMs": now - 366 * 24 * 3600 * 1000, + "toMs": now + })) + .is_err()); + assert!(window_params(&json!({ + "fromMs": now - MAX_WINDOW_MS, + "toMs": now + })) + .is_ok()); + } + + #[test] + fn cursor_round_trips_ids_with_colons() { + let raw = encode_cursor(42, "turn:with:colons"); + let cursor = decode_cursor(&raw).unwrap(); + assert_eq!(cursor.ended_at, 42); + assert_eq!(cursor.id, "turn:with:colons"); + assert!(decode_cursor("not-a-cursor").is_err()); + } +} diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index 57b0186c43..426f3a1388 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -16,6 +16,7 @@ use crate::notifications; use crate::permissions::{PermissionDecision, PermissionEvaluationParams, PermissionManager}; use crate::plans; use crate::plugin_sessions; +use crate::plugin_usage; use crate::providers::{self, DiscoveredModelInput, ProviderCreateInput, ProviderUpdateInput}; use crate::review; use crate::scheduled; @@ -2500,6 +2501,28 @@ async fn handle_request( plugin_sessions::delete(&st.db, plugin_id, ¶ms).map_err(plugin_session_rpc_err) } + // Plugin usage is a read-only facts domain: a keyset page of completed + // turns from non-deleted sessions, served to plugins that hold + // `usage.read` (checked in Electron main before dispatch). The payload + // carries counters and titles — never a message body — and Electron + // main remains the only caller that can supply pluginId. + "plugin.usage.listTurns" => { + let plugin_id = params + .get("pluginId") + .and_then(Value::as_str) + .ok_or_else(|| rpc_err(1002, "pluginId required", "INVALID_PARAMS"))?; + let st = state.lock().await; + let page = + plugin_usage::list_turns_page(&st.db, ¶ms).map_err(plugin_session_rpc_err)?; + tracing::debug!( + method = "plugin.usage.listTurns", + plugin_id, + count = page["turns"].as_array().map(Vec::len).unwrap_or(0), + "plugin usage rpc served" + ); + Ok(page) + } + "session.beginTurn" => { let session_id = params .get("sessionId") @@ -5531,6 +5554,235 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn plugin_usage_rpc_serves_read_only_fact_rows() { + let data_dir = tempfile::tempdir().unwrap(); + let mut app_state = AppState::open(data_dir.path()).unwrap(); + app_state.handshook = true; + let state = Arc::new(Mutex::new(app_state)); + let (tx, _rx) = mpsc::unbounded_channel(); + + // Three completed turns across two sessions: t2 carries all three + // usage_json token kinds, t1 carries none (zeros), and t4 belongs to a + // soft-deleted session so it must never appear. Different ended_at + // values make the ASC ordering and the cursor page observable. + let now = chrono::Utc::now().timestamp_millis(); + { + let st = state.lock().await; + let conn = st.db.conn(); + for (project_id, path, name) in [(1, "/tmp/p1", "P1"), (2, "/tmp/p2", "P2")] { + conn.execute( + "INSERT INTO projects (id, path, name, created_at, last_opened_at) VALUES (?1, ?2, ?3, ?4, ?4)", + rusqlite::params![project_id, path, name, now], + ) + .unwrap(); + } + for (id, title, project) in [("s1", "", 1), ("s2", "Big", 2), ("s3", "Trashed", 1)] { + conn.execute( + "INSERT INTO sessions (id, title, project_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?4)", + rusqlite::params![id, title, project, now], + ) + .unwrap(); + } + conn.execute( + "UPDATE sessions SET deleted_at = ?1 WHERE id = 's3'", + rusqlite::params![now], + ) + .unwrap(); + // (id, session, ended, input, output, usage_json) + let turn = |id: &str, session: &str, ended: i64, usage: Option<&str>| { + conn.execute( + "INSERT INTO turns (id, session_id, status, provider_id, model_id, input_tokens, output_tokens, usage_json, started_at, ended_at) + VALUES (?1, ?2, 'completed', 'prov', 'model-a', ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![id, session, 100, 200, usage, ended - 1_000, ended], + ) + .unwrap(); + }; + turn("t1", "s1", now - 3_000, None); + turn( + "t2", + "s2", + now - 2_000, + Some( + serde_json::json!({ + "cacheReadTokens": 300, + "cacheWriteTokens": 400, + "reasoningTokens": 500 + }) + .to_string(), + ) + .as_deref(), + ); + turn( + "t3", + "s2", + now - 1_000, + Some(r#"{"cacheReadTokens":"bad"}"#), + ); + turn("t4", "s3", now - 500, None); + } + + const METHOD: &str = "plugin.usage.listTurns"; + + // Missing pluginId is a client error, same as the session domain. + let missing = handle_request(state.clone(), METHOD, json!({}), tx.clone()) + .await + .unwrap_err(); + assert_eq!(missing.code, 1002); + assert_eq!(missing.data.unwrap()["errorCode"], "INVALID_PARAMS"); + + // Default window covers every turn; ordering is ended_at ASC and the + // soft-deleted session's turn is absent. + let page = handle_request( + state.clone(), + METHOD, + json!({ "pluginId": "plugin.one" }), + tx.clone(), + ) + .await + .unwrap(); + let turns = page["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 3, "t4 belongs to a trashed session"); + assert_eq!(turns[0]["turnId"], "t1"); + assert_eq!(turns[1]["turnId"], "t2"); + assert_eq!(turns[2]["turnId"], "t3"); + assert!(page["nextCursor"].is_null(), "no more rows, no cursor"); + // Row shape: counters and titles only, camelCase, no message fields. + assert!( + turns[0]["sessionTitle"].is_null(), + "empty title maps to null" + ); + assert_eq!(turns[1]["sessionTitle"], "Big"); + assert_eq!(turns[1]["sessionId"], "s2"); + assert_eq!(turns[1]["projectId"], 2); + assert_eq!(turns[1]["providerId"], "prov"); + assert_eq!(turns[1]["modelId"], "model-a"); + assert_eq!(turns[1]["inputTokens"], 100); + assert_eq!(turns[1]["outputTokens"], 200); + assert_eq!(turns[1]["cacheReadTokens"], 300); + assert_eq!(turns[1]["cacheWriteTokens"], 400); + assert_eq!(turns[1]["reasoningTokens"], 500); + // Missing usage_json yields zeros; a malformed one also yields zeros. + assert_eq!(turns[0]["cacheReadTokens"], 0); + assert_eq!(turns[0]["reasoningTokens"], 0); + assert_eq!(turns[2]["cacheReadTokens"], 0); + assert!(turns[0].get("content").is_none()); + assert!(turns[0].get("messages").is_none()); + + // limit truncates and the returned cursor fetches exactly the rest. + let page1 = handle_request( + state.clone(), + METHOD, + json!({ "pluginId": "plugin.one", "limit": 2 }), + tx.clone(), + ) + .await + .unwrap(); + assert_eq!(page1["turns"].as_array().unwrap().len(), 2); + let cursor1 = page1["nextCursor"].as_str().unwrap(); + let page2 = handle_request( + state.clone(), + METHOD, + json!({ "pluginId": "plugin.one", "limit": 2, "cursor": cursor1 }), + tx.clone(), + ) + .await + .unwrap(); + let rest = page2["turns"].as_array().unwrap(); + assert_eq!(rest.len(), 1, "exactly the remaining row"); + assert_eq!(rest[0]["turnId"], "t3"); + assert!(page2["nextCursor"].is_null()); + + // Filtering: sessionId and projectId narrow the facts. + let only_s2 = handle_request( + state.clone(), + METHOD, + json!({ "pluginId": "plugin.one", "sessionId": "s2" }), + tx.clone(), + ) + .await + .unwrap(); + let s2_turns = only_s2["turns"].as_array().unwrap(); + assert_eq!(s2_turns.len(), 2); + assert!(s2_turns.iter().all(|t| t["sessionId"] == "s2")); + + let only_p1 = handle_request( + state.clone(), + METHOD, + json!({ "pluginId": "plugin.one", "projectId": 1 }), + tx.clone(), + ) + .await + .unwrap(); + let p1_turns = only_p1["turns"].as_array().unwrap(); + assert_eq!(p1_turns.len(), 1); + assert_eq!(p1_turns[0]["sessionId"], "s1"); + + // Explicit bounds exclude out-of-window turns. + let windowed = handle_request( + state.clone(), + METHOD, + json!({ "pluginId": "plugin.one", "fromMs": now - 1_500, "toMs": now }), + tx.clone(), + ) + .await + .unwrap(); + let w = windowed["turns"].as_array().unwrap(); + assert_eq!(w.len(), 1, "only t3 ended inside the window"); + assert_eq!(w[0]["turnId"], "t3"); + + // Client errors the host must reject: bad cursor, bad limit, bad + // window, wrong types. + for bad in [ + json!({ "pluginId": "p", "cursor": "not-a-cursor" }), + json!({ "pluginId": "p", "limit": 0 }), + json!({ "pluginId": "p", "limit": 501 }), + json!({ "pluginId": "p", "limit": "ten" }), + json!({ "pluginId": "p", "fromMs": -1 }), + json!({ "pluginId": "p", "toMs": "now" }), + json!({ "pluginId": "p", "fromMs": now, "toMs": now - 1_000 }), + json!({ + "pluginId": "p", + "fromMs": now - 366 * 24 * 3600 * 1000, + "toMs": now + }), + json!({ "pluginId": "p", "fromMs": 0 }), + json!({ "pluginId": "p", "sessionId": 7 }), + json!({ "pluginId": "p", "sessionId": "" }), + json!({ "pluginId": "p", "projectId": "seven" }), + ] { + let error = handle_request(state.clone(), METHOD, bad.clone(), tx.clone()) + .await + .unwrap_err(); + assert_eq!(error.code, 1002, "{bad}"); + assert_eq!(error.data.unwrap()["errorCode"], "INVALID_PARAMS", "{bad}"); + } + + // Null bounds match omitted bounds; the 365-day window edge is accepted. + let null_bounds = handle_request( + state.clone(), + METHOD, + json!({ "pluginId": "plugin.one", "fromMs": null, "toMs": null }), + tx.clone(), + ) + .await + .unwrap(); + assert_eq!(null_bounds["turns"].as_array().unwrap().len(), 3); + let edge = handle_request( + state.clone(), + METHOD, + json!({ + "pluginId": "plugin.one", + "fromMs": now - 365 * 24 * 3600 * 1000, + "toMs": now + }), + tx.clone(), + ) + .await + .unwrap(); + assert_eq!(edge["turns"].as_array().unwrap().len(), 3); + } + fn available_test_shell_id() -> Option { crate::tools::shell::catalog(None) .effective diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index 8354d3a104..90aa1ce1e4 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -439,6 +439,9 @@ Electron main after plugin permission and manifest-source checks: - `plugin.session.rename` — rename an owned active imported session - `plugin.session.delete` — `trash` hides and retains the transcript; `purge` removes it and permits re-import +- `plugin.usage.listTurns` — keyset page of completed-turn facts (identifiers + and token counters, never a message body) for non-deleted sessions. Gated + in Electron main by `usage.read`. Additive; no protocol version bump. - Successful plugin session mutations cause Electron main to emit one `sessionsChanged` renderer event; the renderer refreshes the session list, and plugins do not emit this UI synchronization event. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 008661ae01..4092e98ad1 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -11023,6 +11023,26 @@ are withdrawn with ADR 0165. - **Milestone**: M6+ - **Status**: Host/RPC/unit-covered; full UI journey Draft (run only in a capable environment when this surface changes) +#### E2E-PLUGIN-usage-listTurns: Plugin usage fact listing + +- **Preconditions**: A test plugin is granted `usage.read`. The host database + has completed turns across live and soft-deleted sessions. +- **Steps**: 1) Call `pi.usage.listTurns` without the permission. 2) Call it + with the permission, page by cursor, and filter by session/project/window. + 3) Pass inverted bounds, a window longer than 365 days, and a malformed + cursor. 4) Confirm rows include token counters and titles but no message + bodies, and that trashed sessions are absent. +- **Expected**: Missing permission returns `PERMISSION_DENIED` and does not + hit the host. Valid calls return keyset pages of completed-turn facts. + Invalid params return `INVALID_PARAMS`. Empty titles are `null`. +- **Specs linked**: `07-plugins/03-plugin-api.md`, + `07-plugins/13-plugin-permissions-matrix.md`, + `03-runtime/06-host-rpc-protocol.md`, ADR 0173, D335 +- **Acceptance**: Security, Quality +- **Milestone**: M6+ +- **Status**: Unit/RPC/wiring-covered (`plugin-session-api.test.mjs`, + host-core `plugin_usage`); full UI journey Draft + #### E2E-216: Explicit plugin project binding and host-owned sidebar refresh - **Preconditions**: A test plugin has `project.create` and `session.import` diff --git a/docs/spec/07-plugins/01-plugin-system.md b/docs/spec/07-plugins/01-plugin-system.md index 0c8725c9cb..f4f3b90e33 100644 --- a/docs/spec/07-plugins/01-plugin-system.md +++ b/docs/spec/07-plugins/01-plugin-system.md @@ -300,6 +300,7 @@ Namespace: `pi.plugin.*` - `pi.session.list()` / `get()` / `listMessages()` // `session.read.own` - `pi.session.rename()` // `session.update.own` - `pi.session.delete()` // `session.delete.own` +- `pi.usage.listTurns()` // `usage.read`; read-only completed-turn facts, no message bodies - `pi.agent.complete(input)` // `agent.complete`; host-owned one-shot Skills are contributed declaratively (`contributes.skills` + `agent.prompt.inject`), diff --git a/docs/spec/07-plugins/02-plugin-manifest-schema.md b/docs/spec/07-plugins/02-plugin-manifest-schema.md index 7fca219cb7..d375f9aa7d 100644 --- a/docs/spec/07-plugins/02-plugin-manifest-schema.md +++ b/docs/spec/07-plugins/02-plugin-manifest-schema.md @@ -328,6 +328,7 @@ type PluginPermission = | "session.read.own" | "session.update.own" | "session.delete.own" + | "usage.read" | "audio.capture.background" | "audio.playback.background" | "speech.adapter.register" diff --git a/docs/spec/07-plugins/03-plugin-api.md b/docs/spec/07-plugins/03-plugin-api.md index f11088f0b2..5e44abb358 100644 --- a/docs/spec/07-plugins/03-plugin-api.md +++ b/docs/spec/07-plugins/03-plugin-api.md @@ -451,6 +451,50 @@ storage. P2/P3 operations (session create, message mutation, arbitrary re-bindin provider/model binding, batch delete, and tags) are intentionally not part of this contract. +### usage (requires `usage.read`) + +Read-only completed-turn facts for the non-deleted sessions the user can +still see. The host serves one flat fact row per turn — counters and +identifiers only; no message body, no transcript projection, and no write +path. Deliberately **no dashboard shape**: streaks, heatmaps, per-model +shares, and top-session rankings are the plugin's own computation on top of +these rows, so changing a metric definition later is never a breaking SDK +change. + +```ts +pi.usage.listTurns(input?: { + fromMs?: number // inclusive window start, epoch ms; default toMs - 30 days + toMs?: number // inclusive window end, epoch ms; default now + projectId?: number | null + sessionId?: string + cursor?: string // opaque page cursor from the previous nextCursor + limit?: number // 1..=500 rows; default 200 +}): Promise<{ + turns: Array<{ + turnId: string; sessionId: string; sessionTitle: string | null + projectId: number | null; providerId: string | null; modelId: string | null + startedAt: number; endedAt: number + inputTokens: number; outputTokens: number + cacheReadTokens: number; cacheWriteTokens: number; reasoningTokens: number + }> + nextCursor: string | null +}> +``` + +Semantics: + +- Only completed turns of non-deleted sessions are listed. A session the + user deleted leaves the listing. +- Rows are ordered by `endedAt` ascending with a keyset cursor, so paging is + stable while the window fills; the ranking a dashboard shows is its own + sort, not the host's. +- The window spans at most 365 days; `limit` is 1..=500 (default 200). The + Electron side validates first, and the host RPC re-checks the same bounds. + Absent and `null` bounds are equivalent; an empty session title is returned + as `null`. +- A missing or malformed `usage_json` yields zero cache/reasoning counters — + never a partial row. + ### session collaboration (requires `desktop.control`) The official Session Orchestrator composes the reviewed desktop-control diff --git a/docs/spec/07-plugins/13-plugin-permissions-matrix.md b/docs/spec/07-plugins/13-plugin-permissions-matrix.md index 817b45dd1f..cc2ee0ac99 100644 --- a/docs/spec/07-plugins/13-plugin-permissions-matrix.md +++ b/docs/spec/07-plugins/13-plugin-permissions-matrix.md @@ -46,6 +46,7 @@ Provide a permission–capability–risk–default-policy reference table for re | `session.read.own` | medium | `pi.session.list`, `pi.session.get`, `pi.session.listMessages` | Confirm at install | Reads only sessions imported by the calling plugin; no cross-plugin access | | `session.update.own` | medium | `pi.session.rename` | Confirm at install | Renames only the calling plugin's active imported sessions | | `session.delete.own` | high | `pi.session.delete` | Confirm at install | Trash/purge only the calling plugin's imported sessions; rate-limited | +| `usage.read` | medium | `pi.usage.listTurns` | Confirm at install | Read-only listing of completed-turn facts (per-turn token counters and identifiers, keyset-paginated); no message body and no write path | | `agent.complete` | high | `pi.agent.complete` | Confirm at install | Host-owned one-shot; spends user quota; `includeSessionContext` also needs `session.read` | | `speech.adapter.register` | high | `pi.speech.registerAdapter` / `unregisterAdapter` | Confirm at install | Registers a speech protocol. Handles stay in the guest; HTTP plans are executed by the host with the bound provider key and must stay on that origin. Built-in protocol ids are reserved | @@ -148,6 +149,7 @@ so "Modify the files it lists" is followed by the list. | `session.read.own` | Read sessions imported by this plugin | 读取此插件导入的会话 | | `session.update.own` | Rename sessions imported by this plugin | 重命名此插件导入的会话 | | `session.delete.own` | Trash or purge sessions imported by this plugin | 将此插件导入的会话移入回收站或清除 | +| `usage.read` | Read usage statistics | 读取用量统计 | | `agent.complete` | Run a one-shot completion with your models | 用你的模型发起一次补全 | | `speech.adapter.register` | Register a speech adapter | 注册语音适配器 | | `audio.capture.background` | Use the microphone in the background | 后台使用麦克风 | diff --git a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md index 2eeefd7637..22672c6650 100644 --- a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md @@ -324,6 +324,9 @@ ids 和非负 `tokensBefore`;它不会插入 message/search 行 只读取调用插件自己导入且仍处于活动状态的会话 - `plugin.session.rename` — 重命名自己拥有的活动导入会话 - `plugin.session.delete` — `trash` 隐藏并保留转录本;`purge` 删除并允许重新导入 +- `plugin.usage.listTurns` — 未删除会话的已完成 turn 事实页(标识符与 token + 计数,绝不含消息正文)。由 Electron main 用 `usage.read` 鉴权。增量方法, + 不升协议版本。 - 插件会话变更成功后,Electron main 发送一次 `sessionsChanged` 渲染器事件, 渲染器刷新会话列表;插件不发送此 UI 同步事件 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 9374ead940..c712bce154 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -6822,6 +6822,16 @@ IPC 请求无法关闭。 - **里程碑**:M6+ - **状态**:主机/RPC/单元已覆盖;完整 UI 路径草稿(适用变更合入前需在具备条件的环境中运行 E2E) +#### E2E-PLUGIN-usage-listTurns:插件用量事实列举 + +- **前置条件**:测试插件获得 `usage.read`;主机库中有未删除与软删会话的已完成 turn。 +- **步骤**:1)无权限调用 `pi.usage.listTurns`。2)有权限调用、按游标翻页、按会话/项目/时间窗过滤。3)传入倒置边界、超过 365 天的窗口、畸形游标。4)确认行含 token 计数与标题、无消息正文,软删会话不出现。 +- **预期**:缺权限返回 `PERMISSION_DENIED` 且不打到主机。合法调用返回已完成 turn 的 keyset 页。非法参数返回 `INVALID_PARAMS`。空标题为 `null`。 +- **关联规格**:`07-plugins/03-plugin-api.md`、`07-plugins/13-plugin-permissions-matrix.md`、`03-runtime/06-host-rpc-protocol.md`、ADR 0173、D335 +- **验收**:安全、质量 +- **里程碑**:M6+ +- **状态**:单元/RPC/连线已覆盖(`plugin-session-api.test.mjs`、host-core `plugin_usage`);完整 UI 路径草稿 + #### E2E-216:插件显式绑定项目与宿主拥有的侧栏刷新 - **前置条件**:测试插件获得 `project.create`、`session.import` 权限并声明会话来源; diff --git a/docs/zh-CN/spec/07-plugins/01-plugin-system.md b/docs/zh-CN/spec/07-plugins/01-plugin-system.md index 5ace947336..7e4094d4b0 100644 --- a/docs/zh-CN/spec/07-plugins/01-plugin-system.md +++ b/docs/zh-CN/spec/07-plugins/01-plugin-system.md @@ -286,6 +286,7 @@ Host Main (PI-Desktop) - `pi.session.list()` / `get()` / `listMessages()` // `session.read.own` - `pi.session.rename()` // `session.update.own` - `pi.session.delete()` // `session.delete.own` +- `pi.usage.listTurns()` // `usage.read`;只读已完成回合事实,不含消息正文 - `pi.agent.complete(input)` // `agent.complete`;宿主代发一次性补全 技能以声明方式贡献(`contributes.skills` + `agent.prompt.inject`), diff --git a/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md b/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md index c7251ea3fc..6ebb3db5b0 100644 --- a/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md +++ b/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md @@ -282,6 +282,7 @@ type PluginPermission = | "session.read.own" | "session.update.own" | "session.delete.own" + | "usage.read" | "audio.capture.background" | "audio.playback.background" | "speech.adapter.register" diff --git a/docs/zh-CN/spec/07-plugins/03-plugin-api.md b/docs/zh-CN/spec/07-plugins/03-plugin-api.md index 3e7dc0c24c..f89167432b 100644 --- a/docs/zh-CN/spec/07-plugins/03-plugin-api.md +++ b/docs/zh-CN/spec/07-plugins/03-plugin-api.md @@ -369,6 +369,44 @@ Projects 页面也会据此刷新持久项目索引;插件不需要、也不 5 次批量导入和 20 次删除。写入前会移除工具 `__pi*` 与 `piDesktop.*` 对象键。 P2/P3(会话创建、消息变更、任意重新绑定、provider/model 绑定、批量删除、标签)不属于本次接口。 +### 用量(需要 `usage.read`) + +面向用户仍可见的未删除会话,提供只读的**已完成回合事实行**。宿主只提供 +每个 turn 一行的扁平事实——计数与标识符;绝不包含消息正文、转录投影或任何 +写路径。**刻意不提供仪表盘形状**:连续天数、热力图、分模型占比、高消耗 +排名都是插件在这些事实行之上自己的计算——日后调整指标口径也不会变成 +SDK 的破坏性变更。 + +```ts +pi.usage.listTurns(input?: { + fromMs?: number // 含端点的窗口起点(epoch ms);默认 toMs - 30 天 + toMs?: number // 含端点的窗口终点(epoch ms);默认当前时间 + projectId?: number | null + sessionId?: string + cursor?: string // 上一次 nextCursor 返回的不透明分页游标 + limit?: number // 1..=500 行;默认 200 +}): Promise<{ + turns: Array<{ + turnId: string; sessionId: string; sessionTitle: string | null + projectId: number | null; providerId: string | null; modelId: string | null + startedAt: number; endedAt: number + inputTokens: number; outputTokens: number + cacheReadTokens: number; cacheWriteTokens: number; reasoningTokens: number + }> + nextCursor: string | null +}> +``` + +语义: + +- 只列出未删除会话的已完成 turn。用户删除的会话会从列表中消失。 +- 行按 `endedAt` 升序 + keyset 游标排列,窗口填充时翻页依然稳定;仪表盘 + 展示的排名是插件自己的排序,不是宿主的。 +- 窗口跨度至多 365 天;`limit` 为 1..=500(默认 200)。Electron 侧先校验, + 宿主 RPC 边界按同样界限再次校验。缺省与 `null` 边界等价;空会话标题返回 + `null`。 +- `usage_json` 缺失或畸形时 cache/reasoning 计数记 0——绝不返回残缺行。 + ### 会话协作(需要 `desktop.control`) 官方 Session Orchestrator 组合了已审查的 desktop-control 目录;这不是第二套 session API, diff --git a/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md b/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md index 33b260c1f5..f373b4b84f 100644 --- a/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md +++ b/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md @@ -49,6 +49,7 @@ | `session.read.own` | 中等 | `pi.session.list`、`pi.session.get`、`pi.session.listMessages` | 安装时确认 | 只能读取本插件导入的会话;不能跨插件访问 | | `session.update.own` | 中等 | `pi.session.rename` | 安装时确认 | 只能重命名本插件拥有的活动导入会话 | | `session.delete.own` | 高 | `pi.session.delete` | 安装时确认 | 只能回收或清除本插件导入的会话;有频率限制 | +| `usage.read` | 中等 | `pi.usage.listTurns` | 安装时确认 | 已完成 turn 事实行的只读列举(每回合 token 计数与标识符,keyset 分页);不含消息正文,无写路径 | | `agent.complete` | 高 | `pi.agent.complete` | 安装时确认 | 宿主代发一次性补全;消耗用户额度;`includeSessionContext` 还需要 `session.read` | | `speech.adapter.register` | 高 | `pi.speech.registerAdapter` / `unregisterAdapter` | 安装时确认 | 注册语音协议。handle 留在插件进程;HTTP 计划由宿主用绑定密钥代发且必须同 origin | @@ -145,6 +146,7 @@ Agent,在 Plan 中不可见。主机返回 `PLUGIN_DISABLED_IN_PLAN` | `session.read.own` | Read sessions imported by this plugin | 读取此插件导入的会话 | | `session.update.own` | Rename sessions imported by this plugin | 重命名此插件导入的会话 | | `session.delete.own` | Trash or purge sessions imported by this plugin | 将此插件导入的会话移入回收站或清除 | +| `usage.read` | Read usage statistics | 读取用量统计 | | `agent.complete` | Run a one-shot completion with your models | 用你的模型发起一次补全 | | `speech.adapter.register` | Register a speech adapter | 注册语音适配器 | | `audio.capture.background` | Use the microphone in the background | 后台使用麦克风 | diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 95f07f4713..6423242f88 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1738,7 +1738,8 @@ sklm: { "net.websocket": "Echtzeitverbindungen öffnen", "bus.publish": "Nachrichten an andere Plugins senden", "bus.subscribe": "Nachrichten von anderen Plugins empfangen", - "browser.cdp": "Den Arbeitspanel-Browser steuern" + "browser.cdp": "Den Arbeitspanel-Browser steuern", + "usage.read": "Nutzungsstatistiken lesen" }, "permissionHelp": { "ui.panel": "Lässt das Plugin sein eigenes Panel innerhalb der App anzeigen.", @@ -1774,7 +1775,9 @@ sklm: { "net.websocket": "Öffnet bidirektionale Echtzeitverbindungen zu den Hosts, die das Plugin deklariert hat.", "bus.publish": "Kann Nachrichten zu den angegebenen Themen senden.", "bus.subscribe": "Kann Nachrichten zu den angegebenen Themen empfangen.", - "browser.cdp": "Kann im Arbeitsbereichsbrowser navigieren, die Seite lesen, JavaScript ausführen und auf der Zulassungsliste stehende Chrome DevTools-Befehle senden. Cookies und Speichermethoden sind blockiert." + "browser.cdp": "Kann im Arbeitsbereichsbrowser navigieren, die Seite lesen, JavaScript ausführen und auf der Zulassungsliste stehende Chrome DevTools-Befehle senden. Cookies und Speichermethoden sind blockiert.", + "usage.read": + "Listet Nutzungsdaten abgeschlossener Runden auf (Token-Zähler pro Runde, seitenweise). Nachrichteninhalte sind nicht enthalten.", } }, "extensions": { diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 54af6f8a2f..7f30d2104a 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1759,6 +1759,7 @@ importConfirm: "Imported extensions run inside the agent process with the same a "bus.publish": "Send messages to other plugins", "bus.subscribe": "Receive messages from other plugins", "browser.cdp": "Control the work-panel browser", + "usage.read": "Read usage statistics", }, permissionHelp: { "ui.panel": "Lets the plugin show its own panel inside the app.", @@ -1808,6 +1809,8 @@ importConfirm: "Imported extensions run inside the agent process with the same a "bus.subscribe": "Can receive messages on the topics it declared.", "browser.cdp": "Can navigate the work-panel browser, read the page, run JavaScript, and send allowlisted Chrome DevTools commands. Cookie and storage methods are blocked.", + "usage.read": + "Lists completed-turn usage facts (paginated token counters and session titles). No message content is included.", }, }, /** diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 4101ad99c0..40bd4daae7 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1738,7 +1738,8 @@ sklm: { "net.websocket": "Abrir conexiones en tiempo real", "bus.publish": "Enviar mensajes a otros complementos", "bus.subscribe": "Recibir mensajes de otros complementos", - "browser.cdp": "Controlar el navegador del panel de trabajo" + "browser.cdp": "Controlar el navegador del panel de trabajo", + "usage.read": "Leer estadísticas de uso" }, "permissionHelp": { "ui.panel": "Permite que el complemento muestre su propio panel dentro de la aplicación.", @@ -1774,7 +1775,9 @@ sklm: { "net.websocket": "Abre conexiones bidireccionales en tiempo real con los hosts que declara el complemento.", "bus.publish": "Puede enviar mensajes sobre los temas que declaró.", "bus.subscribe": "Puede recibir mensajes sobre los temas que declaró.", - "browser.cdp": "Puede navegar por el navegador del panel de trabajo, leer la página, ejecutar JavaScript y enviar comandos de Chrome DevTools incluidos en la lista permitida. Las cookies y los métodos de almacenamiento están bloqueados." + "browser.cdp": "Puede navegar por el navegador del panel de trabajo, leer la página, ejecutar JavaScript y enviar comandos de Chrome DevTools incluidos en la lista permitida. Las cookies y los métodos de almacenamiento están bloqueados.", + "usage.read": + "Enumera los datos de uso de los turnos completados (contadores de tokens por turno, paginados). No incluye el contenido de los mensajes.", } }, "extensions": { diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index ff23a74f8a..f4ab28623c 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1738,7 +1738,8 @@ sklm: { "net.websocket": "Ouvrir des connexions en temps réel", "bus.publish": "Envoyer des messages à d'autres plugins", "bus.subscribe": "Recevoir des messages d'autres plugins", - "browser.cdp": "Contrôler le navigateur du panneau de travail" + "browser.cdp": "Contrôler le navigateur du panneau de travail", + "usage.read": "Lire les statistiques d'utilisation" }, "permissionHelp": { "ui.panel": "Permet au plugin d'afficher son propre panneau dans l'application.", @@ -1774,7 +1775,9 @@ sklm: { "net.websocket": "Ouvre des connexions bidirectionnelles en temps réel vers les hôtes déclarés par le plugin.", "bus.publish": "Peut envoyer des messages sur les sujets qu'il a déclarés.", "bus.subscribe": "Peut recevoir des messages sur les sujets qu'il a déclarés.", - "browser.cdp": "Peut naviguer dans le navigateur du panneau de travail, lire la page, exécuter JavaScript et envoyer des commandes Chrome DevTools sur liste autorisée. Les cookies et les méthodes de stockage sont bloqués." + "browser.cdp": "Peut naviguer dans le navigateur du panneau de travail, lire la page, exécuter JavaScript et envoyer des commandes Chrome DevTools sur liste autorisée. Les cookies et les méthodes de stockage sont bloqués.", + "usage.read": + "Liste les données d'utilisation des tours terminés (compteurs de tokens par tour, paginés). Aucun contenu de message n'est inclus.", } }, "extensions": { diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 98109a2f4a..70bd81c0f8 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1759,6 +1759,7 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이 "bus.publish": "다른 플러그인에 메시지 보내기", "bus.subscribe": "다른 플러그인의 메시지 받기", "browser.cdp": "작업 패널 브라우저 제어", + "usage.read": "사용량 통계 읽기", }, permissionHelp: { "ui.panel": "플러그인이 앱 안에 자체 패널을 표시할 수 있습니다.", @@ -1807,6 +1808,8 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이 "bus.subscribe": "선언한 주제의 메시지를 받을 수 있습니다.", "browser.cdp": "작업 패널 브라우저를 탐색하고 페이지를 읽으며 JavaScript를 실행하고 허용 목록에 있는 Chrome DevTools 명령을 보낼 수 있습니다. 쿠키 및 저장소 메서드는 차단됩니다.", + "usage.read": + "완료된 턴의 사용량 팩트를 페이지별로 나열합니다(턴당 토큰 카운터). 메시지 내용은 포함되지 않습니다.", }, }, /** diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index e52b5a2755..6500398d26 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1759,6 +1759,7 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç "bus.publish": "Diğer eklentilere ileti gönder", "bus.subscribe": "Diğer eklentilerden ileti al", "browser.cdp": "Çalışma paneli tarayıcısını kontrol et", + "usage.read": "Kullanım istatistiklerini oku", }, permissionHelp: { "ui.panel": "Eklentinin uygulama içinde kendi panelini göstermesini sağlar.", @@ -1807,6 +1808,8 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç "bus.subscribe": "Bildirdiği konularda ileti alabilir.", "browser.cdp": "Çalışma paneli tarayıcısında gezebilir, sayfayı okuyabilir, JavaScript çalıştırabilir ve izin listesindeki Chrome DevTools komutlarını gönderebilir. Çerez ve depolama yöntemleri engellenir.", + "usage.read": + "Tamamlanan turların kullanım verilerini sayfalı olarak listeler (tur başına token sayaçları). Mesaj içeriği dahil değildir.", }, }, /** diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 8a7e164fdb..c0ae506954 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1740,6 +1740,7 @@ sklm: { "bus.publish": "向其他插件发送消息", "bus.subscribe": "接收其他插件的消息", "browser.cdp": "控制工作面板浏览器", + "usage.read": "读取用量统计", }, permissionHelp: { "ui.panel": "允许插件在应用内显示独立面板。", @@ -1779,6 +1780,8 @@ sklm: { "bus.subscribe": "可在其声明的主题上接收消息。", "browser.cdp": "可导航工作面板浏览器、读取页面、运行 JavaScript,并发送白名单内的 Chrome DevTools 命令。Cookie 与存储相关方法会被拒绝。", + "usage.read": + "分页列出已完成回合的用量事实(每回合 token 计数与会话标题)。不包含任何消息内容。", }, }, extensions: { diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 82e68a4f3d..3764693212 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1740,6 +1740,7 @@ sklm: { "bus.publish": "向其他外掛傳送訊息", "bus.subscribe": "接收其他外掛的訊息", "browser.cdp": "控制工作面板瀏覽器", + "usage.read": "讀取用量統計", }, permissionHelp: { "ui.panel": "允許外掛在應用內顯示獨立面板。", @@ -1778,6 +1779,8 @@ sklm: { "bus.subscribe": "可在其宣告的主題上接收訊息。", "browser.cdp": "可導航工作面板瀏覽器、讀取頁面、執行 JavaScript,併發送白名單內的 Chrome DevTools 命令。Cookie 與儲存相關方法會被拒絕。", + "usage.read": + "分頁列出已完成回合的用量事實(每回合的 token 計數)。不包含任何訊息內容。", }, }, extensions: { diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts index e9f61f436f..805d7729b5 100644 --- a/packages/plugin-sdk/src/index.test.ts +++ b/packages/plugin-sdk/src/index.test.ts @@ -561,6 +561,7 @@ describe("PLUGIN_PERMISSIONS", () => { "models.list", "project.create", "session.read", + "usage.read", "fs.read", "fs.write", "fs.delete", diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index a774b7affb..33016d9fa9 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -280,6 +280,38 @@ export type PluginSessionGetResult = { updatedAt: string; }; +/** + * One completed turn as a flat fact row (`usage.read`). The host serves raw + * counters — per-turn tokens and identifiers only; no message body ever + * crosses the bridge, and every dashboard shape (streaks, heatmaps, shares) + * stays the plugin's own computation. + */ +export type PluginUsageTurn = { + turnId: string; + sessionId: string; + sessionTitle: string | null; + projectId: number | null; + providerId: string | null; + modelId: string | null; + startedAt: number; + endedAt: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; +}; + +/** + * A keyset-paginated page of completed turns, ordered by `endedAt` + * ascending. `nextCursor` is opaque: pass it back as `cursor` to fetch the + * next page; it is `null` when the window is exhausted. + */ +export type PluginUsageTurnPage = { + turns: PluginUsageTurn[]; + nextCursor: string | null; +}; + export type PluginSessionMessageResult = { id: string; role: "user" | "assistant" | "tool"; @@ -1109,6 +1141,28 @@ export type PluginHostApi = { mode?: "trash" | "purge"; }) => Promise<{ deleted: boolean }>; }; + /** + * Read-only completed-turn facts served by the host (`usage.read`). Flat + * counters and identifiers only — no message body, no write path, and no + * dashboard shape: streaks, heatmaps, and rankings stay the plugin's own + * computation on top of these rows. + */ + usage: { + listTurns: (input?: { + /** Inclusive window start in epoch ms. Default: `toMs` minus 30 days. */ + fromMs?: number; + /** Inclusive window end in epoch ms. Default: now. Window span ≤ 365 days. */ + toMs?: number; + /** Limit rows to one durable project id. */ + projectId?: number | null; + /** Limit rows to one session id. */ + sessionId?: string; + /** Opaque page cursor from the previous `nextCursor`. */ + cursor?: string; + /** 1..=500 rows per page; default 200. */ + limit?: number; + }) => Promise; + }; services: { /** * Register a resident service declared in `contributes.services`. Local @@ -1216,6 +1270,9 @@ export const PLUGIN_PERMISSIONS = [ "session.read.own", "session.update.own", "session.delete.own", + // Read-only usage facts (pi.usage.listTurns): + // completed-turn counters and session titles, never message bodies. + "usage.read", "net.fetch", "shell.openExternal", "mcp.server.local", From af1580c91f6400fdf3d52622b075aa43ff40f2ea Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 21:57:40 +0800 Subject: [PATCH 2/2] chore(ci): clear rustfmt and ADR catalog gates already red on main cargo fmt on sessions.rs and user_skills tests, and make ADR 0287/0288 H1 ids match their filenames so the host and docs jobs can pass. --- crates/host-core/src/sessions.rs | 7 +++--- crates/host-core/src/user_skills/tests.rs | 22 +++++++++---------- ...endered-plugin-scenic-settings-surfaces.md | 2 +- docs/adr/0288-package-local-theme-assets.md | 2 +- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index 0be59e0e3d..5a2bd6eb7d 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -482,9 +482,10 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage { }) .collect::>(); let thinking = (!thinking.is_empty()).then(|| thinking.concat()); - let hosted_search = blocks.iter().find(|b| { - b.get("type").and_then(|t| t.as_str()) == Some("hostedSearch") - }).cloned(); + let hosted_search = blocks + .iter() + .find(|b| b.get("type").and_then(|t| t.as_str()) == Some("hostedSearch")) + .cloned(); let is_error = record.is_error.then_some(true); let attachments = blocks .iter() diff --git a/crates/host-core/src/user_skills/tests.rs b/crates/host-core/src/user_skills/tests.rs index 6380c0e68a..3e7534d496 100644 --- a/crates/host-core/src/user_skills/tests.rs +++ b/crates/host-core/src/user_skills/tests.rs @@ -685,11 +685,7 @@ fn imports_a_directory_with_skill_md_in_copy_mode() { let record = registry .import( source_dir.to_str().unwrap(), - input( - "Ignored", - "project", - Some(app.path().to_str().unwrap()), - ), + input("Ignored", "project", Some(app.path().to_str().unwrap())), ) .unwrap(); let normalized_project = @@ -703,10 +699,16 @@ fn imports_a_directory_with_skill_md_in_copy_mode() { .join("example"); assert!(expected_root.is_dir(), "target dir exists"); assert!(expected_root.join("SKILL.md").is_file(), "SKILL.md placed"); - assert!(expected_root.join("resource.txt").is_file(), "resources copied"); + assert!( + expected_root.join("resource.txt").is_file(), + "resources copied" + ); assert_eq!(record.name, "Example"); // The record path points at the SKILL.md the scanner selects. - assert_eq!(record.path, expected_root.join("SKILL.md").to_string_lossy()); + assert_eq!( + record.path, + expected_root.join("SKILL.md").to_string_lossy() + ); // Source is untouched under copy mode. assert!(source_dir.join("SKILL.md").is_file()); } @@ -799,11 +801,7 @@ fn directory_import_without_skill_md_is_rejected() { fn unknown_import_mode_is_rejected() { let app = tempdir().unwrap(); let source = app.path().join("incoming.md"); - fs::write( - &source, - "---\nname: Any\n---\n\nBody.\n", - ) - .unwrap(); + fs::write(&source, "---\nname: Any\n---\n\nBody.\n").unwrap(); let mut registry = UserSkillRegistry::new(app.path()); let mut payload = input("Ignored", "project", Some(app.path().to_str().unwrap())); payload.mode = Some("teleport".into()); diff --git a/docs/adr/0287-host-rendered-plugin-scenic-settings-surfaces.md b/docs/adr/0287-host-rendered-plugin-scenic-settings-surfaces.md index 0c8cee3f88..4a6242d8ac 100644 --- a/docs/adr/0287-host-rendered-plugin-scenic-settings-surfaces.md +++ b/docs/adr/0287-host-rendered-plugin-scenic-settings-surfaces.md @@ -1,4 +1,4 @@ -# ADR 0280 — Host-rendered plugin scenic Settings surfaces +# ADR 0287 — Host-rendered plugin scenic Settings surfaces - **Status:** Accepted for implementation - **Date:** 2026-09-17 diff --git a/docs/adr/0288-package-local-theme-assets.md b/docs/adr/0288-package-local-theme-assets.md index 4564c9ba0b..d016807456 100644 --- a/docs/adr/0288-package-local-theme-assets.md +++ b/docs/adr/0288-package-local-theme-assets.md @@ -1,4 +1,4 @@ -# ADR 0281: Package-local theme assets remain available +# ADR 0288: Package-local theme assets remain available - Status: Accepted for implementation - Date: 2026-09-17