@leo000001/opencode-quota-sidebar 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,39 @@ import { debug, isRecord, swallow } from './helpers.js';
5
5
  import { isDateKey } from './storage_dates.js';
6
6
  import { parseSessionState } from './storage_parse.js';
7
7
  import { chunkFilePath } from './storage_paths.js';
8
+ async function mkdirpNoSymlink(rootPath, dirPath) {
9
+ const rel = path.relative(rootPath, dirPath);
10
+ if (!rel || rel === '.')
11
+ return;
12
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
13
+ throw new Error(`refusing to mkdir outside root: ${dirPath}`);
14
+ }
15
+ let current = rootPath;
16
+ const parts = rel.split(path.sep).filter(Boolean);
17
+ for (const part of parts) {
18
+ current = path.join(current, part);
19
+ const stat = await fs.lstat(current).catch(() => undefined);
20
+ if (stat) {
21
+ if (stat.isSymbolicLink()) {
22
+ throw new Error(`refusing to write through symlink dir: ${current}`);
23
+ }
24
+ if (!stat.isDirectory()) {
25
+ throw new Error(`expected directory at ${current}`);
26
+ }
27
+ continue;
28
+ }
29
+ await fs.mkdir(current).catch((error) => {
30
+ const code = error.code;
31
+ if (code === 'EEXIST')
32
+ return;
33
+ throw error;
34
+ });
35
+ const created = await fs.lstat(current).catch(() => undefined);
36
+ if (!created || created.isSymbolicLink() || !created.isDirectory()) {
37
+ throw new Error(`unsafe directory created at ${current}`);
38
+ }
39
+ }
40
+ }
8
41
  /** P2: Simple LRU cache for loaded chunks. */
