bridge-agent 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/commands/auth.d.ts +1 -0
- package/dist/commands/auth.js +87 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +21 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +345 -0
- package/dist/metrics.d.ts +10 -0
- package/dist/metrics.js +91 -0
- package/dist/pty/agents.d.ts +25 -0
- package/dist/pty/agents.js +108 -0
- package/dist/pty/claude-quota.d.ts +12 -0
- package/dist/pty/claude-quota.js +114 -0
- package/dist/pty/claude-usage.d.ts +5 -0
- package/dist/pty/claude-usage.js +92 -0
- package/dist/pty/manager.d.ts +18 -0
- package/dist/pty/manager.js +125 -0
- package/dist/shared/types.d.ts +142 -0
- package/dist/shared/types.js +1 -0
- package/dist/ws/client.d.ts +3 -0
- package/dist/ws/client.js +925 -0
- package/package.json +54 -0
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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 ramStats() {
|
|
24
|
+
const total = os.totalmem();
|
|
25
|
+
const free = os.freemem();
|
|
26
|
+
return {
|
|
27
|
+
totalMb: Math.round(total / 1024 / 1024),
|
|
28
|
+
usedMb: Math.round((total - free) / 1024 / 1024),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// ── Battery ─────────────────────────────────────────────────────────────────
|
|
32
|
+
function batteryMacos() {
|
|
33
|
+
try {
|
|
34
|
+
const r = spawnSync('pmset', ['-g', 'batt'], { encoding: 'utf-8', timeout: 2000, stdio: 'pipe' });
|
|
35
|
+
if (r.status !== 0 || !r.stdout)
|
|
36
|
+
return undefined;
|
|
37
|
+
const m = r.stdout.match(/(\d+)%;\s*(charging|discharging|charged|finishing charge)/i);
|
|
38
|
+
if (!m)
|
|
39
|
+
return undefined;
|
|
40
|
+
const percent = parseInt(m[1], 10);
|
|
41
|
+
const charging = /charging|charged|finishing/i.test(m[2]);
|
|
42
|
+
return { percent, charging };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function batteryLinux() {
|
|
49
|
+
try {
|
|
50
|
+
const base = '/sys/class/power_supply';
|
|
51
|
+
const dirs = fs.readdirSync(base).filter(d => /^BAT/i.test(d));
|
|
52
|
+
if (dirs.length === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
const bat = `${base}/${dirs[0]}`;
|
|
55
|
+
const percent = parseInt(fs.readFileSync(`${bat}/capacity`, 'utf-8').trim(), 10);
|
|
56
|
+
const status = fs.readFileSync(`${bat}/status`, 'utf-8').trim().toLowerCase();
|
|
57
|
+
const charging = status === 'charging' || status === 'full';
|
|
58
|
+
return { percent, charging };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function battery() {
|
|
65
|
+
const p = process.platform;
|
|
66
|
+
if (p === 'darwin')
|
|
67
|
+
return batteryMacos();
|
|
68
|
+
if (p === 'linux')
|
|
69
|
+
return batteryLinux();
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
// ── Relay ────────────────────────────────────────────────────────────────────
|
|
73
|
+
const METRICS_INTERVAL_MS = 10_000;
|
|
74
|
+
const BATTERY_EVERY_N = 3; // battery checked every 3rd tick (30s)
|
|
75
|
+
export function startMetricsRelay(sendFn) {
|
|
76
|
+
let tick = 0;
|
|
77
|
+
let cachedBattery = battery();
|
|
78
|
+
const timer = setInterval(() => {
|
|
79
|
+
tick++;
|
|
80
|
+
if (tick % BATTERY_EVERY_N === 0)
|
|
81
|
+
cachedBattery = battery();
|
|
82
|
+
const ram = ramStats();
|
|
83
|
+
sendFn({
|
|
84
|
+
cpu: cpuPercent(),
|
|
85
|
+
ramUsedMb: ram.usedMb,
|
|
86
|
+
ramTotalMb: ram.totalMb,
|
|
87
|
+
battery: cachedBattery,
|
|
88
|
+
});
|
|
89
|
+
}, METRICS_INTERVAL_MS);
|
|
90
|
+
return () => clearInterval(timer);
|
|
91
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
declare const AGENT_SPECS: AgentSpec[];
|
|
23
|
+
export { AGENT_SPECS };
|
|
24
|
+
export type { AgentSpec };
|
|
25
|
+
export declare function detectAgents(): Promise<AgentInfo[]>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import which from 'which';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import net from 'net';
|
|
5
|
+
// Standard \n terminator — works for readline-based CLIs (sh, Codex, Gemini, Ollama, Aider)
|
|
6
|
+
const appendLF = (text) => text + '\n';
|
|
7
|
+
// TUI agents that treat \n as soft newline (multi-line mode) and \r as submit.
|
|
8
|
+
// Strip any trailing \r/\n first to avoid a double-submit on the last line.
|
|
9
|
+
// Applies to: Claude Code, Qwen CLI.
|
|
10
|
+
const appendCR = (text) => text.replace(/[\r\n]+$/, '') + '\r';
|
|
11
|
+
const AGENT_SPECS = [
|
|
12
|
+
{
|
|
13
|
+
key: 'sh',
|
|
14
|
+
displayName: 'Shell',
|
|
15
|
+
binary: 'sh',
|
|
16
|
+
checkAuth: async () => true,
|
|
17
|
+
formatInput: appendLF,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: 'claude',
|
|
21
|
+
displayName: 'Claude Code',
|
|
22
|
+
binary: 'claude',
|
|
23
|
+
checkAuth: async () => checkDir('.claude') || checkEnv('ANTHROPIC_API_KEY'),
|
|
24
|
+
assignSessionId: true,
|
|
25
|
+
spawnArgs: ['--dangerously-skip-permissions'],
|
|
26
|
+
resumeArgs: (id) => ['--dangerously-skip-permissions', '--resume', id],
|
|
27
|
+
supportsMcpConfig: true,
|
|
28
|
+
formatInput: appendCR,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: 'codex',
|
|
32
|
+
displayName: 'Codex CLI',
|
|
33
|
+
binary: 'codex',
|
|
34
|
+
checkAuth: async () => checkEnv('OPENAI_API_KEY'),
|
|
35
|
+
spawnArgs: ['--full-auto'],
|
|
36
|
+
supportsMcpConfig: true,
|
|
37
|
+
formatInput: appendCR,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: 'qwen',
|
|
41
|
+
displayName: 'Qwen CLI',
|
|
42
|
+
binary: 'qwen',
|
|
43
|
+
checkAuth: async () => checkDir('.qwen'),
|
|
44
|
+
assignSessionId: true,
|
|
45
|
+
spawnArgs: ['--yolo'],
|
|
46
|
+
resumeArgs: (id) => ['--resume', id, '--yolo'],
|
|
47
|
+
supportsMcpConfig: true,
|
|
48
|
+
formatInput: appendCR,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: 'gemini',
|
|
52
|
+
displayName: 'Gemini',
|
|
53
|
+
binary: 'gemini',
|
|
54
|
+
checkAuth: async () => checkEnv('GEMINI_API_KEY'),
|
|
55
|
+
supportsMcpConfig: true,
|
|
56
|
+
formatInput: appendLF,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'ollama',
|
|
60
|
+
displayName: 'Ollama',
|
|
61
|
+
binary: 'ollama',
|
|
62
|
+
checkAuth: async () => checkPort(11434),
|
|
63
|
+
formatInput: appendLF,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'aider',
|
|
67
|
+
displayName: 'Aider',
|
|
68
|
+
binary: 'aider',
|
|
69
|
+
checkAuth: async () => checkEnv('OPENAI_API_KEY') || checkEnv('ANTHROPIC_API_KEY'),
|
|
70
|
+
supportsMcpConfig: true,
|
|
71
|
+
formatInput: appendLF,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
export { AGENT_SPECS };
|
|
75
|
+
export async function detectAgents() {
|
|
76
|
+
const results = [];
|
|
77
|
+
for (const spec of AGENT_SPECS) {
|
|
78
|
+
try {
|
|
79
|
+
const binaryPath = await which(spec.binary);
|
|
80
|
+
const authOk = await spec.checkAuth();
|
|
81
|
+
const authStatus = authOk ? 'ok' : 'missing';
|
|
82
|
+
results.push({ key: spec.key, displayName: spec.displayName, binaryPath, authStatus });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// binary not in PATH — skip
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
console.log('[daemon] agent.detect.done', {
|
|
89
|
+
found: results.map(a => a.key),
|
|
90
|
+
missing: AGENT_SPECS.map(s => s.key).filter(k => !results.find(r => r.key === k)),
|
|
91
|
+
});
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
function checkDir(name) {
|
|
95
|
+
return fs.existsSync(path.join(process.env['HOME'] ?? '', name));
|
|
96
|
+
}
|
|
97
|
+
function checkEnv(key) {
|
|
98
|
+
return !!process.env[key];
|
|
99
|
+
}
|
|
100
|
+
async function checkPort(port) {
|
|
101
|
+
return new Promise(resolve => {
|
|
102
|
+
const s = net.createConnection(port, '127.0.0.1');
|
|
103
|
+
s.setTimeout(200);
|
|
104
|
+
s.on('connect', () => { s.destroy(); resolve(true); });
|
|
105
|
+
s.on('error', () => resolve(false));
|
|
106
|
+
s.on('timeout', () => { s.destroy(); resolve(false); });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -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,18 @@
|
|
|
1
|
+
export interface SpawnContext {
|
|
2
|
+
serverUrl: string;
|
|
3
|
+
token: string;
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
projectId: string;
|
|
6
|
+
agentId?: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
/** Extra env vars from .jerico/settings.json — merged over process.env */
|
|
9
|
+
projectEnv?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
export declare class PtyManager {
|
|
12
|
+
private handles;
|
|
13
|
+
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;
|
|
14
|
+
write(agentId: string, data: string, source?: string): void;
|
|
15
|
+
kill(agentId: string, force?: boolean): void;
|
|
16
|
+
resize(agentId: string, cols: number, rows: number): void;
|
|
17
|
+
killAll(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as pty from 'node-pty';
|
|
2
|
+
import { AGENT_SPECS } from './agents.js';
|
|
3
|
+
export class PtyManager {
|
|
4
|
+
handles = new Map();
|
|
5
|
+
spawn(agentId, agentKey, binary, args, cols, rows, onData, onExit, ctx) {
|
|
6
|
+
if (this.handles.has(agentId)) {
|
|
7
|
+
// Kill the stale PTY before spawning a new one for the same agentId
|
|
8
|
+
this.kill(agentId, true);
|
|
9
|
+
}
|
|
10
|
+
const clampedCols = Math.max(1, Math.min(500, cols));
|
|
11
|
+
const clampedRows = Math.max(1, Math.min(500, rows));
|
|
12
|
+
const env = {
|
|
13
|
+
...process.env,
|
|
14
|
+
TERM: 'xterm-256color',
|
|
15
|
+
COLORTERM: 'truecolor',
|
|
16
|
+
};
|
|
17
|
+
if (ctx) {
|
|
18
|
+
env['BRIDGE_SERVER_URL'] = ctx.serverUrl;
|
|
19
|
+
env['BRIDGE_TOKEN'] = ctx.token;
|
|
20
|
+
env['BRIDGE_WORKSPACE_ID'] = ctx.workspaceId;
|
|
21
|
+
env['BRIDGE_PROJECT_ID'] = ctx.projectId;
|
|
22
|
+
// Merge project-scoped env vars — these override process.env
|
|
23
|
+
if (ctx.projectEnv)
|
|
24
|
+
Object.assign(env, ctx.projectEnv);
|
|
25
|
+
}
|
|
26
|
+
const bridgeMcpUrl = process.env['BRIDGE_MCP_URL'];
|
|
27
|
+
if (bridgeMcpUrl) {
|
|
28
|
+
env['BRIDGE_MCP_URL'] = bridgeMcpUrl;
|
|
29
|
+
}
|
|
30
|
+
let proc;
|
|
31
|
+
try {
|
|
32
|
+
proc = pty.spawn(binary, args, {
|
|
33
|
+
name: 'xterm-256color',
|
|
34
|
+
cols: clampedCols,
|
|
35
|
+
rows: clampedRows,
|
|
36
|
+
cwd: ctx?.cwd,
|
|
37
|
+
env,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
42
|
+
console.error('[daemon] pty.spawn.failed', { agentId, agentKey, error: msg });
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const handle = { agentId, agentKey, process: proc, pid: proc.pid, killed: false };
|
|
46
|
+
proc.onData(data => {
|
|
47
|
+
onData(Buffer.from(data).toString('base64'));
|
|
48
|
+
});
|
|
49
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
50
|
+
if (handle.killed)
|
|
51
|
+
return;
|
|
52
|
+
this.handles.delete(agentId);
|
|
53
|
+
console.log('[daemon] pty.exit', { agentId, exitCode, signal });
|
|
54
|
+
onExit(exitCode ?? null, signal ? String(signal) : null);
|
|
55
|
+
});
|
|
56
|
+
this.handles.set(agentId, handle);
|
|
57
|
+
console.log('[daemon] pty.spawn.success', { agentId, agentKey, args, cwd: ctx?.cwd });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
write(agentId, data, source) {
|
|
61
|
+
const handle = this.handles.get(agentId);
|
|
62
|
+
if (!handle)
|
|
63
|
+
return;
|
|
64
|
+
const decoded = Buffer.from(data, 'base64').toString();
|
|
65
|
+
// Only apply agent-specific formatInput for orchestrator injections.
|
|
66
|
+
// User keystrokes from xterm already carry the correct terminators —
|
|
67
|
+
// appending \n/\r to every character would submit each keystroke immediately.
|
|
68
|
+
const spec = AGENT_SPECS.find(s => s.key === handle.agentKey);
|
|
69
|
+
const formatted = (source === 'orchestrator' && spec?.formatInput)
|
|
70
|
+
? spec.formatInput(decoded)
|
|
71
|
+
: decoded;
|
|
72
|
+
handle.process.write(formatted);
|
|
73
|
+
}
|
|
74
|
+
kill(agentId, force = false) {
|
|
75
|
+
const handle = this.handles.get(agentId);
|
|
76
|
+
if (!handle)
|
|
77
|
+
return;
|
|
78
|
+
handle.killed = true;
|
|
79
|
+
this.handles.delete(agentId);
|
|
80
|
+
const pid = handle.pid;
|
|
81
|
+
if (force) {
|
|
82
|
+
// Kill entire process group: SIGTERM first, then SIGKILL after 2s
|
|
83
|
+
try {
|
|
84
|
+
process.kill(-pid, 'SIGTERM');
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
handle.process.kill();
|
|
88
|
+
}
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(-pid, 'SIGKILL');
|
|
92
|
+
}
|
|
93
|
+
catch { /* already dead */ }
|
|
94
|
+
}, 2000);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(-pid, 'SIGTERM');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
handle.process.kill();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log('[daemon] pty.kill', { agentId, force });
|
|
105
|
+
}
|
|
106
|
+
resize(agentId, cols, rows) {
|
|
107
|
+
const handle = this.handles.get(agentId);
|
|
108
|
+
if (!handle)
|
|
109
|
+
return;
|
|
110
|
+
const clampedCols = Math.max(1, Math.min(500, cols));
|
|
111
|
+
const clampedRows = Math.max(1, Math.min(500, rows));
|
|
112
|
+
handle.process.resize(clampedCols, clampedRows);
|
|
113
|
+
}
|
|
114
|
+
killAll() {
|
|
115
|
+
for (const handle of this.handles.values()) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(-handle.pid, 'SIGTERM');
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
handle.process.kill();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.handles.clear();
|
|
124
|
+
}
|
|
125
|
+
}
|