@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.
- package/README.md +20 -4
- package/dist/cost.js +5 -2
- package/dist/descendants.d.ts +22 -0
- package/dist/descendants.js +78 -0
- package/dist/events.d.ts +8 -0
- package/dist/events.js +31 -0
- package/dist/format.js +127 -23
- package/dist/index.js +190 -631
- package/dist/persistence.d.ts +13 -0
- package/dist/persistence.js +63 -0
- package/dist/providers/core/openai.js +2 -1
- package/dist/providers/third_party/rightcode.js +12 -11
- package/dist/quota.js +18 -8
- package/dist/quota_service.d.ts +23 -0
- package/dist/quota_service.js +188 -0
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +62 -24
- package/dist/storage_chunks.js +74 -1
- package/dist/storage_parse.js +8 -0
- package/dist/storage_paths.d.ts +1 -0
- package/dist/storage_paths.js +12 -4
- package/dist/title.d.ts +5 -0
- package/dist/title.js +26 -2
- package/dist/title_apply.d.ts +33 -0
- package/dist/title_apply.js +189 -0
- package/dist/title_refresh.d.ts +9 -0
- package/dist/title_refresh.js +46 -0
- package/dist/tools.d.ts +56 -0
- package/dist/tools.js +63 -0
- package/dist/types.d.ts +12 -0
- package/dist/usage.js +148 -47
- package/dist/usage_service.d.ts +31 -0
- package/dist/usage_service.js +417 -0
- package/package.json +1 -1
- package/quota-sidebar.config.example.json +5 -1
package/dist/storage_chunks.js
CHANGED
|
@@ -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.
|
|
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$/);
|
package/dist/storage_parse.js
CHANGED
|
@@ -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
|
};
|
package/dist/storage_paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/storage_paths.js
CHANGED
|
@@ -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
|
|
14
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -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;
|