@leo000001/opencode-quota-sidebar 1.0.2 → 1.0.3

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.
package/README.md CHANGED
@@ -55,6 +55,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
55
55
  - line 5: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
56
56
  - quota lines: quota text like `OpenAI 5h 80% Rst 16:20`, with multi-window continuation lines indented (e.g. ` Weekly 70% Rst 03-01`)
57
57
  - RightCode daily quota shows `$remaining/$dailyTotal` + expiry (e.g. `RC Daily $105/$60 Exp 02-27`, without trailing percent) and also shows balance on the next indented line when available
58
+ - Session-scoped usage/quota can include descendant subagent sessions (enabled by default via `sidebar.includeChildren=true`). Traversal is bounded by `childrenMaxDepth` (default 6), `childrenMaxSessions` (default 128), and `childrenConcurrency` (default 5); truncation is logged when `OPENCODE_QUOTA_DEBUG=1`. Day/week/month ranges never merge children — only session scope does.
58
59
  - Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
59
60
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
60
61
  - Custom tools:
@@ -81,6 +82,7 @@ The plugin stores lightweight global state and date-partitioned session chunks.
81
82
  - Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
82
83
  - per-session title state (`baseTitle`, `lastAppliedTitle`)
83
84
  - `createdAt`
85
+ - `parentID` (when the session is a subagent child session)
84
86
  - cached usage summary used by `quota_summary`
85
87
  - incremental aggregation cursor
86
88
 
@@ -141,7 +143,11 @@ Create `quota-sidebar.config.json` under your project root:
141
143
  "enabled": true,
142
144
  "width": 36,
143
145
  "showCost": true,
144
- "showQuota": true
146
+ "showQuota": true,
147
+ "includeChildren": true,
148
+ "childrenMaxDepth": 6,
149
+ "childrenMaxSessions": 128,
150
+ "childrenConcurrency": 5
145
151
  },
