@cfio/cohort-sync 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,9 +16,9 @@ __export(keychain_exports, {
16
16
  setCredential: () => setCredential
17
17
  });
18
18
  import { execFile } from "node:child_process";
19
- import os3 from "node:os";
19
+ import os4 from "node:os";
20
20
  function assertMacOS(operation) {
21
- if (os3.platform() !== "darwin") {
21
+ if (os4.platform() !== "darwin") {
22
22
  throw new Error(
23
23
  `cohort-sync: ${operation} requires macOS Keychain. On Linux/Windows, set your API key in OpenClaw config: plugins.entries.cohort-sync.config.apiKey`
24
24
  );
@@ -100,6 +100,7 @@ var init_keychain = __esm({
100
100
 
101
101
  // src/hooks.ts
102
102
  import fs3 from "node:fs";
103
+ import os3 from "node:os";
103
104
  import path3 from "node:path";
104
105
 
105
106
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.48/node_modules/@sinclair/typebox/build/esm/type/guard/value.mjs
@@ -2710,6 +2711,228 @@ var Type = type_exports2;
2710
2711
 
2711
2712
  // src/sync.ts
2712
2713
  import { execSync } from "node:child_process";
2714
+ function extractJson(raw) {
2715
+ const jsonStart = raw.search(/[\[{]/);
2716
+ const jsonEndBracket = raw.lastIndexOf("]");
2717
+ const jsonEndBrace = raw.lastIndexOf("}");
2718
+ const jsonEnd = Math.max(jsonEndBracket, jsonEndBrace);
2719
+ if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) {
2720
+ throw new Error("No JSON found in output");
2721
+ }
2722
+ return raw.slice(jsonStart, jsonEnd + 1);
2723
+ }
2724
+ function fetchSkills(logger) {
2725
+ try {
2726
+ const raw = execSync("openclaw skills list --json", {
2727
+ encoding: "utf8",
2728
+ timeout: 3e4,
2729
+ stdio: ["ignore", "pipe", "ignore"],
2730
+ env: { ...process.env, NO_COLOR: "1" }
2731
+ });
2732
+ const parsed = JSON.parse(extractJson(raw));
2733
+ const list = Array.isArray(parsed) ? parsed : parsed?.skills ?? [];
2734
+ return list.map((s) => ({
2735
+ name: String(s.name ?? s.id ?? "unknown"),
2736
+ description: String(s.description ?? ""),
2737
+ source: String(s.source ?? s.origin ?? "unknown"),
2738
+ ...s.emoji ? { emoji: String(s.emoji) } : {}
2739
+ }));
2740
+ } catch (err) {
2741
+ logger.warn(`cohort-sync: failed to fetch skills: ${String(err)}`);
2742
+ return [];
2743
+ }
2744
+ }
2745
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["idle", "working", "waiting"]);
2746
+ function normalizeStatus(status) {
2747
+ return VALID_STATUSES.has(status) ? status : "idle";
2748
+ }
2749
+ async function v1Get(apiUrl, apiKey, path4) {
2750
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2751
+ headers: { Authorization: `Bearer ${apiKey}` },
2752
+ signal: AbortSignal.timeout(1e4)
2753
+ });
2754
+ if (!res.ok) throw new Error(`GET ${path4} \u2192 ${res.status}`);
2755
+ return res.json();
2756
+ }
2757
+ async function v1Patch(apiUrl, apiKey, path4, body) {
2758
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2759
+ method: "PATCH",
2760
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
2761
+ body: JSON.stringify(body),
2762
+ signal: AbortSignal.timeout(1e4)
2763
+ });
2764
+ if (!res.ok) throw new Error(`PATCH ${path4} \u2192 ${res.status}`);
2765
+ }
2766
+ async function v1Post(apiUrl, apiKey, path4, body) {
2767
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2768
+ method: "POST",
2769
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
2770
+ body: JSON.stringify(body),
2771
+ signal: AbortSignal.timeout(1e4)
2772
+ });
2773
+ if (!res.ok) throw new Error(`POST ${path4} \u2192 ${res.status}`);
2774
+ }
2775
+ async function checkForUpdate(currentVersion, logger) {
2776
+ try {
2777
+ const res = await fetch("https://registry.npmjs.org/@cfio/cohort-sync/latest", {
2778
+ signal: AbortSignal.timeout(5e3)
2779
+ });
2780
+ if (!res.ok) return;
2781
+ const data = await res.json();
2782
+ const latest = data.version;
2783
+ if (latest && latest !== currentVersion) {
2784
+ logger.warn(
2785
+ `cohort-sync: update available (${currentVersion} \u2192 ${latest}) \u2014 run "npm install -g @cfio/cohort-sync" to update`
2786
+ );
2787
+ }
2788
+ } catch {
2789
+ }
2790
+ }
2791
+ async function syncAgentStatus(agentName, status, model, cfg, logger) {
2792
+ try {
2793
+ const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
2794
+ const agents = data?.data ?? [];
2795
+ const agent = agents.find(
2796
+ (a) => a.name.toLowerCase() === agentName.toLowerCase()
2797
+ );
2798
+ if (!agent) {
2799
+ const available = agents.map((a) => a.name).join(", ") || "(none)";
2800
+ logger.warn(
2801
+ `cohort-sync: agent "${agentName}" not found in workspace \u2014 skipping status sync. Available agents: [${available}]. Configure agentNameMap in your plugin config to map OpenClaw agent IDs to Cohort names.`
2802
+ );
2803
+ return;
2804
+ }
2805
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
2806
+ status: normalizeStatus(status),
2807
+ model
2808
+ });
2809
+ logger.info(`cohort-sync: agent "${agentName}" \u2192 ${normalizeStatus(status)}`);
2810
+ } catch (err) {
2811
+ logger.warn(`cohort-sync: syncAgentStatus failed: ${String(err)}`);
2812
+ }
2813
+ }
2814
+ async function syncSkillsToV1(skills, cfg, logger) {
2815
+ for (const skill of skills) {
2816
+ try {
2817
+ await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/skills", {
2818
+ name: skill.name,
2819
+ description: skill.description
2820
+ });
2821
+ } catch (err) {
2822
+ logger.warn(`cohort-sync: failed to sync skill "${skill.name}": ${String(err)}`);
2823
+ }
2824
+ }
2825
+ }
2826
+ var lastKnownRoster = [];
2827
+ function getLastKnownRoster() {
2828
+ return lastKnownRoster;
2829
+ }
2830
+ async function reconcileRoster(openClawAgents, cfg, logger) {
2831
+ const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
2832
+ const cohortAgents = data?.data ?? [];
2833
+ const cohortByName = new Map(cohortAgents.map((a) => [a.name.toLowerCase(), a]));
2834
+ const openClawNames = new Set(
2835
+ openClawAgents.map((a) => {
2836
+ const nameMap = cfg.agentNameMap;
2837
+ return (nameMap?.[a.id] ?? a.identity?.name ?? a.id).toLowerCase();
2838
+ })
2839
+ );
2840
+ for (const oc of openClawAgents) {
2841
+ const agentName = (cfg.agentNameMap?.[oc.id] ?? oc.identity?.name ?? oc.id).toLowerCase();
2842
+ const existing = cohortByName.get(agentName);
2843
+ if (!existing) {
2844
+ try {
2845
+ await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/agents", {
2846
+ name: agentName,
2847
+ displayName: oc.identity?.name ?? agentName,
2848
+ emoji: oc.identity?.emoji ?? "\u{1F916}",
2849
+ model: oc.model,
2850
+ status: "idle"
2851
+ });
2852
+ logger.info(`cohort-sync: provisioned new agent "${agentName}"`);
2853
+ } catch (err) {
2854
+ logger.warn(`cohort-sync: failed to provision agent "${agentName}": ${String(err)}`);
2855
+ }
2856
+ } else {
2857
+ const updates = {
2858
+ model: oc.model,
2859
+ status: "idle"
2860
+ };
2861
+ if (oc.identity?.name) {
2862
+ updates.displayName = oc.identity.name;
2863
+ }
2864
+ if (oc.identity?.emoji) {
2865
+ updates.emoji = oc.identity.emoji;
2866
+ }
2867
+ try {
2868
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${existing.id}`, updates);
2869
+ } catch (err) {
2870
+ logger.warn(`cohort-sync: failed to update agent "${agentName}": ${String(err)}`);
2871
+ }
2872
+ }
2873
+ }
2874
+ for (const cohort of cohortAgents) {
2875
+ if (!openClawNames.has(cohort.name.toLowerCase())) {
2876
+ if (cohort.status === "archived" || cohort.status === "deleted" || cohort.status === "unreachable") {
2877
+ continue;
2878
+ }
2879
+ try {
2880
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${cohort.id}`, {
2881
+ status: "unreachable"
2882
+ });
2883
+ logger.info(`cohort-sync: marked agent "${cohort.name}" as unreachable`);
2884
+ } catch (err) {
2885
+ logger.warn(
2886
+ `cohort-sync: failed to mark agent "${cohort.name}" as unreachable: ${String(err)}`
2887
+ );
2888
+ }
2889
+ }
2890
+ }
2891
+ const updatedData = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
2892
+ const finalRoster = updatedData?.data ?? cohortAgents;
2893
+ lastKnownRoster = finalRoster;
2894
+ return finalRoster;
2895
+ }
2896
+ async function markAllUnreachable(cfg, logger) {
2897
+ const roster = getLastKnownRoster();
2898
+ if (roster.length === 0) {
2899
+ logger.warn("cohort-sync: no cached roster \u2014 skipping markAllUnreachable");
2900
+ return;
2901
+ }
2902
+ for (const agent of roster) {
2903
+ if (agent.status === "unreachable" || agent.status === "archived" || agent.status === "deleted") {
2904
+ continue;
2905
+ }
2906
+ try {
2907
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
2908
+ status: "unreachable"
2909
+ });
2910
+ } catch (err) {
2911
+ logger.warn(`cohort-sync: failed to mark "${agent.name}" as unreachable: ${String(err)}`);
2912
+ }
2913
+ }
2914
+ logger.info("cohort-sync: all agents marked unreachable");
2915
+ }
2916
+ async function fullSync(agentName, model, cfg, logger, openClawAgents) {
2917
+ logger.info("cohort-sync: full sync starting");
2918
+ if (openClawAgents && openClawAgents.length > 0) {
2919
+ try {
2920
+ await reconcileRoster(openClawAgents, cfg, logger);
2921
+ } catch (err) {
2922
+ logger.warn(`cohort-sync: roster reconciliation failed: ${String(err)}`);
2923
+ }
2924
+ } else {
2925
+ await syncAgentStatus(agentName, "working", model, cfg, logger);
2926
+ }
2927
+ const skills = fetchSkills(logger);
2928
+ if (skills.length > 0) {
2929
+ await syncSkillsToV1(skills, cfg, logger);
2930
+ }
2931
+ logger.info("cohort-sync: full sync complete");
2932
+ }
2933
+
2934
+ // src/convex-bridge.ts
2935
+ import { createHash } from "crypto";
2713
2936
 
2714
2937
  // ../../node_modules/.pnpm/convex@1.33.0_patch_hash=l43bztwr6e2lbmpd6ao6hmcg24_react@19.2.1/node_modules/convex/dist/esm/index.js
2715
2938
  var version = "1.33.0";
