@rectify-so/bridge 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.
- package/README.md +48 -0
- package/dist/api.js +33 -0
- package/dist/index.js +122 -0
- package/dist/readers/claude.js +227 -0
- package/dist/readers/codex.js +200 -0
- package/dist/server.js +93 -0
- package/dist/tunnel.js +102 -0
- package/dist/types.js +1 -0
- package/dist/util.js +75 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# rectify-bridge
|
|
2
|
+
|
|
3
|
+
Connect your locally-installed agent CLIs — **Claude Code**, **Codex**, and **OpenClaw** — to the [Rectify](https://rectify.so) AgentPulse dashboard.
|
|
4
|
+
|
|
5
|
+
The bridge runs on your machine, reads your local session data, and opens a secure outbound tunnel so the dashboard can show your sessions, token usage, and let you chat. Nothing is installed system-wide and the bridge only makes **outbound** connections — no inbound SSH server required.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
In the Rectify dashboard, add a Claude Code / Codex / OpenClaw connection. You'll be given a one-line command:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx rectify-bridge connect <token>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run it on the machine where the CLI is installed and **keep the window open**. The dashboard updates automatically once connected. Press `Ctrl+C` to disconnect.
|
|
16
|
+
|
|
17
|
+
### Options
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npx rectify-bridge connect <token> [--api <url>]
|
|
21
|
+
|
|
22
|
+
--api <url> Override the Rectify API base URL (default: https://api.rectify.so)
|
|
23
|
+
-h, --help Show help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- **Node.js 18+** (already present if you have the Claude Code or Codex CLI).
|
|
29
|
+
- The **OpenSSH client** (`ssh`) — built into macOS, Linux, and Windows 10+.
|
|
30
|
+
- For Claude Code connections: the `claude` CLI on your `PATH`.
|
|
31
|
+
- For Codex connections: the `codex` CLI on your `PATH`.
|
|
32
|
+
|
|
33
|
+
## What it reads
|
|
34
|
+
|
|
35
|
+
- **Claude Code:** `~/.claude/projects/**/*.jsonl` (sessions) and `~/.claude/skills` (skills).
|
|
36
|
+
- **Codex:** `~/.codex/sessions/**/*.jsonl` (sessions).
|
|
37
|
+
|
|
38
|
+
Session files are read-only. Chat requests spawn the local CLI (`claude --print` / `codex exec`).
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
1. Fetches its config (tunnel host/port, one-time key) from the Rectify API using the token.
|
|
43
|
+
2. For Claude/Codex: starts a local HTTP server that serves parsed session data and proxies chat to the CLI.
|
|
44
|
+
3. Opens a reverse SSH tunnel (outbound) so the Rectify backend can reach that local server. For OpenClaw it simply forwards the gateway port.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { warn } from './util.js';
|
|
2
|
+
export class ApiClient {
|
|
3
|
+
apiBase;
|
|
4
|
+
token;
|
|
5
|
+
constructor(apiBase, token) {
|
|
6
|
+
this.apiBase = apiBase;
|
|
7
|
+
this.token = token;
|
|
8
|
+
}
|
|
9
|
+
url(path) {
|
|
10
|
+
const base = this.apiBase.replace(/\/+$/, '');
|
|
11
|
+
return `${base}/v1/agent-pulse/bridge/${this.token}${path}`;
|
|
12
|
+
}
|
|
13
|
+
async fetchConfig() {
|
|
14
|
+
const res = await fetch(this.url('/config'));
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = await res.text().catch(() => '');
|
|
17
|
+
throw new Error(`Failed to fetch bridge config (HTTP ${res.status}). ${text}`.trim());
|
|
18
|
+
}
|
|
19
|
+
return (await res.json());
|
|
20
|
+
}
|
|
21
|
+
async reportConnected(info) {
|
|
22
|
+
try {
|
|
23
|
+
await fetch(this.url('/connected'), {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(info),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
warn('Failed to report connected status:', err instanceof Error ? err.message : err);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ApiClient } from './api.js';
|
|
3
|
+
import { Tunnel } from './tunnel.js';
|
|
4
|
+
import { startLocalServer } from './server.js';
|
|
5
|
+
import { log, warn } from './util.js';
|
|
6
|
+
import * as claude from './readers/claude.js';
|
|
7
|
+
import * as codex from './readers/codex.js';
|
|
8
|
+
const DEFAULT_API_BASE = process.env.RECTIFY_API_BASE || 'https://api.rectify.so';
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const args = argv.slice(2);
|
|
11
|
+
let command;
|
|
12
|
+
let token;
|
|
13
|
+
let apiBase = DEFAULT_API_BASE;
|
|
14
|
+
const positionals = [];
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const a = args[i];
|
|
17
|
+
if (a === '--api' || a === '-a') {
|
|
18
|
+
apiBase = args[++i] ?? apiBase;
|
|
19
|
+
}
|
|
20
|
+
else if (a.startsWith('--api=')) {
|
|
21
|
+
apiBase = a.slice('--api='.length);
|
|
22
|
+
}
|
|
23
|
+
else if (a === '--help' || a === '-h') {
|
|
24
|
+
command = 'help';
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
positionals.push(a);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!command)
|
|
31
|
+
command = positionals[0];
|
|
32
|
+
token = positionals[1];
|
|
33
|
+
return { command, token, apiBase };
|
|
34
|
+
}
|
|
35
|
+
function printHelp() {
|
|
36
|
+
console.log(`
|
|
37
|
+
Rectify Bridge — connect your local agent CLIs to the Rectify dashboard.
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
npx rectify-bridge connect <token> [--api <url>]
|
|
41
|
+
|
|
42
|
+
Commands:
|
|
43
|
+
connect <token> Connect using the setup token shown in the Rectify dashboard.
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--api <url> Override the Rectify API base URL (default: ${DEFAULT_API_BASE}).
|
|
47
|
+
-h, --help Show this help.
|
|
48
|
+
|
|
49
|
+
Keep this window open while connected. Press Ctrl+C to disconnect.
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
async function main() {
|
|
53
|
+
const { command, token, apiBase } = parseArgs(process.argv);
|
|
54
|
+
if (command === 'help' || !command) {
|
|
55
|
+
printHelp();
|
|
56
|
+
process.exit(command ? 0 : 1);
|
|
57
|
+
}
|
|
58
|
+
if (command !== 'connect') {
|
|
59
|
+
warn(`Unknown command: ${command}`);
|
|
60
|
+
printHelp();
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (!token) {
|
|
64
|
+
warn('Missing setup token. Usage: npx rectify-bridge connect <token>');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const api = new ApiClient(apiBase, token);
|
|
68
|
+
log('Fetching connection config from Rectify…');
|
|
69
|
+
const config = await api.fetchConfig();
|
|
70
|
+
log(`Connection type: ${config.mode}`);
|
|
71
|
+
let localTarget;
|
|
72
|
+
let closeServer;
|
|
73
|
+
if (config.mode === 'openclaw') {
|
|
74
|
+
localTarget = config.openclawPort ?? 18789;
|
|
75
|
+
log(`Forwarding your local OpenClaw gateway (port ${localTarget}).`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const reader = config.mode === 'codex_local' ? codex : claude;
|
|
79
|
+
const health = await reader.checkHealth();
|
|
80
|
+
if (!health.ok) {
|
|
81
|
+
warn(`Warning: ${health.error}`);
|
|
82
|
+
warn('The bridge will still start, but data will be empty until the issue is resolved.');
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
log(`${health.cli} detected${health.version ? ` (${health.version})` : ''}.`);
|
|
86
|
+
}
|
|
87
|
+
const server = await startLocalServer(config.mode, config.bridgeToken);
|
|
88
|
+
localTarget = server.port;
|
|
89
|
+
closeServer = server.close;
|
|
90
|
+
void api.reportConnected({
|
|
91
|
+
os: process.platform,
|
|
92
|
+
cliInstalled: health.installed,
|
|
93
|
+
cliVersion: health.version ?? null,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const tunnel = new Tunnel({
|
|
97
|
+
tunnelHost: config.tunnelHost,
|
|
98
|
+
tunnelUser: config.tunnelUser,
|
|
99
|
+
remotePort: config.remotePort,
|
|
100
|
+
localTarget,
|
|
101
|
+
privateKey: config.privateKey,
|
|
102
|
+
});
|
|
103
|
+
tunnel.onUp(() => {
|
|
104
|
+
log('✓ Tunnel connected — keep this window open.');
|
|
105
|
+
if (config.mode === 'openclaw') {
|
|
106
|
+
void api.reportConnected({ os: process.platform });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
await tunnel.start();
|
|
110
|
+
const shutdown = async () => {
|
|
111
|
+
log('Disconnecting…');
|
|
112
|
+
await tunnel.stop();
|
|
113
|
+
closeServer?.();
|
|
114
|
+
process.exit(0);
|
|
115
|
+
};
|
|
116
|
+
process.on('SIGINT', () => void shutdown());
|
|
117
|
+
process.on('SIGTERM', () => void shutdown());
|
|
118
|
+
}
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
warn(err instanceof Error ? err.message : String(err));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, basename } from 'node:path';
|
|
4
|
+
import { parseJsonl, whichBinary, run } from '../util.js';
|
|
5
|
+
const ACTIVE_THRESHOLD_MS = 15 * 60 * 1000;
|
|
6
|
+
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
const MODEL_PRICING = {
|
|
8
|
+
'claude-opus-4-7': { input: 15 / 1_000_000, output: 75 / 1_000_000 },
|
|
9
|
+
'claude-opus-4-6': { input: 15 / 1_000_000, output: 75 / 1_000_000 },
|
|
10
|
+
'claude-sonnet-4-6': { input: 3 / 1_000_000, output: 15 / 1_000_000 },
|
|
11
|
+
'claude-haiku-4-5': { input: 0.8 / 1_000_000, output: 4 / 1_000_000 },
|
|
12
|
+
};
|
|
13
|
+
const DEFAULT_PRICING = { input: 3 / 1_000_000, output: 15 / 1_000_000 };
|
|
14
|
+
const claudeDir = () => join(homedir(), '.claude');
|
|
15
|
+
const projectsDir = () => join(claudeDir(), 'projects');
|
|
16
|
+
async function walkJsonl(dir, depth = 0) {
|
|
17
|
+
if (depth > 4)
|
|
18
|
+
return [];
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const full = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
out.push(...(await walkJsonl(full, depth + 1)));
|
|
31
|
+
}
|
|
32
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
33
|
+
try {
|
|
34
|
+
const stat = await fs.stat(full);
|
|
35
|
+
out.push({ path: full, mtimeMs: stat.mtimeMs });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function summarize(file, entries) {
|
|
45
|
+
if (entries.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
const sessionId = basename(file.path).replace(/\.jsonl$/i, '');
|
|
48
|
+
let model = null;
|
|
49
|
+
let gitBranch = null;
|
|
50
|
+
let cwd = null;
|
|
51
|
+
let userMessages = 0;
|
|
52
|
+
let assistantMessages = 0;
|
|
53
|
+
let inputTokens = 0;
|
|
54
|
+
let outputTokens = 0;
|
|
55
|
+
let firstTs = null;
|
|
56
|
+
let lastTs = null;
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.gitBranch)
|
|
59
|
+
gitBranch = entry.gitBranch;
|
|
60
|
+
if (entry.cwd)
|
|
61
|
+
cwd = entry.cwd;
|
|
62
|
+
if (entry.timestamp) {
|
|
63
|
+
const ts = Date.parse(entry.timestamp);
|
|
64
|
+
if (Number.isFinite(ts)) {
|
|
65
|
+
if (firstTs === null || ts < firstTs)
|
|
66
|
+
firstTs = ts;
|
|
67
|
+
if (lastTs === null || ts > lastTs)
|
|
68
|
+
lastTs = ts;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const msg = entry.message;
|
|
72
|
+
if (!msg)
|
|
73
|
+
continue;
|
|
74
|
+
if (msg.model)
|
|
75
|
+
model = msg.model;
|
|
76
|
+
if (msg.role === 'user')
|
|
77
|
+
userMessages++;
|
|
78
|
+
if (msg.role === 'assistant')
|
|
79
|
+
assistantMessages++;
|
|
80
|
+
if (msg.usage) {
|
|
81
|
+
inputTokens += msg.usage.input_tokens ?? 0;
|
|
82
|
+
outputTokens += msg.usage.output_tokens ?? 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const pricing = (model && MODEL_PRICING[model]) || DEFAULT_PRICING;
|
|
86
|
+
const estimatedCost = inputTokens * pricing.input + outputTokens * pricing.output;
|
|
87
|
+
const isActive = (lastTs ?? file.mtimeMs) > Date.now() - ACTIVE_THRESHOLD_MS;
|
|
88
|
+
const projectSlug = cwd ? basename(cwd) : 'claude';
|
|
89
|
+
return {
|
|
90
|
+
id: sessionId,
|
|
91
|
+
key: sessionId,
|
|
92
|
+
sessionId,
|
|
93
|
+
displayName: projectSlug || sessionId,
|
|
94
|
+
projectSlug,
|
|
95
|
+
projectPath: cwd,
|
|
96
|
+
model,
|
|
97
|
+
gitBranch,
|
|
98
|
+
userMessages,
|
|
99
|
+
assistantMessages,
|
|
100
|
+
inputTokens,
|
|
101
|
+
outputTokens,
|
|
102
|
+
totalTokens: inputTokens + outputTokens,
|
|
103
|
+
estimatedCost,
|
|
104
|
+
firstMessageAt: firstTs ? new Date(firstTs).toISOString() : null,
|
|
105
|
+
lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
|
|
106
|
+
status: isActive ? 'active' : 'idle',
|
|
107
|
+
isActive,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export async function checkHealth() {
|
|
111
|
+
const bin = await whichBinary('claude');
|
|
112
|
+
if (!bin) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
cli: 'claude',
|
|
116
|
+
installed: false,
|
|
117
|
+
error: 'Claude Code CLI not found on this machine. Install with: npm install -g @anthropic-ai/claude-code',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
let version = null;
|
|
121
|
+
try {
|
|
122
|
+
const result = await run('claude', ['--version'], { timeoutMs: 8000 });
|
|
123
|
+
version = result.stdout.trim().split(/\r?\n/)[0] || null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await fs.access(claudeDir());
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
cli: 'claude',
|
|
135
|
+
installed: true,
|
|
136
|
+
version,
|
|
137
|
+
error: '~/.claude directory not found. Run `claude` once to initialize.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, cli: 'claude', installed: true, version };
|
|
141
|
+
}
|
|
142
|
+
export async function listSessions(limit = 50, skip = 0) {
|
|
143
|
+
const files = await walkJsonl(projectsDir());
|
|
144
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
145
|
+
const slice = files.slice(skip, skip + limit + 1);
|
|
146
|
+
const sessions = [];
|
|
147
|
+
for (const file of slice.slice(0, limit)) {
|
|
148
|
+
try {
|
|
149
|
+
const stat = await fs.stat(file.path);
|
|
150
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
151
|
+
continue;
|
|
152
|
+
const content = await fs.readFile(file.path, 'utf8');
|
|
153
|
+
const entries = parseJsonl(content);
|
|
154
|
+
const summary = summarize(file, entries);
|
|
155
|
+
if (summary)
|
|
156
|
+
sessions.push(summary);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// skip unreadable
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { sessions, hasMore: slice.length > limit };
|
|
163
|
+
}
|
|
164
|
+
export async function sessionHistory(key, limit = 100, skip = 0) {
|
|
165
|
+
const files = await walkJsonl(projectsDir());
|
|
166
|
+
const target = files.find((f) => basename(f.path) === `${key}.jsonl`);
|
|
167
|
+
if (!target)
|
|
168
|
+
return { messages: [], hasMore: false };
|
|
169
|
+
const content = await fs.readFile(target.path, 'utf8');
|
|
170
|
+
const entries = parseJsonl(content);
|
|
171
|
+
const messages = entries
|
|
172
|
+
.filter((e) => e.message?.role === 'user' || e.message?.role === 'assistant')
|
|
173
|
+
.map((e) => {
|
|
174
|
+
const m = e.message;
|
|
175
|
+
const text = typeof m.content === 'string'
|
|
176
|
+
? m.content
|
|
177
|
+
: Array.isArray(m.content)
|
|
178
|
+
? m.content.filter((c) => c.type === 'text').map((c) => c.text ?? '').join('\n')
|
|
179
|
+
: '';
|
|
180
|
+
return {
|
|
181
|
+
role: m.role ?? 'assistant',
|
|
182
|
+
content: text,
|
|
183
|
+
timestamp: e.timestamp,
|
|
184
|
+
model: m.model ?? null,
|
|
185
|
+
usage: m.usage
|
|
186
|
+
? { input: m.usage.input_tokens ?? 0, output: m.usage.output_tokens ?? 0 }
|
|
187
|
+
: null,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
const sliced = messages.slice(skip, skip + limit);
|
|
191
|
+
return { messages: sliced, hasMore: skip + limit < messages.length };
|
|
192
|
+
}
|
|
193
|
+
export async function listSkills() {
|
|
194
|
+
const dir = join(claudeDir(), 'skills');
|
|
195
|
+
try {
|
|
196
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
197
|
+
const skills = entries
|
|
198
|
+
.filter((e) => e.isDirectory() || e.name.endsWith('.md'))
|
|
199
|
+
.map((e) => ({
|
|
200
|
+
key: e.name.replace(/\.md$/i, ''),
|
|
201
|
+
name: e.name.replace(/\.md$/i, ''),
|
|
202
|
+
enabled: true,
|
|
203
|
+
source: 'local',
|
|
204
|
+
}));
|
|
205
|
+
return { skills };
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return { skills: [] };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export async function usage() {
|
|
212
|
+
const { sessions } = await listSessions(200, 0);
|
|
213
|
+
let totalCost = 0;
|
|
214
|
+
let totalTokens = 0;
|
|
215
|
+
for (const s of sessions) {
|
|
216
|
+
totalCost += s.estimatedCost;
|
|
217
|
+
totalTokens += s.totalTokens;
|
|
218
|
+
}
|
|
219
|
+
return { totalCost, totalTokens, sessionCount: sessions.length };
|
|
220
|
+
}
|
|
221
|
+
export async function chat(message) {
|
|
222
|
+
const result = await run('claude', ['--print'], { input: message, timeoutMs: 180000 });
|
|
223
|
+
if (result.code !== 0) {
|
|
224
|
+
throw new Error(result.stderr || result.stdout || `claude exited with code ${result.code}`);
|
|
225
|
+
}
|
|
226
|
+
return result.stdout.trim();
|
|
227
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, basename } from 'node:path';
|
|
4
|
+
import { parseJsonl, whichBinary, run } from '../util.js';
|
|
5
|
+
const ACTIVE_THRESHOLD_MS = 90 * 60 * 1000;
|
|
6
|
+
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
const codexDir = () => join(homedir(), '.codex');
|
|
8
|
+
const sessionsDir = () => join(codexDir(), 'sessions');
|
|
9
|
+
async function walkJsonl(dir, depth = 0) {
|
|
10
|
+
if (depth > 4)
|
|
11
|
+
return [];
|
|
12
|
+
let entries;
|
|
13
|
+
try {
|
|
14
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const full = join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
out.push(...(await walkJsonl(full, depth + 1)));
|
|
24
|
+
}
|
|
25
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
26
|
+
try {
|
|
27
|
+
const stat = await fs.stat(full);
|
|
28
|
+
out.push({ path: full, mtimeMs: stat.mtimeMs });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function deriveSessionId(filePath) {
|
|
38
|
+
const stripped = basename(filePath).replace(/\.jsonl$/i, '');
|
|
39
|
+
const match = stripped.match(/([0-9a-f]{8,}-[0-9a-f-]{8,})$/i);
|
|
40
|
+
return match?.[1] ?? stripped;
|
|
41
|
+
}
|
|
42
|
+
function summarize(file, entries) {
|
|
43
|
+
if (entries.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
const sessionId = deriveSessionId(file.path);
|
|
46
|
+
let model = null;
|
|
47
|
+
let userMessages = 0;
|
|
48
|
+
let assistantMessages = 0;
|
|
49
|
+
let inputTokens = 0;
|
|
50
|
+
let outputTokens = 0;
|
|
51
|
+
let firstTs = null;
|
|
52
|
+
let lastTs = null;
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (entry.model)
|
|
55
|
+
model = entry.model;
|
|
56
|
+
const role = entry.message?.role ?? entry.role;
|
|
57
|
+
if (role === 'user')
|
|
58
|
+
userMessages++;
|
|
59
|
+
if (role === 'assistant')
|
|
60
|
+
assistantMessages++;
|
|
61
|
+
if (entry.usage) {
|
|
62
|
+
inputTokens += entry.usage.input_tokens ?? entry.usage.prompt_tokens ?? 0;
|
|
63
|
+
outputTokens += entry.usage.output_tokens ?? entry.usage.completion_tokens ?? 0;
|
|
64
|
+
}
|
|
65
|
+
const tsRaw = entry.timestamp ?? entry.ts;
|
|
66
|
+
if (tsRaw !== undefined) {
|
|
67
|
+
const ts = typeof tsRaw === 'number' ? tsRaw : Date.parse(String(tsRaw));
|
|
68
|
+
if (Number.isFinite(ts)) {
|
|
69
|
+
if (firstTs === null || ts < firstTs)
|
|
70
|
+
firstTs = ts;
|
|
71
|
+
if (lastTs === null || ts > lastTs)
|
|
72
|
+
lastTs = ts;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const isActive = (lastTs ?? file.mtimeMs) > Date.now() - ACTIVE_THRESHOLD_MS;
|
|
77
|
+
return {
|
|
78
|
+
id: sessionId,
|
|
79
|
+
key: sessionId,
|
|
80
|
+
sessionId,
|
|
81
|
+
displayName: sessionId,
|
|
82
|
+
projectSlug: 'codex',
|
|
83
|
+
projectPath: null,
|
|
84
|
+
model,
|
|
85
|
+
userMessages,
|
|
86
|
+
assistantMessages,
|
|
87
|
+
inputTokens,
|
|
88
|
+
outputTokens,
|
|
89
|
+
totalTokens: inputTokens + outputTokens,
|
|
90
|
+
estimatedCost: 0,
|
|
91
|
+
firstMessageAt: firstTs ? new Date(firstTs).toISOString() : null,
|
|
92
|
+
lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
|
|
93
|
+
status: isActive ? 'active' : 'idle',
|
|
94
|
+
isActive,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export async function checkHealth() {
|
|
98
|
+
const bin = (await whichBinary('codex')) || (await whichBinary('codex-cli'));
|
|
99
|
+
if (!bin) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
cli: 'codex',
|
|
103
|
+
installed: false,
|
|
104
|
+
error: 'Codex CLI not found on this machine. Install with: npm install -g @openai/codex',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
let version = null;
|
|
108
|
+
try {
|
|
109
|
+
const result = await run('codex', ['--version'], { timeoutMs: 8000 });
|
|
110
|
+
version = result.stdout.trim().split(/\r?\n/)[0] || null;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await fs.access(codexDir());
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
cli: 'codex',
|
|
122
|
+
installed: true,
|
|
123
|
+
version,
|
|
124
|
+
error: '~/.codex directory not found. Run `codex` once to initialize.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, cli: 'codex', installed: true, version };
|
|
128
|
+
}
|
|
129
|
+
export async function listSessions(limit = 50, skip = 0) {
|
|
130
|
+
const files = await walkJsonl(sessionsDir());
|
|
131
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
132
|
+
const slice = files.slice(skip, skip + limit + 1);
|
|
133
|
+
const sessions = [];
|
|
134
|
+
for (const file of slice.slice(0, limit)) {
|
|
135
|
+
try {
|
|
136
|
+
const stat = await fs.stat(file.path);
|
|
137
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
138
|
+
continue;
|
|
139
|
+
const content = await fs.readFile(file.path, 'utf8');
|
|
140
|
+
const entries = parseJsonl(content);
|
|
141
|
+
const summary = summarize(file, entries);
|
|
142
|
+
if (summary)
|
|
143
|
+
sessions.push(summary);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// skip
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { sessions, hasMore: slice.length > limit };
|
|
150
|
+
}
|
|
151
|
+
export async function sessionHistory(key, limit = 100, skip = 0) {
|
|
152
|
+
const files = await walkJsonl(sessionsDir());
|
|
153
|
+
const target = files.find((f) => deriveSessionId(f.path) === key);
|
|
154
|
+
if (!target)
|
|
155
|
+
return { messages: [], hasMore: false };
|
|
156
|
+
const content = await fs.readFile(target.path, 'utf8');
|
|
157
|
+
const entries = parseJsonl(content);
|
|
158
|
+
const messages = entries
|
|
159
|
+
.filter((e) => {
|
|
160
|
+
const role = e.message?.role ?? e.role;
|
|
161
|
+
return role === 'user' || role === 'assistant';
|
|
162
|
+
})
|
|
163
|
+
.map((e) => {
|
|
164
|
+
const role = e.message?.role ?? e.role ?? 'assistant';
|
|
165
|
+
const rawContent = e.message?.content ?? e.content;
|
|
166
|
+
const text = typeof rawContent === 'string'
|
|
167
|
+
? rawContent
|
|
168
|
+
: Array.isArray(rawContent)
|
|
169
|
+
? rawContent.filter((c) => c?.type === 'text').map((c) => c?.text ?? '').join('\n')
|
|
170
|
+
: '';
|
|
171
|
+
return {
|
|
172
|
+
role,
|
|
173
|
+
content: text,
|
|
174
|
+
timestamp: typeof e.timestamp === 'string' ? e.timestamp : undefined,
|
|
175
|
+
model: e.model ?? null,
|
|
176
|
+
usage: e.usage
|
|
177
|
+
? {
|
|
178
|
+
input: e.usage.input_tokens ?? e.usage.prompt_tokens ?? 0,
|
|
179
|
+
output: e.usage.output_tokens ?? e.usage.completion_tokens ?? 0,
|
|
180
|
+
}
|
|
181
|
+
: null,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
const sliced = messages.slice(skip, skip + limit);
|
|
185
|
+
return { messages: sliced, hasMore: skip + limit < messages.length };
|
|
186
|
+
}
|
|
187
|
+
export async function usage() {
|
|
188
|
+
const { sessions } = await listSessions(200, 0);
|
|
189
|
+
let totalTokens = 0;
|
|
190
|
+
for (const s of sessions)
|
|
191
|
+
totalTokens += s.totalTokens;
|
|
192
|
+
return { totalCost: 0, totalTokens, sessionCount: sessions.length };
|
|
193
|
+
}
|
|
194
|
+
export async function chat(message) {
|
|
195
|
+
const result = await run('codex', ['exec', message], { timeoutMs: 180000 });
|
|
196
|
+
if (result.code !== 0) {
|
|
197
|
+
throw new Error(result.stderr || result.stdout || `codex exited with code ${result.code}`);
|
|
198
|
+
}
|
|
199
|
+
return result.stdout.trim();
|
|
200
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { log, warn } from './util.js';
|
|
4
|
+
import * as claude from './readers/claude.js';
|
|
5
|
+
import * as codex from './readers/codex.js';
|
|
6
|
+
function readerFor(mode) {
|
|
7
|
+
return mode === 'codex_local' ? codex : claude;
|
|
8
|
+
}
|
|
9
|
+
async function readBody(req) {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for await (const chunk of req)
|
|
12
|
+
chunks.push(chunk);
|
|
13
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
14
|
+
}
|
|
15
|
+
function sendJson(res, status, body) {
|
|
16
|
+
const payload = JSON.stringify(body);
|
|
17
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
18
|
+
res.end(payload);
|
|
19
|
+
}
|
|
20
|
+
export function startLocalServer(mode, token) {
|
|
21
|
+
const reader = readerFor(mode);
|
|
22
|
+
const server = http.createServer((req, res) => {
|
|
23
|
+
void handle(req, res).catch((err) => {
|
|
24
|
+
warn('Request error:', err?.message ?? err);
|
|
25
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : 'Internal error' });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
async function handle(req, res) {
|
|
29
|
+
const auth = req.headers['authorization'];
|
|
30
|
+
const bearer = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
|
31
|
+
if (bearer !== token) {
|
|
32
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
36
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
37
|
+
const limit = Number(url.searchParams.get('limit') ?? '50') || 50;
|
|
38
|
+
const skip = Number(url.searchParams.get('skip') ?? '0') || 0;
|
|
39
|
+
if (req.method === 'GET' && path === '/health') {
|
|
40
|
+
const health = await reader.checkHealth();
|
|
41
|
+
sendJson(res, 200, health);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (req.method === 'GET' && path === '/sessions') {
|
|
45
|
+
const result = await reader.listSessions(limit, skip);
|
|
46
|
+
sendJson(res, 200, result);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (req.method === 'GET' && path.startsWith('/sessions/') && path.endsWith('/history')) {
|
|
50
|
+
const key = decodeURIComponent(path.slice('/sessions/'.length, -'/history'.length));
|
|
51
|
+
const result = await reader.sessionHistory(key, limit, skip);
|
|
52
|
+
sendJson(res, 200, result);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (req.method === 'GET' && path === '/skills') {
|
|
56
|
+
const result = 'listSkills' in reader ? await reader.listSkills() : { skills: [] };
|
|
57
|
+
sendJson(res, 200, result);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (req.method === 'GET' && path === '/usage') {
|
|
61
|
+
const result = await reader.usage();
|
|
62
|
+
sendJson(res, 200, result);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (req.method === 'POST' && path === '/chat') {
|
|
66
|
+
const raw = await readBody(req);
|
|
67
|
+
let message = '';
|
|
68
|
+
try {
|
|
69
|
+
message = JSON.parse(raw).message ?? '';
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!message.trim()) {
|
|
76
|
+
sendJson(res, 400, { error: 'message is required' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const content = await reader.chat(message);
|
|
80
|
+
sendJson(res, 200, { content });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
84
|
+
}
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
server.listen(0, '127.0.0.1', () => {
|
|
87
|
+
const addr = server.address();
|
|
88
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
89
|
+
log(`Local API server listening on 127.0.0.1:${port}`);
|
|
90
|
+
resolve({ port, close: () => server.close() });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { log, warn } from './util.js';
|
|
7
|
+
const RECONNECT_DELAYS_MS = [3000, 5000, 10000, 30000];
|
|
8
|
+
export class Tunnel {
|
|
9
|
+
opts;
|
|
10
|
+
child = null;
|
|
11
|
+
keyPath = null;
|
|
12
|
+
stopped = false;
|
|
13
|
+
reconnectAttempt = 0;
|
|
14
|
+
onUpCallback;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.opts = opts;
|
|
17
|
+
}
|
|
18
|
+
onUp(cb) {
|
|
19
|
+
this.onUpCallback = cb;
|
|
20
|
+
}
|
|
21
|
+
async writeKey() {
|
|
22
|
+
const dir = await fs.mkdtemp(join(tmpdir(), 'rectify-bridge-'));
|
|
23
|
+
const keyPath = join(dir, `key_${randomBytes(4).toString('hex')}`);
|
|
24
|
+
await fs.writeFile(keyPath, this.opts.privateKey.endsWith('\n') ? this.opts.privateKey : `${this.opts.privateKey}\n`, { mode: 0o600 });
|
|
25
|
+
if (process.platform !== 'win32') {
|
|
26
|
+
try {
|
|
27
|
+
await fs.chmod(keyPath, 0o600);
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore */ }
|
|
30
|
+
}
|
|
31
|
+
return keyPath;
|
|
32
|
+
}
|
|
33
|
+
async start() {
|
|
34
|
+
this.stopped = false;
|
|
35
|
+
if (!this.keyPath) {
|
|
36
|
+
this.keyPath = await this.writeKey();
|
|
37
|
+
}
|
|
38
|
+
this.spawnSsh();
|
|
39
|
+
}
|
|
40
|
+
spawnSsh() {
|
|
41
|
+
if (this.stopped || !this.keyPath)
|
|
42
|
+
return;
|
|
43
|
+
const { tunnelHost, tunnelUser, remotePort, localTarget } = this.opts;
|
|
44
|
+
const args = [
|
|
45
|
+
'-i', this.keyPath,
|
|
46
|
+
'-N',
|
|
47
|
+
'-R', `127.0.0.1:${remotePort}:localhost:${localTarget}`,
|
|
48
|
+
'-o', 'ServerAliveInterval=30',
|
|
49
|
+
'-o', 'ServerAliveCountMax=3',
|
|
50
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
51
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
52
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
53
|
+
`${tunnelUser}@${tunnelHost}`,
|
|
54
|
+
];
|
|
55
|
+
log(`Opening tunnel: remote ${tunnelHost}:${remotePort} → localhost:${localTarget}`);
|
|
56
|
+
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
57
|
+
this.child = child;
|
|
58
|
+
// Heuristic: if the process stays up ~2s, consider the forward established.
|
|
59
|
+
const upTimer = setTimeout(() => {
|
|
60
|
+
if (!this.stopped && this.child === child && child.exitCode === null) {
|
|
61
|
+
this.reconnectAttempt = 0;
|
|
62
|
+
this.onUpCallback?.();
|
|
63
|
+
}
|
|
64
|
+
}, 2500);
|
|
65
|
+
child.stderr?.on('data', (d) => {
|
|
66
|
+
const line = d.toString().trim();
|
|
67
|
+
if (line)
|
|
68
|
+
warn(`ssh: ${line}`);
|
|
69
|
+
});
|
|
70
|
+
child.on('exit', (code, signal) => {
|
|
71
|
+
clearTimeout(upTimer);
|
|
72
|
+
if (this.stopped)
|
|
73
|
+
return;
|
|
74
|
+
warn(`Tunnel exited (code=${code ?? 'null'} signal=${signal ?? 'null'}). Reconnecting…`);
|
|
75
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
76
|
+
this.reconnectAttempt++;
|
|
77
|
+
setTimeout(() => this.spawnSsh(), delay);
|
|
78
|
+
});
|
|
79
|
+
child.on('error', (err) => {
|
|
80
|
+
clearTimeout(upTimer);
|
|
81
|
+
if (err && err.code === 'ENOENT') {
|
|
82
|
+
warn('`ssh` command not found. Install OpenSSH client (built into macOS, Linux, and Windows 10+).');
|
|
83
|
+
this.stop();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async stop() {
|
|
88
|
+
this.stopped = true;
|
|
89
|
+
if (this.child) {
|
|
90
|
+
this.child.kill();
|
|
91
|
+
this.child = null;
|
|
92
|
+
}
|
|
93
|
+
if (this.keyPath) {
|
|
94
|
+
try {
|
|
95
|
+
const dir = join(this.keyPath, '..');
|
|
96
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
catch { /* ignore */ }
|
|
99
|
+
this.keyPath = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function log(...args) {
|
|
3
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
4
|
+
console.log(`[${ts}]`, ...args);
|
|
5
|
+
}
|
|
6
|
+
export function warn(...args) {
|
|
7
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
8
|
+
console.warn(`[${ts}]`, ...args);
|
|
9
|
+
}
|
|
10
|
+
const IS_WIN = process.platform === 'win32';
|
|
11
|
+
export function run(command, args, options = {}) {
|
|
12
|
+
const timeoutMs = options.timeoutMs ?? 120000;
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(command, args, { shell: IS_WIN });
|
|
15
|
+
let stdout = '';
|
|
16
|
+
let stderr = '';
|
|
17
|
+
let settled = false;
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
if (settled)
|
|
20
|
+
return;
|
|
21
|
+
settled = true;
|
|
22
|
+
child.kill('SIGKILL');
|
|
23
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`));
|
|
24
|
+
}, timeoutMs);
|
|
25
|
+
child.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
26
|
+
child.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
27
|
+
child.on('error', (err) => {
|
|
28
|
+
if (settled)
|
|
29
|
+
return;
|
|
30
|
+
settled = true;
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
reject(err);
|
|
33
|
+
});
|
|
34
|
+
child.on('close', (code) => {
|
|
35
|
+
if (settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve({ stdout, stderr, code });
|
|
40
|
+
});
|
|
41
|
+
if (options.input !== undefined && child.stdin) {
|
|
42
|
+
child.stdin.write(options.input);
|
|
43
|
+
child.stdin.end();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export async function whichBinary(name) {
|
|
48
|
+
try {
|
|
49
|
+
const finder = IS_WIN ? 'where' : 'which';
|
|
50
|
+
const result = await run(finder, [name], { timeoutMs: 5000 });
|
|
51
|
+
if (result.code === 0) {
|
|
52
|
+
const first = result.stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
|
53
|
+
return first ?? null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
export function parseJsonl(content) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const line of content.split(/\r?\n/)) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (!trimmed)
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
out.push(JSON.parse(trimmed));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// skip malformed
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rectify-so/bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rectify AgentPulse local bridge — securely connects locally-installed agent CLIs (Claude Code, Codex) and OpenClaw gateways to the Rectify dashboard over a reverse SSH tunnel.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"rectify-bridge": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"dev": "tsc --watch",
|
|
18
|
+
"start": "node dist/index.js",
|
|
19
|
+
"deploy": "npm run build && npm publish --access=public"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"rectify",
|
|
23
|
+
"agentpulse",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"codex",
|
|
26
|
+
"openclaw",
|
|
27
|
+
"bridge",
|
|
28
|
+
"tunnel"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.11.0",
|
|
33
|
+
"typescript": "^5.4.0"
|
|
34
|
+
}
|
|
35
|
+
}
|