@strideops/bridge 0.1.2 → 0.1.5

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 CHANGED
@@ -1,13 +1,14 @@
1
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';
2
+ import { BRIDGE_DIR, requireConfig, log, readConfig, CONFIG_PATH, isConfigured, writeConfig, logError, logWarn, PENDING_REPORTS_PATH, AGENTS_DIR, readJsonSafe, writeJsonAtomic, __require } from './chunk-6LRDE2ZS.js';
3
3
  import { Command } from 'commander';
4
- import { homedir, cpus, totalmem, release, platform, arch, hostname } from 'os';
4
+ import os, { homedir, cpus, totalmem, release, platform, arch, hostname, loadavg, freemem } from 'os';
5
5
  import { join, dirname } from 'path';
6
6
  import { existsSync, mkdirSync, accessSync, constants, writeFileSync, chmodSync } from 'fs';
7
7
  import { execSync, spawn } from 'child_process';
8
+ import { readFile, statfs } from 'fs/promises';
8
9
 
9
10
  // src/version.ts
10
- var VERSION = "0.1.2";
11
+ var VERSION = "0.1.5";
11
12
  async function runPair(pairingCode, apiBaseUrl) {
12
13
  const url = `${apiBaseUrl.replace(/\/$/, "")}/api/bridge/v1/pair`;
13
14
  log(`Pairing with ${apiBaseUrl} ...`);
@@ -82,8 +83,38 @@ async function runPair(pairingCode, apiBaseUrl) {
82
83
  console.log(` Auth mode : ${authMode}`);
83
84
  console.log(` Config : ~/.stride-bridge/config.json`);
84
85
  console.log();
86
+ await offerAutostart();
85
87
  console.log(`Run \`stride-bridge start\` to begin accepting work.`);
86
88
  }
89
+ async function offerAutostart() {
90
+ if (process.platform !== "win32" || !process.stdin.isTTY) {
91
+ if (process.platform === "win32") {
92
+ console.log(
93
+ `Tip: run \`stride-bridge autostart install\` so the daemon starts at login.`
94
+ );
95
+ console.log();
96
+ }
97
+ return;
98
+ }
99
+ const { createInterface } = await import('readline/promises');
100
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
101
+ try {
102
+ const answer = (await rl.question(
103
+ "Start the daemon automatically at Windows login? [Y/n] "
104
+ )).trim().toLowerCase();
105
+ if (answer === "" || answer === "y" || answer === "yes") {
106
+ const { autostartInstall } = await import('./autostart-ASW5MDQS.js');
107
+ autostartInstall();
108
+ } else {
109
+ console.log(
110
+ `Skipped. You can enable it later with \`stride-bridge autostart install\`.`
111
+ );
112
+ }
113
+ } finally {
114
+ rl.close();
115
+ console.log();
116
+ }
117
+ }
87
118
 
88
119
  // src/api.ts
89
120
  var REQUEST_TIMEOUT_MS = 1e4;
@@ -365,6 +396,188 @@ console.log(res.ok ? "identity-sync: onboarding recorded in Stride" : \`identity
365
396
  mkdirSync(claudeDir, { recursive: true });
366
397
  writeFileSync(join(claudeDir, "identity-sync.mjs"), script, { encoding: "utf-8", mode: 448 });
367
398
  }
399
+ function writeMemorySearchScript(opts) {
400
+ const { agentId, hookSecret, apiBaseUrl, workspaceDir } = opts;
401
+ const base = apiBaseUrl.replace(/\/$/, "");
402
+ const searchUrl = `${base}/api/internal/build/agents/${agentId}/memory/search`;
403
+ const script = `#!/usr/bin/env node
404
+ // Auto-generated by stride-bridge. Do not edit.
405
+ // Semantically searches this agent's synced memory in Stride.
406
+
407
+ import { createHmac } from "node:crypto";
408
+
409
+ const HOOK_SECRET = ${JSON.stringify(hookSecret)};
410
+ const SEARCH_URL = ${JSON.stringify(searchUrl)};
411
+
412
+ const query = process.argv.slice(2).join(" ").trim();
413
+ if (!query) {
414
+ console.log("usage: node memory-search.mjs <query>");
415
+ process.exit(0);
416
+ }
417
+
418
+ const body = JSON.stringify({ query, limit: 5 });
419
+ const ts = Math.floor(Date.now() / 1000).toString();
420
+ const sig = createHmac("sha256", HOOK_SECRET).update(ts + "\\n" + body).digest("hex");
421
+
422
+ const res = await fetch(SEARCH_URL, {
423
+ method: "POST",
424
+ headers: {
425
+ "Content-Type": "application/json",
426
+ "x-stride-hook-ts": ts,
427
+ "x-stride-hook-sig": sig,
428
+ },
429
+ body,
430
+ }).catch(() => null);
431
+
432
+ if (!res || !res.ok) {
433
+ console.log("memory-search: unavailable");
434
+ process.exit(0);
435
+ }
436
+ const data = await res.json();
437
+ const results = data?.data?.results ?? [];
438
+ if (results.length === 0) {
439
+ console.log("memory-search: no matches");
440
+ } else {
441
+ for (const r of results) {
442
+ console.log("--- " + (r.label ?? "memory") + " ---");
443
+ console.log(String(r.content ?? "").slice(0, 600));
444
+ }
445
+ }
446
+ `;
447
+ const claudeDir = join(workspaceDir, ".claude");
448
+ mkdirSync(claudeDir, { recursive: true });
449
+ writeFileSync(join(claudeDir, "memory-search.mjs"), script, { encoding: "utf-8", mode: 448 });
450
+ }
451
+ function writeApprovalRequestScript(opts) {
452
+ const { agentId, hookSecret, apiBaseUrl, workspaceDir } = opts;
453
+ const base = apiBaseUrl.replace(/\/$/, "");
454
+ const approvalsUrl = `${base}/api/internal/build/agents/${agentId}/approvals`;
455
+ const script = `#!/usr/bin/env node
456
+ // Auto-generated by stride-bridge. Do not edit.
457
+ // Posts a plan/question/action approval request to Stride (HMAC-signed).
458
+ // Usage: node .claude/approval-request.mjs <title> <plan>
459
+ // After calling this, END your current run and wait to be re-woken.
460
+
461
+ import { createHmac } from "node:crypto";
462
+
463
+ const HOOK_SECRET = ${JSON.stringify(hookSecret)};
464
+ const APPROVALS_URL = ${JSON.stringify(approvalsUrl)};
465
+
466
+ const args = process.argv.slice(2);
467
+ const title = (args[0] ?? "").trim();
468
+ const plan = (args[1] ?? "").trim();
469
+
470
+ if (!title) {
471
+ console.error("approval-request: title is required");
472
+ console.error("usage: node .claude/approval-request.mjs <title> <plan>");
473
+ process.exit(1);
474
+ }
475
+
476
+ const body = JSON.stringify({ type: "plan", title: title.slice(0, 200), body: plan.slice(0, 8000) });
477
+ const ts = Math.floor(Date.now() / 1000).toString();
478
+ const sig = createHmac("sha256", HOOK_SECRET).update(ts + "\\n" + body).digest("hex");
479
+
480
+ const res = await fetch(APPROVALS_URL, {
481
+ method: "POST",
482
+ headers: {
483
+ "Content-Type": "application/json",
484
+ "x-stride-hook-ts": ts,
485
+ "x-stride-hook-sig": sig,
486
+ },
487
+ body,
488
+ }).catch((err) => ({ ok: false, statusText: String(err) }));
489
+
490
+ if (!res.ok) {
491
+ console.error(\`approval-request: failed (\${res.statusText ?? res.status})\`);
492
+ process.exit(1);
493
+ }
494
+
495
+ const data = await res.json();
496
+ console.log(\`approval-request: submitted (approvalId=\${data?.approvalId ?? "?"})\`);
497
+ console.log(data?.message ?? "End your current run and wait to be re-woken.");
498
+ `;
499
+ const claudeDir = join(workspaceDir, ".claude");
500
+ mkdirSync(claudeDir, { recursive: true });
501
+ writeFileSync(join(claudeDir, "approval-request.mjs"), script, { encoding: "utf-8", mode: 448 });
502
+ }
503
+ var PROBE_TIMEOUT_MS = 3e3;
504
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
505
+ var PROBES = [
506
+ { runtime: "claude-code", cmd: "claude", args: ["--version"] },
507
+ { runtime: "codex", cmd: "codex", args: ["--version"] },
508
+ { runtime: "hermes", cmd: "hermes", args: ["--version"] }
509
+ ];
510
+ function probeRuntime(cmd, args) {
511
+ return new Promise((resolve) => {
512
+ let child;
513
+ let resolved = false;
514
+ const done = (value) => {
515
+ if (!resolved) {
516
+ resolved = true;
517
+ resolve(value);
518
+ }
519
+ };
520
+ const useShell = process.platform === "win32";
521
+ try {
522
+ child = spawn(cmd, args, {
523
+ shell: useShell,
524
+ stdio: ["ignore", "pipe", "ignore"],
525
+ env: process.env
526
+ });
527
+ } catch {
528
+ done("");
529
+ return;
530
+ }
531
+ let stdoutBuf = "";
532
+ child.stdout?.on("data", (chunk) => {
533
+ stdoutBuf += chunk.toString("utf-8");
534
+ });
535
+ const timer = setTimeout(() => {
536
+ try {
537
+ child.kill("SIGTERM");
538
+ } catch {
539
+ }
540
+ done("");
541
+ }, PROBE_TIMEOUT_MS);
542
+ child.on("close", () => {
543
+ clearTimeout(timer);
544
+ const firstLine = stdoutBuf.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
545
+ done(firstLine);
546
+ });
547
+ child.on("error", () => {
548
+ clearTimeout(timer);
549
+ done("");
550
+ });
551
+ });
552
+ }
553
+ function extractVersion(raw) {
554
+ if (!raw.trim()) return "";
555
+ const match = raw.match(/\d+\.\d+(?:\.\d+)*/);
556
+ return match ? match[0] : raw.trim().split(/\s+/).pop() ?? "";
557
+ }
558
+ var cached = null;
559
+ async function detectRuntimes(force = false) {
560
+ if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
561
+ return cached.capabilities;
562
+ }
563
+ const results = [];
564
+ for (const probe of PROBES) {
565
+ try {
566
+ const raw = await probeRuntime(probe.cmd, probe.args);
567
+ results.push({ runtime: probe.runtime, version: extractVersion(raw) });
568
+ } catch (err) {
569
+ logWarn(
570
+ `Runtime probe failed for "${probe.runtime}": ${err instanceof Error ? err.message : String(err)}`
571
+ );
572
+ results.push({ runtime: probe.runtime, version: "" });
573
+ }
574
+ }
575
+ cached = { capabilities: results, at: Date.now() };
576
+ return results;
577
+ }
578
+ function invalidateRuntimeCache() {
579
+ cached = null;
580
+ }
368
581
 
369
582
  // src/agents.ts
370
583
  var cachedAgents = [];
@@ -378,10 +591,14 @@ async function refreshAgents(config) {
378
591
  for (const agent of cachedAgents) {
379
592
  await ensureAgentWorkspace(config, agent);
380
593
  }
594
+ invalidateRuntimeCache();
381
595
  } catch (err) {
382
596
  logError("Failed to refresh agents", err);
383
597
  }
384
598
  }
599
+ function getCachedAgents() {
600
+ return cachedAgents;
601
+ }
385
602
  function findAgent(agentId) {
386
603
  return cachedAgents.find((a) => a.id === agentId);
387
604
  }
@@ -394,17 +611,32 @@ async function ensureAgentWorkspace(config, agent) {
394
611
  sections.push(agent.soulMd ?? agent.defaultSoulMd ?? "");
395
612
  if (agent.memoryProtocolMd) sections.push(agent.memoryProtocolMd);
396
613
  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"
614
+ '## 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\nBefore re-researching something you may have learned before, search your own past memory semantically:\n\n```\nnode .claude/memory-search.mjs "your question"\n```\n'
398
615
  );
616
+ if (agent.approvalProtocolMd) {
617
+ sections.push(
618
+ agent.approvalProtocolMd + '\n\n### Approval Request (local agent)\n\nPost a plan or question for human review using the Windows-safe Node script:\n\n```\nnode .claude/approval-request.mjs "<title>" "<plan>"\n```\n\nAfter posting, **end your current run immediately** and wait to be re-woken.'
619
+ );
620
+ }
621
+ if (agent.experimentProtocolMd) {
622
+ sections.push(agent.experimentProtocolMd);
623
+ if (agent.experimentSnippet) {
624
+ sections.push(agent.experimentSnippet);
625
+ }
626
+ }
399
627
  if (!agent.onboardedAt && agent.onboardingMd) {
400
628
  sections.push(
401
629
  "## 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
630
  );
403
631
  }
404
- writeFileSync(join(workspaceDir, "CLAUDE.md"), sections.filter(Boolean).join("\n\n"), "utf-8");
632
+ const claudeMdContent = sections.filter(Boolean).join("\n\n");
633
+ writeFileSync(join(workspaceDir, "CLAUDE.md"), claudeMdContent, "utf-8");
634
+ writeFileSync(join(workspaceDir, "AGENTS.md"), claudeMdContent, "utf-8");
405
635
  if (!agent.onboardedAt && agent.onboardingMd) {
406
636
  writeFileSync(join(workspaceDir, "ONBOARDING.md"), agent.onboardingMd, "utf-8");
407
637
  }
638
+ const soulContent = agent.soulMd ?? agent.defaultSoulMd;
639
+ if (soulContent) writeFileSync(join(workspaceDir, "SOUL.md"), soulContent, "utf-8");
408
640
  if (agent.identityMd) writeFileSync(join(workspaceDir, "IDENTITY.md"), agent.identityMd, "utf-8");
409
641
  if (agent.userMd) writeFileSync(join(workspaceDir, "USER.md"), agent.userMd, "utf-8");
410
642
  if (agent.heartbeatChecklist) {
@@ -436,6 +668,18 @@ async function ensureAgentWorkspace(config, agent) {
436
668
  apiBaseUrl: config.apiBaseUrl,
437
669
  workspaceDir
438
670
  });
671
+ writeMemorySearchScript({
672
+ agentId: agent.id,
673
+ hookSecret: agent.hookSecret,
674
+ apiBaseUrl: config.apiBaseUrl,
675
+ workspaceDir
676
+ });
677
+ writeApprovalRequestScript({
678
+ agentId: agent.id,
679
+ hookSecret: agent.hookSecret,
680
+ apiBaseUrl: config.apiBaseUrl,
681
+ workspaceDir
682
+ });
439
683
  log(`Provisioned workspace for agent "${agent.name}" (${agent.id})`);
440
684
  }