146
152
  "quota": {
147
153
  "refreshMs": 300000,
@@ -166,11 +172,18 @@ Create `quota-sidebar.config.json` under your project root:
166
172
  Notes:
167
173
 
168
174
  - `sidebar.showCost` controls API-cost visibility in sidebar title, `quota_summary` markdown report, and toast message.
175
+ - `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
176
+ - `sidebar.includeChildren` controls whether session-scoped usage/quota includes descendant subagent sessions (default: `true`).
177
+ - `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
178
+ - `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
179
+ - `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
169
180
  - `output` now includes reasoning tokens. Reasoning is no longer rendered as a separate line.
170
181
  - API cost excludes reasoning tokens from output billing (uses `tokens.output` only for output-price multiplication).
171
182
  - `quota.providers` is the extensible per-adapter switch map.
172
183
  - If API Cost is `$0.00`, it usually means the model/provider has no pricing mapping in OpenCode at the moment, so equivalent API cost cannot be estimated.
173
184
 
185
+ `quota_summary` also supports an optional `includeChildren` flag (only effective for `period=session`) to override the config per call. For `day`/`week`/`month` periods, children are never merged — each session is counted independently.
186
+
174
187
  ## Debug logging
175
188
 
176
189
  Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
@@ -196,9 +209,9 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
196
209
  - OpenAI OAuth token refresh is disabled by default; set
197
210
  `quota.refreshAccessToken=true` if you want the plugin to refresh access
198
211
  tokens when expired.
199
- - State file writes refuse to follow symlinks to prevent symlink attacks.
200
- - The `OPENCODE_QUOTA_DATA_HOME` env var can override the home directory for
201
- testing; do not set this in production.
212
+ - State/chunk file writes refuse to write through symlinked targets (best-effort defense-in-depth).
213
+ - The `OPENCODE_QUOTA_DATA_HOME` env var overrides the OpenCode data directory
214
+ path (for testing); do not set this in production.
202
215
 
203
216
  ## Contributing
204
217
 
@@ -0,0 +1,22 @@
1
+ import type { Session } from '@opencode-ai/sdk';
2
+ export type DescendantsOptions = {
3
+ maxDepth: number;
4
+ maxSessions: number;
5
+ concurrency: number;
6
+ };
7
+ export type DescendantsDeps = {
8
+ listChildren: (sessionID: string) => Promise<Session[]>;
9
+ getParentID: (sessionID: string) => string | undefined;
10
+ onDiscover: (session: {
11
+ id: string;
12
+ title: string;
13
+ createdAt: number;
14
+ parentID: string | undefined;
15
+ }) => void;
16
+ debug?: (message: string) => void;
17
+ now?: () => number;
18
+ };
19
+ export declare function createDescendantsResolver(deps: DescendantsDeps): {
20
+ invalidateForAncestors: (sessionID: string | undefined) => void;
21
+ listDescendantSessionIDs: (sessionID: string, opts: DescendantsOptions) => Promise<string[]>;
22
+ };
@@ -0,0 +1,78 @@
1
+ import { mapConcurrent } from './helpers.js';
2
+ export function createDescendantsResolver(deps) {
3
+ const cache = new Map();
4
+ const ttlMs = 5_000;
5
+ const now = deps.now || Date.now;
6
+ const debug = deps.debug || (() => { });
7
+ const invalidateForAncestors = (sessionID) => {
8
+ if (!sessionID)
9
+ return;
10
+ const visited = new Set();
11
+ let current = sessionID;
12
+ for (let i = 0; i < 512 && current; i++) {
13
+ if (visited.has(current))
14
+ return;
15
+ visited.add(current);
16
+ cache.delete(current);
17
+ current = deps.getParentID(current);
18
+ }
19
+ };
20
+ const listDescendantSessionIDs = async (sessionID, opts) => {
21
+ if (opts.maxSessions <= 0) {
22
+ cache.set(sessionID, { sessionIDs: [], expiresAt: now() + ttlMs });
23
+ return [];
24
+ }
25
+ const cached = cache.get(sessionID);
26
+ if (cached && cached.expiresAt > now()) {
27
+ return cached.sessionIDs;
28
+ }
29
+ const visited = new Set([sessionID]);
30
+ const descendants = [];
31
+ let frontier = [sessionID];
32
+ let depth = 0;
33
+ while (frontier.length > 0 &&
34
+ depth < opts.maxDepth &&
35
+ descendants.length < opts.maxSessions) {
36
+ const levels = await mapConcurrent(frontier, opts.concurrency, async (id) => {
37
+ const children = await deps.listChildren(id).catch((error) => {
38
+ debug(`listChildren failed for ${id}: ${String(error)}`);
39
+ return [];
40
+ });
41
+ return children;
42
+ });
43
+ const nextFrontier = [];
44
+ for (const children of levels) {
45
+ for (const child of children) {
46
+ if (visited.has(child.id))
47
+ continue;
48
+ visited.add(child.id);
49
+ descendants.push(child.id);
50
+ deps.onDiscover({
51
+ id: child.id,
52
+ title: child.title,
53
+ createdAt: child.time.created,
54
+ parentID: child.parentID,
55
+ });
56
+ nextFrontier.push(child.id);
57
+ if (descendants.length >= opts.maxSessions)
58
+ break;
59
+ }
60
+ if (descendants.length >= opts.maxSessions)
61
+ break;
62
+ }
63
+ frontier = nextFrontier;
64
+ depth += 1;
65
+ }
66
+ cache.set(sessionID, { sessionIDs: descendants, expiresAt: now() + ttlMs });
67
+ const truncatedByDepth = depth >= opts.maxDepth && frontier.length > 0;
68
+ const truncatedByCount = descendants.length >= opts.maxSessions && frontier.length > 0;
69
+ if (truncatedByDepth || truncatedByCount) {
70
+ debug(`descendants truncated for ${sessionID}: depth=${depth}/${opts.maxDepth}, sessions=${descendants.length}/${opts.maxSessions}`);
71
+ }
72
+ return descendants;
73
+ };
74
+ return {
75
+ invalidateForAncestors,
76
+ listDescendantSessionIDs,
77
+ };
78
+ }
@@ -0,0 +1,8 @@
1
+ import type { AssistantMessage, Event, Session } from '@opencode-ai/sdk';
2
+ export declare function createEventDispatcher(handlers: {
3
+ onSessionCreated: (session: Session) => Promise<void>;
4
+ onSessionUpdated: (session: Session) => Promise<void>;
5
+ onSessionDeleted: (session: Session) => Promise<void>;
6
+ onMessageRemoved: (sessionID: string) => Promise<void>;
7
+ onAssistantMessageCompleted: (message: AssistantMessage) => Promise<void>;
8
+ }): (event: Event) => Promise<void>;
package/dist/events.js ADDED
@@ -0,0 +1,31 @@
1
+ function isAssistantMessage(message) {
2
+ return message.role === 'assistant';
3
+ }
4
+ export function createEventDispatcher(handlers) {
5
+ return async (event) => {
6
+ if (event.type === 'session.created') {
7
+ await handlers.onSessionCreated(event.properties.info);
8
+ return;
9
+ }
10
+ if (event.type === 'session.updated') {
11
+ await handlers.onSessionUpdated(event.properties.info);
12
+ return;
13
+ }
14
+ if (event.type === 'session.deleted') {
15
+ await handlers.onSessionDeleted(event.properties.info);
16
+ return;
17
+ }
18
+ if (event.type === 'message.removed') {
19
+ await handlers.onMessageRemoved(event.properties.sessionID);
20
+ return;
21
+ }
22
+ if (event.type !== 'message.updated')
23
+ return;
24
+ if (!isAssistantMessage(event.properties.info))
25
+ return;
26
+ const completed = event.properties.info.time.completed;
27
+ if (typeof completed !== 'number' || !Number.isFinite(completed))
28
+ return;
29
+ await handlers.onAssistantMessageCompleted(event.properties.info);
30
+ };
31
+ }
package/dist/format.js CHANGED
@@ -19,17 +19,113 @@ function shortNumber(value, decimals = 1) {
19
19
  function sidebarNumber(value) {
20
20
  return shortNumber(value, 1);
21
21
  }
22
+ function sanitizeLine(value) {
23
+ // Sidebars/titles must be plain text: no ANSI and no embedded newlines.
24
+ return (stripAnsi(value)
25
+ .replace(/\r?\n/g, ' ')
26
+ // Remove control characters that can corrupt TUI rendering.
27
+ .replace(/[\x00-\x1F\x7F-\x9F]/g, ' '));
28
+ }
29
+ function isCombiningCodePoint(codePoint) {
30
+ return ((codePoint >= 0x0300 && codePoint <= 0x036f) ||
31
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
32
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
33
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
34
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f));
35
+ }
36
+ function isVariationSelector(codePoint) {
37
+ return ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
38
+ (codePoint >= 0xe0100 && codePoint <= 0xe01ef));
39
+ }
40
+ function isWideCodePoint(codePoint) {
41
+ // Based on commonly used fullwidth ranges (similar to string-width).
42
+ // This intentionally errs toward width=2 to avoid sidebar overflow.
43
+ if (codePoint >= 0x1100) {
44
+ if (codePoint <= 0x115f ||
45
+ codePoint === 0x2329 ||
46
+ codePoint === 0x232a ||
47
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
48
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
49
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
50
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
51
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
52
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
53
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
54
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)) {
55
+ return true;
56
+ }
57
+ }
58
+ // Emoji/symbol ranges (best-effort).
59
+ if ((codePoint >= 0x1f300 && codePoint <= 0x1f5ff) ||
60
+ (codePoint >= 0x1f600 && codePoint <= 0x1f64f) ||
61
+ (codePoint >= 0x1f680 && codePoint <= 0x1f6ff) ||
62
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
63
+ (codePoint >= 0x1fa70 && codePoint <= 0x1faff) ||
64
+ (codePoint >= 0x2600 && codePoint <= 0x26ff) ||
65
+ (codePoint >= 0x2700 && codePoint <= 0x27bf)) {
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ function cellWidthOfCodePoint(codePoint) {
71
+ if (codePoint === 0)
72
+ return 0;
73
+ // ZWJ sequences should not add width (best-effort).
74
+ if (codePoint === 0x200d)
75
+ return 0;
76
+ if (codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0))
77
+ return 0;
78
+ if (isCombiningCodePoint(codePoint))
79
+ return 0;
80
+ if (isVariationSelector(codePoint))
81
+ return 0;
82
+ return isWideCodePoint(codePoint) ? 2 : 1;
83
+ }
84
+ function stringCellWidth(value) {
85
+ let width = 0;
86
+ for (const char of value) {
87
+ width += cellWidthOfCodePoint(char.codePointAt(0) || 0);
88
+ }
89
+ return width;
90
+ }
91
+ function padEndCells(value, targetWidth) {
92
+ const current = stringCellWidth(value);
93
+ if (current >= targetWidth)
94
+ return value;
95
+ return `${value}${' '.repeat(targetWidth - current)}`;
96
+ }
97
+ function truncateToCellWidth(value, width) {
98
+ if (width <= 0)
99
+ return '';
100
+ let used = 0;
101
+ let out = '';
102
+ for (const char of value) {
103
+ const w = cellWidthOfCodePoint(char.codePointAt(0) || 0);
104
+ if (used + w > width)
105
+ break;
106
+ used += w;
107
+ out += char;
108
+ }
109
+ return out;
110
+ }
22
111
  /**
23
- * Truncate `value` to at most `width` visible characters.
112
+ * Truncate `value` to at most `width` terminal cells.
24
113
  * Keep plain text only (no ANSI) to avoid renderer corruption.
25
114
  */
