@lifeaitools/clauth 1.31.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@ import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
14
14
  import * as api from "../api.js";
15
15
  import chalk from "chalk";
16
16
  import ora from "ora";
17
- import { execFileSync, execSync as execSyncTop } from "child_process";
17
+ import { execFileSync, execSync as execSyncTop, spawn } from "child_process";
18
18
  import Conf from "conf";
19
19
  import { getConfOptions } from "../conf-path.js";
20
20
  import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp } from "node:fs/promises";
@@ -30,6 +30,25 @@ import {
30
30
  readWatchdogEvents,
31
31
  restartWatchdogService,
32
32
  } from "../watchdog-registry.js";
33
+ import {
34
+ discoverPlugins,
35
+ getSupervisorDir,
36
+ getSupervisorPort,
37
+ loadSupervisorState,
38
+ listPlugins,
39
+ listRoutes,
40
+ listSurfaces,
41
+ listTunnels,
42
+ readSupervisorEvents,
43
+ addTunnelRoute,
44
+ removeTunnelRoute,
45
+ reconcileSurfaceHealth,
46
+ runPluginAction,
47
+ runSurfaceAction,
48
+ setPluginEnabled,
49
+ supervisorHealth,
50
+ operation,
51
+ } from "../supervisor-registry.js";
33
52
  import {
34
53
  AgentPool,
35
54
  DelegationLane,
@@ -40,6 +59,14 @@ import {
40
59
  DEFAULT_BOOTSTRAP,
41
60
  } from "./agent-pool.js";
42
61
  import { AgentCron, cronEnabled, nextRun } from "./agent-cron.js";
62
+ import pm2 from "pm2";
63
+ import { createPm2Adapter, PM2_OPERATION_CATALOG } from "../ops/pm2-adapter.js";
64
+ import { createOperationPolicy } from "../ops/operation-policy.js";
65
+ import { createJobStore } from "../ops/job-store.js";
66
+ import { createCoolifyAdapter, deploymentUuidFrom } from "../ops/coolify-adapter.js";
67
+ import { createDeploymentAdapter, parseDeploymentRegistry } from "../ops/deployment-adapter.js";
68
+ import { createSerializedExecutor } from "../ops/serialized-executor.js";
69
+ import { requestOps } from "./ops.js";
43
70
 
44
71
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
72
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
@@ -463,6 +490,7 @@ function createRotationEngine(password, machineHash, logFile) {
463
490
 
464
491
  const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
465
492
  const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
493
+ const SUPERVISOR_PID_FILE = path.join(os.tmpdir(), "clauth-supervisor.pid");
466
494
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
467
495
  const LIVE_PORT = 52437;
468
496
  const STAGED_PORT = 52438;
@@ -487,6 +515,74 @@ function validateWriteToken(req, writeSession) {
487
515
  return a.length === b.length && crypto.timingSafeEqual(a, b);
488
516
  }
489
517
 
518
+ export function isLoopbackAddress(remote) {
519
+ return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
520
+ }
521
+
522
+ function summarizeSupervisorTarget(target) {
523
+ if (!target || typeof target !== "object") return null;
524
+ return {
525
+ plugin_id: typeof target.plugin_id === "string" ? target.plugin_id : undefined,
526
+ surface_id: typeof target.surface_id === "string" ? target.surface_id : undefined,
527
+ tunnel_id: typeof target.tunnel_id === "string" ? target.tunnel_id : undefined,
528
+ route_id: typeof target.route_id === "string" ? target.route_id : undefined,
529
+ };
530
+ }
531
+
532
+ function summarizeSupervisorState(value) {
533
+ if (!value || typeof value !== "object") return null;
534
+ return {
535
+ ok: typeof value.ok === "boolean" ? value.ok : undefined,
536
+ state: typeof value.state === "string" ? value.state : undefined,
537
+ reason: typeof value.reason === "string" ? value.reason : undefined,
538
+ status: typeof value.status === "number" ? value.status : undefined,
539
+ private: typeof value.private === "boolean" ? value.private : undefined,
540
+ public_route: typeof value.public_route === "boolean" ? value.public_route : undefined,
541
+ evidence: Array.isArray(value.evidence) ? value.evidence.map((item) => String(item).slice(0, 200)).slice(0, 5) : [],
542
+ };
543
+ }
544
+
545
+ export function supervisorLogDto(event) {
546
+ if (!event || typeof event !== "object") return { kind: "unknown" };
547
+ if (event.kind === "operation") {
548
+ return {
549
+ ts: event.ts || event.created_at,
550
+ kind: "operation",
551
+ operationId: event.operationId,
552
+ actor: event.actor,
553
+ action: event.action,
554
+ target: summarizeSupervisorTarget(event.target),
555
+ resulting_state: summarizeSupervisorState(event.resulting_state),
556
+ completed_at: event.completed_at,
557
+ };
558
+ }
559
+ return {
560
+ ts: event.ts,
561
+ kind: event.kind,
562
+ plugin_id: event.plugin_id,
563
+ source: event.source,
564
+ state: event.state,
565
+ };
566
+ }
567
+
568
+ function supervisorOperationDto(receipt) {
569
+ return {
570
+ operationId: receipt.operationId,
571
+ actor: receipt.actor,
572
+ action: receipt.action,
573
+ target: summarizeSupervisorTarget(receipt.target),
574
+ resulting_state: summarizeSupervisorState(receipt.resulting_state),
575
+ evidence: Array.isArray(receipt.evidence) ? receipt.evidence.map((item) => String(item).slice(0, 200)).slice(0, 5) : [],
576
+ created_at: receipt.created_at,
577
+ completed_at: receipt.completed_at,
578
+ };
579
+ }
580
+
581
+ export function supervisorRequiresWriteToken(port = getSupervisorPort(), env = process.env) {
582
+ if (port !== getSupervisorPort()) return true;
583
+ return env.CLAUTH_SUPERVISOR_REQUIRE_WRITE_TOKEN === "1";
584
+ }
585
+
490
586
  // ── PID helpers ──────────────────────────────────────────────
491
587
  function readPid() {
492
588
  try {
@@ -512,6 +608,18 @@ function writeStagedPid(pid, port) {
512
608
  fs.writeFileSync(STAGED_PID_FILE, `${pid}:${port}`, "utf8");
513
609
  }
514
610
 
611
+ function readSupervisorPid() {
612
+ try {
613
+ const raw = fs.readFileSync(SUPERVISOR_PID_FILE, "utf8").trim();
614
+ const [pid, port] = raw.split(":");
615
+ return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
616
+ } catch { return null; }
617
+ }
618
+
619
+ function writeSupervisorPid(pid, port) {
620
+ fs.writeFileSync(SUPERVISOR_PID_FILE, `${pid}:${port}`, "utf8");
621
+ }
622
+
515
623
  function removeStagedPid() {
516
624
  try { fs.unlinkSync(STAGED_PID_FILE); } catch {}
517
625
  }
@@ -520,6 +628,10 @@ function removePid() {
520
628
  try { fs.unlinkSync(PID_FILE); } catch {}
521
629
  }
522
630
 
631
+ function removeSupervisorPid() {
632
+ try { fs.unlinkSync(SUPERVISOR_PID_FILE); } catch {}
633
+ }
634
+
523
635
  function isProcessAlive(pid) {
524
636
  try { process.kill(pid, 0); return true; } catch { return false; }
525
637
  }
@@ -574,6 +686,9 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
574
686
  .btn-unlock:hover{background:#2563eb}
575
687
  .btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
576
688
  .lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
689
+ #write-unlock-overlay{display:none;position:fixed;inset:0;background:rgba(2,6,23,.72);z-index:9999;align-items:center;justify-content:center;padding:2rem}
690
+ .write-unlock-cancel{width:100%;background:transparent;color:#94a3b8;border:1px solid #334155;border-radius:8px;padding:8px;font-size:.85rem;cursor:pointer;margin-top:.6rem;transition:border-color .15s}
691
+ .write-unlock-cancel:hover{border-color:#64748b;color:#cbd5e1}
577
692
  /* ── Main view ── */
578
693
  #main-view{display:none;padding:2rem}
579
694
  .header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
@@ -658,15 +773,13 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
658
773
  .btn-ccandme{background:linear-gradient(135deg,#1a1a2e,#16213e);color:#a78bfa;border:1px solid #4c1d95;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:600;transition:all .15s;white-space:nowrap}
659
774
  .btn-ccandme:hover{background:linear-gradient(135deg,#2d1b69,#1e1b4b);border-color:#7c3aed;color:#c4b5fd;transform:translateY(-1px)}
660
775
  .btn-ccandme:disabled{opacity:.5;cursor:not-allowed;transform:none}
661
- .btn-tintin{background:linear-gradient(135deg,#103c2f,#0f2f3f);color:#86efac;border:1px solid #166534;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:700;transition:all .15s;white-space:nowrap}
662
- .btn-tintin:hover{background:linear-gradient(135deg,#14532d,#155e75);border-color:#22c55e;color:#bbf7d0;transform:translateY(-1px)}
663
- .btn-tintin:disabled{opacity:.5;cursor:not-allowed;transform:none}
664
776
  .btn-claude{background:linear-gradient(135deg,#d97706,#f59e0b);color:#0a0f1a;padding:8px 18px;font-size:.85rem;border-radius:7px;border:none;cursor:pointer;font-weight:700;letter-spacing:.3px;transition:all .15s;white-space:nowrap}
665
777
  .btn-claude:hover{filter:brightness(1.1);transform:translateY(-1px)}
666
778
  .btn-claude:disabled{opacity:.4;cursor:not-allowed;transform:none;filter:none}
667
779
  .btn-tunnel-stop{background:#1e293b;color:#f87171;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
668
780
  .btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
669
781
  .tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
782
+ .dash-panel{margin-bottom:1.25rem}
670
783
  .build-panel{background:#0f1a2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
671
784
  .build-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
672
785
  .build-dot.idle{background:#64748b}
@@ -677,25 +790,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
677
790
  .build-label{font-size:.85rem;color:#94a3b8;flex:1}
678
791
  .build-label strong{color:#e2e8f0}
679
792
  .build-meta{font-family:'Courier New',monospace;font-size:.75rem;color:#64748b}
680
- .tintin-tab{position:fixed;top:50%;right:0;transform:translateY(-50%);z-index:81;writing-mode:vertical-rl;text-orientation:mixed;background:linear-gradient(180deg,#103c2f,#0f2f3f);color:#86efac;border:1px solid #166534;border-right:none;border-radius:8px 0 0 8px;padding:14px 6px;font-size:.78rem;font-weight:700;cursor:pointer;letter-spacing:.5px;transition:all .2s}
681
- .tintin-tab:hover{background:linear-gradient(180deg,#14532d,#155e75);color:#bbf7d0;padding-right:10px}
682
- .tintin-tab.active{background:#081c1a;border-color:#22c55e;color:#bbf7d0}
683
- .tintin-panel{position:fixed;top:0;right:-440px;bottom:0;z-index:80;width:min(420px,calc(100vw - 24px));background:#081c1a;border-left:1px solid #14532d;padding:0;box-shadow:-22px 0 60px rgba(0,0,0,.42);display:flex;flex-direction:column;transition:right .25s ease}
684
- .tintin-panel.open{right:0}
685
- .tintin-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:.8rem}
686
- .tintin-title{font-size:.95rem;font-weight:700;color:#dcfce7}
687
- .tintin-sub{font-size:.78rem;color:#6ee7b7;margin-top:2px}
688
- .tintin-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
689
- .tintin-field{display:flex;flex-direction:column;gap:4px}
690
- .tintin-field label{font-size:.72rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;font-weight:700}
691
- .tintin-input,.tintin-textarea{background:#031312;border:1px solid #14532d;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:8px 10px;outline:none;transition:border-color .15s}
692
- .tintin-input:focus,.tintin-textarea:focus{border-color:#22c55e}
693
- .tintin-textarea{width:100%;min-height:92px;resize:vertical;line-height:1.45;margin-bottom:10px}
694
- .tintin-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
695
- .tintin-status{font-family:'Courier New',monospace;font-size:.78rem;color:#94a3b8}
696
- .tintin-result{display:none;margin-top:12px;background:#020b0a;border:1px solid #134e4a;border-radius:8px;padding:10px;white-space:pre-wrap;word-break:break-word;font-family:'Courier New',monospace;font-size:.82rem;color:#bbf7d0;max-height:240px;overflow:auto}
697
- .tintin-result.open{display:block}
698
- @media (max-width:720px){.tintin-panel{width:100vw}.tintin-grid{grid-template-columns:1fr}}
699
793
  .mcp-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
700
794
  .mcp-label{font-size:.72rem;color:#64748b;min-width:80px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
701
795
  .mcp-val{flex:1;font-family:'Courier New',monospace;font-size:.82rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:6px 10px;word-break:break-all;user-select:all}
@@ -805,6 +899,36 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
805
899
  .wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
806
900
  .wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
807
901
  .wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
902
+ .supervisor-panel{margin:14px 16px;border:1px solid #233249;background:linear-gradient(135deg,rgba(15,23,42,.96),rgba(10,15,26,.98));border-radius:12px;padding:14px}
903
+ .supervisor-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px}
904
+ .supervisor-title{font-size:.95rem;font-weight:700;color:#e2e8f0}
905
+ .supervisor-sub{font-size:.76rem;color:#94a3b8;margin-top:3px;line-height:1.35}
906
+ .supervisor-actions{display:flex;gap:8px;flex-wrap:wrap}
907
+ .supervisor-action{background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:6px;padding:6px 10px;font-size:.75rem;cursor:pointer}
908
+ .supervisor-action:hover{border-color:#38bdf8;color:#e0f2fe}
909
+ .supervisor-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
910
+ .supervisor-card{border:1px solid #1e293b;background:rgba(2,6,23,.52);border-radius:10px;padding:10px;min-width:0}
911
+ .supervisor-card h4{margin:0 0 8px;color:#cbd5e1;font-size:.75rem;letter-spacing:.08em;text-transform:uppercase}
912
+ .supervisor-kpi{font-size:1.35rem;font-weight:700;color:#f8fafc}
913
+ .supervisor-meta{font-size:.72rem;color:#94a3b8;font-family:'Courier New',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
914
+ .supervisor-pill{display:inline-flex;align-items:center;border-radius:999px;padding:2px 7px;margin:2px 4px 2px 0;font-size:.68rem;border:1px solid #334155;color:#cbd5e1;background:#0f172a}
915
+ .supervisor-pill.ok{border-color:rgba(74,222,128,.35);color:#86efac;background:rgba(34,197,94,.08)}
916
+ .supervisor-pill.warn{border-color:rgba(250,204,21,.35);color:#fde68a;background:rgba(250,204,21,.08)}
917
+ .supervisor-pill.bad{border-color:rgba(248,113,113,.35);color:#fecaca;background:rgba(248,113,113,.08)}
918
+ .supervisor-list{display:flex;flex-direction:column;gap:8px;max-height:320px;overflow:auto}
919
+ .supervisor-row{border:1px solid #1e293b;border-radius:8px;padding:6px 8px;background:rgba(15,23,42,.45);cursor:pointer}
920
+ .supervisor-row:hover{border-color:#334155}
921
+ .supervisor-row.selected{border-color:#38bdf8;background:rgba(56,189,248,.08)}
922
+ .supervisor-row.readonly{cursor:default}
923
+ .supervisor-row-top{display:flex;justify-content:space-between;gap:8px;align-items:center}
924
+ .supervisor-name{font-size:.82rem;color:#e2e8f0;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
925
+ .supervisor-row-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}
926
+ .supervisor-cmdbar{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:8px;padding:8px;border:1px solid #1e293b;border-radius:8px;background:rgba(2,6,23,.4);min-height:36px}
927
+ .supervisor-cmdbar-empty{font-size:.74rem;color:#64748b}
928
+ .supervisor-cmdbar-name{font-size:.78rem;color:#e2e8f0;font-weight:600;margin-right:4px}
929
+ .supervisor-log{font-family:'Courier New',monospace;font-size:.7rem;color:#94a3b8;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow:auto}
930
+ .supervisor-feedback{margin-top:8px;padding:7px 9px;border-radius:6px;font-family:'Courier New',monospace;font-size:.72rem;color:#86efac;background:rgba(34,197,94,.08);border:1px solid rgba(74,222,128,.22);display:none}
931
+ .supervisor-feedback.bad{color:#fecaca;background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25)}
808
932
  </style>
809
933
  </head>
810
934
  <body>
@@ -824,15 +948,27 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
824
948
  </div>
825
949
  </div>
826
950
 
951
+ <!-- Write-unlock modal (replaces window.prompt(), which silently no-ops in
952
+ embedded/webview browser contexts and after a browser suppresses repeated
953
+ native dialogs) -->
954
+ <div id="write-unlock-overlay">
955
+ <div class="lock-card">
956
+ <div class="lock-icon">🔒</div>
957
+ <div class="lock-title">Enable writes</div>
958
+ <div class="lock-sub">Enter your vault password to enable saving changes (30-minute write session)</div>
959
+ <form onsubmit="submitWriteUnlock();return false;" autocomplete="on">
960
+ <input type="text" name="username" value="clauth" autocomplete="username" style="display:none">
961
+ <input class="lock-input" id="write-unlock-input" type="password" placeholder="••••••••••••" autocomplete="current-password">
962
+ <button class="btn-unlock" id="write-unlock-btn" type="submit">Unlock Writes</button>
963
+ </form>
964
+ <div class="lock-err" id="write-unlock-err"></div>
965
+ <button type="button" class="write-unlock-cancel" onclick="closeWriteUnlockModal()">Cancel</button>
966
+ </div>
967
+ </div>
968
+
827
969
  <!-- ── Main view (shown after unlock) ──────── -->
828
970
  <div id="main-view">
829
- <div id="upgrade-banner" style="display:none" class="upgrade-banner">
830
- <div class="upgrade-content">
831
- <strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
832
- <span id="upgrade-details"></span>
833
- <button onclick="dismissUpgrade()">Dismiss ✕</button>
834
- </div>
835
- </div>
971
+ <section class="dash-panel" data-panel="title-status">
836
972
  <div class="header">
837
973
  <div class="dot" id="dot"></div>
838
974
  <h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span>${isStaged ? `<span style="font-size:0.5em;background:#b45309;color:#fef3c7;border-radius:4px;padding:2px 10px;margin-left:12px;font-weight:600;letter-spacing:.5px">STAGED</span>` : ""}</h1>
@@ -851,6 +987,16 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
851
987
  <div>Services: <span id="s-services">${whitelist ? whitelist.join(", ") : "all"}</span></div>
852
988
  <div>Failures: <span id="s-fails">—</span></div>
853
989
  </div>
990
+ </section>
991
+
992
+ <section class="dash-panel" data-panel="status-area">
993
+ <div id="upgrade-banner" style="display:none" class="upgrade-banner">
994
+ <div class="upgrade-content">
995
+ <strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
996
+ <span id="upgrade-details"></span>
997
+ <button onclick="dismissUpgrade()">Dismiss ✕</button>
998
+ </div>
999
+ </div>
854
1000
  <div class="build-panel" id="build-panel">
855
1001
  <div class="build-dot idle" id="build-dot"></div>
856
1002
  <div class="build-label" id="build-label">
@@ -859,8 +1005,9 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
859
1005
  <span id="build-sha" style="font-family:'Courier New',monospace;font-size:.72rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:2px 8px;letter-spacing:.3px"></span>
860
1006
  <div class="build-meta" id="build-meta" style="width:100%;margin-top:4px"></div>
861
1007
  </div>
1008
+ </section>
862
1009
 
863
- <div class="toolbar">
1010
+ <div class="toolbar" data-panel="menu-bar">
864
1011
  <button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
865
1012
  <button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
866
1013
  <button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
@@ -952,7 +1099,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
952
1099
  </form>
953
1100
  </div>
954
1101
 
955
- <div class="tunnel-panel" id="tunnel-panel">
1102
+ <div class="tunnel-panel" id="tunnel-panel" data-panel="tunnel-bar">
956
1103
  <!-- not_configured -->
957
1104
  <div class="tunnel-state not-configured" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
958
1105
  <div class="tunnel-dot off"></div>
@@ -993,40 +1140,38 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
993
1140
  </div>
994
1141
  </div>
995
1142
 
996
- <div class="tintin-tab" id="tintin-tab" onclick="toggleTinTinPanel()">TinTin</div>
997
- <div class="tintin-panel" id="tintin-panel">
998
- <div class="tintin-head" style="padding:12px 16px;border-bottom:1px solid #14532d;flex-shrink:0">
999
- <div style="display:flex;align-items:center;justify-content:space-between">
1000
- <div class="tintin-title">TinTin</div>
1001
- <button class="btn-cancel" onclick="toggleTinTinPanel()" style="font-size:16px;padding:2px 6px">x</button>
1143
+ <section class="supervisor-panel" id="supervisor-panel" data-panel="surfaces-log">
1144
+ <div class="supervisor-head">
1145
+ <div>
1146
+ <div class="supervisor-title">Local Software Factory Supervisor</div>
1147
+ <div class="supervisor-sub">clauth owns local LIFEAI plugin discovery, candidate tests, clauth-owned surface operations, receipts, and supervisor event logs. Dev Center should call this surface instead of PM2 directly.</div>
1148
+ </div>
1149
+ <div class="supervisor-actions">
1150
+ <button class="supervisor-action" onclick="loadSupervisorCockpit()">Refresh</button>
1151
+ <button class="supervisor-action" onclick="rescanSupervisorPlugins()">Rescan plugins</button>
1002
1152
  </div>
1003
- <div class="tintin-sub">claude-sonnet-4-6 · clauth CLI dispatch</div>
1004
- <div style="margin-top:6px"><a href="/tintin/settings/ui" target="_blank" rel="noopener" style="font-size:.72rem;color:#86efac;text-decoration:none;border:1px solid #14532d;padding:2px 6px;border-radius:4px">Agent settings</a></div>
1005
- </div>
1006
- <div id="tintin-messages" style="flex:1;min-height:0;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:10px">
1007
- <div style="font-size:.78rem;color:#6ee7b7;font-style:italic">Ask anything. Runs a Claude CLI agent with full clauth + repo context.</div>
1008
1153
  </div>
1009
- <div style="padding:10px 16px;border-top:1px solid #14532d;flex-shrink:0">
1010
- <div class="tintin-grid" style="margin-bottom:8px">
1011
- <div class="tintin-field">
1012
- <label>CWD</label>
1013
- <input class="tintin-input" id="tintin-cwd" value="C:\\Dev\\regen-root" spellcheck="false" autocomplete="off">
1014
- </div>
1015
- <div class="tintin-field">
1016
- <label>App</label>
1017
- <input class="tintin-input" id="tintin-app" value="clauth" spellcheck="false" autocomplete="off">
1018
- </div>
1154
+ <div id="supervisor-status" class="supervisor-sub">Loading supervisor state…</div>
1155
+ <div id="supervisor-feedback" class="supervisor-feedback" role="status"></div>
1156
+ <div class="supervisor-grid" style="margin-top:10px">
1157
+ <div class="supervisor-card">
1158
+ <h4>Surfaces</h4>
1159
+ <div class="supervisor-kpi" id="supervisor-surface-count">—</div>
1160
+ <div class="supervisor-meta" id="supervisor-surface-meta">destination + owner matrix</div>
1161
+ <div class="supervisor-cmdbar" id="supervisor-cmdbar"><span class="supervisor-cmdbar-empty">Select a surface below to act on it.</span></div>
1162
+ <div class="supervisor-list" id="supervisor-surfaces" style="margin-top:8px"></div>
1019
1163
  </div>
1020
- <textarea class="tintin-textarea" id="tintin-prompt" spellcheck="false" placeholder="Ask TinTin..." style="min-height:64px;margin-bottom:8px"></textarea>
1021
- <div style="display:flex;gap:8px;align-items:center">
1022
- <button class="btn-tintin" id="tintin-send" onclick="sendTinTinMessage()" style="flex:1">Send</button>
1023
- <span class="tintin-status" id="tintin-status">idle</span>
1164
+ <div class="supervisor-card">
1165
+ <h4>Operations + log</h4>
1166
+ <div class="supervisor-kpi" id="supervisor-operation-count">—</div>
1167
+ <div class="supervisor-meta" id="supervisor-log-path">events.jsonl</div>
1168
+ <div class="supervisor-log" id="supervisor-events" style="margin-top:8px">No events loaded.</div>
1024
1169
  </div>
1025
1170
  </div>
1026
- </div>
1171
+ </section>
1027
1172
 
1028
1173
 
1029
- <div class="tunnel-panel" id="webdav-panel" style="flex-direction:column;align-items:stretch;gap:8px">
1174
+ <div class="tunnel-panel" id="webdav-panel" data-panel="webdav-mounts" style="flex-direction:column;align-items:stretch;gap:8px">
1030
1175
  <div style="display:flex;align-items:center;gap:10px;width:100%">
1031
1176
  <div class="tunnel-dot off" id="webdav-dot"></div>
1032
1177
  <strong style="color:#e2e8f0;font-size:.88rem">WebDAV Mounts</strong>
@@ -1062,6 +1207,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
1062
1207
  <div class="wizard-foot" id="wizard-foot"></div>
1063
1208
  </div>
1064
1209
 
1210
+ <section class="dash-panel" data-panel="search-credentials">
1065
1211
  <div id="project-tabs" class="project-tabs" style="display:none"></div>
1066
1212
  <div id="service-search" class="service-search">
1067
1213
  <span class="service-search-label">Search</span>
@@ -1069,11 +1215,17 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
1069
1215
  <span id="service-search-count" class="service-search-count"></span>
1070
1216
  </div>
1071
1217
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
1072
- <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
1218
+ </section>
1219
+ <div class="footer" id="originFooter" data-panel="footer">checking origin… · 10-strike lockout</div>
1073
1220
  </div>
1074
1221
 
1075
1222
  <script>
1076
- const BASE = "http://127.0.0.1:${port}";
1223
+ const BASE = location.origin;
1224
+ (function reportOrigin() {
1225
+ const isLocal = /^(127\\.0\\.0\\.1|localhost|\\[::1\\])$/.test(location.hostname);
1226
+ const el = document.getElementById("originFooter");
1227
+ if (el) el.textContent = (isLocal ? "LOCAL" : "REMOTE") + " · " + BASE + " · 10-strike lockout";
1228
+ })();
1077
1229
 
1078
1230
  const SERVICE_HINTS = {
1079
1231
  "neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
@@ -1305,9 +1457,173 @@ function showMain(ping) {
1305
1457
  pollTunnel();
1306
1458
  loadWebdavMounts();
1307
1459
  updateBuildStatus();
1460
+ loadSupervisorCockpit();
1461
+ startSupervisorLogTail();
1308
1462
  refreshWriteLockUi();
1309
1463
  }
1310
1464
 
1465
+ let supervisorLogTailStarted = false;
1466
+ function startSupervisorLogTail() {
1467
+ if (supervisorLogTailStarted) return; // showMain() can run more than once per page load
1468
+ supervisorLogTailStarted = true;
1469
+ setInterval(loadSupervisorCockpit, 3000);
1470
+ }
1471
+
1472
+ function supervisorBadge(text, kind) {
1473
+ return '<span class="supervisor-pill ' + (kind || '') + '">' + htmlEscape(text) + '</span>';
1474
+ }
1475
+
1476
+ async function supervisorJson(path, options) {
1477
+ const response = await fetch(BASE + path, { cache: "no-store", ...(options || {}) });
1478
+ const data = await response.json().catch(() => ({}));
1479
+ if (!response.ok || data.error) throw new Error(data.error || ("HTTP " + response.status));
1480
+ return data;
1481
+ }
1482
+
1483
+ function supervisorFeedback(text, bad) {
1484
+ const el = document.getElementById("supervisor-feedback");
1485
+ if (!el) return;
1486
+ el.textContent = text || "";
1487
+ el.className = "supervisor-feedback" + (bad ? " bad" : "");
1488
+ el.style.display = text ? "block" : "none";
1489
+ }
1490
+
1491
+ const SUPERVISOR_SURFACE_ACTIONS = ["start", "stop", "restart", "reconcile", "test", "promote", "rollback"];
1492
+ let selectedSupervisorSurface = null; // { id, name } | null
1493
+ let lastSupervisorSurfaceRows = [];
1494
+
1495
+ const CLAUTH_SELF_PSEUDO_SURFACE = {
1496
+ __pseudo: true,
1497
+ id: "clauth:mcp-sse",
1498
+ plugin_id: "clauth",
1499
+ name: "clauth (self)",
1500
+ lifecycle_owner: "external",
1501
+ destination: "self",
1502
+ state: "self_managed",
1503
+ port: null,
1504
+ health: null,
1505
+ };
1506
+
1507
+ async function loadSupervisorCockpit() {
1508
+ const status = document.getElementById("supervisor-status");
1509
+ if (status) status.textContent = "Loading supervisor state…";
1510
+ try {
1511
+ const [health, surfaces, logs] = await Promise.all([
1512
+ supervisorJson("/health"),
1513
+ supervisorJson("/v1/surfaces"),
1514
+ supervisorJson("/v1/logs?limit=40"),
1515
+ ]);
1516
+ const surfaceRows = [CLAUTH_SELF_PSEUDO_SURFACE, ...(surfaces.surfaces || [])];
1517
+ lastSupervisorSurfaceRows = surfaceRows;
1518
+ if (selectedSupervisorSurface && !surfaceRows.some(s => (s.plugin_id + ":" + s.id) === selectedSupervisorSurface.id)) {
1519
+ selectedSupervisorSurface = null;
1520
+ }
1521
+ document.getElementById("supervisor-surface-count").textContent = String(surfaceRows.length - 1);
1522
+ document.getElementById("supervisor-operation-count").textContent = String((logs.operations || []).length);
1523
+ document.getElementById("supervisor-surface-meta").textContent = "pm2 home: " + (health.pm2_home || "—");
1524
+ document.getElementById("supervisor-log-path").textContent = logs.log_path || "events.jsonl";
1525
+ document.getElementById("supervisor-surfaces").innerHTML = surfaceRows.map(renderSupervisorSurface).join("");
1526
+ renderSupervisorCmdbar();
1527
+ const logEl = document.getElementById("supervisor-events");
1528
+ // Newest-at-bottom, like a real tail -- and only auto-scroll if the
1529
+ // reader was already at the bottom, so scrolling up to read history
1530
+ // during a poll tick doesn't get yanked back down.
1531
+ const wasAtBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 12;
1532
+ logEl.textContent = (logs.events || []).slice(-40).map(e => {
1533
+ const label = e.kind === "operation" ? (e.action + " " + JSON.stringify(e.target || {})) : (e.kind + " " + (e.plugin_id || ""));
1534
+ return (e.ts || e.created_at || "") + " " + label;
1535
+ }).join("\\n") || "No events yet.";
1536
+ if (wasAtBottom) logEl.scrollTop = logEl.scrollHeight;
1537
+ if (status) status.innerHTML = supervisorBadge("supervisor " + (health.status === "ok" ? "healthy" : (health.status || "unknown")), health.status === "ok" ? "ok" : "warn") + supervisorBadge("vault " + (health.vault_locked ? "locked" : "unlocked"), health.vault_locked ? "warn" : "ok") + supervisorBadge("v" + (health.clauth_version || "${VERSION}"), "");
1538
+ } catch (err) {
1539
+ if (status) status.innerHTML = supervisorBadge("supervisor unreachable", "bad") + " " + htmlEscape(err.message || err);
1540
+ supervisorFeedback("Supervisor unavailable — actions are disabled until the local control plane returns.", true);
1541
+ }
1542
+ }
1543
+
1544
+ // Derives an "Open UI" target for a surface without storing one on disk —
1545
+ // a real localhost port wins, else the first externally-routable URL.
1546
+ function openUiUrlForSurface(surface) {
1547
+ if (surface.port && surface.port !== "auto") return "http://127.0.0.1:" + surface.port + "/";
1548
+ const external = (surface.routes || []).find(r => r.kind === "external" && r.url);
1549
+ return external ? external.url : null;
1550
+ }
1551
+
1552
+ function selectSupervisorSurface(compositeId, name) {
1553
+ selectedSupervisorSurface = selectedSupervisorSurface && selectedSupervisorSurface.id === compositeId
1554
+ ? null // clicking the already-selected card deselects it
1555
+ : { id: compositeId, name };
1556
+ document.querySelectorAll(".supervisor-row[data-surface-id]").forEach(row => {
1557
+ row.classList.toggle("selected", row.dataset.surfaceId === (selectedSupervisorSurface && selectedSupervisorSurface.id));
1558
+ });
1559
+ renderSupervisorCmdbar();
1560
+ }
1561
+
1562
+ function renderSupervisorCmdbar() {
1563
+ const bar = document.getElementById("supervisor-cmdbar");
1564
+ if (!bar) return;
1565
+ if (!selectedSupervisorSurface) {
1566
+ bar.innerHTML = '<span class="supervisor-cmdbar-empty">Select a surface below to act on it.</span>';
1567
+ return;
1568
+ }
1569
+ const { id, name } = selectedSupervisorSurface;
1570
+ bar.innerHTML = '<span class="supervisor-cmdbar-name">' + htmlEscape(name) + '</span>' +
1571
+ SUPERVISOR_SURFACE_ACTIONS.map(a => '<button class="supervisor-action" data-supervisor-action="' + a + '" onclick="runSupervisorSurface(' + jsArg(id) + ',' + jsArg(a) + ')">' + a + '</button>').join("");
1572
+ }
1573
+
1574
+ function renderSupervisorSurface(surface) {
1575
+ const compositeId = surface.plugin_id + ":" + surface.id;
1576
+ const ownerKind = surface.lifecycle_owner === "clauth" ? "ok" : (surface.lifecycle_owner === "plugin" ? "warn" : "");
1577
+ const stateKind = surface.state === "current" || surface.status === "healthy" ? "ok" : (surface.state === "unavailable" ? "bad" : "warn");
1578
+ const openUrl = surface.__pseudo ? null : openUiUrlForSurface(surface);
1579
+ const isSelected = selectedSupervisorSurface && selectedSupervisorSurface.id === compositeId;
1580
+ const rowClass = "supervisor-row" + (surface.__pseudo ? " readonly" : "") + (isSelected ? " selected" : "");
1581
+ const onclick = surface.__pseudo ? "" : ' onclick="selectSupervisorSurface(' + jsArg(compositeId) + ',' + jsArg(surface.name || compositeId) + ')"';
1582
+ const openBtn = surface.__pseudo
1583
+ ? '<button class="supervisor-action" onclick="event.stopPropagation();openClauthSelfMcp()" title="Open clauth\\'s own MCP endpoint">Open</button>'
1584
+ : (openUrl ? '<button class="supervisor-action" onclick="event.stopPropagation();window.open(' + jsArg(openUrl) + ',\\'_blank\\')" title="Open this surface\\'s UI">Open UI</button>' : "");
1585
+ return '<div class="' + rowClass + '" data-surface-id="' + htmlEscape(compositeId) + '"' + onclick + '>' +
1586
+ '<div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.name || compositeId) + '</span>' +
1587
+ supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(surface.state || surface.status || "unknown", stateKind) +
1588
+ openBtn +
1589
+ '</div>' +
1590
+ '<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
1591
+ '<div class="supervisor-meta">' + htmlEscape(surface.health || surface.health_url || "no health") + '</div>' +
1592
+ '</div>';
1593
+ }
1594
+
1595
+ async function rescanSupervisorPlugins() {
1596
+ try {
1597
+ await supervisorJson("/v1/plugins/rescan", { method: "POST", headers: writeHeaders() });
1598
+ supervisorFeedback("Plugin rescan completed.", false);
1599
+ await loadSupervisorCockpit();
1600
+ } catch (err) { supervisorFeedback("Plugin rescan failed: " + (err.message || err), true); }
1601
+ }
1602
+
1603
+ async function runSupervisorSurface(id, action) {
1604
+ try {
1605
+ const receipt = await supervisorJson("/v1/surfaces/" + encodeURIComponent(id) + "/actions", { method: "POST", headers: writeHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ action }) });
1606
+ const result = receipt.resulting_state || {};
1607
+ supervisorFeedback("Surface " + action + " receipt " + (receipt.operationId || "recorded") + " · " + (result.state || "completed"), result.ok === false);
1608
+ await loadSupervisorCockpit();
1609
+ } catch (err) { supervisorFeedback("Surface " + action + " failed: " + (err.message || err), true); }
1610
+ }
1611
+
1612
+ // clauth doesn't manage itself as a plugin, so its own MCP endpoint has no
1613
+ // entry in the real surfaces registry — this reads the vault value directly
1614
+ // (never hardcoded) rather than storing a synthetic surfaces row.
1615
+ async function openClauthSelfMcp() {
1616
+ try {
1617
+ const res = await fetch(BASE + "/v/mcp-clauth-endpoint");
1618
+ const text = (await res.text()).trim();
1619
+ if (!res.ok) {
1620
+ supervisorFeedback("Could not read clauth's own MCP endpoint: " + text, true);
1621
+ return;
1622
+ }
1623
+ window.open(text, "_blank");
1624
+ } catch (err) { supervisorFeedback("Could not read clauth's own MCP endpoint: " + (err.message || err), true); }
1625
+ }
1626
+
1311
1627
  // ── Unlock ──────────────────────────────────
1312
1628
  async function unlock() {
1313
1629
  const input = document.getElementById("lock-input");
@@ -1389,21 +1705,51 @@ async function lockVault() {
1389
1705
  // ── Unlock writes (re-establish write scope without locking) ──
1390
1706
  // Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
1391
1707
  // unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
1392
- async function unlockWrites() {
1393
- if (writeToken && !confirm("Writes are already unlocked this session. Re-unlock?")) return;
1394
- const pw = prompt("Enter your vault password to enable saving changes (10-minute write session):");
1395
- if (!pw) return;
1708
+ function unlockWrites() {
1709
+ openWriteUnlockModal();
1710
+ }
1711
+
1712
+ function openWriteUnlockModal() {
1713
+ const overlay = document.getElementById("write-unlock-overlay");
1714
+ const input = document.getElementById("write-unlock-input");
1715
+ const err = document.getElementById("write-unlock-err");
1716
+ if (err) err.textContent = "";
1717
+ if (input) { input.value = ""; input.className = "lock-input"; }
1718
+ if (overlay) overlay.style.display = "flex";
1719
+ if (input) setTimeout(() => input.focus(), 50);
1720
+ }
1721
+
1722
+ function closeWriteUnlockModal() {
1723
+ const overlay = document.getElementById("write-unlock-overlay");
1724
+ if (overlay) overlay.style.display = "none";
1725
+ }
1726
+
1727
+ async function submitWriteUnlock() {
1728
+ const input = document.getElementById("write-unlock-input");
1729
+ const btn = document.getElementById("write-unlock-btn");
1730
+ const err = document.getElementById("write-unlock-err");
1731
+ const pw = input ? input.value : "";
1732
+ if (!pw) { if (err) err.textContent = "Password is required."; return; }
1733
+ if (btn) { btn.disabled = true; btn.textContent = "Verifying..."; }
1396
1734
  try {
1397
1735
  const r = await fetch(BASE + "/auth", {
1398
1736
  method: "POST",
1399
1737
  headers: { "Content-Type": "application/json" },
1400
1738
  body: JSON.stringify({ password: pw }),
1401
1739
  }).then(r => r.json());
1402
- if (r.error) { alert("Unlock failed: " + r.error); return; }
1740
+ if (r.error) {
1741
+ if (input) { input.className = "lock-input error"; setTimeout(() => input.className = "lock-input", 600); }
1742
+ if (err) err.textContent = "Invalid: " + (r.error || "Invalid password");
1743
+ return;
1744
+ }
1403
1745
  writeToken = r.write_token || null;
1404
1746
  refreshWriteLockUi();
1405
- alert(writeToken ? "Writes unlocked for 10 minutes." : "Unlock did not return a write token.");
1406
- } catch (e) { alert("Unlock error: " + (e.message || e)); }
1747
+ closeWriteUnlockModal();
1748
+ } catch (e) {
1749
+ if (err) err.textContent = "Unlock error: " + (e.message || e);
1750
+ } finally {
1751
+ if (btn) { btn.disabled = false; btn.textContent = "Unlock Writes"; }
1752
+ }
1407
1753
  }
1408
1754
 
1409
1755
  // Reflect write-lock state on the button so it is obvious when a save will fail.
@@ -1451,124 +1797,6 @@ async function launchCCandMe() {
1451
1797
  }
1452
1798
  }
1453
1799
 
1454
- // ── TinTin local CLI dispatch sidebar ──
1455
- let tintinPollTimer = null;
1456
-
1457
- function toggleTinTinPanel() {
1458
- const panel = document.getElementById("tintin-panel");
1459
- const tab = document.getElementById("tintin-tab");
1460
- if (!panel) return;
1461
- panel.classList.toggle("open");
1462
- if (tab) tab.classList.toggle("active");
1463
- }
1464
-
1465
- function fillTinTinSmoke() {
1466
- const prompt = document.getElementById("tintin-prompt");
1467
- const app = document.getElementById("tintin-app");
1468
- if (app) app.value = "clauth-dashboard";
1469
- if (prompt) prompt.value = "Reply exactly: tintin-dashboard-ok";
1470
- }
1471
-
1472
- function setTinTinStatus(text) {
1473
- const el = document.getElementById("tintin-status");
1474
- if (el) el.textContent = text || "";
1475
- }
1476
-
1477
- function appendTinTinMessage(role, text) {
1478
- const container = document.getElementById("tintin-messages");
1479
- if (!container || !text) return;
1480
- const bubble = document.createElement("div");
1481
- bubble.style.cssText = role === "user"
1482
- ? "background:#14532d;border:1px solid #166534;border-radius:8px;padding:8px 10px;font-size:.82rem;color:#bbf7d0;white-space:pre-wrap;word-break:break-word;align-self:flex-end;max-width:90%"
1483
- : "background:#0a1f1a;border:1px solid #134e4a;border-radius:8px;padding:8px 10px;font-size:.82rem;color:#86efac;font-family:'Courier New',monospace;white-space:pre-wrap;word-break:break-word;max-width:95%;max-height:300px;overflow:auto";
1484
- bubble.textContent = text;
1485
- container.appendChild(bubble);
1486
- container.scrollTop = container.scrollHeight;
1487
- }
1488
-
1489
- function setTinTinResult(text, open) {
1490
- if (open && text) appendTinTinMessage("assistant", text);
1491
- }
1492
-
1493
- async function sendTinTinMessage() {
1494
- const sendBtn = document.getElementById("tintin-send");
1495
- const promptEl = document.getElementById("tintin-prompt");
1496
- const appEl = document.getElementById("tintin-app");
1497
- const cwdEl = document.getElementById("tintin-cwd");
1498
- const prompt = (promptEl && promptEl.value || "").trim();
1499
- const appSlug = (appEl && appEl.value || "clauth-dashboard").trim() || "clauth-dashboard";
1500
- const cwd = (cwdEl && cwdEl.value || "").trim();
1501
- if (!prompt) {
1502
- setTinTinStatus("prompt required");
1503
- return;
1504
- }
1505
- if (tintinPollTimer) {
1506
- clearTimeout(tintinPollTimer);
1507
- tintinPollTimer = null;
1508
- }
1509
- const jobId = "tintin-" + Date.now();
1510
- const body = {
1511
- prompt,
1512
- job_id: jobId,
1513
- cwd,
1514
- agent_context: {
1515
- app: { slug: appSlug, route: "/clauth-dashboard", origin: window.location.origin },
1516
- repo: { root: cwd, cwd },
1517
- runtime: { agent: "clauth-cli", requested_by: "clauth-dashboard", model: "claude-sonnet-4-6" },
1518
- task: { intent: "general_chat", thread_id: jobId }
1519
- }
1520
- };
1521
-
1522
- appendTinTinMessage("user", prompt);
1523
- if (promptEl) promptEl.value = "";
1524
- if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = "Dispatching..."; }
1525
- setTinTinStatus("dispatching");
1526
- try {
1527
- const response = await fetch(BASE + "/tintin/dispatch", {
1528
- method: "POST",
1529
- headers: { "Content-Type": "application/json" },
1530
- body: JSON.stringify(body)
1531
- });
1532
- const result = await response.json();
1533
- if (!response.ok || result.error) {
1534
- setTinTinStatus("dispatch failed");
1535
- setTinTinResult(JSON.stringify(result, null, 2), true);
1536
- return;
1537
- }
1538
- setTinTinStatus("spawned pid " + result.pid + " - polling");
1539
- pollTinTinJob(jobId, 0);
1540
- } catch (err) {
1541
- setTinTinStatus("dispatch error");
1542
- setTinTinResult(err && err.message ? err.message : String(err), true);
1543
- } finally {
1544
- if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = "Dispatch"; }
1545
- }
1546
- }
1547
-
1548
- async function pollTinTinJob(jobId, attempt) {
1549
- try {
1550
- const response = await fetch(BASE + "/tintin/dispatch/" + encodeURIComponent(jobId), { cache: "no-store" });
1551
- const job = await response.json();
1552
- if (!response.ok || job.error) {
1553
- setTinTinStatus("job lookup failed");
1554
- setTinTinResult(JSON.stringify(job, null, 2), true);
1555
- return;
1556
- }
1557
- if (job.status === "running" && attempt < 60) {
1558
- setTinTinStatus("running pid " + (job.pid || "?") + " - " + (attempt + 1));
1559
- tintinPollTimer = setTimeout(function() { pollTinTinJob(jobId, attempt + 1); }, 1500);
1560
- return;
1561
- }
1562
- setTinTinStatus(job.status === "completed" ? "done" : job.status);
1563
- const out = (job.stdout || "").trim();
1564
- if (out) appendTinTinMessage("assistant", out);
1565
- if (job.stderr && job.stderr.trim()) appendTinTinMessage("assistant", "stderr: " + job.stderr.trim());
1566
- } catch (err) {
1567
- setTinTinStatus("poll error");
1568
- setTinTinResult(err && err.message ? err.message : String(err), true);
1569
- }
1570
- }
1571
-
1572
1800
  // ── Restart daemon (keeps boot.key — vault stays unlocked) ──
1573
1801
  async function restartDaemon() {
1574
1802
  if (!confirm("Restart the daemon?\\n\\nThe vault will stay unlocked (boot.key kept).")) return;
@@ -1640,8 +1868,16 @@ function switchProjectTab(key) {
1640
1868
  renderServiceGrid(allServices);
1641
1869
  }
1642
1870
 
1871
+ const MCP_SURFACE_DUPLICATE_SERVICES = new Set([
1872
+ "mcp-clauth-endpoint",
1873
+ "mcp-fs-endpoint",
1874
+ "mcp-regen-media-endpoint",
1875
+ "mcp-web-research-endpoint",
1876
+ ]);
1877
+
1643
1878
  function renderServiceGrid(services) {
1644
1879
  const grid = document.getElementById("grid");
1880
+ services = services.filter(s => !MCP_SURFACE_DUPLICATE_SERVICES.has(s.name));
1645
1881
  let filtered = services;
1646
1882
  if (activeProjectTab === "unassigned") {
1647
1883
  filtered = services.filter(s => !s.project);
@@ -3034,11 +3270,6 @@ setInterval(async () => {
3034
3270
  }, 5000);
3035
3271
 
3036
3272
  boot();
3037
-
3038
- const tintinSidebarScript = document.createElement("script");
3039
- tintinSidebarScript.src = "/tintin/sidebar.user.js";
3040
- tintinSidebarScript.defer = true;
3041
- document.body.appendChild(tintinSidebarScript);
3042
3273
  </script>
3043
3274
  </body>
3044
3275
  </html>`;
@@ -3061,619 +3292,64 @@ function clauthConfigDir() {
3061
3292
  return path.join(os.homedir(), ".config", "clauth");
3062
3293
  }
3063
3294
 
3064
- function tintinConfigPath() {
3065
- return process.env.CLAUTH_MONKEY_CONFIG || path.join(clauthConfigDir(), "monkey.config.json");
3066
- }
3067
-
3068
- function defaultTinTinConfig() {
3069
- return {
3070
- schema_version: 3,
3071
- trust_mode: process.env.CLAUTH_MONKEY_TRUST_MODE || "open-local",
3072
- agent_settings: {
3073
- default_repo_root: process.env.CLAUTH_TINTIN_REPO_ROOT || "C:\\Dev\\regen-root",
3074
- worktree_root: process.env.CLAUTH_TINTIN_WORKTREE_ROOT || "C:\\Dev\\regen-root.wt",
3075
- base_branch: process.env.CLAUTH_TINTIN_BASE_BRANCH || "develop",
3076
- isolation: process.env.CLAUTH_TINTIN_ISOLATION || "worktree",
3077
- launch_mode: "setup_only",
3078
- agents: {
3079
- claude: {
3080
- runtime: "claude",
3081
- model: process.env.CLAUTH_TINTIN_CLAUDE_MODEL || "claude-sonnet-4-6",
3082
- command: process.env.CLAUDE_BIN || "claude",
3083
- enabled: true,
3084
- },
3085
- codex: {
3086
- runtime: "codex",
3087
- model: process.env.CLAUTH_TINTIN_CODEX_MODEL || "gpt-5-codex",
3088
- command: process.env.CODEX_BIN || "codex",
3089
- enabled: true,
3090
- },
3091
- },
3092
- },
3093
- sidebar_settings: {
3094
- enabled: true,
3095
- default_app_slug: "tintin-console",
3096
- session_ttl_hours: 24,
3097
- event_history_limit: 200,
3098
- },
3099
- dispatch_settings: {
3100
- enabled: true,
3101
- default_agent: "claude",
3102
- default_model: process.env.CLAUTH_TINTIN_CLAUDE_MODEL || "claude-sonnet-4-6",
3103
- max_concurrent_workers: 2,
3104
- },
3105
- codevelop_settings: {
3106
- enabled: true,
3107
- default_repo: process.env.CLAUTH_TINTIN_REPO_ROOT || "C:\\Dev\\regen-root",
3108
- message_retention_limit: 500,
3109
- require_peer_join: true,
3110
- },
3111
- agent_sessions: [],
3112
- apps: [
3113
- {
3114
- slug: "rdc-marketing-engine",
3115
- origins: [
3116
- "http://localhost:3000",
3117
- "http://127.0.0.1:3000",
3118
- "https://app.regendevcorp.com",
3119
- "https://rdc-marketing-engine.dev.regendevcorp.com",
3120
- ],
3121
- manifest_url: "https://app.regendevcorp.com/.well-known/monkey.json",
3122
- cwd: "C:\\Dev\\regen-root",
3123
- capabilities: ["general_chat", "skill_request", "handoff"],
3124
- },
3125
- ],
3126
- };
3127
- }
3128
-
3129
- function mergeTinTinAgentSettings(input = {}) {
3130
- const defaults = defaultTinTinConfig().agent_settings;
3131
- const incoming = input && typeof input === "object" ? input : {};
3132
- return {
3133
- ...defaults,
3134
- ...incoming,
3135
- agents: {
3136
- ...defaults.agents,
3137
- ...(incoming.agents && typeof incoming.agents === "object" ? incoming.agents : {}),
3138
- },
3139
- };
3140
- }
3141
-
3142
- function mergeTinTinSectionSettings(sectionName, input = {}) {
3143
- const defaults = defaultTinTinConfig()[sectionName] || {};
3144
- const incoming = input && typeof input === "object" ? input : {};
3145
- return { ...defaults, ...incoming };
3146
- }
3147
-
3148
- function normalizeTinTinConfig(input = {}) {
3149
- const defaults = defaultTinTinConfig();
3150
- return {
3151
- schema_version: Math.max(Number(input.schema_version || 0), defaults.schema_version),
3152
- trust_mode: typeof input.trust_mode === "string" ? input.trust_mode : defaults.trust_mode,
3153
- agent_settings: mergeTinTinAgentSettings(input.agent_settings),
3154
- sidebar_settings: mergeTinTinSectionSettings("sidebar_settings", input.sidebar_settings),
3155
- dispatch_settings: mergeTinTinSectionSettings("dispatch_settings", input.dispatch_settings),
3156
- codevelop_settings: mergeTinTinSectionSettings("codevelop_settings", input.codevelop_settings),
3157
- agent_sessions: Array.isArray(input.agent_sessions) ? input.agent_sessions : defaults.agent_sessions,
3158
- apps: Array.isArray(input.apps) ? input.apps : defaults.apps,
3159
- };
3160
- }
3161
-
3162
- function loadTinTinConfig() {
3163
- const configPath = tintinConfigPath();
3164
- try {
3165
- if (!fs.existsSync(configPath)) return { ...normalizeTinTinConfig(), path: configPath, exists: false };
3166
- const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
3167
- return {
3168
- ...normalizeTinTinConfig(parsed),
3169
- path: configPath,
3170
- exists: true,
3171
- };
3172
- } catch (err) {
3173
- return {
3174
- ...normalizeTinTinConfig(),
3175
- path: configPath,
3176
- exists: false,
3177
- error: err.message,
3178
- };
3295
+ // ── call_agent noauth-host guard (Gate B, bearer-gated tunnel) ───────────────
3296
+ // Pure decision function so the policy is testable without a daemon. Decides
3297
+ // whether a /call-agent request may proceed.
3298
+ // - Browser Origin → reject (CSRF), unchanged, regardless of host/token.
3299
+ // - Local (127.0.0.1, noAuthHost=false) → allow without a token, as today.
3300
+ // - Noauth tunnel host (clauth.regendevcorp.com etc.) → allow ONLY when the
3301
+ // request carries `Authorization: Bearer <expectedToken>` and the token is
3302
+ // a non-empty exact match. Missing/wrong bearer → reject (unchanged
3303
+ // unauthenticated behaviour). If no expectedToken is configured server-side,
3304
+ // the noauth host stays fully closed (cannot be unlocked by any bearer).
3305
+ // Returns { allow: true } or { allow: false, status, error }.
3306
+ export function evaluateCallAgentGuard({ origin, noAuthHost, authHeader, expectedToken }) {
3307
+ if (origin) {
3308
+ return { allow: false, status: 403, error: "call_agent_rejects_browser_origin", origin };
3179
3309
  }
3310
+ if (!noAuthHost) {
3311
+ return { allow: true };
3312
+ }
3313
+ // Noauth tunnel host: require a valid bearer token.
3314
+ const presented = typeof authHeader === "string"
3315
+ ? (authHeader.match(/^Bearer\s+(.+)$/i)?.[1] || "").trim()
3316
+ : "";
3317
+ if (expectedToken && presented && presented === expectedToken) {
3318
+ return { allow: true };
3319
+ }
3320
+ return { allow: false, status: 403, error: "call_agent_not_available_on_noauth_host" };
3180
3321
  }
3181
3322
 
3182
- function saveTinTinConfig(input = {}) {
3183
- const configPath = tintinConfigPath();
3184
- const next = normalizeTinTinConfig(input);
3185
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
3186
- fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
3187
- return { ...next, path: configPath, exists: true };
3188
- }
3189
-
3190
- function slugPart(value, fallback = "session") {
3191
- return String(value || fallback).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || fallback;
3192
- }
3193
-
3194
- function assertInside(parent, child) {
3195
- const rel = path.relative(path.resolve(parent), path.resolve(child));
3196
- return rel && !rel.startsWith("..") && !path.isAbsolute(rel);
3197
- }
3323
+ // ── Server logic (shared by foreground + daemon) ─────────────
3324
+ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
3325
+ mcpHttpBaseUrl = `http://127.0.0.1:${port}`;
3198
3326
 
3199
- function gitTopLevel(repoRoot) {
3200
- try {
3201
- return execSyncTop("git rev-parse --show-toplevel", {
3202
- cwd: repoRoot,
3203
- encoding: "utf8",
3204
- stdio: ["ignore", "pipe", "pipe"],
3205
- windowsHide: true,
3206
- }).trim();
3207
- } catch {
3208
- return null;
3209
- }
3210
- }
3327
+ // tunnelHostname may be updated at runtime (fetched from DB after unlock)
3328
+ let tunnelHostname = tunnelHostnameInit;
3211
3329
 
3212
- function createTinTinAgentSession(input = {}) {
3213
- const config = loadTinTinConfig();
3214
- const settings = mergeTinTinAgentSettings(config.agent_settings);
3215
- const agentKey = slugPart(input.agent || input.runtime || "claude", "claude");
3216
- const agent = settings.agents?.[agentKey] || { runtime: agentKey, model: input.model || "", command: agentKey, enabled: true };
3217
- if (agent.enabled === false) return { ok: false, error: "agent_disabled", agent: agentKey };
3218
-
3219
- const sessionId = slugPart(input.session_id || makeTinTinId("agent"), "agent");
3220
- const repoRoot = path.resolve(String(input.repo_root || settings.default_repo_root || settings.cwd || process.cwd()));
3221
- const top = gitTopLevel(repoRoot);
3222
- if (!top) return { ok: false, error: "not_git_repo", repo_root: repoRoot };
3223
-
3224
- const isolation = String(input.isolation || settings.isolation || "worktree");
3225
- const baseBranch = slugPart(input.base_branch || settings.base_branch || "develop", "develop");
3226
- let cwd = top;
3227
- let branch = null;
3228
- let worktreePath = null;
3229
- let worktreeCreated = false;
3230
- const commands = [];
3231
-
3232
- if (isolation === "worktree") {
3233
- const worktreeRoot = path.resolve(String(input.worktree_root || settings.worktree_root || path.join(path.dirname(top), `${path.basename(top)}.wt`)));
3234
- fs.mkdirSync(worktreeRoot, { recursive: true });
3235
- worktreePath = path.join(worktreeRoot, `tintin-${agentKey}-${sessionId}`);
3236
- if (!assertInside(worktreeRoot, worktreePath)) return { ok: false, error: "worktree_path_escape", worktree_root: worktreeRoot, worktree_path: worktreePath };
3237
- branch = `wt/tintin/${agentKey}/${sessionId}`;
3238
- if (!fs.existsSync(worktreePath)) {
3239
- execSyncTop(`git worktree add -b ${JSON.stringify(branch)} ${JSON.stringify(worktreePath)} ${JSON.stringify(baseBranch)}`, {
3240
- cwd: top,
3241
- stdio: ["ignore", "pipe", "pipe"],
3242
- timeout: 60_000,
3243
- windowsHide: true,
3244
- });
3245
- worktreeCreated = true;
3246
- commands.push(`git worktree add -b ${branch} ${worktreePath} ${baseBranch}`);
3330
+ // Ensure Windows system tools are reachable (bash shells may lack these on PATH)
3331
+ if (os.platform() === "win32") {
3332
+ const sys32 = "C:\\Windows\\System32";
3333
+ if (!process.env.PATH?.includes(sys32 + "\\Wbem")) {
3334
+ process.env.PATH = (process.env.PATH || "") + ";" + sys32 + "\\Wbem";
3335
+ }
3336
+ if (!process.env.PATH?.includes(sys32 + ";") && !process.env.PATH?.endsWith(sys32)) {
3337
+ process.env.PATH = (process.env.PATH || "") + ";" + sys32;
3247
3338
  }
3248
- cwd = worktreePath;
3249
- } else if (isolation !== "cwd") {
3250
- return { ok: false, error: "unsupported_isolation", isolation };
3251
3339
  }
3340
+ const MAX_FAILS = 10;
3341
+ let failCount = 0;
3342
+ const MAX_AUTH_FAILS = 10;
3343
+ let authFailCount = 0;
3252
3344
 
3253
- const runtime = agent.runtime || agentKey;
3254
- const command = agent.command || runtime;
3255
- const model = input.model || agent.model || "";
3256
- const launchCommand = runtime === "codex"
3257
- ? `${command} exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -C ${JSON.stringify(cwd)} -`
3258
- : `${command} -p <prompt> --dangerously-skip-permissions`;
3259
-
3260
- const result = {
3261
- ok: true,
3262
- agent_session_id: sessionId,
3263
- agent: agentKey,
3264
- runtime,
3265
- model,
3266
- isolation,
3267
- repo_root: top,
3268
- cwd,
3269
- worktree_root: isolation === "worktree" ? path.dirname(worktreePath) : null,
3270
- worktree_path: worktreePath,
3271
- worktree_created: worktreeCreated,
3272
- branch,
3273
- base_branch: baseBranch,
3274
- launch_mode: settings.launch_mode || "setup_only",
3275
- launch_command: launchCommand,
3276
- commands,
3277
- };
3278
- const sessionRecord = {
3279
- agent_session_id: result.agent_session_id,
3280
- agent: result.agent,
3281
- runtime: result.runtime,
3282
- model: result.model,
3283
- isolation: result.isolation,
3284
- repo_root: result.repo_root,
3285
- cwd: result.cwd,
3286
- worktree_path: result.worktree_path,
3287
- branch: result.branch,
3288
- base_branch: result.base_branch,
3289
- launch_mode: result.launch_mode,
3290
- created_at: new Date().toISOString(),
3291
- };
3292
- const sessions = [sessionRecord, ...(Array.isArray(config.agent_sessions) ? config.agent_sessions : [])
3293
- .filter((item) => item && item.agent_session_id !== sessionId)]
3294
- .slice(0, 100);
3295
- saveTinTinConfig({ ...config, agent_sessions: sessions });
3296
- return result;
3297
- }
3298
-
3299
- function isLoopbackOrigin(origin) {
3300
- if (!origin) return false;
3301
- try {
3302
- const parsed = new URL(origin);
3303
- return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
3304
- } catch {
3305
- return false;
3306
- }
3307
- }
3308
-
3309
- function tintinAppForOrigin(config, origin) {
3310
- if (!origin) return null;
3311
- return (config.apps || []).find((app) => (
3312
- Array.isArray(app.origins) && app.origins.some((allowed) => allowed === origin || allowed === "*")
3313
- )) || null;
3314
- }
3315
-
3316
- function checkTinTinBrowserAccess(req) {
3317
- const config = loadTinTinConfig();
3318
- const origin = req.headers.origin || "";
3319
- const trustMode = config.trust_mode || "open-local";
3320
- const matchedApp = tintinAppForOrigin(config, origin);
3321
-
3322
- if (trustMode === "disabled") {
3323
- return { allowed: false, config, reason: "monkey_disabled" };
3324
- }
3325
- if (!origin) {
3326
- return { allowed: true, config, app: null, reason: "non_browser_local" };
3327
- }
3328
- if (matchedApp) {
3329
- return { allowed: true, config, app: matchedApp, reason: "configured_origin" };
3330
- }
3331
- if (trustMode === "open-local" && isLoopbackOrigin(origin)) {
3332
- return { allowed: true, config, app: null, reason: "open_local_loopback" };
3333
- }
3334
- return { allowed: false, config, reason: "origin_not_allowed", origin };
3335
- }
3336
-
3337
- function publicTinTinConfig(config) {
3338
- return {
3339
- schema_version: config.schema_version || 1,
3340
- trust_mode: config.trust_mode || "open-local",
3341
- config_path: config.path,
3342
- config_exists: !!config.exists,
3343
- config_error: config.error || null,
3344
- agent_settings: mergeTinTinAgentSettings(config.agent_settings),
3345
- sidebar_settings: mergeTinTinSectionSettings("sidebar_settings", config.sidebar_settings),
3346
- dispatch_settings: mergeTinTinSectionSettings("dispatch_settings", config.dispatch_settings),
3347
- codevelop_settings: mergeTinTinSectionSettings("codevelop_settings", config.codevelop_settings),
3348
- agent_sessions: Array.isArray(config.agent_sessions) ? config.agent_sessions : [],
3349
- apps: (config.apps || []).map((app) => ({
3350
- slug: app.slug,
3351
- origins: app.origins || [],
3352
- manifest_url: app.manifest_url || null,
3353
- cwd: app.cwd || null,
3354
- capabilities: app.capabilities || [],
3355
- })),
3356
- };
3357
- }
3358
-
3359
- function tintinSettingsHtml() {
3360
- return `<!DOCTYPE html>
3361
- <html lang="en">
3362
- <head>
3363
- <meta charset="utf-8">
3364
- <meta name="viewport" content="width=device-width,initial-scale=1">
3365
- <title>clauth TinTin Settings</title>
3366
- <style>
3367
- body{margin:0;background:#07100e;color:#d9fbe8;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
3368
- header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #14352f;background:#091814}
3369
- h1{font-size:20px;margin:0}.sub{font-size:12px;color:#8dd8c2;margin-top:3px}.pill{display:inline-flex;align-items:center;border:1px solid #14532d;color:#86efac;padding:2px 7px;font-size:11px;margin-top:7px}
3370
- main{display:grid;grid-template-columns:minmax(300px,430px) 1fr;gap:14px;padding:14px}
3371
- .stack{display:grid;gap:14px}.wide{display:grid;gap:14px}
3372
- section{border:1px solid #14352f;background:#091814;padding:14px}
3373
- h2{font-size:15px;margin:0 0 10px;color:#dcfce7}
3374
- label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#8dd8c2;font-weight:700;margin:10px 0 4px}
3375
- input,select,textarea{box-sizing:border-box;width:100%;border:1px solid #1d4d43;background:#031312;color:#d9fbe8;padding:8px 9px;font:13px ui-monospace,SFMono-Regular,Consolas,monospace;outline:none}
3376
- textarea{min-height:170px;resize:vertical}.short{min-height:92px}.tiny{min-height:58px}
3377
- .row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
3378
- button,a.btn{border:1px solid #2d6a5e;background:#08231f;color:#b8ffe1;padding:7px 10px;font-size:12px;font-weight:700;text-decoration:none;cursor:pointer}
3379
- button:hover,a.btn:hover{background:#0f2a24}.danger{border-color:#5f3b3b;color:#fecaca;background:#241010}
3380
- pre{white-space:pre-wrap;word-break:break-word;border:1px solid #0d2a25;background:#020b09;color:#c8f9df;padding:10px;max-height:460px;overflow:auto;font-size:12px}
3381
- .status{font:12px ui-monospace,SFMono-Regular,Consolas,monospace;color:#86efac}.hint{font-size:12px;color:#7dd3c0;line-height:1.4}.check{display:flex;align-items:center;gap:8px;margin-top:10px;color:#d9fbe8;font-size:13px}.check input{width:auto}
3382
- @media(max-width:900px){main{grid-template-columns:1fr}.row{grid-template-columns:1fr}}
3383
- </style>
3384
- </head>
3385
- <body>
3386
- <header>
3387
- <div><h1>TinTin Settings</h1><div class="sub">Persistent clauth control plane for agent setup, app sidebars, blackboard dispatch, and co-develop relay.</div><div class="pill">GET/PUT /tintin/settings</div></div>
3388
- <a class="btn" href="/">Vault Dashboard</a>
3389
- </header>
3390
- <main>
3391
- <div class="stack">
3392
- <section>
3393
- <h2>Agent Setup</h2>
3394
- <div class="hint">Creates a proper isolated cwd for Claude or Codex. Worktree setup is explicit and returned before any supervisor launches a process.</div>
3395
- <div class="row">
3396
- <div><label>agent</label><select id="agent"><option value="claude">claude</option><option value="codex">codex</option></select></div>
3397
- <div><label>isolation</label><select id="isolation"><option value="worktree">worktree</option><option value="cwd">cwd</option></select></div>
3398
- </div>
3399
- <label>repo root</label><input id="repoRoot">
3400
- <label>worktree root</label><input id="worktreeRoot">
3401
- <div class="row">
3402
- <div><label>base branch</label><input id="baseBranch"></div>
3403
- <div><label>session id</label><input id="sessionId" placeholder="auto if blank"></div>
3404
- </div>
3405
- <div class="actions">
3406
- <button onclick="loadSettings()" title="GET /tintin/settings">Load</button>
3407
- <button onclick="saveSettings()" title="PUT /tintin/settings">Save</button>
3408
- <button onclick="setupAgent()" title="POST /tintin/agent-sessions">Setup Agent</button>
3409
- </div>
3410
- <p class="status" id="status">idle</p>
3411
- </section>
3412
- <section>
3413
- <h2>Stored Agent Sessions</h2>
3414
- <pre id="agentSessions">No sessions loaded.</pre>
3415
- </section>
3416
- <section>
3417
- <h2>Return</h2>
3418
- <pre id="result">No return yet.</pre>
3419
- </section>
3420
- </div>
3421
- <div class="wide">
3422
- <section>
3423
- <h2>Agent Defaults</h2>
3424
- <div class="row">
3425
- <div><label>Claude model</label><input id="claudeModel"></div>
3426
- <div><label>Codex model</label><input id="codexModel"></div>
3427
- </div>
3428
- <div class="row">
3429
- <div><label>Claude command</label><input id="claudeCommand"></div>
3430
- <div><label>Codex command</label><input id="codexCommand"></div>
3431
- </div>
3432
- <div class="row">
3433
- <label class="check"><input type="checkbox" id="claudeEnabled"> Claude enabled</label>
3434
- <label class="check"><input type="checkbox" id="codexEnabled"> Codex enabled</label>
3435
- </div>
3436
- </section>
3437
- <section>
3438
- <h2>Sidebar Apps</h2>
3439
- <div class="row">
3440
- <div><label>trust mode</label><select id="trustMode"><option value="open-local">open-local</option><option value="configured-origins">configured-origins</option><option value="disabled">disabled</option></select></div>
3441
- <div><label>default app slug</label><input id="defaultAppSlug"></div>
3442
- </div>
3443
- <div class="row">
3444
- <div><label>session ttl hours</label><input id="sessionTtlHours" type="number" min="1" step="1"></div>
3445
- <div><label>event history limit</label><input id="eventHistoryLimit" type="number" min="1" step="1"></div>
3446
- </div>
3447
- <label class="check"><input type="checkbox" id="sidebarEnabled"> Sidebar sessions enabled</label>
3448
- <label>apps JSON</label><textarea class="short" id="appsJson" spellcheck="false"></textarea>
3449
- </section>
3450
- <section>
3451
- <h2>Blackboard Dispatch</h2>
3452
- <div class="row">
3453
- <div><label>default agent</label><select id="dispatchDefaultAgent"><option value="claude">claude</option><option value="codex">codex</option></select></div>
3454
- <div><label>default model</label><input id="dispatchDefaultModel"></div>
3455
- </div>
3456
- <div class="row">
3457
- <div><label>max workers</label><input id="dispatchMaxWorkers" type="number" min="1" step="1"></div>
3458
- <label class="check"><input type="checkbox" id="dispatchEnabled"> Dispatch enabled</label>
3459
- </div>
3460
- </section>
3461
- <section>
3462
- <h2>Co-develop Relay</h2>
3463
- <div class="row">
3464
- <div><label>default repo</label><input id="codevelopDefaultRepo"></div>
3465
- <div><label>message retention</label><input id="codevelopRetention" type="number" min="1" step="1"></div>
3466
- </div>
3467
- <div class="row">
3468
- <label class="check"><input type="checkbox" id="codevelopEnabled"> Co-develop enabled</label>
3469
- <label class="check"><input type="checkbox" id="codevelopRequirePeer"> Require peer join</label>
3470
- </div>
3471
- </section>
3472
- <section>
3473
- <h2>Settings JSON</h2>
3474
- <textarea id="settingsJson" spellcheck="false"></textarea>
3475
- </section>
3476
- </div>
3477
- </main>
3478
- <script>
3479
- const BASE = location.origin;
3480
- let currentSettings = {};
3481
- function setStatus(text){ document.getElementById("status").textContent = text; }
3482
- function show(value){ document.getElementById("result").textContent = typeof value === "string" ? value : JSON.stringify(value,null,2); }
3483
- function num(id, fallback){ const n = Number(document.getElementById(id).value); return Number.isFinite(n) ? n : fallback; }
3484
- function applySettings(data){
3485
- const cfg = data.agent_settings ? data : data.config || data;
3486
- currentSettings = cfg;
3487
- const s = cfg.agent_settings || {};
3488
- const side = cfg.sidebar_settings || {};
3489
- const dispatch = cfg.dispatch_settings || {};
3490
- const codevelop = cfg.codevelop_settings || {};
3491
- const agents = s.agents || {};
3492
- const claude = agents.claude || {};
3493
- const codex = agents.codex || {};
3494
- document.getElementById("trustMode").value = cfg.trust_mode || "open-local";
3495
- document.getElementById("repoRoot").value = s.default_repo_root || "";
3496
- document.getElementById("worktreeRoot").value = s.worktree_root || "";
3497
- document.getElementById("baseBranch").value = s.base_branch || "develop";
3498
- document.getElementById("isolation").value = s.isolation || "worktree";
3499
- document.getElementById("claudeModel").value = claude.model || "";
3500
- document.getElementById("codexModel").value = codex.model || "";
3501
- document.getElementById("claudeCommand").value = claude.command || "claude";
3502
- document.getElementById("codexCommand").value = codex.command || "codex";
3503
- document.getElementById("claudeEnabled").checked = claude.enabled !== false;
3504
- document.getElementById("codexEnabled").checked = codex.enabled !== false;
3505
- document.getElementById("sidebarEnabled").checked = side.enabled !== false;
3506
- document.getElementById("defaultAppSlug").value = side.default_app_slug || "";
3507
- document.getElementById("sessionTtlHours").value = side.session_ttl_hours || 24;
3508
- document.getElementById("eventHistoryLimit").value = side.event_history_limit || 200;
3509
- document.getElementById("appsJson").value = JSON.stringify(cfg.apps || [],null,2);
3510
- document.getElementById("dispatchEnabled").checked = dispatch.enabled !== false;
3511
- document.getElementById("dispatchDefaultAgent").value = dispatch.default_agent || "claude";
3512
- document.getElementById("dispatchDefaultModel").value = dispatch.default_model || "";
3513
- document.getElementById("dispatchMaxWorkers").value = dispatch.max_concurrent_workers || 2;
3514
- document.getElementById("codevelopEnabled").checked = codevelop.enabled !== false;
3515
- document.getElementById("codevelopDefaultRepo").value = codevelop.default_repo || "";
3516
- document.getElementById("codevelopRetention").value = codevelop.message_retention_limit || 500;
3517
- document.getElementById("codevelopRequirePeer").checked = codevelop.require_peer_join !== false;
3518
- document.getElementById("agentSessions").textContent = JSON.stringify(cfg.agent_sessions || [],null,2);
3519
- document.getElementById("settingsJson").value = JSON.stringify(cfg,null,2);
3520
- }
3521
- function collectSettings(){
3522
- let apps;
3523
- try { apps = JSON.parse(document.getElementById("appsJson").value || "[]"); }
3524
- catch(e){ throw new Error("apps JSON: " + e.message); }
3525
- const parsed = JSON.parse(document.getElementById("settingsJson").value || "{}");
3526
- return {
3527
- ...parsed,
3528
- trust_mode: document.getElementById("trustMode").value,
3529
- agent_settings: {
3530
- ...(parsed.agent_settings || {}),
3531
- default_repo_root: document.getElementById("repoRoot").value,
3532
- worktree_root: document.getElementById("worktreeRoot").value,
3533
- base_branch: document.getElementById("baseBranch").value,
3534
- isolation: document.getElementById("isolation").value,
3535
- agents: {
3536
- ...((parsed.agent_settings || {}).agents || {}),
3537
- claude: {
3538
- ...(((parsed.agent_settings || {}).agents || {}).claude || {}),
3539
- runtime: "claude",
3540
- model: document.getElementById("claudeModel").value,
3541
- command: document.getElementById("claudeCommand").value,
3542
- enabled: document.getElementById("claudeEnabled").checked
3543
- },
3544
- codex: {
3545
- ...(((parsed.agent_settings || {}).agents || {}).codex || {}),
3546
- runtime: "codex",
3547
- model: document.getElementById("codexModel").value,
3548
- command: document.getElementById("codexCommand").value,
3549
- enabled: document.getElementById("codexEnabled").checked
3550
- }
3551
- }
3552
- },
3553
- sidebar_settings: {
3554
- ...(parsed.sidebar_settings || {}),
3555
- enabled: document.getElementById("sidebarEnabled").checked,
3556
- default_app_slug: document.getElementById("defaultAppSlug").value,
3557
- session_ttl_hours: num("sessionTtlHours", 24),
3558
- event_history_limit: num("eventHistoryLimit", 200)
3559
- },
3560
- dispatch_settings: {
3561
- ...(parsed.dispatch_settings || {}),
3562
- enabled: document.getElementById("dispatchEnabled").checked,
3563
- default_agent: document.getElementById("dispatchDefaultAgent").value,
3564
- default_model: document.getElementById("dispatchDefaultModel").value,
3565
- max_concurrent_workers: num("dispatchMaxWorkers", 2)
3566
- },
3567
- codevelop_settings: {
3568
- ...(parsed.codevelop_settings || {}),
3569
- enabled: document.getElementById("codevelopEnabled").checked,
3570
- default_repo: document.getElementById("codevelopDefaultRepo").value,
3571
- message_retention_limit: num("codevelopRetention", 500),
3572
- require_peer_join: document.getElementById("codevelopRequirePeer").checked
3573
- },
3574
- agent_sessions: Array.isArray(parsed.agent_sessions) ? parsed.agent_sessions : (currentSettings.agent_sessions || []),
3575
- apps
3576
- };
3577
- }
3578
- async function loadSettings(){
3579
- setStatus("GET /tintin/settings");
3580
- const r = await fetch(BASE + "/tintin/settings", { cache:"no-store" });
3581
- const data = await r.json();
3582
- applySettings(data);
3583
- show(data);
3584
- setStatus(r.ok ? "loaded" : "load failed");
3585
- }
3586
- async function saveSettings(){
3587
- setStatus("PUT /tintin/settings");
3588
- let body;
3589
- try { body = collectSettings(); }
3590
- catch(e){ setStatus("invalid JSON"); show(e.message); return; }
3591
- const r = await fetch(BASE + "/tintin/settings", { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(body) });
3592
- const data = await r.json();
3593
- applySettings(data);
3594
- show(data);
3595
- setStatus(r.ok ? "saved" : "save failed");
3596
- }
3597
- async function setupAgent(){
3598
- const body = {
3599
- agent: document.getElementById("agent").value,
3600
- isolation: document.getElementById("isolation").value,
3601
- repo_root: document.getElementById("repoRoot").value,
3602
- worktree_root: document.getElementById("worktreeRoot").value,
3603
- base_branch: document.getElementById("baseBranch").value,
3604
- session_id: document.getElementById("sessionId").value || undefined
3605
- };
3606
- setStatus("POST /tintin/agent-sessions");
3607
- const r = await fetch(BASE + "/tintin/agent-sessions", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(body) });
3608
- const data = await r.json();
3609
- show(data);
3610
- setStatus(r.ok && data.ok ? "agent setup ready" : "agent setup failed");
3611
- if (r.ok && data.ok) loadSettings().catch(function(){});
3612
- }
3613
- loadSettings().catch(err => { setStatus("load failed"); show(err.message || String(err)); });
3614
- </script>
3615
- </body>
3616
- </html>`;
3617
- }
3618
-
3619
- // ── call_agent noauth-host guard (Gate B, bearer-gated tunnel) ───────────────
3620
- // Pure decision function so the policy is testable without a daemon. Decides
3621
- // whether a /call-agent request may proceed.
3622
- // - Browser Origin → reject (CSRF), unchanged, regardless of host/token.
3623
- // - Local (127.0.0.1, noAuthHost=false) → allow without a token, as today.
3624
- // - Noauth tunnel host (clauth.regendevcorp.com etc.) → allow ONLY when the
3625
- // request carries `Authorization: Bearer <expectedToken>` and the token is
3626
- // a non-empty exact match. Missing/wrong bearer → reject (unchanged
3627
- // unauthenticated behaviour). If no expectedToken is configured server-side,
3628
- // the noauth host stays fully closed (cannot be unlocked by any bearer).
3629
- // Returns { allow: true } or { allow: false, status, error }.
3630
- export function evaluateCallAgentGuard({ origin, noAuthHost, authHeader, expectedToken }) {
3631
- if (origin) {
3632
- return { allow: false, status: 403, error: "call_agent_rejects_browser_origin", origin };
3633
- }
3634
- if (!noAuthHost) {
3635
- return { allow: true };
3636
- }
3637
- // Noauth tunnel host: require a valid bearer token.
3638
- const presented = typeof authHeader === "string"
3639
- ? (authHeader.match(/^Bearer\s+(.+)$/i)?.[1] || "").trim()
3640
- : "";
3641
- if (expectedToken && presented && presented === expectedToken) {
3642
- return { allow: true };
3643
- }
3644
- return { allow: false, status: 403, error: "call_agent_not_available_on_noauth_host" };
3645
- }
3646
-
3647
- // ── Server logic (shared by foreground + daemon) ─────────────
3648
- function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
3649
- mcpHttpBaseUrl = `http://127.0.0.1:${port}`;
3650
-
3651
- // tunnelHostname may be updated at runtime (fetched from DB after unlock)
3652
- let tunnelHostname = tunnelHostnameInit;
3653
-
3654
- // Ensure Windows system tools are reachable (bash shells may lack these on PATH)
3655
- if (os.platform() === "win32") {
3656
- const sys32 = "C:\\Windows\\System32";
3657
- if (!process.env.PATH?.includes(sys32 + "\\Wbem")) {
3658
- process.env.PATH = (process.env.PATH || "") + ";" + sys32 + "\\Wbem";
3659
- }
3660
- if (!process.env.PATH?.includes(sys32 + ";") && !process.env.PATH?.endsWith(sys32)) {
3661
- process.env.PATH = (process.env.PATH || "") + ";" + sys32;
3662
- }
3663
- }
3664
- const MAX_FAILS = 10;
3665
- let failCount = 0;
3666
- const MAX_AUTH_FAILS = 10;
3667
- let authFailCount = 0;
3668
-
3669
- // Per-IP unknown-service strike counter — separate from auth failure budget.
3670
- // Caller typos / stale memory should NOT burn the 10-strike lockout.
3671
- const unknownServiceStrikes = new Map(); // ip → count
3672
- const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
3673
- let authHardLocked = false;
3674
- let password = initPassword || null; // null = locked; set via POST /auth
3675
- let writeSession = null; // null until explicit password auth grants write scope
3676
- const machineHash = getMachineHash();
3345
+ // Per-IP unknown-service strike counter — separate from auth failure budget.
3346
+ // Caller typos / stale memory should NOT burn the 10-strike lockout.
3347
+ const unknownServiceStrikes = new Map(); // ip → count
3348
+ const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
3349
+ let authHardLocked = false;
3350
+ let password = initPassword || null; // null = locked; set via POST /auth
3351
+ let writeSession = null; // null until explicit password auth grants write scope
3352
+ const machineHash = getMachineHash();
3677
3353
 
3678
3354
  // ── call_agent bearer token resolver (noauth-host gate) ────────────────────
3679
3355
  // The shared secret that lets a noauth tunnel host (clauth.regendevcorp.com)
@@ -3770,6 +3446,187 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3770
3446
  });
3771
3447
  },
3772
3448
  });
3449
+ const isSupervisorPort = port === getSupervisorPort();
3450
+ const supervisorTestNoToken = process.env.CLAUTH_SUPERVISOR_TEST_NO_TOKEN === "1";
3451
+ const opsAdapter = createPm2Adapter(pm2);
3452
+ const executePm2 = createSerializedExecutor();
3453
+ const opsPolicy = createOperationPolicy({
3454
+ enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
3455
+ applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
3456
+ adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
3457
+ adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
3458
+ allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
3459
+ });
3460
+ const opsJobs = createJobStore({
3461
+ filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
3462
+ });
3463
+
3464
+ async function getLoopbackSecret(service) {
3465
+ const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
3466
+ if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
3467
+ const value = (await response.text()).trim();
3468
+ if (!value) throw new Error(`${service} is empty`);
3469
+ return value;
3470
+ }
3471
+ const coolify = createCoolifyAdapter({
3472
+ baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
3473
+ getToken: () => getLoopbackSecret("coolify-api"),
3474
+ });
3475
+ const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
3476
+ const deploymentAdapter = createDeploymentAdapter({
3477
+ deployments,
3478
+ reload: async (target) => {
3479
+ await executePm2(async () => {
3480
+ await opsAdapter.connect();
3481
+ try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
3482
+ });
3483
+ },
3484
+ });
3485
+
3486
+ /**
3487
+ * Record an ops failure's upstream message to the LOCAL log only.
3488
+ *
3489
+ * job-store's sanitizer deliberately drops free-form `error` text so an
3490
+ * upstream message cannot carry a credential into the persisted job file or
3491
+ * the API response. That protection left every failure with an empty detail,
3492
+ * so jobs reported `failed` with no reason at all. Jobs now carry an
3493
+ * enumerated `code`; the underlying message goes here, to the same
3494
+ * operator-only log as the rest of the daemon's diagnostics.
3495
+ */
3496
+ function logOpsFailure(kind, operation, error) {
3497
+ const message = String(error?.message || error || "unknown");
3498
+ try {
3499
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
3500
+ } catch {}
3501
+ }
3502
+
3503
+ async function opsBearerRole(req) {
3504
+ const header = req.headers.authorization;
3505
+ const supplied = Array.isArray(header) ? header[0] : header;
3506
+ if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
3507
+ const actual = String(supplied).slice(7).trim();
3508
+ let admin; let agent;
3509
+ try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
3510
+ try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
3511
+ if (admin && agent && admin === agent) return null;
3512
+ for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
3513
+ if (!expected) continue;
3514
+ const a = Buffer.from(actual); const b = Buffer.from(expected);
3515
+ if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
3516
+ }
3517
+ return null;
3518
+ }
3519
+
3520
+ async function requireOpsBearer(req, res) {
3521
+ const role = await opsBearerRole(req);
3522
+ if (role) { req._opsRole = role; return true; }
3523
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
3524
+ res.end(JSON.stringify({ error: "ops_bearer_required" }));
3525
+ return false;
3526
+ }
3527
+
3528
+ function submitOpsJob(operation, input, role = "agent") {
3529
+ const authorization = opsPolicy.authorize(operation, input, role);
3530
+ const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
3531
+ if (!authorization.ok) {
3532
+ return opsJobs.event(job.id, "rejected", { code: authorization.code });
3533
+ }
3534
+ void (async () => {
3535
+ opsJobs.event(job.id, "running");
3536
+ try {
3537
+ const result = await executePm2(async () => {
3538
+ await opsAdapter.connect();
3539
+ try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
3540
+ });
3541
+ opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
3542
+ } catch (error) {
3543
+ // `error` alone is dropped by job-store's sanitizer (it refuses
3544
+ // free-form upstream text so a credential cannot ride along), which
3545
+ // left every failure with an empty detail. Emit an enumerated code so
3546
+ // the failure has a reason; keep the message for the local log only.
3547
+ logOpsFailure("pm2", operation, error);
3548
+ opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
3549
+ }
3550
+ })();
3551
+ return opsJobs.get(job.id);
3552
+ }
3553
+
3554
+ function operationReceipt(operation, result, allowedTargets) {
3555
+ if (["list", "describe", "logs"].includes(operation)) {
3556
+ const processes = Array.isArray(result)
3557
+ ? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
3558
+ : [];
3559
+ return { processes };
3560
+ }
3561
+ if (operation === "ping") return { status: "connected" };
3562
+ return { status: "completed" };
3563
+ }
3564
+
3565
+ function submitPromotionJob(applicationUuid) {
3566
+ const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
3567
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
3568
+ const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
3569
+ if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
3570
+ return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
3571
+ }
3572
+ void (async () => {
3573
+ opsJobs.event(job.id, "running");
3574
+ try {
3575
+ const deployment = await coolify.promote(applicationUuid);
3576
+ // Coolify answers with a `deployments` ARRAY, not a flat object — see
3577
+ // deploymentUuidFrom. Reading the flat field alone marked the job failed
3578
+ // while the deployment was actually running.
3579
+ const deploymentUuid = deploymentUuidFrom(deployment);
3580
+ if (!deploymentUuid) {
3581
+ // The deploy request itself SUCCEEDED (no throw); only the UUID was
3582
+ // unreadable, so the deployment may well be RUNNING. The code says so
3583
+ // explicitly rather than a bare "failed", because a plain failure
3584
+ // invites a retry and a duplicate production deploy.
3585
+ //
3586
+ // An enumerated code, not a free-form error: job-store's sanitizer
3587
+ // drops `error` on purpose to keep upstream text (and any credential
3588
+ // inside it) out of the persisted job.
3589
+ return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
3590
+ }
3591
+ opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
3592
+ const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
3593
+ opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
3594
+ } catch (error) {
3595
+ // The throw may have happened AFTER Coolify accepted the deploy (e.g.
3596
+ // the poll lost the network), so this is not proof nothing shipped.
3597
+ logOpsFailure("coolify", "promote", error);
3598
+ opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
3599
+ }
3600
+ })();
3601
+ return opsJobs.get(job.id);
3602
+ }
3603
+
3604
+ function submitDeploymentJob(application, ref) {
3605
+ const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
3606
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
3607
+ if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
3608
+ void (async () => {
3609
+ opsJobs.event(job.id, "running");
3610
+ try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
3611
+ catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
3612
+ })();
3613
+ return opsJobs.get(job.id);
3614
+ }
3615
+
3616
+ function hasSupervisorWrite(req) {
3617
+ if (validateWriteToken(req, writeSession)) return true;
3618
+ if (!isSupervisorPort) return false;
3619
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
3620
+ return supervisorTestNoToken || !supervisorRequiresWriteToken(port);
3621
+ }
3622
+
3623
+ function rejectSupervisorWrite(res) {
3624
+ res.writeHead(403, { "Content-Type": "application/json", ...CORS });
3625
+ return res.end(JSON.stringify({
3626
+ error: "write_token_required",
3627
+ hint: "Unlock clauth writes, unset CLAUTH_SUPERVISOR_REQUIRE_WRITE_TOKEN for localhost supervisor operations, or set CLAUTH_SUPERVISOR_TEST_NO_TOKEN=1 for temporary localhost-only supervisor tests.",
3628
+ }));
3629
+ }
3773
3630
 
3774
3631
  // ── MCP SSE session tracking ──────────────────────────────
3775
3632
  const sseSessions = new Map(); // sessionId → { res, initialized }
@@ -4310,7 +4167,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4310
4167
 
4311
4168
  const server = http.createServer(async (req, res) => {
4312
4169
  const remote = req.socket.remoteAddress;
4313
- const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
4170
+ const isLocal = isLoopbackAddress(remote);
4314
4171
 
4315
4172
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
4316
4173
  const reqPath = url.pathname;
@@ -4350,6 +4207,205 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4350
4207
  return res.end(JSON.stringify(result));
4351
4208
  }
4352
4209
 
4210
+ if (method === "GET" && reqPath === "/health") {
4211
+ return ok(res, { ...supervisorHealth(), listening_port: port, process_id: process.pid, vault_locked: !password, clauth_version: VERSION });
4212
+ }
4213
+
4214
+ // Bearer-gated remote operations surface. It remains loopback-only at this
4215
+ // layer; ingress/tunnel policy decides whether it is reachable remotely.
4216
+ if (method === "GET" && reqPath === "/v1/ops/catalog") {
4217
+ if (!await requireOpsBearer(req, res)) return;
4218
+ return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
4219
+ }
4220
+
4221
+ if (method === "GET" && reqPath === "/v1/ops/processes") {
4222
+ if (!await requireOpsBearer(req, res)) return;
4223
+ const job = submitOpsJob("list", {}, req._opsRole);
4224
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4225
+ return res.end(JSON.stringify(job));
4226
+ }
4227
+
4228
+ const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
4229
+ if (method === "GET" && opsProcessMatch) {
4230
+ if (!await requireOpsBearer(req, res)) return;
4231
+ const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
4232
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4233
+ return res.end(JSON.stringify(job));
4234
+ }
4235
+
4236
+ if (method === "POST" && reqPath === "/v1/ops/operations") {
4237
+ if (!await requireOpsBearer(req, res)) return;
4238
+ let body;
4239
+ try { body = await readBody(req); } catch {
4240
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4241
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4242
+ }
4243
+ const operation = String(body?.operation || "");
4244
+ if (!PM2_OPERATION_CATALOG[operation]) {
4245
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4246
+ return res.end(JSON.stringify({ error: "unknown_operation" }));
4247
+ }
4248
+ const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
4249
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4250
+ return res.end(JSON.stringify(job));
4251
+ }
4252
+
4253
+ if (method === "POST" && reqPath === "/v1/ops/promotions") {
4254
+ if (!await requireOpsBearer(req, res)) return;
4255
+ let body;
4256
+ try { body = await readBody(req); } catch {
4257
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4258
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4259
+ }
4260
+ const applicationUuid = String(body?.application_uuid || "").trim();
4261
+ if (!applicationUuid) {
4262
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4263
+ return res.end(JSON.stringify({ error: "application_uuid_required" }));
4264
+ }
4265
+ const job = submitPromotionJob(applicationUuid);
4266
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4267
+ return res.end(JSON.stringify(job));
4268
+ }
4269
+
4270
+ if (method === "POST" && reqPath === "/v1/ops/deployments") {
4271
+ if (!await requireOpsBearer(req, res)) return;
4272
+ let body;
4273
+ try { body = await readBody(req); } catch {
4274
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4275
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4276
+ }
4277
+ const application = String(body?.application || "").trim();
4278
+ if (!application) {
4279
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4280
+ return res.end(JSON.stringify({ error: "application_required" }));
4281
+ }
4282
+ const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
4283
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4284
+ return res.end(JSON.stringify(job));
4285
+ }
4286
+
4287
+ const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
4288
+ if (method === "GET" && opsJobMatch) {
4289
+ if (!await requireOpsBearer(req, res)) return;
4290
+ const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
4291
+ res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
4292
+ return res.end(JSON.stringify(job || { error: "job_not_found" }));
4293
+ }
4294
+
4295
+ const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
4296
+ if (method === "GET" && opsJobEventsMatch) {
4297
+ if (!await requireOpsBearer(req, res)) return;
4298
+ const jobId = decodeURIComponent(opsJobEventsMatch[1]);
4299
+ if (!opsJobs.get(jobId)) {
4300
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
4301
+ return res.end(JSON.stringify({ error: "job_not_found" }));
4302
+ }
4303
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
4304
+ const unsubscribe = opsJobs.subscribe(jobId, (job) => {
4305
+ if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
4306
+ });
4307
+ req.on("close", unsubscribe);
4308
+ return;
4309
+ }
4310
+
4311
+ if (method === "GET" && reqPath === "/v1/plugins") {
4312
+ return ok(res, { plugins: listPlugins() });
4313
+ }
4314
+
4315
+ if (method === "POST" && reqPath === "/v1/plugins/rescan") {
4316
+ if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
4317
+ return ok(res, discoverPlugins());
4318
+ }
4319
+
4320
+ const pluginEnableMatch = reqPath.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable|test|promote)$/);
4321
+ if (method === "POST" && pluginEnableMatch) {
4322
+ if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
4323
+ const pluginId = decodeURIComponent(pluginEnableMatch[1]);
4324
+ const op = pluginEnableMatch[2];
4325
+ const result = op === "enable"
4326
+ ? setPluginEnabled(pluginId, true)
4327
+ : op === "disable"
4328
+ ? setPluginEnabled(pluginId, false)
4329
+ : runPluginAction(pluginId, op);
4330
+ res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...CORS });
4331
+ return res.end(JSON.stringify(result));
4332
+ }
4333
+
4334
+ if (method === "GET" && reqPath === "/v1/surfaces") {
4335
+ return ok(res, { surfaces: listSurfaces() });
4336
+ }
4337
+
4338
+ const surfaceActionMatch = reqPath.match(/^\/v1\/surfaces\/([^/]+)\/actions$/);
4339
+ if (method === "POST" && surfaceActionMatch) {
4340
+ if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
4341
+ let body;
4342
+ try { body = await readBody(req); } catch {
4343
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4344
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
4345
+ }
4346
+ const result = runSurfaceAction(decodeURIComponent(surfaceActionMatch[1]), body?.action || "reconcile");
4347
+ res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...CORS });
4348
+ return res.end(JSON.stringify(result));
4349
+ }
4350
+
4351
+ if (method === "GET" && reqPath === "/v1/routes") {
4352
+ return ok(res, { routes: listRoutes() });
4353
+ }
4354
+
4355
+ if (method === "GET" && reqPath === "/v1/tunnels") {
4356
+ return ok(res, { tunnels: listTunnels() });
4357
+ }
4358
+
4359
+ if (method === "GET" && reqPath === "/v1/logs") {
4360
+ const limit = Number(url.searchParams.get("limit") || 100);
4361
+ const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 500)) : 100;
4362
+ const state = loadSupervisorState();
4363
+ return ok(res, {
4364
+ schema: "clauth.supervisor.logs.v1",
4365
+ log_path: path.join(getSupervisorDir(), "events.jsonl"),
4366
+ events: readSupervisorEvents(boundedLimit).map(supervisorLogDto),
4367
+ operations: (state.operations || []).slice(0, boundedLimit).map(supervisorOperationDto),
4368
+ });
4369
+ }
4370
+
4371
+ const tunnelRoutesMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes$/);
4372
+ if (method === "POST" && tunnelRoutesMatch) {
4373
+ if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
4374
+ let body;
4375
+ try { body = await readBody(req); } catch {
4376
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4377
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
4378
+ }
4379
+ return ok(res, addTunnelRoute(decodeURIComponent(tunnelRoutesMatch[1]), body, "localhost"));
4380
+ }
4381
+
4382
+ const tunnelRouteDeleteMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes\/([^/]+)$/);
4383
+ if (method === "DELETE" && tunnelRouteDeleteMatch) {
4384
+ if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
4385
+ return ok(res, removeTunnelRoute(decodeURIComponent(tunnelRouteDeleteMatch[1]), decodeURIComponent(tunnelRouteDeleteMatch[2]), "localhost"));
4386
+ }
4387
+
4388
+ if (method === "GET" && reqPath.startsWith("/v1/operations/")) {
4389
+ const id = decodeURIComponent(reqPath.split("/").pop());
4390
+ const operation = (supervisorHealth(), readSupervisorEvents(500)).find((event) => event.operationId === id);
4391
+ res.writeHead(operation ? 200 : 404, { "Content-Type": "application/json", ...CORS });
4392
+ return res.end(JSON.stringify(operation ? supervisorLogDto(operation) : { error: "operation_not_found" }));
4393
+ }
4394
+
4395
+ if (method === "GET" && reqPath === "/v1/events") {
4396
+ res.writeHead(200, {
4397
+ "Content-Type": "text/event-stream",
4398
+ "Cache-Control": "no-cache",
4399
+ "Connection": "keep-alive",
4400
+ ...CORS,
4401
+ });
4402
+ for (const event of readSupervisorEvents(Number(url.searchParams.get("limit") || 100))) {
4403
+ res.write(`event: supervisor\ndata: ${JSON.stringify(supervisorLogDto(event))}\n\n`);
4404
+ }
4405
+ res.end();
4406
+ return;
4407
+ }
4408
+
4353
4409
  // ── Hosts that bypass OAuth (fresh domains for claude.ai compatibility) ──
4354
4410
  const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
4355
4411
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
@@ -4419,6 +4475,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4419
4475
  saveClients(oauthClients);
4420
4476
  const logMsg = `[${new Date().toISOString()}] OAuth: registered public client ${clientId} (${client.client_name})\n`;
4421
4477
  try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
4478
+ operation("oauth.client_register", { client_id: clientId, client_name: client.client_name }, null, { ok: true });
4422
4479
  res.writeHead(201, { "Content-Type": "application/json", "Cache-Control": "no-store", ...CORS });
4423
4480
  return res.end(JSON.stringify(client));
4424
4481
  }
@@ -4542,6 +4599,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4542
4599
 
4543
4600
  const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
4544
4601
  try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
4602
+ operation("oauth.token_issue", { client_id: stored.client_id }, null, { ok: true });
4545
4603
  res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...CORS });
4546
4604
  return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "mcp:tools", expires_in: 86400 }));
4547
4605
  }
@@ -4552,7 +4610,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4552
4610
  function toolsForPath(p) {
4553
4611
  const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
4554
4612
  if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
4555
- if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "tintin_dispatch" || t.name === "call_agent" || t.name.startsWith("terminal_") || t.name.startsWith("channel_") || t.name.startsWith("handoff_"));
4613
+ if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "call_agent" || t.name.startsWith("terminal_") || t.name.startsWith("channel_") || t.name.startsWith("handoff_"));
4556
4614
  if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
4557
4615
  if (p === "/chitchat") return tools.filter(t => t.name.startsWith("chitchat_"));
4558
4616
  if (p === "/codevelop") return tools.filter(t => t.name.startsWith("codevelop_"));
@@ -4858,8 +4916,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4858
4916
  return res.end(JSON.stringify({ error: "name and path are required" }));
4859
4917
  }
4860
4918
  const result = webdavService.addMount(name.trim(), mountPath.trim());
4861
- if (result.error) { res.writeHead(409, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result)); }
4919
+ if (result.error) {
4920
+ operation("webdav.mount_add", { name: name.trim() }, null, { ok: false, error: result.error });
4921
+ res.writeHead(409, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result));
4922
+ }
4862
4923
  webdavService.shutdown();
4924
+ operation("webdav.mount_add", { name: name.trim(), path: mountPath.trim() }, null, { ok: true });
4863
4925
  return ok(res, result);
4864
4926
  }
4865
4927
 
@@ -4867,8 +4929,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4867
4929
  if (writeGuard(req, res)) return;
4868
4930
  const mountName = decodeURIComponent(reqPath.slice("/webdav/mounts/".length));
4869
4931
  const result = webdavService.removeMount(mountName);
4870
- if (result.error) { res.writeHead(404, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result)); }
4932
+ if (result.error) {
4933
+ operation("webdav.mount_remove", { name: mountName }, null, { ok: false, error: result.error });
4934
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result));
4935
+ }
4871
4936
  webdavService.shutdown();
4937
+ operation("webdav.mount_remove", { name: mountName }, null, { ok: true });
4872
4938
  return ok(res, result);
4873
4939
  }
4874
4940
 
@@ -4882,6 +4948,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4882
4948
  return res.end(JSON.stringify({ error: "Invalid JSON" }));
4883
4949
  }
4884
4950
  const result = startCodevelopSession(body || {});
4951
+ operation("codevelop.start", { session_id: result?.session_id }, null, { ok: !result?.error, error: result?.error });
4885
4952
  return ok(res, result);
4886
4953
  }
4887
4954
 
@@ -4893,12 +4960,19 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4893
4960
  }
4894
4961
  const result = joinCodevelopSession(body || {});
4895
4962
  if (result.error) {
4963
+ operation("codevelop.join", { session_id: body?.session_id }, null, { ok: false, error: result.error });
4896
4964
  res.writeHead(result.error === "not_found" ? 404 : 400, { "Content-Type": "application/json", ...CORS });
4897
4965
  return res.end(JSON.stringify(result));
4898
4966
  }
4967
+ operation("codevelop.join", { session_id: body?.session_id }, null, { ok: true });
4899
4968
  return ok(res, result);
4900
4969
  }
4901
4970
 
4971
+ // codevelop/send is a genuine mutation (adds a message) but excluded from
4972
+ // per-route logging by design — a live peer-to-peer relay can send at a
4973
+ // frequency that would bloat events.jsonl (same risk noted in the audit-
4974
+ // logging retrofit plan for codevelop/poll). Session lifecycle
4975
+ // (start/join/stop) is logged; message throughput is not.
4902
4976
  if (method === "POST" && reqPath === "/codevelop/send") {
4903
4977
  let body;
4904
4978
  try { body = await readBody(req); } catch {
@@ -4982,9 +5056,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4982
5056
  }
4983
5057
  const result = stopCodevelopSession(body?.session_id);
4984
5058
  if (result.error) {
5059
+ operation("codevelop.stop", { session_id: body?.session_id }, null, { ok: false, error: result.error });
4985
5060
  res.writeHead(404, { "Content-Type": "application/json", ...CORS });
4986
5061
  return res.end(JSON.stringify(result));
4987
5062
  }
5063
+ operation("codevelop.stop", { session_id: body?.session_id }, null, { ok: true });
4988
5064
  return ok(res, result);
4989
5065
  }
4990
5066
 
@@ -5172,6 +5248,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5172
5248
  saveTokens(oauthTokens);
5173
5249
  const logMsg = `[${new Date().toISOString()}] OAuth: rolled credentials — all clients and tokens invalidated\n`;
5174
5250
  try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
5251
+ operation("oauth.roll_creds", {}, null, { ok: true });
5175
5252
  return ok(res, { clients_cleared: true, tokens_invalidated: true });
5176
5253
  }
5177
5254
 
@@ -5185,10 +5262,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5185
5262
  }
5186
5263
  if (body.action === "stop") {
5187
5264
  stopTunnel();
5265
+ operation("tunnel.stop", {}, null, { ok: true });
5188
5266
  return ok(res, { status: tunnelStatus, running: false });
5189
5267
  }
5190
5268
  // start
5191
5269
  await startTunnel();
5270
+ operation("tunnel.start", {}, null, { ok: !!tunnelProc, url: tunnelUrl, error: tunnelError });
5192
5271
  return ok(res, { status: tunnelStatus, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
5193
5272
  }
5194
5273
 
@@ -5198,531 +5277,88 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5198
5277
  if (tunnelProc) return ok(res, { status: tunnelStatus, message: "already running" });
5199
5278
  tunnelStatus = "starting";
5200
5279
  startTunnel().catch(() => {});
5201
- return ok(res, { status: "starting" });
5202
- }
5203
-
5204
- // POST /tunnel/stop — explicit stop endpoint
5205
- if (method === "POST" && reqPath === "/tunnel/stop") {
5206
- if (lockedGuard(res)) return;
5207
- stopTunnel();
5208
- return ok(res, { status: tunnelStatus });
5209
- }
5210
-
5211
- // POST /launch-ccandme — open a new CCandMe WezTerm window without killing existing sessions
5212
- if (method === "POST" && reqPath === "/launch-ccandme") {
5213
- if (lockedGuard(res)) return;
5214
- try {
5215
- const { spawn } = await import("child_process");
5216
- const { existsSync, readFileSync, writeFileSync } = await import("fs");
5217
-
5218
- // Locate WezTerm
5219
- const wezCandidates = [
5220
- "C:/Program Files/WezTerm/wezterm.exe",
5221
- "C:/Program Files (x86)/WezTerm/wezterm.exe",
5222
- process.env.WEZTERM_EXE,
5223
- ].filter(Boolean);
5224
- const wezExe = wezCandidates.find(existsSync) || "wezterm";
5225
-
5226
- // Render the lua config from the CCandMe template (skip the kill-existing step)
5227
- const CCANDME_DIR = "C:/Dev/CCandMe";
5228
- const WORK_DIR = "C:/Dev/regen-root";
5229
- const templatePath = path.join(CCANDME_DIR, "templates", "wezterm.lua");
5230
- const luaOutPath = path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
5231
-
5232
- if (existsSync(templatePath)) {
5233
- const lua = readFileSync(templatePath, "utf8")
5234
- .replaceAll("__SUPERVISOR_DIR__", CCANDME_DIR.replace(/\//g, "\\\\"))
5235
- .replaceAll("__WORK_DIR__", WORK_DIR.replace(/\//g, "\\\\"))
5236
- .replaceAll("__WORKSPACE__", "ccandme")
5237
- .replaceAll("__CLAUDE_CMD__", "claude")
5238
- .replaceAll("__CODEX_CMD__", "codex")
5239
- .replaceAll("__PACKAGE_ROOT__", CCANDME_DIR.replace(/\//g, "\\\\"));
5240
- writeFileSync(luaOutPath, lua, "utf8");
5241
- }
5242
-
5243
- // Launch WezTerm with the CCandMe config — no kill of existing sessions
5244
- const luaArg = existsSync(luaOutPath) ? luaOutPath : path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
5245
- const child = spawn(wezExe, ["--config-file", luaArg, "start"], {
5246
- detached: true,
5247
- stdio: "ignore",
5248
- });
5249
- child.unref();
5250
- return ok(res, { ok: true, message: "CCandMe launched" });
5251
- } catch (err) {
5252
- res.writeHead(500, { "Content-Type": "application/json", ...CORS });
5253
- return res.end(JSON.stringify({ error: err.message }));
5254
- }
5255
- }
5256
-
5257
- // POST /restart — spawn fresh process then exit (keeps boot.key, vault stays unlocked)
5258
- if (method === "POST" && reqPath === "/restart") {
5259
- ok(res, { ok: true, message: "restarting" });
5260
- const { spawn } = await import("child_process");
5261
- const cliEntry = path.resolve(__dirname, "../index.js");
5262
- const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
5263
- if (password) childArgs.push("--pw", password);
5264
- if (whitelist) childArgs.push("--services", whitelist.join(","));
5265
- if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
5266
- const out = fs.openSync(LOG_FILE, "a");
5267
- const child = spawn(process.execPath, childArgs, {
5268
- detached: true,
5269
- stdio: ["ignore", out, out],
5270
- env: { ...process.env, __CLAUTH_DAEMON: "1" },
5271
- windowsHide: true,
5272
- });
5273
- child.unref();
5274
- stopTunnel();
5275
- removePid();
5276
- setTimeout(() => process.exit(0), 300);
5277
- return;
5278
- }
5279
-
5280
- // GET /tintin/manifest — app manifest for the TinTin sidebar loader.
5281
- // GET /monkey/manifest — backward-compat alias.
5282
- if (method === "GET" && (reqPath === "/tintin/manifest" || reqPath === "/monkey/manifest")) {
5283
- return ok(res, {
5284
- schema_version: 1,
5285
- app: {
5286
- slug: "clauth-dashboard",
5287
- name: "clauth Dashboard",
5288
- },
5289
- load: {
5290
- mode: "external-sidebar",
5291
- requires_compile_in: false,
5292
- recommended_loader: "chrome-extension-or-tampermonkey",
5293
- userscript_url: "/tintin/sidebar.user.js",
5294
- },
5295
- local_clauth: {
5296
- base_url: `http://127.0.0.1:${port}`,
5297
- capabilities_endpoint: "/tintin/capabilities",
5298
- session_endpoint: "/tintin/sessions",
5299
- transport: "tintin",
5300
- },
5301
- repo: {
5302
- root_hint: "C:\\Dev\\regen-root",
5303
- cwd_hint: "C:\\Dev\\regen-root",
5304
- },
5305
- capabilities: ["general_chat", "skill_request", "handoff", "dashboard_test"],
5306
- agent_context: {
5307
- app: { slug: "clauth-dashboard" },
5308
- runtime: { requested_by: "clauth-dashboard-monkey" },
5309
- },
5310
- });
5311
- }
5312
-
5313
- // GET /tintin/sidebar.user.js — shared app-side TinTin sidebar loader.
5314
- // GET /monkey/sidebar.user.js — backward-compat alias.
5315
- if (method === "GET" && (reqPath === "/tintin/sidebar.user.js" || reqPath === "/monkey/sidebar.user.js")) {
5316
- const candidates = [
5317
- process.env.CLAUTH_MONKEY_SIDEBAR_SCRIPT,
5318
- "C:\\Dev\\regen-root\\scripts\\monkey-sidebar.user.js",
5319
- path.resolve(process.cwd(), "..", "regen-root", "scripts", "monkey-sidebar.user.js"),
5320
- ].filter(Boolean);
5321
- const scriptPath = candidates.find((candidate) => {
5322
- try { return fs.existsSync(candidate); } catch { return false; }
5323
- });
5324
- if (!scriptPath) {
5325
- res.writeHead(404, { "Content-Type": "application/json", ...CORS });
5326
- return res.end(JSON.stringify({ error: "monkey_sidebar_script_not_found" }));
5327
- }
5328
- res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", ...CORS });
5329
- return res.end(fs.readFileSync(scriptPath, "utf8"));
5330
- }
5331
-
5332
- // GET /tintin/capabilities — browser-safe local TinTin sidebar capability probe.
5333
- // GET /monkey/capabilities — backward-compat alias.
5334
- if (method === "GET" && (reqPath === "/tintin/capabilities" || reqPath === "/monkey/capabilities")) {
5335
- const access = checkTinTinBrowserAccess(req);
5336
- if (!access.allowed) {
5337
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5338
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5339
- }
5340
- return ok(res, {
5341
- ok: true,
5342
- status: password ? "available" : "locked",
5343
- locked: !password,
5344
- trust_mode: access.config.trust_mode || "open-local",
5345
- app: access.app?.slug || null,
5346
- config: publicTinTinConfig(access.config),
5347
- transports: ["dispatch", "sse", "polling", "result"],
5348
- endpoints: {
5349
- sessions: "/tintin/sessions",
5350
- events: "/tintin/sessions/:session_id/events",
5351
- messages: "/tintin/sessions/:session_id/messages",
5352
- result: "/tintin/messages/:message_id/result",
5353
- },
5354
- });
5355
- }
5356
-
5357
- // GET /tintin/config — local operator view of the app allowlist/config.
5358
- // GET /monkey/config — backward-compat alias.
5359
- if (method === "GET" && (reqPath === "/tintin/config" || reqPath === "/monkey/config")) {
5360
- const access = checkTinTinBrowserAccess(req);
5361
- if (!access.allowed) {
5362
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5363
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5364
- }
5365
- return ok(res, { ok: true, ...publicTinTinConfig(access.config) });
5366
- }
5367
-
5368
- if (method === "GET" && reqPath === "/tintin/settings/ui") {
5369
- res.writeHead(200, { "Content-Type": "text/html", ...CORS });
5370
- return res.end(tintinSettingsHtml());
5371
- }
5372
-
5373
- if (method === "GET" && (reqPath === "/tintin/settings" || reqPath === "/monkey/settings")) {
5374
- const config = loadTinTinConfig();
5375
- return ok(res, { ok: true, ...publicTinTinConfig(config) });
5376
- }
5377
-
5378
- if ((method === "PUT" || method === "POST") && (reqPath === "/tintin/settings" || reqPath === "/monkey/settings")) {
5379
- let body;
5380
- try { body = await readBody(req); } catch {
5381
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5382
- return res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
5383
- }
5384
- try {
5385
- const saved = saveTinTinConfig(body || {});
5386
- return ok(res, { ok: true, ...publicTinTinConfig(saved) });
5387
- } catch (err) {
5388
- res.writeHead(500, { "Content-Type": "application/json", ...CORS });
5389
- return res.end(JSON.stringify({ ok: false, error: "settings_save_failed", message: err.message }));
5390
- }
5391
- }
5392
-
5393
- if (method === "POST" && (reqPath === "/tintin/agent-sessions" || reqPath === "/monkey/agent-sessions")) {
5394
- let body;
5395
- try { body = await readBody(req); } catch {
5396
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5397
- return res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
5398
- }
5399
- try {
5400
- const result = createTinTinAgentSession(body || {});
5401
- if (!result.ok) {
5402
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5403
- return res.end(JSON.stringify(result));
5404
- }
5405
- return ok(res, result);
5406
- } catch (err) {
5407
- res.writeHead(500, { "Content-Type": "application/json", ...CORS });
5408
- return res.end(JSON.stringify({ ok: false, error: "agent_session_setup_failed", message: err.message }));
5409
- }
5410
- }
5411
-
5412
- // POST /tintin/sessions — create or attach to a local CLI-backed TinTin session.
5413
- // POST /monkey/sessions — backward-compat alias.
5414
- if (method === "POST" && (reqPath === "/tintin/sessions" || reqPath === "/monkey/sessions")) {
5415
- const access = checkTinTinBrowserAccess(req);
5416
- if (!access.allowed) {
5417
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5418
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5419
- }
5420
- let body;
5421
- try { body = await readBody(req); } catch {
5422
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5423
- return res.end(JSON.stringify({ error: "Invalid JSON body" }));
5424
- }
5425
- const session = createTinTinSession(body || {});
5426
- pushTinTinEvent(session.id, {
5427
- source: "clauth",
5428
- type: "status",
5429
- content: "session ready",
5430
- payload: { status: "ready" },
5431
- });
5432
- return ok(res, {
5433
- ok: true,
5434
- session_id: session.id,
5435
- status: session.status,
5436
- agent_context: session.agent_context,
5437
- last_seq: session.seq,
5438
- });
5439
- }
5440
-
5441
- // GET /tintin/sessions — list in-memory local TinTin sessions.
5442
- // GET /monkey/sessions — backward-compat alias.
5443
- if (method === "GET" && (reqPath === "/tintin/sessions" || reqPath === "/monkey/sessions")) {
5444
- const access = checkTinTinBrowserAccess(req);
5445
- if (!access.allowed) {
5446
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5447
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5448
- }
5449
- return ok(res, {
5450
- ok: true,
5451
- sessions: [...tintinSessions.values()].map((session) => ({
5452
- session_id: session.id,
5453
- status: session.status,
5454
- created_at: session.created_at,
5455
- updated_at: session.updated_at,
5456
- last_seq: session.seq,
5457
- agent_context: session.agent_context,
5458
- })),
5459
- });
5460
- }
5461
-
5462
- const tintinSessionMatch = reqPath.match(/^\/(?:tintin|monkey)\/sessions\/([^/]+)$/);
5463
- if (method === "GET" && tintinSessionMatch) {
5464
- const access = checkTinTinBrowserAccess(req);
5465
- if (!access.allowed) {
5466
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5467
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5468
- }
5469
- const sessionId = decodeURIComponent(tintinSessionMatch[1]);
5470
- const session = getTinTinSession(sessionId);
5471
- if (!session) {
5472
- res.writeHead(404, { "Content-Type": "application/json", ...CORS });
5473
- return res.end(JSON.stringify({ error: "not_found", session_id: sessionId }));
5474
- }
5475
- return ok(res, {
5476
- ok: true,
5477
- session_id: session.id,
5478
- status: session.status,
5479
- created_at: session.created_at,
5480
- updated_at: session.updated_at,
5481
- last_seq: session.seq,
5482
- agent_context: session.agent_context,
5483
- });
5484
- }
5485
-
5486
- const tintinMessagesMatch = reqPath.match(/^\/(?:tintin|monkey)\/sessions\/([^/]+)\/messages$/);
5487
- if (method === "GET" && tintinMessagesMatch) {
5488
- const access = checkTinTinBrowserAccess(req);
5489
- if (!access.allowed) {
5490
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5491
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5492
- }
5493
- const sessionId = decodeURIComponent(tintinMessagesMatch[1]);
5494
- const afterSeq = Number(url.searchParams.get("after_seq") || url.searchParams.get("after") || 0);
5495
- const events = listTinTinEvents(sessionId, Number.isFinite(afterSeq) ? afterSeq : 0);
5496
- if (!events) {
5497
- res.writeHead(404, { "Content-Type": "application/json", ...CORS });
5498
- return res.end(JSON.stringify({ error: "not_found", session_id: sessionId }));
5499
- }
5500
- return ok(res, { ok: true, session_id: sessionId, events });
5501
- }
5502
-
5503
- if (method === "POST" && tintinMessagesMatch) {
5504
- const access = checkTinTinBrowserAccess(req);
5505
- if (!access.allowed) {
5506
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5507
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5508
- }
5509
- if (!password) {
5510
- res.writeHead(401, { "Content-Type": "application/json", ...CORS });
5511
- return res.end(JSON.stringify({ error: "Vault is locked", locked: true }));
5512
- }
5513
- const sessionId = decodeURIComponent(tintinMessagesMatch[1]);
5514
- let session = getTinTinSession(sessionId);
5515
- let body;
5516
- try { body = await readBody(req); } catch {
5517
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5518
- return res.end(JSON.stringify({ error: "Invalid JSON body" }));
5519
- }
5520
- if (!session) session = createTinTinSession({ ...(body || {}), session_id: sessionId });
5521
- const content = String(body?.content || body?.prompt || "").trim();
5522
- if (!content) {
5523
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5524
- return res.end(JSON.stringify({ error: "content required" }));
5525
- }
5526
-
5527
- const messageId = body.message_id || makeTinTinId("msg");
5528
- const jobId = body.job_id || messageId;
5529
- const agentContext = normalizeAgentContext(body.agent_context || {
5530
- ...session.agent_context,
5531
- task: {
5532
- ...(session.agent_context?.task || {}),
5533
- intent: body.skill ? "skill_request" : "general_chat",
5534
- thread_id: sessionId,
5535
- },
5536
- });
5537
- pushTinTinEvent(sessionId, {
5538
- source: "sidebar",
5539
- type: "message",
5540
- role: "user",
5541
- message_id: messageId,
5542
- job_id: jobId,
5543
- content,
5544
- payload: { skill: body.skill || null },
5545
- });
5546
-
5547
- const prompt = buildTinTinPrompt({
5548
- prompt: body.skill ? `Skill: ${body.skill}\n\n${content}` : content,
5549
- job_id: jobId,
5550
- agent_context: agentContext,
5551
- });
5552
- const dispatchCwd = resolveDispatchCwd(body.cwd || session.cwd, agentContext);
5553
- const result = spawnClaudeTask(prompt, jobId, dispatchCwd, agentContext);
5554
- if (result.error) {
5555
- pushTinTinEvent(sessionId, {
5556
- source: "clauth",
5557
- type: "error",
5558
- role: "assistant",
5559
- message_id: messageId,
5560
- job_id: jobId,
5561
- content: result.message || result.error,
5562
- payload: { code: result.error, retryable: result.error === "concurrency_limit" },
5563
- });
5564
- res.writeHead(503, { "Content-Type": "application/json", ...CORS });
5565
- return res.end(JSON.stringify(result));
5566
- }
5567
-
5568
- tintinMessageIndex.set(messageId, { session_id: sessionId, job_id: jobId });
5569
- pushTinTinEvent(sessionId, {
5570
- source: "clauth",
5571
- type: "status",
5572
- role: "assistant",
5573
- message_id: messageId,
5574
- job_id: jobId,
5575
- content: "spawned",
5576
- payload: result,
5577
- });
5578
- startTinTinMessageMonitor(sessionId, messageId, jobId);
5579
- return ok(res, {
5580
- ok: true,
5581
- session_id: sessionId,
5582
- message_id: messageId,
5583
- job_id: jobId,
5584
- status: "spawned",
5585
- dispatch: result,
5586
- });
5587
- }
5588
-
5589
- const tintinEventsMatch = reqPath.match(/^\/(?:tintin|monkey)\/sessions\/([^/]+)\/events$/);
5590
- if (method === "GET" && tintinEventsMatch) {
5591
- const access = checkTinTinBrowserAccess(req);
5592
- if (!access.allowed) {
5593
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5594
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5595
- }
5596
- const sessionId = decodeURIComponent(tintinEventsMatch[1]);
5597
- const afterSeq = Number(url.searchParams.get("after_seq") || url.searchParams.get("after") || 0);
5598
- const session = getTinTinSession(sessionId);
5599
- if (!session) {
5600
- res.writeHead(404, { "Content-Type": "application/json", ...CORS });
5601
- return res.end(JSON.stringify({ error: "not_found", session_id: sessionId }));
5602
- }
5603
-
5604
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "Connection": "keep-alive", ...CORS });
5605
- const send = (event) => {
5606
- const eventType = event.type || "message";
5607
- res.write(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`);
5608
- };
5609
- res.write(`event: ready\ndata: ${JSON.stringify({ ok: true, session_id: sessionId, last_seq: session.seq })}\n\n`);
5610
- for (const event of listTinTinEvents(sessionId, Number.isFinite(afterSeq) ? afterSeq : 0) || []) send(event);
5611
- const unsubscribe = subscribeTinTinEvents(sessionId, send);
5612
- const heartbeat = setInterval(() => {
5613
- try { res.write(`event: heartbeat\ndata: ${JSON.stringify({ session_id: sessionId, at: new Date().toISOString() })}\n\n`); }
5614
- catch { clearInterval(heartbeat); unsubscribe(); }
5615
- }, 15000);
5616
- req.on("close", () => {
5617
- clearInterval(heartbeat);
5618
- unsubscribe();
5619
- });
5620
- return;
5621
- }
5622
-
5623
- const tintinResultMatch = reqPath.match(/^\/(?:tintin|monkey)\/messages\/([^/]+)\/result$/);
5624
- if (method === "GET" && tintinResultMatch) {
5625
- const access = checkTinTinBrowserAccess(req);
5626
- if (!access.allowed) {
5627
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5628
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5629
- }
5630
- const messageId = decodeURIComponent(tintinResultMatch[1]);
5631
- const ref = tintinMessageIndex.get(messageId);
5632
- if (!ref) {
5633
- res.writeHead(404, { "Content-Type": "application/json", ...CORS });
5634
- return res.end(JSON.stringify({ error: "not_found", message_id: messageId }));
5635
- }
5636
- const job = tintinJobs.get(ref.job_id);
5637
- const session = getTinTinSession(ref.session_id);
5638
- return ok(res, {
5639
- ok: true,
5640
- message_id: messageId,
5641
- session_id: ref.session_id,
5642
- job_id: ref.job_id,
5643
- status: job?.status || "unknown",
5644
- stdout: job?.stdout || "",
5645
- stderr: job?.stderr || "",
5646
- events: session?.events.filter((event) => event.message_id === messageId) || [],
5647
- });
5648
- }
5649
-
5650
- // POST /tintin/dispatch — browser-safe, config-checked CLI worker spawn.
5651
- // POST /monkey/dispatch, /dispatch, and /monkey-dispatch remain legacy backward-compat aliases.
5652
- if (method === "POST" && (reqPath === "/tintin/dispatch" || reqPath === "/dispatch" || reqPath === "/monkey-dispatch" || reqPath === "/monkey/dispatch")) {
5653
- if (reqPath === "/tintin/dispatch" || reqPath === "/monkey/dispatch") {
5654
- const access = checkTinTinBrowserAccess(req);
5655
- if (!access.allowed) {
5656
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5657
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5658
- }
5659
- } else if (req.headers.origin) {
5660
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5661
- return res.end(JSON.stringify({ ok: false, error: "legacy_dispatch_rejects_browser_origin", use: "/tintin/dispatch" }));
5662
- }
5663
- let body = "";
5664
- req.on("data", d => body += d);
5665
- req.on("end", () => {
5666
- try {
5667
- const { prompt, job_id, cwd, agent_context } = JSON.parse(body || "{}");
5668
- if (!prompt && !job_id) {
5669
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5670
- return res.end(JSON.stringify({ error: "prompt required" }));
5671
- }
5672
- const normalizedContext = normalizeAgentContext(agent_context);
5673
- const wrappedPrompt = buildTinTinPrompt({ prompt, job_id, agent_context: normalizedContext });
5674
- const dispatchCwd = resolveDispatchCwd(cwd, normalizedContext);
5675
- const result = spawnClaudeTask(wrappedPrompt, job_id || "untracked", dispatchCwd, normalizedContext);
5676
- const status = result.error ? 503 : 200;
5677
- res.writeHead(status, { "Content-Type": "application/json", ...CORS });
5678
- res.end(JSON.stringify({ ...result, context: normalizedContext }));
5679
- } catch {
5680
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5681
- res.end(JSON.stringify({ error: "invalid JSON" }));
5682
- }
5683
- });
5684
- return;
5280
+ operation("tunnel.start", {}, null, { ok: true, status: "starting" });
5281
+ return ok(res, { status: "starting" });
5685
5282
  }
5686
5283
 
5687
- // POST /tintin/dispatch/:jobId/killkill a running dispatch job.
5688
- // POST /monkey/dispatch/:jobId/kill — backward-compat alias.
5689
- const dispatchKillMatch = reqPath.match(/^\/(?:tintin|monkey)\/dispatch\/([^/]+)\/kill$/);
5690
- if (method === "POST" && dispatchKillMatch) {
5691
- const jobId = decodeURIComponent(dispatchKillMatch[1]);
5692
- const job = tintinJobs.get(jobId);
5693
- if (!job) {
5694
- res.writeHead(404, { "Content-Type": "application/json", ...CORS });
5695
- return res.end(JSON.stringify({ ok: false, error: "not_found" }));
5696
- }
5697
- if (job.pid && job.status === "running") {
5698
- try { process.kill(job.pid); } catch {}
5699
- job.status = "killed";
5700
- job.completed_at = new Date().toISOString();
5701
- }
5702
- res.writeHead(200, { "Content-Type": "application/json", ...CORS });
5703
- return res.end(JSON.stringify({ ok: true, status: job.status, jobId }));
5284
+ // POST /tunnel/stopexplicit stop endpoint
5285
+ if (method === "POST" && reqPath === "/tunnel/stop") {
5286
+ if (lockedGuard(res)) return;
5287
+ stopTunnel();
5288
+ operation("tunnel.stop", {}, null, { ok: true });
5289
+ return ok(res, { status: tunnelStatus });
5704
5290
  }
5705
5291
 
5706
- const dispatchStatusMatch = reqPath.match(/^\/(?:tintin|monkey)\/dispatch\/([^/]+)$/) || reqPath.match(/^\/dispatch\/([^/]+)$/);
5707
- if (method === "GET" && dispatchStatusMatch) {
5708
- if (reqPath.startsWith("/tintin/dispatch/") || reqPath.startsWith("/monkey/dispatch/")) {
5709
- const access = checkTinTinBrowserAccess(req);
5710
- if (!access.allowed) {
5711
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5712
- return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
5292
+ // POST /launch-ccandme open a new CCandMe WezTerm window without killing existing sessions
5293
+ if (method === "POST" && reqPath === "/launch-ccandme") {
5294
+ if (lockedGuard(res)) return;
5295
+ try {
5296
+ const { spawn } = await import("child_process");
5297
+ const { existsSync, readFileSync, writeFileSync } = await import("fs");
5298
+
5299
+ // Locate WezTerm
5300
+ const wezCandidates = [
5301
+ "C:/Program Files/WezTerm/wezterm.exe",
5302
+ "C:/Program Files (x86)/WezTerm/wezterm.exe",
5303
+ process.env.WEZTERM_EXE,
5304
+ ].filter(Boolean);
5305
+ const wezExe = wezCandidates.find(existsSync) || "wezterm";
5306
+
5307
+ // Render the lua config from the CCandMe template (skip the kill-existing step)
5308
+ const CCANDME_DIR = "C:/Dev/CCandMe";
5309
+ const WORK_DIR = "C:/Dev/regen-root";
5310
+ const templatePath = path.join(CCANDME_DIR, "templates", "wezterm.lua");
5311
+ const luaOutPath = path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
5312
+
5313
+ if (existsSync(templatePath)) {
5314
+ const lua = readFileSync(templatePath, "utf8")
5315
+ .replaceAll("__SUPERVISOR_DIR__", CCANDME_DIR.replace(/\//g, "\\\\"))
5316
+ .replaceAll("__WORK_DIR__", WORK_DIR.replace(/\//g, "\\\\"))
5317
+ .replaceAll("__WORKSPACE__", "ccandme")
5318
+ .replaceAll("__CLAUDE_CMD__", "claude")
5319
+ .replaceAll("__CODEX_CMD__", "codex")
5320
+ .replaceAll("__PACKAGE_ROOT__", CCANDME_DIR.replace(/\//g, "\\\\"));
5321
+ writeFileSync(luaOutPath, lua, "utf8");
5713
5322
  }
5714
- } else if (req.headers.origin) {
5715
- res.writeHead(403, { "Content-Type": "application/json", ...CORS });
5716
- return res.end(JSON.stringify({ ok: false, error: "legacy_dispatch_rejects_browser_origin", use: "/tintin/dispatch/:job_id" }));
5717
- }
5718
- const jobId = decodeURIComponent(dispatchStatusMatch[1]);
5719
- const job = tintinJobs.get(jobId);
5720
- if (!job) {
5721
- res.writeHead(404, { "Content-Type": "application/json", ...CORS });
5722
- return res.end(JSON.stringify({ error: "not_found", job_id: jobId }));
5323
+
5324
+ // Launch WezTerm with the CCandMe config — no kill of existing sessions
5325
+ const luaArg = existsSync(luaOutPath) ? luaOutPath : path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
5326
+ const child = spawn(wezExe, ["--config-file", luaArg, "start"], {
5327
+ detached: true,
5328
+ stdio: "ignore",
5329
+ });
5330
+ child.unref();
5331
+ operation("ccandme.launch", {}, null, { ok: true });
5332
+ return ok(res, { ok: true, message: "CCandMe launched" });
5333
+ } catch (err) {
5334
+ operation("ccandme.launch", {}, null, { ok: false, error: err.message });
5335
+ res.writeHead(500, { "Content-Type": "application/json", ...CORS });
5336
+ return res.end(JSON.stringify({ error: err.message }));
5723
5337
  }
5724
- res.writeHead(200, { "Content-Type": "application/json", ...CORS });
5725
- return res.end(JSON.stringify(job));
5338
+ }
5339
+
5340
+ // POST /restart — spawn fresh process then exit (keeps boot.key, vault stays unlocked)
5341
+ if (method === "POST" && reqPath === "/restart") {
5342
+ operation("daemon.restart_requested", { port }, null, { ok: true });
5343
+ ok(res, { ok: true, message: "restarting" });
5344
+ const { spawn } = await import("child_process");
5345
+ const cliEntry = path.resolve(__dirname, "../index.js");
5346
+ const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
5347
+ if (password) childArgs.push("--pw", password);
5348
+ if (whitelist) childArgs.push("--services", whitelist.join(","));
5349
+ if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
5350
+ const out = fs.openSync(LOG_FILE, "a");
5351
+ const child = spawn(process.execPath, childArgs, {
5352
+ detached: true,
5353
+ stdio: ["ignore", out, out],
5354
+ env: { ...process.env, __CLAUTH_DAEMON: "1" },
5355
+ windowsHide: true,
5356
+ });
5357
+ child.unref();
5358
+ stopTunnel();
5359
+ removePid();
5360
+ setTimeout(() => process.exit(0), 300);
5361
+ return;
5726
5362
  }
5727
5363
 
5728
5364
  // ── call_agent (Gate B) ───────────────────────────────────────────────────
@@ -5863,6 +5499,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5863
5499
  try {
5864
5500
  const { name } = JSON.parse(body || "{}");
5865
5501
  const result = await startChitchatSession(name || "collab");
5502
+ operation("chitchat.start", { name: name || "collab" }, null, { ok: !result?.error, session_id: result?.session_id, error: result?.error });
5866
5503
  res.writeHead(200, { "Content-Type": "application/json", ...CORS });
5867
5504
  res.end(JSON.stringify(result));
5868
5505
  } catch (e) {
@@ -5873,14 +5510,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5873
5510
  return;
5874
5511
  }
5875
5512
 
5876
- // POST /handoff, /tintin/handoff, and /monkey/handoff — visible local handoff from claude.ai or a sidebar.
5877
- if (method === "POST" && (reqPath === "/handoff" || reqPath === "/tintin/handoff" || reqPath === "/monkey/handoff")) {
5513
+ // POST /handoff — visible local handoff from claude.ai or a sidebar.
5514
+ if (method === "POST" && reqPath === "/handoff") {
5878
5515
  let body;
5879
5516
  try { body = await readBody(req); } catch {
5880
5517
  res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5881
5518
  return res.end(JSON.stringify({ error: "invalid JSON" }));
5882
5519
  }
5883
5520
  const result = await startHandoffSession(body || {});
5521
+ operation("handoff.start", {}, null, { ok: !result.error, error: result.error });
5884
5522
  const status = result.error ? 503 : 200;
5885
5523
  res.writeHead(status, { "Content-Type": "application/json", ...CORS });
5886
5524
  return res.end(JSON.stringify(result));
@@ -5898,6 +5536,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5898
5536
  return res.end(JSON.stringify({ error: "session_id and message required" }));
5899
5537
  }
5900
5538
  const result = sendChitchatMessage(session_id, message);
5539
+ operation("chitchat.send", { session_id }, null, { ok: !result.error, error: result.error });
5901
5540
  const status = result.error === 'not_found' ? 404 : result.error ? 400 : 200;
5902
5541
  res.writeHead(status, { "Content-Type": "application/json", ...CORS });
5903
5542
  res.end(JSON.stringify(result));
@@ -5922,6 +5561,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5922
5561
  }
5923
5562
  const tier = knowledge_tier || 'db_only';
5924
5563
  const result = startTerminalSession(name, tier, context_md || null);
5564
+ operation("terminal.start", { name, tier }, null, { ok: !result.error, error: result.error });
5925
5565
  const status = result.error ? 503 : 200;
5926
5566
  res.writeHead(status, { "Content-Type": "application/json", ...CORS });
5927
5567
  res.end(JSON.stringify(result));
@@ -5945,6 +5585,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5945
5585
  return res.end(JSON.stringify({ error: "session_id and message required" }));
5946
5586
  }
5947
5587
  const result = sendTerminalMessage(session_id, message);
5588
+ operation("terminal.send", { session_id }, null, { ok: !result.error, error: result.error });
5948
5589
  const status = result.error === 'session_busy' ? 409 : result.error ? 404 : 200;
5949
5590
  res.writeHead(status, { "Content-Type": "application/json", ...CORS });
5950
5591
  res.end(JSON.stringify(result));
@@ -5998,6 +5639,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5998
5639
  return res.end(JSON.stringify({ error: "session_id required" }));
5999
5640
  }
6000
5641
  const result = stopTerminalSession(session_id);
5642
+ operation("terminal.stop", { session_id }, null, { ok: !result.error, error: result.error });
6001
5643
  const status = result.error ? 404 : 200;
6002
5644
  res.writeHead(status, { "Content-Type": "application/json", ...CORS });
6003
5645
  res.end(JSON.stringify(result));
@@ -6057,6 +5699,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6057
5699
  channelEvents.push(entry);
6058
5700
  if (channelEvents.length > MAX_CHANNEL_EVENTS) channelEvents.shift();
6059
5701
  console.log(`[channel] queued event ${eventId} type=${event}`);
5702
+ operation("channel.event", { event, resource, repository }, null, { ok: true, event_id: eventId });
6060
5703
  return ok(res, { received: true, event_id: eventId });
6061
5704
  } catch {
6062
5705
  res.writeHead(400, { "Content-Type": "application/json", ...CORS });
@@ -6069,6 +5712,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6069
5712
  // GET|POST /shutdown (for daemon stop — programmatic, keeps boot.key)
6070
5713
  // Accept POST as well — older scripts and curl default to POST
6071
5714
  if ((method === "GET" || method === "POST") && reqPath === "/shutdown") {
5715
+ operation("daemon.shutdown", { port }, null, { ok: true });
6072
5716
  stopTunnel();
6073
5717
  ok(res, { ok: true, message: "shutting down" });
6074
5718
  removePid();
@@ -6078,6 +5722,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6078
5722
 
6079
5723
  // POST /shutdown-ui (user-initiated stop — clears boot.key so password is required on restart)
6080
5724
  if (method === "POST" && reqPath === "/shutdown-ui") {
5725
+ operation("daemon.shutdown_ui", { port }, null, { ok: true });
6081
5726
  stopTunnel();
6082
5727
  // Clear boot.key so watchdog can't auto-unlock on restart
6083
5728
  const bootKeyPath = getBootKeyPath();
@@ -6159,10 +5804,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6159
5804
  if (promoted) {
6160
5805
  const okLog = `[${new Date().toISOString()}] Make-live: promoted to live on port ${LIVE_PORT}\n`;
6161
5806
  try { fs.appendFileSync(LOG_FILE, okLog); } catch {}
5807
+ operation("daemon.make_live", { from_port: port, live_port: LIVE_PORT }, null, { ok: true });
6162
5808
  ok(res, { ok: true, message: "promoted to live", live_port: LIVE_PORT });
6163
5809
  } else {
6164
5810
  const failLog = `[${new Date().toISOString()}] Make-live: new daemon failed to start on port ${LIVE_PORT}\n`;
6165
5811
  try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
5812
+ operation("daemon.make_live", { from_port: port, live_port: LIVE_PORT }, null, { ok: false, error: "new daemon failed to start" });
6166
5813
  ok(res, { ok: false, error: "New daemon failed to start on live port — check log" });
6167
5814
  }
6168
5815
 
@@ -6501,6 +6148,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6501
6148
  tunnelStatus = "starting";
6502
6149
  startTunnel().catch(() => {});
6503
6150
  }
6151
+ operation("vault.unlock", {}, null, { ok: true });
6504
6152
  return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
6505
6153
  } catch (authErr) {
6506
6154
  const msg = authErr.message || "";
@@ -6526,6 +6174,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6526
6174
  // No strike, no hard-lock — the verdict was never rendered.
6527
6175
  const failLog = `[${new Date().toISOString()}] [BACKEND ${backendKind}] vault backend unreachable, no auth strike — ${detail}\n`;
6528
6176
  try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
6177
+ operation("vault.unlock", {}, null, { ok: false, backend_error: true, kind: backendKind });
6529
6178
  res.writeHead(503, { "Content-Type": "application/json", ...CORS });
6530
6179
  return res.end(JSON.stringify({
6531
6180
  error: friendly,
@@ -6550,6 +6199,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6550
6199
  authHardLocked = true;
6551
6200
  const lockLog = `[${new Date().toISOString()}] Server rejected with terminal verdict${reasonSuffix} — hard-locking locally to stop strike accrual; recover via the runbook (unlock machine + re-seal boot.key)\n`;
6552
6201
  try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
6202
+ operation("vault.unlock", {}, null, { ok: false, terminal: true, reason: serverReason });
6553
6203
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
6554
6204
  return res.end(JSON.stringify({ error: "Vault rejected credentials — recovery required", reason: serverReason, hard_locked: true, terminal: true }));
6555
6205
  }
@@ -6557,9 +6207,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6557
6207
  authHardLocked = true;
6558
6208
  const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
6559
6209
  try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
6210
+ operation("vault.unlock", {}, null, { ok: false, hard_locked: true, reason: serverReason });
6560
6211
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
6561
6212
  return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", reason: serverReason, hard_locked: true }));
6562
6213
  }
6214
+ operation("vault.unlock", {}, null, { ok: false, reason: serverReason, failures_remaining: authRemaining });
6563
6215
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
6564
6216
  return res.end(JSON.stringify({ error: "Invalid password", reason: serverReason, failures_remaining: authRemaining }));
6565
6217
  }
@@ -6573,6 +6225,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6573
6225
  stopTunnel();
6574
6226
  const logLine = `[${new Date().toISOString()}] Vault locked\n`;
6575
6227
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
6228
+ operation("vault.lock", {}, null, { ok: true });
6576
6229
  return ok(res, { ok: true, locked: true, hard_locked: authHardLocked });
6577
6230
  }
6578
6231
 
@@ -6594,10 +6247,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6594
6247
  try {
6595
6248
  const { token, timestamp } = deriveToken(password, machineHash);
6596
6249
  const result = await api.updateService(password, machineHash, token, timestamp, service, { name: newName, label: newName });
6597
- if (result.error) return strike(res, 502, result.error);
6250
+ if (result.error) {
6251
+ operation("service.rename", { service, new_name: newName }, null, { ok: false, error: result.error });
6252
+ return strike(res, 502, result.error);
6253
+ }
6598
6254
  invalidateServiceStatusCache(machineHash);
6255
+ operation("service.rename", { service, new_name: newName }, null, { ok: true });
6599
6256
  return ok(res, { ok: true, old_name: service, new_name: newName });
6600
6257
  } catch (err) {
6258
+ operation("service.rename", { service, new_name: newName }, null, { ok: false, error: err.message });
6601
6259
  return strike(res, 502, err.message);
6602
6260
  }
6603
6261
  }
@@ -6610,10 +6268,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6610
6268
  try {
6611
6269
  const { token, timestamp } = deriveToken(password, machineHash);
6612
6270
  const result = await api.removeService(password, machineHash, token, timestamp, service, `CONFIRM REMOVE ${service.toUpperCase()}`);
6613
- if (result.error) return strike(res, 502, result.error);
6271
+ if (result.error) {
6272
+ operation("service.delete", { service }, null, { ok: false, error: result.error });
6273
+ return strike(res, 502, result.error);
6274
+ }
6614
6275
  invalidateServiceStatusCache(machineHash);
6276
+ operation("service.delete", { service }, null, { ok: true });
6615
6277
  return ok(res, { ok: true, deleted: service });
6616
6278
  } catch (err) {
6279
+ operation("service.delete", { service }, null, { ok: false, error: err.message });
6617
6280
  return strike(res, 502, err.message);
6618
6281
  }
6619
6282
  }
@@ -6639,10 +6302,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6639
6302
  try {
6640
6303
  const { token, timestamp } = deriveToken(password, machineHash);
6641
6304
  const result = await api.enable(password, machineHash, token, timestamp, service, enabled);
6642
- if (result.error) return strike(res, 502, result.error);
6305
+ if (result.error) {
6306
+ operation("service.toggle", { service, enabled }, null, { ok: false, error: result.error });
6307
+ return strike(res, 502, result.error);
6308
+ }
6643
6309
  invalidateServiceStatusCache(machineHash);
6310
+ operation("service.toggle", { service, enabled }, null, { ok: true });
6644
6311
  return ok(res, { ok: true, service, enabled });
6645
6312
  } catch (err) {
6313
+ operation("service.toggle", { service, enabled }, null, { ok: false, error: err.message });
6646
6314
  return strike(res, 502, err.message);
6647
6315
  }
6648
6316
  }
@@ -6729,6 +6397,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6729
6397
  return res.end(JSON.stringify({ error: "Service name required" }));
6730
6398
  }
6731
6399
  const result = await rotationEngine.rotateService(service);
6400
+ operation("service.rotate", { service }, null, { ok: !result?.error, error: result?.error });
6732
6401
  return ok(res, result);
6733
6402
  }
6734
6403
 
@@ -6742,6 +6411,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6742
6411
  return res.end(JSON.stringify({ error: "Invalid JSON body" }));
6743
6412
  }
6744
6413
  rotationEngine.setExpiry(service, body.expires_at, body.rotation_days);
6414
+ operation("service.set_expiry", { service }, null, { ok: true, expires_at: body.expires_at, rotation_days: body.rotation_days });
6745
6415
  return ok(res, { ok: true, service, expires_at: body.expires_at });
6746
6416
  }
6747
6417
 
@@ -6925,8 +6595,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6925
6595
  });
6926
6596
  }
6927
6597
 
6598
+ operation("tunnel.setup.cf_token", {}, null, { ok: true, accountId, accountName });
6928
6599
  return ok(res, { ok: true, accountId, accountName });
6929
6600
  } catch (err) {
6601
+ operation("tunnel.setup.cf_token", {}, null, { ok: false, error: err.message });
6930
6602
  return strike(res, 502, err.message);
6931
6603
  }
6932
6604
  }
@@ -6997,8 +6669,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6997
6669
  tunnelHostname = hostname;
6998
6670
  tunnelUrl = `https://${hostname}`;
6999
6671
 
6672
+ operation("tunnel.setup.cf_save", { hostname, tunnelId }, null, { ok: true });
7000
6673
  return ok(res, { ok: true, hostname });
7001
6674
  } catch (err) {
6675
+ operation("tunnel.setup.cf_save", { hostname }, null, { ok: false, error: err.message });
7002
6676
  return strike(res, 502, err.message);
7003
6677
  }
7004
6678
  }
@@ -7078,8 +6752,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7078
6752
  tunnelHostname = hostname;
7079
6753
  tunnelUrl = `https://${hostname}`;
7080
6754
 
6755
+ operation("tunnel.setup.cf_create_api", { hostname, tunnelId }, null, { ok: true });
7081
6756
  return ok(res, { ok: true, tunnelId, hostname });
7082
6757
  } catch (err) {
6758
+ operation("tunnel.setup.cf_create_api", { hostname }, null, { ok: false, error: err.message });
7083
6759
  return strike(res, 502, err.message);
7084
6760
  }
7085
6761
  }
@@ -7120,9 +6796,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7120
6796
  const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"], windowsHide: true });
7121
6797
  proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
7122
6798
  proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
7123
- proc.on("close", code => { sendEvt({ done: true, code }); res.end(); });
6799
+ proc.on("close", code => {
6800
+ operation("tunnel.setup.cf_login", {}, null, { ok: code === 0, exit_code: code });
6801
+ sendEvt({ done: true, code }); res.end();
6802
+ });
7124
6803
  req.on("close", () => { try { proc.kill(); } catch {} });
7125
6804
  } catch (err) {
6805
+ operation("tunnel.setup.cf_login", {}, null, { ok: false, error: err.message });
7126
6806
  sendEvt({ done: true, code: 1, error: err.message });
7127
6807
  res.end();
7128
6808
  }
@@ -7194,9 +6874,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7194
6874
  tunnelHostname = hostname;
7195
6875
  tunnelUrl = `https://${hostname}`;
7196
6876
 
6877
+ operation("tunnel.setup.cf_create", { name, hostname, tunnelId }, null, { ok: true });
7197
6878
  sendEvt({ done: true, tunnelId, hostname });
7198
6879
  res.end();
7199
6880
  } catch (err) {
6881
+ operation("tunnel.setup.cf_create", { name, hostname }, null, { ok: false, error: err.message });
7200
6882
  sendEvt({ error: err.message, done: true });
7201
6883
  res.end();
7202
6884
  }
@@ -7228,8 +6910,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7228
6910
  writeSession = makeWriteToken();
7229
6911
  const logLine = `[${new Date().toISOString()}] Password changed\n`;
7230
6912
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
6913
+ operation("vault.change_password", {}, null, { ok: true });
7231
6914
  return ok(res, { ok: true, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
7232
6915
  } catch (err) {
6916
+ operation("vault.change_password", {}, null, { ok: false, error: err.message });
7233
6917
  res.writeHead(502, { "Content-Type": "application/json", ...CORS });
7234
6918
  return res.end(JSON.stringify({ error: err.message }));
7235
6919
  }
@@ -7257,11 +6941,17 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7257
6941
  }
7258
6942
 
7259
6943
  try {
6944
+ // NEVER log `value` — it is the secret being written.
7260
6945
  const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password, machineHash, service, value, logFile: LOG_FILE });
7261
- if (result.error) return strike(res, 502, result.error);
6946
+ if (result.error) {
6947
+ operation("service.set", { service }, null, { ok: false, error: result.error });
6948
+ return strike(res, 502, result.error);
6949
+ }
7262
6950
  invalidateServiceStatusCache(machineHash);
6951
+ operation("service.set", { service }, null, { ok: true, normalized });
7263
6952
  return ok(res, { ok: true, service, recovery_snapshot: snapshot?.ok ? true : false, normalized });
7264
6953
  } catch (err) {
6954
+ operation("service.set", { service }, null, { ok: false, error: err.message });
7265
6955
  return strike(res, 502, err.message);
7266
6956
  }
7267
6957
  }
@@ -7290,11 +6980,17 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7290
6980
  try {
7291
6981
  const randomHex = crypto.randomBytes(32).toString("hex");
7292
6982
  const token = `${prefix}${randomHex}`;
6983
+ // NEVER log `token` — it is the secret being generated/stored.
7293
6984
  const { result, snapshot } = await writeCredentialWithRecovery({ password, machineHash, service, value: token, logFile: LOG_FILE, normalize: false });
7294
- if (result.error) return strike(res, 502, result.error);
6985
+ if (result.error) {
6986
+ operation("service.generate_token", { service }, null, { ok: false, error: result.error });
6987
+ return strike(res, 502, result.error);
6988
+ }
7295
6989
  invalidateServiceStatusCache(machineHash);
6990
+ operation("service.generate_token", { service }, null, { ok: true });
7296
6991
  return ok(res, { token, service, stored: true, recovery_snapshot: snapshot?.ok ? true : false });
7297
6992
  } catch (err) {
6993
+ operation("service.generate_token", { service }, null, { ok: false, error: err.message });
7298
6994
  return strike(res, 502, err.message);
7299
6995
  }
7300
6996
  }
@@ -7324,10 +7020,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7324
7020
  try {
7325
7021
  const { token, timestamp } = deriveToken(password, machineHash);
7326
7022
  const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "", project || undefined);
7327
- if (result.error) return strike(res, 502, result.error);
7023
+ if (result.error) {
7024
+ operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: false, error: result.error });
7025
+ return strike(res, 502, result.error);
7026
+ }
7328
7027
  invalidateServiceStatusCache(machineHash);
7028
+ operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: true, key_type: type });
7329
7029
  return ok(res, { ok: true, service: name.trim().toLowerCase() });
7330
7030
  } catch (err) {
7031
+ operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: false, error: err.message });
7331
7032
  return strike(res, 502, err.message);
7332
7033
  }
7333
7034
  }
@@ -7361,10 +7062,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7361
7062
  try {
7362
7063
  const { token, timestamp } = deriveToken(password, machineHash);
7363
7064
  const result = await api.updateService(password, machineHash, token, timestamp, service.toLowerCase(), updates);
7364
- if (result.error) return strike(res, 502, result.error);
7065
+ if (result.error) {
7066
+ operation("service.update", { name: service.toLowerCase() }, null, { ok: false, error: result.error });
7067
+ return strike(res, 502, result.error);
7068
+ }
7365
7069
  invalidateServiceStatusCache(machineHash);
7070
+ operation("service.update", { name: service.toLowerCase() }, null, { ok: true, fields: Object.keys(updates) });
7366
7071
  return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
7367
7072
  } catch (err) {
7073
+ operation("service.update", { name: service.toLowerCase() }, null, { ok: false, error: err.message });
7368
7074
  return strike(res, 502, err.message);
7369
7075
  }
7370
7076
  }
@@ -7415,11 +7121,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7415
7121
  try {
7416
7122
  const { token, timestamp } = deriveToken(password, machineHash);
7417
7123
  const result = await api.createEnrollment(password, machineHash, token, timestamp, label, ttlMinutes);
7418
- if (result.error) return strike(res, 502, result.error);
7124
+ if (result.error) {
7125
+ operation("machine.enroll", { label, target }, null, { ok: false, error: result.error });
7126
+ return strike(res, 502, result.error);
7127
+ }
7419
7128
  const localConfig = new Conf(getConfOptions());
7420
7129
  const supabaseUrl = localConfig.get("supabase_url") || process.env.CLAUTH_SUPABASE_URL || "";
7421
7130
  const anonKey = localConfig.get("supabase_anon_key") || process.env.CLAUTH_SUPABASE_ANON_KEY || "";
7131
+ // NEVER log `enrollment_code` — it is a one-time credential.
7422
7132
  const scriptPath = writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode: result.enrollment_code, label, target });
7133
+ operation("machine.enroll", { label, target }, null, { ok: true, expires_at: result.expires_at });
7423
7134
  return ok(res, {
7424
7135
  ok: true,
7425
7136
  enrollment_code: result.enrollment_code,
@@ -7427,6 +7138,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7427
7138
  script_path: scriptPath,
7428
7139
  });
7429
7140
  } catch (err) {
7141
+ operation("machine.enroll", { label, target }, null, { ok: false, error: err.message });
7430
7142
  return strike(res, 502, err.message);
7431
7143
  }
7432
7144
  }
@@ -7465,6 +7177,27 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7465
7177
  });
7466
7178
  }
7467
7179
 
7180
+ // The localhost supervisor is the only process allowed to repair clauth-owned
7181
+ // local surfaces. Keep this loop out of the vault/staged instances and make
7182
+ // the cadence configurable for deterministic tests.
7183
+ if (port === getSupervisorPort() && process.env.CLAUTH_SUPERVISOR_HEALTH_RECONCILE !== "0") {
7184
+ const configuredInterval = Number(process.env.CLAUTH_SUPERVISOR_HEALTH_INTERVAL_MS || 10000);
7185
+ const intervalMs = Number.isFinite(configuredInterval) ? Math.max(1000, Math.min(configuredInterval, 300000)) : 10000;
7186
+ let healthReconcileInFlight = false;
7187
+ const runHealthReconcile = () => {
7188
+ if (healthReconcileInFlight) return;
7189
+ healthReconcileInFlight = true;
7190
+ reconcileSurfaceHealth().catch((err) => {
7191
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] supervisor health reconcile failed: ${err.message}\n`); } catch {}
7192
+ }).finally(() => { healthReconcileInFlight = false; });
7193
+ };
7194
+ const healthTimer = setInterval(runHealthReconcile, intervalMs);
7195
+ healthTimer.unref?.();
7196
+ server.__supervisorHealthTimer = healthTimer;
7197
+ server.on("close", () => clearInterval(healthTimer));
7198
+ setImmediate(runHealthReconcile);
7199
+ }
7200
+
7468
7201
  return server;
7469
7202
  }
7470
7203
 
@@ -7480,6 +7213,61 @@ async function verifyAuth(password) {
7480
7213
  }
7481
7214
  }
7482
7215
 
7216
+ async function supervisorResponds(port = getSupervisorPort()) {
7217
+ try {
7218
+ const resp = await fetch(`http://127.0.0.1:${port}/health`);
7219
+ return resp.ok;
7220
+ } catch {
7221
+ return false;
7222
+ }
7223
+ }
7224
+
7225
+ async function ensureSupervisorStarted(cliEntry) {
7226
+ const port = getSupervisorPort();
7227
+ const existing = readSupervisorPid();
7228
+ if (existing && isProcessAlive(existing.pid) && await supervisorResponds(existing.port)) {
7229
+ return { started: false, pid: existing.pid, port: existing.port, state: "already_running" };
7230
+ }
7231
+ if (existing && !isProcessAlive(existing.pid)) removeSupervisorPid();
7232
+ if (await supervisorResponds(port)) {
7233
+ return { started: false, pid: existing?.pid || null, port, state: "port_already_live" };
7234
+ }
7235
+
7236
+ const out = fs.openSync(LOG_FILE, "a");
7237
+ const child = spawn(process.execPath, [cliEntry, "serve", "supervisor", "--port", String(port)], {
7238
+ detached: true,
7239
+ stdio: ["ignore", out, out],
7240
+ env: { ...process.env, __CLAUTH_SUPERVISOR_DAEMON: "1" },
7241
+ });
7242
+ child.unref();
7243
+ writeSupervisorPid(child.pid, port);
7244
+
7245
+ for (let attempt = 0; attempt < 5; attempt++) {
7246
+ await new Promise(r => setTimeout(r, 500));
7247
+ if (await supervisorResponds(port)) {
7248
+ return { started: true, pid: child.pid, port, state: "started" };
7249
+ }
7250
+ }
7251
+ return { started: true, pid: child.pid, port, state: "start_unverified" };
7252
+ }
7253
+
7254
+ async function stopSupervisorSibling() {
7255
+ const info = readSupervisorPid();
7256
+ if (!info) return null;
7257
+ if (!isProcessAlive(info.pid)) {
7258
+ removeSupervisorPid();
7259
+ return { stopped: false, pid: info.pid, port: info.port, state: "stale" };
7260
+ }
7261
+ try {
7262
+ process.kill(info.pid, "SIGTERM");
7263
+ await new Promise(r => setTimeout(r, 300));
7264
+ removeSupervisorPid();
7265
+ return { stopped: true, pid: info.pid, port: info.port, state: "stopped" };
7266
+ } catch (err) {
7267
+ return { stopped: false, pid: info.pid, port: info.port, state: "stop_failed", error: err.message };
7268
+ }
7269
+ }
7270
+
7483
7271
  async function actionStart(opts) {
7484
7272
  if (opts.isolated) {
7485
7273
  return actionForeground(opts);
@@ -7681,6 +7469,11 @@ async function actionStart(opts) {
7681
7469
  console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
7682
7470
  console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
7683
7471
  console.log(chalk.gray(` Log: ${LOG_FILE}`));
7472
+ if (!isStaged) {
7473
+ const supervisor = await ensureSupervisorStarted(cliEntry);
7474
+ const verb = supervisor.started ? "started" : "available";
7475
+ console.log(chalk.gray(` Supervisor: ${verb} on 127.0.0.1:${supervisor.port}${supervisor.pid ? ` (PID ${supervisor.pid})` : ""}`));
7476
+ }
7684
7477
  if (isStaged) {
7685
7478
  console.log(chalk.yellow(`\n ⚡ Staged on port ${port} — open dashboard to verify, then click "Make Live"`));
7686
7479
  } else if (!password) {
@@ -7698,6 +7491,7 @@ async function actionStart(opts) {
7698
7491
  async function actionStop() {
7699
7492
  const info = readPid();
7700
7493
  if (!info) {
7494
+ await stopSupervisorSibling();
7701
7495
  console.log(chalk.yellow("\n No clauth serve PID file found — not running.\n"));
7702
7496
  return;
7703
7497
  }
@@ -7705,6 +7499,7 @@ async function actionStop() {
7705
7499
  if (!isProcessAlive(info.pid)) {
7706
7500
  console.log(chalk.yellow(`\n PID ${info.pid} is not running (stale PID file). Cleaning up.\n`));
7707
7501
  removePid();
7502
+ await stopSupervisorSibling();
7708
7503
  return;
7709
7504
  }
7710
7505
 
@@ -7715,6 +7510,7 @@ async function actionStop() {
7715
7510
  await new Promise(r => setTimeout(r, 300));
7716
7511
  console.log(chalk.green(`\n 🛑 clauth serve stopped (was PID ${info.pid}, port ${info.port})\n`));
7717
7512
  removePid();
7513
+ await stopSupervisorSibling();
7718
7514
  return;
7719
7515
  }
7720
7516
  } catch {}
@@ -7728,6 +7524,7 @@ async function actionStop() {
7728
7524
  console.log(chalk.yellow(`\n Could not kill PID ${info.pid}: ${err.message}\n`));
7729
7525
  }
7730
7526
  removePid();
7527
+ await stopSupervisorSibling();
7731
7528
  }
7732
7529
 
7733
7530
  async function actionPing() {
@@ -7772,7 +7569,9 @@ async function actionRestart(opts) {
7772
7569
  async function actionForeground(opts) {
7773
7570
  const port = parseInt(opts.port || "52437", 10);
7774
7571
  const isolated = !!opts.isolated;
7775
- const password = isolated ? null : (opts.pw || null);
7572
+ const containerPassword = process.env.CLAUTH_MASTER_PASSWORD || process.env["clauth-master-password"] || null;
7573
+ const password = isolated ? null : (opts.pw || containerPassword);
7574
+ const bindHost = process.env.CLAUTH_BIND_HOST || "127.0.0.1";
7776
7575
  const tunnelHostname = opts.tunnel || null;
7777
7576
  const whitelist = opts.services
7778
7577
  ? opts.services.split(",").map(s => s.trim().toLowerCase())
@@ -7798,14 +7597,40 @@ async function actionForeground(opts) {
7798
7597
  console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
7799
7598
  }
7800
7599
 
7801
- console.log(chalk.gray(` Port: 127.0.0.1:${port}`));
7600
+ console.log(chalk.gray(` Port: ${bindHost}:${port}`));
7802
7601
  console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
7803
7602
  console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
7804
7603
 
7604
+ // Plugin discovery previously only ran under `serve supervisor` or an
7605
+ // on-demand rescan — normal `serve start`/`foreground` boot never ran it,
7606
+ // so a stale/never-discovered plugin list could sit unnoticed until
7607
+ // someone happened to hit rescan. Discovery touching disk must never be
7608
+ // allowed to crash daemon boot, hence the try/catch.
7609
+ let discoveryResult = null;
7610
+ let discoveryError = null;
7611
+ try {
7612
+ discoveryResult = discoverPlugins();
7613
+ } catch (err) {
7614
+ discoveryError = err;
7615
+ console.log(chalk.yellow(` ⚠ plugin discovery failed at boot: ${err.message}`));
7616
+ }
7617
+
7805
7618
  const server = createServer(password, whitelist, port, tunnelHostname);
7806
- server.listen(port, "127.0.0.1", () => {
7619
+ server.listen(port, bindHost, () => {
7807
7620
  if (!isolated) writePid(process.pid, port);
7808
- console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
7621
+ try {
7622
+ operation("daemon.started", { port, isolated }, null, { ok: true, version: VERSION }, "system");
7623
+ operation(
7624
+ "daemon.plugin_discovery",
7625
+ { port },
7626
+ null,
7627
+ discoveryError
7628
+ ? { ok: false, error: discoveryError.message }
7629
+ : { ok: true, plugin_count: (discoveryResult?.plugins || discoveryResult || []).length },
7630
+ "system",
7631
+ );
7632
+ } catch { /* startup logging must never block the daemon from serving */ }
7633
+ console.log(chalk.green(` clauth serve → http://${bindHost}:${port}`));
7809
7634
  if (tunnelHostname) {
7810
7635
  console.log(chalk.cyan(` Tunnel: https://${tunnelHostname}/sse`));
7811
7636
  console.log("");
@@ -7845,7 +7670,7 @@ async function actionForeground(opts) {
7845
7670
  import { createInterface } from "readline";
7846
7671
  import { execSync, spawn as spawnProc, spawnSync } from "child_process";
7847
7672
 
7848
- // ── TinTin dispatch (formerly Monkey) — headless Claude CLI worker ───
7673
+ // ── Shared headless Claude CLI worker helpers (used by call-agent, terminal, codevelop) ───
7849
7674
  function findClaudeBinary() {
7850
7675
  const candidates = [
7851
7676
  process.env.CLAUDE_BIN,
@@ -8336,7 +8161,7 @@ async function runCallAgent(args = {}) {
8336
8161
  const persistMeta = { prompt, skill_slug: args.skill_slug, model, agent_context: args.agent_context };
8337
8162
 
8338
8163
  if (mode === "async") {
8339
- const jobId = makeTinTinId("call");
8164
+ const jobId = `call-${crypto.randomUUID()}`;
8340
8165
  // Persist the queued row FIRST so the { jobId } we return is recoverable
8341
8166
  // even if the daemon restarts before the worker settles.
8342
8167
  await persistCallAgentDispatch({ jobId, ...persistMeta });
@@ -8360,307 +8185,6 @@ async function runCallAgent(args = {}) {
8360
8185
  return { ok: true, package: rec.package, jobId: rec.jobId, model: rec.model, ms: rec.ms };
8361
8186
  }
8362
8187
 
8363
- const tintinJobs = new Map();
8364
- const MAX_TINTIN_JOBS = 100;
8365
- const tintinSessions = new Map();
8366
- const tintinMessageIndex = new Map();
8367
- const tintinEventStreams = new Map();
8368
- const tintinMessageMonitors = new Map();
8369
- const MAX_TINTIN_SESSION_EVENTS = 500;
8370
-
8371
- function normalizeAgentContext(input = {}) {
8372
- const context = input && typeof input === "object" ? input : {};
8373
- const app = context.app && typeof context.app === "object" ? context.app : {};
8374
- const repo = context.repo && typeof context.repo === "object" ? context.repo : {};
8375
- const runtime = context.runtime && typeof context.runtime === "object" ? context.runtime : {};
8376
- const task = context.task && typeof context.task === "object" ? context.task : {};
8377
-
8378
- return {
8379
- app: {
8380
- slug: String(app.slug || context.app_slug || "unknown-app"),
8381
- route: String(app.route || context.route || "/"),
8382
- origin: String(app.origin || context.origin || "unknown-origin"),
8383
- },
8384
- repo: {
8385
- root: String(repo.root || context.repo_root || ""),
8386
- cwd: String(repo.cwd || context.cwd || ""),
8387
- },
8388
- runtime: {
8389
- agent: String(runtime.agent || context.agent || "clauth-cli"),
8390
- requested_by: String(runtime.requested_by || context.requested_by || "tintin"),
8391
- },
8392
- task: {
8393
- intent: String(task.intent || context.intent || "tintin-dispatch"),
8394
- thread_id: task.thread_id || context.thread_id || null,
8395
- },
8396
- };
8397
- }
8398
-
8399
- function makeTinTinId(prefix) {
8400
- return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
8401
- }
8402
-
8403
- function createTinTinSession(input = {}) {
8404
- const sessionId = input.session_id || input.thread_id || makeTinTinId("ms");
8405
- const agentContext = normalizeAgentContext(input.agent_context || {
8406
- app: {
8407
- slug: input.app_id || input.app_slug || "unknown-app",
8408
- route: input.route || "/",
8409
- origin: input.origin || "unknown-origin",
8410
- },
8411
- repo: {
8412
- root: input.repo_root || "",
8413
- cwd: input.cwd || "",
8414
- },
8415
- runtime: {
8416
- agent: "clauth-cli",
8417
- requested_by: "tintin-sidebar",
8418
- },
8419
- task: {
8420
- intent: input.intent || "general_chat",
8421
- thread_id: sessionId,
8422
- },
8423
- });
8424
- const existing = tintinSessions.get(sessionId);
8425
- if (existing) {
8426
- existing.agent_context = agentContext;
8427
- existing.updated_at = new Date().toISOString();
8428
- tintinSessions.set(sessionId, existing);
8429
- return existing;
8430
- }
8431
- const session = {
8432
- id: sessionId,
8433
- status: "ready",
8434
- created_at: new Date().toISOString(),
8435
- updated_at: new Date().toISOString(),
8436
- seq: 0,
8437
- agent_context: agentContext,
8438
- cwd: input.cwd || agentContext.repo.cwd || agentContext.repo.root || "",
8439
- events: [],
8440
- };
8441
- tintinSessions.set(sessionId, session);
8442
- return session;
8443
- }
8444
-
8445
- function getTinTinSession(sessionId) {
8446
- return tintinSessions.get(sessionId);
8447
- }
8448
-
8449
- function pushTinTinEvent(sessionId, event) {
8450
- const session = getTinTinSession(sessionId);
8451
- if (!session) return null;
8452
- session.seq += 1;
8453
- session.updated_at = new Date().toISOString();
8454
- const fullEvent = {
8455
- id: event.id || makeTinTinId("mev"),
8456
- session_id: sessionId,
8457
- seq: session.seq,
8458
- source: event.source || "clauth",
8459
- type: event.type || "status",
8460
- role: event.role,
8461
- message_id: event.message_id,
8462
- job_id: event.job_id,
8463
- content: event.content,
8464
- payload: event.payload || {},
8465
- created_at: new Date().toISOString(),
8466
- };
8467
- session.events.push(fullEvent);
8468
- session.events = session.events.slice(-MAX_TINTIN_SESSION_EVENTS);
8469
- tintinSessions.set(sessionId, session);
8470
- const streams = tintinEventStreams.get(sessionId);
8471
- if (streams) {
8472
- for (const send of [...streams]) {
8473
- try {
8474
- send(fullEvent);
8475
- } catch {
8476
- streams.delete(send);
8477
- }
8478
- }
8479
- }
8480
- return fullEvent;
8481
- }
8482
-
8483
- function listTinTinEvents(sessionId, afterSeq = 0) {
8484
- const session = getTinTinSession(sessionId);
8485
- if (!session) return null;
8486
- return session.events.filter((event) => event.seq > afterSeq);
8487
- }
8488
-
8489
- function subscribeTinTinEvents(sessionId, send) {
8490
- const streams = tintinEventStreams.get(sessionId) || new Set();
8491
- streams.add(send);
8492
- tintinEventStreams.set(sessionId, streams);
8493
- return () => {
8494
- const current = tintinEventStreams.get(sessionId);
8495
- if (!current) return;
8496
- current.delete(send);
8497
- if (current.size === 0) tintinEventStreams.delete(sessionId);
8498
- };
8499
- }
8500
-
8501
- function startTinTinMessageMonitor(sessionId, messageId, jobId) {
8502
- const key = `${sessionId}:${messageId}`;
8503
- if (tintinMessageMonitors.has(key)) return;
8504
- let lastStdoutLength = 0;
8505
- const timer = setInterval(() => {
8506
- const job = tintinJobs.get(jobId);
8507
- if (!job) return;
8508
- const stdout = job.stdout || "";
8509
- if (stdout.length > lastStdoutLength) {
8510
- const chunk = stdout.slice(lastStdoutLength);
8511
- lastStdoutLength = stdout.length;
8512
- pushTinTinEvent(sessionId, {
8513
- source: "claude",
8514
- type: "delta",
8515
- role: "assistant",
8516
- message_id: messageId,
8517
- job_id: jobId,
8518
- content: chunk,
8519
- });
8520
- }
8521
- if (job.status !== "running") {
8522
- clearInterval(timer);
8523
- tintinMessageMonitors.delete(key);
8524
- pushTinTinEvent(sessionId, {
8525
- source: "clauth",
8526
- type: job.status === "completed" ? "done" : "error",
8527
- role: "assistant",
8528
- message_id: messageId,
8529
- job_id: jobId,
8530
- content: job.status === "completed" ? "" : (job.stderr || job.stdout || "TinTin worker failed"),
8531
- payload: {
8532
- status: job.status,
8533
- exit_code: job.exit_code,
8534
- completed_at: job.completed_at,
8535
- },
8536
- });
8537
- }
8538
- }, 500);
8539
- tintinMessageMonitors.set(key, timer);
8540
- }
8541
-
8542
- function resolveDispatchCwd(requestedCwd, agentContext = {}) {
8543
- const raw = requestedCwd || agentContext?.repo?.cwd || agentContext?.repo?.root || CHITCHAT_FALLBACK_CWD;
8544
- if (!raw || typeof raw !== "string") return CHITCHAT_FALLBACK_CWD;
8545
- const resolved = path.resolve(raw);
8546
- try {
8547
- const st = fs.statSync(resolved);
8548
- if (!st.isDirectory()) return CHITCHAT_FALLBACK_CWD;
8549
- return resolved;
8550
- } catch {
8551
- return CHITCHAT_FALLBACK_CWD;
8552
- }
8553
- }
8554
-
8555
- function buildTinTinPrompt({ prompt, job_id, agent_context }) {
8556
- const normalized = normalizeAgentContext(agent_context);
8557
- const jobInstruction = job_id
8558
- ? [
8559
- `TinTin job id: ${job_id}`,
8560
- "If the prompt does not contain all job details, use clauth at http://127.0.0.1:52437 to fetch exact credentials and query the app database/API directly.",
8561
- "Do not assume agent MCP/plugin tools are available to this spawned CLI process.",
8562
- ].join("\n")
8563
- : "";
8564
-
8565
- const body = prompt || [
8566
- "Process this TinTin job using the provided agent context.",
8567
- jobInstruction,
8568
- "Find the job payload from the consuming app's declared data path before making changes.",
8569
- ].filter(Boolean).join("\n\n");
8570
-
8571
- return [
8572
- "You are a clauth-spawned TinTin CLI agent (model: claude-sonnet-4-6).",
8573
- "Agent context setup follows. Treat it as the runtime contract for this task.",
8574
- "```json",
8575
- JSON.stringify(normalized, null, 2),
8576
- "```",
8577
- "",
8578
- "Execution rules:",
8579
- "- You are a local CLI process, not the parent agent runtime.",
8580
- "- You do not inherit Codex/Claude MCP or plugin tools.",
8581
- "- Use clauth HTTP, direct HTTP APIs, repo files, or app endpoints for access.",
8582
- "- Keep results tied to the supplied app, route, cwd, thread, and job id.",
8583
- "",
8584
- jobInstruction,
8585
- "",
8586
- body,
8587
- ].filter(Boolean).join("\n");
8588
- }
8589
-
8590
- function spawnClaudeTask(prompt, jobId, cwd, agentContext) {
8591
- if (activeCliWorkers >= MAX_CLI_WORKERS) {
8592
- return { error: 'concurrency_limit', message: `Max ${MAX_CLI_WORKERS} CLI workers active` };
8593
- }
8594
- const binary = findClaudeBinary();
8595
- if (!binary) {
8596
- return { error: 'binary_not_found', message: 'claude CLI not found in PATH or AppData/npm' };
8597
- }
8598
-
8599
- const resolvedCwd = resolveDispatchCwd(cwd, agentContext);
8600
- activeCliWorkers++;
8601
- const startedAtIso = new Date().toISOString();
8602
- tintinJobs.set(jobId, {
8603
- jobId,
8604
- status: "running",
8605
- cwd: resolvedCwd,
8606
- pid: null,
8607
- started_at: startedAtIso,
8608
- completed_at: null,
8609
- exit_code: null,
8610
- stdout: "",
8611
- stderr: "",
8612
- context: normalizeAgentContext(agentContext),
8613
- });
8614
- while (tintinJobs.size > MAX_TINTIN_JOBS) {
8615
- const firstKey = tintinJobs.keys().next().value;
8616
- tintinJobs.delete(firstKey);
8617
- }
8618
- const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(binary);
8619
- const command = isCmdShim ? "cmd" : binary;
8620
- const args = isCmdShim
8621
- ? ["/d", "/s", "/c", `"${binary}"`, "-p", prompt, "--dangerously-skip-permissions"]
8622
- : ["-p", prompt, "--dangerously-skip-permissions"];
8623
- const proc = spawnProc(command, args, {
8624
- cwd: resolvedCwd,
8625
- env: process.env,
8626
- stdio: ['ignore', 'pipe', 'pipe'],
8627
- shell: false,
8628
- windowsHide: true,
8629
- });
8630
-
8631
- const tracked = tintinJobs.get(jobId);
8632
- if (tracked) {
8633
- tracked.pid = proc.pid;
8634
- tintinJobs.set(jobId, tracked);
8635
- }
8636
-
8637
- const startedAt = Date.now();
8638
- proc.stdout?.on("data", (chunk) => {
8639
- const trackedJob = tintinJobs.get(jobId);
8640
- if (!trackedJob) return;
8641
- trackedJob.stdout = `${trackedJob.stdout}${chunk.toString()}`.slice(-20000);
8642
- tintinJobs.set(jobId, trackedJob);
8643
- });
8644
- proc.stderr?.on("data", (chunk) => {
8645
- const trackedJob = tintinJobs.get(jobId);
8646
- if (!trackedJob) return;
8647
- trackedJob.stderr = `${trackedJob.stderr}${chunk.toString()}`.slice(-20000);
8648
- tintinJobs.set(jobId, trackedJob);
8649
- });
8650
- proc.on('close', (code) => {
8651
- activeCliWorkers--;
8652
- const trackedJob = tintinJobs.get(jobId);
8653
- if (trackedJob) {
8654
- trackedJob.status = code === 0 ? "completed" : "failed";
8655
- trackedJob.exit_code = code;
8656
- trackedJob.completed_at = new Date().toISOString();
8657
- tintinJobs.set(jobId, trackedJob);
8658
- }
8659
- console.log(`[tintin] job ${jobId} exited code=${code} in ${Date.now() - startedAt}ms`);
8660
- });
8661
-
8662
- return { status: 'spawned', pid: proc.pid, jobId, cwd: resolvedCwd, activeWorkers: activeCliWorkers };
8663
- }
8664
8188
 
8665
8189
  // ── Terminal session manager ─────────────────────────────────────
8666
8190
  // Rolling-context approach: each session stores a context string.
@@ -8678,7 +8202,7 @@ function generateSessionId() {
8678
8202
  }
8679
8203
 
8680
8204
  function defaultTerminalCwd() {
8681
- const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-tintin-terminal");
8205
+ const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-terminal");
8682
8206
  try {
8683
8207
  fs.mkdirSync(dir, { recursive: true });
8684
8208
  } catch {
@@ -8712,7 +8236,7 @@ function startTerminalSession(name, knowledge_tier, context_md, cwd, use_warm =
8712
8236
  };
8713
8237
 
8714
8238
  // Try to acquire a warm worker only when explicitly requested. The warm pool
8715
- // is optimized for call_agent one-shots; TinTin browser chat needs reliable
8239
+ // is optimized for call_agent one-shots; a terminal session needs reliable
8716
8240
  // repeated turns more than experimental low-latency pinning.
8717
8241
  if (use_warm) {
8718
8242
  try {
@@ -8920,12 +8444,12 @@ function showTerminalSession(session_id) {
8920
8444
 
8921
8445
  if (process.platform === "win32") {
8922
8446
  try {
8923
- const title = `TinTin ${session.name || session_id}`;
8447
+ const title = `clauth terminal ${session.name || session_id}`;
8924
8448
  const lines = [
8925
8449
  `$Host.UI.RawUI.WindowTitle = ${JSON.stringify(title)}`,
8926
8450
  `Set-Location -LiteralPath ${JSON.stringify(session.cwd || os.homedir())}`,
8927
8451
  "Clear-Host",
8928
- `Write-Host ${JSON.stringify(`TinTin session ${session_id}`)} -ForegroundColor Cyan`,
8452
+ `Write-Host ${JSON.stringify(`clauth terminal session ${session_id}`)} -ForegroundColor Cyan`,
8929
8453
  `Write-Host ${JSON.stringify(`Name: ${session.name || ""}`)}`,
8930
8454
  `Write-Host ${JSON.stringify(`Status: ${session.status}`)}`,
8931
8455
  `Write-Host ${JSON.stringify(`CWD: ${session.cwd || ""}`)}`,
@@ -9894,7 +9418,7 @@ function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFI
9894
9418
  return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
9895
9419
  }
9896
9420
 
9897
- const MCP_TOOLS = [
9421
+ export const MCP_TOOLS = [
9898
9422
  {
9899
9423
  name: "clauth_ping",
9900
9424
  description: "Check if the vault is locked or unlocked, show failure count",
@@ -10044,7 +9568,7 @@ const MCP_TOOLS = [
10044
9568
  },
10045
9569
  {
10046
9570
  name: "call_agent",
10047
- description: "Call a headless Haiku agent for a single prompt or skill and get the answer back. Lean one-shot through a warm pool (model default claude-haiku-4-5, ~4-5s warm). sync mode (default) blocks and returns { ok, package, jobId, model, ms }; async returns { ok, jobId } for polling. Use this for call-and-return — not tintin_dispatch (which is fire-and-poll).",
9571
+ description: "Call a headless Haiku agent for a single prompt or skill and get the answer back. Lean one-shot through a warm pool (model default claude-haiku-4-5, ~4-5s warm). sync mode (default) blocks and returns { ok, package, jobId, model, ms }; async returns { ok, jobId } for polling.",
10048
9572
  inputSchema: {
10049
9573
  type: "object",
10050
9574
  properties: {
@@ -10085,24 +9609,6 @@ const MCP_TOOLS = [
10085
9609
  additionalProperties: false,
10086
9610
  },
10087
9611
  },
10088
- {
10089
- name: "tintin_dispatch", // formerly monkey_dispatch
10090
- description: "Dispatch a TinTin job to a headless Claude Code CLI worker with an app/repo/runtime/task agent_context envelope. Max 2 concurrent workers.",
10091
- inputSchema: {
10092
- type: "object",
10093
- properties: {
10094
- prompt: { type: "string", description: "Full prompt for the CLI worker to execute. Optional when job_id is supplied." },
10095
- job_id: { type: "string", description: "tintin job UUID or app job id for tracking" },
10096
- cwd: { type: "string", description: "Existing local directory where the CLI worker should run" },
10097
- agent_context: {
10098
- type: "object",
10099
- description: "App-neutral context envelope with app, repo, runtime, and task fields",
10100
- additionalProperties: true,
10101
- },
10102
- },
10103
- additionalProperties: false,
10104
- },
10105
- },
10106
9612
  {
10107
9613
  name: "handoff_start",
10108
9614
  description: "Start a visible local Claude Code handoff session from a /handoff command and return immediately with session metadata.",
@@ -10154,7 +9660,7 @@ const MCP_TOOLS = [
10154
9660
  },
10155
9661
  {
10156
9662
  name: "terminal_show",
10157
- description: "Reveal/focus a TinTin terminal session on this Windows machine. If no live Claude process window exists, opens a visible session inspector at the session cwd.",
9663
+ description: "Reveal/focus a clauth terminal session on this Windows machine. If no live Claude process window exists, opens a visible session inspector at the session cwd.",
10158
9664
  inputSchema: {
10159
9665
  type: "object",
10160
9666
  properties: {
@@ -10882,6 +10388,41 @@ const MCP_TOOLS = [
10882
10388
  additionalProperties: false,
10883
10389
  },
10884
10390
  },
10391
+ {
10392
+ name: "clauth_ops_catalog",
10393
+ description: "Return the Vultr deployment-control API catalog. Availability is still decided on Vultr.",
10394
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
10395
+ },
10396
+ {
10397
+ name: "clauth_ops_processes",
10398
+ description: "Submit a scoped PM2 process-status query to the Vultr control plane. Only server-approved applications are returned.",
10399
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
10400
+ },
10401
+ {
10402
+ name: "clauth_ops_describe",
10403
+ description: "Submit a scoped PM2 status query for one server-approved application.",
10404
+ inputSchema: { type: "object", properties: { application: { type: "string" } }, required: ["application"], additionalProperties: false },
10405
+ },
10406
+ {
10407
+ name: "clauth_ops_deploy",
10408
+ description: "Submit one manifest-scoped development deployment to Vultr. The server chooses the repository, build, PM2 process, and allowed ref.",
10409
+ inputSchema: { type: "object", properties: { application: { type: "string" }, ref: { type: "string" } }, required: ["application"], additionalProperties: false },
10410
+ },
10411
+ {
10412
+ name: "clauth_ops_promote",
10413
+ description: "Submit one server-allowlisted Coolify promotion and return its asynchronous job receipt.",
10414
+ inputSchema: { type: "object", properties: { application_uuid: { type: "string" } }, required: ["application_uuid"], additionalProperties: false },
10415
+ },
10416
+ {
10417
+ name: "clauth_ops_job",
10418
+ description: "Read the terminal or in-progress receipt for a deployment-control job.",
10419
+ inputSchema: { type: "object", properties: { job_id: { type: "string" } }, required: ["job_id"], additionalProperties: false },
10420
+ },
10421
+ {
10422
+ name: "clauth_ops_run",
10423
+ description: "Submit one PM2 operation through the Vultr control plane. The remote agent/admin profile decides whether the operation and target are available.",
10424
+ inputSchema: { type: "object", properties: { operation: { type: "string" }, input: { type: "object" } }, required: ["operation"], additionalProperties: false },
10425
+ },
10885
10426
  ];
10886
10427
 
10887
10428
  const MCP_WRITE_TOOL_NAMES = new Set([
@@ -10889,6 +10430,9 @@ const MCP_WRITE_TOOL_NAMES = new Set([
10889
10430
  "clauth_disable",
10890
10431
  "clauth_set_project",
10891
10432
  "clauth_generate_token",
10433
+ "clauth_ops_deploy",
10434
+ "clauth_ops_promote",
10435
+ "clauth_ops_run",
10892
10436
  ]);
10893
10437
 
10894
10438
  function filterMcpToolsForWriteMode(tools) {
@@ -10922,6 +10466,24 @@ function mcpError(text) {
10922
10466
  return { content: [{ type: "text", text }], isError: true };
10923
10467
  }
10924
10468
 
10469
+ async function callOpsFromMcp(vault, method, requestPath, body, { write = false } = {}) {
10470
+ if (write && !vault.writeEnabled) return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
10471
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
10472
+ const endpoint = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
10473
+ if (!endpoint) return mcpError("service_not_available");
10474
+ try {
10475
+ const url = new URL(endpoint);
10476
+ if (url.protocol !== "https:") return mcpError("service_not_available");
10477
+ const service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token";
10478
+ const credential = await vaultRetrieveValue(vault, service);
10479
+ if (credential.error || !credential.value) return mcpError("service_not_available");
10480
+ const payload = await requestOps({ endpoint: url.toString().replace(/\/$/, ""), token: String(credential.value), method, path: requestPath, body });
10481
+ return mcpResult(JSON.stringify(payload, null, 2));
10482
+ } catch {
10483
+ return mcpError("service_not_available");
10484
+ }
10485
+ }
10486
+
10925
10487
  // Windows cmd.exe doesn't support single quotes — use bash for gws JSON args
10926
10488
  const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
10927
10489
 
@@ -10932,6 +10494,13 @@ async function handleMcpTool(vault, name, args) {
10932
10494
  };
10933
10495
 
10934
10496
  switch (name) {
10497
+ case "clauth_ops_catalog": return callOpsFromMcp(vault, "GET", "/v1/ops/catalog");
10498
+ case "clauth_ops_processes": return callOpsFromMcp(vault, "GET", "/v1/ops/processes");
10499
+ case "clauth_ops_describe": return callOpsFromMcp(vault, "GET", `/v1/ops/processes/${encodeURIComponent(args.application || "")}`);
10500
+ case "clauth_ops_deploy": return callOpsFromMcp(vault, "POST", "/v1/ops/deployments", { application: args.application, ...(args.ref ? { ref: args.ref } : {}) }, { write: true });
10501
+ case "clauth_ops_promote": return callOpsFromMcp(vault, "POST", "/v1/ops/promotions", { application_uuid: args.application_uuid }, { write: true });
10502
+ case "clauth_ops_job": return callOpsFromMcp(vault, "GET", `/v1/ops/jobs/${encodeURIComponent(args.job_id || "")}`);
10503
+ case "clauth_ops_run": return callOpsFromMcp(vault, "POST", "/v1/ops/operations", { operation: args.operation, input: args.input && typeof args.input === "object" ? args.input : {} }, { write: true });
10935
10504
  case "clauth_ping": {
10936
10505
  return mcpResult(
10937
10506
  vault.password
@@ -11997,19 +11566,6 @@ async function handleMcpTool(vault, name, args) {
11997
11566
  return mcpResult(JSON.stringify(result));
11998
11567
  }
11999
11568
 
12000
- case "tintin_dispatch": // formerly monkey_dispatch
12001
- case "monkey_dispatch": {
12002
- const { prompt, job_id, cwd: requestedCwd, agent_context } = args;
12003
- if (!prompt && !job_id) return mcpError("prompt required");
12004
- const fallbackCwd = await resolveChitchatRoot(vault);
12005
- const normalizedContext = normalizeAgentContext(agent_context);
12006
- const dispatchCwd = resolveDispatchCwd(requestedCwd || fallbackCwd, normalizedContext);
12007
- const wrappedPrompt = buildTinTinPrompt({ prompt, job_id, agent_context: normalizedContext });
12008
- const result = spawnClaudeTask(wrappedPrompt, job_id || "untracked", dispatchCwd, normalizedContext);
12009
- if (result.error) return mcpError(`${result.error}: ${result.message}`);
12010
- return mcpResult(JSON.stringify({ ...result, context: normalizedContext }));
12011
- }
12012
-
12013
11569
  case "handoff_start": {
12014
11570
  const result = await startHandoffSession(args || {}, vault);
12015
11571
  if (result.error) return mcpError(`${result.error}: ${result.message}`);
@@ -13132,6 +12688,14 @@ async function actionUpgrade(opts) {
13132
12688
  return actionStart(opts);
13133
12689
  }
13134
12690
 
12691
+ async function actionSupervisor(opts) {
12692
+ opts.isolated = true;
12693
+ opts.port = String(opts.port || getSupervisorPort());
12694
+ // discoverPlugins() now runs unconditionally inside actionForeground at
12695
+ // every boot — calling it here too would double-run discovery.
12696
+ return actionForeground(opts);
12697
+ }
12698
+
13135
12699
  export async function runServe(opts) {
13136
12700
  const action = opts.action || "foreground";
13137
12701
 
@@ -13142,12 +12706,13 @@ export async function runServe(opts) {
13142
12706
  case "ping": return actionPing();
13143
12707
  case "foreground": return actionForeground(opts);
13144
12708
  case "mcp": return actionMcp(opts);
12709
+ case "supervisor": return actionSupervisor(opts);
13145
12710
  case "install": return actionInstall(opts);
13146
12711
  case "uninstall": return actionUninstall();
13147
12712
  case "upgrade": return actionUpgrade(opts);
13148
12713
  default:
13149
12714
  console.log(chalk.red(`\n Unknown serve action: ${action}`));
13150
- console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall | upgrade\n"));
12715
+ console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | supervisor | install | uninstall | upgrade\n"));
13151
12716
  process.exit(1);
13152
12717
  }
13153
12718
  }