bridge-agent 0.2.11 → 0.2.13

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.
@@ -0,0 +1,11 @@
1
+ export interface SystemMetrics {
2
+ cpu: number;
3
+ ramUsedMb: number;
4
+ ramTotalMb: number;
5
+ ramCachedMb: number;
6
+ battery?: {
7
+ percent: number;
8
+ charging: boolean;
9
+ };
10
+ }
11
+ export declare function startMetricsRelay(sendFn: (metrics: SystemMetrics) => void): () => void;
@@ -0,0 +1,123 @@
1
+ import os from 'os';
2
+ import fs from 'fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ function cpuSnapshot() {
5
+ let idle = 0, total = 0;
6
+ for (const cpu of os.cpus()) {
7
+ idle += cpu.times.idle;
8
+ total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + (cpu.times.irq ?? 0);
9
+ }
10
+ return { idle, total };
11
+ }
12
+ let lastSnapshot = cpuSnapshot();
13
+ function cpuPercent() {
14
+ const now = cpuSnapshot();
15
+ const dIdle = now.idle - lastSnapshot.idle;
16
+ const dTotal = now.total - lastSnapshot.total;
17
+ lastSnapshot = now;
18
+ if (dTotal === 0)
19
+ return 0;
20
+ return Math.round((1 - dIdle / dTotal) * 100);
21
+ }
22
+ // ── RAM ─────────────────────────────────────────────────────────────────────
23
+ function ramStatsMacos() {
24
+ const total = os.totalmem();
25
+ const free = os.freemem();
26
+ // Parse vm_stat to get cached pages
27
+ // "Cached" = (Pages speculative + Pages inactive) * page_size
28
+ // These are file-backed pages that can be reclaimed by the system
29
+ let cachedPages = 0;
30
+ try {
31
+ const r = spawnSync('vm_stat', ['-c', '10'], { encoding: 'utf-8', timeout: 2000, stdio: 'pipe' });
32
+ if (r.status === 0 && r.stdout) {
33
+ // vm_stat reports page size at top: "page size of 16384 bytes"
34
+ const pgSizeMatch = r.stdout.match(/page size of (\d+) bytes/);
35
+ const speccMatch = r.stdout.match(/Pages speculative:\s*(\d+)/);
36
+ const inactMatch = r.stdout.match(/Pages inactive:\s*(\d+)/);
37
+ if (speccMatch && inactMatch) {
38
+ const PAGE_SIZE = pgSizeMatch ? parseInt(pgSizeMatch[1], 10) : 4096;
39
+ cachedPages = (parseInt(speccMatch[1], 10) + parseInt(inactMatch[1], 10)) * PAGE_SIZE;
40
+ }
41
+ }
42
+ }
43
+ catch { /* ignore */ }
44
+ return {
45
+ totalMb: Math.round(total / 1024 / 1024),
46
+ usedMb: Math.round((total - free) / 1024 / 1024),
47
+ cachedMb: Math.round(cachedPages / 1024 / 1024),
48
+ };
49
+ }
50
+ function ramStats() {
51
+ if (process.platform === 'darwin')
52
+ return ramStatsMacos();
53
+ // Linux: cached is included in free per os.freemem() docs, so cached ~= 0
54
+ const total = os.totalmem();
55
+ const free = os.freemem();
56
+ return {
57
+ totalMb: Math.round(total / 1024 / 1024),
58
+ usedMb: Math.round((total - free) / 1024 / 1024),
59
+ cachedMb: 0,
60
+ };
61
+ }
62
+ // ── Battery ─────────────────────────────────────────────────────────────────
63
+ function batteryMacos() {
64
+ try {
65
+ const r = spawnSync('pmset', ['-g', 'batt'], { encoding: 'utf-8', timeout: 2000, stdio: 'pipe' });
66
+ if (r.status !== 0 || !r.stdout)
67
+ return undefined;
68
+ const m = r.stdout.match(/(\d+)%;\s*(charging|discharging|charged|finishing charge)/i);
69
+ if (!m)
70
+ return undefined;
71
+ const percent = parseInt(m[1], 10);
72
+ const charging = /charging|charged|finishing/i.test(m[2]);
73
+ return { percent, charging };
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ function batteryLinux() {
80
+ try {
81
+ const base = '/sys/class/power_supply';
82
+ const dirs = fs.readdirSync(base).filter(d => /^BAT/i.test(d));
83
+ if (dirs.length === 0)
84
+ return undefined;
85
+ const bat = `${base}/${dirs[0]}`;
86
+ const percent = parseInt(fs.readFileSync(`${bat}/capacity`, 'utf-8').trim(), 10);
87
+ const status = fs.readFileSync(`${bat}/status`, 'utf-8').trim().toLowerCase();
88
+ const charging = status === 'charging' || status === 'full';
89
+ return { percent, charging };
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
95
+ function battery() {
96
+ const p = process.platform;
97
+ if (p === 'darwin')
98
+ return batteryMacos();
99
+ if (p === 'linux')
100
+ return batteryLinux();
101
+ return undefined;
102
+ }
103
+ // ── Relay ────────────────────────────────────────────────────────────────────
104
+ const METRICS_INTERVAL_MS = 10_000;
105
+ const BATTERY_EVERY_N = 3; // battery checked every 3rd tick (30s)
106
+ export function startMetricsRelay(sendFn) {
107
+ let tick = 0;
108
+ let cachedBattery = battery();
109
+ const timer = setInterval(() => {
110
+ tick++;
111
+ if (tick % BATTERY_EVERY_N === 0)
112
+ cachedBattery = battery();
113
+ const ram = ramStats();
114
+ sendFn({
115
+ cpu: cpuPercent(),
116
+ ramUsedMb: ram.usedMb,
117
+ ramTotalMb: ram.totalMb,
118
+ ramCachedMb: ram.cachedMb,
119
+ battery: cachedBattery,
120
+ });
121
+ }, METRICS_INTERVAL_MS);
122
+ return () => clearInterval(timer);
123
+ }
@@ -0,0 +1,30 @@
1
+ import type { AgentKey, AgentInfo } from '../shared/types.js';
2
+ interface AgentSpec {
3
+ key: AgentKey;
4
+ displayName: string;
5
+ binary: string;
6
+ checkAuth: () => Promise<boolean>;
7
+ /** If true, daemon generates a UUID at spawn and passes --session-id <uuid> to the CLI */
8
+ assignSessionId?: boolean;
9
+ /** Args to always prepend on fresh spawn (before --session-id) */
10
+ spawnArgs?: string[];
11
+ /** Args to prepend when resuming a specific session */
12
+ resumeArgs?: (sessionId: string) => string[];
13
+ /** Whether this agent supports `--mcp-config <path>` */
14
+ supportsMcpConfig?: boolean;
15
+ /**
16
+ * Transform injection text before writing to the PTY.
17
+ * Responsible for appending the correct line terminator for this agent's TUI.
18
+ * Daemons >= v1.1 apply this; the server sends raw text without a terminator.
19
+ */
20
+ formatInput?: (text: string) => string;
21
+ /**
22
+ * Agent-specific version directory globs (relative to HOME).
23
+ * Each entry is a glob pattern; the binary name is appended.
24
+ */
25
+ versionDirGlobs?: string[];
26
+ }
27
+ declare const AGENT_SPECS: AgentSpec[];
28
+ export { AGENT_SPECS };
29
+ export type { AgentSpec };
30
+ export declare function detectAgents(globalAgentPaths?: Record<string, string>): Promise<AgentInfo[]>;
@@ -0,0 +1,228 @@
1
+ import which from 'which';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import net from 'net';
5
+ import { spawnSync } from 'child_process';
6
+ // Standard \n terminator — works for readline-based CLIs (sh, Codex, Gemini, Ollama, Aider)
7
+ const appendLF = (text) => text + '\n';
8
+ // TUI agents that treat \n as soft newline (multi-line mode) and \r as submit.
9
+ // Strip any trailing \r/\n first to avoid a double-submit on the last line.
10
+ // Applies to: Claude Code, Qwen CLI.
11
+ const appendCR = (text) => text.replace(/[\r\n]+$/, '') + '\r';
12
+ const AGENT_SPECS = [
13
+ {
14
+ key: 'sh',
15
+ displayName: 'Shell',
16
+ binary: 'sh',
17
+ checkAuth: async () => true,
18
+ formatInput: appendLF,
19
+ },
20
+ {
21
+ key: 'claude',
22
+ displayName: 'Claude Code',
23
+ binary: 'claude',
24
+ checkAuth: async () => checkDir('.claude') || checkEnv('ANTHROPIC_API_KEY'),
25
+ assignSessionId: true,
26
+ spawnArgs: ['--dangerously-skip-permissions'],
27
+ resumeArgs: (id) => ['--dangerously-skip-permissions', '--resume', id],
28
+ supportsMcpConfig: true,
29
+ formatInput: appendCR,
30
+ versionDirGlobs: ['.local/share/claude/versions/*'],
31
+ },
32
+ {
33
+ key: 'codex',
34
+ displayName: 'Codex CLI',
35
+ binary: 'codex',
36
+ checkAuth: async () => checkEnv('OPENAI_API_KEY'),
37
+ spawnArgs: ['--full-auto'],
38
+ supportsMcpConfig: true,
39
+ formatInput: appendCR,
40
+ },
41
+ {
42
+ key: 'qwen',
43
+ displayName: 'Qwen CLI',
44
+ binary: 'qwen',
45
+ checkAuth: async () => checkDir('.qwen'),
46
+ assignSessionId: true,
47
+ spawnArgs: ['--yolo'],
48
+ resumeArgs: (id) => ['--resume', id, '--yolo'],
49
+ supportsMcpConfig: true,
50
+ formatInput: appendCR,
51
+ versionDirGlobs: ['.local/share/qwen/versions/*'],
52
+ },
53
+ {
54
+ key: 'gemini',
55
+ displayName: 'Gemini',
56
+ binary: 'gemini',
57
+ checkAuth: async () => checkEnv('GEMINI_API_KEY'),
58
+ supportsMcpConfig: true,
59
+ formatInput: appendLF,
60
+ },
61
+ {
62
+ key: 'ollama',
63
+ displayName: 'Ollama',
64
+ binary: 'ollama',
65
+ checkAuth: async () => checkPort(11434),
66
+ formatInput: appendLF,
67
+ },
68
+ {
69
+ key: 'aider',
70
+ displayName: 'Aider',
71
+ binary: 'aider',
72
+ checkAuth: async () => checkEnv('OPENAI_API_KEY') || checkEnv('ANTHROPIC_API_KEY'),
73
+ supportsMcpConfig: true,
74
+ formatInput: appendLF,
75
+ },
76
+ {
77
+ key: 'kimi',
78
+ displayName: 'Kimi Code',
79
+ binary: 'kimi',
80
+ checkAuth: async () => checkDir('.kimi') || checkEnv('KIMI_API_KEY'),
81
+ assignSessionId: true,
82
+ spawnArgs: ['--yolo'],
83
+ resumeArgs: (id) => ['-r', id, '--yolo'],
84
+ supportsMcpConfig: true,
85
+ // NOTE: Kimi does not support --system-prompt-file
86
+ // Role prompts disabled via buildRolePromptArgs()
87
+ formatInput: appendCR,
88
+ versionDirGlobs: ['.local/share/uv/tools/kimi-cli/bin', '.local/share/kimi/versions/*'],
89
+ },
90
+ ];
91
+ export { AGENT_SPECS };
92
+ // ── Version detection helpers ────────────────────────────────────────────────
93
+ const HOME = process.env['HOME'] ?? '/Users/unknown';
94
+ /** Run `binary --version` and return the first non-empty stdout line, or undefined on failure. */
95
+ function getVersionFromBinary(binaryPath) {
96
+ try {
97
+ const r = spawnSync(binaryPath, ['--version'], { timeout: 5000 });
98
+ if (r.status !== 0)
99
+ return undefined;
100
+ const firstLine = (r.stdout ?? r.stderr ?? Buffer.from(''))
101
+ .toString('utf8')
102
+ .split('\n')[0]
103
+ .trim();
104
+ return firstLine || undefined;
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ /**
111
+ * Scan agent-specific version directories for a working binary.
112
+ * Returns candidates in order they should be tried (newest first).
113
+ */
114
+ function scanVersionDirs(spec) {
115
+ if (!spec.versionDirGlobs?.length)
116
+ return [];
117
+ // Use a simple glob-style scan — no extra dependencies needed
118
+ const results = [];
119
+ const patterns = spec.versionDirGlobs;
120
+ for (const pattern of patterns) {
121
+ const baseDir = path.join(HOME, pattern.replace(/\/\*$/, ''));
122
+ // If pattern ends in /*, scan that directory for versions
123
+ if (pattern.endsWith('/*')) {
124
+ let entries = [];
125
+ try {
126
+ entries = fs.readdirSync(baseDir);
127
+ }
128
+ catch { /* dir doesn't exist */ }
129
+ // Sort newest-first (assuming version numbers in dir names)
130
+ entries.sort((a, b) => b.localeCompare(a));
131
+ for (const entry of entries) {
132
+ const candidate = path.join(baseDir, entry, spec.binary);
133
+ if (fs.existsSync(candidate)) {
134
+ const version = getVersionFromBinary(candidate);
135
+ results.push({ path: candidate, version });
136
+ }
137
+ }
138
+ }
139
+ else {
140
+ // Literal path (e.g. ~/.local/share/uv/tools/kimi-cli/bin/kimi)
141
+ const candidate = path.join(HOME, pattern);
142
+ if (fs.existsSync(candidate)) {
143
+ const version = getVersionFromBinary(candidate);
144
+ results.push({ path: candidate, version });
145
+ }
146
+ }
147
+ }
148
+ return results;
149
+ }
150
+ /**
151
+ * Resolve the best working binary for an agent.
152
+ *
153
+ * Priority order:
154
+ * 1. Config override (global agentPaths from BridgeConfig)
155
+ * 2. which() result + spawnSync --version validation
156
+ * 3. Version directory scan (newest first) + spawnSync validation
157
+ *
158
+ * Returns { path, version } or throws if no working binary found.
159
+ */
160
+ async function resolveAgentBinary(spec, globalAgentPaths = {}) {
161
+ // 1. Config override
162
+ if (globalAgentPaths[spec.key]) {
163
+ const override = globalAgentPaths[spec.key];
164
+ const version = getVersionFromBinary(override);
165
+ if (version)
166
+ return { path: override, version };
167
+ }
168
+ // 2. which() + spawnSync validation
169
+ try {
170
+ const whichPath = await which(spec.binary);
171
+ if (whichPath && fs.existsSync(whichPath)) {
172
+ const version = getVersionFromBinary(whichPath);
173
+ if (version !== undefined) {
174
+ return { path: whichPath, version };
175
+ }
176
+ }
177
+ }
178
+ catch { /* not in PATH */ }
179
+ // 3. Version directory scan
180
+ const candidates = scanVersionDirs(spec);
181
+ for (const candidate of candidates) {
182
+ if (candidate.version !== undefined) {
183
+ return candidate;
184
+ }
185
+ }
186
+ // Last resort: which() result even if --version failed (backward compat)
187
+ try {
188
+ const whichPath = await which(spec.binary);
189
+ if (whichPath)
190
+ return { path: whichPath };
191
+ }
192
+ catch { /* not in PATH */ }
193
+ throw new Error(`No working binary found for agent '${spec.key}'`);
194
+ }
195
+ export async function detectAgents(globalAgentPaths = {}) {
196
+ const results = [];
197
+ for (const spec of AGENT_SPECS) {
198
+ try {
199
+ const { path: binaryPath, version } = await resolveAgentBinary(spec, globalAgentPaths);
200
+ const authOk = await spec.checkAuth();
201
+ const authStatus = authOk ? 'ok' : 'missing';
202
+ results.push({ key: spec.key, displayName: spec.displayName, binaryPath, authStatus, version });
203
+ }
204
+ catch {
205
+ // binary not found or not spawnable — skip
206
+ }
207
+ }
208
+ console.log('[daemon] agent.detect.done', {
209
+ found: results.map(a => a.key),
210
+ missing: AGENT_SPECS.map(s => s.key).filter(k => !results.find(r => r.key === k)),
211
+ });
212
+ return results;
213
+ }
214
+ function checkDir(name) {
215
+ return fs.existsSync(path.join(process.env['HOME'] ?? '', name));
216
+ }
217
+ function checkEnv(key) {
218
+ return !!process.env[key];
219
+ }
220
+ async function checkPort(port) {
221
+ return new Promise(resolve => {
222
+ const s = net.createConnection(port, '127.0.0.1');
223
+ s.setTimeout(200);
224
+ s.on('connect', () => { s.destroy(); resolve(true); });
225
+ s.on('error', () => resolve(false));
226
+ s.on('timeout', () => { s.destroy(); resolve(false); });
227
+ });
228
+ }
@@ -0,0 +1,12 @@
1
+ export interface QuotaInfo {
2
+ prompts5h: number;
3
+ limit5h: number;
4
+ resetAt: number;
5
+ tier: string;
6
+ }
7
+ /**
8
+ * Start a global watcher that polls Claude Code JSONL files every 60s
9
+ * to count user prompts within the rolling 5-hour window.
10
+ * Returns a cleanup function.
11
+ */
12
+ export declare function startClaudeQuotaWatcher(onQuota: (info: QuotaInfo) => void): () => void;
@@ -0,0 +1,114 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const WINDOW_MS = 5 * 60 * 60 * 1000; // 5 hours in ms
5
+ const TIER_LIMITS = {
6
+ free: 10,
7
+ pro: 40,
8
+ max_5x: 200,
9
+ max_20x: 200,
10
+ };
11
+ function readTier() {
12
+ const configPath = path.join(os.homedir(), '.jerico', 'settings.json');
13
+ try {
14
+ if (!fs.existsSync(configPath))
15
+ return 'pro';
16
+ const obj = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
17
+ const tier = obj['claudeTier'];
18
+ if (typeof tier === 'string' && tier in TIER_LIMITS)
19
+ return tier;
20
+ }
21
+ catch { /* ignore */ }
22
+ return 'pro';
23
+ }
24
+ function countPromptsInWindow() {
25
+ const base = path.join(os.homedir(), '.claude', 'projects');
26
+ if (!fs.existsSync(base))
27
+ return { prompts5h: 0, resetAt: 0 };
28
+ const now = Date.now();
29
+ const cutoff = now - WINDOW_MS;
30
+ let oldestInWindow = Infinity;
31
+ let count = 0;
32
+ try {
33
+ const dirs = fs.readdirSync(base, { withFileTypes: true }).filter(d => d.isDirectory());
34
+ for (const dir of dirs) {
35
+ const dirPath = path.join(base, dir.name);
36
+ let files;
37
+ try {
38
+ files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ for (const file of files) {
44
+ const filePath = path.join(dirPath, file);
45
+ let content;
46
+ try {
47
+ // Skip files larger than 20MB to avoid blocking the event loop
48
+ const stat = fs.statSync(filePath);
49
+ if (stat.size > 20 * 1024 * 1024)
50
+ continue;
51
+ content = fs.readFileSync(filePath, 'utf-8');
52
+ }
53
+ catch {
54
+ continue;
55
+ }
56
+ for (const line of content.split('\n')) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed)
59
+ continue;
60
+ try {
61
+ const entry = JSON.parse(trimmed);
62
+ // Only count external user prompts (not tool results, system messages, etc.)
63
+ if (entry['type'] !== 'user')
64
+ continue;
65
+ const msg = entry['message'];
66
+ if (msg?.['role'] !== 'user')
67
+ continue;
68
+ if (entry['userType'] !== undefined && entry['userType'] !== 'external')
69
+ continue;
70
+ const ts = entry['timestamp'];
71
+ if (typeof ts !== 'string')
72
+ continue;
73
+ const epoch = Date.parse(ts);
74
+ if (isNaN(epoch) || epoch < cutoff)
75
+ continue;
76
+ count++;
77
+ if (epoch < oldestInWindow)
78
+ oldestInWindow = epoch;
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ catch { /* no-op */ }
88
+ const resetAt = isFinite(oldestInWindow) ? oldestInWindow + WINDOW_MS : 0;
89
+ return { prompts5h: count, resetAt };
90
+ }
91
+ /**
92
+ * Start a global watcher that polls Claude Code JSONL files every 60s
93
+ * to count user prompts within the rolling 5-hour window.
94
+ * Returns a cleanup function.
95
+ */
96
+ export function startClaudeQuotaWatcher(onQuota) {
97
+ const tick = () => {
98
+ const tier = readTier();
99
+ const limit5h = TIER_LIMITS[tier] ?? 40;
100
+ const { prompts5h, resetAt } = countPromptsInWindow();
101
+ onQuota({ prompts5h, limit5h, resetAt, tier });
102
+ };
103
+ try {
104
+ tick();
105
+ }
106
+ catch (err) {
107
+ console.warn('[quota] initial poll failed', err);
108
+ }
109
+ const interval = setInterval(() => { try {
110
+ tick();
111
+ }
112
+ catch { /* silent */ } }, 60_000);
113
+ return () => clearInterval(interval);
114
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Start polling Claude's JSONL session file for token usage.
3
+ * Calls onUsage whenever new data is found. Returns a cleanup function.
4
+ */
5
+ export declare function startClaudeUsageWatcher(agentId: string, sessionId: string, onUsage: (agentId: string, usedPct: number, usedTokens: number) => void): () => void;
@@ -0,0 +1,92 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ // All current Claude models have 200k context window
5
+ const CONTEXT_WINDOW = 200_000;
6
+ /**
7
+ * Search ~/.claude/projects/ subdirectories for a JSONL file matching the given sessionId.
8
+ * Claude Code stores sessions at: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl
9
+ */
10
+ function findJSONL(sessionId) {
11
+ const base = path.join(os.homedir(), '.claude', 'projects');
12
+ if (!fs.existsSync(base))
13
+ return undefined;
14
+ try {
15
+ const dirs = fs.readdirSync(base, { withFileTypes: true })
16
+ .filter(d => d.isDirectory());
17
+ for (const dir of dirs) {
18
+ const candidate = path.join(base, dir.name, `${sessionId}.jsonl`);
19
+ if (fs.existsSync(candidate))
20
+ return candidate;
21
+ }
22
+ }
23
+ catch { /* no-op */ }
24
+ return undefined;
25
+ }
26
+ /**
27
+ * Read the JSONL file and return the most recent token usage.
28
+ * Claude Code persists: entry.message.usage = { input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens }
29
+ * used% = (input + cache_creation + cache_read) / context_window
30
+ */
31
+ function parseLastUsage(filePath) {
32
+ try {
33
+ const content = fs.readFileSync(filePath, 'utf-8');
34
+ const lines = content.trim().split('\n');
35
+ // Scan from end to find the most recent assistant entry with usage
36
+ for (let i = lines.length - 1; i >= 0; i--) {
37
+ const line = lines[i]?.trim();
38
+ if (!line)
39
+ continue;
40
+ try {
41
+ const entry = JSON.parse(line);
42
+ const usage = entry.message?.usage
43
+ ?? entry.usage;
44
+ if (!usage || typeof usage !== 'object')
45
+ continue;
46
+ const u = usage;
47
+ const usedTokens = (u.input_tokens ?? 0)
48
+ + (u.cache_creation_input_tokens ?? 0)
49
+ + (u.cache_read_input_tokens ?? 0);
50
+ if (usedTokens === 0)
51
+ continue;
52
+ const usedPct = Math.min(100, Math.round((usedTokens / CONTEXT_WINDOW) * 100));
53
+ return { usedPct, usedTokens };
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ }
59
+ }
60
+ catch { /* file not readable */ }
61
+ return null;
62
+ }
63
+ /**
64
+ * Start polling Claude's JSONL session file for token usage.
65
+ * Calls onUsage whenever new data is found. Returns a cleanup function.
66
+ */
67
+ export function startClaudeUsageWatcher(agentId, sessionId, onUsage) {
68
+ let filePath;
69
+ let lastUsedTokens = -1;
70
+ const tick = () => {
71
+ // Lazily locate the JSONL file (may not exist immediately after spawn)
72
+ if (!filePath)
73
+ filePath = findJSONL(sessionId);
74
+ if (!filePath)
75
+ return;
76
+ const result = parseLastUsage(filePath);
77
+ if (!result)
78
+ return;
79
+ // Only emit when value actually changed to avoid spamming the WS
80
+ if (result.usedTokens === lastUsedTokens)
81
+ return;
82
+ lastUsedTokens = result.usedTokens;
83
+ onUsage(agentId, result.usedPct, result.usedTokens);
84
+ };
85
+ // First poll after 2s (JSONL may not exist yet), then every 3s
86
+ const initial = setTimeout(tick, 2000);
87
+ const interval = setInterval(tick, 3000);
88
+ return () => {
89
+ clearTimeout(initial);
90
+ clearInterval(interval);
91
+ };
92
+ }
@@ -0,0 +1,22 @@
1
+ import type { WorkspaceId, ProjectId, AgentId } from '../shared/types.js';
2
+ export interface SpawnContext {
3
+ serverUrl: string;
4
+ token: string;
5
+ workspaceId: WorkspaceId;
6
+ projectId: ProjectId;
7
+ agentId?: AgentId;
8
+ cwd?: string;
9
+ /** Extra env vars from .jerico/settings.json — merged over process.env */
10
+ projectEnv?: Record<string, string>;
11
+ }
12
+ export declare class PtyManager {
13
+ private handles;
14
+ private nextInstanceId;
15
+ private lastErrors;
16
+ spawn(agentId: string, agentKey: string, binary: string, args: string[], cols: number, rows: number, onData: (data: string) => void, onExit: (exitCode: number | null, signal: string | null) => void, ctx?: SpawnContext): boolean;
17
+ write(agentId: string, data: string, source?: string): void;
18
+ kill(agentId: string, force?: boolean): void;
19
+ resize(agentId: string, cols: number, rows: number): void;
20
+ getLastError(agentId: string): string | undefined;
21
+ killAll(): void;
22
+ }