@strideops/bridge 0.1.1

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/cli.js ADDED
@@ -0,0 +1,1023 @@
1
+ #!/usr/bin/env node
2
+ import { requireConfig, log, readConfig, CONFIG_PATH, isConfigured, writeConfig, logError, logWarn, readJsonSafe, PENDING_REPORTS_PATH, AGENTS_DIR, writeJsonAtomic } from './chunk-GBLMB3XB.js';
3
+ import { Command } from 'commander';
4
+ import { homedir, cpus, totalmem, release, platform, arch, hostname } from 'os';
5
+ import { join, dirname } from 'path';
6
+ import { existsSync, mkdirSync, accessSync, constants, writeFileSync, chmodSync } from 'fs';
7
+ import { execSync, spawn } from 'child_process';
8
+
9
+ // src/version.ts
10
+ var VERSION = "0.1.0";
11
+ async function runPair(pairingCode, apiBaseUrl) {
12
+ const url = `${apiBaseUrl.replace(/\/$/, "")}/api/bridge/v1/pair`;
13
+ log(`Pairing with ${apiBaseUrl} ...`);
14
+ const cpuCount = cpus().length;
15
+ const totalMemMb = Math.round(totalmem() / 1024 / 1024);
16
+ const payload = {
17
+ pairingCode,
18
+ daemonVersion: VERSION,
19
+ machineInfo: {
20
+ hostname: hostname(),
21
+ platform: platform(),
22
+ arch: arch(),
23
+ cpus: cpuCount,
24
+ totalMemMb
25
+ },
26
+ osInfo: {
27
+ platform: platform(),
28
+ release: release()
29
+ }
30
+ };
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), 15e3);
33
+ let res;
34
+ try {
35
+ res = await fetch(url, {
36
+ method: "POST",
37
+ signal: controller.signal,
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify(payload)
40
+ });
41
+ } catch (err) {
42
+ clearTimeout(timer);
43
+ if (err instanceof Error && err.name === "AbortError") {
44
+ console.error("[stride-bridge] Pairing request timed out. Check your connection.");
45
+ process.exit(1);
46
+ }
47
+ console.error(
48
+ `[stride-bridge] Network error during pairing: ${err instanceof Error ? err.message : String(err)}`
49
+ );
50
+ process.exit(1);
51
+ } finally {
52
+ clearTimeout(timer);
53
+ }
54
+ let body;
55
+ try {
56
+ body = await res.json();
57
+ } catch {
58
+ console.error(
59
+ `[stride-bridge] Failed to parse pairing response (HTTP ${res.status}).`
60
+ );
61
+ process.exit(1);
62
+ }
63
+ if (!res.ok || !body.success || !body.data) {
64
+ console.error(
65
+ `[stride-bridge] Pairing failed: ${body.error ?? `HTTP ${res.status}`}`
66
+ );
67
+ process.exit(1);
68
+ }
69
+ const { bridgeId, orgId, name, authMode, bridgeToken } = body.data;
70
+ writeConfig({
71
+ apiBaseUrl,
72
+ bridgeToken,
73
+ bridgeId,
74
+ orgId,
75
+ bridgeName: name,
76
+ authMode
77
+ });
78
+ console.log(`[stride-bridge] Connected successfully!`);
79
+ console.log(` Bridge name : ${name}`);
80
+ console.log(` Bridge ID : ${bridgeId}`);
81
+ console.log(` Org ID : ${orgId}`);
82
+ console.log(` Auth mode : ${authMode}`);
83
+ console.log(` Config : ~/.stride-bridge/config.json`);
84
+ console.log();
85
+ console.log(`Run \`stride-bridge start\` to begin accepting work.`);
86
+ }
87
+
88
+ // src/api.ts
89
+ var REQUEST_TIMEOUT_MS = 1e4;
90
+ var ApiError = class extends Error {
91
+ constructor(status, message) {
92
+ super(message);
93
+ this.status = status;
94
+ this.name = "ApiError";
95
+ }
96
+ status;
97
+ };
98
+ async function authedFetch(config, path, init = {}) {
99
+ const url = `${config.apiBaseUrl.replace(/\/$/, "")}${path}`;
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
102
+ let res;
103
+ try {
104
+ res = await fetch(url, {
105
+ ...init,
106
+ signal: controller.signal,
107
+ headers: {
108
+ "Content-Type": "application/json",
109
+ Authorization: `Bearer ${config.bridgeToken}`,
110
+ ...init.headers
111
+ }
112
+ });
113
+ } catch (err) {
114
+ clearTimeout(timer);
115
+ if (err instanceof Error && err.name === "AbortError") {
116
+ throw new ApiError(null, `Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${path}`);
117
+ }
118
+ throw new ApiError(null, `Network error: ${err instanceof Error ? err.message : String(err)}`);
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
122
+ let body;
123
+ try {
124
+ body = await res.json();
125
+ } catch {
126
+ throw new ApiError(res.status, `Failed to parse JSON response from ${path} (HTTP ${res.status})`);
127
+ }
128
+ if (!res.ok || !body.success) {
129
+ throw new ApiError(
130
+ res.status,
131
+ body.error ?? `API error from ${path} (HTTP ${res.status})`
132
+ );
133
+ }
134
+ return body.data;
135
+ }
136
+ async function post(config, path, body) {
137
+ return authedFetch(config, path, {
138
+ method: "POST",
139
+ body: JSON.stringify(body)
140
+ });
141
+ }
142
+ async function get(config, path) {
143
+ return authedFetch(config, path, { method: "GET" });
144
+ }
145
+ async function postBestEffort(config, path, body) {
146
+ try {
147
+ await post(config, path, body);
148
+ } catch {
149
+ }
150
+ }
151
+ function buildHookRunnerScript(preToolUseUrl, hookSecret) {
152
+ return `#!/usr/bin/env node
153
+ // Auto-generated by stride-bridge. Do not edit.
154
+ // Signed PreToolUse hook runner for Stride Build agents.
155
+ // HMAC scheme: HMAC-SHA256(timestamp+"\\n"+body) -> hex
156
+ // Headers: x-stride-hook-ts, x-stride-hook-sig
157
+
158
+ import { createHmac } from "node:crypto";
159
+ import { readFileSync } from "node:fs";
160
+
161
+ const HOOK_SECRET = ${JSON.stringify(hookSecret)};
162
+ const PRE_TOOL_USE_URL = ${JSON.stringify(preToolUseUrl)};
163
+ const TIMEOUT_MS = 8_000;
164
+
165
+ function allow() {
166
+ // No output + exit 0 = allow in Claude Code's hook protocol.
167
+ process.exit(0);
168
+ }
169
+
170
+ async function main() {
171
+ // Claude Code writes the hook input to stdin as JSON. fd 0 works on
172
+ // Windows too ("/dev/stdin" does not).
173
+ let input = {};
174
+ try {
175
+ input = JSON.parse(readFileSync(0, "utf-8"));
176
+ } catch {
177
+ allow(); // unreadable input \u2014 fail-open
178
+ }
179
+
180
+ // Transform Claude Code's hook input into the server's PreToolUse schema.
181
+ const body = JSON.stringify({
182
+ tool: typeof input.tool_name === "string" && input.tool_name ? input.tool_name : "unknown",
183
+ args: typeof input.tool_input === "object" && input.tool_input !== null ? input.tool_input : undefined,
184
+ sessionId: typeof input.session_id === "string" ? input.session_id : undefined,
185
+ });
186
+
187
+ const ts = Math.floor(Date.now() / 1000).toString();
188
+ const sig = createHmac("sha256", HOOK_SECRET)
189
+ .update(ts + "\\n" + body)
190
+ .digest("hex");
191
+
192
+ const controller = new AbortController();
193
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
194
+ const res = await fetch(PRE_TOOL_USE_URL, {
195
+ method: "POST",
196
+ signal: controller.signal,
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ "x-stride-hook-ts": ts,
200
+ "x-stride-hook-sig": sig,
201
+ },
202
+ body,
203
+ });
204
+ clearTimeout(timer);
205
+
206
+ const decision = await res.json();
207
+ if (decision && (decision.permissionDecision === "deny" || decision.permissionDecision === "ask")) {
208
+ // Forward Stride's decision so Claude Code actually blocks the call.
209
+ process.stdout.write(JSON.stringify({
210
+ hookSpecificOutput: {
211
+ hookEventName: "PreToolUse",
212
+ permissionDecision: decision.permissionDecision,
213
+ permissionDecisionReason: decision.permissionDecisionReason || "Blocked by Stride policy",
214
+ },
215
+ }) + "\\n");
216
+ }
217
+ process.exit(0);
218
+ }
219
+
220
+ // Fail-open: an unreachable server or unexpected error must never block the agent.
221
+ main().catch(() => allow());
222
+ `;
223
+ }
224
+ function writeAgentHooks(opts) {
225
+ const { agentId, hookSecret, apiBaseUrl, workspaceDir } = opts;
226
+ const base = apiBaseUrl.replace(/\/$/, "");
227
+ const preToolUseUrl = `${base}/api/internal/build/agents/${agentId}/hook/pre-tool-use`;
228
+ const claudeDir = join(workspaceDir, ".claude");
229
+ mkdirSync(claudeDir, { recursive: true });
230
+ const runnerPath = join(claudeDir, "hook-runner.mjs");
231
+ const runnerContent = buildHookRunnerScript(preToolUseUrl, hookSecret);
232
+ writeFileSync(runnerPath, runnerContent, { encoding: "utf-8", mode: 448 });
233
+ if (process.platform !== "win32") {
234
+ try {
235
+ chmodSync(runnerPath, 448);
236
+ } catch {
237
+ }
238
+ }
239
+ const preToolUseCmd = `"${process.execPath}" "${runnerPath}"`;
240
+ const settingsJson = {
241
+ hooks: {
242
+ PreToolUse: [
243
+ {
244
+ matcher: ".*",
245
+ hooks: [
246
+ {
247
+ type: "command",
248
+ command: preToolUseCmd,
249
+ timeout: 10
250
+ }
251
+ ]
252
+ }
253
+ ]
254
+ }
255
+ };
256
+ writeJsonAtomic(join(claudeDir, "settings.json"), settingsJson);
257
+ }
258
+ function writeMemorySyncScript(opts) {
259
+ const { agentId, hookSecret, apiBaseUrl, workspaceDir } = opts;
260
+ const base = apiBaseUrl.replace(/\/$/, "");
261
+ const syncUrl = `${base}/api/internal/build/agents/${agentId}/memory/sync`;
262
+ const script = `#!/usr/bin/env node
263
+ // Auto-generated by stride-bridge. Do not edit.
264
+ // Uploads MEMORY.md + today's daily journal to Stride (HMAC-signed).
265
+
266
+ import { createHmac } from "node:crypto";
267
+ import { readFileSync, existsSync } from "node:fs";
268
+ import { join, dirname } from "node:path";
269
+ import { fileURLToPath } from "node:url";
270
+
271
+ const HOOK_SECRET = ${JSON.stringify(hookSecret)};
272
+ const SYNC_URL = ${JSON.stringify(syncUrl)};
273
+ const workspace = dirname(dirname(fileURLToPath(import.meta.url)));
274
+
275
+ const files = [];
276
+ const memoryMd = join(workspace, "MEMORY.md");
277
+ if (existsSync(memoryMd)) {
278
+ files.push({ type: "learnings", content: readFileSync(memoryMd, "utf-8").slice(0, 50000) });
279
+ }
280
+ const today = new Date().toISOString().slice(0, 10);
281
+ const daily = join(workspace, "memory", today + ".md");
282
+ if (existsSync(daily)) {
283
+ files.push({ type: "daily", date: today, content: readFileSync(daily, "utf-8").slice(0, 50000) });
284
+ }
285
+
286
+ if (files.length === 0) {
287
+ console.log("memory-sync: nothing to sync");
288
+ process.exit(0);
289
+ }
290
+
291
+ const body = JSON.stringify({ files });
292
+ const ts = Math.floor(Date.now() / 1000).toString();
293
+ const sig = createHmac("sha256", HOOK_SECRET).update(ts + "\\n" + body).digest("hex");
294
+
295
+ const res = await fetch(SYNC_URL, {
296
+ method: "POST",
297
+ headers: {
298
+ "Content-Type": "application/json",
299
+ "x-stride-hook-ts": ts,
300
+ "x-stride-hook-sig": sig,
301
+ },
302
+ body,
303
+ }).catch((err) => ({ ok: false, statusText: String(err) }));
304
+
305
+ console.log(res.ok ? \`memory-sync: synced \${files.length} file(s)\` : \`memory-sync: failed (\${res.statusText ?? res.status})\`);
306
+ `;
307
+ const claudeDir = join(workspaceDir, ".claude");
308
+ mkdirSync(claudeDir, { recursive: true });
309
+ writeFileSync(join(claudeDir, "memory-sync.mjs"), script, { encoding: "utf-8", mode: 448 });
310
+ }
311
+ function writeIdentitySyncScript(opts) {
312
+ const { agentId, hookSecret, apiBaseUrl, workspaceDir } = opts;
313
+ const base = apiBaseUrl.replace(/\/$/, "");
314
+ const identityUrl = `${base}/api/internal/build/agents/${agentId}/identity`;
315
+ const script = `#!/usr/bin/env node
316
+ // Auto-generated by stride-bridge. Do not edit.
317
+ // Uploads identity files + goals to Stride and marks onboarding complete.
318
+
319
+ import { createHmac } from "node:crypto";
320
+ import { readFileSync, existsSync } from "node:fs";
321
+ import { join, dirname } from "node:path";
322
+ import { fileURLToPath } from "node:url";
323
+
324
+ const HOOK_SECRET = ${JSON.stringify(hookSecret)};
325
+ const IDENTITY_URL = ${JSON.stringify(identityUrl)};
326
+ const workspace = dirname(dirname(fileURLToPath(import.meta.url)));
327
+
328
+ function readIf(name) {
329
+ const p = join(workspace, name);
330
+ return existsSync(p) ? readFileSync(p, "utf-8").slice(0, 20000) : undefined;
331
+ }
332
+
333
+ const payload = {
334
+ soulMd: readIf("SOUL.md"),
335
+ identityMd: readIf("IDENTITY.md"),
336
+ userMd: readIf("USER.md"),
337
+ heartbeatChecklist: readIf("HEARTBEAT.md"),
338
+ onboardingComplete: true,
339
+ };
340
+ const goalsRaw = readIf("goals.json");
341
+ if (goalsRaw) {
342
+ try {
343
+ const parsed = JSON.parse(goalsRaw);
344
+ if (Array.isArray(parsed.goals)) payload.goals = parsed.goals.slice(0, 5);
345
+ } catch { /* ignore malformed goals.json */ }
346
+ }
347
+
348
+ const body = JSON.stringify(payload);
349
+ const ts = Math.floor(Date.now() / 1000).toString();
350
+ const sig = createHmac("sha256", HOOK_SECRET).update(ts + "\\n" + body).digest("hex");
351
+
352
+ const res = await fetch(IDENTITY_URL, {
353
+ method: "POST",
354
+ headers: {
355
+ "Content-Type": "application/json",
356
+ "x-stride-hook-ts": ts,
357
+ "x-stride-hook-sig": sig,
358
+ },
359
+ body,
360
+ }).catch((err) => ({ ok: false, statusText: String(err) }));
361
+
362
+ console.log(res.ok ? "identity-sync: onboarding recorded in Stride" : \`identity-sync: failed (\${res.statusText ?? res.status})\`);
363
+ `;
364
+ const claudeDir = join(workspaceDir, ".claude");
365
+ mkdirSync(claudeDir, { recursive: true });
366
+ writeFileSync(join(claudeDir, "identity-sync.mjs"), script, { encoding: "utf-8", mode: 448 });
367
+ }
368
+
369
+ // src/agents.ts
370
+ var cachedAgents = [];
371
+ var REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
372
+ var refreshTimer = null;
373
+ async function refreshAgents(config) {
374
+ try {
375
+ const data = await get(config, "/api/bridge/v1/agents");
376
+ cachedAgents = data.agents ?? [];
377
+ log(`Refreshed ${cachedAgents.length} agent(s)`);
378
+ for (const agent of cachedAgents) {
379
+ await ensureAgentWorkspace(config, agent);
380
+ }
381
+ } catch (err) {
382
+ logError("Failed to refresh agents", err);
383
+ }
384
+ }
385
+ function findAgent(agentId) {
386
+ return cachedAgents.find((a) => a.id === agentId);
387
+ }
388
+ async function ensureAgentWorkspace(config, agent) {
389
+ const workspaceDir = agentWorkspaceDir(agent.id);
390
+ mkdirSync(workspaceDir, { recursive: true });
391
+ mkdirSync(join(workspaceDir, "memory"), { recursive: true });
392
+ const sections = [];
393
+ if (agent.systemPrompt) sections.push(agent.systemPrompt);
394
+ sections.push(agent.soulMd ?? agent.defaultSoulMd ?? "");
395
+ if (agent.memoryProtocolMd) sections.push(agent.memoryProtocolMd);
396
+ sections.push(
397
+ "## Memory sync (local agent)\n\nAfter updating MEMORY.md or today's daily journal, sync them to Stride by running:\n\n```\nnode .claude/memory-sync.mjs\n```\n"
398
+ );
399
+ if (!agent.onboardedAt && agent.onboardingMd) {
400
+ sections.push(
401
+ "## FIRST BOOT \u2014 onboarding required\n\nYou have not been onboarded. Read ONBOARDING.md in this workspace and complete the interview BEFORE regular work. To sync your identity after the interview, run:\n\n```\nnode .claude/identity-sync.mjs\n```\n"
402
+ );
403
+ }
404
+ writeFileSync(join(workspaceDir, "CLAUDE.md"), sections.filter(Boolean).join("\n\n"), "utf-8");
405
+ if (!agent.onboardedAt && agent.onboardingMd) {
406
+ writeFileSync(join(workspaceDir, "ONBOARDING.md"), agent.onboardingMd, "utf-8");
407
+ }
408
+ if (agent.identityMd) writeFileSync(join(workspaceDir, "IDENTITY.md"), agent.identityMd, "utf-8");
409
+ if (agent.userMd) writeFileSync(join(workspaceDir, "USER.md"), agent.userMd, "utf-8");
410
+ if (agent.heartbeatChecklist) {
411
+ writeFileSync(join(workspaceDir, "HEARTBEAT.md"), agent.heartbeatChecklist, "utf-8");
412
+ }
413
+ const memoryMdPath = join(workspaceDir, "MEMORY.md");
414
+ if (!existsSync(memoryMdPath)) {
415
+ writeFileSync(
416
+ memoryMdPath,
417
+ "# Long-term memory\n\nDurable learnings only \u2014 format: `## Topic \u2014 YYYY-MM-DD` followed by what you learned.\n",
418
+ "utf-8"
419
+ );
420
+ }
421
+ writeAgentHooks({
422
+ agentId: agent.id,
423
+ hookSecret: agent.hookSecret,
424
+ apiBaseUrl: config.apiBaseUrl,
425
+ workspaceDir
426
+ });
427
+ writeMemorySyncScript({
428
+ agentId: agent.id,
429
+ hookSecret: agent.hookSecret,
430
+ apiBaseUrl: config.apiBaseUrl,
431
+ workspaceDir
432
+ });
433
+ writeIdentitySyncScript({
434
+ agentId: agent.id,
435
+ hookSecret: agent.hookSecret,
436
+ apiBaseUrl: config.apiBaseUrl,
437
+ workspaceDir
438
+ });
439
+ log(`Provisioned workspace for agent "${agent.name}" (${agent.id})`);
440
+ }
441
+ function agentWorkspaceDir(agentId) {
442
+ return join(AGENTS_DIR, agentId, "workspace");
443
+ }
444
+ function startAgentRefreshTimer(config) {
445
+ if (refreshTimer !== null) return;
446
+ refreshTimer = setInterval(() => {
447
+ void refreshAgents(config);
448
+ }, REFRESH_INTERVAL_MS);
449
+ if (refreshTimer.unref) {
450
+ refreshTimer.unref();
451
+ }
452
+ }
453
+ function stopAgentRefreshTimer() {
454
+ if (refreshTimer !== null) {
455
+ clearInterval(refreshTimer);
456
+ refreshTimer = null;
457
+ }
458
+ }
459
+ var EMPTY_STATE = {
460
+ sessionId: null,
461
+ sessionStartedAt: null,
462
+ runCount: 0
463
+ };
464
+ function shouldRotateSession(state, nowMs, opts = {}) {
465
+ const { maxAgeMs = 24 * 60 * 6e4, maxRuns = 40 } = opts;
466
+ if (!state.sessionId || state.sessionStartedAt === null) return true;
467
+ if (nowMs - state.sessionStartedAt >= maxAgeMs) return true;
468
+ if (state.runCount >= maxRuns) return true;
469
+ return false;
470
+ }
471
+ function sessionStatePath(agentId) {
472
+ return join(dirname(agentWorkspaceDir(agentId)), "session.json");
473
+ }
474
+ function loadSessionState(agentId) {
475
+ const parsed = readJsonSafe(sessionStatePath(agentId));
476
+ if (!parsed) return { ...EMPTY_STATE };
477
+ return {
478
+ sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null,
479
+ sessionStartedAt: typeof parsed.sessionStartedAt === "number" ? parsed.sessionStartedAt : null,
480
+ runCount: typeof parsed.runCount === "number" ? parsed.runCount : 0
481
+ };
482
+ }
483
+ function saveSessionState(agentId, state) {
484
+ writeJsonAtomic(sessionStatePath(agentId), state);
485
+ }
486
+ function recordRunSession(agentId, newSessionId, previous, nowMs) {
487
+ if (!newSessionId) return previous;
488
+ const continued = previous.sessionId === newSessionId;
489
+ const next = continued ? { ...previous, runCount: previous.runCount + 1 } : { sessionId: newSessionId, sessionStartedAt: nowMs, runCount: 1 };
490
+ saveSessionState(agentId, next);
491
+ return next;
492
+ }
493
+
494
+ // src/runner.ts
495
+ var RUN_TIMEOUT_MS = 15 * 60 * 1e3;
496
+ var STDOUT_CAP = 1e4;
497
+ var STDERR_CAP = 4e3;
498
+ var REPORT_RETRY_COUNT = 3;
499
+ var REPORT_RETRY_BASE_MS = 2e3;
500
+ function parseClaudeOutput(raw) {
501
+ const trimmed = raw.trim();
502
+ const lastBrace = trimmed.lastIndexOf("}");
503
+ if (lastBrace === -1) return {};
504
+ let depth = 0;
505
+ let start = -1;
506
+ for (let i = lastBrace; i >= 0; i--) {
507
+ if (trimmed[i] === "}") depth++;
508
+ else if (trimmed[i] === "{") {
509
+ depth--;
510
+ if (depth === 0) {
511
+ start = i;
512
+ break;
513
+ }
514
+ }
515
+ }
516
+ if (start === -1) return {};
517
+ try {
518
+ const parsed = JSON.parse(trimmed.slice(start, lastBrace + 1));
519
+ const result = {};
520
+ if (typeof parsed["total_cost_usd"] === "number") {
521
+ result.costUsd = parsed["total_cost_usd"];
522
+ }
523
+ if (typeof parsed["session_id"] === "string") {
524
+ result.sessionId = parsed["session_id"];
525
+ }
526
+ const usage = parsed["usage"];
527
+ if (usage && typeof usage === "object") {
528
+ const u = usage;
529
+ if (typeof u["input_tokens"] === "number") result.tokensInput = u["input_tokens"];
530
+ if (typeof u["output_tokens"] === "number") result.tokensOutput = u["output_tokens"];
531
+ }
532
+ return result;
533
+ } catch {
534
+ return {};
535
+ }
536
+ }
537
+ async function runWorkItem(config, work) {
538
+ const agent = findAgent(work.agent.id);
539
+ const model = agent?.model ?? work.agent.model;
540
+ const agentWorkspace = agentWorkspaceDir(work.agent.id);
541
+ let cwd = agentWorkspace;
542
+ if (work.workingDir && existsSync(work.workingDir)) {
543
+ cwd = work.workingDir;
544
+ } else if (work.workingDir) {
545
+ logWarn(
546
+ `workingDir "${work.workingDir}" does not exist; falling back to agent workspace`
547
+ );
548
+ }
549
+ log(`Starting run ${work.runId} (agent "${work.agent.name}", model "${model}")`);
550
+ log(` cwd: ${cwd}`);
551
+ const isWindows = process.platform === "win32";
552
+ const settingsPath = join(agentWorkspace, ".claude", "settings.json");
553
+ const rawArgs = [
554
+ "-p",
555
+ "--output-format",
556
+ "json",
557
+ "--max-turns",
558
+ "25",
559
+ "--model",
560
+ model,
561
+ "--settings",
562
+ settingsPath
563
+ ];
564
+ const persistent = work.agent.localSessionMode === "persistent";
565
+ let sessionState = loadSessionState(work.agent.id);
566
+ if (persistent) {
567
+ if (shouldRotateSession(sessionState, Date.now())) {
568
+ if (sessionState.sessionId) {
569
+ log(`Rotating session for agent "${work.agent.name}" (age/run limit reached)`);
570
+ }
571
+ sessionState = { sessionId: null, sessionStartedAt: null, runCount: 0 };
572
+ } else if (sessionState.sessionId) {
573
+ rawArgs.push("--resume", sessionState.sessionId);
574
+ }
575
+ }
576
+ const args = isWindows ? rawArgs.map((a) => /[\s"^&|<>%]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a) : rawArgs;
577
+ const env = { ...process.env };
578
+ let stdoutBuf = "";
579
+ let stderrBuf = "";
580
+ let exitCode = null;
581
+ let spawnError;
582
+ await new Promise((resolve) => {
583
+ let child;
584
+ try {
585
+ child = spawn("claude", args, {
586
+ cwd,
587
+ env,
588
+ // shell on Windows: `claude` is a .cmd shim Node can't exec directly.
589
+ // Safe because argv contains only our fixed, pre-quoted tokens.
590
+ shell: isWindows,
591
+ // stdin: pipe — the prompt is delivered via stdin, not argv.
592
+ stdio: ["pipe", "pipe", "pipe"]
593
+ });
594
+ child.stdin?.write(work.prompt);
595
+ child.stdin?.end();
596
+ } catch (err) {
597
+ spawnError = err instanceof Error ? err.message : String(err);
598
+ resolve();
599
+ return;
600
+ }
601
+ const timeoutHandle = setTimeout(() => {
602
+ logWarn(`Run ${work.runId} exceeded timeout \u2014 sending SIGTERM`);
603
+ try {
604
+ child.kill("SIGTERM");
605
+ } catch {
606
+ }
607
+ setTimeout(() => {
608
+ try {
609
+ child.kill("SIGKILL");
610
+ } catch {
611
+ }
612
+ }, 5e3);
613
+ }, RUN_TIMEOUT_MS);
614
+ child.stdout?.on("data", (chunk) => {
615
+ stdoutBuf += chunk.toString("utf-8");
616
+ });
617
+ child.stderr?.on("data", (chunk) => {
618
+ stderrBuf += chunk.toString("utf-8");
619
+ });
620
+ child.on("close", (code) => {
621
+ clearTimeout(timeoutHandle);
622
+ exitCode = code;
623
+ resolve();
624
+ });
625
+ child.on("error", (err) => {
626
+ clearTimeout(timeoutHandle);
627
+ spawnError = err.message;
628
+ resolve();
629
+ });
630
+ });
631
+ const stdoutExcerpt = stdoutBuf.length > STDOUT_CAP ? stdoutBuf.slice(-STDOUT_CAP) : stdoutBuf;
632
+ const stderrExcerpt = stderrBuf.length > STDERR_CAP ? stderrBuf.slice(-STDERR_CAP) : stderrBuf;
633
+ const succeeded = spawnError === void 0 && exitCode === 0;
634
+ const parsed = succeeded ? parseClaudeOutput(stdoutBuf) : {};
635
+ const report = {
636
+ runId: work.runId,
637
+ wakeupId: work.wakeupId,
638
+ status: succeeded ? "completed" : "failed",
639
+ stdoutExcerpt,
640
+ stderrExcerpt,
641
+ error: spawnError,
642
+ exitCode,
643
+ ...parsed
644
+ };
645
+ log(
646
+ `Run ${work.runId} ${report.status} (exit ${exitCode ?? "n/a"}${parsed.costUsd !== void 0 ? `, cost $${parsed.costUsd.toFixed(4)}` : ""})`
647
+ );
648
+ if (persistent && report.status === "completed") {
649
+ recordRunSession(work.agent.id, parsed.sessionId, sessionState, Date.now());
650
+ }
651
+ await sendReport(config, report);
652
+ }
653
+ async function sendReport(config, report) {
654
+ const path = `/api/bridge/v1/runs/${report.runId}`;
655
+ for (let attempt = 1; attempt <= REPORT_RETRY_COUNT; attempt++) {
656
+ try {
657
+ await post(config, path, report);
658
+ log(`Reported run ${report.runId} (attempt ${attempt})`);
659
+ removePendingReport(report.runId);
660
+ return;
661
+ } catch (err) {
662
+ logError(`Failed to report run ${report.runId} (attempt ${attempt})`, err);
663
+ if (attempt < REPORT_RETRY_COUNT) {
664
+ const delay = REPORT_RETRY_BASE_MS * Math.pow(2, attempt - 1);
665
+ await sleep(delay);
666
+ }
667
+ }
668
+ }
669
+ logWarn(`Persisting failed report for run ${report.runId} to pending-reports.json`);
670
+ appendPendingReport(report);
671
+ }
672
+ async function retryPendingReports(config) {
673
+ const pending = readJsonSafe(PENDING_REPORTS_PATH);
674
+ if (!pending || pending.length === 0) return;
675
+ log(`Retrying ${pending.length} pending report(s)`);
676
+ for (const report of pending) {
677
+ await sendReport(config, report);
678
+ }
679
+ }
680
+ function appendPendingReport(report) {
681
+ const existing = readJsonSafe(PENDING_REPORTS_PATH) ?? [];
682
+ const deduped = existing.filter((r) => r.runId !== report.runId);
683
+ deduped.push(report);
684
+ writeJsonAtomic(PENDING_REPORTS_PATH, deduped);
685
+ }
686
+ function removePendingReport(runId) {
687
+ const existing = readJsonSafe(PENDING_REPORTS_PATH);
688
+ if (!existing || existing.length === 0) return;
689
+ const filtered = existing.filter((r) => r.runId !== runId);
690
+ if (filtered.length !== existing.length) {
691
+ writeJsonAtomic(PENDING_REPORTS_PATH, filtered);
692
+ }
693
+ }
694
+ function sleep(ms) {
695
+ return new Promise((resolve) => setTimeout(resolve, ms));
696
+ }
697
+ var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
698
+ var CACHE_TTL_MS = 6e4;
699
+ var FETCH_TIMEOUT_MS = 5e3;
700
+ function remainingPct(utilization) {
701
+ if (typeof utilization !== "number" || !Number.isFinite(utilization)) return null;
702
+ return Math.max(0, Math.min(100, Math.round((1 - utilization) * 100)));
703
+ }
704
+ function parseUsageResponse(body, nowIso) {
705
+ return {
706
+ fiveHourRemainingPct: remainingPct(body.five_hour?.utilization),
707
+ sevenDayRemainingPct: remainingPct(body.seven_day?.utilization),
708
+ fetchedAt: nowIso
709
+ };
710
+ }
711
+ function readOauthToken() {
712
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
713
+ const creds = readJsonSafe(
714
+ join(homedir(), ".claude", ".credentials.json")
715
+ );
716
+ return creds?.claudeAiOauth?.accessToken ?? null;
717
+ }
718
+ var cached = null;
719
+ var warnedNoToken = false;
720
+ async function getQuotaSnapshot() {
721
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.snapshot;
722
+ const token = readOauthToken();
723
+ if (!token) {
724
+ if (!warnedNoToken) {
725
+ logWarn("No Claude OAuth token found \u2014 quota reporting disabled");
726
+ warnedNoToken = true;
727
+ }
728
+ return null;
729
+ }
730
+ try {
731
+ const controller = new AbortController();
732
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
733
+ const res = await fetch(USAGE_URL, {
734
+ headers: { Authorization: `Bearer ${token}` },
735
+ signal: controller.signal
736
+ });
737
+ clearTimeout(timer);
738
+ if (!res.ok) return cached?.snapshot ?? null;
739
+ const body = await res.json();
740
+ const snapshot = parseUsageResponse(body, (/* @__PURE__ */ new Date()).toISOString());
741
+ cached = { snapshot, at: Date.now() };
742
+ return snapshot;
743
+ } catch {
744
+ return cached?.snapshot ?? null;
745
+ }
746
+ }
747
+
748
+ // src/poller.ts
749
+ var POLL_INTERVAL_MS = 2500;
750
+ var HEARTBEAT_INTERVAL_MS = 6e4;
751
+ var BACKOFF_STEPS_MS = [2500, 5e3, 1e4, 3e4];
752
+ var running = false;
753
+ async function startDaemon(config) {
754
+ running = true;
755
+ const shutdown = async (signal) => {
756
+ if (!running) return;
757
+ running = false;
758
+ log(`Received ${signal} \u2014 shutting down gracefully`);
759
+ stopAgentRefreshTimer();
760
+ await postBestEffort(config, "/api/bridge/v1/events", {
761
+ kind: "daemon_stopping",
762
+ message: `Daemon stopping (${signal})`,
763
+ daemonVersion: VERSION
764
+ });
765
+ process.exit(0);
766
+ };
767
+ process.on("SIGINT", () => void shutdown("SIGINT"));
768
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
769
+ await postBestEffort(config, "/api/bridge/v1/events", {
770
+ kind: "daemon_started",
771
+ message: "Daemon started",
772
+ daemonVersion: VERSION,
773
+ osInfo: { platform: platform(), release: release() }
774
+ });
775
+ log(`Daemon started (v${VERSION}, bridge ${config.bridgeId})`);
776
+ await refreshAgents(config);
777
+ startAgentRefreshTimer(config);
778
+ const sendHeartbeat = async () => {
779
+ const payload = {
780
+ daemonVersion: VERSION,
781
+ osInfo: { platform: platform(), release: release() },
782
+ agents: []
783
+ };
784
+ const quota = await getQuotaSnapshot();
785
+ if (quota) payload.quota = quota;
786
+ try {
787
+ await post(config, "/api/bridge/v1/heartbeat", payload);
788
+ } catch (err) {
789
+ logWarn(`Heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
790
+ }
791
+ };
792
+ void sendHeartbeat();
793
+ const heartbeatTimer = setInterval(() => void sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
794
+ if (heartbeatTimer.unref) heartbeatTimer.unref();
795
+ let backoffIndex = 0;
796
+ while (running) {
797
+ try {
798
+ await retryPendingReports(config);
799
+ } catch {
800
+ }
801
+ try {
802
+ const data = await post(config, "/api/bridge/v1/work", {});
803
+ backoffIndex = 0;
804
+ if (data.work) {
805
+ log(`Received work item: run ${data.work.runId}`);
806
+ try {
807
+ await runWorkItem(config, data.work);
808
+ } catch (err) {
809
+ logError(`Unhandled error in runWorkItem for run ${data.work.runId}`, err);
810
+ }
811
+ continue;
812
+ }
813
+ } catch (err) {
814
+ const delay = BACKOFF_STEPS_MS[Math.min(backoffIndex, BACKOFF_STEPS_MS.length - 1)];
815
+ logError(
816
+ `Poll error (backoff ${delay}ms)`,
817
+ err
818
+ );
819
+ backoffIndex = Math.min(backoffIndex + 1, BACKOFF_STEPS_MS.length - 1);
820
+ await sleep2(delay);
821
+ continue;
822
+ }
823
+ await sleep2(POLL_INTERVAL_MS);
824
+ }
825
+ clearInterval(heartbeatTimer);
826
+ }
827
+ function sleep2(ms) {
828
+ return new Promise((resolve) => setTimeout(resolve, ms));
829
+ }
830
+ var DEFAULT_API_URL = "https://app.strideops.ai";
831
+ var program = new Command();
832
+ program.name("stride-bridge").description("Stride Bridge daemon \u2014 run StrideOps Build agents locally").version(VERSION);
833
+ program.command("connect <pairingCode>").description("Pair this machine with StrideOps using a one-time pairing code").option(
834
+ "--api-url <url>",
835
+ "StrideOps API base URL",
836
+ DEFAULT_API_URL
837
+ ).action(async (pairingCode, opts) => {
838
+ await runPair(pairingCode, opts.apiUrl);
839
+ });
840
+ program.command("start").description("Start the daemon (foreground; press Ctrl+C to stop)").action(async () => {
841
+ const config = requireConfig();
842
+ log(`Starting daemon with bridge "${config.bridgeName}" (${config.bridgeId})`);
843
+ await startDaemon(config);
844
+ });
845
+ program.command("status").description("Print current configuration and server connectivity").action(async () => {
846
+ const config = readConfig();
847
+ console.log("=== Stride Bridge Status ===");
848
+ console.log();
849
+ if (!config) {
850
+ console.log("Status : NOT CONNECTED");
851
+ console.log(`Config path : ${CONFIG_PATH}`);
852
+ console.log();
853
+ console.log("Run `stride-bridge connect <PAIRING_CODE>` to get started.");
854
+ return;
855
+ }
856
+ console.log(`Status : CONNECTED`);
857
+ console.log(`Bridge name : ${config.bridgeName}`);
858
+ console.log(`Bridge ID : ${config.bridgeId}`);
859
+ console.log(`Org ID : ${config.orgId}`);
860
+ console.log(`API URL : ${config.apiBaseUrl}`);
861
+ console.log(`Auth mode : ${config.authMode}`);
862
+ console.log(`Config path : ${CONFIG_PATH}`);
863
+ console.log();
864
+ process.stdout.write("Server : checking... ");
865
+ try {
866
+ await post(config, "/api/bridge/v1/heartbeat", {
867
+ daemonVersion: VERSION,
868
+ osInfo: {},
869
+ agents: []
870
+ });
871
+ console.log("REACHABLE");
872
+ } catch (err) {
873
+ console.log(
874
+ `UNREACHABLE (${err instanceof Error ? err.message : String(err)})`
875
+ );
876
+ }
877
+ console.log();
878
+ });
879
+ program.command("autostart <action>").description("Manage start-at-logon: install | remove | status (Windows Task Scheduler)").action(async (action) => {
880
+ const { autostartInstall, autostartRemove, autostartStatus } = await import('./autostart-77BPPTEG.js');
881
+ if (action === "install") autostartInstall();
882
+ else if (action === "remove") autostartRemove();
883
+ else if (action === "status") autostartStatus();
884
+ else {
885
+ console.error(`Unknown action "${action}" \u2014 use install, remove, or status`);
886
+ process.exitCode = 1;
887
+ }
888
+ });
889
+ program.command("doctor").description("Check all prerequisites for running the bridge daemon").action(async () => {
890
+ console.log("=== Stride Bridge Doctor ===");
891
+ console.log();
892
+ let allGood = true;
893
+ const nodeVersion = process.versions.node;
894
+ const [major] = nodeVersion.split(".").map(Number);
895
+ const nodeOk = major >= 20;
896
+ printCheck(
897
+ "Node.js >= 20",
898
+ nodeOk,
899
+ `Found v${nodeVersion}`,
900
+ `Found v${nodeVersion} \u2014 upgrade to Node.js 20+`
901
+ );
902
+ if (!nodeOk) allGood = false;
903
+ let claudeVersion = "";
904
+ let claudeOnPath = false;
905
+ try {
906
+ claudeVersion = execSync("claude --version", {
907
+ encoding: "utf-8",
908
+ timeout: 5e3,
909
+ stdio: ["ignore", "pipe", "ignore"]
910
+ }).trim();
911
+ claudeOnPath = true;
912
+ } catch {
913
+ claudeOnPath = false;
914
+ }
915
+ printCheck(
916
+ "claude CLI on PATH",
917
+ claudeOnPath,
918
+ claudeVersion || "found",
919
+ "claude not found \u2014 install Claude Code CLI from https://docs.anthropic.com/claude-code"
920
+ );
921
+ if (!claudeOnPath) allGood = false;
922
+ let claudeAuthed = false;
923
+ let claudeAuthNote = "";
924
+ if (claudeOnPath) {
925
+ const possiblePaths = [
926
+ join(homedir(), ".claude", "settings.json"),
927
+ join(homedir(), ".config", "claude", "settings.json"),
928
+ join(
929
+ process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"),
930
+ "Claude",
931
+ "settings.json"
932
+ )
933
+ ];
934
+ claudeAuthed = possiblePaths.some(existsSync);
935
+ claudeAuthNote = claudeAuthed ? "config found" : "no config found \u2014 run `claude` to authenticate";
936
+ } else {
937
+ claudeAuthNote = "skipped (claude not on PATH)";
938
+ }
939
+ printCheck(
940
+ "Claude authenticated",
941
+ claudeAuthed,
942
+ claudeAuthNote,
943
+ claudeAuthNote
944
+ );
945
+ if (!claudeAuthed && claudeOnPath) allGood = false;
946
+ const configured = isConfigured();
947
+ const configData = readConfig();
948
+ const configValid = configured && configData !== null;
949
+ printCheck(
950
+ "Bridge config",
951
+ configValid,
952
+ configValid ? `${CONFIG_PATH} (bridge: ${configData?.bridgeName})` : configured ? `${CONFIG_PATH} exists but could not be parsed` : "not found \u2014 run `stride-bridge connect <CODE>`",
953
+ configured ? `${CONFIG_PATH} could not be parsed \u2014 re-run connect` : "not found \u2014 run `stride-bridge connect <CODE>`"
954
+ );
955
+ if (!configValid) allGood = false;
956
+ if (configData) {
957
+ process.stdout.write("[ ] StrideOps server reachable ... ");
958
+ try {
959
+ await post(configData, "/api/bridge/v1/heartbeat", {
960
+ daemonVersion: VERSION,
961
+ osInfo: {},
962
+ agents: []
963
+ });
964
+ clearLine();
965
+ printCheck("StrideOps server reachable", true, configData.apiBaseUrl, "");
966
+ } catch (err) {
967
+ clearLine();
968
+ printCheck(
969
+ "StrideOps server reachable",
970
+ false,
971
+ "",
972
+ `${configData.apiBaseUrl} \u2014 ${err instanceof Error ? err.message : String(err)}`
973
+ );
974
+ allGood = false;
975
+ }
976
+ } else {
977
+ printCheck(
978
+ "StrideOps server reachable",
979
+ false,
980
+ "",
981
+ "skipped (not configured)"
982
+ );
983
+ }
984
+ const bridgeDir = join(homedir(), ".stride-bridge");
985
+ try {
986
+ mkdirSync(bridgeDir, { recursive: true });
987
+ accessSync(bridgeDir, constants.W_OK);
988
+ printCheck("Bridge workspace writable", true, bridgeDir, "");
989
+ } catch {
990
+ printCheck(
991
+ "Bridge workspace writable",
992
+ false,
993
+ "",
994
+ `${bridgeDir} is not writable`
995
+ );
996
+ allGood = false;
997
+ }
998
+ console.log();
999
+ if (allGood) {
1000
+ console.log("All checks passed. Run `stride-bridge start` to launch the daemon.");
1001
+ } else {
1002
+ console.log(
1003
+ "Some checks failed. Fix the issues above, then re-run `stride-bridge doctor`."
1004
+ );
1005
+ process.exit(1);
1006
+ }
1007
+ });
1008
+ function printCheck(label, ok, successNote, failureNote) {
1009
+ const icon = ok ? "[ok]" : "[!!]";
1010
+ const note = ok ? successNote : failureNote;
1011
+ console.log(`${icon} ${label}${note ? " \u2014 " + note : ""}`);
1012
+ }
1013
+ function clearLine() {
1014
+ process.stdout.write("\r\x1B[K");
1015
+ }
1016
+ program.parseAsync(process.argv).catch((err) => {
1017
+ console.error(
1018
+ `[stride-bridge] Fatal: ${err instanceof Error ? err.message : String(err)}`
1019
+ );
1020
+ process.exit(1);
1021
+ });
1022
+ //# sourceMappingURL=cli.js.map
1023
+ //# sourceMappingURL=cli.js.map