441
685
  function agentWorkspaceDir(agentId) {
@@ -491,6 +735,151 @@ function recordRunSession(agentId, newSessionId, previous, nowMs) {
491
735
  return next;
492
736
  }
493
737
 
738
+ // src/prompt.ts
739
+ function buildPromptText(opts) {
740
+ try {
741
+ const { workPrompt, agent, cwd, agentWorkspace, rotated } = opts;
742
+ const parts = [];
743
+ const soulText = agent?.soulMd ?? agent?.defaultSoulMd;
744
+ const shouldInjectSoul = cwd !== agentWorkspace && soulText != null && soulText.trim().length > 0;
745
+ if (shouldInjectSoul) {
746
+ let identityBlock = `## Who you are
747
+
748
+ ${soulText}`;
749
+ if (agent?.identityMd && agent.identityMd.trim().length > 0) {
750
+ identityBlock += `
751
+
752
+ ${agent.identityMd}`;
753
+ }
754
+ parts.push(identityBlock);
755
+ }
756
+ if (rotated) {
757
+ parts.push(
758
+ "## Session rotated\nYour previous session ended (context rotation). Read HANDOFF.md and your recent memory/ journal entries before starting."
759
+ );
760
+ }
761
+ parts.push(workPrompt);
762
+ return parts.join("\n\n---\n\n");
763
+ } catch {
764
+ return opts.workPrompt;
765
+ }
766
+ }
767
+ var DEAD_REPORTS_PATH = join(BRIDGE_DIR, "dead-reports.json");
768
+ var MAX_ATTEMPTS = 20;
769
+ function isPermanentClientError(err) {
770
+ if (!(err instanceof ApiError)) return false;
771
+ const { status } = err;
772
+ if (status === null) return false;
773
+ if (status < 400 || status >= 500) return false;
774
+ if (status === 408 || status === 429) return false;
775
+ return true;
776
+ }
777
+ function normalizeEntry(raw) {
778
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
779
+ logWarn("[report-queue] Dropping unrecognised queue entry (not an object)");
780
+ return null;
781
+ }
782
+ const r = raw;
783
+ if (typeof r["runId"] !== "string" || !r["runId"]) {
784
+ logWarn("[report-queue] Dropping queue entry missing runId");
785
+ return null;
786
+ }
787
+ const runId = r["runId"];
788
+ if (typeof r["status"] === "string" && (r["report"] === void 0 || r["report"] === null)) {
789
+ logWarn(
790
+ `[report-queue] Normalising legacy flat RunReport for run ${runId} (pre-WS1 daemon)`
791
+ );
792
+ return {
793
+ runId,
794
+ report: r,
795
+ attempts: 0,
796
+ firstFailedAt: (/* @__PURE__ */ new Date()).toISOString()
797
+ };
798
+ }
799
+ if (r["report"] === null || typeof r["report"] !== "object" || Array.isArray(r["report"])) {
800
+ logWarn(`[report-queue] Dropping queue entry for run ${runId}: invalid .report field`);
801
+ return null;
802
+ }
803
+ const rawAttempts = r["attempts"];
804
+ const attempts = Number.isFinite(rawAttempts) ? rawAttempts : 0;
805
+ const firstFailedAt = typeof r["firstFailedAt"] === "string" ? r["firstFailedAt"] : (/* @__PURE__ */ new Date()).toISOString();
806
+ return {
807
+ runId,
808
+ report: r["report"],
809
+ attempts,
810
+ firstFailedAt
811
+ };
812
+ }
813
+ function readQueue(path) {
814
+ const raw = readJsonSafe(path);
815
+ if (!raw) return [];
816
+ const normalised = [];
817
+ for (const entry of raw) {
818
+ const item = normalizeEntry(entry);
819
+ if (item !== null) normalised.push(item);
820
+ }
821
+ return normalised;
822
+ }
823
+ function writeQueue(path, items) {
824
+ writeJsonAtomic(path, items);
825
+ }
826
+ function removePendingById(runId, path) {
827
+ const items = readQueue(path);
828
+ const filtered = items.filter((r) => r.runId !== runId);
829
+ if (filtered.length !== items.length) {
830
+ writeQueue(path, filtered);
831
+ }
832
+ }
833
+ function updatePending(item, path) {
834
+ const items = readQueue(path);
835
+ const updated = items.map((r) => r.runId === item.runId ? item : r);
836
+ writeQueue(path, updated);
837
+ }
838
+ function deadLetter(item, reason) {
839
+ logError(
840
+ `[report-queue] Dead-lettering run ${item.runId} (${reason}, attempts=${item.attempts})`
841
+ );
842
+ const existing = readQueue(DEAD_REPORTS_PATH);
843
+ const deduped = existing.filter((r) => r.runId !== item.runId);
844
+ deduped.push(item);
845
+ writeQueue(DEAD_REPORTS_PATH, deduped);
846
+ }
847
+ function enqueue(item) {
848
+ const existing = readQueue(PENDING_REPORTS_PATH);
849
+ const deduped = existing.filter((r) => r.runId !== item.runId);
850
+ deduped.push(item);
851
+ writeQueue(PENDING_REPORTS_PATH, deduped);
852
+ }
853
+ function removePendingReport(runId) {
854
+ removePendingById(runId, PENDING_REPORTS_PATH);
855
+ }
856
+ async function drain(send) {
857
+ const items = readQueue(PENDING_REPORTS_PATH);
858
+ if (items.length === 0) return;
859
+ for (const item of items) {
860
+ try {
861
+ await send(item);
862
+ removePendingById(item.runId, PENDING_REPORTS_PATH);
863
+ } catch (err) {
864
+ if (isPermanentClientError(err)) {
865
+ removePendingById(item.runId, PENDING_REPORTS_PATH);
866
+ deadLetter(item, `permanent ${err.status}`);
867
+ } else {
868
+ const updated = { ...item, attempts: item.attempts + 1 };
869
+ if (updated.attempts >= MAX_ATTEMPTS) {
870
+ removePendingById(item.runId, PENDING_REPORTS_PATH);
871
+ deadLetter(updated, "max_attempts_exceeded");
872
+ } else {
873
+ updatePending(updated, PENDING_REPORTS_PATH);
874
+ logWarn(
875
+ `[report-queue] Transient failure for run ${item.runId} (attempt ${updated.attempts}/${MAX_ATTEMPTS})`
876
+ );
877
+ }
878
+ }
879
+ }
880
+ }
881
+ }
882
+
494
883
  // src/runner.ts
