@guenbankhai/tools 0.0.0-stage → 0.1.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.
Files changed (40) hide show
  1. package/README.md +53 -2
  2. package/README.vi.md +54 -0
  3. package/agentmoni/lib/agents/claude/billing.js +13 -0
  4. package/agentmoni/lib/agents/claude/home.js +4 -0
  5. package/agentmoni/lib/agents/claude/index.js +181 -0
  6. package/agentmoni/lib/agents/claude/sources/oauth-usage.js +74 -0
  7. package/agentmoni/lib/agents/claude/timeline.js +26 -0
  8. package/agentmoni/lib/agents/codex/billing.js +9 -0
  9. package/agentmoni/lib/agents/codex/index.js +280 -0
  10. package/agentmoni/lib/agents/codex/sources/rollout.js +26 -0
  11. package/agentmoni/lib/agents/codex/timeline.js +22 -0
  12. package/agentmoni/lib/agents/index.js +13 -0
  13. package/agentmoni/lib/catalog.js +96 -0
  14. package/agentmoni/lib/catalog.json +493 -0
  15. package/agentmoni/lib/collect.js +11 -0
  16. package/agentmoni/lib/colors.js +35 -0
  17. package/agentmoni/lib/cost.js +87 -0
  18. package/agentmoni/lib/format.js +18 -0
  19. package/agentmoni/lib/graph.js +106 -0
  20. package/agentmoni/lib/history.js +50 -0
  21. package/agentmoni/lib/json-fields.js +58 -0
  22. package/agentmoni/lib/jsonl.js +43 -0
  23. package/agentmoni/lib/launch.js +118 -0
  24. package/agentmoni/lib/limits.js +11 -0
  25. package/agentmoni/lib/links.js +139 -0
  26. package/agentmoni/lib/list.js +71 -0
  27. package/agentmoni/lib/log.js +226 -0
  28. package/agentmoni/lib/monitor.js +58 -0
  29. package/agentmoni/lib/page.html +591 -0
  30. package/agentmoni/lib/present.js +184 -0
  31. package/agentmoni/lib/procs.js +22 -0
  32. package/agentmoni/lib/quota.js +34 -0
  33. package/agentmoni/lib/repokey.js +44 -0
  34. package/agentmoni/lib/share.js +106 -0
  35. package/agentmoni/lib/state.js +62 -0
  36. package/agentmoni/lib/terminal.js +143 -0
  37. package/agentmoni/lib/timeline.js +40 -0
  38. package/agentmoni/lib/web.js +75 -0
  39. package/agentmoni/monitor.js +98 -0
  40. package/package.json +22 -4
