@rectify-so/bridge 0.1.1 → 0.1.3

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/dist/index.js CHANGED
@@ -11,6 +11,7 @@ function parseArgs(argv) {
11
11
  let command;
12
12
  let token;
13
13
  let apiBase = DEFAULT_API_BASE;
14
+ let local = false;
14
15
  const positionals = [];
15
16
  for (let i = 0; i < args.length; i++) {
16
17
  const a = args[i];
@@ -20,6 +21,9 @@ function parseArgs(argv) {
20
21
  else if (a.startsWith('--api=')) {
21
22
  apiBase = a.slice('--api='.length);
22
23
  }
24
+ else if (a === '--local') {
25
+ local = true;
26
+ }
23
27
  else if (a === '--help' || a === '-h') {
24
28
  command = 'help';
25
29
  }
@@ -30,7 +34,7 @@ function parseArgs(argv) {
30
34
  if (!command)
31
35
  command = positionals[0];
32
36
  token = positionals[1];
33
- return { command, token, apiBase };
37
+ return { command, token, apiBase, local };
34
38
  }
35
39
  function printHelp() {
36
40
  console.log(`
@@ -44,13 +48,15 @@ Commands:
44
48
 
45
49
  Options:
46
50
  --api <url> Override the Rectify API base URL (default: ${DEFAULT_API_BASE}).
51
+ --local Dev mode: bind the local server on the connection's port and
52
+ skip the SSH tunnel (for testing against a backend on this machine).
47
53
  -h, --help Show this help.
48
54
 
49
55
  Keep this window open while connected. Press Ctrl+C to disconnect.
50
56
  `);
51
57
  }
52
58
  async function main() {
53
- const { command, token, apiBase } = parseArgs(process.argv);
59
+ const { command, token, apiBase, local } = parseArgs(process.argv);
54
60
  if (command === 'help' || !command) {
55
61
  printHelp();
56
62
  process.exit(command ? 0 : 1);
@@ -68,6 +74,32 @@ async function main() {
68
74
  log('Fetching connection config from Rectify…');
69
75
  const config = await api.fetchConfig();
70
76
  log(`Connection type: ${config.mode}`);
77
+ // Local dev mode: bind the server on the connection's port and skip the tunnel.
78
+ // A backend on this same machine reaches the bridge directly at 127.0.0.1:<remotePort>.
79
+ if (local) {
80
+ if (config.mode === 'openclaw') {
81
+ warn('--local is only supported for Claude Code / Codex connections.');
82
+ process.exit(1);
83
+ }
84
+ const reader = config.mode === 'codex_local' ? codex : claude;
85
+ const health = await reader.checkHealth();
86
+ if (!health.ok)
87
+ warn(`Warning: ${health.error}`);
88
+ else
89
+ log(`${health.cli} detected${health.version ? ` (${health.version})` : ''}.`);
90
+ const server = await startLocalServer(config.mode, config.bridgeToken, config.remotePort);
91
+ void api.reportConnected({
92
+ os: process.platform,
93
+ cliInstalled: health.installed,
94
+ cliVersion: health.version ?? null,
95
+ });
96
+ log(`✓ Local dev mode — serving on 127.0.0.1:${server.port} (tunnel skipped).`);
97
+ log(` Your backend on this machine can reach the bridge directly. Keep this window open.`);
98
+ const shutdownLocal = () => { server.close(); process.exit(0); };
99
+ process.on('SIGINT', shutdownLocal);
100
+ process.on('SIGTERM', shutdownLocal);
101
+ return;
102
+ }
71
103
  let localTarget;
72
104
  let closeServer;
73
105
  if (config.mode === 'openclaw') {
@@ -50,10 +50,14 @@ function summarize(file, entries) {
50
50
  let cwd = null;
51
51
  let userMessages = 0;
52
52
  let assistantMessages = 0;
53
+ let toolUses = 0;
53
54
  let inputTokens = 0;
54
55
  let outputTokens = 0;
56
+ let cacheReadTokens = 0;
57
+ let cacheCreationTokens = 0;
55
58
  let firstTs = null;
56
59
  let lastTs = null;
60
+ let lastUserPrompt = null;
57
61
  for (const entry of entries) {
58
62
  if (entry.gitBranch)
59
63
  gitBranch = entry.gitBranch;
@@ -68,22 +72,41 @@ function summarize(file, entries) {
68
72
  lastTs = ts;
69
73
  }
70
74
  }
75
+ // Skip subagent side-conversations so counts/tokens aren't inflated.
76
+ if (entry.isSidechain)
77
+ continue;
71
78
  const msg = entry.message;
72
79
  if (!msg)
73
80
  continue;
74
81
  if (msg.model)
75
82
  model = msg.model;
76
- if (msg.role === 'user')
83
+ if (msg.role === 'user') {
77
84
  userMessages++;
78
- if (msg.role === 'assistant')
85
+ if (typeof msg.content === 'string' && msg.content.length > 0) {
86
+ lastUserPrompt = msg.content.slice(0, 500);
87
+ }
88
+ }
89
+ if (msg.role === 'assistant') {
79
90
  assistantMessages++;
91
+ if (Array.isArray(msg.content)) {
92
+ for (const block of msg.content) {
93
+ if (block.type === 'tool_use')
94
+ toolUses++;
95
+ }
96
+ }
97
+ }
80
98
  if (msg.usage) {
81
99
  inputTokens += msg.usage.input_tokens ?? 0;
82
100
  outputTokens += msg.usage.output_tokens ?? 0;
101
+ cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
102
+ cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0;
83
103
  }
84
104
  }
85
105
  const pricing = (model && MODEL_PRICING[model]) || DEFAULT_PRICING;
86
- const estimatedCost = inputTokens * pricing.input + outputTokens * pricing.output;
106
+ const estimatedCost = inputTokens * pricing.input +
107
+ cacheReadTokens * pricing.input * 0.1 +
108
+ cacheCreationTokens * pricing.input * 1.25 +
109
+ outputTokens * pricing.output;
87
110
  const isActive = (lastTs ?? file.mtimeMs) > Date.now() - ACTIVE_THRESHOLD_MS;
88
111
  const projectSlug = cwd ? basename(cwd) : 'claude';
89
112
  return {
@@ -97,12 +120,16 @@ function summarize(file, entries) {
97
120
  gitBranch,
98
121
  userMessages,
99
122
  assistantMessages,
123
+ toolUses,
100
124
  inputTokens,
101
125
  outputTokens,
102
- totalTokens: inputTokens + outputTokens,
103
- estimatedCost,
126
+ cacheReadTokens,
127
+ cacheCreationTokens,
128
+ totalTokens: inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens,
129
+ estimatedCost: Math.round(estimatedCost * 10000) / 10000,
104
130
  firstMessageAt: firstTs ? new Date(firstTs).toISOString() : null,
105
131
  lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
132
+ lastUserPrompt,
106
133
  status: isActive ? 'active' : 'idle',
107
134
  isActive,
108
135
  };
@@ -161,6 +188,33 @@ export async function listSessions(limit = 50, skip = 0) {
161
188
  }
162
189
  return { sessions, hasMore: slice.length > limit };
163
190
  }
191
+ function toParts(content) {
192
+ if (typeof content === 'string') {
193
+ return content ? [{ type: 'text', text: content }] : [];
194
+ }
195
+ const parts = [];
196
+ for (const block of content) {
197
+ if (block.type === 'text' && block.text) {
198
+ parts.push({ type: 'text', text: block.text });
199
+ }
200
+ else if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.trim()) {
201
+ parts.push({ type: 'thinking', thinking: block.thinking.slice(0, 4000) });
202
+ }
203
+ else if (block.type === 'tool_use') {
204
+ const input = block.input !== undefined ? JSON.stringify(block.input) : '';
205
+ parts.push({ type: 'tool_use', name: block.name ?? 'tool', input: input.slice(0, 2000) });
206
+ }
207
+ else if (block.type === 'tool_result') {
208
+ const c = typeof block.content === 'string'
209
+ ? block.content
210
+ : Array.isArray(block.content)
211
+ ? block.content.map((b) => b?.text ?? '').join('\n')
212
+ : block.content !== undefined ? JSON.stringify(block.content) : '';
213
+ parts.push({ type: 'tool_result', content: String(c).slice(0, 4000), isError: block.is_error === true });
214
+ }
215
+ }
216
+ return parts;
217
+ }
164
218
  export async function sessionHistory(key, limit = 100, skip = 0) {
165
219
  const files = await walkJsonl(projectsDir());
166
220
  const target = files.find((f) => basename(f.path) === `${key}.jsonl`);
@@ -169,27 +223,63 @@ export async function sessionHistory(key, limit = 100, skip = 0) {
169
223
  const content = await fs.readFile(target.path, 'utf8');
170
224
  const entries = parseJsonl(content);
171
225
  const messages = entries
172
- .filter((e) => e.message?.role === 'user' || e.message?.role === 'assistant')
226
+ .filter((e) => !e.isSidechain && (e.message?.role === 'user' || e.message?.role === 'assistant'))
173
227
  .map((e) => {
174
228
  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
- : '';
229
+ const parts = toParts(m.content ?? '');
180
230
  return {
181
231
  role: m.role ?? 'assistant',
182
- content: text,
232
+ content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts,
183
233
  timestamp: e.timestamp,
184
234
  model: m.model ?? null,
185
235
  usage: m.usage
186
236
  ? { input: m.usage.input_tokens ?? 0, output: m.usage.output_tokens ?? 0 }
187
237
  : null,
188
238
  };
189
- });
239
+ })
240
+ .filter((m) => m.content.length > 0);
190
241
  const sliced = messages.slice(skip, skip + limit);
191
242
  return { messages: sliced, hasMore: skip + limit < messages.length };
192
243
  }
244
+ export async function sessionUsage(key) {
245
+ const empty = { input: 0, output: 0, totalTokens: 0, cost: 0, totalCost: 0, requests: 0, requestCount: 0 };
246
+ const files = await walkJsonl(projectsDir());
247
+ const target = files.find((f) => basename(f.path) === `${key}.jsonl`);
248
+ if (!target)
249
+ return empty;
250
+ const content = await fs.readFile(target.path, 'utf8');
251
+ const summary = summarize(target, parseJsonl(content));
252
+ if (!summary)
253
+ return empty;
254
+ const input = summary.inputTokens + summary.cacheReadTokens + summary.cacheCreationTokens;
255
+ return {
256
+ input,
257
+ output: summary.outputTokens,
258
+ totalTokens: summary.totalTokens,
259
+ cost: summary.estimatedCost,
260
+ totalCost: summary.estimatedCost,
261
+ requests: summary.assistantMessages,
262
+ requestCount: summary.assistantMessages,
263
+ totals: { input, output: summary.outputTokens, total: summary.totalTokens, cost: summary.estimatedCost, requests: summary.assistantMessages },
264
+ };
265
+ }
266
+ async function resolveSessionCwd(key) {
267
+ const files = await walkJsonl(projectsDir());
268
+ const target = files.find((f) => basename(f.path) === `${key}.jsonl`);
269
+ if (!target)
270
+ return undefined;
271
+ try {
272
+ const content = await fs.readFile(target.path, 'utf8');
273
+ for (const entry of parseJsonl(content)) {
274
+ if (entry.cwd)
275
+ return entry.cwd;
276
+ }
277
+ }
278
+ catch {
279
+ // ignore
280
+ }
281
+ return undefined;
282
+ }
193
283
  export async function listSkills() {
194
284
  const dir = join(claudeDir(), 'skills');
195
285
  try {
@@ -218,8 +308,29 @@ export async function usage() {
218
308
  }
219
309
  return { totalCost, totalTokens, sessionCount: sessions.length };
220
310
  }
221
- export async function chat(message) {
222
- const result = await run('claude', ['--print'], { input: message, timeoutMs: 180000 });
311
+ export async function chat(message, sessionKey) {
312
+ // Only resume when the session key matches a real Claude session file
313
+ // (the Agents-page chat uses a synthetic key with no transcript to resume).
314
+ let resume = false;
315
+ let cwd;
316
+ if (sessionKey) {
317
+ const files = await walkJsonl(projectsDir());
318
+ const target = files.find((f) => basename(f.path) === `${sessionKey}.jsonl`);
319
+ if (target) {
320
+ resume = true;
321
+ cwd = await resolveSessionCwd(sessionKey);
322
+ }
323
+ }
324
+ const tryRun = (doResume) => {
325
+ const args = ['--print'];
326
+ if (doResume && sessionKey)
327
+ args.push('--resume', sessionKey);
328
+ return run('claude', args, { input: message, timeoutMs: 180000, cwd });
329
+ };
330
+ let result = await tryRun(resume);
331
+ if (result.code !== 0 && resume && /no conversation found|not a uuid|valid session|not found|unknown session/i.test(result.stderr)) {
332
+ result = await tryRun(false);
333
+ }
223
334
  if (result.code !== 0) {
224
335
  throw new Error(result.stderr || result.stdout || `claude exited with code ${result.code}`);
225
336
  }
@@ -46,18 +46,26 @@ function summarize(file, entries) {
46
46
  let model = null;
47
47
  let userMessages = 0;
48
48
  let assistantMessages = 0;
49
+ let toolUses = 0;
49
50
  let inputTokens = 0;
50
51
  let outputTokens = 0;
51
52
  let firstTs = null;
52
53
  let lastTs = null;
54
+ let lastUserPrompt = null;
53
55
  for (const entry of entries) {
54
56
  if (entry.model)
55
57
  model = entry.model;
56
58
  const role = entry.message?.role ?? entry.role;
57
- if (role === 'user')
59
+ if (role === 'user') {
58
60
  userMessages++;
61
+ const c = entry.message?.content ?? entry.content;
62
+ if (typeof c === 'string' && c.length > 0)
63
+ lastUserPrompt = c.slice(0, 500);
64
+ }
59
65
  if (role === 'assistant')
60
66
  assistantMessages++;
67
+ if (entry.type === 'function_call' || entry.type === 'tool_use' || entry.type === 'local_shell_call')
68
+ toolUses++;
61
69
  if (entry.usage) {
62
70
  inputTokens += entry.usage.input_tokens ?? entry.usage.prompt_tokens ?? 0;
63
71
  outputTokens += entry.usage.output_tokens ?? entry.usage.completion_tokens ?? 0;
@@ -84,12 +92,16 @@ function summarize(file, entries) {
84
92
  model,
85
93
  userMessages,
86
94
  assistantMessages,
95
+ toolUses,
87
96
  inputTokens,
88
97
  outputTokens,
98
+ cacheReadTokens: 0,
99
+ cacheCreationTokens: 0,
89
100
  totalTokens: inputTokens + outputTokens,
90
101
  estimatedCost: 0,
91
102
  firstMessageAt: firstTs ? new Date(firstTs).toISOString() : null,
92
103
  lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
104
+ lastUserPrompt,
93
105
  status: isActive ? 'active' : 'idle',
94
106
  isActive,
95
107
  };
@@ -184,6 +196,27 @@ export async function sessionHistory(key, limit = 100, skip = 0) {
184
196
  const sliced = messages.slice(skip, skip + limit);
185
197
  return { messages: sliced, hasMore: skip + limit < messages.length };
186
198
  }
199
+ export async function sessionUsage(key) {
200
+ const empty = { input: 0, output: 0, totalTokens: 0, cost: 0, totalCost: 0, requests: 0, requestCount: 0 };
201
+ const files = await walkJsonl(sessionsDir());
202
+ const target = files.find((f) => deriveSessionId(f.path) === key);
203
+ if (!target)
204
+ return empty;
205
+ const content = await fs.readFile(target.path, 'utf8');
206
+ const summary = summarize(target, parseJsonl(content));
207
+ if (!summary)
208
+ return empty;
209
+ return {
210
+ input: summary.inputTokens,
211
+ output: summary.outputTokens,
212
+ totalTokens: summary.totalTokens,
213
+ cost: summary.estimatedCost,
214
+ totalCost: summary.estimatedCost,
215
+ requests: summary.assistantMessages,
216
+ requestCount: summary.assistantMessages,
217
+ totals: { input: summary.inputTokens, output: summary.outputTokens, total: summary.totalTokens, cost: summary.estimatedCost, requests: summary.assistantMessages },
218
+ };
219
+ }
187
220
  export async function usage() {
188
221
  const { sessions } = await listSessions(200, 0);
189
222
  let totalTokens = 0;
@@ -191,8 +224,22 @@ export async function usage() {
191
224
  totalTokens += s.totalTokens;
192
225
  return { totalCost: 0, totalTokens, sessionCount: sessions.length };
193
226
  }
194
- export async function chat(message) {
195
- const result = await run('codex', ['exec', message], { timeoutMs: 180000 });
227
+ export async function chat(message, sessionKey) {
228
+ // Only resume when the key matches a real session file (the Agents-page chat
229
+ // uses a synthetic key with no session to resume). cross-spawn passes the
230
+ // prompt as a discrete arg with no shell, so it can't be injected.
231
+ let resume = false;
232
+ if (sessionKey) {
233
+ const files = await walkJsonl(sessionsDir());
234
+ resume = files.some((f) => deriveSessionId(f.path) === sessionKey);
235
+ }
236
+ const args = resume && sessionKey
237
+ ? ['exec', 'resume', sessionKey, message, '--skip-git-repo-check']
238
+ : ['exec', message];
239
+ let result = await run('codex', args, { timeoutMs: 180000 });
240
+ if (result.code !== 0 && resume && /not found|no session|unknown/i.test(result.stderr)) {
241
+ result = await run('codex', ['exec', message], { timeoutMs: 180000 });
242
+ }
196
243
  if (result.code !== 0) {
197
244
  throw new Error(result.stderr || result.stdout || `codex exited with code ${result.code}`);
198
245
  }
package/dist/server.js CHANGED
@@ -17,7 +17,7 @@ function sendJson(res, status, body) {
17
17
  res.writeHead(status, { 'Content-Type': 'application/json' });
18
18
  res.end(payload);
19
19
  }
20
- export function startLocalServer(mode, token) {
20
+ export function startLocalServer(mode, token, fixedPort = 0) {
21
21
  const reader = readerFor(mode);
22
22
  const server = http.createServer((req, res) => {
23
23
  void handle(req, res).catch((err) => {
@@ -52,6 +52,12 @@ export function startLocalServer(mode, token) {
52
52
  sendJson(res, 200, result);
53
53
  return;
54
54
  }
55
+ if (req.method === 'GET' && path.startsWith('/sessions/') && path.endsWith('/usage')) {
56
+ const key = decodeURIComponent(path.slice('/sessions/'.length, -'/usage'.length));
57
+ const result = await reader.sessionUsage(key);
58
+ sendJson(res, 200, result);
59
+ return;
60
+ }
55
61
  if (req.method === 'GET' && path === '/skills') {
56
62
  const result = 'listSkills' in reader ? await reader.listSkills() : { skills: [] };
57
63
  sendJson(res, 200, result);
@@ -65,8 +71,11 @@ export function startLocalServer(mode, token) {
65
71
  if (req.method === 'POST' && path === '/chat') {
66
72
  const raw = await readBody(req);
67
73
  let message = '';
74
+ let sessionKey;
68
75
  try {
69
- message = JSON.parse(raw).message ?? '';
76
+ const body = JSON.parse(raw);
77
+ message = body.message ?? '';
78
+ sessionKey = body.sessionKey;
70
79
  }
71
80
  catch {
72
81
  sendJson(res, 400, { error: 'Invalid JSON body' });
@@ -76,14 +85,15 @@ export function startLocalServer(mode, token) {
76
85
  sendJson(res, 400, { error: 'message is required' });
77
86
  return;
78
87
  }
79
- const content = await reader.chat(message);
88
+ const content = await reader.chat(message, sessionKey);
80
89
  sendJson(res, 200, { content });
81
90
  return;
82
91
  }
83
92
  sendJson(res, 404, { error: 'Not found' });
84
93
  }
85
- return new Promise((resolve) => {
86
- server.listen(0, '127.0.0.1', () => {
94
+ return new Promise((resolve, reject) => {
95
+ server.once('error', reject);
96
+ server.listen(fixedPort, '127.0.0.1', () => {
87
97
  const addr = server.address();
88
98
  const port = typeof addr === 'object' && addr ? addr.port : 0;
89
99
  log(`Local API server listening on 127.0.0.1:${port}`);
package/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import spawn from 'cross-spawn';
2
2
  export function log(...args) {
3
3
  const ts = new Date().toISOString().slice(11, 19);
4
4
  console.log(`[${ts}]`, ...args);
@@ -11,7 +11,9 @@ const IS_WIN = process.platform === 'win32';
11
11
  export function run(command, args, options = {}) {
12
12
  const timeoutMs = options.timeoutMs ?? 120000;
13
13
  return new Promise((resolve, reject) => {
14
- const child = spawn(command, args, { shell: IS_WIN });
14
+ // cross-spawn safely runs Windows .cmd/.bat shims WITHOUT a shell, so
15
+ // arguments (including user chat prompts) can never be interpreted as shell commands.
16
+ const child = spawn(command, args, { shell: false, cwd: options.cwd });
15
17
  let stdout = '';
16
18
  let stderr = '';
17
19
  let settled = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rectify-so/bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,11 @@
28
28
  "tunnel"
29
29
  ],
30
30
  "license": "MIT",
31
+ "dependencies": {
32
+ "cross-spawn": "^7.0.6"
33
+ },
31
34
  "devDependencies": {
35
+ "@types/cross-spawn": "^6.0.6",
32
36
  "@types/node": "^20.11.0",
33
37
  "typescript": "^5.4.0"
34
38
  }