495
884
  var RUN_TIMEOUT_MS = 15 * 60 * 1e3;
496
885
  var STDOUT_CAP = 1e4;
@@ -503,6 +892,12 @@ function resolveClaudeCommand() {
503
892
  if (existsSync(nativeExe)) return { cmd: nativeExe, shell: false };
504
893
  return { cmd: "claude", shell: true };
505
894
  }
895
+ function shellEscapeArgs(args) {
896
+ return args.map((a) => /[\s"^&|<>%]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a);
897
+ }
898
+ function isValidModelSlug(model) {
899
+ return /^[A-Za-z0-9._:+/-]+$/.test(model);
900
+ }
506
901
  function parseClaudeOutput(raw) {
507
902
  const trimmed = raw.trim();
508
903
  const lastBrace = trimmed.lastIndexOf("}");
@@ -521,148 +916,526 @@ function parseClaudeOutput(raw) {
521
916
  }
522
917
  if (start === -1) return {};
523
918
  try {
524
- const parsed = JSON.parse(trimmed.slice(start, lastBrace + 1));
525
- const result = {};
526
- if (typeof parsed["total_cost_usd"] === "number") {
527
- result.costUsd = parsed["total_cost_usd"];
528
- }
529
- if (typeof parsed["session_id"] === "string") {
530
- result.sessionId = parsed["session_id"];
531
- }
532
- if (typeof parsed["is_error"] === "boolean") {
533
- result.isError = parsed["is_error"];
534
- }
535
- if (typeof parsed["result"] === "string") {
536
- result.resultText = parsed["result"];
919
+ const p = JSON.parse(trimmed.slice(start, lastBrace + 1));
920
+ const r = {};
921
+ if (typeof p["total_cost_usd"] === "number") r.costUsd = p["total_cost_usd"];
922
+ if (typeof p["session_id"] === "string") r.sessionId = p["session_id"];
923
+ if (typeof p["is_error"] === "boolean") r.isError = p["is_error"];
924
+ if (typeof p["result"] === "string") r.resultText = p["result"];
925
+ if (typeof p["api_error_status"] === "number") r.apiErrorStatus = p["api_error_status"];
926
+ const u = p["usage"];
927
+ if (u && typeof u === "object") {
928
+ const uo = u;
929
+ if (typeof uo["input_tokens"] === "number") r.tokensInput = uo["input_tokens"];
930
+ if (typeof uo["output_tokens"] === "number") r.tokensOutput = uo["output_tokens"];
537
931
  }
538
- const usage = parsed["usage"];
539
- if (usage && typeof usage === "object") {
540
- const u = usage;
541
- if (typeof u["input_tokens"] === "number") result.tokensInput = u["input_tokens"];
542
- if (typeof u["output_tokens"] === "number") result.tokensOutput = u["output_tokens"];
543
- }
544
- return result;
932
+ return r;
545
933
  } catch {
546
934
  return {};
547
935
  }
548
936
  }
549
- async function runWorkItem(config, work) {
550
- const agent = findAgent(work.agent.id);
551
- const model = agent?.model ?? work.agent.model;
552
- const agentWorkspace = agentWorkspaceDir(work.agent.id);
553
- let cwd = agentWorkspace;
554
- if (work.workingDir && existsSync(work.workingDir)) {
555
- cwd = work.workingDir;
556
- } else if (work.workingDir) {
557
- logWarn(
558
- `workingDir "${work.workingDir}" does not exist; falling back to agent workspace`
559
- );
937
+ function classifyFailure(stderr, resultText, apiErrorStatus) {
938
+ const h = `${stderr}
939
+ ${resultText ?? ""}`.toLowerCase();
940
+ if (apiErrorStatus === 429 || apiErrorStatus === 529 || /rate.?limit|overloaded|too many requests|usage limit|quota|exhausted/.test(h)) return "rate_limited";
941
+ if (/enoent|not recognized|command not found|login|authenticate|credentials|api key/.test(h))
942
+ return "environment";
943
+ return "error";
944
+ }
945
+ function resolveRuntime(agentRuntime) {
946
+ switch (agentRuntime) {
947
+ case "codex":
948
+ return "codex";
949
+ case "hermes":
950
+ return "hermes";
951
+ default:
952
+ return "claude-code";
560
953
  }
561
- log(`Starting run ${work.runId} (agent "${work.agent.name}", model "${model}")`);
562
- log(` cwd: ${cwd}`);
563
- const claude = resolveClaudeCommand();
564
- const settingsPath = join(agentWorkspace, ".claude", "settings.json").replace(/\\/g, "/");
954
+ }
955
+ function parseCodexOutput(raw) {
956
+ const trimmed = raw.trim();
957
+ const lastBrace = trimmed.lastIndexOf("}");
958
+ if (lastBrace === -1) return {};
959
+ let depth = 0;
960
+ let start = -1;
961
+ for (let i = lastBrace; i >= 0; i--) {
962
+ if (trimmed[i] === "}") depth++;
963
+ else if (trimmed[i] === "{") {
964
+ depth--;
965
+ if (depth === 0) {
966
+ start = i;
967
+ break;
968
+ }
969
+ }
970
+ }
971
+ if (start === -1) return {};
972
+ try {
973
+ const p = JSON.parse(trimmed.slice(start, lastBrace + 1));
974
+ const r = {};
975
+ if (typeof p["result"] === "string") r.resultText = p["result"];
976
+ if (typeof p["output"] === "string") r.resultText = p["output"];
977
+ if (typeof p["cost"] === "number") r.costUsd = p["cost"];
978
+ if (p["error"] !== void 0) r.isError = true;
979
+ return r;
980
+ } catch {
981
+ return {};
982
+ }
983
+ }
984
+ var HANDOFF_TIMEOUT_MS = 4 * 60 * 1e3;
985
+ async function runHandoffTurn(claude, agentWorkspace, settingsPath, model, oldSessionId) {
565
986
  const rawArgs = [
566
987
  "-p",
567
988
  "--output-format",
568
989
  "json",
569
990
  "--max-turns",
570
- "25",
991
+ "6",
571
992
  "--model",
572
993
  model,
573
994
  "--settings",
574
- settingsPath
995
+ settingsPath,
996
+ "--resume",
997
+ oldSessionId
575
998
  ];
576
- const persistent = work.agent.localSessionMode === "persistent";
577
- let sessionState = loadSessionState(work.agent.id);
578
- if (persistent) {
579
- if (shouldRotateSession(sessionState, Date.now())) {
580
- if (sessionState.sessionId) {
581
- log(`Rotating session for agent "${work.agent.name}" (age/run limit reached)`);
582
- }
583
- sessionState = { sessionId: null, sessionStartedAt: null, runCount: 0 };
584
- } else if (sessionState.sessionId) {
585
- rawArgs.push("--resume", sessionState.sessionId);
586
- }
587
- }
588
- const args = claude.shell ? rawArgs.map((a) => /[\s"^&|<>%]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a) : rawArgs;
589
- const env = { ...process.env };
590
- let stdoutBuf = "";
591
- let stderrBuf = "";
592
- let exitCode = null;
593
- let spawnError;
999
+ const args = claude.shell ? shellEscapeArgs(rawArgs) : rawArgs;
594
1000
  await new Promise((resolve) => {
595
1001
  let child;
596
1002
  try {
597
1003
  child = spawn(claude.cmd, args, {
598
- cwd,
599
- env,
600
- // shell only as a last resort (Windows .cmd shim). Safe because argv
601
- // contains only our fixed, pre-quoted tokens.
1004
+ cwd: agentWorkspace,
1005
+ env: { ...process.env },
602
1006
  shell: claude.shell,
603
- // stdin: pipe — the prompt is delivered via stdin, not argv.
604
- stdio: ["pipe", "pipe", "pipe"]
1007
+ stdio: ["pipe", "ignore", "ignore"]
605
1008
  });
606
- child.stdin?.write(work.prompt);
1009
+ child.stdin?.write(
1010
+ "Your session is about to be rotated (context limit). Write or overwrite HANDOFF.md in your workspace root RIGHT NOW: what you were working on, current state, decisions in flight, and the immediate next step. Max 60 lines. Do nothing else."
1011
+ );
607
1012
  child.stdin?.end();
608
1013
  } catch (err) {
609
- spawnError = err instanceof Error ? err.message : String(err);
1014
+ logWarn(`Handoff turn failed to spawn: ${err instanceof Error ? err.message : String(err)}`);
610
1015
  resolve();
611
1016
  return;
612
1017
  }
613
- const timeoutHandle = setTimeout(() => {
614
- logWarn(`Run ${work.runId} exceeded timeout \u2014 sending SIGTERM`);
1018
+ const timer = setTimeout(() => {
1019
+ logWarn("Handoff turn timed out \u2014 rotating without handoff doc");
615
1020
  try {
616
1021
  child.kill("SIGTERM");
617
1022
  } catch {
618
1023
  }
619
- setTimeout(() => {
620
- try {
621
- child.kill("SIGKILL");
622
- } catch {
623
- }
624
- }, 5e3);
625
- }, RUN_TIMEOUT_MS);
626
- child.stdout?.on("data", (chunk) => {
627
- stdoutBuf += chunk.toString("utf-8");
1024
+ }, HANDOFF_TIMEOUT_MS);
1025
+ child.on("close", () => {
1026
+ clearTimeout(timer);
1027
+ log("Handoff turn complete");
1028
+ resolve();
1029
+ });
1030
+ child.on("error", () => {
1031
+ clearTimeout(timer);
1032
+ resolve();
628
1033
  });
629
- child.stderr?.on("data", (chunk) => {
630
- stderrBuf += chunk.toString("utf-8");
1034
+ });
1035
+ }
1036
+ async function spawnMemorySync(agentWorkspace) {
1037
+ const scriptPath = join(agentWorkspace, ".claude", "memory-sync.mjs");
1038
+ if (!existsSync(scriptPath)) return;
1039
+ await new Promise((resolve) => {
1040
+ let child;
1041
+ try {
1042
+ child = spawn(process.execPath, [scriptPath], {
1043
+ cwd: agentWorkspace,
1044
+ env: { ...process.env },
1045
+ shell: false,
1046
+ stdio: ["ignore", "ignore", "pipe"]
1047
+ });
1048
+ } catch (err) {
1049
+ logWarn(`Memory sync spawn failed: ${err instanceof Error ? err.message : String(err)}`);
1050
+ resolve();
1051
+ return;
1052
+ }
1053
+ let stderrBuf = "";
1054
+ child.stderr?.on("data", (c) => {
1055
+ stderrBuf += c.toString("utf-8");
631
1056
  });
1057
+ const timer = setTimeout(() => {
1058
+ logWarn("Memory sync timed out after 60s \u2014 killing");
1059
+ try {
1060
+ child.kill("SIGTERM");
1061
+ } catch {
1062
+ }
1063
+ }, 6e4);
632
1064
  child.on("close", (code) => {
633
- clearTimeout(timeoutHandle);
634
- exitCode = code;
1065
+ clearTimeout(timer);
1066
+ if (code !== 0) logWarn(`Memory sync exited ${String(code)}${stderrBuf ? `: ${stderrBuf.slice(-500)}` : ""}`);
635
1067
  resolve();
636
1068
  });
637
1069
  child.on("error", (err) => {
638
- clearTimeout(timeoutHandle);
639
- spawnError = err.message;
1070
+ clearTimeout(timer);
1071
+ logWarn(`Memory sync error: ${err.message}`);
640
1072
  resolve();
641
1073
  });
642
1074
  });
643
- const stdoutExcerpt = stdoutBuf.length > STDOUT_CAP ? stdoutBuf.slice(-STDOUT_CAP) : stdoutBuf;
644
- const stderrExcerpt = stderrBuf.length > STDERR_CAP ? stderrBuf.slice(-STDERR_CAP) : stderrBuf;
645
- const parsed = parseClaudeOutput(stdoutBuf);
646
- const succeeded = spawnError === void 0 && exitCode === 0 && parsed.isError !== true;
647
- const errorMessage = succeeded ? void 0 : spawnError ?? (parsed.isError ? parsed.resultText?.slice(0, 1e3) : void 0) ?? (stderrExcerpt.trim() ? stderrExcerpt.trim().slice(-1e3) : void 0) ?? `claude exited with code ${exitCode ?? "unknown"}`;
648
- const { isError: _isError, resultText: _resultText, ...reportFields } = parsed;
649
- const report = {
650
- runId: work.runId,
651
- wakeupId: work.wakeupId,
652
- status: succeeded ? "completed" : "failed",
653
- stdoutExcerpt,
654
- stderrExcerpt,
655
- error: errorMessage,
656
- exitCode,
657
- ...reportFields
658
- };
659
- log(
660
- `Run ${work.runId} ${report.status} (exit ${exitCode ?? "n/a"}${parsed.costUsd !== void 0 ? `, cost $${parsed.costUsd.toFixed(4)}` : ""})`
1075
+ }
1076
+ function buildCodexSpawnConfig(model, allowedTools) {
1077
+ const useShell = process.platform === "win32";
1078
+ const rawArgs = ["exec", "--json"];
1079
+ if (model && model.trim().length > 0) {
1080
+ rawArgs.push("--model", model);
1081
+ }
1082
+ if (allowedTools.length > 0) {
1083
+ log(`codex runtime: --allowedTools [${allowedTools.join(",")}] not forwarded (no codex equivalent); use sandbox isolation for tool restriction`);
1084
+ }
1085
+ const args = useShell ? shellEscapeArgs(rawArgs) : rawArgs;
1086
+ return { cmd: "codex", args, shell: useShell, promptAsArg: false };
1087
+ }
1088
+ function buildHermesSpawnConfig(model, allowedTools) {
1089
+ const useShell = process.platform === "win32";
1090
+ const rawArgs = ["-z"];
1091
+ if (model && model.trim().length > 0) {
1092
+ rawArgs.push("--model", model);
1093
+ }
1094
+ if (allowedTools.length > 0) {
1095
+ const hermesToolsets = allowedTools.filter(
1096
+ (t) => /^(web|terminal|development|safe|memory|messaging|calendar|files|code|search)$/i.test(t)
1097
+ );
1098
+ if (hermesToolsets.length > 0) {
1099
+ rawArgs.push("--toolsets", hermesToolsets.join(","));
1100
+ } else {
1101
+ log(`hermes runtime: allowedTools [${allowedTools.join(",")}] not in hermes toolset format; skipping --toolsets`);
1102
+ }
1103
+ }
1104
+ const args = useShell ? shellEscapeArgs(rawArgs) : rawArgs;
1105
+ return { cmd: "hermes", args, shell: useShell, promptAsArg: false };
1106
+ }
1107
+ async function runWorkItem(config, work) {
1108
+ if (!work.agent) {
1109
+ logWarn(`Run ${work.runId} has no agent \u2014 not an agent_run work item; skipping`);
1110
+ await sendReport(config, {
1111
+ runId: work.runId,
1112
+ wakeupId: work.wakeupId,
1113
+ status: "failed",
1114
+ failureKind: "config_error",
1115
+ stdoutExcerpt: "",
1116
+ stderrExcerpt: "",
1117
+ error: "Work item has no agent (wrong kind reached runWorkItem)",
1118
+ exitCode: null
1119
+ });
1120
+ return;
1121
+ }
1122
+ const workAgent = work.agent;
1123
+ const agent = findAgent(workAgent.id);
1124
+ const model = agent?.model ?? workAgent.model;
1125
+ const agentWorkspace = agentWorkspaceDir(workAgent.id);
1126
+ const runtime = resolveRuntime(
1127
+ workAgent.agentRuntime ?? agent?.agentRuntime
661
1128
  );
662
- if (persistent && report.status === "completed") {
663
- recordRunSession(work.agent.id, parsed.sessionId, sessionState, Date.now());
1129
+ let cwd = agentWorkspace;
1130
+ if (work.workingDir && existsSync(work.workingDir)) {
1131
+ cwd = work.workingDir;
1132
+ } else if (work.workingDir) {
1133
+ logWarn(`workingDir "${work.workingDir}" does not exist; falling back to agent workspace`);
1134
+ }
1135
+ log(`Starting run ${work.runId} (agent "${workAgent.name}", runtime "${runtime}", model "${model}")`);
1136
+ log(` cwd: ${cwd}`);
1137
+ const env = { ...process.env };
1138
+ if (runtime === "hermes") {
1139
+ env["HERMES_HOME"] = join(agentWorkspace, ".hermes");
1140
+ }
1141
+ const promptText = buildPromptText({
1142
+ workPrompt: work.prompt,
1143
+ agent,
1144
+ cwd,
1145
+ agentWorkspace,
1146
+ rotated: false
1147
+ });
1148
+ let stdoutBuf = "", stderrBuf = "";
1149
+ let exitCode = null;
1150
+ let spawnError;
1151
+ if (runtime === "claude-code") {
1152
+ const claude = resolveClaudeCommand();
1153
+ const settingsPath = join(agentWorkspace, ".claude", "settings.json").replace(/\\/g, "/");
1154
+ const rawArgs = [
1155
+ "-p",
1156
+ "--output-format",
1157
+ "json",
1158
+ "--max-turns",
1159
+ "25",
1160
+ "--model",
1161
+ model,
1162
+ "--settings",
1163
+ settingsPath
1164
+ ];
1165
+ if (Array.isArray(workAgent.allowedTools) && workAgent.allowedTools.length > 0) {
1166
+ rawArgs.push("--allowedTools", workAgent.allowedTools.join(","));
1167
+ }
1168
+ const persistent = workAgent.localSessionMode === "persistent";
1169
+ let sessionState = loadSessionState(workAgent.id);
1170
+ let rotated = false;
1171
+ if (persistent) {
1172
+ if (shouldRotateSession(sessionState, Date.now())) {
1173
+ if (sessionState.sessionId) {
1174
+ log(`Rotating session for agent "${workAgent.name}" (age/run limit reached)`);
1175
+ await runHandoffTurn(claude, agentWorkspace, settingsPath, model, sessionState.sessionId);
1176
+ rotated = true;
1177
+ }
1178
+ sessionState = { sessionId: null, sessionStartedAt: null, runCount: 0 };
1179
+ } else if (sessionState.sessionId) {
1180
+ rawArgs.push("--resume", sessionState.sessionId);
1181
+ }
1182
+ }
1183
+ const claudePromptText = buildPromptText({
1184
+ workPrompt: work.prompt,
1185
+ agent,
1186
+ cwd,
1187
+ agentWorkspace,
1188
+ rotated
1189
+ });
1190
+ const args = claude.shell ? shellEscapeArgs(rawArgs) : rawArgs;
1191
+ await new Promise((resolve) => {
1192
+ let child;
1193
+ try {
1194
+ child = spawn(claude.cmd, args, {
1195
+ cwd,
1196
+ env,
1197
+ shell: claude.shell,
1198
+ stdio: ["pipe", "pipe", "pipe"]
1199
+ });
1200
+ child.stdin?.write(claudePromptText);
1201
+ child.stdin?.end();
1202
+ } catch (err) {
1203
+ spawnError = err instanceof Error ? err.message : String(err);
1204
+ resolve();
1205
+ return;
1206
+ }
1207
+ const timeoutHandle = setTimeout(() => {
1208
+ logWarn(`Run ${work.runId} exceeded timeout \u2014 sending SIGTERM`);
1209
+ try {
1210
+ child.kill("SIGTERM");
1211
+ } catch {
1212
+ }
1213
+ setTimeout(() => {
1214
+ try {
1215
+ child.kill("SIGKILL");
1216
+ } catch {
1217
+ }
1218
+ }, 5e3);
1219
+ }, RUN_TIMEOUT_MS);
1220
+ child.stdout?.on("data", (c) => {
1221
+ stdoutBuf += c.toString("utf-8");
1222
+ });
1223
+ child.stderr?.on("data", (c) => {
1224
+ stderrBuf += c.toString("utf-8");
1225
+ });
1226
+ child.on("close", (code) => {
1227
+ clearTimeout(timeoutHandle);
1228
+ exitCode = code;
1229
+ resolve();
1230
+ });
1231
+ child.on("error", (err) => {
1232
+ clearTimeout(timeoutHandle);
1233
+ spawnError = err.message;
1234
+ resolve();
1235
+ });
1236
+ });
1237
+ const stdoutExcerpt = stdoutBuf.length > STDOUT_CAP ? stdoutBuf.slice(-STDOUT_CAP) : stdoutBuf;
1238
+ const stderrExcerpt = stderrBuf.length > STDERR_CAP ? stderrBuf.slice(-STDERR_CAP) : stderrBuf;
1239
+ const parsed = parseClaudeOutput(stdoutBuf);
1240
+ const succeeded = spawnError === void 0 && exitCode === 0 && parsed.isError !== true;
1241
+ const errorMessage = succeeded ? void 0 : spawnError ?? (parsed.isError ? parsed.resultText?.slice(0, 1e3) : void 0) ?? (stderrExcerpt.trim() ? stderrExcerpt.trim().slice(-1e3) : void 0) ?? `claude exited with code ${exitCode ?? "unknown"}`;
1242
+ const failureKind = succeeded ? void 0 : classifyFailure(stderrExcerpt, parsed.resultText, parsed.apiErrorStatus);
1243
+ const { isError: _ie, resultText: _rt, apiErrorStatus: _aes, ...reportFields } = parsed;
1244
+ const report = {
1245
+ runId: work.runId,
1246
+ wakeupId: work.wakeupId,
1247
+ status: succeeded ? "completed" : "failed",
1248
+ failureKind,
1249
+ stdoutExcerpt,
1250
+ stderrExcerpt,
1251
+ error: errorMessage,
1252
+ exitCode,
1253
+ ...reportFields
1254
+ };
1255
+ log(`Run ${work.runId} ${report.status} (exit ${exitCode ?? "n/a"}${parsed.costUsd !== void 0 ? `, cost $${parsed.costUsd.toFixed(4)}` : ""})`);
1256
+ if (persistent && report.status === "completed") {
1257
+ recordRunSession(workAgent.id, parsed.sessionId, sessionState, Date.now());
1258
+ }
1259
+ await sendReport(config, report);
1260
+ if (report.status === "completed") {
1261
+ await spawnMemorySync(agentWorkspace);
1262
+ }
1263
+ } else if (runtime === "codex") {
1264
+ if (!isValidModelSlug(model)) {
1265
+ logWarn(`Run ${work.runId} aborted \u2014 model slug failed validation: "${model}"`);
1266
+ const report2 = {
1267
+ runId: work.runId,
1268
+ wakeupId: work.wakeupId,
1269
+ status: "failed",
1270
+ failureKind: "config_error",
1271
+ stdoutExcerpt: "",
1272
+ stderrExcerpt: "",
1273
+ error: `Invalid model slug: "${model}" \u2014 must match ^[A-Za-z0-9._:+/-]+$`,
1274
+ exitCode: null
1275
+ };
1276
+ await sendReport(config, report2);
1277
+ return;
1278
+ }
1279
+ const spawnCfg = buildCodexSpawnConfig(model, workAgent.allowedTools ?? []);
1280
+ const args = spawnCfg.args;
1281
+ await new Promise((resolve) => {
1282
+ let child;
1283
+ try {
1284
+ child = spawn(spawnCfg.cmd, args, {
1285
+ cwd,
1286
+ env,
1287
+ shell: spawnCfg.shell,
1288
+ stdio: ["pipe", "pipe", "pipe"]
1289
+ });
1290
+ if (!spawnCfg.promptAsArg) {
1291
+ child.stdin?.write(promptText);
1292
+ }
1293
+ child.stdin?.end();
1294
+ } catch (err) {
1295
+ spawnError = err instanceof Error ? err.message : String(err);
1296
+ resolve();
1297
+ return;
1298
+ }
1299
+ const timeoutHandle = setTimeout(() => {
1300
+ logWarn(`Run ${work.runId} exceeded timeout \u2014 sending SIGTERM`);
1301
+ try {
1302
+ child.kill("SIGTERM");
1303
+ } catch {
1304
+ }
1305
+ setTimeout(() => {
1306
+ try {
1307
+ child.kill("SIGKILL");
1308
+ } catch {
1309
+ }
1310
+ }, 5e3);
1311
+ }, RUN_TIMEOUT_MS);
1312
+ child.stdout?.on("data", (c) => {
1313
+ stdoutBuf += c.toString("utf-8");
1314
+ });
1315
+ child.stderr?.on("data", (c) => {
1316
+ stderrBuf += c.toString("utf-8");
1317
+ });
1318
+ child.on("close", (code) => {
1319
+ clearTimeout(timeoutHandle);
1320
+ exitCode = code;
1321
+ resolve();
1322
+ });
1323
+ child.on("error", (err) => {
1324
+ clearTimeout(timeoutHandle);
1325
+ spawnError = err.message;
1326
+ resolve();
1327
+ });
1328
+ });
1329
+ const stdoutExcerpt = stdoutBuf.length > STDOUT_CAP ? stdoutBuf.slice(-STDOUT_CAP) : stdoutBuf;
1330
+ const stderrExcerpt = stderrBuf.length > STDERR_CAP ? stderrBuf.slice(-STDERR_CAP) : stderrBuf;
1331
+ const parsed = parseCodexOutput(stdoutBuf);
1332
+ const succeeded = spawnError === void 0 && exitCode === 0 && parsed.isError !== true;
1333
+ const errorMessage = succeeded ? void 0 : spawnError ?? (stderrExcerpt.trim() ? stderrExcerpt.trim().slice(-1e3) : void 0) ?? `codex exited with code ${exitCode ?? "unknown"}`;
1334
+ const failureKind = succeeded ? void 0 : classifyFailure(stderrExcerpt, parsed.resultText ?? stdoutBuf.slice(0, 500), void 0);
1335
+ const report = {
1336
+ runId: work.runId,
1337
+ wakeupId: work.wakeupId,
1338
+ status: succeeded ? "completed" : "failed",
1339
+ failureKind,
1340
+ stdoutExcerpt,
1341
+ stderrExcerpt,
1342
+ error: errorMessage,
1343
+ exitCode,
1344
+ costUsd: parsed.costUsd
1345
+ };
1346
+ log(`Run ${work.runId} ${report.status} \u2014 codex (exit ${exitCode ?? "n/a"}${parsed.costUsd !== void 0 ? `, cost $${parsed.costUsd.toFixed(4)}` : ""})`);
1347
+ await sendReport(config, report);
1348
+ if (report.status === "completed") {
1349
+ await spawnMemorySync(agentWorkspace);
1350
+ }
1351
+ } else {
1352
+ log(`Hermes runtime: HERMES_YOLO_MODE is forced on by -z \u2014 ensure this bridge machine is sandboxed/isolated`);
1353
+ log(` HERMES_HOME: ${env["HERMES_HOME"] ?? "(default)"}`);
1354
+ if (!isValidModelSlug(model)) {
1355
+ logWarn(`Run ${work.runId} aborted \u2014 model slug failed validation: "${model}"`);
1356
+ const report2 = {
1357
+ runId: work.runId,
1358
+ wakeupId: work.wakeupId,
1359
+ status: "failed",
1360
+ failureKind: "config_error",
1361
+ stdoutExcerpt: "",
1362
+ stderrExcerpt: "",
1363
+ error: `Invalid model slug: "${model}" \u2014 must match ^[A-Za-z0-9._:+/-]+$`,
1364
+ exitCode: null
1365
+ };
1366
+ await sendReport(config, report2);
1367
+ return;
1368
+ }
1369
+ const spawnCfg = buildHermesSpawnConfig(model, workAgent.allowedTools ?? []);
1370
+ const args = spawnCfg.args;
1371
+ await new Promise((resolve) => {
1372
+ let child;
1373
+ try {
1374
+ child = spawn(spawnCfg.cmd, args, {
1375
+ cwd,
1376
+ env,
1377
+ shell: spawnCfg.shell,
1378
+ stdio: ["pipe", "pipe", "pipe"]
1379
+ });
1380
+ child.stdin?.write(promptText);
1381
+ child.stdin?.end();
1382
+ } catch (err) {
1383
+ spawnError = err instanceof Error ? err.message : String(err);
1384
+ resolve();
1385
+ return;
1386
+ }
1387
+ const timeoutHandle = setTimeout(() => {
1388
+ logWarn(`Run ${work.runId} exceeded timeout \u2014 sending SIGTERM`);
1389
+ try {
1390
+ child.kill("SIGTERM");
1391
+ } catch {
1392
+ }
1393
+ setTimeout(() => {
1394
+ try {
1395
+ child.kill("SIGKILL");
1396
+ } catch {
1397
+ }
1398
+ }, 5e3);
1399
+ }, RUN_TIMEOUT_MS);
1400
+ child.stdout?.on("data", (c) => {
1401
+ stdoutBuf += c.toString("utf-8");
1402
+ });
1403
+ child.stderr?.on("data", (c) => {
1404
+ stderrBuf += c.toString("utf-8");
1405
+ });
1406
+ child.on("close", (code) => {
1407
+ clearTimeout(timeoutHandle);
1408
+ exitCode = code;
1409
+ resolve();
1410
+ });
1411
+ child.on("error", (err) => {
1412
+ clearTimeout(timeoutHandle);
1413
+ spawnError = err.message;
1414
+ resolve();
1415
+ });
1416
+ });
1417
+ const stdoutExcerpt = stdoutBuf.length > STDOUT_CAP ? stdoutBuf.slice(-STDOUT_CAP) : stdoutBuf;
1418
+ const stderrExcerpt = stderrBuf.length > STDERR_CAP ? stderrBuf.slice(-STDERR_CAP) : stderrBuf;
1419
+ const succeeded = spawnError === void 0 && exitCode === 0;
1420
+ const errorMessage = succeeded ? void 0 : spawnError ?? (stderrExcerpt.trim() ? stderrExcerpt.trim().slice(-1e3) : void 0) ?? `hermes exited with code ${exitCode ?? "unknown"}`;
1421
+ const failureKind = succeeded ? void 0 : classifyFailure(stderrExcerpt, stdoutBuf.slice(0, 500), void 0);
1422
+ const report = {
1423
+ runId: work.runId,
1424
+ wakeupId: work.wakeupId,
1425
+ status: succeeded ? "completed" : "failed",
1426
+ failureKind,
1427
+ stdoutExcerpt,
1428
+ stderrExcerpt,
1429
+ error: errorMessage,
1430
+ exitCode
1431
+ // No costUsd / tokensInput / tokensOutput from hermes -z (FLAG above).
1432
+ };
1433
+ log(`Run ${work.runId} ${report.status} \u2014 hermes (exit ${exitCode ?? "n/a"})`);
1434
+ await sendReport(config, report);
1435
+ if (report.status === "completed") {
1436
+ await spawnMemorySync(agentWorkspace);
1437
+ }
664
1438
  }
665
- await sendReport(config, report);
666
1439
  }
667
1440
  async function sendReport(config, report) {
668
1441
  const path = `/api/bridge/v1/runs/${report.runId}`;
@@ -674,46 +1447,251 @@ async function sendReport(config, report) {
674
1447
  return;
675
1448
  } catch (err) {
676
1449
  logError(`Failed to report run ${report.runId} (attempt ${attempt})`, err);
677
- if (attempt < REPORT_RETRY_COUNT) {
678
- const delay = REPORT_RETRY_BASE_MS * Math.pow(2, attempt - 1);
679
- await sleep(delay);
680
- }
1450
+ if (attempt < REPORT_RETRY_COUNT) await sleep(REPORT_RETRY_BASE_MS * Math.pow(2, attempt - 1));
681
1451
  }
682
1452
  }
683
1453
  logWarn(`Persisting failed report for run ${report.runId} to pending-reports.json`);
684
- appendPendingReport(report);
1454
+ enqueue({ runId: report.runId, report, attempts: REPORT_RETRY_COUNT, firstFailedAt: (/* @__PURE__ */ new Date()).toISOString() });
685
1455
  }
686
1456
  async function retryPendingReports(config) {
687
- const pending = readJsonSafe(PENDING_REPORTS_PATH);
688
- if (!pending || pending.length === 0) return;
689
- log(`Retrying ${pending.length} pending report(s)`);
690
- for (const report of pending) {
691
- await sendReport(config, report);
1457
+ await drain(async (item) => {
1458
+ const path = `/api/bridge/v1/runs/${item.report.runId}`;
1459
+ await post(config, path, item.report);
1460
+ log(`Reported run ${item.report.runId} (from queue)`);
1461
+ });
1462
+ }
1463
+ function sleep(ms) {
1464
+ return new Promise((resolve) => setTimeout(resolve, ms));
1465
+ }
1466
+ var STDOUT_CAP2 = 1e4;
1467
+ var STDERR_CAP2 = 4e3;
1468
+ var DEFAULT_EXEC_TIMEOUT_MS = 3e4;
1469
+ var MAX_EXEC_TIMEOUT_MS = 6e4;
1470
+ var KILL_GRACE_MS = 5e3;
1471
+ function resolveExecTimeout(timeoutMs) {
1472
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1473
+ return DEFAULT_EXEC_TIMEOUT_MS;
692
1474
  }
1475
+ return Math.min(timeoutMs, MAX_EXEC_TIMEOUT_MS);
693
1476
  }
694
- function appendPendingReport(report) {
695
- const existing = readJsonSafe(PENDING_REPORTS_PATH) ?? [];
696
- const deduped = existing.filter((r) => r.runId !== report.runId);
697
- deduped.push(report);
698
- writeJsonAtomic(PENDING_REPORTS_PATH, deduped);
1477
+ function capExcerpt(buf, cap) {
1478
+ return buf.length > cap ? buf.slice(-cap) : buf;
699
1479
  }
700
- function removePendingReport(runId) {
701
- const existing = readJsonSafe(PENDING_REPORTS_PATH);
702
- if (!existing || existing.length === 0) return;
703
- const filtered = existing.filter((r) => r.runId !== runId);
704
- if (filtered.length !== existing.length) {
705
- writeJsonAtomic(PENDING_REPORTS_PATH, filtered);
1480
+ function classifyExec(opts) {
1481
+ if (opts.timedOut) return { exitCode: opts.exitCode, status: "timeout" };
1482
+ if (opts.spawnError !== void 0) return { exitCode: opts.exitCode, status: "failed" };
1483
+ if (opts.exitCode === 0) return { exitCode: 0, status: "completed" };
1484
+ return { exitCode: opts.exitCode, status: "failed" };
1485
+ }
1486
+ function resolveExecShell(command) {
1487
+ if (process.platform === "win32") {
1488
+ return { cmd: process.env["ComSpec"] ?? "cmd.exe", args: ["/d", "/s", "/c", command], shell: false };
706
1489
  }
1490
+ const sh = process.env["SHELL"] ?? "/bin/sh";
1491
+ return { cmd: sh, args: ["-c", command], shell: false };
707
1492
  }
708
- function sleep(ms) {
709
- return new Promise((resolve) => setTimeout(resolve, ms));
1493
+ async function execShellCommand(command, options = {}) {
1494
+ const timeoutMs = resolveExecTimeout(options.timeoutMs);
1495
+ const { cmd, args, shell } = resolveExecShell(command);
1496
+ const spawnCwd = options.cwd && options.cwd.length > 0 ? options.cwd : void 0;
1497
+ let stdoutBuf = "";
1498
+ let stderrBuf = "";
1499
+ let exitCode = null;
1500
+ let spawnError;
1501
+ let timedOut = false;
1502
+ await new Promise((resolve) => {
1503
+ let child;
1504
+ try {
1505
+ child = spawn(cmd, args, {
1506
+ cwd: spawnCwd,
1507
+ env: { ...process.env },
1508
+ shell,
1509
+ stdio: ["ignore", "pipe", "pipe"],
1510
+ windowsHide: true
1511
+ });
1512
+ } catch (err) {
1513
+ spawnError = err instanceof Error ? err.message : String(err);
1514
+ resolve();
1515
+ return;
1516
+ }
1517
+ const timeoutHandle = setTimeout(() => {
1518
+ timedOut = true;
1519
+ try {
1520
+ child.kill("SIGTERM");
1521
+ } catch {
1522
+ }
1523
+ setTimeout(() => {
1524
+ try {
1525
+ child.kill("SIGKILL");
1526
+ } catch {
1527
+ }
1528
+ }, KILL_GRACE_MS);
1529
+ }, timeoutMs);
1530
+ child.stdout?.on("data", (c) => {
1531
+ stdoutBuf += c.toString("utf-8");
1532
+ });
1533
+ child.stderr?.on("data", (c) => {
1534
+ stderrBuf += c.toString("utf-8");
1535
+ });
1536
+ child.on("close", (code) => {
1537
+ clearTimeout(timeoutHandle);
1538
+ exitCode = code;
1539
+ resolve();
1540
+ });
1541
+ child.on("error", (err) => {
1542
+ clearTimeout(timeoutHandle);
1543
+ spawnError = err.message;
1544
+ resolve();
1545
+ });
1546
+ });
1547
+ const stdoutExcerpt = capExcerpt(stdoutBuf, STDOUT_CAP2);
1548
+ const stderrSource = spawnError !== void 0 && stderrBuf.length === 0 ? spawnError : stderrBuf;
1549
+ const stderrExcerpt = capExcerpt(stderrSource, STDERR_CAP2);
1550
+ const { exitCode: finalExit, status } = classifyExec({ exitCode, spawnError, timedOut });
1551
+ return { exitCode: finalExit, stdoutExcerpt, stderrExcerpt, status };
1552
+ }
1553
+ var DEFAULT_COLS = 120;
1554
+ var DEFAULT_ROWS = 30;
1555
+ function loadNodePty() {
1556
+ try {
1557
+ return __require("node-pty");
1558
+ } catch (err) {
1559
+ logWarn(`node-pty unavailable: ${err instanceof Error ? err.message : String(err)}`);
1560
+ return null;
1561
+ }
1562
+ }
1563
+ function resolveShell(override) {
1564
+ if (override && override.length > 0) {
1565
+ return { shell: override, args: os.platform() === "win32" ? [] : ["--login"] };
1566
+ }
1567
+ if (os.platform() === "win32") {
1568
+ const gitBashPaths = [
1569
+ "C:\\Program Files\\Git\\bin\\bash.exe",
1570
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe"
1571
+ ];
1572
+ const bashPath = gitBashPaths.find((p) => existsSync(p));
1573
+ if (bashPath) return { shell: bashPath, args: ["--login", "-i"] };
1574
+ return { shell: "powershell.exe", args: ["-NoLogo"] };
1575
+ }
1576
+ return { shell: process.env["SHELL"] ?? "/bin/bash", args: ["--login"] };
1577
+ }
1578
+ function buildDaemonJoinUrl(relayWsUrl, joinToken) {
1579
+ const base = relayWsUrl.replace(/\/+$/, "");
1580
+ return `${base}/daemon?token=${encodeURIComponent(joinToken)}`;
1581
+ }
1582
+ async function openTerminalSession(opts) {
1583
+ const { WebSocket } = await import('ws');
1584
+ const url = buildDaemonJoinUrl(opts.relayWsUrl, opts.joinToken);
1585
+ const cols = opts.cols && opts.cols > 0 ? opts.cols : DEFAULT_COLS;
1586
+ const rows = opts.rows && opts.rows > 0 ? opts.rows : DEFAULT_ROWS;
1587
+ const ws = new WebSocket(url);
1588
+ let pty = null;
1589
+ let closed = false;
1590
+ const send = (frame) => {
1591
+ if (ws.readyState === WebSocket.OPEN) {
1592
+ ws.send(JSON.stringify(frame));
1593
+ }
1594
+ };
1595
+ const cleanup = (reason) => {
1596
+ if (closed) return;
1597
+ closed = true;
1598
+ if (pty) {
1599
+ try {
1600
+ pty.kill();
1601
+ } catch {
1602
+ }
1603
+ pty = null;
1604
+ }
1605
+ try {
1606
+ ws.close();
1607
+ } catch {
1608
+ }
1609
+ log(`[terminal] session ${opts.sessionId} closed (${reason})`);
1610
+ if (opts.onClosed) {
1611
+ try {
1612
+ opts.onClosed(opts.sessionId);
1613
+ } catch {
1614
+ }
1615
+ }
1616
+ };
1617
+ const handle = {
1618
+ sessionId: opts.sessionId,
1619
+ close: () => cleanup("handle.close")
1620
+ };
1621
+ ws.on("open", () => {
1622
+ send({ type: "auth", joinToken: opts.joinToken, sessionId: opts.sessionId });
1623
+ const nodePty = loadNodePty();
1624
+ if (!nodePty) {
1625
+ send({
1626
+ type: "output",
1627
+ data: "\r\n[stride-bridge] Interactive terminal unavailable on this machine: node-pty is not installed. Non-interactive commands (Processes, Services, Logs, Actions, Snippets) still work.\r\n"
1628
+ });
1629
+ send({ type: "error", message: "node-pty unavailable on daemon host" });
1630
+ cleanup("node-pty unavailable");
1631
+ return;
1632
+ }
1633
+ const { shell, args } = resolveShell(opts.shell);
1634
+ try {
1635
+ pty = nodePty.spawn(shell, args, {
1636
+ name: "xterm-256color",
1637
+ cols,
1638
+ rows,
1639
+ cwd: opts.cwd && opts.cwd.length > 0 ? opts.cwd : os.homedir(),
1640
+ env: process.env
1641
+ });
1642
+ } catch (err) {
1643
+ send({
1644
+ type: "error",
1645
+ message: `Failed to spawn shell: ${err instanceof Error ? err.message : String(err)}`
1646
+ });
1647
+ cleanup("pty spawn failed");
1648
+ return;
1649
+ }
1650
+ pty.onData((data) => {
1651
+ send({ type: "output", data });
1652
+ });
1653
+ pty.onExit(({ exitCode }) => {
1654
+ send({ type: "exit", code: exitCode });
1655
+ cleanup(`pty exit ${exitCode}`);
1656
+ });
1657
+ send({ type: "ready" });
1658
+ log(`[terminal] session ${opts.sessionId} ready (${shell})`);
1659
+ });
1660
+ ws.on("message", (raw) => {
1661
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf-8") : Array.isArray(raw) ? Buffer.concat(raw).toString("utf-8") : Buffer.from(raw).toString("utf-8");
1662
+ let frame;
1663
+ try {
1664
+ frame = JSON.parse(text);
1665
+ } catch {
1666
+ if (pty) pty.write(text);
1667
+ return;
1668
+ }
1669
+ if (frame.type === "input" && typeof frame.data === "string") {
1670
+ if (pty) pty.write(frame.data);
1671
+ } else if (frame.type === "resize" && typeof frame.cols === "number" && typeof frame.rows === "number") {
1672
+ if (pty) {
1673
+ try {
1674
+ pty.resize(frame.cols, frame.rows);
1675
+ } catch {
1676
+ }
1677
+ }
1678
+ } else if (frame.type === "ping") {
1679
+ send({ type: "pong" });
1680
+ }
1681
+ });
1682
+ ws.on("close", () => cleanup("ws close"));
1683
+ ws.on("error", (err) => {
1684
+ logWarn(`[terminal] session ${opts.sessionId} ws error: ${err.message}`);
1685
+ cleanup("ws error");
1686
+ });
1687
+ return handle;
710
1688
  }
711
1689
  var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
712
- var CACHE_TTL_MS = 6e4;
1690
+ var CACHE_TTL_MS2 = 6e4;
713
1691
  var FETCH_TIMEOUT_MS = 5e3;
714
1692
  function remainingPct(utilization) {
715
1693
  if (typeof utilization !== "number" || !Number.isFinite(utilization)) return null;
716
- return Math.max(0, Math.min(100, Math.round((1 - utilization) * 100)));
1694
+ return Math.max(0, Math.min(100, Math.round(100 - utilization)));
717
1695
  }
718
1696
  function parseUsageResponse(body, nowIso) {
719
1697
  return {
@@ -729,10 +1707,10 @@ function readOauthToken() {
729
1707
  );
730
1708
  return creds?.claudeAiOauth?.accessToken ?? null;
731
1709
  }
732
- var cached = null;
1710
+ var cached2 = null;
733
1711
  var warnedNoToken = false;
734
1712
  async function getQuotaSnapshot() {
735
- if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.snapshot;
1713
+ if (cached2 && Date.now() - cached2.at < CACHE_TTL_MS2) return cached2.snapshot;
736
1714
  const token = readOauthToken();
737
1715
  if (!token) {
738
1716
  if (!warnedNoToken) {
@@ -749,20 +1727,243 @@ async function getQuotaSnapshot() {
749
1727
  signal: controller.signal
750
1728
  });
751
1729
  clearTimeout(timer);
752
- if (!res.ok) return cached?.snapshot ?? null;
1730
+ if (!res.ok) return cached2?.snapshot ?? null;
753
1731
  const body = await res.json();
754
1732
  const snapshot = parseUsageResponse(body, (/* @__PURE__ */ new Date()).toISOString());
755
- cached = { snapshot, at: Date.now() };
1733
+ cached2 = { snapshot, at: Date.now() };
756
1734
  return snapshot;
757
1735
  } catch {
758
- return cached?.snapshot ?? null;
1736
+ return cached2?.snapshot ?? null;
1737
+ }
1738
+ }
1739
+ function readCpuTotals() {
1740
+ let idle = 0;
1741
+ let total = 0;
1742
+ for (const cpu of cpus()) {
1743
+ idle += cpu.times.idle;
1744
+ total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
1745
+ }
1746
+ return { idle, total };
1747
+ }
1748
+ function computeCpuPct(prev, curr) {
1749
+ const dTotal = curr.total - prev.total;
1750
+ if (dTotal <= 0) return void 0;
1751
+ const dIdle = curr.idle - prev.idle;
1752
+ const pct = 100 * (1 - dIdle / dTotal);
1753
+ return Math.round(Math.min(100, Math.max(0, pct)) * 10) / 10;
1754
+ }
1755
+ function parseMeminfo(content) {
1756
+ const totalMatch = content.match(/MemTotal:\s+(\d+)\s*kB/);
1757
+ const availMatch = content.match(/MemAvailable:\s+(\d+)\s*kB/);
1758
+ if (!totalMatch || !availMatch) return void 0;
1759
+ const totalMb = Math.round(Number(totalMatch[1]) / 1024);
1760
+ const availMb = Math.round(Number(availMatch[1]) / 1024);
1761
+ return { memUsedMb: totalMb - availMb, memTotalMb: totalMb };
1762
+ }
1763
+ function parseNetDev(content) {
1764
+ let rxBytes = 0;
1765
+ let txBytes = 0;
1766
+ for (const line of content.split("\n")) {
1767
+ const match = line.match(/^\s*([^:\s]+):\s*(\d+)(?:\s+\d+){7}\s+(\d+)/);
1768
+ if (!match || match[1] === "lo") continue;
1769
+ rxBytes += Number(match[2]);
1770
+ txBytes += Number(match[3]);
1771
+ }
1772
+ return { rxBytes, txBytes };
1773
+ }
1774
+ function computeNetKbps(prev, curr) {
1775
+ const seconds = (curr.at - prev.at) / 1e3;
1776
+ if (seconds <= 0) return void 0;
1777
+ const rx = (curr.rxBytes - prev.rxBytes) / 1024 / seconds;
1778
+ const tx = (curr.txBytes - prev.txBytes) / 1024 / seconds;
1779
+ if (rx < 0 || tx < 0) return void 0;
1780
+ return { netRxKbps: Math.round(rx * 10) / 10, netTxKbps: Math.round(tx * 10) / 10 };
1781
+ }
1782
+ var prevCpu = null;
1783
+ var prevNet = null;
1784
+ async function collectHostMetrics() {
1785
+ const metrics = {};
1786
+ try {
1787
+ const curr = readCpuTotals();
1788
+ if (prevCpu) {
1789
+ const pct = computeCpuPct(prevCpu, curr);
1790
+ if (pct !== void 0) metrics.cpuPct = pct;
1791
+ }
1792
+ prevCpu = curr;
1793
+ } catch {
1794
+ }
1795
+ try {
1796
+ const load = loadavg()[0];
1797
+ if (Number.isFinite(load)) metrics.load1 = Math.round(load * 100) / 100;
1798
+ } catch {
1799
+ }
1800
+ try {
1801
+ const meminfo = parseMeminfo(await readFile("/proc/meminfo", "utf8"));
1802
+ if (meminfo) Object.assign(metrics, meminfo);
1803
+ } catch {
1804
+ const totalMb = Math.round(totalmem() / 1024 / 1024);
1805
+ metrics.memTotalMb = totalMb;
1806
+ metrics.memUsedMb = totalMb - Math.round(freemem() / 1024 / 1024);
1807
+ }
1808
+ try {
1809
+ const root = platform() === "win32" ? "C:\\" : "/";
1810
+ const stats = await statfs(root);
1811
+ const totalGb = stats.blocks * stats.bsize / 1024 ** 3;
1812
+ const freeGb = stats.bfree * stats.bsize / 1024 ** 3;
1813
+ metrics.diskTotalGb = Math.round(totalGb * 10) / 10;
1814
+ metrics.diskUsedGb = Math.round((totalGb - freeGb) * 10) / 10;
1815
+ } catch {
1816
+ }
1817
+ try {
1818
+ const parsed = parseNetDev(await readFile("/proc/net/dev", "utf8"));
1819
+ const curr = { ...parsed, at: Date.now() };
1820
+ if (prevNet) {
1821
+ const kbps = computeNetKbps(prevNet, curr);
1822
+ if (kbps) Object.assign(metrics, kbps);
1823
+ }
1824
+ prevNet = curr;
1825
+ } catch {
759
1826
  }
1827
+ return metrics;
760
1828
  }
761
1829
 
762
1830
  // src/poller.ts
763
1831
  var POLL_INTERVAL_MS = 2500;
764
1832
  var HEARTBEAT_INTERVAL_MS = 6e4;
765
1833
  var BACKOFF_STEPS_MS = [2500, 5e3, 1e4, 3e4];
1834
+ var activeRunAgentId = null;
1835
+ var activeRunTask = void 0;
1836
+ var activeTerminalSessions = /* @__PURE__ */ new Map();
1837
+ var MAX_CONCURRENT_TERMINALS = 8;
1838
+ function classifyWorkItem(work) {
1839
+ switch (work.kind) {
1840
+ case "exec":
1841
+ return "exec";
1842
+ case "terminal_open":
1843
+ return "terminal_open";
1844
+ default:
1845
+ return "agent_run";
1846
+ }
1847
+ }
1848
+ async function handleExecWork(config, work) {
1849
+ if (!work.execId || typeof work.command !== "string") {
1850
+ logWarn(`exec work item missing execId/command (run ${work.runId}); ignoring`);
1851
+ return;
1852
+ }
1853
+ const execId = work.execId;
1854
+ log(`Running exec ${execId}`);
1855
+ const result = await execShellCommand(work.command, {
1856
+ cwd: work.cwd,
1857
+ timeoutMs: work.timeoutMs
1858
+ });
1859
+ const payload = {
1860
+ execId,
1861
+ status: result.status,
1862
+ exitCode: result.exitCode,
1863
+ stdoutExcerpt: result.stdoutExcerpt,
1864
+ stderrExcerpt: result.stderrExcerpt
1865
+ };
1866
+ try {
1867
+ await post(config, `/api/bridge/v1/exec/${execId}`, payload);
1868
+ log(`Reported exec ${execId} (${result.status})`);
1869
+ } catch (err) {
1870
+ logError(`Failed to report exec ${execId}`, err);
1871
+ }
1872
+ }
1873
+ function dispatchTerminalWork(work) {
1874
+ if (!work.sessionId || !work.relayWsUrl || !work.joinToken) {
1875
+ logWarn(`terminal_open work item missing sessionId/relayWsUrl/joinToken (run ${work.runId}); ignoring`);
1876
+ return;
1877
+ }
1878
+ const sessionId = work.sessionId;
1879
+ if (activeTerminalSessions.has(sessionId)) {
1880
+ return;
1881
+ }
1882
+ if (activeTerminalSessions.size >= MAX_CONCURRENT_TERMINALS) {
1883
+ logWarn(
1884
+ `terminal_open ignored \u2014 concurrency cap reached (${MAX_CONCURRENT_TERMINALS} active sessions)`
1885
+ );
1886
+ return;
1887
+ }
1888
+ const relayWsUrl = work.relayWsUrl;
1889
+ const joinToken = work.joinToken;
1890
+ const onClosed = (id) => {
1891
+ activeTerminalSessions.delete(id);
1892
+ };
1893
+ let realHandle = null;
1894
+ activeTerminalSessions.set(sessionId, {
1895
+ sessionId,
1896
+ close: () => {
1897
+ if (realHandle) realHandle.close();
1898
+ activeTerminalSessions.delete(sessionId);
1899
+ }
1900
+ });
1901
+ log(`Opening terminal session ${sessionId} (detached)`);
1902
+ void openTerminalSession({
1903
+ relayWsUrl,
1904
+ joinToken,
1905
+ sessionId,
1906
+ cols: work.cols,
1907
+ rows: work.rows,
1908
+ shell: work.shell,
1909
+ cwd: work.cwd,
1910
+ onClosed
1911
+ }).then((handle) => {
1912
+ realHandle = handle;
1913
+ if (!activeTerminalSessions.has(sessionId)) {
1914
+ handle.close();
1915
+ }
1916
+ }).catch((err) => {
1917
+ logError(`Failed to open terminal session ${sessionId}`, err);
1918
+ activeTerminalSessions.delete(sessionId);
1919
+ });
1920
+ }
1921
+ function closeAllTerminalSessions() {
1922
+ if (activeTerminalSessions.size === 0) return;
1923
+ log(`Closing ${activeTerminalSessions.size} terminal session(s)`);
1924
+ for (const handle of activeTerminalSessions.values()) {
1925
+ try {
1926
+ handle.close();
1927
+ } catch {
1928
+ }
1929
+ }
1930
+ activeTerminalSessions.clear();
1931
+ }
1932
+ function buildEventPayload(opts) {
1933
+ const details = {
1934
+ daemonVersion: opts.daemonVersion,
1935
+ ...opts.osInfo ? { osInfo: opts.osInfo } : {},
1936
+ ...opts.extra ?? {}
1937
+ };
1938
+ const payload = {
1939
+ kind: opts.kind,
1940
+ message: opts.message,
1941
+ details
1942
+ };
1943
+ if (opts.agentId !== void 0) payload.agentId = opts.agentId;
1944
+ return payload;
1945
+ }
1946
+ function buildHeartbeatPayload(opts) {
1947
+ const agents = opts.agentIds.map((agentId) => {
1948
+ if (agentId === opts.activeAgentId) {
1949
+ const entry = { agentId, status: "running" };
1950
+ if (opts.activeTask) entry.currentTask = opts.activeTask;
1951
+ return entry;
1952
+ }
1953
+ return { agentId, status: "idle" };
1954
+ });
1955
+ const payload = {
1956
+ daemonVersion: opts.daemonVersion,
1957
+ osInfo: opts.osInfo,
1958
+ agents
1959
+ };
1960
+ if (opts.quota !== void 0) payload.quota = opts.quota;
1961
+ if (opts.runtimes !== void 0) payload.capabilities = opts.runtimes;
1962
+ if (opts.hostMetrics !== void 0 && Object.keys(opts.hostMetrics).length > 0) {
1963
+ payload.hostMetrics = opts.hostMetrics;
1964
+ }
1965
+ return payload;
1966
+ }
766
1967
  var running = false;
767
1968
  async function startDaemon(config) {
768
1969
  running = true;
@@ -771,32 +1972,59 @@ async function startDaemon(config) {
771
1972
  running = false;
772
1973
  log(`Received ${signal} \u2014 shutting down gracefully`);
773
1974
  stopAgentRefreshTimer();
774
- await postBestEffort(config, "/api/bridge/v1/events", {
775
- kind: "daemon_stopping",
776
- message: `Daemon stopping (${signal})`,
777
- daemonVersion: VERSION
778
- });
1975
+ closeAllTerminalSessions();
1976
+ await postBestEffort(
1977
+ config,
1978
+ "/api/bridge/v1/events",
1979
+ buildEventPayload({
1980
+ kind: "daemon_stopping",
1981
+ message: `Daemon stopping (${signal})`,
1982
+ daemonVersion: VERSION
1983
+ })
1984
+ );
779
1985
  process.exit(0);
780
1986
  };
781
1987
  process.on("SIGINT", () => void shutdown("SIGINT"));
782
1988
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
783
- await postBestEffort(config, "/api/bridge/v1/events", {
784
- kind: "daemon_started",
785
- message: "Daemon started",
786
- daemonVersion: VERSION,
787
- osInfo: { platform: platform(), release: release() }
788
- });
1989
+ await postBestEffort(
1990
+ config,
1991
+ "/api/bridge/v1/events",
1992
+ buildEventPayload({
1993
+ kind: "daemon_started",
1994
+ message: "Daemon started",
1995
+ daemonVersion: VERSION,
1996
+ osInfo: { platform: platform(), release: release() }
1997
+ })
1998
+ );
789
1999
  log(`Daemon started (v${VERSION}, bridge ${config.bridgeId})`);
790
2000
  await refreshAgents(config);
791
2001
  startAgentRefreshTimer(config);
792
2002
  const sendHeartbeat = async () => {
793
- const payload = {
2003
+ const cachedAgents2 = getCachedAgents();
2004
+ const agentIds = cachedAgents2.map((a) => a.id);
2005
+ const quota = await getQuotaSnapshot();
2006
+ let runtimes;
2007
+ try {
2008
+ const all = await detectRuntimes();
2009
+ const present = all.filter((r) => r.version.length > 0);
2010
+ runtimes = present.length > 0 ? present : void 0;
2011
+ } catch {
2012
+ }
2013
+ let hostMetrics;
2014
+ try {
2015
+ hostMetrics = await collectHostMetrics();
2016
+ } catch {
2017
+ }
2018
+ const payload = buildHeartbeatPayload({
794
2019
  daemonVersion: VERSION,
795
2020
  osInfo: { platform: platform(), release: release() },
796
- agents: []
797
- };
798
- const quota = await getQuotaSnapshot();
799
- if (quota) payload.quota = quota;
2021
+ agentIds,
2022
+ activeAgentId: activeRunAgentId,
2023
+ activeTask: activeRunTask,
2024
+ quota: quota ?? void 0,
2025
+ runtimes,
2026
+ hostMetrics
2027
+ });
800
2028
  try {
801
2029
  await post(config, "/api/bridge/v1/heartbeat", payload);
802
2030
  } catch (err) {
@@ -816,11 +2044,30 @@ async function startDaemon(config) {
816
2044
  const data = await post(config, "/api/bridge/v1/work", {});
817
2045
  backoffIndex = 0;
818
2046
  if (data.work) {
819
- log(`Received work item: run ${data.work.runId}`);
2047
+ const work = data.work;
2048
+ const kind = classifyWorkItem(work);
2049
+ log(`Received work item: run ${work.runId} (kind ${kind})`);
2050
+ if (kind === "terminal_open") {
2051
+ dispatchTerminalWork(work);
2052
+ continue;
2053
+ }
2054
+ if (kind === "exec") {
2055
+ try {
2056
+ await handleExecWork(config, work);
2057
+ } catch (err) {
2058
+ logError(`Unhandled error in handleExecWork for run ${work.runId}`, err);
2059
+ }
2060
+ continue;
2061
+ }
2062
+ activeRunAgentId = work.agent?.id ?? null;
2063
+ activeRunTask = work.prompt.slice(0, 200);
820
2064
  try {
821
- await runWorkItem(config, data.work);
2065
+ await runWorkItem(config, work);
822
2066
  } catch (err) {
823
- logError(`Unhandled error in runWorkItem for run ${data.work.runId}`, err);
2067
+ logError(`Unhandled error in runWorkItem for run ${work.runId}`, err);
2068
+ } finally {
2069
+ activeRunAgentId = null;
2070
+ activeRunTask = void 0;
824
2071
  }
825
2072
  continue;
826
2073
  }
@@ -891,7 +2138,7 @@ program.command("status").description("Print current configuration and server co
891
2138
  console.log();
892
2139
  });
893
2140
  program.command("autostart <action>").description("Manage start-at-logon: install | remove | status (Windows Task Scheduler)").action(async (action) => {
894
- const { autostartInstall, autostartRemove, autostartStatus } = await import('./autostart-77BPPTEG.js');
2141
+ const { autostartInstall, autostartRemove, autostartStatus } = await import('./autostart-ASW5MDQS.js');
895
2142
  if (action === "install") autostartInstall();
896
2143
  else if (action === "remove") autostartRemove();
897
2144
  else if (action === "status") autostartStatus();