@@ -0,0 +1,184 @@
1
+ // Presenter: collected state -> a plain JSON view model. The single source for the terminal and the web (no formatting, no UI).
2
+ const { summarize, byDay, recordsOf, billingView } = require('./cost.js');
3
+ const { repoKey, repoRoot } = require('./repokey.js');
4
+
5
+ const windowsOf = (registry, id) => registry.find(a => a.id === id)?.windows ?? [];
6
+ const agents = require('./agents/index.js');
7
+ const LOW_LEFT = 15; // account window with less than 15% left needs attention
8
+ const RECENT_MAX = 50;
9
+ const HISTORY_MAX = 300;
10
+ const key = s => `${s.provider}:${s.id}`;
11
+
12
+ // Share windows keep null parts when nothing was spent; show them as 0, except unsupported cache-write tokens (stay null).
13
+ function shareOf(s, registry) {
14
+ if (s.billing === 'api') return {};
15
+ const out = {};
16
+ for (const { id: w } of windowsOf(registry, s.provider)) {
17
+ const x = s.share?.[w];
18
+ out[w] = x == null ? null : { total: x.total, input: x.input ?? 0, cacheRead: x.cacheRead ?? 0, cacheWrite: registry.find(a => a.id === s.provider)?.cacheWrite === false ? null : (x.cacheWrite ?? 0), output: x.output ?? 0 };
19
+ }
20
+ return out;
21
+ }
22
+
23
+ function quotaOf(account, now, declarations) {
24
+ const windows = {};
25
+ for (const { id: w, label } of declarations) {
26
+ const r = account?.windows?.[w];
27
+ const reset = !!r?.reset || (r?.resetsAt != null && r.resetsAt <= now);
28
+ const percent = reset ? null : r?.percent ?? null;
29
+ windows[w] = { label, percent, reset, source: r?.source ?? null, stale: !r || percent == null, resetInMs: r?.resetsAt > now ? r.resetsAt - now : null, readAt: r?.readAt ?? r?.at ?? null, low: percent != null && 100 - percent < LOW_LEFT };
30
+ }
31
+ return { windows, error: account?.error ?? null };
32
+ }
33
+
34
+ // A provider is shown only when it has a session, live or in history.
35
+ const providersOf = (state, registry = agents) => registry.map(a => a.id).filter(p => [...state.sessions, ...(state.history ?? [])].some(s => s.provider === p));
36
+
37
+ // All members count when their root is live, including ended workers.
38
+ function quotaProvidersOf(state, registry = agents) {
39
+ const named = new Map([...(state.history ?? []), ...state.sessions].map(s => [key(s), s]));
40
+ const providers = new Set();
41
+ for (const s of named.values()) {
42
+ let root = s;
43
+ const seen = new Set([key(s)]);
44
+ while (named.has(root.parent) && !seen.has(root.parent)) {
45
+ seen.add(root.parent); root = named.get(root.parent);
46
+ }
47
+ if (root.state && root.state !== 'ended') providers.add(s.provider);
48
+ }
49
+ return registry.map(a => a.id).filter(p => providers.has(p));
50
+ }
51
+
52
+ // opts: { mode: 'quota'|'token', repo: repo key or null for all, now, catalog, limits: (provider, model) -> context limit or null }
53
+ function present(state, { registry = agents, mode = 'quota', repo = null, now = Date.now(), catalog, limits, treeKey = null } = {}) {
54
+ catalog ??= require('./catalog.json');
55
+ const money = s => ({ ...billingView(s.billing), cost: summarize(recordsOf(s), catalog), costByDay: byDay(recordsOf(s), catalog) });
56
+ const named = new Map([...state.history, ...state.sessions].map(s => [key(s), s]));
57
+ // Resolve against the full history, before the web's 300-session limit hides ancestors.
58
+ const rootSession = s => {
59
+ const seen = new Set([key(s)]);
60
+ while (named.has(s.parent) && !seen.has(s.parent)) {
61
+ seen.add(s.parent); s = named.get(s.parent);
62
+ }
63
+ return s;
64
+ };
65
+ // Linked descendants belong in their leader's project even when they work in another cwd.
66
+ const inScope = s => {
67
+ if (repo == null) return true;
68
+ const seen = new Set();
69
+ for (let a = s; a && !seen.has(key(a)); a = named.get(a.parent)) {
70
+ if ((a.repo ?? repoKey(a.cwd)) === repo) return true;
71
+ seen.add(key(a));
72
+ }
73
+ return false;
74
+ };
75
+ const all = [...state.sessions.filter(inScope), ...state.history.filter(inScope)];
76
+ const target = treeKey && named.get(treeKey);
77
+ const treeRoot = target && rootSession(target);
78
+ const live = treeKey ? [...named.values()].filter(s => key(rootSession(s)) === key(treeRoot)) : state.sessions.filter(s => s.state !== 'ended' && inScope(s));
79
+ const liveKeys = new Set(live.map(key));
80
+
81
+ const nodes = new Map(live.map(s => {
82
+ // A limit below the observed context is wrong (e.g. 200k vs a 1M session): treat it as unknown, never warn on it.
83
+ let limit = limits?.(s.provider, s.model) ?? null;
84
+ if (limit != null && s.context != null && s.context > limit) limit = null;
85
+ const ratio = limit && s.context != null ? s.context / limit : null;
86
+ return [key(s), {
87
+ ...money(s), key: key(s), provider: s.provider, id: s.id, billing: s.billing ?? 'unknown', model: s.model ?? null, effort: s.effort ?? null, kind: s.kind, agentType: s.agentType ?? null, state: s.state, name: s.name ?? null,
88
+ cwd: s.cwd ?? null, parent: s.parent ?? null, startedAt: s.startedAt, lastActivityAt: s.lastActivityAt, endedAt: s.state === 'ended' ? s.lastActivityAt : null, turn: s.turn ?? null, orphan: !!s.orphan, resume: s.resume ?? 0,
89
+ link: s.link ? { kind: s.link.kind, label: s.link.label, evidence: s.link.evidence ?? null } : null,
90
+ context: { tokens: s.context ?? null, limit, warn: ratio != null && ratio >= (catalog?.contextWarn ?? 0.8) },
91
+ ...(s.tokens ? { tokens: { ...s.tokens } } : {}), share: shareOf(s, registry), runs: s.runs ?? [], done: 0, repo: s.repo ?? repoKey(s.cwd),
92
+ tree: null, children: [], _p: s.parent
93
+ }];
94
+ }));
95
+ for (const n of nodes.values()) {
96
+ const parent = n._p && liveKeys.has(n._p) ? nodes.get(n._p) : null;
97
+ if (parent) parent.children.push(n);
98
+ }
99
+ const sortedChildren = n => { n.children.sort((a, b) => a.startedAt - b.startedAt); n.children.forEach(sortedChildren); };
100
+
101
+ // Parent totals: the node plus all descendants of the same provider (separate accounts are never added), each once.
102
+ // Shares come from the collector's shareTree, which also covers ended children.
103
+ const treeSessions = [...named.values()];
104
+ const full = new Map(treeSessions.map(s => [key(s), s]));
105
+ const kids = new Map();
106
+ for (const s of treeSessions) if (s.parent) kids.set(s.parent, [...(kids.get(s.parent) ?? []), s]);
107
+ for (const n of nodes.values()) {
108
+ if (!kids.has(n.key)) continue;
109
+ const t = { input: 0, cacheRead: 0, cacheWrite: registry.find(a => a.id === n.provider)?.cacheWrite === false ? null : 0, output: 0 };
110
+ // Same walk as the collector's shareTree: every child session, ended ones too, each once.
111
+ const costRecords = [];
112
+ const seen = new Set(), walk = c => {
113
+ costRecords.push(...recordsOf(c));
114
+ seen.add(key(c));
115
+ if (c.provider === n.provider) for (const k of Object.keys(t)) if (t[k] != null && c.tokens?.[k] != null) t[k] += c.tokens[k];
116
+ for (const k of kids.get(key(c)) ?? []) if (!seen.has(key(k))) walk(k);
117
+ };
118
+ walk(full.get(n.key) ?? n);
119
+ const tree = full.get(n.key)?.shareTree?.[n.provider] ?? {};
120
+ n.tree = { cost: summarize(costRecords, catalog), costByDay: byDay(costRecords, catalog), ...(n.tokens ? { tokens: t } : {}), share: Object.fromEntries(windowsOf(registry, n.provider).map(({ id: w }) => [w, tree[w]?.total ?? null])) };
121
+ }
122
+
123
+ // A repo group is its roots' repo and counts the whole tree under each root, so a group never has a count without rows.
124
+ const byRepo = new Map();
125
+ const count = n => n.children.reduce((s, c) => s + count(c), 1);
126
+ const newest = n => Math.max(n.lastActivityAt, ...n.children.map(newest));
127
+ for (const n of nodes.values()) {
128
+ if (n._p && liveKeys.has(n._p)) continue;
129
+ if (!byRepo.has(n.repo)) byRepo.set(n.repo, { key: n.repo, name: n.repo.replace(/-[0-9a-f]{4}$/, ''), root: repoRoot(n.cwd), roots: [], active: 0, lastAt: 0 });
130
+ const r = byRepo.get(n.repo);
131
+ r.active += count(n); r.lastAt = Math.max(r.lastAt, newest(n));
132
+ r.roots.push(n);
133
+ }
134
+ const repos = [...byRepo.values()].sort((a, b) => b.lastAt - a.lastAt);
135
+ for (const r of repos) { r.roots.sort((a, b) => b.lastActivityAt - a.lastActivityAt); r.roots.forEach(sortedChildren); }
136
+
137
+ const quotaProviders = treeKey ? registry.map(a => a.id).filter(p => live.some(s => s.provider === p)) : quotaProvidersOf(state, registry);
138
+ const quota = Object.fromEntries(registry.map(a => [a.id, quotaOf(state.accounts[a.id], now, quotaProviders.includes(a.id) ? (all.some(s => s.provider === a.id && s.billing !== 'api') || state.accounts[a.id]?.billing === 'subscription') ? a.windows ?? [] : [] : [])]));
139
+ const attention = [];
140
+ for (const n of nodes.values()) {
141
+ if (n.orphan) attention.push({ type: 'orphan', key: n.key, repo: n.repo, provider: n.provider, model: n.model });
142
+ if (n.context.warn) attention.push({ type: 'context', key: n.key, repo: n.repo, provider: n.provider, model: n.model, tokens: n.context.tokens, limit: n.context.limit });
143
+ }
144
+ for (const p of quotaProviders) for (const w of Object.keys(quota[p].windows)) if (quota[p].windows[w].low) attention.push({ type: 'quota', provider: p, window: w, percent: quota[p].windows[w].percent });
145
+
146
+ const ended = all.filter(s => s.state === 'ended' && !liveKeys.has(key(s))).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
147
+ const recent = { total: ended.length, items: ended.slice(0, RECENT_MAX).map(s => ({ key: key(s), provider: s.provider, model: s.model ?? null, kind: s.kind, turn: s.turn ?? null, label: s.kind === 'sub' ? (s.agentType ?? s.name ?? null) : null, endedAt: s.lastActivityAt })) };
148
+
149
+ // History for the web: every ended session in scope, with what the table and the detail drawer show.
150
+ const history = ended.slice(0, HISTORY_MAX).map(s => ({
151
+ ...money(s), key: key(s), provider: s.provider, id: s.id, billing: s.billing ?? 'unknown', model: s.model ?? null, effort: s.effort ?? null, kind: s.kind, turn: s.turn ?? null, agentType: s.agentType ?? null, name: s.name ?? null,
152
+ cwd: s.cwd ?? null, repo: s.repo ?? repoKey(s.cwd), rootRepo: rootSession(s).repo ?? repoKey(rootSession(s).cwd), rootPath: repoRoot(rootSession(s).cwd), parent: s.parent ?? null, startedAt: s.startedAt, endedAt: s.lastActivityAt, resume: s.resume ?? 0,
153
+ link: s.link ? { kind: s.link.kind, label: s.link.label, evidence: s.link.evidence ?? null } : null, ...(s.tokens ? { tokens: { ...s.tokens } } : {}), share: shareOf(s, registry), runs: s.runs ?? []
154
+ }));
155
+ // Count each visible History row for every live ancestor, matching the subtree filter.
156
+ for (const s of history) {
157
+ const seen = new Set([s.key]);
158
+ for (let p = s.parent; p && !seen.has(p); p = named.get(p)?.parent) {
159
+ seen.add(p);
160
+ if (nodes.has(p)) nodes.get(p).done++;
161
+ }
162
+ }
163
+
164
+ // Links the data could not decide, as short descriptions of the sessions involved (only those in scope).
165
+ const who = k => { const s = named.get(k); return { key: k, label: s ? `${s.provider} ${s.model ?? '?'}${s.name ? ' \u00b7 ' + s.name : ''}` : k }; };
166
+ const ambiguous = (state.ambiguous ?? []).filter(a => a.sessions.some(k => named.has(k) && inScope(named.get(k)))).map(a => ({ sessions: a.sessions.map(who), parents: a.parents.map(who) }));
167
+ if (ambiguous.length) attention.push({ type: 'links', count: ambiguous.length });
168
+
169
+ const strip = n => { delete n._p; n.children.forEach(strip); };
170
+ repos.forEach(r => r.roots.forEach(strip));
171
+ const costRecords = (treeKey ? live : all).flatMap(recordsOf);
172
+ const result = { cost: summarize(costRecords, catalog), costByDay: byDay(costRecords, catalog), mode, repo, now, providers: providersOf(state, registry), quotaProviders, quota, outside: Object.fromEntries(quotaProviders.map(p => [p, state.outside?.[p]])), repos, attention, recent, history, ambiguous, warnings: state.warnings ?? [] };
173
+ return result;
174
+ }
175
+
176
+ // The whole tree containing `key`, or null when the key is unknown or no root is found.
177
+ function treeOf(state, k, opts) {
178
+ if (![...state.history, ...state.sessions].some(s => key(s) === k)) return null;
179
+ const view = present(state, { ...opts, treeKey: k });
180
+ const root = view.repos[0]?.roots[0];
181
+ return root ? { key: k, root, cost: view.cost, costByDay: view.costByDay, quota: Object.fromEntries(view.quotaProviders.map(p => [p, view.quota[p]])) } : null;
182
+ }
183
+
184
+ module.exports = { present, providersOf, quotaProvidersOf, treeOf };
@@ -0,0 +1,22 @@
1
+ // Process sources (Windows). Both readers take their input as a parameter so tests can inject sample data.
2
+ const agents = require('./agents/index.js');
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ // Keep the whole process tree, but capture command lines only for registered agent executables.
6
+ const script = registry => `Get-CimInstance Win32_Process |
7
+ Select-Object ProcessId, ParentProcessId, Name, @{n='CommandLine';e={ if ($_.Name -in ${registry.flatMap(a => a.processNames).map(n => `'${n.replace(/'/g, "''")}'`).join(',')}) { $_.CommandLine } }}, @{n='Start';e={$_.CreationDate.ToUniversalTime().ToString('o')}} |
8
+ ConvertTo-Json -Compress`;
9
+
10
+ // -> [{ pid, ppid, name, cmd, startMs }]; [] when the table cannot be read (the caller then sees every process as gone).
11
+ function listProcesses(registry = agents) {
12
+ let out;
13
+ try { out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script(registry)], { encoding: 'utf8', timeout: 15000, windowsHide: true }); }
14
+ catch { return []; }
15
+ if (!out.trim()) return [];
16
+ let rows;
17
+ try { rows = JSON.parse(out); } catch { return []; }
18
+ return [].concat(rows).map(r => ({ pid: r.ProcessId, ppid: r.ParentProcessId, name: String(r.Name ?? '').toLowerCase(),
19
+ cmd: r.CommandLine ?? '', startMs: Date.parse(r.Start) })).filter(p => Number.isSafeInteger(p.pid));
20
+ }
21
+
22
+ module.exports = { listProcesses };
@@ -0,0 +1,34 @@
1
+ // Account sources are supplied by the agent registry.
2
+ const agents = require('./agents/index.js');
3
+ function orderedSources(sources) {
4
+ return {
5
+ async refresh(now, sessions) {
6
+ for (const q of sources) {
7
+ await q.refresh?.(now);
8
+ if (q.read(sessions, now)?.windows?.length) break;
9
+ }
10
+ },
11
+ read(sessions, now) {
12
+ let result;
13
+ for (const q of sources) { result = q.read(sessions, now); if (result?.windows?.length) return result; }
14
+ return result;
15
+ }
16
+ };
17
+ }
18
+ function createQuota(options = {}, registry = agents) {
19
+ const sources = registry.map(a => [a, a.createQuota?.(options)]);
20
+ return {
21
+ refresh: (now, providers = [], sessions = []) => Promise.all(sources.filter(([a]) => providers.includes(a.id)).map(([, q]) => q?.refresh?.(now, sessions))),
22
+ read: (sessions, now) => Object.fromEntries(sources.map(([a, q]) => {
23
+ const quota = q?.read(sessions, now);
24
+ const windows = Object.fromEntries((quota?.windows ?? []).map(w => {
25
+ const reset = w.reset || (w.resetsAt != null && w.resetsAt <= now);
26
+ return [w.id, { percent: reset ? null : w.usedPercent, resetsAt: w.resetsAt,
27
+ at: w.readAt ?? quota.readAt, readAt: w.readAt ?? quota.readAt, source: w.source ?? quota.source,
28
+ reset: !!reset, ...(reset || w.usedPercent == null ? { stale: true } : {}) }];
29
+ }));
30
+ return [a.id, { provider: a.id, billing: a.readBilling?.(options) ?? 'unknown', ...(quota ? { quota } : {}), windows, error: quota?.error ?? null }];
31
+ }))
32
+ };
33
+ }
34
+ module.exports = { createQuota, orderedSources };
@@ -0,0 +1,44 @@
1
+ // Repo key of a cwd: the git root (a worktree maps to its main repo), else the cwd itself, as `<basename>-<4 hex of the path hash>`.
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const crypto = require('node:crypto');
5
+
6
+ const norm = p => path.resolve(p).replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
7
+
8
+ function gitRoot(cwd) {
9
+ let dir = path.resolve(cwd);
10
+ for (;;) {
11
+ let st = null;
12
+ try { st = fs.statSync(path.join(dir, '.git')); } catch { /* no .git here */ }
13
+ if (st?.isDirectory()) return dir;
14
+ if (st?.isFile()) {
15
+ // A linked worktree has a `.git` file: "gitdir: <main>/.git/worktrees/<name>".
16
+ let gitdir = null;
17
+ try { gitdir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(dir, '.git'), 'utf8'))?.[1].trim(); } catch { /* unreadable: treat as its own repo */ }
18
+ if (gitdir) {
19
+ const g = path.resolve(dir, gitdir);
20
+ if (path.basename(path.dirname(g)) === 'worktrees') return path.dirname(path.dirname(path.dirname(g)));
21
+ }
22
+ return dir; // submodule or unreadable file
23
+ }
24
+ const up = path.dirname(dir);
25
+ if (up === dir) return null;
26
+ dir = up;
27
+ }
28
+ }
29
+
30
+ const cache = new Map();
31
+ // shortcut: a worktree whose folder is already deleted has no .git to follow, so it keys as a plain folder.
32
+ function repoInfo(cwd) {
33
+ if (typeof cwd !== 'string' || !cwd) return { key: '_unknown', root: null };
34
+ if (cache.has(cwd)) return cache.get(cwd);
35
+ const root = gitRoot(cwd) ?? path.resolve(cwd);
36
+ const base = (path.basename(root) || 'root').toLowerCase().replace(/[^\w.-]/g, '_');
37
+ const info = { key: `${base}-${crypto.createHash('sha1').update(norm(root)).digest('hex').slice(0, 4)}`, root };
38
+ cache.set(cwd, info);
39
+ return info;
40
+ }
41
+ const repoKey = cwd => repoInfo(cwd).key;
42
+ const repoRoot = cwd => repoInfo(cwd).root;
43
+
44
+ module.exports = { repoKey, repoRoot };
@@ -0,0 +1,106 @@
1
+ // A session's share of its account windows: token x catalog weight / window capacity (cost.js),
2
+ // counting only usage inside the window. Shares are approximate (the catalog weights are fitted, not published).
3
+ const { windowIds } = require('./catalog.js');
4
+ const { cost } = require('./cost.js');
5
+
6
+ const DAY_MS = 86400e3;
7
+ const key = s => `${s.provider}:${s.id}`;
8
+ const zero = types => Object.fromEntries([...types.map(t => [t, null]), ['total', 0]]);
9
+
10
+ // Current window of the account reading: [reset - length, end]; with no live reading, the latest rolling span.
11
+ function span(reading, len, now, end = now) {
12
+ // A reset inside the latest window length means the current window began at that reset.
13
+ const start = reading?.resetsAt > now ? reading.resetsAt - len : Math.max(now - len, reading?.resetsAt ?? 0);
14
+ return [start, end];
15
+ }
16
+
17
+ // -> { share: { windowId: token parts and total | null }, unpriced: [model ids] }
18
+ // A null window means some usage in it has no price (unknown model), so no honest number exists.
19
+ // shortcut: usage is priced per (model, UTC day) with the weights valid at noon of that day, not per turn.
20
+ function sessionShare(catalog, s, spans) {
21
+ const share = {}, unpriced = new Set(), TOKEN_TYPES = catalog.tokenTypes;
22
+ for (const [w, [start, end]] of Object.entries(spans)) {
23
+ const buckets = new Map();
24
+ for (const e of s.usage ?? []) {
25
+ if (e.at < start || e.at > end) continue;
26
+ const model = e.model ?? s.model, k = `${model}|${Math.floor(e.at / DAY_MS)}`;
27
+ const b = buckets.get(k) ?? buckets.set(k, { model, at: Math.floor(e.at / DAY_MS) * DAY_MS + DAY_MS / 2, tokens: Object.fromEntries(TOKEN_TYPES.map(t => [t, t === 'cacheWrite' ? null : 0])) }).get(k);
28
+ for (const t of TOKEN_TYPES) if (e.tokens?.[t] != null) b.tokens[t] = (b.tokens[t] ?? 0) + e.tokens[t];
29
+ }
30
+ let sum = zero(TOKEN_TYPES), ok = true;
31
+ for (const b of buckets.values()) {
32
+ let c = null;
33
+ try { c = cost(catalog, b.model, b.tokens, b.at); } catch { /* model not in the catalog */ }
34
+ if (!c || c.units == null || !c.parts[w]) { ok = false; unpriced.add(b.model); continue; }
35
+ for (const t of TOKEN_TYPES) if (c.parts[w][t] != null) sum[t] = (sum[t] ?? 0) + c.parts[w][t];
36
+ sum.total += c.percent[w];
37
+ }
38
+ share[w] = ok ? sum : null;
39
+ }
40
+ return { share, unpriced: [...unpriced] };
41
+ }
42
+
43
+ function add(a, b, types) {
44
+ if (!a || !b) return null;
45
+ const out = {};
46
+ for (const t of [...types, 'total']) out[t] = a[t] == null && b[t] == null ? null : (a[t] ?? 0) + (b[t] ?? 0);
47
+ return out;
48
+ }
49
+
50
+ // -> { sessions (with share and shareTree), outside, warnings }
51
+ // shareTree[provider][window]: the session plus all its descendants of that provider; each session counts once.
52
+ // Different providers are different accounts, so their percentages are never added together.
53
+ // outside[provider][window]: the account's rise in the current window minus the sessions' sum up to the reading, never negative.
54
+ function computeShares({ catalog, sessions, history = [], accounts, now = Date.now() }) {
55
+ const warnings = new Set();
56
+ const windowSpan = (p, w, reading, end = now) => span(reading, catalog.agents[p].windows.find(x => x.id === w).durationMs, now, end);
57
+ const byKey = new Map(), kids = new Map();
58
+ const out = sessions.map(s => {
59
+ const spans = Object.fromEntries(windowIds(catalog, s.provider).map(w => [w, windowSpan(s.provider, w, accounts[s.provider]?.windows?.[w])]));
60
+ const r = sessionShare(catalog, s, spans);
61
+ for (const m of r.unpriced) warnings.add(`model ${m} has no price in the catalog: its share is unknown`);
62
+ const o = { ...s, share: r.share };
63
+ byKey.set(key(o), o);
64
+ return o;
65
+ });
66
+ for (const s of out) if (s.parent && byKey.has(s.parent)) kids.set(s.parent, [...(kids.get(s.parent) ?? []), s]);
67
+
68
+ function tree(s, seen) {
69
+ const t = { [s.provider]: { ...s.share } };
70
+ seen.add(key(s));
71
+ for (const c of kids.get(key(s)) ?? []) {
72
+ if (seen.has(key(c))) continue;
73
+ for (const [p, ws] of Object.entries(tree(c, seen))) {
74
+ t[p] = t[p] ? Object.fromEntries(windowIds(catalog, p).map(w => [w, add(t[p][w], ws[w], catalog.tokenTypes)])) : ws;
75
+ }
76
+ }
77
+ return t;
78
+ }
79
+ for (const s of out) s.shareTree = tree(s, new Set());
80
+
81
+ const outside = {};
82
+ for (const p of Object.keys(accounts)) {
83
+ outside[p] = {};
84
+ for (const w of windowIds(catalog, p)) {
85
+ const reading = accounts[p]?.windows?.[w];
86
+ outside[p][w] = null;
87
+ if (!reading || reading.percent == null || !(reading.resetsAt > now) || !Number.isFinite(reading.at)) continue;
88
+ const end = Math.min(reading.at, now);
89
+ let sum = 0, known = true;
90
+ for (const s of sessions.filter(x => x.provider === p)) {
91
+ const sh = sessionShare(catalog, s, { [w]: windowSpan(p, w, reading, end) }).share[w];
92
+ if (!sh) { known = false; break; }
93
+ sum += sh.total;
94
+ }
95
+ // Sessions known only from their summaries count when saved inside this window (their share was for it).
96
+ for (const h of history.filter(x => x.provider === p)) {
97
+ const sh = h.share?.[w];
98
+ if (h.writtenAt >= windowSpan(p, w, reading, end)[0]) { if (sh) sum += sh.total; else known = false; }
99
+ }
100
+ if (known) outside[p][w] = Math.max(0, reading.percent - sum);
101
+ }
102
+ }
103
+ return { sessions: out, outside, warnings: [...warnings] };
104
+ }
105
+
106
+ module.exports = { computeShares, span };
@@ -0,0 +1,62 @@
1
+ // Session lifecycle tracking shared by all agents.
2
+ const agents = require('./agents/index.js');
3
+ const START_SLACK_MS = 60000;
4
+ function claim(candidates, session, claimed, owners = new Map()) {
5
+ const mine = `${session.provider}:${session.id}`;
6
+ let best = null;
7
+ for (const p of candidates) {
8
+ if (claimed.has(p.pid) || (owners.has(p.pid) && owners.get(p.pid) !== mine)) continue;
9
+ const d = Math.abs(p.startMs - session.startedAt);
10
+ if (d <= START_SLACK_MS && (!best || d < best.d)) best = { p, d };
11
+ }
12
+ if (best) claimed.add(best.p.pid);
13
+ return best?.p ?? null;
14
+ }
15
+
16
+ // Remembers each session's runs between refreshes: a run is one process (pid) or one hosted stay in the app-server.
17
+ // A new run on an ended session is a resume. In memory only; the log (step 5) persists it.
18
+ function createTracker() {
19
+ const runs = new Map();
20
+ return {
21
+ // pid -> "provider:id" for every run still open.
22
+ owners() {
23
+ const m = new Map();
24
+ for (const [k, list] of runs) if (list.at(-1)?.end === null && list.at(-1).pid != null) m.set(list.at(-1).pid, k);
25
+ return m;
26
+ },
27
+ // Gives back runs saved by an earlier monitor (an open run continues if its process is still the one running).
28
+ restore(k, saved) {
29
+ if (runs.has(k) || !Array.isArray(saved)) return;
30
+ const ok = r => Number.isFinite(r?.start) && (r.end === null || Number.isFinite(r.end));
31
+ runs.set(k, saved.filter(ok).map(r => ({ key: r.pid ?? 'hosted', pid: r.pid ?? null, start: r.start, end: r.end })));
32
+ },
33
+ observe(session, { state, pid }, procs, now) {
34
+ const list = runs.get(`${session.provider}:${session.id}`) ?? [];
35
+ runs.set(`${session.provider}:${session.id}`, list);
36
+ const open = list.at(-1)?.end === null ? list.at(-1) : null;
37
+ const want = state === 'ended' ? null : pid ?? 'hosted';
38
+ if (open && open.key !== want) open.end = now;
39
+ if (want != null && open?.key !== want) {
40
+ const proc = pid == null ? null : procs.find(p => p.pid === pid);
41
+ list.push({ key: want, pid, start: proc?.startMs ?? now, end: null });
42
+ }
43
+ return list.map(({ pid, start, end }) => ({ pid, start, end }));
44
+ }
45
+ };
46
+ }
47
+
48
+ // -> sessions with state, pid, runs, resume (number of times it came back) and orphan.
49
+ function resolveStates({ sessions, procs, pidFiles, now = Date.now(), tracker = createTracker(), registry = agents }) {
50
+ const live = new Map(registry.flatMap(a => [...a.liveness({ sessions: sessions.filter(s => s.provider === a.id), procs, pidFiles, now, owners: tracker.owners(), claim })]));
51
+ const result = sessions.map(s => {
52
+ const l = live.get(`${s.provider}:${s.id}`);
53
+ const runs = tracker.observe(s, l, procs, now);
54
+ return { ...s, state: l.state, pid: l.pid, runs, resume: Math.max(0, runs.length - 1) };
55
+ });
56
+ const byKey = new Map(result.map(s => [`${s.provider}:${s.id}`, s]));
57
+ // A child still alive under an ended parent is an orphan (it keeps working with nobody waiting for it).
58
+ for (const s of result) s.orphan = s.state !== 'ended' && byKey.get(`${s.provider}:${s.parentId}`)?.state === 'ended';
59
+ return result;
60
+ }
61
+
62
+ module.exports = { resolveStates, createTracker };
@@ -0,0 +1,143 @@
1
+ // Terminal renderer: view model (present.js) -> text frame, per the Terminal section of docs/design.md.
2
+ const { pct, tok, ctx, dur, hhmm, rise } = require('./format.js');
3
+ const { ansi, partAnsi } = require('./colors.js');
4
+
5
+ const C = { green: 32, yellow: 33, red: 31, dim: 90, bold: 1 };
6
+ const NAMES = require('./colors.js').NAMES;
7
+ const SYMBOL = { running: ['\u25cf', C.green], waiting: ['\u25d0', C.yellow] };
8
+ const PARTS = [['I', 'input', 'i'], ['CR', 'cacheRead', 'cr'], ['CW', 'cacheWrite', 'cw'], ['O', 'output', 'o']];
9
+
10
+ // Transcript text must not inject escape codes.
11
+ const clean = v => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, ' ');
12
+ const short = m => m ? require('./colors.js').short(clean(m)) : '?';
13
+ const color = (text, code, on) => on && code ? `\x1b[${code}m${text}\x1b[0m` : text;
14
+ // Widths and cuts count visible code points (all frame characters are single-width); escape codes are skipped.
15
+ // A cut keeps the colours before it and closes them with a reset.
16
+ const ANSI = /\x1b\[[\d;]*m/g;
17
+ const vlen = text => [...text.replace(ANSI, '')].length;
18
+ function cut(text, cols) {
19
+ if (vlen(text) <= cols) return text;
20
+ let seen = 0, out = '';
21
+ for (const part of text.split(/(\x1b\[[\d;]*m)/)) { // odd parts are escape codes
22
+ if (part.startsWith('\x1b')) { out += part; continue; }
23
+ const room = Math.max(0, cols - 1 - seen), cps = [...part];
24
+ out += cps.slice(0, room).join(''); seen += Math.min(room, cps.length);
25
+ if (cps.length > room) break;
26
+ }
27
+ return out + '\u2026' + (out.includes('\x1b') ? '\x1b[0m' : '');
28
+ }
29
+ const right = (left, tail, cols) => { const gap = cols - vlen(left) - vlen(tail); return gap >= 2 ? left + ' '.repeat(gap) + tail : left + ' ' + tail; };
30
+
31
+ function bar(percent, on) {
32
+ const filled = percent == null ? 0 : Math.max(0, Math.min(10, Math.round(percent / 10)));
33
+ const code = percent == null ? C.dim : percent >= 85 ? C.red : percent >= 60 ? C.yellow : C.green;
34
+ return '[' + color('\u2501'.repeat(filled), code, on) + color('\u2500'.repeat(10 - filled), C.dim, on) + ']';
35
+ }
36
+
37
+ function quotaLines(view, on) {
38
+ const lines = [];
39
+ const providers = (view.quotaProviders ?? Object.keys(view.quota)).filter(p => Object.keys(view.quota[p].windows).length);
40
+ if (!providers.length) return lines;
41
+ providers.forEach((p, i) => {
42
+ const cells = Object.keys(view.quota[p].windows).map(w => {
43
+ const x = view.quota[p].windows[w];
44
+ return `${x.label ?? w} ${bar(x.percent, on)} ${x.percent == null ? '\u2014' : pct(x.percent) + '%'}${x.reset ? ' reset since read' : ''}${x.source ? ` (${x.source}, ${x.readAt == null ? '?' : dur(Math.max(0, view.now - x.readAt))})` : ''}${x.low ? ' \u26a0' : ''}${x.resetInMs != null ? ' reset ' + dur(x.resetInMs) : ''}`;
45
+ });
46
+ lines.push(`${i ? ' ' : 'QUOTA'} ${color(NAMES[p] ?? p, ansi('provider', p), on)}${' '.repeat(Math.max(0, 6 - (NAMES[p] ?? p).length))} ${cells.join(' ')}`);
47
+ });
48
+ lines.push(' outside monitor: ' + providers.map(p => `${color(NAMES[p] ?? p, ansi('provider', p), on)} ${Object.keys(view.quota[p].windows).slice(0, 1).map(w => `${view.quota[p].windows[w].label ?? w} ${rise(view.outside?.[p]?.[w])}%`).join(' ')}`).join(' '));
49
+ return lines;
50
+ }
51
+
52
+ // Token lines are built plain (to measure and cut) and coloured like the web: label and value in the I/CR/CW/O legend
53
+ // colour, window names dim, totals bold. Padding sits outside the colour so a trimmed line has no stray spaces.
54
+ const cell = (label, v, key, on) => color(`${label} ${v}`, partAnsi(key), on) + ' '.repeat(Math.max(0, 3 - String(v).length));
55
+ const total = (w, v, on) => color(w, C.dim, on) + '\u2248' + color(v == null ? '\u2014' : pct(v) + '%', C.bold, on);
56
+ const shareText = (s, w, on) => s ? `${total(w, s.total, on)} [${PARTS.map(([l, k, c]) => cell(l, pct(s[k]), c, on)).join(' ').trimEnd()}]` : `${total(w, null, on)}`;
57
+ const tokenText = (t, on) => t ? PARTS.map(([l, k, c]) => cell(l, tok(t[k]), c, on)).join(' ').trimEnd() : '';
58
+ const moneyText = n => n.cost ? `${n.costKind === 'api' ? 'API' : 'API equivalent'} ${n.cost.costUSD == null ? '?' : '$' + n.cost.costUSD.toFixed(4)} (${n.cost.sources.join('+')})${n.cost.unknownRecords ? ` + ${n.cost.unknownRecords} unknown records/${n.cost.unknownModels.length} models` : ''}${n.cost.uncertainRecords ? ' (uncertain)' : ''}` : '';
59
+ const quotaDetail = (n, mode, on) => mode === 'token' ? tokenText(n.tokens, on) : Object.keys(n.share).map(w => shareText(n.share[w], w, on)).join(' ');
60
+ const detail = (n, mode, on) => [moneyText(n), n.showQuota === false && mode !== 'token' ? '' : quotaDetail(n, mode, on).trimEnd()].filter(Boolean).join(' ');
61
+ const groupText = (n, mode, on) => mode === 'token' ? tokenText(n.tree.tokens, on) : Object.keys(n.tree.share).map(w => total(w, n.tree.share[w], on)).join(' ');
62
+ const fit = (prefix, make, cols, on) => cut(prefix + make(on), cols);
63
+
64
+ // One session = 2 lines (3 for a parent: group total). A block is droppable when waiting with nothing running below it.
65
+ function nodeBlocks(n, view, cols, on, depth, last, trail, out) {
66
+ const [sym, code] = SYMBOL[n.state] ?? ['?', 0];
67
+ const branch = depth ? trail + (last ? ' \u2514 ' : ' \u251c ') : '';
68
+ const pad = depth ? trail + (last ? ' ' : ' \u2502 ') : '';
69
+ const age = n.state === 'waiting' ? `waiting ${dur(view.now - n.lastActivityAt)}` : dur(view.now - n.startedAt);
70
+ const head = `${n.provider} ${short(n.model)}${n.effort ? ' ' + clean(n.effort) : ''} \u00b7 ${n.kind} \u00b7 ${age}${n.orphan ? ' \u00b7 orphan' : ''}${n.resume ? ` \u00b7 resume ${n.resume}` : ''}`;
71
+ const c = n.context, limit = '/' + ctx(c.limit);
72
+ const tail = `ctx ${ctx(c.tokens)}${c.limit ? limit : '/\u2014'}`;
73
+ const title = n.name ? ` \u00b7 ${clean(n.name)}` : '';
74
+ const first = right(cut(branch + sym + ' ' + head + title, Math.max(10, cols - [...tail].length - 2)), tail, cols);
75
+ // Provider, model (by tier) and effort carry their colours; the ctx tail is dim, red past the warning mark.
76
+ const effort = n.effort ? ' ' + clean(n.effort) : '';
77
+ const who = `${n.provider} ${short(n.model)}${effort}`;
78
+ const paint = () => `${color(n.provider, ansi('provider', n.provider), on)} ${color(short(n.model), ansi('model', n.model), on)}${effort && ' ' + color(effort.slice(1), ansi('effort', n.effort, n.provider), on)}`;
79
+ const body = first.slice(0, first.length - tail.length) + color(tail, c.warn ? C.red : C.dim, on);
80
+ const lines = [body.replace(sym, color(sym, code, on)).replace(who, paint)];
81
+ lines.push(fit(`${pad} `, o => detail(n, view.mode, o), cols, on));
82
+ if (n.tree) lines.push(fit(`${pad} `, o => color('group:', C.dim, o) + ' ' + [n.showQuota === false ? '' : groupText(n, view.mode, o), moneyText({ ...n, cost: n.tree.cost })].filter(Boolean).join(' '), cols, on));
83
+ const runningBelow = m => m.state === 'running' || m.children.some(runningBelow);
84
+ out.push({ lines, droppable: n.state === 'waiting' && !runningBelow(n) });
85
+ n.children.forEach((k, i) => nodeBlocks(k, view, cols, on, depth + 1, i === n.children.length - 1, pad, out));
86
+ }
87
+
88
+ const whoText = (provider, model, on) => `${color(provider, ansi('provider', provider), on)} ${color(short(model), ansi('model', model), on)}`;
89
+ function attentionText(a, on) {
90
+ const who = a.provider ? whoText(a.provider, a.model, on) : '';
91
+ if (a.type === 'orphan') return `\u26a0 ${who} orphan`;
92
+ if (a.type === 'links') return `\u26a0 ${a.count} unclear links`;
93
+ if (a.type === 'context') return `\u26a0 ${who} ctx ${ctx(a.tokens)}/${ctx(a.limit)}`;
94
+ return `\u26a0 ${color(NAMES[a.provider], ansi('provider', a.provider), on)} ${a.window} quota left ${pct(100 - a.percent)}%`;
95
+ }
96
+
97
+ // Wrap items into lines of at most `cols` characters.
98
+ function wrap(prefix, items, cols) {
99
+ const lines = []; let line = prefix;
100
+ for (const it of items) {
101
+ const next = line === prefix ? line + it : line + ' ' + it;
102
+ if (vlen(next) > cols && line !== prefix) { lines.push(line); line = prefix + it; } else line = next;
103
+ }
104
+ lines.push(line);
105
+ return lines.map(l => cut(l, cols));
106
+ }
107
+
108
+ function recentLine(view, cols, on) {
109
+ const r = view.recent;
110
+ if (!r.total) return [];
111
+ const items = r.items.map(i => `${i.turn === 'aborted' ? '\u2298' : '\u2713'} ${whoText(i.provider, i.model, on)}${i.label ? ` (${cut(clean(i.label), 24)})` : ''}${i.turn === 'aborted' ? ' \u00b7 interrupted' : ''} ${hhmm(i.endedAt)}`);
112
+ const build = k => 'RECENT ' + items.slice(0, k).join(' \u00b7 ') + (r.total > k ? ` (+${r.total - k})` : '');
113
+ let shown = items.length;
114
+ while (shown > 0 && vlen(build(shown)) > cols) shown--;
115
+ return [cut(build(shown), cols)];
116
+ }
117
+
118
+ // opts: { cols, rows (Infinity for none), color }. Trimming order when rows are short: RECENT, then waiting sessions
119
+ // (last first), keeping running sessions and ATTENTION; if that is still not enough the session area is cut from the bottom.
120
+ function renderFrame(view, { cols = 120, rows = Infinity, color: on = false } = {}) {
121
+ const top = quotaLines(view, on).map(l => cut(l, cols));
122
+ const attention = view.attention.length ? ['ATTENTION', ...wrap(' ', view.attention.map(a => attentionText(a, on)), cols)] : [];
123
+ const blocks = [];
124
+ for (const r of view.repos) {
125
+ blocks.push({ lines: [cut(`${clean(r.name)} (${r.active} active)`, cols)], droppable: false });
126
+ r.roots.forEach(n => nodeBlocks(n, view, cols, on, 0, true, '', blocks));
127
+ }
128
+ if (!view.repos.length) blocks.push({ lines: [cut(`no active sessions${view.repo ? ' in this repo' : ''}`, cols)], droppable: false });
129
+ let recent = recentLine(view, cols, on);
130
+ const count = () => top.length + blocks.reduce((s, b) => s + b.lines.length, 0) + attention.length + recent.length;
131
+ if (count() > rows) recent = [];
132
+ let hidden = 0;
133
+ for (let i = blocks.length - 1; i >= 0 && count() + (hidden ? 1 : 0) > rows; i--) {
134
+ if (blocks[i].droppable) { blocks.splice(i, 1); hidden++; }
135
+ }
136
+ const body = blocks.flatMap(b => b.lines);
137
+ if (hidden) body.push(cut(`\u2026 ${hidden} waiting sessions hidden (--once prints all)`, cols));
138
+ const room = rows - top.length - attention.length - recent.length;
139
+ if (body.length > room) { body.length = Math.max(0, room - 1); body.push('\u2026'); }
140
+ return [...top, ...body, ...attention, ...recent].join('\n');
141
+ }
142
+
143
+ module.exports = { renderFrame };
@@ -0,0 +1,40 @@
1
+ // Turn and tool-call timeline of one transcript, read on demand for the detail drawer (never copied into the log).
2
+ const { scan } = require('./jsonl.js');
3
+
4
+ const MAX_ENTRIES = 300;
5
+ const MAX_TEXT = 240;
6
+ const clip = v => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
7
+ // Injected context ("<...>" tags, the AGENTS.md preamble) is not what the user typed.
8
+ const typed = t => typeof t === 'string' && t.trim() && !/^\s*(<|# AGENTS\.md)/.test(t);
9
+ // Tool results and outputs are the huge lines and add nothing to the timeline: skip them before parsing.
10
+ const want = l => !/"type":"tool_result"|"function_call_output"|"custom_tool_call_output"/.test(l);
11
+
12
+ function toolText(name, input) {
13
+ let v = input;
14
+ if (typeof v === 'string') { try { v = JSON.parse(v); } catch { return `${name} ${clip(v)}`; } }
15
+ const arg = v?.command ?? v?.cmd ?? v?.file_path ?? v?.path ?? v?.description ?? v?.pattern ?? v?.prompt ?? (v && typeof v === 'object' ? JSON.stringify(v) : v);
16
+ return `${name} ${clip(Array.isArray(arg) ? arg.join(' ') : arg)}`;
17
+ }
18
+
19
+ const n = v => Number.isSafeInteger(v) && v >= 0 ? v : 0;
20
+
21
+ // -> [{ at, kind: 'user'|'assistant'|'tool'|'turn', text, tokens? }] (last MAX_ENTRIES), plus the count of malformed lines.
22
+ // tokens ({ input, cacheRead, cacheWrite, output }) sit on the last entry of a model message or on the entry before a token_count.
23
+ function read(file, visit) {
24
+ const out = [];
25
+ const add = (r, kind, text) => { if (text) out.push({ at: Date.parse(r.timestamp) || null, kind, text }); };
26
+ const lastOf = new Map(), seen = new Set(); // message id -> its last entry (streaming repeats a message with growing output)
27
+ const bad = scan(file, want, r => {
28
+ visit(r, { out, add, lastOf, seen });
29
+ });
30
+ return { entries: out.slice(-MAX_ENTRIES), total: out.length, bad };
31
+ }
32
+
33
+ function readTimeline(file, provider) {
34
+ const registry = require('./agents/index.js');
35
+ if (provider) return registry.find(a => a.id === provider)?.readTimeline?.(file) ?? { entries: [], total: 0, bad: 0 };
36
+ // Standalone callers can omit the provider; each reader recognizes only its own records.
37
+ const results = registry.flatMap(a => a.readTimeline ? [a.readTimeline(file)] : []);
38
+ return results.find(r => r.total) ?? results[0] ?? { entries: [], total: 0, bad: 0 };
39
+ }
40
+ module.exports = { readTimeline, read, clip, typed, toolText, n };