@@ -7641,14 +7864,14 @@ var require_node_gyp_build = __commonJS({
7641
7864
  "../common/temp/node_modules/.pnpm/node-gyp-build@4.8.4/node_modules/node-gyp-build/node-gyp-build.js"(exports, module) {
7642
7865
  var fs4 = __require("fs");
7643
7866
  var path4 = __require("path");
7644
- var os4 = __require("os");
7867
+ var os5 = __require("os");
7645
7868
  var runtimeRequire = typeof __webpack_require__ === "function" ? __non_webpack_require__ : __require;
7646
7869
  var vars = process.config && process.config.variables || {};
7647
7870
  var prebuildsOnly = !!process.env.PREBUILDS_ONLY;
7648
7871
  var abi = process.versions.modules;
7649
7872
  var runtime = isElectron() ? "electron" : isNwjs() ? "node-webkit" : "node";
7650
- var arch = process.env.npm_config_arch || os4.arch();
7651
- var platform = process.env.npm_config_platform || os4.platform();
7873
+ var arch = process.env.npm_config_arch || os5.arch();
7874
+ var platform = process.env.npm_config_platform || os5.platform();
7652
7875
  var libc = process.env.LIBC || (isAlpine(platform) ? "musl" : "glibc");
7653
7876
  var armv = process.env.ARM_VERSION || (arch === "arm64" ? "8" : vars.arm_version) || "";
7654
7877
  var uv = (process.versions.uv || "").split(".")[0];
@@ -10000,7 +10223,7 @@ var require_websocket = __commonJS({
10000
10223
  var http = __require("http");
10001
10224
  var net = __require("net");
10002
10225
  var tls = __require("tls");
10003
- var { randomBytes, createHash } = __require("crypto");
10226
+ var { randomBytes, createHash: createHash2 } = __require("crypto");
10004
10227
  var { Duplex, Readable } = __require("stream");
10005
10228
  var { URL: URL2 } = __require("url");
10006
10229
  var PerMessageDeflate = require_permessage_deflate();
@@ -10657,7 +10880,7 @@ var require_websocket = __commonJS({
10657
10880
  abortHandshake(websocket, socket, "Invalid Upgrade header");
10658
10881
  return;
10659
10882
  }
10660
- const digest = createHash("sha1").update(key + GUID).digest("base64");
10883
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
10661
10884
  if (res.headers["sec-websocket-accept"] !== digest) {
10662
10885
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
10663
10886
  return;
@@ -10922,7 +11145,7 @@ var require_websocket_server = __commonJS({
10922
11145
  var EventEmitter = __require("events");
10923
11146
  var http = __require("http");
10924
11147
  var { Duplex } = __require("stream");
10925
- var { createHash } = __require("crypto");
11148
+ var { createHash: createHash2 } = __require("crypto");
10926
11149
  var extension = require_extension();
10927
11150
  var PerMessageDeflate = require_permessage_deflate();
10928
11151
  var subprotocol = require_subprotocol();
@@ -11217,7 +11440,7 @@ var require_websocket_server = __commonJS({
11217
11440
  );
11218
11441
  }
11219
11442
  if (this._state > RUNNING) return abortHandshake(socket, 503);
11220
- const digest = createHash("sha1").update(key + GUID).digest("base64");
11443
+ const digest = createHash2("sha1").update(key + GUID).digest("base64");
11221
11444
  const headers = [
11222
11445
  "HTTP/1.1 101 Switching Protocols",
11223
11446
  "Upgrade: websocket",
@@ -11553,130 +11776,98 @@ function reverseResolveAgentName(cohortName, forwardMap) {
11553
11776
  return cohortName;
11554
11777
  }
11555
11778
 
11556
- // src/subscription.ts
11779
+ // src/commands.ts
11780
+ async function executeCommand(cmd, gwClient, cfg, resolveAgentName, logger) {
11781
+ if (cmd.type === "restart") {
11782
+ logger.info("cohort-sync: restart command, terminating in 500ms");
11783
+ await new Promise((r) => setTimeout(r, 500));
11784
+ process.kill(process.pid, "SIGTERM");
11785
+ return;
11786
+ }
11787
+ if (cmd.type.startsWith("cron")) {
11788
+ if (!gwClient || !gwClient.isAlive()) {
11789
+ logger.warn(`cohort-sync: no gateway client, cannot execute ${cmd.type}`);
11790
+ return;
11791
+ }
11792
+ const nameMap = cfg.agentNameMap ?? {};
11793
+ switch (cmd.type) {
11794
+ case "cronEnable":
11795
+ await gwClient.request("cron.update", {
11796
+ jobId: cmd.payload?.jobId,
11797
+ patch: { enabled: true }
11798
+ });
11799
+ break;
11800
+ case "cronDisable":
11801
+ await gwClient.request("cron.update", {
11802
+ jobId: cmd.payload?.jobId,
11803
+ patch: { enabled: false }
11804
+ });
11805
+ break;
11806
+ case "cronDelete":
11807
+ await gwClient.request("cron.remove", {
11808
+ jobId: cmd.payload?.jobId
11809
+ });
11810
+ break;
11811
+ case "cronRunNow": {
11812
+ await gwClient.request("cron.run", { jobId: cmd.payload?.jobId });
11813
+ break;
11814
+ }
11815
+ case "cronCreate": {
11816
+ const agentId = reverseResolveAgentName(cmd.payload?.agentId ?? "main", nameMap);
11817
+ await gwClient.request("cron.add", {
11818
+ job: {
11819
+ agentId,
11820
+ name: cmd.payload?.name,
11821
+ enabled: true,
11822
+ schedule: cmd.payload?.schedule,
11823
+ payload: { kind: "agentTurn", message: cmd.payload?.message },
11824
+ sessionTarget: "isolated",
11825
+ wakeMode: "now"
11826
+ }
11827
+ });
11828
+ break;
11829
+ }
11830
+ case "cronUpdate": {
11831
+ const patch = {};
11832
+ if (cmd.payload?.name) patch.name = cmd.payload.name;
11833
+ if (cmd.payload?.schedule) patch.schedule = cmd.payload.schedule;
11834
+ if (cmd.payload?.message) patch.payload = { kind: "agentTurn", message: cmd.payload.message };
11835
+ if (cmd.payload?.agentId) patch.agentId = reverseResolveAgentName(cmd.payload.agentId, nameMap);
11836
+ await gwClient.request("cron.update", {
11837
+ jobId: cmd.payload?.jobId,
11838
+ patch
11839
+ });
11840
+ break;
11841
+ }
11842
+ default:
11843
+ logger.warn(`cohort-sync: unknown cron command type: ${cmd.type}`);
11844
+ }
11845
+ if (gwClient.isAlive()) {
11846
+ try {
11847
+ const snapResult = await gwClient.request("cron.list", { includeDisabled: true });
11848
+ const freshJobs = Array.isArray(snapResult) ? snapResult : snapResult?.jobs ?? [];
11849
+ const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11850
+ await pushCronSnapshot(cfg.apiKey, mapped);
11851
+ } catch (snapErr) {
11852
+ logger.warn(`cohort-sync: post-command snapshot push failed: ${String(snapErr)}`);
11853
+ }
11854
+ }
11855
+ return;
11856
+ }
11857
+ logger.warn(`cohort-sync: unknown command type: ${cmd.type}`);
11858
+ }
11859
+
11860
+ // src/convex-bridge.ts
11861
+ function hashApiKey(key) {
11862
+ return createHash("sha256").update(key).digest("hex");
11863
+ }
11557
11864
  function deriveConvexUrl(apiUrl) {
11865
+ const normalized = apiUrl.replace(/\/+$/, "");
11866
+ if (/^https?:\/\/api\.cohort\.bot$/i.test(normalized)) {
11867
+ return normalized.replace(/api\.cohort\.bot$/i, "ws.cohort.bot");
11868
+ }
11558
11869
  return apiUrl.replace(/\.convex\.site\/?$/, ".convex.cloud");
11559
11870
  }
11560
- var DEFAULT_BEHAVIORAL_PROMPT = `BEFORE RESPONDING:
11561
- - Does your planned response address the task's stated scope? If not, do not comment.
11562
- - Do not post acknowledgment-only responses ("got it", "sounds good", "confirmed"). If you have no new information to add, do not comment.
11563
- - If the work is complete, transition the task to "waiting" and set noReply=true on your final comment, then stop working on this task.`;
11564
- function buildNotificationMessage(n) {
11565
- let header;
11566
- let cta;
11567
- switch (n.type) {
11568
- case "comment":
11569
- if (n.isMentioned) {
11570
- header = `You were @mentioned on task #${n.taskNumber} "${n.taskTitle}"
11571
- By: ${n.actorName}`;
11572
- cta = "You were directly mentioned. Read the comment and respond using the cohort_comment tool.";
11573
- } else {
11574
- header = `New comment on task #${n.taskNumber} "${n.taskTitle}"
11575
- From: ${n.actorName}`;
11576
- cta = "Read the comment thread and respond using the cohort_comment tool if appropriate.";
11577
- }
11578
- break;
11579
- case "assignment":
11580
- header = `You were assigned to task #${n.taskNumber} "${n.taskTitle}"
11581
- By: ${n.actorName}`;
11582
- cta = "Review the task description and begin working on it.";
11583
- break;
11584
- case "status_change":
11585
- header = `Status changed on task #${n.taskNumber} "${n.taskTitle}"
11586
- By: ${n.actorName}`;
11587
- cta = "Review the status change and take any follow-up action needed.";
11588
- break;
11589
- default:
11590
- header = `Notification on task #${n.taskNumber} "${n.taskTitle}"
11591
- From: ${n.actorName}`;
11592
- cta = "Check the task and respond if needed.";
11593
- }
11594
- const body = n.preview ? `
11595
- Comment: "${n.preview}"` : "";
11596
- let scope = "";
11597
- if (n.taskDescription && n.type === "comment") {
11598
- const truncated = n.taskDescription.length > 500 ? n.taskDescription.slice(0, 500) + "..." : n.taskDescription;
11599
- scope = `
11600
-
11601
- Scope: ${truncated}`;
11602
- }
11603
- const prompt = n.behavioralPrompt ? `${n.behavioralPrompt}
11604
-
11605
- ${DEFAULT_BEHAVIORAL_PROMPT}` : DEFAULT_BEHAVIORAL_PROMPT;
11606
- const promptBlock = n.type === "comment" ? `
11607
-
11608
- ---
11609
- ${prompt}` : "";
11610
- return `${header}${scope}${body}
11611
-
11612
- ${cta}${promptBlock}`;
11613
- }
11614
- async function injectNotification(port, hooksToken, n, agentId = "main") {
11615
- const response = await fetch(`http://localhost:${port}/hooks/agent`, {
11616
- method: "POST",
11617
- headers: {
11618
- "Content-Type": "application/json",
11619
- "Authorization": `Bearer ${hooksToken}`
11620
- },
11621
- body: JSON.stringify({
11622
- message: buildNotificationMessage(n),
11623
- name: "Cohort",
11624
- agentId,
11625
- deliver: false,
11626
- sessionKey: `hook:cohort:task-${n.taskNumber}`
11627
- })
11628
- });
11629
- if (!response.ok) {
11630
- throw new Error(`/hooks/agent returned ${response.status} ${response.statusText}`);
11631
- }
11632
- }
11633
- var getUndeliveredForPlugin = makeFunctionReference("notifications:getUndeliveredForPlugin");
11634
- var markDeliveredByPlugin = makeFunctionReference("notifications:markDeliveredByPlugin");
11635
- var upsertTelemetryFromPlugin = makeFunctionReference("telemetryPlugin:upsertTelemetryFromPlugin");
11636
- var upsertSessionsFromPlugin = makeFunctionReference("telemetryPlugin:upsertSessionsFromPlugin");
11637
- var pushActivityFromPluginRef = makeFunctionReference("activityFeed:pushActivityFromPlugin");
11638
- var upsertCronSnapshotFromPluginRef = makeFunctionReference("telemetryPlugin:upsertCronSnapshotFromPlugin");
11639
- var getPendingCommandsForPlugin = makeFunctionReference("gatewayCommands:getPendingForPlugin");
11640
- var acknowledgeCommandRef = makeFunctionReference("gatewayCommands:acknowledgeCommand");
11641
- var failCommandRef = makeFunctionReference("gatewayCommands:failCommand");
11642
- var addCommentFromPluginRef = makeFunctionReference("comments:addCommentFromPlugin");
11643
- var HOT_KEY = "__cohort_sync__";
11644
- function getHotState() {
11645
- let state = globalThis[HOT_KEY];
11646
- if (!state) {
11647
- state = {
11648
- tracker: null,
11649
- client: null,
11650
- convexUrl: null,
11651
- unsubscribers: [],
11652
- lastKnownRoster: [],
11653
- intervals: { heartbeat: null, activityFlush: null },
11654
- activityBuffer: [],
11655
- channelAgentBridge: {},
11656
- gatewayPort: null,
11657
- gatewayToken: null,
11658
- gatewayProtocolClient: null,
11659
- commandSubscription: null,
11660
- cronRunNowPoll: null
11661
- };
11662
- globalThis[HOT_KEY] = state;
11663
- }
11664
- if (!state.activityBuffer) state.activityBuffer = [];
11665
- if (!state.intervals) state.intervals = { heartbeat: null, activityFlush: null };
11666
- if (!state.channelAgentBridge) state.channelAgentBridge = {};
11667
- if (!state.unsubscribers) state.unsubscribers = [];
11668
- if (!state.lastKnownRoster) state.lastKnownRoster = [];
11669
- return state;
11670
- }
11671
- function clearHotState() {
11672
- delete globalThis[HOT_KEY];
11673
- }
11674
- function setRosterHotState(roster) {
11675
- getHotState().lastKnownRoster = roster;
11676
- }
11677
- function getRosterHotState() {
11678
- return getHotState().lastKnownRoster;
11679
- }
11680
11871
  var savedLogger = null;
11681
11872
  function setLogger(logger) {
11682
11873
  savedLogger = logger;
@@ -11691,288 +11882,18 @@ function getLogger() {
11691
11882
  var client = null;
11692
11883
  var savedConvexUrl = null;
11693
11884
  var unsubscribers = [];
11694
- function setConvexUrl(cfg) {
11695
- const url = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
11696
- savedConvexUrl = url;
11697
- getHotState().convexUrl = url;
11698
- }
11699
- function restoreFromHotReload(logger) {
11700
- const state = getHotState();
11701
- if (!client && state.client) {
11702
- client = state.client;
11703
- savedConvexUrl = state.convexUrl;
11704
- logger.info("cohort-sync: recovered ConvexClient after hot-reload");
11705
- } else if (client && state.client && client !== state.client) {
11706
- try {
11707
- state.client.close();
11708
- } catch {
11709
- }
11710
- state.client = client;
11711
- logger.info("cohort-sync: closed orphaned ConvexClient from hot state");
11885
+ function createClient(convexUrl) {
11886
+ if (client) {
11887
+ client.close();
11712
11888
  }
11713
- if (unsubscribers.length === 0 && state.unsubscribers.length > 0) {
11714
- unsubscribers.push(...state.unsubscribers);
11715
- logger.info(`cohort-sync: recovered ${state.unsubscribers.length} notification subscriptions after hot-reload`);
11716
- }
11717
- }
11718
- function getOrCreateClient() {
11719
- if (client) return client;
11720
- const state = getHotState();
11721
- if (state.client) {
11722
- client = state.client;
11723
- getLogger().info("cohort-sync: recovered ConvexClient from globalThis");
11724
- return client;
11725
- }
11726
- if (!savedConvexUrl) return null;
11727
- client = new ConvexClient(savedConvexUrl);
11728
- getLogger().info(`cohort-sync: created fresh ConvexClient (${savedConvexUrl})`);
11729
- state.client = client;
11730
- state.convexUrl = savedConvexUrl;
11731
- return client;
11732
- }
11733
- async function initSubscription(port, cfg, hooksToken, logger) {
11734
- const convexUrl = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
11735
11889
  savedConvexUrl = convexUrl;
11736
- const state = getHotState();
11737
- if (state.client) {
11738
- client = state.client;
11739
- logger.info("cohort-sync: reusing hot-reload ConvexClient for subscription");
11740
- } else {
11741
- client = new ConvexClient(convexUrl);
11742
- logger.info(`cohort-sync: created new ConvexClient for subscription (${convexUrl})`);
11743
- }
11744
- state.client = client;
11745
- state.convexUrl = convexUrl;
11746
- if (!hooksToken) {
11747
- logger.warn(
11748
- `cohort-sync: hooks.token not configured \u2014 real-time notifications disabled (telemetry will still be pushed).`
11749
- );
11750
- return;
11751
- }
11752
- const agentNames = cfg.agentNameMap ? Object.values(cfg.agentNameMap) : ["main"];
11753
- const reverseNameMap = {};
11754
- if (cfg.agentNameMap) {
11755
- for (const [openclawId, cohortName] of Object.entries(cfg.agentNameMap)) {
11756
- reverseNameMap[cohortName] = openclawId;
11757
- }
11758
- }
11759
- for (const agentName of agentNames) {
11760
- const openclawAgentId = reverseNameMap[agentName] ?? agentName;
11761
- logger.info(`cohort-sync: subscribing to notifications for agent "${agentName}" (openclawId: "${openclawAgentId}")`);
11762
- let processing = false;
11763
- const unsubscribe = client.onUpdate(
11764
- getUndeliveredForPlugin,
11765
- { agent: agentName, apiKey: cfg.apiKey },
11766
- async (notifications) => {
11767
- if (processing) return;
11768
- processing = true;
11769
- try {
11770
- for (const n of notifications) {
11771
- try {
11772
- await injectNotification(port, hooksToken, n, openclawAgentId);
11773
- logger.info(`cohort-sync: injected notification for task #${n.taskNumber} (${agentName})`);
11774
- await client.mutation(markDeliveredByPlugin, {
11775
- notificationId: n._id,
11776
- apiKey: cfg.apiKey
11777
- });
11778
- } catch (err) {
11779
- logger.warn(`cohort-sync: failed to inject notification ${n._id}: ${String(err)}`);
11780
- }
11781
- }
11782
- } finally {
11783
- processing = false;
11784
- }
11785
- },
11786
- (err) => {
11787
- logger.error(`cohort-sync: subscription error for "${agentName}": ${String(err)}`);
11788
- }
11789
- );
11790
- unsubscribers.push(unsubscribe);
11791
- }
11792
- state.unsubscribers = [...unsubscribers];
11793
- }
11794
- function initCommandSubscription(cfg, logger, resolveAgentName) {
11795
- const c = getOrCreateClient();
11796
- if (!c) {
11797
- logger.warn("cohort-sync: no ConvexClient \u2014 command subscription skipped");
11798
- return null;
11799
- }
11800
- let processing = false;
11801
- const unsubscribe = c.onUpdate(
11802
- getPendingCommandsForPlugin,
11803
- { apiKey: cfg.apiKey },
11804
- async (commands) => {
11805
- if (processing) return;
11806
- if (commands.length === 0) return;
11807
- processing = true;
11808
- try {
11809
- for (const cmd of commands) {
11810
- logger.info(`cohort-sync: processing command: ${cmd.type} (${cmd._id})`);
11811
- try {
11812
- await c.mutation(acknowledgeCommandRef, {
11813
- commandId: cmd._id,
11814
- apiKey: cfg.apiKey
11815
- });
11816
- if (cmd.type === "restart") {
11817
- logger.info("cohort-sync: restart acknowledged, terminating in 500ms");
11818
- await new Promise((r) => setTimeout(r, 500));
11819
- process.kill(process.pid, "SIGTERM");
11820
- return;
11821
- }
11822
- if (cmd.type.startsWith("cron")) {
11823
- const hotState = getHotState();
11824
- const port = hotState.gatewayPort;
11825
- const token = hotState.gatewayToken;
11826
- if (!port || !token) {
11827
- logger.warn(`cohort-sync: no gateway port/token, cannot execute ${cmd.type}`);
11828
- continue;
11829
- }
11830
- const gwClient = new GatewayClient(port, token, logger);
11831
- try {
11832
- await gwClient.connect();
11833
- const nameMap = cfg.agentNameMap ?? {};
11834
- switch (cmd.type) {
11835
- case "cronEnable":
11836
- await gwClient.request("cron.update", {
11837
- jobId: cmd.payload?.jobId,
11838
- patch: { enabled: true }
11839
- });
11840
- break;
11841
- case "cronDisable":
11842
- await gwClient.request("cron.update", {
11843
- jobId: cmd.payload?.jobId,
11844
- patch: { enabled: false }
11845
- });
11846
- break;
11847
- case "cronDelete":
11848
- await gwClient.request("cron.remove", {
11849
- jobId: cmd.payload?.jobId
11850
- });
11851
- break;
11852
- case "cronRunNow": {
11853
- const runResult = await gwClient.request(
11854
- "cron.run",
11855
- { jobId: cmd.payload?.jobId }
11856
- );
11857
- if (runResult?.ok && runResult?.ran) {
11858
- const jobId = cmd.payload?.jobId;
11859
- let polls = 0;
11860
- const hs = getHotState();
11861
- if (hs.cronRunNowPoll) clearInterval(hs.cronRunNowPoll);
11862
- const pollInterval = setInterval(async () => {
11863
- polls++;
11864
- if (polls >= 15) {
11865
- clearInterval(pollInterval);
11866
- hs.cronRunNowPoll = null;
11867
- return;
11868
- }
11869
- try {
11870
- const pollClient = getHotState().gatewayProtocolClient;
11871
- if (!pollClient || !pollClient.isAlive()) {
11872
- clearInterval(pollInterval);
11873
- hs.cronRunNowPoll = null;
11874
- return;
11875
- }
11876
- const pollResult = await pollClient.request("cron.list");
11877
- const freshJobs = Array.isArray(pollResult) ? pollResult : pollResult?.jobs ?? [];
11878
- const job = freshJobs.find((j) => j.id === jobId);
11879
- if (job && !job.state?.runningAtMs) {
11880
- clearInterval(pollInterval);
11881
- hs.cronRunNowPoll = null;
11882
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11883
- await pushCronSnapshot(cfg.apiKey, mapped);
11884
- }
11885
- } catch {
11886
- }
11887
- }, 2e3);
11888
- hs.cronRunNowPoll = pollInterval;
11889
- }
11890
- break;
11891
- }
11892
- case "cronCreate": {
11893
- const agentId = reverseResolveAgentName(cmd.payload?.agentId ?? "main", nameMap);
11894
- await gwClient.request("cron.add", {
11895
- job: {
11896
- agentId,
11897
- name: cmd.payload?.name,
11898
- enabled: true,
11899
- schedule: cmd.payload?.schedule,
11900
- payload: { kind: "agentTurn", message: cmd.payload?.message },
11901
- sessionTarget: "isolated",
11902
- wakeMode: "now"
11903
- }
11904
- });
11905
- break;
11906
- }
11907
- case "cronUpdate": {
11908
- const patch = {};
11909
- if (cmd.payload?.name) patch.name = cmd.payload.name;
11910
- if (cmd.payload?.schedule) patch.schedule = cmd.payload.schedule;
11911
- if (cmd.payload?.message) patch.payload = { kind: "agentTurn", message: cmd.payload.message };
11912
- if (cmd.payload?.agentId) patch.agentId = reverseResolveAgentName(cmd.payload.agentId, nameMap);
11913
- await gwClient.request("cron.update", {
11914
- jobId: cmd.payload?.jobId,
11915
- patch
11916
- });
11917
- break;
11918
- }
11919
- default:
11920
- logger.warn(`cohort-sync: unknown cron command type: ${cmd.type}`);
11921
- }
11922
- if (gwClient.isAlive()) {
11923
- try {
11924
- const snapResult = await gwClient.request("cron.list");
11925
- const freshJobs = Array.isArray(snapResult) ? snapResult : snapResult?.jobs ?? [];
11926
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11927
- await pushCronSnapshot(cfg.apiKey, mapped);
11928
- } catch (snapErr) {
11929
- logger.warn(`cohort-sync: post-command snapshot push failed: ${String(snapErr)}`);
11930
- }
11931
- }
11932
- } finally {
11933
- gwClient.close();
11934
- }
11935
- } else {
11936
- logger.warn(`cohort-sync: unknown command type: ${cmd.type}`);
11937
- }
11938
- } catch (err) {
11939
- logger.error(`cohort-sync: failed to process command ${cmd._id}: ${String(err)}`);
11940
- try {
11941
- await c.mutation(failCommandRef, {
11942
- commandId: cmd._id,
11943
- apiKey: cfg.apiKey,
11944
- reason: String(err).slice(0, 500)
11945
- });
11946
- } catch (failErr) {
11947
- logger.error(`cohort-sync: failed to mark command ${cmd._id} as failed: ${String(failErr)}`);
11948
- }
11949
- }
11950
- }
11951
- } finally {
11952
- processing = false;
11953
- }
11954
- },
11955
- (err) => {
11956
- logger.error(`cohort-sync: command subscription error: ${String(err)}`);
11957
- }
11958
- );
11959
- logger.info("cohort-sync: command subscription active");
11960
- return unsubscribe;
11890
+ client = new ConvexClient(convexUrl);
11891
+ return client;
11961
11892
  }
11962
- async function callAddCommentFromPlugin(apiKey, args) {
11963
- const c = getOrCreateClient();
11964
- if (!c) {
11965
- throw new Error("Convex client not initialized \u2014 subscription may not be active");
11966
- }
11967
- return await c.mutation(addCommentFromPluginRef, {
11968
- apiKey,
11969
- taskNumber: args.taskNumber,
11970
- agentName: args.agentName,
11971
- content: args.content,
11972
- noReply: args.noReply
11973
- });
11893
+ function getClient() {
11894
+ return client;
11974
11895
  }
11975
- function closeSubscription() {
11896
+ function closeBridge() {
11976
11897
  for (const unsub of unsubscribers) {
11977
11898
  try {
11978
11899
  unsub();
@@ -11980,327 +11901,258 @@ function closeSubscription() {
11980
11901
  }
11981
11902
  }
11982
11903
  unsubscribers.length = 0;
11983
- const state = getHotState();
11984
- for (const unsub of state.unsubscribers) {
11985
- try {
11986
- unsub();
11987
- } catch {
11988
- }
11989
- }
11990
- if (state.commandSubscription) {
11904
+ if (commandUnsubscriber) {
11991
11905
  try {
11992
- state.commandSubscription();
11906
+ commandUnsubscriber();
11993
11907
  } catch {
11994
11908
  }
11995
- state.commandSubscription = null;
11909
+ commandUnsubscriber = null;
11996
11910
  }
11997
- if (state.gatewayProtocolClient) {
11998
- try {
11999
- state.gatewayProtocolClient.close();
12000
- } catch {
12001
- }
12002
- state.gatewayProtocolClient = null;
11911
+ if (client) {
11912
+ client.close();
11913
+ client = null;
12003
11914
  }
12004
- client?.close();
12005
- client = null;
12006
- clearHotState();
11915
+ savedConvexUrl = null;
12007
11916
  }
11917
+ var commandUnsubscriber = null;
11918
+ var upsertTelemetryFromPlugin = makeFunctionReference("telemetryPlugin:upsertTelemetryFromPlugin");
11919
+ var upsertSessionsFromPlugin = makeFunctionReference("telemetryPlugin:upsertSessionsFromPlugin");
11920
+ var pushActivityFromPluginRef = makeFunctionReference("activityFeed:pushActivityFromPlugin");
11921
+ var upsertCronSnapshotFromPluginRef = makeFunctionReference("telemetryPlugin:upsertCronSnapshotFromPlugin");
11922
+ var getUndeliveredForPlugin = makeFunctionReference("notifications:getUndeliveredForPlugin");
11923
+ var markDeliveredByPlugin = makeFunctionReference("notifications:markDeliveredByPlugin");
11924
+ var getPendingCommandsForPlugin = makeFunctionReference("gatewayCommands:getPendingForPlugin");
11925
+ var acknowledgeCommandRef = makeFunctionReference("gatewayCommands:acknowledgeCommand");
11926
+ var failCommandRef = makeFunctionReference("gatewayCommands:failCommand");
11927
+ var addCommentFromPluginRef = makeFunctionReference("comments:addCommentFromPlugin");
12008
11928
  async function pushTelemetry(apiKey, data) {
12009
- const c = getOrCreateClient();
11929
+ const c = getClient();
12010
11930
  if (!c) return;
12011
11931
  try {
12012
- await c.mutation(upsertTelemetryFromPlugin, { apiKey, ...data });
11932
+ await c.mutation(upsertTelemetryFromPlugin, { apiKeyHash: hashApiKey(apiKey), ...data });
12013
11933
  } catch (err) {
12014
11934
  getLogger().error(`cohort-sync: pushTelemetry failed: ${err}`);
12015
11935
  }
12016
11936
  }
12017
11937
  async function pushSessions(apiKey, agentName, sessions) {
12018
- const c = getOrCreateClient();
11938
+ const c = getClient();
12019
11939
  if (!c) return;
12020
11940
  try {
12021
- await c.mutation(upsertSessionsFromPlugin, { apiKey, agentName, sessions });
11941
+ await c.mutation(upsertSessionsFromPlugin, { apiKeyHash: hashApiKey(apiKey), agentName, sessions });
12022
11942
  } catch (err) {
12023
11943
  getLogger().error(`cohort-sync: pushSessions failed: ${err}`);
12024
11944
  }
12025
11945
  }
12026
11946
  async function pushActivity(apiKey, entries) {
12027
11947
  if (entries.length === 0) return;
12028
- const c = getOrCreateClient();
11948
+ const c = getClient();
12029
11949
  if (!c) return;
12030
11950
  try {
12031
- await c.mutation(pushActivityFromPluginRef, { apiKey, entries });
11951
+ await c.mutation(pushActivityFromPluginRef, { apiKeyHash: hashApiKey(apiKey), entries });
12032
11952
  } catch (err) {
12033
11953
  getLogger().error(`cohort-sync: pushActivity failed: ${err}`);
12034
11954
  }
12035
11955
  }
12036
11956
  async function pushCronSnapshot(apiKey, jobs) {
12037
- const c = getOrCreateClient();
12038
- if (!c) return;
11957
+ const c = getClient();
11958
+ if (!c) return false;
12039
11959
  try {
12040
- await c.mutation(upsertCronSnapshotFromPluginRef, { apiKey, jobs });
11960
+ await c.mutation(upsertCronSnapshotFromPluginRef, { apiKeyHash: hashApiKey(apiKey), jobs });
11961
+ return true;
12041
11962
  } catch (err) {
12042
11963
  getLogger().error(`cohort-sync: pushCronSnapshot failed: ${err}`);
11964
+ return false;
12043
11965
  }
12044
11966
  }
12045
- function saveIntervalsToHot(heartbeat, activityFlush) {
12046
- const state = getHotState();
12047
- state.intervals = { heartbeat, activityFlush };
12048
- }
12049
- function clearIntervalsFromHot() {
12050
- const state = getHotState();
12051
- if (state.intervals.heartbeat) clearInterval(state.intervals.heartbeat);
12052
- if (state.intervals.activityFlush) clearInterval(state.intervals.activityFlush);
12053
- if (state.cronRunNowPoll) clearInterval(state.cronRunNowPoll);
12054
- state.intervals = { heartbeat: null, activityFlush: null };
12055
- state.cronRunNowPoll = null;
12056
- }
12057
- function addActivityToHot(entry) {
12058
- const state = getHotState();
12059
- state.activityBuffer.push(entry);
12060
- const logger = savedLogger;
12061
- if (logger) {
12062
- logger.info(`cohort-sync: +activity [${entry.category}] "${entry.text}"`);
12063
- }
12064
- }
12065
- function drainActivityFromHot() {
12066
- const state = getHotState();
12067
- const buf = state.activityBuffer;
12068
- state.activityBuffer = [];
12069
- return buf;
12070
- }
12071
- function setChannelAgent(channelId, agentName) {
12072
- getHotState().channelAgentBridge[channelId] = agentName;
12073
- }
12074
- function getChannelAgent(channelId) {
12075
- return getHotState().channelAgentBridge[channelId] ?? null;
12076
- }
12077
-
12078
- // src/sync.ts
12079
- function extractJson(raw) {
12080
- const jsonStart = raw.search(/[\[{]/);
12081
- const jsonEndBracket = raw.lastIndexOf("]");
12082
- const jsonEndBrace = raw.lastIndexOf("}");
12083
- const jsonEnd = Math.max(jsonEndBracket, jsonEndBrace);
12084
- if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) {
12085
- throw new Error("No JSON found in output");
12086
- }
12087
- return raw.slice(jsonStart, jsonEnd + 1);
12088
- }
12089
- function fetchSkills(logger) {
12090
- try {
12091
- const raw = execSync("openclaw skills list --json", {
12092
- encoding: "utf8",
12093
- timeout: 3e4,
12094
- stdio: ["ignore", "pipe", "ignore"],
12095
- env: { ...process.env, NO_COLOR: "1" }
12096
- });
12097
- const parsed = JSON.parse(extractJson(raw));
12098
- const list = Array.isArray(parsed) ? parsed : parsed?.skills ?? [];
12099
- return list.map((s) => ({
12100
- name: String(s.name ?? s.id ?? "unknown"),
12101
- description: String(s.description ?? ""),
12102
- source: String(s.source ?? s.origin ?? "unknown"),
12103
- ...s.emoji ? { emoji: String(s.emoji) } : {}
12104
- }));
12105
- } catch (err) {
12106
- logger.warn(`cohort-sync: failed to fetch skills: ${String(err)}`);
12107
- return [];
11967
+ async function callAddCommentFromPlugin(apiKey, args) {
11968
+ const c = getClient();
11969
+ if (!c) {
11970
+ throw new Error("Convex client not initialized \u2014 subscription may not be active");
12108
11971
  }
12109
- }
12110
- var VALID_STATUSES = /* @__PURE__ */ new Set(["idle", "working", "waiting"]);
12111
- function normalizeStatus(status) {
12112
- return VALID_STATUSES.has(status) ? status : "idle";
12113
- }
12114
- async function v1Get(apiUrl, apiKey, path4) {
12115
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12116
- headers: { Authorization: `Bearer ${apiKey}` },
12117
- signal: AbortSignal.timeout(1e4)
12118
- });
12119
- if (!res.ok) throw new Error(`GET ${path4} \u2192 ${res.status}`);
12120
- return res.json();
12121
- }
12122
- async function v1Patch(apiUrl, apiKey, path4, body) {
12123
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12124
- method: "PATCH",
12125
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12126
- body: JSON.stringify(body),
12127
- signal: AbortSignal.timeout(1e4)
12128
- });
12129
- if (!res.ok) throw new Error(`PATCH ${path4} \u2192 ${res.status}`);
12130
- }
12131
- async function v1Post(apiUrl, apiKey, path4, body) {
12132
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12133
- method: "POST",
12134
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12135
- body: JSON.stringify(body),
12136
- signal: AbortSignal.timeout(1e4)
11972
+ return await c.mutation(addCommentFromPluginRef, {
11973
+ apiKeyHash: hashApiKey(apiKey),
11974
+ taskNumber: args.taskNumber,
11975
+ agentName: args.agentName,
11976
+ content: args.content,
11977
+ noReply: args.noReply
12137
11978
  });
12138
- if (!res.ok) throw new Error(`POST ${path4} \u2192 ${res.status}`);
12139
- }
12140
- async function checkForUpdate(currentVersion, logger) {
12141
- try {
12142
- const res = await fetch("https://registry.npmjs.org/@cfio/cohort-sync/latest", {
12143
- signal: AbortSignal.timeout(5e3)
12144
- });
12145
- if (!res.ok) return;
12146
- const data = await res.json();
12147
- const latest = data.version;
12148
- if (latest && latest !== currentVersion) {
12149
- logger.warn(
12150
- `cohort-sync: update available (${currentVersion} \u2192 ${latest}) \u2014 run "npm install -g @cfio/cohort-sync" to update`
12151
- );
12152
- }
12153
- } catch {
12154
- }
12155
- }
12156
- async function syncAgentStatus(agentName, status, model, cfg, logger) {
12157
- try {
12158
- const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12159
- const agents = data?.data ?? [];
12160
- const agent = agents.find(
12161
- (a) => a.name.toLowerCase() === agentName.toLowerCase()
12162
- );
12163
- if (!agent) {
12164
- const available = agents.map((a) => a.name).join(", ") || "(none)";
12165
- logger.warn(
12166
- `cohort-sync: agent "${agentName}" not found in workspace \u2014 skipping status sync. Available agents: [${available}]. Configure agentNameMap in your plugin config to map OpenClaw agent IDs to Cohort names.`
12167
- );
12168
- return;
12169
- }
12170
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
12171
- status: normalizeStatus(status),
12172
- model
12173
- });
12174
- logger.info(`cohort-sync: agent "${agentName}" \u2192 ${normalizeStatus(status)}`);
12175
- } catch (err) {
12176
- logger.warn(`cohort-sync: syncAgentStatus failed: ${String(err)}`);
12177
- }
12178
11979
  }
12179
- async function syncSkillsToV1(skills, cfg, logger) {
12180
- for (const skill of skills) {
12181
- try {
12182
- await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/skills", {
12183
- name: skill.name,
12184
- description: skill.description
12185
- });
12186
- } catch (err) {
12187
- logger.warn(`cohort-sync: failed to sync skill "${skill.name}": ${String(err)}`);
12188
- }
11980
+ var DEFAULT_BEHAVIORAL_PROMPT = `BEFORE RESPONDING:
11981
+ - Does your planned response address the task's stated scope? If not, do not comment.
11982
+ - Do not post acknowledgment-only responses ("got it", "sounds good", "confirmed"). If you have no new information to add, do not comment.
11983
+ - If the work is complete, transition the task to "waiting" and set noReply=true on your final comment, then stop working on this task.`;
11984
+ function buildNotificationMessage(n) {
11985
+ let header;
11986
+ let cta;
11987
+ switch (n.type) {
11988
+ case "comment":
11989
+ if (n.isMentioned) {
11990
+ header = `You were @mentioned on task #${n.taskNumber} "${n.taskTitle}"
11991
+ By: ${n.actorName}`;
11992
+ cta = "You were directly mentioned. Read the comment and respond using the cohort_comment tool.";
11993
+ } else {
11994
+ header = `New comment on task #${n.taskNumber} "${n.taskTitle}"
11995
+ From: ${n.actorName}`;
11996
+ cta = "Read the comment thread and respond using the cohort_comment tool if appropriate.";
11997
+ }
11998
+ break;
11999
+ case "assignment":
12000
+ header = `You were assigned to task #${n.taskNumber} "${n.taskTitle}"
12001
+ By: ${n.actorName}`;
12002
+ cta = "Review the task description and begin working on it.";
12003
+ break;
12004
+ case "status_change":
12005
+ header = `Status changed on task #${n.taskNumber} "${n.taskTitle}"
12006
+ By: ${n.actorName}`;
12007
+ cta = "Review the status change and take any follow-up action needed.";
12008
+ break;
12009
+ default:
12010
+ header = `Notification on task #${n.taskNumber} "${n.taskTitle}"
12011
+ From: ${n.actorName}`;
12012
+ cta = "Check the task and respond if needed.";
12189
12013
  }
12190
- }
12191
- var lastKnownRoster = [];
12192
- function getLastKnownRoster() {
12193
- return lastKnownRoster;
12194
- }
12195
- function restoreRosterFromHotReload(hotRoster, logger) {
12196
- if (hotRoster && hotRoster.length > 0 && lastKnownRoster.length === 0) {
12197
- lastKnownRoster = hotRoster;
12198
- logger.info(`cohort-sync: recovered roster (${hotRoster.length} agents) after hot-reload`);
12014
+ const body = n.preview ? `
12015
+ Comment: "${n.preview}"` : "";
12016
+ let scope = "";
12017
+ if (n.taskDescription && n.type === "comment") {
12018
+ const truncated = n.taskDescription.length > 500 ? n.taskDescription.slice(0, 500) + "..." : n.taskDescription;
12019
+ scope = `
12020
+
12021
+ Scope: ${truncated}`;
12199
12022
  }
12023
+ const prompt = n.behavioralPrompt ? `${n.behavioralPrompt}
12024
+
12025
+ ${DEFAULT_BEHAVIORAL_PROMPT}` : DEFAULT_BEHAVIORAL_PROMPT;
12026
+ const promptBlock = n.type === "comment" ? `
12027
+
12028
+ ---
12029
+ ${prompt}` : "";
12030
+ return `${header}${scope}${body}
12031
+
12032
+ ${cta}${promptBlock}`;
12200
12033
  }
12201
- async function reconcileRoster(openClawAgents, cfg, logger) {
12202
- const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12203
- const cohortAgents = data?.data ?? [];
12204
- const cohortByName = new Map(cohortAgents.map((a) => [a.name.toLowerCase(), a]));
12205
- const openClawNames = new Set(
12206
- openClawAgents.map((a) => {
12207
- const nameMap = cfg.agentNameMap;
12208
- return (nameMap?.[a.id] ?? a.identity?.name ?? a.id).toLowerCase();
12209
- })
12210
- );
12211
- for (const oc of openClawAgents) {
12212
- const agentName = (cfg.agentNameMap?.[oc.id] ?? oc.identity?.name ?? oc.id).toLowerCase();
12213
- const existing = cohortByName.get(agentName);
12214
- if (!existing) {
12215
- try {
12216
- await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/agents", {
12217
- name: agentName,
12218
- displayName: oc.identity?.name ?? agentName,
12219
- emoji: oc.identity?.emoji ?? "\u{1F916}",
12220
- model: oc.model,
12221
- status: "idle"
12222
- });
12223
- logger.info(`cohort-sync: provisioned new agent "${agentName}"`);
12224
- } catch (err) {
12225
- logger.warn(`cohort-sync: failed to provision agent "${agentName}": ${String(err)}`);
12226
- }
12227
- } else {
12228
- const updates = {
12229
- model: oc.model,
12230
- status: "idle"
12231
- };
12232
- if (oc.identity?.name) {
12233
- updates.displayName = oc.identity.name;
12234
- }
12235
- if (oc.identity?.emoji) {
12236
- updates.emoji = oc.identity.emoji;
12237
- }
12238
- try {
12239
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${existing.id}`, updates);
12240
- } catch (err) {
12241
- logger.warn(`cohort-sync: failed to update agent "${agentName}": ${String(err)}`);
12242
- }
12243
- }
12244
- }
12245
- for (const cohort of cohortAgents) {
12246
- if (!openClawNames.has(cohort.name.toLowerCase())) {
12247
- if (cohort.status === "archived" || cohort.status === "deleted" || cohort.status === "unreachable") {
12248
- continue;
12249
- }
12250
- try {
12251
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${cohort.id}`, {
12252
- status: "unreachable"
12253
- });
12254
- logger.info(`cohort-sync: marked agent "${cohort.name}" as unreachable`);
12255
- } catch (err) {
12256
- logger.warn(
12257
- `cohort-sync: failed to mark agent "${cohort.name}" as unreachable: ${String(err)}`
12258
- );
12259
- }
12260
- }
12034
+ async function injectNotification(port, hooksToken, n, agentId = "main") {
12035
+ const response = await fetch(`http://localhost:${port}/hooks/agent`, {
12036
+ method: "POST",
12037
+ headers: {
12038
+ "Content-Type": "application/json",
12039
+ "Authorization": `Bearer ${hooksToken}`
12040
+ },
12041
+ body: JSON.stringify({
12042
+ message: buildNotificationMessage(n),
12043
+ name: "Cohort",
12044
+ agentId,
12045
+ deliver: false,
12046
+ sessionKey: `hook:cohort:task-${n.taskNumber}`
12047
+ })
12048
+ });
12049
+ if (!response.ok) {
12050
+ throw new Error(`/hooks/agent returned ${response.status} ${response.statusText}`);
12261
12051
  }
12262
- const updatedData = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12263
- const finalRoster = updatedData?.data ?? cohortAgents;
12264
- lastKnownRoster = finalRoster;
12265
- setRosterHotState(finalRoster);
12266
- return finalRoster;
12267
12052
  }
12268
- async function markAllUnreachable(cfg, logger) {
12269
- const roster = getLastKnownRoster();
12270
- if (roster.length === 0) {
12271
- logger.warn("cohort-sync: no cached roster \u2014 skipping markAllUnreachable");
12053
+ async function startNotificationSubscription(port, cfg, hooksToken, logger) {
12054
+ const c = getClient();
12055
+ if (!c) {
12056
+ logger.warn("cohort-sync: no ConvexClient \u2014 notification subscription skipped");
12272
12057
  return;
12273
12058
  }
12274
- for (const agent of roster) {
12275
- if (agent.status === "unreachable" || agent.status === "archived" || agent.status === "deleted") {
12276
- continue;
12277
- }
12278
- try {
12279
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
12280
- status: "unreachable"
12281
- });
12282
- } catch (err) {
12283
- logger.warn(`cohort-sync: failed to mark "${agent.name}" as unreachable: ${String(err)}`);
12284
- }
12059
+ if (!hooksToken) {
12060
+ logger.warn(
12061
+ `cohort-sync: hooks.token not configured \u2014 real-time notifications disabled (telemetry will still be pushed).`
12062
+ );
12063
+ return;
12285
12064
  }
12286
- logger.info("cohort-sync: all agents marked unreachable");
12287
- }
12288
- async function fullSync(agentName, model, cfg, logger, openClawAgents) {
12289
- logger.info("cohort-sync: full sync starting");
12290
- if (openClawAgents && openClawAgents.length > 0) {
12291
- try {
12292
- await reconcileRoster(openClawAgents, cfg, logger);
12293
- } catch (err) {
12294
- logger.warn(`cohort-sync: roster reconciliation failed: ${String(err)}`);
12065
+ const agentNames = cfg.agentNameMap ? Object.values(cfg.agentNameMap) : ["main"];
12066
+ const reverseNameMap = {};
12067
+ if (cfg.agentNameMap) {
12068
+ for (const [openclawId, cohortName] of Object.entries(cfg.agentNameMap)) {
12069
+ reverseNameMap[cohortName] = openclawId;
12295
12070
  }
12296
- } else {
12297
- await syncAgentStatus(agentName, "working", model, cfg, logger);
12298
12071
  }
12299
- const skills = fetchSkills(logger);
12300
- if (skills.length > 0) {
12301
- await syncSkillsToV1(skills, cfg, logger);
12072
+ for (const agentName of agentNames) {
12073
+ const openclawAgentId = reverseNameMap[agentName] ?? agentName;
12074
+ logger.info(`cohort-sync: subscribing to notifications for agent "${agentName}" (openclawId: "${openclawAgentId}")`);
12075
+ let processing = false;
12076
+ const apiKeyHash = hashApiKey(cfg.apiKey);
12077
+ const unsubscribe = c.onUpdate(
12078
+ getUndeliveredForPlugin,
12079
+ { agent: agentName, apiKeyHash },
12080
+ async (notifications) => {
12081
+ if (processing) return;
12082
+ processing = true;
12083
+ try {
12084
+ for (const n of notifications) {
12085
+ try {
12086
+ await injectNotification(port, hooksToken, n, openclawAgentId);
12087
+ logger.info(`cohort-sync: injected notification for task #${n.taskNumber} (${agentName})`);
12088
+ await c.mutation(markDeliveredByPlugin, {
12089
+ notificationId: n._id,
12090
+ apiKeyHash
12091
+ });
12092
+ } catch (err) {
12093
+ logger.warn(`cohort-sync: failed to inject notification ${n._id}: ${String(err)}`);
12094
+ }
12095
+ }
12096
+ } finally {
12097
+ processing = false;
12098
+ }
12099
+ },
12100
+ (err) => {
12101
+ logger.error(`cohort-sync: subscription error for "${agentName}": ${String(err)}`);
12102
+ }
12103
+ );
12104
+ unsubscribers.push(unsubscribe);
12302
12105
  }
12303
- logger.info("cohort-sync: full sync complete");
12106
+ }
12107
+ function startCommandSubscription(cfg, logger, resolveAgentName, gwClient) {
12108
+ const c = getClient();
12109
+ if (!c) {
12110
+ logger.warn("cohort-sync: no ConvexClient \u2014 command subscription skipped");
12111
+ return null;
12112
+ }
12113
+ let processing = false;
12114
+ const apiKeyHash = hashApiKey(cfg.apiKey);
12115
+ const unsubscribe = c.onUpdate(
12116
+ getPendingCommandsForPlugin,
12117
+ { apiKeyHash },
12118
+ async (commands) => {
12119
+ if (processing) return;
12120
+ if (commands.length === 0) return;
12121
+ processing = true;
12122
+ try {
12123
+ for (const cmd of commands) {
12124
+ logger.info(`cohort-sync: processing command: ${cmd.type} (${cmd._id})`);
12125
+ try {
12126
+ await c.mutation(acknowledgeCommandRef, {
12127
+ commandId: cmd._id,
12128
+ apiKeyHash
12129
+ });
12130
+ await executeCommand(cmd, gwClient, cfg, resolveAgentName, logger);
12131
+ if (cmd.type === "restart") return;
12132
+ } catch (err) {
12133
+ logger.error(`cohort-sync: failed to process command ${cmd._id}: ${String(err)}`);
12134
+ try {
12135
+ await c.mutation(failCommandRef, {
12136
+ commandId: cmd._id,
12137
+ apiKeyHash,
12138
+ reason: String(err).slice(0, 500)
12139
+ });
12140
+ } catch (failErr) {
12141
+ logger.error(`cohort-sync: failed to mark command ${cmd._id} as failed: ${String(failErr)}`);
12142
+ }
12143
+ }
12144
+ }
12145
+ } finally {
12146
+ processing = false;
12147
+ }
12148
+ },
12149
+ (err) => {
12150
+ logger.error(`cohort-sync: command subscription error: ${String(err)}`);
12151
+ }
12152
+ );
12153
+ commandUnsubscriber = unsubscribe;
12154
+ logger.info("cohort-sync: command subscription active");
12155
+ return unsubscribe;
12304
12156
  }
12305
12157
 
12306
12158
  // src/gateway-client.ts
@@ -12428,7 +12280,7 @@ function buildConnectFrame(id, token, pluginVersion, identity, nonce) {
12428
12280
  clientId: "gateway-client",
12429
12281
  clientMode: "backend",
12430
12282
  role: "operator",
12431
- scopes: ["operator.read", "operator.write"],
12283
+ scopes: ["operator.read", "operator.write", "operator.admin"],
12432
12284
  signedAtMs,
12433
12285
  token,
12434
12286
  nonce,
@@ -12450,7 +12302,7 @@ function buildConnectFrame(id, token, pluginVersion, identity, nonce) {
12450
12302
  mode: "backend"
12451
12303
  },
12452
12304
  role: "operator",
12453
- scopes: ["operator.read", "operator.write"],
12305
+ scopes: ["operator.read", "operator.write", "operator.admin"],
12454
12306
  auth: { token },
12455
12307
  device: {
12456
12308
  id: identity.deviceId,
@@ -12487,13 +12339,7 @@ function parseHelloOk(response) {
12487
12339
  snapshot: payload.snapshot
12488
12340
  };
12489
12341
  }
12490
- function getPendingRequests() {
12491
- const g = globalThis;
12492
- const hot = g.__cohort_sync__ ?? (g.__cohort_sync__ = {});
12493
- if (!hot.pendingGatewayRequests) hot.pendingGatewayRequests = /* @__PURE__ */ new Map();
12494
- return hot.pendingGatewayRequests;
12495
- }
12496
- var GatewayClient2 = class {
12342
+ var GatewayClient = class {
12497
12343
  port;
12498
12344
  logger;
12499
12345
  ws = null;
@@ -12507,9 +12353,12 @@ var GatewayClient2 = class {
12507
12353
  tickIntervalMs = 15e3;
12508
12354
  // default; overwritten by hello-ok response
12509
12355
  deviceIdentity;
12356
+ pendingRequests = /* @__PURE__ */ new Map();
12510
12357
  /** Public Sets populated from hello-ok — consumers can inspect gateway capabilities */
12511
12358
  availableMethods = /* @__PURE__ */ new Set();
12512
12359
  availableEvents = /* @__PURE__ */ new Set();
12360
+ /** Called after a successful reconnection (WebSocket re-established after drop) */
12361
+ onReconnect = null;
12513
12362
  /**
12514
12363
  * @param port - Gateway WebSocket port
12515
12364
  * @param token - Auth token (stored in closure via constructor param, NOT on this or globalThis)
@@ -12636,12 +12485,11 @@ var GatewayClient2 = class {
12636
12485
  this.stopTickWatchdog();
12637
12486
  diag("GW_CLIENT_WS_CLOSED", { port: this.port });
12638
12487
  this.logger.warn("cohort-sync: gateway client WebSocket closed");
12639
- const pending = getPendingRequests();
12640
- for (const [, entry] of pending) {
12488
+ for (const [, entry] of this.pendingRequests) {
12641
12489
  clearTimeout(entry.timer);
12642
12490
  entry.reject(new Error("Gateway WebSocket closed"));
12643
12491
  }
12644
- pending.clear();
12492
+ this.pendingRequests.clear();
12645
12493
  if (!settled) {
12646
12494
  settle(new Error("Gateway WebSocket closed during handshake"));
12647
12495
  }
@@ -12672,6 +12520,18 @@ var GatewayClient2 = class {
12672
12520
  }
12673
12521
  handlers.add(handler);
12674
12522
  }
12523
+ /**
12524
+ * Remove an event handler previously registered with on().
12525
+ */
12526
+ off(event, handler) {
12527
+ const handlers = this.eventHandlers.get(event);
12528
+ if (handlers) {
12529
+ handlers.delete(handler);
12530
+ if (handlers.size === 0) {
12531
+ this.eventHandlers.delete(event);
12532
+ }
12533
+ }
12534
+ }
12675
12535
  /**
12676
12536
  * Send a request to the gateway and wait for the response.
12677
12537
  *
@@ -12692,13 +12552,12 @@ var GatewayClient2 = class {
12692
12552
  method,
12693
12553
  params
12694
12554
  };
12695
- const pending = getPendingRequests();
12696
12555
  return new Promise((resolve, reject) => {
12697
12556
  const timer = setTimeout(() => {
12698
- pending.delete(id);
12557
+ this.pendingRequests.delete(id);
12699
12558
  reject(new Error(`Gateway method "${method}" timed out after ${timeoutMs}ms`));
12700
12559
  }, timeoutMs);
12701
- pending.set(id, {
12560
+ this.pendingRequests.set(id, {
12702
12561
  resolve,
12703
12562
  reject,
12704
12563
  timer
@@ -12717,12 +12576,11 @@ var GatewayClient2 = class {
12717
12576
  clearTimeout(this.reconnectTimer);
12718
12577
  this.reconnectTimer = null;
12719
12578
  }
12720
- const pending = getPendingRequests();
12721
- for (const [, entry] of pending) {
12579
+ for (const [, entry] of this.pendingRequests) {
12722
12580
  clearTimeout(entry.timer);
12723
12581
  entry.reject(new Error("Gateway client closed"));
12724
12582
  }
12725
- pending.clear();
12583
+ this.pendingRequests.clear();
12726
12584
  if (this.ws) {
12727
12585
  this.ws.close();
12728
12586
  this.ws = null;
@@ -12752,10 +12610,9 @@ var GatewayClient2 = class {
12752
12610
  }
12753
12611
  }
12754
12612
  handleResponse(frame) {
12755
- const pending = getPendingRequests();
12756
- const entry = pending.get(frame.id);
12613
+ const entry = this.pendingRequests.get(frame.id);
12757
12614
  if (!entry) return;
12758
- pending.delete(frame.id);
12615
+ this.pendingRequests.delete(frame.id);
12759
12616
  clearTimeout(entry.timer);
12760
12617
  if (frame.ok) {
12761
12618
  entry.resolve(frame.payload);
@@ -12830,6 +12687,7 @@ var GatewayClient2 = class {
12830
12687
  diag("GW_CLIENT_RECONNECTING", { attempt: this.reconnectAttempts });
12831
12688
  await this.connect();
12832
12689
  diag("GW_CLIENT_RECONNECTED", { attempt: this.reconnectAttempts });
12690
+ this.onReconnect?.();
12833
12691
  } catch (err) {
12834
12692
  diag("GW_CLIENT_RECONNECT_FAILED", {
12835
12693
  attempt: this.reconnectAttempts,
@@ -12840,6 +12698,94 @@ var GatewayClient2 = class {
12840
12698
  }
12841
12699
  };
12842
12700
 
12701
+ // src/micro-batch.ts
12702
+ var MicroBatch = class {
12703
+ buffer = [];
12704
+ timer = null;
12705
+ destroyed = false;
12706
+ maxSize;
12707
+ maxDelayMs;
12708
+ flushFn;
12709
+ onError;
12710
+ constructor(options) {
12711
+ this.maxSize = options.maxSize;
12712
+ this.maxDelayMs = options.maxDelayMs;
12713
+ this.flushFn = options.flush;
12714
+ this.onError = options.onError ?? ((err) => console.error("MicroBatch flush error:", err));
12715
+ }
12716
+ /**
12717
+ * Add an item to the batch.
12718
+ *
12719
+ * If the buffer was empty before this add, schedules an immediate flush
12720
+ * on the next tick (setTimeout 0). If the buffer already had items (burst),
12721
+ * starts a coalescing timer that fires after maxDelayMs.
12722
+ *
12723
+ * If the buffer reaches maxSize, flushes immediately.
12724
+ */
12725
+ add(item) {
12726
+ if (this.destroyed) return;
12727
+ const wasEmpty = this.buffer.length === 0;
12728
+ this.buffer.push(item);
12729
+ if (this.buffer.length >= this.maxSize) {
12730
+ this.clearTimer();
12731
+ this.doFlush();
12732
+ return;
12733
+ }
12734
+ if (wasEmpty) {
12735
+ this.clearTimer();
12736
+ this.timer = setTimeout(() => {
12737
+ this.timer = null;
12738
+ this.doFlush();
12739
+ }, 0);
12740
+ } else if (!this.timer) {
12741
+ this.timer = setTimeout(() => {
12742
+ this.timer = null;
12743
+ this.doFlush();
12744
+ }, this.maxDelayMs);
12745
+ }
12746
+ }
12747
+ /**
12748
+ * Flush all remaining items. Used for graceful shutdown.
12749
+ * No-op when buffer is empty.
12750
+ */
12751
+ drain() {
12752
+ this.clearTimer();
12753
+ if (this.buffer.length > 0) {
12754
+ this.doFlush();
12755
+ }
12756
+ }
12757
+ /**
12758
+ * Clear any pending timer and discard buffered items.
12759
+ * The batch will not accept new items after destroy.
12760
+ */
12761
+ destroy() {
12762
+ this.destroyed = true;
12763
+ this.clearTimer();
12764
+ this.buffer = [];
12765
+ }
12766
+ doFlush() {
12767
+ if (this.buffer.length === 0) return;
12768
+ const items = this.buffer;
12769
+ this.buffer = [];
12770
+ try {
12771
+ const result = this.flushFn(items);
12772
+ if (result && typeof result.catch === "function") {
12773
+ result.catch((err) => {
12774
+ this.onError(err);
12775
+ });
12776
+ }
12777
+ } catch (err) {
12778
+ this.onError(err);
12779
+ }
12780
+ }
12781
+ clearTimer() {
12782
+ if (this.timer !== null) {
12783
+ clearTimeout(this.timer);
12784
+ this.timer = null;
12785
+ }
12786
+ }
12787
+ };
12788
+
12843
12789
  // src/agent-state.ts
12844
12790
  import { basename } from "node:path";
12845
12791
 
@@ -13088,6 +13034,16 @@ function buildActivityEntry(agentName, hook, context) {
13088
13034
  return null;
13089
13035
  }
13090
13036
  }
13037
+ var channelAgentBridge = /* @__PURE__ */ new Map();
13038
+ function setChannelAgent(channelId, agentName) {
13039
+ channelAgentBridge.set(channelId, agentName);
13040
+ }
13041
+ function getChannelAgent(channelId) {
13042
+ return channelAgentBridge.get(channelId) ?? null;
13043
+ }
13044
+ function getChannelAgentBridge() {
13045
+ return channelAgentBridge;
13046
+ }
13091
13047
  var AgentStateTracker = class {
13092
13048
  agents = /* @__PURE__ */ new Map();
13093
13049
  activityBuffer = [];
@@ -13338,11 +13294,26 @@ var POCKET_GUIDE = `# Cohort Agent Guide (Pocket Version)
13338
13294
  `;
13339
13295
 
13340
13296
  // src/hooks.ts
13341
- var BUILD_ID = "B9-ACCOUNTID-20260311";
13297
+ var REDACT_KEYS = /* @__PURE__ */ new Set([
13298
+ "token",
13299
+ "apikey",
13300
+ "secret",
13301
+ "password",
13302
+ "credential",
13303
+ "authorization",
13304
+ "accesstoken",
13305
+ "refreshtoken",
13306
+ "bearer",
13307
+ "privatekey"
13308
+ ]);
13342
13309
  function dumpCtx(ctx) {
13343
13310
  if (!ctx || typeof ctx !== "object") return { _raw: String(ctx) };
13344
13311
  const out = {};
13345
13312
  for (const key of Object.keys(ctx)) {
13313
+ if (REDACT_KEYS.has(key.toLowerCase())) {
13314
+ out[key] = "[REDACTED]";
13315
+ continue;
13316
+ }
13346
13317
  const val = ctx[key];
13347
13318
  if (typeof val === "function") {
13348
13319
  out[key] = "[Function]";
@@ -13368,8 +13339,7 @@ try {
13368
13339
  PLUGIN_VERSION = pkgJson.version ?? "unknown";
13369
13340
  } catch {
13370
13341
  }
13371
- diag("MODULE_LOADED", { BUILD_ID, PLUGIN_VERSION });
13372
- var lastCronSnapshotJson = "";
13342
+ diag("MODULE_LOADED", { PLUGIN_VERSION });
13373
13343
  function resolveGatewayToken(api) {
13374
13344
  const rawToken = api.config?.gateway?.auth?.token;
13375
13345
  if (typeof rawToken === "string") return rawToken;
@@ -13378,26 +13348,6 @@ function resolveGatewayToken(api) {
13378
13348
  }
13379
13349
  return null;
13380
13350
  }
13381
- async function quickCronSync(port, token, cfg, resolveAgentName, logger) {
13382
- const client2 = new GatewayClient2(port, token, logger, PLUGIN_VERSION);
13383
- try {
13384
- await client2.connect();
13385
- const result = await client2.request("cron.list", { includeDisabled: true });
13386
- diag("GW_CLIENT_CRON_LIST_RESULT", { type: typeof result, isArray: Array.isArray(result), keys: result && typeof result === "object" ? Object.keys(result) : [], length: Array.isArray(result) ? result.length : void 0 });
13387
- const jobs = Array.isArray(result) ? result : result?.jobs ?? [];
13388
- const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13389
- const serialized = JSON.stringify(mapped);
13390
- if (serialized !== lastCronSnapshotJson) {
13391
- await pushCronSnapshot(cfg.apiKey, mapped);
13392
- lastCronSnapshotJson = serialized;
13393
- diag("HEARTBEAT_CRON_PUSHED", { count: mapped.length });
13394
- } else {
13395
- diag("HEARTBEAT_CRON_UNCHANGED", {});
13396
- }
13397
- } finally {
13398
- client2.close();
13399
- }
13400
- }
13401
13351
  function registerCronEventHandlers(client2, cfg, resolveAgentName) {
13402
13352
  if (client2.availableEvents.has("cron")) {
13403
13353
  let debounceTimer = null;
@@ -13449,23 +13399,25 @@ function resolveIdentity(configIdentity, workspaceDir) {
13449
13399
  avatar: configIdentity?.avatar ?? fileIdentity?.avatar
13450
13400
  };
13451
13401
  }
13402
+ var tracker = null;
13452
13403
  function getOrCreateTracker() {
13453
- const state = getHotState();
13454
- if (state.tracker instanceof AgentStateTracker) {
13455
- return state.tracker;
13456
- }
13457
- const fresh = new AgentStateTracker();
13458
- state.tracker = fresh;
13459
- return fresh;
13460
- }
13404
+ if (!tracker) tracker = new AgentStateTracker();
13405
+ return tracker;
13406
+ }
13407
+ var gatewayPort = null;
13408
+ var gatewayToken = null;
13409
+ var persistentGwClient = null;
13410
+ var gwClientInitialized = false;
13411
+ var keepaliveInterval = null;
13412
+ var commandUnsubscriber2 = null;
13461
13413
  var STATE_FILE_PATH = "";
13462
- function saveSessionsToDisk(tracker) {
13414
+ function saveSessionsToDisk(tracker2) {
13463
13415
  try {
13464
- const state = tracker.exportState();
13416
+ const state = tracker2.exportState();
13465
13417
  const data = {
13466
13418
  sessions: [],
13467
13419
  sessionKeyToAgent: Object.fromEntries(state.sessionKeyToAgent),
13468
- channelAgents: { ...globalThis["__cohort_sync_channel_agent__"] ?? {} },
13420
+ channelAgents: Object.fromEntries(getChannelAgentBridge()),
13469
13421
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
13470
13422
  };
13471
13423
  for (const [name, agent] of state.agents) {
@@ -13477,7 +13429,7 @@ function saveSessionsToDisk(tracker) {
13477
13429
  } catch {
13478
13430
  }
13479
13431
  }
13480
- function loadSessionsFromDisk(tracker, logger) {
13432
+ function loadSessionsFromDisk(tracker2, logger) {
13481
13433
  try {
13482
13434
  if (!fs3.existsSync(STATE_FILE_PATH)) return;
13483
13435
  const data = JSON.parse(fs3.readFileSync(STATE_FILE_PATH, "utf8"));
@@ -13487,13 +13439,13 @@ function loadSessionsFromDisk(tracker, logger) {
13487
13439
  }
13488
13440
  let count = 0;
13489
13441
  for (const { agentName, key } of data.sessions) {
13490
- if (!tracker.hasSession(agentName, key)) {
13491
- tracker.addSession(agentName, key);
13442
+ if (!tracker2.hasSession(agentName, key)) {
13443
+ tracker2.addSession(agentName, key);
13492
13444
  count++;
13493
13445
  }
13494
13446
  }
13495
13447
  for (const [key, agent] of Object.entries(data.sessionKeyToAgent)) {
13496
- tracker.setSessionAgent(key, agent);
13448
+ tracker2.setSessionAgent(key, agent);
13497
13449
  }
13498
13450
  for (const [channelId, agent] of Object.entries(data.channelAgents ?? {})) {
13499
13451
  setChannelAgent(channelId, agent);
@@ -13521,16 +13473,52 @@ async function fetchAgentContext(apiKey, apiUrl, logger) {
13521
13473
  return POCKET_GUIDE;
13522
13474
  }
13523
13475
  }
13476
+ function initGatewayClient(port, token, cfg, resolveAgentName, logger) {
13477
+ const client2 = new GatewayClient(port, token, logger, PLUGIN_VERSION);
13478
+ persistentGwClient = client2;
13479
+ gwClientInitialized = true;
13480
+ const onConnected = async () => {
13481
+ diag("GW_CLIENT_CONNECTED", { methods: client2.availableMethods.size, events: client2.availableEvents.size });
13482
+ logger.info(`cohort-sync: gateway client connected (methods=${client2.availableMethods.size}, events=${client2.availableEvents.size})`);
13483
+ registerCronEventHandlers(client2, cfg, resolveAgentName);
13484
+ if (client2.availableEvents.has("shutdown")) {
13485
+ client2.on("shutdown", () => {
13486
+ diag("GW_CLIENT_SHUTDOWN_EVENT", {});
13487
+ logger.info("cohort-sync: gateway shutdown event received");
13488
+ });
13489
+ }
13490
+ try {
13491
+ const cronResult = await client2.request("cron.list", { includeDisabled: true });
13492
+ const jobs = Array.isArray(cronResult) ? cronResult : cronResult?.jobs ?? [];
13493
+ const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13494
+ await pushCronSnapshot(cfg.apiKey, mapped);
13495
+ diag("GW_CLIENT_CRON_PUSH", { count: mapped.length });
13496
+ } catch (err) {
13497
+ diag("GW_CLIENT_CRON_PUSH_FAILED", { error: String(err) });
13498
+ }
13499
+ };
13500
+ client2.onReconnect = onConnected;
13501
+ client2.connect().then(() => onConnected()).catch((err) => {
13502
+ diag("GW_CLIENT_INITIAL_CONNECT_DEFERRED", { error: String(err) });
13503
+ logger.warn(`cohort-sync: GW connect will retry: ${String(err)}`);
13504
+ });
13505
+ }
13524
13506
  function registerHooks(api, cfg) {
13525
13507
  STATE_FILE_PATH = path3.join(cfg.stateDir, "session-state.json");
13526
13508
  const { logger, config } = api;
13527
13509
  const nameMap = cfg.agentNameMap;
13528
- const tracker = getOrCreateTracker();
13529
- let heartbeatInterval = null;
13530
- let activityFlushInterval = null;
13531
- logger.info(`cohort-sync: registerHooks [${BUILD_ID}]`);
13510
+ const tracker2 = getOrCreateTracker();
13511
+ const convexUrl = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
13512
+ createClient(convexUrl);
13513
+ setLogger(logger);
13514
+ gatewayPort = api.config?.gateway?.port ?? null;
13515
+ gatewayToken = resolveGatewayToken(api);
13516
+ if (gatewayPort && gatewayToken) {
13517
+ initGatewayClient(gatewayPort, gatewayToken, cfg, resolveAgentName, logger);
13518
+ }
13519
+ const cronStorePath = api.config?.cron?.store ?? path3.join(os3.homedir(), ".openclaw", "cron", "jobs.json");
13520
+ logger.info(`cohort-sync: registerHooks v${PLUGIN_VERSION}`);
13532
13521
  diag("REGISTER_HOOKS", {
13533
- BUILD_ID,
13534
13522
  PLUGIN_VERSION,
13535
13523
  hasNameMap: !!nameMap,
13536
13524
  nameMapKeys: nameMap ? Object.keys(nameMap) : [],
@@ -13539,10 +13527,6 @@ function registerHooks(api, cfg) {
13539
13527
  agentIds: (config?.agents?.list ?? []).map((a) => a.id),
13540
13528
  agentMessageProviders: (config?.agents?.list ?? []).map((a) => ({ id: a.id, mp: a.messageProvider }))
13541
13529
  });
13542
- setConvexUrl(cfg);
13543
- setLogger(logger);
13544
- restoreFromHotReload(logger);
13545
- restoreRosterFromHotReload(getRosterHotState(), logger);
13546
13530
  const identityNameMap = {};
13547
13531
  const mainIdentity = parseIdentityFile(process.cwd());
13548
13532
  if (mainIdentity?.name) {
@@ -13555,14 +13539,14 @@ function registerHooks(api, cfg) {
13555
13539
  }
13556
13540
  }
13557
13541
  diag("IDENTITY_NAME_MAP", { identityNameMap });
13558
- if (tracker.getAgentNames().length === 0) {
13559
- loadSessionsFromDisk(tracker, logger);
13560
- const restoredAgents = tracker.getAgentNames();
13542
+ if (tracker2.getAgentNames().length === 0) {
13543
+ loadSessionsFromDisk(tracker2, logger);
13544
+ const restoredAgents = tracker2.getAgentNames();
13561
13545
  for (const agentName of restoredAgents) {
13562
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13546
+ const sessSnapshot = tracker2.getSessionsSnapshot(agentName);
13563
13547
  if (sessSnapshot.length > 0) {
13564
13548
  pushSessions(cfg.apiKey, agentName, sessSnapshot).then(() => {
13565
- tracker.markSessionsPushed(agentName);
13549
+ tracker2.markSessionsPushed(agentName);
13566
13550
  logger.info(`cohort-sync: pushed ${sessSnapshot.length} restored sessions for ${agentName}`);
13567
13551
  }).catch((err) => {
13568
13552
  logger.warn(`cohort-sync: failed to push restored sessions for ${agentName}: ${String(err)}`);
@@ -13593,7 +13577,7 @@ function registerHooks(api, cfg) {
13593
13577
  }
13594
13578
  const sessionKey = ctx.sessionKey ?? ctx.sessionId;
13595
13579
  if (sessionKey && typeof sessionKey === "string") {
13596
- const mapped = tracker.getSessionAgent(sessionKey);
13580
+ const mapped = tracker2.getSessionAgent(sessionKey);
13597
13581
  if (mapped) {
13598
13582
  diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "sessionKey_mapped", sessionKey, mapped });
13599
13583
  return mapped;
@@ -13615,10 +13599,10 @@ function registerHooks(api, cfg) {
13615
13599
  if (channelId && typeof channelId === "string") {
13616
13600
  const channelAgent = getChannelAgent(channelId);
13617
13601
  if (channelAgent) {
13618
- diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_bridge", channelId, channelAgent, bridgeState: { ...getHotState().channelAgentBridge } });
13602
+ diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_bridge", channelId, channelAgent, bridgeState: Object.fromEntries(getChannelAgentBridge()) });
13619
13603
  return channelAgent;
13620
13604
  }
13621
- diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_raw", channelId, bridgeState: { ...getHotState().channelAgentBridge } });
13605
+ diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_raw", channelId, bridgeState: Object.fromEntries(getChannelAgentBridge()) });
13622
13606
  return String(channelId);
13623
13607
  }
13624
13608
  const resolved = resolveAgentName("main");
@@ -13639,48 +13623,20 @@ function registerHooks(api, cfg) {
13639
13623
  }
13640
13624
  return "unknown";
13641
13625
  }
13642
- const pendingActivity = drainActivityFromHot();
13643
- if (pendingActivity.length > 0) {
13644
- pushActivity(cfg.apiKey, pendingActivity).catch((err) => {
13645
- logger.warn(`cohort-sync: pre-reload activity flush failed: ${String(err)}`);
13646
- });
13647
- logger.info(`cohort-sync: flushed ${pendingActivity.length} pending activity entries before hot-reload`);
13648
- }
13649
- clearIntervalsFromHot();
13650
- const heartbeatMs = cfg.syncIntervalMs ?? 12e4;
13651
- heartbeatInterval = setInterval(() => {
13652
- pushHeartbeat().catch((err) => {
13653
- logger.warn(`cohort-sync: heartbeat tick failed: ${String(err)}`);
13654
- });
13655
- }, heartbeatMs);
13656
- activityFlushInterval = setInterval(() => {
13657
- flushActivityBuffer().catch((err) => {
13658
- logger.warn(`cohort-sync: activity flush tick failed: ${String(err)}`);
13659
- });
13660
- }, 3e3);
13661
- saveIntervalsToHot(heartbeatInterval, activityFlushInterval);
13662
- logger.info(`cohort-sync: intervals created (heartbeat=${heartbeatMs / 1e3}s, activityFlush=3s)`);
13663
- {
13664
- const hotState = getHotState();
13665
- if (hotState.commandSubscription) {
13666
- hotState.commandSubscription();
13667
- hotState.commandSubscription = null;
13668
- }
13669
- const unsub = initCommandSubscription(cfg, logger, resolveAgentName);
13670
- hotState.commandSubscription = unsub;
13671
- }
13626
+ const activityBatch = new MicroBatch({
13627
+ maxSize: 10,
13628
+ maxDelayMs: 1e3,
13629
+ flush: (entries) => pushActivity(cfg.apiKey, entries),
13630
+ onError: (err) => logger.warn(`cohort-sync: activity batch flush failed: ${String(err)}`)
13631
+ });
13632
+ const KEEPALIVE_INTERVAL_MS = 15e4;
13672
13633
  {
13673
- const port = api.config?.gateway?.port;
13674
- const token = resolveGatewayToken(api);
13675
- if (port && token) {
13676
- const hotState = getHotState();
13677
- hotState.gatewayPort = port;
13678
- hotState.gatewayToken = token;
13679
- diag("REGISTER_HOOKS_CRON_SYNC", { port });
13680
- quickCronSync(port, token, cfg, resolveAgentName, logger).catch((err) => {
13681
- diag("REGISTER_HOOKS_CRON_SYNC_FAILED", { error: String(err) });
13682
- });
13634
+ if (commandUnsubscriber2) {
13635
+ commandUnsubscriber2();
13636
+ commandUnsubscriber2 = null;
13683
13637
  }
13638
+ const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13639
+ commandUnsubscriber2 = unsub;
13684
13640
  }
13685
13641
  api.registerTool((toolCtx) => {
13686
13642
  const agentId = toolCtx.agentId ?? "main";
@@ -13796,52 +13752,6 @@ Do not attempt to make more comments until ${resetAt}.`
13796
13752
  if (m.includes("deepseek")) return 128e3;
13797
13753
  return 2e5;
13798
13754
  }
13799
- async function pushHeartbeat() {
13800
- const allAgentIds = ["main", ...(config?.agents?.list ?? []).map((a) => a.id)];
13801
- for (const agentId of allAgentIds) {
13802
- const agentName = resolveAgentName(agentId);
13803
- const pruned = tracker.pruneStaleSessions(agentName, 864e5);
13804
- if (pruned.length > 0) {
13805
- logger.info(`cohort-sync: pruned ${pruned.length} stale sessions for ${agentName}`);
13806
- }
13807
- }
13808
- for (const agentId of allAgentIds) {
13809
- const agentName = resolveAgentName(agentId);
13810
- try {
13811
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13812
- if (snapshot) {
13813
- await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13814
- } else {
13815
- logger.info(`cohort-sync: heartbeat skipped ${agentName} \u2014 no snapshot in tracker`);
13816
- }
13817
- } catch (err) {
13818
- logger.warn(`cohort-sync: heartbeat push failed for ${agentName}: ${String(err)}`);
13819
- }
13820
- }
13821
- for (const agentId of allAgentIds) {
13822
- const agentName = resolveAgentName(agentId);
13823
- try {
13824
- if (tracker.shouldPushSessions(agentName)) {
13825
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13826
- await pushSessions(cfg.apiKey, agentName, sessSnapshot);
13827
- tracker.markSessionsPushed(agentName);
13828
- }
13829
- } catch (err) {
13830
- logger.warn(`cohort-sync: heartbeat session push failed for ${agentName}: ${String(err)}`);
13831
- }
13832
- }
13833
- saveSessionsToDisk(tracker);
13834
- logger.info(`cohort-sync: heartbeat pushed for ${allAgentIds.length} agents`);
13835
- }
13836
- async function flushActivityBuffer() {
13837
- const entries = drainActivityFromHot();
13838
- if (entries.length === 0) return;
13839
- try {
13840
- await pushActivity(cfg.apiKey, entries);
13841
- } catch (err) {
13842
- logger.warn(`cohort-sync: activity flush failed: ${String(err)}`);
13843
- }
13844
- }
13845
13755
  api.on("gateway_start", async (event) => {
13846
13756
  diag("HOOK_gateway_start", { port: event.port, eventKeys: Object.keys(event) });
13847
13757
  try {
@@ -13867,24 +13777,20 @@ Do not attempt to make more comments until ${resetAt}.`
13867
13777
  setChannelAgent(mp, agentName);
13868
13778
  }
13869
13779
  }
13870
- diag("GATEWAY_START_BRIDGE_AFTER_SEED", { bridge: { ...getHotState().channelAgentBridge } });
13871
- const hotState = getHotState();
13872
- hotState.gatewayPort = event.port;
13780
+ diag("GATEWAY_START_BRIDGE_AFTER_SEED", { bridge: Object.fromEntries(getChannelAgentBridge()) });
13781
+ gatewayPort = event.port;
13873
13782
  const token = resolveGatewayToken(api);
13874
13783
  if (token) {
13784
+ gatewayToken = token;
13875
13785
  diag("GW_CLIENT_CONNECTING", { port: event.port, hasToken: true });
13876
13786
  try {
13877
- const client2 = new GatewayClient2(event.port, token, logger, PLUGIN_VERSION);
13878
- await client2.connect();
13879
- hotState.gatewayProtocolClient = client2;
13880
- registerCronEventHandlers(client2, cfg, resolveAgentName);
13881
- diag("GW_CLIENT_CONNECTED", { methods: client2.availableMethods.size, events: client2.availableEvents.size });
13882
- const cronResult = await client2.request("cron.list", { includeDisabled: true });
13883
- const jobs = Array.isArray(cronResult) ? cronResult : cronResult?.jobs ?? [];
13884
- const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13885
- await pushCronSnapshot(cfg.apiKey, mapped);
13886
- lastCronSnapshotJson = JSON.stringify(mapped);
13887
- diag("GW_CLIENT_INITIAL_CRON_PUSH", { count: mapped.length });
13787
+ await initGatewayClient(event.port, token, cfg, resolveAgentName, logger);
13788
+ if (commandUnsubscriber2) {
13789
+ commandUnsubscriber2();
13790
+ commandUnsubscriber2 = null;
13791
+ }
13792
+ const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13793
+ commandUnsubscriber2 = unsub;
13888
13794
  } catch (err) {
13889
13795
  diag("GW_CLIENT_CONNECT_FAILED", { error: String(err) });
13890
13796
  logger.error(`cohort-sync: gateway client connect failed: ${String(err)}`);
@@ -13893,7 +13799,7 @@ Do not attempt to make more comments until ${resetAt}.`
13893
13799
  diag("GW_CLIENT_NO_TOKEN", {});
13894
13800
  logger.warn("cohort-sync: no gateway auth token \u2014 cron operations disabled");
13895
13801
  }
13896
- await initSubscription(
13802
+ await startNotificationSubscription(
13897
13803
  event.port,
13898
13804
  cfg,
13899
13805
  api.config.hooks?.token,
@@ -13905,31 +13811,61 @@ Do not attempt to make more comments until ${resetAt}.`
13905
13811
  for (const agentId of allAgentIds) {
13906
13812
  const agentName = resolveAgentName(agentId);
13907
13813
  try {
13908
- tracker.setModel(agentName, resolveModel(agentId));
13909
- tracker.updateStatus(agentName, "idle");
13910
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13814
+ tracker2.setModel(agentName, resolveModel(agentId));
13815
+ tracker2.updateStatus(agentName, "idle");
13816
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13911
13817
  if (snapshot) {
13912
13818
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13913
- tracker.markTelemetryPushed(agentName);
13819
+ tracker2.markTelemetryPushed(agentName);
13914
13820
  }
13915
13821
  } catch (err) {
13916
13822
  logger.warn(`cohort-sync: initial telemetry seed failed for ${agentName}: ${String(err)}`);
13917
13823
  }
13918
13824
  }
13919
13825
  logger.info(`cohort-sync: seeded telemetry for ${allAgentIds.length} agents`);
13826
+ if (keepaliveInterval) clearInterval(keepaliveInterval);
13827
+ keepaliveInterval = setInterval(async () => {
13828
+ for (const agentId of allAgentIds) {
13829
+ const agentName = resolveAgentName(agentId);
13830
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13831
+ if (snapshot) {
13832
+ await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION }).catch(() => {
13833
+ });
13834
+ }
13835
+ }
13836
+ for (const agentId of allAgentIds) {
13837
+ const agentName = resolveAgentName(agentId);
13838
+ tracker2.pruneStaleSessions(agentName, 864e5);
13839
+ }
13840
+ saveSessionsToDisk(tracker2);
13841
+ }, KEEPALIVE_INTERVAL_MS);
13842
+ logger.info(`cohort-sync: keepalive interval started (${KEEPALIVE_INTERVAL_MS / 1e3}s)`);
13920
13843
  });
13921
13844
  api.on("agent_end", async (event, ctx) => {
13922
13845
  diag("HOOK_agent_end", { ctx: dumpCtx(ctx), success: event.success, error: event.error, durationMs: event.durationMs });
13923
13846
  const agentId = ctx.agentId ?? "main";
13924
13847
  const agentName = resolveAgentName(agentId);
13925
13848
  try {
13926
- tracker.updateStatus(agentName, "idle");
13849
+ tracker2.updateStatus(agentName, "idle");
13927
13850
  await syncAgentStatus(agentName, "idle", resolveModel(agentId), cfg, logger);
13928
- if (tracker.shouldPushTelemetry(agentName)) {
13929
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13851
+ if (tracker2.shouldPushTelemetry(agentName)) {
13852
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13930
13853
  if (snapshot) {
13931
13854
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13932
- tracker.markTelemetryPushed(agentName);
13855
+ tracker2.markTelemetryPushed(agentName);
13856
+ }
13857
+ }
13858
+ const sessionKey = ctx.sessionKey;
13859
+ if (sessionKey && sessionKey.includes(":cron:")) {
13860
+ try {
13861
+ const raw = fs3.readFileSync(cronStorePath, "utf8");
13862
+ const store = JSON.parse(raw);
13863
+ const jobs = store.jobs ?? [];
13864
+ const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13865
+ await pushCronSnapshot(cfg.apiKey, mapped);
13866
+ diag("CRON_AGENT_END_PUSH", { count: mapped.length, sessionKey });
13867
+ } catch (err) {
13868
+ diag("CRON_AGENT_END_PUSH_FAILED", { error: String(err) });
13933
13869
  }
13934
13870
  }
13935
13871
  if (event.success === false) {
@@ -13939,7 +13875,7 @@ Do not attempt to make more comments until ${resetAt}.`
13939
13875
  durationMs: event.durationMs,
13940
13876
  sessionKey: ctx.sessionKey
13941
13877
  });
13942
- if (entry) addActivityToHot(entry);
13878
+ if (entry) activityBatch.add(entry);
13943
13879
  }
13944
13880
  } catch (err) {
13945
13881
  logger.warn(`cohort-sync: agent_end sync failed: ${String(err)}`);
@@ -13964,31 +13900,31 @@ Do not attempt to make more comments until ${resetAt}.`
13964
13900
  const agentName = resolveAgentName(agentId);
13965
13901
  try {
13966
13902
  const sessionKey = ctx.sessionKey;
13967
- tracker.updateFromLlmOutput(agentName, sessionKey, {
13903
+ tracker2.updateFromLlmOutput(agentName, sessionKey, {
13968
13904
  model,
13969
13905
  tokensIn: usage.input ?? 0,
13970
13906
  tokensOut: usage.output ?? 0,
13971
13907
  contextTokens,
13972
13908
  contextLimit
13973
13909
  });
13974
- if (sessionKey && !tracker.hasSession(agentName, sessionKey)) {
13975
- tracker.addSession(agentName, sessionKey);
13910
+ if (sessionKey && !tracker2.hasSession(agentName, sessionKey)) {
13911
+ tracker2.addSession(agentName, sessionKey);
13976
13912
  logger.info(`cohort-sync: inferred session for ${agentName} from llm_output (${sessionKey})`);
13977
13913
  }
13978
13914
  if (sessionKey) {
13979
- tracker.setSessionAgent(sessionKey, agentName);
13915
+ tracker2.setSessionAgent(sessionKey, agentName);
13980
13916
  }
13981
- if (tracker.shouldPushTelemetry(agentName)) {
13982
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13917
+ if (tracker2.shouldPushTelemetry(agentName)) {
13918
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13983
13919
  if (snapshot) {
13984
13920
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13985
- tracker.markTelemetryPushed(agentName);
13921
+ tracker2.markTelemetryPushed(agentName);
13986
13922
  }
13987
13923
  }
13988
- if (tracker.shouldPushSessions(agentName)) {
13989
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
13924
+ if (tracker2.shouldPushSessions(agentName)) {
13925
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
13990
13926
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
13991
- tracker.markSessionsPushed(agentName);
13927
+ tracker2.markSessionsPushed(agentName);
13992
13928
  }
13993
13929
  } catch (err) {
13994
13930
  logger.warn(`cohort-sync: llm_output telemetry failed: ${String(err)}`);
@@ -13999,15 +13935,15 @@ Do not attempt to make more comments until ${resetAt}.`
13999
13935
  const agentId = ctx.agentId ?? "main";
14000
13936
  const agentName = resolveAgentName(agentId);
14001
13937
  try {
14002
- tracker.updateFromCompaction(agentName, {
13938
+ tracker2.updateFromCompaction(agentName, {
14003
13939
  contextTokens: event.tokenCount ?? 0,
14004
13940
  contextLimit: getModelContextLimit(resolveModel(agentId))
14005
13941
  });
14006
- if (tracker.shouldPushTelemetry(agentName)) {
14007
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13942
+ if (tracker2.shouldPushTelemetry(agentName)) {
13943
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
14008
13944
  if (snapshot) {
14009
13945
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
14010
- tracker.markTelemetryPushed(agentName);
13946
+ tracker2.markTelemetryPushed(agentName);
14011
13947
  }
14012
13948
  }
14013
13949
  const entry = buildActivityEntry(agentName, "after_compaction", {
@@ -14015,7 +13951,7 @@ Do not attempt to make more comments until ${resetAt}.`
14015
13951
  compactedCount: event.compactedCount,
14016
13952
  sessionKey: ctx.sessionKey
14017
13953
  });
14018
- if (entry) addActivityToHot(entry);
13954
+ if (entry) activityBatch.add(entry);
14019
13955
  } catch (err) {
14020
13956
  logger.warn(`cohort-sync: after_compaction telemetry failed: ${String(err)}`);
14021
13957
  }
@@ -14026,26 +13962,39 @@ Do not attempt to make more comments until ${resetAt}.`
14026
13962
  const agentName = resolveAgentName(agentId);
14027
13963
  diag("HOOK_before_agent_start_RESOLVED", { agentId, agentName, ctxChannelId: ctx.channelId, ctxMessageProvider: ctx.messageProvider, ctxSessionKey: ctx.sessionKey, ctxAccountId: ctx.accountId });
14028
13964
  try {
14029
- tracker.updateStatus(agentName, "working");
14030
- if (tracker.shouldPushTelemetry(agentName)) {
14031
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13965
+ if (!gwClientInitialized && gatewayPort && gatewayToken) {
13966
+ try {
13967
+ await initGatewayClient(gatewayPort, gatewayToken, cfg, resolveAgentName, logger);
13968
+ if (commandUnsubscriber2) {
13969
+ commandUnsubscriber2();
13970
+ commandUnsubscriber2 = null;
13971
+ }
13972
+ const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13973
+ commandUnsubscriber2 = unsub;
13974
+ } catch (err) {
13975
+ diag("GW_CLIENT_LAZY_INIT_FAILED", { error: String(err) });
13976
+ }
13977
+ }
13978
+ tracker2.updateStatus(agentName, "working");
13979
+ if (tracker2.shouldPushTelemetry(agentName)) {
13980
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
14032
13981
  if (snapshot) {
14033
13982
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
14034
- tracker.markTelemetryPushed(agentName);
13983
+ tracker2.markTelemetryPushed(agentName);
14035
13984
  }
14036
13985
  }
14037
13986
  const sessionKey = ctx.sessionKey;
14038
- if (sessionKey && !tracker.hasSession(agentName, sessionKey)) {
14039
- tracker.addSession(agentName, sessionKey);
14040
- tracker.setSessionAgent(sessionKey, agentName);
13987
+ if (sessionKey && !tracker2.hasSession(agentName, sessionKey)) {
13988
+ tracker2.addSession(agentName, sessionKey);
13989
+ tracker2.setSessionAgent(sessionKey, agentName);
14041
13990
  logger.info(`cohort-sync: inferred session for ${agentName} (${sessionKey})`);
14042
- if (tracker.shouldPushSessions(agentName)) {
14043
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13991
+ if (tracker2.shouldPushSessions(agentName)) {
13992
+ const sessSnapshot = tracker2.getSessionsSnapshot(agentName);
14044
13993
  await pushSessions(cfg.apiKey, agentName, sessSnapshot);
14045
- tracker.markSessionsPushed(agentName);
13994
+ tracker2.markSessionsPushed(agentName);
14046
13995
  }
14047
13996
  } else if (sessionKey) {
14048
- tracker.setSessionAgent(sessionKey, agentName);
13997
+ tracker2.setSessionAgent(sessionKey, agentName);
14049
13998
  }
14050
13999
  const ctxChannelId = ctx.channelId;
14051
14000
  if (ctxChannelId) {
@@ -14065,11 +14014,11 @@ Do not attempt to make more comments until ${resetAt}.`
14065
14014
  const agentName = resolveAgentName(agentId);
14066
14015
  try {
14067
14016
  const sessionKey = ctx.sessionId ?? String(Date.now());
14068
- tracker.addSession(agentName, sessionKey);
14069
- if (tracker.shouldPushSessions(agentName)) {
14070
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
14017
+ tracker2.addSession(agentName, sessionKey);
14018
+ if (tracker2.shouldPushSessions(agentName)) {
14019
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
14071
14020
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
14072
- tracker.markSessionsPushed(agentName);
14021
+ tracker2.markSessionsPushed(agentName);
14073
14022
  }
14074
14023
  const parsed = parseSessionKey(sessionKey);
14075
14024
  const entry = buildActivityEntry(agentName, "session_start", {
@@ -14077,7 +14026,7 @@ Do not attempt to make more comments until ${resetAt}.`
14077
14026
  sessionKey,
14078
14027
  resumedFrom: event.resumedFrom
14079
14028
  });
14080
- if (entry) addActivityToHot(entry);
14029
+ if (entry) activityBatch.add(entry);
14081
14030
  } catch (err) {
14082
14031
  logger.warn(`cohort-sync: session_start tracking failed: ${String(err)}`);
14083
14032
  }
@@ -14088,18 +14037,18 @@ Do not attempt to make more comments until ${resetAt}.`
14088
14037
  const agentName = resolveAgentName(agentId);
14089
14038
  try {
14090
14039
  const sessionKey = ctx.sessionId ?? "";
14091
- tracker.removeSession(agentName, sessionKey);
14092
- if (tracker.shouldPushSessions(agentName)) {
14093
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
14040
+ tracker2.removeSession(agentName, sessionKey);
14041
+ if (tracker2.shouldPushSessions(agentName)) {
14042
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
14094
14043
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
14095
- tracker.markSessionsPushed(agentName);
14044
+ tracker2.markSessionsPushed(agentName);
14096
14045
  }
14097
14046
  const entry = buildActivityEntry(agentName, "session_end", {
14098
14047
  sessionKey,
14099
14048
  messageCount: event.messageCount,
14100
14049
  durationMs: event.durationMs
14101
14050
  });
14102
- if (entry) addActivityToHot(entry);
14051
+ if (entry) activityBatch.add(entry);
14103
14052
  } catch (err) {
14104
14053
  logger.warn(`cohort-sync: session_end tracking failed: ${String(err)}`);
14105
14054
  }
@@ -14116,7 +14065,7 @@ Do not attempt to make more comments until ${resetAt}.`
14116
14065
  sessionKey: ctx.sessionKey,
14117
14066
  model: resolveModel(ctx.agentId ?? "main")
14118
14067
  });
14119
- if (entry) addActivityToHot(entry);
14068
+ if (entry) activityBatch.add(entry);
14120
14069
  } catch (err) {
14121
14070
  logger.warn(`cohort-sync: after_tool_call activity failed: ${String(err)}`);
14122
14071
  }
@@ -14125,7 +14074,7 @@ Do not attempt to make more comments until ${resetAt}.`
14125
14074
  diag("HOOK_message_received_RAW", {
14126
14075
  ctx: dumpCtx(ctx),
14127
14076
  event: dumpEvent(_event),
14128
- bridgeStateBefore: { ...getHotState().channelAgentBridge }
14077
+ bridgeStateBefore: Object.fromEntries(getChannelAgentBridge())
14129
14078
  });
14130
14079
  const agentName = resolveAgentFromContext(ctx);
14131
14080
  const channel = ctx.channelId;
@@ -14140,7 +14089,7 @@ Do not attempt to make more comments until ${resetAt}.`
14140
14089
  const entry = buildActivityEntry(agentName, "message_received", {
14141
14090
  channel: channel ?? "unknown"
14142
14091
  });
14143
- if (entry) addActivityToHot(entry);
14092
+ if (entry) activityBatch.add(entry);
14144
14093
  } catch (err) {
14145
14094
  logger.warn(`cohort-sync: message_received activity failed: ${String(err)}`);
14146
14095
  }
@@ -14149,7 +14098,7 @@ Do not attempt to make more comments until ${resetAt}.`
14149
14098
  diag("HOOK_message_sent_RAW", {
14150
14099
  ctx: dumpCtx(ctx),
14151
14100
  event: dumpEvent(event),
14152
- bridgeStateBefore: { ...getHotState().channelAgentBridge }
14101
+ bridgeStateBefore: Object.fromEntries(getChannelAgentBridge())
14153
14102
  });
14154
14103
  const agentName = resolveAgentFromContext(ctx);
14155
14104
  const channel = ctx.channelId;
@@ -14168,7 +14117,7 @@ Do not attempt to make more comments until ${resetAt}.`
14168
14117
  success: event.success,
14169
14118
  error: event.error
14170
14119
  });
14171
- if (entry) addActivityToHot(entry);
14120
+ if (entry) activityBatch.add(entry);
14172
14121
  } catch (err) {
14173
14122
  logger.warn(`cohort-sync: message_sent activity failed: ${String(err)}`);
14174
14123
  }
@@ -14181,7 +14130,7 @@ Do not attempt to make more comments until ${resetAt}.`
14181
14130
  const entry = buildActivityEntry(agentName, "before_compaction", {
14182
14131
  sessionKey: ctx.sessionKey
14183
14132
  });
14184
- if (entry) addActivityToHot(entry);
14133
+ if (entry) activityBatch.add(entry);
14185
14134
  } catch (err) {
14186
14135
  logger.warn(`cohort-sync: before_compaction activity failed: ${String(err)}`);
14187
14136
  }
@@ -14195,25 +14144,24 @@ Do not attempt to make more comments until ${resetAt}.`
14195
14144
  reason: event.reason,
14196
14145
  sessionKey: ctx.sessionKey
14197
14146
  });
14198
- if (entry) addActivityToHot(entry);
14147
+ if (entry) activityBatch.add(entry);
14199
14148
  } catch (err) {
14200
14149
  logger.warn(`cohort-sync: before_reset activity failed: ${String(err)}`);
14201
14150
  }
14202
14151
  });
14203
14152
  api.on("gateway_stop", async () => {
14204
- diag("HOOK_gateway_stop", { bridgeState: { ...getHotState().channelAgentBridge } });
14205
- clearIntervalsFromHot();
14206
- heartbeatInterval = null;
14207
- activityFlushInterval = null;
14208
- await flushActivityBuffer().catch((err) => {
14209
- logger.warn(`cohort-sync: final activity flush failed: ${String(err)}`);
14210
- });
14153
+ diag("HOOK_gateway_stop", { bridgeState: Object.fromEntries(getChannelAgentBridge()) });
14154
+ if (keepaliveInterval) {
14155
+ clearInterval(keepaliveInterval);
14156
+ keepaliveInterval = null;
14157
+ }
14158
+ activityBatch.drain();
14211
14159
  const allAgentIds = ["main", ...(config?.agents?.list ?? []).map((a) => a.id)];
14212
14160
  for (const agentId of allAgentIds) {
14213
14161
  const agentName = resolveAgentName(agentId);
14214
14162
  try {
14215
- tracker.updateStatus(agentName, "unreachable");
14216
- const snapshot = tracker.getTelemetrySnapshot(agentName);
14163
+ tracker2.updateStatus(agentName, "unreachable");
14164
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
14217
14165
  if (snapshot) {
14218
14166
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
14219
14167
  }
@@ -14221,15 +14169,20 @@ Do not attempt to make more comments until ${resetAt}.`
14221
14169
  logger.warn(`cohort-sync: final unreachable push failed for ${agentName}: ${String(err)}`);
14222
14170
  }
14223
14171
  }
14224
- saveSessionsToDisk(tracker);
14172
+ saveSessionsToDisk(tracker2);
14173
+ if (persistentGwClient) {
14174
+ persistentGwClient.close();
14175
+ persistentGwClient = null;
14176
+ gwClientInitialized = false;
14177
+ }
14225
14178
  try {
14226
14179
  await markAllUnreachable(cfg, logger);
14227
14180
  } catch (err) {
14228
14181
  logger.warn(`cohort-sync: markAllUnreachable failed: ${String(err)}`);
14229
14182
  }
14230
- tracker.clear();
14231
- closeSubscription();
14232
- logger.info("cohort-sync: subscription closed");
14183
+ tracker2.clear();
14184
+ closeBridge();
14185
+ logger.info("cohort-sync: gateway stopped, all resources cleaned up");
14233
14186
  });
14234
14187
  }
14235
14188
 
@@ -14394,14 +14347,19 @@ function registerCohortCli(ctx, cfg) {
14394
14347
 
14395
14348
  // index.ts
14396
14349
  init_keychain();
14397
- var DEFAULT_API_URL = "https://fortunate-chipmunk-286.convex.site";
14398
14350
  var plugin = {
14399
14351
  id: "cohort-sync",
14400
14352
  name: "Cohort Sync",
14401
14353
  description: "Syncs agent status and skills to Cohort dashboard",
14402
14354
  register(api) {
14403
14355
  const cfg = api.pluginConfig;
14404
- const apiUrl = cfg?.apiUrl ?? DEFAULT_API_URL;
14356
+ const apiUrl = cfg?.apiUrl;
14357
+ if (!apiUrl) {
14358
+ api.logger.error(
14359
+ "cohort-sync: apiUrl is required in plugin config \u2014 set it in your OpenClaw configuration"
14360
+ );
14361
+ return;
14362
+ }
14405
14363
  api.registerCli(
14406
14364
  (ctx) => registerCohortCli(ctx, {
14407
14365
  apiUrl,
@@ -14435,12 +14393,11 @@ var plugin = {
14435
14393
  apiUrl,
14436
14394
  apiKey,
14437
14395
  stateDir: svcCtx.stateDir,
14438
- agentNameMap: cfg?.agentNameMap,
14439
- syncIntervalMs: cfg?.syncIntervalMs
14396
+ agentNameMap: cfg?.agentNameMap
14440
14397
  });
14441
14398
  },
14442
14399
  async stop() {
14443
- closeSubscription();
14400
+ closeBridge();
14444
14401
  api.logger.info("cohort-sync: service stopped");
14445
14402
  }
14446
14403
  });