26
115
  function fitLine(value, width) {
27
116
  if (width <= 0)
28
117
  return '';
29
- if (value.length > width) {
30
- return width <= 1 ? value.slice(0, width) : `${value.slice(0, width - 1)}~`;
31
- }
32
- return value;
118
+ const safe = sanitizeLine(value);
119
+ if (stringCellWidth(safe) <= width)
120
+ return safe;
121
+ if (width <= 1)
122
+ return truncateToCellWidth(safe, width);
123
+ const head = truncateToCellWidth(safe, width - 1);
124
+ // If we couldn't fit any characters with a suffix reserved, fall back to a
125
+ // best-effort truncation without the suffix.
126
+ if (!head)
127
+ return truncateToCellWidth(safe, width);
128
+ return `${head}~`;
33
129
  }
34
130
  function formatApiCostValue(value) {
35
131
  const safe = Number.isFinite(value) && value > 0 ? value : 0;
@@ -41,12 +137,16 @@ function formatApiCostLine(value) {
41
137
  function alignPairs(pairs, indent = ' ') {
42
138
  if (pairs.length === 0)
43
139
  return [];
44
- const labelWidth = Math.max(...pairs.map((pair) => pair.label.length), 0);
45
- return pairs.map((pair) => {
140
+ const safePairs = pairs.map((pair) => ({
141
+ label: sanitizeLine(pair.label || ''),
142
+ value: sanitizeLine(pair.value || ''),
143
+ }));
144
+ const labelWidth = Math.max(...safePairs.map((pair) => stringCellWidth(pair.label)), 0);
145
+ return safePairs.map((pair) => {
46
146
  if (!pair.label) {
47
147
  return `${indent}${' '.repeat(labelWidth)} ${pair.value}`;
48
148
  }
49
- return `${indent}${pair.label.padEnd(labelWidth)} ${pair.value}`;
149
+ return `${indent}${padEndCells(pair.label, labelWidth)} ${pair.value}`;
50
150
  });
51
151
  }
52
152
  /**
@@ -83,8 +183,8 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
83
183
  if (config.sidebar.showQuota) {
84
184
  const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
85
185
  const labelWidth = visibleQuotas.reduce((max, item) => {
86
- const label = quotaDisplayLabel(item);
87
- return Math.max(max, label.length);
186
+ const label = sanitizeLine(quotaDisplayLabel(item));
187
+ return Math.max(max, stringCellWidth(label));
88
188
  }, 0);
89
189
  const quotaItems = visibleQuotas
90
190
  .flatMap((item) => compactQuotaWide(item, labelWidth))
@@ -105,9 +205,9 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
105
205
  * Copilot: "Copilot Monthly 70% Rst 03-01"
106
206
  */
107
207
  function compactQuotaWide(quota, labelWidth = 0) {
108
- const label = quotaDisplayLabel(quota);
109
- const labelPadding = ' '.repeat(Math.max(0, labelWidth - label.length));
110
- const withLabel = (content) => `${label}${labelPadding} ${content}`;
208
+ const label = sanitizeLine(quotaDisplayLabel(quota));
209
+ const labelPadded = padEndCells(label, labelWidth);
210
+ const withLabel = (content) => `${labelPadded} ${content}`;
111
211
  if (quota.status === 'error')
112
212
  return [withLabel('Remaining ?')];
113
213
  if (quota.status === 'unsupported')
@@ -126,12 +226,12 @@ function compactQuotaWide(quota, labelWidth = 0) {
126
226
  : `${Math.round(win.remainingPercent)}%`;
127
227
  const parts = win.label
128
228
  ? showPercent
129
- ? [win.label, pct]
130
- : [win.label]
229
+ ? [sanitizeLine(win.label), pct]
230
+ : [sanitizeLine(win.label)]
131
231
  : [pct];
132
232
  const reset = compactReset(win.resetAt);
133
233
  if (reset) {
134
- parts.push(`${win.resetLabel || 'Rst'} ${reset}`);
234
+ parts.push(`${sanitizeLine(win.resetLabel || 'Rst')} ${reset}`);
135
235
  }
136
236
  return parts.join(' ');
137
237
  };
@@ -203,6 +303,7 @@ function periodLabel(period) {
203
303
  }
204
304
  export function renderMarkdownReport(period, usage, quotas, options) {
205
305
  const showCost = options?.showCost !== false;
306
+ const mdCell = (value) => sanitizeLine(value).replace(/\|/g, '\\|');
206
307
  const rightCodeSubscriptionProviderIDs = new Set(collapseQuotaSnapshots(quotas)
207
308
  .filter((quota) => quota.adapterID === 'rightcode')
208
309
  .filter((quota) => quota.status === 'ok')
@@ -252,34 +353,37 @@ export function renderMarkdownReport(period, usage, quotas, options) {
252
353
  };
253
354
  const providerRows = Object.values(usage.providers)
254
355
  .sort((a, b) => b.total - a.total)
255
- .map((provider) => showCost
256
- ? `| ${provider.providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
257
- : `| ${provider.providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`);
356
+ .map((provider) => {
357
+ const providerID = mdCell(provider.providerID);
358
+ return showCost
359
+ ? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
360
+ : `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
361
+ });
258
362
  const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
259
363
  // Multi-window detail
260
364
  if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
261
365
  return quota.windows.map((win) => {
262
366
  if (win.showPercent === false) {
263
367
  const winLabel = win.label ? ` (${win.label})` : '';
264
- return `- ${quota.label}${winLabel}: ${quota.status} | reset ${dateLine(win.resetAt)}`;
368
+ return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${dateLine(win.resetAt)}`);
265
369
  }
266
370
  const remaining = win.remainingPercent === undefined
267
371
  ? '-'
268
372
  : `${win.remainingPercent.toFixed(1)}%`;
269
373
  const winLabel = win.label ? ` (${win.label})` : '';
270
- return `- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${dateLine(win.resetAt)}`;
374
+ return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${dateLine(win.resetAt)}`);
271
375
  });
272
376
  }
273
377
  if (quota.status === 'ok' && quota.balance) {
274
378
  return [
275
- `- ${quota.label}: ${quota.status} | balance ${quota.balance.currency}${quota.balance.amount.toFixed(2)}`,
379
+ mdCell(`- ${quota.label}: ${quota.status} | balance ${quota.balance.currency}${quota.balance.amount.toFixed(2)}`),
276
380
  ];
277
381
  }
278
382
  const remaining = quota.remainingPercent === undefined
279
383
  ? '-'
280
384
  : `${quota.remainingPercent.toFixed(1)}%`;
281
385
  return [
282
- `- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${dateLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`,
386
+ mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${dateLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
283
387
  ];
284
388
  });
285
389
  return [