9
42
  class ChunkCache {
10
43
  cache = new Map();
@@ -41,10 +74,17 @@ class ChunkCache {
41
74
  }
42
75
  const chunkCache = new ChunkCache();
43
76
  export async function readDayChunk(rootPath, dateKey) {
77
+ if (!isDateKey(dateKey))
78
+ return {};
44
79
  const cached = chunkCache.get(dateKey);
45
80
  if (cached)
46
81
  return cached;
47
82
  const filePath = chunkFilePath(rootPath, dateKey);
83
+ const stat = await fs.lstat(filePath).catch(() => undefined);
84
+ if (stat?.isSymbolicLink()) {
85
+ debug(`refusing to read symlink chunk: ${filePath}`);
86
+ return {};
87
+ }
48
88
  const parsed = await fs
49
89
  .readFile(filePath, 'utf8')
50
90
  .then((value) => JSON.parse(value))
@@ -76,6 +116,12 @@ export async function safeWriteFile(filePath, content) {
76
116
  debug(message);
77
117
  throw new Error(message);
78
118
  }
119
+ const dirStat = await fs.lstat(path.dirname(filePath)).catch(() => undefined);
120
+ if (dirStat?.isSymbolicLink()) {
121
+ const message = `refusing to write through symlink dir: ${path.dirname(filePath)}`;
122
+ debug(message);
123
+ throw new Error(message);
124
+ }
79
125
  // M4: atomic write via temp + rename
80
126
  const dir = path.dirname(filePath);
81
127
  const name = path.basename(filePath);
@@ -109,8 +155,25 @@ export async function safeWriteFile(filePath, content) {
109
155
  : new Error(`safeWriteFile failed for ${filePath}`);
110
156
  }
111
157
  export async function writeDayChunk(rootPath, dateKey, sessions) {
158
+ if (!isDateKey(dateKey)) {
159
+ throw new Error(`invalid dateKey: ${dateKey}`);
160
+ }
112
161
  const filePath = chunkFilePath(rootPath, dateKey);
113
- await fs.mkdir(path.dirname(filePath), { recursive: true });
162
+ const rootStat = await fs.lstat(rootPath).catch(() => undefined);
163
+ if (rootStat?.isSymbolicLink()) {
164
+ throw new Error(`refusing to write through symlink dir: ${rootPath}`);
165
+ }
166
+ if (rootStat && !rootStat.isDirectory()) {
167
+ throw new Error(`expected directory at ${rootPath}`);
168
+ }
169
+ await fs.mkdir(rootPath, { recursive: true });
170
+ const createdRoot = await fs.lstat(rootPath).catch(() => undefined);
171
+ if (!createdRoot ||
172
+ createdRoot.isSymbolicLink() ||
173
+ !createdRoot.isDirectory()) {
174
+ throw new Error(`unsafe chunk root at ${rootPath}`);
175
+ }
176
+ await mkdirpNoSymlink(rootPath, path.dirname(filePath));
114
177
  const chunk = {
115
178
  version: 1,
116
179
  dateKey,
@@ -126,11 +189,21 @@ export async function discoverChunks(rootPath) {
126
189
  if (!/^\d{4}$/.test(year))
127
190
  continue;
128
191
  const yearPath = path.join(rootPath, year);
192
+ const yearStat = await fs.lstat(yearPath).catch(() => undefined);
193
+ if (!yearStat || yearStat.isSymbolicLink() || !yearStat.isDirectory()) {
194
+ continue;
195
+ }
129
196
  const months = await fs.readdir(yearPath).catch(() => []);
130
197
  for (const month of months) {
131
198
  if (!/^\d{2}$/.test(month))
132
199
  continue;
133
200
  const monthPath = path.join(yearPath, month);
201
+ const monthStat = await fs.lstat(monthPath).catch(() => undefined);
202
+ if (!monthStat ||
203
+ monthStat.isSymbolicLink() ||
204
+ !monthStat.isDirectory()) {
205
+ continue;
206
+ }
134
207
  const days = await fs.readdir(monthPath).catch(() => []);
135
208
  for (const dayFile of days) {
136
209
  const match = dayFile.match(/^(\d{2})\.json$/);
@@ -55,9 +55,16 @@ function parseCachedUsage(value) {
55
55
  function parseCursor(value) {
56
56
  if (!isRecord(value))
57
57
  return undefined;
58
+ const idsRaw = value.lastMessageIdsAtTime;
59
+ const lastMessageIdsAtTime = Array.isArray(idsRaw)
60
+ ? idsRaw.filter((item) => typeof item === 'string' && !!item)
61
+ : undefined;
58
62
  return {
59
63
  lastMessageId: typeof value.lastMessageId === 'string' ? value.lastMessageId : undefined,
60
64
  lastMessageTime: asNumber(value.lastMessageTime),
65
+ lastMessageIdsAtTime: lastMessageIdsAtTime && lastMessageIdsAtTime.length
66
+ ? Array.from(new Set(lastMessageIdsAtTime)).sort()
67
+ : undefined,
61
68
  };
62
69
  }
63
70
  export function parseSessionState(value) {
@@ -72,6 +79,7 @@ export function parseSessionState(value) {
72
79
  return {
73
80
  ...title,
74
81
  createdAt,
82
+ parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
75
83
  usage: parseCachedUsage(value.usage),
76
84
  cursor: parseCursor(value.cursor),
77
85
  };
@@ -6,6 +6,7 @@
6
6
  * This applies on all platforms including Windows and macOS.
7
7
  *
8
8
  * S4 fix: renamed env var from OPENCODE_TEST_HOME to OPENCODE_QUOTA_DATA_HOME.
9
+ * OPENCODE_QUOTA_DATA_HOME overrides the full data directory path.
9
10
  */
10
11
  export declare function resolveOpencodeDataDir(): string;
11
12
  export declare function stateFilePath(dataDir: string): string;
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
+ import { isDateKey } from './storage_dates.js';
3
4
  /**
4
5
  * Resolve the OpenCode data directory.
5
6
  *
@@ -8,13 +9,16 @@ import path from 'node:path';
8
9
  * This applies on all platforms including Windows and macOS.
9
10
  *
10
11
  * S4 fix: renamed env var from OPENCODE_TEST_HOME to OPENCODE_QUOTA_DATA_HOME.
12
+ * OPENCODE_QUOTA_DATA_HOME overrides the full data directory path.
11
13
  */
12
14
  export function resolveOpencodeDataDir() {
13
- const home = process.env.OPENCODE_QUOTA_DATA_HOME || os.homedir();
14
- const xdg = process.env.XDG_DATA_HOME;
15
+ const override = process.env.OPENCODE_QUOTA_DATA_HOME?.trim();
16
+ if (override)
17
+ return path.resolve(override);
18
+ const xdg = process.env.XDG_DATA_HOME?.trim();
15
19
  if (xdg)
16
- return path.join(xdg, 'opencode');
17
- return path.join(home, '.local', 'share', 'opencode');
20
+ return path.join(path.resolve(xdg), 'opencode');
21
+ return path.join(os.homedir(), '.local', 'share', 'opencode');
18
22
  }
19
23
  export function stateFilePath(dataDir) {
20
24
  return path.join(dataDir, 'quota-sidebar.state.json');
@@ -26,6 +30,10 @@ export function chunkRootPathFromStateFile(statePath) {
26
30
  return path.join(path.dirname(statePath), 'quota-sidebar-sessions');
27
31
  }
28
32
  export function chunkFilePath(rootPath, dateKey) {
33
+ // Defense-in-depth: ensure we never build paths from untrusted inputs.
34
+ if (!isDateKey(dateKey)) {
35
+ throw new Error(`invalid dateKey: ${dateKey}`);
36
+ }
29
37
  const [year, month, day] = dateKey.split('-');
30
38
  return path.join(rootPath, year, month, `${day}.json`);
31
39
  }
package/dist/title.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export declare function normalizeBaseTitle(title: string): string;
2
2
  export declare function stripAnsi(value: string): string;
3
3
  export declare function canonicalizeTitle(value: string): string;
4
+ /**
5
+ * Comparison canonicalizer for decorated titles.
6
+ * OpenCode may normalize runs of spaces; treat those as equivalent.
7
+ */
8
+ export declare function canonicalizeTitleForCompare(value: string): string;
4
9
  /**
5
10
  * Detect whether a title already contains our decoration.
6
11
  * Current layout has token/quota lines after base title line.
package/dist/title.js CHANGED
@@ -1,8 +1,19 @@
1
1
  export function normalizeBaseTitle(title) {
2
- return stripAnsi(title).split(/\r?\n/, 1)[0] || 'Session';
2
+ const firstLine = stripAnsi(title).split(/\r?\n/, 1)[0] || 'Session';
3
+ return firstLine.replace(/[\x00-\x1F\x7F-\x9F]/g, ' ').trimEnd() || 'Session';
3
4
  }
4
5
  export function stripAnsi(value) {
5
- return value.replace(/\u001b\[[0-9;]*m/g, '');
6
+ // Remove terminal escape sequences. Sidebar titles must be plain text.
7
+ // We intentionally strip more than SGR to avoid resize/render corruption.
8
+ return (value
9
+ // OSC: ESC ] ... BEL or ST (ESC \)
10
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
11
+ // CSI: ESC [ ... final byte
12
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
13
+ // 2-byte escapes and other single-ESC controls
14
+ .replace(/\u001b[@-Z\\-_]/g, '')
15
+ // Any leftover ESC
16
+ .replace(/\u001b/g, ''));
6
17
  }
7
18
  export function canonicalizeTitle(value) {
8
19
  return stripAnsi(value)
@@ -10,6 +21,19 @@ export function canonicalizeTitle(value) {
10
21
  .map((line) => line.trimEnd())
11
22
  .join('\n');
12
23
  }
24
+ /**
25
+ * Comparison canonicalizer for decorated titles.
26
+ * OpenCode may normalize runs of spaces; treat those as equivalent.
27
+ */
28
+ export function canonicalizeTitleForCompare(value) {
29
+ const lines = stripAnsi(value).split(/\r?\n/);
30
+ return lines
31
+ .map((line, index) => {
32
+ const safe = line.replace(/[\x00-\x1F\x7F-\x9F]/g, ' ').trimEnd();
33
+ return safe.trim().replace(/[ \t]+/g, ' ');
34
+ })
35
+ .join('\n');
36
+ }
13
37
  /**
14
38
  * Detect whether a title already contains our decoration.
15
39
  * Current layout has token/quota lines after base title line.
@@ -0,0 +1,33 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import type { QuotaSidebarConfig, QuotaSidebarState, QuotaSnapshot, SessionState } from './types.js';
3
+ import type { UsageSummary } from './usage.js';
4
+ export declare function createTitleApplicator(deps: {
5
+ state: QuotaSidebarState;
6
+ config: QuotaSidebarConfig;
7
+ client: PluginInput['client'];
8
+ directory: string;
9
+ ensureSessionState: (sessionID: string, title: string, createdAt: number, parentID?: string | null) => SessionState;
10
+ markDirty: (dateKey: string | undefined) => void;
11
+ scheduleSave: () => void;
12
+ renderSidebarTitle: (baseTitle: string, usage: UsageSummary, quotas: QuotaSnapshot[], config: QuotaSidebarConfig) => string;
13
+ quotaRuntime: {
14
+ normalizeProviderID: (providerID: string) => string;
15
+ };
16
+ getQuotaSnapshots: (providerIDs: string[], options?: {
17
+ allowDefault?: boolean;
18
+ }) => Promise<QuotaSnapshot[]>;
19
+ summarizeSessionUsageForDisplay: (sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
20
+ scheduleParentRefreshIfSafe: (sessionID: string, parentID?: string) => void;
21
+ restoreConcurrency: number;
22
+ }): {
23
+ applyTitle: (sessionID: string) => Promise<void>;
24
+ handleSessionUpdatedTitle: (args: {
25
+ sessionID: string;
26
+ incomingTitle: string;
27
+ sessionState: SessionState;
28
+ scheduleRefresh: (sessionID: string, delay?: number) => void;
29
+ }) => Promise<void>;
30
+ restoreSessionTitle: (sessionID: string) => Promise<void>;
31
+ restoreAllVisibleTitles: () => Promise<void>;
32
+ forgetSession: (sessionID: string) => void;
33
+ };
@@ -0,0 +1,189 @@
1
+ import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, normalizeBaseTitle, } from './title.js';
2
+ import { swallow, debug, mapConcurrent } from './helpers.js';
3
+ export function createTitleApplicator(deps) {
4
+ const pendingAppliedTitle = new Map();
5
+ const forgetSession = (sessionID) => {
6
+ pendingAppliedTitle.delete(sessionID);
7
+ };
8
+ const applyTitle = async (sessionID) => {
9
+ if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
10
+ return;
11
+ let stateMutated = false;
12
+ const session = await deps.client.session
13
+ .get({
14
+ path: { id: sessionID },
15
+ query: { directory: deps.directory },
16
+ throwOnError: true,
17
+ })
18
+ .catch(swallow('applyTitle:getSession'));
19
+ if (!session)
20
+ return;
21
+ const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
22
+ // Detect whether the current title is our own decorated form.
23
+ const currentTitle = session.data.title;
24
+ if (canonicalizeTitle(currentTitle) !==
25
+ canonicalizeTitle(sessionState.lastAppliedTitle || '')) {
26
+ if (looksDecorated(currentTitle)) {
27
+ // Ignore decorated echoes as base-title source.
28
+ // If we previously applied a decorated title, treat this as an
29
+ // equivalent echo (OpenCode may normalize whitespace) and keep
30
+ // lastAppliedTitle in sync so restoreAllVisibleTitles still works.
31
+ if (sessionState.lastAppliedTitle &&
32
+ looksDecorated(sessionState.lastAppliedTitle)) {
33
+ if (sessionState.lastAppliedTitle !== currentTitle) {
34
+ sessionState.lastAppliedTitle = currentTitle;
35
+ stateMutated = true;
36
+ }
37
+ }
38
+ else {
39
+ debug(`ignoring decorated current title for session ${sessionID}`);
40
+ if (sessionState.lastAppliedTitle !== undefined) {
41
+ sessionState.lastAppliedTitle = undefined;
42
+ stateMutated = true;
43
+ }
44
+ }
45
+ }
46
+ else {
47
+ const nextBase = normalizeBaseTitle(currentTitle);
48
+ if (sessionState.baseTitle !== nextBase) {
49
+ sessionState.baseTitle = nextBase;
50
+ stateMutated = true;
51
+ }
52
+ if (sessionState.lastAppliedTitle !== undefined) {
53
+ sessionState.lastAppliedTitle = undefined;
54
+ stateMutated = true;
55
+ }
56
+ }
57
+ }
58
+ const usage = await deps.summarizeSessionUsageForDisplay(sessionID, deps.config.sidebar.includeChildren);
59
+ const quotaProviders = Array.from(new Set(Object.keys(usage.providers).map((id) => deps.quotaRuntime.normalizeProviderID(id))));
60
+ const quotas = deps.config.sidebar.showQuota && quotaProviders.length > 0
61
+ ? await deps.getQuotaSnapshots(quotaProviders)
62
+ : [];
63
+ const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config);
64
+ if (canonicalizeTitleForCompare(nextTitle) ===
65
+ canonicalizeTitleForCompare(session.data.title)) {
66
+ if (looksDecorated(session.data.title)) {
67
+ if (sessionState.lastAppliedTitle !== session.data.title) {
68
+ sessionState.lastAppliedTitle = session.data.title;
69
+ stateMutated = true;
70
+ }
71
+ }
72
+ if (stateMutated) {
73
+ deps.markDirty(deps.state.sessionDateMap[sessionID]);
74
+ }
75
+ deps.scheduleSave();
76
+ deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
77
+ return;
78
+ }
79
+ // Mark pending title to ignore the immediate echo `session.updated` event.
80
+ // H3 fix: use longer TTL (15s) and add decoration detection as backup.
81
+ pendingAppliedTitle.set(sessionID, {
82
+ title: nextTitle,
83
+ expiresAt: Date.now() + 15_000,
84
+ });
85
+ const previousApplied = sessionState.lastAppliedTitle;
86
+ sessionState.lastAppliedTitle = nextTitle;
87
+ deps.markDirty(deps.state.sessionDateMap[sessionID]);
88
+ const updated = await deps.client.session
89
+ .update({
90
+ path: { id: sessionID },
91
+ query: { directory: deps.directory },
92
+ body: { title: nextTitle },
93
+ throwOnError: true,
94
+ })
95
+ .catch(swallow('applyTitle:update'));
96
+ if (!updated) {
97
+ pendingAppliedTitle.delete(sessionID);
98
+ sessionState.lastAppliedTitle = previousApplied;
99
+ deps.scheduleSave();
100
+ deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
101
+ return;
102
+ }
103
+ pendingAppliedTitle.delete(sessionID);
104
+ deps.scheduleSave();
105
+ deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
106
+ };
107
+ const handleSessionUpdatedTitle = async (args) => {
108
+ const pending = pendingAppliedTitle.get(args.sessionID);
109
+ if (pending) {
110
+ if (pending.expiresAt > Date.now()) {
111
+ if (canonicalizeTitleForCompare(args.incomingTitle) ===
112
+ canonicalizeTitleForCompare(pending.title)) {
113
+ pendingAppliedTitle.delete(args.sessionID);
114
+ // Keep in sync with what the server actually stored.
115
+ args.sessionState.lastAppliedTitle = args.incomingTitle;
116
+ deps.markDirty(deps.state.sessionDateMap[args.sessionID]);
117
+ deps.scheduleSave();
118
+ return;
119
+ }
120
+ }
121
+ else {
122
+ pendingAppliedTitle.delete(args.sessionID);
123
+ }
124
+ }
125
+ // H3 fix: if the incoming title looks decorated, it's likely a late echo
126
+ // of our own update. Extract the base title from line 1 instead of
127
+ // treating the whole decorated string as the new base title.
128
+ if (canonicalizeTitleForCompare(args.incomingTitle) ===
129
+ canonicalizeTitleForCompare(args.sessionState.lastAppliedTitle || '')) {
130
+ return;
131
+ }
132
+ if (looksDecorated(args.incomingTitle)) {
133
+ debug(`ignoring late decorated echo for session ${args.sessionID}`);
134
+ return;
135
+ }
136
+ args.sessionState.baseTitle = normalizeBaseTitle(args.incomingTitle);
137
+ args.sessionState.lastAppliedTitle = undefined;
138
+ deps.markDirty(deps.state.sessionDateMap[args.sessionID]);
139
+ deps.scheduleSave();
140
+ args.scheduleRefresh(args.sessionID);
141
+ };
142
+ const restoreSessionTitle = async (sessionID) => {
143
+ const session = await deps.client.session
144
+ .get({
145
+ path: { id: sessionID },
146
+ query: { directory: deps.directory },
147
+ throwOnError: true,
148
+ })
149
+ .catch(swallow('restoreSessionTitle:get'));
150
+ if (!session)
151
+ return;
152
+ const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
153
+ const baseTitle = normalizeBaseTitle(sessionState.baseTitle);
154
+ if (session.data.title === baseTitle)
155
+ return;
156
+ await deps.client.session
157
+ .update({
158
+ path: { id: sessionID },
159
+ query: { directory: deps.directory },
160
+ body: { title: baseTitle },
161
+ throwOnError: true,
162
+ })
163
+ .catch(swallow('restoreSessionTitle:update'));
164
+ sessionState.lastAppliedTitle = undefined;
165
+ deps.markDirty(deps.state.sessionDateMap[sessionID]);
166
+ deps.scheduleSave();
167
+ };
168
+ const restoreAllVisibleTitles = async () => {
169
+ const list = await deps.client.session
170
+ .list({
171
+ query: { directory: deps.directory },
172
+ throwOnError: true,
173
+ })
174
+ .catch(swallow('restoreAllVisibleTitles:list'));
175
+ if (!list?.data)
176
+ return;
177
+ const touched = list.data.filter((s) => deps.state.sessions[s.id]?.lastAppliedTitle);
178
+ await mapConcurrent(touched, deps.restoreConcurrency, async (s) => {
179
+ await restoreSessionTitle(s.id);
180
+ });
181
+ };
182
+ return {
183
+ applyTitle,
184
+ handleSessionUpdatedTitle,
185
+ restoreSessionTitle,
186
+ restoreAllVisibleTitles,
187
+ forgetSession,
188
+ };
189
+ }
@@ -0,0 +1,9 @@
1
+ export declare function createTitleRefreshScheduler(options: {
2
+ apply: (sessionID: string) => Promise<void>;
3
+ onError?: (error: unknown) => void;
4
+ }): {
5
+ schedule: (sessionID: string, delay?: number) => void;
6
+ apply: (sessionID: string) => Promise<void>;
7
+ cancel: (sessionID: string) => void;
8
+ dispose: () => void;
9
+ };
@@ -0,0 +1,46 @@
1
+ export function createTitleRefreshScheduler(options) {
2
+ const refreshTimer = new Map();
3
+ const applyLocks = new Map();
4
+ const onError = options.onError || (() => { });
5
+ const applyLocked = async (sessionID) => {
6
+ const previous = applyLocks.get(sessionID) ?? Promise.resolve();
7
+ const promise = previous
8
+ .then(() => options.apply(sessionID))
9
+ .catch(onError)
10
+ .finally(() => {
11
+ if (applyLocks.get(sessionID) === promise) {
12
+ applyLocks.delete(sessionID);
13
+ }
14
+ });
15
+ applyLocks.set(sessionID, promise);
16
+ await promise;
17
+ };
18
+ const schedule = (sessionID, delay = 250) => {
19
+ const previous = refreshTimer.get(sessionID);
20
+ if (previous)
21
+ clearTimeout(previous);
22
+ const timer = setTimeout(() => {
23
+ refreshTimer.delete(sessionID);
24
+ void applyLocked(sessionID);
25
+ }, delay);
26
+ refreshTimer.set(sessionID, timer);
27
+ };
28
+ const cancel = (sessionID) => {
29
+ const timer = refreshTimer.get(sessionID);
30
+ if (timer)
31
+ clearTimeout(timer);
32
+ refreshTimer.delete(sessionID);
33
+ };
34
+ const dispose = () => {
35
+ for (const timer of refreshTimer.values())
36
+ clearTimeout(timer);
37
+ refreshTimer.clear();
38
+ applyLocks.clear();
39
+ };
40
+ return {
41
+ schedule,
42
+ apply: applyLocked,
43
+ cancel,
44
+ dispose,
45
+ };
46
+ }
@@ -0,0 +1,56 @@
1
+ import type { QuotaSnapshot } from './types.js';
2
+ import type { UsageSummary } from './usage.js';
3
+ export declare function createQuotaSidebarTools(deps: {
4
+ getTitleEnabled: () => boolean;
5
+ setTitleEnabled: (enabled: boolean) => void;
6
+ scheduleSave: () => void;
7
+ refreshSessionTitle: (sessionID: string, delay?: number) => void;
8
+ restoreAllVisibleTitles: () => Promise<void>;
9
+ showToast: (period: 'session' | 'day' | 'week' | 'month' | 'toggle', message: string) => Promise<void>;
10
+ summarizeForTool: (period: 'session' | 'day' | 'week' | 'month', sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
11
+ getQuotaSnapshots: (providerIDs: string[], options?: {
12
+ allowDefault?: boolean;
13
+ }) => Promise<QuotaSnapshot[]>;
14
+ renderMarkdownReport: (period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
15
+ showCost?: boolean;
16
+ }) => string;
17
+ renderToastMessage: (period: string, usage: UsageSummary, quotas: QuotaSnapshot[], options?: {
18
+ showCost?: boolean;
19
+ width?: number;
20
+ }) => string;
21
+ config: {
22
+ sidebar: {
23
+ showCost: boolean;
24
+ width: number;
25
+ includeChildren: boolean;
26
+ };
27
+ };
28
+ }): {
29
+ quota_summary: {
30
+ description: string;
31
+ args: {
32
+ period: import("zod").ZodOptional<import("zod").ZodEnum<{
33
+ day: "day";
34
+ week: "week";
35
+ month: "month";
36
+ session: "session";
37
+ }>>;
38
+ toast: import("zod").ZodOptional<import("zod").ZodBoolean>;
39
+ includeChildren: import("zod").ZodOptional<import("zod").ZodBoolean>;
40
+ };
41
+ execute(args: {
42
+ period?: "day" | "week" | "month" | "session" | undefined;
43
+ toast?: boolean | undefined;
44
+ includeChildren?: boolean | undefined;
45
+ }, context: import("@opencode-ai/plugin/tool").ToolContext): Promise<string>;
46
+ };
47
+ quota_show: {
48
+ description: string;
49
+ args: {
50
+ enabled: import("zod").ZodOptional<import("zod").ZodBoolean>;
51
+ };
52
+ execute(args: {
53
+ enabled?: boolean | undefined;
54
+ }, context: import("@opencode-ai/plugin/tool").ToolContext): Promise<string>;
55
+ };
56
+ };
package/dist/tools.js ADDED
@@ -0,0 +1,63 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ const z = tool.schema;
3
+ export function createQuotaSidebarTools(deps) {
4
+ return {
5
+ quota_summary: tool({
6
+ description: 'Show usage and quota summary for session/day/week/month.',
7
+ args: {
8
+ period: z.enum(['session', 'day', 'week', 'month']).optional(),
9
+ toast: z.boolean().optional(),
10
+ includeChildren: z
11
+ .boolean()
12
+ .optional()
13
+ .describe('For period=session, include descendant subagent sessions in usage aggregation.'),
14
+ },
15
+ execute: async (args, context) => {
16
+ const period = args.period || 'session';
17
+ const includeChildren = period === 'session'
18
+ ? (args.includeChildren ?? deps.config.sidebar.includeChildren)
19
+ : false;
20
+ const usage = await deps.summarizeForTool(period, context.sessionID, includeChildren);
21
+ deps.scheduleSave();
22
+ // For quota_summary, always show all subscription quota balances,
23
+ // regardless of which providers were used in the session.
24
+ const quotas = await deps.getQuotaSnapshots([], { allowDefault: true });
25
+ const markdown = deps.renderMarkdownReport(period, usage, quotas, {
26
+ showCost: deps.config.sidebar.showCost,
27
+ });
28
+ if (args.toast !== false) {
29
+ await deps.showToast(period, deps.renderToastMessage(period, usage, quotas, {
30
+ showCost: deps.config.sidebar.showCost,
31
+ width: Math.max(44, deps.config.sidebar.width + 18),
32
+ }));
33
+ }
34
+ return markdown;
35
+ },
36
+ }),
37
+ quota_show: tool({
38
+ description: 'Toggle sidebar title display mode. When on, titles show token usage and quota; when off, titles revert to original.',
39
+ args: {
40
+ enabled: z
41
+ .boolean()
42
+ .optional()
43
+ .describe('Explicit on/off. Omit to toggle current state.'),
44
+ },
45
+ execute: async (args, context) => {
46
+ const current = deps.getTitleEnabled();
47
+ const next = args.enabled !== undefined ? args.enabled : !current;
48
+ deps.setTitleEnabled(next);
49
+ deps.scheduleSave();
50
+ if (next) {
51
+ // Turning on — re-render current session immediately
52
+ deps.refreshSessionTitle(context.sessionID, 0);
53
+ await deps.showToast('toggle', 'Sidebar usage display: ON');
54
+ return 'Sidebar usage display is now ON. Session titles will show token usage and quota.';
55
+ }
56
+ // Turning off — restore all touched sessions to base titles
57
+ await deps.restoreAllVisibleTitles();
58
+ await deps.showToast('toggle', 'Sidebar usage display: OFF');
59
+ return 'Sidebar usage display is now OFF. Session titles restored to original.';
60
+ },
61
+ }),
62
+ };
63
+ }
package/dist/types.d.ts CHANGED
@@ -67,9 +67,13 @@ export type IncrementalCursor = {
67
67
  lastMessageId?: string;
68
68
  /** Timestamp of the last processed assistant message. */
69
69
  lastMessageTime?: number;
70
+ /** IDs processed at lastMessageTime (for same-timestamp correctness). */
71
+ lastMessageIdsAtTime?: string[];
70
72
  };
71
73
  export type SessionState = SessionTitleState & {
72
74
  createdAt: number;
75
+ /** Parent session ID for subagent child sessions. */
76
+ parentID?: string;
73
77
  usage?: CachedSessionUsage;
74
78
  /** Incremental aggregation cursor (P1). */
75
79
  cursor?: IncrementalCursor;
@@ -93,6 +97,14 @@ export type QuotaSidebarConfig = {
93
97
  width: number;
94
98
  showCost: boolean;
95
99
  showQuota: boolean;
100
+ /** Include descendant subagent sessions in session-scoped usage/quota. */
101
+ includeChildren: boolean;
102
+ /** Max descendant traversal depth when includeChildren is enabled. */
103
+ childrenMaxDepth: number;
104
+ /** Max number of descendant sessions to include when includeChildren is enabled. */
105
+ childrenMaxSessions: number;
106
+ /** Concurrency for fetching descendant session messages (bounded). */
107
+ childrenConcurrency: number;
96
108
  };
97
109
  quota: {
98
110
  refreshMs: number;